Refactor markdown toolbar functionality for improved text formatting
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run

- Removed deprecated functions and streamlined the applyMarkdownWrap function to return a ToolbarResult object.
- Introduced new functions for applying headings, bullet lists, and numbered lists, enhancing the text formatting capabilities.
- Updated the InterviewWizardModal to utilize the new toolbar functions for better integration and user experience.
- Improved selection handling and cursor positioning after formatting operations.
This commit is contained in:
Lars 2026-01-16 15:13:40 +01:00
parent d7aa9bd964
commit 587ef3010a
3 changed files with 731 additions and 181 deletions

View File

@ -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/);
});
});
});

View File

@ -24,8 +24,6 @@ import {
import { extractFrontmatterId } from "../parser/parseFrontmatter";
import {
createMarkdownToolbar,
applyMarkdownWrap,
applyLinePrefix,
} from "./markdownToolbar";
export interface WizardResult {

View File

@ -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);
return { newText, newSelectionStart, newSelectionEnd };
}
// Apply prefix to each line
/**
* 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()) {
return prefix + line;
// 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 newSelection = prefixedLines.join("\n");
const newText =
text.substring(0, lineStart) + newSelection + text.substring(fullLineEnd);
const newBlock = prefixedLines.join("\n");
newText = text.substring(0, startLine) + newBlock + text.substring(endLine);
newSelectionStart = startLine;
newSelectionEnd = startLine + newBlock.length;
}
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 bullet list prefix to selected lines.
* Toggles bullets if already present.
* Always operates on complete lines, even for partial selections.
*/
export function removeLinePrefix(
textarea: HTMLTextAreaElement,
prefix: string
): void {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
export function applyBullets(
text: string,
selectionStart: number,
selectionEnd: number
): ToolbarResult {
const prefix = "- ";
// Find line boundaries
const beforeSelection = text.substring(0, start);
const selection = text.substring(start, end);
const afterSelection = text.substring(end);
// 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;
const lineStart = beforeSelection.lastIndexOf("\n") + 1;
const lineEnd = afterSelection.indexOf("\n");
const fullLineEnd = end + (lineEnd >= 0 ? lineEnd : afterSelection.length);
// effectiveEnd: if selection ends at newline, don't include next line
const effectiveEnd = (selectionEnd > 0 && text[selectionEnd - 1] === "\n")
? selectionEnd - 1
: selectionEnd;
// Get all lines in selection
const lines = selection.split("\n");
// 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;
// Remove prefix from each line
const unprefixedLines = lines.map((line) => {
if (line.startsWith(prefix)) {
return line.substring(prefix.length);
}
return line;
// 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());
});
const newSelection = unprefixedLines.join("\n");
const newText =
text.substring(0, lineStart) + newSelection + text.substring(fullLineEnd);
let newText: string;
let newSelectionStart: number;
let newSelectionEnd: number;
textarea.value = newText;
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
});
// Restore selection
const newStart = lineStart;
const newEnd = lineStart + newSelection.length;
textarea.setSelectionRange(newStart, newEnd);
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();
// 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);