@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,86 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Render-path mapping — pure logic shared by the legacy in-realm path mapper (which stores paths in
|
|
4
|
+
* the `elToPath` WeakMap) and the iframe host (which stamps them as `data-jx-path` attributes).
|
|
5
|
+
*
|
|
6
|
+
* The runtime fires `onNodeCreated(el, renderPath, def, state)` for every node. The render path is
|
|
7
|
+
* not always the document path: layout-wrapped pages shift page content under a layout prefix, and
|
|
8
|
+
* `prepareForEditMode` renders mapped arrays (`$prototype: Array`) as a `repeater-perimeter` whose
|
|
9
|
+
* single child hop must be collapsed back to a `map` segment. {@link classifyRenderNode} resolves a
|
|
10
|
+
* render path to either a document path or a "layout" marker; {@link serializeJxPath} encodes the
|
|
11
|
+
* result for the `data-jx-path` attribute.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { JxPath } from "../state";
|
|
15
|
+
|
|
16
|
+
/** Context the mapper needs, computed once per render by whoever prepared the document. */
|
|
17
|
+
export interface PathMapCtx {
|
|
18
|
+
canvasMode: string;
|
|
19
|
+
layoutWrapped: boolean;
|
|
20
|
+
pageContentPrefix: JxPath | null;
|
|
21
|
+
pageContentOffset: number | null;
|
|
22
|
+
arrayPaths: Set<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A render node either belongs to the layout chrome (no document path) or maps to a document path. */
|
|
26
|
+
export type RenderNodeClass = { kind: "layout" } | { kind: "path"; path: JxPath };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a runtime render path to a document path (or flag it as layout-originated). Pure — the
|
|
30
|
+
* caller decides whether to record the result in a WeakMap or a `data-jx-path` attribute.
|
|
31
|
+
*/
|
|
32
|
+
export function classifyRenderNode(path: JxPath, def: unknown, ctx: PathMapCtx): RenderNodeClass {
|
|
33
|
+
// Layout-originated nodes have no page-document path; the caller marks them (data-jx-layout).
|
|
34
|
+
if (ctx.layoutWrapped && typeof def === "object" && (def as { $__layout?: unknown })?.$__layout) {
|
|
35
|
+
return { kind: "layout" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let mappedPath = path;
|
|
39
|
+
|
|
40
|
+
// Strip the layout prefix so paths are relative to the original page document.
|
|
41
|
+
// Page children render at indices [offset, offset+1, …] when the layout places siblings before
|
|
42
|
+
// The <slot>, so subtract the offset to recover the page's 0-based child indices.
|
|
43
|
+
if (ctx.layoutWrapped && ctx.pageContentPrefix) {
|
|
44
|
+
const pfx = ctx.pageContentPrefix;
|
|
45
|
+
if (path.length >= pfx.length && pfx.every((seg, i) => path[i] === seg)) {
|
|
46
|
+
const rest = path.slice(pfx.length);
|
|
47
|
+
const [containerIdx] = rest;
|
|
48
|
+
mappedPath =
|
|
49
|
+
typeof containerIdx === "number"
|
|
50
|
+
? ["children", containerIdx - (ctx.pageContentOffset ?? 0), ...rest.slice(1)]
|
|
51
|
+
: ["children", ...rest];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Collapse repeater-perimeter template hops: for an array document path P, a render path of
|
|
56
|
+
// `[...P, "children", 0, ...rest]` maps to `[...P, "map", ...rest]`. Loop for nested repeaters.
|
|
57
|
+
if ((ctx.canvasMode === "design" || ctx.canvasMode === "edit") && ctx.arrayPaths.size > 0) {
|
|
58
|
+
let changed = true;
|
|
59
|
+
while (changed) {
|
|
60
|
+
changed = false;
|
|
61
|
+
for (let i = 1; i < mappedPath.length - 1; i++) {
|
|
62
|
+
if (
|
|
63
|
+
mappedPath[i] === "children" &&
|
|
64
|
+
mappedPath[i + 1] === 0 &&
|
|
65
|
+
ctx.arrayPaths.has(mappedPath.slice(0, i).join("/"))
|
|
66
|
+
) {
|
|
67
|
+
mappedPath = [...mappedPath.slice(0, i), "map", ...mappedPath.slice(i + 2)];
|
|
68
|
+
changed = true;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { kind: "path", path: mappedPath };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Encode a document path for the `data-jx-path` attribute (preserves string-vs-number segments). */
|
|
79
|
+
export function serializeJxPath(path: JxPath): string {
|
|
80
|
+
return JSON.stringify(path);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Decode a `data-jx-path` attribute back into a document path. */
|
|
84
|
+
export function parseJxPath(serialized: string): JxPath {
|
|
85
|
+
return JSON.parse(serialized) as JxPath;
|
|
86
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize the iframe's resolved `$defs` scope (buildScope's output) into a structured-clone-safe
|
|
3
|
+
* plain object the iframe can postMessage to the parent editor. The data-explorer panel reads this
|
|
4
|
+
* from `S.canvas.scope` to show each data source's live value; the iframe (not the parent) resolves
|
|
5
|
+
* the scope since the iframe canvas migration moved buildScope into the iframe realm.
|
|
6
|
+
*
|
|
7
|
+
* The input is the resolved `$defs` — typically a Vue reactive proxy. Reading `defs[key]`
|
|
8
|
+
* auto-unwraps a top-level `ref`/`computed` to its current value; a JSON round-trip then reads
|
|
9
|
+
* THROUGH any nested reactive proxies and drops non-cloneable residue (nested functions, symbols,
|
|
10
|
+
* proxy artifacts) so the result crosses postMessage without a DataCloneError.
|
|
11
|
+
*
|
|
12
|
+
* Pure and DOM-free — no reactivity/runtime imports — so it stays trivially unit-testable.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-value size cap (in JSON-string chars). A single large content collection can be tens of KB;
|
|
17
|
+
* past this cap we store a small placeholder so one big data source can't bloat every render's
|
|
18
|
+
* postMessage. Generous on purpose — most scope values are far smaller.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_VALUE_CHARS = 256_000;
|
|
21
|
+
|
|
22
|
+
/** The placeholder stored in place of a value whose JSON string exceeds {@link MAX_VALUE_CHARS}. */
|
|
23
|
+
const LARGE_VALUE_PLACEHOLDER = "[large value omitted]";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Produce a structured-clone-safe snapshot of `defs` for the data-explorer.
|
|
27
|
+
*
|
|
28
|
+
* For each own enumerable key: - functions (`typeof v === "function"`) are SKIPPED entirely
|
|
29
|
+
* (handlers/server fns aren't data); - other values are JSON deep-cloned
|
|
30
|
+
* (`JSON.parse(JSON.stringify(v))`) inside try/catch — a throw (e.g. a computed getter that throws)
|
|
31
|
+
* or a circular/unserializable value maps the key to `null`; - a value whose JSON string exceeds
|
|
32
|
+
* the size cap is replaced with a short placeholder string.
|
|
33
|
+
*
|
|
34
|
+
* @param defs The resolved `$defs` scope (may be a Vue reactive proxy).
|
|
35
|
+
* @returns A plain object safe to `postMessage` (every value is JSON-round-trippable).
|
|
36
|
+
*/
|
|
37
|
+
export function serializeDataScope(defs: Record<string, unknown>): Record<string, unknown> {
|
|
38
|
+
const out: Record<string, unknown> = {};
|
|
39
|
+
for (const key of Object.keys(defs)) {
|
|
40
|
+
try {
|
|
41
|
+
// Reading through the reactive proxy auto-unwraps a top-level ref/computed to its value, and
|
|
42
|
+
// Can THROW (a computed getter that throws) — done inside the try so that maps the key to null
|
|
43
|
+
// Rather than aborting the whole snapshot.
|
|
44
|
+
const value = defs[key];
|
|
45
|
+
if (typeof value === "function") {
|
|
46
|
+
continue; // Data sources only — a resolved server/handler fn isn't a "value".
|
|
47
|
+
}
|
|
48
|
+
const json = JSON.stringify(value);
|
|
49
|
+
if (json === undefined) {
|
|
50
|
+
// JSON.stringify returns undefined for a bare function/symbol/undefined at the top level.
|
|
51
|
+
out[key] = null;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (json.length > MAX_VALUE_CHARS) {
|
|
55
|
+
out[key] = LARGE_VALUE_PLACEHOLDER;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
out[key] = JSON.parse(json) as unknown;
|
|
59
|
+
} catch {
|
|
60
|
+
// A circular structure or a throwing getter → the key exists but carries no serializable value.
|
|
61
|
+
out[key] = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Parent-realm adapter for canvas right-clicks: the iframe suppresses the browser menu and posts
|
|
4
|
+
* `contextMenu` with the nearest element path + iframe-viewport coords; the host converts to
|
|
5
|
+
* parent-viewport coords and calls this handler, which shows the Jx element context menu (copy /
|
|
6
|
+
* paste / insert before/after / convert / delete…). Replaces the legacy panel-events canvas
|
|
7
|
+
* contextmenu handler that died with the in-parent <div> canvas.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { dismissContextMenu, showContextMenu } from "./context-menu";
|
|
11
|
+
import { bubbleInlinePath } from "../canvas/canvas-helpers";
|
|
12
|
+
import { activeTab } from "../workspace/workspace";
|
|
13
|
+
import type { CanvasContextMenuHandler } from "../canvas/iframe-host";
|
|
14
|
+
import type { JxPath } from "../state";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build the handler with its studio-owned dependency (component navigation), mirroring the other
|
|
18
|
+
* host DI factories.
|
|
19
|
+
*/
|
|
20
|
+
export function makeCanvasContextMenuHandler(deps: {
|
|
21
|
+
navigateToComponent: (path: string) => void | Promise<void>;
|
|
22
|
+
}): CanvasContextMenuHandler {
|
|
23
|
+
return {
|
|
24
|
+
dismiss: () => dismissContextMenu(),
|
|
25
|
+
show: ({ path, clientX, clientY }) => {
|
|
26
|
+
if (!path) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Lift inline-element paths (a <strong> inside a <p>) to their block parent — right-clicking
|
|
30
|
+
// Formatted text should act on the block, matching the legacy handler.
|
|
31
|
+
const bubbled = bubbleInlinePath(activeTab.value?.doc.document, path as JxPath);
|
|
32
|
+
// ShowContextMenu reads only preventDefault/clientX/clientY from the event, so a synthetic
|
|
33
|
+
// MouseEvent carries the converted coords safely.
|
|
34
|
+
const evt = new MouseEvent("contextmenu", { clientX, clientY });
|
|
35
|
+
showContextMenu(evt, bubbled, {
|
|
36
|
+
onEditComponent: (p) => void deps.navigateToComponent(p),
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Parent-realm adapter between the canvas iframe's slash-menu bridge and the real lit/Spectrum
|
|
4
|
+
* menu. The iframe engine detects "/", its {@link file://../canvas/iframe-slash.ts} controller posts
|
|
5
|
+
* `slashShow`/`slashNav`/`slashDismiss`, the host converts coordinates and calls this handler;
|
|
6
|
+
* select/dismiss flow back over the same channel as `slashSelect`/`slashDismissed`. Registered via
|
|
7
|
+
* {@link setCanvasSlashHandler} in studio.ts (DI keeps the host module free of the menu's lit
|
|
8
|
+
* dependencies).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { dismissSlashMenu, handleSlashMenuKey, showSlashMenuAtRect } from "./slash-menu";
|
|
12
|
+
import type { CanvasSlashHandler } from "../canvas/iframe-host";
|
|
13
|
+
|
|
14
|
+
export const canvasSlashHandler: CanvasSlashHandler = {
|
|
15
|
+
dismiss: () => dismissSlashMenu(),
|
|
16
|
+
nav: (key) => handleSlashMenuKey(key),
|
|
17
|
+
// No filter input: the author keeps typing in the iframe contenteditable and the engine re-posts
|
|
18
|
+
// SlashShow with the updated filter.
|
|
19
|
+
show: ({ rect, filter, onSelect, onDismiss }) =>
|
|
20
|
+
showSlashMenuAtRect(rect, filter, { onDismiss, onSelect }),
|
|
21
|
+
};
|
|
@@ -19,6 +19,7 @@ import { convertToComponent } from "./convert-to-component";
|
|
|
19
19
|
import { convertToRepeater } from "./convert-to-repeater";
|
|
20
20
|
import { componentRegistry } from "../files/components";
|
|
21
21
|
import { renderPopover } from "../ui/layers";
|
|
22
|
+
import { rectOf } from "../utils/geometry";
|
|
22
23
|
|
|
23
24
|
import type { JxPath } from "../state";
|
|
24
25
|
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
@@ -445,7 +446,7 @@ export function showContextMenu(
|
|
|
445
446
|
}
|
|
446
447
|
requestAnimationFrame(() => {
|
|
447
448
|
const popover = el as HTMLElement;
|
|
448
|
-
const menuRect = popover
|
|
449
|
+
const menuRect = rectOf(popover);
|
|
449
450
|
if (x + menuRect.width > window.innerWidth) {
|
|
450
451
|
x = window.innerWidth - menuRect.width - 4;
|
|
451
452
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Inline-edit apply logic — turns the serializable result of a contenteditable session (committed
|
|
4
|
+
* children/text, a split, or a slash-insert) into `transactDoc` mutations. The inline editing
|
|
5
|
+
* session lives inside the canvas iframe, which posts these plain-JSON results across the bridge
|
|
6
|
+
* for this apply. Every input here is plain JSON — no DOM, no Range — so it crosses the frame
|
|
7
|
+
* boundary.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { childIndex, getNodeAtPath, parentElementPath } from "../store";
|
|
11
|
+
import { isTabActive } from "../workspace/workspace";
|
|
12
|
+
import { mutateInsertNode, mutateUpdateProperty, transactDoc } from "../tabs/transact";
|
|
13
|
+
import { defaultDef } from "../panels/shared";
|
|
14
|
+
|
|
15
|
+
import type { JxContentResult, SlashCommand } from "./inline-edit";
|
|
16
|
+
import type { JxPath } from "../state";
|
|
17
|
+
import type { Tab } from "../tabs/tab";
|
|
18
|
+
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a committed inline-edit result is effectively empty (blank text / lone whitespace /
|
|
22
|
+
* `<br>`).
|
|
23
|
+
*/
|
|
24
|
+
export function isEmptyContent(commitData?: JxContentResult): boolean {
|
|
25
|
+
if (!commitData) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (commitData.textContent != null && commitData.textContent.trim() === "") {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
const kids = commitData.children;
|
|
32
|
+
if (!kids) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (kids.length === 0) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (kids.length === 1 && typeof kids[0] === "string" && kids[0].trim() === "") {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return kids.length === 1 && typeof kids[0] === "object" && kids[0]?.tagName === "br";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Commit edited content to the node at `path` of `tab`'s document (children when rich, else
|
|
46
|
+
* textContent). No-op if unchanged or the originating tab is gone. `tab` is the tab the edit
|
|
47
|
+
* session belonged to (resolved host-side from the posting iframe) — NEVER the active tab at
|
|
48
|
+
* message time, which may have changed while the commit was in flight.
|
|
49
|
+
*/
|
|
50
|
+
export function applyInlineCommit(
|
|
51
|
+
tab: Tab | null,
|
|
52
|
+
path: JxPath,
|
|
53
|
+
children: (JxMutableNode | string)[] | null,
|
|
54
|
+
textContent: string | null,
|
|
55
|
+
): void {
|
|
56
|
+
if (!tab) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const node = getNodeAtPath(tab.doc.document, path);
|
|
60
|
+
// Only clear the counterpart key when it actually exists: deleting an absent key is a semantic
|
|
61
|
+
// No-op, but the recorded op isn't — a spurious `set-prop children` turns a cheap in-place text
|
|
62
|
+
// Patch into a subtree re-render (and, pre-relaxation, forced a full render inside components).
|
|
63
|
+
if (children) {
|
|
64
|
+
if (node && JSON.stringify(node.children) === JSON.stringify(children)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
transactDoc(tab, (t) => {
|
|
68
|
+
if (node?.textContent != null) {
|
|
69
|
+
mutateUpdateProperty(t, path, "textContent");
|
|
70
|
+
}
|
|
71
|
+
mutateUpdateProperty(t, path, "children", children);
|
|
72
|
+
});
|
|
73
|
+
} else if (textContent != null) {
|
|
74
|
+
if (node && node.textContent === textContent && !node.children) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
transactDoc(tab, (t) => {
|
|
78
|
+
if (node?.children) {
|
|
79
|
+
mutateUpdateProperty(t, path, "children");
|
|
80
|
+
}
|
|
81
|
+
mutateUpdateProperty(t, path, "textContent", textContent);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Apply a paragraph split to `tab`'s document: keep `before` in the node, insert a new `<p>` with
|
|
88
|
+
* `after`. Returns its path (unchanged when `tab` is gone — the caller may still use it for
|
|
89
|
+
* bookkeeping, but nothing is mutated).
|
|
90
|
+
*/
|
|
91
|
+
export function applyInlineSplit(
|
|
92
|
+
tab: Tab | null,
|
|
93
|
+
path: JxPath,
|
|
94
|
+
before: JxContentResult,
|
|
95
|
+
after: JxContentResult,
|
|
96
|
+
): JxPath {
|
|
97
|
+
const parentPath = parentElementPath(path) as JxPath;
|
|
98
|
+
const idx = childIndex(path) as number;
|
|
99
|
+
const newPath = [...parentPath, "children", idx + 1];
|
|
100
|
+
if (!tab) {
|
|
101
|
+
return newPath;
|
|
102
|
+
}
|
|
103
|
+
const newNode: JxMutableNode = { tagName: "p" };
|
|
104
|
+
if (after.textContent != null) {
|
|
105
|
+
newNode.textContent = after.textContent;
|
|
106
|
+
} else if (after.children) {
|
|
107
|
+
newNode.children = after.children;
|
|
108
|
+
} else {
|
|
109
|
+
newNode.textContent = "";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
transactDoc(tab, (t) => {
|
|
113
|
+
if (before.textContent != null) {
|
|
114
|
+
mutateUpdateProperty(t, path, "children");
|
|
115
|
+
mutateUpdateProperty(t, path, "textContent", before.textContent);
|
|
116
|
+
} else if (before.children) {
|
|
117
|
+
mutateUpdateProperty(t, path, "textContent");
|
|
118
|
+
mutateUpdateProperty(t, path, "children", before.children);
|
|
119
|
+
}
|
|
120
|
+
mutateInsertNode(t, parentPath, idx + 1, newNode);
|
|
121
|
+
// A background tab's selection stays exactly as the user left it — only the visible tab's
|
|
122
|
+
// Selection follows the split.
|
|
123
|
+
if (isTabActive(tab)) {
|
|
124
|
+
t.session.selection = newPath;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
return newPath;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Apply a slash-insert at `path` of `tab`'s document: swap the (empty) node's tag in place, or
|
|
132
|
+
* commit pending content and insert a new element after it. Returns the path to edit next (the
|
|
133
|
+
* swapped node or the new one); nothing is mutated when `tab` is gone.
|
|
134
|
+
*/
|
|
135
|
+
export function applyInlineInsert(
|
|
136
|
+
tab: Tab | null,
|
|
137
|
+
path: JxPath,
|
|
138
|
+
cmd: SlashCommand,
|
|
139
|
+
commitData: JxContentResult | undefined,
|
|
140
|
+
): JxPath {
|
|
141
|
+
if (isEmptyContent(commitData)) {
|
|
142
|
+
if (!tab) {
|
|
143
|
+
return path;
|
|
144
|
+
}
|
|
145
|
+
transactDoc(tab, (t) => {
|
|
146
|
+
mutateUpdateProperty(t, path, "tagName", cmd.tag);
|
|
147
|
+
mutateUpdateProperty(t, path, "children");
|
|
148
|
+
const def = defaultDef(cmd.tag);
|
|
149
|
+
if (def.textContent && def.textContent !== "Paragraph text") {
|
|
150
|
+
mutateUpdateProperty(t, path, "textContent", def.textContent);
|
|
151
|
+
} else {
|
|
152
|
+
mutateUpdateProperty(t, path, "textContent");
|
|
153
|
+
}
|
|
154
|
+
if (isTabActive(tab)) {
|
|
155
|
+
t.session.selection = path;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return path;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const parentPath = parentElementPath(path) as JxPath;
|
|
162
|
+
const idx = childIndex(path) as number;
|
|
163
|
+
const newPath = [...parentPath, "children", idx + 1];
|
|
164
|
+
if (!tab) {
|
|
165
|
+
return newPath;
|
|
166
|
+
}
|
|
167
|
+
const elementDef = defaultDef(cmd.tag);
|
|
168
|
+
|
|
169
|
+
transactDoc(tab, (t) => {
|
|
170
|
+
if (commitData?.children) {
|
|
171
|
+
mutateUpdateProperty(t, path, "textContent");
|
|
172
|
+
mutateUpdateProperty(t, path, "children", commitData.children);
|
|
173
|
+
} else if (commitData?.textContent != null) {
|
|
174
|
+
mutateUpdateProperty(t, path, "children");
|
|
175
|
+
mutateUpdateProperty(t, path, "textContent", commitData.textContent);
|
|
176
|
+
}
|
|
177
|
+
mutateInsertNode(t, parentPath, idx + 1, structuredClone(elementDef));
|
|
178
|
+
if (isTabActive(tab)) {
|
|
179
|
+
t.session.selection = newPath;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return newPath;
|
|
183
|
+
}
|
|
@@ -12,11 +12,37 @@ import { normalizeInlineContent, toggleInlineFormat } from "./inline-format";
|
|
|
12
12
|
import type { JxPath } from "../state";
|
|
13
13
|
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The slash-menu surface inline editing drives. Injected (rather than imported) so this module
|
|
17
|
+
* stays free of the menu's heavy `lit-html` / `ui/layers` dependencies — the editor realm wires the
|
|
18
|
+
* real menu, the canvas iframe supplies its own (or a no-op) without bloating the slim iframe
|
|
19
|
+
* bundle.
|
|
20
|
+
*/
|
|
21
|
+
export interface SlashController {
|
|
22
|
+
show: (
|
|
23
|
+
anchorEl: HTMLElement,
|
|
24
|
+
filter: string,
|
|
25
|
+
cbs: { onSelect: (cmd: SlashCommand) => void; showFilter?: boolean; commands?: SlashCommand[] },
|
|
26
|
+
) => void;
|
|
27
|
+
dismiss: () => void;
|
|
28
|
+
isOpen: () => boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const noopSlash: SlashController = {
|
|
32
|
+
dismiss: () => {
|
|
33
|
+
// No slash controller wired in this realm.
|
|
34
|
+
},
|
|
35
|
+
isOpen: () => false,
|
|
36
|
+
show: () => {
|
|
37
|
+
// No slash controller wired in this realm.
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
let slash: SlashController = noopSlash;
|
|
41
|
+
|
|
42
|
+
/** Inject the slash-menu controller inline editing should drive (call once at realm init). */
|
|
43
|
+
export function setSlashController(controller: SlashController): void {
|
|
44
|
+
slash = controller;
|
|
45
|
+
}
|
|
20
46
|
|
|
21
47
|
export interface InlineAction {
|
|
22
48
|
tag: string;
|
|
@@ -159,6 +185,24 @@ let insertFn:
|
|
|
159
185
|
| null = null; // Function(path, elementDef, commitData?) to insert after current block
|
|
160
186
|
let endFn: (() => void) | null = null; // Function() called when editing stops
|
|
161
187
|
|
|
188
|
+
/**
|
|
189
|
+
* When a parent toolbar (or its link popover) may take focus during a live session, the iframe sets
|
|
190
|
+
* this so {@link handleBlur} does NOT schedule a `stopEditing()` (the BLOCKER focus-loss race fix —
|
|
191
|
+
* a parent-toolbar click blurs the iframe editable, which must NOT tear the session down). Only an
|
|
192
|
+
* explicit commit (Escape/Enter/click-away-in-iframe) ends the session while suspended.
|
|
193
|
+
*/
|
|
194
|
+
let _blurCloseSuspended = false;
|
|
195
|
+
|
|
196
|
+
/** Suspend blur-driven `stopEditing` (call while the parent toolbar/popover may steal focus). */
|
|
197
|
+
export function suspendBlurClose() {
|
|
198
|
+
_blurCloseSuspended = true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Resume blur-driven `stopEditing` (call on real editEnd/teardown). */
|
|
202
|
+
export function resumeBlurClose() {
|
|
203
|
+
_blurCloseSuspended = false;
|
|
204
|
+
}
|
|
205
|
+
|
|
162
206
|
/**
|
|
163
207
|
* Check if an element is a text-bearing editable block.
|
|
164
208
|
*
|
|
@@ -228,7 +272,9 @@ export function startEditing(
|
|
|
228
272
|
},
|
|
229
273
|
) {
|
|
230
274
|
if (activeEl) {
|
|
231
|
-
|
|
275
|
+
// Re-enter (e.g. after a split/insert re-render): tear the old session down WITHOUT firing the
|
|
276
|
+
// User-visible `onEnd` — a re-enter must not reset the parent toolbar via a stray `editEnd`.
|
|
277
|
+
stopEditing(true);
|
|
232
278
|
}
|
|
233
279
|
|
|
234
280
|
activeEl = el;
|
|
@@ -262,14 +308,19 @@ export function startEditing(
|
|
|
262
308
|
el.addEventListener("paste", handlePaste);
|
|
263
309
|
}
|
|
264
310
|
|
|
265
|
-
/**
|
|
266
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Stop editing and commit changes. Pass `silent` to skip the `onEnd` callback (used by the re-enter
|
|
313
|
+
* path so a stop→start sequence doesn't post a user-visible `editEnd`).
|
|
314
|
+
*
|
|
315
|
+
* @param {boolean} [silent]
|
|
316
|
+
*/
|
|
317
|
+
export function stopEditing(silent = false) {
|
|
267
318
|
if (!activeEl) {
|
|
268
319
|
return;
|
|
269
320
|
}
|
|
270
321
|
|
|
271
322
|
commitChanges();
|
|
272
|
-
|
|
323
|
+
slash.dismiss();
|
|
273
324
|
|
|
274
325
|
activeEl.contentEditable = "false";
|
|
275
326
|
activeEl.style.pointerEvents = "";
|
|
@@ -288,7 +339,9 @@ export function stopEditing() {
|
|
|
288
339
|
splitFn = null;
|
|
289
340
|
insertFn = null;
|
|
290
341
|
|
|
291
|
-
if (
|
|
342
|
+
if (silent) {
|
|
343
|
+
endFn = null;
|
|
344
|
+
} else if (endFn) {
|
|
292
345
|
const fn = endFn;
|
|
293
346
|
endFn = null;
|
|
294
347
|
fn();
|
|
@@ -313,6 +366,25 @@ export function getActiveElement() {
|
|
|
313
366
|
return activeEl;
|
|
314
367
|
}
|
|
315
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Get the document path of the currently editing element (null when no session is live).
|
|
371
|
+
*
|
|
372
|
+
* @returns {JxPath | null}
|
|
373
|
+
*/
|
|
374
|
+
export function getActivePath() {
|
|
375
|
+
return activePath;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Whether the DI'd slash menu reports itself open — session-lifecycle guards must not commit-and-
|
|
380
|
+
* end on a pointerdown that is really a slash-menu interaction.
|
|
381
|
+
*
|
|
382
|
+
* @returns {boolean}
|
|
383
|
+
*/
|
|
384
|
+
export function isSlashActive() {
|
|
385
|
+
return slash.isOpen();
|
|
386
|
+
}
|
|
387
|
+
|
|
316
388
|
// ─── Event handlers ────────────────────────────────────────────────────────
|
|
317
389
|
|
|
318
390
|
/** @param {KeyboardEvent} e */
|
|
@@ -325,7 +397,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|
|
325
397
|
}
|
|
326
398
|
|
|
327
399
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
328
|
-
if (
|
|
400
|
+
if (slash.isOpen()) {
|
|
329
401
|
return;
|
|
330
402
|
} // Shared slash menu captures Enter
|
|
331
403
|
e.preventDefault();
|
|
@@ -374,17 +446,14 @@ function handleKeydown(e: KeyboardEvent) {
|
|
|
374
446
|
}
|
|
375
447
|
|
|
376
448
|
// Dismiss slash menu on non-matching keys
|
|
377
|
-
if (
|
|
378
|
-
isSlashMenuOpen() &&
|
|
379
|
-
!["ArrowUp", "ArrowDown", "Enter", "Backspace", "Delete"].includes(e.key)
|
|
380
|
-
) {
|
|
449
|
+
if (slash.isOpen() && !["ArrowUp", "ArrowDown", "Enter", "Backspace", "Delete"].includes(e.key)) {
|
|
381
450
|
// Let the input handler deal with filtering
|
|
382
451
|
}
|
|
383
452
|
}
|
|
384
453
|
|
|
385
454
|
function handleInput() {
|
|
386
455
|
// Check if slash menu should update or dismiss
|
|
387
|
-
if (
|
|
456
|
+
if (slash.isOpen()) {
|
|
388
457
|
updateSlashMenu();
|
|
389
458
|
}
|
|
390
459
|
}
|
|
@@ -392,12 +461,21 @@ function handleInput() {
|
|
|
392
461
|
/** @param {FocusEvent} _e */
|
|
393
462
|
function handleBlur(_e: FocusEvent) {
|
|
394
463
|
// Don't close if focus moved to slash menu
|
|
395
|
-
if (
|
|
464
|
+
if (slash.isOpen()) {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// The parent format toolbar/link popover may have taken focus across the bridge — blur must NOT
|
|
469
|
+
// Tear the session down (Phase 4b-2 focus-loss BLOCKER). Only an explicit commit ends it.
|
|
470
|
+
if (_blurCloseSuspended) {
|
|
396
471
|
return;
|
|
397
472
|
}
|
|
398
473
|
|
|
399
474
|
// Delay to allow click events to fire
|
|
400
475
|
setTimeout(() => {
|
|
476
|
+
if (_blurCloseSuspended) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
401
479
|
if (activeEl && document.activeElement !== activeEl) {
|
|
402
480
|
stopEditing();
|
|
403
481
|
}
|
|
@@ -669,7 +747,7 @@ function openSlashMenu() {
|
|
|
669
747
|
const range = sel.getRangeAt(0);
|
|
670
748
|
_slashFilterStart = getTextBeforeCursor(range).length;
|
|
671
749
|
|
|
672
|
-
|
|
750
|
+
slash.show(activeEl, "", { onSelect: handleSlashSelect });
|
|
673
751
|
}
|
|
674
752
|
|
|
675
753
|
function updateSlashMenu() {
|
|
@@ -679,7 +757,7 @@ function updateSlashMenu() {
|
|
|
679
757
|
|
|
680
758
|
const sel = window.getSelection();
|
|
681
759
|
if (!sel || !sel.rangeCount) {
|
|
682
|
-
|
|
760
|
+
slash.dismiss();
|
|
683
761
|
return;
|
|
684
762
|
}
|
|
685
763
|
|
|
@@ -688,12 +766,12 @@ function updateSlashMenu() {
|
|
|
688
766
|
const slashIdx = fullText.lastIndexOf("/");
|
|
689
767
|
|
|
690
768
|
if (slashIdx === -1 || fullText.length < _slashFilterStart - 1) {
|
|
691
|
-
|
|
769
|
+
slash.dismiss();
|
|
692
770
|
return;
|
|
693
771
|
}
|
|
694
772
|
|
|
695
773
|
const filter = fullText.slice(slashIdx + 1).toLowerCase();
|
|
696
|
-
|
|
774
|
+
slash.show(activeEl, filter, { onSelect: handleSlashSelect });
|
|
697
775
|
}
|
|
698
776
|
|
|
699
777
|
/** @param {SlashCommand} cmd */
|