smoodly 0.0.12 → 0.0.13

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.
Files changed (132) hide show
  1. package/README.md +219 -25
  2. package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
  3. package/dist/admin/editor/CanvasOverlay.js +9 -11
  4. package/dist/admin/editor/EditorView.js +130 -25
  5. package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
  6. package/dist/admin/editor/FloatingToolbar.js +14 -0
  7. package/dist/admin/editor/InlineRichtext.d.ts +9 -0
  8. package/dist/admin/editor/InlineRichtext.js +50 -0
  9. package/dist/admin/editor/Outline.d.ts +16 -10
  10. package/dist/admin/editor/Outline.js +13 -10
  11. package/dist/admin/editor/inline-dom.d.ts +27 -0
  12. package/dist/admin/editor/inline-dom.js +43 -0
  13. package/dist/admin/editor/inline-session.d.ts +30 -0
  14. package/dist/admin/editor/inline-session.js +19 -0
  15. package/dist/admin/editor/protocol.d.ts +22 -1
  16. package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
  17. package/dist/admin/editor/useCanvasBridge.js +12 -7
  18. package/dist/admin/editor/useInlineSession.d.ts +46 -0
  19. package/dist/admin/editor/useInlineSession.js +244 -0
  20. package/dist/admin/field-paths.d.ts +13 -0
  21. package/dist/admin/field-paths.js +49 -0
  22. package/dist/admin/forms/FieldWidget.d.ts +7 -2
  23. package/dist/admin/forms/FieldWidget.js +12 -40
  24. package/dist/admin/forms/ListField.d.ts +10 -0
  25. package/dist/admin/forms/ListField.js +42 -0
  26. package/dist/admin/forms/MediaWidget.js +10 -3
  27. package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
  28. package/dist/admin/forms/RichtextElementCard.js +37 -0
  29. package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
  30. package/dist/admin/forms/RichtextImageCard.js +42 -0
  31. package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
  32. package/dist/admin/forms/RichtextInsertMenu.js +6 -0
  33. package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
  34. package/dist/admin/forms/RichtextPopovers.js +25 -0
  35. package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
  36. package/dist/admin/forms/RichtextToolbar.js +4 -2
  37. package/dist/admin/forms/RichtextWidget.d.ts +4 -1
  38. package/dist/admin/forms/RichtextWidget.js +23 -120
  39. package/dist/admin/forms/elements-context.d.ts +7 -0
  40. package/dist/admin/forms/elements-context.js +13 -0
  41. package/dist/admin/forms/reveal.d.ts +5 -0
  42. package/dist/admin/forms/reveal.js +47 -0
  43. package/dist/admin/forms/richtext-extensions.d.ts +8 -0
  44. package/dist/admin/forms/richtext-extensions.js +11 -0
  45. package/dist/admin/forms/richtext-form-context.d.ts +16 -0
  46. package/dist/admin/forms/richtext-form-context.js +18 -0
  47. package/dist/admin/forms/richtext-insert.d.ts +38 -0
  48. package/dist/admin/forms/richtext-insert.js +37 -0
  49. package/dist/admin/forms/richtext-nodes.d.ts +37 -0
  50. package/dist/admin/forms/richtext-nodes.js +85 -0
  51. package/dist/admin/forms/richtext-paste.js +5 -0
  52. package/dist/admin/forms/tabs.d.ts +3 -0
  53. package/dist/admin/forms/tabs.js +6 -0
  54. package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
  55. package/dist/admin/forms/useRichtextEditor.js +281 -0
  56. package/dist/admin/next/bridge.js +51 -20
  57. package/dist/admin/ops-impl.js +13 -8
  58. package/dist/admin/richtext-doc.d.ts +3 -0
  59. package/dist/admin/richtext-doc.js +12 -0
  60. package/dist/admin/serialize.d.ts +7 -0
  61. package/dist/admin/serialize.js +16 -1
  62. package/dist/admin/shell/AdminApp.js +2 -1
  63. package/dist/admin/shell/EntriesList.js +34 -12
  64. package/dist/admin/shell/EntryForm.js +22 -15
  65. package/dist/admin/shell/IconRail.js +6 -5
  66. package/dist/admin/shell/ListView.d.ts +9 -2
  67. package/dist/admin/shell/ListView.js +7 -6
  68. package/dist/admin/shell/LoginView.js +2 -2
  69. package/dist/admin/shell/PagesList.js +53 -24
  70. package/dist/admin/shell/SharedForm.js +17 -11
  71. package/dist/admin/shell/SharedList.js +5 -3
  72. package/dist/admin/shell/entries-list.d.ts +4 -0
  73. package/dist/admin/shell/entries-list.js +6 -0
  74. package/dist/admin/shell/pages-tree.d.ts +24 -0
  75. package/dist/admin/shell/pages-tree.js +59 -1
  76. package/dist/admin/tree-ops.d.ts +9 -1
  77. package/dist/admin/tree-ops.js +27 -0
  78. package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
  79. package/dist/admin/ui/LocaleSwitcher.js +28 -8
  80. package/dist/admin/ui/Modal.js +14 -2
  81. package/dist/admin/ui/ResizeHandle.d.ts +1 -0
  82. package/dist/admin/ui/ResizeHandle.js +127 -0
  83. package/dist/admin/ui/primitives.d.ts +29 -4
  84. package/dist/admin/ui/primitives.js +29 -10
  85. package/dist/admin/ui/resize.d.ts +16 -0
  86. package/dist/admin/ui/resize.js +23 -0
  87. package/dist/admin/ui/sortable.d.ts +23 -0
  88. package/dist/admin/ui/sortable.js +57 -0
  89. package/dist/admin/ui/theme.d.ts +3 -1
  90. package/dist/admin/ui/theme.js +512 -165
  91. package/dist/admin/ui/useSortable.d.ts +35 -0
  92. package/dist/admin/ui/useSortable.js +220 -0
  93. package/dist/admin/validate.d.ts +6 -1
  94. package/dist/admin/validate.js +28 -9
  95. package/dist/config.d.ts +1 -0
  96. package/dist/config.js +36 -0
  97. package/dist/entry-store.d.ts +5 -2
  98. package/dist/entry-store.js +4 -3
  99. package/dist/fields.d.ts +4 -0
  100. package/dist/fields.js +2 -2
  101. package/dist/image/SmoodlyImage.d.ts +4 -1
  102. package/dist/image/SmoodlyImage.js +2 -2
  103. package/dist/index.d.ts +8 -1
  104. package/dist/index.js +5 -1
  105. package/dist/marks.d.ts +53 -0
  106. package/dist/marks.js +43 -0
  107. package/dist/media.d.ts +3 -2
  108. package/dist/media.js +48 -6
  109. package/dist/refs.d.ts +2 -2
  110. package/dist/refs.js +33 -12
  111. package/dist/render.js +2 -1
  112. package/dist/resolve.d.ts +8 -2
  113. package/dist/resolve.js +8 -7
  114. package/dist/richtext/index.d.ts +12 -0
  115. package/dist/richtext/index.js +9 -0
  116. package/dist/richtext-render.d.ts +22 -2
  117. package/dist/richtext-render.js +40 -13
  118. package/dist/richtext-walk.d.ts +44 -0
  119. package/dist/richtext-walk.js +46 -0
  120. package/dist/schema.d.ts +1 -1
  121. package/dist/schema.js +13 -1
  122. package/dist/section-wrapper.js +2 -1
  123. package/dist/site.d.ts +2 -0
  124. package/dist/site.js +7 -4
  125. package/dist/store.d.ts +3 -1
  126. package/dist/store.js +4 -4
  127. package/dist/supabase-entry-store.d.ts +1 -0
  128. package/dist/supabase-entry-store.js +2 -1
  129. package/dist/supabase-store.js +1 -1
  130. package/dist/toolset.d.ts +7 -1
  131. package/dist/toolset.js +13 -4
  132. package/package.json +6 -1
