smoodly 0.0.11 → 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 +259 -38
  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 +10 -1
@@ -8,7 +8,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
8
8
  // the geometry the bridge reports map 1:1 onto the overlay layer above
9
9
  // it. Every gesture mutates client tree state through Task 4's pure ops;
10
10
  // a debounced saveDraft persists, and the bridge refreshes the iframe on
11
- // the save ack (refresh-on-change preview, DESIGN.md §3). Every load,
11
+ // the save ack (refresh-on-change preview, DESIGN.md §3). Inline editing
12
+ // (spec 2026-09-10) runs beside it: `useInlineSession` owns the sessions
13
+ // in the iframe's document, `InlineRichtext` the canvas editor, and the
14
+ // save-ack refresh waits for a session to end. Every load,
12
15
  // edit, autosave and publish targets ONE locale — the page's own tree in
13
16
  // that locale (spec 2026-09-06 §6); switching flushes the pending save
14
17
  // first.
@@ -26,11 +29,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
26
29
  // that knows about Next, and it ships to the site, not the admin.
27
30
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
28
31
  import { SHARED_NODE_TYPE } from "../../shared.js";
32
+ import { descriptorAt, getAtPath } from "../field-paths.js";
29
33
  import { FieldWidget } from "../forms/FieldWidget.js";
30
- import { tabsFor } from "../forms/tabs.js";
31
- import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
34
+ import { revealField } from "../forms/reveal.js";
35
+ import { tabOf, tabsFor } from "../forms/tabs.js";
36
+ import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodePath, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
32
37
  import { useDraftAutosave } from "./autosave.js";
33
38
  import { CanvasOverlay } from "./CanvasOverlay.js";
39
+ import { InlineRichtext } from "./InlineRichtext.js";
34
40
  import { InsertPicker } from "./InsertPicker.js";
35
41
  import { NoticePane } from "./NoticePane.js";
36
42
  import { Outline } from "./Outline.js";
@@ -38,13 +44,15 @@ import { PageSettings } from "./PageSettings.js";
38
44
  import { localeFromUrl } from "./locale-url.js";
39
45
  import { plusButtonsFor } from "./overlay-geometry.js";
40
46
  import { useCanvasBridge } from "./useCanvasBridge.js";
47
+ import { useInlineSession } from "./useInlineSession.js";
41
48
  import { usePageLocale } from "./usePageLocale.js";
42
49
  import { BASE } from "../shell/base.js";
43
50
  import { RouteLink } from "../shell/RouteLink.js";
44
51
  import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
45
52
  import { entryTitle } from "../shell/entries-list.js";
46
53
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
47
- import { Banner as BannerBox, Breadcrumb, Button, Divider, IconButton, LinkButton, SaveIndicator, StatusBadge, Tabs, TopBar, } from "../ui/primitives.js";
54
+ import { ResizeHandle } from "../ui/ResizeHandle.js";
55
+ import { Banner as BannerBox, Breadcrumb, Button, IconButton, LinkButton, SaveIndicator, StatusBadge, Tabs, TopBar, } from "../ui/primitives.js";
48
56
  export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
