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";
|
"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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 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 = ``;
|
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(
|
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,
|
||||||
|
|
|
||||||
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