@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,154 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* IframeChannel — the typed message boundary between the editor (parent) and the canvas iframe.
|
|
4
|
+
*
|
|
5
|
+
* Per the migration's cross-origin bridge decision, the parent never touches the iframe's
|
|
6
|
+
* `contentDocument`; everything (render commands, patches, geometry, selection, hit-tests) crosses
|
|
7
|
+
* as serializable messages through this one interface. Phases 1+ instantiate it with concrete
|
|
8
|
+
* message unions (`ParentToIframe`/`IframeToParent`); this module owns only the transport contract
|
|
9
|
+
* plus an in-memory `fakeChannelPair` so cross-frame logic is unit-testable without a live iframe.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A typed, bidirectional message channel. `TOut` is what this side sends; `TIn` is what it
|
|
14
|
+
* receives. Implementations: a real `postMessage` channel over an `<iframe>` (added with the iframe
|
|
15
|
+
* host), and {@link fakeChannelPair} for tests.
|
|
16
|
+
*/
|
|
17
|
+
export interface IframeChannel<TOut, TIn> {
|
|
18
|
+
/** Send a message to the other side. */
|
|
19
|
+
post: (message: TOut) => void;
|
|
20
|
+
/** Subscribe to messages from the other side. Returns an unsubscribe function. */
|
|
21
|
+
onMessage: (handler: (message: TIn) => void) => () => void;
|
|
22
|
+
/** Detach all handlers and release the transport. */
|
|
23
|
+
dispose: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Envelope key that tags every cross-frame message so foreign `message` events are ignored. */
|
|
27
|
+
const ENVELOPE = "jx:canvas";
|
|
28
|
+
|
|
29
|
+
interface PostMessageTarget {
|
|
30
|
+
postMessage: (message: unknown, targetOrigin: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface MessageSource {
|
|
34
|
+
addEventListener: (type: "message", listener: (event: MessageEvent) => void) => void;
|
|
35
|
+
removeEventListener: (type: "message", listener: (event: MessageEvent) => void) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A real `IframeChannel` over `window.postMessage`. Outbound messages are wrapped in an envelope
|
|
40
|
+
* tagged with a shared `token`; inbound messages are dropped unless their `event.origin` matches
|
|
41
|
+
* `acceptOrigin` (pass `"*"` to skip the origin check) AND they carry the same token. This is the
|
|
42
|
+
* origin + secret-token authentication that keeps other local pages from driving the canvas.
|
|
43
|
+
*
|
|
44
|
+
* Parent side: `target` = the iframe's `contentWindow`, `source` = `window`, origins = the iframe
|
|
45
|
+
* origin. Iframe side: `target` = `window.parent`, `source` = `window`, origins = the editor
|
|
46
|
+
* origin.
|
|
47
|
+
*/
|
|
48
|
+
export function postMessageChannel<TOut, TIn>(opts: {
|
|
49
|
+
target: PostMessageTarget;
|
|
50
|
+
source: MessageSource;
|
|
51
|
+
targetOrigin: string;
|
|
52
|
+
acceptOrigin: string;
|
|
53
|
+
token: string;
|
|
54
|
+
}): IframeChannel<TOut, TIn> {
|
|
55
|
+
const { target, source, targetOrigin, acceptOrigin, token } = opts;
|
|
56
|
+
const handlers = new Set<(message: TIn) => void>();
|
|
57
|
+
|
|
58
|
+
const listener = (event: MessageEvent) => {
|
|
59
|
+
if (acceptOrigin !== "*" && event.origin !== acceptOrigin) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const data = event.data as { [ENVELOPE]?: string; payload?: TIn } | null;
|
|
63
|
+
if (!data || typeof data !== "object" || data[ENVELOPE] !== token) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const handler of handlers) {
|
|
67
|
+
handler(data.payload as TIn);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
source.addEventListener("message", listener);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
dispose() {
|
|
74
|
+
handlers.clear();
|
|
75
|
+
source.removeEventListener("message", listener);
|
|
76
|
+
},
|
|
77
|
+
onMessage(handler) {
|
|
78
|
+
handlers.add(handler);
|
|
79
|
+
return () => handlers.delete(handler);
|
|
80
|
+
},
|
|
81
|
+
post(message) {
|
|
82
|
+
target.postMessage({ [ENVELOPE]: token, payload: message }, targetOrigin);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Two in-memory channels wired to each other for tests. A message `parent.post(x)` is delivered to
|
|
89
|
+
* the `iframe` side's handlers (and vice versa) only when `flush()` is called — so tests drive the
|
|
90
|
+
* inherently-async postMessage ordering deterministically. Messages posted _during_ a flush are
|
|
91
|
+
* queued for the next flush, never delivered re-entrantly.
|
|
92
|
+
*/
|
|
93
|
+
export function fakeChannelPair<ParentOut, IframeOut>(): {
|
|
94
|
+
parent: IframeChannel<ParentOut, IframeOut>;
|
|
95
|
+
iframe: IframeChannel<IframeOut, ParentOut>;
|
|
96
|
+
flush: () => void;
|
|
97
|
+
/** Number of messages waiting to be delivered (both directions). */
|
|
98
|
+
pending: () => number;
|
|
99
|
+
} {
|
|
100
|
+
let toIframe: ParentOut[] = [];
|
|
101
|
+
let toParent: IframeOut[] = [];
|
|
102
|
+
const iframeHandlers = new Set<(m: ParentOut) => void>();
|
|
103
|
+
const parentHandlers = new Set<(m: IframeOut) => void>();
|
|
104
|
+
|
|
105
|
+
const parent: IframeChannel<ParentOut, IframeOut> = {
|
|
106
|
+
dispose() {
|
|
107
|
+
parentHandlers.clear();
|
|
108
|
+
},
|
|
109
|
+
onMessage(handler) {
|
|
110
|
+
parentHandlers.add(handler);
|
|
111
|
+
return () => parentHandlers.delete(handler);
|
|
112
|
+
},
|
|
113
|
+
post(message) {
|
|
114
|
+
toIframe.push(message);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const iframe: IframeChannel<IframeOut, ParentOut> = {
|
|
119
|
+
dispose() {
|
|
120
|
+
iframeHandlers.clear();
|
|
121
|
+
},
|
|
122
|
+
onMessage(handler) {
|
|
123
|
+
iframeHandlers.add(handler);
|
|
124
|
+
return () => iframeHandlers.delete(handler);
|
|
125
|
+
},
|
|
126
|
+
post(message) {
|
|
127
|
+
toParent.push(message);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function flush() {
|
|
132
|
+
const forIframe = toIframe;
|
|
133
|
+
const forParent = toParent;
|
|
134
|
+
toIframe = [];
|
|
135
|
+
toParent = [];
|
|
136
|
+
for (const message of forIframe) {
|
|
137
|
+
for (const handler of iframeHandlers) {
|
|
138
|
+
handler(message);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const message of forParent) {
|
|
142
|
+
for (const handler of parentHandlers) {
|
|
143
|
+
handler(message);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
flush,
|
|
150
|
+
iframe,
|
|
151
|
+
parent,
|
|
152
|
+
pending: () => toIframe.length + toParent.length,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/// <reference lib="dom.iterable" />
|
|
3
|
+
/**
|
|
4
|
+
* In-iframe drop math (Phase 4c). Runs INSIDE the canvas iframe, in the iframe's own realm/coords.
|
|
5
|
+
*
|
|
6
|
+
* Split for testability: - {@link resolveDropTarget} is the thin DOM adapter (point hit-test →
|
|
7
|
+
* nearest `data-jx-path`); it is CDP-ONLY because happy-dom's `elementFromPoint` returns null (no
|
|
8
|
+
* layout), so it carries no branching logic worth unit-proving against a fake. -
|
|
9
|
+
* {@link computeDropInstruction} is PURE: it reads element rects through {@link rectOf} (stubbable)
|
|
10
|
+
* and the iframe's shadow doc, and resolves the structural placement. It ports the legacy
|
|
11
|
+
* `getCanvasDropResult`/`nearestChildEdge` math (canvas-dnd.ts) against IFRAME-realm geometry, and
|
|
12
|
+
* reproduces the EXACT `[...parentPath, "children", index]` targetPath shape so the parent's
|
|
13
|
+
* realm-agnostic `applyDropInstruction` resolves the same parent/index.
|
|
14
|
+
*
|
|
15
|
+
* The drop is computed FRESH in the iframe's `drop` handler from the live DOM — a `dragOver`
|
|
16
|
+
* preview is display-only and never the source of truth (patch-mid-drag safety).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { parseJxPath } from "./path-mapping";
|
|
20
|
+
import { rectOf, elementAtPoint } from "../utils/geometry";
|
|
21
|
+
import { getNodeAtPath, isAncestor, pathsEqual } from "../state";
|
|
22
|
+
import { isEditing } from "../editor/inline-edit";
|
|
23
|
+
import type { IframeChannel } from "./iframe-channel";
|
|
24
|
+
import type { DragSrcKind, DropPreview, IframeToParent, ParentToIframe } from "./iframe-protocol";
|
|
25
|
+
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
26
|
+
import type { JxPath } from "../state";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Void (self-closing) HTML tags that cannot accept children — a leaf for drop purposes. COPIED from
|
|
30
|
+
* the store's `VOID_ELEMENTS` literal so this module stays dependency-light (the iframe bundle must
|
|
31
|
+
* not pull in the store). Keep in sync with store.ts:VOID_ELEMENTS.
|
|
32
|
+
*/
|
|
33
|
+
const VOID_TAGS = new Set([
|
|
34
|
+
"area",
|
|
35
|
+
"base",
|
|
36
|
+
"br",
|
|
37
|
+
"col",
|
|
38
|
+
"embed",
|
|
39
|
+
"hr",
|
|
40
|
+
"img",
|
|
41
|
+
"input",
|
|
42
|
+
"link",
|
|
43
|
+
"meta",
|
|
44
|
+
"param",
|
|
45
|
+
"source",
|
|
46
|
+
"track",
|
|
47
|
+
"wbr",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Walk up from an element to the nearest ancestor carrying a `data-jx-path`. Refactored out of the
|
|
52
|
+
* `nearestHit` walk (iframe-interaction.ts) to take an element directly so the hit-test adapter can
|
|
53
|
+
* reuse it. Returns null when no addressable node is found.
|
|
54
|
+
*/
|
|
55
|
+
function nearestPathEl(start: Element | null): HTMLElement | null {
|
|
56
|
+
let el = start instanceof Element ? (start as HTMLElement) : null;
|
|
57
|
+
while (el) {
|
|
58
|
+
if (el.dataset?.jxPath) {
|
|
59
|
+
return el;
|
|
60
|
+
}
|
|
61
|
+
el = el.parentElement;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Point hit-test in iframe-viewport coords (`x`,`y`): the topmost element, then walk to the nearest
|
|
68
|
+
* `[data-jx-path]` ancestor. CDP-ONLY — happy-dom's `elementFromPoint` returns null (no layout), so
|
|
69
|
+
* this thin adapter is exercised under a real browser, not a unit test.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveDropTarget(x: number, y: number, doc: Document): HTMLElement | null {
|
|
72
|
+
const hit = elementAtPoint(x, y, doc);
|
|
73
|
+
return nearestPathEl(hit);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compute the structural drop placement for a cursor over `targetEl`, PURE against iframe-realm
|
|
78
|
+
* rects.
|
|
79
|
+
*
|
|
80
|
+
* `cursorY` is in iframe-viewport coords (same space as {@link rectOf}); `shadowDoc` is the
|
|
81
|
+
* iframe's non-reactive shadow doc (path coordinate space); `src` is the realm-agnostic drag
|
|
82
|
+
* source.
|
|
83
|
+
*
|
|
84
|
+
* Branching (ported from canvas-dnd.ts getCanvasDropResult/nearestChildEdge):
|
|
85
|
+
*
|
|
86
|
+
* - Root (path.length===0) with element children → nearest child edge (reorder-above/below that
|
|
87
|
+
* child).
|
|
88
|
+
* - Leaf (void tag, or no element children) → relY<0.5 above (edge top) else below (edge bottom).
|
|
89
|
+
* - Container → relY<0.25 above, >0.75 below, else make-child (edge inside). Returns null when the
|
|
90
|
+
* drop is disallowed (a tree-node onto its own ancestor or itself).
|
|
91
|
+
*/
|
|
92
|
+
export function computeDropInstruction(
|
|
93
|
+
targetEl: HTMLElement,
|
|
94
|
+
cursorY: number,
|
|
95
|
+
shadowDoc: JxMutableNode,
|
|
96
|
+
src: DragSrcKind,
|
|
97
|
+
): DropPreview | null {
|
|
98
|
+
const serialized = targetEl.dataset?.jxPath;
|
|
99
|
+
if (serialized == null) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const targetPath = parseJxPath(serialized) as JxPath;
|
|
103
|
+
|
|
104
|
+
// Root: pick the nearest child edge among the element's element children.
|
|
105
|
+
if (targetPath.length === 0) {
|
|
106
|
+
const children = [...targetEl.children] as HTMLElement[];
|
|
107
|
+
if (children.length === 0) {
|
|
108
|
+
return canDrop(src, targetPath)
|
|
109
|
+
? {
|
|
110
|
+
edge: "inside",
|
|
111
|
+
instruction: "make-child",
|
|
112
|
+
referenceRect: rectFor(targetEl),
|
|
113
|
+
targetPath,
|
|
114
|
+
}
|
|
115
|
+
: null;
|
|
116
|
+
}
|
|
117
|
+
return nearestChildEdge(children, cursorY, targetPath, src);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!canDrop(src, targetPath)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const node = getNodeAtPath(shadowDoc, targetPath) as JxMutableNode | undefined;
|
|
125
|
+
const tag = (node?.tagName || "div").toLowerCase();
|
|
126
|
+
const hasElementChildren =
|
|
127
|
+
Array.isArray(node?.children) &&
|
|
128
|
+
node.children.some((c: unknown) => c != null && typeof c === "object");
|
|
129
|
+
const isLeaf = VOID_TAGS.has(tag) || !hasElementChildren;
|
|
130
|
+
|
|
131
|
+
const rect = rectFor(targetEl);
|
|
132
|
+
const relY = rect.height === 0 ? 0 : (cursorY - rect.y) / rect.height;
|
|
133
|
+
|
|
134
|
+
if (isLeaf) {
|
|
135
|
+
return relY < 0.5
|
|
136
|
+
? { edge: "top", instruction: "reorder-above", referenceRect: rect, targetPath }
|
|
137
|
+
: { edge: "bottom", instruction: "reorder-below", referenceRect: rect, targetPath };
|
|
138
|
+
}
|
|
139
|
+
if (relY < 0.25) {
|
|
140
|
+
return { edge: "top", instruction: "reorder-above", referenceRect: rect, targetPath };
|
|
141
|
+
}
|
|
142
|
+
if (relY > 0.75) {
|
|
143
|
+
return { edge: "bottom", instruction: "reorder-below", referenceRect: rect, targetPath };
|
|
144
|
+
}
|
|
145
|
+
return { edge: "inside", instruction: "make-child", referenceRect: rect, targetPath };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the nearest child-edge drop among `children` (ports nearestChildEdge, canvas-dnd.ts). The
|
|
150
|
+
* resulting `targetPath` is `[...parentPath, "children", closestIdx]` so the parent's
|
|
151
|
+
* parentElementPath(slice(0,-2)) / childIndex(at(-1)) read the same parent + index.
|
|
152
|
+
*/
|
|
153
|
+
function nearestChildEdge(
|
|
154
|
+
children: HTMLElement[],
|
|
155
|
+
cursorY: number,
|
|
156
|
+
parentPath: JxPath,
|
|
157
|
+
src: DragSrcKind,
|
|
158
|
+
): DropPreview | null {
|
|
159
|
+
let closestDist = Infinity;
|
|
160
|
+
let instruction: "reorder-above" | "reorder-below" = "reorder-below";
|
|
161
|
+
let closestIdx = children.length - 1;
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < children.length; i++) {
|
|
164
|
+
const rect = rectFor(children[i]!);
|
|
165
|
+
const topDist = Math.abs(cursorY - rect.y);
|
|
166
|
+
const bottomDist = Math.abs(cursorY - (rect.y + rect.height));
|
|
167
|
+
if (topDist < closestDist) {
|
|
168
|
+
closestDist = topDist;
|
|
169
|
+
instruction = "reorder-above";
|
|
170
|
+
closestIdx = i;
|
|
171
|
+
}
|
|
172
|
+
if (bottomDist < closestDist) {
|
|
173
|
+
closestDist = bottomDist;
|
|
174
|
+
instruction = "reorder-below";
|
|
175
|
+
closestIdx = i;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const childPath = [...parentPath, "children", closestIdx] as JxPath;
|
|
180
|
+
if (!canDrop(src, childPath)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
edge: instruction === "reorder-above" ? "top" : "bottom",
|
|
185
|
+
instruction,
|
|
186
|
+
referenceRect: rectFor(children[closestIdx]!),
|
|
187
|
+
targetPath: childPath,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A tree-node may not drop onto its own subtree (ancestor-or-self); a block may always drop. */
|
|
192
|
+
function canDrop(src: DragSrcKind, targetPath: JxPath): boolean {
|
|
193
|
+
if (src.type !== "tree-node") {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
const srcPath = src.path as JxPath;
|
|
197
|
+
if (pathsEqual(srcPath, targetPath)) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
return !isAncestor(srcPath, targetPath);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Read an element's iframe-viewport rect as a {@link DropPreview} `referenceRect`. */
|
|
204
|
+
function rectFor(el: Element): DropPreview["referenceRect"] {
|
|
205
|
+
const r = rectOf(el);
|
|
206
|
+
return { height: r.height, width: r.width, x: r.x, y: r.y };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── Flow 3: grab-anywhere drag detector (Phase 4c, commit 3; rebuilt 4c flow-3 fix) ──────────────
|
|
210
|
+
// The iframe owns no pragmatic-dnd, so an element-body drag is detected manually: a pointerdown on a
|
|
211
|
+
// `[data-jx-path]` arms a candidate, and the first pointermove past a small threshold posts
|
|
212
|
+
// `dragOriginate`. A drag that begins inside the iframe delivers its held-button pointermoves to the
|
|
213
|
+
// IFRAME document (implicit pointer capture), so the PARENT never sees them while the cursor is over
|
|
214
|
+
// The canvas. Flow 3 is therefore FULLY IFRAME-DRIVEN: after originating, the detector keeps driving
|
|
215
|
+
// — it computes the preview/drop LOCALLY from its own cursor (already iframe-viewport coords, no
|
|
216
|
+
// Parent->iframe round-trip) and posts dragOver/dropResult directly. The parent only adopts the
|
|
217
|
+
// Iframe's dragSeq, draws the indicator, positions the ghost from the posted cursor, and applies the
|
|
218
|
+
// Drop. (Flows 1/2/4 stay parent-originated: pragmatic's parent-document pointer stream does deliver
|
|
219
|
+
// Moves over the iframe for a drag that began in parent DOM, so those are sound and untouched.)
|
|
220
|
+
|
|
221
|
+
/** Pixels the pointer must travel from pointerdown before a body-grab counts as a drag. */
|
|
222
|
+
const GRAB_THRESHOLD = 4;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Whether an iframe-originated (flow 3) drag is currently active. Read by iframe-keys to suppress
|
|
226
|
+
* forwarding Escape to the parent during such a drag (the iframe owns cancel locally, see
|
|
227
|
+
* {@link cancelIframeDrag}), and by the entry's dragEnd/dragCancel handling.
|
|
228
|
+
*/
|
|
229
|
+
let dragActive = false;
|
|
230
|
+
|
|
231
|
+
/** True while a flow-3 (iframe-originated) drag is live. */
|
|
232
|
+
export function isDragActive(): boolean {
|
|
233
|
+
return dragActive;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The local cancel hook (clears preview/auto-scroll + posts dragEnd) installed by the active drag. */
|
|
237
|
+
let cancelHook: (() => void) | null = null;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Cancel the active iframe-originated drag locally: clear any in-iframe drag state and run the
|
|
241
|
+
* installed cancel hook (the entry posts `dragEnd` so the parent tears down ghost/indicator). A
|
|
242
|
+
* no-op when no flow-3 drag is active. Single-sourced through here (and the parent's pragmatic
|
|
243
|
+
* cancel for the parent-source flows) so cancel never double-fires.
|
|
244
|
+
*/
|
|
245
|
+
export function cancelIframeDrag(): void {
|
|
246
|
+
if (!dragActive) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const hook = cancelHook;
|
|
250
|
+
clearIframeDrag();
|
|
251
|
+
hook?.();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Reset the flow-3 drag state without firing the cancel hook (used on natural drop/dragEnd). */
|
|
255
|
+
export function clearIframeDrag(): void {
|
|
256
|
+
dragActive = false;
|
|
257
|
+
cancelHook = null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Mark a flow-3 drag active and register its local cancel hook. Called by the entry when it accepts
|
|
262
|
+
* an iframe-originated session (after posting dragOriginate). Idempotent per drag.
|
|
263
|
+
*/
|
|
264
|
+
export function beginIframeDrag(onCancel: () => void): void {
|
|
265
|
+
dragActive = true;
|
|
266
|
+
cancelHook = onCancel;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Whether a pointerdown on `target` may originate a body-grab drag: it must resolve to a
|
|
271
|
+
* `[data-jx-path]` element and NOT be inside an active inline-edit session (typing/selecting text
|
|
272
|
+
* must never start a reorder). Returns the resolved path, or null when no drag should originate.
|
|
273
|
+
*/
|
|
274
|
+
export function grabCandidatePath(target: EventTarget | null): JxPath | null {
|
|
275
|
+
if (isEditing()) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
const el = nearestPathEl(target instanceof Element ? target : null);
|
|
279
|
+
if (!el) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
return parseJxPath(el.dataset.jxPath as string) as JxPath;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Whether the pointer has moved past the grab threshold from its pointerdown origin (PURE). */
|
|
286
|
+
export function passedGrabThreshold(
|
|
287
|
+
origin: { x: number; y: number },
|
|
288
|
+
now: { x: number; y: number },
|
|
289
|
+
): boolean {
|
|
290
|
+
return (
|
|
291
|
+
Math.abs(now.x - origin.x) >= GRAB_THRESHOLD || Math.abs(now.y - origin.y) >= GRAB_THRESHOLD
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── Auto-scroll (Phase 4c, commit 6) ──────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/** Edge band height (px from the top/bottom of the viewport) that triggers auto-scroll. */
|
|
298
|
+
export const AUTO_SCROLL_BAND = 40;
|
|
299
|
+
|
|
300
|
+
/** Pixels scrolled per auto-scroll frame. */
|
|
301
|
+
export const AUTO_SCROLL_STEP = 12;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The auto-scroll direction for a cursor `y` within a viewport of height `viewportH` (PURE).
|
|
305
|
+
* Returns `-1` (scroll up) when the cursor is in the top `band`, `+1` (scroll down) in the bottom
|
|
306
|
+
* `band`, or `0` outside both bands. The iframe's rAF loop multiplies this by
|
|
307
|
+
* {@link AUTO_SCROLL_STEP} and keeps scrolling while it stays non-zero (a stationary edge-hold
|
|
308
|
+
* self-sustains).
|
|
309
|
+
*/
|
|
310
|
+
export function scrollDirection(y: number, viewportH: number, band = AUTO_SCROLL_BAND): -1 | 0 | 1 {
|
|
311
|
+
if (y < band) {
|
|
312
|
+
return -1;
|
|
313
|
+
}
|
|
314
|
+
if (y > viewportH - band) {
|
|
315
|
+
return 1;
|
|
316
|
+
}
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The iframe-side capabilities the flow-3 detector needs to DRIVE a drag locally. Injected from
|
|
322
|
+
* {@link file://./iframe-entry.ts} (the entry owns the render generation, the shadow doc, and the
|
|
323
|
+
* self-sustaining auto-scroll loop) rather than duplicated here, so the detector and the
|
|
324
|
+
* `dragMove`/`drop` message handlers share the EXACT same preview/drop math and auto-scroll state.
|
|
325
|
+
*/
|
|
326
|
+
export interface GrabDetectorDeps {
|
|
327
|
+
/**
|
|
328
|
+
* Resolve the display preview / authoritative drop for an IFRAME-VIEWPORT cursor (point hit-test
|
|
329
|
+
* → nearest `[data-jx-path]` → pure {@link computeDropInstruction}); null when over no droppable.
|
|
330
|
+
* Identical to the function the `dragMove`/`drop` handlers use — flow 3 takes the iframe-local
|
|
331
|
+
* cursor DIRECTLY (no parentCursorToIframe; the iframe already owns its coordinate space).
|
|
332
|
+
*/
|
|
333
|
+
previewAt: (cursor: { x: number; y: number }, src: DragSrcKind) => DropPreview | null;
|
|
334
|
+
/** The generation the iframe's DOM currently reflects, so replies can be stale-gated parent-side. */
|
|
335
|
+
gen: () => number;
|
|
336
|
+
/**
|
|
337
|
+
* The live render's canvas mode. Grab-anywhere drags are a document-editing affordance —
|
|
338
|
+
* suppressed for stylebook renders (specimens can't be reordered). Absent = permissive.
|
|
339
|
+
*/
|
|
340
|
+
getMode?: () => string;
|
|
341
|
+
/**
|
|
342
|
+
* Arm the self-sustaining auto-scroll loop for a flow-3 cursor. `src` lets the loop recompute the
|
|
343
|
+
* preview during an edge-hold (the parent never posts a dragStart for flow 3, so the entry's own
|
|
344
|
+
* `dragSrc` is null); the loop also re-posts dragOver WITH the cursor so the ghost keeps
|
|
345
|
+
* tracking.
|
|
346
|
+
*/
|
|
347
|
+
armAutoScroll: (cursor: { x: number; y: number }, dragSeq: number, src: DragSrcKind) => void;
|
|
348
|
+
/** Stop the auto-scroll loop (on drop / band-exit / cancel). */
|
|
349
|
+
stopAutoScroll: () => void;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Wire the flow-3 grab detector on the iframe document and DRIVE the whole gesture locally (the
|
|
354
|
+
* iframe owns the pointer it started — its held-button moves never reach the parent document).
|
|
355
|
+
*
|
|
356
|
+
* - Pointerdown on a node body (primary button, not while editing) arms a candidate.
|
|
357
|
+
* - The first pointermove past {@link GRAB_THRESHOLD} originates: bumps the local seq, marks the drag
|
|
358
|
+
* active (with a cancel hook that posts `dragEnd`), posts `dragOriginate{path,dragSeq}`, and then
|
|
359
|
+
* immediately drives the first move.
|
|
360
|
+
* - Every later pointermove computes the preview LOCALLY from its own (iframe-viewport) cursor and
|
|
361
|
+
* posts `dragOver{dragSeq, gen, preview, cursor}` (the parent positions the ghost from `cursor`)
|
|
362
|
+
* and updates auto-scroll.
|
|
363
|
+
* - Pointerup computes the drop FRESH and posts `dropResult`, then tears down.
|
|
364
|
+
*
|
|
365
|
+
* The parent adopts this seq ({@link file://../panels/canvas-dnd-bridge.ts}'s
|
|
366
|
+
* `startIframeOriginatedDrag` → `adoptDragSession`) so the dragOver/dropResult pass its seq gate;
|
|
367
|
+
* it attaches NO parent-document listeners for flow 3. Returns a teardown. `deps` injects the
|
|
368
|
+
* iframe-side preview/gen/auto-scroll capabilities (see {@link GrabDetectorDeps}).
|
|
369
|
+
*/
|
|
370
|
+
export function startGrabDetector(
|
|
371
|
+
channel: IframeChannel<IframeToParent, ParentToIframe>,
|
|
372
|
+
doc: Document = document,
|
|
373
|
+
deps?: GrabDetectorDeps,
|
|
374
|
+
): () => void {
|
|
375
|
+
let candidate: { path: JxPath; origin: { x: number; y: number } } | null = null;
|
|
376
|
+
let localSeq = 0;
|
|
377
|
+
// The realm-agnostic source kind for the live drag, kept in closure so previewAt's canDrop
|
|
378
|
+
// (isAncestor/self) sees the grabbed node's path for the whole gesture.
|
|
379
|
+
let src: DragSrcKind | null = null;
|
|
380
|
+
|
|
381
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
382
|
+
// Only a primary-button drag originates; ignore right/middle and multi-touch gestures.
|
|
383
|
+
if (e.button !== 0) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (deps?.getMode?.() === "stylebook") {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const path = grabCandidatePath(e.target);
|
|
390
|
+
if (!path) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
candidate = { origin: { x: e.clientX, y: e.clientY }, path };
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// A REAL mouse press-drag over an <img> (draggable by default) or a text selection starts a
|
|
397
|
+
// NATIVE HTML5 drag, which fires pointercancel and hijacks the pointer stream — the grab dies
|
|
398
|
+
// Before it originates (synthetic/CDP input never triggers this, which is why only real-mouse
|
|
399
|
+
// Drags broke). While a grab candidate is armed or a drag is live, native drags are never wanted;
|
|
400
|
+
// Outside that (no path target, inline editing) native behavior is preserved.
|
|
401
|
+
const onDragStart = (e: Event) => {
|
|
402
|
+
if (candidate || dragActive) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// Suppress text selection only while the drag is LIVE (plain clicks and inline editing keep
|
|
408
|
+
// Native selection); the origination path below also clears any selection the pre-threshold
|
|
409
|
+
// Moves already started.
|
|
410
|
+
const onSelectStart = (e: Event) => {
|
|
411
|
+
if (dragActive) {
|
|
412
|
+
e.preventDefault();
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/** Compute + post the dragOver preview for the live cursor, and (re)arm auto-scroll. */
|
|
417
|
+
const drive = (cursor: { x: number; y: number }) => {
|
|
418
|
+
if (!src) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const preview = deps ? deps.previewAt(cursor, src) : null;
|
|
422
|
+
channel.post({
|
|
423
|
+
cursor,
|
|
424
|
+
dragSeq: localSeq,
|
|
425
|
+
gen: deps ? deps.gen() : -1,
|
|
426
|
+
kind: "dragOver",
|
|
427
|
+
preview,
|
|
428
|
+
});
|
|
429
|
+
deps?.armAutoScroll(cursor, localSeq, src);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
433
|
+
const cursor = { x: e.clientX, y: e.clientY };
|
|
434
|
+
if (!dragActive) {
|
|
435
|
+
if (!candidate || !passedGrabThreshold(candidate.origin, cursor)) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
localSeq += 1;
|
|
439
|
+
const { path } = candidate;
|
|
440
|
+
candidate = null;
|
|
441
|
+
src = { path: [...path], type: "tree-node" };
|
|
442
|
+
beginIframeDrag(() => channel.post({ dragSeq: localSeq, kind: "dragEnd" }));
|
|
443
|
+
// Drop any text selection the pre-threshold moves started (real-mouse drags select as they
|
|
444
|
+
// Go); onSelectStart keeps new selections suppressed for the rest of the gesture.
|
|
445
|
+
doc.getSelection?.()?.removeAllRanges();
|
|
446
|
+
channel.post({ dragSeq: localSeq, kind: "dragOriginate", path: [...path] });
|
|
447
|
+
// Fall through and drive the first move immediately (the originating move is also a move).
|
|
448
|
+
}
|
|
449
|
+
drive(cursor);
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const onPointerUp = (e: PointerEvent) => {
|
|
453
|
+
candidate = null;
|
|
454
|
+
if (!dragActive || !src) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const cursor = { x: e.clientX, y: e.clientY };
|
|
458
|
+
const drop = deps ? deps.previewAt(cursor, src) : null;
|
|
459
|
+
channel.post({
|
|
460
|
+
dragSeq: localSeq,
|
|
461
|
+
gen: deps ? deps.gen() : -1,
|
|
462
|
+
instruction: drop ? drop.instruction : null,
|
|
463
|
+
kind: "dropResult",
|
|
464
|
+
targetPath: drop ? drop.targetPath : null,
|
|
465
|
+
});
|
|
466
|
+
src = null;
|
|
467
|
+
deps?.stopAutoScroll();
|
|
468
|
+
clearIframeDrag();
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
doc.addEventListener("pointerdown", onPointerDown, true);
|
|
472
|
+
doc.addEventListener("pointermove", onPointerMove, true);
|
|
473
|
+
doc.addEventListener("pointerup", onPointerUp, true);
|
|
474
|
+
doc.addEventListener("dragstart", onDragStart, true);
|
|
475
|
+
doc.addEventListener("selectstart", onSelectStart, true);
|
|
476
|
+
|
|
477
|
+
return () => {
|
|
478
|
+
doc.removeEventListener("pointerdown", onPointerDown, true);
|
|
479
|
+
doc.removeEventListener("pointermove", onPointerMove, true);
|
|
480
|
+
doc.removeEventListener("pointerup", onPointerUp, true);
|
|
481
|
+
doc.removeEventListener("dragstart", onDragStart, true);
|
|
482
|
+
doc.removeEventListener("selectstart", onSelectStart, true);
|
|
483
|
+
};
|
|
484
|
+
}
|