1
0
Fork 0

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:
Jason Fraley 2026-09-06 05:32:51 -04:00
parent 51f7c803e0
commit 55a03e8094
8 changed files with 802 additions and 47 deletions

View file

@ -1,10 +1,10 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Markdown from "react-markdown";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -19,14 +19,11 @@ import {
import { createWikiPage, updateWikiPage } from "@/app/(frontend)/wiki/actions"; import { createWikiPage, updateWikiPage } from "@/app/(frontend)/wiki/actions";
import { WIKI_CATEGORIES, type WikiCategory } from "@/lib/wiki/categories"; import { WIKI_CATEGORIES, type WikiCategory } from "@/lib/wiki/categories";
import { prepareMarkdown } from "@/lib/wiki/prepare"; import { prepareMarkdown } from "@/lib/wiki/prepare";
import { import { buildTemplateStyles } from "@/lib/wiki/templateCss";
wikiMarkdownComponents,
wikiRemarkPlugins,
wikiRemarkRehypeOptions,
} from "@/lib/wiki/markdown";
import { MarkdownGuide } from "@/components/frontend/wiki/MarkdownGuide"; import { MarkdownGuide } from "@/components/frontend/wiki/MarkdownGuide";
import { WikiEditorBody } from "@/components/frontend/wiki/WikiEditorBody"; import { WikiEditorBody } from "@/components/frontend/wiki/WikiEditorBody";
import { WikiStandardsDialog } from "@/components/frontend/wiki/WikiStandardsDialog"; import { WikiStandardsDialog } from "@/components/frontend/wiki/WikiStandardsDialog";
import { WikiPreparedBody } from "@/components/frontend/wiki/WikiContent";
const STANDARDS_ACCEPTED_KEY = "ptf:wiki-standards-accepted"; const STANDARDS_ACCEPTED_KEY = "ptf:wiki-standards-accepted";
@ -43,6 +40,8 @@ interface WikiEditorProps {
readonly initial?: WikiEditorInitial; readonly initial?: WikiEditorInitial;
readonly initialTitle?: string; readonly initialTitle?: string;
readonly templates: Record<string, string>; readonly templates: Record<string, string>;
readonly templateCssMap?: Record<string, string>;
readonly templateHtmlMap?: Record<string, string>;
readonly existingPages: readonly { title: string }[]; readonly existingPages: readonly { title: string }[];
readonly isFirstEdit?: boolean; readonly isFirstEdit?: boolean;
} }
@ -67,6 +66,8 @@ export function WikiEditor({
initial, initial,
initialTitle, initialTitle,
templates, templates,
templateCssMap = {},
templateHtmlMap = {},
existingPages, existingPages,
isFirstEdit = false, isFirstEdit = false,
}: WikiEditorProps) { }: WikiEditorProps) {
@ -83,7 +84,19 @@ export function WikiEditor({
if (!isFirstEdit || typeof window === "undefined") return false; if (!isFirstEdit || typeof window === "undefined") return false;
return window.localStorage.getItem(STANDARDS_ACCEPTED_KEY) !== "1"; 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() { function handleStandardsAccept() {
window.localStorage.setItem(STANDARDS_ACCEPTED_KEY, "1"); window.localStorage.setItem(STANDARDS_ACCEPTED_KEY, "1");
@ -133,6 +146,7 @@ export function WikiEditor({
return ( return (
<form className="flex flex-col gap-6" onSubmit={(event) => void handleSave(event)}> <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="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5 sm:col-span-2"> <div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="wiki-title">Title</Label> <Label htmlFor="wiki-title">Title</Label>
@ -190,43 +204,41 @@ export function WikiEditor({
</div> </div>
</div> </div>
<WikiEditorBody <div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
body={body} <div className="lg:sticky lg:top-5">
bodyRef={bodyRef} <WikiEditorBody
existingPages={existingPages} body={body}
linkDialogOpen={linkDialogOpen} bodyRef={bodyRef}
onBodyChange={setBody} existingPages={existingPages}
onLinkDialogChange={setLinkDialogOpen} linkDialogOpen={linkDialogOpen}
pending={pending} 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 lg:sticky lg:top-5">
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground"> <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Live preview Live preview
</h2> </h2>
<div className="min-h-60 rounded-md border bg-muted/20 p-4"> <div className="min-h-60 rounded-md border bg-muted/20 p-4">
{preparedPreview ? ( {preparedPreview ? (
<div className="wiki-markdown flex flex-col gap-4 text-sm"> <div className={cn("wiki-markdown", previewScopeClasses)}>
<Markdown <WikiPreparedBody prepared={preparedPreview} htmlTemplates={templateHtmlMap} />
remarkPlugins={wikiRemarkPlugins}
remarkRehypeOptions={wikiRemarkRehypeOptions}
components={wikiMarkdownComponents}
>
{preparedPreview}
</Markdown>
</div> </div>
) : ( ) : (
<p className="text-sm italic text-muted-foreground">Nothing to preview yet.</p> <p className="text-sm italic text-muted-foreground">Nothing to preview yet.</p>
)} )}
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> </div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Markdown guide <div className="flex flex-col gap-2">
</h2> <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
<MarkdownGuide /> Markdown guide
</div> </h2>
<MarkdownGuide />
</div> </div>
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">

View file

@ -11,11 +11,14 @@ import {
Image, Image,
ImageUp, ImageUp,
Italic, Italic,
LayoutTemplate,
Link2, Link2,
List, List,
ListOrdered, ListOrdered,
NotebookPen, NotebookPen,
PanelRight,
Quote, Quote,
Sparkles,
Strikethrough, Strikethrough,
Table, Table,
} from "lucide-react"; } from "lucide-react";
@ -23,13 +26,21 @@ import { toast } from "sonner";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { WikiLinkDialog } from "@/components/frontend/wiki/WikiLinkDialog"; 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 { uploadWikiImage } from "@/app/(frontend)/wiki/actions";
import type { TemplateMap } from "@/lib/wiki/templates";
import { import {
IMAGE_SNIPPET, IMAGE_SNIPPET,
INFOBOX_SNIPPET,
TABLE_SNIPPET, TABLE_SNIPPET,
THREE_COLUMN_SNIPPET, THREE_COLUMN_SNIPPET,
TWO_COLUMN_SNIPPET, TWO_COLUMN_SNIPPET,
createLinePrefixUpdate, createLinePrefixUpdate,
createListContinueUpdate,
createFormatUpdate,
createIndentUpdate,
createReferenceUpdate, createReferenceUpdate,
createSnippetUpdate, createSnippetUpdate,
createWrappedUpdate, createWrappedUpdate,
@ -51,6 +62,7 @@ interface WikiEditorBodyProps {
readonly onBodyChange: (value: string) => void; readonly onBodyChange: (value: string) => void;
readonly onLinkDialogChange: (open: boolean) => void; readonly onLinkDialogChange: (open: boolean) => void;
readonly pending: boolean; readonly pending: boolean;
readonly templates: TemplateMap;
} }
export function WikiEditorBody({ export function WikiEditorBody({
@ -61,9 +73,55 @@ export function WikiEditorBody({
onBodyChange, onBodyChange,
onLinkDialogChange, onLinkDialogChange,
pending, pending,
templates,
}: WikiEditorBodyProps) { }: WikiEditorBodyProps) {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
const { record, undo, redo } = useEditorHistory(body, bodyRef);
const [uploading, setUploading] = useState(false); 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>) { async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; 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); onBodyChange(update.value);
requestAnimationFrame(() => { requestAnimationFrame(() => {
const textarea = bodyRef.current; requestAnimationFrame(() => {
textarea?.focus(); const textarea = bodyRef.current;
textarea?.setSelectionRange(update.selectionStart, update.selectionEnd); 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) { function wrapSelection(format: WrappedFormat) {
const selection = readBodySelection(body, bodyRef); const selection = readBodySelection(body, bodyRef);
applyBodyUpdate(createWrappedUpdate(body, selection, format)); applyBodyUpdate(createWrappedUpdate(body, selection, format));
@ -128,7 +200,56 @@ export function WikiEditorBody({
onLinkDialogChange(false); 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>) { 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; const modifierPressed = event.metaKey || event.ctrlKey;
if (!modifierPressed || event.altKey) return; if (!modifierPressed || event.altKey) return;
@ -153,6 +274,11 @@ export function WikiEditorBody({
prefixLines("## "); prefixLines("## ");
return; return;
} }
if (key === "f" && event.shiftKey) {
event.preventDefault();
formatBody();
return;
}
if (key === "k" && !event.shiftKey) { if (key === "k" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
onLinkDialogChange(true); onLinkDialogChange(true);
@ -161,6 +287,12 @@ export function WikiEditorBody({
if (key === "e" && !event.shiftKey) { if (key === "e" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
wrapSelection({ prefix: "`", suffix: "`", placeholder: "code" }); 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, icon: Heading2,
onClick: () => prefixLines("## "), 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", label: "2 columns",
icon: Columns2, icon: Columns2,
@ -253,6 +396,11 @@ export function WikiEditorBody({
onClick: () => onClick: () =>
insertSnippet({ insertion: THREE_COLUMN_SNIPPET, placeholder: "Left column content" }), 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} disabled={pending}
/> />
</div> </div>
<WikiEditorToolbar groups={groups} disabled={pending || uploading} /> <WikiTemplateDialog
<Textarea templates={templates}
ref={bodyRef} open={templateDialogOpen}
id="wiki-body" onOpenChange={setTemplateDialogOpen}
rows={18}
value={body}
onChange={(event) => onBodyChange(event.target.value)}
onKeyDown={handleBodyKeyDown}
placeholder="Write the field guide entry in Markdown."
disabled={pending} 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> </div>
); );
} }

View 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>
);
});

View 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>
);
}

View 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 };
}

View file

@ -1,4 +1,5 @@
import type { RefObject } from "react"; import type { RefObject } from "react";
import { formatMarkdown } from "@/lib/wiki/formatMarkdown";
export interface BodySelection { export interface BodySelection {
readonly start: number; readonly start: number;
@ -57,6 +58,16 @@ export const TABLE_SNIPPET = `| Header | Header |
export const IMAGE_SNIPPET = `![Alt text](https:// "Caption on hover")`; export const IMAGE_SNIPPET = `![Alt text](https:// "Caption on hover")`;
export const INFOBOX_SNIPPET = `::::infobox
**Infobox title**
| Field | Value |
| ----- | ----- |
| Field 1 | Value 1 |
| Field 2 | Value 2 |
| Field 3 | Value 3 |
::::`;
export function readBodySelection( export function readBodySelection(
body: string, body: string,
bodyRef: RefObject<HTMLTextAreaElement | null>, 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( export function createReferenceUpdate(
body: string, body: string,
selection: BodySelection, selection: BodySelection,

View 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;
}

View 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`;
}