mindnet_obsidian/src/ui/markdownToolbar.ts

318 lines
9.2 KiB
TypeScript
Raw Normal View History

/**
* Markdown toolbar helpers for text editors.
*/
/**
* Apply markdown formatting to selected text in textarea.
* If no selection, inserts before/after at cursor position.
*/
export function applyMarkdownWrap(
textarea: HTMLTextAreaElement,
before: string,
after: string = ""
): void {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
let newText: string;
let newCursorPos: number;
if (selectedText) {
// Wrap selected text
newText = text.substring(0, start) + before + selectedText + after + text.substring(end);
newCursorPos = start + before.length + selectedText.length + after.length;
} else {
// Insert at cursor position
newText = text.substring(0, start) + before + after + text.substring(end);
newCursorPos = start + before.length;
}
textarea.value = newText;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
// Trigger change event
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
/**
* Apply prefix to each selected line (for lists).
*/
export function applyLinePrefix(
textarea: HTMLTextAreaElement,
prefix: string
): void {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
// Find line boundaries
const beforeSelection = text.substring(0, start);
const selection = text.substring(start, end);
const afterSelection = text.substring(end);
const lineStart = beforeSelection.lastIndexOf("\n") + 1;
const lineEnd = afterSelection.indexOf("\n");
const fullLineEnd = end + (lineEnd >= 0 ? lineEnd : afterSelection.length);
// Get all lines in selection
const lines = selection.split("\n");
const firstLineStart = beforeSelection.lastIndexOf("\n") + 1;
const firstLinePrefix = text.substring(firstLineStart, start);
// Apply prefix to each line
const prefixedLines = lines.map((line) => {
if (line.trim()) {
return prefix + line;
}
return line;
});
const newSelection = prefixedLines.join("\n");
const newText =
text.substring(0, lineStart) + newSelection + text.substring(fullLineEnd);
textarea.value = newText;
// Restore selection
const newStart = lineStart;
const newEnd = lineStart + newSelection.length;
textarea.setSelectionRange(newStart, newEnd);
textarea.focus();
// Trigger change event
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
/**
* Remove prefix from each selected line (for lists).
*/
export function removeLinePrefix(
textarea: HTMLTextAreaElement,
prefix: string
): void {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
// Find line boundaries
const beforeSelection = text.substring(0, start);
const selection = text.substring(start, end);
const afterSelection = text.substring(end);
const lineStart = beforeSelection.lastIndexOf("\n") + 1;
const lineEnd = afterSelection.indexOf("\n");
const fullLineEnd = end + (lineEnd >= 0 ? lineEnd : afterSelection.length);
// Get all lines in selection
const lines = selection.split("\n");
// Remove prefix from each line
const unprefixedLines = lines.map((line) => {
if (line.startsWith(prefix)) {
return line.substring(prefix.length);
}
return line;
});
const newSelection = unprefixedLines.join("\n");
const newText =
text.substring(0, lineStart) + newSelection + text.substring(fullLineEnd);
textarea.value = newText;
// Restore selection
const newStart = lineStart;
const newEnd = lineStart + newSelection.length;
textarea.setSelectionRange(newStart, newEnd);
textarea.focus();
// Trigger change event
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
/**
* Create markdown toolbar with buttons.
*/
export function createMarkdownToolbar(
textarea: HTMLTextAreaElement,
onTogglePreview?: () => void
): HTMLElement {
const toolbar = document.createElement("div");
toolbar.className = "markdown-toolbar";
toolbar.style.display = "flex";
toolbar.style.gap = "0.25em";
toolbar.style.padding = "0.5em";
toolbar.style.borderBottom = "1px solid var(--background-modifier-border)";
toolbar.style.flexWrap = "wrap";
toolbar.style.alignItems = "center";
// Bold
const boldBtn = createToolbarButton("B", "Bold (Ctrl+B)", () => {
applyMarkdownWrap(textarea, "**", "**");
});
toolbar.appendChild(boldBtn);
// Italic
const italicBtn = createToolbarButton("I", "Italic (Ctrl+I)", () => {
applyMarkdownWrap(textarea, "*", "*");
});
toolbar.appendChild(italicBtn);
// H2
const h2Btn = createToolbarButton("H2", "Heading 2", () => {
applyLinePrefix(textarea, "## ");
});
toolbar.appendChild(h2Btn);
// H3
const h3Btn = createToolbarButton("H3", "Heading 3", () => {
applyLinePrefix(textarea, "### ");
});
toolbar.appendChild(h3Btn);
// Bullet List
const bulletBtn = createToolbarButton("•", "Bullet List", () => {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
// Check if lines already have bullet prefix
const lines = selectedText.split("\n");
const hasBullets = lines.some((line) => line.trim().startsWith("- "));
if (hasBullets) {
removeLinePrefix(textarea, "- ");
} else {
applyLinePrefix(textarea, "- ");
}
});
toolbar.appendChild(bulletBtn);
// Numbered List
const numberedBtn = createToolbarButton("1.", "Numbered List", () => {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
// Check if lines already have numbered prefix
const lines = selectedText.split("\n");
const hasNumbers = lines.some((line) => /^\d+\.\s/.test(line.trim()));
if (hasNumbers) {
// Remove numbered prefix (simple version - removes "1. " pattern)
const unprefixedLines = lines.map((line) => {
const match = line.match(/^(\d+\.\s)(.*)$/);
return match ? match[2] : line;
});
const newSelection = unprefixedLines.join("\n");
textarea.value =
text.substring(0, start) + newSelection + text.substring(end);
textarea.setSelectionRange(start, start + newSelection.length);
textarea.focus();
textarea.dispatchEvent(new Event("input", { bubbles: true }));
} else {
// Add numbered prefix
let counter = 1;
const numberedLines = lines.map((line) => {
if (line.trim()) {
return `${counter++}. ${line}`;
}
return line;
});
const newSelection = numberedLines.join("\n");
textarea.value =
text.substring(0, start) + newSelection + text.substring(end);
textarea.setSelectionRange(start, start + newSelection.length);
textarea.focus();
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
});
toolbar.appendChild(numberedBtn);
// Code
const codeBtn = createToolbarButton("</>", "Code (Ctrl+`)", () => {
applyMarkdownWrap(textarea, "`", "`");
});
toolbar.appendChild(codeBtn);
// Link
const linkBtn = createToolbarButton("🔗", "Link", () => {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
if (selectedText) {
// Wrap selected text as link
applyMarkdownWrap(textarea, "[", "](url)");
// Select "url" part
setTimeout(() => {
const newPos = textarea.selectionStart - 5; // "url)".length
textarea.setSelectionRange(newPos, newPos + 3);
}, 0);
} else {
// Insert link template
applyMarkdownWrap(textarea, "[text](url)", "");
// Select "text" part
setTimeout(() => {
const newPos = textarea.selectionStart - 9; // "](url)".length
textarea.setSelectionRange(newPos, newPos + 4);
}, 0);
}
});
toolbar.appendChild(linkBtn);
// Preview toggle
if (onTogglePreview) {
const previewBtn = createToolbarButton("👁️", "Toggle Preview", () => {
onTogglePreview();
});
previewBtn.style.marginLeft = "auto";
toolbar.appendChild(previewBtn);
}
return toolbar;
}
/**
* Create a toolbar button.
*/
function createToolbarButton(
label: string,
title: string,
onClick: () => void
): HTMLElement {
const button = document.createElement("button");
button.textContent = label;
button.title = title;
button.className = "markdown-toolbar-button";
button.style.padding = "0.25em 0.5em";
button.style.border = "1px solid var(--background-modifier-border)";
button.style.borderRadius = "4px";
button.style.background = "var(--background-primary)";
button.style.cursor = "pointer";
button.style.fontSize = "0.9em";
button.style.minWidth = "2em";
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
onClick();
});
button.addEventListener("mouseenter", () => {
button.style.background = "var(--interactive-hover)";
});
button.addEventListener("mouseleave", () => {
button.style.background = "var(--background-primary)";
});
return button;
}