- 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
450 lines
14 KiB
TypeScript
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: ``, 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>
|
|
);
|
|
}
|