"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; 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(null); const mirrorRef = useRef(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) { 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) { 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) { 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", '), }, ], [ { 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 (