@@ -5,18 +5,19 @@
5
5
  // goes dark, the iframe and the outline keep working.
6
6
  import { useCallback, useEffect, useRef, useState } from "react";
7
7
  const GEOMETRY_TIMEOUT_MS = 4000;
8
- export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover }) {
8
+ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRendered }) {
9
9
  const [rects, setRects] = useState([]);
10
10
  const [emptyZones, setEmptyZones] = useState([]);
11
+ const [fields, setFields] = useState([]);
11
12
  const [docH, setDocH] = useState(900);
12
13
  const [geometry, setGeometry] = useState("waiting");
13
14
  // The listener registers once; the handlers are read through refs.
14
- const handlers = useRef({ onSelect, onHover });
15
- handlers.current = { onSelect, onHover };
15
+ const handlers = useRef({ onSelect, onHover, onRendered });
16
+ handlers.current = { onSelect, onHover, onRendered };
16
17
  // A new canvas URL (a locale switch, a rename) means new geometry — the
17
18
  // previous page's rects are stale the instant the iframe starts loading
18
19
  // a different route, so clear them alongside resetting geometry.
19
- useEffect(() => { setGeometry("waiting"); setRects([]); setEmptyZones([]); }, [iframeSrc]);
20
+ useEffect(() => { setGeometry("waiting"); setRects([]); setEmptyZones([]); setFields([]); }, [iframeSrc]);
20
21
  const postToCanvas = useCallback((m) => {
21
22
  iframeRef.current?.contentWindow?.postMessage(m, "*");
22
23
  }, [iframeRef]);
@@ -30,14 +31,18 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover }) {
30
31
  if (m.kind === "rects") {
31
32
  setRects(m.nodes);
32
33
  setEmptyZones(m.emptyZones);
34
+ setFields(m.fields);
33
35
  setDocH(m.docH);
34
36
  setGeometry("ok");
35
37
  }
36
38
  else if (m.kind === "select") {
37
- handlers.current.onSelect(m.id);
39
+ handlers.current.onSelect(m.id, m.path, m.x, m.y);
38
40
  }
39
41
  else if (m.kind === "hover") {
40
- handlers.current.onHover(m.id);
42
+ handlers.current.onHover(m.id, m.path);
43
+ }
44
+ else if (m.kind === "rendered") {
45
+ handlers.current.onRendered();
41
46
  }
42
47
  };