49
57
  export function EditorView({ ops, registry, id }) {
50
58
  const [record, setRecord] = useState(null);
@@ -59,6 +67,11 @@ export function EditorView({ ops, registry, id }) {
59
67
  const [parentLocales, setParentLocales] = useState([]);
60
68
  const [selectedId, setSelectedId] = useState(null);
61
69
  const [hoverId, setHoverId] = useState(null);
70
+ const [hoverField, setHoverField] = useState(null);
71
+ const [revealReq, setRevealReq] = useState(null);
72
+ const inspectorRef = useRef(null);
73
+ // The session hook is created after `mutate`; the autosave and the bridge reach it through this ref.
74
+ const inlineRef = useRef(null);
62
75
  const [picker, setPicker] = useState(null);
63
76
  const [tab, setTab] = useState("Content");
64
77
  const [banner, setBanner] = useState(null);
@@ -76,11 +89,12 @@ export function EditorView({ ops, registry, id }) {
76
89
  // flushes first (switchLocale), so the ref and the tree always agree.
77
90
  const treeLocaleRef = useRef(localeFromUrl(registry));
78
91
  // Every edit marks the draft dirty; a debounced saveDraft persists, with
79
- // retry on failure, and the bridge refreshes the iframe on the save ack.
92
+ // retry on failure, and the bridge refreshes the iframe on the save ack
93
+ // now, or once an inline session ends (spec 2026-09-10 §7).
80
94
  const { saveState, mark, flush } = useDraftAutosave({
81
95
  read: () => treeRef.current,
82
96
  persist: (t) => ops.pages.saveDraft(id, treeLocaleRef.current, t),
83
- onSaved: () => postToCanvas({ source: "smoodly", kind: "refresh" }),
97
+ onSaved: () => inlineRef.current?.onSaved(),
84
98
  });
85
99
  const { locale, adding, switchLocale, doAddLocale, doRemoveLocale } = usePageLocale({
86
100
  registry, ops, id, record, setRecord, flush,
@@ -97,8 +111,15 @@ export function EditorView({ ops, registry, id }) {
97
111
  const iframeSrc = ready ? withEditorParam(siteUrl) : null;
98
112
  // Canvas-originated selection: the node is under the cursor already, so
99
113
  // this path deliberately skips the scroll every other selection does.
100
- const { rects, emptyZones, docH, geometry, postToCanvas } = useCanvasBridge({
101
- iframeRef, iframeSrc, onSelect: setSelectedId, onHover: setHoverId,
114
+ // (`onCanvasSelect` is declared further down; the handler runs at event time.)
115
+ const { rects, emptyZones, fields: fieldRects, docH, geometry, postToCanvas } = useCanvasBridge({
116
+ iframeRef, iframeSrc,
117
+ onSelect: (id, path, x, y) => onCanvasSelect(id, path, x, y),
118
+ onHover: (id, path) => {
119
+ setHoverId(id);
120
+ setHoverField((prev) => (prev && prev.node === id && prev.path === path ? prev : id && path ? { node: id, path } : null));
121
+ },
122
+ onRendered: () => inlineRef.current?.onRendered(),
102
123
  });
103
124
  // ── load: draft mode FIRST, then the record; only then point the iframe
104
125
  // at the site route (an iframe loaded before the cookie exists would
@@ -291,6 +312,71 @@ export function EditorView({ ops, registry, id }) {
291
312
  });
292
313
  mark();
293
314
  }, [mark]);
315
+ // ── inline editing (spec 2026-09-10) ──────────────────────────────────
316
+ const inline = useInlineSession({
317
+ iframeRef, postToCanvas,
318
+ patch: (node, path, value) => {
319
+ mutate((t) => patchNodePath(t, node, path, value));
320
+ // A second element marked with the same field follows the one being typed in.
321
+ if (typeof value === "string")
322
+ inlineRef.current?.writeText(node, path, value);
323
+ },
324
+ });
325
+ inlineRef.current = inline;
326
+ // A different selection ends the session; a new canvas drops everything.
327
+ useEffect(() => { inline.onSelection(selectedId); }, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps
328
+ useEffect(() => { inline.onCanvasChange(); }, [iframeSrc]); // eslint-disable-line react-hooks/exhaustive-deps
329
+ /** Inspector edits of text fields land in the canvas the same tick (spec §7): every
330
+ * marked text element of the node, by its reported path, from the fields after the patch. */
331
+ const syncTexts = (id, section, fields) => {
332
+ for (const f of fieldRects) {
333
+ if (f.node !== id)
334
+ continue;
335
+ const d = descriptorAt(section, f.path);
336
+ const v = getAtPath(fields, f.path);
337
+ if (d?.type === "text" && typeof v === "string")
338
+ inline.writeText(id, f.path, v);
339
+ }
340
+ };
341
+ /** Scroll the inspector to a field's widget, pulse it, focus it (spec §8). The field's tab first —
342
+ * marks address content fields, and the projection puts a content image on Media. */
343
+ const reveal = (path, section) => setRevealReq((r) => ({ path, tab: tabOf(section, path) ?? "Content", n: (r?.n ?? 0) + 1 }));
344
+ useEffect(() => {
345
+ if (!revealReq)
346
+ return;
347
+ setTab(revealReq.tab);
348
+ const frame = requestAnimationFrame(() => { if (inspectorRef.current)
349
+ revealField(inspectorRef.current, revealReq.path); });
350
+ return () => cancelAnimationFrame(frame);
351
+ }, [revealReq]);
352
+ /** A click in the canvas: on a marked field of the selected section it acts on the field (spec §2), else it selects. */
353
+ const onCanvasSelect = (id, path, x, y) => {
354
+ if (id && id === selectedId && path) {
355
+ onFieldClick(id, path, x, y);
356
+ return;
357
+ }
358
+ setSelectedId(id);
359
+ };
360
+ const onFieldClick = (id, path, x, y) => {
361
+ const node = tree ? findNode(tree, id) : null;
362
+ const section = node ? sectionOf(node.type) : null;
363
+ if (!node || !section)
364
+ return; // a shared placement is edited under Globals — nothing inline here
365
+ const descriptor = descriptorAt(section, path);
366
+ if (!descriptor) {
367
+ if (process.env.NODE_ENV !== "production") {
368
+ console.warn(`smoodly: "${section.name}" has no field at "${path}" — check its data-smd-field / data-smd-item marks.`);
369
+ }
370
+ return;
371
+ }
372
+ if (inline.session && inline.session.node === id && inline.session.path === path)
373
+ return;
374
+ const kind = descriptor.type === "text" ? "text" : descriptor.type === "richtext" ? "richtext" : null;
375
+ if (kind && inline.start({ node: id, path, kind }, { x, y }))
376
+ return;
377
+ // Any other field, or a text mark on a container: the inspector reveals its widget.
378
+ reveal(path, section);
379
+ };
294
380
  // A zone bound to a shared item needs the item's row id (ops.shared.list),
295
381
  // which can land after the draft did. Fill then; nothing else changes.
296
382
  useEffect(() => {
@@ -350,6 +436,7 @@ export function EditorView({ ops, registry, id }) {
350
436
  setSelectedId((current) => (current === id ? null : current));
351
437
  };
352
438
  const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir));
439
+ const doMoveTo = (id, index) => mutate((t) => moveNodeTo(t, id, index));
353
440
  const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value));
354
441
  // ── publish ──────────────────────────────────────────────────────────
355
442
  const doPublish = async () => {
@@ -396,7 +483,7 @@ export function EditorView({ ops, registry, id }) {
396
483
  return _jsx(NoticePane, { children: _jsx(BannerBox, { tone: "error", children: loadError }) });
397
484
  }
398
485
  if (folder) {
399
- return (_jsx(NoticePane, { children: _jsx(BannerBox, { tone: "warn", children: "This is a folder \u2014 it has a title and a place in the tree, but no page. Use \u201CAdd page here\u201D in the Pages list to give it one." }) }));
486
+ return (_jsx(NoticePane, { children: _jsx(BannerBox, { tone: "warn", children: "This is a path \u2014 it has a title and a place in the tree, but no page. Use \u201CAdd page here\u201D in the Pages list to give it one." }) }));
400
487
  }
401
488
  if (record && !record.locales[locale]) {
402
489
  const from = sourceLocaleOf(record, registry);
@@ -410,7 +497,10 @@ export function EditorView({ ops, registry, id }) {
410
497
  const rectById = new Map(rects.map((r) => [r.id, r]));
411
498
  const selRect = selected ? rectById.get(selected.id) : undefined;
412
499
  const hoverRect = hoverId && hoverId !== selectedId ? rectById.get(hoverId) : undefined;
413
- const blockCount = tree ? Object.values(tree.zones).reduce((n, list) => n + list.length, 0) : 0;
500
+ const fieldRectOf = (f) => f ? fieldRects.find((r) => r.node === f.node && r.path === f.path) : undefined;
501
+ const fieldHover = hoverField && hoverField.node === selectedId ? fieldRectOf(hoverField) : undefined;
502
+ const editing = fieldRectOf(inline.session);
503
+ const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
414
504
  const selection = selected && selRect && (selectedSection || selectedShared)
415
505
  ? {
416
506
  node: selected, rect: selRect,
@@ -436,21 +526,36 @@ export function EditorView({ ops, registry, id }) {
436
526
  const tabs = selectedSection ? tabsFor(selectedSection) : [];
437
527
  const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
438
528
  const entries = tabs.find((t) => t.tab === activeTab)?.entries ?? [];
439
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(Breadcrumb, { backHref: `${BASE}/pages`, items: [{ label: "Pages", href: `${BASE}/pages` }, { label }] }), right: _jsxs(_Fragment, { children: [_jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), record?.locales[locale] && (_jsx(StatusBadge, { status: localeStatusOf(record.locales[locale]) })), _jsx(Button, { variant: published ? "ok" : "primary", onClick: () => { void doPublish(); }, children: published ? "Published ✓" : "Publish" })] }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [_jsxs("div", { className: "sm-canvas__meta", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 10 }, children: [_jsx("span", { children: label }), registry.locales.supported.length > 1 ? (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })) : (_jsxs("span", { children: ["\u00B7 ", locale] }))] }), _jsxs("span", { children: [blockCount, " BLOCK", blockCount === 1 ? "" : "S"] })] }), (banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), banner.fields && banner.fields.length > 0 && (_jsx("ul", { className: "sm-mono", style: { margin: "6px 0 0", paddingLeft: 18, fontSize: "var(--text-sm)" }, children: banner.fields.map((f) => _jsxs("li", { children: [f.field, ": ", f.message] }, f.field)) }))] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-mono", style: {
440
- height: 240, display: "grid", placeItems: "center",
441
- color: "var(--gray-500)", fontSize: "var(--text-xs)", letterSpacing: ".04em",
442
- }, children: "LOADING CANVAS\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, selection: selection, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove })] }) })] }), _jsxs("aside", { className: "sm-inspector", children: [selected && (selectedSection || selectedShared) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : (_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-label", children: "Nothing selected \u2014 page settings below" }) })), _jsxs("div", { className: "sm-inspector__body", children: [!selected && record && (_jsxs(_Fragment, { children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onRemoveLocale: Object.keys(record.locales).length > 1 &&
443
- !isFixedNode(record, registry) &&
444
- !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
445
- ? () => { void doRemoveLocale(); }
446
- : undefined }), _jsx(Divider, { bleed: 14 })] })), selected && selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`))), selected && selectedShared && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx("p", { className: "sm-hint", children: "This is shared content. Editing it changes every page it is placed on." }), _jsx(RouteLink, { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] })), _jsx(Divider, { bleed: 14 }), _jsx(Outline, { selectedId: selectedId, onSelect: select, onMove: doMove, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
447
- zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
448
- nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
449
- })), onAddFirst: (zone) => {
450
- const first = allowedSections(policyOf(zone), registry.sections)[0];
451
- if (first)
452
- doInsert(zone, 0, first);
453
- } })] })] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, sharedIds: sharedIds ?? {}, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertShared: (item) => doInsertShared(picker.zone, picker.index, item), onClose: () => setPicker(null) }))] }));
529
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/pages`, items: [
530
+ {
531
+ label: "Pages", href: `${BASE}/pages`,
532
+ trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
533
+ },
534
+ { label },
535
+ ] }) }), right: _jsxs(_Fragment, { children: [_jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { href: siteUrl, target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), record?.locales[locale] && (_jsx(StatusBadge, { status: localeStatusOf(record.locales[locale]) })), _jsx(Button, { variant: published ? "ok" : "primary", onClick: () => { void doPublish(); }, children: published ? "Published ✓" : "Publish" })] }) }), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), banner.fields && banner.fields.length > 0 && (_jsx("ul", { className: "sm-mono", style: { margin: "6px 0 0", paddingLeft: 18, fontSize: "var(--text-sm)" }, children: banner.fields.map((f) => _jsxs("li", { children: [f.field, ": ", f.message] }, f.field)) }))] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && tree && (() => {
536
+ const { session } = inline.richtext;
537
+ const node = findNode(tree, session.node);
538
+ const section = node ? sectionOf(node.type) : null;
539
+ if (!node || !section)
540
+ return null;
541
+ return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(node.fields, session.path), toolset: descriptorAt(section, session.path)?.toolset, onChange: (doc) => { inline.markChanged(); mutate((t) => patchNodePath(t, session.node, session.path, doc)); }, onEscape: inline.end }));
542
+ })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && (selectedSection || selectedShared) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : (_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-label", children: "Nothing selected \u2014 page settings below" }) })), _jsxs("div", { className: "sm-inspector__body", children: [!selected && record && (_jsx(_Fragment, { children: _jsx(PageSettings, { record: record, locale: locale, registry: registry, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onRemoveLocale: Object.keys(record.locales).length > 1 &&
543
+ !isFixedNode(record, registry) &&
544
+ !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
545
+ ? () => { void doRemoveLocale(); }
546
+ : undefined }) })), selected && selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), inlinePath: ns === "fields" && inline.session?.node === selected.id ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
547
+ reveal(p, selectedSection); }, onChange: (value) => {
548
+ doPatch(selected.id, ns, key, value);
549
+ if (ns === "fields")
550
+ syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
551
+ } }, `${selected.id}.${ns}.${key}`))), selected && selectedShared && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx("p", { className: "sm-hint", children: "This is shared content. Editing it changes every page it is placed on." }), _jsx(RouteLink, { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] }))] })] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMove: doMove, onMoveTo: doMoveTo, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
552
+ zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
553
+ nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
554
+ })), onAddFirst: (zone) => {
555
+ const first = allowedSections(policyOf(zone), registry.sections)[0];
556
+ if (first)
557
+ doInsert(zone, 0, first);
558
+ } }) })] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, sharedIds: sharedIds ?? {}, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertShared: (item) => doInsertShared(picker.zone, picker.index, item), onClose: () => setPicker(null) }))] }));
454
559
  }
