smoodly 0.0.53 → 0.0.54
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/dist/admin/editor/CanvasOverlay.d.ts +3 -1
- package/dist/admin/editor/CanvasOverlay.js +2 -2
- package/dist/admin/editor/EditorView.js +243 -63
- package/dist/admin/editor/InsertPicker.d.ts +8 -1
- package/dist/admin/editor/InsertPicker.js +2 -2
- package/dist/admin/editor/InspectorFields.js +1 -1
- package/dist/admin/editor/Outline.d.ts +2 -3
- package/dist/admin/editor/PageInspector.js +1 -1
- package/dist/admin/editor/StructureView.d.ts +31 -0
- package/dist/admin/editor/StructureView.js +58 -0
- package/dist/admin/editor/card-preview.d.ts +10 -0
- package/dist/admin/editor/card-preview.js +50 -0
- package/dist/admin/editor/global-edit.js +29 -22
- package/dist/admin/editor/history.d.ts +39 -0
- package/dist/admin/editor/history.js +64 -0
- package/dist/admin/editor/section-clipboard.d.ts +40 -0
- package/dist/admin/editor/section-clipboard.js +106 -0
- package/dist/admin/editor/undo-keys.d.ts +19 -0
- package/dist/admin/editor/undo-keys.js +27 -0
- package/dist/admin/editor/useInlineSession.d.ts +4 -1
- package/dist/admin/editor/useInlineSession.js +11 -2
- package/dist/admin/editor/view-memory.d.ts +9 -0
- package/dist/admin/editor/view-memory.js +17 -0
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/ui/ActionMenu.d.ts +2 -0
- package/dist/admin/ui/ActionMenu.js +1 -1
- package/dist/admin/ui/primitives.d.ts +1 -0
- package/dist/admin/ui/primitives.js +1 -1
- package/dist/admin/ui/theme.js +70 -0
- package/dist/admin/ui/useSortable.js +3 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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, onRemove, }: {
|
|
13
|
+
export declare function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, 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. */
|
|
@@ -25,5 +25,7 @@ export declare function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, sele
|
|
|
25
25
|
onPlus(button: PlusButton, clientX: number, clientY: number): void;
|
|
26
26
|
onMove(id: string, dir: -1 | 1): void;
|
|
27
27
|
onDuplicate(node: TreeNode): void;
|
|
28
|
+
/** Copy to the section clipboard (spec 2026-10-19 editor at launch §3). */
|
|
29
|
+
onCopy(node: TreeNode): void;
|
|
28
30
|
onRemove(id: string): void;
|
|
29
31
|
}): import("react").JSX.Element;
|
|
@@ -9,7 +9,7 @@ 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, onRemove, }) {
|
|
12
|
+
export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onCopy, onRemove, }) {
|
|
13
13
|
const [hoverPlus, setHoverPlus] = useState(null);
|
|
14
14
|
return (_jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [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),
|
|
@@ -27,7 +27,7 @@ export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, p
|
|
|
27
27
|
} }), _jsxs("span", { className: cx("sm-blocktag", selection.global && "sm-blocktag--global", selection.rect.y < TAG_CLEARANCE && "sm-blocktag--inside"), style: { position: "absolute", left: selection.rect.x, top: selection.rect.y }, children: [selection.title, selection.global && " · global", selection.locked && " · locked"] }), !selection.layout && (_jsx("div", { className: "sm-blocktoolbar", style: {
|
|
28
28
|
position: "absolute", left: selection.rect.x + selection.rect.w - 8, top: selection.rect.y + 8,
|
|
29
29
|
transform: "translateX(-100%)", pointerEvents: "auto",
|
|
30
|
-
}, children: selection.locked ? (_jsx("span", { className: "sm-blocktoolbar__note", children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => onMove(selection.node.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => onMove(selection.node.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => onDuplicate(selection.node), children: "\u29C9" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => onRemove(selection.node.id), children: "\u00D7" })] })) }))] })), plusButtons.map((p) => {
|
|
30
|
+
}, children: selection.locked ? (_jsx("span", { className: "sm-blocktoolbar__note", children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => onMove(selection.node.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => onMove(selection.node.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => onDuplicate(selection.node), children: "\u29C9" }), _jsx(IconButton, { label: "Copy", tone: "inverse", onClick: () => onCopy(selection.node), children: "\u2398" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => onRemove(selection.node.id), children: "\u00D7" })] })) }))] })), plusButtons.map((p) => {
|
|
31
31
|
const key = `${p.zone}-${p.index}`;
|
|
32
32
|
const hot = hoverPlus === key;
|
|
33
33
|
return (_jsxs("div", { style: { position: "absolute", left: 0, right: 0, top: p.y }, children: [hot && _jsx("div", { className: "sm-guide", style: { margin: "0 12px" } }), _jsx("div", { style: {
|
|
@@ -27,8 +27,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
27
27
|
//
|
|
28
28
|
// Core code: no next/* imports live here — the bridge is the only piece
|
|
29
29
|
// that knows about Next, and it ships to the site, not the admin.
|
|
30
|
-
import { Eye } from "lucide-react";
|
|
31
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
30
|
+
import { AppWindow, Eye, Redo2, Rows3, Undo2 } from "lucide-react";
|
|
31
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
32
32
|
import { notePublished } from "../delivery-mode.js";
|
|
33
33
|
import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, globalNameOf } from "../../global.js";
|
|
34
34
|
import { descriptorAt, getAtPath, unsetAtPath } from "../field-paths.js";
|
|
@@ -48,6 +48,12 @@ import { customNode, customNodeLabel, customSection } from "../custom-section.js
|
|
|
48
48
|
import { CUSTOM_NODE_TYPE } from "../../custom/index.js";
|
|
49
49
|
import { NoticePane } from "./NoticePane.js";
|
|
50
50
|
import { Outline } from "./Outline.js";
|
|
51
|
+
import { StructureView } from "./StructureView.js";
|
|
52
|
+
import { cardPreview } from "./card-preview.js";
|
|
53
|
+
import { rememberView, rememberedView } from "./view-memory.js";
|
|
54
|
+
import { createHistory } from "./history.js";
|
|
55
|
+
import { takesUndoKeys, undoKeyOf } from "./undo-keys.js";
|
|
56
|
+
import { canPaste, clipOf, clipboardSpace, pastedNode, readClip, subscribeClip, writeClip } from "./section-clipboard.js";
|
|
51
57
|
import { PageInspector } from "./PageInspector.js";
|
|
52
58
|
import { GlobalInspector } from "./GlobalInspector.js";
|
|
53
59
|
import { localeFromUrl } from "./locale-url.js";
|
|
@@ -78,7 +84,18 @@ import { driftMessage, misfitErrors } from "../drift-copy.js";
|
|
|
78
84
|
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "../shell/presence-context.js";
|
|
79
85
|
import { editorsOf } from "../shell/presence.js";
|
|
80
86
|
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
81
|
-
import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Tabs, TopBar, } from "../ui/primitives.js";
|
|
87
|
+
import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Segmented, Tabs, TopBar, cx, } from "../ui/primitives.js";
|
|
88
|
+
/** Cmd on a Mac, Ctrl elsewhere, for the undo keys (spec 2026-10-19 editor at launch §2). */
|
|
89
|
+
const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
|
90
|
+
/** The site's storage for the section clipboard; a browser that refuses the property reads as empty and refuses the write. */
|
|
91
|
+
const clipStorage = () => {
|
|
92
|
+
try {
|
|
93
|
+
return window.localStorage;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { getItem: () => null, setItem: () => { throw new Error("blocked"); } };
|
|
97
|
+
}
|
|
98
|
+
};
|
|
82
99
|
export function EditorView({ ops, registry, id }) {
|
|
83
100
|
const adminHref = useAdminHref();
|
|
84
101
|
const [record, setRecord] = useState(null);
|
|
@@ -108,6 +125,18 @@ export function EditorView({ ops, registry, id }) {
|
|
|
108
125
|
const inlineRef = useRef(null);
|
|
109
126
|
const [picker, setPicker] = useState(null);
|
|
110
127
|
const [tab, setTab] = useState("Content");
|
|
128
|
+
/** Canvas or Structure (spec 2026-10-19 editor at launch §1): one tree, two renderers; remembered per browser. */
|
|
129
|
+
const [view, setView] = useState(() => rememberedView());
|
|
130
|
+
/** Undo and redo (spec 2026-10-19 editor at launch §2): the steps since the page opened, in the tab. */
|
|
131
|
+
const [undoHistory] = useState(() => createHistory());
|
|
132
|
+
const undoState = useSyncExternalStore(undoHistory.subscribe, () => `${undoHistory.canUndo()}:${undoHistory.canRedo()}`, () => "false:false");
|
|
133
|
+
const canUndo = undoState.startsWith("true");
|
|
134
|
+
const canRedo = undoState.endsWith("true");
|
|
135
|
+
/** The frame's load count: the undo keys are attached to each new frame document. */
|
|
136
|
+
const [frameLoads, setFrameLoads] = useState(0);
|
|
137
|
+
/** The section clipboard (spec 2026-10-19 editor at launch §3): this space's clip in the site's storage, every tab's. */
|
|
138
|
+
const [clip, setClip] = useState(() => readClip(clipStorage(), clipboardSpace()));
|
|
139
|
+
useEffect(() => subscribeClip(window, () => setClip(readClip(clipStorage(), clipboardSpace()))), []);
|
|
111
140
|
const [banner, setBanner] = useState(null);
|
|
112
141
|
const [published, setPublished] = useState(false);
|
|
113
142
|
/** An action of the page's control in flight. */
|
|
@@ -173,7 +202,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
173
202
|
// through the same inline-session deferral as the page's.
|
|
174
203
|
const globalEdit = useGlobalEdit({ ops, onSaved: (name) => { setGlobalDirty(false); inlineRef.current?.onSaved(); presence?.announce({ kind: "global", id: name, locale }, null); } });
|
|
175
204
|
useUnloadGuard(dirty || saveState !== "saved" || globalDirty || globalEdit.saveState !== "saved");
|
|
176
|
-
const patchGlobal = (name, path, value) => {
|
|
205
|
+
const patchGlobal = (name, path, value, opts = {}) => {
|
|
206
|
+
// A global step (spec 2026-10-19 editor at launch §2): the value before and after at the path, coalesced by `name:path`
|
|
207
|
+
// (an inline session passes its group's key, so the whole session stays one step).
|
|
208
|
+
if (opts.record !== false)
|
|
209
|
+
undoHistory.record({ kind: "global", name, path, before: getAtPath(globalEdit.items[name]?.fields, path), after: value, key: opts.key ?? `${name}:${path}`, at: Date.now(), focus: selectedId ?? undefined });
|
|
177
210
|
globalEdit.patch(name, path, value);
|
|
178
211
|
setGlobalDirty(true);
|
|
179
212
|
setGlobalTouched(true);
|
|
@@ -223,6 +256,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
223
256
|
setReady(false);
|
|
224
257
|
setTree(null);
|
|
225
258
|
treeRef.current = null;
|
|
259
|
+
undoHistory.clear(); // a tree loaded from the server starts the history over (a locale switch, a reload, Discard, a restore, Load their version)
|
|
226
260
|
const keepSelection = keepSelectionRef.current;
|
|
227
261
|
keepSelectionRef.current = false;
|
|
228
262
|
if (!keepSelection) {
|
|
@@ -294,7 +328,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
294
328
|
})();
|
|
295
329
|
return () => { cancelled = true; };
|
|
296
330
|
}, [ops, id, locale, reloadKey]);
|
|
297
|
-
|
|
331
|
+
// No lagging `treeRef.current = tree` effect: every writer (the load, the fill, mutate, applyTree) sets the ref synchronously,
|
|
332
|
+
// and a passive effect could move it back between a commit and a later mutate (review 2026-10-20).
|
|
298
333
|
// After a rename only the record and the URL indexes are stale — never
|
|
299
334
|
// re-read the tree here: the draft in memory may be ahead of the store.
|
|
300
335
|
const reload = useCallback(async () => {
|
|
@@ -446,33 +481,115 @@ export function EditorView({ ops, registry, id }) {
|
|
|
446
481
|
revealRef.current(id);
|
|
447
482
|
}, []);
|
|
448
483
|
// ── persistence ──────────────────────────────────────────────────────
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
484
|
+
/** Every edit of the tree: the op over the current tree, a history step when it changed (`key` coalesces a field's
|
|
485
|
+
* burst, `focus` is the node the gesture concerned), then the marking the autosave debounces on. */
|
|
486
|
+
const mutate = useCallback((fn, opts) => {
|
|
487
|
+
const t = treeRef.current;
|
|
488
|
+
if (!t)
|
|
489
|
+
return;
|
|
490
|
+
const next = fn(t);
|
|
491
|
+
if (next !== t)
|
|
492
|
+
undoHistory.record({ kind: "tree", before: t, after: next, key: opts?.key, focus: opts?.focus, at: Date.now() });
|
|
493
|
+
treeRef.current = next;
|
|
494
|
+
setTree(next);
|
|
457
495
|
mark();
|
|
458
496
|
setDirty(true);
|
|
459
497
|
setTouched(true);
|
|
460
|
-
}, [mark]);
|
|
498
|
+
}, [mark, undoHistory]);
|
|
461
499
|
// ── inline editing (spec 2026-09-10) ──────────────────────────────────
|
|
462
500
|
const inline = useInlineSession({
|
|
463
501
|
iframeRef, postToCanvas,
|
|
502
|
+
// An inline session is one step: its key stays grouped from start to end (spec 2026-10-19 editor at launch §2).
|
|
503
|
+
onSession: (s) => undoHistory.group(`inline:${s.node}:${s.path}`),
|
|
464
504
|
patch: (node, path, value) => {
|
|
465
505
|
const item = globalItemOf(node);
|
|
506
|
+
// The session's steps carry its group's key (`inline:<node>:<path>`), so every change it dispatches merges into one step.
|
|
466
507
|
if (item)
|
|
467
|
-
patchGlobal(item.name, path, value);
|
|
508
|
+
patchGlobal(item.name, path, value, { key: `inline:${node}:${path}` });
|
|
468
509
|
else
|
|
469
|
-
mutate((t) => patchNodePath(t, node, path, value));
|
|
510
|
+
mutate((t) => patchNodePath(t, node, path, value), { key: `inline:${node}:${path}`, focus: node });
|
|
470
511
|
// A second element marked with the same field follows the one being typed in.
|
|
471
512
|
if (typeof value === "string")
|
|
472
513
|
inlineRef.current?.writeText(node, path, value);
|
|
473
514
|
},
|
|
474
515
|
});
|
|
475
516
|
inlineRef.current = inline;
|
|
517
|
+
/** The switch: an inline session ends on it (the closing editor waits for the refresh), the picker closes, the choice is remembered. */
|
|
518
|
+
const switchView = (v) => {
|
|
519
|
+
if (v === view)
|
|
520
|
+
return;
|
|
521
|
+
inline.end();
|
|
522
|
+
setPicker(null);
|
|
523
|
+
setView(v);
|
|
524
|
+
rememberView(v);
|
|
525
|
+
};
|
|
526
|
+
// ── undo and redo (spec 2026-10-19 editor at launch §2) ─────────────
|
|
527
|
+
/** A tree step's side, set through the path `mutate` marks by, without recording; the focus node selected when it exists. */
|
|
528
|
+
const applyTree = (tree, focus) => {
|
|
529
|
+
treeRef.current = tree;
|
|
530
|
+
setTree(tree);
|
|
531
|
+
mark();
|
|
532
|
+
setDirty(true);
|
|
533
|
+
setTouched(true);
|
|
534
|
+
setSelectedId(focus && findNode(tree, focus) ? focus : null);
|
|
535
|
+
};
|
|
536
|
+
/** A global step's side through the item's own save; an item not loaded (the editor switched locale and back) is loaded first, never skipped. */
|
|
537
|
+
const applyGlobal = async (step, dir) => {
|
|
538
|
+
if (!globalEdit.items[step.name])
|
|
539
|
+
await globalEdit.load(step.name, locale);
|
|
540
|
+
patchGlobal(step.name, step.path, dir === "undo" ? step.before : step.after, { record: false });
|
|
541
|
+
const focus = step.focus;
|
|
542
|
+
const t = treeRef.current;
|
|
543
|
+
setSelectedId(focus && ((t && findNode(t, focus)) || globalItemOf(focus)) ? focus : null);
|
|
544
|
+
};
|
|
545
|
+
const doUndo = () => {
|
|
546
|
+
if (stale)
|
|
547
|
+
return;
|
|
548
|
+
const step = undoHistory.undo();
|
|
549
|
+
if (!step)
|
|
550
|
+
return;
|
|
551
|
+
inline.end();
|
|
552
|
+
if (step.kind === "tree")
|
|
553
|
+
applyTree(step.before, step.focus);
|
|
554
|
+
else
|
|
555
|
+
void applyGlobal(step, "undo");
|
|
556
|
+
};
|
|
557
|
+
const doRedo = () => {
|
|
558
|
+
if (stale)
|
|
559
|
+
return;
|
|
560
|
+
const step = undoHistory.redo();
|
|
561
|
+
if (!step)
|
|
562
|
+
return;
|
|
563
|
+
inline.end();
|
|
564
|
+
if (step.kind === "tree")
|
|
565
|
+
applyTree(step.after, step.focus);
|
|
566
|
+
else
|
|
567
|
+
void applyGlobal(step, "redo");
|
|
568
|
+
};
|
|
569
|
+
const undoRef = useRef({ doUndo, doRedo });
|
|
570
|
+
undoRef.current = { doUndo, doRedo };
|
|
571
|
+
/** Cmd+Z / Shift+Cmd+Z (Ctrl elsewhere): the editor's outside text controls and inside the inspector's field widgets. */
|
|
572
|
+
const onUndoKey = useCallback((e) => {
|
|
573
|
+
if (e.defaultPrevented)
|
|
574
|
+
return; // a widget with its own undo (the sidebar richtext) took the key
|
|
575
|
+
const k = undoKeyOf(e, IS_MAC);
|
|
576
|
+
if (!k || !takesUndoKeys(e.target))
|
|
577
|
+
return;
|
|
578
|
+
e.preventDefault();
|
|
579
|
+
if (k === "undo")
|
|
580
|
+
undoRef.current.doUndo();
|
|
581
|
+
else
|
|
582
|
+
undoRef.current.doRedo();
|
|
583
|
+
}, []);
|
|
584
|
+
// The same keys from the canvas frame's document (a click on a block leaves the focus there): attached on every frame
|
|
585
|
+
// load, detached while an inline session is open, so inside a session the field's own undo runs.
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
const d = iframeRef.current?.contentDocument;
|
|
588
|
+
if (!d || inline.session)
|
|
589
|
+
return;
|
|
590
|
+
d.addEventListener("keydown", onUndoKey);
|
|
591
|
+
return () => d.removeEventListener("keydown", onUndoKey);
|
|
592
|
+
}, [frameLoads, inline.session, onUndoKey]);
|
|
476
593
|
// A different selection ends the session; a new canvas drops everything.
|
|
477
594
|
useEffect(() => { inline.onSelection(selectedId); }, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
478
595
|
useEffect(() => { inline.onCanvasChange(); }, [iframeSrc]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
@@ -569,13 +686,13 @@ export function EditorView({ ops, registry, id }) {
|
|
|
569
686
|
};
|
|
570
687
|
const doInsert = (zone, index, section) => {
|
|
571
688
|
const node = nodeFromSample(section, newNodeId(section.name));
|
|
572
|
-
mutate((t) => insertNode(t, zone, index, node));
|
|
689
|
+
mutate((t) => insertNode(t, zone, index, node), { focus: node.id });
|
|
573
690
|
setPicker(null);
|
|
574
691
|
select(node.id);
|
|
575
692
|
};
|
|
576
693
|
const doInsertCustom = (zone, index) => {
|
|
577
694
|
const node = customNode(registry, newNodeId(CUSTOM_NODE_TYPE));
|
|
578
|
-
mutate((t) => insertNode(t, zone, index, node));
|
|
695
|
+
mutate((t) => insertNode(t, zone, index, node), { focus: node.id });
|
|
579
696
|
setPicker(null);
|
|
580
697
|
select(node.id);
|
|
581
698
|
};
|
|
@@ -584,7 +701,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
584
701
|
if (!ref)
|
|
585
702
|
return;
|
|
586
703
|
const node = placementNode(item, ref, newNodeId(GLOBAL_NODE_TYPE));
|
|
587
|
-
mutate((t) => insertNode(t, zone, index, node));
|
|
704
|
+
mutate((t) => insertNode(t, zone, index, node), { focus: node.id });
|
|
588
705
|
setPicker(null);
|
|
589
706
|
select(node.id);
|
|
590
707
|
};
|
|
@@ -605,7 +722,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
605
722
|
};
|
|
606
723
|
if (node.css === undefined)
|
|
607
724
|
delete copy.css;
|
|
608
|
-
mutate((t) => insertNode(t, zone, index, copy));
|
|
725
|
+
mutate((t) => insertNode(t, zone, index, copy), { focus: copy.id });
|
|
609
726
|
select(copy.id);
|
|
610
727
|
};
|
|
611
728
|
const doRemove = async (id, ask = true) => {
|
|
@@ -622,15 +739,53 @@ export function EditorView({ ops, registry, id }) {
|
|
|
622
739
|
subject: node ? { name: labelOf(node) } : undefined,
|
|
623
740
|
})))
|
|
624
741
|
return;
|
|
625
|
-
mutate((t) => removeNode(t, id));
|
|
742
|
+
mutate((t) => removeNode(t, id), { focus: id }); // undo selects the restored node again
|
|
626
743
|
setSelectedId((current) => (current === id ? null : current));
|
|
627
744
|
};
|
|
628
|
-
const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir));
|
|
629
|
-
const doMoveTo = (id, index) => mutate((t) => moveNodeTo(t, id, index));
|
|
630
|
-
const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value));
|
|
631
|
-
const doPatchCss = (id, css) => mutate((t) => patchNodeCss(t, id, css));
|
|
745
|
+
const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir), { focus: id });
|
|
746
|
+
const doMoveTo = (id, index) => mutate((t) => moveNodeTo(t, id, index), { focus: id });
|
|
747
|
+
const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value), { key: `${id}:${ns === "fields" ? key : `${ns}.${key}`}`, focus: id });
|
|
748
|
+
const doPatchCss = (id, css) => mutate((t) => patchNodeCss(t, id, css), { key: `${id}:css`, focus: id });
|
|
632
749
|
/** A page-level value (page fields, SEO meta) — same dirty/debounce path as a node's (spec 2026-09-10 page fields §5). */
|
|
633
|
-
const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
|
|
750
|
+
const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value), { key: `page:${ns}.${key}` });
|
|
751
|
+
// ── the section clipboard (spec 2026-10-19 editor at launch §3) ──────
|
|
752
|
+
const doCopy = (node) => {
|
|
753
|
+
const next = clipOf(node, globalOf(node)?.title ?? labelOf(node), clipboardSpace()); // a placement's title without the outline's suffix, as its card reads
|
|
754
|
+
if (!writeClip(clipStorage(), next)) {
|
|
755
|
+
setBanner({ text: "Couldn't copy — this browser blocks site storage." });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
setClip(next);
|
|
759
|
+
};
|
|
760
|
+
/** Whether `zone` takes the clip, the picker's own check; null without a clip. */
|
|
761
|
+
const pasteCheck = (zone) => clip ? canPaste(clip, policyOf(zone), nodesIn(zone).length, registry, globalIds ?? {}) : null;
|
|
762
|
+
/** One mutate, one undo step, the new node selected. */
|
|
763
|
+
const doPaste = (zone, index) => {
|
|
764
|
+
if (!clip || !pasteCheck(zone)?.ok)
|
|
765
|
+
return;
|
|
766
|
+
const node = pastedNode(clip, globalIds ?? {});
|
|
767
|
+
mutate((t) => insertNode(t, zone, index, node), { focus: node.id });
|
|
768
|
+
setPicker(null);
|
|
769
|
+
select(node.id);
|
|
770
|
+
};
|
|
771
|
+
const pasteEntry = (zone, label, index) => {
|
|
772
|
+
const check = pasteCheck(zone);
|
|
773
|
+
return check ? { label, run: () => doPaste(zone, index), disabled: !check.ok, title: check.ok ? undefined : check.reason } : null;
|
|
774
|
+
};
|
|
775
|
+
/** The structure card's ⋯ beyond Remove: Copy, then Paste before and after when a clip exists; a locked zone's card gets Copy only. */
|
|
776
|
+
const cardActions = (node, zone, index) => {
|
|
777
|
+
const copy = { label: "Copy", run: () => doCopy(node) };
|
|
778
|
+
if (!clip || policyOf(zone)?.kind === "locked")
|
|
779
|
+
return [copy];
|
|
780
|
+
return [copy, pasteEntry(zone, `Paste "${clip.title}" before`, index), pasteEntry(zone, `Paste "${clip.title}" after`, index + 1)].filter((e) => e !== null);
|
|
781
|
+
};
|
|
782
|
+
/** The zone heading's ⋯: Paste at the zone's end, when a clip exists and the zone is not locked. */
|
|
783
|
+
const zoneActions = (zone) => {
|
|
784
|
+
if (!clip || policyOf(zone)?.kind === "locked")
|
|
785
|
+
return [];
|
|
786
|
+
const entry = pasteEntry(zone, `Paste "${clip.title}"`, nodesIn(zone).length);
|
|
787
|
+
return entry ? [entry] : [];
|
|
788
|
+
};
|
|
634
789
|
// ── publish ──────────────────────────────────────────────────────────
|
|
635
790
|
const doPublish = async () => {
|
|
636
791
|
// A section the preview shows as crashed (spec 2026-09-27 section guard §6): ask before it goes live.
|
|
@@ -816,6 +971,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
816
971
|
}
|
|
817
972
|
// The item reloads from the store; the canvas refreshes through the same path as a save ack.
|
|
818
973
|
globalEdit.reset();
|
|
974
|
+
undoHistory.forget((s) => s.kind === "global" && s.name === name); // the item's steps alone (spec 2026-10-19 editor at launch §2)
|
|
819
975
|
setGlobalDirty(false);
|
|
820
976
|
void globalEdit.load(name, locale);
|
|
821
977
|
inlineRef.current?.onSaved();
|
|
@@ -826,11 +982,13 @@ export function EditorView({ ops, registry, id }) {
|
|
|
826
982
|
if (e.key === "Escape") {
|
|
827
983
|
setPicker(null);
|
|
828
984
|
setSelectedId(null);
|
|
985
|
+
return;
|
|
829
986
|
}
|
|
987
|
+
onUndoKey(e);
|
|
830
988
|
};
|
|
831
989
|
window.addEventListener("keydown", onKey);
|
|
832
990
|
return () => window.removeEventListener("keydown", onKey);
|
|
833
|
-
}, []);
|
|
991
|
+
}, [onUndoKey]);
|
|
834
992
|
if (loadError) {
|
|
835
993
|
return _jsx(NoticePane, { children: _jsx(BannerBox, { tone: "error", children: loadError }) });
|
|
836
994
|
}
|
|
@@ -879,6 +1037,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
879
1037
|
// Reveal is defined here, where the rect map exists; select() calls it
|
|
880
1038
|
// through the ref so its own identity stays stable.
|
|
881
1039
|
revealRef.current = (id) => {
|
|
1040
|
+
if (view === "structure")
|
|
1041
|
+
return; // the card clicked is in view; the hidden frame is not scrolled under it
|
|
882
1042
|
const rect = rectById.get(id);
|
|
883
1043
|
const scroller = scrollerRef.current;
|
|
884
1044
|
if (!rect || !scroller)
|
|
@@ -886,6 +1046,40 @@ export function EditorView({ ops, registry, id }) {
|
|
|
886
1046
|
const top = rect.y - Math.max(0, (scroller.clientHeight - rect.h) / 2);
|
|
887
1047
|
scroller.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
|
|
888
1048
|
};
|
|
1049
|
+
// The outline's view model (spec 2026-09-27 editor safety §5), shared with the structure view (spec 2026-10-19 editor at
|
|
1050
|
+
// launch §1): the declared zones in order, then stored zones the page type no longer declares, listed after the real ones.
|
|
1051
|
+
const outlineZones = [
|
|
1052
|
+
...zoneOrder.map((zone) => ({
|
|
1053
|
+
zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
|
|
1054
|
+
note: findingsAt.get(`zone:${zone}`)?.map(driftMessage)[0],
|
|
1055
|
+
nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node), mark: markOf(node.id) })),
|
|
1056
|
+
})),
|
|
1057
|
+
...Object.keys(tree?.zones ?? {}).filter((z) => !zoneOrder.includes(z) && (tree.zones[z]?.length ?? 0) > 0).map((zone) => ({
|
|
1058
|
+
zone, locked: false, canTake: false, removed: true,
|
|
1059
|
+
nodes: tree.zones[zone].map((node) => ({ node, label: labelOf(node) })),
|
|
1060
|
+
})),
|
|
1061
|
+
];
|
|
1062
|
+
// A card: the section's icon, its preview line; a placed global item's title carries the badge instead of the outline's suffix.
|
|
1063
|
+
const structureZones = view !== "structure" ? [] : outlineZones.map((z) => ({
|
|
1064
|
+
...z,
|
|
1065
|
+
nodes: z.nodes.map((n) => {
|
|
1066
|
+
const global = globalOf(n.node);
|
|
1067
|
+
const section = sectionOf(n.node.type);
|
|
1068
|
+
return {
|
|
1069
|
+
...n,
|
|
1070
|
+
label: global ? global.title : n.label,
|
|
1071
|
+
icon: section?.icon ?? (global ? "⛓" : "▤"),
|
|
1072
|
+
preview: global ? { text: null, image: null } : cardPreview(n.node, section),
|
|
1073
|
+
global: global !== null,
|
|
1074
|
+
};
|
|
1075
|
+
}),
|
|
1076
|
+
}));
|
|
1077
|
+
const clearZoneAsked = async (zone) => {
|
|
1078
|
+
const count = tree?.zones[zone]?.length ?? 0;
|
|
1079
|
+
if (!(await confirm({ title: "Clear this zone?", body: `Its ${count === 1 ? "section is" : `${count} sections are`} removed from the draft. The live page keeps ${count === 1 ? "it" : "them"} until you publish.`, confirmLabel: "Clear zone", destructive: true })))
|
|
1080
|
+
return;
|
|
1081
|
+
mutate((t) => clearZone(t, zone));
|
|
1082
|
+
};
|
|
889
1083
|
const plusButtons = plusButtonsFor({ zoneOrder, canTake: zoneCanTake, nodesIn, rects: rectById, emptyZones });
|
|
890
1084
|
const tabs = selectedSection ? tabsFor(selectedSection) : selectedItem ? tabsFor(selectedItem) : [];
|
|
891
1085
|
const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
|
|
@@ -896,23 +1090,25 @@ export function EditorView({ ops, registry, id }) {
|
|
|
896
1090
|
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); } })),
|
|
897
1091
|
},
|
|
898
1092
|
{ label },
|
|
899
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && (
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1093
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), ready && tree && (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Undo", disabled: !canUndo || !!stale, onClick: doUndo, children: _jsx(Undo2, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) }), _jsx(IconButton, { label: "Redo", disabled: !canRedo || !!stale, onClick: doRedo, children: _jsx(Redo2, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })] })), ready && tree && (_jsx(Segmented, { options: ["canvas", "structure"], value: view, onChange: switchView, className: "sm-seg--view", label: (o) => o === "canvas"
|
|
1094
|
+
? _jsx(AppWindow, { size: 14, strokeWidth: 1.5, "aria-hidden": "true" })
|
|
1095
|
+
: _jsx(Rows3, { size: 14, strokeWidth: 1.5, "aria-hidden": "true" }), option: (o) => ({ title: o === "canvas" ? "Canvas" : "Structure", "aria-label": o === "canvas" ? "Canvas" : "Structure" }) })), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 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: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: versionAddress(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || diagnosis || crashedLines.length > 0) && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), diagnosis && _jsx(CanvasWarning, { cause: diagnosis.cause, path: diagnosis.path }), crashedLines.length > 0 && (_jsx(BannerBox, { tone: "warn", children: crashedLines.map((line, i) => _jsx("p", { children: line }, i)) }))] })), _jsxs("div", { className: cx("sm-canvas__scroll", view === "structure" && "sm-canvas__scroll--structure"), ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
|
|
1096
|
+
setSelectedId(null); }, children: [view === "structure" && !(ready && tree) && _jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" }), view === "structure" && ready && tree && (_jsx(StructureView, { zones: structureZones, selectedId: selectedId, onSelect: select, onMoveTo: doMoveTo, onRemove: doRemove, onClearZone: clearZoneAsked, cardActions: cardActions, zoneActions: zoneActions, onAdd: (zone, index, x, y) => setPicker({ zone, index, x: x - 150, y: y + 12 }) })), _jsxs("div", { className: cx("sm-page", view === "structure" && "sm-canvas__hidden"), style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", onLoad: () => { onFrameLoad(); setFrameLoads((n) => n + 1); }, 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, hoverGlobal: hoverGlobal, 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, onCopy: doCopy, onRemove: doRemove }), inline.richtext && (() => {
|
|
1097
|
+
const { session } = inline.richtext;
|
|
1098
|
+
const item = globalItemOf(session.node);
|
|
1099
|
+
const node = !item && tree ? findNode(tree, session.node) : null;
|
|
1100
|
+
const schema = item ?? (node ? sectionOf(node.type) : null);
|
|
1101
|
+
const fields = item ? globalEdit.items[item.name]?.fields : node?.fields;
|
|
1102
|
+
if (!schema || !fields)
|
|
1103
|
+
return null;
|
|
1104
|
+
return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(fields, session.path), toolset: descriptorAt(schema, session.path)?.toolset, onChange: (doc) => {
|
|
1105
|
+
inline.markChanged();
|
|
1106
|
+
if (item)
|
|
1107
|
+
patchGlobal(item.name, session.path, doc, { key: `inline:${session.node}:${session.path}` });
|
|
1108
|
+
else
|
|
1109
|
+
mutate((t) => patchNodePath(t, session.node, session.path, doc), { key: `inline:${session.node}:${session.path}`, focus: session.node });
|
|
1110
|
+
}, onEscape: inline.end }));
|
|
1111
|
+
})()] })] })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && !selectedSection && !selectedItem ? (_jsx(UnknownNode, { node: selected, label: labelOf(selected), onRemove: () => { void doRemove(selected.id, true); } })) : selectedSection || selectedItem ? (_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 ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error" || globalEdit.saveState === "refused") && _jsx(SaveIndicator, { state: globalEdit.saveState, pending: globalDirty })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], redirects: redirects[locale] ?? [],
|
|
916
1112
|
// Code-owned, or no longer in code (only deleting is open then): either way the URL is not the editor's.
|
|
917
1113
|
fixed: isFixedNode(record, registry) || !!record.fixed, onRename: onRename, onPatch: doPatchPage, drift: findingsAt.get("page") ?? [], onClearPageField: (key) => mutate((t) => ({ ...t, fields: unsetAtPath(t.fields ?? {}, key) })), onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
918
1114
|
!record.fixed &&
|
|
@@ -928,7 +1124,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
928
1124
|
syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
|
|
929
1125
|
} }, `${selected.id}.${ns}.${key}`)) })), selected && selectedSection && (
|
|
930
1126
|
// Element findings sit under the node id too, but their paths are the element card's (Task 10), not node keys.
|
|
931
|
-
_jsx(OrphanFields, { orphans: (findingsAt.get(selected.id) ?? []).filter((f) => !f.path.includes(".elements.")), strip: selected.id, onClear: (path) => mutate((t) => unsetNodePath(t, selected.id, path)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, tabs: tabs.length > 0 ? _jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }) : undefined, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
|
|
1127
|
+
_jsx(OrphanFields, { orphans: (findingsAt.get(selected.id) ?? []).filter((f) => !f.path.includes(".elements.")), strip: selected.id, onClear: (path) => mutate((t) => unsetNodePath(t, selected.id, path), { focus: selected.id }) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, tabs: tabs.length > 0 ? _jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }) : undefined, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
|
|
932
1128
|
reveal(p, selectedItem); }, onChange: (ns, key, value) => {
|
|
933
1129
|
patchGlobal(selectedItem.name, globalPath(ns, key), value);
|
|
934
1130
|
if (ns === "fields") {
|
|
@@ -936,27 +1132,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
936
1132
|
if (current)
|
|
937
1133
|
syncTexts(selectedId, selectedItem, { ...current.fields, [key]: value });
|
|
938
1134
|
}
|
|
939
|
-
}, onPublish: () => { void doPublishGlobal(selectedItem.name); }, onDiscard: () => { void doDiscardGlobal(selectedItem.name); }, onUnpublish: () => { void doUnpublishGlobal(selectedItem.name); } }))] }))] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMoveTo: doMoveTo, onRemove: doRemove, zones:
|
|
940
|
-
...zoneOrder.map((zone) => ({
|
|
941
|
-
zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
|
|
942
|
-
note: findingsAt.get(`zone:${zone}`)?.map(driftMessage)[0],
|
|
943
|
-
nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node), mark: markOf(node.id) })),
|
|
944
|
-
})),
|
|
945
|
-
// Stored zones the page type no longer declares (spec 2026-09-27 editor safety §5): listed after the real ones.
|
|
946
|
-
...Object.keys(tree?.zones ?? {}).filter((z) => !zoneOrder.includes(z) && (tree.zones[z]?.length ?? 0) > 0).map((zone) => ({
|
|
947
|
-
zone, locked: false, canTake: false, removed: true,
|
|
948
|
-
nodes: tree.zones[zone].map((node) => ({ node, label: labelOf(node) })),
|
|
949
|
-
})),
|
|
950
|
-
], onClearZone: async (zone) => {
|
|
951
|
-
const count = tree?.zones[zone]?.length ?? 0;
|
|
952
|
-
if (!(await confirm({ title: "Clear this zone?", body: `Its ${count === 1 ? "section is" : `${count} sections are`} removed from the draft. The live page keeps ${count === 1 ? "it" : "them"} until you publish.`, confirmLabel: "Clear zone", destructive: true })))
|
|
953
|
-
return;
|
|
954
|
-
mutate((t) => clearZone(t, zone));
|
|
955
|
-
}, onAddFirst: (zone) => {
|
|
1135
|
+
}, onPublish: () => { void doPublishGlobal(selectedItem.name); }, onDiscard: () => { void doDiscardGlobal(selectedItem.name); }, onUnpublish: () => { void doUnpublishGlobal(selectedItem.name); } }))] }))] }), view === "canvas" && (_jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMoveTo: doMoveTo, onRemove: doRemove, zones: outlineZones, onClearZone: clearZoneAsked, onAddFirst: (zone) => {
|
|
956
1136
|
const first = allowedSections(policyOf(zone), registry.sections)[0];
|
|
957
1137
|
if (first)
|
|
958
1138
|
doInsert(zone, 0, first);
|
|
959
|
-
} }) })] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, globalIds: globalIds ?? {}, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertGlobal: (item) => doInsertGlobal(picker.zone, picker.index, item), onInsertCustom: () => doInsertCustom(picker.zone, picker.index), onClose: () => setPicker(null) }))] }));
|
|
1139
|
+
} }) }))] })] }), picker && (_jsx(InsertPicker, { at: picker, policy: policyOf(picker.zone), registry: registry, globalIds: globalIds ?? {}, paste: clip ? { title: clip.title, check: pasteCheck(picker.zone), onPaste: () => doPaste(picker.zone, picker.index) } : undefined, onInsert: (section) => doInsert(picker.zone, picker.index, section), onInsertGlobal: (item) => doInsertGlobal(picker.zone, picker.index, item), onInsertCustom: () => doInsertCustom(picker.zone, picker.index), onClose: () => setPicker(null) }))] }));
|
|
960
1140
|
}
|
|
961
1141
|
/** The selected node's ref field (if any) resolves to its target
|
|
962
1142
|
* collection's loaded options — the EntryForm shape, section-scoped. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ZonePolicy } from "../../zones.ts";
|
|
2
2
|
import type { AdminRegistry, AdminSection, AdminGlobal } from "../serialize.ts";
|
|
3
|
+
import type { PasteCheck } from "./section-clipboard.ts";
|
|
3
4
|
/** Where the "+" put the panel: `y` is 12px under the button's centre (EditorView). */
|
|
4
5
|
export type PickerAt = {
|
|
5
6
|
zone: string;
|
|
@@ -20,8 +21,14 @@ export declare function placePicker(at: PickerAt, height: number, viewport: {
|
|
|
20
21
|
width: number;
|
|
21
22
|
height: number;
|
|
22
23
|
}): PickerPlace;
|
|
23
|
-
export declare function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
|
|
24
|
+
export declare function InsertPicker({ at, policy, registry, globalIds, paste, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
|
|
24
25
|
at: PickerAt;
|
|
26
|
+
/** The section clipboard's entry, first in the list (spec 2026-10-19 editor at launch §3): disabled with the reason when the zone refuses. */
|
|
27
|
+
paste?: {
|
|
28
|
+
title: string;
|
|
29
|
+
check: PasteCheck;
|
|
30
|
+
onPaste(): void;
|
|
31
|
+
};
|
|
25
32
|
policy: ZonePolicy | undefined;
|
|
26
33
|
registry: AdminRegistry;
|
|
27
34
|
/** Global item name → row id; an item without one cannot be placed. */
|
|
@@ -33,7 +33,7 @@ function firstPlace(at) {
|
|
|
33
33
|
return { left: at.x, top: at.y };
|
|
34
34
|
return placePicker(at, 0, { width: window.innerWidth, height: window.innerHeight });
|
|
35
35
|
}
|
|
36
|
-
export function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }) {
|
|
36
|
+
export function InsertPicker({ at, policy, registry, globalIds, paste, onInsert, onInsertGlobal, onInsertCustom, onClose }) {
|
|
37
37
|
const sections = allowedSections(policy, registry.sections);
|
|
38
38
|
const globals = allowedGlobals(policy, registry.globals, globalIds);
|
|
39
39
|
const custom = allowsCustom(policy);
|
|
@@ -46,5 +46,5 @@ export function InsertPicker({ at, policy, registry, globalIds, onInsert, onInse
|
|
|
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] }), 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: "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." }))] }) }));
|
|
50
50
|
}
|
|
@@ -17,6 +17,6 @@ export function InspectorFields({ groups, values, render }) {
|
|
|
17
17
|
const entries = visibleEntries(g.entries, values);
|
|
18
18
|
if (entries.length === 0)
|
|
19
19
|
return null;
|
|
20
|
-
return (_jsxs("div", { className: "sm-inspector__group", children: [g.title && _jsx(SectionLabel, { caps: true, children: g.title }), entries.map((e) => render(e, { emptyLabel: e.ns === "styles" ? "Default" : undefined, scope }))] }, g.title ?? i));
|
|
20
|
+
return (_jsxs("div", { className: "sm-inspector__group", "data-smd-undo": "", children: [g.title && _jsx(SectionLabel, { caps: true, children: g.title }), entries.map((e) => render(e, { emptyLabel: e.ns === "styles" ? "Default" : undefined, scope }))] }, g.title ?? i));
|
|
21
21
|
}) }));
|
|
22
22
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TreeNode } from "../../render.tsx";
|
|
2
|
-
type ZoneRows = {
|
|
2
|
+
export type ZoneRows = {
|
|
3
3
|
zone: string;
|
|
4
4
|
locked: boolean;
|
|
5
5
|
canTake: boolean;
|
|
@@ -13,7 +13,7 @@ type ZoneRows = {
|
|
|
13
13
|
/** A zone rule the stored nodes break — "Zone `main` allows 3 sections and has 5." */
|
|
14
14
|
note?: string;
|
|
15
15
|
};
|
|
16
|
-
type Handlers = {
|
|
16
|
+
export type Handlers = {
|
|
17
17
|
selectedId: string | null;
|
|
18
18
|
onSelect(id: string): void;
|
|
19
19
|
/** The empty row of an insertable zone was clicked. */
|
|
@@ -27,4 +27,3 @@ type Handlers = {
|
|
|
27
27
|
export declare function Outline({ zones, ...handlers }: {
|
|
28
28
|
zones: ZoneRows[];
|
|
29
29
|
} & Handlers): import("react").JSX.Element;
|
|
30
|
-
export {};
|