43
48
  window.addEventListener("message", onMessage);
@@ -49,5 +54,5 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover }) {
49
54
  const t = setTimeout(() => setGeometry((g) => (g === "waiting" ? "missing" : g)), GEOMETRY_TIMEOUT_MS);
50
55
  return () => clearTimeout(t);
51
56
  }, [iframeSrc, geometry]);
52
- return { rects, emptyZones, docH, geometry, postToCanvas };
57
+ return { rects, emptyZones, fields, docH, geometry, postToCanvas };
53
58
  }
@@ -0,0 +1,46 @@
1
+ import { type RefObject } from "react";
2
+ import { type Session } from "./inline-session.ts";
3
+ import type { ToBridge } from "./protocol.ts";
4
+ export type RichtextMount = {
5
+ session: Session;
6
+ /** The marked container; its rendered children are hidden while `root` is inside it. */
7
+ container: HTMLElement;
8
+ /** The editor root appended inside the container, in the iframe's document. It is
9
+ * `display: contents`: the container's flex/grid layout goes on the element holding
10
+ * the blocks (InlineRichtext puts `layout` on the ProseMirror root), so the page's
11
+ * gap between paragraphs survives the session. */
12
+ root: HTMLElement;
13
+ /** The container's layout to copy onto the ProseMirror root (inline-dom `layoutToCopy`). */
14
+ layout: Record<string, string>;
15
+ /** Removes the document-level Escape listener; set by the mount, called by the release. */
16
+ unlisten?: () => void;
17
+ /** Where the click landed, iframe client coordinates — the caret goes there. */
18
+ at: {
19
+ x: number;
20
+ y: number;
21
+ } | null;
22
+ /** Ended with changes: read-only until the refresh has rendered (spec §5). */
23
+ closing: boolean;
24
+ };
25
+ type Point = {
26
+ x: number;
27
+ y: number;
28
+ } | null;
29
+ export declare function useInlineSession({ iframeRef, postToCanvas, patch }: {
30
+ iframeRef: RefObject<HTMLIFrameElement | null>;
31
+ postToCanvas(m: ToBridge): void;
32
+ /** Write a field value into the tree by node id and dotted path. */
33
+ patch(node: string, path: string, value: unknown): void;
34
+ }): {
35
+ session: Session | null;
36
+ richtext: RichtextMount | null;
37
+ start: (session: Session, at: Point) => boolean;
38
+ end: () => void;
39
+ writeText: (node: string, path: string, value: string) => void;
40
+ markChanged: () => void;
41
+ onSaved: () => void;
42
+ onRendered: () => void;
43
+ onSelection: (id: string | null) => void;
44
+ onCanvasChange: () => void;
45
+ };
46
+ export {};
@@ -0,0 +1,244 @@
1
+ "use client";
2
+ // The inline editing session's DOM side (spec 2026-09-10 §3–§7). The
3
+ // admin reaches the canvas iframe's document — same origin, the reason
4
+ // the admin is embedded (DESIGN.md §2) — for exactly three things: a text
5
+ // session (contentEditable on the real element), a richtext session (a
6
+ // root appended inside the real container for InlineRichtext to portal
7
+ // into, the rendered children hidden meanwhile) and the instant text
8
+ // write. Everything mutates in place — the site's React keeps owning its
9
+ // nodes, and a refresh reconciles into them. The decisions (one session,
10
+ // the deferred refresh) are the pure reducer in ./inline-session.ts.
11
+ import { useCallback, useEffect, useRef, useState } from "react";
12
+ import { EDITOR_ATTR, FIELD_ATTR, resolveMarkPath } from "../../marks.js";
13
+ import { ADMIN_CSS } from "../ui/theme.js";
14
+ import { isPlainTextHost, layoutToCopy, writeTextInto } from "./inline-dom.js";
15
+ import { INITIAL_INLINE, reduceInline } from "./inline-session.js";
16
+ /** How long a closing richtext editor waits for the refresh's `rendered` before letting go anyway. */
17
+ const CLOSE_FALLBACK_MS = 4000;
18
+ /** On a hidden child: its inline display before the session, restored after. */
19
+ const HIDDEN_ATTR = "data-smd-hidden";
20
+ export function useInlineSession({ iframeRef, postToCanvas, patch }) {
21
+ const [state, setState] = useState(INITIAL_INLINE);
22
+ const stateRef = useRef(state);
23
+ const [richtext, setRichtext] = useState(null);
24
+ const richtextRef = useRef(null);
25
+ const changedRef = useRef(false);
26
+ const textRef = useRef(null);
27
+ const closeTimer = useRef(null);
28
+ const patchRef = useRef(patch);
29
+ patchRef.current = patch;
30
+ const dispatch = useCallback((event) => {
31
+ const { state: next, refresh } = reduceInline(stateRef.current, event);
32
+ stateRef.current = next;
33
+ setState(next);
34
+ if (refresh)
35
+ postToCanvas({ source: "smoodly", kind: "refresh" });
36
+ }, [postToCanvas]);
37
+ /** The page's elements marked for this node and path — never the admin's own editor DOM. */
38
+ const findMarked = useCallback((d, node, path) => Array.from(d.querySelectorAll(`[${FIELD_ATTR}]`)).filter((el) => {
39
+ if (el.closest(`[${EDITOR_ATTR}]`))
40
+ return false;
41
+ const m = resolveMarkPath(el);
42
+ return m !== null && m.node === node && m.path === path;
43
+ }), []);
44
+ // ── richtext: mount and release ────────────────────────────────────────
45
+ const release = useCallback((mount) => {
46
+ if (closeTimer.current) {
47
+ clearTimeout(closeTimer.current);
48
+ closeTimer.current = null;
49
+ }
50
+ mount.unlisten?.();
51
+ richtextRef.current = null;
52
+ setRichtext(null);
53
+ mount.root.remove();
54
+ for (const child of Array.from(mount.container.children)) {
55
+ const was = child.getAttribute(HIDDEN_ATTR);
56
+ if (was === null)
57
+ continue;
58
+ child.style.display = was;
59
+ child.removeAttribute(HIDDEN_ATTR);
60
+ }
61
+ }, []);
62
+ const endRef = useRef(() => { }); // `end`, declared below — both sessions end through it
63
+ const mountRichtext = useCallback((d, session, at) => {
64
+ const container = findMarked(d, session.node, session.path)[0];
65
+ if (!container)
66
+ return false;
67
+ ensureStyles(d);
68
+ const root = d.createElement("div");
69
+ root.setAttribute(EDITOR_ATTR, "");
70
+ root.style.display = "contents";
71
+ const view = d.defaultView;
72
+ const layout = view ? layoutToCopy(view.getComputedStyle(container)) : {};
73
+ for (const child of Array.from(container.children)) {
74
+ child.setAttribute(HIDDEN_ATTR, child.style.display);
75
+ child.style.display = "none";
76
+ }
77
+ container.appendChild(root);
78
+ // Escape with the focus outside the editor — a card's button took it — still ends the
79
+ // session; inside, the editor's own keymap runs first (useRichtextEditor's onEscape).
80
+ const onKey = (e) => {
81
+ // No `instanceof Node`: the target belongs to the iframe's realm, not the admin's.
82
+ if (e.key !== "Escape" || richtextRef.current?.closing || root.contains(e.target))
83
+ return;
84
+ e.preventDefault();
85
+ e.stopPropagation();
86
+ endRef.current();
87
+ };
88
+ d.addEventListener("keydown", onKey, true);
89
+ const mount = { session, container, root, at, closing: false, layout, unlisten: () => d.removeEventListener("keydown", onKey, true) };
90
+ richtextRef.current = mount;
91
+ setRichtext(mount);
92
+ changedRef.current = false;
93
+ return true;
94
+ }, [findMarked]);
95
+ // ── the text session ────────────────────────────────────────────────────
96
+ const mountText = useCallback((d, session, at) => {
97
+ const el = findMarked(d, session.node, session.path).find(isPlainTextHost);
98
+ if (!el)
99
+ return false;
100
+ el.setAttribute("contenteditable", "plaintext-only");
101
+ const onInput = () => patchRef.current(session.node, session.path, el.textContent ?? "");
102
+ const onKey = (e) => {
103
+ if (e.key === "Enter" || e.key === "Escape") {
104
+ e.preventDefault();
105
+ e.stopPropagation();
106
+ endRef.current();
107
+ }
108
+ };
109
+ const onBlur = () => endRef.current();
110
+ el.addEventListener("input", onInput);
111
+ el.addEventListener("keydown", onKey);
112
+ el.addEventListener("blur", onBlur);
113
+ textRef.current = {
114
+ el,
115
+ teardown: () => {
116
+ el.removeEventListener("input", onInput);
117
+ el.removeEventListener("keydown", onKey);
118
+ el.removeEventListener("blur", onBlur);
119
+ el.removeAttribute("contenteditable");
120
+ // Back to one text node holding the value — what the site's React rendered.
121
+ const single = el.childNodes.length === 1 && el.firstChild?.nodeType === 3;
122
+ if (!single)
123
+ el.textContent = el.textContent ?? "";
124
+ },
125
+ };
126
+ el.focus({ preventScroll: true });
127
+ if (at)
128
+ placeCaret(d, at.x, at.y);
129
+ return true;
130
+ }, [findMarked]);
131
+ // ── start and end ───────────────────────────────────────────────────────
132
+ const end = useCallback(() => {
133
+ if (!stateRef.current.session)
134
+ return;
135
+ const text = textRef.current;
136
+ const mount = richtextRef.current;
137
+ const changed = changedRef.current;
138
+ // The reducer first: the blur that removing contenteditable fires re-enters here and must see no session.
139
+ dispatch({ type: "end" });
140
+ if (text) {
141
+ textRef.current = null;
142
+ text.teardown();
143
+ }
144
+ if (mount && !mount.closing) {
145
+ if (changed) {
146
+ // Stay mounted read-only until the refresh has rendered, so the old markup never flashes.
147
+ const closing = { ...mount, closing: true };
148
+ richtextRef.current = closing;
149
+ setRichtext(closing);
150
+ closeTimer.current = setTimeout(() => { if (richtextRef.current === closing)
151
+ release(closing); }, CLOSE_FALLBACK_MS);
152
+ }
153
+ else {
154
+ release(mount);
155
+ }
156
+ }
157
+ }, [dispatch, release]);
158
+ endRef.current = end;
159
+ const start = useCallback((session, at) => {
160
+ const d = iframeRef.current?.contentDocument;
161
+ if (!d)
162
+ return false;
163
+ // A closing editor still holds its container: a new session on that field waits; any other releases it.
164
+ const mount = richtextRef.current;
165
+ if (mount?.closing) {
166
+ if (mount.session.node === session.node && mount.session.path === session.path)
167
+ return false;
168
+ release(mount);
169
+ }
170
+ end();
171
+ const ok = session.kind === "text" ? mountText(d, session, at) : mountRichtext(d, session, at);
172
+ if (ok)
173
+ dispatch({ type: "start", session });
174
+ return ok;
175
+ }, [iframeRef, dispatch, end, release, mountText, mountRichtext]);
176
+ // ── the instant write (spec §7) ─────────────────────────────────────────
177
+ const writeText = useCallback((node, path, value) => {
178
+ const d = iframeRef.current?.contentDocument;
179
+ if (!d)
180
+ return;
181
+ for (const el of findMarked(d, node, path)) {
182
+ if (textRef.current?.el === el)
183
+ continue; // the session's own element is the source
184
+ writeTextInto(el, value);
185
+ }
186
+ }, [iframeRef, findMarked]);
187
+ // ── signals from the editor ─────────────────────────────────────────────
188
+ const markChanged = useCallback(() => { changedRef.current = true; }, []);
189
+ const onSaved = useCallback(() => dispatch({ type: "saved" }), [dispatch]);
190
+ const onRendered = useCallback(() => {
191
+ const mount = richtextRef.current;
192
+ if (mount?.closing)
193
+ release(mount);
194
+ }, [release]);
195
+ const onSelection = useCallback((id) => {
196
+ const s = stateRef.current.session;
197
+ if (s && s.node !== id)
198
+ end();
199
+ }, [end]);
200
+ const onCanvasChange = useCallback(() => {
201
+ // The old document is gone: tear down without a refresh, tolerating detached nodes.
202
+ const text = textRef.current;
203
+ textRef.current = null;
204
+ if (text) {
205
+ try {
206
+ text.teardown();
207
+ }
208
+ catch { /* the element left with its document */ }
209
+ }
210
+ const mount = richtextRef.current;
211
+ if (mount)
212
+ release(mount);
213
+ dispatch({ type: "canvas" });
214
+ }, [dispatch, release]);
215
+ useEffect(() => () => { if (closeTimer.current)
216
+ clearTimeout(closeTimer.current); }, []);
217
+ return { session: state.session, richtext, start, end, writeText, markChanged, onSaved, onRendered, onSelection, onCanvasChange };
218
+ }
219
+ /** The admin stylesheet inside the iframe, once: the cards inside the canvas editor are admin UI. */
220
+ function ensureStyles(d) {
221
+ if (d.querySelector("style[data-smoodly-admin-styles]"))
222
+ return;
223
+ const style = d.createElement("style");
224
+ style.setAttribute("data-smoodly-admin-styles", "");
225
+ style.textContent = ADMIN_CSS;
226
+ d.head.appendChild(style);
227
+ }
228
+ /** The caret at a point, for the text session (spec §4). */
229
+ function placeCaret(d, x, y) {
230
+ const sel = d.getSelection();
231
+ if (!sel)
232
+ return;
233
+ const doc = d;
234
+ const pos = doc.caretPositionFromPoint?.(x, y);
235
+ if (pos) {
236
+ sel.collapse(pos.offsetNode, pos.offset);
237
+ return;
238
+ }
239
+ const range = doc.caretRangeFromPoint?.(x, y);
240
+ if (range) {
241
+ sel.removeAllRanges();
242
+ sel.addRange(range);
243
+ }
244
+ }
@@ -0,0 +1,13 @@
1
+ import type { AdminField } from "./serialize.ts";
2
+ export declare const splitPath: (path: string) => string[];
3
+ /** The descriptor at `path` in `section.fields`; null when the path is
4
+ * not in the schema. A list without an item descriptor is a list of text
5
+ * (the same default FieldWidget renders). */
6
+ export declare function descriptorAt(section: {
7
+ fields: Record<string, AdminField>;
8
+ }, path: string): AdminField | null;
9
+ export declare function getAtPath(fields: Record<string, unknown> | undefined, path: string): unknown;
10
+ /** `fields` with the value at `path` replaced. Copies only the spine —
11
+ * siblings keep their identity — and keeps arrays arrays. Missing
12
+ * objects along the way are created. */
13
+ export declare function setAtPath(fields: Record<string, unknown> | undefined, path: string, value: unknown): Record<string, unknown>;
@@ -0,0 +1,49 @@
1
+ export const splitPath = (path) => path.split(".").filter((s) => s !== "");
2
+ const isIndex = (s) => /^\d+$/.test(s);
3
+ /** The descriptor at `path` in `section.fields`; null when the path is
4
+ * not in the schema. A list without an item descriptor is a list of text
5
+ * (the same default FieldWidget renders). */
6
+ export function descriptorAt(section, path) {
7
+ const segments = splitPath(path);
8
+ if (segments.length === 0)
9
+ return null;
10
+ let d = section.fields[segments[0]];
11
+ for (const seg of segments.slice(1)) {
12
+ if (!d)
13
+ return null;
14
+ if (d.type === "list")
15
+ d = isIndex(seg) ? d.item ?? { type: "text" } : undefined;
16
+ else if (d.type === "object")
17
+ d = d.fields?.[seg];
18
+ else
19
+ return null;
20
+ }
21
+ return d ?? null;
22
+ }
23
+ export function getAtPath(fields, path) {
24
+ let v = fields;
25
+ for (const seg of splitPath(path)) {
26
+ if (v === null || typeof v !== "object")
27
+ return undefined;
28
+ v = v[seg];
29
+ }
30
+ return v;
31
+ }
32
+ /** `fields` with the value at `path` replaced. Copies only the spine —
33
+ * siblings keep their identity — and keeps arrays arrays. Missing
34
+ * objects along the way are created. */
35
+ export function setAtPath(fields, path, value) {
36
+ const set = (obj, segments) => {
37
+ if (segments.length === 0)
38
+ return value;
39
+ const [head, ...rest] = segments;
40
+ if (Array.isArray(obj)) {
41
+ const copy = obj.slice();
42
+ copy[Number(head)] = set(obj[Number(head)], rest);
43
+ return copy;
44
+ }
45
+ const o = (obj !== null && typeof obj === "object" ? obj : {});
46
+ return { ...o, [head]: set(o[head], rest) };
47
+ };
48
+ return set(fields ?? {}, splitPath(path));
49
+ }
@@ -1,6 +1,6 @@
1
- import { type ReactElement } from "react";
1
+ import type { ReactElement, ReactNode } from "react";
2
2
  import type { AdminField } from "../serialize.ts";
