292 lines
8.3 KiB
TypeScript
292 lines
8.3 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,
|
|
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<HTMLTextAreaElement | null>;
|
|
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<HTMLInputElement>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
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) {
|
|
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<HTMLTextAreaElement>) {
|
|
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", <URL>'),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
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 (
|
|
<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>
|
|
<WikiEditorToolbar groups={groups} disabled={pending || uploading} />
|
|
<Textarea
|
|
ref={bodyRef}
|
|
id="wiki-body"
|
|
rows={18}
|
|
value={body}
|
|
onChange={(event) => onBodyChange(event.target.value)}
|
|
onKeyDown={handleBodyKeyDown}
|
|
placeholder="Write the field guide entry in Markdown."
|
|
disabled={pending}
|
|
className="font-mono text-sm"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|