feat: added a twist
This commit is contained in:
44
frontend/src/components/editor/BacklinkIndicator.tsx
Normal file
44
frontend/src/components/editor/BacklinkIndicator.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { BacklinkPage } from "@/hooks/useBacklinks";
|
||||
|
||||
interface Props {
|
||||
backlinks: BacklinkPage[];
|
||||
}
|
||||
|
||||
export default function BacklinkIndicator({ backlinks }: Props) {
|
||||
if (backlinks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground h-7 px-2">
|
||||
← {backlinks.length} backlink{backlinks.length !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="end">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-2 px-1">
|
||||
Pages linking here
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{backlinks.map((page) => (
|
||||
<li key={page.name}>
|
||||
<Link
|
||||
to={`/editor/${page.name}`}
|
||||
className="flex items-center gap-1.5 text-sm px-2 py-1 rounded hover:bg-accent"
|
||||
>
|
||||
<span>{page.title ?? page.name}</span>
|
||||
{page.title && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({page.name})
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
61
frontend/src/components/editor/EditorPane.tsx
Normal file
61
frontend/src/components/editor/EditorPane.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||
import { searchKeymap } from "@codemirror/search";
|
||||
import { oneDark } from "./oneDarkTheme";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
extensions?: Extension[];
|
||||
}
|
||||
|
||||
export default function EditorPane({ value, onChange, extensions = [] }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||
oneDark,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
"&": { height: "100%", fontSize: "14px" },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".cm-content": { fontFamily: "monospace", padding: "16px" },
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({ state, parent: containerRef.current });
|
||||
viewRef.current = view;
|
||||
|
||||
return () => view.destroy();
|
||||
}, []); // Only mount once
|
||||
|
||||
// Sync external value changes (e.g. loading a page)
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (view && view.state.doc.toString() !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return <div ref={containerRef} style={{ height: "100%" }} />;
|
||||
}
|
||||
88
frontend/src/components/editor/PreviewPane.tsx
Normal file
88
frontend/src/components/editor/PreviewPane.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import { renderMicron } from "./micronRenderer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PreviewMode = "ascii" | "micron" | "raw";
|
||||
|
||||
export default function PreviewPane() {
|
||||
const previewMode = useEditorStore((s) => s.previewMode);
|
||||
const setPreviewMode = useEditorStore((s) => s.setPreviewMode);
|
||||
const compiledAscii = useEditorStore((s) => s.compiledAscii);
|
||||
const compiledMicron = useEditorStore((s) => s.compiledMicron);
|
||||
const isCompiling = useEditorStore((s) => s.isCompiling);
|
||||
const compileError = useEditorStore((s) => s.compileError);
|
||||
|
||||
const tabs: { value: PreviewMode; label: string }[] = [
|
||||
{ value: "ascii", label: "ASCII" },
|
||||
{ value: "micron", label: "Micron" },
|
||||
{ value: "raw", label: "Raw" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center px-3 py-1.5 border-b shrink-0 gap-2">
|
||||
<span className="text-xs text-muted-foreground flex-1">
|
||||
Preview
|
||||
{isCompiling && (
|
||||
<span className="ml-2 text-yellow-500 animate-pulse">
|
||||
compiling…
|
||||
</span>
|
||||
)}
|
||||
{compileError && (
|
||||
<span className="ml-2 text-red-400" title={compileError}>
|
||||
✗ error
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-0.5 bg-muted rounded-md p-0.5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setPreviewMode(tab.value)}
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded transition-colors",
|
||||
previewMode === tab.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 bg-background">
|
||||
{previewMode === "ascii" ? (
|
||||
<pre className="p-4 font-mono text-sm whitespace-pre leading-tight text-green-100/90">
|
||||
{compiledAscii || (
|
||||
<span className="text-muted-foreground">
|
||||
ASCII preview will appear here…
|
||||
</span>
|
||||
)}
|
||||
</pre>
|
||||
) : previewMode === "micron" ? (
|
||||
compiledMicron ? (
|
||||
<div
|
||||
className="p-4 font-mono text-sm whitespace-pre leading-tight"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMicron(compiledMicron),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Micron preview will appear here…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<pre className="p-4 font-mono text-sm whitespace-pre-wrap break-words text-muted-foreground">
|
||||
{compiledMicron || "Raw Micron output will appear here…"}
|
||||
</pre>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/editor/ToolBar.tsx
Normal file
56
frontend/src/components/editor/ToolBar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import { useBacklinks } from "@/hooks/useBacklinks";
|
||||
import BacklinkIndicator from "@/components/editor/BacklinkIndicator";
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
onNameChange?: (name: string) => void;
|
||||
onSaveDraft: () => void;
|
||||
onPublish: () => void;
|
||||
saving: boolean;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
export default function ToolBar({
|
||||
pageName,
|
||||
onNameChange,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
saving,
|
||||
isDirty,
|
||||
}: Props) {
|
||||
const currentPage = useEditorStore((s) => s.currentPage);
|
||||
const backlinks = useBacklinks(currentPage?.name);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b bg-card shrink-0">
|
||||
{onNameChange ? (
|
||||
<Input
|
||||
value={pageName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="page-name"
|
||||
className="font-mono w-48 h-8 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-mono font-semibold text-sm">{pageName}</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<BacklinkIndicator backlinks={backlinks} />
|
||||
|
||||
{isDirty && (
|
||||
<span className="text-xs text-muted-foreground">Unsaved</span>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button size="sm" onClick={onPublish} disabled={saving}>
|
||||
Publish
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/editor/micronHighlight.ts
Normal file
57
frontend/src/components/editor/micronHighlight.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { StreamLanguage, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
const micronLanguage = StreamLanguage.define({
|
||||
token(stream) {
|
||||
if (stream.sol()) {
|
||||
// Depth-4+ indent (before >>> so ">>>> " doesn't match heading3)
|
||||
if (stream.match(/>>>>/)) { stream.skipToEnd(); return "keyword"; }
|
||||
// Headings — longest prefix first
|
||||
if (stream.match(/>>>/)) { stream.skipToEnd(); return "heading3"; }
|
||||
if (stream.match(/>>/)) { stream.skipToEnd(); return "heading2"; }
|
||||
if (stream.match(/>/)) { stream.skipToEnd(); return "heading1"; }
|
||||
// Dividers: line starting with - followed by a non-space, non-dash char
|
||||
if (stream.match(/-[^\s\-]/)) { stream.skipToEnd(); return "contentSeparator"; }
|
||||
// Comment lines
|
||||
if (stream.match(/#/)) { stream.skipToEnd(); return "lineComment"; }
|
||||
// Standalone depth-reset "<"
|
||||
if (stream.string.trim() === "<") { stream.next(); return "meta"; }
|
||||
}
|
||||
|
||||
// Backtick-based format tags: `! `* `_ `` `F `f `B `b `c `r `l `a `= `<
|
||||
if (stream.match(/`[!*_`FfBbCcRrLlAa=<]/)) return "meta";
|
||||
|
||||
// Hex color values (exactly 3 hex digits) — appear right after `F or `B tags
|
||||
if (stream.match(/[0-9a-fA-F]{3}(?![0-9a-fA-F])/)) return "number";
|
||||
|
||||
// Links [label`url] — consume the whole bracket expression
|
||||
if (stream.match(/\[[^\]]*\]/)) return "link";
|
||||
|
||||
// Form elements <fieldname`default> etc.
|
||||
if (stream.match(/<[^>]+>/)) return "string";
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
startState: () => ({}),
|
||||
copyState: (s) => ({ ...s }),
|
||||
blankLine: () => {},
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
const micronStyle = HighlightStyle.define([
|
||||
{ tag: tags.heading1, color: "#7ee8a2", fontWeight: "bold" },
|
||||
{ tag: tags.heading2, color: "#70c4e8", fontWeight: "bold" },
|
||||
{ tag: tags.heading3, color: "#a8c4e8", fontWeight: "bold" },
|
||||
{ tag: tags.keyword, color: "#c9d1d9", fontStyle: "italic" }, // depth-4+ indent
|
||||
{ tag: tags.contentSeparator, color: "#484f58", fontStyle: "italic" },
|
||||
{ tag: tags.lineComment, color: "#484f58", fontStyle: "italic" }, // # comments
|
||||
{ tag: tags.meta, color: "#d2a8ff" }, // backtick format codes
|
||||
{ tag: tags.number, color: "#f8d4a8" }, // hex color values
|
||||
{ tag: tags.link, color: "#7dc4e4", textDecoration: "underline" },
|
||||
{ tag: tags.string, color: "#d4a8f8" }, // form elements
|
||||
]);
|
||||
|
||||
export function micronHighlight() {
|
||||
return [micronLanguage, syntaxHighlighting(micronStyle)];
|
||||
}
|
||||
157
frontend/src/components/editor/micronRenderer.ts
Normal file
157
frontend/src/components/editor/micronRenderer.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Micron markup → HTML renderer for the editor preview pane.
|
||||
* Spec: https://github.com/fr33n0w/micron-composer
|
||||
*/
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Render inline Micron formatting codes within a line of text. */
|
||||
function renderInline(raw: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
const openTags: string[] = [];
|
||||
|
||||
const closeAll = () => {
|
||||
while (openTags.length) out += openTags.pop()!;
|
||||
};
|
||||
|
||||
while (i < raw.length) {
|
||||
// Backtick formatting codes
|
||||
if (raw[i] === "`") {
|
||||
const code = raw[i + 1];
|
||||
if (code === "!") {
|
||||
out += "<strong>"; openTags.push("</strong>"); i += 2; continue;
|
||||
} else if (code === "*") {
|
||||
out += "<em>"; openTags.push("</em>"); i += 2; continue;
|
||||
} else if (code === "_") {
|
||||
out += "<u>"; openTags.push("</u>"); i += 2; continue;
|
||||
} else if (code === "`") {
|
||||
closeAll(); i += 2; continue;
|
||||
} else if (code === "f" || code === "b") {
|
||||
out += "</span>"; i += 2; continue;
|
||||
} else if (code === "a") {
|
||||
out += "</span>"; i += 2; continue;
|
||||
} else if (code === "F") {
|
||||
// Foreground color — 3-digit hex only per spec
|
||||
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
|
||||
if (hexMatch) {
|
||||
const [r, g, b] = hexMatch[1].split("");
|
||||
const hex = r + r + g + g + b + b;
|
||||
out += `<span style="color:#${hex}">`;
|
||||
openTags.push("</span>");
|
||||
i += 2 + hexMatch[1].length;
|
||||
continue;
|
||||
}
|
||||
} else if (code === "B") {
|
||||
// Background color — 3-digit hex only per spec
|
||||
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
|
||||
if (hexMatch) {
|
||||
const [r, g, b] = hexMatch[1].split("");
|
||||
const hex = r + r + g + g + b + b;
|
||||
out += `<span style="background:#${hex}">`;
|
||||
openTags.push("</span>");
|
||||
i += 2 + hexMatch[1].length;
|
||||
continue;
|
||||
}
|
||||
} else if (code === "c") {
|
||||
out += `<span style="display:block;text-align:center">`;
|
||||
openTags.push("</span>"); i += 2; continue;
|
||||
} else if (code === "r") {
|
||||
out += `<span style="display:block;text-align:right">`;
|
||||
openTags.push("</span>"); i += 2; continue;
|
||||
} else if (code === "l") {
|
||||
out += `<span style="display:block;text-align:left">`;
|
||||
openTags.push("</span>"); i += 2; continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Links: [label`slug] or [label`slug.mu]
|
||||
if (raw[i] === "[") {
|
||||
const close = raw.indexOf("]", i);
|
||||
if (close !== -1) {
|
||||
const inner = raw.slice(i + 1, close);
|
||||
const backtick = inner.indexOf("`");
|
||||
if (backtick !== -1) {
|
||||
const label = escapeHtml(inner.slice(0, backtick));
|
||||
const slug = escapeHtml(inner.slice(backtick + 1).replace(/\.mu$/, ""));
|
||||
out += `<a href="/view/${slug}" style="color:#7dc4e4;text-decoration:underline">${label}</a>`;
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out += escapeHtml(raw[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
closeAll();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render a form element line as a styled badge. */
|
||||
function renderForm(line: string): string {
|
||||
const inner = escapeHtml(line);
|
||||
return `<span style="color:#d4a8f8;background:rgba(212,168,248,0.08);border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px;font-size:0.9em">${inner}</span>`;
|
||||
}
|
||||
|
||||
export function renderMicron(source: string): string {
|
||||
const lines = source.split("\n");
|
||||
const htmlLines: string[] = [];
|
||||
let literalMode = false;
|
||||
|
||||
for (const line of lines) {
|
||||
// Toggle literal mode on standalone `= line
|
||||
if (line.trimEnd() === "`=") {
|
||||
literalMode = !literalMode;
|
||||
continue;
|
||||
}
|
||||
|
||||
// In literal mode — render verbatim
|
||||
if (literalMode) {
|
||||
htmlLines.push(`<div style="font-family:monospace;opacity:0.75;white-space:pre">${escapeHtml(line)}</div>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Comment lines — hidden in output
|
||||
if (line.startsWith("#")) continue;
|
||||
|
||||
// All output uses inline spans — the parent container has white-space:pre
|
||||
// so newlines come from the \n join at the end.
|
||||
|
||||
// Depth-4+ indent (before >>> check)
|
||||
if (line.startsWith(">>>>")) {
|
||||
htmlLines.push(`<span style="color:#c9d1d9;font-style:italic">${renderInline(line.slice(4))}</span>`);
|
||||
// Headings
|
||||
} else if (line.startsWith(">>>")) {
|
||||
htmlLines.push(`<span style="color:#a8c4e8;font-weight:bold">${renderInline(line.slice(3))}</span>`);
|
||||
} else if (line.startsWith(">>")) {
|
||||
htmlLines.push(`<span style="color:#70c4e8;font-weight:bold">${renderInline(line.slice(2))}</span>`);
|
||||
} else if (line.startsWith(">")) {
|
||||
htmlLines.push(`<span style="color:#7ee8a2;font-weight:bold">${renderInline(line.slice(1))}</span>`);
|
||||
// Dividers: - followed by a non-space, non-dash character
|
||||
} else if (/^-[^\s-]/.test(line)) {
|
||||
const char = line[1];
|
||||
htmlLines.push(`<span style="color:#484f58">${char.repeat(40)}</span>`);
|
||||
// Standalone depth-reset "<"
|
||||
} else if (line.trim() === "<") {
|
||||
htmlLines.push(`<span style="color:#d2a8ff;opacity:0.4">↩ depth reset</span>`);
|
||||
// Empty line
|
||||
} else if (line.trim() === "") {
|
||||
htmlLines.push("");
|
||||
// Form elements on their own line
|
||||
} else if (/^`?<[^>]+>$/.test(line)) {
|
||||
htmlLines.push(renderForm(line));
|
||||
} else {
|
||||
htmlLines.push(renderInline(line));
|
||||
}
|
||||
}
|
||||
|
||||
return htmlLines.join("\n");
|
||||
}
|
||||
28
frontend/src/components/editor/oneDarkTheme.ts
Normal file
28
frontend/src/components/editor/oneDarkTheme.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
export const oneDark = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
backgroundColor: "#0d1117",
|
||||
color: "#c9d1d9",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: "#c9d1d9",
|
||||
},
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "#264f78",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "#161b2266",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "#0d1117",
|
||||
color: "#484f58",
|
||||
borderRight: "1px solid #21262d",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "#161b2266",
|
||||
},
|
||||
},
|
||||
{ dark: true }
|
||||
);
|
||||
84
frontend/src/components/editor/slashCommands.ts
Normal file
84
frontend/src/components/editor/slashCommands.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { snippet } from "@codemirror/autocomplete";
|
||||
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
|
||||
interface SlashEntry {
|
||||
label: string;
|
||||
detail: string;
|
||||
section: string;
|
||||
apply: Completion["apply"];
|
||||
}
|
||||
|
||||
// Insert text, replacing from the "/" character (from-1) through the cursor
|
||||
function insert(text: string): Completion["apply"] {
|
||||
return (view: EditorView, _completion: Completion, from: number, to: number) => {
|
||||
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
||||
};
|
||||
}
|
||||
|
||||
// Wrap snippet() to also replace the preceding "/" character
|
||||
function slashSnippet(template: string): Completion["apply"] {
|
||||
const snip = snippet(template);
|
||||
return (view: EditorView, completion: Completion, from: number, to: number) => {
|
||||
snip(view, completion, from - 1, to - 1);
|
||||
};
|
||||
}
|
||||
|
||||
const COMMANDS: SlashEntry[] = [
|
||||
// Headings
|
||||
{ label: "H1", detail: ">...", section: "Heading", apply: slashSnippet(">\${text}") },
|
||||
{ label: "H2", detail: ">>...", section: "Heading", apply: slashSnippet(">>\${text}") },
|
||||
{ label: "H3", detail: ">>>...", section: "Heading", apply: slashSnippet(">>>\${text}") },
|
||||
|
||||
// Text formatting
|
||||
{ label: "Bold", detail: "`!..`!", section: "Format", apply: slashSnippet("`!\${text}`!") },
|
||||
{ label: "Italic", detail: "`*..`*", section: "Format", apply: slashSnippet("`*\${text}`*") },
|
||||
{ label: "Underline", detail: "`_..`_", section: "Format", apply: slashSnippet("`_\${text}`_") },
|
||||
{ label: "Reset", detail: "``", section: "Format", apply: insert("``") },
|
||||
{ label: "Literal", detail: "`=...`=", section: "Format", apply: slashSnippet("`=\n\${content}\n`=") },
|
||||
|
||||
// Alignment
|
||||
{ label: "Center", detail: "`c..`a", section: "Align", apply: slashSnippet("`c\${text}`a") },
|
||||
{ label: "Right", detail: "`r..`a", section: "Align", apply: slashSnippet("`r\${text}`a") },
|
||||
{ label: "Left", detail: "`l..`a", section: "Align", apply: slashSnippet("`l\${text}`a") },
|
||||
|
||||
// Color (3-digit hex)
|
||||
{ label: "Color", detail: "`Fhex..`f", section: "Color", apply: slashSnippet("`F\${hex}\${text}`f") },
|
||||
{ label: "BgColor", detail: "`Bhex..`b", section: "Color", apply: slashSnippet("`B\${hex}\${text}`b") },
|
||||
|
||||
// Links
|
||||
{ label: "Link", detail: "[label`page]", section: "Link", apply: slashSnippet("[\${label}`\${page}]") },
|
||||
|
||||
// Dividers
|
||||
{ label: "Divider ─", detail: "-─", section: "Divider", apply: insert("-─") },
|
||||
{ label: "Divider ━", detail: "-━", section: "Divider", apply: insert("-━") },
|
||||
{ label: "Divider ═", detail: "-═", section: "Divider", apply: insert("-═") },
|
||||
{ label: "Divider ★", detail: "-★", section: "Divider", apply: insert("-★") },
|
||||
|
||||
// Forms — pipe separators per micron-composer spec
|
||||
{ label: "Field", detail: "<name`default>", section: "Form", apply: slashSnippet("<\${name}`\${default}>") },
|
||||
{ label: "Password", detail: "<!w|name`placeholder>", section: "Form", apply: slashSnippet("<!\${width}|\${name}`\${placeholder}>") },
|
||||
{ label: "Checkbox", detail: "<?|name|val`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}`\${label}>") },
|
||||
{ label: "Checked", detail: "<?|name|val|*`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}|*`\${label}>") },
|
||||
{ label: "Radio", detail: "<^|grp|val`label>", section: "Form", apply: slashSnippet("<^\${group}|\${value}`\${label}>") },
|
||||
|
||||
// Depth
|
||||
{ label: "Reset depth", detail: "<", section: "Depth", apply: insert("<\n") },
|
||||
];
|
||||
|
||||
export function slashCommandSource(ctx: CompletionContext): CompletionResult | null {
|
||||
const match = ctx.matchBefore(/\/\w*/);
|
||||
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
||||
return {
|
||||
// Start after "/" so the filter text doesn't include "/" (which would block all matches)
|
||||
from: match.from + 1,
|
||||
filter: true,
|
||||
options: COMMANDS.map((cmd) => ({
|
||||
label: cmd.label,
|
||||
detail: cmd.detail,
|
||||
section: cmd.section,
|
||||
apply: cmd.apply,
|
||||
boost: 99,
|
||||
})),
|
||||
};
|
||||
}
|
||||
195
frontend/src/components/editor/uframeCommands.ts
Normal file
195
frontend/src/components/editor/uframeCommands.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { snippet } from "@codemirror/autocomplete";
|
||||
import type {
|
||||
Completion,
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
} from "@codemirror/autocomplete";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
|
||||
interface CmdEntry {
|
||||
label: string;
|
||||
detail: string;
|
||||
section: string;
|
||||
apply: Completion["apply"];
|
||||
}
|
||||
|
||||
function insert(text: string): Completion["apply"] {
|
||||
return (view: EditorView, _c: Completion, from: number, to: number) => {
|
||||
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
||||
};
|
||||
}
|
||||
|
||||
function slashSnippet(template: string): Completion["apply"] {
|
||||
const snip = snippet(template);
|
||||
return (view: EditorView, c: Completion, from: number, to: number) => {
|
||||
snip(view, c, from - 1, to - 1);
|
||||
};
|
||||
}
|
||||
|
||||
const COMMANDS: CmdEntry[] = [
|
||||
// Layout
|
||||
{
|
||||
label: "page",
|
||||
detail: 'page "Title" 64',
|
||||
section: "Layout",
|
||||
apply: slashSnippet('page "${title}" ${width:64}'),
|
||||
},
|
||||
{
|
||||
label: "box",
|
||||
detail: 'box light "Title"',
|
||||
section: "Layout",
|
||||
apply: slashSnippet('box ${weight:light} "${title}"'),
|
||||
},
|
||||
{
|
||||
label: "row",
|
||||
detail: "row [gap]",
|
||||
section: "Layout",
|
||||
apply: slashSnippet("row ${gap:2}"),
|
||||
},
|
||||
{
|
||||
label: "col",
|
||||
detail: "col [width]",
|
||||
section: "Layout",
|
||||
apply: slashSnippet("col ${width}"),
|
||||
},
|
||||
{
|
||||
label: "spacer",
|
||||
detail: "spacer [lines]",
|
||||
section: "Layout",
|
||||
apply: insert("spacer"),
|
||||
},
|
||||
{
|
||||
label: "pad",
|
||||
detail: "pad t r b l",
|
||||
section: "Layout",
|
||||
apply: slashSnippet("pad ${top:1} ${right:1} ${bottom:1} ${left:1}"),
|
||||
},
|
||||
|
||||
// Content
|
||||
{
|
||||
label: "heading",
|
||||
detail: 'heading 1 "Text"',
|
||||
section: "Content",
|
||||
apply: slashSnippet('heading ${level:1} "${text}"'),
|
||||
},
|
||||
{
|
||||
label: "text",
|
||||
detail: 'text "Content"',
|
||||
section: "Content",
|
||||
apply: slashSnippet('text "${content}"'),
|
||||
},
|
||||
{
|
||||
label: "label",
|
||||
detail: 'label "Key" "Value"',
|
||||
section: "Content",
|
||||
apply: slashSnippet('label "${key}" "${value}"'),
|
||||
},
|
||||
{
|
||||
label: "divider",
|
||||
detail: "divider heavy",
|
||||
section: "Content",
|
||||
apply: slashSnippet("divider ${style:light}"),
|
||||
},
|
||||
{
|
||||
label: "link",
|
||||
detail: 'link "Text" "/path.mu"',
|
||||
section: "Content",
|
||||
apply: slashSnippet('link "${display}" "${dest}"'),
|
||||
},
|
||||
{
|
||||
label: "list",
|
||||
detail: "list bullet",
|
||||
section: "Content",
|
||||
apply: slashSnippet("list ${style:bullet}\n item \"${entry}\""),
|
||||
},
|
||||
|
||||
// Data Viz
|
||||
{
|
||||
label: "gauge",
|
||||
detail: "gauge label val max width",
|
||||
section: "Data",
|
||||
apply: slashSnippet(
|
||||
'gauge "${label}" ${value} ${max:100} ${width:28} warn=${warn:75} crit=${crit:90}',
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "sparkline",
|
||||
detail: "sparkline label values width",
|
||||
section: "Data",
|
||||
apply: slashSnippet('sparkline "${label}" "${values}" ${width:20}'),
|
||||
},
|
||||
{
|
||||
label: "status",
|
||||
detail: "status label state",
|
||||
section: "Data",
|
||||
apply: slashSnippet('status "${label}" ${state:online}'),
|
||||
},
|
||||
|
||||
// Style
|
||||
{
|
||||
label: "align",
|
||||
detail: "align center",
|
||||
section: "Style",
|
||||
apply: slashSnippet("align ${align:center}"),
|
||||
},
|
||||
{
|
||||
label: "color",
|
||||
detail: "color hex",
|
||||
section: "Style",
|
||||
apply: slashSnippet("color ${hex}"),
|
||||
},
|
||||
{
|
||||
label: "bold",
|
||||
detail: "bold",
|
||||
section: "Style",
|
||||
apply: insert("bold"),
|
||||
},
|
||||
|
||||
// Templates
|
||||
{
|
||||
label: "dashboard",
|
||||
detail: "Full dashboard template",
|
||||
section: "Template",
|
||||
apply: insert(
|
||||
`page "Dashboard" 64
|
||||
box double "Node Status"
|
||||
align center
|
||||
text "Reticulum Network Node"
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Resources"
|
||||
|
||||
gauge "CPU" 0 100 28 warn=75 crit=90
|
||||
gauge "MEM" 0 100 28 warn=80 crit=95
|
||||
|
||||
spacer
|
||||
|
||||
heading 2 "Network"
|
||||
|
||||
status "Relay East" online
|
||||
status "Bridge South" online
|
||||
|
||||
divider heavy
|
||||
link "Home" "/page/index.mu"`,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function uframeCommandSource(
|
||||
ctx: CompletionContext,
|
||||
): CompletionResult | null {
|
||||
const match = ctx.matchBefore(/\/\w*/);
|
||||
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
||||
return {
|
||||
from: match.from + 1,
|
||||
filter: true,
|
||||
options: COMMANDS.map((cmd) => ({
|
||||
label: cmd.label,
|
||||
detail: cmd.detail,
|
||||
section: cmd.section,
|
||||
apply: cmd.apply,
|
||||
boost: 99,
|
||||
})),
|
||||
};
|
||||
}
|
||||
101
frontend/src/components/editor/uframeHighlight.ts
Normal file
101
frontend/src/components/editor/uframeHighlight.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
StreamLanguage,
|
||||
HighlightStyle,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
/**
|
||||
* CodeMirror 6 syntax highlighting for the µFrame .uf DSL.
|
||||
*
|
||||
* Keywords: page, box, row, col, spacer, pad, heading, text, label,
|
||||
* divider, link, list, item, gauge, sparkline, status,
|
||||
* table, columns, form, field, radio, checkbox, button,
|
||||
* source, let, if, elif, else, for, align, color, bg,
|
||||
* bold, italic, underline, cache, state, on_submit
|
||||
*/
|
||||
|
||||
const KEYWORDS = new Set([
|
||||
"page", "box", "row", "col", "spacer", "pad",
|
||||
"heading", "text", "label", "divider", "link",
|
||||
"list", "item", "gauge", "sparkline", "status",
|
||||
"table", "columns", "form", "field", "radio",
|
||||
"checkbox", "button", "source", "let", "if",
|
||||
"elif", "else", "for", "align", "color", "bg",
|
||||
"bold", "italic", "underline", "cache", "state",
|
||||
"on_submit", "meter", "bar_h", "bar_v", "bar",
|
||||
"heatmap", "component", "use",
|
||||
]);
|
||||
|
||||
const WEIGHT_VALS = new Set([
|
||||
"light", "heavy", "double", "rounded",
|
||||
"bullet", "dash", "number", "arrow",
|
||||
"left", "center", "right",
|
||||
"online", "offline", "degraded", "unknown",
|
||||
]);
|
||||
|
||||
const uframeLanguage = StreamLanguage.define({
|
||||
token(stream) {
|
||||
// Comments
|
||||
if (stream.sol() && stream.match(/\s*#/)) {
|
||||
stream.skipToEnd();
|
||||
return "lineComment";
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
if (stream.eatSpace()) return null;
|
||||
|
||||
// Quoted strings
|
||||
if (stream.match(/"/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.next() === '"') break;
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
// @modifier{} syntax
|
||||
if (stream.match(/@\w+/)) return "keyword";
|
||||
|
||||
// $variable references
|
||||
if (stream.match(/\$[\w.]+/)) return "variableName";
|
||||
|
||||
// Numbers (integers, floats, hex colors)
|
||||
if (stream.match(/\b\d+(\.\d+)?\b/)) return "number";
|
||||
|
||||
// warn=N, crit=N parameters
|
||||
if (stream.match(/\b(warn|crit)=/)) return "attributeName";
|
||||
|
||||
// Pipe separator for inline lists
|
||||
if (stream.match(/\|/)) return "punctuation";
|
||||
|
||||
// Keywords and values
|
||||
if (stream.match(/\b\w+\b/)) {
|
||||
const word = stream.current();
|
||||
if (KEYWORDS.has(word)) return "keyword";
|
||||
if (WEIGHT_VALS.has(word)) return "atom";
|
||||
return null;
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
startState: () => ({}),
|
||||
copyState: (s) => ({ ...s }),
|
||||
blankLine: () => {},
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
const uframeStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#c792ea", fontWeight: "bold" },
|
||||
{ tag: tags.string, color: "#c3e88d" },
|
||||
{ tag: tags.lineComment, color: "#546e7a", fontStyle: "italic" },
|
||||
{ tag: tags.variableName, color: "#f78c6c" },
|
||||
{ tag: tags.number, color: "#f78c6c" },
|
||||
{ tag: tags.atom, color: "#89ddff" },
|
||||
{ tag: tags.attributeName, color: "#ffcb6b" },
|
||||
{ tag: tags.punctuation, color: "#89ddff" },
|
||||
]);
|
||||
|
||||
export function uframeHighlight() {
|
||||
return [uframeLanguage, syntaxHighlighting(uframeStyle)];
|
||||
}
|
||||
23
frontend/src/components/editor/wikiLinkCompletion.ts
Normal file
23
frontend/src/components/editor/wikiLinkCompletion.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { PageMeta } from "@/stores/editorStore";
|
||||
|
||||
export function wikiLinkSource(pagesRef: MutableRefObject<PageMeta[]>) {
|
||||
return (context: CompletionContext): CompletionResult | null => {
|
||||
const match = context.matchBefore(/\[\[[\w-]*/);
|
||||
if (!match || (match.from === match.to && !context.explicit)) return null;
|
||||
|
||||
const options: Completion[] = pagesRef.current.map((page) => ({
|
||||
label: page.title ?? page.name,
|
||||
detail: page.name,
|
||||
apply: (view, _completion, from, to) => {
|
||||
const title = page.title ?? page.name;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: `[${title}\`${page.name}]` },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
return { from: match.from, options, filter: true };
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user