318 lines
9.2 KiB
TypeScript
318 lines
9.2 KiB
TypeScript
|
|
/**
|
||
|
|
* Markdown toolbar helpers for text editors.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply markdown formatting to selected text in textarea.
|
||
|
|
* If no selection, inserts before/after at cursor position.
|
||
|
|
*/
|
||
|
|
export function applyMarkdownWrap(
|
||
|
|
textarea: HTMLTextAreaElement,
|
||
|
|
before: string,
|
||
|
|
after: string = ""
|
||
|
|
): void {
|
||
|
|
const start = textarea.selectionStart;
|
||
|
|
const end = textarea.selectionEnd;
|
||
|
|
const text = textarea.value;
|
||
|
|
const selectedText = text.substring(start, end);
|
||
|
|
|
||
|
|
let newText: string;
|
||
|
|
let newCursorPos: number;
|
||
|
|
|
||
|
|
if (selectedText) {
|
||
|
|
// Wrap selected text
|
||
|
|
newText = text.substring(0, start) + before + selectedText + after + text.substring(end);
|
||
|
|
newCursorPos = start + before.length + selectedText.length + after.length;
|
||
|
|
} else {
|
||
|
|
// Insert at cursor position
|
||
|
|
newText = text.substring(0, start) + before + after + text.substring(end);
|
||
|
|
newCursorPos = start + before.length;
|
||
|
|
}
|
||
|
|
|
||
|
|
textarea.value = newText;
|
||
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||
|
|
textarea.focus();
|
||
|
|
|
||
|
|
// Trigger change event
|
||
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply prefix to each selected line (for lists).
|
||
|
|
*/
|
||
|
|
export function applyLinePrefix(
|
||
|
|
textarea: HTMLTextAreaElement,
|
||
|
|
prefix: string
|
||
|
|
): void {
|
||
|
|
const start = textarea.selectionStart;
|
||
|
|
const end = textarea.selectionEnd;
|
||
|
|
const text = textarea.value;
|
||
|
|
|
||
|
|
// Find line boundaries
|
||
|
|
const beforeSelection = text.substring(0, start);
|
||
|
|
const selection = text.substring(start, end);
|
||
|
|
const afterSelection = text.substring(end);
|
||
|
|
|
||
|
|
const lineStart = beforeSelection.lastIndexOf("\n") + 1;
|
||
|
|
const lineEnd = afterSelection.indexOf("\n");
|
||
|
|
const fullLineEnd = end + (lineEnd >= 0 ? lineEnd : afterSelection.length);
|
||
|
|
|
||
|
|
// Get all lines in selection
|
||
|
|
const lines = selection.split("\n");
|
||
|
|
const firstLineStart = beforeSelection.lastIndexOf("\n") + 1;
|
||
|
|
const firstLinePrefix = text.substring(firstLineStart, start);
|
||
|
|
|
||
|
|
// Apply prefix to each line
|
||
|
|
const prefixedLines = lines.map((line) => {
|
||
|
|
if (line.trim()) {
|
||
|
|
return prefix + line;
|
||
|
|
}
|
||
|
|
return line;
|
||
|
|
});
|
||
|
|
|
||
|
|
const newSelection = prefixedLines.join("\n");
|
||
|
|
const newText =
|
||
|
|
text.substring(0, lineStart) + newSelection + text.substring(fullLineEnd);
|
||
|
|
|
||
|
|
textarea.value = newText;
|
||
|
|
|
||
|
|
// Restore selection
|
||
|
|
const newStart = lineStart;
|
||
|
|
const newEnd = lineStart + newSelection.length;
|
||
|
|
textarea.setSelectionRange(newStart, newEnd);
|
||
|
|
textarea.focus();
|
||
|
|
|
||
|
|
// Trigger change event
|
||
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Remove prefix from each selected line (for lists).
|
||
|
|
*/
|
||
|
|
export function removeLinePrefix(
|
||
|
|
textarea: HTMLTextAreaElement,
|
||
|
|
prefix: string
|
||
|
|
): void {
|
||
|
|
const start = textarea.selectionStart;
|
||
|
|
const end = textarea.selectionEnd;
|
||
|
|
const text = textarea.value;
|
||
|
|
|
||
|
|
// Find line boundaries
|
||
|
|
const beforeSelection = text.substring(0, start);
|
||
|
|
const selection = text.substring(start, end);
|
||
|
|
const afterSelection = text.substring(end);
|
||
|
|
|
||
|
|
const lineStart = beforeSelection.lastIndexOf("\n") + 1;
|
||
|
|
const lineEnd = afterSelection.indexOf("\n");
|
||
|
|
const fullLineEnd = end + (lineEnd >= 0 ? lineEnd : afterSelection.length);
|
||
|
|
|
||
|
|
// Get all lines in selection
|
||
|
|
const lines = selection.split("\n");
|
||
|
|
|
||
|
|
// Remove prefix from each line
|
||
|
|
const unprefixedLines = lines.map((line) => {
|
||
|
|
if (line.startsWith(prefix)) {
|
||
|
|
return line.substring(prefix.length);
|
||
|
|
}
|
||
|
|
return line;
|
||
|
|
});
|
||
|
|
|
||
|
|
const newSelection = unprefixedLines.join("\n");
|
||
|
|
const newText =
|
||
|
|
text.substring(0, lineStart) + newSelection + text.substring(fullLineEnd);
|
||
|
|
|
||
|
|
textarea.value = newText;
|
||
|
|
|
||
|
|
// Restore selection
|
||
|
|
const newStart = lineStart;
|
||
|
|
const newEnd = lineStart + newSelection.length;
|
||
|
|
textarea.setSelectionRange(newStart, newEnd);
|
||
|
|
textarea.focus();
|
||
|
|
|
||
|
|
// Trigger change event
|
||
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create markdown toolbar with buttons.
|
||
|
|
*/
|
||
|
|
export function createMarkdownToolbar(
|
||
|
|
textarea: HTMLTextAreaElement,
|
||
|
|
onTogglePreview?: () => void
|
||
|
|
): HTMLElement {
|
||
|
|
const toolbar = document.createElement("div");
|
||
|
|
toolbar.className = "markdown-toolbar";
|
||
|
|
toolbar.style.display = "flex";
|
||
|
|
toolbar.style.gap = "0.25em";
|
||
|
|
toolbar.style.padding = "0.5em";
|
||
|
|
toolbar.style.borderBottom = "1px solid var(--background-modifier-border)";
|
||
|
|
toolbar.style.flexWrap = "wrap";
|
||
|
|
toolbar.style.alignItems = "center";
|
||
|
|
|
||
|
|
// Bold
|
||
|
|
const boldBtn = createToolbarButton("B", "Bold (Ctrl+B)", () => {
|
||
|
|
applyMarkdownWrap(textarea, "**", "**");
|
||
|
|
});
|
||
|
|
toolbar.appendChild(boldBtn);
|
||
|
|
|
||
|
|
// Italic
|
||
|
|
const italicBtn = createToolbarButton("I", "Italic (Ctrl+I)", () => {
|
||
|
|
applyMarkdownWrap(textarea, "*", "*");
|
||
|
|
});
|
||
|
|
toolbar.appendChild(italicBtn);
|
||
|
|
|
||
|
|
// H2
|
||
|
|
const h2Btn = createToolbarButton("H2", "Heading 2", () => {
|
||
|
|
applyLinePrefix(textarea, "## ");
|
||
|
|
});
|
||
|
|
toolbar.appendChild(h2Btn);
|
||
|
|
|
||
|
|
// H3
|
||
|
|
const h3Btn = createToolbarButton("H3", "Heading 3", () => {
|
||
|
|
applyLinePrefix(textarea, "### ");
|
||
|
|
});
|
||
|
|
toolbar.appendChild(h3Btn);
|
||
|
|
|
||
|
|
// Bullet List
|
||
|
|
const bulletBtn = createToolbarButton("•", "Bullet List", () => {
|
||
|
|
const start = textarea.selectionStart;
|
||
|
|
const end = textarea.selectionEnd;
|
||
|
|
const text = textarea.value;
|
||
|
|
const selectedText = text.substring(start, end);
|
||
|
|
|
||
|
|
// Check if lines already have bullet prefix
|
||
|
|
const lines = selectedText.split("\n");
|
||
|
|
const hasBullets = lines.some((line) => line.trim().startsWith("- "));
|
||
|
|
|
||
|
|
if (hasBullets) {
|
||
|
|
removeLinePrefix(textarea, "- ");
|
||
|
|
} else {
|
||
|
|
applyLinePrefix(textarea, "- ");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
toolbar.appendChild(bulletBtn);
|
||
|
|
|
||
|
|
// Numbered List
|
||
|
|
const numberedBtn = createToolbarButton("1.", "Numbered List", () => {
|
||
|
|
const start = textarea.selectionStart;
|
||
|
|
const end = textarea.selectionEnd;
|
||
|
|
const text = textarea.value;
|
||
|
|
const selectedText = text.substring(start, end);
|
||
|
|
|
||
|
|
// Check if lines already have numbered prefix
|
||
|
|
const lines = selectedText.split("\n");
|
||
|
|
const hasNumbers = lines.some((line) => /^\d+\.\s/.test(line.trim()));
|
||
|
|
|
||
|
|
if (hasNumbers) {
|
||
|
|
// Remove numbered prefix (simple version - removes "1. " pattern)
|
||
|
|
const unprefixedLines = lines.map((line) => {
|
||
|
|
const match = line.match(/^(\d+\.\s)(.*)$/);
|
||
|
|
return match ? match[2] : line;
|
||
|
|
});
|
||
|
|
const newSelection = unprefixedLines.join("\n");
|
||
|
|
textarea.value =
|
||
|
|
text.substring(0, start) + newSelection + text.substring(end);
|
||
|
|
textarea.setSelectionRange(start, start + newSelection.length);
|
||
|
|
textarea.focus();
|
||
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
|
|
} else {
|
||
|
|
// Add numbered prefix
|
||
|
|
let counter = 1;
|
||
|
|
const numberedLines = lines.map((line) => {
|
||
|
|
if (line.trim()) {
|
||
|
|
return `${counter++}. ${line}`;
|
||
|
|
}
|
||
|
|
return line;
|
||
|
|
});
|
||
|
|
const newSelection = numberedLines.join("\n");
|
||
|
|
textarea.value =
|
||
|
|
text.substring(0, start) + newSelection + text.substring(end);
|
||
|
|
textarea.setSelectionRange(start, start + newSelection.length);
|
||
|
|
textarea.focus();
|
||
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
toolbar.appendChild(numberedBtn);
|
||
|
|
|
||
|
|
// Code
|
||
|
|
const codeBtn = createToolbarButton("</>", "Code (Ctrl+`)", () => {
|
||
|
|
applyMarkdownWrap(textarea, "`", "`");
|
||
|
|
});
|
||
|
|
toolbar.appendChild(codeBtn);
|
||
|
|
|
||
|
|
// Link
|
||
|
|
const linkBtn = createToolbarButton("🔗", "Link", () => {
|
||
|
|
const start = textarea.selectionStart;
|
||
|
|
const end = textarea.selectionEnd;
|
||
|
|
const text = textarea.value;
|
||
|
|
const selectedText = text.substring(start, end);
|
||
|
|
|
||
|
|
if (selectedText) {
|
||
|
|
// Wrap selected text as link
|
||
|
|
applyMarkdownWrap(textarea, "[", "](url)");
|
||
|
|
// Select "url" part
|
||
|
|
setTimeout(() => {
|
||
|
|
const newPos = textarea.selectionStart - 5; // "url)".length
|
||
|
|
textarea.setSelectionRange(newPos, newPos + 3);
|
||
|
|
}, 0);
|
||
|
|
} else {
|
||
|
|
// Insert link template
|
||
|
|
applyMarkdownWrap(textarea, "[text](url)", "");
|
||
|
|
// Select "text" part
|
||
|
|
setTimeout(() => {
|
||
|
|
const newPos = textarea.selectionStart - 9; // "](url)".length
|
||
|
|
textarea.setSelectionRange(newPos, newPos + 4);
|
||
|
|
}, 0);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
toolbar.appendChild(linkBtn);
|
||
|
|
|
||
|
|
// Preview toggle
|
||
|
|
if (onTogglePreview) {
|
||
|
|
const previewBtn = createToolbarButton("👁️", "Toggle Preview", () => {
|
||
|
|
onTogglePreview();
|
||
|
|
});
|
||
|
|
previewBtn.style.marginLeft = "auto";
|
||
|
|
toolbar.appendChild(previewBtn);
|
||
|
|
}
|
||
|
|
|
||
|
|
return toolbar;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a toolbar button.
|
||
|
|
*/
|
||
|
|
function createToolbarButton(
|
||
|
|
label: string,
|
||
|
|
title: string,
|
||
|
|
onClick: () => void
|
||
|
|
): HTMLElement {
|
||
|
|
const button = document.createElement("button");
|
||
|
|
button.textContent = label;
|
||
|
|
button.title = title;
|
||
|
|
button.className = "markdown-toolbar-button";
|
||
|
|
button.style.padding = "0.25em 0.5em";
|
||
|
|
button.style.border = "1px solid var(--background-modifier-border)";
|
||
|
|
button.style.borderRadius = "4px";
|
||
|
|
button.style.background = "var(--background-primary)";
|
||
|
|
button.style.cursor = "pointer";
|
||
|
|
button.style.fontSize = "0.9em";
|
||
|
|
button.style.minWidth = "2em";
|
||
|
|
|
||
|
|
button.addEventListener("click", (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopPropagation();
|
||
|
|
onClick();
|
||
|
|
});
|
||
|
|
|
||
|
|
button.addEventListener("mouseenter", () => {
|
||
|
|
button.style.background = "var(--interactive-hover)";
|
||
|
|
});
|
||
|
|
|
||
|
|
button.addEventListener("mouseleave", () => {
|
||
|
|
button.style.background = "var(--background-primary)";
|
||
|
|
});
|
||
|
|
|
||
|
|
return button;
|
||
|
|
}
|