smoodly 0.0.61 → 0.0.62

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.
@@ -10,7 +10,7 @@ export type OverlaySelection = {
10
10
  /** Rendered by the site's layout, not placed in a zone: no tree gestures (spec 2026-09-13 §2). */
11
11
  layout?: boolean;
12
12
  };
13
- export declare function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onCopy, onRemove, }: {
13
+ export declare function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, dimOutside, onPlus, onMove, onDuplicate, onCopy, onRemove, }: {
14
14
  emptyZones: ZoneRect[];
15
15
  hoverRect: BridgeRect | undefined;
16
16
  /** The hovered block is a global item: the ring takes the global colour. */
@@ -21,6 +21,9 @@ export declare function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, sele
21
21
  fieldHover?: FieldRect;
22
22
  /** The field with an active inline session. */
23
23
  editing?: FieldRect;
24
+ /** The page above and below the selection is dimmed: the selection is the one subject (a global's own canvas,
25
+ * spec 2026-11-14). A section is a full-width band, so two bands cover the rest. */
26
+ dimOutside?: boolean;
24
27
  /** A "+" was clicked, at these viewport coordinates. */
25
28
  onPlus(button: PlusButton, clientX: number, clientY: number): void;
26
29
  onMove(id: string, dir: -1 | 1): void;
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The overlay layer above the canvas iframe — every pixel of chrome is
4
4
  // ours: empty-zone placeholders, the hover and selection outlines, the
5
5
  // selected block's tag and toolbar, and a "+" per insertable boundary.
@@ -9,9 +9,9 @@ import { useState } from "react";
9
9
  import { cx, IconButton } from "../ui/primitives.js";
10
10
  /** Pixels above a block the riding pill needs (half its height); less and it moves inside. */
11
11
  const TAG_CLEARANCE = 14;
12
- export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onCopy, onRemove, }) {
12
+ export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, dimOutside, onPlus, onMove, onDuplicate, onCopy, onRemove, }) {
13
13
  const [hoverPlus, setHoverPlus] = useState(null);
14
- return (_jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [emptyZones.map((z) => (_jsxs("div", { className: "sm-dashed", style: {
14
+ return (_jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [dimOutside && selection && (_jsxs(_Fragment, { children: [_jsx("div", { "data-dim": "above", className: "sm-dim", style: { position: "absolute", left: 0, right: 0, top: 0, height: Math.max(0, selection.rect.y) } }), _jsx("div", { "data-dim": "below", className: "sm-dim", style: { position: "absolute", left: 0, right: 0, top: selection.rect.y + selection.rect.h, bottom: 0 } })] })), emptyZones.map((z) => (_jsxs("div", { className: "sm-dashed", style: {
15
15
  position: "absolute", left: z.x + 12, top: z.y, width: Math.max(0, z.w - 24),
16
16
  height: Math.max(56, z.h),
17
17
  }, children: [z.zone, " \u00B7 empty"] }, `empty-${z.zone}`))), hoverRect && (_jsx("div", { style: {
@@ -0,0 +1,27 @@
1
+ import { type ReactElement, type Ref } from "react";
2
+ import type { AdminOps } from "../ops.ts";
3
+ import type { AdminGlobal } from "../serialize.ts";
4
+ export type GlobalCanvasHandle = {
5
+ /** A save of the item landed: the frame renders again (after an open inline session ends, as in the page editor). */
6
+ refresh(): void;
7
+ /** End an open inline session (the stale freeze, a reload of the item). */
8
+ endInline(): void;
9
+ /** A text value typed in the inspector, written into its marked element at once (the refresh follows the save). */
10
+ writeText(path: string, value: string): void;
11
+ };
12
+ export declare function GlobalCanvas({ ops, item, pageId, locale, fields, onPatch, onMissing, onInlinePath, ref }: {
13
+ ops: Pick<AdminOps, "pages" | "preview">;
14
+ /** A visual item. */
15
+ item: AdminGlobal;
16
+ pageId: string;
17
+ locale: string;
18
+ /** The form's draft fields: the canvas richtext editor reads its value here. */
19
+ fields: Record<string, unknown>;
20
+ /** A value typed in the frame, by dotted path inside the item's fields. */
21
+ onPatch(path: string, value: unknown): void;
22
+ /** The frame settled without drawing the item, or the page could not be loaded. Once per mount. */
23
+ onMissing(): void;
24
+ /** The content field with an open inline session, or null. */
25
+ onInlinePath?(path: string | null): void;
26
+ ref?: Ref<GlobalCanvasHandle>;
27
+ }): ReactElement;
@@ -0,0 +1,159 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The canvas of one global item (spec 2026-11-14 sections and the global canvas §2): a real page of the site
4
+ // that draws the item, in the page editor's frame, with the item selected for good and the rest of the page
5
+ // dimmed. The site has no address that draws a global alone, and the admin holds none of the site's
6
+ // components, so the picture is always a page. Composed from the page editor's pieces — the bridge, the
7
+ // overlay, the inline session — and it owns nothing of the page: it reads the page's draft tree to find a
8
+ // placement and never writes a page. The item's values, its save and its publish stay with the form
9
+ // (shell/GlobalForm.tsx); this reports what was typed in the frame and is told when a save landed.
10
+ import { useEffect, useImperativeHandle, useRef, useState } from "react";
11
+ import { descriptorAt, getAtPath } from "../field-paths.js";
12
+ import { MISS_QUIET_MS, globalNodeIn, missing, settled } from "../shell/global-canvas.js";
13
+ import { canvasAddress, previewUrl } from "./canvas-address.js";
14
+ import { CanvasOverlay } from "./CanvasOverlay.js";
15
+ import { CanvasWarning } from "./CanvasWarning.js";
16
+ import { InlineRichtext } from "./InlineRichtext.js";
17
+ import { enableDraft } from "./preview-grant.js";
18
+ import { useCanvasBridge } from "./useCanvasBridge.js";
19
+ import { useCanvasDiagnosis } from "./useCanvasDiagnosis.js";
20
+ import { useInlineSession } from "./useInlineSession.js";
21
+ /** The gap kept above the item when the frame first scrolls to it. */
22
+ const REVEAL_GAP = 24;
23
+ const noop = () => { };
24
+ export function GlobalCanvas({ ops, item, pageId, locale, fields, onPatch, onMissing, onInlinePath, ref }) {
25
+ const iframeRef = useRef(null);
26
+ const scrollerRef = useRef(null);
27
+ const [page, setPage] = useState(null);
28
+ const [hoverField, setHoverField] = useState(null);
29
+ const [seen, setSeen] = useState({ rendered: false, rectsAfterRendered: false });
30
+ const inlineRef = useRef(null);
31
+ const nodeRef = useRef(null);
32
+ const missed = useRef(false);
33
+ const followRef = useRef(true);
34
+ const latest = useRef({ onMissing, onPatch, onInlinePath });
35
+ latest.current = { onMissing, onPatch, onInlinePath };
36
+ const miss = () => { if (!missed.current) {
37
+ missed.current = true;
38
+ latest.current.onMissing();
39
+ } };
40
+ // Draft mode FIRST, then the page; only then the frame — a frame loaded before the cookie exists renders the
41
+ // published page (EditorView's order). A refusal, a folder or a page with no address here is "missing".
42
+ useEffect(() => {
43
+ let live = true;
44
+ (async () => {
45
+ const draft = await enableDraft(() => ops.preview.enable(), previewUrl());
46
+ if (!live)
47
+ return;
48
+ if (!draft.ok) {
49
+ miss();
50
+ return;
51
+ }
52
+ let got;
53
+ try {
54
+ got = await ops.pages.get(pageId, locale);
55
+ }
56
+ catch {
57
+ got = null;
58
+ }
59
+ if (!live)
60
+ return;
61
+ const url = got?.ok ? got.data.urls[locale] : undefined;
62
+ if (!got?.ok || !got.data.draft || !url) {
63
+ miss();
64
+ return;
65
+ }
66
+ setPage({ src: canvasAddress(url), tree: got.data.draft });
67
+ })();
68
+ return () => { live = false; };
69
+ // The form keys this component by page and locale: one mount, one page.
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, []);
72
+ const iframeSrc = page?.src ?? null;
73
+ const { rects, fields: fieldRects, docH, geometry, postToCanvas, onFrameLoad } = useCanvasBridge({
74
+ iframeRef, iframeSrc,
75
+ // Only the item answers a click: with a marked field under it, the inline session; anywhere else, nothing.
76
+ onSelect: (id, path, x, y) => {
77
+ if (id && id === nodeRef.current)
78
+ followRef.current = false;
79
+ if (!id || id !== nodeRef.current || !path)
80
+ return;
81
+ const descriptor = descriptorAt(item, path);
82
+ const kind = descriptor?.type === "text" ? "text" : descriptor?.type === "richtext" ? "richtext" : null;
83
+ const inline = inlineRef.current;
84
+ if (!kind || !inline)
85
+ return;
86
+ if (inline.session && inline.session.path === path)
87
+ return;
88
+ inline.start({ node: id, path, kind }, { x, y });
89
+ },
90
+ onHover: (id, path) => setHoverField(id && id === nodeRef.current ? path : null),
91
+ onRendered: () => {
92
+ inlineRef.current?.onRendered();
93
+ setSeen((s) => (s.rendered ? s : { rendered: true, rectsAfterRendered: false }));
94
+ },
95
+ });
96
+ // A geometry report after the first committed render: only then may "no node" be believed (global-canvas `settled`).
97
+ useEffect(() => {
98
+ setSeen((s) => (s.rendered && !s.rectsAfterRendered ? { rendered: true, rectsAfterRendered: true } : s));
99
+ }, [rects]);
100
+ const diagnosis = useCanvasDiagnosis(iframeSrc, geometry);
101
+ const nodeId = page ? globalNodeIn(item.name, rects, page.tree) : null;
102
+ nodeRef.current = nodeId;
103
+ const rect = nodeId ? rects.find((r) => r.id === nodeId) : undefined;
104
+ // "This page does not draw the item" is believed only once the frame's reports have gone quiet without it
105
+ // (global-canvas `missing`): every report restarts the wait, and the item arriving cancels it.
106
+ useEffect(() => {
107
+ if (!settled(seen) || nodeId !== null)
108
+ return;
109
+ const timer = setTimeout(() => { if (missing(seen, nodeRef.current, MISS_QUIET_MS))
110
+ miss(); }, MISS_QUIET_MS);
111
+ return () => clearTimeout(timer);
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
113
+ }, [seen, nodeId, rects]);
114
+ // The frame opens on the item and stays on it while the page settles: a footer's place moves as the images
115
+ // above it load and the document grows. The first scroll that is not this one — the editor's — ends it, and so
116
+ // does a click on the item: from then on the place under the editor is theirs.
117
+ const follow = followRef;
118
+ const lastTop = useRef(null);
119
+ const rectY = rect?.y;
120
+ useEffect(() => {
121
+ const scroller = scrollerRef.current;
122
+ if (!follow.current || rectY === undefined || !scroller)
123
+ return;
124
+ scroller.scrollTop = Math.max(0, rectY - REVEAL_GAP);
125
+ lastTop.current = scroller.scrollTop;
126
+ }, [rectY, docH]);
127
+ const onScroll = () => {
128
+ const scroller = scrollerRef.current;
129
+ if (scroller && lastTop.current !== null && Math.abs(scroller.scrollTop - lastTop.current) > 2)
130
+ follow.current = false;
131
+ };
132
+ const inline = useInlineSession({
133
+ iframeRef, postToCanvas,
134
+ patch: (node, path, value) => {
135
+ if (node !== nodeRef.current)
136
+ return;
137
+ latest.current.onPatch(path, value);
138
+ // A second element marked with the same field follows the one being typed in.
139
+ if (typeof value === "string")
140
+ inlineRef.current?.writeText(node, path, value);
141
+ },
142
+ });
143
+ inlineRef.current = inline;
144
+ const sessionPath = inline.session?.path ?? null;
145
+ useEffect(() => { latest.current.onInlinePath?.(sessionPath); }, [sessionPath]);
146
+ useImperativeHandle(ref, () => ({
147
+ refresh: () => inlineRef.current?.onSaved(),
148
+ endInline: () => inlineRef.current?.end(),
149
+ writeText: (path, value) => { if (nodeRef.current)
150
+ inlineRef.current?.writeText(nodeRef.current, path, value); },
151
+ }), []);
152
+ const fieldRectOf = (path) => (path && nodeId ? fieldRects.find((r) => r.node === nodeId && r.path === path) : undefined);
153
+ const selection = nodeId && rect
154
+ // `layout`: no tree gestures, whichever way the page draws the item — nothing here moves or removes a page's node.
155
+ ? { node: { id: nodeId, type: item.section ?? item.name, fields: {} }, rect, title: item.title, global: true, locked: false, layout: true }
156
+ : null;
157
+ const pageWidth = rects.reduce((w, r) => Math.max(w, r.x + r.w), 320);
158
+ return (_jsxs("div", { className: "sm-canvas", children: [diagnosis && (_jsx("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: _jsx(CanvasWarning, { cause: diagnosis.cause, path: diagnosis.path }) })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onScroll: onScroll, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Global canvas", onLoad: onFrameLoad, style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: [], hoverRect: undefined, selection: selection, plusButtons: [], dimOutside: true, fieldHover: fieldRectOf(hoverField), editing: fieldRectOf(sessionPath), onPlus: noop, onMove: noop, onDuplicate: noop, onCopy: noop, onRemove: noop }), inline.richtext && (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(fields, inline.richtext.session.path), toolset: descriptorAt(item, inline.richtext.session.path)?.toolset, onChange: (doc) => { inline.markChanged(); onPatch(inline.richtext.session.path, doc); }, onEscape: inline.end }))] }) })] }));
159
+ }
@@ -46,5 +46,5 @@ export function InsertPicker({ at, policy, registry, globalIds, paste, onInsert,
46
46
  return;
47
47
  setPlace(placePicker(at, el.scrollHeight + 2, { width: window.innerWidth, height: window.innerHeight }));
48
48
  }, [at]);
49
- return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { ref: panel, className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", overflowY: "auto", ...place }, children: [_jsxs(SectionLabel, { caps: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), paste && (_jsxs("button", { type: "button", className: "sm-picker__item sm-picker__item--paste", disabled: !paste.check.ok, title: paste.check.ok ? undefined : paste.check.reason, onClick: paste.onPaste, children: [_jsx("span", { className: "sm-picker__icon", children: "\u2398" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsxs("span", { style: { display: "block", fontWeight: 600 }, children: ["Paste \"", paste.title, "\""] }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: paste.check.ok ? "From the clipboard" : paste.check.reason })] })] })), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), custom && (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: onInsertCustom, children: [_jsx("span", { className: "sm-picker__icon", children: "\u25A6" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: registry.custom.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: registry.custom.description })] })] })), globals.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { caps: true, style: { padding: "8px 8px 4px" }, children: "Site" }), globals.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertGlobal(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Global \u00B7 edited in one place" })] })] }, `global-${item.name}`)))] })), sections.length === 0 && globals.length === 0 && !custom && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
49
+ return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { ref: panel, className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", overflowY: "auto", ...place }, children: [_jsxs(SectionLabel, { caps: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), paste && (_jsxs("button", { type: "button", className: "sm-picker__item sm-picker__item--paste", disabled: !paste.check.ok, title: paste.check.ok ? undefined : paste.check.reason, onClick: paste.onPaste, children: [_jsx("span", { className: "sm-picker__icon", children: "\u2398" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsxs("span", { style: { display: "block", fontWeight: 600 }, children: ["Paste \"", paste.title, "\""] }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: paste.check.ok ? "From the clipboard" : paste.check.reason })] })] })), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), custom && (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: onInsertCustom, children: [_jsx("span", { className: "sm-picker__icon", children: "\u25A6" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: registry.custom.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: registry.custom.description })] })] })), globals.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { caps: true, style: { padding: "8px 8px 4px" }, children: "Global" }), globals.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertGlobal(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Global \u00B7 edited in one place" })] })] }, `global-${item.name}`)))] })), sections.length === 0 && globals.length === 0 && !custom && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
50
50
  }