- 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.
566 lines
18 KiB
TypeScript
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;
|
|
}
|