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.
Files changed (159) hide show
  1. package/README.md +294 -40
  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 +135 -28
  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/PageInspector.d.ts +21 -0
  12. package/dist/admin/editor/PageInspector.js +24 -0
  13. package/dist/admin/editor/PageSettings.d.ts +1 -3
  14. package/dist/admin/editor/PageSettings.js +7 -6
  15. package/dist/admin/editor/SeoSettings.d.ts +7 -0
  16. package/dist/admin/editor/SeoSettings.js +15 -0
  17. package/dist/admin/editor/inline-dom.d.ts +27 -0
  18. package/dist/admin/editor/inline-dom.js +43 -0
  19. package/dist/admin/editor/inline-session.d.ts +30 -0
  20. package/dist/admin/editor/inline-session.js +19 -0
  21. package/dist/admin/editor/protocol.d.ts +22 -1
  22. package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
  23. package/dist/admin/editor/useCanvasBridge.js +12 -7
  24. package/dist/admin/editor/useInlineSession.d.ts +46 -0
  25. package/dist/admin/editor/useInlineSession.js +244 -0
  26. package/dist/admin/field-paths.d.ts +13 -0
  27. package/dist/admin/field-paths.js +49 -0
  28. package/dist/admin/forms/FieldWidget.d.ts +7 -2
  29. package/dist/admin/forms/FieldWidget.js +35 -43
  30. package/dist/admin/forms/ListField.d.ts +10 -0
  31. package/dist/admin/forms/ListField.js +42 -0
  32. package/dist/admin/forms/MediaWidget.js +10 -3
  33. package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
  34. package/dist/admin/forms/RichtextElementCard.js +37 -0
  35. package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
  36. package/dist/admin/forms/RichtextImageCard.js +42 -0
  37. package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
  38. package/dist/admin/forms/RichtextInsertMenu.js +6 -0
  39. package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
  40. package/dist/admin/forms/RichtextPopovers.js +25 -0
  41. package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
  42. package/dist/admin/forms/RichtextToolbar.js +4 -2
  43. package/dist/admin/forms/RichtextWidget.d.ts +4 -1
  44. package/dist/admin/forms/RichtextWidget.js +23 -120
  45. package/dist/admin/forms/elements-context.d.ts +7 -0
  46. package/dist/admin/forms/elements-context.js +13 -0
  47. package/dist/admin/forms/reveal.d.ts +5 -0
  48. package/dist/admin/forms/reveal.js +47 -0
  49. package/dist/admin/forms/richtext-extensions.d.ts +8 -0
  50. package/dist/admin/forms/richtext-extensions.js +11 -0
  51. package/dist/admin/forms/richtext-form-context.d.ts +16 -0
  52. package/dist/admin/forms/richtext-form-context.js +18 -0
  53. package/dist/admin/forms/richtext-insert.d.ts +38 -0
  54. package/dist/admin/forms/richtext-insert.js +37 -0
  55. package/dist/admin/forms/richtext-nodes.d.ts +37 -0
  56. package/dist/admin/forms/richtext-nodes.js +85 -0
  57. package/dist/admin/forms/richtext-paste.js +5 -0
  58. package/dist/admin/forms/tabs.d.ts +3 -0
  59. package/dist/admin/forms/tabs.js +6 -0
  60. package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
  61. package/dist/admin/forms/useRichtextEditor.js +281 -0
  62. package/dist/admin/next/bridge.js +51 -20
  63. package/dist/admin/ops-impl.js +85 -19
  64. package/dist/admin/richtext-doc.d.ts +3 -0
  65. package/dist/admin/richtext-doc.js +12 -0
  66. package/dist/admin/serialize.d.ts +11 -0
  67. package/dist/admin/serialize.js +18 -1
  68. package/dist/admin/shell/AdminApp.js +2 -1
  69. package/dist/admin/shell/EntriesList.js +34 -12
  70. package/dist/admin/shell/EntryForm.js +22 -15
  71. package/dist/admin/shell/IconRail.js +6 -5
  72. package/dist/admin/shell/ListView.d.ts +9 -2
  73. package/dist/admin/shell/ListView.js +7 -6
  74. package/dist/admin/shell/LoginView.js +2 -2
  75. package/dist/admin/shell/PagesList.js +53 -24
  76. package/dist/admin/shell/SharedForm.js +17 -11
  77. package/dist/admin/shell/SharedList.js +5 -3
  78. package/dist/admin/shell/entries-list.d.ts +4 -0
  79. package/dist/admin/shell/entries-list.js +6 -0
  80. package/dist/admin/shell/pages-tree.d.ts +24 -0
  81. package/dist/admin/shell/pages-tree.js +59 -1
  82. package/dist/admin/tree-ops.d.ts +11 -1
  83. package/dist/admin/tree-ops.js +32 -1
  84. package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
  85. package/dist/admin/ui/LocaleSwitcher.js +28 -8
  86. package/dist/admin/ui/Modal.js +14 -2
  87. package/dist/admin/ui/ResizeHandle.d.ts +1 -0
  88. package/dist/admin/ui/ResizeHandle.js +127 -0
  89. package/dist/admin/ui/primitives.d.ts +38 -4
  90. package/dist/admin/ui/primitives.js +35 -10
  91. package/dist/admin/ui/resize.d.ts +16 -0
  92. package/dist/admin/ui/resize.js +23 -0
  93. package/dist/admin/ui/sortable.d.ts +23 -0
  94. package/dist/admin/ui/sortable.js +57 -0
  95. package/dist/admin/ui/theme.d.ts +3 -1
  96. package/dist/admin/ui/theme.js +527 -165
  97. package/dist/admin/ui/useSortable.d.ts +35 -0
  98. package/dist/admin/ui/useSortable.js +220 -0
  99. package/dist/admin/validate.d.ts +6 -1
  100. package/dist/admin/validate.js +52 -12
  101. package/dist/collections.d.ts +6 -5
  102. package/dist/config.d.ts +1 -0
  103. package/dist/config.js +36 -0
  104. package/dist/entry-store.d.ts +23 -3
  105. package/dist/entry-store.js +31 -5
  106. package/dist/field-defaults.d.ts +8 -0
  107. package/dist/field-defaults.js +18 -0
  108. package/dist/fields.d.ts +30 -1
  109. package/dist/fields.js +33 -3
  110. package/dist/image/SmoodlyImage.d.ts +4 -1
  111. package/dist/image/SmoodlyImage.js +2 -2
  112. package/dist/index.d.ts +16 -7
  113. package/dist/index.js +10 -4
  114. package/dist/marks.d.ts +53 -0
  115. package/dist/marks.js +43 -0
  116. package/dist/media.d.ts +3 -2
  117. package/dist/media.js +48 -6
  118. package/dist/next/index.d.ts +2 -0
  119. package/dist/next/index.js +1 -0
  120. package/dist/next/meta.d.ts +7 -3
  121. package/dist/next/meta.js +18 -9
  122. package/dist/next/page-renderer.js +1 -1
  123. package/dist/next/queries.d.ts +36 -0
  124. package/dist/next/queries.js +87 -0
  125. package/dist/page-tree.d.ts +3 -0
  126. package/dist/page-tree.js +6 -0
  127. package/dist/page.d.ts +11 -3
  128. package/dist/page.js +26 -0
  129. package/dist/pairing.d.ts +2 -1
  130. package/dist/pairing.js +2 -9
  131. package/dist/refs.d.ts +2 -2
  132. package/dist/refs.js +33 -12
  133. package/dist/render.d.ts +14 -7
  134. package/dist/render.js +4 -3
  135. package/dist/resolve.d.ts +12 -2
  136. package/dist/resolve.js +16 -7
  137. package/dist/revalidate.d.ts +6 -0
  138. package/dist/revalidate.js +6 -0
  139. package/dist/richtext/index.d.ts +12 -0
  140. package/dist/richtext/index.js +9 -0
  141. package/dist/richtext-render.d.ts +22 -2
  142. package/dist/richtext-render.js +40 -13
  143. package/dist/richtext-walk.d.ts +44 -0
  144. package/dist/richtext-walk.js +46 -0
  145. package/dist/schema.d.ts +1 -1
  146. package/dist/schema.js +13 -1
  147. package/dist/section-wrapper.js +2 -1
  148. package/dist/shared.d.ts +7 -7
  149. package/dist/site.d.ts +25 -1
  150. package/dist/site.js +89 -22
  151. package/dist/store.d.ts +7 -1
  152. package/dist/store.js +13 -4
  153. package/dist/supabase-entry-store.d.ts +1 -0
  154. package/dist/supabase-entry-store.js +34 -11
  155. package/dist/supabase-store.d.ts +1 -0
  156. package/dist/supabase-store.js +14 -1
  157. package/dist/toolset.d.ts +7 -1
  158. package/dist/toolset.js +13 -4
  159. package/package.json +6 -1
