/** * 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; }