@mosetta/editor 0.0.0-stage → 0.0.28
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/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/mosetta.json +6 -0
- package/package.json +35 -3
- package/prefabs/workbench/prefab.json +11 -0
- package/prefabs/workbench/versions/1.json +120 -0
- package/prefabs/workbench/versions/2.json +13 -0
- package/prefabs/workbench/versions/3.json +19 -0
- package/prefabs/workbench/versions/4.json +29 -0
- package/prefabs/workbench/versions/5.json +29 -0
- package/tiles/canvas/canvas.component.tsx +2038 -0
- package/tiles/canvas/canvas.css +973 -0
- package/tiles/canvas/canvasTools.ts +66 -0
- package/tiles/canvas/chromeNodes.ts +35 -0
- package/tiles/canvas/ghosts.ts +33 -0
- package/tiles/canvas/insertPopover.tsx +56 -0
- package/tiles/canvas/popoverPlacement.ts +104 -0
- package/tiles/canvas/popoverSize.ts +87 -0
- package/tiles/canvas/providerChrome.tsx +191 -0
- package/tiles/canvas/scrollWatch.ts +43 -0
- package/tiles/canvas/settingsPopover.tsx +257 -0
- package/tiles/canvas/settingsTabs.ts +78 -0
- package/tiles/canvas/toolPanel.tsx +86 -0
- package/tiles/content/autoBind.ts +61 -0
- package/tiles/content/blockPage.css +390 -0
- package/tiles/content/blockPage.tsx +379 -0
- package/tiles/content/blockPicker.css +215 -0
- package/tiles/content/blockPicker.tsx +501 -0
- package/tiles/content/blockUpgrades.ts +145 -0
- package/tiles/content/blockVariants.ts +185 -0
- package/tiles/content/blockVocabulary.ts +77 -0
- package/tiles/content/catalogVersions.ts +106 -0
- package/tiles/content/componentPreview.css +82 -0
- package/tiles/content/componentPreview.tsx +249 -0
- package/tiles/content/content.server.ts +593 -0
- package/tiles/content/contentCommon.ts +133 -0
- package/tiles/content/disconnected.ts +56 -0
- package/tiles/content/distributions.css +256 -0
- package/tiles/content/distributions.tsx +271 -0
- package/tiles/content/editingAutosave.ts +125 -0
- package/tiles/content/editingClipboard.ts +185 -0
- package/tiles/content/editingConflicts.ts +223 -0
- package/tiles/content/editingHotkeys.ts +59 -0
- package/tiles/content/editingNotices.ts +105 -0
- package/tiles/content/formRoot.ts +18 -0
- package/tiles/content/fuzzySearch.ts +121 -0
- package/tiles/content/mediaUpload.ts +55 -0
- package/tiles/content/moduleSiblings.ts +9 -0
- package/tiles/content/mountedTree.ts +136 -0
- package/tiles/content/nodeSettings.css +483 -0
- package/tiles/content/nodeSettings.tsx +963 -0
- package/tiles/content/pasteText.ts +131 -0
- package/tiles/content/prefabFields.tsx +158 -0
- package/tiles/content/prefabParts.ts +105 -0
- package/tiles/content/previewEnvironment.ts +46 -0
- package/tiles/content/projectConfig.ts +46 -0
- package/tiles/content/projectPrefabs.ts +57 -0
- package/tiles/content/publishModal.css +109 -0
- package/tiles/content/publishModal.tsx +92 -0
- package/tiles/content/refsEditing.ts +412 -0
- package/tiles/content/scaledStage.ts +63 -0
- package/tiles/content/searchField.css +32 -0
- package/tiles/content/searchField.tsx +32 -0
- package/tiles/content/settingsDraft.ts +178 -0
- package/tiles/content/settingsIssues.tsx +124 -0
- package/tiles/content/themeScope.ts +72 -0
- package/tiles/content/themeVariables.ts +47 -0
- package/tiles/content/tooltipPlacement.ts +17 -0
- package/tiles/content/tooltips.tsx +131 -0
- package/tiles/content/treeMounter.ts +33 -0
- package/tiles/content/updatesModal.css +124 -0
- package/tiles/content/updatesModal.tsx +110 -0
- package/tiles/content/useCtrlHeld.ts +24 -0
- package/tiles/content/useEditingSession.ts +115 -0
- package/tiles/content/useMountedTree.ts +47 -0
- package/tiles/content/workbenchScale.css +27 -0
- package/tiles/hostFallback/hostFallback.component.tsx +32 -0
- package/tiles/hostFallback/hostFallback.css +12 -0
- package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
- package/tiles/page/page.component.tsx +7 -0
- package/tiles/page/page.script.tsx +5 -0
- package/tiles/pageModules/pageModules.component.tsx +740 -0
- package/tiles/pageModules/pageModules.css +383 -0
- package/tiles/pageModules/pageModules.icon.svg +1 -0
- package/tiles/pageModules/servedPage.ts +465 -0
- package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
- package/tiles/pagesPanel/pagesPanel.css +713 -0
- package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
- package/tiles/pagesPanel/pagesSearch.ts +194 -0
- package/tiles/pagesPanel/routeTree.ts +98 -0
- package/tiles/previewBridge/landing.ts +66 -0
- package/tiles/previewBridge/previewAgent.ts +457 -0
- package/tiles/previewBridge/previewBridge.component.tsx +137 -0
- package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
- package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
- package/tiles/previewBridge/standIn.ts +22 -0
- package/tiles/treeView/treeView.component.tsx +514 -0
- package/tiles/treeView/treeView.css +303 -0
- package/tiles/treeView/treeView.editor.tsx +12 -0
- package/tiles/workbench/assistant.css +401 -0
- package/tiles/workbench/assistant.tsx +516 -0
- package/tiles/workbench/assistantChat.ts +165 -0
- package/tiles/workbench/dictation.ts +354 -0
- package/tiles/workbench/projectInfo.ts +72 -0
- package/tiles/workbench/recent.ts +67 -0
- package/tiles/workbench/workbench.component.tsx +476 -0
- package/tiles/workbench/workbench.css +376 -0
- package/tiles/workbench/workbenchAddress.ts +202 -0
- package/tiles/workbench/workspace.ts +775 -0
- package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
- package/tiles/workbenchEditor/workbenchEditor.css +370 -0
- package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
- package/tiles/workbenchHome/workbenchHome.css +271 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// The editor's AGENT inside the preview realm. Draws nothing (the overlay lives in the editor's
|
|
2
|
+
// own document, where it cannot collide with the application's UI) — it measures, hit-tests, and
|
|
3
|
+
// reports over the channel described in previewBridgeCommon.
|
|
4
|
+
//
|
|
5
|
+
// Why an agent is needed at all: geometry and hit-testing only exist inside the iframe. The editor
|
|
6
|
+
// knows the page TREE; the iframe knows where that tree landed on screen.
|
|
7
|
+
//
|
|
8
|
+
// While armed it also SWALLOWS the application's pointer handling: inside the editor a click on a
|
|
9
|
+
// block means "select this block", not "follow the link". Suppression happens in the capture phase
|
|
10
|
+
// at the document root, above react's root container, so the application's handlers never run —
|
|
11
|
+
// nothing to undo afterwards.
|
|
12
|
+
import { NODE_ID_ATTRIBUTE, previewSlots, SLOT_ATTRIBUTE, slotKey } from '@mosetta/react';
|
|
13
|
+
import type { IBlueprintNode, IBlueprint } from '@mosetta/core';
|
|
14
|
+
import { expandUnitsFrom, isOutletReference, unitOwnerOf } from '@mosetta/content/units';
|
|
15
|
+
import type { IBlueprintNodeDoc } from '@mosetta/content/units';
|
|
16
|
+
import {
|
|
17
|
+
ILandingPlace,
|
|
18
|
+
INodeBox,
|
|
19
|
+
isPreviewPortOffer,
|
|
20
|
+
ISlotBox,
|
|
21
|
+
PREVIEW_HELLO,
|
|
22
|
+
PREVIEW_PROTOCOL_VERSION,
|
|
23
|
+
STAND_IN_ATTRIBUTE,
|
|
24
|
+
TPreviewCommand,
|
|
25
|
+
TPreviewEvent,
|
|
26
|
+
} from './previewBridgeCommon';
|
|
27
|
+
|
|
28
|
+
const NODE_SELECTOR = `[${NODE_ID_ATTRIBUTE}]`;
|
|
29
|
+
|
|
30
|
+
// the greeting repeats until the embedder answers: the agent may well mount before the editor
|
|
31
|
+
// side is listening (a reloaded iframe races the overlay's own effects)
|
|
32
|
+
const HELLO_INTERVAL_MS = 300;
|
|
33
|
+
const HELLO_ATTEMPTS = 20;
|
|
34
|
+
|
|
35
|
+
// events the agent eats while armed. Pointer and touch events stay untouched on purpose —
|
|
36
|
+
// swallowing them would take native scrolling and hovering with them.
|
|
37
|
+
const SUPPRESSED_EVENTS = ['click', 'auxclick', 'dblclick', 'mousedown', 'mouseup', 'submit'] as const;
|
|
38
|
+
|
|
39
|
+
/** how long after the last scroll event the page is considered still, and worth measuring again */
|
|
40
|
+
const SCROLL_SETTLE_MS = 150;
|
|
41
|
+
|
|
42
|
+
// a block of an expanded PREFAB carries `<prefabNode>/<inner>` as its id: to the editor it IS the
|
|
43
|
+
// prefab node — hovering or picking the prefab's blocks addresses the prefab, never its insides
|
|
44
|
+
const ownerOf = (id: string): string => unitOwnerOf(id) ?? id;
|
|
45
|
+
|
|
46
|
+
function nodeIdOf(target: EventTarget | null): string | null {
|
|
47
|
+
const host = target instanceof Element ? target.closest(NODE_SELECTOR) : null;
|
|
48
|
+
const id = host?.getAttribute(NODE_ID_ATTRIBUTE) ?? null;
|
|
49
|
+
return id === null ? null : ownerOf(id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* …and WHICH of the prefab's own blocks it was — its expanded id, for the editor to show it
|
|
54
|
+
* (read-only); null over the page's own block. A prefab's outlet is the page's place, not a block
|
|
55
|
+
* of the prefab: the search goes on to the block around it.
|
|
56
|
+
*/
|
|
57
|
+
function partOf(target: EventTarget | null): string | null {
|
|
58
|
+
let host = target instanceof Element ? target.closest(NODE_SELECTOR) : null;
|
|
59
|
+
while (host) {
|
|
60
|
+
const id = host.getAttribute(NODE_ID_ATTRIBUTE) ?? '';
|
|
61
|
+
if (unitOwnerOf(id) === null) return null;
|
|
62
|
+
if (!isOutletReference(host.className)) return id;
|
|
63
|
+
host = host.parentElement?.closest(NODE_SELECTOR) ?? null;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function depthOf(element: Element): number {
|
|
69
|
+
let depth = 0;
|
|
70
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
71
|
+
if (parent.hasAttribute(NODE_ID_ATTRIBUTE)) depth++;
|
|
72
|
+
}
|
|
73
|
+
return depth;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Where a node's contents landed. The scope host is `display: contents`, so it generates no box of
|
|
78
|
+
* its own — the union of what it CONTAINS is measured instead; an element that does produce a box
|
|
79
|
+
* still answers directly.
|
|
80
|
+
*/
|
|
81
|
+
function boxOf(element: Element, range: Range): INodeBox | null {
|
|
82
|
+
const id = element.getAttribute(NODE_ID_ATTRIBUTE);
|
|
83
|
+
if (!id) return null;
|
|
84
|
+
range.selectNodeContents(element);
|
|
85
|
+
let rect = range.getBoundingClientRect();
|
|
86
|
+
if (!rect.width && !rect.height) rect = element.getBoundingClientRect();
|
|
87
|
+
if (!rect.width && !rect.height) return null; // renders nothing — nothing to outline
|
|
88
|
+
return {
|
|
89
|
+
id,
|
|
90
|
+
// a stand-in (standIn.ts) is the agent's own module, but to the editor it is the block it
|
|
91
|
+
// stands for — the outline names that one
|
|
92
|
+
reference: element.firstElementChild?.getAttribute(STAND_IN_ATTRIBUTE) ?? element.className.trim(),
|
|
93
|
+
depth: depthOf(element),
|
|
94
|
+
x: rect.x,
|
|
95
|
+
y: rect.y,
|
|
96
|
+
width: rect.width,
|
|
97
|
+
height: rect.height,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The tree to render after a live update. The editor's document knows nothing of the agent — the
|
|
103
|
+
* node was attached on the way out, by the render runtime — so an incoming tree would quietly
|
|
104
|
+
* unmount the very module that received it. What the realm already holds wins twice here: the
|
|
105
|
+
* agent's own node is carried over, and so is everything the boot envelope added on top of the
|
|
106
|
+
* document (the catalog slice, the host base, the ssr stamp), which no editor ever sees.
|
|
107
|
+
*/
|
|
108
|
+
export function withAgentNode(current: IBlueprint, next: IBlueprint, agentReference: string): IBlueprint {
|
|
109
|
+
const arriving = new Set((next.children ?? []).map((child) => child.id));
|
|
110
|
+
const mine = (current.children ?? []).filter(
|
|
111
|
+
(child) => child.reference === agentReference && !arriving.has(child.id),
|
|
112
|
+
);
|
|
113
|
+
const children: IBlueprintNode[] = mine.length ? [...(next.children ?? []), ...mine] : (next.children ?? []);
|
|
114
|
+
// prefabs the realm already shows expand from their rendered blocks — the editor's document
|
|
115
|
+
// names them raw, and a raw prefab node has no module to render
|
|
116
|
+
const merged = { ...current, ...next, children } as unknown as IBlueprintNodeDoc;
|
|
117
|
+
return expandUnitsFrom(current, merged) as unknown as IBlueprint;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface IPreviewAgentOptions {
|
|
121
|
+
/** what a live tree update does in this realm — in practice the page manager's setCurrentPage */
|
|
122
|
+
readonly applyPage?: (page: IBlueprint) => void;
|
|
123
|
+
/** where a block is about to go, or nowhere — the landing space follows (withLanding) */
|
|
124
|
+
readonly applyLanding?: (at: ILandingPlace | null) => void;
|
|
125
|
+
/** what new provider views do in this realm — in practice the application's views store */
|
|
126
|
+
readonly applyViews?: (views: Readonly<Record<string, unknown>>) => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class PreviewAgent {
|
|
130
|
+
private port: MessagePort | null = null;
|
|
131
|
+
|
|
132
|
+
private armed = true;
|
|
133
|
+
|
|
134
|
+
private helloTimer: ReturnType<typeof setInterval> | null = null;
|
|
135
|
+
|
|
136
|
+
private frame: number | null = null;
|
|
137
|
+
|
|
138
|
+
private hovered: string | null = null;
|
|
139
|
+
|
|
140
|
+
/** the prefab's own block under the pointer, when it is over one */
|
|
141
|
+
private hoveredPart: string | null = null;
|
|
142
|
+
|
|
143
|
+
private hoveredCtrl = false;
|
|
144
|
+
|
|
145
|
+
/** the pointer's latest position, sent at most once a frame */
|
|
146
|
+
private pointer: { x: number; y: number } | null = null;
|
|
147
|
+
|
|
148
|
+
private pointerFrame: number | null = null;
|
|
149
|
+
|
|
150
|
+
private readonly range = document.createRange();
|
|
151
|
+
|
|
152
|
+
private resizeObserver: ResizeObserver | null = null;
|
|
153
|
+
|
|
154
|
+
/** elements currently under the size observer — see watchSizes */
|
|
155
|
+
private readonly watched = new Set<Element>();
|
|
156
|
+
|
|
157
|
+
private settleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
158
|
+
|
|
159
|
+
private readonly mutationObserver = new MutationObserver(() => this.schedule());
|
|
160
|
+
|
|
161
|
+
private unwatchSlots: (() => void) | null = null;
|
|
162
|
+
|
|
163
|
+
constructor(private readonly options: IPreviewAgentOptions = {}) {}
|
|
164
|
+
|
|
165
|
+
start(): void {
|
|
166
|
+
// not embedded → nobody to talk to (a preview URL opened directly, for instance)
|
|
167
|
+
if (window.parent === window) return;
|
|
168
|
+
window.addEventListener('message', this.onWindowMessage);
|
|
169
|
+
let attempts = 0;
|
|
170
|
+
const greet = (): void => {
|
|
171
|
+
if (this.port || attempts++ >= HELLO_ATTEMPTS) return this.stopGreeting();
|
|
172
|
+
// the greeting carries no page data, so an unknown embedder learns nothing from it; the
|
|
173
|
+
// channel that follows is what actually opens the conversation
|
|
174
|
+
window.parent.postMessage({ mosetta: PREVIEW_HELLO, protocol: PREVIEW_PROTOCOL_VERSION }, '*');
|
|
175
|
+
};
|
|
176
|
+
greet();
|
|
177
|
+
this.helloTimer = setInterval(greet, HELLO_INTERVAL_MS);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
stop(): void {
|
|
181
|
+
this.stopGreeting();
|
|
182
|
+
window.removeEventListener('message', this.onWindowMessage);
|
|
183
|
+
this.unwatchSlots?.();
|
|
184
|
+
this.unwatchSlots = null;
|
|
185
|
+
this.detachDocument();
|
|
186
|
+
if (this.frame !== null) cancelAnimationFrame(this.frame);
|
|
187
|
+
if (this.pointerFrame !== null) cancelAnimationFrame(this.pointerFrame);
|
|
188
|
+
this.port?.close();
|
|
189
|
+
this.port = null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private stopGreeting(): void {
|
|
193
|
+
if (this.helloTimer !== null) clearInterval(this.helloTimer);
|
|
194
|
+
this.helloTimer = null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private readonly onWindowMessage = (event: MessageEvent): void => {
|
|
198
|
+
if (this.port || !isPreviewPortOffer(event.data)) return;
|
|
199
|
+
const port = event.ports[0];
|
|
200
|
+
if (!port) return;
|
|
201
|
+
this.port = port;
|
|
202
|
+
this.stopGreeting();
|
|
203
|
+
port.onmessage = (message: MessageEvent) => this.onCommand(message.data as TPreviewCommand);
|
|
204
|
+
port.start();
|
|
205
|
+
this.attachDocument();
|
|
206
|
+
// arming happens HERE and not a moment earlier: markers appearing during the first client
|
|
207
|
+
// render would disagree with the server's HTML and break hydration. The channel being open
|
|
208
|
+
// means the app is mounted.
|
|
209
|
+
this.unwatchSlots = previewSlots.subscribe(this.schedule);
|
|
210
|
+
previewSlots.arm();
|
|
211
|
+
this.schedule();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
private onCommand(command: TPreviewCommand): void {
|
|
215
|
+
if (command.t === 'armed') this.armed = command.armed;
|
|
216
|
+
else if (command.t === 'measure') this.schedule();
|
|
217
|
+
else if (command.t === 'views') {
|
|
218
|
+
this.options.applyViews?.(command.views);
|
|
219
|
+
this.schedule();
|
|
220
|
+
} else if (command.t === 'page') {
|
|
221
|
+
// the re-render lands after this turn, so the measurement worth reporting is the one the
|
|
222
|
+
// mutation observer takes then; this one keeps a layout-only change from going unnoticed
|
|
223
|
+
this.options.applyPage?.(command.page);
|
|
224
|
+
this.schedule();
|
|
225
|
+
} else if (command.t === 'landing') {
|
|
226
|
+
this.options.applyLanding?.(command.at);
|
|
227
|
+
this.schedule();
|
|
228
|
+
} else if (command.t === 'reveal') {
|
|
229
|
+
// scanned rather than selected: node ids come from a content document — any string at all,
|
|
230
|
+
// and a selector built out of one is a quoting accident waiting to happen
|
|
231
|
+
for (const element of document.querySelectorAll(NODE_SELECTOR)) {
|
|
232
|
+
if (element.getAttribute(NODE_ID_ATTRIBUTE) !== command.nodeId) continue;
|
|
233
|
+
element.firstElementChild?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private attachDocument(): void {
|
|
240
|
+
for (const type of SUPPRESSED_EVENTS) {
|
|
241
|
+
document.addEventListener(type, this.onSuppressed, true);
|
|
242
|
+
}
|
|
243
|
+
document.addEventListener('pointermove', this.onPointerMove, { capture: true, passive: true });
|
|
244
|
+
// on the ROOT element and without capture: leaving any nested element would otherwise read as
|
|
245
|
+
// leaving the preview altogether
|
|
246
|
+
document.documentElement.addEventListener('pointerleave', this.onPointerLeave, { passive: true });
|
|
247
|
+
// anything that can move a box: the viewport, scrolling of ANY container (capture — scroll
|
|
248
|
+
// does not bubble), layout changes, and late-arriving images and fonts
|
|
249
|
+
window.addEventListener('resize', this.schedule, { passive: true });
|
|
250
|
+
window.addEventListener('load', this.schedule, { passive: true });
|
|
251
|
+
document.addEventListener('scroll', this.onScroll, { capture: true, passive: true });
|
|
252
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
253
|
+
this.resizeObserver = new ResizeObserver(() => this.schedule());
|
|
254
|
+
this.resizeObserver.observe(document.documentElement);
|
|
255
|
+
}
|
|
256
|
+
this.mutationObserver.observe(document.documentElement, {
|
|
257
|
+
subtree: true, childList: true, attributes: true, characterData: true,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private detachDocument(): void {
|
|
262
|
+
for (const type of SUPPRESSED_EVENTS) {
|
|
263
|
+
document.removeEventListener(type, this.onSuppressed, true);
|
|
264
|
+
}
|
|
265
|
+
document.removeEventListener('pointermove', this.onPointerMove, true);
|
|
266
|
+
document.documentElement.removeEventListener('pointerleave', this.onPointerLeave);
|
|
267
|
+
window.removeEventListener('resize', this.schedule);
|
|
268
|
+
window.removeEventListener('load', this.schedule);
|
|
269
|
+
document.removeEventListener('scroll', this.onScroll, true);
|
|
270
|
+
if (this.settleTimer !== null) clearTimeout(this.settleTimer);
|
|
271
|
+
this.settleTimer = null;
|
|
272
|
+
this.resizeObserver?.disconnect();
|
|
273
|
+
this.resizeObserver = null;
|
|
274
|
+
this.watched.clear();
|
|
275
|
+
this.mutationObserver.disconnect();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private readonly onSuppressed = (event: Event): void => {
|
|
279
|
+
if (!this.armed) return;
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
event.stopPropagation();
|
|
282
|
+
if (event.type === 'click') {
|
|
283
|
+
const { ctrlKey, metaKey } = event as MouseEvent;
|
|
284
|
+
this.send({ t: 'pick', nodeId: nodeIdOf(event.target), part: partOf(event.target), ctrl: ctrlKey || metaKey });
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
private readonly onPointerMove = (event: Event): void => {
|
|
289
|
+
if (!this.armed) return;
|
|
290
|
+
const { clientX, clientY } = event as PointerEvent;
|
|
291
|
+
this.pointer = { x: clientX, y: clientY };
|
|
292
|
+
// the position goes out once a frame, however many moves arrive in it
|
|
293
|
+
this.pointerFrame ??= requestAnimationFrame(() => {
|
|
294
|
+
this.pointerFrame = null;
|
|
295
|
+
if (this.pointer) this.send({ t: 'pointer', ...this.pointer, scroll: { x: window.scrollX, y: window.scrollY } });
|
|
296
|
+
});
|
|
297
|
+
const nodeId = nodeIdOf(event.target);
|
|
298
|
+
const part = partOf(event.target);
|
|
299
|
+
const { ctrlKey, metaKey } = event as PointerEvent;
|
|
300
|
+
const ctrl = ctrlKey || metaKey;
|
|
301
|
+
// the modifier changing is worth a report too — the first move after Ctrl goes down re-sends
|
|
302
|
+
// the same node with the new colouring (a STATIONARY press is the editor realm's to notice:
|
|
303
|
+
// its key listeners cover that, since suppressed mousedowns keep focus on the editor side)
|
|
304
|
+
if (nodeId === this.hovered && part === this.hoveredPart && ctrl === this.hoveredCtrl) return;
|
|
305
|
+
this.hovered = nodeId;
|
|
306
|
+
this.hoveredPart = part;
|
|
307
|
+
this.hoveredCtrl = ctrl;
|
|
308
|
+
this.send({ t: 'hover', nodeId, part, ctrl });
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
private readonly onPointerLeave = (): void => {
|
|
312
|
+
if (this.hovered === null) return;
|
|
313
|
+
this.hovered = null;
|
|
314
|
+
this.hoveredPart = null;
|
|
315
|
+
this.hoveredCtrl = false;
|
|
316
|
+
this.send({ t: 'hover', nodeId: null });
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The page scrolled. Nothing about the layout changed — every block moved by exactly the same
|
|
321
|
+
* amount — so the editor is told the new POSITION and slides its overlay by the difference. A
|
|
322
|
+
* full re-measurement here is what made the outlines trail the page by a visible step.
|
|
323
|
+
*
|
|
324
|
+
* An inner scroller is a different matter: it moves its own contents and nothing else, so that
|
|
325
|
+
* one is measured as usual. And once the page settles, one honest measurement follows anyway —
|
|
326
|
+
* anything fixed or sticky did NOT move with the rest.
|
|
327
|
+
*/
|
|
328
|
+
private readonly onScroll = (event: Event): void => {
|
|
329
|
+
const target = event.target;
|
|
330
|
+
const isPage = target === document || target === document.scrollingElement || target === document.documentElement;
|
|
331
|
+
if (!isPage) {
|
|
332
|
+
this.schedule();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.send({ t: 'scroll', x: window.scrollX, y: window.scrollY });
|
|
336
|
+
if (this.settleTimer !== null) clearTimeout(this.settleTimer);
|
|
337
|
+
this.settleTimer = setTimeout(this.schedule, SCROLL_SETTLE_MS);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/** one measurement per frame however many signals arrive */
|
|
341
|
+
private readonly schedule = (): void => {
|
|
342
|
+
if (this.frame !== null) return;
|
|
343
|
+
this.frame = requestAnimationFrame(() => {
|
|
344
|
+
this.frame = null;
|
|
345
|
+
this.measure();
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
private measure(): void {
|
|
350
|
+
// A measurement taken while the frame has no viewport is not a measurement: every rect comes
|
|
351
|
+
// back zero, blocks vanish from the report and the editor's outlines go with them. That is
|
|
352
|
+
// what a hidden or mid-relayout iframe looks like — the editor re-asks once it is on screen.
|
|
353
|
+
if (!window.innerWidth || !window.innerHeight) return;
|
|
354
|
+
const boxes: INodeBox[] = [];
|
|
355
|
+
// a prefab's blocks report ONE box — their union — under the prefab node's id
|
|
356
|
+
const unions = new Map<string, INodeBox>();
|
|
357
|
+
// every prefab whose blocks stand in the DOM — a box or not — is one the realm can re-expand
|
|
358
|
+
const units = new Set<string>();
|
|
359
|
+
// …and each of those blocks on its own, for the editor to frame one picked to be seen
|
|
360
|
+
const parts: INodeBox[] = [];
|
|
361
|
+
for (const element of document.querySelectorAll(NODE_SELECTOR)) {
|
|
362
|
+
const shownOwner = unitOwnerOf(element.getAttribute(NODE_ID_ATTRIBUTE) ?? '');
|
|
363
|
+
if (shownOwner !== null) units.add(shownOwner);
|
|
364
|
+
const box = boxOf(element, this.range);
|
|
365
|
+
if (!box) continue;
|
|
366
|
+
const owner = unitOwnerOf(box.id);
|
|
367
|
+
if (owner === null) {
|
|
368
|
+
boxes.push(box);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (!isOutletReference(element.className)) parts.push(box);
|
|
372
|
+
const known = unions.get(owner);
|
|
373
|
+
if (!known) {
|
|
374
|
+
unions.set(owner, { ...box, id: owner, reference: 'prefab' });
|
|
375
|
+
} else {
|
|
376
|
+
const right = Math.max(known.x + known.width, box.x + box.width);
|
|
377
|
+
const bottom = Math.max(known.y + known.height, box.y + box.height);
|
|
378
|
+
const x = Math.min(known.x, box.x);
|
|
379
|
+
const y = Math.min(known.y, box.y);
|
|
380
|
+
unions.set(owner, { ...known, x, y, width: right - x, height: bottom - y, depth: Math.min(known.depth, box.depth) });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
boxes.push(...unions.values());
|
|
384
|
+
this.send({
|
|
385
|
+
t: 'geometry',
|
|
386
|
+
boxes,
|
|
387
|
+
units: [...units],
|
|
388
|
+
parts,
|
|
389
|
+
slots: this.measureSlots(),
|
|
390
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
391
|
+
scroll: { x: window.scrollX, y: window.scrollY },
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The insertion points, from the slots registered RIGHT NOW. A declaration whose marker is not in
|
|
397
|
+
* the document is dropped: it belongs to a render that has already been replaced, and an
|
|
398
|
+
* insertion point nobody can see is worse than none.
|
|
399
|
+
*/
|
|
400
|
+
private measureSlots(): ISlotBox[] {
|
|
401
|
+
const markers = new Map<string, Element>();
|
|
402
|
+
for (const element of document.querySelectorAll(`[${SLOT_ATTRIBUTE}]`)) {
|
|
403
|
+
markers.set(element.getAttribute(SLOT_ATTRIBUTE)!, element);
|
|
404
|
+
}
|
|
405
|
+
const slots: ISlotBox[] = [];
|
|
406
|
+
const containers = new Set<Element>();
|
|
407
|
+
for (const raw of previewSlots.list()) {
|
|
408
|
+
const marker = markers.get(slotKey(raw.ownerId, raw.slot));
|
|
409
|
+
if (!marker) continue;
|
|
410
|
+
// inside an expanded layout only the OUTLET is a place for the page's blocks — and that
|
|
411
|
+
// place belongs to the layout node (its children are what the outlet shows)
|
|
412
|
+
const owner = unitOwnerOf(raw.ownerId);
|
|
413
|
+
let declaration = raw;
|
|
414
|
+
if (owner !== null) {
|
|
415
|
+
const host = marker.closest(NODE_SELECTOR);
|
|
416
|
+
if (!host || !isOutletReference(host.className)) continue;
|
|
417
|
+
declaration = { ...raw, ownerId: owner };
|
|
418
|
+
}
|
|
419
|
+
if (marker.parentElement) containers.add(marker.parentElement);
|
|
420
|
+
const point = marker.getBoundingClientRect();
|
|
421
|
+
// the marker is sizeless — for an empty slot the area worth highlighting is what encloses it
|
|
422
|
+
const parent = marker.parentElement?.getBoundingClientRect();
|
|
423
|
+
slots.push({
|
|
424
|
+
...declaration,
|
|
425
|
+
x: point.x,
|
|
426
|
+
y: point.y,
|
|
427
|
+
area: parent ? { x: parent.x, y: parent.y, width: parent.width, height: parent.height } : null,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
this.watchSizes(containers);
|
|
431
|
+
return slots;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Keep an eye on the containers a slot lives in. A container grows the moment a block lands in
|
|
436
|
+
* it, and that growth is often pure layout — no mutation to notice, no scroll, nothing the other
|
|
437
|
+
* signals would catch. Only NEW elements are observed: re-observing on every measurement would
|
|
438
|
+
* make the observer fire on its own output, forever.
|
|
439
|
+
*/
|
|
440
|
+
private watchSizes(containers: Set<Element>): void {
|
|
441
|
+
if (!this.resizeObserver) return;
|
|
442
|
+
for (const element of containers) {
|
|
443
|
+
if (this.watched.has(element)) continue;
|
|
444
|
+
this.watched.add(element);
|
|
445
|
+
this.resizeObserver.observe(element);
|
|
446
|
+
}
|
|
447
|
+
for (const element of this.watched) {
|
|
448
|
+
if (containers.has(element)) continue; // gone from the page, or its slot no longer rendered
|
|
449
|
+
this.resizeObserver.unobserve(element);
|
|
450
|
+
this.watched.delete(element);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private send(event: TPreviewEvent): void {
|
|
455
|
+
this.port?.postMessage(event);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The editor's agent inside the preview realm, as a MODULE: the render runtime attaches this node
|
|
2
|
+
// under the page root when the canvas asks for an editable preview (see the agent itself in
|
|
3
|
+
// previewAgent.ts). It renders nothing — everything visible is drawn by the editor's overlay, in
|
|
4
|
+
// the editor's own document.
|
|
5
|
+
//
|
|
6
|
+
// A component facet, not a page-level script, on purpose: the agent then arrives, loads and dies
|
|
7
|
+
// through the very protocol it serves — one code path for every module of a page.
|
|
8
|
+
import { defineComponentSide, markInternalTile } from '@mosetta/react';
|
|
9
|
+
import { useComponent, usePreviewType, PALETTE_PREVIEW, useSettings, parseReference } from '@mosetta/react';
|
|
10
|
+
import { useDi } from '@mosetta/react';
|
|
11
|
+
import { PAGE_MANAGER_TOKEN, REF_VIEWS_TOKEN } from '@mosetta/runtime/client';
|
|
12
|
+
import { useEffect, useRef } from 'react';
|
|
13
|
+
import { PreviewAgent, withAgentNode } from './previewAgent';
|
|
14
|
+
import { withLanding } from './landing';
|
|
15
|
+
import { withStandIns } from './standIn';
|
|
16
|
+
import { previewBridgeIdOf, STAND_IN_ATTRIBUTE } from './previewBridgeCommon';
|
|
17
|
+
import type { ILandingPlace } from './previewBridgeCommon';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What a SERVICE module shows as its preview: not its representation (it has none), but what it
|
|
21
|
+
* is for — the rule for every distribution component without a face.
|
|
22
|
+
*/
|
|
23
|
+
function BridgeAbout() {
|
|
24
|
+
return (
|
|
25
|
+
<div style={{ padding: '4px 2px', maxWidth: '260px', color: '#71717a', fontSize: '13px', lineHeight: 1.5 }}>
|
|
26
|
+
Service module: the editor’s agent inside the live page preview. It connects the page realm
|
|
27
|
+
to the workbench overlay — geometry, selection, live tree updates — and draws nothing itself.
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function BridgeLive({ disconnected }: { disconnected: readonly string[] }) {
|
|
33
|
+
// the page MANAGER is how a new tree reaches a mounted application: the editor sends the tree
|
|
34
|
+
// it holds, this puts it where the root component reads from, and react does the rest
|
|
35
|
+
const pageManager = useDi(PAGE_MANAGER_TOKEN);
|
|
36
|
+
// the editor's views of reference providers land in the store every application is born with
|
|
37
|
+
const views = useDi(REF_VIEWS_TOKEN);
|
|
38
|
+
// this module's own full name — the node to carry over every incoming tree
|
|
39
|
+
const self = previewBridgeIdOf(useComponent().reference);
|
|
40
|
+
// where a block is about to go: every tree that arrives meanwhile gets the landing space too
|
|
41
|
+
const landing = useRef<ILandingPlace | null>(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const agent = new PreviewAgent({
|
|
45
|
+
// the editor's tree names a disconnected distribution's blocks as they are: here they stand
|
|
46
|
+
// in, as in the server's first render (standIn.ts)
|
|
47
|
+
applyPage: (page) => pageManager.setCurrentPage(
|
|
48
|
+
withLanding(withStandIns(withAgentNode(pageManager.getCurrentPage(), page, self), disconnected, self), landing.current, self),
|
|
49
|
+
),
|
|
50
|
+
applyLanding: (at) => {
|
|
51
|
+
landing.current = at;
|
|
52
|
+
pageManager.setCurrentPage(withLanding(pageManager.getCurrentPage(), at, self));
|
|
53
|
+
},
|
|
54
|
+
applyViews: (next) => views.set(next),
|
|
55
|
+
});
|
|
56
|
+
agent.start();
|
|
57
|
+
return () => agent.stop();
|
|
58
|
+
}, [pageManager, views, self, disconnected]);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A STAND-IN for a block whose distribution the project disconnected (standIn.ts): the site shows
|
|
64
|
+
* nothing there, the editor shows this — the block's name and why it is not on the site. Which
|
|
65
|
+
* distribution it was, by its title, the editor's settings say (the realm knows no titles).
|
|
66
|
+
*/
|
|
67
|
+
function StandIn({ reference }: { reference: string }) {
|
|
68
|
+
let name = reference;
|
|
69
|
+
try {
|
|
70
|
+
({ name } = parseReference(reference));
|
|
71
|
+
} catch {
|
|
72
|
+
// a reference that is no module's (a prefab's): shown as it is
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
<div {...{ [STAND_IN_ATTRIBUTE]: reference }}
|
|
76
|
+
style={{
|
|
77
|
+
display: 'flex',
|
|
78
|
+
flexDirection: 'column',
|
|
79
|
+
gap: '4px',
|
|
80
|
+
boxSizing: 'border-box',
|
|
81
|
+
minHeight: '64px',
|
|
82
|
+
padding: '12px 16px',
|
|
83
|
+
border: '1px dashed #a1a1aa',
|
|
84
|
+
borderRadius: '6px',
|
|
85
|
+
background: 'repeating-linear-gradient(135deg, #fafafa 0 8px, #f4f4f5 8px 16px)',
|
|
86
|
+
color: '#52525b',
|
|
87
|
+
font: '13px/1.45 ui-sans-serif, system-ui, sans-serif',
|
|
88
|
+
justifyContent: 'center',
|
|
89
|
+
}}>
|
|
90
|
+
<strong style={{ color: '#27272a', fontWeight: 600 }}>{name}</strong>
|
|
91
|
+
<span>Its tile palette is disconnected from this project — the site does not show this block.</span>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The LANDING SPACE: a little emptiness where a block is about to go, so the editor can frame the
|
|
98
|
+
* very spot (the overlay draws on it, this draws nothing). It does not know the layout around it,
|
|
99
|
+
* and does not need to: a block in flow takes the whole width, a flex row gives it its least width
|
|
100
|
+
* and stretches it to the row's height, a column or a grid stretches it across.
|
|
101
|
+
*/
|
|
102
|
+
function LandingSpace() {
|
|
103
|
+
return (
|
|
104
|
+
<div data-mosetta-landing
|
|
105
|
+
aria-hidden="true"
|
|
106
|
+
style={{
|
|
107
|
+
display: 'block',
|
|
108
|
+
boxSizing: 'border-box',
|
|
109
|
+
minWidth: '96px',
|
|
110
|
+
minHeight: '48px',
|
|
111
|
+
flex: '0 0 auto',
|
|
112
|
+
alignSelf: 'stretch',
|
|
113
|
+
justifySelf: 'stretch',
|
|
114
|
+
pointerEvents: 'none',
|
|
115
|
+
}} />
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const NONE: readonly string[] = [];
|
|
120
|
+
|
|
121
|
+
// By the preview's TYPE, never the flag: a FULL-APP preview (the canvas realm) is exactly where
|
|
122
|
+
// this module WORKS — branching on useIsPreviewMode() would turn the agent into a description
|
|
123
|
+
// card at its own workplace. Only a palette specimen means there is no realm to serve.
|
|
124
|
+
function BridgeComponent() {
|
|
125
|
+
const { landing, standIn, disconnected } = useSettings<{ landing?: boolean; standIn?: string; disconnected?: readonly string[] }>();
|
|
126
|
+
const type = usePreviewType();
|
|
127
|
+
if (landing) return <LandingSpace />;
|
|
128
|
+
if (standIn) return <StandIn reference={standIn} />;
|
|
129
|
+
return type === PALETTE_PREVIEW ? <BridgeAbout /> : <BridgeLive disconnected={disconnected ?? NONE} />;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export default markInternalTile(defineComponentSide({
|
|
133
|
+
Component: BridgeComponent,
|
|
134
|
+
defaultSettings() {
|
|
135
|
+
return {};
|
|
136
|
+
},
|
|
137
|
+
}));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineEditorSide } from '@mosetta/react';
|
|
2
|
+
|
|
3
|
+
export default defineEditorSide({
|
|
4
|
+
// a service module previews as a card that says what it is FOR (the component branches on the
|
|
5
|
+
// host's preview contract) — its working self renders nothing at all
|
|
6
|
+
preview: 'self',
|
|
7
|
+
Component() {
|
|
8
|
+
return <div data-no-settings>This block has no settings.</div>;
|
|
9
|
+
},
|
|
10
|
+
});
|