1
0
Fork 0
polaris-task-force/src/components/frontend/wiki/WikiEditorBody.tsx
Z8MB1E 55a03e8094 feat(wiki): add editor undo/redo, template dialog, highlighting, and formatting
- 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
2026-09-06 05:32:51 -04:00

450 lines
14 KiB
TypeScript

"use client";
import { type ChangeEvent, type KeyboardEvent, type RefObject, useRef, useState } from "react";
import {
Bold,
Code,
Columns2,
Columns3,
FileCode2,
Heading2,
Image,
ImageUp,
Italic,
LayoutTemplate,
Link2,
List,
ListOrdered,
NotebookPen,
PanelRight,
Quote,
Sparkles,
Strikethrough,
Table,
} from "lucide-react";
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { WikiLinkDialog } from "@/components/frontend/wiki/WikiLinkDialog";
import { WikiEditorHighlightLayer } from "@/components/frontend/wiki/WikiEditorHighlightLayer";
import { WikiTemplateDialog } from "@/components/frontend/wiki/WikiTemplateDialog";
import { useEditorHistory } from "@/components/frontend/wiki/useEditorHistory";
import { uploadWikiImage } from "@/app/(frontend)/wiki/actions";
import type { TemplateMap } from "@/lib/wiki/templates";
import {
IMAGE_SNIPPET,
INFOBOX_SNIPPET,
TABLE_SNIPPET,
THREE_COLUMN_SNIPPET,
TWO_COLUMN_SNIPPET,
createLinePrefixUpdate,
createListContinueUpdate,
createFormatUpdate,
createIndentUpdate,
createReferenceUpdate,
createSnippetUpdate,
createWrappedUpdate,
readBodySelection,
type BodyUpdate,
type SnippetInsertion,
type WrappedFormat,
} from "@/lib/wiki/editorFormatting";
import {
WikiEditorToolbar,
type WikiEditorToolbarAction,
} from "@/components/frontend/wiki/WikiEditorToolbar";
interface WikiEditorBodyProps {
readonly body: string;
readonly bodyRef: RefObject<HTMLTextAreaElement | null>;
readonly existingPages: readonly { title: string }[];
readonly linkDialogOpen: boolean;
readonly onBodyChange: (value: string) => void;
readonly onLinkDialogChange: (open: boolean) => void;
readonly pending: boolean;
readonly templates: TemplateMap;
}
export function WikiEditorBody({
body,
bodyRef,
existingPages,
linkDialogOpen,
onBodyChange,
onLinkDialogChange,
pending,
templates,
}: WikiEditorBodyProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
const { record, undo, redo } = useEditorHistory(body, bodyRef);
const [uploading, setUploading] = useState(false);
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
function syncMirrorScroll(textarea: HTMLTextAreaElement) {
if (mirrorRef.current) mirrorRef.current.scrollTop = textarea.scrollTop;
}
function scrollCaretIntoView(caret: number) {
const textarea = bodyRef.current;
const mirror = mirrorRef.current;
if (!textarea || !mirror || caret < 0) return;
const walker = document.createTreeWalker(mirror, NodeFilter.SHOW_TEXT);
let remaining = caret;
let node = walker.nextNode() as Text | null;
let lastNode = node;
while (node && remaining > (node.textContent?.length ?? 0)) {
remaining -= node.textContent?.length ?? 0;
lastNode = node;
node = walker.nextNode() as Text | null;
}
const target = node ?? lastNode;
if (!target) return;
const offset = Math.min(remaining, target.textContent?.length ?? 0);
const range = document.createRange();
try {
range.setStart(target, offset);
} catch {
return;
}
range.collapse(true);
const mirrorRect = mirror.getBoundingClientRect();
const caretTop =
range.getBoundingClientRect().top - mirrorRect.top + mirror.scrollTop;
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 20;
const viewTop = textarea.scrollTop;
const viewBottom = viewTop + textarea.clientHeight - lineHeight;
if (caretTop < viewTop || caretTop > viewBottom) {
textarea.scrollTop = Math.max(0, caretTop - textarea.clientHeight / 2);
syncMirrorScroll(textarea);
}
}
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const result = await uploadWikiImage(formData);
if (!result.success || !result.data) {
toast.error(result.error ?? "Upload failed.");
return;
}
const { url, alt } = result.data;
insertSnippet({ insertion: `![${alt}](${url})`, placeholder: alt });
toast.success("Image uploaded.");
} catch {
toast.error("Upload failed.");
} finally {
setUploading(false);
}
}
function applyBodyUpdate(update: BodyUpdate, recordHistory = true) {
if (recordHistory) {
record(update.value, update.selectionStart, update.selectionEnd, false);
}
onBodyChange(update.value);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const textarea = bodyRef.current;
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(update.selectionStart, update.selectionEnd);
scrollCaretIntoView(update.selectionStart);
syncMirrorScroll(textarea);
});
});
}
function handleBodyChange(event: ChangeEvent<HTMLTextAreaElement>) {
const { value, selectionStart, selectionEnd } = event.target;
record(value, selectionStart, selectionEnd, true);
onBodyChange(value);
}
function wrapSelection(format: WrappedFormat) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createWrappedUpdate(body, selection, format));
}
function prefixLines(prefix: string) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createLinePrefixUpdate(body, selection, prefix));
}
function insertSnippet(snippet: SnippetInsertion) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createSnippetUpdate(body, selection, snippet));
}
function insertReference(definitionSuffix: string) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createReferenceUpdate(body, selection, { definitionSuffix }));
}
function insertWikilink(pageTitle: string) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(
createSnippetUpdate(body, selection, {
insertion: `[[${pageTitle}]]`,
}),
);
onLinkDialogChange(false);
}
function insertTemplateBody(value: string) {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(
createSnippetUpdate(body, selection, { insertion: value }),
);
setTemplateDialogOpen(false);
}
function formatBody() {
const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createFormatUpdate(body, selection));
}
function handleBodyKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.nativeEvent.isComposing) return;
if ((event.metaKey || event.ctrlKey) && !event.altKey) {
const undoKey = event.key.toLowerCase();
if (undoKey === "z") {
event.preventDefault();
const entry = event.shiftKey ? redo() : undo();
if (entry) applyBodyUpdate(entry, false);
return;
}
if (undoKey === "y" && !event.shiftKey) {
event.preventDefault();
const entry = redo();
if (entry) applyBodyUpdate(entry, false);
return;
}
}
if (event.key === "Tab") {
event.preventDefault();
const selection = readBodySelection(body, bodyRef);
const update = createIndentUpdate(body, selection, event.shiftKey);
if (update) applyBodyUpdate(update);
return;
}
if (event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
const selection = readBodySelection(body, bodyRef);
const update = createListContinueUpdate(body, selection);
if (update) {
event.preventDefault();
applyBodyUpdate(update);
}
return;
}
const modifierPressed = event.metaKey || event.ctrlKey;
if (!modifierPressed || event.altKey) return;
const key = event.key.toLowerCase();
if (key === "b" && !event.shiftKey) {
event.preventDefault();
wrapSelection({ prefix: "**", suffix: "**", placeholder: "bold text" });
return;
}
if (key === "i" && !event.shiftKey) {
event.preventDefault();
wrapSelection({ prefix: "*", suffix: "*", placeholder: "italic text" });
return;
}
if (key === "x" && event.shiftKey) {
event.preventDefault();
wrapSelection({ prefix: "~~", suffix: "~~", placeholder: "strikethrough text" });
return;
}
if (key === "h" && event.shiftKey) {
event.preventDefault();
prefixLines("## ");
return;
}
if (key === "f" && event.shiftKey) {
event.preventDefault();
formatBody();
return;
}
if (key === "k" && !event.shiftKey) {
event.preventDefault();
onLinkDialogChange(true);
return;
}
if (key === "e" && !event.shiftKey) {
event.preventDefault();
wrapSelection({ prefix: "`", suffix: "`", placeholder: "code" });
return;
}
const browserComboKeys = new Set(["b", "i", "e", "k", "h", "u"]);
if (browserComboKeys.has(key)) {
event.preventDefault();
}
}
const groups: readonly (readonly WikiEditorToolbarAction[])[] = [
[
{
label: "Bold",
shortcut: "Ctrl/Cmd+B",
icon: Bold,
onClick: () => wrapSelection({ prefix: "**", suffix: "**", placeholder: "bold text" }),
},
{
label: "Italic",
shortcut: "Ctrl/Cmd+I",
icon: Italic,
onClick: () => wrapSelection({ prefix: "*", suffix: "*", placeholder: "italic text" }),
},
{
label: "Strikethrough",
shortcut: "Ctrl/Cmd+Shift+X",
icon: Strikethrough,
onClick: () =>
wrapSelection({ prefix: "~~", suffix: "~~", placeholder: "strikethrough text" }),
},
{
label: "Heading",
shortcut: "Ctrl/Cmd+Shift+H",
icon: Heading2,
onClick: () => prefixLines("## "),
},
{
label: "Format",
shortcut: "Ctrl/Cmd+Shift+F",
icon: Sparkles,
onClick: formatBody,
},
],
[
{
label: "Inline code",
shortcut: "Ctrl/Cmd+E",
icon: Code,
onClick: () => wrapSelection({ prefix: "`", suffix: "`", placeholder: "code" }),
},
{
label: "Code block",
icon: FileCode2,
onClick: () =>
wrapSelection({ prefix: "```\n", suffix: "\n```", placeholder: "code block" }),
},
{ label: "Quote", icon: Quote, onClick: () => prefixLines("> ") },
],
[
{ label: "Bulleted list", icon: List, onClick: () => prefixLines("- ") },
{ label: "Numbered list", icon: ListOrdered, onClick: () => prefixLines("1. ") },
{
label: "Table",
icon: Table,
onClick: () => insertSnippet({ insertion: TABLE_SNIPPET, placeholder: "Header" }),
},
],
[
{
label: "Wikilink",
shortcut: "Ctrl/Cmd+K",
icon: Link2,
onClick: () => onLinkDialogChange(true),
},
{
label: "Captioned image",
icon: Image,
onClick: () => insertSnippet({ insertion: IMAGE_SNIPPET, placeholder: "Alt text" }),
},
{
label: "Upload image",
icon: ImageUp,
onClick: () => fileInputRef.current?.click(),
},
{ label: "Footnote", icon: NotebookPen, onClick: () => insertReference("") },
{
label: "Citation",
icon: NotebookPen,
onClick: () => insertReference('Author, "Title", <URL>'),
},
],
[
{
label: "Infobox",
icon: PanelRight,
onClick: () => insertSnippet({ insertion: INFOBOX_SNIPPET, placeholder: "Infobox title" }),
},
{
label: "2 columns",
icon: Columns2,
onClick: () =>
insertSnippet({ insertion: TWO_COLUMN_SNIPPET, placeholder: "Left column content" }),
},
{
label: "3 columns",
icon: Columns3,
onClick: () =>
insertSnippet({ insertion: THREE_COLUMN_SNIPPET, placeholder: "Left column content" }),
},
{
label: "Template",
icon: LayoutTemplate,
onClick: () => setTemplateDialogOpen(true),
},
],
];
return (
<div className="flex flex-col gap-1.5">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor="wiki-body">Body</Label>
<WikiLinkDialog
open={linkDialogOpen}
onOpenChange={onLinkDialogChange}
existingPages={existingPages}
onSelect={insertWikilink}
disabled={pending}
/>
</div>
<WikiTemplateDialog
templates={templates}
open={templateDialogOpen}
onOpenChange={setTemplateDialogOpen}
disabled={pending}
onInsert={insertTemplateBody}
/>
<WikiEditorToolbar groups={groups} disabled={pending || uploading} />
<div className="relative min-h-[23.5rem] w-full rounded-md border border-input bg-transparent shadow-sm transition-[box-shadow] focus-within:border-ring focus-within:ring-1 focus-within:ring-ring lg:h-[calc(100vh-10rem)]">
<WikiEditorHighlightLayer body={body} ref={mirrorRef} />
<Textarea
ref={bodyRef}
id="wiki-body"
value={body}
onChange={handleBodyChange}
onScroll={(event) => syncMirrorScroll(event.currentTarget)}
onKeyDown={handleBodyKeyDown}
placeholder="Write the field guide entry in Markdown."
disabled={pending}
className="absolute inset-0 h-full w-full resize-none border-0 bg-transparent font-mono text-sm text-transparent caret-foreground shadow-none selection:bg-primary/30 focus-visible:outline-none focus-visible:ring-0"
/>
</div>
</div>
);
}