@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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { getNode, getParentInfo } from '@elytracms/core/project-graph';
|
|
3
|
+
import { canNestInto, containerFlowAxis, dropIndicatorFor, dropPositionForPoint, resolveDrop, } from '@elytracms/studio/lib/dnd/drop-rules';
|
|
4
|
+
import { activeDrag, beginDrag, endDrag, onDragSessionEnd } from './drag-session.js';
|
|
5
|
+
import { wrapperAt } from './canvas-dom.js';
|
|
6
|
+
/**
|
|
7
|
+
* Wire the canvas drag-and-drop listeners onto the iframe document (native —
|
|
8
|
+
* React's synthetic events don't cross frames). Returns the detach function.
|
|
9
|
+
*/
|
|
10
|
+
export function attachCanvasDndListeners(doc, deps) {
|
|
11
|
+
const { currentRoot, getManifest, dispatch } = deps;
|
|
12
|
+
let dropWrapper = null;
|
|
13
|
+
let dropPlan = null;
|
|
14
|
+
const clearIndicator = () => {
|
|
15
|
+
dropWrapper?.removeAttribute('data-ec-drop');
|
|
16
|
+
dropWrapper?.removeAttribute('data-ec-drop-axis');
|
|
17
|
+
dropWrapper = null;
|
|
18
|
+
dropPlan = null;
|
|
19
|
+
};
|
|
20
|
+
const showIndicator = (wrapper, state, axis) => {
|
|
21
|
+
if (dropWrapper && dropWrapper !== wrapper) {
|
|
22
|
+
dropWrapper.removeAttribute('data-ec-drop');
|
|
23
|
+
dropWrapper.removeAttribute('data-ec-drop-axis');
|
|
24
|
+
}
|
|
25
|
+
dropWrapper = wrapper;
|
|
26
|
+
if (!wrapper)
|
|
27
|
+
return;
|
|
28
|
+
if (state) {
|
|
29
|
+
wrapper.setAttribute('data-ec-drop', state);
|
|
30
|
+
// Before/after bars render along the parent's flow axis (EC-162).
|
|
31
|
+
wrapper.setAttribute('data-ec-drop-axis', axis === 'horizontal' ? 'x' : 'y');
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
wrapper.removeAttribute('data-ec-drop');
|
|
35
|
+
wrapper.removeAttribute('data-ec-drop-axis');
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
// Wrappers are armed draggable per-gesture on mousedown, never via render
|
|
39
|
+
// state: the active inline editor (EC-158) must keep native caret clicks
|
|
40
|
+
// and text selection, so a press inside the editing host disarms its
|
|
41
|
+
// wrapper instead of starting a node drag.
|
|
42
|
+
const onMouseDown = (e) => {
|
|
43
|
+
const wrapper = wrapperAt(e.target);
|
|
44
|
+
if (!wrapper)
|
|
45
|
+
return;
|
|
46
|
+
const root = currentRoot();
|
|
47
|
+
const nodeId = wrapper.getAttribute('data-ec-node-id');
|
|
48
|
+
const inEditor = deps.isEditingTarget(e.target);
|
|
49
|
+
if (root && nodeId && nodeId !== root.id && !inEditor) {
|
|
50
|
+
wrapper.setAttribute('draggable', 'true');
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
wrapper.removeAttribute('draggable');
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const onDragStart = (e) => {
|
|
57
|
+
const event = e;
|
|
58
|
+
// Drags inside the live inline editor are native text drags — leave them alone.
|
|
59
|
+
if (deps.isEditingTarget(event.target))
|
|
60
|
+
return;
|
|
61
|
+
const wrapper = wrapperAt(event.target);
|
|
62
|
+
if (!wrapper)
|
|
63
|
+
return;
|
|
64
|
+
const root = currentRoot();
|
|
65
|
+
const nodeId = wrapper.getAttribute('data-ec-node-id');
|
|
66
|
+
const node = root ? getNode(root, nodeId) : undefined;
|
|
67
|
+
if (!root || !node || nodeId === root.id) {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
beginDrag({ subject: { kind: 'node', nodeId, componentId: node.componentId }, source: 'canvas', label: node.componentId }, event.dataTransfer);
|
|
72
|
+
};
|
|
73
|
+
const onDragOver = (e) => {
|
|
74
|
+
const event = e;
|
|
75
|
+
const drag = activeDrag();
|
|
76
|
+
if (!drag)
|
|
77
|
+
return;
|
|
78
|
+
const root = currentRoot();
|
|
79
|
+
if (!root)
|
|
80
|
+
return;
|
|
81
|
+
const wrapper = wrapperAt(event.target);
|
|
82
|
+
// Empty space around the page subtree targets the root slot (append).
|
|
83
|
+
const refId = wrapper?.getAttribute('data-ec-node-id') ?? root.id;
|
|
84
|
+
const refWrapper = wrapper ?? doc.querySelector(`[data-ec-node-id="${root.id}"]`);
|
|
85
|
+
const refNode = getNode(root, refId);
|
|
86
|
+
if (!refNode) {
|
|
87
|
+
clearIndicator();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
let position = 'into';
|
|
91
|
+
let flow = 'vertical';
|
|
92
|
+
if (wrapper && refId !== root.id) {
|
|
93
|
+
const rect = wrapper.getBoundingClientRect();
|
|
94
|
+
// Hit-bands follow the parent's flow axis (EC-162); the cross-axis
|
|
95
|
+
// side bands of vertically flowing targets are the wrap gesture.
|
|
96
|
+
flow = containerFlowAxis(getParentInfo(root, refId)?.parent);
|
|
97
|
+
position = dropPositionForPoint({
|
|
98
|
+
x: rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5,
|
|
99
|
+
y: rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5,
|
|
100
|
+
flow,
|
|
101
|
+
nestable: canNestInto(getManifest, refNode.componentId),
|
|
102
|
+
edges: true,
|
|
103
|
+
wrappable: true,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const resolution = resolveDrop({
|
|
107
|
+
root,
|
|
108
|
+
getManifest,
|
|
109
|
+
subject: drag.subject,
|
|
110
|
+
location: { nodeId: refId, position },
|
|
111
|
+
});
|
|
112
|
+
showIndicator(refWrapper, dropIndicatorFor(resolution, position), flow);
|
|
113
|
+
if (resolution.eligible) {
|
|
114
|
+
dropPlan = resolution;
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
if (event.dataTransfer) {
|
|
117
|
+
event.dataTransfer.dropEffect = drag.subject.kind === 'node' ? 'move' : 'copy';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
dropPlan = null;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const onDrop = (e) => {
|
|
125
|
+
const event = e;
|
|
126
|
+
const drag = activeDrag();
|
|
127
|
+
if (!drag)
|
|
128
|
+
return;
|
|
129
|
+
const plan = dropPlan;
|
|
130
|
+
clearIndicator();
|
|
131
|
+
if (!plan)
|
|
132
|
+
return;
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
// Exactly one dispatch per completed drop (one undo step, one command).
|
|
135
|
+
if (plan.kind === 'move') {
|
|
136
|
+
dispatch({ type: 'move', id: plan.nodeId, target: plan.target });
|
|
137
|
+
}
|
|
138
|
+
else if (plan.kind === 'wrap') {
|
|
139
|
+
// EC-162 gesture: reference + subject become a two-column Grid.
|
|
140
|
+
const subject = plan.nodeId !== undefined
|
|
141
|
+
? { kind: 'node', nodeId: plan.nodeId }
|
|
142
|
+
: drag.buildNode
|
|
143
|
+
? { kind: 'component', node: drag.buildNode() }
|
|
144
|
+
: null;
|
|
145
|
+
if (!subject) {
|
|
146
|
+
endDrag('cancelled');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
dispatch({ type: 'wrap', referenceId: plan.referenceId, side: plan.side, subject });
|
|
150
|
+
}
|
|
151
|
+
else if (drag.buildNode) {
|
|
152
|
+
dispatch({ type: 'insert', target: plan.target, node: drag.buildNode() });
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
endDrag('cancelled');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
endDrag('dropped');
|
|
159
|
+
};
|
|
160
|
+
const onDragEnd = () => {
|
|
161
|
+
clearIndicator();
|
|
162
|
+
endDrag('cancelled');
|
|
163
|
+
};
|
|
164
|
+
const onDragLeave = (e) => {
|
|
165
|
+
// Leaving the iframe document entirely (relatedTarget is null).
|
|
166
|
+
if (!e.relatedTarget)
|
|
167
|
+
clearIndicator();
|
|
168
|
+
};
|
|
169
|
+
// Cleanup when the drag ends anywhere (e.g. dropped on the page tree).
|
|
170
|
+
const unsubscribe = onDragSessionEnd(() => clearIndicator());
|
|
171
|
+
doc.addEventListener('mousedown', onMouseDown, true);
|
|
172
|
+
doc.addEventListener('dragstart', onDragStart);
|
|
173
|
+
doc.addEventListener('dragover', onDragOver);
|
|
174
|
+
doc.addEventListener('drop', onDrop);
|
|
175
|
+
doc.addEventListener('dragend', onDragEnd);
|
|
176
|
+
doc.addEventListener('dragleave', onDragLeave);
|
|
177
|
+
return () => {
|
|
178
|
+
unsubscribe();
|
|
179
|
+
clearIndicator();
|
|
180
|
+
doc.removeEventListener('mousedown', onMouseDown, true);
|
|
181
|
+
doc.removeEventListener('dragstart', onDragStart);
|
|
182
|
+
doc.removeEventListener('dragover', onDragOver);
|
|
183
|
+
doc.removeEventListener('drop', onDrop);
|
|
184
|
+
doc.removeEventListener('dragend', onDragEnd);
|
|
185
|
+
doc.removeEventListener('dragleave', onDragLeave);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The React face of the canvas DnD wiring: attach on the iframe document once
|
|
190
|
+
* it exists, detach on doc swap/unmount. State lives in refs on purpose —
|
|
191
|
+
* listeners outlive renders and must read the *current* graph and editing
|
|
192
|
+
* session without re-subscribing on every edit.
|
|
193
|
+
*/
|
|
194
|
+
export function useCanvasDnd(doc, deps) {
|
|
195
|
+
const { presentRef, dispatch, studio, editingRef } = deps;
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!doc)
|
|
198
|
+
return;
|
|
199
|
+
return attachCanvasDndListeners(doc, {
|
|
200
|
+
currentRoot: () => presentRef.current,
|
|
201
|
+
getManifest: (id) => studio.registry.getManifest(id),
|
|
202
|
+
dispatch,
|
|
203
|
+
isEditingTarget: (target) => editingRef.current?.host.contains(target) ?? false,
|
|
204
|
+
});
|
|
205
|
+
}, [doc, dispatch, studio, presentRef, editingRef]);
|
|
206
|
+
}
|
|
207
|
+
//# sourceMappingURL=canvas-dnd.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvas-dnd.js","sourceRoot":"","sources":["../../../src/components/builder/canvas-dnd.ts"],"names":[],"mappings":"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","sourcesContent":["import { useEffect } from 'react'\nimport type { RefObject } from 'react'\nimport { getNode, getParentInfo } from '@elytracms/core/project-graph'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport {\n canNestInto,\n containerFlowAxis,\n dropIndicatorFor,\n dropPositionForPoint,\n resolveDrop,\n} from '@elytracms/studio/lib/dnd/drop-rules'\nimport type { DropPlan, DropPosition, FlowAxis, ManifestRules } from '@elytracms/studio/lib/dnd/drop-rules'\nimport type { StudioRegistry } from '@elytracms/studio/lib/registry'\nimport type { BuilderAction } from './builder-store'\nimport { activeDrag, beginDrag, endDrag, onDragSessionEnd } from './drag-session'\nimport type { ActiveInlineEdit } from './canvas-inline-editing'\nimport { wrapperAt } from './canvas-dom'\n\n/**\n * Drag & drop on the canvas (EC-160), extracted from `BuilderCanvas` (EC-330).\n * Eligibility is the pure shared rule set (`lib/dnd/drop-rules`) — the same\n * functions the page tree consumes — evaluated live during `dragover`; the\n * wrapper under the pointer shows the resolution (`data-ec-drop`:\n * into/before/after, or `blocked` for a visible rejection). A completed drop\n * dispatches exactly one builder action (move or insert) → one operations\n * command → one undo step. Ineligible targets never `preventDefault`, so\n * releasing over one is a native no-op.\n *\n * The listener wiring is framework-free ({@link attachCanvasDndListeners},\n * the `clipboard-actions` pattern) so the whole gesture state machine —\n * arm-on-mousedown, live indicator, plan resolution, one-dispatch drop — is\n * unit-testable without a DOM environment; {@link useCanvasDnd} only owns the\n * React effect lifecycle.\n */\n\n/** The structural slice of the iframe document the DnD wiring needs. */\nexport interface CanvasDndDocument {\n addEventListener(type: string, listener: (e: Event) => void, capture?: boolean): void\n removeEventListener(type: string, listener: (e: Event) => void, capture?: boolean): void\n querySelector(selectors: string): Element | null\n}\n\nexport interface CanvasDndDeps {\n /** The current composition root (read live — listeners outlive renders). */\n currentRoot(): ComponentNode\n /** Manifest lookup feeding the pure drop rules. */\n getManifest(componentId: string): ManifestRules | undefined\n dispatch(action: BuilderAction): void\n /**\n * Whether `target` sits inside the live inline editor (EC-158): a press\n * there must keep native caret clicks/text selection, never start a node drag.\n */\n isEditingTarget(target: EventTarget | null): boolean\n}\n\n/**\n * Wire the canvas drag-and-drop listeners onto the iframe document (native —\n * React's synthetic events don't cross frames). Returns the detach function.\n */\nexport function attachCanvasDndListeners(doc: CanvasDndDocument, deps: CanvasDndDeps): () => void {\n const { currentRoot, getManifest, dispatch } = deps\n\n let dropWrapper: HTMLElement | null = null\n let dropPlan: DropPlan | null = null\n const clearIndicator = () => {\n dropWrapper?.removeAttribute('data-ec-drop')\n dropWrapper?.removeAttribute('data-ec-drop-axis')\n dropWrapper = null\n dropPlan = null\n }\n const showIndicator = (wrapper: HTMLElement | null, state: string | null, axis: FlowAxis) => {\n if (dropWrapper && dropWrapper !== wrapper) {\n dropWrapper.removeAttribute('data-ec-drop')\n dropWrapper.removeAttribute('data-ec-drop-axis')\n }\n dropWrapper = wrapper\n if (!wrapper) return\n if (state) {\n wrapper.setAttribute('data-ec-drop', state)\n // Before/after bars render along the parent's flow axis (EC-162).\n wrapper.setAttribute('data-ec-drop-axis', axis === 'horizontal' ? 'x' : 'y')\n } else {\n wrapper.removeAttribute('data-ec-drop')\n wrapper.removeAttribute('data-ec-drop-axis')\n }\n }\n\n // Wrappers are armed draggable per-gesture on mousedown, never via render\n // state: the active inline editor (EC-158) must keep native caret clicks\n // and text selection, so a press inside the editing host disarms its\n // wrapper instead of starting a node drag.\n const onMouseDown = (e: Event) => {\n const wrapper = wrapperAt(e.target)\n if (!wrapper) return\n const root = currentRoot()\n const nodeId = wrapper.getAttribute('data-ec-node-id')\n const inEditor = deps.isEditingTarget(e.target)\n if (root && nodeId && nodeId !== root.id && !inEditor) {\n wrapper.setAttribute('draggable', 'true')\n } else {\n wrapper.removeAttribute('draggable')\n }\n }\n\n const onDragStart = (e: Event) => {\n const event = e as DragEvent\n // Drags inside the live inline editor are native text drags — leave them alone.\n if (deps.isEditingTarget(event.target)) return\n const wrapper = wrapperAt(event.target)\n if (!wrapper) return\n const root = currentRoot()\n const nodeId = wrapper.getAttribute('data-ec-node-id')!\n const node = root ? getNode(root, nodeId) : undefined\n if (!root || !node || nodeId === root.id) {\n event.preventDefault()\n return\n }\n beginDrag(\n { subject: { kind: 'node', nodeId, componentId: node.componentId }, source: 'canvas', label: node.componentId },\n event.dataTransfer,\n )\n }\n\n const onDragOver = (e: Event) => {\n const event = e as DragEvent\n const drag = activeDrag()\n if (!drag) return\n const root = currentRoot()\n if (!root) return\n const wrapper = wrapperAt(event.target)\n // Empty space around the page subtree targets the root slot (append).\n const refId = wrapper?.getAttribute('data-ec-node-id') ?? root.id\n const refWrapper =\n wrapper ?? (doc.querySelector(`[data-ec-node-id=\"${root.id}\"]`) as HTMLElement | null)\n const refNode = getNode(root, refId)\n if (!refNode) {\n clearIndicator()\n return\n }\n let position: DropPosition = 'into'\n let flow: FlowAxis = 'vertical'\n if (wrapper && refId !== root.id) {\n const rect = wrapper.getBoundingClientRect()\n // Hit-bands follow the parent's flow axis (EC-162); the cross-axis\n // side bands of vertically flowing targets are the wrap gesture.\n flow = containerFlowAxis(getParentInfo(root, refId)?.parent)\n position = dropPositionForPoint({\n x: rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5,\n y: rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5,\n flow,\n nestable: canNestInto(getManifest, refNode.componentId),\n edges: true,\n wrappable: true,\n })\n }\n const resolution = resolveDrop({\n root,\n getManifest,\n subject: drag.subject,\n location: { nodeId: refId, position },\n })\n showIndicator(refWrapper, dropIndicatorFor(resolution, position), flow)\n if (resolution.eligible) {\n dropPlan = resolution\n event.preventDefault()\n if (event.dataTransfer) {\n event.dataTransfer.dropEffect = drag.subject.kind === 'node' ? 'move' : 'copy'\n }\n } else {\n dropPlan = null\n }\n }\n\n const onDrop = (e: Event) => {\n const event = e as DragEvent\n const drag = activeDrag()\n if (!drag) return\n const plan = dropPlan\n clearIndicator()\n if (!plan) return\n event.preventDefault()\n // Exactly one dispatch per completed drop (one undo step, one command).\n if (plan.kind === 'move') {\n dispatch({ type: 'move', id: plan.nodeId, target: plan.target })\n } else if (plan.kind === 'wrap') {\n // EC-162 gesture: reference + subject become a two-column Grid.\n const subject =\n plan.nodeId !== undefined\n ? { kind: 'node' as const, nodeId: plan.nodeId }\n : drag.buildNode\n ? { kind: 'component' as const, node: drag.buildNode() }\n : null\n if (!subject) {\n endDrag('cancelled')\n return\n }\n dispatch({ type: 'wrap', referenceId: plan.referenceId, side: plan.side, subject })\n } else if (drag.buildNode) {\n dispatch({ type: 'insert', target: plan.target, node: drag.buildNode() })\n } else {\n endDrag('cancelled')\n return\n }\n endDrag('dropped')\n }\n\n const onDragEnd = () => {\n clearIndicator()\n endDrag('cancelled')\n }\n\n const onDragLeave = (e: Event) => {\n // Leaving the iframe document entirely (relatedTarget is null).\n if (!(e as DragEvent).relatedTarget) clearIndicator()\n }\n\n // Cleanup when the drag ends anywhere (e.g. dropped on the page tree).\n const unsubscribe = onDragSessionEnd(() => clearIndicator())\n\n doc.addEventListener('mousedown', onMouseDown, true)\n doc.addEventListener('dragstart', onDragStart)\n doc.addEventListener('dragover', onDragOver)\n doc.addEventListener('drop', onDrop)\n doc.addEventListener('dragend', onDragEnd)\n doc.addEventListener('dragleave', onDragLeave)\n return () => {\n unsubscribe()\n clearIndicator()\n doc.removeEventListener('mousedown', onMouseDown, true)\n doc.removeEventListener('dragstart', onDragStart)\n doc.removeEventListener('dragover', onDragOver)\n doc.removeEventListener('drop', onDrop)\n doc.removeEventListener('dragend', onDragEnd)\n doc.removeEventListener('dragleave', onDragLeave)\n }\n}\n\n/**\n * The React face of the canvas DnD wiring: attach on the iframe document once\n * it exists, detach on doc swap/unmount. State lives in refs on purpose —\n * listeners outlive renders and must read the *current* graph and editing\n * session without re-subscribing on every edit.\n */\nexport function useCanvasDnd(\n doc: Document | null,\n deps: {\n /** The live composition root (`builder.present`, mirrored into a ref). */\n presentRef: RefObject<ComponentNode>\n dispatch: (action: BuilderAction) => void\n studio: StudioRegistry\n /** The live inline-edit session (owned by `useCanvasInlineEditing`). */\n editingRef: RefObject<ActiveInlineEdit | null>\n },\n): void {\n const { presentRef, dispatch, studio, editingRef } = deps\n useEffect(() => {\n if (!doc) return\n return attachCanvasDndListeners(doc, {\n currentRoot: () => presentRef.current,\n getManifest: (id) => studio.registry.getManifest(id),\n dispatch,\n isEditingTarget: (target) => editingRef.current?.host.contains(target as Node) ?? false,\n })\n }, [doc, dispatch, studio, presentRef, editingRef])\n}\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared canvas DOM helpers (EC-330): the per-node editable wrappers the
|
|
3
|
+
* canvas renders (`decorateNode` stamps `data-ec-node-id`) are the anchor
|
|
4
|
+
* every iframe listener resolves gestures against — selection/hover
|
|
5
|
+
* (canvas.tsx), drag & drop (`canvas-dnd`), and inline editing
|
|
6
|
+
* (`canvas-inline-editing`) all share these two lookups.
|
|
7
|
+
*/
|
|
8
|
+
/** The node id of the editable wrapper containing `target`, or null. */
|
|
9
|
+
export declare function nodeIdAt(target: EventTarget | null): string | null;
|
|
10
|
+
/** The editable wrapper element containing `target`, or null. */
|
|
11
|
+
export declare function wrapperAt(target: EventTarget | null): HTMLElement | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared canvas DOM helpers (EC-330): the per-node editable wrappers the
|
|
3
|
+
* canvas renders (`decorateNode` stamps `data-ec-node-id`) are the anchor
|
|
4
|
+
* every iframe listener resolves gestures against — selection/hover
|
|
5
|
+
* (canvas.tsx), drag & drop (`canvas-dnd`), and inline editing
|
|
6
|
+
* (`canvas-inline-editing`) all share these two lookups.
|
|
7
|
+
*/
|
|
8
|
+
/** The node id of the editable wrapper containing `target`, or null. */
|
|
9
|
+
export function nodeIdAt(target) {
|
|
10
|
+
return wrapperAt(target)?.getAttribute('data-ec-node-id') ?? null;
|
|
11
|
+
}
|
|
12
|
+
/** The editable wrapper element containing `target`, or null. */
|
|
13
|
+
export function wrapperAt(target) {
|
|
14
|
+
const el = target;
|
|
15
|
+
return el?.closest?.('[data-ec-node-id]') ?? null;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=canvas-dom.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvas-dom.js","sourceRoot":"","sources":["../../../src/components/builder/canvas-dom.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,wEAAwE;AACxE,MAAM,UAAU,QAAQ,CAAC,MAA0B;IACjD,OAAO,SAAS,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC,iBAAiB,CAAC,IAAI,IAAI,CAAA;AACnE,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,SAAS,CAAC,MAA0B;IAClD,MAAM,EAAE,GAAG,MAA4B,CAAA;IACvC,OAAQ,EAAE,EAAE,OAAO,EAAE,CAAC,mBAAmB,CAAwB,IAAI,IAAI,CAAA;AAC3E,CAAC","sourcesContent":["/**\n * Shared canvas DOM helpers (EC-330): the per-node editable wrappers the\n * canvas renders (`decorateNode` stamps `data-ec-node-id`) are the anchor\n * every iframe listener resolves gestures against — selection/hover\n * (canvas.tsx), drag & drop (`canvas-dnd`), and inline editing\n * (`canvas-inline-editing`) all share these two lookups.\n */\n\n/** The node id of the editable wrapper containing `target`, or null. */\nexport function nodeIdAt(target: EventTarget | null): string | null {\n return wrapperAt(target)?.getAttribute('data-ec-node-id') ?? null\n}\n\n/** The editable wrapper element containing `target`, or null. */\nexport function wrapperAt(target: EventTarget | null): HTMLElement | null {\n const el = target as HTMLElement | null\n return (el?.closest?.('[data-ec-node-id]') as HTMLElement | null) ?? null\n}\n"]}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { RefObject } from 'react';
|
|
2
|
+
import type { ComponentManifest } from '@elytracms/core/component-registry';
|
|
3
|
+
import type { ComponentNode } from '@elytracms/core/project-graph';
|
|
4
|
+
import type { StudioRegistry } from '@elytracms/studio/lib/registry';
|
|
5
|
+
import type { BuilderAction } from './builder-store.js';
|
|
6
|
+
/**
|
|
7
|
+
* Inline editing on the canvas (EC-158 plain text, EC-159 rich text),
|
|
8
|
+
* extracted from `BuilderCanvas` (EC-330): double-click on a node whose
|
|
9
|
+
* manifest flags an inline-editable prop turns the *rendered element itself*
|
|
10
|
+
* contenteditable — no overlay, no input swap, so typography and layout are
|
|
11
|
+
* identical in edit and render states. A session finishes exactly once, and a
|
|
12
|
+
* commit dispatches exactly one `setProp` action — one undo step. The graph
|
|
13
|
+
* stays the single source of truth.
|
|
14
|
+
*
|
|
15
|
+
* Commit/cancel gestures: plain text commits on Enter or blur; rich text is
|
|
16
|
+
* multiline, so plain Enter inserts a paragraph and the commit gesture is
|
|
17
|
+
* Cmd/Ctrl+Enter or blur (Enter-to-commit would make multi-paragraph content
|
|
18
|
+
* unauthorable in place). Esc always cancels, dispatching nothing.
|
|
19
|
+
*
|
|
20
|
+
* The listener wiring is framework-free
|
|
21
|
+
* ({@link attachCanvasInlineEditingListeners}, the `clipboard-actions`
|
|
22
|
+
* pattern) so the begin/commit/cancel state machine is unit-testable without
|
|
23
|
+
* a DOM environment; {@link useCanvasInlineEditing} owns the React state the
|
|
24
|
+
* canvas renders from (the live rich-text host, the remount epochs) and the
|
|
25
|
+
* session ref other canvas listeners consult.
|
|
26
|
+
*/
|
|
27
|
+
/** A live inline-editing session bound to its DOM host (EC-158/EC-159). */
|
|
28
|
+
export interface ActiveInlineEdit {
|
|
29
|
+
nodeId: string;
|
|
30
|
+
host: HTMLElement;
|
|
31
|
+
/** Finish the session exactly once (later calls are no-ops). */
|
|
32
|
+
finish: (mode: 'commit' | 'cancel') => void;
|
|
33
|
+
}
|
|
34
|
+
/** The structural slice of the iframe document the editing wiring needs. */
|
|
35
|
+
export interface CanvasInlineEditingDocument {
|
|
36
|
+
addEventListener(type: string, listener: (e: Event) => void, capture?: boolean): void;
|
|
37
|
+
removeEventListener(type: string, listener: (e: Event) => void, capture?: boolean): void;
|
|
38
|
+
getSelection(): Selection | null;
|
|
39
|
+
createRange(): Range;
|
|
40
|
+
}
|
|
41
|
+
export interface CanvasInlineEditingDeps {
|
|
42
|
+
/** The current composition root (read live — listeners outlive renders). */
|
|
43
|
+
currentRoot(): ComponentNode;
|
|
44
|
+
getManifest(componentId: string): ComponentManifest | undefined;
|
|
45
|
+
dispatch(action: BuilderAction): void;
|
|
46
|
+
/** The live session — imperative DOM state, mirrored for other listeners. */
|
|
47
|
+
getEditing(): ActiveInlineEdit | null;
|
|
48
|
+
setEditing(edit: ActiveInlineEdit | null): void;
|
|
49
|
+
/** Mirror the rich-text host into render state (mounts the mark toolbar). */
|
|
50
|
+
setRichEditHost(host: HTMLElement | null): void;
|
|
51
|
+
/**
|
|
52
|
+
* A rich-text session mutates the rendered DOM behind React's back
|
|
53
|
+
* (contenteditable + mark commands), so finishing one must REMOUNT the
|
|
54
|
+
* node's subtree — bumping its epoch changes the decorate key, React
|
|
55
|
+
* discards the edited DOM wholesale and re-renders from the graph (the
|
|
56
|
+
* committed value, or the untouched original on cancel). Patching the old
|
|
57
|
+
* fiber tree against editor-mutated DOM would leave stale artifacts.
|
|
58
|
+
*/
|
|
59
|
+
bumpRichEditEpoch(nodeId: string): void;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Wire the inline-editing dblclick listener onto the iframe document (native —
|
|
63
|
+
* React's synthetic events don't cross frames). Returns the detach function;
|
|
64
|
+
* detaching mid-session cancels it (never dispatch from cleanup).
|
|
65
|
+
*/
|
|
66
|
+
export declare function attachCanvasInlineEditingListeners(doc: CanvasInlineEditingDocument, deps: CanvasInlineEditingDeps): () => void;
|
|
67
|
+
/** What `useCanvasInlineEditing` hands back to the canvas. */
|
|
68
|
+
export interface CanvasInlineEditing {
|
|
69
|
+
/**
|
|
70
|
+
* The active session — imperative DOM state inside the iframe, not render
|
|
71
|
+
* state. Other canvas listeners (selection clicks, DnD arming, clipboard
|
|
72
|
+
* blocking) consult it live through this ref.
|
|
73
|
+
*/
|
|
74
|
+
editingRef: RefObject<ActiveInlineEdit | null>;
|
|
75
|
+
/** The live rich-text host, mirrored into render state (mark toolbar mount). */
|
|
76
|
+
richEditHost: HTMLElement | null;
|
|
77
|
+
/** Per-node remount epochs — decorate keys change when a rich session ends. */
|
|
78
|
+
richEditEpochs: Record<string, number>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The React face of the canvas inline editing (EC-158/EC-159): owns the
|
|
82
|
+
* session ref and the render state (`richEditHost`, `richEditEpochs`), wires
|
|
83
|
+
* the dblclick listener when the iframe document exists, and cancels a live
|
|
84
|
+
* session on teardown.
|
|
85
|
+
*/
|
|
86
|
+
export declare function useCanvasInlineEditing(doc: Document | null, deps: {
|
|
87
|
+
/** The live composition root (`builder.present`, mirrored into a ref). */
|
|
88
|
+
presentRef: RefObject<ComponentNode>;
|
|
89
|
+
dispatch: (action: BuilderAction) => void;
|
|
90
|
+
studio: StudioRegistry;
|
|
91
|
+
}): CanvasInlineEditing;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { getNode, staticProp } from '@elytracms/core/project-graph';
|
|
3
|
+
import { PRESERVE_NODE_ATTR, collectPreservedPairs, richTextDocFromDom, } from '@elytracms/core/rich-text';
|
|
4
|
+
import { createInlineEditSession, createInlineRichTextSession, findInlineTextHost, resolveInlineEditableProp, resolveInlineRichTextProp, } from './inline-edit.js';
|
|
5
|
+
import { wrapperAt } from './canvas-dom.js';
|
|
6
|
+
/**
|
|
7
|
+
* The rendered `.rich-text` element inside a node's wrapper — the rich-text
|
|
8
|
+
* editing host (EC-159). Never crosses into a nested node's wrapper.
|
|
9
|
+
*/
|
|
10
|
+
function findRichTextHost(wrapper) {
|
|
11
|
+
const host = wrapper.querySelector('.rich-text');
|
|
12
|
+
return host && host.closest('[data-ec-node-id]') === wrapper ? host : null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Wire the inline-editing dblclick listener onto the iframe document (native —
|
|
16
|
+
* React's synthetic events don't cross frames). Returns the detach function;
|
|
17
|
+
* detaching mid-session cancels it (never dispatch from cleanup).
|
|
18
|
+
*/
|
|
19
|
+
export function attachCanvasInlineEditingListeners(doc, deps) {
|
|
20
|
+
const { dispatch } = deps;
|
|
21
|
+
// ---- plain text (EC-158) ------------------------------------------------
|
|
22
|
+
const beginText = (wrapper, host, session) => {
|
|
23
|
+
let done = false;
|
|
24
|
+
const finish = (mode) => {
|
|
25
|
+
if (done)
|
|
26
|
+
return;
|
|
27
|
+
done = true;
|
|
28
|
+
deps.setEditing(null);
|
|
29
|
+
const text = host.textContent ?? '';
|
|
30
|
+
host.removeEventListener('keydown', onKeyDown);
|
|
31
|
+
host.removeEventListener('blur', onBlur);
|
|
32
|
+
host.removeAttribute('contenteditable');
|
|
33
|
+
host.removeAttribute('data-ec-edit');
|
|
34
|
+
wrapper.removeAttribute('data-ec-editing');
|
|
35
|
+
if (mode === 'commit') {
|
|
36
|
+
const outcome = session.commit(text);
|
|
37
|
+
if (outcome) {
|
|
38
|
+
dispatch({
|
|
39
|
+
type: 'setProp',
|
|
40
|
+
id: outcome.nodeId,
|
|
41
|
+
name: outcome.propName,
|
|
42
|
+
value: staticProp(outcome.value),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const outcome = session.cancel();
|
|
48
|
+
if (outcome)
|
|
49
|
+
host.textContent = outcome.value;
|
|
50
|
+
}
|
|
51
|
+
doc.getSelection()?.removeAllRanges();
|
|
52
|
+
};
|
|
53
|
+
const onKeyDown = (e) => {
|
|
54
|
+
if (e.key === 'Enter') {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
finish('commit');
|
|
58
|
+
}
|
|
59
|
+
else if (e.key === 'Escape') {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
e.stopPropagation();
|
|
62
|
+
finish('cancel');
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const onBlur = () => finish('commit');
|
|
66
|
+
// `plaintext-only` keeps paste/typing plain; fall back where unsupported.
|
|
67
|
+
host.setAttribute('contenteditable', 'plaintext-only');
|
|
68
|
+
if (!host.isContentEditable)
|
|
69
|
+
host.setAttribute('contenteditable', 'true');
|
|
70
|
+
host.setAttribute('data-ec-edit', 'text');
|
|
71
|
+
wrapper.setAttribute('data-ec-editing', '');
|
|
72
|
+
host.addEventListener('keydown', onKeyDown);
|
|
73
|
+
host.addEventListener('blur', onBlur);
|
|
74
|
+
deps.setEditing({ nodeId: session.nodeId, host, finish });
|
|
75
|
+
host.focus({ preventScroll: true });
|
|
76
|
+
// Keep the gesture's native caret/word selection when it landed inside
|
|
77
|
+
// the host; otherwise select the whole value.
|
|
78
|
+
const sel = doc.getSelection();
|
|
79
|
+
if (!sel || sel.rangeCount === 0 || !host.contains(sel.anchorNode)) {
|
|
80
|
+
const range = doc.createRange();
|
|
81
|
+
range.selectNodeContents(host);
|
|
82
|
+
sel?.removeAllRanges();
|
|
83
|
+
sel?.addRange(range);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
// ---- rich text (EC-159) -------------------------------------------------
|
|
87
|
+
const beginRichText = (wrapper, nodeId, target) => {
|
|
88
|
+
const host = findRichTextHost(wrapper);
|
|
89
|
+
if (!host)
|
|
90
|
+
return;
|
|
91
|
+
// Pair the storage tree against the rendered DOM and stamp every
|
|
92
|
+
// non-invertible node (custom blocks, unknown nodes, images, embeds)
|
|
93
|
+
// with its exact JSON: they stay rendered *inside* the live editor as
|
|
94
|
+
// non-editable atoms and survive the session byte-exact. A pairing
|
|
95
|
+
// mismatch means the DOM diverged from the deterministic render —
|
|
96
|
+
// refuse to start rather than risk lossy parsing ("polished or absent").
|
|
97
|
+
const pairs = collectPreservedPairs(target.value.doc, host);
|
|
98
|
+
if (!pairs)
|
|
99
|
+
return;
|
|
100
|
+
const session = createInlineRichTextSession({
|
|
101
|
+
nodeId,
|
|
102
|
+
propName: target.propName,
|
|
103
|
+
original: target.value,
|
|
104
|
+
});
|
|
105
|
+
for (const pair of pairs) {
|
|
106
|
+
const el = pair.element;
|
|
107
|
+
el.setAttribute(PRESERVE_NODE_ATTR, JSON.stringify(pair.node));
|
|
108
|
+
el.setAttribute('contenteditable', 'false');
|
|
109
|
+
}
|
|
110
|
+
let done = false;
|
|
111
|
+
const finish = (mode) => {
|
|
112
|
+
if (done)
|
|
113
|
+
return;
|
|
114
|
+
done = true;
|
|
115
|
+
deps.setEditing(null);
|
|
116
|
+
deps.setRichEditHost(null);
|
|
117
|
+
// Parse the edited DOM, detach the session, then bump the node's
|
|
118
|
+
// remount epoch: React discards the editor-mutated DOM and renders
|
|
119
|
+
// fresh from the graph — committed content or, on cancel, the
|
|
120
|
+
// untouched original. Never patch a fiber tree against DOM the
|
|
121
|
+
// editing commands rewrote.
|
|
122
|
+
const parsed = mode === 'commit' ? richTextDocFromDom(host) : null;
|
|
123
|
+
host.removeEventListener('keydown', onKeyDown);
|
|
124
|
+
host.removeEventListener('blur', onBlur);
|
|
125
|
+
host.removeAttribute('contenteditable');
|
|
126
|
+
host.removeAttribute('data-ec-edit');
|
|
127
|
+
wrapper.removeAttribute('data-ec-editing');
|
|
128
|
+
deps.bumpRichEditEpoch(nodeId);
|
|
129
|
+
if (parsed) {
|
|
130
|
+
// An unchanged document commits nothing: the stored value is never
|
|
131
|
+
// rewritten, so untouched content round-trips byte-identical.
|
|
132
|
+
const outcome = session.commit(parsed.doc);
|
|
133
|
+
if (outcome) {
|
|
134
|
+
dispatch({
|
|
135
|
+
type: 'setProp',
|
|
136
|
+
id: outcome.nodeId,
|
|
137
|
+
name: outcome.propName,
|
|
138
|
+
value: staticProp(outcome.value),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
session.cancel();
|
|
144
|
+
}
|
|
145
|
+
doc.getSelection()?.removeAllRanges();
|
|
146
|
+
};
|
|
147
|
+
const onKeyDown = (e) => {
|
|
148
|
+
if (e.key === 'Escape') {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
e.stopPropagation();
|
|
151
|
+
finish('cancel');
|
|
152
|
+
}
|
|
153
|
+
else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
e.stopPropagation();
|
|
156
|
+
finish('commit');
|
|
157
|
+
}
|
|
158
|
+
// Plain Enter falls through: native paragraph insertion (multiline).
|
|
159
|
+
};
|
|
160
|
+
const onBlur = (e) => {
|
|
161
|
+
// Interacting with the floating toolbar is part of the session.
|
|
162
|
+
const next = e.relatedTarget;
|
|
163
|
+
if (next?.closest?.('[data-ec-rt-toolbar]'))
|
|
164
|
+
return;
|
|
165
|
+
finish('commit');
|
|
166
|
+
};
|
|
167
|
+
host.setAttribute('contenteditable', 'true');
|
|
168
|
+
host.setAttribute('data-ec-edit', 'rich');
|
|
169
|
+
wrapper.setAttribute('data-ec-editing', '');
|
|
170
|
+
host.addEventListener('keydown', onKeyDown);
|
|
171
|
+
host.addEventListener('blur', onBlur);
|
|
172
|
+
deps.setEditing({ nodeId, host, finish });
|
|
173
|
+
deps.setRichEditHost(host);
|
|
174
|
+
host.focus({ preventScroll: true });
|
|
175
|
+
};
|
|
176
|
+
const onDblClick = (e) => {
|
|
177
|
+
const wrapper = wrapperAt(e.target);
|
|
178
|
+
if (!wrapper)
|
|
179
|
+
return;
|
|
180
|
+
const nodeId = wrapper.getAttribute('data-ec-node-id');
|
|
181
|
+
const editing = deps.getEditing();
|
|
182
|
+
if (editing) {
|
|
183
|
+
// Double-click inside the live editor is native word selection.
|
|
184
|
+
if (editing.nodeId === nodeId)
|
|
185
|
+
return;
|
|
186
|
+
editing.finish('commit');
|
|
187
|
+
}
|
|
188
|
+
const node = getNode(deps.currentRoot(), nodeId);
|
|
189
|
+
if (!node)
|
|
190
|
+
return;
|
|
191
|
+
const manifest = deps.getManifest(node.componentId);
|
|
192
|
+
const target = resolveInlineEditableProp(manifest, node);
|
|
193
|
+
if (target) {
|
|
194
|
+
const host = findInlineTextHost(wrapper, target.value);
|
|
195
|
+
if (!host || typeof host.focus !== 'function')
|
|
196
|
+
return;
|
|
197
|
+
dispatch({ type: 'select', id: nodeId });
|
|
198
|
+
beginText(wrapper, host, createInlineEditSession({ nodeId, propName: target.propName, original: target.value }));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const richTarget = resolveInlineRichTextProp(manifest, node);
|
|
202
|
+
if (!richTarget)
|
|
203
|
+
return;
|
|
204
|
+
dispatch({ type: 'select', id: nodeId });
|
|
205
|
+
beginRichText(wrapper, nodeId, richTarget);
|
|
206
|
+
};
|
|
207
|
+
doc.addEventListener('dblclick', onDblClick, true);
|
|
208
|
+
return () => {
|
|
209
|
+
doc.removeEventListener('dblclick', onDblClick, true);
|
|
210
|
+
// Teardown mid-session (doc swap/unmount): cancel — never dispatch from cleanup.
|
|
211
|
+
deps.getEditing()?.finish('cancel');
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The React face of the canvas inline editing (EC-158/EC-159): owns the
|
|
216
|
+
* session ref and the render state (`richEditHost`, `richEditEpochs`), wires
|
|
217
|
+
* the dblclick listener when the iframe document exists, and cancels a live
|
|
218
|
+
* session on teardown.
|
|
219
|
+
*/
|
|
220
|
+
export function useCanvasInlineEditing(doc, deps) {
|
|
221
|
+
const editingRef = useRef(null);
|
|
222
|
+
const [richEditHost, setRichEditHost] = useState(null);
|
|
223
|
+
const [richEditEpochs, setRichEditEpochs] = useState({});
|
|
224
|
+
const { presentRef, dispatch, studio } = deps;
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (!doc)
|
|
227
|
+
return;
|
|
228
|
+
return attachCanvasInlineEditingListeners(doc, {
|
|
229
|
+
currentRoot: () => presentRef.current,
|
|
230
|
+
getManifest: (id) => studio.registry.getManifest(id),
|
|
231
|
+
dispatch,
|
|
232
|
+
getEditing: () => editingRef.current,
|
|
233
|
+
setEditing: (edit) => {
|
|
234
|
+
editingRef.current = edit;
|
|
235
|
+
},
|
|
236
|
+
setRichEditHost,
|
|
237
|
+
bumpRichEditEpoch: (nodeId) => setRichEditEpochs((prev) => ({ ...prev, [nodeId]: (prev[nodeId] ?? 0) + 1 })),
|
|
238
|
+
});
|
|
239
|
+
}, [doc, dispatch, studio, presentRef]);
|
|
240
|
+
return { editingRef, richEditHost, richEditEpochs };
|
|
241
|
+
}
|
|
242
|
+
//# sourceMappingURL=canvas-inline-editing.js.map
|