feat(wiki): add editor undo/redo, template dialog, highlighting, and formatting
- 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
This commit is contained in:
parent
51f7c803e0
commit
55a03e8094
8 changed files with 802 additions and 47 deletions
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Markdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -19,14 +19,11 @@ import {
|
|||
import { createWikiPage, updateWikiPage } from "@/app/(frontend)/wiki/actions";
|
||||
import { WIKI_CATEGORIES, type WikiCategory } from "@/lib/wiki/categories";
|
||||
import { prepareMarkdown } from "@/lib/wiki/prepare";
|
||||
import {
|
||||
wikiMarkdownComponents,
|
||||
wikiRemarkPlugins,
|
||||
wikiRemarkRehypeOptions,
|
||||
} from "@/lib/wiki/markdown";
|
||||
import { buildTemplateStyles } from "@/lib/wiki/templateCss";
|
||||
import { MarkdownGuide } from "@/components/frontend/wiki/MarkdownGuide";
|
||||
import { WikiEditorBody } from "@/components/frontend/wiki/WikiEditorBody";
|
||||
import { WikiStandardsDialog } from "@/components/frontend/wiki/WikiStandardsDialog";
|
||||
import { WikiPreparedBody } from "@/components/frontend/wiki/WikiContent";
|
||||
|
||||
const STANDARDS_ACCEPTED_KEY = "ptf:wiki-standards-accepted";
|
||||
|
||||
|
|
@ -43,6 +40,8 @@ interface WikiEditorProps {
|
|||
readonly initial?: WikiEditorInitial;
|
||||
readonly initialTitle?: string;
|
||||
readonly templates: Record<string, string>;
|
||||
readonly templateCssMap?: Record<string, string>;
|
||||
readonly templateHtmlMap?: Record<string, string>;
|
||||
readonly existingPages: readonly { title: string }[];
|
||||
readonly isFirstEdit?: boolean;
|
||||
}
|
||||
|
|
@ -67,6 +66,8 @@ export function WikiEditor({
|
|||
initial,
|
||||
initialTitle,
|
||||
templates,
|
||||
templateCssMap = {},
|
||||
templateHtmlMap = {},
|
||||
existingPages,
|
||||
isFirstEdit = false,
|
||||
}: WikiEditorProps) {
|
||||
|
|
@ -83,7 +84,19 @@ export function WikiEditor({
|
|||
if (!isFirstEdit || typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem(STANDARDS_ACCEPTED_KEY) !== "1";
|
||||
});
|
||||
const preparedPreview = prepareMarkdown(body, templates);
|
||||
const preparedPreview = prepareMarkdown(body, templates, templateHtmlMap);
|
||||
const { css: previewCss, scopeClasses: previewScopeClasses } = useMemo(
|
||||
() =>
|
||||
buildTemplateStyles(
|
||||
body,
|
||||
Object.entries(templates).map(([name, templateBody]) => ({
|
||||
name,
|
||||
body: templateBody,
|
||||
css: templateCssMap[name] ?? null,
|
||||
})),
|
||||
),
|
||||
[body, templates, templateCssMap],
|
||||
);
|
||||
|
||||
function handleStandardsAccept() {
|
||||
window.localStorage.setItem(STANDARDS_ACCEPTED_KEY, "1");
|
||||
|
|
@ -133,6 +146,7 @@ export function WikiEditor({
|
|||
|
||||
return (
|
||||
<form className="flex flex-col gap-6" onSubmit={(event) => void handleSave(event)}>
|
||||
{previewCss && <style dangerouslySetInnerHTML={{ __html: previewCss }} />}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<Label htmlFor="wiki-title">Title</Label>
|
||||
|
|
@ -190,43 +204,41 @@ export function WikiEditor({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<WikiEditorBody
|
||||
body={body}
|
||||
bodyRef={bodyRef}
|
||||
existingPages={existingPages}
|
||||
linkDialogOpen={linkDialogOpen}
|
||||
onBodyChange={setBody}
|
||||
onLinkDialogChange={setLinkDialogOpen}
|
||||
pending={pending}
|
||||
/>
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||
<div className="lg:sticky lg:top-5">
|
||||
<WikiEditorBody
|
||||
body={body}
|
||||
bodyRef={bodyRef}
|
||||
existingPages={existingPages}
|
||||
linkDialogOpen={linkDialogOpen}
|
||||
onBodyChange={setBody}
|
||||
onLinkDialogChange={setLinkDialogOpen}
|
||||
pending={pending}
|
||||
templates={templates}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 lg:sticky lg:top-5">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Live preview
|
||||
</h2>
|
||||
<div className="min-h-60 rounded-md border bg-muted/20 p-4">
|
||||
{preparedPreview ? (
|
||||
<div className="wiki-markdown flex flex-col gap-4 text-sm">
|
||||
<Markdown
|
||||
remarkPlugins={wikiRemarkPlugins}
|
||||
remarkRehypeOptions={wikiRemarkRehypeOptions}
|
||||
components={wikiMarkdownComponents}
|
||||
>
|
||||
{preparedPreview}
|
||||
</Markdown>
|
||||
<div className={cn("wiki-markdown", previewScopeClasses)}>
|
||||
<WikiPreparedBody prepared={preparedPreview} htmlTemplates={templateHtmlMap} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">Nothing to preview yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Markdown guide
|
||||
</h2>
|
||||
<MarkdownGuide />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Markdown guide
|
||||
</h2>
|
||||
<MarkdownGuide />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -11,11 +11,14 @@ import {
|
|||
Image,
|
||||
ImageUp,
|
||||
Italic,
|
||||
LayoutTemplate,
|
||||
Link2,
|
||||
List,
|
||||
ListOrdered,
|
||||
NotebookPen,
|
||||
PanelRight,
|
||||
Quote,
|
||||
Sparkles,
|
||||
Strikethrough,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
|
|
@ -23,13 +26,21 @@ 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,
|
||||
|
|
@ -51,6 +62,7 @@ interface WikiEditorBodyProps {
|
|||
readonly onBodyChange: (value: string) => void;
|
||||
readonly onLinkDialogChange: (open: boolean) => void;
|
||||
readonly pending: boolean;
|
||||
readonly templates: TemplateMap;
|
||||
}
|
||||
|
||||
export function WikiEditorBody({
|
||||
|
|
@ -61,9 +73,55 @@ export function WikiEditorBody({
|
|||
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];
|
||||
|
|
@ -89,15 +147,29 @@ export function WikiEditorBody({
|
|||
}
|
||||
}
|
||||
|
||||
function applyBodyUpdate(update: BodyUpdate) {
|
||||
function applyBodyUpdate(update: BodyUpdate, recordHistory = true) {
|
||||
if (recordHistory) {
|
||||
record(update.value, update.selectionStart, update.selectionEnd, false);
|
||||
}
|
||||
onBodyChange(update.value);
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = bodyRef.current;
|
||||
textarea?.focus();
|
||||
textarea?.setSelectionRange(update.selectionStart, update.selectionEnd);
|
||||
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));
|
||||
|
|
@ -128,7 +200,56 @@ export function WikiEditorBody({
|
|||
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;
|
||||
|
||||
|
|
@ -153,6 +274,11 @@ export function WikiEditorBody({
|
|||
prefixLines("## ");
|
||||
return;
|
||||
}
|
||||
if (key === "f" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
formatBody();
|
||||
return;
|
||||
}
|
||||
if (key === "k" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onLinkDialogChange(true);
|
||||
|
|
@ -161,6 +287,12 @@ export function WikiEditorBody({
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +323,12 @@ export function WikiEditorBody({
|
|||
icon: Heading2,
|
||||
onClick: () => prefixLines("## "),
|
||||
},
|
||||
{
|
||||
label: "Format",
|
||||
shortcut: "Ctrl/Cmd+Shift+F",
|
||||
icon: Sparkles,
|
||||
onClick: formatBody,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
|
|
@ -241,6 +379,11 @@ export function WikiEditorBody({
|
|||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: "Infobox",
|
||||
icon: PanelRight,
|
||||
onClick: () => insertSnippet({ insertion: INFOBOX_SNIPPET, placeholder: "Infobox title" }),
|
||||
},
|
||||
{
|
||||
label: "2 columns",
|
||||
icon: Columns2,
|
||||
|
|
@ -253,6 +396,11 @@ export function WikiEditorBody({
|
|||
onClick: () =>
|
||||
insertSnippet({ insertion: THREE_COLUMN_SNIPPET, placeholder: "Left column content" }),
|
||||
},
|
||||
{
|
||||
label: "Template",
|
||||
icon: LayoutTemplate,
|
||||
onClick: () => setTemplateDialogOpen(true),
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
|
|
@ -275,18 +423,28 @@ export function WikiEditorBody({
|
|||
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."
|
||||
<WikiTemplateDialog
|
||||
templates={templates}
|
||||
open={templateDialogOpen}
|
||||
onOpenChange={setTemplateDialogOpen}
|
||||
disabled={pending}
|
||||
className="font-mono text-sm"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
49
src/components/frontend/wiki/WikiEditorHighlightLayer.tsx
Normal file
49
src/components/frontend/wiki/WikiEditorHighlightLayer.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef, useMemo, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { highlightMarkdownLines } from "@/lib/wiki/editorHighlight";
|
||||
|
||||
interface WikiEditorHighlightLayerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colored text layer rendered underneath the editor textarea (which uses
|
||||
* transparent text). Sizing must mirror the textarea exactly: same padding,
|
||||
* font, and a transparent border so text starts at the same 1px + padding
|
||||
* offset inside the shared wrapper's border.
|
||||
*/
|
||||
export const WikiEditorHighlightLayer = forwardRef<
|
||||
HTMLDivElement,
|
||||
WikiEditorHighlightLayerProps
|
||||
>(function WikiEditorHighlightLayer({ body, className, ...rest }, ref) {
|
||||
const lines = useMemo(() => highlightMarkdownLines(body), [body]);
|
||||
const nodes: ReactNode[] = [];
|
||||
lines.forEach((segments, lineIndex) => {
|
||||
if (lineIndex > 0) nodes.push("\n");
|
||||
segments.forEach((segment, segmentIndex) => {
|
||||
if (segment.className) {
|
||||
nodes.push(
|
||||
<span key={`${lineIndex}-${segmentIndex}`} className={segment.className}>
|
||||
{segment.text}
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
nodes.push(segment.text);
|
||||
}
|
||||
});
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
aria-hidden="true"
|
||||
className={cn("pointer-events-none absolute inset-0 overflow-hidden", className)}
|
||||
{...rest}
|
||||
>
|
||||
<div className="whitespace-pre-wrap break-words border border-transparent px-3 py-2 font-mono text-sm text-foreground/90">
|
||||
{nodes}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
164
src/components/frontend/wiki/WikiTemplateDialog.tsx
Normal file
164
src/components/frontend/wiki/WikiTemplateDialog.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { LayoutTemplate } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { extractPlaceholders, type TemplateMap } from "@/lib/wiki/templates";
|
||||
|
||||
interface WikiTemplateDialogProps {
|
||||
readonly templates: TemplateMap;
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
readonly disabled?: boolean;
|
||||
readonly onInsert: (body: string) => void;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function fillTemplate(
|
||||
body: string,
|
||||
values: Readonly<Record<string, string>>,
|
||||
): string {
|
||||
let filled = body;
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
if (value.length === 0) continue;
|
||||
filled = filled.replace(
|
||||
new RegExp(`\\{\\{\\s*${escapeRegExp(name)}\\s*\\}\\}`, "g"),
|
||||
value,
|
||||
);
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
export function WikiTemplateDialog({
|
||||
templates,
|
||||
open,
|
||||
onOpenChange,
|
||||
disabled,
|
||||
onInsert,
|
||||
}: WikiTemplateDialogProps) {
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
|
||||
const names = useMemo(() => Object.keys(templates).sort(), [templates]);
|
||||
const selectedBody = selectedName !== null ? (templates[selectedName] ?? "") : "";
|
||||
const placeholders = useMemo(
|
||||
() => extractPlaceholders(selectedBody),
|
||||
[selectedBody],
|
||||
);
|
||||
const filled = useMemo(
|
||||
() => fillTemplate(selectedBody, values),
|
||||
[selectedBody, values],
|
||||
);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
setSelectedName(null);
|
||||
setValues({});
|
||||
}
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
function handleInsert() {
|
||||
onInsert(filled);
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick a wiki template, fill in its fields, and insert it at the
|
||||
cursor. Leave a field blank to keep its {"{{marker}}"} in the article.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{names.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No templates are available yet. Templates can be created in the
|
||||
admin panel.
|
||||
</p>
|
||||
) : selectedName === null ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{names.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setSelectedName(name)}
|
||||
className="flex items-start gap-2.5 rounded-md border p-2.5 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<LayoutTemplate className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{name}</span>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{templates[name]}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedName(null)}
|
||||
className="self-start text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
← Back to all templates
|
||||
</button>
|
||||
{placeholders.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{placeholders.map((name) => (
|
||||
<div key={name} className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`template-field-${name}`}>{name}</Label>
|
||||
<Input
|
||||
id={`template-field-${name}`}
|
||||
value={values[name] ?? ""}
|
||||
onChange={(event) =>
|
||||
setValues((previous) => ({
|
||||
...previous,
|
||||
[name]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This template has no fields to fill in.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Preview
|
||||
</span>
|
||||
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{filled}
|
||||
</pre>
|
||||
</div>
|
||||
<div className={cn("flex justify-end gap-2")}>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleInsert}>Insert</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
91
src/components/frontend/wiki/useEditorHistory.ts
Normal file
91
src/components/frontend/wiki/useEditorHistory.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import { type RefObject, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export interface HistoryEntry {
|
||||
readonly value: string;
|
||||
readonly selectionStart: number;
|
||||
readonly selectionEnd: number;
|
||||
}
|
||||
|
||||
const COALESCE_WINDOW_MS = 400;
|
||||
const COALESCE_MAX_DELTA = 8;
|
||||
const MAX_HISTORY = 200;
|
||||
|
||||
function countLines(value: string): number {
|
||||
let lines = 1;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
if (value.charCodeAt(i) === 10) lines += 1;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function useEditorHistory(
|
||||
body: string,
|
||||
bodyRef: RefObject<HTMLTextAreaElement | null>,
|
||||
) {
|
||||
const stackRef = useRef<HistoryEntry[]>([]);
|
||||
const indexRef = useRef(-1);
|
||||
const lastRecordAtRef = useRef(0);
|
||||
|
||||
if (stackRef.current.length === 0) {
|
||||
stackRef.current = [{ value: body, selectionStart: 0, selectionEnd: 0 }];
|
||||
indexRef.current = 0;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const top = stackRef.current[indexRef.current];
|
||||
if (top && top.value === body) return;
|
||||
stackRef.current = [
|
||||
{
|
||||
value: body,
|
||||
selectionStart: bodyRef.current?.selectionStart ?? 0,
|
||||
selectionEnd: bodyRef.current?.selectionEnd ?? 0,
|
||||
},
|
||||
];
|
||||
indexRef.current = 0;
|
||||
lastRecordAtRef.current = 0;
|
||||
}, [body, bodyRef]);
|
||||
|
||||
const record = useCallback(
|
||||
(value: string, selectionStart: number, selectionEnd: number, coalesce: boolean) => {
|
||||
const stack = stackRef.current;
|
||||
const top = stack[indexRef.current];
|
||||
const now = Date.now();
|
||||
const canCoalesce =
|
||||
coalesce &&
|
||||
top !== undefined &&
|
||||
now - lastRecordAtRef.current <= COALESCE_WINDOW_MS &&
|
||||
Math.abs(value.length - top.value.length) <= COALESCE_MAX_DELTA &&
|
||||
countLines(value) === countLines(top.value);
|
||||
|
||||
if (canCoalesce && top) {
|
||||
stack[indexRef.current] = { value, selectionStart, selectionEnd };
|
||||
} else {
|
||||
stack.length = indexRef.current + 1;
|
||||
stack.push({ value, selectionStart, selectionEnd });
|
||||
if (stack.length > MAX_HISTORY) stack.shift();
|
||||
indexRef.current = stack.length - 1;
|
||||
}
|
||||
lastRecordAtRef.current = now;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const undo = useCallback((): HistoryEntry | null => {
|
||||
if (indexRef.current <= 0) return null;
|
||||
indexRef.current -= 1;
|
||||
lastRecordAtRef.current = 0;
|
||||
return stackRef.current[indexRef.current];
|
||||
}, []);
|
||||
|
||||
const redo = useCallback((): HistoryEntry | null => {
|
||||
const stack = stackRef.current;
|
||||
if (indexRef.current >= stack.length - 1) return null;
|
||||
indexRef.current += 1;
|
||||
lastRecordAtRef.current = 0;
|
||||
return stack[indexRef.current];
|
||||
}, []);
|
||||
|
||||
return { record, undo, redo };
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { RefObject } from "react";
|
||||
import { formatMarkdown } from "@/lib/wiki/formatMarkdown";
|
||||
|
||||
export interface BodySelection {
|
||||
readonly start: number;
|
||||
|
|
@ -57,6 +58,16 @@ export const TABLE_SNIPPET = `| Header | Header |
|
|||
|
||||
export const IMAGE_SNIPPET = ``;
|
||||
|
||||
export const INFOBOX_SNIPPET = `::::infobox
|
||||
**Infobox title**
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Field 1 | Value 1 |
|
||||
| Field 2 | Value 2 |
|
||||
| Field 3 | Value 3 |
|
||||
::::`;
|
||||
|
||||
export function readBodySelection(
|
||||
body: string,
|
||||
bodyRef: RefObject<HTMLTextAreaElement | null>,
|
||||
|
|
@ -138,6 +149,111 @@ export function createSnippetUpdate(
|
|||
};
|
||||
}
|
||||
|
||||
const INDENT_UNIT = " ";
|
||||
|
||||
export function createIndentUpdate(
|
||||
body: string,
|
||||
selection: BodySelection,
|
||||
outdent: boolean,
|
||||
): BodyUpdate | null {
|
||||
const collapsed = selection.start === selection.end;
|
||||
|
||||
if (collapsed || !body.slice(selection.start, selection.end).includes("\n")) {
|
||||
const lineStart = body.lastIndexOf("\n", selection.start - 1) + 1;
|
||||
|
||||
if (outdent) {
|
||||
const leading = /^[ \t]*/.exec(body.slice(lineStart))?.[0] ?? "";
|
||||
const remove = leading.startsWith("\t") ? 1 : Math.min(INDENT_UNIT.length, leading.length);
|
||||
if (remove === 0) return null;
|
||||
const cursor = Math.max(lineStart, selection.start - remove);
|
||||
return {
|
||||
value: `${body.slice(0, lineStart)}${body.slice(lineStart + remove)}`,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor,
|
||||
};
|
||||
}
|
||||
|
||||
const content = body.slice(selection.start, selection.end);
|
||||
const value = `${body.slice(0, selection.start)}${INDENT_UNIT}${content}${body.slice(selection.end)}`;
|
||||
const cursor = selection.start + INDENT_UNIT.length;
|
||||
return { value, selectionStart: cursor, selectionEnd: cursor + content.length };
|
||||
}
|
||||
|
||||
const selectedEnd =
|
||||
selection.end > selection.start && body[selection.end - 1] === "\n"
|
||||
? selection.end - 1
|
||||
: selection.end;
|
||||
const lineStart = body.lastIndexOf("\n", selection.start - 1) + 1;
|
||||
const nextLine = body.indexOf("\n", selectedEnd);
|
||||
const lineEnd = nextLine === -1 ? body.length : nextLine;
|
||||
const formatted = body
|
||||
.slice(lineStart, lineEnd)
|
||||
.split("\n")
|
||||
.map((line) =>
|
||||
outdent
|
||||
? line.startsWith("\t")
|
||||
? line.slice(1)
|
||||
: line.replace(/^ {1,2}/, "")
|
||||
: `${INDENT_UNIT}${line}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return {
|
||||
value: `${body.slice(0, lineStart)}${formatted}${body.slice(lineEnd)}`,
|
||||
selectionStart: lineStart,
|
||||
selectionEnd: lineStart + formatted.length,
|
||||
};
|
||||
}
|
||||
|
||||
const LIST_ITEM_RE =
|
||||
/^(\s*)(?:([-*+])|(\d+)([.)]))[ \t]+(\[[ xX]\][ \t]+)?(.*)$/;
|
||||
const FENCE_LINE_RE = /^\s{0,3}(?:```|~~~)/;
|
||||
|
||||
export function createListContinueUpdate(
|
||||
body: string,
|
||||
selection: BodySelection,
|
||||
): BodyUpdate | null {
|
||||
if (selection.start !== selection.end) return null;
|
||||
const cursor = selection.start;
|
||||
const lineStart = body.lastIndexOf("\n", cursor - 1) + 1;
|
||||
|
||||
let fences = 0;
|
||||
for (const line of body.slice(0, lineStart).split("\n")) {
|
||||
if (FENCE_LINE_RE.test(line)) fences += 1;
|
||||
}
|
||||
if (fences % 2 === 1) return null;
|
||||
|
||||
const beforeCursor = body.slice(lineStart, cursor);
|
||||
const match = LIST_ITEM_RE.exec(beforeCursor);
|
||||
if (!match) return null;
|
||||
const [, indent, bullet, num, delim, task, content] = match;
|
||||
|
||||
const lineEndIndex = body.indexOf("\n", cursor);
|
||||
const hasTextAfterCursor = lineEndIndex !== cursor;
|
||||
if (!hasTextAfterCursor && content.trim() === "") {
|
||||
const end = lineEndIndex === -1 ? body.length : lineEndIndex + 1;
|
||||
return {
|
||||
value: `${body.slice(0, lineStart)}${body.slice(end)}`,
|
||||
selectionStart: lineStart,
|
||||
selectionEnd: lineStart,
|
||||
};
|
||||
}
|
||||
|
||||
const marker = bullet ?? `${parseInt(num, 10) + 1}${delim}`;
|
||||
const insertion = `\n${indent}${marker} ${task ? "[ ] " : ""}`;
|
||||
return {
|
||||
value: `${body.slice(0, cursor)}${insertion}${body.slice(cursor)}`,
|
||||
selectionStart: cursor + insertion.length,
|
||||
selectionEnd: cursor + insertion.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFormatUpdate(body: string, selection: BodySelection): BodyUpdate {
|
||||
const value = formatMarkdown(body);
|
||||
const cursor = Math.min(selection.start, value.length);
|
||||
return { value, selectionStart: cursor, selectionEnd: cursor };
|
||||
}
|
||||
|
||||
export function createReferenceUpdate(
|
||||
body: string,
|
||||
selection: BodySelection,
|
||||
|
|
|
|||
109
src/lib/wiki/editorHighlight.ts
Normal file
109
src/lib/wiki/editorHighlight.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Lightweight markdown tokenizer for the editor highlight mirror.
|
||||
*
|
||||
* Pure module — safe to import client-side. Splits the source into lines and
|
||||
* returns, per line, segments tagged with a Tailwind class for the mirror
|
||||
* layer behind the editor textarea. Not a full parser: fence state is tracked
|
||||
* across lines, everything else is line-local.
|
||||
*/
|
||||
|
||||
export interface HighlightSegment {
|
||||
readonly text: string;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
const HL = {
|
||||
heading: "font-semibold text-sky-200",
|
||||
bold: "font-semibold text-foreground",
|
||||
italic: "italic text-foreground",
|
||||
strike: "line-through text-muted-foreground/70",
|
||||
code: "rounded bg-muted px-0.5 text-emerald-300",
|
||||
wikilink: "text-sky-300 underline underline-offset-2",
|
||||
link: "text-sky-300 underline underline-offset-2",
|
||||
quote: "italic text-muted-foreground/80",
|
||||
directive: "text-violet-300",
|
||||
fence: "text-muted-foreground/60",
|
||||
codeBlock: "text-emerald-300/50",
|
||||
listMarker: "text-amber-300",
|
||||
} as const;
|
||||
|
||||
const INLINE_RE =
|
||||
/(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*)|((?<![A-Za-z0-9])_[^_\n]+_(?![A-Za-z0-9]))|(\[\[[^\]\n]+\]\])|(\[[^\]\n]*\]\([^)\n]+\))/g;
|
||||
|
||||
const INLINE_CLASSES = [
|
||||
HL.code,
|
||||
HL.bold,
|
||||
HL.bold,
|
||||
HL.strike,
|
||||
HL.italic,
|
||||
HL.italic,
|
||||
HL.wikilink,
|
||||
HL.link,
|
||||
] as const;
|
||||
|
||||
function tokenizeInline(text: string): HighlightSegment[] {
|
||||
const segments: HighlightSegment[] = [];
|
||||
let cursor = 0;
|
||||
for (const match of text.matchAll(INLINE_RE)) {
|
||||
const start = match.index ?? 0;
|
||||
if (start > cursor) segments.push({ text: text.slice(cursor, start) });
|
||||
const groupIndex =
|
||||
match.findIndex((capture, index) => index > 0 && capture !== undefined) - 1;
|
||||
segments.push({ text: match[0], className: INLINE_CLASSES[groupIndex] });
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
segments.push({ text: text.slice(cursor) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
const FENCE_RE = /^\s{0,3}(```|~~~)/;
|
||||
const HEADING_RE = /^\s{0,3}#{1,6}\s/;
|
||||
const QUOTE_RE = /^\s{0,3}>/;
|
||||
const DIRECTIVE_RE = /^\s{0,3}:::/;
|
||||
const HRULE_RE = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
||||
const LIST_MARKER_RE = /^(\s*)([-*+]|\d+[.)])(\s+)/;
|
||||
|
||||
export function highlightMarkdownLines(source: string): HighlightSegment[][] {
|
||||
const lines: HighlightSegment[][] = [];
|
||||
let inFence = false;
|
||||
for (const line of source.split("\n")) {
|
||||
if (FENCE_RE.test(line)) {
|
||||
lines.push([{ text: line, className: HL.fence }]);
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
lines.push([{ text: line, className: HL.codeBlock }]);
|
||||
continue;
|
||||
}
|
||||
if (HEADING_RE.test(line)) {
|
||||
lines.push([{ text: line, className: HL.heading }]);
|
||||
continue;
|
||||
}
|
||||
if (QUOTE_RE.test(line)) {
|
||||
lines.push([{ text: line, className: HL.quote }]);
|
||||
continue;
|
||||
}
|
||||
if (DIRECTIVE_RE.test(line)) {
|
||||
lines.push([{ text: line, className: HL.directive }]);
|
||||
continue;
|
||||
}
|
||||
if (HRULE_RE.test(line)) {
|
||||
lines.push([{ text: line, className: HL.fence }]);
|
||||
continue;
|
||||
}
|
||||
const listMatch = line.match(LIST_MARKER_RE);
|
||||
if (listMatch) {
|
||||
const markerEnd = listMatch[0].length;
|
||||
lines.push([
|
||||
{ text: listMatch[0], className: HL.listMarker },
|
||||
...tokenizeInline(line.slice(markerEnd)),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
lines.push(tokenizeInline(line));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
56
src/lib/wiki/formatMarkdown.ts
Normal file
56
src/lib/wiki/formatMarkdown.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
const FENCE_LINE_RE = /^\s{0,3}(```|~~~)/;
|
||||
const HEADING_RE = /^(#{1,6})[ \t]*(?=[^#\s])/;
|
||||
const BULLET_RE = /^([ \t]*)[*+][ \t]+(?=\S)/;
|
||||
const QUOTE_RE = /^>[ \t]*(?=[^>\s])/;
|
||||
const HRULE_RE = /^\s*([-*_])([ \t]*\1){2,}[ \t]*$/;
|
||||
const BLANK_RE = /^[ \t]*$/;
|
||||
|
||||
function trimTrailingWhitespace(line: string): string {
|
||||
return line.replace(/[ \t]+$/, (ws) => {
|
||||
if (ws === " ") return ws;
|
||||
return ws.length > 2 ? " " : "";
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMarkdown(source: string): string {
|
||||
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
||||
const out: string[] = [];
|
||||
let fenceMarker: string | null = null;
|
||||
let previousBlank = true;
|
||||
|
||||
for (const line of lines) {
|
||||
const fenceMatch = FENCE_LINE_RE.exec(line);
|
||||
|
||||
if (fenceMarker) {
|
||||
out.push(line);
|
||||
if (fenceMatch && fenceMatch[1] === fenceMarker) fenceMarker = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fenceMatch) {
|
||||
fenceMarker = fenceMatch[1];
|
||||
out.push(line);
|
||||
previousBlank = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (BLANK_RE.test(line)) {
|
||||
if (!previousBlank && out.length > 0) out.push("");
|
||||
previousBlank = true;
|
||||
continue;
|
||||
}
|
||||
previousBlank = false;
|
||||
|
||||
let formatted = trimTrailingWhitespace(line);
|
||||
if (!HRULE_RE.test(formatted)) {
|
||||
formatted = formatted
|
||||
.replace(HEADING_RE, "$1 ")
|
||||
.replace(BULLET_RE, "$1- ")
|
||||
.replace(QUOTE_RE, "> ");
|
||||
}
|
||||
out.push(formatted);
|
||||
}
|
||||
|
||||
while (out.length > 0 && out[out.length - 1] === "") out.pop();
|
||||
return out.length === 0 ? "" : `${out.join("\n")}\n`;
|
||||
}
|
||||
Loading…
Reference in a new issue