- 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
91 lines
2.6 KiB
TypeScript
91 lines
2.6 KiB
TypeScript
"use client";
|
|
|
|
import { type RefObject, useCallback, useEffect, useRef } from "react";
|
|
|
|
export interface HistoryEntry {
|
|
readonly value: string;
|
|
readonly selectionStart: number;
|
|
readonly selectionEnd: number;
|
|
}
|
|
|
|
const COALESCE_WINDOW_MS = 400;
|
|
const COALESCE_MAX_DELTA = 8;
|
|
const MAX_HISTORY = 200;
|
|
|
|
function countLines(value: string): number {
|
|
let lines = 1;
|
|
for (let i = 0; i < value.length; i += 1) {
|
|
if (value.charCodeAt(i) === 10) lines += 1;
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
export function useEditorHistory(
|
|
body: string,
|
|
bodyRef: RefObject<HTMLTextAreaElement | null>,
|
|
) {
|
|
const stackRef = useRef<HistoryEntry[]>([]);
|
|
const indexRef = useRef(-1);
|
|
const lastRecordAtRef = useRef(0);
|
|
|
|
if (stackRef.current.length === 0) {
|
|
stackRef.current = [{ value: body, selectionStart: 0, selectionEnd: 0 }];
|
|
indexRef.current = 0;
|
|
}
|
|
|
|
useEffect(() => {
|
|
const top = stackRef.current[indexRef.current];
|
|
if (top && top.value === body) return;
|
|
stackRef.current = [
|
|
{
|
|
value: body,
|
|
selectionStart: bodyRef.current?.selectionStart ?? 0,
|
|
selectionEnd: bodyRef.current?.selectionEnd ?? 0,
|
|
},
|
|
];
|
|
indexRef.current = 0;
|
|
lastRecordAtRef.current = 0;
|
|
}, [body, bodyRef]);
|
|
|
|
const record = useCallback(
|
|
(value: string, selectionStart: number, selectionEnd: number, coalesce: boolean) => {
|
|
const stack = stackRef.current;
|
|
const top = stack[indexRef.current];
|
|
const now = Date.now();
|
|
const canCoalesce =
|
|
coalesce &&
|
|
top !== undefined &&
|
|
now - lastRecordAtRef.current <= COALESCE_WINDOW_MS &&
|
|
Math.abs(value.length - top.value.length) <= COALESCE_MAX_DELTA &&
|
|
countLines(value) === countLines(top.value);
|
|
|
|
if (canCoalesce && top) {
|
|
stack[indexRef.current] = { value, selectionStart, selectionEnd };
|
|
} else {
|
|
stack.length = indexRef.current + 1;
|
|
stack.push({ value, selectionStart, selectionEnd });
|
|
if (stack.length > MAX_HISTORY) stack.shift();
|
|
indexRef.current = stack.length - 1;
|
|
}
|
|
lastRecordAtRef.current = now;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const undo = useCallback((): HistoryEntry | null => {
|
|
if (indexRef.current <= 0) return null;
|
|
indexRef.current -= 1;
|
|
lastRecordAtRef.current = 0;
|
|
return stackRef.current[indexRef.current];
|
|
}, []);
|
|
|
|
const redo = useCallback((): HistoryEntry | null => {
|
|
const stack = stackRef.current;
|
|
if (indexRef.current >= stack.length - 1) return null;
|
|
indexRef.current += 1;
|
|
lastRecordAtRef.current = 0;
|
|
return stack[indexRef.current];
|
|
}, []);
|
|
|
|
return { record, undo, redo };
|
|
}
|