@@ -0,0 +1,43 @@
1
+ // The DOM rules of a session (spec 2026-09-10 §3–§5), pure over structural
2
+ // types so they run under bare node: what a text session may edit, how a
3
+ // value is written in place, and which layout the richtext editor root
4
+ // copies from its container.
5
+ /** A marked element the text session may edit: no child elements. A mark
6
+ * on a container (an <a> inside a <p>) falls through to the inspector. */
7
+ export const isPlainTextHost = (el) => el.childElementCount === 0;
8
+ /** Write `value` in place: into the one existing text node when there is
9
+ * one, so the site's React keeps pointing at the node it rendered and a
10
+ * refresh reconciles into it; else as new text content. False when the
11
+ * element is not a plain text host. */
12
+ export function writeTextInto(el, value) {
13
+ if (!isPlainTextHost(el))
14
+ return false;
15
+ const first = el.firstChild;
16
+ if (first && first.nodeType === 3 && first.nextSibling === null) {
17
+ if (first.nodeValue !== value)
18
+ first.nodeValue = value;
19
+ }
20
+ else {
21
+ el.textContent = value;
22
+ }
23
+ return true;
24
+ }
25
+ /** The container's layout the richtext editor root copies (spec §5), so a
26
+ * flex-column or grid container keeps its block spacing while its
27
+ * children are hidden behind the editor. Kebab-cased, for setProperty. */
28
+ export const LAYOUT_PROPS = [
29
+ "display", "flex-direction", "gap", "row-gap", "column-gap",
30
+ "grid-template-columns", "grid-auto-flow", "align-items", "justify-content",
31
+ ];
32
+ export function layoutToCopy(computed) {
33
+ const display = computed.getPropertyValue("display");
34
+ if (display !== "flex" && display !== "grid")
35
+ return {};
36
+ const out = {};
37
+ for (const prop of LAYOUT_PROPS) {
38
+ const v = computed.getPropertyValue(prop);
39
+ if (v)
40
+ out[prop] = v;
41
+ }
42
+ return out;
43
+ }
@@ -0,0 +1,30 @@
1
+ export type SessionKind = "text" | "richtext";
2
+ export type Session = {
3
+ node: string;
4
+ path: string;
5
+ kind: SessionKind;
6
+ };
7
+ export type InlineState = {
8
+ session: Session | null;
9
+ pendingRefresh: boolean;
10
+ };
11
+ export type InlineEvent = {
12
+ type: "start";
13
+ session: Session;
14
+ } | {
15
+ type: "end";
16
+ }
17
+ /** The autosave's save ack arrived. */
18
+ | {
19
+ type: "saved";
20
+ }
21
+ /** The iframe points at a new URL: nothing from the old canvas survives. */
22
+ | {
23
+ type: "canvas";
24
+ };
25
+ export declare const INITIAL_INLINE: InlineState;
26
+ /** The next state, and whether to post a refresh to the bridge now. */
27
+ export declare function reduceInline(state: InlineState, event: InlineEvent): {
28
+ state: InlineState;
29
+ refresh: boolean;
30
+ };
@@ -0,0 +1,19 @@
1
+ export const INITIAL_INLINE = { session: null, pendingRefresh: false };
2
+ /** The next state, and whether to post a refresh to the bridge now. */
3
+ export function reduceInline(state, event) {
4
+ switch (event.type) {
5
+ case "start":
6
+ // A pending refresh stays pending: it fires only once no session is active.
7
+ return { state: { session: event.session, pendingRefresh: state.pendingRefresh }, refresh: false };
8
+ case "end":
9
+ if (!state.session)
10
+ return { state, refresh: false };
11
+ return { state: INITIAL_INLINE, refresh: state.pendingRefresh };
12
+ case "saved":
13
+ return state.session
14
+ ? { state: { session: state.session, pendingRefresh: true }, refresh: false }
15
+ : { state, refresh: true };
16
+ case "canvas":
17
+ return { state: INITIAL_INLINE, refresh: false };
18
+ }
19
+ }
@@ -12,20 +12,41 @@ export type ZoneRect = {
12
12
  w: number;
13
13
  h: number;
14
14
  };
