smoodly 0.0.12 → 0.0.14
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 +294 -40
- package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
- package/dist/admin/editor/CanvasOverlay.js +9 -11
- package/dist/admin/editor/EditorView.js +135 -28
- package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
- package/dist/admin/editor/FloatingToolbar.js +14 -0
- package/dist/admin/editor/InlineRichtext.d.ts +9 -0
- package/dist/admin/editor/InlineRichtext.js +50 -0
- package/dist/admin/editor/Outline.d.ts +16 -10
- package/dist/admin/editor/Outline.js +13 -10
- package/dist/admin/editor/PageInspector.d.ts +21 -0
- package/dist/admin/editor/PageInspector.js +24 -0
- package/dist/admin/editor/PageSettings.d.ts +1 -3
- package/dist/admin/editor/PageSettings.js +7 -6
- package/dist/admin/editor/SeoSettings.d.ts +7 -0
- package/dist/admin/editor/SeoSettings.js +15 -0
- package/dist/admin/editor/inline-dom.d.ts +27 -0
- package/dist/admin/editor/inline-dom.js +43 -0
- package/dist/admin/editor/inline-session.d.ts +30 -0
- package/dist/admin/editor/inline-session.js +19 -0
- package/dist/admin/editor/protocol.d.ts +22 -1
- package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
- package/dist/admin/editor/useCanvasBridge.js +12 -7
- package/dist/admin/editor/useInlineSession.d.ts +46 -0
- package/dist/admin/editor/useInlineSession.js +244 -0
- package/dist/admin/field-paths.d.ts +13 -0
- package/dist/admin/field-paths.js +49 -0
- package/dist/admin/forms/FieldWidget.d.ts +7 -2
- package/dist/admin/forms/FieldWidget.js +35 -43
- package/dist/admin/forms/ListField.d.ts +10 -0
- package/dist/admin/forms/ListField.js +42 -0
- package/dist/admin/forms/MediaWidget.js +10 -3
- package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
- package/dist/admin/forms/RichtextElementCard.js +37 -0
- package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
- package/dist/admin/forms/RichtextImageCard.js +42 -0
- package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
- package/dist/admin/forms/RichtextInsertMenu.js +6 -0
- package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
- package/dist/admin/forms/RichtextPopovers.js +25 -0
- package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
- package/dist/admin/forms/RichtextToolbar.js +4 -2
- package/dist/admin/forms/RichtextWidget.d.ts +4 -1
- package/dist/admin/forms/RichtextWidget.js +23 -120
- package/dist/admin/forms/elements-context.d.ts +7 -0
- package/dist/admin/forms/elements-context.js +13 -0
- package/dist/admin/forms/reveal.d.ts +5 -0
- package/dist/admin/forms/reveal.js +47 -0
- package/dist/admin/forms/richtext-extensions.d.ts +8 -0
- package/dist/admin/forms/richtext-extensions.js +11 -0
- package/dist/admin/forms/richtext-form-context.d.ts +16 -0
- package/dist/admin/forms/richtext-form-context.js +18 -0
- package/dist/admin/forms/richtext-insert.d.ts +38 -0
- package/dist/admin/forms/richtext-insert.js +37 -0
- package/dist/admin/forms/richtext-nodes.d.ts +37 -0
- package/dist/admin/forms/richtext-nodes.js +85 -0
- package/dist/admin/forms/richtext-paste.js +5 -0
- package/dist/admin/forms/tabs.d.ts +3 -0
- package/dist/admin/forms/tabs.js +6 -0
- package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
- package/dist/admin/forms/useRichtextEditor.js +281 -0
- package/dist/admin/next/bridge.js +51 -20
- package/dist/admin/ops-impl.js +85 -19
- package/dist/admin/richtext-doc.d.ts +3 -0
- package/dist/admin/richtext-doc.js +12 -0
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +18 -1
- package/dist/admin/shell/AdminApp.js +2 -1
- package/dist/admin/shell/EntriesList.js +34 -12
- package/dist/admin/shell/EntryForm.js +22 -15
- package/dist/admin/shell/IconRail.js +6 -5
- package/dist/admin/shell/ListView.d.ts +9 -2
- package/dist/admin/shell/ListView.js +7 -6
- package/dist/admin/shell/LoginView.js +2 -2
- package/dist/admin/shell/PagesList.js +53 -24
- package/dist/admin/shell/SharedForm.js +17 -11
- package/dist/admin/shell/SharedList.js +5 -3
- package/dist/admin/shell/entries-list.d.ts +4 -0
- package/dist/admin/shell/entries-list.js +6 -0
- package/dist/admin/shell/pages-tree.d.ts +24 -0
- package/dist/admin/shell/pages-tree.js +59 -1
- package/dist/admin/tree-ops.d.ts +11 -1
- package/dist/admin/tree-ops.js +32 -1
- package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
- package/dist/admin/ui/LocaleSwitcher.js +28 -8
- package/dist/admin/ui/Modal.js +14 -2
- package/dist/admin/ui/ResizeHandle.d.ts +1 -0
- package/dist/admin/ui/ResizeHandle.js +127 -0
- package/dist/admin/ui/primitives.d.ts +38 -4
- package/dist/admin/ui/primitives.js +35 -10
- package/dist/admin/ui/resize.d.ts +16 -0
- package/dist/admin/ui/resize.js +23 -0
- package/dist/admin/ui/sortable.d.ts +23 -0
- package/dist/admin/ui/sortable.js +57 -0
- package/dist/admin/ui/theme.d.ts +3 -1
- package/dist/admin/ui/theme.js +527 -165
- package/dist/admin/ui/useSortable.d.ts +35 -0
- package/dist/admin/ui/useSortable.js +220 -0
- package/dist/admin/validate.d.ts +6 -1
- package/dist/admin/validate.js +52 -12
- package/dist/collections.d.ts +6 -5
- package/dist/config.d.ts +1 -0
- package/dist/config.js +36 -0
- package/dist/entry-store.d.ts +23 -3
- package/dist/entry-store.js +31 -5
- package/dist/field-defaults.d.ts +8 -0
- package/dist/field-defaults.js +18 -0
- package/dist/fields.d.ts +30 -1
- package/dist/fields.js +33 -3
- package/dist/image/SmoodlyImage.d.ts +4 -1
- package/dist/image/SmoodlyImage.js +2 -2
- package/dist/index.d.ts +16 -7
- package/dist/index.js +10 -4
- package/dist/marks.d.ts +53 -0
- package/dist/marks.js +43 -0
- package/dist/media.d.ts +3 -2
- package/dist/media.js +48 -6
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +1 -0
- package/dist/next/meta.d.ts +7 -3
- package/dist/next/meta.js +18 -9
- package/dist/next/page-renderer.js +1 -1
- package/dist/next/queries.d.ts +36 -0
- package/dist/next/queries.js +87 -0
- package/dist/page-tree.d.ts +3 -0
- package/dist/page-tree.js +6 -0
- package/dist/page.d.ts +11 -3
- package/dist/page.js +26 -0
- package/dist/pairing.d.ts +2 -1
- package/dist/pairing.js +2 -9
- package/dist/refs.d.ts +2 -2
- package/dist/refs.js +33 -12
- package/dist/render.d.ts +14 -7
- package/dist/render.js +4 -3
- package/dist/resolve.d.ts +12 -2
- package/dist/resolve.js +16 -7
- package/dist/revalidate.d.ts +6 -0
- package/dist/revalidate.js +6 -0
- package/dist/richtext/index.d.ts +12 -0
- package/dist/richtext/index.js +9 -0
- package/dist/richtext-render.d.ts +22 -2
- package/dist/richtext-render.js +40 -13
- package/dist/richtext-walk.d.ts +44 -0
- package/dist/richtext-walk.js +46 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +13 -1
- package/dist/section-wrapper.js +2 -1
- package/dist/shared.d.ts +7 -7
- package/dist/site.d.ts +25 -1
- package/dist/site.js +89 -22
- package/dist/store.d.ts +7 -1
- package/dist/store.js +13 -4
- package/dist/supabase-entry-store.d.ts +1 -0
- package/dist/supabase-entry-store.js +34 -11
- package/dist/supabase-store.d.ts +1 -0
- package/dist/supabase-store.js +14 -1
- package/dist/toolset.d.ts +7 -1
- package/dist/toolset.js +13 -4
- package/package.json +6 -1
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
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
3
|
import { RichtextWidget } from "./RichtextWidget.js";
|
|
10
4
|
import { MediaWidget } from "./MediaWidget.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
5
|
+
import { ListField } from "./ListField.js";
|
|
6
|
+
import { optionsOf } from "../../fields.js";
|
|
7
|
+
import { Button, Field, IconButton, Switch } from "../ui/primitives.js";
|
|
8
|
+
import { patchObject } from "./list.js";
|
|
13
9
|
const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
|
|
14
|
-
export function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, }) {
|
|
10
|
+
export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, mark, inlinePath, onEditHere, }) {
|
|
15
11
|
const path = pathProp ?? fieldKey;
|
|
16
12
|
const message = errors?.[path];
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
// so a move or remove doesn't reassign which row looks open.
|
|
20
|
-
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
13
|
+
// The descriptor's default shows while the value is absent (spec 2026-09-10 page fields §1).
|
|
14
|
+
const value = valueProp === undefined && d.default !== undefined ? d.default : valueProp;
|
|
21
15
|
const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
|
|
22
16
|
let control;
|
|
23
17
|
let counter;
|
|
@@ -36,15 +30,34 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
|
|
|
36
30
|
case "number":
|
|
37
31
|
control = (_jsx("input", { className: "sm-input", type: "number", value: value == null ? "" : String(value), onChange: (e) => onChange(e.target.value === "" ? undefined : Number(e.target.value)) }));
|
|
38
32
|
break;
|
|
33
|
+
case "boolean":
|
|
34
|
+
control = _jsx(Switch, { checked: value === true, onChange: onChange });
|
|
35
|
+
break;
|
|
39
36
|
case "select": {
|
|
40
|
-
const options =
|
|
41
|
-
control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value), children: [d.optional === true && _jsx("option", { value: "", children: "\u2014" }), options.map((o) => _jsx("option", { value: o, children: o }, o))] }));
|
|
37
|
+
const options = optionsOf(d.options);
|
|
38
|
+
control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value), children: [d.optional === true && _jsx("option", { value: "", children: "\u2014" }), options.map((o) => _jsx("option", { value: o.value, children: o.label }, o.value))] }));
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case "radio": {
|
|
42
|
+
asDiv = true; // several inputs: never inside one implicit label
|
|
43
|
+
control = (_jsx("div", { className: "sm-choices", role: "radiogroup", children: optionsOf(d.options).map((o) => (_jsxs("label", { className: "sm-choice", children: [_jsx("input", { type: "radio", name: path, value: o.value, checked: value === o.value, onChange: () => onChange(o.value) }), o.label] }, o.value))) }));
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case "checkboxes": {
|
|
47
|
+
asDiv = true;
|
|
48
|
+
const chosen = Array.isArray(value) ? value : [];
|
|
49
|
+
const all = optionsOf(d.options);
|
|
50
|
+
// Stored in option order, whatever order the boxes were ticked in.
|
|
51
|
+
const toggle = (v, on) => onChange(all.map((o) => o.value).filter((x) => (x === v ? on : chosen.includes(x))));
|
|
52
|
+
control = (_jsx("div", { className: "sm-choices", children: all.map((o) => (_jsxs("label", { className: "sm-choice", children: [_jsx("input", { type: "checkbox", checked: chosen.includes(o.value), onChange: (e) => toggle(o.value, e.target.checked) }), o.label] }, o.value))) }));
|
|
42
53
|
break;
|
|
43
54
|
}
|
|
44
55
|
case "richtext":
|
|
45
56
|
// The toolbar holds buttons, so this control can never sit in a <label>.
|
|
46
57
|
asDiv = true;
|
|
47
|
-
control =
|
|
58
|
+
control = inlinePath === path
|
|
59
|
+
? _jsx(InlineNote, { onEditHere: onEditHere })
|
|
60
|
+
: _jsx(RichtextWidget, { value: value, toolset: d.toolset, onChange: onChange, errors: errors, path: path });
|
|
48
61
|
break;
|
|
49
62
|
case "image":
|
|
50
63
|
case "video":
|
|
@@ -64,33 +77,16 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
|
|
|
64
77
|
case "object": {
|
|
65
78
|
asDiv = true;
|
|
66
79
|
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))) }));
|
|
80
|
+
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}`, inlinePath: inlinePath, onEditHere: onEditHere, onChange: (v) => onChange(patchObject(obj, k, v, d.optional === true)) }, k))) }));
|
|
68
81
|
break;
|
|
69
82
|
}
|
|
70
83
|
case "list": {
|
|
71
84
|
asDiv = true;
|
|
72
85
|
const items = Array.isArray(value) ? value : [];
|
|
73
86
|
const item = d.item ?? { type: "text" };
|
|
74
|
-
const atMax = typeof d.max === "number" && items.length >= d.max;
|
|
75
87
|
if (typeof d.max === "number")
|
|
76
88
|
counter = `${items.length} of ${d.max}`;
|
|
77
|
-
control = (
|
|
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" })] }));
|
|
89
|
+
control = (_jsx(ListField, { descriptor: d, items: items, onChange: onChange, path: path, renderItem: (it, i) => (_jsx(FieldWidget, { fieldKey: String(i), descriptor: item, value: it, errors: errors, path: `${path}.${i}`, bare: true, inlinePath: inlinePath, onEditHere: onEditHere, onChange: (v) => onChange(items.map((x, j) => (j === i ? v : x))) })) }));
|
|
94
90
|
break;
|
|
95
91
|
}
|
|
96
92
|
default:
|
|
@@ -102,13 +98,9 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
|
|
|
102
98
|
// A <label> activates its first labelable descendant, so it may only wrap a
|
|
103
99
|
// single control — around refList's rows, an object's fieldset or a list's
|
|
104
100
|
// 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 }));
|
|
101
|
+
return (_jsx(Field, { label: label(fieldKey), mark: mark, counter: counter, error: message, asDiv: asDiv, path: path, children: control }));
|
|
106
102
|
}
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
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 })] }));
|
|
103
|
+
/** The richtext field while it is edited in the canvas (spec 2026-09-10 §5). */
|
|
104
|
+
function InlineNote({ onEditHere }) {
|
|
105
|
+
return (_jsxs("div", { className: "sm-inline-note", children: [_jsx("span", { children: "Editing in the canvas" }), _jsx(Button, { variant: "secondary", onClick: onEditHere, children: "Edit here" })] }));
|
|
114
106
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { AdminField } from "../serialize.ts";
|
|
3
|
+
export declare function ListField({ descriptor: d, items, onChange, renderItem, path }: {
|
|
4
|
+
descriptor: AdminField;
|
|
5
|
+
items: unknown[];
|
|
6
|
+
onChange(value: unknown[]): void;
|
|
7
|
+
renderItem(item: unknown, index: number): ReactNode;
|
|
8
|
+
/** The list's dotted path; each row carries `<path>.<i>` for the canvas reveal. */
|
|
9
|
+
path?: string;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// A list field's rows: collapse per row, add and remove, and reordering
|
|
4
|
+
// by drag (useSortable), by the arrow keys on the grip, or by the ↑/↓
|
|
5
|
+
// buttons — every path through the pure helpers in ./list.ts. The item
|
|
6
|
+
// widget is rendered by the caller (FieldWidget), which keeps this file
|
|
7
|
+
// free of the recursion.
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { Button, Grip, IconButton } from "../ui/primitives.js";
|
|
10
|
+
import { useSortable } from "../ui/useSortable.js";
|
|
11
|
+
import { addItem, itemSummary, moveIndex, moveItem, removeIndex, removeItem } from "./list.js";
|
|
12
|
+
export function ListField({ descriptor: d, items, onChange, renderItem, path }) {
|
|
13
|
+
// Keyed to the item's index rather than to `key={i}` position, so a move
|
|
14
|
+
// or remove doesn't hand a row someone else's open/closed state.
|
|
15
|
+
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
16
|
+
const atMax = typeof d.max === "number" && items.length >= d.max;
|
|
17
|
+
const move = (from, to) => {
|
|
18
|
+
const moved = moveItem(items, from, to);
|
|
19
|
+
if (moved === items)
|
|
20
|
+
return;
|
|
21
|
+
onChange(moved);
|
|
22
|
+
setCollapsed((prev) => moveIndex(prev, from, to));
|
|
23
|
+
};
|
|
24
|
+
const sortable = useSortable({ count: items.length, onDrop: move });
|
|
25
|
+
return (_jsxs("div", { className: "sm-list", ref: sortable.containerRef, children: [items.map((it, i) => (_jsx(ListRow, { index: i, count: items.length, summary: itemSummary(it, i), open: !collapsed.has(i), path: path === undefined ? undefined : `${path}.${i}`, style: sortable.rowStyle(i), className: sortable.rowClass(i), grip: _jsx(Grip, { ...sortable.handleProps(i) }), onToggle: () => setCollapsed((prev) => {
|
|
26
|
+
const next = new Set(prev);
|
|
27
|
+
if (next.has(i))
|
|
28
|
+
next.delete(i);
|
|
29
|
+
else
|
|
30
|
+
next.add(i);
|
|
31
|
+
return next;
|
|
32
|
+
}), onMove: (to) => move(i, to), onRemove: () => {
|
|
33
|
+
onChange(removeItem(items, i));
|
|
34
|
+
setCollapsed((prev) => removeIndex(prev, i));
|
|
35
|
+
}, children: renderItem(it, i) }, i))), _jsx(Button, { variant: "secondary", disabled: atMax, onClick: () => onChange(addItem(items, d)), children: "Add" })] }));
|
|
36
|
+
}
|
|
37
|
+
/** One list item: a header with the grip, the summary and the row
|
|
38
|
+
* controls, the item's widget underneath while expanded. Header is a
|
|
39
|
+
* div, not a label (a label would press its first button on any click). */
|
|
40
|
+
function ListRow({ index, count, summary, open, style, className, grip, path, onToggle, onMove, onRemove, children }) {
|
|
41
|
+
return (_jsxs("div", { className: className ? `sm-listrow ${className}` : "sm-listrow", style: style, "data-sm-path": path, children: [_jsxs("div", { className: "sm-listrow__head", children: [grip, _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 })] }));
|
|
42
|
+
}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
// The media field widget (spec 2026-09-08 media §10): empty is a drop
|
|
4
4
|
// zone (upload straight away, or open the library); filled is the
|
|
5
|
-
// thumbnail with the crop applied
|
|
5
|
+
// thumbnail with the crop applied — contained on a canvas at most
|
|
6
|
+
// CANVAS_H tall, the frame sized min(CANVAS_H tall, full width) so it
|
|
7
|
+
// keeps its aspect — the alt, and Replace / Edit / Remove.
|
|
6
8
|
// The record comes from the assets context (cached per id); `asset`
|
|
7
9
|
// preloads it for static renders.
|
|
8
10
|
import { useEffect, useState } from "react";
|
|
@@ -14,6 +16,8 @@ import { uploadFile } from "./upload.js";
|
|
|
14
16
|
import { MediaPicker } from "./MediaPicker.js";
|
|
15
17
|
import { CropEditor } from "./CropEditor.js";
|
|
16
18
|
import { AssetThumb } from "./MediaGrid.js";
|
|
19
|
+
/** The filled preview's canvas height cap (the stylesheet repeats it). */
|
|
20
|
+
const CANVAS_H = 300;
|
|
17
21
|
export function MediaWidget({ descriptor, value, onChange, asset: preloaded }) {
|
|
18
22
|
const assets = useAssets();
|
|
19
23
|
const kind = descriptor.type;
|
|
@@ -75,8 +79,11 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded }) {
|
|
|
75
79
|
const image = { url: asset.url, width: asset.width, height: asset.height, mime: asset.mime, alt: "", crop: v.crop, hotspot: v.hotspot, aspect: v.aspect ?? (typeof descriptor.aspect === "string" ? descriptor.aspect : undefined) };
|
|
76
80
|
const box = boxAspectOf(image);
|
|
77
81
|
const f = frameFor(image, box);
|
|
78
|
-
return (_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(box) }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${f.left}%`, top: `${f.top}%`, width: `${f.width}%`, height: `${f.height}%` } }) }));
|
|
79
|
-
})() : asset.kind === "video" ? (
|
|
82
|
+
return (_jsx("div", { className: "sm-media__canvas", children: _jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(box), width: `calc(${CANVAS_H}px * ${box})` }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${f.left}%`, top: `${f.top}%`, width: `${f.width}%`, height: `${f.height}%` } }) }) }));
|
|
83
|
+
})() : asset.kind === "video" ? (() => {
|
|
84
|
+
const ratio = asset.width && asset.height ? asset.width / asset.height : 16 / 9;
|
|
85
|
+
return (_jsx("div", { className: "sm-media__canvas", children: _jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(ratio), width: `calc(${CANVAS_H}px * ${ratio})` }, children: _jsx(AssetThumb, { asset: asset, className: "sm-mediacard__img" }) }) }));
|
|
86
|
+
})() : (_jsxs("div", { className: "sm-media__chip", children: [_jsx("span", { style: { fontWeight: 500 }, children: asset.filename }), _jsx("span", { className: "sm-media__alt", children: formatBytes(asset.size) })] }));
|
|
80
87
|
body = (_jsxs("div", { className: "sm-media", children: [preview, asset.kind === "image" && _jsx("span", { className: "sm-media__alt", children: v.alt ?? asset.alt ?? "No alt text" }), _jsxs("div", { className: "sm-media__actions", children: [_jsx(Button, { variant: "secondary", onClick: () => setPicker(true), children: "Replace" }), asset.kind === "image" && _jsx(Button, { variant: "secondary", onClick: () => setCrop(true), children: "Edit" }), _jsx(Button, { variant: "secondary", onClick: () => onChange(undefined), children: "Remove" })] })] }));
|
|
81
88
|
}
|
|
82
89
|
return (_jsxs(_Fragment, { children: [body, error && _jsx("span", { className: "sm-error", children: error }), picker && _jsx(MediaPicker, { kinds: [kind], onPick: select, onClose: () => setPicker(false) }), crop && asset && (_jsx(CropEditor, { asset: asset, descriptor: descriptor, value: value, onSave: (next) => { onChange(next); setCrop(false); }, onClose: () => setCrop(false) }))] }));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The element node view (spec 2026-09-08 block nodes §3): the node view IS
|
|
4
|
+
// the form — a header (icon, title, collapse, delete) and the element's
|
|
5
|
+
// fields then styles through FieldWidget, reading and writing attrs.
|
|
6
|
+
// A richtext field inside mounts a nested RichtextWidget with its own
|
|
7
|
+
// toolset. Errors come from the enclosing form under
|
|
8
|
+
// `<path>.elements.<i>.<key>`, i being this node's index among the
|
|
9
|
+
// document's element nodes. Collapse is component state only: a card
|
|
10
|
+
// starts collapsed (2026-09-09) unless it holds nothing yet — a freshly
|
|
11
|
+
// inserted element opens, so its fields can be filled straight away.
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { NodeViewWrapper } from "@tiptap/react";
|
|
14
|
+
import { IconButton } from "../ui/primitives.js";
|
|
15
|
+
import { FieldWidget } from "./FieldWidget.js";
|
|
16
|
+
import { useElements } from "./elements-context.js";
|
|
17
|
+
import { cardClass, useRichtextForm } from "./richtext-form-context.js";
|
|
18
|
+
import { elementIndexAt } from "./richtext-insert.js";
|
|
19
|
+
/** No field holds a value yet — the state right after an insert. */
|
|
20
|
+
function isBlank(fields) {
|
|
21
|
+
return Object.values(fields).every((v) => v === undefined || v === null || v === "");
|
|
22
|
+
}
|
|
23
|
+
export function RichtextElementCard({ node, editor, getPos, updateAttributes, deleteNode, selected }) {
|
|
24
|
+
const elements = useElements();
|
|
25
|
+
const form = useRichtextForm();
|
|
26
|
+
const name = String(node.attrs.name ?? "");
|
|
27
|
+
const element = elements.find((e) => e.name === name);
|
|
28
|
+
const fields = (node.attrs.fields && typeof node.attrs.fields === "object" ? node.attrs.fields : {});
|
|
29
|
+
const styles = (node.attrs.styles && typeof node.attrs.styles === "object" ? node.attrs.styles : {});
|
|
30
|
+
const [open, setOpen] = useState(() => isBlank(fields));
|
|
31
|
+
const pos = getPos();
|
|
32
|
+
const index = typeof pos === "number" ? elementIndexAt(editor.state.doc, pos) : 0;
|
|
33
|
+
const base = `${form.path}.elements.${index}`;
|
|
34
|
+
const select = () => { if (typeof pos === "number")
|
|
35
|
+
editor.commands.setNodeSelection(pos); };
|
|
36
|
+
return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [Object.entries(element.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), Object.entries(element.styles).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: styles[key], path: `${base}.styles.${key}`, onChange: (v) => updateAttributes({ styles: { ...styles, [key]: v } }) }, `style-${key}`)))] }))] }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The image node view (spec 2026-09-08 block nodes §3): thumbnail,
|
|
4
|
+
// filename, an alt input whose placeholder is the library's alt, a
|
|
5
|
+
// caption input, Replace and Delete. Attribute edits go through
|
|
6
|
+
// updateAttributes so the outer history sees them. Inputs stop keys from
|
|
7
|
+
// reaching the editor (TipTap's node view stops events from form
|
|
8
|
+
// controls), so Backspace in the caption never deletes the node.
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
import { NodeViewWrapper } from "@tiptap/react";
|
|
11
|
+
import { IconButton } from "../ui/primitives.js";
|
|
12
|
+
import { useAssets } from "./assets-context.js";
|
|
13
|
+
import { MediaPicker } from "./MediaPicker.js";
|
|
14
|
+
import { cardClass, useRichtextForm } from "./richtext-form-context.js";
|
|
15
|
+
export function RichtextImageCard({ node, editor, getPos, updateAttributes, deleteNode, selected }) {
|
|
16
|
+
const assets = useAssets();
|
|
17
|
+
const form = useRichtextForm();
|
|
18
|
+
const value = (node.attrs.image ?? {});
|
|
19
|
+
const caption = typeof node.attrs.caption === "string" ? node.attrs.caption : "";
|
|
20
|
+
const id = typeof value.asset === "string" ? value.asset : null;
|
|
21
|
+
const [asset, setAsset] = useState(null);
|
|
22
|
+
const [picker, setPicker] = useState(false);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!id) {
|
|
25
|
+
setAsset(null);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
let live = true;
|
|
29
|
+
assets.get(id).then((r) => { if (live)
|
|
30
|
+
setAsset(r); });
|
|
31
|
+
return () => { live = false; };
|
|
32
|
+
}, [id, assets]);
|
|
33
|
+
const select = () => { const pos = getPos(); if (typeof pos === "number")
|
|
34
|
+
editor.commands.setNodeSelection(pos); };
|
|
35
|
+
const setAlt = (alt) => updateAttributes({ image: alt === "" ? { asset: id } : { asset: id, alt } });
|
|
36
|
+
const replace = (record) => {
|
|
37
|
+
assets.remember(record);
|
|
38
|
+
setPicker(false);
|
|
39
|
+
updateAttributes({ image: value.alt ? { asset: record.id, alt: value.alt } : { asset: record.id } });
|
|
40
|
+
};
|
|
41
|
+
return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, "data-drag-handle": true, children: [_jsxs("div", { className: "sm-rtcard__head", onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: "\u25A3" }), _jsx("span", { className: "sm-rtcard__title", children: "Image" }), _jsx(IconButton, { label: "Replace image", onClick: () => setPicker(true), children: "\u21BB" }), _jsx(IconButton, { label: "Delete image", onClick: () => deleteNode(), children: "\u00D7" })] }), _jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [asset ? _jsx("img", { className: "sm-rtcard__thumb", src: asset.url, alt: "" }) : _jsx("div", { className: "sm-rtcard__thumb", style: { height: 80 } }), asset && _jsx("span", { className: "sm-rtcard__file", children: asset.filename }), _jsx("input", { className: "sm-input", "aria-label": "Alt text", placeholder: asset?.alt ?? "Alt text", value: value.alt ?? "", onChange: (e) => setAlt(e.target.value) }), _jsx("input", { className: "sm-input", "aria-label": "Caption", placeholder: "Caption", value: caption, onChange: (e) => updateAttributes({ caption: e.target.value }) })] }), picker && _jsx(MediaPicker, { kinds: ["image"], onPick: replace, onClose: () => setPicker(false) })] }));
|
|
42
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
const keepFocus = (e) => e.preventDefault();
|
|
4
|
+
export function RichtextInsertMenu({ items, active, onPick }) {
|
|
5
|
+
return (_jsxs("div", { className: "sm-picker sm-richtext__insert", role: "listbox", "aria-label": "Insert", children: [items.length === 0 && _jsx("div", { className: "sm-richtext__insert-empty", children: "Nothing matches" }), items.map((item, i) => (_jsxs("button", { type: "button", role: "option", "aria-selected": i === active, className: i === active ? "sm-picker__item sm-picker__item--active" : "sm-picker__item", onMouseDown: keepFocus, onClick: () => onPick(item), children: [_jsx("span", { className: "sm-picker__icon", children: item.icon }), _jsx("span", { style: { fontWeight: 600 }, children: item.title })] }, `${item.kind}-${item.title}`)))] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RichtextApi } from "./useRichtextEditor.ts";
|
|
2
|
+
export declare function RichtextMenu({ menu }: {
|
|
3
|
+
menu: RichtextApi["menu"];
|
|
4
|
+
}): import("react").JSX.Element;
|
|
5
|
+
export declare function RichtextLinkRow({ link }: {
|
|
6
|
+
link: RichtextApi["link"];
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The richtext editor's two popovers, shared by the sidebar widget and
|
|
4
|
+
// the canvas toolbar (spec 2026-09-10 §5): the "+" insert menu with its
|
|
5
|
+
// filter, and the link row. Presentational over the hook's state.
|
|
6
|
+
import { Button } from "../ui/primitives.js";
|
|
7
|
+
import { RichtextInsertMenu } from "./RichtextInsertMenu.js";
|
|
8
|
+
export function RichtextMenu({ menu }) {
|
|
9
|
+
// Focus leaving the menu (a click anywhere else) closes it, without stealing focus back to the editor.
|
|
10
|
+
return (_jsxs("div", { className: "sm-richtext__menu", onBlur: menu.dismiss, children: [_jsx("input", { className: "sm-input", placeholder: "Filter\u2026", value: menu.query, autoFocus: true, "aria-label": "Insert filter", onChange: (e) => menu.setQuery(e.target.value), onKeyDown: menu.onKeyDown }), _jsx(RichtextInsertMenu, { items: menu.items, active: menu.active, onPick: menu.pick })] }));
|
|
11
|
+
}
|
|
12
|
+
export function RichtextLinkRow({ link }) {
|
|
13
|
+
return (_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) => link.setHref(e.target.value), onKeyDown: (e) => {
|
|
14
|
+
// Escape must not reach the editor's window handler, which clears the block selection.
|
|
15
|
+
if (e.key === "Enter") {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
link.apply();
|
|
18
|
+
}
|
|
19
|
+
if (e.key === "Escape") {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
e.stopPropagation();
|
|
22
|
+
link.close();
|
|
23
|
+
}
|
|
24
|
+
} }), _jsx(Button, { onClick: link.apply, children: "Apply" }), _jsx(Button, { variant: "secondary", onClick: link.remove, children: "Remove" })] }));
|
|
25
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Allowed } from "../../toolset.ts";
|
|
2
|
-
export type ToolbarCommand = "bold" | "italic" | "link" | "bulletList" | "orderedList" | "blockquote" | {
|
|
2
|
+
export type ToolbarCommand = "bold" | "italic" | "link" | "bulletList" | "orderedList" | "blockquote" | "insert" | {
|
|
3
3
|
heading: number;
|
|
4
4
|
};
|
|
5
5
|
export type ActiveState = {
|
|
@@ -13,8 +13,10 @@ export type ActiveState = {
|
|
|
13
13
|
heading: number | null;
|
|
14
14
|
};
|
|
15
15
|
export declare const INACTIVE: ActiveState;
|
|
16
|
-
export declare function RichtextToolbar({ allowed, active, onCommand }: {
|
|
16
|
+
export declare function RichtextToolbar({ allowed, active, canInsert, onCommand }: {
|
|
17
17
|
allowed: Allowed;
|
|
18
18
|
active: ActiveState;
|
|
19
|
+
/** Whether the toolset allows an image or an element — the "+" button. */
|
|
20
|
+
canInsert: boolean;
|
|
19
21
|
onCommand(command: ToolbarCommand): void;
|
|
20
22
|
}): import("react").JSX.Element | null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
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";
|
|
3
|
+
import { Bold, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Italic, Link, List, ListOrdered, Plus, Quote } from "lucide-react";
|
|
4
4
|
import { IconButton } from "../ui/primitives.js";
|
|
5
5
|
export const INACTIVE = {
|
|
6
6
|
bold: false, italic: false, link: false, bulletList: false, orderedList: false, blockquote: false, heading: null,
|
|
@@ -9,7 +9,7 @@ const HEADING_ICONS = { 1: Heading1, 2: Heading2, 3: Heading3, 4: Heading4, 5: H
|
|
|
9
9
|
const ICON = 14;
|
|
10
10
|
/** Keep the editor's selection: a toolbar press must not blur the content. */
|
|
11
11
|
const keepFocus = (e) => e.preventDefault();
|
|
12
|
-
export function RichtextToolbar({ allowed, active, onCommand }) {
|
|
12
|
+
export function RichtextToolbar({ allowed, active, canInsert, onCommand }) {
|
|
13
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
14
|
const groups = [];
|
|
15
15
|
const marks = [
|
|
@@ -31,6 +31,8 @@ export function RichtextToolbar({ allowed, active, onCommand }) {
|
|
|
31
31
|
].filter(Boolean);
|
|
32
32
|
if (blocks.length > 0)
|
|
33
33
|
groups.push(blocks);
|
|
34
|
+
if (canInsert)
|
|
35
|
+
groups.push([button("Insert", false, "insert", Plus)]);
|
|
34
36
|
if (groups.length === 0)
|
|
35
37
|
return null;
|
|
36
38
|
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))) }));
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export declare function RichtextWidget({ value, toolset, onChange }: {
|
|
1
|
+
export declare function RichtextWidget({ value, toolset, onChange, errors, path }: {
|
|
2
2
|
value: unknown;
|
|
3
3
|
toolset: unknown;
|
|
4
4
|
onChange(doc: unknown): void;
|
|
5
|
+
/** The enclosing form's errors and this field's dotted path, for the cards. */
|
|
6
|
+
errors?: Record<string, string>;
|
|
7
|
+
path?: string;
|
|
5
8
|
}): import("react").JSX.Element;
|
|
@@ -1,123 +1,26 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// The richtext field widget (spec 2026-09-08 §3):
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 })] }));
|
|
3
|
+
// The richtext field widget (spec 2026-09-08 §3): the shared editor
|
|
4
|
+
// (useRichtextEditor) under a fixed toolbar, the "+" menu floating under
|
|
5
|
+
// the toolbar (never pushing the content), the slash menu under the
|
|
6
|
+
// caret, the picker as a modal. Inside the canvas (spec 2026-09-10 §5) a
|
|
7
|
+
// nested widget in an element card inherits the form context's `canvas`
|
|
8
|
+
// flag, so its own cards stay admin-styled.
|
|
9
|
+
import { useMemo } from "react";
|
|
10
|
+
import { EditorContent } from "@tiptap/react";
|
|
11
|
+
import { RichtextToolbar } from "./RichtextToolbar.js";
|
|
12
|
+
import { RichtextInsertMenu } from "./RichtextInsertMenu.js";
|
|
13
|
+
import { RichtextLinkRow, RichtextMenu } from "./RichtextPopovers.js";
|
|
14
|
+
import { RichtextFormProvider, useRichtextForm } from "./richtext-form-context.js";
|
|
15
|
+
import { MediaPicker } from "./MediaPicker.js";
|
|
16
|
+
import { useRichtextEditor } from "./useRichtextEditor.js";
|
|
17
|
+
export function RichtextWidget({ value, toolset, onChange, errors, path = "" }) {
|
|
18
|
+
const rt = useRichtextEditor({ value, toolset, onChange, contentClass: "sm-richtext__content" });
|
|
19
|
+
const parent = useRichtextForm();
|
|
20
|
+
const formInfo = useMemo(() => ({ errors, path, canvas: parent.canvas }), [errors, path, parent.canvas]);
|
|
21
|
+
return (_jsx(RichtextFormProvider, { value: formInfo, children: _jsxs("div", { className: "sm-richtext", children: [_jsxs("div", { className: "sm-richtext__head", children: [_jsx(RichtextToolbar, { allowed: rt.allowed, active: rt.active, canInsert: rt.canInsert, onCommand: rt.run }), rt.menu.open && _jsx(RichtextMenu, { menu: rt.menu })] }), rt.link.open && _jsx(RichtextLinkRow, { link: rt.link }), _jsxs("div", { className: "sm-richtext__body", children: [_jsx(EditorContent, { editor: rt.editor }), rt.slash && (_jsx("div", { className: "sm-richtext__slash", style: { top: rt.slash.state.rect ? rt.slash.state.rect.bottom - (editorTop(rt.editor) ?? 0) + 4 : 0 }, children: _jsx(RichtextInsertMenu, { items: rt.slash.state.items, active: rt.slash.active, onPick: (item) => rt.slash?.state.pick(item) }) }))] }), rt.picker.open && _jsx(MediaPicker, { kinds: ["image"], onPick: rt.picker.pick, onClose: rt.picker.close })] }) }));
|
|
22
|
+
}
|
|
23
|
+
/** The editor's top in viewport coordinates, to place the slash menu relative to the body wrapper. */
|
|
24
|
+
function editorTop(editor) {
|
|
25
|
+
return editor?.view.dom.parentElement?.getBoundingClientRect().top;
|
|
123
26
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import type { AdminElement } from "../serialize.ts";
|
|
3
|
+
export declare function ElementsProvider({ elements, children }: {
|
|
4
|
+
elements: AdminElement[];
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}): ReactElement;
|
|
7
|
+
export declare function useElements(): AdminElement[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The serialized element descriptors, for the richtext widget's insert
|
|
4
|
+
// menu and element cards. Defaults to none, so a form rendered without
|
|
5
|
+
// the shell (tests, a bare page) still works.
|
|
6
|
+
import { createContext, useContext } from "react";
|
|
7
|
+
const Ctx = createContext([]);
|
|
8
|
+
export function ElementsProvider({ elements, children }) {
|
|
9
|
+
return _jsx(Ctx.Provider, { value: elements, children: children });
|
|
10
|
+
}
|
|
11
|
+
export function useElements() {
|
|
12
|
+
return useContext(Ctx);
|
|
13
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const REVEAL_CLASS = "sm-field--reveal";
|
|
2
|
+
export declare const PATH_ATTR = "data-sm-path";
|
|
3
|
+
/** The path, then each enclosing path, outermost last. */
|
|
4
|
+
export declare function ancestorsOf(path: string): string[];
|
|
5
|
+
export declare function revealField(root: ParentNode, path: string): void;
|