smoodly 0.0.9 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -18
- package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
- package/dist/admin/editor/CanvasOverlay.js +39 -0
- package/dist/admin/editor/EditorView.js +56 -237
- package/dist/admin/editor/InsertPicker.d.ts +18 -0
- package/dist/admin/editor/InsertPicker.js +18 -0
- package/dist/admin/editor/NoticePane.d.ts +4 -0
- package/dist/admin/editor/NoticePane.js +4 -0
- package/dist/admin/editor/Outline.d.ts +18 -0
- package/dist/admin/editor/Outline.js +12 -0
- package/dist/admin/editor/locale-url.d.ts +12 -0
- package/dist/admin/editor/locale-url.js +32 -0
- package/dist/admin/editor/overlay-geometry.d.ts +14 -0
- package/dist/admin/editor/overlay-geometry.js +27 -0
- package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
- package/dist/admin/editor/useCanvasBridge.js +53 -0
- package/dist/admin/editor/usePageLocale.d.ts +23 -0
- package/dist/admin/editor/usePageLocale.js +88 -0
- package/dist/admin/forms/FieldWidget.js +4 -22
- package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
- package/dist/admin/forms/RichtextToolbar.js +37 -0
- package/dist/admin/forms/RichtextWidget.d.ts +5 -0
- package/dist/admin/forms/RichtextWidget.js +123 -0
- package/dist/admin/forms/richtext-extensions.d.ts +5 -0
- package/dist/admin/forms/richtext-extensions.js +74 -0
- package/dist/admin/forms/richtext-paste.d.ts +22 -0
- package/dist/admin/forms/richtext-paste.js +59 -0
- package/dist/admin/index.d.ts +0 -1
- package/dist/admin/index.js +0 -1
- package/dist/admin/next/create-admin.d.ts +3 -7
- package/dist/admin/next/create-admin.js +14 -5
- package/dist/admin/richtext-doc.d.ts +7 -0
- package/dist/admin/richtext-doc.js +18 -0
- package/dist/admin/shell/AdminApp.d.ts +7 -2
- package/dist/admin/shell/AdminApp.js +12 -6
- package/dist/admin/shell/EntriesList.js +3 -2
- package/dist/admin/shell/EntryForm.js +8 -24
- package/dist/admin/shell/ListView.js +3 -1
- package/dist/admin/shell/PagesList.js +3 -2
- package/dist/admin/shell/RouteLink.d.ts +4 -0
- package/dist/admin/shell/RouteLink.js +12 -0
- package/dist/admin/shell/SharedForm.js +3 -21
- package/dist/admin/shell/router.d.ts +37 -0
- package/dist/admin/shell/router.js +84 -0
- package/dist/admin/shell/session.js +3 -1
- package/dist/admin/ui/primitives.d.ts +2 -1
- package/dist/admin/ui/primitives.js +5 -3
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +27 -1
- package/dist/admin/validate.js +10 -0
- package/dist/collections.d.ts +0 -14
- package/dist/collections.js +0 -10
- package/dist/fields.d.ts +2 -1
- package/dist/fields.js +2 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/toolset.d.ts +30 -0
- package/dist/toolset.js +49 -0
- package/package.json +11 -1
- package/dist/admin/richtext.d.ts +0 -16
- package/dist/admin/richtext.js +0 -98
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type RefObject } from "react";
|
|
2
|
+
import type { BridgeRect, ToBridge, ZoneRect } from "./protocol.ts";
|
|
3
|
+
export type Geometry = "waiting" | "ok" | "missing";
|
|
4
|
+
export declare function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover }: {
|
|
5
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
6
|
+
iframeSrc: string | null;
|
|
7
|
+
onSelect(id: string | null): void;
|
|
8
|
+
onHover(id: string | null): void;
|
|
9
|
+
}): {
|
|
10
|
+
rects: BridgeRect[];
|
|
11
|
+
emptyZones: ZoneRect[];
|
|
12
|
+
docH: number;
|
|
13
|
+
geometry: Geometry;
|
|
14
|
+
postToCanvas: (m: ToBridge) => void;
|
|
15
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The admin side of the editor⇄canvas protocol: the geometry the bridge
|
|
2
|
+
// reports (node rects, empty zones, document height), select/hover
|
|
3
|
+
// forwarded from the iframe, and `postToCanvas` for refresh/scroll.
|
|
4
|
+
// Geometry silence is survivable — after GEOMETRY_TIMEOUT_MS the overlay
|
|
5
|
+
// goes dark, the iframe and the outline keep working.
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
7
|
+
const GEOMETRY_TIMEOUT_MS = 4000;
|
|
8
|
+
export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover }) {
|
|
9
|
+
const [rects, setRects] = useState([]);
|
|
10
|
+
const [emptyZones, setEmptyZones] = useState([]);
|
|
11
|
+
const [docH, setDocH] = useState(900);
|
|
12
|
+
const [geometry, setGeometry] = useState("waiting");
|
|
13
|
+
// The listener registers once; the handlers are read through refs.
|
|
14
|
+
const handlers = useRef({ onSelect, onHover });
|
|
15
|
+
handlers.current = { onSelect, onHover };
|
|
16
|
+
// A new canvas URL (a locale switch, a rename) means new geometry — the
|
|
17
|
+
// previous page's rects are stale the instant the iframe starts loading
|
|
18
|
+
// a different route, so clear them alongside resetting geometry.
|
|
19
|
+
useEffect(() => { setGeometry("waiting"); setRects([]); setEmptyZones([]); }, [iframeSrc]);
|
|
20
|
+
const postToCanvas = useCallback((m) => {
|
|
21
|
+
iframeRef.current?.contentWindow?.postMessage(m, "*");
|
|
22
|
+
}, [iframeRef]);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const onMessage = (e) => {
|
|
25
|
+
const m = e.data;
|
|
26
|
+
if (m?.source !== "smoodly")
|
|
27
|
+
return;
|
|
28
|
+
if (e.source !== iframeRef.current?.contentWindow)
|
|
29
|
+
return;
|
|
30
|
+
if (m.kind === "rects") {
|
|
31
|
+
setRects(m.nodes);
|
|
32
|
+
setEmptyZones(m.emptyZones);
|
|
33
|
+
setDocH(m.docH);
|
|
34
|
+
setGeometry("ok");
|
|
35
|
+
}
|
|
36
|
+
else if (m.kind === "select") {
|
|
37
|
+
handlers.current.onSelect(m.id);
|
|
38
|
+
}
|
|
39
|
+
else if (m.kind === "hover") {
|
|
40
|
+
handlers.current.onHover(m.id);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
window.addEventListener("message", onMessage);
|
|
44
|
+
return () => window.removeEventListener("message", onMessage);
|
|
45
|
+
}, [iframeRef]);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!iframeSrc || geometry !== "waiting")
|
|
48
|
+
return;
|
|
49
|
+
const t = setTimeout(() => setGeometry((g) => (g === "waiting" ? "missing" : g)), GEOMETRY_TIMEOUT_MS);
|
|
50
|
+
return () => clearTimeout(t);
|
|
51
|
+
}, [iframeSrc, geometry]);
|
|
52
|
+
return { rects, emptyZones, docH, geometry, postToCanvas };
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PageRecord } from "../../store.ts";
|
|
2
|
+
import type { AdminOps } from "../ops.ts";
|
|
3
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
4
|
+
export declare function usePageLocale({ registry, ops, id, record, setRecord, flush, fail, clearBanner, reload }: {
|
|
5
|
+
registry: AdminRegistry;
|
|
6
|
+
ops: AdminOps;
|
|
7
|
+
id: string;
|
|
8
|
+
record: PageRecord | null;
|
|
9
|
+
setRecord(record: PageRecord): void;
|
|
10
|
+
/** Flush the pending autosave; false when it could not land. */
|
|
11
|
+
flush(): Promise<boolean>;
|
|
12
|
+
/** Show an error banner. */
|
|
13
|
+
fail(text: string): void;
|
|
14
|
+
clearBanner(): void;
|
|
15
|
+
/** Reload the editor for the current locale. */
|
|
16
|
+
reload(): void;
|
|
17
|
+
}): {
|
|
18
|
+
locale: string;
|
|
19
|
+
adding: boolean;
|
|
20
|
+
switchLocale: (next: string) => Promise<void>;
|
|
21
|
+
doAddLocale: (target: string) => Promise<void>;
|
|
22
|
+
doRemoveLocale: () => Promise<void>;
|
|
23
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The editor's locale lifecycle: which locale is being edited (from the
|
|
2
|
+
// URL), switching, adding this page to a locale, removing it from one.
|
|
3
|
+
// Every transition flushes the pending autosave first — a save landing
|
|
4
|
+
// after the switch would persist THIS tree under the new locale.
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { sourceLocaleOf } from "../shell/pages-tree.js";
|
|
7
|
+
import { localeFromUrl, writeLocaleToUrl } from "./locale-url.js";
|
|
8
|
+
export function usePageLocale({ registry, ops, id, record, setRecord, flush, fail, clearBanner, reload }) {
|
|
9
|
+
const [locale, setLocale] = useState(() => localeFromUrl(registry));
|
|
10
|
+
const [adding, setAdding] = useState(false);
|
|
11
|
+
const switchLocale = async (next) => {
|
|
12
|
+
if (next === locale)
|
|
13
|
+
return;
|
|
14
|
+
const flushed = await flush();
|
|
15
|
+
if (!flushed) {
|
|
16
|
+
fail("Couldn't save your latest changes — switch cancelled.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
writeLocaleToUrl(next);
|
|
20
|
+
clearBanner();
|
|
21
|
+
setLocale(next);
|
|
22
|
+
};
|
|
23
|
+
/** Add a locale, copying the tree on screen (or the record's source locale when this one is absent). */
|
|
24
|
+
const doAddLocale = async (target) => {
|
|
25
|
+
if (!record || adding)
|
|
26
|
+
return;
|
|
27
|
+
const from = record.locales[locale] ? locale : sourceLocaleOf(record, registry);
|
|
28
|
+
if (from === locale) {
|
|
29
|
+
const flushed = await flush();
|
|
30
|
+
if (!flushed) {
|
|
31
|
+
fail("Couldn't save your latest changes — add cancelled.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
setAdding(true);
|
|
36
|
+
let result;
|
|
37
|
+
try {
|
|
38
|
+
result = await ops.pages.addLocale(id, target, { from });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
42
|
+
}
|
|
43
|
+
setAdding(false);
|
|
44
|
+
if (!result.ok) {
|
|
45
|
+
fail(result.message);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// The record now HAS the locale; keep it, or the pane below would go
|
|
49
|
+
// on offering "doesn't exist yet" until the reload lands.
|
|
50
|
+
setRecord(result.data);
|
|
51
|
+
if (target === locale)
|
|
52
|
+
reload();
|
|
53
|
+
else
|
|
54
|
+
void switchLocale(target);
|
|
55
|
+
};
|
|
56
|
+
const doRemoveLocale = async () => {
|
|
57
|
+
if (!record)
|
|
58
|
+
return;
|
|
59
|
+
const remaining = Object.keys(record.locales).filter((l) => l !== locale);
|
|
60
|
+
if (remaining.length === 0)
|
|
61
|
+
return;
|
|
62
|
+
if (!window.confirm(`Remove this page from ${locale}? Its ${locale} content and history are deleted.`))
|
|
63
|
+
return;
|
|
64
|
+
// Let a pending save land before the row goes, so no save races the
|
|
65
|
+
// delete — and if it cannot land, stop: a retry mid-flight would
|
|
66
|
+
// recreate the very draft the removal is deleting.
|
|
67
|
+
const flushed = await flush();
|
|
68
|
+
if (!flushed) {
|
|
69
|
+
fail("Couldn't save your latest changes — removal cancelled.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let result;
|
|
73
|
+
try {
|
|
74
|
+
result = await ops.pages.removeLocale(id, locale);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
78
|
+
}
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
fail(result.message);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const next = remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0];
|
|
84
|
+
writeLocaleToUrl(next);
|
|
85
|
+
setLocale(next);
|
|
86
|
+
};
|
|
87
|
+
return { locale, adding, switchLocale, doAddLocale, doRemoveLocale };
|
|
88
|
+
}
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
// Objects and lists recurse through this same component; the list's
|
|
7
7
|
// logic lives in ./list.ts as pure functions.
|
|
8
8
|
import { useState } from "react";
|
|
9
|
-
import {
|
|
9
|
+
import { RichtextWidget } from "./RichtextWidget.js";
|
|
10
10
|
import { Button, Field, IconButton } from "../ui/primitives.js";
|
|
11
11
|
import { addItem, itemSummary, moveIndex, moveItem, patchObject, removeIndex, removeItem } from "./list.js";
|
|
12
12
|
const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
|
|
@@ -41,7 +41,9 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
|
|
|
41
41
|
break;
|
|
42
42
|
}
|
|
43
43
|
case "richtext":
|
|
44
|
-
|
|
44
|
+
// The toolbar holds buttons, so this control can never sit in a <label>.
|
|
45
|
+
asDiv = true;
|
|
46
|
+
control = _jsx(RichtextWidget, { value: value, toolset: d.toolset, onChange: onChange });
|
|
45
47
|
break;
|
|
46
48
|
case "image":
|
|
47
49
|
case "video":
|
|
@@ -110,23 +112,3 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
|
|
|
110
112
|
function ListRow({ index, count, summary, open, onToggle, onMove, onRemove, children }) {
|
|
111
113
|
return (_jsxs("div", { className: "sm-listrow", children: [_jsxs("div", { className: "sm-listrow__head", children: [_jsxs("button", { type: "button", className: "sm-listrow__toggle", "aria-expanded": open, onClick: onToggle, children: [_jsx("span", { "aria-hidden": "true", children: open ? "▾" : "▸" }), _jsx("span", { className: "sm-listrow__summary", children: summary })] }), _jsx(IconButton, { label: "Move up", disabled: index === 0, onClick: () => onMove(index - 1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", disabled: index === count - 1, onClick: () => onMove(index + 1), children: "\u2193" }), _jsx(IconButton, { label: "Remove", onClick: onRemove, children: "\u00D7" })] }), open && _jsx("div", { className: "sm-listrow__body", children: children })] }));
|
|
112
114
|
}
|
|
113
|
-
/** The interim richtext widget owns its text: the line syntax drops a trailing
|
|
114
|
-
* newline on the round trip, so deriving the value from the document each
|
|
115
|
-
* render would snap the caret back after Enter at the end of the text. Re-seeds
|
|
116
|
-
* when the document changes underneath it (load, locale switch). The parent must
|
|
117
|
-
* store the emitted document by reference — a clone or a server copy handed back
|
|
118
|
-
* would re-seed the text and bring the caret snap back. */
|
|
119
|
-
function RichtextArea({ value, onChange }) {
|
|
120
|
-
const [text, setText] = useState(() => richtextToPlain(value));
|
|
121
|
-
const [seen, setSeen] = useState(value);
|
|
122
|
-
if (value !== seen) {
|
|
123
|
-
setSeen(value);
|
|
124
|
-
setText(richtextToPlain(value));
|
|
125
|
-
}
|
|
126
|
-
return (_jsx("textarea", { className: "sm-textarea", rows: 5, value: text, onChange: (e) => {
|
|
127
|
-
const doc = plainToRichtext(e.target.value);
|
|
128
|
-
setText(e.target.value);
|
|
129
|
-
setSeen(doc);
|
|
130
|
-
onChange(doc);
|
|
131
|
-
} }));
|
|
132
|
-
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Allowed } from "../../toolset.ts";
|
|
2
|
+
export type ToolbarCommand = "bold" | "italic" | "link" | "bulletList" | "orderedList" | "blockquote" | {
|
|
3
|
+
heading: number;
|
|
4
|
+
};
|
|
5
|
+
export type ActiveState = {
|
|
6
|
+
bold: boolean;
|
|
7
|
+
italic: boolean;
|
|
8
|
+
link: boolean;
|
|
9
|
+
bulletList: boolean;
|
|
10
|
+
orderedList: boolean;
|
|
11
|
+
blockquote: boolean;
|
|
12
|
+
/** The active heading level, or null in a paragraph. */
|
|
13
|
+
heading: number | null;
|
|
14
|
+
};
|
|
15
|
+
export declare const INACTIVE: ActiveState;
|
|
16
|
+
export declare function RichtextToolbar({ allowed, active, onCommand }: {
|
|
17
|
+
allowed: Allowed;
|
|
18
|
+
active: ActiveState;
|
|
19
|
+
onCommand(command: ToolbarCommand): void;
|
|
20
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Bold, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Italic, Link, List, ListOrdered, Quote } from "lucide-react";
|
|
4
|
+
import { IconButton } from "../ui/primitives.js";
|
|
5
|
+
export const INACTIVE = {
|
|
6
|
+
bold: false, italic: false, link: false, bulletList: false, orderedList: false, blockquote: false, heading: null,
|
|
7
|
+
};
|
|
8
|
+
const HEADING_ICONS = { 1: Heading1, 2: Heading2, 3: Heading3, 4: Heading4, 5: Heading5, 6: Heading6 };
|
|
9
|
+
const ICON = 14;
|
|
10
|
+
/** Keep the editor's selection: a toolbar press must not blur the content. */
|
|
11
|
+
const keepFocus = (e) => e.preventDefault();
|
|
12
|
+
export function RichtextToolbar({ allowed, active, onCommand }) {
|
|
13
|
+
const button = (label, pressed, command, Icon) => (_jsx(IconButton, { label: label, "aria-pressed": pressed, onMouseDown: keepFocus, onClick: () => onCommand(command), children: _jsx(Icon, { size: ICON, "aria-hidden": "true" }) }, label));
|
|
14
|
+
const groups = [];
|
|
15
|
+
const marks = [
|
|
16
|
+
allowed.marks.has("bold") && button("Bold", active.bold, "bold", Bold),
|
|
17
|
+
allowed.marks.has("italic") && button("Italic", active.italic, "italic", Italic),
|
|
18
|
+
allowed.marks.has("link") && button("Link", active.link, "link", Link),
|
|
19
|
+
].filter(Boolean);
|
|
20
|
+
if (marks.length > 0)
|
|
21
|
+
groups.push(marks);
|
|
22
|
+
const headings = allowed.headingLevels
|
|
23
|
+
.filter((l) => l >= 1 && l <= 6)
|
|
24
|
+
.map((l) => button(`Heading ${l}`, active.heading === l, { heading: l }, HEADING_ICONS[l]));
|
|
25
|
+
if (headings.length > 0)
|
|
26
|
+
groups.push(headings);
|
|
27
|
+
const blocks = [
|
|
28
|
+
allowed.nodes.has("bulletList") && button("Bullet list", active.bulletList, "bulletList", List),
|
|
29
|
+
allowed.nodes.has("orderedList") && button("Numbered list", active.orderedList, "orderedList", ListOrdered),
|
|
30
|
+
allowed.nodes.has("blockquote") && button("Quote", active.blockquote, "blockquote", Quote),
|
|
31
|
+
].filter(Boolean);
|
|
32
|
+
if (blocks.length > 0)
|
|
33
|
+
groups.push(blocks);
|
|
34
|
+
if (groups.length === 0)
|
|
35
|
+
return null;
|
|
36
|
+
return (_jsx("div", { className: "sm-richtext__bar", role: "toolbar", "aria-label": "Formatting", children: groups.map((group, i) => (_jsxs("span", { style: { display: "contents" }, children: [i > 0 && _jsx("span", { className: "sm-richtext__sep", "aria-hidden": "true" }), group] }, i))) }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The richtext field widget (spec 2026-09-08 §3): a TipTap editor under a
|
|
4
|
+
// fixed toolbar. The editor owns its document and emits TipTap JSON on
|
|
5
|
+
// every change; the parent stores the emitted document by reference, so
|
|
6
|
+
// a value that is not the last emitted document is a change from outside
|
|
7
|
+
// (load, locale switch, a list row moving) and re-seeds the editor. The
|
|
8
|
+
// schema is always full; `allowed` gates creation (extensions, paste,
|
|
9
|
+
// toolbar). Only this file and richtext-extensions.ts import TipTap.
|
|
10
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
|
|
12
|
+
import { Slice } from "@tiptap/pm/model";
|
|
13
|
+
import { allowedOf } from "../../toolset.js";
|
|
14
|
+
import { plainDoc } from "../richtext-doc.js";
|
|
15
|
+
import { Button } from "../ui/primitives.js";
|
|
16
|
+
import { richtextExtensions } from "./richtext-extensions.js";
|
|
17
|
+
import { gatePastedSlice } from "./richtext-paste.js";
|
|
18
|
+
import { INACTIVE, RichtextToolbar } from "./RichtextToolbar.js";
|
|
19
|
+
const EMPTY = { type: "doc", content: [{ type: "paragraph" }] };
|
|
20
|
+
const LEVELS = [1, 2, 3, 4, 5, 6];
|
|
21
|
+
const isDoc = (v) => !!v && typeof v === "object" && v.type === "doc";
|
|
22
|
+
export function RichtextWidget({ value, toolset, onChange }) {
|
|
23
|
+
const allowed = useMemo(() => allowedOf(toolset), [toolset]);
|
|
24
|
+
// The parent's onChange closes over its render; the editor's onUpdate is
|
|
25
|
+
// created once, so read the latest through a ref.
|
|
26
|
+
const onChangeRef = useRef(onChange);
|
|
27
|
+
onChangeRef.current = onChange;
|
|
28
|
+
// The last document this widget emitted — anything else arriving as
|
|
29
|
+
// `value` came from outside and re-seeds the editor.
|
|
30
|
+
const emitted = useRef(value);
|
|
31
|
+
const [link, setLink] = useState({ open: false, href: "" });
|
|
32
|
+
const openLinkRef = useRef(() => { });
|
|
33
|
+
const editor = useEditor({
|
|
34
|
+
immediatelyRender: false,
|
|
35
|
+
extensions: richtextExtensions(allowed, { onLink: () => openLinkRef.current() }),
|
|
36
|
+
content: isDoc(value) ? value : EMPTY,
|
|
37
|
+
editorProps: {
|
|
38
|
+
attributes: { class: "sm-richtext__content" },
|
|
39
|
+
transformPasted: (slice, view) => Slice.fromJSON(view.state.schema, gatePastedSlice(slice.toJSON(), allowed)),
|
|
40
|
+
},
|
|
41
|
+
onUpdate: ({ editor }) => {
|
|
42
|
+
const doc = plainDoc(editor.getJSON());
|
|
43
|
+
emitted.current = doc;
|
|
44
|
+
onChangeRef.current(doc);
|
|
45
|
+
},
|
|
46
|
+
}, [allowed]);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!editor || value === emitted.current)
|
|
49
|
+
return;
|
|
50
|
+
emitted.current = value;
|
|
51
|
+
editor.commands.setContent(isDoc(value) ? value : EMPTY, { emitUpdate: false });
|
|
52
|
+
}, [editor, value]);
|
|
53
|
+
const active = useEditorState({
|
|
54
|
+
editor,
|
|
55
|
+
selector: ({ editor }) => editor
|
|
56
|
+
? {
|
|
57
|
+
bold: editor.isActive("bold"),
|
|
58
|
+
italic: editor.isActive("italic"),
|
|
59
|
+
link: editor.isActive("link"),
|
|
60
|
+
bulletList: editor.isActive("bulletList"),
|
|
61
|
+
orderedList: editor.isActive("orderedList"),
|
|
62
|
+
blockquote: editor.isActive("blockquote"),
|
|
63
|
+
heading: LEVELS.find((level) => editor.isActive("heading", { level })) ?? null,
|
|
64
|
+
}
|
|
65
|
+
: INACTIVE,
|
|
66
|
+
});
|
|
67
|
+
// Read the caret's link from the editor at open time, not from the last
|
|
68
|
+
// render: a selection change and the press can land in the same tick.
|
|
69
|
+
const openLink = () => setLink({ open: true, href: editor?.getAttributes("link").href ?? "" });
|
|
70
|
+
openLinkRef.current = openLink;
|
|
71
|
+
const closeLink = () => {
|
|
72
|
+
setLink({ open: false, href: "" });
|
|
73
|
+
editor?.commands.focus();
|
|
74
|
+
};
|
|
75
|
+
const applyLink = () => {
|
|
76
|
+
if (!editor)
|
|
77
|
+
return;
|
|
78
|
+
const href = link.href.trim();
|
|
79
|
+
const chain = editor.chain().focus().extendMarkRange("link");
|
|
80
|
+
if (href === "")
|
|
81
|
+
chain.unsetLink().run();
|
|
82
|
+
else
|
|
83
|
+
chain.setLink({ href }).run();
|
|
84
|
+
setLink({ open: false, href: "" });
|
|
85
|
+
};
|
|
86
|
+
const removeLink = () => {
|
|
87
|
+
editor?.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
88
|
+
setLink({ open: false, href: "" });
|
|
89
|
+
};
|
|
90
|
+
const run = (command) => {
|
|
91
|
+
if (!editor)
|
|
92
|
+
return;
|
|
93
|
+
if (command === "link") {
|
|
94
|
+
openLink();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const chain = editor.chain().focus();
|
|
98
|
+
if (typeof command === "object")
|
|
99
|
+
chain.toggleHeading({ level: command.heading }).run();
|
|
100
|
+
else if (command === "bold")
|
|
101
|
+
chain.toggleBold().run();
|
|
102
|
+
else if (command === "italic")
|
|
103
|
+
chain.toggleItalic().run();
|
|
104
|
+
else if (command === "bulletList")
|
|
105
|
+
chain.toggleBulletList().run();
|
|
106
|
+
else if (command === "orderedList")
|
|
107
|
+
chain.toggleOrderedList().run();
|
|
108
|
+
else if (command === "blockquote")
|
|
109
|
+
chain.toggleBlockquote().run();
|
|
110
|
+
};
|
|
111
|
+
return (_jsxs("div", { className: "sm-richtext", children: [_jsx(RichtextToolbar, { allowed: allowed, active: active ?? INACTIVE, onCommand: run }), link.open && (_jsxs("div", { className: "sm-richtext__link", children: [_jsx("input", { className: "sm-input", placeholder: "https://\u2026 or /path", value: link.href, autoFocus: true, "aria-label": "Link URL", onChange: (e) => setLink({ open: true, href: e.target.value }), onKeyDown: (e) => {
|
|
112
|
+
// Escape must not reach the editor's window handler, which clears the block selection.
|
|
113
|
+
if (e.key === "Enter") {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
applyLink();
|
|
116
|
+
}
|
|
117
|
+
if (e.key === "Escape") {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
closeLink();
|
|
121
|
+
}
|
|
122
|
+
} }), _jsx(Button, { onClick: applyLink, children: "Apply" }), _jsx(Button, { variant: "secondary", onClick: removeLink, children: "Remove" })] })), _jsx(EditorContent, { editor: editor })] }));
|
|
123
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The editor's extension set (spec 2026-09-08 §2): always the FULL schema
|
|
2
|
+
// of what `RichText` draws, so content a toolset forbids survives the
|
|
3
|
+
// round trip; the toolset gates only creation — input rules, paste rules
|
|
4
|
+
// and keyboard shortcuts here, the toolbar in RichtextToolbar, paste in
|
|
5
|
+
// richtext-paste. StarterKit supplies document, paragraph, text, hard
|
|
6
|
+
// break, list item, list keymap and undo/redo; every gated node or mark
|
|
7
|
+
// is switched off there and added back extended.
|
|
8
|
+
import { Extension, textblockTypeInputRule } from "@tiptap/core";
|
|
9
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
10
|
+
import Heading from "@tiptap/extension-heading";
|
|
11
|
+
import Bold from "@tiptap/extension-bold";
|
|
12
|
+
import Italic from "@tiptap/extension-italic";
|
|
13
|
+
import Link from "@tiptap/extension-link";
|
|
14
|
+
import Blockquote from "@tiptap/extension-blockquote";
|
|
15
|
+
import { BulletList, OrderedList } from "@tiptap/extension-list";
|
|
16
|
+
/** Keep the mark in the schema; add no way to create it when `on` is false. */
|
|
17
|
+
function gateMarkConfig(on) {
|
|
18
|
+
return {
|
|
19
|
+
addInputRules() { return on ? this.parent?.() ?? [] : []; },
|
|
20
|
+
addPasteRules() { return on ? this.parent?.() ?? [] : []; },
|
|
21
|
+
addKeyboardShortcuts() { return on ? this.parent?.() ?? {} : {}; },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function gateNodeConfig(on) {
|
|
25
|
+
return {
|
|
26
|
+
addInputRules() { return on ? this.parent?.() ?? [] : []; },
|
|
27
|
+
addPasteRules() { return on ? this.parent?.() ?? [] : []; },
|
|
28
|
+
addKeyboardShortcuts() { return on ? this.parent?.() ?? {} : {}; },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const ALL_LEVELS = [1, 2, 3, 4, 5, 6];
|
|
32
|
+
export function richtextExtensions(allowed, hooks) {
|
|
33
|
+
const link = allowed.marks.has("link");
|
|
34
|
+
const levels = allowed.headingLevels;
|
|
35
|
+
return [
|
|
36
|
+
StarterKit.configure({
|
|
37
|
+
heading: false, bold: false, italic: false, link: false, blockquote: false,
|
|
38
|
+
bulletList: false, orderedList: false,
|
|
39
|
+
code: false, codeBlock: false, strike: false, underline: false, horizontalRule: false,
|
|
40
|
+
dropcursor: false, gapcursor: false, trailingNode: false,
|
|
41
|
+
}),
|
|
42
|
+
Heading.extend({
|
|
43
|
+
// Exact `#` counts, and only the allowed levels; `# ` does nothing when h1 is forbidden.
|
|
44
|
+
addInputRules() {
|
|
45
|
+
return levels.map((level) => textblockTypeInputRule({ find: new RegExp(`^(#{${level}})\\s$`), type: this.type, getAttributes: { level } }));
|
|
46
|
+
},
|
|
47
|
+
addKeyboardShortcuts() {
|
|
48
|
+
return Object.fromEntries(levels.map((level) => [`Mod-Alt-${level}`, () => this.editor.commands.toggleHeading({ level })]));
|
|
49
|
+
},
|
|
50
|
+
}).configure({ levels: ALL_LEVELS }),
|
|
51
|
+
Bold.extend(gateMarkConfig(allowed.marks.has("bold"))),
|
|
52
|
+
Italic.extend(gateMarkConfig(allowed.marks.has("italic"))),
|
|
53
|
+
Link.extend({
|
|
54
|
+
...gateMarkConfig(link),
|
|
55
|
+
// href only (spec §3): no target, rel or class in the stored JSON.
|
|
56
|
+
addAttributes() { return { href: { default: null } }; },
|
|
57
|
+
}).configure({
|
|
58
|
+
openOnClick: false,
|
|
59
|
+
autolink: link,
|
|
60
|
+
linkOnPaste: link,
|
|
61
|
+
// Site-relative targets are links too; the site renderer's scheme filter is the safety net.
|
|
62
|
+
isAllowedUri: (url, ctx) => /^[/#?.]/.test(url) || ctx.defaultValidate(url),
|
|
63
|
+
}),
|
|
64
|
+
Blockquote.extend(gateNodeConfig(allowed.nodes.has("blockquote"))),
|
|
65
|
+
BulletList.extend(gateNodeConfig(allowed.nodes.has("bulletList"))),
|
|
66
|
+
OrderedList.extend(gateNodeConfig(allowed.nodes.has("orderedList"))),
|
|
67
|
+
Extension.create({
|
|
68
|
+
name: "smoodlyLinkShortcut",
|
|
69
|
+
addKeyboardShortcuts() {
|
|
70
|
+
return link ? { "Mod-k": () => { hooks.onLink(); return true; } } : {};
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Allowed } from "../../toolset.ts";
|
|
2
|
+
export type PmJSON = {
|
|
3
|
+
type: string;
|
|
4
|
+
attrs?: Record<string, unknown>;
|
|
5
|
+
content?: PmJSON[];
|
|
6
|
+
text?: string;
|
|
7
|
+
marks?: {
|
|
8
|
+
type: string;
|
|
9
|
+
attrs?: Record<string, unknown>;
|
|
10
|
+
}[];
|
|
11
|
+
};
|
|
12
|
+
/** ProseMirror's Slice.toJSON() shape. */
|
|
13
|
+
export type SliceJSON = {
|
|
14
|
+
content?: PmJSON[] | null;
|
|
15
|
+
openStart?: number;
|
|
16
|
+
openEnd?: number;
|
|
17
|
+
};
|
|
18
|
+
/** The allowed level closest to `level`; the smaller number on a tie;
|
|
19
|
+
* null when no heading is allowed. */
|
|
20
|
+
export declare function clampLevel(level: number, levels: number[]): number | null;
|
|
21
|
+
export declare function gateNodes(nodes: PmJSON[], allowed: Allowed): PmJSON[];
|
|
22
|
+
export declare function gatePastedSlice(slice: SliceJSON, allowed: Allowed): SliceJSON;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** The allowed level closest to `level`; the smaller number on a tie;
|
|
2
|
+
* null when no heading is allowed. */
|
|
3
|
+
export function clampLevel(level, levels) {
|
|
4
|
+
if (levels.length === 0)
|
|
5
|
+
return null;
|
|
6
|
+
let best = levels[0];
|
|
7
|
+
for (const l of levels) {
|
|
8
|
+
const d = Math.abs(l - level);
|
|
9
|
+
const bd = Math.abs(best - level);
|
|
10
|
+
if (d < bd || (d === bd && l < best))
|
|
11
|
+
best = l;
|
|
12
|
+
}
|
|
13
|
+
return best;
|
|
14
|
+
}
|
|
15
|
+
export function gateNodes(nodes, allowed) {
|
|
16
|
+
return nodes.flatMap((n) => gateNode(n, allowed));
|
|
17
|
+
}
|
|
18
|
+
function gateNode(n, allowed) {
|
|
19
|
+
if (n.type === "text") {
|
|
20
|
+
const marks = (n.marks ?? []).filter((m) => allowed.marks.has(m.type));
|
|
21
|
+
const { marks: _drop, ...rest } = n;
|
|
22
|
+
return [marks.length > 0 ? { ...rest, marks } : rest];
|
|
23
|
+
}
|
|
24
|
+
const content = n.content ? gateNodes(n.content, allowed) : undefined;
|
|
25
|
+
const withContent = (node) => (content ? { ...node, content } : node);
|
|
26
|
+
if (n.type === "heading") {
|
|
27
|
+
const level = clampLevel(Number(n.attrs?.level ?? 1), allowed.headingLevels);
|
|
28
|
+
if (level === null)
|
|
29
|
+
return [withContent({ type: "paragraph" })];
|
|
30
|
+
return [withContent({ ...n, attrs: { ...n.attrs, level } })];
|
|
31
|
+
}
|
|
32
|
+
if (n.type === "blockquote" && !allowed.nodes.has("blockquote"))
|
|
33
|
+
return content ?? [];
|
|
34
|
+
if ((n.type === "bulletList" && !allowed.nodes.has("bulletList")) || (n.type === "orderedList" && !allowed.nodes.has("orderedList"))) {
|
|
35
|
+
return (content ?? []).flatMap((item) => item.content ?? []);
|
|
36
|
+
}
|
|
37
|
+
return [withContent(n)];
|
|
38
|
+
}
|
|
39
|
+
/** Depth of the first (or last) child chain, counting every non-text node. */
|
|
40
|
+
function edgeDepth(nodes, side) {
|
|
41
|
+
let depth = 0;
|
|
42
|
+
let list = nodes;
|
|
43
|
+
while (list.length > 0) {
|
|
44
|
+
const n = side === "first" ? list[0] : list[list.length - 1];
|
|
45
|
+
if (n.type === "text")
|
|
46
|
+
break;
|
|
47
|
+
depth++;
|
|
48
|
+
list = n.content ?? [];
|
|
49
|
+
}
|
|
50
|
+
return depth;
|
|
51
|
+
}
|
|
52
|
+
export function gatePastedSlice(slice, allowed) {
|
|
53
|
+
const content = gateNodes(slice.content ?? [], allowed);
|
|
54
|
+
return {
|
|
55
|
+
content,
|
|
56
|
+
openStart: Math.min(slice.openStart ?? 0, edgeDepth(content, "first")),
|
|
57
|
+
openEnd: Math.min(slice.openEnd ?? 0, edgeDepth(content, "last")),
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -9,4 +9,3 @@ export { serializeAdminConfig } from "./serialize.ts";
|
|
|
9
9
|
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminShared, AdminField } from "./serialize.ts";
|
|
10
10
|
export * from "./tree-ops.ts";
|
|
11
11
|
export { validateFields, type FieldError } from "./validate.ts";
|
|
12
|
-
export { plainToRichtext, richtextToPlain } from "./richtext.ts";
|
package/dist/admin/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
import type { ResolvedConfig } from "../../config.ts";
|
|
3
3
|
import type { AdminOpAction } from "../ops.ts";
|
|
4
4
|
import type { AuthConfig } from "../shell/session.ts";
|
|
@@ -7,12 +7,8 @@ export declare function createSmoodlyAdmin(input: {
|
|
|
7
7
|
op: AdminOpAction;
|
|
8
8
|
auth: () => AuthConfig;
|
|
9
9
|
}): {
|
|
10
|
-
AdminPage: (
|
|
11
|
-
params: Promise<{
|
|
12
|
-
segments?: string[];
|
|
13
|
-
}>;
|
|
14
|
-
}) => Promise<ReactElement>;
|
|
10
|
+
AdminPage: () => null;
|
|
15
11
|
AdminLayout: ({ children }: {
|
|
16
12
|
children: ReactNode;
|
|
17
|
-
}) =>
|
|
13
|
+
}) => import("react").JSX.Element;
|
|
18
14
|
};
|
|
@@ -4,16 +4,25 @@ import { AdminApp } from "../shell/AdminApp.js";
|
|
|
4
4
|
import { AdminStyles } from "../ui/theme.js";
|
|
5
5
|
export function createSmoodlyAdmin(input) {
|
|
6
6
|
const registry = serializeAdminConfig(input.config);
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
/** The catch-all page only makes every /admin URL exist. The shell is
|
|
8
|
+
* mounted by the layout, not here: a server action that revalidates
|
|
9
|
+
* (publish, delete, a save of published content) answers with a
|
|
10
|
+
* re-rendered tree for the URL it was posted to, and Next remounts a
|
|
11
|
+
* page whose dynamic segment changed since the document load, while a
|
|
12
|
+
* layout persists (spec 2026-09-08 client routing, §1 amendment). */
|
|
13
|
+
function AdminPage() {
|
|
14
|
+
return null;
|
|
10
15
|
}
|
|
11
16
|
/** The admin owns the viewport: the whole UI is a fixed three-column
|
|
12
17
|
* shell that scrolls internally, never the host document. The design
|
|
13
18
|
* system's tokens ride along in one <style> — the host app imports
|
|
14
|
-
* nothing (see ui/theme.tsx).
|
|
19
|
+
* nothing (see ui/theme.tsx). The shell reads its route from the URL
|
|
20
|
+
* on mount (shell/router.ts); `children` is the page's nothing.
|
|
21
|
+
* suppressHydrationWarning: the theme boot script sets `data-theme`
|
|
22
|
+
* on this element before React hydrates, on purpose (no white flash
|
|
23
|
+
* for a dark-mode editor), so the attribute differs from the server's. */
|
|
15
24
|
function AdminLayout({ children }) {
|
|
16
|
-
return (_jsxs("div", { className: "sm-admin", children: [_jsx(AdminStyles, {}), children] }));
|
|
25
|
+
return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth() }), children] }));
|
|
17
26
|
}
|
|
18
27
|
return { AdminPage, AdminLayout };
|
|
19
28
|
}
|