mindnet_obsidian/src/ui/markdownToolbar.ts
Lars 7ea36fbed4
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run
Enhance inline micro edge handling and settings
- Introduced new settings for enabling inline micro edge suggestions and configuring the maximum number of alternatives displayed.
- Updated the InterviewWizardModal to support inline micro edging, allowing users to select edge types immediately after inserting links.
- Enhanced the semantic mapping builder to incorporate pending edge assignments, improving the handling of rel:type links.
- Improved the user experience with clearer logging and error handling during inline edge type selection and mapping processes.
2026-01-17 11:54:14 +01:00

566 lines
18 KiB
TypeScript

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