@jxsuite/studio 0.33.0 → 0.34.0
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/iframe-entry.js +6230 -0
- package/dist/iframe-entry.js.map +35 -0
- package/dist/studio.js +10754 -11060
- package/dist/studio.js.map +83 -72
- package/package.json +7 -7
- package/src/browse/browse.ts +11 -4
- package/src/canvas/canvas-helpers.ts +2 -56
- package/src/canvas/canvas-live-render.ts +102 -435
- package/src/canvas/canvas-origin.ts +66 -0
- package/src/canvas/canvas-patcher.ts +63 -403
- package/src/canvas/canvas-render.ts +70 -212
- package/src/canvas/canvas-utils.ts +37 -65
- package/src/canvas/iframe-channel.ts +154 -0
- package/src/canvas/iframe-drop.ts +484 -0
- package/src/canvas/iframe-entry.ts +600 -0
- package/src/canvas/iframe-host.ts +1373 -0
- package/src/canvas/iframe-inline-edit.ts +367 -0
- package/src/canvas/iframe-insert.ts +164 -0
- package/src/canvas/iframe-interaction.ts +176 -0
- package/src/canvas/iframe-keys.ts +85 -0
- package/src/canvas/iframe-overlay.ts +218 -0
- package/src/canvas/iframe-patch.ts +363 -0
- package/src/canvas/iframe-protocol.ts +361 -0
- package/src/canvas/iframe-render.ts +458 -0
- package/src/canvas/iframe-slash.ts +114 -0
- package/src/canvas/iframe-subtree.ts +113 -0
- package/src/canvas/path-mapping.ts +86 -0
- package/src/canvas/serialize-scope.ts +65 -0
- package/src/editor/canvas-context-menu.ts +40 -0
- package/src/editor/canvas-slash-bridge.ts +21 -0
- package/src/editor/context-menu.ts +2 -1
- package/src/editor/inline-edit-apply.ts +183 -0
- package/src/editor/inline-edit.ts +99 -21
- package/src/editor/inline-link.ts +89 -0
- package/src/editor/insert-zone-action.ts +35 -0
- package/src/editor/merge-tags.ts +26 -2
- package/src/editor/repeater-scope.ts +144 -0
- package/src/editor/shortcuts.ts +14 -28
- package/src/editor/slash-menu.ts +73 -42
- package/src/files/files.ts +2 -1
- package/src/page-params.ts +383 -0
- package/src/panels/ai-panel.ts +5 -7
- package/src/panels/block-action-bar.ts +296 -138
- package/src/panels/canvas-dnd-bridge.ts +397 -0
- package/src/panels/component-preview.ts +56 -0
- package/src/panels/dnd.ts +41 -17
- package/src/panels/drag-ghost.ts +62 -0
- package/src/panels/editors.ts +1 -1
- package/src/panels/overlays.ts +10 -125
- package/src/panels/properties-panel.ts +210 -0
- package/src/panels/right-panel.ts +0 -2
- package/src/panels/signals-panel.ts +136 -22
- package/src/panels/stylebook-doc.ts +373 -0
- package/src/panels/stylebook-panel.ts +46 -689
- package/src/panels/tab-bar.ts +159 -13
- package/src/panels/toolbar.ts +3 -2
- package/src/platforms/devserver.ts +15 -0
- package/src/services/monaco-setup.ts +12 -0
- package/src/services/render-critic.ts +9 -9
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/store.ts +4 -62
- package/src/studio.ts +90 -40
- package/src/tabs/doc-op-apply.ts +89 -0
- package/src/tabs/patch-ops.ts +6 -2
- package/src/tabs/tab.ts +23 -4
- package/src/tabs/transact.ts +2 -74
- package/src/types.ts +14 -18
- package/src/ui/jx-theme.ts +63 -0
- package/src/ui/media-picker.ts +6 -4
- package/src/ui/spectrum.ts +5 -0
- package/src/utils/canvas-media.ts +0 -137
- package/src/utils/edit-display.ts +23 -3
- package/src/utils/geometry.ts +43 -0
- package/src/utils/link-target.ts +93 -0
- package/src/utils/strip-events.ts +54 -0
- package/src/view.ts +0 -23
- package/src/workspace/workspace.ts +14 -1
- package/src/canvas/canvas-subtree-render.ts +0 -113
- package/src/editor/component-inline-edit.ts +0 -349
- package/src/editor/content-inline-edit.ts +0 -207
- package/src/editor/insertion-helper.ts +0 -308
- package/src/panels/canvas-dnd.ts +0 -329
- package/src/panels/panel-events.ts +0 -306
- package/src/panels/preview-render.ts +0 -132
- package/src/panels/pseudo-preview.ts +0 -75
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* In-iframe interaction — listens for pointer events inside the canvas iframe, resolves the target
|
|
4
|
+
* to its nearest `data-jx-path` node, and reports hit (click) / hover (move) to the parent with the
|
|
5
|
+
* node's iframe-space rect. The parent owns selection + overlay rendering (cross-origin bridge);
|
|
6
|
+
* the iframe only reports what was pointed at.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parseJxPath, serializeJxPath } from "./path-mapping";
|
|
10
|
+
import { rectOf } from "../utils/geometry";
|
|
11
|
+
import { computeInsertZones, insertZonesKey } from "./iframe-insert";
|
|
12
|
+
import { getActiveElement, isEditing } from "../editor/inline-edit";
|
|
13
|
+
import type { IframeChannel } from "./iframe-channel";
|
|
14
|
+
import type { IframeToParent, NodeHit, ParentToIframe } from "./iframe-protocol";
|
|
15
|
+
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
16
|
+
|
|
17
|
+
/** Walk up from an element to the nearest ancestor carrying a `data-jx-path`; null if none. */
|
|
18
|
+
function nearestPathEl(start: Element | null): HTMLElement | null {
|
|
19
|
+
let el = start instanceof Element ? (start as HTMLElement) : null;
|
|
20
|
+
while (el) {
|
|
21
|
+
if (el.dataset?.jxPath) {
|
|
22
|
+
return el;
|
|
23
|
+
}
|
|
24
|
+
el = el.parentElement;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Walk up from an event target to the nearest element carrying a `data-jx-path`; null if none. */
|
|
30
|
+
export function nearestHit(target: EventTarget | null): NodeHit | null {
|
|
31
|
+
const el = nearestPathEl(target instanceof Element ? target : null);
|
|
32
|
+
if (!el) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const r = rectOf(el);
|
|
36
|
+
return {
|
|
37
|
+
path: parseJxPath(el.dataset.jxPath as string),
|
|
38
|
+
rect: { height: r.height, width: r.width, x: r.x, y: r.y },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Measure the current rect of each requested document path by locating its `data-jx-path` element.
|
|
44
|
+
* Paths with no matching node are omitted. The serialized path is the same string the renderer
|
|
45
|
+
* stamps, so a stored selection path round-trips back to its element.
|
|
46
|
+
*/
|
|
47
|
+
export function measureHits(paths: (string | number)[][], doc: Document = document): NodeHit[] {
|
|
48
|
+
const out: NodeHit[] = [];
|
|
49
|
+
for (const path of paths) {
|
|
50
|
+
const serialized = serializeJxPath(path);
|
|
51
|
+
// Wrap in single quotes (the serialized JSON only ever uses double quotes) and escape the few
|
|
52
|
+
// Characters that could still break out of an attribute-value selector.
|
|
53
|
+
const esc = serialized.replaceAll("\\", String.raw`\\`).replaceAll("'", String.raw`\'`);
|
|
54
|
+
const el = doc.querySelector(`[data-jx-path='${esc}']`);
|
|
55
|
+
if (el) {
|
|
56
|
+
const r = rectOf(el);
|
|
57
|
+
out.push({ path, rect: { height: r.height, width: r.width, x: r.x, y: r.y } });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The iframe-side capabilities the interaction wiring needs beyond raw pointer events. Injected
|
|
65
|
+
* from {@link file://./iframe-entry.ts} (the entry owns the shadow doc), mirroring how
|
|
66
|
+
* {@link file://./iframe-drop.ts}'s `startGrabDetector` receives its deps rather than reaching for
|
|
67
|
+
* module state.
|
|
68
|
+
*/
|
|
69
|
+
export interface InteractionDeps {
|
|
70
|
+
/**
|
|
71
|
+
* The iframe's current non-reactive shadow doc (path coordinate space), or null before the first
|
|
72
|
+
* render. Threaded so the insertion-zone computation reads the SAME doc the patch/drag paths
|
|
73
|
+
* use.
|
|
74
|
+
*/
|
|
75
|
+
getShadowDoc: () => JxMutableNode | null;
|
|
76
|
+
/**
|
|
77
|
+
* The live render's canvas mode. Insertion "+" zones are a document-editing affordance —
|
|
78
|
+
* suppressed for stylebook renders (specimens aren't insert targets). Absent = permissive.
|
|
79
|
+
*/
|
|
80
|
+
getMode?: () => string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Wire pointer listeners on the iframe document and report hit/hover (and, when `deps` is given,
|
|
85
|
+
* the insertion "+" zones) to the parent. Hover/zones are only reported when they change, to keep
|
|
86
|
+
* the channel quiet. Returns a teardown function.
|
|
87
|
+
*
|
|
88
|
+
* The insertion-zone hook hangs off the SAME pointermove as hover (the cross-origin cousin of the
|
|
89
|
+
* legacy in-realm insertion-helper mousemove): it resolves the hovered `[data-jx-path]` element and
|
|
90
|
+
* posts `insertZones` only when the zone set's key changes; `null` is posted on pointerleave and
|
|
91
|
+
* whenever the cursor sits mid-element (no near-edge zone), so the parent clears any stale "+".
|
|
92
|
+
*/
|
|
93
|
+
export function startInteraction(
|
|
94
|
+
channel: IframeChannel<IframeToParent, ParentToIframe>,
|
|
95
|
+
doc: Document = document,
|
|
96
|
+
deps?: InteractionDeps,
|
|
97
|
+
): () => void {
|
|
98
|
+
let lastHoverKey: string | null = null;
|
|
99
|
+
let lastZonesKey: string | null = null;
|
|
100
|
+
|
|
101
|
+
const onClick = (e: Event) => {
|
|
102
|
+
const hit = nearestHit(e.target);
|
|
103
|
+
if (hit) {
|
|
104
|
+
channel.post({ hit, kind: "hit" });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const reportHover = (hit: NodeHit | null) => {
|
|
109
|
+
const key = hit ? JSON.stringify(hit.path) : null;
|
|
110
|
+
if (key === lastHoverKey) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
lastHoverKey = key;
|
|
114
|
+
channel.post({ hit, kind: "hover" });
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Resolve + post the insertion "+" zones for an iframe-viewport cursor, deduped by key. */
|
|
118
|
+
const reportInsertZones = (target: EventTarget | null, cursor: { x: number; y: number }) => {
|
|
119
|
+
if (!deps || deps.getMode?.() === "stylebook") {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const shadowDoc = deps.getShadowDoc();
|
|
123
|
+
const el = nearestPathEl(target instanceof Element ? target : null);
|
|
124
|
+
const zones = el && shadowDoc ? computeInsertZones(el, cursor, shadowDoc) : null;
|
|
125
|
+
const key = insertZonesKey(zones);
|
|
126
|
+
if (key === lastZonesKey) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
lastZonesKey = key;
|
|
130
|
+
channel.post({ kind: "insertZones", zones });
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const onMove = (e: Event) => {
|
|
134
|
+
reportHover(nearestHit(e.target));
|
|
135
|
+
const pe = e as PointerEvent;
|
|
136
|
+
reportInsertZones(e.target, { x: pe.clientX, y: pe.clientY });
|
|
137
|
+
};
|
|
138
|
+
const onLeave = () => {
|
|
139
|
+
reportHover(null);
|
|
140
|
+
if (deps && lastZonesKey !== "none") {
|
|
141
|
+
lastZonesKey = "none";
|
|
142
|
+
channel.post({ kind: "insertZones", zones: null });
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const onContextMenu = (e: Event) => {
|
|
147
|
+
const me = e as MouseEvent;
|
|
148
|
+
// Inside the ACTIVE editable keep the NATIVE menu (spellcheck / paste) — the session owns it.
|
|
149
|
+
const active = isEditing() ? getActiveElement() : null;
|
|
150
|
+
if (active && e.target instanceof Node && active.contains(e.target)) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Suppress the browser menu everywhere else (legacy parity — the deleted panel-events handler
|
|
154
|
+
// PreventDefaulted even with no element hit) and let the parent show the Jx element menu.
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
const hit = nearestHit(e.target);
|
|
157
|
+
channel.post({
|
|
158
|
+
kind: "contextMenu",
|
|
159
|
+
path: hit ? hit.path : null,
|
|
160
|
+
x: me.clientX,
|
|
161
|
+
y: me.clientY,
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
doc.addEventListener("click", onClick, true);
|
|
166
|
+
doc.addEventListener("pointermove", onMove, true);
|
|
167
|
+
doc.addEventListener("pointerleave", onLeave, true);
|
|
168
|
+
doc.addEventListener("contextmenu", onContextMenu, true);
|
|
169
|
+
|
|
170
|
+
return () => {
|
|
171
|
+
doc.removeEventListener("click", onClick, true);
|
|
172
|
+
doc.removeEventListener("pointermove", onMove, true);
|
|
173
|
+
doc.removeEventListener("pointerleave", onLeave, true);
|
|
174
|
+
doc.removeEventListener("contextmenu", onContextMenu, true);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* In-iframe keyboard forwarding. Studio's shortcuts are bound to the EDITOR document, so once focus
|
|
4
|
+
* moves into the canvas iframe (a click selects a node there) undo/redo/save/delete/duplicate/arrow
|
|
5
|
+
* navigation stop firing. This captures the global-shortcut subset inside the iframe, prevents the
|
|
6
|
+
* browser default, and posts a flattened event the parent re-dispatches to its shortcut handler.
|
|
7
|
+
*
|
|
8
|
+
* The subset mirrors `editor/shortcuts.ts`: any Ctrl/Cmd combo, plus the bare editing/navigation
|
|
9
|
+
* keys. Keystrokes while an editable element (input / contenteditable — e.g. a future inline edit)
|
|
10
|
+
* is focused inside the iframe are left alone so typing still works.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { cancelIframeDrag, isDragActive } from "./iframe-drop";
|
|
14
|
+
import type { IframeChannel } from "./iframe-channel";
|
|
15
|
+
import type { IframeToParent, ParentToIframe, SerializedKey } from "./iframe-protocol";
|
|
16
|
+
|
|
17
|
+
/** Bare (non-modifier) keys the editor's shortcut handler acts on. */
|
|
18
|
+
const BARE_FORWARD_KEYS: ReadonlySet<string> = new Set([
|
|
19
|
+
"Delete",
|
|
20
|
+
"Backspace",
|
|
21
|
+
"Escape",
|
|
22
|
+
"Enter",
|
|
23
|
+
"ArrowUp",
|
|
24
|
+
"ArrowDown",
|
|
25
|
+
"ArrowLeft",
|
|
26
|
+
"ArrowRight",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** Whether `el` is a text-entry target whose own keystrokes must not be hijacked. */
|
|
30
|
+
function isEditableTarget(el: EventTarget | null): boolean {
|
|
31
|
+
if (!(el instanceof HTMLElement)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (el.isContentEditable) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether this keydown belongs to the global-shortcut subset the parent handles. */
|
|
41
|
+
export function shouldForwardKey(e: KeyboardEvent): boolean {
|
|
42
|
+
if (isEditableTarget(e.target)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return e.ctrlKey || e.metaKey || BARE_FORWARD_KEYS.has(e.key);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Flatten a KeyboardEvent to the structured-cloneable subset the bridge carries. */
|
|
49
|
+
export function serializeKey(e: KeyboardEvent): SerializedKey {
|
|
50
|
+
return {
|
|
51
|
+
altKey: e.altKey,
|
|
52
|
+
code: e.code,
|
|
53
|
+
ctrlKey: e.ctrlKey,
|
|
54
|
+
key: e.key,
|
|
55
|
+
metaKey: e.metaKey,
|
|
56
|
+
shiftKey: e.shiftKey,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Listen for global-shortcut keystrokes on the iframe document, prevent their default, and forward
|
|
62
|
+
* them to the parent. Returns a teardown function.
|
|
63
|
+
*/
|
|
64
|
+
export function startKeyForwarding(
|
|
65
|
+
channel: IframeChannel<IframeToParent, ParentToIframe>,
|
|
66
|
+
doc: Document = document,
|
|
67
|
+
): () => void {
|
|
68
|
+
const onKey = (e: KeyboardEvent) => {
|
|
69
|
+
// Escape during a flow-3 (iframe-originated) drag cancels locally — the iframe owns cancel for
|
|
70
|
+
// That case (single-sourced through iframe-drop), so it must NOT also forward Escape to the
|
|
71
|
+
// Parent (which would tear down via a second path and double-fire).
|
|
72
|
+
if (e.key === "Escape" && isDragActive()) {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
cancelIframeDrag();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!shouldForwardKey(e)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
channel.post({ event: serializeKey(e), kind: "forwardKey" });
|
|
82
|
+
};
|
|
83
|
+
doc.addEventListener("keydown", onKey, true);
|
|
84
|
+
return () => doc.removeEventListener("keydown", onKey, true);
|
|
85
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Parent-side canvas overlay layer for the iframe host. Because the cross-origin bridge forbids the
|
|
4
|
+
* parent from reading the iframe's DOM, selection/hover boxes are drawn from rects the iframe posts
|
|
5
|
+
* back (see {@link measureHits}/`hover`). This module owns the pure rect→overlay math ({@link
|
|
6
|
+
* canvasRectToParent}) and a small DOM layer that floats above the iframe, reusing the same
|
|
7
|
+
* `overlay-box`/`overlay-selection`/`overlay-hover` classes as the legacy canvas for visual
|
|
8
|
+
* parity.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { SerializableRect } from "./iframe-protocol";
|
|
12
|
+
|
|
13
|
+
/** An overlay box position in the parent overlay layer's local coordinates. */
|
|
14
|
+
export interface OverlayPlacement {
|
|
15
|
+
left: number;
|
|
16
|
+
top: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Map a rect measured in the iframe's own viewport to the parent overlay layer's coordinates. The
|
|
23
|
+
* overlay layer is positioned exactly over the iframe (same top-left), so the only transform is the
|
|
24
|
+
* zoom `scale` (applied as a CSS `transform: scale()` on the iframe element, hence a plain multiply
|
|
25
|
+
* here). Kept pure so the zoom math is unit-tested independently of the DOM.
|
|
26
|
+
*/
|
|
27
|
+
export function canvasRectToParent(rect: SerializableRect, scale = 1): OverlayPlacement {
|
|
28
|
+
return {
|
|
29
|
+
height: rect.height * scale,
|
|
30
|
+
left: rect.x * scale,
|
|
31
|
+
top: rect.y * scale,
|
|
32
|
+
width: rect.width * scale,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Map a parent-viewport cursor (e.g. a pragmatic `location.current.input`, in true post-transform
|
|
38
|
+
* parent px) into the iframe's own viewport coordinates. The iframe + overlay are descendants of
|
|
39
|
+
* the scaled `panzoom-wrap`, so the cursor must be DIVIDED by the zoom `scale` (the inverse of the
|
|
40
|
+
* CSS `transform: scale()` the browser already applied). `iframeRect` is `rectOf(iframe)` (its GBCR
|
|
41
|
+
* bakes in pan + scale, so subtracting its `left`/`top` cancels the pan offset). Pure, so the
|
|
42
|
+
* sign/order of the transform is unit-tested independently of the DOM. Empirical `scale` is derived
|
|
43
|
+
* by the caller as `rectOf(iframe).width / iframe.clientWidth` (NOT `effectiveZoom()` — a separate
|
|
44
|
+
* path that can desync).
|
|
45
|
+
*/
|
|
46
|
+
export function parentCursorToIframe(
|
|
47
|
+
cursor: { x: number; y: number },
|
|
48
|
+
iframeRect: { left: number; top: number },
|
|
49
|
+
scale = 1,
|
|
50
|
+
): { x: number; y: number } {
|
|
51
|
+
return {
|
|
52
|
+
x: (cursor.x - iframeRect.left) / scale,
|
|
53
|
+
y: (cursor.y - iframeRect.top) / scale,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The geometric side a drop indicator draws on (mirrors {@link DropPreview}'s `edge`). */
|
|
58
|
+
export type DropEdge = "top" | "bottom" | "inside";
|
|
59
|
+
|
|
60
|
+
/** A floating overlay layer over the iframe that draws a selection box and a hover box. */
|
|
61
|
+
export interface OverlayLayer {
|
|
62
|
+
/**
|
|
63
|
+
* Position the selection box (or hide it when `placement` is null). `label` shows a small badge
|
|
64
|
+
* on the box (stylebook draws the selected tag, e.g. "<p>", via the legacy `.overlay-label` CSS);
|
|
65
|
+
* omitted/null hides it.
|
|
66
|
+
*/
|
|
67
|
+
setSelection: (placement: OverlayPlacement | null, label?: string | null) => void;
|
|
68
|
+
/** Position the hover box (or hide it when `placement` is null). */
|
|
69
|
+
setHover: (placement: OverlayPlacement | null) => void;
|
|
70
|
+
/**
|
|
71
|
+
* Draw the drop indicator for a drag-over (Phase 4c). `placement` is the reference node's rect in
|
|
72
|
+
* overlay-local coords (the caller maps the iframe-space `referenceRect` via
|
|
73
|
+
* {@link canvasRectToParent} at scale=1 — the overlay is INSIDE the scaled panzoom-wrap, so the
|
|
74
|
+
* browser already scales it; multiplying by zoom would double-scale, D-2). `edge` "inside" → a
|
|
75
|
+
* dashed box over the reference; "top"/"bottom" → a thin line at the reference's top/bottom edge.
|
|
76
|
+
* Pass null to hide.
|
|
77
|
+
*/
|
|
78
|
+
setDropIndicator: (placement: OverlayPlacement | null, edge?: DropEdge) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Position the clickable insertion "+" at `placement` (overlay-local coords, mapped via
|
|
81
|
+
* {@link canvasRectToParent} at scale=1 like the drop indicator, D-2), centered on the anchor box
|
|
82
|
+
* and tagged with `edge` (for the `[data-edge]` styling). Pass null to hide it. This is the ONE
|
|
83
|
+
* element on the otherwise `pointer-events:none` overlay that captures pointer events, so the
|
|
84
|
+
* author can actually click it; the caller wires its click/mouseenter/mouseleave via
|
|
85
|
+
* {@link insertButton}.
|
|
86
|
+
*/
|
|
87
|
+
setInsertZone: (placement: OverlayPlacement | null, edge?: InsertButtonEdge) => void;
|
|
88
|
+
/** The clickable "+" button, so the caller can attach click + hover (grace-timer) listeners. */
|
|
89
|
+
readonly insertButton: HTMLButtonElement;
|
|
90
|
+
/** The layer root element (caller appends it over the iframe). */
|
|
91
|
+
readonly root: HTMLElement;
|
|
92
|
+
/** Remove the layer from the DOM. */
|
|
93
|
+
dispose: () => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The geometric side the insertion "+" anchors to (mirrors {@link InsertZone}'s `edge`). */
|
|
97
|
+
export type InsertButtonEdge = "top" | "bottom" | "left" | "right" | "center";
|
|
98
|
+
|
|
99
|
+
function place(box: HTMLElement, placement: OverlayPlacement | null): void {
|
|
100
|
+
if (!placement) {
|
|
101
|
+
box.style.display = "none";
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
box.style.display = "block";
|
|
105
|
+
box.style.left = `${placement.left}px`;
|
|
106
|
+
box.style.top = `${placement.top}px`;
|
|
107
|
+
box.style.width = `${placement.width}px`;
|
|
108
|
+
box.style.height = `${placement.height}px`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Build (but do not mount) an overlay layer with hidden selection + hover boxes. */
|
|
112
|
+
export function createOverlayLayer(doc: Document = document): OverlayLayer {
|
|
113
|
+
const root = doc.createElement("div");
|
|
114
|
+
root.className = "jx-canvas-iframe-overlay";
|
|
115
|
+
// Float over the iframe without intercepting pointer events (the iframe handles its own hit-test).
|
|
116
|
+
root.style.cssText = "position:absolute;inset:0;pointer-events:none;overflow:hidden;z-index:2";
|
|
117
|
+
|
|
118
|
+
const selectionBox = doc.createElement("div");
|
|
119
|
+
selectionBox.className = "overlay-box overlay-selection";
|
|
120
|
+
selectionBox.style.display = "none";
|
|
121
|
+
|
|
122
|
+
const selectionLabel = doc.createElement("div");
|
|
123
|
+
selectionLabel.className = "overlay-label";
|
|
124
|
+
selectionLabel.style.display = "none";
|
|
125
|
+
selectionBox.append(selectionLabel);
|
|
126
|
+
|
|
127
|
+
const hoverBox = doc.createElement("div");
|
|
128
|
+
hoverBox.className = "overlay-box overlay-hover";
|
|
129
|
+
hoverBox.style.display = "none";
|
|
130
|
+
|
|
131
|
+
// Reuses the legacy `.canvas-drop-indicator` CSS (`.line` = thin bar, `.inside` = dashed box).
|
|
132
|
+
const dropBox = doc.createElement("div");
|
|
133
|
+
dropBox.className = "canvas-drop-indicator";
|
|
134
|
+
dropBox.style.display = "none";
|
|
135
|
+
|
|
136
|
+
// The clickable insertion "+" — reuses the legacy `.insertion-helper` circle styling. It is the
|
|
137
|
+
// ONE element on the `pointer-events:none` overlay that re-enables pointer events, so the author
|
|
138
|
+
// Can click it (the legacy CSS-anchor positioning rules are inert here — no `position-anchor` is
|
|
139
|
+
// Set — so it is positioned explicitly via left/top by placeInsertButton).
|
|
140
|
+
const insertButton = doc.createElement("button");
|
|
141
|
+
insertButton.className = "insertion-helper";
|
|
142
|
+
insertButton.textContent = "+";
|
|
143
|
+
insertButton.style.pointerEvents = "auto";
|
|
144
|
+
insertButton.style.display = "none";
|
|
145
|
+
|
|
146
|
+
root.append(hoverBox, selectionBox, dropBox, insertButton);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
dispose: () => root.remove(),
|
|
150
|
+
insertButton,
|
|
151
|
+
root,
|
|
152
|
+
setDropIndicator: (placement, edge = "inside") => placeDropIndicator(dropBox, placement, edge),
|
|
153
|
+
setHover: (placement) => place(hoverBox, placement),
|
|
154
|
+
setInsertZone: (placement, edge = "center") => placeInsertButton(insertButton, placement, edge),
|
|
155
|
+
setSelection: (placement, label = null) => {
|
|
156
|
+
place(selectionBox, placement);
|
|
157
|
+
if (placement && label) {
|
|
158
|
+
selectionLabel.textContent = label;
|
|
159
|
+
selectionLabel.style.display = "block";
|
|
160
|
+
} else {
|
|
161
|
+
selectionLabel.style.display = "none";
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Position the insertion "+" centered on the anchor `placement` (a zero-thickness edge box from the
|
|
169
|
+
* iframe), or hide it when null. Sets `data-edge` for the `[data-edge]` styling and toggles the
|
|
170
|
+
* `visible` class the legacy CSS uses (display + opacity). Explicit left/top centering replaces the
|
|
171
|
+
* legacy CSS-anchor positioning, which can't reach across the iframe boundary.
|
|
172
|
+
*/
|
|
173
|
+
function placeInsertButton(
|
|
174
|
+
btn: HTMLButtonElement,
|
|
175
|
+
placement: OverlayPlacement | null,
|
|
176
|
+
edge: InsertButtonEdge,
|
|
177
|
+
): void {
|
|
178
|
+
if (!placement) {
|
|
179
|
+
btn.style.display = "none";
|
|
180
|
+
btn.classList.remove("visible");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
btn.dataset.edge = edge;
|
|
184
|
+
btn.classList.add("visible");
|
|
185
|
+
// Center the 20×20 circle on the anchor box's midpoint (the edge for a zero-thickness box).
|
|
186
|
+
btn.style.display = "grid";
|
|
187
|
+
btn.style.left = `${placement.left + placement.width / 2}px`;
|
|
188
|
+
btn.style.top = `${placement.top + placement.height / 2}px`;
|
|
189
|
+
btn.style.translate = "-50% -50%";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Position the drop indicator box. `inside` → a dashed box over the reference rect; `top`/`bottom`
|
|
194
|
+
* → a thin horizontal line at the reference's top/bottom (full reference width, zero-height bar).
|
|
195
|
+
* The placement is already in overlay-local coords at scale=1 (D-2).
|
|
196
|
+
*/
|
|
197
|
+
function placeDropIndicator(
|
|
198
|
+
box: HTMLElement,
|
|
199
|
+
placement: OverlayPlacement | null,
|
|
200
|
+
edge: DropEdge,
|
|
201
|
+
): void {
|
|
202
|
+
if (!placement) {
|
|
203
|
+
box.style.display = "none";
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
box.style.display = "block";
|
|
207
|
+
box.style.left = `${placement.left}px`;
|
|
208
|
+
box.style.width = `${placement.width}px`;
|
|
209
|
+
if (edge === "inside") {
|
|
210
|
+
box.className = "canvas-drop-indicator inside";
|
|
211
|
+
box.style.top = `${placement.top}px`;
|
|
212
|
+
box.style.height = `${placement.height}px`;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
box.className = "canvas-drop-indicator line";
|
|
216
|
+
box.style.height = "";
|
|
217
|
+
box.style.top = `${edge === "top" ? placement.top : placement.top + placement.height}px`;
|
|
218
|
+
}
|