3
- export declare function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, }: {
3
+ export declare function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, mark, inlinePath, onEditHere, }: {
4
4
  descriptor: AdminField;
5
5
  value: unknown;
6
6
  onChange(value: unknown): void;
@@ -16,4 +16,9 @@ export declare function FieldWidget({ descriptor: d, value, onChange, errors, pa
16
16
  }[];
17
17
  fieldKey: string;
18
18
  prefix?: string;
19
+ /** A glyph after the label — the forms' "same in every language" mark. */
20
+ mark?: ReactNode;
21
+ /** The path of the field being edited in the canvas: its richtext widget yields to a note. */
22
+ inlinePath?: string;
23
+ onEditHere?(): void;
19
24
  }): ReactElement;
@@ -1,23 +1,14 @@
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";
5
+ import { ListField } from "./ListField.js";
11
6
  import { Button, Field, IconButton } from "../ui/primitives.js";
12
- import { addItem, itemSummary, moveIndex, moveItem, patchObject, removeIndex, removeItem } from "./list.js";
7
+ import { patchObject } from "./list.js";
13
8
  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, }) {
9
+ export function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, mark, inlinePath, onEditHere, }) {
15
10
  const path = pathProp ?? fieldKey;
16
11
  const message = errors?.[path];
17
- // Unused for non-list types, but hooks must be unconditional — a list's
18
- // rows key their collapse state to this rather than to array position,
19
- // so a move or remove doesn't reassign which row looks open.
20
- const [collapsed, setCollapsed] = useState(() => new Set());
21
12
  const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
22
13
  let control;
23
14
  let counter;
@@ -44,7 +35,9 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
44
35
  case "richtext":
45
36
  // The toolbar holds buttons, so this control can never sit in a <label>.
46
37
  asDiv = true;
47
- control = _jsx(RichtextWidget, { value: value, toolset: d.toolset, onChange: onChange });
38
+ control = inlinePath === path
39
+ ? _jsx(InlineNote, { onEditHere: onEditHere })
40
+ : _jsx(RichtextWidget, { value: value, toolset: d.toolset, onChange: onChange, errors: errors, path: path });
48
41
  break;
49
42
  case "image":
50
43
  case "video":
@@ -64,33 +57,16 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
64
57
  case "object": {
65
58
  asDiv = true;
66
59
  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))) }));
