/** * Markdown toolbar helpers for text editors. */ /** * Result of a toolbar operation. */ export interface ToolbarResult { newText: string; newSelectionStart: number; newSelectionEnd: number; } /** * Apply markdown formatting to selected text. * If no selection, inserts before/after at cursor position. */ export function applyMarkdownWrap( text: string, selectionStart: number, selectionEnd: number, before: string, after: string = "" ): ToolbarResult { const selectedText = text.substring(selectionStart, selectionEnd); let newText: string; let newSelectionStart: number; let newSelectionEnd: number; if (selectedText) { // Wrap selected text newText = text.substring(0, selectionStart) + before + selectedText + after + text.substring(selectionEnd); newSelectionStart = selectionStart + before.length + selectedText.length + after.length; newSelectionEnd = newSelectionStart; } else { // Insert at cursor position newText = text.substring(0, selectionStart) + before + after + text.substring(selectionEnd); newSelectionStart = selectionStart + before.length; newSelectionEnd = newSelectionStart; } return { newText, newSelectionStart, newSelectionEnd }; } /** * Apply wiki link formatting to selected text. * If no selection, inserts [[text]] with cursor between brackets. */ export function applyWikiLink( text: string, selectionStart: number, selectionEnd: number ): ToolbarResult { const selectedText = text.substring(selectionStart, selectionEnd); let newText: string; let newSelectionStart: number; let newSelectionEnd: number; if (selectedText) { // Wrap selected text with [[...]] newText = text.substring(0, selectionStart) + "[[" + selectedText + "]]" + text.substring(selectionEnd); newSelectionStart = selectionStart + 2 + selectedText.length + 2; newSelectionEnd = newSelectionStart; } else { // Insert [[text]] with cursor between brackets newText = text.substring(0, selectionStart) + "[[text]]" + text.substring(selectionEnd); newSelectionStart = selectionStart + 2; newSelectionEnd = selectionStart + 6; // Select "text" } return { newText, newSelectionStart, newSelectionEnd }; } /** * Apply rel:type link formatting (for inline edge type assignment). * Format: [[rel:edgeType|link]] * If no selection, inserts [[rel:type|text]] with cursor between brackets. */ export function applyRelLink( text: string, selectionStart: number, selectionEnd: number, edgeType: string ): ToolbarResult { const selectedText = text.substring(selectionStart, selectionEnd); let newText: string; let newSelectionStart: number; let newSelectionEnd: number; if (selectedText) { // Wrap selected text with [[rel:type|...]] const relLink = `[[rel:${edgeType}|${selectedText}]]`; newText = text.substring(0, selectionStart) + relLink + text.substring(selectionEnd); newSelectionStart = selectionStart + relLink.length; newSelectionEnd = newSelectionStart; } else { // Insert [[rel:type|text]] with cursor between brackets const relLink = `[[rel:${edgeType}|text]]`; newText = text.substring(0, selectionStart) + relLink + text.substring(selectionEnd); // Select "text" part for easy editing newSelectionStart = selectionStart + relLink.indexOf("|") + 1; newSelectionEnd = selectionStart + 4; // Select "text" } return { newText, newSelectionStart, newSelectionEnd }; } /** * Apply heading prefix to current line(s). * Toggles heading if already present. * Always operates on complete lines, even for partial selections. * Never deletes text content. */ export function applyHeading( text: string, selectionStart: number, selectionEnd: number, level: 2 | 3 ): ToolbarResult { const prefix = "#".repeat(level) + " "; // Find line boundaries: operate on complete lines touched by selection // startLine: beginning of first line touched by selection const startLine = selectionStart > 0 ? text.lastIndexOf("\n", selectionStart - 1) + 1 : 0; // effectiveEnd: if selection ends at newline, don't include next line const effectiveEnd = (selectionEnd > 0 && text[selectionEnd - 1] === "\n") ? selectionEnd - 1 : selectionEnd; // endLine: end of last line touched by selection (including newline if present) const nextNewline = text.indexOf("\n", effectiveEnd); const endLine = nextNewline >= 0 ? nextNewline + 1 : text.length; // Extract the complete block of lines to edit const block = text.substring(startLine, endLine); const lines = block.split("\n"); // Check if all non-empty lines have the exact same heading prefix as requested // Must match exactly (e.g., "## " not "### " or "# ") const nonEmptyLines = lines.filter((line) => line.trim()); const hasSameHeading = nonEmptyLines.length > 0 && nonEmptyLines.every((line) => { const trimmed = line.trim(); // Check if line starts with exactly the requested prefix // Match the heading pattern and compare to requested prefix const headingMatch = trimmed.match(/^(#+\s)/); return headingMatch && headingMatch[1] === prefix; }); let newText: string; let newSelectionStart: number; let newSelectionEnd: number; if (hasSameHeading) { // Toggle: Remove heading prefix from ALL lines if they match the requested level const unprefixedLines = lines.map((line) => { if (line.trim()) { const trimmed = line.trim(); // Check if line has exactly the requested heading prefix const headingMatch = trimmed.match(/^(#+\s)/); if (headingMatch && headingMatch[1] === prefix) { // Remove the matching heading prefix, preserve indentation const indentMatch = line.match(/^(\s*)/); const indent = indentMatch?.[1] ?? ""; // Remove the prefix (e.g., "## ") const content = trimmed.substring(prefix.length); return indent + content; } } return line; // Empty lines or lines without matching prefix stay as-is }); const newBlock = unprefixedLines.join("\n"); newText = text.substring(0, startLine) + newBlock + text.substring(endLine); newSelectionStart = startLine; newSelectionEnd = startLine + newBlock.length; } else { // Add or replace heading prefix: remove any existing heading, then add new one const prefixedLines = lines.map((line) => { if (line.trim()) { // Preserve indentation const indentMatch = line.match(/^(\s*)/); const indent = indentMatch?.[1] ?? ""; // Get content without indentation const withoutIndent = line.substring(indent.length); // Remove any existing heading prefix (one or more # followed by space) // Use a more robust regex that handles multiple spaces const content = withoutIndent.replace(/^#+\s+/, "").trimStart(); // Ensure we have content (not just whitespace) if (content) { return indent + prefix + content; } // If content is empty after removing heading, return original line return line; } return line; // Preserve empty lines }); const newBlock = prefixedLines.join("\n"); newText = text.substring(0, startLine) + newBlock + text.substring(endLine); newSelectionStart = startLine; newSelectionEnd = startLine + newBlock.length; } return { newText, newSelectionStart, newSelectionEnd }; } /** * Apply bullet list prefix to selected lines. * Toggles bullets if already present. * Always operates on complete lines, even for partial selections. */ export function applyBullets( text: string, selectionStart: number, selectionEnd: number ): ToolbarResult { const prefix = "- "; // Find line boundaries: operate on complete lines touched by selection // startLine: beginning of first line touched by selection const startLine = selectionStart > 0 ? text.lastIndexOf("\n", selectionStart - 1) + 1 : 0; // effectiveEnd: if selection ends at newline, don't include next line const effectiveEnd = (selectionEnd > 0 && text[selectionEnd - 1] === "\n") ? selectionEnd - 1 : selectionEnd; // endLine: end of last line touched by selection (including newline if present) const nextNewline = text.indexOf("\n", effectiveEnd); const endLine = nextNewline >= 0 ? nextNewline + 1 : text.length; // Extract the complete block of lines to edit const block = text.substring(startLine, endLine); const lines = block.split("\n"); // Check if all non-empty lines have bullet prefix const nonEmptyLines = lines.filter((line) => line.trim()); const hasBullets = nonEmptyLines.length > 0 && nonEmptyLines.every((line) => { const trimmed = line.trim(); return trimmed.startsWith(prefix.trim()); }); let newText: string; let newSelectionStart: number; let newSelectionEnd: number; if (hasBullets) { // Remove bullet prefix from all lines const unprefixedLines = lines.map((line) => { const trimmed = line.trim(); if (trimmed.startsWith(prefix.trim())) { // Remove prefix, preserve indentation const indentMatch = line.match(/^(\s*)/); const indent = indentMatch?.[1] ?? ""; return indent + trimmed.substring(prefix.length); } return line; // Empty lines or lines without prefix stay as-is }); const newBlock = unprefixedLines.join("\n"); newText = text.substring(0, startLine) + newBlock + text.substring(endLine); newSelectionStart = startLine; newSelectionEnd = startLine + newBlock.length; } else { // Add bullet prefix to each non-empty line const prefixedLines = lines.map((line) => { if (line.trim()) { // Preserve indentation const indentMatch = line.match(/^(\s*)/); const indent = indentMatch?.[1] ?? ""; const content = line.substring(indent.length); return indent + prefix + content; } return line; // Preserve empty lines }); const newBlock = prefixedLines.join("\n"); newText = text.substring(0, startLine) + newBlock + text.substring(endLine); newSelectionStart = startLine; newSelectionEnd = startLine + newBlock.length; } return { newText, newSelectionStart, newSelectionEnd }; } /** * Apply numbered list prefix to selected lines. * Toggles numbered list if already present. * Always operates on complete lines, even for partial selections. */ export function applyNumberedList( text: string, selectionStart: number, selectionEnd: number ): ToolbarResult { // Find line boundaries: operate on complete lines touched by selection // startLine: beginning of first line touched by selection const startLine = selectionStart > 0 ? text.lastIndexOf("\n", selectionStart - 1) + 1 : 0; // effectiveEnd: if selection ends at newline, don't include next line const effectiveEnd = (selectionEnd > 0 && text[selectionEnd - 1] === "\n") ? selectionEnd - 1 : selectionEnd; // endLine: end of last line touched by selection (including newline if present) const nextNewline = text.indexOf("\n", effectiveEnd); const endLine = nextNewline >= 0 ? nextNewline + 1 : text.length; // Extract the complete block of lines to edit const block = text.substring(startLine, endLine); const lines = block.split("\n"); // Check if all non-empty lines have numbered prefix const nonEmptyLines = lines.filter((line) => line.trim()); const hasNumbers = nonEmptyLines.length > 0 && nonEmptyLines.every((line) => { const trimmed = line.trim(); return /^\d+\.\s/.test(trimmed); }); let newText: string; let newSelectionStart: number; let newSelectionEnd: number; if (hasNumbers) { // Remove numbered prefix from all lines const unprefixedLines = lines.map((line) => { const trimmed = line.trim(); const match = trimmed.match(/^(\d+\.\s)(.*)$/); if (match) { // Preserve indentation const indentMatch = line.match(/^(\s*)/); const indent = indentMatch?.[1] ?? ""; return indent + match[2]; } return line; // Empty lines or lines without prefix stay as-is }); const newBlock = unprefixedLines.join("\n"); newText = text.substring(0, startLine) + newBlock + text.substring(endLine); newSelectionStart = startLine; newSelectionEnd = startLine + newBlock.length; } else { // Add numbered prefix to each non-empty line let counter = 1; const prefixedLines = lines.map((line) => { if (line.trim()) { // Preserve indentation const indentMatch = line.match(/^(\s*)/); const indent = indentMatch?.[1] ?? ""; const content = line.substring(indent.length); return indent + `${counter++}. ${content}`; } return line; // Preserve empty lines (do not number blank lines) }); const newBlock = prefixedLines.join("\n"); newText = text.substring(0, startLine) + newBlock + text.substring(endLine); newSelectionStart = startLine; newSelectionEnd = startLine + newBlock.length; } return { newText, newSelectionStart, newSelectionEnd }; } /** * Apply toolbar operation to textarea. */ function applyToTextarea( textarea: HTMLTextAreaElement, result: ToolbarResult ): void { textarea.value = result.newText; textarea.setSelectionRange(result.newSelectionStart, result.newSelectionEnd); textarea.focus(); textarea.dispatchEvent(new Event("input", { bubbles: true })); } /** * Create markdown toolbar with buttons. */ export function createMarkdownToolbar( textarea: HTMLTextAreaElement, onTogglePreview?: () => void, onPickNote?: (app: any) => 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)", () => { const result = applyMarkdownWrap( textarea.value, textarea.selectionStart, textarea.selectionEnd, "**", "**" ); applyToTextarea(textarea, result); }); toolbar.appendChild(boldBtn); // Italic const italicBtn = createToolbarButton("I", "Italic (Ctrl+I)", () => { const result = applyMarkdownWrap( textarea.value, textarea.selectionStart, textarea.selectionEnd, "*", "*" ); applyToTextarea(textarea, result); }); toolbar.appendChild(italicBtn); // H2 const h2Btn = createToolbarButton("H2", "Heading 2", () => { const result = applyHeading( textarea.value, textarea.selectionStart, textarea.selectionEnd, 2 ); applyToTextarea(textarea, result); }); toolbar.appendChild(h2Btn); // H3 const h3Btn = createToolbarButton("H3", "Heading 3", () => { const result = applyHeading( textarea.value, textarea.selectionStart, textarea.selectionEnd, 3 ); applyToTextarea(textarea, result); }); toolbar.appendChild(h3Btn); // Bullet List const bulletBtn = createToolbarButton("β€’", "Bullet List", () => { const result = applyBullets( textarea.value, textarea.selectionStart, textarea.selectionEnd ); applyToTextarea(textarea, result); }); toolbar.appendChild(bulletBtn); // Numbered List const numberedBtn = createToolbarButton("1.", "Numbered List", () => { const result = applyNumberedList( textarea.value, textarea.selectionStart, textarea.selectionEnd ); applyToTextarea(textarea, result); }); toolbar.appendChild(numberedBtn); // Code const codeBtn = createToolbarButton("", "Code (Ctrl+`)", () => { const result = applyMarkdownWrap( textarea.value, textarea.selectionStart, textarea.selectionEnd, "`", "`" ); applyToTextarea(textarea, result); }); toolbar.appendChild(codeBtn); // Link (WikiLink) const linkBtn = createToolbarButton("πŸ”—", "Wiki Link", () => { const result = applyWikiLink( textarea.value, textarea.selectionStart, textarea.selectionEnd ); applyToTextarea(textarea, result); }); toolbar.appendChild(linkBtn); // Pick note button (if callback provided) if (onPickNote) { const pickNoteBtn = createToolbarButton("πŸ“Ž", "Pick note…", () => { // Get app from window (Obsidian exposes it globally) const app = (window as any).app; if (app && onPickNote) { onPickNote(app); } else { console.warn("App not available for entity picker"); } }); toolbar.appendChild(pickNoteBtn); } // 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; }