455
560
  /** The selected node's ref field (if any) resolves to its target
456
561
  * collection's loaded options — the EntryForm shape, section-scoped. */
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Allowed } from "../../toolset.ts";
3
+ import { type ActiveState, type ToolbarCommand } from "../forms/RichtextToolbar.tsx";
4
+ export declare const FLOATBAR_W = 340;
5
+ export declare function FloatingToolbar({ x, top, bottom, pageWidth, allowed, active, canInsert, onCommand, popover }: {
6
+ /** The caret's left, top and bottom in overlay coordinates. */
7
+ x: number;
8
+ top: number;
9
+ bottom: number;
10
+ pageWidth: number;
11
+ allowed: Allowed;
12
+ active: ActiveState;
13
+ canInsert: boolean;
14
+ onCommand(command: ToolbarCommand): void;
15
+ popover?: ReactNode;
16
+ }): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { RichtextToolbar } from "../forms/RichtextToolbar.js";
4
+ export const FLOATBAR_W = 340;
5
+ const GAP = 10;
6
+ const FLIP_AT = 56;
7
+ export function FloatingToolbar({ x, top, bottom, pageWidth, allowed, active, canInsert, onCommand, popover }) {
8
+ const left = Math.max(8, Math.min(x, pageWidth - FLOATBAR_W - 8));
9
+ const below = top < FLIP_AT;
10
+ const style = below
11
+ ? { left, top: bottom + 8 }
12
+ : { left, top: top - GAP, transform: "translateY(-100%)" };
13
+ return (_jsxs("div", { className: "sm-floatbar", style: style, children: [_jsx(RichtextToolbar, { allowed: allowed, active: active, canInsert: canInsert, onCommand: onCommand }), popover] }));
14
+ }
@@ -0,0 +1,9 @@
1
+ import type { RichtextMount } from "./useInlineSession.ts";
2
+ export declare function InlineRichtext({ mount, value, toolset, pageWidth, onChange, onEscape }: {
3
+ mount: RichtextMount;
4
+ value: unknown;
5
+ toolset: unknown;
6
+ pageWidth: number;
7
+ onChange(doc: unknown): void;
8
+ onEscape(): void;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // A richtext session in the canvas (spec 2026-09-10 §5): the TipTap
4
+ // editor portalled into the root useInlineSession appended inside the
5
+ // marked container — the same extensions, gating and node views as the
6
+ // sidebar widget, through useRichtextEditor — and the chrome in the
7
+ // admin: the floating toolbar above the selection, the slash menu at the
8
+ // caret, the media picker. The form context's `canvas` flag gives the
9
+ // cards inside the admin's classes and the light theme. Rendered inside
10
+ // the page box, so the toolbar's coordinates are the caret's.
11
+ import { useEffect, useMemo } from "react";
12
+ import { createPortal } from "react-dom";
13
+ import { EditorContent, useEditorState } from "@tiptap/react";
14
+ import { MediaPicker } from "../forms/MediaPicker.js";
15
+ import { RichtextInsertMenu } from "../forms/RichtextInsertMenu.js";
16
+ import { RichtextLinkRow, RichtextMenu } from "../forms/RichtextPopovers.js";
17
+ import { RichtextFormProvider } from "../forms/richtext-form-context.js";
18
+ import { useRichtextEditor } from "../forms/useRichtextEditor.js";
19
+ import { FloatingToolbar } from "./FloatingToolbar.js";
20
+ export function InlineRichtext({ mount, value, toolset, pageWidth, onChange, onEscape }) {
21
+ const rt = useRichtextEditor({ value, toolset, onChange, contentClass: "sm-inline-richtext", onEscape });
22
+ const { editor } = rt;
23
+ const formInfo = useMemo(() => ({ path: mount.session.path, canvas: true }), [mount.session.path]);
24
+ // The caret where the click landed, once the editor exists.
25
+ useEffect(() => {
26
+ if (!editor)
27
+ return;
28
+ const pos = mount.at ? editor.view.posAtCoords({ left: mount.at.x, top: mount.at.y })?.pos : undefined;
29
+ editor.commands.focus(pos ?? "end");
30
+ }, [editor, mount.at]);
31
+ // The container's flex/grid layout lands on the element that holds the blocks — the
32
+ // ProseMirror root — and every wrapper between it and the mount root is made
33
+ // transparent, so a flex column's gap still separates the paragraphs (spec §5).
34
+ useEffect(() => {
35
+ if (!editor)
36
+ return;
37
+ const dom = editor.view.dom;
38
+ for (let el = dom.parentElement; el && el !== mount.root; el = el.parentElement)
39
+ el.style.display = "contents";
40
+ for (const [prop, value] of Object.entries(mount.layout))
41
+ dom.style.setProperty(prop, value);
42
+ }, [editor, mount.root, mount.layout]);
43
+ // Read-only while the refresh that carries its edits renders (spec §5).
44
+ useEffect(() => { editor?.setEditable(!mount.closing); }, [editor, mount.closing]);
45
+ // The selection's start, in iframe document coordinates = overlay coordinates.
46
+ const from = useEditorState({ editor, selector: ({ editor }) => editor?.state.selection.from ?? null });
47
+ const caret = editor && from !== null && editor.view.dom.isConnected ? editor.view.coordsAtPos(from) : null;
48
+ const slashRect = rt.slash?.state.rect ?? null;
49
+ return (_jsxs(RichtextFormProvider, { value: formInfo, children: [createPortal(_jsx(EditorContent, { editor: editor }), mount.root), !mount.closing && caret && (_jsx(FloatingToolbar, { x: caret.left, top: caret.top, bottom: caret.bottom, pageWidth: pageWidth, allowed: rt.allowed, active: rt.active, canInsert: rt.canInsert, onCommand: rt.run, popover: rt.menu.open ? _jsx(RichtextMenu, { menu: rt.menu }) : rt.link.open ? _jsx(RichtextLinkRow, { link: rt.link }) : null })), !mount.closing && rt.slash && (_jsx("div", { className: "sm-richtext__slash", style: { left: slashRect?.left ?? 0, top: (slashRect?.bottom ?? 0) + 4, pointerEvents: "auto" }, 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 })] }));
50
+ }
@@ -1,18 +1,24 @@
1
1
  import type { TreeNode } from "../../render.tsx";
2
- export declare function Outline({ zones, selectedId, onSelect, onAddFirst, onMove, onRemove }: {
3
- zones: {
4
- zone: string;
5
- locked: boolean;
6
- canTake: boolean;
7
- nodes: {
8
- node: TreeNode;
9
- label: string;
10
- }[];
2
+ type ZoneRows = {
3
+ zone: string;
4
+ locked: boolean;
5
+ canTake: boolean;
6
+ nodes: {
7
+ node: TreeNode;
8
+ label: string;
11
9
  }[];
10
+ };
11
+ type Handlers = {
12
12
  selectedId: string | null;
13
13
  onSelect(id: string): void;
14
14
  /** The empty row of an insertable zone was clicked. */
15
15
  onAddFirst(zone: string): void;
16
16
  onMove(id: string, dir: -1 | 1): void;
17
+ /** The drop and the grip's arrow keys: `index` in the resulting order. */
18
+ onMoveTo(id: string, index: number): void;
17
19
  onRemove(id: string): void;
18
- }): import("react").JSX.Element;
20
+ };
21
+ export declare function Outline({ zones, ...handlers }: {
22
+ zones: ZoneRows[];
23
+ } & Handlers): import("react").JSX.Element;
24
+ export {};
@@ -1,12 +1,15 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { IconButton, SectionLabel, cx } from "../ui/primitives.js";
4
- export function Outline({ zones, selectedId, onSelect, onAddFirst, onMove, onRemove }) {
5
- return (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Outline" }), zones.map(({ zone, locked, canTake, nodes }) => (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
6
- onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { style: { color: "var(--accent)", fontWeight: 600 }, children: "+ Add block" })] })), nodes.map(({ node, label }, i) => {
7
- if (locked) {
8
- return (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { style: { fontSize: "var(--text-xs)", fontWeight: 600, letterSpacing: ".06em" }, children: "LOCKED" })] }, node.id));
9
- }
10
- return (_jsxs("div", { onClick: () => onSelect(node.id), className: cx("sm-outline-row", "sm-outline-row--clickable", node.id === selectedId && "sm-outline-row--selected"), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); onMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); onMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] })] }, node.id));
11
- })] }, zone)))] }));
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Grip, IconButton, SectionLabel, cx } from "../ui/primitives.js";
4
+ import { useSortable } from "../ui/useSortable.js";
5
+ export function Outline({ zones, ...handlers }) {
6
+ return (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Outline" }), zones.map((z) => _jsx(OutlineZone, { ...z, ...handlers }, z.zone))] }));
7
+ }
8
+ function OutlineZone({ zone, locked, canTake, nodes, selectedId, onSelect, onAddFirst, onMove, onMoveTo, onRemove }) {
9
+ const sortable = useSortable({
10
+ count: locked ? 0 : nodes.length,
11
+ onDrop: (from, to) => onMoveTo(nodes[from].node.id, to),
12
+ });
13
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
14
+ onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); onMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); onMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] })] }, node.id))) }))] }));
12
15
  }
