@jxsuite/studio 0.33.0 → 0.35.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 +6238 -0
- package/dist/iframe-entry.js.map +35 -0
- package/dist/studio.js +30972 -17222
- package/dist/studio.js.map +328 -83
- package/package.json +9 -9
- 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 +5 -1
- package/src/new-project/design-fields.ts +260 -0
- package/src/new-project/import-tab.ts +322 -0
- package/src/new-project/new-project-modal.ts +472 -89
- package/src/new-project/templates.ts +61 -0
- package/src/packages/ensure-deps.ts +6 -0
- package/src/packages/pull-package-sync.ts +339 -0
- package/src/page-params.ts +383 -0
- package/src/panels/ai-chat/attached-context.ts +49 -0
- package/src/panels/ai-chat/chat-markdown.ts +38 -0
- package/src/panels/ai-chat/chat-view.ts +250 -0
- package/src/panels/ai-chat/composer.ts +315 -0
- package/src/panels/ai-chat/sessions-view.ts +98 -0
- package/src/panels/ai-panel.ts +189 -457
- 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/git-panel.ts +16 -1
- package/src/panels/overlays.ts +10 -125
- package/src/panels/properties-panel.ts +210 -0
- package/src/panels/right-panel.ts +21 -7
- 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 +45 -1
- package/src/services/agent-seed.ts +91 -0
- package/src/services/ai-models.ts +64 -0
- package/src/services/ai-session-store.ts +250 -0
- package/src/services/ai-settings.ts +5 -0
- package/src/services/automation.ts +140 -0
- package/src/services/document-assistant.ts +98 -38
- package/src/services/import-client.ts +134 -0
- package/src/services/monaco-setup.ts +12 -0
- package/src/services/render-critic.ts +9 -9
- package/src/services/settings-store.ts +93 -0
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/store.ts +4 -62
- package/src/studio.ts +115 -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 +98 -18
- package/src/ui/ai-credentials-form.ts +205 -0
- package/src/ui/jx-theme.ts +63 -0
- package/src/ui/media-picker.ts +6 -4
- package/src/ui/spectrum.ts +13 -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
|
@@ -8,11 +8,12 @@ import { html, render as litRender, nothing } from "lit-html";
|
|
|
8
8
|
import { styleMap } from "lit-html/directives/style-map.js";
|
|
9
9
|
import { ref } from "lit-html/directives/ref.js";
|
|
10
10
|
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
|
11
|
+
import { disableNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview";
|
|
11
12
|
|
|
12
13
|
import {
|
|
14
|
+
canvasWrap,
|
|
13
15
|
childIndex,
|
|
14
16
|
childList,
|
|
15
|
-
elToScope,
|
|
16
17
|
getNodeAtPath,
|
|
17
18
|
nodeLabel,
|
|
18
19
|
parentElementPath,
|
|
@@ -20,21 +21,27 @@ import {
|
|
|
20
21
|
import { activeTab } from "../workspace/workspace";
|
|
21
22
|
import { mutateMoveNode, mutateUpdateProperty, transactDoc } from "../tabs/transact";
|
|
22
23
|
import { view } from "../view";
|
|
23
|
-
import {
|
|
24
|
+
import { getInlineActions } from "../editor/inline-edit";
|
|
24
25
|
import type { InlineAction } from "../editor/inline-edit";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
26
|
+
import { buildMergeTags, buildRepeaterTagsFromFields } from "../editor/merge-tags";
|
|
27
|
+
import { findEnclosingRepeater, resolveRepeaterItemFields } from "../editor/repeater-scope";
|
|
28
|
+
import { projectState } from "../state";
|
|
27
29
|
import { componentRegistry } from "../files/components";
|
|
28
30
|
import { convertToComponent } from "../editor/convert-to-component";
|
|
29
|
-
import {
|
|
31
|
+
import { getEditBarAnchorRect, getEditSnapshot, postApplyFormat } from "../canvas/iframe-host";
|
|
30
32
|
import { getLayerSlot } from "../ui/layers";
|
|
31
33
|
import { showSlashMenu } from "../editor/slash-menu";
|
|
32
34
|
import { getConvertTargets } from "../editor/convert-targets";
|
|
35
|
+
import { rectOf } from "../utils/geometry";
|
|
33
36
|
|
|
37
|
+
import type { ApplyFormatIntent } from "../canvas/iframe-protocol";
|
|
34
38
|
import type { JxPath } from "../state";
|
|
35
39
|
import type { TemplateResult } from "lit-html";
|
|
36
40
|
import type { SlashCommand } from "../editor/convert-targets.js";
|
|
37
41
|
|
|
42
|
+
/** The plain format commands (everything an action button posts except link/insertData). */
|
|
43
|
+
type FormatCommand = Extract<ApplyFormatIntent, { command: "bold" }>["command"];
|
|
44
|
+
|
|
38
45
|
/**
|
|
39
46
|
* @type {{
|
|
40
47
|
* getCanvasMode: () => string;
|
|
@@ -59,6 +66,149 @@ export function initBlockActionBar(ctx: {
|
|
|
59
66
|
navigateToComponent: (path: string) => void;
|
|
60
67
|
}) {
|
|
61
68
|
_ctx = ctx;
|
|
69
|
+
if (!_formatShortcutBound) {
|
|
70
|
+
document.addEventListener("keydown", handleParentFormatShortcut);
|
|
71
|
+
// `scroll` doesn't bubble but IS deliverable capture-phase at the document — one app-lifetime
|
|
72
|
+
// Listener covers .content-edit-canvas (edit mode), window scrolls, and any future scroll
|
|
73
|
+
// Container, keeping the position:fixed bar tracking its (scrolling) anchor.
|
|
74
|
+
document.addEventListener("scroll", onCanvasScroll, { capture: true, passive: true });
|
|
75
|
+
_formatShortcutBound = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Register the parent-document format-shortcut + scroll handlers exactly once. */
|
|
80
|
+
let _formatShortcutBound = false;
|
|
81
|
+
|
|
82
|
+
/** One reposition per frame, no matter how many scroll events land in it. */
|
|
83
|
+
let _scrollRafPending = false;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reposition the bar on a canvas-area scroll — rAF-throttled and via the style fast path ({@link
|
|
87
|
+
* repositionBlockActionBar}), NOT a full lit re-render (which would tear down and re-create the
|
|
88
|
+
* drag-handle's pragmatic-dnd registration every frame). Exported for tests.
|
|
89
|
+
*
|
|
90
|
+
* @param {Event} e
|
|
91
|
+
*/
|
|
92
|
+
export function onCanvasScroll(e: Event): void {
|
|
93
|
+
if (!_ctx || _linkPopoverOpen) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!activeTab.value?.session.selection) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const mode = _ctx.getCanvasMode();
|
|
100
|
+
if (mode !== "design" && mode !== "edit") {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Only canvas-area scrolls (or the window itself) move the anchor; left/right panel scrolling
|
|
104
|
+
// Never does.
|
|
105
|
+
const isCanvasScroll =
|
|
106
|
+
e.target === document ||
|
|
107
|
+
(canvasWrap != null && e.target instanceof Node && canvasWrap.contains(e.target));
|
|
108
|
+
if (!isCanvasScroll || _scrollRafPending) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
_scrollRafPending = true;
|
|
112
|
+
requestAnimationFrame(() => {
|
|
113
|
+
_scrollRafPending = false;
|
|
114
|
+
repositionBlockActionBar();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Style-only fast path: move the existing bar to the anchor's current position (fresh
|
|
120
|
+
* {@link getEditBarAnchorRect} — the iframe's GBCR moves with the scroll). Hides via `visibility`
|
|
121
|
+
* when the anchor left the canvas area so the lit tree (and the drag handle's dnd registration)
|
|
122
|
+
* survives; a bar hidden by the full render path (`nothing`) is resurrected with a full render.
|
|
123
|
+
*/
|
|
124
|
+
function repositionBlockActionBar(): void {
|
|
125
|
+
const bar = view.blockActionBarEl?.firstElementChild as HTMLElement | null;
|
|
126
|
+
if (!bar) {
|
|
127
|
+
renderBlockActionBar();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const anchor = getEditBarAnchorRect();
|
|
131
|
+
const pos = anchor ? barPosition(anchor) : null;
|
|
132
|
+
if (!pos) {
|
|
133
|
+
bar.style.visibility = "hidden";
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
bar.style.visibility = "";
|
|
137
|
+
bar.style.left = `${pos.left}px`;
|
|
138
|
+
bar.style.top = `${pos.top}px`;
|
|
139
|
+
clampBarToWindow(bar);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The bar's position for `anchor` (flipping below the element near the top edge), or null when the
|
|
144
|
+
* anchor's vertical extent has left the canvas area — the popover layer has no clipping, so a
|
|
145
|
+
* clamped bar would float detached over the app toolbar / panel headers.
|
|
146
|
+
*
|
|
147
|
+
* @param {{ left: number; top: number; height: number }} anchor
|
|
148
|
+
*/
|
|
149
|
+
function barPosition(anchor: {
|
|
150
|
+
left: number;
|
|
151
|
+
top: number;
|
|
152
|
+
height: number;
|
|
153
|
+
}): { left: number; top: number } | null {
|
|
154
|
+
if (canvasWrap) {
|
|
155
|
+
const wrap = rectOf(canvasWrap);
|
|
156
|
+
if (anchor.top + anchor.height < wrap.top || anchor.top > wrap.bottom) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
left: anchor.left,
|
|
162
|
+
top: anchor.top < 80 ? anchor.top + anchor.height + 4 : anchor.top - 38,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Pull the bar back inside the window's right edge.
|
|
168
|
+
*
|
|
169
|
+
* @param {HTMLElement} bar
|
|
170
|
+
*/
|
|
171
|
+
function clampBarToWindow(bar: HTMLElement): void {
|
|
172
|
+
const barRect = rectOf(bar);
|
|
173
|
+
if (barRect.right > window.innerWidth) {
|
|
174
|
+
bar.style.left = `${Math.max(0, window.innerWidth - barRect.width)}px`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Route Ctrl/Cmd+B/I/`/K to the iframe while an inline-edit session is live but focus is on the
|
|
180
|
+
* PARENT (the format toolbar or its link popover) — the keystroke never reaches the iframe's own
|
|
181
|
+
* contenteditable handler. When focus is inside the canvas iframe, do nothing (the iframe handles
|
|
182
|
+
* it and forwards globals via `forwardKey`). Exported so the unit test can dispatch it directly.
|
|
183
|
+
*
|
|
184
|
+
* @param {KeyboardEvent} e
|
|
185
|
+
*/
|
|
186
|
+
export function handleParentFormatShortcut(e: KeyboardEvent): void {
|
|
187
|
+
if (!(e.ctrlKey || e.metaKey) || e.altKey) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!getEditSnapshot().editing) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Focus inside the cross-origin canvas iframe surfaces as the <iframe> element being active.
|
|
194
|
+
const active = document.activeElement;
|
|
195
|
+
if (active instanceof HTMLIFrameElement && active.classList.contains("jx-canvas-iframe")) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const key = e.key.toLowerCase();
|
|
199
|
+
if (key === "b") {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
postApplyFormat({ command: "bold" });
|
|
202
|
+
} else if (key === "i") {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
postApplyFormat({ command: "italic" });
|
|
205
|
+
} else if (key === "`") {
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
postApplyFormat({ command: "code" });
|
|
208
|
+
} else if (key === "k") {
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
openLinkPopoverFromShortcut();
|
|
211
|
+
}
|
|
62
212
|
}
|
|
63
213
|
|
|
64
214
|
/** Pre-built icon templates for inline format buttons (avoids unsafeStatic) */
|
|
@@ -114,15 +264,10 @@ function onTagBadgeClick(e: MouseEvent, targets: SlashCommand[], selection: JxPa
|
|
|
114
264
|
});
|
|
115
265
|
}
|
|
116
266
|
|
|
117
|
-
/** Saved selection range for format button mousedown→click flow */
|
|
118
|
-
function captureSelectionRange() {
|
|
119
|
-
const sel = window.getSelection();
|
|
120
|
-
if (sel && sel.rangeCount) {
|
|
121
|
-
view.savedRange = sel.getRangeAt(0).cloneRange();
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
267
|
/**
|
|
268
|
+
* Handle a format-button click. The iframe owns the Selection — link opens the parent popover;
|
|
269
|
+
* every other command posts an `applyFormat` intent across the bridge.
|
|
270
|
+
*
|
|
126
271
|
* @param {MouseEvent} e
|
|
127
272
|
* @param {InlineAction} action
|
|
128
273
|
*/
|
|
@@ -130,18 +275,8 @@ function onFormatClick(e: MouseEvent, action: InlineAction) {
|
|
|
130
275
|
e.stopPropagation();
|
|
131
276
|
if (action.command === "link") {
|
|
132
277
|
showLinkPopover((e.target as HTMLElement).closest("sp-action-button") as HTMLElement);
|
|
133
|
-
} else if (
|
|
134
|
-
|
|
135
|
-
const anchor = view.savedRange.startContainer;
|
|
136
|
-
const editableRoot = (
|
|
137
|
-
anchor?.nodeType === Node.ELEMENT_NODE ? (anchor as Element) : anchor?.parentElement
|
|
138
|
-
)?.closest("[contenteditable]");
|
|
139
|
-
if (editableRoot) {
|
|
140
|
-
(editableRoot as HTMLElement).focus();
|
|
141
|
-
sel?.removeAllRanges();
|
|
142
|
-
sel?.addRange(view.savedRange);
|
|
143
|
-
applyInlineFormat(action);
|
|
144
|
-
}
|
|
278
|
+
} else if (action.command) {
|
|
279
|
+
postApplyFormat({ command: action.command as FormatCommand });
|
|
145
280
|
}
|
|
146
281
|
}
|
|
147
282
|
|
|
@@ -208,33 +343,10 @@ function renderMoveArrows() {
|
|
|
208
343
|
`;
|
|
209
344
|
}
|
|
210
345
|
|
|
211
|
-
/**
|
|
212
|
-
* Apply an inline format action.
|
|
213
|
-
*
|
|
214
|
-
* @param {InlineAction} action
|
|
215
|
-
*/
|
|
216
|
-
function applyInlineFormat(action: InlineAction) {
|
|
217
|
-
const cmdToTag: Record<string, string> = {
|
|
218
|
-
bold: "strong",
|
|
219
|
-
code: "code",
|
|
220
|
-
italic: "em",
|
|
221
|
-
strikethrough: "del",
|
|
222
|
-
subscript: "sub",
|
|
223
|
-
superscript: "sup",
|
|
224
|
-
underline: "u",
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
const tag = action.command ? cmdToTag[action.command] : undefined;
|
|
228
|
-
if (tag) {
|
|
229
|
-
const editableRoot = getActiveElement();
|
|
230
|
-
toggleInlineFormat(tag, editableRoot);
|
|
231
|
-
}
|
|
232
|
-
requestAnimationFrame(() => renderBlockActionBar());
|
|
233
|
-
}
|
|
234
|
-
|
|
235
346
|
/**
|
|
236
347
|
* Open the merge-tag menu — a searchable list of `${…}` template tokens for the data available in
|
|
237
348
|
* the current state. Reuses the shared slash-menu popover (filter + keyboard nav + dismiss).
|
|
349
|
+
* Selecting a token posts an `insertData` intent the iframe applies at its caret.
|
|
238
350
|
*
|
|
239
351
|
* @param {MouseEvent} e
|
|
240
352
|
*/
|
|
@@ -242,12 +354,26 @@ function onMergeTagClick(e: MouseEvent) {
|
|
|
242
354
|
e.stopPropagation();
|
|
243
355
|
const anchorEl = e.currentTarget as HTMLElement;
|
|
244
356
|
const tab = activeTab.value;
|
|
245
|
-
const editable = getActiveElement();
|
|
246
357
|
const state = (tab?.doc.document.state ?? {}) as Record<string, unknown>;
|
|
247
|
-
|
|
248
|
-
|
|
358
|
+
// The live resolved scope lives inside the iframe realm and is not threaded out, so the parent
|
|
359
|
+
// Offers only top-level `state.*` tokens (buildMergeTags tolerates the null scopes).
|
|
360
|
+
const tags = buildMergeTags(state, null, null);
|
|
361
|
+
|
|
362
|
+
// When the caret sits inside a repeater, offer its local scope (item/index + item fields). There is
|
|
363
|
+
// No live `$map` in edit mode — the perimeter renders one glyph template — so fields resolve
|
|
364
|
+
// Parent-side from schema, keyed off the selected element's doc path (which carries a `map` segment).
|
|
365
|
+
const selPath = getEditSnapshot().snapshot?.path ?? tab?.session.selection;
|
|
366
|
+
const arrayNode = tab && selPath ? findEnclosingRepeater(tab.doc.document, selPath) : null;
|
|
367
|
+
if (arrayNode) {
|
|
368
|
+
const tokens = resolveRepeaterItemFields(
|
|
369
|
+
arrayNode,
|
|
370
|
+
tab!.doc.document.state as Record<string, unknown>,
|
|
371
|
+
projectState?.projectConfig,
|
|
372
|
+
);
|
|
373
|
+
tags.push(...buildRepeaterTagsFromFields(tokens));
|
|
374
|
+
}
|
|
249
375
|
|
|
250
|
-
const commands =
|
|
376
|
+
const commands = tags.map((t) => ({
|
|
251
377
|
description: t.hint,
|
|
252
378
|
label: t.label,
|
|
253
379
|
tag: t.token,
|
|
@@ -255,39 +381,14 @@ function onMergeTagClick(e: MouseEvent) {
|
|
|
255
381
|
|
|
256
382
|
showSlashMenu(anchorEl, "", {
|
|
257
383
|
commands,
|
|
258
|
-
onSelect: (cmd) =>
|
|
384
|
+
onSelect: (cmd) => postApplyFormat({ command: "insertData", token: cmd.tag }),
|
|
259
385
|
showFilter: true,
|
|
260
386
|
});
|
|
261
387
|
}
|
|
262
388
|
|
|
263
|
-
/**
|
|
264
|
-
* Insert a `${token}` template expression at the saved selection inside the active contenteditable.
|
|
265
|
-
* Mirrors onFormatClick's range-restore flow and inserts via execCommand so it joins the
|
|
266
|
-
* contenteditable's native undo stack (like paste).
|
|
267
|
-
*
|
|
268
|
-
* @param {string} token
|
|
269
|
-
*/
|
|
270
|
-
function insertMergeTag(token: string) {
|
|
271
|
-
if (!view.savedRange) {
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
const anchor = view.savedRange.startContainer;
|
|
275
|
-
const editableRoot = (
|
|
276
|
-
anchor?.nodeType === Node.ELEMENT_NODE ? (anchor as Element) : anchor?.parentElement
|
|
277
|
-
)?.closest("[contenteditable]");
|
|
278
|
-
if (!editableRoot) {
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
const sel = window.getSelection();
|
|
282
|
-
(editableRoot as HTMLElement).focus();
|
|
283
|
-
sel?.removeAllRanges();
|
|
284
|
-
sel?.addRange(view.savedRange);
|
|
285
|
-
document.execCommand("insertText", false, `\${${token}}`);
|
|
286
|
-
requestAnimationFrame(() => renderBlockActionBar());
|
|
287
|
-
}
|
|
288
|
-
|
|
289
389
|
/** Dismiss the link popover if open. */
|
|
290
390
|
export function dismissLinkPopover() {
|
|
391
|
+
_linkPopoverOpen = false;
|
|
291
392
|
const host = getLayerSlot("popover", "link-popover");
|
|
292
393
|
litRender(nothing, host);
|
|
293
394
|
}
|
|
@@ -299,60 +400,81 @@ export function dismissBlockActionBar() {
|
|
|
299
400
|
}
|
|
300
401
|
}
|
|
301
402
|
|
|
302
|
-
/**
|
|
403
|
+
/**
|
|
404
|
+
* Whether the link popover is open. A snapshot-driven {@link renderBlockActionBar} must NOT
|
|
405
|
+
* re-render (and so re-mount) the open popover — typing the URL would re-create the field and lose
|
|
406
|
+
* focus/caret. Guarded around the toolbar re-render.
|
|
407
|
+
*/
|
|
408
|
+
let _linkPopoverOpen = false;
|
|
409
|
+
|
|
410
|
+
/** True while the link URL popover is open (so the toolbar refresh skips a disruptive re-render). */
|
|
411
|
+
export function isLinkPopoverOpen(): boolean {
|
|
412
|
+
return _linkPopoverOpen;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Whether `target` sits inside the edit-session chrome — the block action bar, its link popover, or
|
|
417
|
+
* the slash menu. The parent's pointerdown commit-guard must NOT end the inline-edit session for
|
|
418
|
+
* clicks on these (they operate ON the session); any other parent-chrome press commits it. This
|
|
419
|
+
* module owns those popover roots, hence the helper lives here.
|
|
420
|
+
*
|
|
421
|
+
* @param {EventTarget | null} target
|
|
422
|
+
*/
|
|
423
|
+
export function isEditChromeTarget(target: EventTarget | null): boolean {
|
|
424
|
+
if (!(target instanceof Node)) {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
const roots = [
|
|
428
|
+
view.blockActionBarEl,
|
|
429
|
+
getLayerSlot("popover", "link-popover"),
|
|
430
|
+
getLayerSlot("popover", "slash-menu"),
|
|
431
|
+
];
|
|
432
|
+
return roots.some((root) => root != null && root.contains(target));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Show the link URL popover. The iframe owns the Selection, so the existing-link state comes from
|
|
437
|
+
* the latest selection snapshot; Apply/Remove post `applyFormat` link intents the iframe applies.
|
|
438
|
+
*
|
|
439
|
+
* @param {HTMLElement} anchorBtn
|
|
440
|
+
*/
|
|
303
441
|
function showLinkPopover(anchorBtn: HTMLElement) {
|
|
304
442
|
const host = getLayerSlot("popover", "link-popover");
|
|
305
443
|
litRender(nothing, host);
|
|
306
444
|
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
if (sel?.rangeCount) {
|
|
310
|
-
let node: Node | null = sel.anchorNode;
|
|
311
|
-
while (node && node !== document.body) {
|
|
312
|
-
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "a") {
|
|
313
|
-
existingLink = node as HTMLAnchorElement;
|
|
314
|
-
break;
|
|
315
|
-
}
|
|
316
|
-
node = node.parentNode;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
445
|
+
const link = getEditSnapshot().snapshot?.link ?? { active: false, href: null };
|
|
446
|
+
const existing = link.active;
|
|
319
447
|
|
|
320
|
-
const rect = anchorBtn
|
|
448
|
+
const rect = rectOf(anchorBtn);
|
|
321
449
|
|
|
322
450
|
let _linkField: HTMLInputElement | null = null;
|
|
323
451
|
|
|
452
|
+
const close = () => {
|
|
453
|
+
_linkPopoverOpen = false;
|
|
454
|
+
litRender(nothing, host);
|
|
455
|
+
};
|
|
456
|
+
|
|
324
457
|
const onApply = () => {
|
|
325
458
|
const url = _linkField?.value || "";
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
document.execCommand("createLink", false, url);
|
|
330
|
-
}
|
|
331
|
-
litRender(nothing, host);
|
|
332
|
-
renderBlockActionBar();
|
|
459
|
+
// Apply then let the popover close itself (do not steal focus back into the iframe here).
|
|
460
|
+
postApplyFormat({ command: "link", href: url || "" });
|
|
461
|
+
close();
|
|
333
462
|
};
|
|
334
463
|
|
|
335
464
|
const onRemove = () => {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
const frag = document.createDocumentFragment();
|
|
340
|
-
while (existingLink.firstChild) {
|
|
341
|
-
frag.append(existingLink.firstChild);
|
|
342
|
-
}
|
|
343
|
-
existingLink.parentNode.replaceChild(frag, existingLink);
|
|
344
|
-
litRender(nothing, host);
|
|
345
|
-
renderBlockActionBar();
|
|
465
|
+
postApplyFormat({ command: "link", href: null });
|
|
466
|
+
close();
|
|
346
467
|
};
|
|
347
468
|
|
|
348
469
|
const onKeydown = (e: KeyboardEvent) => {
|
|
349
470
|
if (e.key === "Enter") {
|
|
350
471
|
onApply();
|
|
351
472
|
} else if (e.key === "Escape") {
|
|
352
|
-
|
|
473
|
+
close();
|
|
353
474
|
}
|
|
354
475
|
};
|
|
355
476
|
|
|
477
|
+
_linkPopoverOpen = true;
|
|
356
478
|
litRender(
|
|
357
479
|
html`
|
|
358
480
|
<sp-popover
|
|
@@ -369,7 +491,7 @@ function showLinkPopover(anchorBtn: HTMLElement) {
|
|
|
369
491
|
placeholder="https://..."
|
|
370
492
|
size="s"
|
|
371
493
|
style="width:200px"
|
|
372
|
-
value=${
|
|
494
|
+
value=${link.href || ""}
|
|
373
495
|
@keydown=${onKeydown}
|
|
374
496
|
${ref((el) => {
|
|
375
497
|
_linkField = (el as HTMLInputElement | null) || null;
|
|
@@ -379,9 +501,9 @@ function showLinkPopover(anchorBtn: HTMLElement) {
|
|
|
379
501
|
})}
|
|
380
502
|
></sp-textfield>
|
|
381
503
|
<sp-action-button size="xs" @click=${onApply}>
|
|
382
|
-
${
|
|
504
|
+
${existing ? "Update" : "Apply"}
|
|
383
505
|
</sp-action-button>
|
|
384
|
-
${
|
|
506
|
+
${existing
|
|
385
507
|
? html` <sp-action-button size="xs" @click=${onRemove}>Remove</sp-action-button> `
|
|
386
508
|
: nothing}
|
|
387
509
|
</sp-popover>
|
|
@@ -390,6 +512,19 @@ function showLinkPopover(anchorBtn: HTMLElement) {
|
|
|
390
512
|
);
|
|
391
513
|
}
|
|
392
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Open the link popover from the Ctrl/Cmd+K shortcut (anchored to the toolbar's Link button if it
|
|
517
|
+
* is on screen, else the bar itself). Used by the parent-focus format-shortcut handler.
|
|
518
|
+
*/
|
|
519
|
+
export function openLinkPopoverFromShortcut(): void {
|
|
520
|
+
const bar = view.blockActionBarEl?.querySelector(".block-action-bar") as HTMLElement | null;
|
|
521
|
+
const linkBtn =
|
|
522
|
+
(bar?.querySelector('sp-action-button[title^="Link"]') as HTMLElement | null) ?? bar;
|
|
523
|
+
if (linkBtn) {
|
|
524
|
+
showLinkPopover(linkBtn);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
393
528
|
/** Move the selected node up (swap with previous sibling). */
|
|
394
529
|
function moveSelectionUp() {
|
|
395
530
|
const tab = activeTab.value;
|
|
@@ -446,30 +581,41 @@ export function renderBlockActionBar() {
|
|
|
446
581
|
return;
|
|
447
582
|
}
|
|
448
583
|
|
|
584
|
+
// A snapshot-driven refresh must not re-mount an open link popover (it would re-create the URL
|
|
585
|
+
// Field and lose the caret) — preserve it by skipping this render pass.
|
|
586
|
+
if (_linkPopoverOpen) {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
449
590
|
const { selection } = tab.session;
|
|
450
|
-
const
|
|
451
|
-
if (!
|
|
591
|
+
const node = getNodeAtPath(tab.doc.document, selection);
|
|
592
|
+
if (!node) {
|
|
452
593
|
litRender(nothing, view.blockActionBarEl);
|
|
453
594
|
return;
|
|
454
595
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
596
|
+
|
|
597
|
+
// Position from the iframe-host's viewport-space anchor (the bar is position:fixed). The parent
|
|
598
|
+
// Never reads the iframe DOM, so geometry crosses the bridge as the selection snapshot's rect.
|
|
599
|
+
const anchor = getEditBarAnchorRect();
|
|
600
|
+
const pos = anchor ? barPosition(anchor) : null;
|
|
601
|
+
if (!pos) {
|
|
458
602
|
litRender(nothing, view.blockActionBarEl);
|
|
459
603
|
return;
|
|
460
604
|
}
|
|
461
605
|
|
|
462
606
|
const tag = (node.tagName ?? "div").toLowerCase();
|
|
463
|
-
const elRect = el.getBoundingClientRect();
|
|
464
|
-
const topPos = elRect.top < 80 ? elRect.bottom + 4 : elRect.top - 38;
|
|
465
607
|
|
|
466
|
-
// Inline format state
|
|
467
|
-
const
|
|
608
|
+
// Inline format state, sourced from the iframe's selection snapshot.
|
|
609
|
+
const { editing, snapshot } = getEditSnapshot();
|
|
610
|
+
const inlineEditing = editing;
|
|
468
611
|
const actions = getInlineActions(tag) || [];
|
|
469
612
|
const showFormat = inlineEditing && actions.length > 0;
|
|
470
|
-
const activeValues =
|
|
471
|
-
|
|
472
|
-
|
|
613
|
+
const activeValues =
|
|
614
|
+
showFormat && snapshot
|
|
615
|
+
? actions.filter((a) => snapshot.activeTags.includes(a.tag)).map((a) => a.tag)
|
|
616
|
+
: [];
|
|
617
|
+
// A collapsed caret can't format a range — disable format buttons (link/insertData stay enabled).
|
|
618
|
+
const formatDisabled = snapshot?.collapsed ?? false;
|
|
473
619
|
|
|
474
620
|
// Conversion targets for badge click
|
|
475
621
|
const isComponent =
|
|
@@ -480,14 +626,17 @@ export function renderBlockActionBar() {
|
|
|
480
626
|
!node.textContent &&
|
|
481
627
|
(children.length === 0 ||
|
|
482
628
|
(children.length === 1 && typeof children[0] === "object" && children[0]?.tagName === "br"));
|
|
483
|
-
|
|
629
|
+
// Repeater ($prototype:"Array") pseudo-elements have no tagName — show the "Repeater → items" label
|
|
630
|
+
// (not a bare "div") and don't offer tag-conversion targets, which are meaningless for a repeater.
|
|
631
|
+
const isRepeater = node.$prototype === "Array";
|
|
632
|
+
const convertTargets = !isComponent && !isRepeater ? getConvertTargets(tag, isEmpty) : [];
|
|
484
633
|
const badgeInteractive = convertTargets.length > 0;
|
|
485
634
|
|
|
486
635
|
litRender(
|
|
487
636
|
html`
|
|
488
637
|
<div
|
|
489
638
|
class="block-action-bar"
|
|
490
|
-
style=${styleMap({ left: `${
|
|
639
|
+
style=${styleMap({ left: `${pos.left}px`, top: `${pos.top}px` })}
|
|
491
640
|
@mousedown=${onBarMousedown}
|
|
492
641
|
>
|
|
493
642
|
${selection.length >= 2 ? renderParentSelector() : nothing}
|
|
@@ -497,7 +646,7 @@ export function renderBlockActionBar() {
|
|
|
497
646
|
@click=${badgeInteractive
|
|
498
647
|
? (e: MouseEvent) => onTagBadgeClick(e, convertTargets, selection)
|
|
499
648
|
: nothing}
|
|
500
|
-
>${node.$id || (node.tagName ?? "div")}</span
|
|
649
|
+
>${isRepeater ? nodeLabel(node) : node.$id || (node.tagName ?? "div")}</span
|
|
501
650
|
>
|
|
502
651
|
|
|
503
652
|
${selection.length >= 2
|
|
@@ -515,9 +664,21 @@ export function renderBlockActionBar() {
|
|
|
515
664
|
view.selDragCleanup = draggable({
|
|
516
665
|
element: handleEl as HTMLElement,
|
|
517
666
|
getInitialData: () => ({
|
|
518
|
-
|
|
667
|
+
// Snapshot the selection: the live array is a Vue reactive proxy, which
|
|
668
|
+
// Structured clone rejects when the src crosses postMessage (DataCloneError
|
|
669
|
+
// Killed the whole handle drag), and a live reference would also mutate the
|
|
670
|
+
// Retained srcData if the selection changed mid-drag.
|
|
671
|
+
path: [...(activeTab.value?.session.selection ?? [])],
|
|
519
672
|
type: "tree-node",
|
|
520
673
|
}),
|
|
674
|
+
onGenerateDragPreview: ({
|
|
675
|
+
nativeSetDragImage,
|
|
676
|
+
}: {
|
|
677
|
+
nativeSetDragImage: ((image: Element, x: number, y: number) => void) | null;
|
|
678
|
+
}) => {
|
|
679
|
+
// Suppress the native drag image; the cross-frame ghost is the drag affordance.
|
|
680
|
+
disableNativeDragPreview({ nativeSetDragImage });
|
|
681
|
+
},
|
|
521
682
|
});
|
|
522
683
|
})}
|
|
523
684
|
>⠿</span
|
|
@@ -569,7 +730,8 @@ export function renderBlockActionBar() {
|
|
|
569
730
|
size="xs"
|
|
570
731
|
value=${action.tag}
|
|
571
732
|
title="${action.label}${action.shortcut ? ` (${action.shortcut})` : ""}"
|
|
572
|
-
|
|
733
|
+
?disabled=${formatDisabled && action.command !== "link"}
|
|
734
|
+
@mousedown=${(e: MouseEvent) => e.preventDefault()}
|
|
573
735
|
@click=${(e: MouseEvent) => onFormatClick(e, action)}
|
|
574
736
|
>
|
|
575
737
|
${action.icon ? (formatIconMap[action.icon] ?? nothing) : nothing}
|
|
@@ -581,7 +743,7 @@ export function renderBlockActionBar() {
|
|
|
581
743
|
size="xs"
|
|
582
744
|
quiet
|
|
583
745
|
title="Insert data"
|
|
584
|
-
@mousedown=${
|
|
746
|
+
@mousedown=${(e: MouseEvent) => e.preventDefault()}
|
|
585
747
|
@click=${onMergeTagClick}
|
|
586
748
|
>
|
|
587
749
|
<sp-icon-data slot="icon"></sp-icon-data>
|
|
@@ -599,10 +761,6 @@ export function renderBlockActionBar() {
|
|
|
599
761
|
if (!bar) {
|
|
600
762
|
return;
|
|
601
763
|
}
|
|
602
|
-
|
|
603
|
-
const barRect = bar.getBoundingClientRect();
|
|
604
|
-
if (barRect.right > window.innerWidth) {
|
|
605
|
-
bar.style.left = `${Math.max(0, window.innerWidth - barRect.width)}px`;
|
|
606
|
-
}
|
|
764
|
+
clampBarToWindow(bar);
|
|
607
765
|
});
|
|
608
766
|
}
|