"use client"; import { type ChangeEvent, type KeyboardEvent, type RefObject, useRef, useState } from "react"; import { Bold, Code, Columns2, Columns3, FileCode2, Heading2, Image, ImageUp, Italic, Link2, List, ListOrdered, NotebookPen, Quote, 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 { uploadWikiImage } from "@/app/(frontend)/wiki/actions"; import { IMAGE_SNIPPET, TABLE_SNIPPET, THREE_COLUMN_SNIPPET, TWO_COLUMN_SNIPPET, createLinePrefixUpdate, 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; } export function WikiEditorBody({ body, bodyRef, existingPages, linkDialogOpen, onBodyChange, onLinkDialogChange, pending, }: WikiEditorBodyProps) { const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); 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) { onBodyChange(update.value); requestAnimationFrame(() => { const textarea = bodyRef.current; textarea?.focus(); textarea?.setSelectionRange(update.selectionStart, update.selectionEnd); }); } 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 handleBodyKeyDown(event: KeyboardEvent) { 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 === "k" && !event.shiftKey) { event.preventDefault(); onLinkDialogChange(true); return; } if (key === "e" && !event.shiftKey) { event.preventDefault(); wrapSelection({ prefix: "`", suffix: "`", placeholder: "code" }); } } 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: "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: "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" }), }, ], ]; return (