smoodly 0.0.53 → 0.0.55
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 +11 -3
- package/dist/admin/editor/Outline.js +12 -3
- package/dist/admin/editor/PageInspector.js +1 -1
- package/dist/admin/editor/StructureView.d.ts +23 -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 +69 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TreeNode } from "../../render.tsx";
|
|
2
|
-
type
|
|
2
|
+
import { type ActionMenuItem } from "../ui/ActionMenu.tsx";
|
|
3
|
+
export type ZoneRows = {
|
|
3
4
|
zone: string;
|
|
4
5
|
locked: boolean;
|
|
5
6
|
canTake: boolean;
|
|
@@ -13,7 +14,7 @@ type ZoneRows = {
|
|
|
13
14
|
/** A zone rule the stored nodes break — "Zone `main` allows 3 sections and has 5." */
|
|
14
15
|
note?: string;
|
|
15
16
|
};
|
|
16
|
-
type Handlers = {
|
|
17
|
+
export type Handlers = {
|
|
17
18
|
selectedId: string | null;
|
|
18
19
|
onSelect(id: string): void;
|
|
19
20
|
/** The empty row of an insertable zone was clicked. */
|
|
@@ -23,8 +24,15 @@ type Handlers = {
|
|
|
23
24
|
onRemove(id: string): void;
|
|
24
25
|
/** A removed zone's Clear zone. */
|
|
25
26
|
onClearZone?(zone: string): void;
|
|
27
|
+
/** The row's ⋯ entries before Remove: Copy and the Paste entries (spec 2026-10-19 editor at launch §3). */
|
|
28
|
+
cardActions?(node: TreeNode, zone: string, index: number): ActionMenuItem[];
|
|
26
29
|
};
|
|
30
|
+
/** The row's menu, shared with the structure view's cards: the given entries, then Remove (danger; disabled in a locked zone). */
|
|
31
|
+
export declare function cardMenu({ locked, onRemove, actions }: {
|
|
32
|
+
locked: boolean;
|
|
33
|
+
onRemove(): void;
|
|
34
|
+
actions: ActionMenuItem[];
|
|
35
|
+
}): ActionMenuItem[];
|
|
27
36
|
export declare function Outline({ zones, ...handlers }: {
|
|
28
37
|
zones: ZoneRows[];
|
|
29
38
|
} & Handlers): import("react").JSX.Element;
|
|
30
|
-
export {};
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { ActionMenu } from "../ui/ActionMenu.js";
|
|
4
|
+
import { Button, Grip, SectionLabel, cx } from "../ui/primitives.js";
|
|
4
5
|
import { useSortable } from "../ui/useSortable.js";
|
|
6
|
+
/** The row's menu, shared with the structure view's cards: the given entries, then Remove (danger; disabled in a locked zone). */
|
|
7
|
+
export function cardMenu({ locked, onRemove, actions }) {
|
|
8
|
+
return [...actions, { label: "Remove", run: onRemove, danger: true, disabled: locked }];
|
|
9
|
+
}
|
|
10
|
+
const stop = (e) => e.stopPropagation();
|
|
11
|
+
/** The ⋯'s Enter and Space must not select the row; every other key (the editor's Cmd+Z) bubbles on. */
|
|
12
|
+
const stopActivation = (e) => { if (e.key === "Enter" || e.key === " ")
|
|
13
|
+
e.stopPropagation(); };
|
|
5
14
|
export function Outline({ zones, ...handlers }) {
|
|
6
15
|
return (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Page Outline" }), zones.map((z) => _jsx(OutlineZone, { ...z, ...handlers }, z.zone))] }));
|
|
7
16
|
}
|
|
8
|
-
function OutlineZone({ zone, locked, canTake, nodes, removed, note, selectedId, onSelect, onAddFirst, onMoveTo, onRemove, onClearZone }) {
|
|
17
|
+
function OutlineZone({ zone, locked, canTake, nodes, removed, note, selectedId, onSelect, onAddFirst, onMoveTo, onRemove, onClearZone, cardActions }) {
|
|
9
18
|
const sortable = useSortable({
|
|
10
19
|
count: locked || removed ? 0 : nodes.length,
|
|
11
20
|
onDrop: (from, to) => onMoveTo(nodes[from].node.id, to),
|
|
@@ -14,5 +23,5 @@ function OutlineZone({ zone, locked, canTake, nodes, removed, note, selectedId,
|
|
|
14
23
|
return (_jsx("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, "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" })] }) }));
|
|
15
24
|
}
|
|
16
25
|
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { caps: true, children: [zone, locked && " · locked"] }), note && _jsx("p", { className: "sm-outline-note", children: note }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
|
|
17
|
-
onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label, mark }) => (_jsxs("div", { className: cx("sm-outline-row sm-outline-row--locked sm-outline-row--clickable", mark && `sm-outline-row--${mark}`), "data-testid": "outline-row", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label, mark }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), "data-testid": "outline-row", className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", mark && `sm-outline-row--${mark}`, sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx(
|
|
26
|
+
onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label, mark }) => (_jsxs("div", { className: cx("sm-outline-row sm-outline-row--locked sm-outline-row--clickable", mark && `sm-outline-row--${mark}`), "data-testid": "outline-row", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label, mark }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), "data-testid": "outline-row", className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", mark && `sm-outline-row--${mark}`, sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__menu", onClick: stop, onKeyDown: stopActivation, children: _jsx(ActionMenu, { small: true, label: "Section actions", items: cardMenu({ locked: false, onRemove: () => onRemove(node.id), actions: cardActions?.(node, zone, i) ?? [] }) }) })] }, node.id))) }))] }));
|
|
18
27
|
}
|
|
@@ -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,23 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import { type ActionMenuItem } from "../ui/ActionMenu.tsx";
|
|
3
|
+
import type { CardPreview } from "./card-preview.ts";
|
|
4
|
+
import { cardMenu, type Handlers, type ZoneRows } from "./Outline.tsx";
|
|
5
|
+
export { cardMenu };
|
|
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 the zone's end, or the empty zone's first slot: opens the picker there. */
|
|
19
|
+
onAdd(zone: string, index: number, clientX: number, clientY: number): void;
|
|
20
|
+
/** The zone heading's ⋯ entries (Paste). Absent or empty: no ⋯ on the heading. */
|
|
21
|
+
zoneActions?(zone: string): ActionMenuItem[];
|
|
22
|
+
};
|
|
23
|
+
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. One "+ Add section"
|
|
12
|
+
// per zone, at its end (revised 2026-10-20: one between every pair of
|
|
13
|
+
// cards read as noise); a place in the middle is a paste or a drag.
|
|
14
|
+
import { useEffect, useState } from "react";
|
|
15
|
+
import { ActionMenu } from "../ui/ActionMenu.js";
|
|
16
|
+
import { Button, Grip, SectionLabel, cx } from "../ui/primitives.js";
|
|
17
|
+
import { useSortable } from "../ui/useSortable.js";
|
|
18
|
+
import { AssetThumb } from "../forms/MediaGrid.js";
|
|
19
|
+
import { useAssets } from "../forms/assets-context.js";
|
|
20
|
+
import { cardMenu } from "./Outline.js";
|
|
21
|
+
export { cardMenu };
|
|
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 && (_jsx("div", { ref: sortable.containerRef, className: "sm-structure__rows", children: nodes.map(({ node, label, mark, icon, preview, global }, i) => (_jsx("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) ?? [] }) }) })] }) }, node.id))) })), nodes.length > 0 && canTake && add(nodes.length)] }));
|
|
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
|
-
|
|
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
|
-
|
|
63
|
+
load(name, locale) {
|
|
63
64
|
const key = `${name}/${locale}`;
|
|
64
|
-
if (items[name]?.locale === locale
|
|
65
|
-
return;
|
|
66
|
-
loading.
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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;
|