smoodly 0.0.52 → 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.
Files changed (79) hide show
  1. package/dist/admin/client-ops.js +1 -1
  2. package/dist/admin/editor/CanvasOverlay.d.ts +3 -1
  3. package/dist/admin/editor/CanvasOverlay.js +2 -2
  4. package/dist/admin/editor/EditorView.js +243 -63
  5. package/dist/admin/editor/InsertPicker.d.ts +8 -1
  6. package/dist/admin/editor/InsertPicker.js +2 -2
  7. package/dist/admin/editor/InspectorFields.js +1 -1
  8. package/dist/admin/editor/Outline.d.ts +2 -3
  9. package/dist/admin/editor/PageInspector.js +1 -1
  10. package/dist/admin/editor/StructureView.d.ts +31 -0
  11. package/dist/admin/editor/StructureView.js +58 -0
  12. package/dist/admin/editor/card-preview.d.ts +10 -0
  13. package/dist/admin/editor/card-preview.js +50 -0
  14. package/dist/admin/editor/global-edit.js +29 -22
  15. package/dist/admin/editor/history.d.ts +39 -0
  16. package/dist/admin/editor/history.js +64 -0
  17. package/dist/admin/editor/section-clipboard.d.ts +40 -0
  18. package/dist/admin/editor/section-clipboard.js +106 -0
  19. package/dist/admin/editor/undo-keys.d.ts +19 -0
  20. package/dist/admin/editor/undo-keys.js +27 -0
  21. package/dist/admin/editor/useInlineSession.d.ts +4 -1
  22. package/dist/admin/editor/useInlineSession.js +11 -2
  23. package/dist/admin/editor/view-memory.d.ts +9 -0
  24. package/dist/admin/editor/view-memory.js +17 -0
  25. package/dist/admin/files.generated.js +1 -1
  26. package/dist/admin/handler.d.ts +1 -1
  27. package/dist/admin/index.d.ts +2 -2
  28. package/dist/admin/ops.d.ts +4 -10
  29. package/dist/admin/page-data.d.ts +1 -1
  30. package/dist/admin/session-flow.d.ts +24 -2
  31. package/dist/admin/session-flow.js +29 -2
  32. package/dist/admin/shell/AdminApp.d.ts +1 -1
  33. package/dist/admin/shell/IconRail.js +5 -4
  34. package/dist/admin/shell/presence.d.ts +1 -1
  35. package/dist/admin/shell/session.d.ts +1 -2
  36. package/dist/admin/shell/session.js +10 -9
  37. package/dist/admin/ui/ActionMenu.d.ts +2 -0
  38. package/dist/admin/ui/ActionMenu.js +1 -1
  39. package/dist/admin/ui/primitives.d.ts +1 -0
  40. package/dist/admin/ui/primitives.js +3 -2
  41. package/dist/admin/ui/theme.js +70 -0
  42. package/dist/admin/ui/useSortable.js +3 -1
  43. package/dist/api-client.js +1 -1
  44. package/dist/assemble.js +1 -1
  45. package/dist/astro/cache.d.ts +1 -1
  46. package/dist/astro/index.js +11 -6
  47. package/dist/astro/runtime.d.ts +1 -1
  48. package/dist/astro/runtime.js +1 -1
  49. package/dist/astro/shell.d.ts +1 -1
  50. package/dist/astro/shell.js +1 -1
  51. package/dist/content/content-api.d.ts +1 -0
  52. package/dist/content/content-api.js +3 -1
  53. package/dist/css.js +1 -1
  54. package/dist/entry-store.d.ts +5 -5
  55. package/dist/entry-store.js +7 -7
  56. package/dist/image/markup.js +1 -1
  57. package/dist/index.d.ts +2 -2
  58. package/dist/index.js +1 -1
  59. package/dist/media-cut.d.ts +1 -1
  60. package/dist/media-cut.js +2 -2
  61. package/dist/media.d.ts +1 -1
  62. package/dist/media.js +2 -2
  63. package/dist/member-store.d.ts +0 -29
  64. package/dist/member-store.js +0 -4
  65. package/dist/paths.d.ts +1 -1
  66. package/dist/paths.js +1 -1
  67. package/dist/revalidate.d.ts +1 -1
  68. package/dist/revalidate.js +1 -1
  69. package/dist/space-transfer.d.ts +1 -1
  70. package/dist/space-transfer.js +1 -1
  71. package/dist/version.d.ts +1 -1
  72. package/dist/version.js +1 -1
  73. package/package.json +1 -1
  74. package/dist/admin/auth-config.d.ts +0 -7
  75. package/dist/admin/auth-config.js +0 -1
  76. package/dist/admin/browser/fetch-op.d.ts +0 -10
  77. package/dist/admin/browser/fetch-op.js +0 -45
  78. package/dist/admin/browser/main.d.ts +0 -1
  79. package/dist/admin/browser/main.js +0 -27
