- useEditorHistory: undo/redo stack over the textarea body
- WikiTemplateDialog: insert {{Template}} transclusions from the
toolbar, with per-template {{param}} placeholder prompts
- WikiEditorHighlightLayer: mirrored overlay highlighting headings,
directives, and template placeholders over the textarea
- editorHighlight.ts: tokenizer driving the overlay
- formatMarkdown.ts + createFormatUpdate: auto-format the document
- editorFormatting.ts: Tab/Shift+Tab indent/outdent, list
continuation on Enter, inline bold/italic/code formatting, infobox
snippet
- WikiEditor: editor and live preview side by side, preview renders
template CSS/HTML via WikiPreparedBody
109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
/**
|
|
* Lightweight markdown tokenizer for the editor highlight mirror.
|
|
*
|
|
* Pure module — safe to import client-side. Splits the source into lines and
|
|
* returns, per line, segments tagged with a Tailwind class for the mirror
|
|
* layer behind the editor textarea. Not a full parser: fence state is tracked
|
|
* across lines, everything else is line-local.
|
|
*/
|
|
|
|
export interface HighlightSegment {
|
|
readonly text: string;
|
|
readonly className?: string;
|
|
}
|
|
|
|
const HL = {
|
|
heading: "font-semibold text-sky-200",
|
|
bold: "font-semibold text-foreground",
|
|
italic: "italic text-foreground",
|
|
strike: "line-through text-muted-foreground/70",
|
|
code: "rounded bg-muted px-0.5 text-emerald-300",
|
|
wikilink: "text-sky-300 underline underline-offset-2",
|
|
link: "text-sky-300 underline underline-offset-2",
|
|
quote: "italic text-muted-foreground/80",
|
|
directive: "text-violet-300",
|
|
fence: "text-muted-foreground/60",
|
|
codeBlock: "text-emerald-300/50",
|
|
listMarker: "text-amber-300",
|
|
} as const;
|
|
|
|
const INLINE_RE =
|
|
/(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*)|((?<![A-Za-z0-9])_[^_\n]+_(?![A-Za-z0-9]))|(\[\[[^\]\n]+\]\])|(\[[^\]\n]*\]\([^)\n]+\))/g;
|
|
|
|
const INLINE_CLASSES = [
|
|
HL.code,
|
|
HL.bold,
|
|
HL.bold,
|
|
HL.strike,
|
|
HL.italic,
|
|
HL.italic,
|
|
HL.wikilink,
|
|
HL.link,
|
|
] as const;
|
|
|
|
function tokenizeInline(text: string): HighlightSegment[] {
|
|
const segments: HighlightSegment[] = [];
|
|
let cursor = 0;
|
|
for (const match of text.matchAll(INLINE_RE)) {
|
|
const start = match.index ?? 0;
|
|
if (start > cursor) segments.push({ text: text.slice(cursor, start) });
|
|
const groupIndex =
|
|
match.findIndex((capture, index) => index > 0 && capture !== undefined) - 1;
|
|
segments.push({ text: match[0], className: INLINE_CLASSES[groupIndex] });
|
|
cursor = start + match[0].length;
|
|
}
|
|
if (cursor < text.length) {
|
|
segments.push({ text: text.slice(cursor) });
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
const FENCE_RE = /^\s{0,3}(```|~~~)/;
|
|
const HEADING_RE = /^\s{0,3}#{1,6}\s/;
|
|
const QUOTE_RE = /^\s{0,3}>/;
|
|
const DIRECTIVE_RE = /^\s{0,3}:::/;
|
|
const HRULE_RE = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
const LIST_MARKER_RE = /^(\s*)([-*+]|\d+[.)])(\s+)/;
|
|
|
|
export function highlightMarkdownLines(source: string): HighlightSegment[][] {
|
|
const lines: HighlightSegment[][] = [];
|
|
let inFence = false;
|
|
for (const line of source.split("\n")) {
|
|
if (FENCE_RE.test(line)) {
|
|
lines.push([{ text: line, className: HL.fence }]);
|
|
inFence = !inFence;
|
|
continue;
|
|
}
|
|
if (inFence) {
|
|
lines.push([{ text: line, className: HL.codeBlock }]);
|
|
continue;
|
|
}
|
|
if (HEADING_RE.test(line)) {
|
|
lines.push([{ text: line, className: HL.heading }]);
|
|
continue;
|
|
}
|
|
if (QUOTE_RE.test(line)) {
|
|
lines.push([{ text: line, className: HL.quote }]);
|
|
continue;
|
|
}
|
|
if (DIRECTIVE_RE.test(line)) {
|
|
lines.push([{ text: line, className: HL.directive }]);
|
|
continue;
|
|
}
|
|
if (HRULE_RE.test(line)) {
|
|
lines.push([{ text: line, className: HL.fence }]);
|
|
continue;
|
|
}
|
|
const listMatch = line.match(LIST_MARKER_RE);
|
|
if (listMatch) {
|
|
const markerEnd = listMatch[0].length;
|
|
lines.push([
|
|
{ text: listMatch[0], className: HL.listMarker },
|
|
...tokenizeInline(line.slice(markerEnd)),
|
|
]);
|
|
continue;
|
|
}
|
|
lines.push(tokenizeInline(line));
|
|
}
|
|
return lines;
|
|
}
|