diff --git a/src/tests/ui/markdownToolbar.test.ts b/src/tests/ui/markdownToolbar.test.ts new file mode 100644 index 0000000..9111986 --- /dev/null +++ b/src/tests/ui/markdownToolbar.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect } from "vitest"; +import { + applyMarkdownWrap, + applyWikiLink, + applyHeading, + applyBullets, + applyNumberedList, +} from "../../ui/markdownToolbar"; + +describe("markdownToolbar", () => { + describe("applyMarkdownWrap", () => { + it("should wrap selected text", () => { + const text = "Hello world"; + const result = applyMarkdownWrap(text, 0, 5, "**", "**"); + expect(result.newText).toBe("**Hello** world"); + // Cursor should be after the wrapped text + expect(result.newSelectionStart).toBe(9); // After "**Hello**" + expect(result.newSelectionEnd).toBe(9); + }); + + it("should insert at cursor when no selection", () => { + const text = "Hello world"; + const result = applyMarkdownWrap(text, 5, 5, "**", "**"); + expect(result.newText).toBe("Hello**** world"); + expect(result.newSelectionStart).toBe(7); + expect(result.newSelectionEnd).toBe(7); + }); + }); + + describe("applyWikiLink", () => { + it("should wrap selected text with wikilink", () => { + const text = "See my note"; + const result = applyWikiLink(text, 4, 6); + expect(result.newText).toBe("See [[my]] note"); + expect(result.newSelectionStart).toBe(10); // After "[[my]]" + expect(result.newSelectionEnd).toBe(10); + }); + + it("should insert [[text]] with selection when no text selected", () => { + const text = "Hello world"; + const result = applyWikiLink(text, 5, 5); + expect(result.newText).toBe("Hello[[text]] world"); + expect(result.newSelectionStart).toBe(7); + expect(result.newSelectionEnd).toBe(11); // "text" selected + }); + }); + + describe("applyHeading", () => { + it("should add H2 prefix to line", () => { + const text = "My heading\nSome text"; + const result = applyHeading(text, 0, 11, 2); + expect(result.newText).toBe("## My heading\nSome text"); + expect(result.newSelectionStart).toBe(0); + expect(result.newSelectionEnd).toBeGreaterThan(0); + }); + + it("should toggle H2 prefix if already present", () => { + const text = "## My heading\nSome text"; + const result = applyHeading(text, 0, 14, 2); + // Should remove the ## prefix + expect(result.newText).toContain("My heading"); + expect(result.newText).not.toContain("## ##"); + expect(result.newText).not.toContain("## My heading"); + expect(result.newSelectionStart).toBe(0); + }); + + it("should not add duplicate heading prefix", () => { + const text = "## My heading\nSome text"; + const result = applyHeading(text, 0, 14, 2); + // Should remove existing heading, not add another + const result2 = applyHeading(result.newText, 0, result.newText.indexOf("\n"), 2); + expect(result2.newText).toContain("## My heading"); + expect(result2.newText).not.toContain("## ## My heading"); + }); + + it("should handle multi-line selection - all lines should get heading", () => { + const text = "Line 1\nLine 2\nLine 3"; + const result = applyHeading(text, 0, 13, 2); + expect(result.newText).toContain("## Line 1"); + expect(result.newText).toContain("## Line 2"); + expect(result.newText).not.toContain("## Line 3"); // Third line unchanged (not in selection) + }); + + it("should apply heading to all selected lines", () => { + const text = "First line\nSecond line\nThird line"; + const result = applyHeading(text, 0, 22, 2); // Select first two lines + expect(result.newText).toContain("## First line"); + expect(result.newText).toContain("## Second line"); + expect(result.newText).toContain("Third line"); // Third line unchanged + }); + + it("should preserve indentation", () => { + const text = " Indented line"; + const result = applyHeading(text, 0, 15, 2); + expect(result.newText).toBe(" ## Indented line"); + }); + + it("should not delete existing text", () => { + const text = "This is important content"; + const result = applyHeading(text, 0, 25, 2); + expect(result.newText).toContain("This is important content"); + expect(result.newText.length).toBeGreaterThan(text.length); + }); + + it("should operate on complete lines for partial selection within one line", () => { + const text = "This is a long line with important content"; + const result = applyHeading(text, 5, 15, 2); // Select "is a long" + expect(result.newText).toContain("## This is a long line with important content"); + expect(result.newText).toContain("line with important content"); // Should not be deleted + }); + + it("should operate on complete lines for multi-line partial selection", () => { + const text = "First line content\nSecond line content\nThird line content"; + const result = applyHeading(text, 5, 30, 2); // Start mid-first, end mid-second + expect(result.newText).toContain("## First line content"); + expect(result.newText).toContain("## Second line content"); + expect(result.newText).toContain("Third line content"); // Third line should remain unchanged + }); + + it("should not include next line when selection ends at newline", () => { + const text = "Item 1\nItem 2\nItem 3"; + const result = applyHeading(text, 0, 6, 2); // Select "Item 1\n" (ends at newline) + expect(result.newText).toContain("## Item 1"); + expect(result.newText).toContain("Item 2"); // Should not have heading + expect(result.newText).toContain("Item 3"); // Should not have heading + }); + + it("should toggle correctly when all non-empty lines have same heading level", () => { + const text = "## Item 1\n## Item 2\n\n## Item 3"; + const result = applyHeading(text, 0, 25, 2); + expect(result.newText).toContain("Item 1"); + expect(result.newText).toContain("Item 2"); + expect(result.newText).toContain("\n\n"); // Empty line preserved + expect(result.newText).toContain("Item 3"); + expect(result.newText).not.toMatch(/^#+\s/); // No headings should remain + }); + + it("should replace different heading levels with requested level", () => { + const text = "## Item 1\n### Item 2\n\n## Item 3"; + const result = applyHeading(text, 0, 25, 2); + // Lines with ## should be removed (toggle), lines with ### should be replaced with ## + expect(result.newText).toContain("Item 1"); // ## removed + expect(result.newText).toContain("## Item 2"); // ### replaced with ## + expect(result.newText).toContain("\n\n"); // Empty line preserved + expect(result.newText).toContain("Item 3"); // ## removed + }); + + it("should replace existing heading with new level", () => { + const text = "### Existing heading"; + const result = applyHeading(text, 0, 20, 2); + // Should replace ### with ##, not add ## before ### + expect(result.newText).toBe("## Existing heading"); + expect(result.newText).not.toContain("###"); + expect(result.newText).not.toContain("## ##"); + }); + + it("should toggle when same heading level is applied again", () => { + const text = "## Existing heading"; + const result = applyHeading(text, 0, 20, 2); + // Should remove ## because it's the same level + expect(result.newText).toBe("Existing heading"); + expect(result.newText).not.toContain("##"); + }); + + it("should toggle all lines when multiple lines with same heading are selected", () => { + const text = "## Line 1\n## Line 2\n## Line 3"; + const result = applyHeading(text, 0, 25, 2); + // All lines should have heading removed + expect(result.newText).toContain("Line 1"); + expect(result.newText).toContain("Line 2"); + expect(result.newText).toContain("Line 3"); + expect(result.newText).not.toContain("##"); + }); + + it("should not delete text when partially selected heading line is toggled", () => { + const text = "## This is a complete heading line"; + const result = applyHeading(text, 5, 15, 2); // Select "is a compl" + // Should toggle the heading, but keep all text + expect(result.newText).toContain("This is a complete heading line"); + expect(result.newText).not.toContain("##"); + // Verify no text was deleted + expect(result.newText.length).toBeGreaterThanOrEqual(text.length - 3); // -3 for "## " + }); + + it("should not add duplicate heading when whole heading line is selected and toggled", () => { + const text = "## Complete heading line"; + const result = applyHeading(text, 0, 25, 2); // Select entire line + // Should remove heading (toggle), not add another + expect(result.newText).toBe("Complete heading line"); + expect(result.newText).not.toContain("##"); + // Apply again should add heading back + const result2 = applyHeading(result.newText, 0, 25, 2); + expect(result2.newText).toBe("## Complete heading line"); + expect(result2.newText).not.toContain("## ##"); + }); + }); + + describe("applyBullets", () => { + it("should add bullet prefix to selected lines", () => { + const text = "Item 1\nItem 2\nItem 3"; + const result = applyBullets(text, 0, 18); + expect(result.newText).toContain("- Item 1"); + expect(result.newText).toContain("- Item 2"); + expect(result.newText).toContain("- Item 3"); + }); + + it("should toggle bullets if already present", () => { + const text = "- Item 1\n- Item 2"; + const result = applyBullets(text, 0, 15); + expect(result.newText).toContain("Item 1"); + expect(result.newText).toContain("Item 2"); + expect(result.newText).not.toContain("- "); + }); + + it("should preserve indentation", () => { + const text = " Item 1\n Item 2"; + const result = applyBullets(text, 0, 16); + expect(result.newText).toContain(" - Item 1"); + expect(result.newText).toContain(" - Item 2"); + }); + + it("should skip empty lines", () => { + const text = "Item 1\n\nItem 2"; + const result = applyBullets(text, 0, 12); + expect(result.newText).toContain("- Item 1"); + expect(result.newText).toContain("\n\n"); + expect(result.newText).toContain("- Item 2"); + // Verify both items are present + const lines = result.newText.split("\n"); + expect(lines.filter(l => l.includes("Item")).length).toBeGreaterThanOrEqual(2); + }); + + it("should not delete existing text", () => { + const text = "Important content here"; + const result = applyBullets(text, 0, 22); + expect(result.newText).toContain("Important content here"); + expect(result.newText.length).toBeGreaterThanOrEqual(text.length); + }); + + it("should operate on complete lines for partial selection within one line", () => { + const text = "This is a long line with important content"; + const result = applyBullets(text, 5, 15); // Select "is a long" + expect(result.newText).toContain("- This is a long line with important content"); + expect(result.newText).toContain("line with important content"); // Should not be deleted + }); + + it("should operate on complete lines for multi-line partial selection", () => { + const text = "First line content\nSecond line content\nThird line content"; + const result = applyBullets(text, 5, 30); // Start mid-first, end mid-second + expect(result.newText).toContain("- First line content"); + expect(result.newText).toContain("- Second line content"); + expect(result.newText).toContain("Third line content"); // Third line should remain unchanged + }); + + it("should not include next line when selection ends at newline", () => { + const text = "Item 1\nItem 2\nItem 3"; + const result = applyBullets(text, 0, 6); // Select "Item 1\n" (ends at newline) + expect(result.newText).toContain("- Item 1"); + expect(result.newText).toContain("Item 2"); // Should not have bullet + expect(result.newText).toContain("Item 3"); // Should not have bullet + }); + + it("should toggle correctly when all non-empty lines have bullets", () => { + const text = "- Item 1\n- Item 2\n\n- Item 3"; + const result = applyBullets(text, 0, 25); + expect(result.newText).toContain("Item 1"); + expect(result.newText).toContain("Item 2"); + expect(result.newText).toContain("\n\n"); // Empty line preserved + expect(result.newText).toContain("Item 3"); + expect(result.newText).not.toContain("- "); + }); + }); + + describe("applyNumberedList", () => { + it("should add numbered prefix to selected lines", () => { + const text = "Item 1\nItem 2\nItem 3"; + const result = applyNumberedList(text, 0, 18); + expect(result.newText).toContain("1. Item 1"); + expect(result.newText).toContain("2. Item 2"); + expect(result.newText).toContain("3. Item 3"); + }); + + it("should toggle numbered list if already present", () => { + const text = "1. Item 1\n2. Item 2"; + const result = applyNumberedList(text, 0, 17); + expect(result.newText).toContain("Item 1"); + expect(result.newText).toContain("Item 2"); + expect(result.newText).not.toMatch(/\d+\.\s/); + }); + + it("should preserve indentation", () => { + const text = " Item 1\n Item 2"; + const result = applyNumberedList(text, 0, 16); + expect(result.newText).toContain(" 1. Item 1"); + expect(result.newText).toContain(" 2. Item 2"); + // Verify both items are present + const lines = result.newText.split("\n"); + expect(lines.filter(l => l.includes("Item")).length).toBeGreaterThanOrEqual(2); + }); + + it("should not delete existing text", () => { + const text = "Important content"; + const result = applyNumberedList(text, 0, 18); + expect(result.newText).toContain("Important content"); + expect(result.newText.length).toBeGreaterThanOrEqual(text.length); + }); + + it("should operate on complete lines for partial selection within one line", () => { + const text = "This is a long line with important content"; + const result = applyNumberedList(text, 5, 15); // Select "is a long" + expect(result.newText).toContain("1. This is a long line with important content"); + expect(result.newText).toContain("line with important content"); // Should not be deleted + }); + + it("should operate on complete lines for multi-line partial selection", () => { + const text = "First line content\nSecond line content\nThird line content"; + const result = applyNumberedList(text, 5, 30); // Start mid-first, end mid-second + expect(result.newText).toContain("1. First line content"); + expect(result.newText).toContain("2. Second line content"); + expect(result.newText).toContain("Third line content"); // Third line should remain unchanged + }); + + it("should not include next line when selection ends at newline", () => { + const text = "Item 1\nItem 2\nItem 3"; + const result = applyNumberedList(text, 0, 6); // Select "Item 1\n" (ends at newline) + expect(result.newText).toContain("1. Item 1"); + expect(result.newText).toContain("Item 2"); // Should not have number + expect(result.newText).toContain("Item 3"); // Should not have number + }); + + it("should not number empty lines", () => { + const text = "Item 1\n\nItem 2"; + const result = applyNumberedList(text, 0, 12); + expect(result.newText).toContain("1. Item 1"); + expect(result.newText).toContain("\n\n"); // Empty line preserved + expect(result.newText).toContain("2. Item 2"); + // Verify empty line is not numbered + const lines = result.newText.split("\n"); + const emptyLineIndex = lines.findIndex(l => l.trim() === ""); + expect(emptyLineIndex).toBeGreaterThanOrEqual(0); + expect(lines[emptyLineIndex]).not.toMatch(/^\d+\.\s/); + }); + + it("should toggle correctly when all non-empty lines have numbers", () => { + const text = "1. Item 1\n2. Item 2\n\n3. Item 3"; + const result = applyNumberedList(text, 0, 25); + expect(result.newText).toContain("Item 1"); + expect(result.newText).toContain("Item 2"); + expect(result.newText).toContain("\n\n"); // Empty line preserved + expect(result.newText).toContain("Item 3"); + expect(result.newText).not.toMatch(/\d+\.\s/); + }); + }); +}); diff --git a/src/ui/InterviewWizardModal.ts b/src/ui/InterviewWizardModal.ts index ce8a05c..f62774b 100644 --- a/src/ui/InterviewWizardModal.ts +++ b/src/ui/InterviewWizardModal.ts @@ -24,8 +24,6 @@ import { import { extractFrontmatterId } from "../parser/parseFrontmatter"; import { createMarkdownToolbar, - applyMarkdownWrap, - applyLinePrefix, } from "./markdownToolbar"; export interface WizardResult { diff --git a/src/ui/markdownToolbar.ts b/src/ui/markdownToolbar.ts index 92dd62e..e9cf403 100644 --- a/src/ui/markdownToolbar.ts +++ b/src/ui/markdownToolbar.ts @@ -3,133 +3,353 @@ */ /** - * Apply markdown formatting to selected text in textarea. + * 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( - textarea: HTMLTextAreaElement, + text: string, + selectionStart: number, + selectionEnd: number, before: string, after: string = "" -): void { - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const text = textarea.value; - const selectedText = text.substring(start, end); +): ToolbarResult { + const selectedText = text.substring(selectionStart, selectionEnd); let newText: string; - let newCursorPos: number; + let newSelectionStart: number; + let newSelectionEnd: 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; + 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, start) + before + after + text.substring(end); - newCursorPos = start + before.length; + newText = text.substring(0, selectionStart) + before + after + text.substring(selectionEnd); + newSelectionStart = selectionStart + before.length; + newSelectionEnd = newSelectionStart; } - textarea.value = newText; - textarea.setSelectionRange(newCursorPos, newCursorPos); - textarea.focus(); - - // Trigger change event - textarea.dispatchEvent(new Event("input", { bubbles: true })); + return { newText, newSelectionStart, newSelectionEnd }; } /** - * Apply prefix to each selected line (for lists). + * Apply wiki link formatting to selected text. + * If no selection, inserts [[text]] with cursor between brackets. */ -export function applyLinePrefix( - textarea: HTMLTextAreaElement, - prefix: string -): void { - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const text = textarea.value; +export function applyWikiLink( + text: string, + selectionStart: number, + selectionEnd: number +): ToolbarResult { + const selectedText = text.substring(selectionStart, selectionEnd); - // Find line boundaries - const beforeSelection = text.substring(0, start); - const selection = text.substring(start, end); - const afterSelection = text.substring(end); + let newText: string; + let newSelectionStart: number; + let newSelectionEnd: number; - const lineStart = beforeSelection.lastIndexOf("\n") + 1; - const lineEnd = afterSelection.indexOf("\n"); - const fullLineEnd = end + (lineEnd >= 0 ? lineEnd : afterSelection.length); + 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" + } - // 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 })); + return { newText, newSelectionStart, newSelectionEnd }; } /** - * Remove prefix from each selected line (for lists). + * 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 removeLinePrefix( +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, - prefix: string + result: ToolbarResult ): 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.value = result.newText; + textarea.setSelectionRange(result.newSelectionStart, result.newSelectionEnd); textarea.focus(); - - // Trigger change event textarea.dispatchEvent(new Event("input", { bubbles: true })); } @@ -151,119 +371,97 @@ export function createMarkdownToolbar( // Bold const boldBtn = createToolbarButton("B", "Bold (Ctrl+B)", () => { - applyMarkdownWrap(textarea, "**", "**"); + const result = applyMarkdownWrap( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd, + "**", + "**" + ); + applyToTextarea(textarea, result); }); toolbar.appendChild(boldBtn); // Italic const italicBtn = createToolbarButton("I", "Italic (Ctrl+I)", () => { - applyMarkdownWrap(textarea, "*", "*"); + const result = applyMarkdownWrap( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd, + "*", + "*" + ); + applyToTextarea(textarea, result); }); toolbar.appendChild(italicBtn); // H2 const h2Btn = createToolbarButton("H2", "Heading 2", () => { - applyLinePrefix(textarea, "## "); + const result = applyHeading( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd, + 2 + ); + applyToTextarea(textarea, result); }); toolbar.appendChild(h2Btn); // H3 const h3Btn = createToolbarButton("H3", "Heading 3", () => { - applyLinePrefix(textarea, "### "); + const result = applyHeading( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd, + 3 + ); + applyToTextarea(textarea, result); }); 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, "- "); - } + const result = applyBullets( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd + ); + applyToTextarea(textarea, result); }); 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 })); - } + const result = applyNumberedList( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd + ); + applyToTextarea(textarea, result); }); toolbar.appendChild(numberedBtn); // Code const codeBtn = createToolbarButton("", "Code (Ctrl+`)", () => { - applyMarkdownWrap(textarea, "`", "`"); + const result = applyMarkdownWrap( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd, + "`", + "`" + ); + applyToTextarea(textarea, result); }); 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); - } + // Link (WikiLink) + const linkBtn = createToolbarButton("🔗", "Wiki Link", () => { + const result = applyWikiLink( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd + ); + applyToTextarea(textarea, result); }); toolbar.appendChild(linkBtn);