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.
- package/README.md +219 -25
- package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
- package/dist/admin/editor/CanvasOverlay.js +9 -11
- package/dist/admin/editor/EditorView.js +130 -25
- package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
- package/dist/admin/editor/FloatingToolbar.js +14 -0
- package/dist/admin/editor/InlineRichtext.d.ts +9 -0
- package/dist/admin/editor/InlineRichtext.js +50 -0
- package/dist/admin/editor/Outline.d.ts +16 -10
- package/dist/admin/editor/Outline.js +13 -10
- package/dist/admin/editor/inline-dom.d.ts +27 -0
- package/dist/admin/editor/inline-dom.js +43 -0
- package/dist/admin/editor/inline-session.d.ts +30 -0
- package/dist/admin/editor/inline-session.js +19 -0
- package/dist/admin/editor/protocol.d.ts +22 -1
- package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
- package/dist/admin/editor/useCanvasBridge.js +12 -7
- package/dist/admin/editor/useInlineSession.d.ts +46 -0
- package/dist/admin/editor/useInlineSession.js +244 -0
- package/dist/admin/field-paths.d.ts +13 -0
- package/dist/admin/field-paths.js +49 -0
- package/dist/admin/forms/FieldWidget.d.ts +7 -2
- package/dist/admin/forms/FieldWidget.js +12 -40
- package/dist/admin/forms/ListField.d.ts +10 -0
- package/dist/admin/forms/ListField.js +42 -0
- package/dist/admin/forms/MediaWidget.js +10 -3
- package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
- package/dist/admin/forms/RichtextElementCard.js +37 -0
- package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
- package/dist/admin/forms/RichtextImageCard.js +42 -0
- package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
- package/dist/admin/forms/RichtextInsertMenu.js +6 -0
- package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
- package/dist/admin/forms/RichtextPopovers.js +25 -0
- package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
- package/dist/admin/forms/RichtextToolbar.js +4 -2
- package/dist/admin/forms/RichtextWidget.d.ts +4 -1
- package/dist/admin/forms/RichtextWidget.js +23 -120
- package/dist/admin/forms/elements-context.d.ts +7 -0
- package/dist/admin/forms/elements-context.js +13 -0
- package/dist/admin/forms/reveal.d.ts +5 -0
- package/dist/admin/forms/reveal.js +47 -0
- package/dist/admin/forms/richtext-extensions.d.ts +8 -0
- package/dist/admin/forms/richtext-extensions.js +11 -0
- package/dist/admin/forms/richtext-form-context.d.ts +16 -0
- package/dist/admin/forms/richtext-form-context.js +18 -0
- package/dist/admin/forms/richtext-insert.d.ts +38 -0
- package/dist/admin/forms/richtext-insert.js +37 -0
- package/dist/admin/forms/richtext-nodes.d.ts +37 -0
- package/dist/admin/forms/richtext-nodes.js +85 -0
- package/dist/admin/forms/richtext-paste.js +5 -0
- package/dist/admin/forms/tabs.d.ts +3 -0
- package/dist/admin/forms/tabs.js +6 -0
- package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
- package/dist/admin/forms/useRichtextEditor.js +281 -0
- package/dist/admin/next/bridge.js +51 -20
- package/dist/admin/ops-impl.js +13 -8
- package/dist/admin/richtext-doc.d.ts +3 -0
- package/dist/admin/richtext-doc.js +12 -0
- package/dist/admin/serialize.d.ts +7 -0
- package/dist/admin/serialize.js +16 -1
- package/dist/admin/shell/AdminApp.js +2 -1
- package/dist/admin/shell/EntriesList.js +34 -12
- package/dist/admin/shell/EntryForm.js +22 -15
- package/dist/admin/shell/IconRail.js +6 -5
- package/dist/admin/shell/ListView.d.ts +9 -2
- package/dist/admin/shell/ListView.js +7 -6
- package/dist/admin/shell/LoginView.js +2 -2
- package/dist/admin/shell/PagesList.js +53 -24
- package/dist/admin/shell/SharedForm.js +17 -11
- package/dist/admin/shell/SharedList.js +5 -3
- package/dist/admin/shell/entries-list.d.ts +4 -0
- package/dist/admin/shell/entries-list.js +6 -0
- package/dist/admin/shell/pages-tree.d.ts +24 -0
- package/dist/admin/shell/pages-tree.js +59 -1
- package/dist/admin/tree-ops.d.ts +9 -1
- package/dist/admin/tree-ops.js +27 -0
- package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
- package/dist/admin/ui/LocaleSwitcher.js +28 -8
- package/dist/admin/ui/Modal.js +14 -2
- package/dist/admin/ui/ResizeHandle.d.ts +1 -0
- package/dist/admin/ui/ResizeHandle.js +127 -0
- package/dist/admin/ui/primitives.d.ts +29 -4
- package/dist/admin/ui/primitives.js +29 -10
- package/dist/admin/ui/resize.d.ts +16 -0
- package/dist/admin/ui/resize.js +23 -0
- package/dist/admin/ui/sortable.d.ts +23 -0
- package/dist/admin/ui/sortable.js +57 -0
- package/dist/admin/ui/theme.d.ts +3 -1
- package/dist/admin/ui/theme.js +512 -165
- package/dist/admin/ui/useSortable.d.ts +35 -0
- package/dist/admin/ui/useSortable.js +220 -0
- package/dist/admin/validate.d.ts +6 -1
- package/dist/admin/validate.js +28 -9
- package/dist/config.d.ts +1 -0
- package/dist/config.js +36 -0
- package/dist/entry-store.d.ts +5 -2
- package/dist/entry-store.js +4 -3
- package/dist/fields.d.ts +4 -0
- package/dist/fields.js +2 -2
- package/dist/image/SmoodlyImage.d.ts +4 -1
- package/dist/image/SmoodlyImage.js +2 -2
- package/dist/index.d.ts +8 -1
- package/dist/index.js +5 -1
- package/dist/marks.d.ts +53 -0
- package/dist/marks.js +43 -0
- package/dist/media.d.ts +3 -2
- package/dist/media.js +48 -6
- package/dist/refs.d.ts +2 -2
- package/dist/refs.js +33 -12
- package/dist/render.js +2 -1
- package/dist/resolve.d.ts +8 -2
- package/dist/resolve.js +8 -7
- package/dist/richtext/index.d.ts +12 -0
- package/dist/richtext/index.js +9 -0
- package/dist/richtext-render.d.ts +22 -2
- package/dist/richtext-render.js +40 -13
- package/dist/richtext-walk.d.ts +44 -0
- package/dist/richtext-walk.js +46 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +13 -1
- package/dist/section-wrapper.js +2 -1
- package/dist/site.d.ts +2 -0
- package/dist/site.js +7 -4
- package/dist/store.d.ts +3 -1
- package/dist/store.js +4 -4
- package/dist/supabase-entry-store.d.ts +1 -0
- package/dist/supabase-entry-store.js +2 -1
- package/dist/supabase-store.js +1 -1
- package/dist/toolset.d.ts +7 -1
- package/dist/toolset.js +13 -4
- package/package.json +6 -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).
|
|
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 {
|
|
31
|
-
import {
|
|
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 {
|
|
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: () =>
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
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
|
|
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(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
}
|
|
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,
|
|
3
|
-
import { IconButton, SectionLabel, cx } from "../ui/primitives.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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;
|