60
+ 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
61
  break;
69
62
  }
70
63
  case "list": {
71
64
  asDiv = true;
72
65
  const items = Array.isArray(value) ? value : [];
73
66
  const item = d.item ?? { type: "text" };
74
- const atMax = typeof d.max === "number" && items.length >= d.max;
75
67
  if (typeof d.max === "number")
76
68
  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" })] }));
69
+ 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
70
  break;
95
71
  }
96
72
  default:
@@ -102,13 +78,9 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
102
78
  // A <label> activates its first labelable descendant, so it may only wrap a
103
79
  // single control — around refList's rows, an object's fieldset or a list's
104
80
  // 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 }));
81
+ return (_jsx(Field, { label: label(fieldKey), mark: mark, counter: counter, error: message, asDiv: asDiv, path: path, children: control }));
106
82
  }
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 })] }));
83
+ /** The richtext field while it is edited in the canvas (spec 2026-09-10 §5). */
84
+ function InlineNote({ onEditHere }) {
85
+ 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
86
  }
@@ -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, the alt, and Replace / Edit / Remove.
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" ? (_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: asset.width && asset.height ? `${asset.width} / ${asset.height}` : "16 / 9" }, children: _jsx(AssetThumb, { asset: asset, className: "sm-mediacard__img" }) })) : (_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) })] }));
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,3 @@
1
+ import { type ReactElement } from "react";
2
+ import { type NodeViewProps } from "@tiptap/react";
3
+ export declare function RichtextElementCard({ node, editor, getPos, updateAttributes, deleteNode, selected }: NodeViewProps): ReactElement;