@@ -0,0 +1,27 @@
1
+ /** What the text helpers need from an element — a DOM Element satisfies it. */
2
+ export type TextHost = {
3
+ childElementCount: number;
4
+ firstChild: {
5
+ nodeType: number;
6
+ nodeValue: string | null;
7
+ nextSibling: unknown;
8
+ } | null;
9
+ textContent: string | null;
10
+ };
11
+ /** A marked element the text session may edit: no child elements. A mark
12
+ * on a container (an <a> inside a <p>) falls through to the inspector. */
13
+ export declare const isPlainTextHost: (el: {
14
+ childElementCount: number;
15
+ }) => boolean;
16
+ /** Write `value` in place: into the one existing text node when there is
17
+ * one, so the site's React keeps pointing at the node it rendered and a
18
+ * refresh reconciles into it; else as new text content. False when the
19
+ * element is not a plain text host. */
20
+ export declare function writeTextInto(el: TextHost, value: string): boolean;
21
+ /** The container's layout the richtext editor root copies (spec §5), so a
22
+ * flex-column or grid container keeps its block spacing while its
23
+ * children are hidden behind the editor. Kebab-cased, for setProperty. */
24
+ export declare const LAYOUT_PROPS: readonly ["display", "flex-direction", "gap", "row-gap", "column-gap", "grid-template-columns", "grid-auto-flow", "align-items", "justify-content"];
25
+ export declare function layoutToCopy(computed: {
26
+ getPropertyValue(name: string): string;
27
+ }): Record<string, string>;
@@ -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;