smoodly 0.0.62 → 0.0.63
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/client-ops.js +1 -1
- package/dist/admin/drift.js +3 -1
- package/dist/admin/editor/EditorView.js +65 -9
- package/dist/admin/editor/InsertPicker.d.ts +8 -27
- package/dist/admin/editor/InsertPicker.js +56 -47
- package/dist/admin/editor/SamplePreview.d.ts +7 -0
- package/dist/admin/editor/SamplePreview.js +86 -0
- package/dist/admin/editor/SectionIcon.d.ts +4 -0
- package/dist/admin/editor/SectionIcon.js +8 -0
- package/dist/admin/editor/StructureView.d.ts +2 -2
- package/dist/admin/editor/StructureView.js +2 -1
- package/dist/admin/editor/canvas-address.d.ts +2 -0
- package/dist/admin/editor/canvas-address.js +3 -1
- package/dist/admin/editor/picker-rows.d.ts +33 -0
- package/dist/admin/editor/picker-rows.js +38 -0
- package/dist/admin/editor/sample-frame.d.ts +36 -0
- package/dist/admin/editor/sample-frame.js +58 -0
- package/dist/admin/editor/sample-picks.d.ts +34 -0
- package/dist/admin/editor/sample-picks.js +49 -0
- package/dist/admin/editor/view-memory.d.ts +5 -0
- package/dist/admin/editor/view-memory.js +19 -0
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/forms/MediaWidget.d.ts +1 -1
- package/dist/admin/forms/MediaWidget.js +10 -2
- package/dist/admin/ops.d.ts +5 -0
- package/dist/admin/shell/GlobalForm.js +1 -1
- package/dist/admin/shell/GlobalList.js +1 -1
- package/dist/admin/shell/NavBar.js +1 -1
- package/dist/admin/shell/PagesList.js +4 -4
- package/dist/admin/shell/console-links.js +1 -1
- package/dist/admin/shell/health-list.js +1 -1
- package/dist/admin/tree-ops.d.ts +11 -4
- package/dist/admin/tree-ops.js +28 -17
- package/dist/admin/ui/theme.js +32 -13
- package/dist/admin/validate.js +3 -2
- package/dist/api-client.js +12 -2
- package/dist/astro/middleware.js +2 -0
- package/dist/astro/shell.d.ts +3 -0
- package/dist/astro/shell.js +8 -1
- package/dist/canvas-marker.d.ts +5 -0
- package/dist/canvas-marker.js +18 -6
- package/dist/conditions.js +5 -2
- package/dist/media.d.ts +15 -2
- package/dist/media.js +68 -10
- package/dist/next/page-renderer.js +12 -5
- package/dist/resolve.d.ts +6 -1
- package/dist/resolve.js +7 -7
- package/dist/sample-fill.d.ts +40 -0
- package/dist/sample-fill.js +145 -0
- package/dist/sample-tree.d.ts +30 -0
- package/dist/sample-tree.js +76 -0
- package/dist/site.d.ts +13 -0
- package/dist/site.js +10 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/content-modeling/sections.md +19 -3
- package/docs/content-modeling/zones.md +2 -2
- package/docs/getting-started/working-together.md +1 -1
- package/docs/globals/defining.md +1 -1
- package/docs/globals/header-and-footer.md +1 -1
- package/package.json +1 -1
package/dist/admin/client-ops.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const GROUPS = {
|
|
2
2
|
pages: ["list", "get", "create", "saveDraft", "publish", "unpublish", "discardDraft", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale", "listVersions", "restore"],
|
|
3
|
-
entries: ["list", "page", "distinct", "get", "create", "save", "setStatus", "discardDraft", "delete", "move", "addLocale", "removeLocale", "listVersions", "getVersion", "restore"],
|
|
3
|
+
entries: ["list", "page", "distinct", "samplePicks", "get", "create", "save", "setStatus", "discardDraft", "delete", "move", "addLocale", "removeLocale", "listVersions", "getVersion", "restore"],
|
|
4
4
|
globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
|
|
5
5
|
assets: ["list", "get", "update", "delete", "usage"],
|
|
6
6
|
members: ["list", "me"],
|
package/dist/admin/drift.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { optionValues } from "../fields.js";
|
|
2
|
+
import { isImagePlaceholder } from "../media.js";
|
|
2
3
|
import { isHidden } from "../conditions.js";
|
|
3
4
|
import { allowedOf } from "../toolset.js";
|
|
4
5
|
import { CUSTOM_NODE_TYPE } from "../custom/schema.js";
|
|
@@ -38,7 +39,8 @@ export function fits(d, value) {
|
|
|
38
39
|
const allowed = optionValues(d.options);
|
|
39
40
|
return Array.isArray(value) && value.every((v) => typeof v === "string" && allowed.includes(v));
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
+
// A fresh node's image placeholder is a value of an image field (spec 2026-11-14 filled samples), of no other.
|
|
43
|
+
case "image": return isObject(value) && (typeof value.asset === "string" || isImagePlaceholder(value));
|
|
42
44
|
case "video":
|
|
43
45
|
case "file": return isObject(value) && typeof value.asset === "string";
|
|
44
46
|
case "ref": return typeof value === "string";
|
|
@@ -44,13 +44,14 @@ import { enableDraft } from "./preview-grant.js";
|
|
|
44
44
|
import { CanvasOverlay } from "./CanvasOverlay.js";
|
|
45
45
|
import { InlineRichtext } from "./InlineRichtext.js";
|
|
46
46
|
import { InsertPicker } from "./InsertPicker.js";
|
|
47
|
+
import { insertStands, loadPicks, picksCache } from "./sample-picks.js";
|
|
47
48
|
import { customNode, customNodeLabel, customSection } from "../custom-section.js";
|
|
48
49
|
import { CUSTOM_NODE_TYPE } from "../../custom/index.js";
|
|
49
50
|
import { NoticePane } from "./NoticePane.js";
|
|
50
51
|
import { Outline } from "./Outline.js";
|
|
51
52
|
import { StructureView } from "./StructureView.js";
|
|
52
53
|
import { cardPreview } from "./card-preview.js";
|
|
53
|
-
import { rememberView, rememberedView } from "./view-memory.js";
|
|
54
|
+
import { rememberFilled, rememberView, rememberedFilled, rememberedView } from "./view-memory.js";
|
|
54
55
|
import { createHistory } from "./history.js";
|
|
55
56
|
import { takesUndoKeys, undoKeyOf } from "./undo-keys.js";
|
|
56
57
|
import { canPaste, clipOf, clipboardSpace, pastedNode, readClip, subscribeClip, writeClip } from "./section-clipboard.js";
|
|
@@ -123,6 +124,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
123
124
|
const inspectorRef = useRef(null);
|
|
124
125
|
// The session hook is created after `mutate`; the autosave and the bridge reach it through this ref.
|
|
125
126
|
const inlineRef = useRef(null);
|
|
127
|
+
/** The open insert picker: where the pick lands. */
|
|
126
128
|
const [picker, setPicker] = useState(null);
|
|
127
129
|
const [tab, setTab] = useState("Content");
|
|
128
130
|
/** Canvas or Structure (spec 2026-10-19 editor at launch §1): one tree, two renderers; remembered per browser. */
|
|
@@ -162,6 +164,17 @@ export function EditorView({ ops, registry, id }) {
|
|
|
162
164
|
const scrollerRef = useRef(null);
|
|
163
165
|
const treeRef = useRef(null);
|
|
164
166
|
const registryRef = useRef(registry);
|
|
167
|
+
// A fresh node's reference picks and whether the service takes an image placeholder (spec 2026-11-14 filled
|
|
168
|
+
// samples): asked once per locale for this editor's life, on the first picker open, insert or locked-zone fill.
|
|
169
|
+
const picksRef = useRef(null);
|
|
170
|
+
// Counts the draft's loads and the editor's leaving: an insert that waited across one is dropped.
|
|
171
|
+
const loadCountRef = useRef(0);
|
|
172
|
+
useEffect(() => () => { loadCountRef.current++; }, []);
|
|
173
|
+
const picksFor = useCallback((forLocale) => {
|
|
174
|
+
if (picksRef.current?.ops !== ops)
|
|
175
|
+
picksRef.current = { ops, cache: picksCache() };
|
|
176
|
+
return picksRef.current.cache.get(forLocale, () => loadPicks(ops, registryRef.current.sections, forLocale));
|
|
177
|
+
}, [ops]);
|
|
165
178
|
registryRef.current = registry;
|
|
166
179
|
// The tree in memory belongs to the locale it was loaded for; a switch
|
|
167
180
|
// flushes first (switchLocale), so the ref and the tree always agree.
|
|
@@ -253,6 +266,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
253
266
|
let cancelled = false;
|
|
254
267
|
(async () => {
|
|
255
268
|
// A new locale (or a reload) is a new canvas: nothing from the previous one may survive.
|
|
269
|
+
loadCountRef.current++;
|
|
256
270
|
setReady(false);
|
|
257
271
|
setTree(null);
|
|
258
272
|
treeRef.current = null;
|
|
@@ -318,7 +332,16 @@ export function EditorView({ ops, registry, id }) {
|
|
|
318
332
|
// effect: a load runs for a new page, locale or reload, nothing else.
|
|
319
333
|
const reg = registryRef.current;
|
|
320
334
|
const template = reg.pages.find((p) => p.name === result.data.record.template);
|
|
321
|
-
|
|
335
|
+
// A zone that will be filled waits for the picks, so its references and its placeholder are the picker's; any
|
|
336
|
+
// other load asks nothing.
|
|
337
|
+
const stored = result.data.draft;
|
|
338
|
+
let draft = stored;
|
|
339
|
+
if (fillLockedZones(stored, template?.zones ?? {}, reg.sections) !== stored) {
|
|
340
|
+
const fresh = await picksFor(locale);
|
|
341
|
+
if (cancelled)
|
|
342
|
+
return;
|
|
343
|
+
draft = fillLockedZones(stored, template?.zones ?? {}, reg.sections, {}, fresh);
|
|
344
|
+
}
|
|
322
345
|
treeLocaleRef.current = locale;
|
|
323
346
|
setTree(draft);
|
|
324
347
|
treeRef.current = draft;
|
|
@@ -655,7 +678,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
655
678
|
if (!tree || !globalIds || !record)
|
|
656
679
|
return;
|
|
657
680
|
const template = registryRef.current.pages.find((p) => p.name === record.template);
|
|
658
|
-
|
|
681
|
+
// The answer in hand, if any: the load already waited for it where a section fills a zone.
|
|
682
|
+
const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, globalIds, picksRef.current?.cache.known(locale));
|
|
659
683
|
// Saved like the fill on load, and like it not an edit of the editor's (editedRef).
|
|
660
684
|
if (filled !== tree) {
|
|
661
685
|
setTree(filled);
|
|
@@ -684,8 +708,40 @@ export function EditorView({ ops, registry, id }) {
|
|
|
684
708
|
const fs = findingsAt.get(nodeId);
|
|
685
709
|
return !fs ? undefined : fs.some(isBroken) ? "broken" : "orphan";
|
|
686
710
|
};
|
|
687
|
-
const
|
|
688
|
-
|
|
711
|
+
const pickerOpen = picker !== null;
|
|
712
|
+
useEffect(() => { if (pickerOpen)
|
|
713
|
+
void picksFor(locale); }, [pickerOpen, locale, picksFor]);
|
|
714
|
+
// An insert waits for the picks (asked when the picker opened, so they are usually there). One at a time: a second
|
|
715
|
+
// click while the first waits inserts nothing, and an answer that lands on another locale's canvas is dropped.
|
|
716
|
+
const insertingRef = useRef(false);
|
|
717
|
+
const pickerRef = useRef(picker);
|
|
718
|
+
pickerRef.current = picker;
|
|
719
|
+
const idRef = useRef(id);
|
|
720
|
+
idRef.current = id;
|
|
721
|
+
/** Where the editor stands now, as an insert's answer is checked against (sample-picks.ts insertStands). */
|
|
722
|
+
const standing = (fromPicker) => ({ page: idRef.current, locale: treeLocaleRef.current, load: loadCountRef.current, picker: fromPicker ? pickerRef.current : null });
|
|
723
|
+
const [filled, setFilled] = useState(() => rememberedFilled());
|
|
724
|
+
const filledRef = useRef(filled);
|
|
725
|
+
filledRef.current = filled;
|
|
726
|
+
const changeFilled = (next) => { setFilled(next); rememberFilled(next); };
|
|
727
|
+
const doInsert = async (zone, index, section, fromPicker = true) => {
|
|
728
|
+
if (insertingRef.current)
|
|
729
|
+
return;
|
|
730
|
+
insertingRef.current = true;
|
|
731
|
+
const asked = standing(fromPicker);
|
|
732
|
+
let fresh;
|
|
733
|
+
try {
|
|
734
|
+
fresh = await picksFor(asked.locale);
|
|
735
|
+
}
|
|
736
|
+
finally {
|
|
737
|
+
insertingRef.current = false;
|
|
738
|
+
}
|
|
739
|
+
// The editor moved on while the answer was on its way: nothing is inserted behind its back.
|
|
740
|
+
if (!treeRef.current || !insertStands(asked, standing(fromPicker)))
|
|
741
|
+
return;
|
|
742
|
+
// The switch is read when the node is built: off, neither the sample nor generated values (spec 2026-11-14 filled
|
|
743
|
+
// samples). It is the picker's: a gesture with no modal (the outline's "add first") always inserts filled.
|
|
744
|
+
const node = nodeFromSample(section, newNodeId(section.name), { picks: fresh.picks, images: fresh.images, empty: fromPicker && !filledRef.current });
|
|
689
745
|
mutate((t) => insertNode(t, zone, index, node), { focus: node.id });
|
|
690
746
|
setPicker(null);
|
|
691
747
|
select(node.id);
|
|
@@ -1068,7 +1124,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
1068
1124
|
return {
|
|
1069
1125
|
...n,
|
|
1070
1126
|
label: global ? global.title : n.label,
|
|
1071
|
-
icon: section
|
|
1127
|
+
...(section?.icon ? { icon: section.icon } : global ? { icon: "⛓" } : {}),
|
|
1072
1128
|
preview: global ? { text: null, image: null } : cardPreview(n.node, section),
|
|
1073
1129
|
global: global !== null,
|
|
1074
1130
|
};
|
|
@@ -1093,7 +1149,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
1093
1149
|
] }) }), 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
1150
|
? _jsx(AppWindow, { size: 14, strokeWidth: 1.5, "aria-hidden": "true" })
|
|
1095
1151
|
: _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
|
|
1152
|
+
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) => setPicker({ zone, index }) })), _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) => setPicker({ zone: p.zone, index: p.index }), onMove: doMove, onDuplicate: doDuplicate, onCopy: doCopy, onRemove: doRemove }), inline.richtext && (() => {
|
|
1097
1153
|
const { session } = inline.richtext;
|
|
1098
1154
|
const item = globalItemOf(session.node);
|
|
1099
1155
|
const node = !item && tree ? findNode(tree, session.node) : null;
|
|
@@ -1135,8 +1191,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
1135
1191
|
}, 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, cardActions: cardActions, onAddFirst: (zone) => {
|
|
1136
1192
|
const first = allowedSections(policyOf(zone), registry.sections)[0];
|
|
1137
1193
|
if (first)
|
|
1138
|
-
doInsert(zone, 0, first);
|
|
1139
|
-
} }) }))] })] }), picker && (_jsx(InsertPicker, {
|
|
1194
|
+
void doInsert(zone, 0, first, false);
|
|
1195
|
+
} }) }))] })] }), picker && (_jsx(InsertPicker, { zone: picker.zone, siteUrl: siteUrl, policy: policyOf(picker.zone), registry: registry, globalIds: globalIds ?? {}, paste: clip ? { title: clip.title, check: pasteCheck(picker.zone), onPaste: () => doPaste(picker.zone, picker.index) } : undefined, filled: filled, onFilledChange: changeFilled, onInsert: (section) => { void doInsert(picker.zone, picker.index, section); }, onInsertGlobal: (item) => doInsertGlobal(picker.zone, picker.index, item), onInsertCustom: () => doInsertCustom(picker.zone, picker.index), onClose: () => setPicker(null) }))] }));
|
|
1140
1196
|
}
|
|
1141
1197
|
/** The selected node's ref field (if any) resolves to its target
|
|
1142
1198
|
* collection's loaded options — the EntryForm shape, section-scoped. */
|
|
@@ -1,38 +1,19 @@
|
|
|
1
1
|
import type { ZonePolicy } from "../../zones.ts";
|
|
2
2
|
import type { AdminRegistry, AdminSection, AdminGlobal } from "../serialize.ts";
|
|
3
|
-
import type
|
|
4
|
-
|
|
5
|
-
export type PickerAt = {
|
|
3
|
+
import { type PickerPaste } from "./picker-rows.ts";
|
|
4
|
+
export declare function InsertPicker({ zone, siteUrl, policy, registry, globalIds, paste, filled, onFilledChange, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
|
|
6
5
|
zone: string;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
y: number;
|
|
10
|
-
};
|
|
11
|
-
export type PickerPlace = {
|
|
12
|
-
left: number;
|
|
13
|
-
top?: number;
|
|
14
|
-
bottom?: number;
|
|
15
|
-
maxHeight?: number;
|
|
16
|
-
};
|
|
17
|
-
/** Where the panel sits, given its natural height and the viewport: under the
|
|
18
|
-
* anchor when it fits there, above it when that side has more room, and never
|
|
19
|
-
* taller than the room on its side (the panel scrolls). Pure. */
|
|
20
|
-
export declare function placePicker(at: PickerAt, height: number, viewport: {
|
|
21
|
-
width: number;
|
|
22
|
-
height: number;
|
|
23
|
-
}): PickerPlace;
|
|
24
|
-
export declare function InsertPicker({ at, policy, registry, globalIds, paste, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
|
|
25
|
-
at: PickerAt;
|
|
6
|
+
/** The page's address in the edited locale, as the canvas takes it: the samples frame is that page. */
|
|
7
|
+
siteUrl: string;
|
|
26
8
|
/** 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
|
-
};
|
|
9
|
+
paste?: PickerPaste;
|
|
32
10
|
policy: ZonePolicy | undefined;
|
|
33
11
|
registry: AdminRegistry;
|
|
34
12
|
/** Global item name → row id; an item without one cannot be placed. */
|
|
35
13
|
globalIds: Record<string, string>;
|
|
14
|
+
/** The switch's state, held by the editor (remembered per browser); the editor reads it when a section is inserted. */
|
|
15
|
+
filled: boolean;
|
|
16
|
+
onFilledChange(filled: boolean): void;
|
|
36
17
|
onInsert(section: AdminSection): void;
|
|
37
18
|
onInsertGlobal(item: AdminGlobal): void;
|
|
38
19
|
onInsertCustom(): void;
|
|
@@ -1,50 +1,59 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
3
|
-
// The insert picker a "+" opens:
|
|
4
|
-
//
|
|
5
|
-
// then the global items that can be placed there
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
// Measure the panel's full height (scrollHeight ignores a max-height already set), then place it.
|
|
43
|
-
useLayoutEffect(() => {
|
|
44
|
-
const el = panel.current;
|
|
45
|
-
if (!el)
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The insert picker a "+" opens (spec 2026-11-14 add section previews): a modal with the zone's rows on the left —
|
|
4
|
+
// the clipboard's Paste, the sections the zone's policy allows, the Custom section where the zone allows it (spec
|
|
5
|
+
// 2026-09-14 §6), then the global items that can be placed there — and on the right the current row drawn by the
|
|
6
|
+
// site itself from its sample (SamplePreview). A click on a row inserts, as it always did; hovering a row, or
|
|
7
|
+
// stepping to it with the arrow keys, only moves the picture. A zone that takes nothing says so and has no frame.
|
|
8
|
+
// Under the rows the "Insert filled" switch (spec 2026-11-14 filled samples): off, a section arrives with neither
|
|
9
|
+
// its sample nor generated values. The picture never follows it: it always shows the filled section.
|
|
10
|
+
import { useRef, useState } from "react";
|
|
11
|
+
import { Modal } from "../ui/Modal.js";
|
|
12
|
+
import { cx, SectionLabel, Switch } from "../ui/primitives.js";
|
|
13
|
+
import { samplesAddress } from "./canvas-address.js";
|
|
14
|
+
import { firstRow, pickerRows, stepRow } from "./picker-rows.js";
|
|
15
|
+
import { SamplePreview } from "./SamplePreview.js";
|
|
16
|
+
import { SectionIcon } from "./SectionIcon.js";
|
|
17
|
+
/** Below this window width the picture would be too small to read: the modal is the list alone, and no frame loads. */
|
|
18
|
+
const PANE_MIN_WINDOW = 820;
|
|
19
|
+
export function InsertPicker({ zone, siteUrl, policy, registry, globalIds, paste, filled, onFilledChange, onInsert, onInsertGlobal, onInsertCustom, onClose }) {
|
|
20
|
+
const rows = pickerRows({ policy, registry, globalIds, paste });
|
|
21
|
+
const [current, setCurrent] = useState(() => firstRow(rows));
|
|
22
|
+
const [wide] = useState(() => typeof window === "undefined" || window.innerWidth >= PANE_MIN_WINDOW);
|
|
23
|
+
const list = useRef(null);
|
|
24
|
+
const row = rows[current];
|
|
25
|
+
// Something to insert: a zone that takes nothing (a refused paste alone included) gets no picture.
|
|
26
|
+
const insertable = rows.some((r) => !r.disabled);
|
|
27
|
+
const pane = wide && insertable;
|
|
28
|
+
const pick = (r) => {
|
|
29
|
+
if (r.disabled)
|
|
30
|
+
return;
|
|
31
|
+
if (r.kind === "paste")
|
|
32
|
+
paste?.onPaste();
|
|
33
|
+
else if (r.kind === "section")
|
|
34
|
+
onInsert(r.section);
|
|
35
|
+
else if (r.kind === "custom")
|
|
36
|
+
onInsertCustom();
|
|
37
|
+
else
|
|
38
|
+
onInsertGlobal(r.item);
|
|
39
|
+
};
|
|
40
|
+
const onKeyDown = (e) => {
|
|
41
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp")
|
|
46
42
|
return;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
const next = stepRow(rows, current, e.key === "ArrowDown" ? 1 : -1);
|
|
45
|
+
setCurrent(next);
|
|
46
|
+
list.current?.querySelectorAll(".sm-picker__item")[next]?.focus();
|
|
47
|
+
};
|
|
48
|
+
const firstGlobal = rows.findIndex((r) => r.kind === "global");
|
|
49
|
+
const empty = !rows.some((r) => r.kind !== "paste");
|
|
50
|
+
// The switch speaks for the section rows and the Custom section; Paste and a global item carry their own content.
|
|
51
|
+
const switchable = rows.some((r) => !r.disabled && (r.kind === "section" || r.kind === "custom"));
|
|
52
|
+
return (_jsx(Modal, { title: `Add to ${zone}`, width: pane ? 980 : 380, height: pane ? "min(640px, 85vh)" : undefined, flush: true, onClose: onClose, children: _jsxs("div", { className: cx("sm-insert", pane && "sm-insert--pane"), onKeyDown: onKeyDown, children: [_jsxs("div", { className: "sm-insert__side", children: [_jsxs("div", { ref: list, className: "sm-picker sm-insert__list", children: [rows.map((r, i) => (_jsxs("div", { style: { display: "contents" }, children: [i === firstGlobal && _jsx(SectionLabel, { caps: true, style: { padding: "8px 8px 4px" }, children: "Global" }), _jsxs("button", { type: "button", autoFocus: i === current, "data-kind": r.kind, className: cx("sm-picker__item", r.kind === "paste" && "sm-picker__item--paste", i === current && "sm-picker__item--active"), disabled: r.disabled, title: r.disabled ? r.description : undefined,
|
|
53
|
+
// The pointer takes the focus with it, as in a menu: the arrow keys then step from the row under it.
|
|
54
|
+
onMouseEnter: (e) => { if (!r.disabled) {
|
|
55
|
+
setCurrent(i);
|
|
56
|
+
e.currentTarget.focus({ preventScroll: true });
|
|
57
|
+
} }, onFocus: () => { if (!r.disabled)
|
|
58
|
+
setCurrent(i); }, onClick: () => pick(r), children: [_jsx("span", { className: "sm-picker__icon", children: _jsx(SectionIcon, { icon: r.icon }) }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: r.title }), r.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: r.description }))] })] })] }, r.key))), empty && _jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." })] }), switchable && (_jsxs("label", { className: "sm-insert__foot", title: "Off: a section is inserted empty, without its sample content", children: [_jsx(Switch, { checked: filled, onChange: onFilledChange, label: "Insert filled" }), _jsx("span", { "aria-hidden": "true", children: "Insert filled" })] }))] }), pane && row && (_jsxs("div", { className: "sm-insert__pane", children: [_jsx(SamplePreview, { src: samplesAddress(siteUrl, zone), nodeId: row.nodeId, fallback: _jsx("span", { className: "sm-insert__icon", children: _jsx(SectionIcon, { icon: row.icon, size: 32 }) }) }), _jsxs("div", { className: "sm-insert__caption", children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: row.title }), row.description && _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: row.description })] })] }))] }) }));
|
|
50
59
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export declare function SamplePreview({ src, nodeId, fallback }: {
|
|
3
|
+
src: string;
|
|
4
|
+
/** The current row's sample node; null for a row that has none (Paste). */
|
|
5
|
+
nodeId: string | null;
|
|
6
|
+
fallback: ReactNode;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The picture in the insert picker (spec 2026-11-14 add section previews): ONE frame on the zone's samples address
|
|
4
|
+
// (the site's own draft page with the zone's rows drawn from their samples), not interactive, scaled to the pane and
|
|
5
|
+
// cropped to the current row's node. The frame is same-origin, so its document is read directly: no bridge, nothing
|
|
6
|
+
// posted. Until it has loaded, and for a row the site drew no node for, the pane shows `fallback`.
|
|
7
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
8
|
+
import { SAMPLE_VIEWPORT, isSmoodlyDraft, isolationCss, sampleBox, sampleNode } from "./sample-frame.js";
|
|
9
|
+
export function SamplePreview({ src, nodeId, fallback }) {
|
|
10
|
+
const pane = useRef(null);
|
|
11
|
+
const frame = useRef(null);
|
|
12
|
+
const [width, setWidth] = useState(0);
|
|
13
|
+
// Counts the frame's loads: a document Smoodly rendered as a draft, with its layout hidden. 0 = none yet.
|
|
14
|
+
const [loaded, setLoaded] = useState(0);
|
|
15
|
+
const [crop, setCrop] = useState(null);
|
|
16
|
+
// The pane's width decides the scale.
|
|
17
|
+
useLayoutEffect(() => {
|
|
18
|
+
const el = pane.current;
|
|
19
|
+
if (!el)
|
|
20
|
+
return;
|
|
21
|
+
// The content box: the pane's padding leaves the crop's shadow room (2026-11-14).
|
|
22
|
+
const measure = () => { const cs = getComputedStyle(el); setWidth(el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)); };
|
|
23
|
+
measure();
|
|
24
|
+
const observer = new ResizeObserver(measure);
|
|
25
|
+
observer.observe(el);
|
|
26
|
+
return () => observer.disconnect();
|
|
27
|
+
}, []);
|
|
28
|
+
const onLoad = () => {
|
|
29
|
+
const doc = frame.current?.contentDocument;
|
|
30
|
+
if (!doc || !isSmoodlyDraft(doc)) {
|
|
31
|
+
setLoaded(0);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// The layout around the samples goes, and the scrollbar with it (the frame is still scrolled from here). As a
|
|
35
|
+
// constructed stylesheet of the frame's own realm: no element of the site's document is written to.
|
|
36
|
+
const Sheet = frame.current?.contentWindow?.CSSStyleSheet;
|
|
37
|
+
if (Sheet) {
|
|
38
|
+
const sheet = new Sheet();
|
|
39
|
+
// `nextjs-portal`: Next's dev indicator, which sits fixed in a corner of the picture in development.
|
|
40
|
+
sheet.replaceSync(`html{overflow:hidden!important}nextjs-portal{display:none!important}${isolationCss(doc)}`);
|
|
41
|
+
doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, sheet];
|
|
42
|
+
}
|
|
43
|
+
setLoaded((n) => n + 1);
|
|
44
|
+
};
|
|
45
|
+
// A frame that finished loading before React attached the handler (a cached document).
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (frame.current?.contentDocument?.readyState === "complete" && frame.current.contentWindow?.location.href !== "about:blank")
|
|
48
|
+
onLoad();
|
|
49
|
+
}, []);
|
|
50
|
+
// The current row's node: scroll the frame to it and crop to it, again whenever it or the page around it resizes
|
|
51
|
+
// (images and fonts arrive late).
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const win = frame.current?.contentWindow;
|
|
54
|
+
const doc = frame.current?.contentDocument;
|
|
55
|
+
if (!loaded || !nodeId || !win || !doc) {
|
|
56
|
+
setCrop(null);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const node = sampleNode(doc, nodeId);
|
|
60
|
+
if (!node) {
|
|
61
|
+
setCrop(null);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const measure = () => {
|
|
65
|
+
const box = sampleBox(node, win.scrollY);
|
|
66
|
+
win.scrollTo({ top: box.top, behavior: "instant" });
|
|
67
|
+
// A document too short to scroll that far leaves the node lower in the viewport.
|
|
68
|
+
const offset = Math.max(0, Math.round(node.getBoundingClientRect().top));
|
|
69
|
+
const height = Math.min(box.height, SAMPLE_VIEWPORT.height - offset);
|
|
70
|
+
setCrop((was) => (was && was.offset === offset && was.height === height ? was : height > 0 ? { offset, height } : null));
|
|
71
|
+
};
|
|
72
|
+
measure();
|
|
73
|
+
const Observer = win.ResizeObserver ?? ResizeObserver;
|
|
74
|
+
const observer = new Observer(measure);
|
|
75
|
+
observer.observe(node);
|
|
76
|
+
if (doc.body)
|
|
77
|
+
observer.observe(doc.body);
|
|
78
|
+
return () => observer.disconnect();
|
|
79
|
+
}, [loaded, nodeId]);
|
|
80
|
+
const scale = width > 0 ? width / SAMPLE_VIEWPORT.width : 0;
|
|
81
|
+
const shown = crop !== null && scale > 0;
|
|
82
|
+
return (_jsxs("div", { ref: pane, className: "sm-insert__preview", children: [_jsx("div", { className: shown ? "sm-insert__crop sm-insert__crop--shown" : "sm-insert__crop", style: { height: shown ? Math.round(crop.height * scale) : 0, visibility: shown ? "visible" : "hidden" }, children: _jsx("iframe", { ref: frame, src: src, title: "Section preview", "aria-hidden": "true", tabIndex: -1, width: SAMPLE_VIEWPORT.width, height: SAMPLE_VIEWPORT.height, onLoad: onLoad, style: {
|
|
83
|
+
pointerEvents: "none", border: 0, display: "block", position: "absolute", left: 0, top: shown ? -Math.round(crop.offset * scale) : 0,
|
|
84
|
+
width: SAMPLE_VIEWPORT.width, height: SAMPLE_VIEWPORT.height, transform: `scale(${scale})`, transformOrigin: "0 0",
|
|
85
|
+
} }) }), !shown && _jsx("div", { className: "sm-insert__fallback", "data-testid": "sample-fallback", children: fallback })] }));
|
|
86
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// A section's icon (2026-11-14): the schema's own `icon` when it sets one, else Lucide's rectangle-horizontal — the
|
|
4
|
+
// one default for every section (and the Custom section) in the picker and on the Structure cards.
|
|
5
|
+
import { RectangleHorizontal } from "lucide-react";
|
|
6
|
+
export function SectionIcon({ icon, size = 14 }) {
|
|
7
|
+
return icon ? _jsx(_Fragment, { children: icon }) : _jsx(RectangleHorizontal, { size: size, strokeWidth: 1.75, "aria-hidden": "true" });
|
|
8
|
+
}
|
|
@@ -3,9 +3,9 @@ import { type ActionMenuItem } from "../ui/ActionMenu.tsx";
|
|
|
3
3
|
import type { CardPreview } from "./card-preview.ts";
|
|
4
4
|
import { cardMenu, type Handlers, type ZoneRows } from "./Outline.tsx";
|
|
5
5
|
export { cardMenu };
|
|
6
|
-
/** A card: the outline's row plus the section's icon from the registry (
|
|
6
|
+
/** A card: the outline's row plus the section's icon from the registry (none = SectionIcon's default), its preview, and whether it is a placed global item. */
|
|
7
7
|
export type StructureNode = ZoneRows["nodes"][number] & {
|
|
8
|
-
icon
|
|
8
|
+
icon?: string;
|
|
9
9
|
preview: CardPreview;
|
|
10
10
|
global: boolean;
|
|
11
11
|
};
|
|
@@ -17,6 +17,7 @@ import { Button, Grip, SectionLabel, cx } from "../ui/primitives.js";
|
|
|
17
17
|
import { useSortable } from "../ui/useSortable.js";
|
|
18
18
|
import { AssetThumb } from "../forms/MediaGrid.js";
|
|
19
19
|
import { useAssets } from "../forms/assets-context.js";
|
|
20
|
+
import { SectionIcon } from "./SectionIcon.js";
|
|
20
21
|
import { cardMenu } from "./Outline.js";
|
|
21
22
|
export { cardMenu };
|
|
22
23
|
export function StructureView({ zones, ...rest }) {
|
|
@@ -39,7 +40,7 @@ function StructureZoneView({ zone, locked, canTake, nodes, removed, note, select
|
|
|
39
40
|
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
41
|
e.preventDefault();
|
|
41
42
|
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
|
+
} }, 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: _jsx(SectionIcon, { icon: 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
44
|
? _jsx("span", { className: "sm-structure__preview", children: "Global \u00B7 edited in one place" })
|
|
44
45
|
: 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
|
}
|
|
@@ -5,6 +5,8 @@ export declare function setCanvasMarks(value: "path" | "query"): void;
|
|
|
5
5
|
export declare const canvasAddress: (siteUrl: string) => string;
|
|
6
6
|
/** History's frame (spec 2026-09-13 history §5): a stored version, no bridge. */
|
|
7
7
|
export declare const versionAddress: (siteUrl: string, versionId: string) => string;
|
|
8
|
+
/** The picker's samples frame (spec 2026-11-14 add section previews): the zone's rows drawn from their samples, no bridge. */
|
|
9
|
+
export declare const samplesAddress: (siteUrl: string, zone: string) => string;
|
|
8
10
|
/** The site's preview route, from the page data: where the grant goes. */
|
|
9
11
|
export declare function setPreviewUrl(value: string): void;
|
|
10
12
|
export declare function previewUrl(): string;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// they mark what they want: in the query, or in the path's last segment where the host strips the query
|
|
4
4
|
// (Next on Vercel; canvas-marker.ts). Module state, set by the browser entry before the shell mounts: the
|
|
5
5
|
// admin is one script on one page.
|
|
6
|
-
import { markCanvasPath } from "../../canvas-marker.js";
|
|
6
|
+
import { markCanvasPath, SAMPLES_PARAM } from "../../canvas-marker.js";
|
|
7
7
|
let base = "";
|
|
8
8
|
let marks = "query";
|
|
9
9
|
export function setPreviewBase(value) {
|
|
@@ -18,6 +18,8 @@ const withQuery = (url, key, value) => `${url}${url.includes("?") ? "&" : "?"}${
|
|
|
18
18
|
export const canvasAddress = (siteUrl) => marks === "path" ? markCanvasPath(draftAddress(siteUrl), { editor: true }) : withQuery(draftAddress(siteUrl), "smoodly-editor", "1");
|
|
19
19
|
/** History's frame (spec 2026-09-13 history §5): a stored version, no bridge. */
|
|
20
20
|
export const versionAddress = (siteUrl, versionId) => marks === "path" ? markCanvasPath(draftAddress(siteUrl), { version: versionId }) : withQuery(draftAddress(siteUrl), "smoodly-version", versionId);
|
|
21
|
+
/** The picker's samples frame (spec 2026-11-14 add section previews): the zone's rows drawn from their samples, no bridge. */
|
|
22
|
+
export const samplesAddress = (siteUrl, zone) => marks === "path" ? markCanvasPath(draftAddress(siteUrl), { samples: zone }) : withQuery(draftAddress(siteUrl), SAMPLES_PARAM, zone);
|
|
21
23
|
let url = "";
|
|
22
24
|
/** The site's preview route, from the page data: where the grant goes. */
|
|
23
25
|
export function setPreviewUrl(value) { url = value; }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ZonePolicy } from "../../zones.ts";
|
|
2
|
+
import type { AdminGlobal, AdminRegistry, AdminSection } from "../serialize.ts";
|
|
3
|
+
import type { PasteCheck } from "./section-clipboard.ts";
|
|
4
|
+
export type PickerPaste = {
|
|
5
|
+
title: string;
|
|
6
|
+
check: PasteCheck;
|
|
7
|
+
onPaste(): void;
|
|
8
|
+
};
|
|
9
|
+
export type PickerRow = {
|
|
10
|
+
key: string;
|
|
11
|
+
kind: "paste" | "section" | "custom" | "global";
|
|
12
|
+
title: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
/** The schema's own icon; none = the default (SectionIcon). */
|
|
15
|
+
icon?: string;
|
|
16
|
+
/** The row's node in the samples frame; null for Paste, whose content is not a sample. */
|
|
17
|
+
nodeId: string | null;
|
|
18
|
+
/** A refused paste: shown, never picked. */
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
section?: AdminSection;
|
|
21
|
+
item?: AdminGlobal;
|
|
22
|
+
};
|
|
23
|
+
export declare function pickerRows({ policy, registry, globalIds, paste }: {
|
|
24
|
+
policy: ZonePolicy | undefined;
|
|
25
|
+
registry: AdminRegistry;
|
|
26
|
+
globalIds: Record<string, string>;
|
|
27
|
+
paste?: PickerPaste;
|
|
28
|
+
}): PickerRow[];
|
|
29
|
+
/** The row the modal opens on: the first that can be picked and is not Paste (its preview is a card, not a picture);
|
|
30
|
+
* Paste when it is all there is; -1 when nothing can be picked. */
|
|
31
|
+
export declare function firstRow(rows: PickerRow[]): number;
|
|
32
|
+
/** The next row an arrow key lands on: the nearest one in `dir` that can be picked; the current one at an end. */
|
|
33
|
+
export declare function stepRow(rows: PickerRow[], current: number, dir: -1 | 1): number;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { sampleNodeId } from "../../sample-tree.js";
|
|
2
|
+
import { allowedGlobals, allowedSections } from "../tree-ops.js";
|
|
3
|
+
import { allowsCustom } from "../custom-section.js";
|
|
4
|
+
export function pickerRows({ policy, registry, globalIds, paste }) {
|
|
5
|
+
const rows = [];
|
|
6
|
+
if (paste) {
|
|
7
|
+
rows.push({
|
|
8
|
+
key: "paste", kind: "paste", title: `Paste "${paste.title}"`, icon: "⎘", nodeId: null,
|
|
9
|
+
description: paste.check.ok ? "From the clipboard" : paste.check.reason, ...(paste.check.ok ? {} : { disabled: true }),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
for (const section of allowedSections(policy, registry.sections)) {
|
|
13
|
+
rows.push({
|
|
14
|
+
key: `section-${section.name}`, kind: "section", title: section.title, ...(section.icon ? { icon: section.icon } : {}), section,
|
|
15
|
+
nodeId: sampleNodeId("section", section.name),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (allowsCustom(policy)) {
|
|
19
|
+
rows.push({ key: "custom", kind: "custom", title: registry.custom.title, nodeId: sampleNodeId("custom") });
|
|
20
|
+
}
|
|
21
|
+
for (const item of allowedGlobals(policy, registry.globals, globalIds)) {
|
|
22
|
+
rows.push({ key: `global-${item.name}`, kind: "global", title: item.title, icon: "⛓", item, nodeId: sampleNodeId("global", item.name) });
|
|
23
|
+
}
|
|
24
|
+
return rows;
|
|
25
|
+
}
|
|
26
|
+
/** The row the modal opens on: the first that can be picked and is not Paste (its preview is a card, not a picture);
|
|
27
|
+
* Paste when it is all there is; -1 when nothing can be picked. */
|
|
28
|
+
export function firstRow(rows) {
|
|
29
|
+
const insertable = rows.findIndex((r) => !r.disabled && r.kind !== "paste");
|
|
30
|
+
return insertable !== -1 ? insertable : rows.findIndex((r) => !r.disabled);
|
|
31
|
+
}
|
|
32
|
+
/** The next row an arrow key lands on: the nearest one in `dir` that can be picked; the current one at an end. */
|
|
33
|
+
export function stepRow(rows, current, dir) {
|
|
34
|
+
for (let i = current + dir; i >= 0 && i < rows.length; i += dir)
|
|
35
|
+
if (!rows[i].disabled)
|
|
36
|
+
return i;
|
|
37
|
+
return current;
|
|
38
|
+
}
|