Refactor markdown toolbar functionality for improved text formatting
- 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:
parent
d7aa9bd964
commit
587ef3010a
354
src/tests/ui/markdownToolbar.test.ts
Normal file
354
src/tests/ui/markdownToolbar.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -24,8 +24,6 @@ import {
|
|||
import { extractFrontmatterId } from "../parser/parseFrontmatter";
|
||||
import {
|
||||
createMarkdownToolbar,
|
||||
applyMarkdownWrap,
|
||||
applyLinePrefix,
|
||||
} from "./markdownToolbar";
|
||||
|
||||
export interface WizardResult {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user