1
0
Fork 0
polaris-task-force/src/components/frontend/wiki/WikiEditorBody.tsx

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: `![${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<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>
);
}