15
+ /** A marked element (spec 2026-09-10 §6): its section node and its resolved dotted path. */
16
+ export type FieldRect = {
17
+ node: string;
18
+ path: string;
19
+ x: number;
20
+ y: number;
21
+ w: number;
22
+ h: number;
23
+ };
15
24
  export type FromBridge = {
16
25
  source: "smoodly";
17
26
  kind: "rects";
18
27
  nodes: BridgeRect[];
19
28
  emptyZones: ZoneRect[];
29
+ fields: FieldRect[];
20
30
  docH: number;
21
- } | {
31
+ }
32
+ /** A click: the node under it, the marked field under it, and where (iframe client coordinates). */
33
+ | {
22
34
  source: "smoodly";
23
35
  kind: "select";
24
36
  id: string | null;
37
+ path: string | null;
38
+ x: number;
39
+ y: number;
25
40
  } | {
26
41
  source: "smoodly";
27
42
  kind: "hover";
28
43
  id: string | null;
44
+ path: string | null;
45
+ }
46
+ /** The bridge committed a render — after a refresh, the new markup is in the DOM. */
47
+ | {
48
+ source: "smoodly";
49
+ kind: "rendered";
29
50
  };
30
51
  export type ToBridge = {
31
52
  source: "smoodly";
@@ -1,14 +1,18 @@
1
1
  import { type RefObject } from "react";
2
- import type { BridgeRect, ToBridge, ZoneRect } from "./protocol.ts";
2
+ import type { BridgeRect, FieldRect, ToBridge, ZoneRect } from "./protocol.ts";
3
3
  export type Geometry = "waiting" | "ok" | "missing";
4
- export declare function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover }: {
4
+ export declare function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRendered }: {
5
5
  iframeRef: RefObject<HTMLIFrameElement | null>;
6
6
  iframeSrc: string | null;
7
- onSelect(id: string | null): void;
8
- onHover(id: string | null): void;
7
+ /** A click in the canvas: the node, the marked field under it (or null), and where in the iframe. */
8
+ onSelect(id: string | null, path: string | null, x: number, y: number): void;
9
+ onHover(id: string | null, path: string | null): void;
10
+ /** The bridge committed a render (the mount, then every refresh). */
11
+ onRendered(): void;
9
12
  }): {
10
13
  rects: BridgeRect[];
11
14
  emptyZones: ZoneRect[];
15
+ fields: FieldRect[];
12
16
  docH: number;
13
17
  geometry: Geometry;
14
18
  postToCanvas: (m: ToBridge) => void;
@@ -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: valueProp, 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;