smoodly 0.0.8 → 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 +228 -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.d.ts +7 -3
- package/dist/admin/forms/FieldWidget.js +68 -8
- 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/list.d.ts +18 -0
- package/dist/admin/forms/list.js +81 -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/serialize.d.ts +4 -0
- package/dist/admin/serialize.js +3 -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 +9 -25
- 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 +4 -22
- 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 +40 -1
- package/dist/admin/validate.d.ts +7 -2
- package/dist/admin/validate.js +53 -13
- package/dist/collections.d.ts +0 -14
- package/dist/collections.js +0 -10
- package/dist/entry-store.js +7 -1
- package/dist/fields.d.ts +18 -2
- package/dist/fields.js +51 -5
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/dist/refs.js +5 -0
- package/dist/resolve.js +3 -0
- package/dist/richtext-render.d.ts +3 -0
- package/dist/richtext-render.js +62 -0
- package/dist/shared-id.d.ts +3 -0
- package/dist/shared-id.js +36 -0
- package/dist/sql-space.d.ts +4 -0
- package/dist/sql-space.js +5 -1
- package/dist/sql.js +1 -1
- package/dist/supabase-entry-store.d.ts +8 -0
- package/dist/supabase-entry-store.js +37 -2
- 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 -14
- package/dist/admin/richtext.js +0 -17
|
@@ -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
|
+
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
2
|
import type { AdminField } from "../serialize.ts";
|
|
3
|
-
export declare function FieldWidget({ descriptor: d, value, onChange,
|
|
3
|
+
export declare function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, }: {
|
|
4
4
|
descriptor: AdminField;
|
|
5
5
|
value: unknown;
|
|
6
6
|
onChange(value: unknown): void;
|
|
7
|
-
|
|
7
|
+
errors?: Record<string, string>;
|
|
8
|
+
/** Dotted path of this widget in the form; defaults to `fieldKey`. */
|
|
9
|
+
path?: string;
|
|
10
|
+
/** Render the control alone, no label wrapper — list items. */
|
|
11
|
+
bare?: boolean;
|
|
8
12
|
refOptions?: {
|
|
9
13
|
id: string;
|
|
10
14
|
title: string;
|
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// One schema descriptor → one labelled control, in the design system's
|
|
4
|
+
// field language: uppercase 9px label, inset control with no border at
|
|
5
|
+
// rest, 1.5px accent border + soft ring on focus (all in ui/theme.tsx).
|
|
6
|
+
// Objects and lists recurse through this same component; the list's
|
|
7
|
+
// logic lives in ./list.ts as pure functions.
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { RichtextWidget } from "./RichtextWidget.js";
|
|
10
|
+
import { Button, Field, IconButton } from "../ui/primitives.js";
|
|
11
|
+
import { addItem, itemSummary, moveIndex, moveItem, patchObject, removeIndex, removeItem } from "./list.js";
|
|
5
12
|
const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
|
|
6
|
-
export function FieldWidget({ descriptor: d, value, onChange,
|
|
13
|
+
export function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, }) {
|
|
14
|
+
const path = pathProp ?? fieldKey;
|
|
15
|
+
const message = errors?.[path];
|
|
16
|
+
// Unused for non-list types, but hooks must be unconditional — a list's
|
|
17
|
+
// rows key their collapse state to this rather than to array position,
|
|
18
|
+
// so a move or remove doesn't reassign which row looks open.
|
|
19
|
+
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
7
20
|
const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
|
|
8
21
|
let control;
|
|
22
|
+
let counter;
|
|
23
|
+
let asDiv = d.type === "refList";
|
|
9
24
|
switch (d.type) {
|
|
10
25
|
case "text":
|
|
11
26
|
case "link":
|
|
@@ -26,7 +41,9 @@ export function FieldWidget({ descriptor: d, value, onChange, error, refOptions
|
|
|
26
41
|
break;
|
|
27
42
|
}
|
|
28
43
|
case "richtext":
|
|
29
|
-
|
|
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 });
|
|
30
47
|
break;
|
|
31
48
|
case "image":
|
|
32
49
|
case "video":
|
|
@@ -44,11 +61,54 @@ export function FieldWidget({ descriptor: d, value, onChange, error, refOptions
|
|
|
44
61
|
control = (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [ids.map((id, i) => (_jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [_jsx("span", { className: "sm-input", style: { display: "flex", alignItems: "center", flex: 1 }, children: byId.get(id) ? optionTitle(byId.get(id)) : id }), _jsx(IconButton, { label: "Remove", size: 28, onClick: () => onChange(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${id}-${i}`))), _jsxs("select", { className: "sm-select", value: "", onChange: (e) => e.target.value && onChange([...ids, e.target.value]), children: [_jsx("option", { value: "", children: "Add\u2026" }), refOptions.filter((o) => !ids.includes(o.id)).map((o) => (_jsx("option", { value: o.id, children: optionTitle(o) }, o.id)))] })] }));
|
|
45
62
|
break;
|
|
46
63
|
}
|
|
64
|
+
case "object": {
|
|
65
|
+
asDiv = true;
|
|
66
|
+
const obj = (value && typeof value === "object" && !Array.isArray(value) ? value : {});
|
|
67
|
+
control = (_jsx("div", { className: "sm-group", children: Object.entries(d.fields ?? {}).map(([k, sub]) => (_jsx(FieldWidget, { fieldKey: k, descriptor: sub, value: obj[k], errors: errors, path: `${path}.${k}`, onChange: (v) => onChange(patchObject(obj, k, v, d.optional === true)) }, k))) }));
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "list": {
|
|
71
|
+
asDiv = true;
|
|
72
|
+
const items = Array.isArray(value) ? value : [];
|
|
73
|
+
const item = d.item ?? { type: "text" };
|
|
74
|
+
const atMax = typeof d.max === "number" && items.length >= d.max;
|
|
75
|
+
if (typeof d.max === "number")
|
|
76
|
+
counter = `${items.length} of ${d.max}`;
|
|
77
|
+
control = (_jsxs("div", { className: "sm-list", children: [items.map((it, i) => (_jsx(ListRow, { index: i, count: items.length, summary: itemSummary(it, i), open: !collapsed.has(i), onToggle: () => setCollapsed((prev) => {
|
|
78
|
+
const next = new Set(prev);
|
|
79
|
+
if (next.has(i))
|
|
80
|
+
next.delete(i);
|
|
81
|
+
else
|
|
82
|
+
next.add(i);
|
|
83
|
+
return next;
|
|
84
|
+
}), onMove: (to) => {
|
|
85
|
+
const moved = moveItem(items, i, to);
|
|
86
|
+
if (moved !== items) {
|
|
87
|
+
onChange(moved);
|
|
88
|
+
setCollapsed((prev) => moveIndex(prev, i, to));
|
|
89
|
+
}
|
|
90
|
+
}, onRemove: () => {
|
|
91
|
+
onChange(removeItem(items, i));
|
|
92
|
+
setCollapsed((prev) => removeIndex(prev, i));
|
|
93
|
+
}, children: _jsx(FieldWidget, { fieldKey: String(i), descriptor: item, value: it, errors: errors, path: `${path}.${i}`, bare: true, onChange: (v) => onChange(items.map((x, j) => (j === i ? v : x))) }) }, i))), _jsx(Button, { variant: "secondary", disabled: atMax, onClick: () => onChange(addItem(items, d)), children: "Add" })] }));
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
47
96
|
default:
|
|
48
97
|
control = _jsx("textarea", { className: "sm-textarea", disabled: true, value: JSON.stringify(value ?? null, null, 2), rows: 2 });
|
|
49
98
|
}
|
|
99
|
+
if (bare) {
|
|
100
|
+
return (_jsxs(_Fragment, { children: [control, message && _jsx("span", { className: "sm-error", children: message })] }));
|
|
101
|
+
}
|
|
50
102
|
// A <label> activates its first labelable descendant, so it may only wrap a
|
|
51
|
-
// single control — around refList's rows
|
|
52
|
-
// press of the first
|
|
53
|
-
return (_jsx(Field, { label: label(fieldKey), error:
|
|
103
|
+
// single control — around refList's rows, an object's fieldset or a list's
|
|
104
|
+
// rows it would turn every click into a press of the first button.
|
|
105
|
+
return (_jsx(Field, { label: label(fieldKey), counter: counter, error: message, asDiv: asDiv, children: control }));
|
|
106
|
+
}
|
|
107
|
+
/** One list item: a header with the summary and the row controls, the
|
|
108
|
+
* item's widget underneath while expanded. Header is a div, not a label.
|
|
109
|
+
* Collapse state lives in the parent `FieldWidget`, keyed to the item's
|
|
110
|
+
* index rather than to `key={i}` position, so a move or remove doesn't
|
|
111
|
+
* hand this row someone else's open/closed state. */
|
|
112
|
+
function ListRow({ index, count, summary, open, onToggle, onMove, onRemove, children }) {
|
|
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 })] }));
|
|
54
114
|
}
|
|
@@ -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,18 @@
|
|
|
1
|
+
import type { AdminField } from "../serialize.ts";
|
|
2
|
+
/** An empty item: an object of its subfields' defaults, else the default. */
|
|
3
|
+
export declare function emptyValue(d: AdminField): unknown;
|
|
4
|
+
export declare function addItem(items: unknown[], list: AdminField): unknown[];
|
|
5
|
+
export declare function removeItem(items: unknown[], index: number): unknown[];
|
|
6
|
+
export declare function moveItem(items: unknown[], from: number, to: number): unknown[];
|
|
7
|
+
/** Permutes a set of item indices (e.g. collapse state) for the same move
|
|
8
|
+
* `moveItem` just applied: the index at `from` becomes `to`; indices strictly
|
|
9
|
+
* between the two shift by one toward `from`; every other index is unchanged. */
|
|
10
|
+
export declare function moveIndex(set: Set<number>, from: number, to: number): Set<number>;
|
|
11
|
+
/** Permutes a set of item indices for a removal at `index`: drops it, and
|
|
12
|
+
* shifts every higher index down by one. */
|
|
13
|
+
export declare function removeIndex(set: Set<number>, index: number): Set<number>;
|
|
14
|
+
/** The row header's text: the value, or its first non-empty string, or "Item N". */
|
|
15
|
+
export declare function itemSummary(value: unknown, index: number): string;
|
|
16
|
+
/** A subfield change on an object value. `collapseEmpty` (an optional
|
|
17
|
+
* object) turns an object with no defined values into `undefined`. */
|
|
18
|
+
export declare function patchObject(obj: Record<string, unknown>, key: string, value: unknown, collapseEmpty: boolean): Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** An empty item: an object of its subfields' defaults, else the default. */
|
|
2
|
+
export function emptyValue(d) {
|
|
3
|
+
if (d.type === "object") {
|
|
4
|
+
const out = {};
|
|
5
|
+
for (const [k, sub] of Object.entries(d.fields ?? {})) {
|
|
6
|
+
const v = emptyValue(sub);
|
|
7
|
+
if (v !== undefined)
|
|
8
|
+
out[k] = v;
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
return d.default;
|
|
13
|
+
}
|
|
14
|
+
export function addItem(items, list) {
|
|
15
|
+
if (typeof list.max === "number" && items.length >= list.max)
|
|
16
|
+
return items;
|
|
17
|
+
return [...items, list.item ? emptyValue(list.item) : undefined];
|
|
18
|
+
}
|
|
19
|
+
export function removeItem(items, index) {
|
|
20
|
+
return items.filter((_, i) => i !== index);
|
|
21
|
+
}
|
|
22
|
+
export function moveItem(items, from, to) {
|
|
23
|
+
if (to < 0 || to >= items.length || from === to)
|
|
24
|
+
return items;
|
|
25
|
+
const next = [...items];
|
|
26
|
+
const [moved] = next.splice(from, 1);
|
|
27
|
+
next.splice(to, 0, moved);
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
30
|
+
/** Permutes a set of item indices (e.g. collapse state) for the same move
|
|
31
|
+
* `moveItem` just applied: the index at `from` becomes `to`; indices strictly
|
|
32
|
+
* between the two shift by one toward `from`; every other index is unchanged. */
|
|
33
|
+
export function moveIndex(set, from, to) {
|
|
34
|
+
const next = new Set();
|
|
35
|
+
for (const i of set) {
|
|
36
|
+
if (i === from)
|
|
37
|
+
next.add(to);
|
|
38
|
+
else if (from < to && i > from && i <= to)
|
|
39
|
+
next.add(i - 1);
|
|
40
|
+
else if (to < from && i >= to && i < from)
|
|
41
|
+
next.add(i + 1);
|
|
42
|
+
else
|
|
43
|
+
next.add(i);
|
|
44
|
+
}
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
/** Permutes a set of item indices for a removal at `index`: drops it, and
|
|
48
|
+
* shifts every higher index down by one. */
|
|
49
|
+
export function removeIndex(set, index) {
|
|
50
|
+
const next = new Set();
|
|
51
|
+
for (const i of set) {
|
|
52
|
+
if (i === index)
|
|
53
|
+
continue;
|
|
54
|
+
next.add(i > index ? i - 1 : i);
|
|
55
|
+
}
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
|
|
59
|
+
/** The row header's text: the value, or its first non-empty string, or "Item N". */
|
|
60
|
+
export function itemSummary(value, index) {
|
|
61
|
+
if (nonEmpty(value))
|
|
62
|
+
return value;
|
|
63
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
64
|
+
const hit = Object.values(value).find(nonEmpty);
|
|
65
|
+
if (hit)
|
|
66
|
+
return hit;
|
|
67
|
+
}
|
|
68
|
+
return `Item ${index + 1}`;
|
|
69
|
+
}
|
|
70
|
+
/** A subfield change on an object value. `collapseEmpty` (an optional
|
|
71
|
+
* object) turns an object with no defined values into `undefined`. */
|
|
72
|
+
export function patchObject(obj, key, value, collapseEmpty) {
|
|
73
|
+
const next = { ...obj };
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
delete next[key];
|
|
76
|
+
else
|
|
77
|
+
next[key] = value;
|
|
78
|
+
if (collapseEmpty && Object.values(next).every((v) => v === undefined))
|
|
79
|
+
return undefined;
|
|
80
|
+
return next;
|
|
81
|
+
}
|