@elytracms/studio 0.0.58 → 0.0.60
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/components/app-views/studio-config.d.ts +34 -0
- package/dist/components/app-views/studio-config.js.map +1 -1
- package/dist/components/builder/blocks-model.js +12 -7
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/canvas-chrome.d.ts +65 -0
- package/dist/components/builder/canvas-chrome.js +125 -0
- package/dist/components/builder/canvas-chrome.js.map +1 -0
- package/dist/components/builder/canvas-dnd.d.ts +59 -0
- package/dist/components/builder/canvas-dnd.js +207 -0
- package/dist/components/builder/canvas-dnd.js.map +1 -0
- package/dist/components/builder/canvas-dom.d.ts +11 -0
- package/dist/components/builder/canvas-dom.js +17 -0
- package/dist/components/builder/canvas-dom.js.map +1 -0
- package/dist/components/builder/canvas-inline-editing.d.ts +91 -0
- package/dist/components/builder/canvas-inline-editing.js +242 -0
- package/dist/components/builder/canvas-inline-editing.js.map +1 -0
- package/dist/components/builder/canvas-render-context.d.ts +83 -0
- package/dist/components/builder/canvas-render-context.js +196 -0
- package/dist/components/builder/canvas-render-context.js.map +1 -0
- package/dist/components/builder/canvas.d.ts +4 -1
- package/dist/components/builder/canvas.js +44 -657
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/composition-field-editor.js +14 -4
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/insert-context.d.ts +5 -2
- package/dist/components/builder/insert-context.js +1 -3
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/node-factory.d.ts +12 -0
- package/dist/components/builder/node-factory.js +18 -1
- package/dist/components/builder/node-factory.js.map +1 -1
- package/dist/components/cms/asset-field.d.ts +22 -0
- package/dist/components/cms/asset-field.js +300 -0
- package/dist/components/cms/asset-field.js.map +1 -0
- package/dist/components/cms/field-control-lib.d.ts +43 -0
- package/dist/components/cms/field-control-lib.js +98 -0
- package/dist/components/cms/field-control-lib.js.map +1 -0
- package/dist/components/cms/field-control.d.ts +7 -35
- package/dist/components/cms/field-control.js +13 -556
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/object-array-field.d.ts +29 -0
- package/dist/components/cms/object-array-field.js +205 -0
- package/dist/components/cms/object-array-field.js.map +1 -0
- package/dist/components/cms/prop-fields.d.ts +15 -0
- package/dist/components/cms/prop-fields.js +38 -1
- package/dist/components/cms/prop-fields.js.map +1 -1
- package/dist/components/cms/rich-text/editor.d.ts +8 -0
- package/dist/components/cms/rich-text/editor.js +46 -3
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/toolbar.d.ts +5 -1
- package/dist/components/cms/rich-text/toolbar.js +4 -4
- package/dist/components/cms/rich-text/toolbar.js.map +1 -1
- package/dist/components/project-session.d.ts +50 -1
- package/dist/components/project-session.js +75 -14
- package/dist/components/project-session.js.map +1 -1
- package/dist/components/use-hydration-phase.d.ts +6 -4
- package/dist/components/use-hydration-phase.js.map +1 -1
- package/dist/customization.d.ts +1 -1
- package/dist/customization.js.map +1 -1
- package/dist/lib/data/collection-content.d.ts +12 -7
- package/dist/lib/data/collection-content.js +6 -4
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/i18n/messages.en.js +5 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/session-hydration.d.ts +4 -1
- package/dist/lib/operations/session-hydration.js +4 -1
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/persistence/index.js +13 -1
- package/dist/lib/persistence/index.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +9 -1
- package/dist/lib/workspace/index.js +11 -1
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/lib/workspace/workspace-source.d.ts +17 -22
- package/dist/lib/workspace/workspace-source.js +1 -49
- package/dist/lib/workspace/workspace-source.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,145 +1,45 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect,
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { createPortal } from 'react-dom';
|
|
4
4
|
import { renderPageComposition } from '@elytracms/core/runtime-renderer';
|
|
5
5
|
import { useParams } from '@elytracms/studio/router';
|
|
6
|
-
import { getNode, getParentInfo, staticProp } from '@elytracms/core/project-graph';
|
|
7
|
-
import { canNestInto, containerFlowAxis, dropIndicatorFor, dropPositionForPoint, resolveDrop, } from '@elytracms/studio/lib/dnd/drop-rules';
|
|
8
|
-
import { activeDrag, beginDrag, endDrag, onDragSessionEnd } from './drag-session.js';
|
|
9
|
-
import { PRESERVE_NODE_ATTR, collectPreservedPairs, richTextDocFromDom, } from '@elytracms/core/rich-text';
|
|
10
6
|
import { getStudioRegistry } from '@elytracms/studio/lib/registry';
|
|
11
7
|
import { translate } from '@elytracms/studio/lib/i18n';
|
|
12
|
-
import { useOptionalAssets } from '../assets/assets-store.js';
|
|
13
|
-
import { resolveCanvasLink, resolveCanvasListing, resolveCanvasRelation, useData, } from '@elytracms/studio/lib/data';
|
|
14
|
-
import { cmsBindingFallback, useStudioLocale } from '@elytracms/studio/lib/locale';
|
|
15
8
|
import { getInstantiatedKit } from '@elytracms/studio/lib/starters/kit-session';
|
|
9
|
+
import { resolveDeploymentCanvasTheme } from '@elytracms/studio/lib/workspace';
|
|
10
|
+
import { applyCanvasChrome } from './canvas-chrome.js';
|
|
16
11
|
import { getCanvasStylesheetUrl } from './canvas-stylesheet.js';
|
|
17
12
|
import { useBuilder } from './builder-store.js';
|
|
18
|
-
import { useReuse } from './reuse-context.js';
|
|
19
|
-
import { reuseEmbedRef } from './reuse.js';
|
|
20
13
|
import { useCanvasViewports, viewportWidth } from './canvas-breakpoints.js';
|
|
14
|
+
import { nodeIdAt } from './canvas-dom.js';
|
|
15
|
+
import { useCanvasDnd } from './canvas-dnd.js';
|
|
16
|
+
import { useCanvasInlineEditing } from './canvas-inline-editing.js';
|
|
17
|
+
import { useCanvasRenderContext } from './canvas-render-context.js';
|
|
21
18
|
import { ClipboardNoticeOverlay, attachClipboardListeners, useStudioClipboard, } from './clipboard-actions.js';
|
|
22
|
-
import { createInlineEditSession, createInlineRichTextSession, findInlineTextHost, resolveInlineEditableProp, resolveInlineRichTextProp, } from './inline-edit.js';
|
|
23
19
|
import { RichTextToolbar } from './rich-text-toolbar.js';
|
|
24
20
|
import { SpacingHandles, hasSpacingHandles } from './spacing-handles.js';
|
|
25
|
-
/** Selection/hover outlines, scoped to the per-node editable wrappers. */
|
|
26
|
-
const OUTLINE_CSS = `
|
|
27
|
-
/* The canvas backdrop is deliberately always light/site-like (see the
|
|
28
|
-
wireUp note below) — force the native form-control/scrollbar theme to
|
|
29
|
-
match, so a page with real inputs never shows dark native chrome floating
|
|
30
|
-
on a light page. */
|
|
31
|
-
html { color-scheme: light; }
|
|
32
|
-
body { margin: 0; padding: 16px; }
|
|
33
|
-
.ec-editable { outline-offset: -1px; cursor: default; }
|
|
34
|
-
.ec-editable[data-hovered]:not([data-selected]) { outline: 1px dashed rgba(130,150,190,0.75); }
|
|
35
|
-
.ec-editable[data-selected] { outline: 2px solid #5b8cff; }
|
|
36
|
-
/* Inline editing (EC-158 text, EC-159 rich text): the rendered element
|
|
37
|
-
itself becomes the editor — no overlay, no replacement input — so
|
|
38
|
-
typography, alignment, and layout are pixel-identical between edit and
|
|
39
|
-
render states. The wrapper keeps the selection outline; the host only
|
|
40
|
-
gets a caret. pre-wrap applies to PLAIN text hosts only — rich-text
|
|
41
|
-
markup must keep its rendered whitespace handling (no layout shift). */
|
|
42
|
-
.ec-editable[data-ec-editing] { outline: 2px solid #5b8cff; }
|
|
43
|
-
.ec-editable [data-ec-edit] { outline: none; cursor: text; }
|
|
44
|
-
.ec-editable [data-ec-edit='text'] { white-space: pre-wrap; }
|
|
45
|
-
/* Preserved atoms during a rich-text session (custom blocks, unknown nodes,
|
|
46
|
-
images, embeds): visible and rendered, but not text-editable. */
|
|
47
|
-
.ec-editable [data-ec-edit='rich'] [${PRESERVE_NODE_ATTR}] { user-select: none; }
|
|
48
|
-
/* Floating mark toolbar (EC-159), portaled into the iframe. */
|
|
49
|
-
.ec-rt-toolbar { color-scheme: dark; }
|
|
50
|
-
.ec-rt-toolbar-row { display: flex; align-items: center; gap: 2px; padding: 3px;
|
|
51
|
-
border: 1px solid rgba(130,150,190,0.4); border-radius: 8px;
|
|
52
|
-
background: rgb(24,26,32); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
|
|
53
|
-
.ec-rt-toolbar-btn { min-width: 26px; height: 24px; padding: 0 7px; border: 0; border-radius: 5px;
|
|
54
|
-
background: transparent; color: rgb(225,228,236); font: 600 12px/1 ui-sans-serif, system-ui, sans-serif;
|
|
55
|
-
cursor: pointer; }
|
|
56
|
-
.ec-rt-toolbar-btn:hover { background: rgba(130,150,190,0.25); }
|
|
57
|
-
.ec-rt-toolbar-btn[data-active] { background: #5b8cff; color: white; }
|
|
58
|
-
.ec-rt-toolbar-input { width: 200px; height: 24px; padding: 0 7px; border-radius: 5px;
|
|
59
|
-
border: 1px solid rgba(130,150,190,0.4); background: rgb(16,18,22); color: rgb(225,228,236);
|
|
60
|
-
font: 12px/1 ui-sans-serif, system-ui, sans-serif; outline: none; }
|
|
61
|
-
/* Locale-fallback chips (EC-176): a node whose bound content is currently
|
|
62
|
-
served via locale fallback wears a small corner chip naming the locale it
|
|
63
|
-
actually came from — the canvas never silently mixes locales. */
|
|
64
|
-
.ec-editable[data-ec-locale-fallback] { position: relative; }
|
|
65
|
-
.ec-editable[data-ec-locale-fallback]::after {
|
|
66
|
-
content: attr(data-ec-locale-fallback);
|
|
67
|
-
position: absolute; top: 2px; right: 2px; z-index: 5; pointer-events: none;
|
|
68
|
-
padding: 0 4px; border-radius: 4px;
|
|
69
|
-
font: 600 9px/1.5 ui-sans-serif, system-ui, sans-serif;
|
|
70
|
-
letter-spacing: 0.05em; text-transform: uppercase;
|
|
71
|
-
color: #fbbf24; background: rgba(180,83,9,0.25); border: 1px solid rgba(245,158,11,0.45);
|
|
72
|
-
}
|
|
73
|
-
/* Drag & drop states (EC-160/EC-162): eligibility comes from the shared pure
|
|
74
|
-
rules (lib/dnd/drop-rules); these classes only *show* the resolution.
|
|
75
|
-
Rejection is explicit and visible during the drag — never a silent failed
|
|
76
|
-
drop. Before/after bars follow the parent's flow axis (data-ec-drop-axis);
|
|
77
|
-
left/right are the wrap-into-columns gesture (violet, paired highlight). */
|
|
78
|
-
.ec-editable[data-ec-drop='into'] { outline: 2px solid #34d399; outline-offset: -2px; background-color: rgba(52, 211, 153, 0.08); }
|
|
79
|
-
.ec-editable[data-ec-drop='before'] { box-shadow: 0 -3px 0 0 #38bdf8; }
|
|
80
|
-
.ec-editable[data-ec-drop='after'] { box-shadow: 0 3px 0 0 #38bdf8; }
|
|
81
|
-
.ec-editable[data-ec-drop='before'][data-ec-drop-axis='x'] { box-shadow: -3px 0 0 0 #38bdf8; }
|
|
82
|
-
.ec-editable[data-ec-drop='after'][data-ec-drop-axis='x'] { box-shadow: 3px 0 0 0 #38bdf8; }
|
|
83
|
-
.ec-editable[data-ec-drop='left'] { box-shadow: -3px 0 0 0 #a78bfa; background-color: rgba(167, 139, 250, 0.1); }
|
|
84
|
-
.ec-editable[data-ec-drop='right'] { box-shadow: 3px 0 0 0 #a78bfa; background-color: rgba(167, 139, 250, 0.1); }
|
|
85
|
-
.ec-editable[data-ec-drop='blocked'] { outline: 2px solid #ef4444; outline-offset: -2px; background-color: rgba(239, 68, 68, 0.08); cursor: not-allowed; }
|
|
86
|
-
`;
|
|
87
|
-
function nodeIdAt(target) {
|
|
88
|
-
return wrapperAt(target)?.getAttribute('data-ec-node-id') ?? null;
|
|
89
|
-
}
|
|
90
|
-
function wrapperAt(target) {
|
|
91
|
-
const el = target;
|
|
92
|
-
return el?.closest?.('[data-ec-node-id]') ?? null;
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* The rendered `.rich-text` element inside a node's wrapper — the rich-text
|
|
96
|
-
* editing host (EC-159). Never crosses into a nested node's wrapper.
|
|
97
|
-
*/
|
|
98
|
-
function findRichTextHost(wrapper) {
|
|
99
|
-
const host = wrapper.querySelector('.rich-text');
|
|
100
|
-
return host && host.closest('[data-ec-node-id]') === wrapper ? host : null;
|
|
101
|
-
}
|
|
102
|
-
/** Collect every node id under a root (the page subtree). */
|
|
103
|
-
function collectIds(node, out = new Set()) {
|
|
104
|
-
out.add(node.id);
|
|
105
|
-
for (const children of Object.values(node.slots ?? {})) {
|
|
106
|
-
for (const child of children)
|
|
107
|
-
collectIds(child, out);
|
|
108
|
-
}
|
|
109
|
-
return out;
|
|
110
|
-
}
|
|
111
21
|
/**
|
|
112
22
|
* Iframe-isolated page preview (brief §8.2, EC-061, EC-064). The graph renders
|
|
113
23
|
* into a same-origin iframe via a React portal — true style isolation + true
|
|
114
24
|
* responsive widths. Each node is wrapped with an editable marker so clicks
|
|
115
25
|
* select and hover/selection draw outlines. Native listeners bridge the iframe
|
|
116
|
-
* boundary (React's synthetic events don't cross frames)
|
|
26
|
+
* boundary (React's synthetic events don't cross frames); the two big gesture
|
|
27
|
+
* state machines live in their own tested modules (EC-330): drag & drop in
|
|
28
|
+
* `canvas-dnd`, inline text/rich-text editing in `canvas-inline-editing`, and
|
|
29
|
+
* the render-context assembly in `canvas-render-context`.
|
|
117
30
|
*/
|
|
118
31
|
export function BuilderCanvas({ perspective = 'draft', } = {}) {
|
|
119
32
|
const builder = useBuilder();
|
|
120
33
|
const { dispatch } = builder;
|
|
121
34
|
const studio = getStudioRegistry();
|
|
122
|
-
const data = useData();
|
|
123
35
|
// The edited composition root, composed for preview. EC-287 retired the project
|
|
124
36
|
// graph and its published-graph preview; the canvas always renders the live,
|
|
125
37
|
// editable draft (content publishing is per document).
|
|
126
38
|
const activeRoot = builder.present;
|
|
127
39
|
const iframeRef = useRef(null);
|
|
128
40
|
const [doc, setDoc] = useState(null);
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// let the native dblclick handler read the *current* graph without
|
|
132
|
-
// re-subscribing listeners on every edit. The rich-text host additionally
|
|
133
|
-
// mirrors into render state so the floating mark toolbar can mount.
|
|
134
|
-
const editingRef = useRef(null);
|
|
135
|
-
const [richEditHost, setRichEditHost] = useState(null);
|
|
136
|
-
// A rich-text session mutates the rendered DOM behind React's back
|
|
137
|
-
// (contenteditable + mark commands), so finishing one must REMOUNT the
|
|
138
|
-
// node's subtree — bumping its epoch changes the decorate key, React
|
|
139
|
-
// discards the edited DOM wholesale and re-renders from the graph (the
|
|
140
|
-
// committed value, or the untouched original on cancel). Patching the old
|
|
141
|
-
// fiber tree against editor-mutated DOM would leave stale artifacts.
|
|
142
|
-
const [richEditEpochs, setRichEditEpochs] = useState({});
|
|
41
|
+
// Graph/selection refs let the native iframe listeners read the *current*
|
|
42
|
+
// state without re-subscribing on every edit.
|
|
143
43
|
const presentRef = useRef(builder.present);
|
|
144
44
|
presentRef.current = builder.present;
|
|
145
45
|
const selectedIdRef = useRef(builder.selectedId);
|
|
@@ -159,34 +59,17 @@ export function BuilderCanvas({ perspective = 'draft', } = {}) {
|
|
|
159
59
|
// dark-themed and the iframe root inherits that class). The injected
|
|
160
60
|
// canvas stylesheet's `:root` (light) tokens supply that backdrop for free
|
|
161
61
|
// once `.dark` is never added here (mirrors the components-preview fix,
|
|
162
|
-
// `component-preview.tsx`). The editing chrome
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
if (!d.getElementById('ec-outline-css')) {
|
|
175
|
-
const style = d.createElement('style');
|
|
176
|
-
style.id = 'ec-outline-css';
|
|
177
|
-
style.textContent = OUTLINE_CSS;
|
|
178
|
-
d.head.appendChild(style);
|
|
179
|
-
}
|
|
180
|
-
if (kitTokensCss) {
|
|
181
|
-
const existing = d.getElementById('ec-kit-tokens');
|
|
182
|
-
if (existing)
|
|
183
|
-
existing.remove();
|
|
184
|
-
const style = d.createElement('style');
|
|
185
|
-
style.id = 'ec-kit-tokens';
|
|
186
|
-
style.textContent = kitTokensCss;
|
|
187
|
-
d.head.appendChild(style);
|
|
188
|
-
}
|
|
189
|
-
d.body.className = 'bg-background text-foreground font-sans';
|
|
62
|
+
// `component-preview.tsx`). The editing chrome (selection/hover outlines,
|
|
63
|
+
// drop indicators, the rich-text toolbar, locale-fallback chips) uses
|
|
64
|
+
// hardcoded colors rather than these tokens, so it stays visible against a
|
|
65
|
+
// light page regardless. The full injection — stylesheet link, editing
|
|
66
|
+
// chrome, kit tokens, and the consumer's `defineStudio({ canvas })` theme
|
|
67
|
+
// (EC-389, injected LAST so it wins) — lives in `applyCanvasChrome`.
|
|
68
|
+
applyCanvasChrome(d, {
|
|
69
|
+
stylesheetUrl: getCanvasStylesheetUrl(),
|
|
70
|
+
kitTokensCss,
|
|
71
|
+
theme: resolveDeploymentCanvasTheme(),
|
|
72
|
+
});
|
|
190
73
|
setDoc(d);
|
|
191
74
|
}, [kitTokensCss]);
|
|
192
75
|
// Handle the already-loaded case (srcDoc may load before onLoad attaches).
|
|
@@ -194,6 +77,11 @@ export function BuilderCanvas({ perspective = 'draft', } = {}) {
|
|
|
194
77
|
if (iframeRef.current?.contentDocument?.readyState === 'complete')
|
|
195
78
|
wireUp();
|
|
196
79
|
}, [wireUp]);
|
|
80
|
+
// Inline editing (EC-158/EC-159): the dblclick-to-edit state machine, the
|
|
81
|
+
// live session ref other listeners consult, the rich-text host (mark
|
|
82
|
+
// toolbar), and the per-node remount epochs (decorate keys).
|
|
83
|
+
const inline = useCanvasInlineEditing(doc, { presentRef, dispatch, studio });
|
|
84
|
+
const { editingRef, richEditHost, richEditEpochs } = inline;
|
|
197
85
|
// Native selection/hover listeners inside the iframe document.
|
|
198
86
|
useEffect(() => {
|
|
199
87
|
if (!doc)
|
|
@@ -218,399 +106,9 @@ export function BuilderCanvas({ perspective = 'draft', } = {}) {
|
|
|
218
106
|
doc.removeEventListener('mouseover', onOver);
|
|
219
107
|
doc.removeEventListener('mouseleave', onLeave, true);
|
|
220
108
|
};
|
|
221
|
-
}, [doc, dispatch]);
|
|
222
|
-
// Drag & drop on the canvas (EC-160)
|
|
223
|
-
|
|
224
|
-
// evaluated live during `dragover`; the wrapper under the pointer shows the
|
|
225
|
-
// resolution (`data-ec-drop`: into/before/after, or `blocked` for a visible
|
|
226
|
-
// rejection). A completed drop dispatches exactly one builder action (move
|
|
227
|
-
// or insert) → one operations command → one undo step. Ineligible targets
|
|
228
|
-
// never `preventDefault`, so releasing over one is a native no-op.
|
|
229
|
-
useEffect(() => {
|
|
230
|
-
if (!doc)
|
|
231
|
-
return;
|
|
232
|
-
const getManifest = (id) => studio.registry.getManifest(id);
|
|
233
|
-
const currentRoot = () => presentRef.current;
|
|
234
|
-
let dropWrapper = null;
|
|
235
|
-
let dropPlan = null;
|
|
236
|
-
const clearIndicator = () => {
|
|
237
|
-
dropWrapper?.removeAttribute('data-ec-drop');
|
|
238
|
-
dropWrapper?.removeAttribute('data-ec-drop-axis');
|
|
239
|
-
dropWrapper = null;
|
|
240
|
-
dropPlan = null;
|
|
241
|
-
};
|
|
242
|
-
const showIndicator = (wrapper, state, axis) => {
|
|
243
|
-
if (dropWrapper && dropWrapper !== wrapper) {
|
|
244
|
-
dropWrapper.removeAttribute('data-ec-drop');
|
|
245
|
-
dropWrapper.removeAttribute('data-ec-drop-axis');
|
|
246
|
-
}
|
|
247
|
-
dropWrapper = wrapper;
|
|
248
|
-
if (!wrapper)
|
|
249
|
-
return;
|
|
250
|
-
if (state) {
|
|
251
|
-
wrapper.setAttribute('data-ec-drop', state);
|
|
252
|
-
// Before/after bars render along the parent's flow axis (EC-162).
|
|
253
|
-
wrapper.setAttribute('data-ec-drop-axis', axis === 'horizontal' ? 'x' : 'y');
|
|
254
|
-
}
|
|
255
|
-
else {
|
|
256
|
-
wrapper.removeAttribute('data-ec-drop');
|
|
257
|
-
wrapper.removeAttribute('data-ec-drop-axis');
|
|
258
|
-
}
|
|
259
|
-
};
|
|
260
|
-
// Wrappers are armed draggable per-gesture on mousedown, never via render
|
|
261
|
-
// state: the active inline editor (EC-158) must keep native caret clicks
|
|
262
|
-
// and text selection, so a press inside the editing host disarms its
|
|
263
|
-
// wrapper instead of starting a node drag.
|
|
264
|
-
const onMouseDown = (e) => {
|
|
265
|
-
const wrapper = wrapperAt(e.target);
|
|
266
|
-
if (!wrapper)
|
|
267
|
-
return;
|
|
268
|
-
const root = currentRoot();
|
|
269
|
-
const nodeId = wrapper.getAttribute('data-ec-node-id');
|
|
270
|
-
const inEditor = editingRef.current?.host.contains(e.target) ?? false;
|
|
271
|
-
if (root && nodeId && nodeId !== root.id && !inEditor) {
|
|
272
|
-
wrapper.setAttribute('draggable', 'true');
|
|
273
|
-
}
|
|
274
|
-
else {
|
|
275
|
-
wrapper.removeAttribute('draggable');
|
|
276
|
-
}
|
|
277
|
-
};
|
|
278
|
-
const onDragStart = (e) => {
|
|
279
|
-
// Drags inside the live inline editor are native text drags — leave them alone.
|
|
280
|
-
if (editingRef.current?.host.contains(e.target))
|
|
281
|
-
return;
|
|
282
|
-
const wrapper = wrapperAt(e.target);
|
|
283
|
-
if (!wrapper)
|
|
284
|
-
return;
|
|
285
|
-
const root = currentRoot();
|
|
286
|
-
const nodeId = wrapper.getAttribute('data-ec-node-id');
|
|
287
|
-
const node = root ? getNode(root, nodeId) : undefined;
|
|
288
|
-
if (!root || !node || nodeId === root.id) {
|
|
289
|
-
e.preventDefault();
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
beginDrag({ subject: { kind: 'node', nodeId, componentId: node.componentId }, source: 'canvas', label: node.componentId }, e.dataTransfer);
|
|
293
|
-
};
|
|
294
|
-
const onDragOver = (e) => {
|
|
295
|
-
const drag = activeDrag();
|
|
296
|
-
if (!drag)
|
|
297
|
-
return;
|
|
298
|
-
const root = currentRoot();
|
|
299
|
-
if (!root)
|
|
300
|
-
return;
|
|
301
|
-
const wrapper = wrapperAt(e.target);
|
|
302
|
-
// Empty space around the page subtree targets the root slot (append).
|
|
303
|
-
const refId = wrapper?.getAttribute('data-ec-node-id') ?? root.id;
|
|
304
|
-
const refWrapper = wrapper ?? doc.querySelector(`[data-ec-node-id="${root.id}"]`);
|
|
305
|
-
const refNode = getNode(root, refId);
|
|
306
|
-
if (!refNode) {
|
|
307
|
-
clearIndicator();
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
let position = 'into';
|
|
311
|
-
let flow = 'vertical';
|
|
312
|
-
if (wrapper && refId !== root.id) {
|
|
313
|
-
const rect = wrapper.getBoundingClientRect();
|
|
314
|
-
// Hit-bands follow the parent's flow axis (EC-162); the cross-axis
|
|
315
|
-
// side bands of vertically flowing targets are the wrap gesture.
|
|
316
|
-
flow = containerFlowAxis(getParentInfo(root, refId)?.parent);
|
|
317
|
-
position = dropPositionForPoint({
|
|
318
|
-
x: rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0.5,
|
|
319
|
-
y: rect.height > 0 ? (e.clientY - rect.top) / rect.height : 0.5,
|
|
320
|
-
flow,
|
|
321
|
-
nestable: canNestInto(getManifest, refNode.componentId),
|
|
322
|
-
edges: true,
|
|
323
|
-
wrappable: true,
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
const resolution = resolveDrop({
|
|
327
|
-
root,
|
|
328
|
-
getManifest,
|
|
329
|
-
subject: drag.subject,
|
|
330
|
-
location: { nodeId: refId, position },
|
|
331
|
-
});
|
|
332
|
-
showIndicator(refWrapper, dropIndicatorFor(resolution, position), flow);
|
|
333
|
-
if (resolution.eligible) {
|
|
334
|
-
dropPlan = resolution;
|
|
335
|
-
e.preventDefault();
|
|
336
|
-
if (e.dataTransfer) {
|
|
337
|
-
e.dataTransfer.dropEffect = drag.subject.kind === 'node' ? 'move' : 'copy';
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
else {
|
|
341
|
-
dropPlan = null;
|
|
342
|
-
}
|
|
343
|
-
};
|
|
344
|
-
const onDrop = (e) => {
|
|
345
|
-
const drag = activeDrag();
|
|
346
|
-
if (!drag)
|
|
347
|
-
return;
|
|
348
|
-
const plan = dropPlan;
|
|
349
|
-
clearIndicator();
|
|
350
|
-
if (!plan)
|
|
351
|
-
return;
|
|
352
|
-
e.preventDefault();
|
|
353
|
-
// Exactly one dispatch per completed drop (one undo step, one command).
|
|
354
|
-
if (plan.kind === 'move') {
|
|
355
|
-
dispatch({ type: 'move', id: plan.nodeId, target: plan.target });
|
|
356
|
-
}
|
|
357
|
-
else if (plan.kind === 'wrap') {
|
|
358
|
-
// EC-162 gesture: reference + subject become a two-column Grid.
|
|
359
|
-
const subject = plan.nodeId !== undefined
|
|
360
|
-
? { kind: 'node', nodeId: plan.nodeId }
|
|
361
|
-
: drag.buildNode
|
|
362
|
-
? { kind: 'component', node: drag.buildNode() }
|
|
363
|
-
: null;
|
|
364
|
-
if (!subject) {
|
|
365
|
-
endDrag('cancelled');
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
dispatch({ type: 'wrap', referenceId: plan.referenceId, side: plan.side, subject });
|
|
369
|
-
}
|
|
370
|
-
else if (drag.buildNode) {
|
|
371
|
-
dispatch({ type: 'insert', target: plan.target, node: drag.buildNode() });
|
|
372
|
-
}
|
|
373
|
-
else {
|
|
374
|
-
endDrag('cancelled');
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
endDrag('dropped');
|
|
378
|
-
};
|
|
379
|
-
const onDragEnd = () => {
|
|
380
|
-
clearIndicator();
|
|
381
|
-
endDrag('cancelled');
|
|
382
|
-
};
|
|
383
|
-
const onDragLeave = (e) => {
|
|
384
|
-
// Leaving the iframe document entirely (relatedTarget is null).
|
|
385
|
-
if (!e.relatedTarget)
|
|
386
|
-
clearIndicator();
|
|
387
|
-
};
|
|
388
|
-
// Cleanup when the drag ends anywhere (e.g. dropped on the page tree).
|
|
389
|
-
const unsubscribe = onDragSessionEnd(() => clearIndicator());
|
|
390
|
-
doc.addEventListener('mousedown', onMouseDown, true);
|
|
391
|
-
doc.addEventListener('dragstart', onDragStart);
|
|
392
|
-
doc.addEventListener('dragover', onDragOver);
|
|
393
|
-
doc.addEventListener('drop', onDrop);
|
|
394
|
-
doc.addEventListener('dragend', onDragEnd);
|
|
395
|
-
doc.addEventListener('dragleave', onDragLeave);
|
|
396
|
-
return () => {
|
|
397
|
-
unsubscribe();
|
|
398
|
-
clearIndicator();
|
|
399
|
-
doc.removeEventListener('mousedown', onMouseDown, true);
|
|
400
|
-
doc.removeEventListener('dragstart', onDragStart);
|
|
401
|
-
doc.removeEventListener('dragover', onDragOver);
|
|
402
|
-
doc.removeEventListener('drop', onDrop);
|
|
403
|
-
doc.removeEventListener('dragend', onDragEnd);
|
|
404
|
-
doc.removeEventListener('dragleave', onDragLeave);
|
|
405
|
-
};
|
|
406
|
-
}, [doc, dispatch, studio]);
|
|
407
|
-
// Inline editing (EC-158 plain text, EC-159 rich text): double-click on a
|
|
408
|
-
// node whose manifest flags an inline-editable prop turns the *rendered
|
|
409
|
-
// element itself* contenteditable — no overlay, no input swap, so typography
|
|
410
|
-
// and layout are identical in edit and render states. A session finishes
|
|
411
|
-
// exactly once, and a commit dispatches exactly one `setProp` action — one
|
|
412
|
-
// undo step, mirrored as one `graph.setNodeProp` operations command by the
|
|
413
|
-
// builder-store persister. The graph stays the single source of truth.
|
|
414
|
-
//
|
|
415
|
-
// Commit/cancel gestures: plain text commits on Enter or blur; rich text is
|
|
416
|
-
// multiline, so plain Enter inserts a paragraph and the commit gesture is
|
|
417
|
-
// Cmd/Ctrl+Enter or blur (Enter-to-commit would make multi-paragraph content
|
|
418
|
-
// unauthorable in place). Esc always cancels, dispatching nothing.
|
|
419
|
-
useEffect(() => {
|
|
420
|
-
if (!doc)
|
|
421
|
-
return;
|
|
422
|
-
// ---- plain text (EC-158) ------------------------------------------------
|
|
423
|
-
const beginText = (wrapper, host, session) => {
|
|
424
|
-
let done = false;
|
|
425
|
-
const finish = (mode) => {
|
|
426
|
-
if (done)
|
|
427
|
-
return;
|
|
428
|
-
done = true;
|
|
429
|
-
editingRef.current = null;
|
|
430
|
-
const text = host.textContent ?? '';
|
|
431
|
-
host.removeEventListener('keydown', onKeyDown);
|
|
432
|
-
host.removeEventListener('blur', onBlur);
|
|
433
|
-
host.removeAttribute('contenteditable');
|
|
434
|
-
host.removeAttribute('data-ec-edit');
|
|
435
|
-
wrapper.removeAttribute('data-ec-editing');
|
|
436
|
-
if (mode === 'commit') {
|
|
437
|
-
const outcome = session.commit(text);
|
|
438
|
-
if (outcome) {
|
|
439
|
-
dispatch({
|
|
440
|
-
type: 'setProp',
|
|
441
|
-
id: outcome.nodeId,
|
|
442
|
-
name: outcome.propName,
|
|
443
|
-
value: staticProp(outcome.value),
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
else {
|
|
448
|
-
const outcome = session.cancel();
|
|
449
|
-
if (outcome)
|
|
450
|
-
host.textContent = outcome.value;
|
|
451
|
-
}
|
|
452
|
-
doc.getSelection()?.removeAllRanges();
|
|
453
|
-
};
|
|
454
|
-
const onKeyDown = (e) => {
|
|
455
|
-
if (e.key === 'Enter') {
|
|
456
|
-
e.preventDefault();
|
|
457
|
-
e.stopPropagation();
|
|
458
|
-
finish('commit');
|
|
459
|
-
}
|
|
460
|
-
else if (e.key === 'Escape') {
|
|
461
|
-
e.preventDefault();
|
|
462
|
-
e.stopPropagation();
|
|
463
|
-
finish('cancel');
|
|
464
|
-
}
|
|
465
|
-
};
|
|
466
|
-
const onBlur = () => finish('commit');
|
|
467
|
-
// `plaintext-only` keeps paste/typing plain; fall back where unsupported.
|
|
468
|
-
host.setAttribute('contenteditable', 'plaintext-only');
|
|
469
|
-
if (!host.isContentEditable)
|
|
470
|
-
host.setAttribute('contenteditable', 'true');
|
|
471
|
-
host.setAttribute('data-ec-edit', 'text');
|
|
472
|
-
wrapper.setAttribute('data-ec-editing', '');
|
|
473
|
-
host.addEventListener('keydown', onKeyDown);
|
|
474
|
-
host.addEventListener('blur', onBlur);
|
|
475
|
-
editingRef.current = { nodeId: session.nodeId, host, finish };
|
|
476
|
-
host.focus({ preventScroll: true });
|
|
477
|
-
// Keep the gesture's native caret/word selection when it landed inside
|
|
478
|
-
// the host; otherwise select the whole value.
|
|
479
|
-
const sel = doc.getSelection();
|
|
480
|
-
if (!sel || sel.rangeCount === 0 || !host.contains(sel.anchorNode)) {
|
|
481
|
-
const range = doc.createRange();
|
|
482
|
-
range.selectNodeContents(host);
|
|
483
|
-
sel?.removeAllRanges();
|
|
484
|
-
sel?.addRange(range);
|
|
485
|
-
}
|
|
486
|
-
};
|
|
487
|
-
// ---- rich text (EC-159) -------------------------------------------------
|
|
488
|
-
const beginRichText = (wrapper, nodeId, target) => {
|
|
489
|
-
const host = findRichTextHost(wrapper);
|
|
490
|
-
if (!host)
|
|
491
|
-
return;
|
|
492
|
-
// Pair the storage tree against the rendered DOM and stamp every
|
|
493
|
-
// non-invertible node (custom blocks, unknown nodes, images, embeds)
|
|
494
|
-
// with its exact JSON: they stay rendered *inside* the live editor as
|
|
495
|
-
// non-editable atoms and survive the session byte-exact. A pairing
|
|
496
|
-
// mismatch means the DOM diverged from the deterministic render —
|
|
497
|
-
// refuse to start rather than risk lossy parsing ("polished or absent").
|
|
498
|
-
const pairs = collectPreservedPairs(target.value.doc, host);
|
|
499
|
-
if (!pairs)
|
|
500
|
-
return;
|
|
501
|
-
const session = createInlineRichTextSession({
|
|
502
|
-
nodeId,
|
|
503
|
-
propName: target.propName,
|
|
504
|
-
original: target.value,
|
|
505
|
-
});
|
|
506
|
-
for (const pair of pairs) {
|
|
507
|
-
const el = pair.element;
|
|
508
|
-
el.setAttribute(PRESERVE_NODE_ATTR, JSON.stringify(pair.node));
|
|
509
|
-
el.setAttribute('contenteditable', 'false');
|
|
510
|
-
}
|
|
511
|
-
let done = false;
|
|
512
|
-
const finish = (mode) => {
|
|
513
|
-
if (done)
|
|
514
|
-
return;
|
|
515
|
-
done = true;
|
|
516
|
-
editingRef.current = null;
|
|
517
|
-
setRichEditHost(null);
|
|
518
|
-
// Parse the edited DOM, detach the session, then bump the node's
|
|
519
|
-
// remount epoch: React discards the editor-mutated DOM and renders
|
|
520
|
-
// fresh from the graph — committed content or, on cancel, the
|
|
521
|
-
// untouched original. Never patch a fiber tree against DOM the
|
|
522
|
-
// editing commands rewrote.
|
|
523
|
-
const parsed = mode === 'commit' ? richTextDocFromDom(host) : null;
|
|
524
|
-
host.removeEventListener('keydown', onKeyDown);
|
|
525
|
-
host.removeEventListener('blur', onBlur);
|
|
526
|
-
host.removeAttribute('contenteditable');
|
|
527
|
-
host.removeAttribute('data-ec-edit');
|
|
528
|
-
wrapper.removeAttribute('data-ec-editing');
|
|
529
|
-
setRichEditEpochs((prev) => ({ ...prev, [nodeId]: (prev[nodeId] ?? 0) + 1 }));
|
|
530
|
-
if (parsed) {
|
|
531
|
-
// An unchanged document commits nothing: the stored value is never
|
|
532
|
-
// rewritten, so untouched content round-trips byte-identical.
|
|
533
|
-
const outcome = session.commit(parsed.doc);
|
|
534
|
-
if (outcome) {
|
|
535
|
-
dispatch({
|
|
536
|
-
type: 'setProp',
|
|
537
|
-
id: outcome.nodeId,
|
|
538
|
-
name: outcome.propName,
|
|
539
|
-
value: staticProp(outcome.value),
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
else {
|
|
544
|
-
session.cancel();
|
|
545
|
-
}
|
|
546
|
-
doc.getSelection()?.removeAllRanges();
|
|
547
|
-
};
|
|
548
|
-
const onKeyDown = (e) => {
|
|
549
|
-
if (e.key === 'Escape') {
|
|
550
|
-
e.preventDefault();
|
|
551
|
-
e.stopPropagation();
|
|
552
|
-
finish('cancel');
|
|
553
|
-
}
|
|
554
|
-
else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
555
|
-
e.preventDefault();
|
|
556
|
-
e.stopPropagation();
|
|
557
|
-
finish('commit');
|
|
558
|
-
}
|
|
559
|
-
// Plain Enter falls through: native paragraph insertion (multiline).
|
|
560
|
-
};
|
|
561
|
-
const onBlur = (e) => {
|
|
562
|
-
// Interacting with the floating toolbar is part of the session.
|
|
563
|
-
const next = e.relatedTarget;
|
|
564
|
-
if (next?.closest?.('[data-ec-rt-toolbar]'))
|
|
565
|
-
return;
|
|
566
|
-
finish('commit');
|
|
567
|
-
};
|
|
568
|
-
host.setAttribute('contenteditable', 'true');
|
|
569
|
-
host.setAttribute('data-ec-edit', 'rich');
|
|
570
|
-
wrapper.setAttribute('data-ec-editing', '');
|
|
571
|
-
host.addEventListener('keydown', onKeyDown);
|
|
572
|
-
host.addEventListener('blur', onBlur);
|
|
573
|
-
editingRef.current = { nodeId, host, finish };
|
|
574
|
-
setRichEditHost(host);
|
|
575
|
-
host.focus({ preventScroll: true });
|
|
576
|
-
};
|
|
577
|
-
const onDblClick = (e) => {
|
|
578
|
-
const wrapper = wrapperAt(e.target);
|
|
579
|
-
if (!wrapper)
|
|
580
|
-
return;
|
|
581
|
-
const nodeId = wrapper.getAttribute('data-ec-node-id');
|
|
582
|
-
if (editingRef.current) {
|
|
583
|
-
// Double-click inside the live editor is native word selection.
|
|
584
|
-
if (editingRef.current.nodeId === nodeId)
|
|
585
|
-
return;
|
|
586
|
-
editingRef.current.finish('commit');
|
|
587
|
-
}
|
|
588
|
-
const node = getNode(presentRef.current, nodeId);
|
|
589
|
-
if (!node)
|
|
590
|
-
return;
|
|
591
|
-
const manifest = studio.registry.getManifest(node.componentId);
|
|
592
|
-
const target = resolveInlineEditableProp(manifest, node);
|
|
593
|
-
if (target) {
|
|
594
|
-
const host = findInlineTextHost(wrapper, target.value);
|
|
595
|
-
if (!host || typeof host.focus !== 'function')
|
|
596
|
-
return;
|
|
597
|
-
dispatch({ type: 'select', id: nodeId });
|
|
598
|
-
beginText(wrapper, host, createInlineEditSession({ nodeId, propName: target.propName, original: target.value }));
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
const richTarget = resolveInlineRichTextProp(manifest, node);
|
|
602
|
-
if (!richTarget)
|
|
603
|
-
return;
|
|
604
|
-
dispatch({ type: 'select', id: nodeId });
|
|
605
|
-
beginRichText(wrapper, nodeId, richTarget);
|
|
606
|
-
};
|
|
607
|
-
doc.addEventListener('dblclick', onDblClick, true);
|
|
608
|
-
return () => {
|
|
609
|
-
doc.removeEventListener('dblclick', onDblClick, true);
|
|
610
|
-
// Teardown mid-session (doc swap/unmount): cancel — never dispatch from cleanup.
|
|
611
|
-
editingRef.current?.finish('cancel');
|
|
612
|
-
};
|
|
613
|
-
}, [doc, dispatch, studio]);
|
|
109
|
+
}, [doc, dispatch, editingRef]);
|
|
110
|
+
// Drag & drop on the canvas (EC-160/EC-162) — see `canvas-dnd`.
|
|
111
|
+
useCanvasDnd(doc, { presentRef, dispatch, studio, editingRef });
|
|
614
112
|
// Copy/cut/paste on the canvas (EC-175): the same studio clipboard the page
|
|
615
113
|
// tree and blocks panel use, wired to the *iframe* document (events never
|
|
616
114
|
// cross frames). Shortcuts stay inert while an inline editor is live and in
|
|
@@ -627,102 +125,18 @@ export function BuilderCanvas({ perspective = 'draft', } = {}) {
|
|
|
627
125
|
asyncClipboard: doc.defaultView?.navigator.clipboard,
|
|
628
126
|
getSelection: () => doc.getSelection(),
|
|
629
127
|
});
|
|
630
|
-
}, [doc, clipboard]);
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const assetsStore = useOptionalAssets();
|
|
643
|
-
const resolveAsset = useCallback((assetId) => {
|
|
644
|
-
const record = assetsStore?.assets.find((a) => a.id === assetId);
|
|
645
|
-
if (!record?.url)
|
|
646
|
-
return null;
|
|
647
|
-
return {
|
|
648
|
-
url: record.url,
|
|
649
|
-
width: record.width ?? null,
|
|
650
|
-
height: record.height ?? null,
|
|
651
|
-
alt: record.alt ?? null,
|
|
652
|
-
focalPoint: record.focalPoint ?? null,
|
|
653
|
-
};
|
|
654
|
-
}, [assetsStore]);
|
|
655
|
-
// Locale-fallback chips (EC-176): a node whose bound content currently
|
|
656
|
-
// previews via EC-018 locale fallback wears a corner chip naming the locale
|
|
657
|
-
// the value actually came from. Same decision function as the inspector's
|
|
658
|
-
// Content tab (`cmsBindingFallback`), so chip and label can never disagree.
|
|
659
|
-
const { locale: activeLocale } = useStudioLocale();
|
|
660
|
-
// EC-300: the reuse capability identifies a transcluding embed + its relation prop,
|
|
661
|
-
// so the canvas can detect a HARD reference whose target doesn't resolve and show a
|
|
662
|
-
// placeholder instead of an empty section. Null-safe (no provider / no capability).
|
|
663
|
-
const reuse = useReuse();
|
|
664
|
-
// EC-254: populate a block's `relation` prop (the editor's document pick — a
|
|
665
|
-
// ProductGrid's products, a Reusable's element) to delivery shape from the
|
|
666
|
-
// active content session, in the canvas's active locale, so the canvas previews
|
|
667
|
-
// picked documents exactly as the host renders them. The studio analog of the
|
|
668
|
-
// host's baked relations map. A missing/invisible target → null → the block's
|
|
669
|
-
// own empty state.
|
|
670
|
-
const resolveRelation = useCallback((ref) => resolveCanvasRelation(ref, activeLocale, perspective), [activeLocale, perspective]);
|
|
671
|
-
// EC-255: resolve a listing block's query (a ProductGrid keyed to a category) to
|
|
672
|
-
// its documents from the active content session, in the canvas's active locale, so
|
|
673
|
-
// the canvas previews a dynamic grid exactly as the host renders it. The studio
|
|
674
|
-
// analog of the host's baked listings map; an empty/invalid query → [] → the
|
|
675
|
-
// block's own empty state.
|
|
676
|
-
const resolveListing = useCallback((query) => resolveCanvasListing(query, activeLocale, perspective), [activeLocale, perspective]);
|
|
677
|
-
// EC-291: resolve a rich-text internal link (`{type:'reference'}`) to its target's
|
|
678
|
-
// URL path from the active content session, in the canvas's active locale — the
|
|
679
|
-
// studio analog of the host's baked links map. A missing/unpublished/unroutable
|
|
680
|
-
// target → null → the rich-text renderer shows the visible `unresolved-ref` issue.
|
|
681
|
-
const resolveLink = useCallback((ref) => resolveCanvasLink(ref, activeLocale, perspective), [activeLocale, perspective]);
|
|
682
|
-
// EC-300/AD-16: a transcluding embed whose target doesn't resolve in the active
|
|
683
|
-
// perspective renders a placeholder, never an empty section. `unpublished` = the
|
|
684
|
-
// target exists as a draft but isn't live (Published view of a draft-only reusable);
|
|
685
|
-
// `missing` = no resolvable target at all (deleted). `null` = resolves fine, or the
|
|
686
|
-
// node isn't an embed / has no target picked yet (its own empty state owns that).
|
|
687
|
-
const unresolvedReuse = useCallback((node) => {
|
|
688
|
-
const cap = reuse?.capability;
|
|
689
|
-
if (!cap)
|
|
690
|
-
return null;
|
|
691
|
-
const ref = reuseEmbedRef(node, cap);
|
|
692
|
-
if (!ref)
|
|
693
|
-
return null;
|
|
694
|
-
if (resolveCanvasRelation(ref, activeLocale, perspective))
|
|
695
|
-
return null;
|
|
696
|
-
return resolveCanvasRelation(ref, activeLocale, 'draft') ? 'unpublished' : 'missing';
|
|
697
|
-
}, [reuse, activeLocale, perspective]);
|
|
698
|
-
const localeFallbackOf = useCallback((node) => {
|
|
699
|
-
for (const value of Object.values(node.props ?? {})) {
|
|
700
|
-
if (value.kind !== 'binding')
|
|
701
|
-
continue;
|
|
702
|
-
const fallback = cmsBindingFallback(value.binding, data.getSource(value.binding.sourceId), activeLocale);
|
|
703
|
-
if (fallback)
|
|
704
|
-
return fallback.sourceLocale;
|
|
705
|
-
}
|
|
706
|
-
return undefined;
|
|
707
|
-
}, [data, activeLocale]);
|
|
708
|
-
const ctx = useMemo(() => ({
|
|
709
|
-
registry: studio.registry,
|
|
710
|
-
implementations: studio.implementations,
|
|
711
|
-
resolveBinding,
|
|
712
|
-
resolveAsset,
|
|
713
|
-
resolveRelation,
|
|
714
|
-
resolveListing,
|
|
715
|
-
resolveLink,
|
|
716
|
-
decorateNode: (node, rendered) => {
|
|
717
|
-
if (!pageNodeIds.has(node.id))
|
|
718
|
-
return rendered;
|
|
719
|
-
// An unresolved hard reference shows the placeholder in place of the empty
|
|
720
|
-
// transclusion; the node stays selectable, so its inspector relation chip
|
|
721
|
-
// (EC-296) deep-links to the reusable to edit/publish it.
|
|
722
|
-
const unresolved = unresolvedReuse(node);
|
|
723
|
-
return (_jsx("div", { "data-ec-node-id": node.id, className: "ec-editable", "data-selected": node.id === builder.selectedId || undefined, "data-hovered": node.id === builder.hoveredId || undefined, "data-ec-locale-fallback": localeFallbackOf(node), children: unresolved ? _jsx(UnresolvedReusablePlaceholder, { status: unresolved }) : rendered }, `${node.id}#${richEditEpochs[node.id] ?? 0}`));
|
|
724
|
-
},
|
|
725
|
-
}), [studio, resolveBinding, resolveAsset, resolveRelation, resolveListing, resolveLink, unresolvedReuse, pageNodeIds, builder.selectedId, builder.hoveredId, localeFallbackOf, richEditEpochs]);
|
|
128
|
+
}, [doc, clipboard, editingRef]);
|
|
129
|
+
// The render context: the seven delivery-parity resolvers + the editable-
|
|
130
|
+
// wrapper decorator (selection/hover, locale chips, remount epochs) — see
|
|
131
|
+
// `canvas-render-context`.
|
|
132
|
+
const { ctx, pageNodeIds } = useCanvasRenderContext({
|
|
133
|
+
present: builder.present,
|
|
134
|
+
selectedId: builder.selectedId,
|
|
135
|
+
hoveredId: builder.hoveredId,
|
|
136
|
+
studio,
|
|
137
|
+
perspective,
|
|
138
|
+
richEditEpochs,
|
|
139
|
+
});
|
|
726
140
|
// Tokenized spacing handles (EC-163): a selected Stack/Grid with 2+ children
|
|
727
141
|
// gets draggable gap handles between them, portaled into the iframe.
|
|
728
142
|
const spacingTarget = doc && hasSpacingHandles(builder.selectedNode) && pageNodeIds.has(builder.selectedNode.id)
|
|
@@ -739,31 +153,4 @@ export function BuilderCanvas({ perspective = 'draft', } = {}) {
|
|
|
739
153
|
renderPageComposition(activeRoot, ctx), doc.body)
|
|
740
154
|
: null, spacingTarget && doc ? (_jsx(SpacingHandles, { doc: doc, node: spacingTarget }, spacingTarget.id)) : null, richEditHost && doc ? _jsx(RichTextToolbar, { doc: doc, host: richEditHost }) : null] })] }));
|
|
741
155
|
}
|
|
742
|
-
/**
|
|
743
|
-
* In-canvas placeholder for a transcluding embed whose target doesn't resolve
|
|
744
|
-
* (EC-300/AD-16) — shown in place of the empty section so a hard reference never
|
|
745
|
-
* silently vanishes. Rendered INTO the iframe (portal), which carries only the
|
|
746
|
-
* project's stylesheet, so it uses inline styles rather than studio UI classes.
|
|
747
|
-
* The node stays selectable; the inspector's `element` relation chip (EC-296)
|
|
748
|
-
* deep-links to the reusable to edit or publish it.
|
|
749
|
-
*/
|
|
750
|
-
function UnresolvedReusablePlaceholder({ status }) {
|
|
751
|
-
const title = status === 'unpublished'
|
|
752
|
-
? translate('builder.canvas.reusableUnpublishedTitle')
|
|
753
|
-
: translate('builder.canvas.reusableMissingTitle');
|
|
754
|
-
const hint = status === 'unpublished'
|
|
755
|
-
? translate('builder.canvas.reusableUnpublishedHint')
|
|
756
|
-
: translate('builder.canvas.reusableMissingHint');
|
|
757
|
-
return (_jsxs("div", { style: {
|
|
758
|
-
boxSizing: 'border-box',
|
|
759
|
-
border: '1px dashed rgba(120,120,120,0.5)',
|
|
760
|
-
borderRadius: 8,
|
|
761
|
-
padding: '24px 28px',
|
|
762
|
-
margin: '12px 16px',
|
|
763
|
-
background: 'rgba(127,127,127,0.06)',
|
|
764
|
-
color: 'rgba(90,90,90,0.95)',
|
|
765
|
-
font: '14px/1.5 system-ui, -apple-system, sans-serif',
|
|
766
|
-
textAlign: 'center',
|
|
767
|
-
}, children: [_jsxs("div", { style: { fontWeight: 600 }, children: ["\u26A0 ", title] }), _jsx("div", { style: { marginTop: 6, fontSize: 13, opacity: 0.85 }, children: hint })] }));
|
|
768
|
-
}
|
|
769
156
|
//# sourceMappingURL=canvas.js.map
|