@@ -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 {};
@@ -22,5 +22,5 @@ export function PageInspector({ record, locale, registry, tree, path, fixed, red
22
22
  useEffect(() => { setTab(pageTabsFor(template)[0]); }, [record.id]); // eslint-disable-line react-hooks/exhaustive-deps
23
23
  const active = tabs.includes(tab) ? tab : tabs[0];
24
24
  const title = record.locales[locale]?.title ?? "";
25
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, redirects: redirects, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], errors: misfitErrors(drift, "page.fields"), onChange: (value) => onPatch("fields", key, value) }, key))), onClearPageField && _jsx(OrphanFields, { orphans: drift, strip: "page.fields", onClear: onClearPageField }), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
25
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx("div", { "data-smd-undo": "", style: { display: "contents" }, children: _jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) }) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, redirects: redirects, onRename: onRename }), _jsx("div", { "data-smd-undo": "", style: { display: "contents" }, children: Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], errors: misfitErrors(drift, "page.fields"), onChange: (value) => onPatch("fields", key, value) }, key))) }), onClearPageField && _jsx(OrphanFields, { orphans: drift, strip: "page.fields", onClear: onClearPageField }), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
26
26
  }
@@ -0,0 +1,31 @@
1
+ import { type ReactElement } from "react";
2
+ import type { TreeNode } from "../../page-context.ts";
3
+ import { type ActionMenuItem } from "../ui/ActionMenu.tsx";
4
+ import type { CardPreview } from "./card-preview.ts";
5
+ import type { Handlers, ZoneRows } from "./Outline.tsx";
6
+ /** A card: the outline's row plus the section's icon from the registry (`icon ?? "▤"`), its preview, and whether it is a placed global item. */
7
+ export type StructureNode = ZoneRows["nodes"][number] & {
8
+ icon: string;
9
+ preview: CardPreview;
10
+ global: boolean;
11
+ };
12
+ export type StructureZone = Omit<ZoneRows, "nodes"> & {
13
+ nodes: StructureNode[];
14
+ };
15
+ export type StructureViewProps = {
16
+ zones: StructureZone[];
17
+ } & Omit<Handlers, "onAddFirst"> & {
18
+ /** "+ Add section" at a boundary, or the empty zone's first slot: opens the picker there. */
19
+ onAdd(zone: string, index: number, clientX: number, clientY: number): void;
20
+ /** The card's ⋯ entries beyond Remove; plan (c) adds Copy and the Paste entries here. */
21
+ cardActions?(node: TreeNode, zone: string, index: number): ActionMenuItem[];
22
+ /** The zone heading's ⋯ entries; plan (c) adds Paste. Absent or empty: no ⋯ on the heading. */
23
+ zoneActions?(zone: string): ActionMenuItem[];
24
+ };
25
+ /** The card's menu: the given entries, then Remove (danger; disabled in a locked zone). */
26
+ export declare function cardMenu({ locked, onRemove, actions }: {
27
+ locked: boolean;
28
+ onRemove(): void;
29
+ actions: ActionMenuItem[];
30
+ }): ActionMenuItem[];
31
+ export declare function StructureView({ zones, ...rest }: StructureViewProps): ReactElement;
@@ -0,0 +1,58 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The structure view (spec 2026-10-19 editor at launch §1): the canvas
4
+ // column's second renderer of the same tree — every zone a heading row
5
+ // and its sections as cards (grip, icon, title, a line of preview, a
6
+ // thumbnail, the ⋯ menu), dragged within the zone by the outline's
7
+ // `useSortable`. Every gesture lands on the editor's existing handlers;
8
+ // nothing here touches the tree. A card is a `div[role=button]`, not a
9
+ // <button>: it holds the grip and the menu, and a button inside a button
10
+ // is invalid HTML. The outline's marks carry over: broken red, orphan
11
+ // muted, a removed zone as one row with Clear zone.
12
+ import { useEffect, useState } from "react";
13
+ import { ActionMenu } from "../ui/ActionMenu.js";
14
+ import { Button, Grip, SectionLabel, cx } from "../ui/primitives.js";
15
+ import { useSortable } from "../ui/useSortable.js";
16
+ import { AssetThumb } from "../forms/MediaGrid.js";
17
+ import { useAssets } from "../forms/assets-context.js";
18
+ /** The card's menu: the given entries, then Remove (danger; disabled in a locked zone). */
19
+ export function cardMenu({ locked, onRemove, actions }) {
20
+ return [...actions, { label: "Remove", run: onRemove, danger: true, disabled: locked }];
21
+ }
22
+ export function StructureView({ zones, ...rest }) {
23
+ return (_jsx("div", { className: "sm-structure", "data-testid": "structure", children: zones.map((z) => _jsx(StructureZoneView, { ...z, ...rest }, z.zone)) }));
24
+ }
25
+ const stop = (e) => e.stopPropagation();
26
+ /** The ⋯'s Enter and Space must not select the card; every other key (the editor's Cmd+Z) bubbles on. */
27
+ const stopActivation = (e) => { if (e.key === "Enter" || e.key === " ")
28
+ e.stopPropagation(); };
29
+ function StructureZoneView({ zone, locked, canTake, nodes, removed, note, selectedId, onSelect, onMoveTo, onRemove, onClearZone, onAdd, cardActions, zoneActions, }) {
30
+ const sortable = useSortable({
31
+ count: locked || removed ? 0 : nodes.length,
32
+ onDrop: (from, to) => onMoveTo(nodes[from].node.id, to),
33
+ });
34
+ if (removed) {
35
+ return (_jsx("div", { className: "sm-structure__group", "data-testid": "removed-zone", children: _jsxs("div", { className: "sm-outline-row sm-outline-row--orphan sm-outline-row--removed", children: [_jsxs("span", { className: "sm-outline-row__label", children: ["Removed zone `", zone, "` \u00B7 ", nodes.length, " ", nodes.length === 1 ? "section" : "sections"] }), _jsx(Button, { variant: "danger", size: "sm", onClick: () => onClearZone?.(zone), children: "Clear zone" })] }) }));
36
+ }
37
+ const actions = zoneActions?.(zone) ?? [];
38
+ const add = (index) => (_jsx("span", { className: "sm-structure__slot", children: _jsxs("button", { type: "button", className: "sm-structure__add", "aria-label": `Add to ${zone}`, title: `Add to ${zone}`, onClick: (e) => { e.stopPropagation(); onAdd(zone, index, e.clientX, e.clientY); }, children: [_jsx("span", { className: "sm-structure__add-icon", "aria-hidden": "true", children: "+" }), "Add section"] }) }));
39
+ return (_jsxs("div", { className: "sm-structure__group", children: [_jsxs("div", { className: "sm-structure__zone", children: [_jsxs(SectionLabel, { caps: true, children: [zone, locked && " · locked"] }), actions.length > 0 && _jsx(ActionMenu, { small: true, label: "Zone actions", items: actions })] }), note && _jsx("p", { className: "sm-outline-note", children: note }), nodes.length === 0 && (_jsxs("div", { className: "sm-structure__empty", children: [_jsx("span", { children: "Empty" }), canTake && add(0)] })), nodes.length > 0 && canTake && add(0), nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, className: "sm-structure__rows", children: nodes.map(({ node, label, mark, icon, preview, global }, i) => (_jsxs("div", { className: cx("sm-structure__row", sortable.rowClass(i)), style: sortable.rowStyle(i), children: [_jsxs("div", { role: "button", tabIndex: 0, "data-testid": "structure-card", "data-node": node.id, className: cx("sm-structure__card", node.id === selectedId && "sm-structure__card--selected", locked && "sm-structure__card--locked", mark && `sm-structure__card--${mark}`), onClick: () => onSelect(node.id), onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") {
40
+ e.preventDefault();
41
+ onSelect(node.id);
42
+ } }, children: [locked ? _jsx("span", { className: "sm-structure__gripslot", "aria-hidden": "true" }) : _jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-structure__icon", "aria-hidden": "true", children: icon }), _jsxs("span", { className: "sm-structure__text", children: [_jsxs("span", { className: "sm-structure__title", children: [label, global && _jsx("span", { className: "sm-structure__badge", children: "global" })] }), global
43
+ ? _jsx("span", { className: "sm-structure__preview", children: "Global \u00B7 edited in one place" })
44
+ : preview.text !== null && _jsx("span", { className: "sm-structure__preview", children: preview.text })] }), !global && preview.image && _jsx(Thumb, { image: preview.image }), _jsx("span", { className: "sm-structure__menu", onClick: stop, onKeyDown: stopActivation, children: _jsx(ActionMenu, { small: true, label: "Section actions", items: cardMenu({ locked, onRemove: () => onRemove(node.id), actions: cardActions?.(node, zone, i) ?? [] }) }) })] }), canTake && add(i + 1)] }, node.id))) }))] }));
45
+ }
46
+ /** The thumbnail: the asset record fetched through the assets context (cached per shell lifetime), the box drawn at once. */
47
+ function Thumb({ image }) {
48
+ const assets = useAssets();
49
+ const [asset, setAsset] = useState(null);
50
+ useEffect(() => {
51
+ let live = true;
52
+ setAsset(null);
53
+ assets.get(image.asset).then((r) => { if (live)
54
+ setAsset(r); });
55
+ return () => { live = false; };
56
+ }, [assets, image.asset]);
57
+ return (_jsx("span", { className: "sm-structure__thumb", children: asset && asset.status !== "pending" && _jsx(AssetThumb, { asset: asset, className: "sm-structure__img" }) }));
58
+ }
@@ -0,0 +1,10 @@
1
+ import type { TreeNode } from "../../page-context.ts";
2
+ import type { ImageValue } from "../../media.ts";
3
+ import type { AdminSection } from "../serialize.ts";
4
+ export type CardPreview = {
5
+ text: string | null;
6
+ image: ImageValue | null;
7
+ };
8
+ /** Every text node's text joined by spaces, whitespace collapsed, cut at `max` with an ellipsis. */
9
+ export declare function richtextPlainText(doc: unknown, max?: number): string;
10
+ export declare function cardPreview(node: TreeNode, section: Pick<AdminSection, "fields"> | null): CardPreview;
@@ -0,0 +1,50 @@
1
+ import { fieldLabel } from "../field-label.js";
2
+ import { hasText } from "../richtext-doc.js";
3
+ /** Every text node's text joined by spaces, whitespace collapsed, cut at `max` with an ellipsis. */
4
+ export function richtextPlainText(doc, max = 120) {
5
+ const parts = [];
6
+ const walk = (n) => {
7
+ if (!n || typeof n !== "object")
8
+ return;
9
+ const o = n;
10
+ if (typeof o.text === "string")
11
+ parts.push(o.text);
12
+ if (Array.isArray(o.content))
13
+ o.content.forEach(walk);
14
+ };
15
+ walk(doc);
16
+ const text = parts.join(" ").replace(/\s+/g, " ").trim();
17
+ return text.length > max ? `${text.slice(0, max)}…` : text;
18
+ }
19
+ const isSetImage = (v) => !!v && typeof v === "object" && typeof v.asset === "string" && v.asset !== "";
20
+ export function cardPreview(node, section) {
21
+ if (!section)
22
+ return { text: null, image: null };
23
+ const values = node.fields ?? {};
24
+ let text = null;
25
+ let richtext = null;
26
+ let list = null;
27
+ let image = null;
28
+ for (const [key, descriptor] of Object.entries(section.fields)) {
29
+ const v = values[key];
30
+ switch (descriptor.type) {
31
+ case "text":
32
+ if (text === null && typeof v === "string" && v.trim() !== "")
33
+ text = v.trim();
34
+ break;
35
+ case "richtext":
36
+ if (richtext === null && hasText(v))
37
+ richtext = richtextPlainText(v);
38
+ break;
39
+ case "list":
40
+ if (list === null && Array.isArray(v) && v.length > 0)
41
+ list = `${v.length} · ${fieldLabel(key)}`;
42
+ break;
43
+ case "image":
44
+ if (image === null && isSetImage(v))
45
+ image = v;
46
+ break;
47
+ }
48
+ }
49
+ return { text: text ?? richtext ?? list, image };
50
+ }
@@ -7,7 +7,8 @@ export function createGlobalEdit(opts) {
7
7
  let loadError = null;
8
8
  let stale = null;
9
9
  let generation = 0;
10
- const loading = new Set();
10
+ /** In-flight loads by `name/locale`: a repeat while one runs awaits it (an undone global step patches a loaded item). */
11
+ const loading = new Map();
11
12
  const rev = new Map();
12
13
  const savedRev = new Map();
13
14
  const listeners = new Set();
@@ -59,30 +60,36 @@ export function createGlobalEdit(opts) {
59
60
  listeners.add(listener);
60
61
  return () => { listeners.delete(listener); };
61
62
  },
62
- async load(name, locale) {
63
+ load(name, locale) {
63
64
  const key = `${name}/${locale}`;
64
- if (items[name]?.locale === locale || loading.has(key))
65
- return;
66
- loading.add(key);
65
+ if (items[name]?.locale === locale)
66
+ return Promise.resolve();
67
+ const pending = loading.get(key);
68
+ if (pending)
69
+ return pending;
67
70
  const gen = generation;
68
- let r;
69
- try {
70
- r = await opts.ops.globals.get(name, locale);
71
- }
72
- catch {
73
- r = { ok: false, code: "internal", message: "Couldn't reach the server." };
74
- }
75
- loading.delete(key);
76
- if (gen !== generation)
77
- return; // reset() ran meanwhile — this canvas is gone
78
- if (!r.ok) {
79
- loadError = r.message;
71
+ const run = (async () => {
72
+ let r;
73
+ try {
74
+ r = await opts.ops.globals.get(name, locale);
75
+ }
76
+ catch {
77
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
78
+ }
79
+ loading.delete(key);
80
+ if (gen !== generation)
81
+ return; // reset() ran meanwhile — this canvas is gone
82
+ if (!r.ok) {
83
+ loadError = r.message;
84
+ emit();
85
+ return;
86
+ }
87
+ items = { ...items, [name]: { name, locale, record: r.data.record, fields: r.data.fields, usage: r.data.usage, errors: {} } };
88
+ loadError = null;
80
89
  emit();
81
- return;
82
- }
83
- items = { ...items, [name]: { name, locale, record: r.data.record, fields: r.data.fields, usage: r.data.usage, errors: {} } };
84
- loadError = null;
85
- emit();
90
+ })();
91
+ loading.set(key, run);
92
+ return run;
86
93
  },
87
94
  patch(name, path, value) {
88
95
  const item = items[name];
@@ -0,0 +1,39 @@
1
+ import type { PageTree } from "../../page-context.ts";
2
+ export type TreeStep = {
3
+ kind: "tree";
4
+ before: PageTree;
5
+ after: PageTree;
6
+ key?: string;
7
+ at: number;
8
+ focus?: string;
9
+ };
10
+ export type GlobalStep = {
11
+ kind: "global";
12
+ name: string;
13
+ path: string;
14
+ before: unknown;
15
+ after: unknown;
16
+ key: string;
17
+ at: number;
18
+ focus?: string;
19
+ };
20
+ export type Step = TreeStep | GlobalStep;
21
+ export type History = {
22
+ /** Pushes a step (or merges it into the last one of the same key) and drops the redo stack. */
23
+ record(step: Step): void;
24
+ /** The step to reverse, moved onto redo; null when there is none. */
25
+ undo(): Step | null;
26
+ redo(): Step | null;
27
+ /** Keeps `key` merging whatever the timing until every release returned for it was called. */
28
+ group(key: string): () => void;
29
+ clear(): void;
30
+ /** Drops the matching steps from both stacks (a global item's Discard). */
31
+ forget(match: (step: Step) => boolean): void;
32
+ canUndo(): boolean;
33
+ canRedo(): boolean;
34
+ subscribe(fn: () => void): () => void;
35
+ };
36
+ export declare function createHistory({ coalesceMs, max }?: {
37
+ coalesceMs?: number;
38
+ max?: number;
39
+ }): History;
@@ -0,0 +1,64 @@
1
+ export function createHistory({ coalesceMs = 1000, max = 100 } = {}) {
2
+ let undo = [];
3
+ let redo = [];
4
+ const groups = new Map();
5
+ /** Keys whose next step must start fresh: a group just opened (a new inline session never merges into the last one's step). */
6
+ const fresh = new Set();
7
+ const listeners = new Set();
8
+ const notify = () => { for (const fn of listeners)
9
+ fn(); };
10
+ const merges = (last, step) => !!last && step.key !== undefined && last.key === step.key &&
11
+ (step.at - last.at <= coalesceMs || (groups.get(step.key) ?? 0) > 0);
12
+ return {
13
+ record(step) {
14
+ redo = [];
15
+ const last = undo[undo.length - 1];
16
+ const boundary = step.key !== undefined && fresh.delete(step.key);
17
+ if (!boundary && merges(last, step)) {
18
+ undo[undo.length - 1] = { ...last, after: step.after, at: step.at, focus: step.focus ?? last.focus };
19
+ }
20
+ else {
21
+ undo.push(step);
22
+ if (undo.length > max)
23
+ undo = undo.slice(undo.length - max);
24
+ }
25
+ notify();
26
+ },
27
+ undo() {
28
+ const step = undo.pop() ?? null;
29
+ if (step) {
30
+ redo.push(step);
31
+ notify();
32
+ }
33
+ return step;
34
+ },
35
+ redo() {
36
+ const step = redo.pop() ?? null;
37
+ if (step) {
38
+ undo.push(step);
39
+ notify();
40
+ }
41
+ return step;
42
+ },
43
+ group(key) {
44
+ groups.set(key, (groups.get(key) ?? 0) + 1);
45
+ fresh.add(key);
46
+ let released = false;
47
+ return () => {
48
+ if (released)
49
+ return;
50
+ released = true;
51
+ const n = (groups.get(key) ?? 1) - 1;
52
+ if (n <= 0)
53
+ groups.delete(key);
54
+ else
55
+ groups.set(key, n);
56
+ };
57
+ },
58
+ clear() { undo = []; redo = []; notify(); },
59
+ forget(match) { undo = undo.filter((s) => !match(s)); redo = redo.filter((s) => !match(s)); notify(); },
60
+ canUndo: () => undo.length > 0,
61
+ canRedo: () => redo.length > 0,
62
+ subscribe(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; },
63
+ };
64
+ }
@@ -0,0 +1,40 @@
1
+ import type { TreeNode } from "../../page-context.ts";
2
+ import type { ZonePolicy } from "../../zones.ts";
3
+ import type { AdminRegistry } from "../serialize.ts";
4
+ export declare const CLIPBOARD_KEY = "smoodly-clipboard";
5
+ export type Clip = {
6
+ v: 1;
7
+ space: string;
8
+ title: string;
9
+ node: Omit<TreeNode, "id">;
10
+ copiedAt: number;
11
+ };
12
+ export type ClipStorage = {
13
+ getItem(key: string): string | null;
14
+ setItem(key: string, value: string): void;
15
+ };
16
+ export type PasteCheck = {
17
+ ok: true;
18
+ } | {
19
+ ok: false;
20
+ reason: string;
21
+ };
22
+ /** The space this admin serves, from the entry's payload (admin/browser/main.tsx). */
23
+ export declare function setClipboardSpace(s: string): void;
24
+ export declare function clipboardSpace(): string;
25
+ /** The clip of a node: its deep clone without the id, titled as the outline labels it. A placement copies as its placement. */
26
+ export declare function clipOf(node: TreeNode, title: string, forSpace: string, now?: number): Clip;
27
+ /** False when the storage throws (private mode, blocked site data). */
28
+ export declare function writeClip(storage: ClipStorage, clip: Clip): boolean;
29
+ /** The clip for this space, or null: another space, another version, bad JSON and a throwing storage all read as empty. */
30
+ export declare function readClip(storage: ClipStorage, forSpace: string): Clip | null;
31
+ /** A fresh node from the clip: a new id, a fresh deep clone (a clip pasted twice never shares a list). A placement takes the
32
+ * item's current row id when `globalIds` knows it: the copied one may be dead after a reseed of the space. */
33
+ export declare function pastedNode(clip: Clip, globalIds?: Record<string, string>): TreeNode;
34
+ /** Whether the target zone takes the clip, with the reason when not — the picker's own rules (spec §3). */
35
+ export declare function canPaste(clip: Clip, policy: ZonePolicy | undefined, count: number, registry: Pick<AdminRegistry, "sections" | "globals" | "custom">, globalIds: Record<string, string>): PasteCheck;
36
+ /** The `storage` event for the key (another tab wrote the clip, or the storage was cleared); the release removes the listener. */
37
+ export declare function subscribeClip(win: {
38
+ addEventListener: Window["addEventListener"];
39
+ removeEventListener: Window["removeEventListener"];
40
+ }, fn: () => void): () => void;
@@ -0,0 +1,106 @@
1
+ import { GLOBAL_NODE_TYPE } from "../../global.js";
2
+ import { CUSTOM_NODE_TYPE } from "../../custom/index.js";
3
+ import { allowedGlobals, canInsert, newNodeId } from "../tree-ops.js";
4
+ import { allowsCustom } from "../custom-section.js";
5
+ export const CLIPBOARD_KEY = "smoodly-clipboard";
6
+ let space = "";
7
+ /** The space this admin serves, from the entry's payload (admin/browser/main.tsx). */
8
+ export function setClipboardSpace(s) { space = s; }
9
+ export function clipboardSpace() { return space; }
10
+ /** Deep copies as Duplicate does: a shared list (a custom section's rows) would edit both pages. */
11
+ const cloneNode = (node) => {
12
+ const out = { type: node.type, fields: structuredClone(node.fields) };
13
+ if (node.settings)
14
+ out.settings = structuredClone(node.settings);
15
+ if (node.styles)
16
+ out.styles = structuredClone(node.styles);
17
+ if (node.css !== undefined)
18
+ out.css = node.css;
19
+ return out;
20
+ };
21
+ /** The clip of a node: its deep clone without the id, titled as the outline labels it. A placement copies as its placement. */
22
+ export function clipOf(node, title, forSpace, now = Date.now()) {
23
+ return { v: 1, space: forSpace, title, node: cloneNode(node), copiedAt: now };
24
+ }
25
+ /** False when the storage throws (private mode, blocked site data). */
26
+ export function writeClip(storage, clip) {
27
+ try {
28
+ storage.setItem(CLIPBOARD_KEY, JSON.stringify(clip));
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ /** The clip for this space, or null: another space, another version, bad JSON and a throwing storage all read as empty. */
36
+ export function readClip(storage, forSpace) {
37
+ let raw;
38
+ try {
39
+ raw = storage.getItem(CLIPBOARD_KEY);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ if (!raw)
45
+ return null;
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(raw);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ if (!parsed || typeof parsed !== "object")
54
+ return null;
55
+ const c = parsed;
56
+ if (c.v !== 1 || c.space !== forSpace || typeof c.title !== "string" || typeof c.copiedAt !== "number")
57
+ return null;
58
+ if (!c.node || typeof c.node !== "object" || typeof c.node.type !== "string" || !c.node.fields || typeof c.node.fields !== "object")
59
+ return null;
60
+ return { v: 1, space: c.space, title: c.title, node: c.node, copiedAt: c.copiedAt };
61
+ }
62
+ /** A fresh node from the clip: a new id, a fresh deep clone (a clip pasted twice never shares a list). A placement takes the
63
+ * item's current row id when `globalIds` knows it: the copied one may be dead after a reseed of the space. */
64
+ export function pastedNode(clip, globalIds = {}) {
65
+ const node = { id: newNodeId(clip.node.type), ...cloneNode(clip.node) };
66
+ if (node.type === GLOBAL_NODE_TYPE && typeof node.fields.item === "string" && globalIds[node.fields.item]) {
67
+ node.fields = { ...node.fields, ref: globalIds[node.fields.item] };
68
+ }
69
+ return node;
70
+ }
71
+ const refuse = (reason) => ({ ok: false, reason });
72
+ /** Whether the target zone takes the clip, with the reason when not — the picker's own rules (spec §3). */
73
+ export function canPaste(clip, policy, count, registry, globalIds) {
74
+ const { type } = clip.node;
75
+ const notTaken = refuse(`This zone doesn't take “${clip.title}”`);
76
+ if (type === GLOBAL_NODE_TYPE) {
77
+ const name = clip.node.fields.item;
78
+ const item = typeof name === "string" ? registry.globals.find((g) => g.name === name) : undefined;
79
+ if (!item)
80
+ return refuse(`“${clip.title}” is no longer registered on this site`);
81
+ if (!allowedGlobals(policy, [item], globalIds).length)
82
+ return notTaken;
83
+ }
84
+ else if (type === CUSTOM_NODE_TYPE) {
85
+ if (!allowsCustom(policy))
86
+ return notTaken;
87
+ }
88
+ else {
89
+ if (!registry.sections.some((s) => s.name === type))
90
+ return refuse(`“${clip.title}” is no longer registered on this site`);
91
+ if (!policy || policy.kind === "locked")
92
+ return notTaken;
93
+ if (policy.allow !== "*" && !policy.allow.includes(type))
94
+ return notTaken;
95
+ }
96
+ if (!canInsert(policy, count))
97
+ return policy && policy.kind !== "locked" ? refuse("This zone is full") : notTaken;
98
+ return { ok: true };
99
+ }
100
+ /** The `storage` event for the key (another tab wrote the clip, or the storage was cleared); the release removes the listener. */
101
+ export function subscribeClip(win, fn) {
102
+ const onStorage = (e) => { if (e.key === null || e.key === CLIPBOARD_KEY)
103
+ fn(); };
104
+ win.addEventListener("storage", onStorage);
105
+ return () => win.removeEventListener("storage", onStorage);
106
+ }
@@ -0,0 +1,19 @@
1
+ export type UndoKey = "undo" | "redo" | null;
2
+ type KeyLike = {
3
+ key: string;
4
+ metaKey: boolean;
5
+ ctrlKey: boolean;
6
+ shiftKey: boolean;
7
+ altKey: boolean;
8
+ };
9
+ export declare function undoKeyOf(e: KeyLike, platformMac: boolean): UndoKey;
10
+ type TargetLike = {
11
+ tagName: string;
12
+ isContentEditable: boolean;
13
+ closest(selector: string): unknown;
14
+ getAttribute?(name: string): string | null;
15
+ };
16
+ /** Inside the field widgets, a plain input, textarea or select is the editor's; a contentEditable there is the sidebar richtext
17
+ * (ProseMirror keeps its own undo) and a combobox keeps its search text — both as the spec lists them. */
18
+ export declare function takesUndoKeys(target: TargetLike | null | undefined): boolean;
19
+ export {};
@@ -0,0 +1,27 @@
1
+ export function undoKeyOf(e, platformMac) {
2
+ if (e.altKey)
3
+ return null;
4
+ const primary = platformMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;
5
+ if (!primary)
6
+ return null;
7
+ const k = e.key.toLowerCase();
8
+ if (k === "z")
9
+ return e.shiftKey ? "redo" : "undo";
10
+ if (k === "y" && !platformMac && !e.shiftKey)
11
+ return "redo";
12
+ return null;
13
+ }
14
+ const TEXT_CONTROLS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
15
+ /** Inside the field widgets, a plain input, textarea or select is the editor's; a contentEditable there is the sidebar richtext
16
+ * (ProseMirror keeps its own undo) and a combobox keeps its search text — both as the spec lists them. */
17
+ export function takesUndoKeys(target) {
18
+ if (!target)
19
+ return true;
20
+ if (target.isContentEditable)
21
+ return false;
22
+ if (!TEXT_CONTROLS.has(target.tagName.toUpperCase()))
23
+ return true;
24
+ if (target.getAttribute?.("role") === "combobox")
25
+ return false;
26
+ return target.closest("[data-smd-undo]") !== null;
27
+ }
@@ -26,11 +26,14 @@ type Point = {
26
26
  x: number;
27
27
  y: number;
28
28
  } | null;
29
- export declare function useInlineSession({ iframeRef, postToCanvas, patch }: {
29
+ export declare function useInlineSession({ iframeRef, postToCanvas, patch, onSession }: {
30
30
  iframeRef: RefObject<HTMLIFrameElement | null>;
31
31
  postToCanvas(m: ToBridge): void;
32
32
  /** Write a field value into the tree by node id and dotted path. */
33
33
  patch(node: string, path: string, value: unknown): void;
34
+ /** A session opened: returns its release, called when the session ends or the canvas changes (the undo group,
35
+ * spec 2026-10-19 editor at launch §2: an inline session is one step). */
36
+ onSession?(session: Session): () => void;
34
37
  }): {
35
38
  session: Session | null;
36
39
  richtext: RichtextMount | null;
@@ -17,7 +17,7 @@ import { INITIAL_INLINE, reduceInline } from "./inline-session.js";
17
17
  const CLOSE_FALLBACK_MS = 4000;
18
18
  /** On a hidden child: its inline display before the session, restored after. */
19
19
  const HIDDEN_ATTR = "data-smd-hidden";
20
- export function useInlineSession({ iframeRef, postToCanvas, patch }) {
20
+ export function useInlineSession({ iframeRef, postToCanvas, patch, onSession }) {
21
21
  const [state, setState] = useState(INITIAL_INLINE);
22
22
  const stateRef = useRef(state);
23
23
  const [richtext, setRichtext] = useState(null);
@@ -27,6 +27,11 @@ export function useInlineSession({ iframeRef, postToCanvas, patch }) {
27
27
  const closeTimer = useRef(null);
28
28
  const patchRef = useRef(patch);
29
29
  patchRef.current = patch;
30
+ const onSessionRef = useRef(onSession);
31
+ onSessionRef.current = onSession;
32
+ /** The open session's release from `onSession`. */
33
+ const releaseSessionRef = useRef(null);
34
+ const releaseSession = () => { const r = releaseSessionRef.current; releaseSessionRef.current = null; r?.(); };
30
35
  const dispatch = useCallback((event) => {
31
36
  const { state: next, refresh } = reduceInline(stateRef.current, event);
32
37
  stateRef.current = next;
@@ -137,6 +142,7 @@ export function useInlineSession({ iframeRef, postToCanvas, patch }) {
137
142
  const changed = changedRef.current;
138
143
  // The reducer first: the blur that removing contenteditable fires re-enters here and must see no session.
139
144
  dispatch({ type: "end" });
145
+ releaseSession();
140
146
  if (text) {
141
147
  textRef.current = null;
142
148
  text.teardown();
@@ -169,8 +175,10 @@ export function useInlineSession({ iframeRef, postToCanvas, patch }) {
169
175
  }
170
176
  end();
171
177
  const ok = session.kind === "text" ? mountText(d, session, at) : mountRichtext(d, session, at);
172
- if (ok)
178
+ if (ok) {
173
179
  dispatch({ type: "start", session });
180
+ releaseSessionRef.current = onSessionRef.current?.(session) ?? null;
181
+ }
174
182
  return ok;
175
183
  }, [iframeRef, dispatch, end, release, mountText, mountRichtext]);
176
184
  // ── the instant write (spec §7) ─────────────────────────────────────────
@@ -210,6 +218,7 @@ export function useInlineSession({ iframeRef, postToCanvas, patch }) {
210
218
  const mount = richtextRef.current;
211
219
  if (mount)
212
220
  release(mount);
221
+ releaseSession();
213
222
  dispatch({ type: "canvas" });
214
223
  }, [dispatch, release]);
215
224
  useEffect(() => () => { if (closeTimer.current)
@@ -0,0 +1,9 @@
1
+ export type EditorViewMode = "canvas" | "structure";
2
+ export declare const VIEW_KEY = "smoodly-editor-view";
3
+ type Storage = {
4
+ getItem(key: string): string | null;
5
+ setItem(key: string, value: string): void;
6
+ };
7
+ export declare function rememberedView(storage?: Storage): EditorViewMode;
8
+ export declare function rememberView(view: EditorViewMode, storage?: Storage): void;
9
+ export {};