@jxsuite/studio 0.32.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 +22791 -13709
- package/dist/studio.js.map +181 -74
- package/package.json +12 -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 +386 -328
- 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 +18 -47
- package/src/services/ai-settings.ts +107 -0
- package/src/services/ai-system-prompt.ts +617 -0
- package/src/services/ai-tools.ts +854 -0
- package/src/services/context-manager.ts +200 -0
- package/src/services/document-assistant.ts +183 -0
- package/src/services/jx-validate.ts +65 -0
- package/src/services/monaco-setup.ts +12 -0
- package/src/services/render-critic.ts +75 -0
- package/src/services/token-lint.ts +140 -0
- package/src/services/tool-executor.ts +156 -0
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/state.ts +29 -0
- 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 +33 -69
- package/src/types.ts +16 -24
- 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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link-target.ts — pure, dependency-free helpers for classifying and composing anchor `href`
|
|
3
|
+
* targets in the properties panel's Link section.
|
|
4
|
+
*
|
|
5
|
+
* `classifyHref` splits a raw href string into a discriminated `{ kind, value }` pair so the UI can
|
|
6
|
+
* offer a target-kind selector (Internal page / External URL / #anchor / mailto: / tel:).
|
|
7
|
+
* `composeHref` is its inverse — the two round-trip for every kind so editing through the UI never
|
|
8
|
+
* corrupts an href.
|
|
9
|
+
*
|
|
10
|
+
* Both functions are total (never throw) and have no DOM or platform dependencies, so they are
|
|
11
|
+
* fully unit-testable in isolation.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The distinct target kinds an anchor href can address. */
|
|
15
|
+
export type LinkKind = "internal" | "external" | "anchor" | "mailto" | "tel";
|
|
16
|
+
|
|
17
|
+
/** A classified href: its {@link LinkKind} plus the "bare" value for that kind's input. */
|
|
18
|
+
export interface LinkTarget {
|
|
19
|
+
kind: LinkKind;
|
|
20
|
+
/**
|
|
21
|
+
* The user-facing value for the kind's input: - internal → the path (e.g. `/about/`) - external →
|
|
22
|
+
* the full href (e.g. `https://x.com`) - anchor → the fragment WITHOUT the leading `#` (e.g.
|
|
23
|
+
* `section`) - mailto → the email address WITHOUT the `mailto:` scheme - tel → the phone number
|
|
24
|
+
* WITHOUT the `tel:` scheme
|
|
25
|
+
*/
|
|
26
|
+
value: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Matches an absolute URL scheme like `http://`, `https://`, `ftp://`, or protocol-relative `//`. */
|
|
30
|
+
const ABSOLUTE_SCHEME = /^([a-z][a-z\d+.-]*:)?\/\//i;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Classify a raw `href` string into a {@link LinkTarget}.
|
|
34
|
+
*
|
|
35
|
+
* Rules (checked in order): 1. `mailto:…` → `{ kind: "mailto", value: address }` 2. `tel:…` → `{
|
|
36
|
+
* kind: "tel", value: number }` 3. `#…` → `{ kind: "anchor", value: rest }` (leading `#` stripped)
|
|
37
|
+
* 4. absolute (`http(s)://…`) or protocol-relative (`//…`) → `{ kind: "external", value: href }` 5.
|
|
38
|
+
* `/…` → `{ kind: "internal", value: path }` 6. anything else → `{ kind: "external", value: href
|
|
39
|
+
* }`
|
|
40
|
+
*
|
|
41
|
+
* @param href The raw attribute value (may be empty)
|
|
42
|
+
*/
|
|
43
|
+
export function classifyHref(href = ""): LinkTarget {
|
|
44
|
+
const value = href;
|
|
45
|
+
const lower = value.toLowerCase();
|
|
46
|
+
|
|
47
|
+
if (lower.startsWith("mailto:")) {
|
|
48
|
+
return { kind: "mailto", value: value.slice("mailto:".length) };
|
|
49
|
+
}
|
|
50
|
+
if (lower.startsWith("tel:")) {
|
|
51
|
+
return { kind: "tel", value: value.slice("tel:".length) };
|
|
52
|
+
}
|
|
53
|
+
if (value.startsWith("#")) {
|
|
54
|
+
return { kind: "anchor", value: value.slice(1) };
|
|
55
|
+
}
|
|
56
|
+
if (ABSOLUTE_SCHEME.test(value)) {
|
|
57
|
+
return { kind: "external", value };
|
|
58
|
+
}
|
|
59
|
+
if (value.startsWith("/")) {
|
|
60
|
+
return { kind: "internal", value };
|
|
61
|
+
}
|
|
62
|
+
return { kind: "external", value };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compose an `href` string from a {@link LinkKind} and its bare value — the inverse of
|
|
67
|
+
* {@link classifyHref}. An empty (or whitespace-only) value yields `""` for every kind so callers
|
|
68
|
+
* can treat "cleared" uniformly. Round-trips with `classifyHref` for every kind.
|
|
69
|
+
*
|
|
70
|
+
* @param kind The target kind
|
|
71
|
+
* @param value The bare value for that kind (address/number/fragment/path/href)
|
|
72
|
+
*/
|
|
73
|
+
export function composeHref(kind: LinkKind, value: string): string {
|
|
74
|
+
const bare = (value ?? "").trim();
|
|
75
|
+
if (!bare) {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
switch (kind) {
|
|
79
|
+
case "mailto": {
|
|
80
|
+
return `mailto:${bare}`;
|
|
81
|
+
}
|
|
82
|
+
case "tel": {
|
|
83
|
+
return `tel:${bare}`;
|
|
84
|
+
}
|
|
85
|
+
case "anchor": {
|
|
86
|
+
return bare.startsWith("#") ? bare : `#${bare}`;
|
|
87
|
+
}
|
|
88
|
+
// Internal / external — the bare value IS the href.
|
|
89
|
+
default: {
|
|
90
|
+
return bare;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip `on*` event-handler bindings from a Jx document tree (returns a shallow-rebuilt copy). Used
|
|
3
|
+
* to neutralize handlers before edit/design rendering so the canvas never wires live behavior, and
|
|
4
|
+
* to drop `timing: "server"` state entries the canvas can't run.
|
|
5
|
+
*
|
|
6
|
+
* Lives in its own dependency-light module (it pulls only the pure `@jxsuite/schema/guards`
|
|
7
|
+
* predicate) so the slim canvas-iframe subtree renderer can import it without dragging in the
|
|
8
|
+
* editor's reactive `store.ts`; `store.ts` re-exports it for the legacy in-realm patcher.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { isEventBinding } from "@jxsuite/schema/guards";
|
|
12
|
+
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
13
|
+
|
|
14
|
+
/** Strip all `on*` event-handler properties (and server-timed state) from a document tree. */
|
|
15
|
+
export function stripEventHandlers(node: JxMutableNode): JxMutableNode {
|
|
16
|
+
if (!node || typeof node !== "object") {
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
if (Array.isArray(node)) {
|
|
20
|
+
// Arrays of nodes round-trip element-wise; the array itself is not a node.
|
|
21
|
+
return node.map((n: JxMutableNode) => stripEventHandlers(n)) as unknown as JxMutableNode;
|
|
22
|
+
}
|
|
23
|
+
const out: Record<string, unknown> = {};
|
|
24
|
+
for (const [k, v] of Object.entries(node)) {
|
|
25
|
+
if (k.startsWith("on") && isEventBinding(v)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (k === "children") {
|
|
29
|
+
out.children = Array.isArray(v)
|
|
30
|
+
? v.map((c: JxMutableNode) => stripEventHandlers(c))
|
|
31
|
+
: stripEventHandlers(v as JxMutableNode);
|
|
32
|
+
} else if (k === "cases" && typeof v === "object") {
|
|
33
|
+
const cases: Record<string, unknown> = {};
|
|
34
|
+
for (const [ck, cv] of Object.entries(v as Record<string, unknown>)) {
|
|
35
|
+
cases[ck] = stripEventHandlers(cv as JxMutableNode);
|
|
36
|
+
}
|
|
37
|
+
out.cases = cases;
|
|
38
|
+
} else if (k === "state" && typeof v === "object" && v !== null) {
|
|
39
|
+
const state: Record<string, unknown> = {};
|
|
40
|
+
for (const [sk, sv] of Object.entries(v as Record<string, unknown>)) {
|
|
41
|
+
if (sv && typeof sv === "object" && (sv as Record<string, unknown>).timing === "server") {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
state[sk] = sv;
|
|
45
|
+
}
|
|
46
|
+
out.state = state;
|
|
47
|
+
} else if (k === "style" || k === "attributes" || k === "$media") {
|
|
48
|
+
out[k] = v;
|
|
49
|
+
} else {
|
|
50
|
+
out[k] = v;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
package/src/view.ts
CHANGED
|
@@ -28,15 +28,6 @@ interface ViewState {
|
|
|
28
28
|
_editingTarget?: string | null;
|
|
29
29
|
})
|
|
30
30
|
| null;
|
|
31
|
-
componentInlineEdit: {
|
|
32
|
-
el: HTMLElement;
|
|
33
|
-
path: (string | number)[];
|
|
34
|
-
originalText: string;
|
|
35
|
-
mediaName: string | null;
|
|
36
|
-
_outsideHandler?: ((e: MouseEvent) => void) | null;
|
|
37
|
-
[k: string]: unknown;
|
|
38
|
-
} | null;
|
|
39
|
-
inlineEditCleanup: (() => void) | null;
|
|
40
31
|
blockActionBarEl: HTMLElement | null;
|
|
41
32
|
selDragCleanup: (() => void) | null;
|
|
42
33
|
dndCleanups: (() => void)[];
|
|
@@ -46,16 +37,9 @@ interface ViewState {
|
|
|
46
37
|
forcedAttrEl: HTMLElement | null;
|
|
47
38
|
elementsCollapsed: Set<string>;
|
|
48
39
|
elementsFilter: string;
|
|
49
|
-
lastDragInput: {
|
|
50
|
-
clientX: number;
|
|
51
|
-
clientY: number;
|
|
52
|
-
[k: string]: unknown;
|
|
53
|
-
} | null;
|
|
54
40
|
_currentDropTargetRow: HTMLElement | null;
|
|
55
41
|
layerDragSourceHeight: number;
|
|
56
|
-
savedRange: Range | null;
|
|
57
42
|
_completionRegistered: boolean;
|
|
58
|
-
stylebookElToTag: WeakMap<Element, string>;
|
|
59
43
|
showAddBreakpointForm: boolean;
|
|
60
44
|
addBreakpointPreview: string;
|
|
61
45
|
layoutSelection: unknown;
|
|
@@ -81,10 +65,6 @@ export const view: ViewState = {
|
|
|
81
65
|
monacoEditor: null,
|
|
82
66
|
functionEditor: null,
|
|
83
67
|
|
|
84
|
-
// Inline editing
|
|
85
|
-
componentInlineEdit: null,
|
|
86
|
-
inlineEditCleanup: null,
|
|
87
|
-
|
|
88
68
|
// Floating UI containers
|
|
89
69
|
blockActionBarEl: null,
|
|
90
70
|
|
|
@@ -105,16 +85,13 @@ export const view: ViewState = {
|
|
|
105
85
|
elementsFilter: "",
|
|
106
86
|
|
|
107
87
|
// Drag interaction
|
|
108
|
-
lastDragInput: null,
|
|
109
88
|
_currentDropTargetRow: null,
|
|
110
89
|
layerDragSourceHeight: 0,
|
|
111
90
|
|
|
112
91
|
// Editor state
|
|
113
|
-
savedRange: null,
|
|
114
92
|
_completionRegistered: false,
|
|
115
93
|
|
|
116
94
|
// Canvas / stylebook
|
|
117
|
-
stylebookElToTag: new WeakMap(),
|
|
118
95
|
|
|
119
96
|
// Responsive breakpoints UI
|
|
120
97
|
showAddBreakpointForm: false,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, reactive } from "../reactivity";
|
|
1
|
+
import { computed, reactive, toRaw } from "../reactivity";
|
|
2
2
|
import { createTab, disposeTab } from "../tabs/tab";
|
|
3
3
|
import type { Tab } from "../tabs/tab";
|
|
4
4
|
|
|
@@ -56,6 +56,19 @@ export const activeTab = computed(() =>
|
|
|
56
56
|
workspace.activeTabId ? (workspace.tabs.get(workspace.activeTabId) ?? null) : null,
|
|
57
57
|
) as unknown as ComputedRef<Tab | null>;
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Whether `tab` is the active tab. Identity check survives reactive-proxy wrapping (activeTab.value
|
|
61
|
+
* is a proxy; callers may hold either the raw tab or a proxy of it).
|
|
62
|
+
*
|
|
63
|
+
* @param {Tab | null} tab
|
|
64
|
+
*/
|
|
65
|
+
export function isTabActive(tab: Tab | null): boolean {
|
|
66
|
+
const active = activeTab.value;
|
|
67
|
+
return (
|
|
68
|
+
tab !== null && active !== null && toRaw(active as object) === toRaw(tab as unknown as object)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
/**
|
|
60
73
|
* Open a new tab and make it active.
|
|
61
74
|
*
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/// <reference lib="dom" />
|
|
2
|
-
/// <reference lib="dom.iterable" />
|
|
3
|
-
/**
|
|
4
|
-
* Isolated subtree rendering for surgical canvas patches. Re-renders a single document node into
|
|
5
|
-
* fresh DOM using the same edit-mode transform, runtime renderer, scope, and path mapper as the
|
|
6
|
-
* panel's last full render — so the patched DOM is indistinguishable from a full re-render.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { elToRenderScope, elToScope, getNodeAtPath, stripEventHandlers } from "../store";
|
|
10
|
-
import { prepareForEditMode } from "../utils/edit-display";
|
|
11
|
-
import { renderNode as runtimeRenderNode } from "@jxsuite/runtime";
|
|
12
|
-
import { effectScope } from "../reactivity";
|
|
13
|
-
import { canvasPerf } from "./canvas-perf";
|
|
14
|
-
|
|
15
|
-
import type { JxPath } from "../state";
|
|
16
|
-
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
17
|
-
import type { CanvasPanel, PanelLiveCtx } from "../types";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Render the document node at docPath into a detached DOM subtree for the given panel.
|
|
21
|
-
*
|
|
22
|
-
* The scope comes from the parent element's recorded render scope (elToScope), falling back to the
|
|
23
|
-
* panel's root scope — so $-bindings, $media, and state references resolve exactly as they did in
|
|
24
|
-
* the full render. Throws when the panel has no live render context.
|
|
25
|
-
*
|
|
26
|
-
* @param {CanvasPanel} panel
|
|
27
|
-
* @param {JxMutableNode} doc Raw (non-reactive) document root
|
|
28
|
-
* @param {JxPath} docPath
|
|
29
|
-
* @param {Element} parentEl The rendered parent element (scope source)
|
|
30
|
-
*/
|
|
31
|
-
export function renderSubtree(
|
|
32
|
-
panel: CanvasPanel,
|
|
33
|
-
doc: JxMutableNode,
|
|
34
|
-
docPath: JxPath,
|
|
35
|
-
parentEl: Element,
|
|
36
|
-
): HTMLElement | Text {
|
|
37
|
-
const { liveCtx } = panel;
|
|
38
|
-
if (!liveCtx) {
|
|
39
|
-
throw new Error("panel-missing-live-ctx");
|
|
40
|
-
}
|
|
41
|
-
const node = getNodeAtPath(doc, docPath) as JxMutableNode | string | undefined;
|
|
42
|
-
if (node === undefined) {
|
|
43
|
-
throw new Error(`node-not-found:${docPath.join("/")}`);
|
|
44
|
-
}
|
|
45
|
-
const def = typeof node === "string" ? node : prepareForEditMode(stripEventHandlers(node));
|
|
46
|
-
const scope = elToScope.get(parentEl) ?? liveCtx.scope;
|
|
47
|
-
|
|
48
|
-
// Render inside an effect scope parented to the panel's render scope: disposable on its own
|
|
49
|
-
// When the subtree is later replaced/removed, and disposed with the panel on full renders.
|
|
50
|
-
const render = () => {
|
|
51
|
-
const subScope = effectScope();
|
|
52
|
-
const rendered = subScope.run(() =>
|
|
53
|
-
runtimeRenderNode(def, scope, {
|
|
54
|
-
_path: docPathToRenderPath(docPath, liveCtx),
|
|
55
|
-
onNodeCreated: liveCtx.pathMapper,
|
|
56
|
-
}),
|
|
57
|
-
)!;
|
|
58
|
-
if (rendered instanceof HTMLElement) {
|
|
59
|
-
elToRenderScope.set(rendered, subScope);
|
|
60
|
-
}
|
|
61
|
-
return rendered;
|
|
62
|
-
};
|
|
63
|
-
const el = panel.renderScope ? panel.renderScope.run(render)! : render();
|
|
64
|
-
canvasPerf.subtreeRenders += 1;
|
|
65
|
-
|
|
66
|
-
// Match full-render edit display: canvas content never receives pointer events directly.
|
|
67
|
-
if (el instanceof HTMLElement) {
|
|
68
|
-
el.style.pointerEvents = "none";
|
|
69
|
-
for (const child of el.querySelectorAll("*")) {
|
|
70
|
-
(child as HTMLElement).style.pointerEvents = "none";
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return el;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Inverse of makePathMapper's layout-prefix strip: document paths are relative to the page doc,
|
|
78
|
-
* render paths to the layout-merged doc. ($map/$switch paths never reach here — the patcher
|
|
79
|
-
* escalates them.)
|
|
80
|
-
*
|
|
81
|
-
* @param {JxPath} docPath
|
|
82
|
-
* @param {PanelLiveCtx} liveCtx
|
|
83
|
-
*/
|
|
84
|
-
function docPathToRenderPath(docPath: JxPath, liveCtx: PanelLiveCtx): (string | number)[] {
|
|
85
|
-
if (liveCtx.layoutWrapped && liveCtx.pageContentPrefix && docPath[0] === "children") {
|
|
86
|
-
// Re-apply the slot-container offset stripped by makePathMapper: page child index N renders at
|
|
87
|
-
// Container index N + offset (offset = leading layout siblings before the <slot>).
|
|
88
|
-
const [, idx] = docPath;
|
|
89
|
-
return typeof idx === "number"
|
|
90
|
-
? [...liveCtx.pageContentPrefix, idx + (liveCtx.pageContentOffset ?? 0), ...docPath.slice(2)]
|
|
91
|
-
: [...liveCtx.pageContentPrefix, ...docPath.slice(1)];
|
|
92
|
-
}
|
|
93
|
-
return docPath;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* The DOM node currently occupying children[index] of the rendered parent, or null when appending
|
|
98
|
-
* at the end. The runtime renders an optional leading text node (from textContent) followed by one
|
|
99
|
-
* DOM node per children entry, in order.
|
|
100
|
-
*
|
|
101
|
-
* @param {Element} parentEl
|
|
102
|
-
* @param {JxMutableNode} parentNode
|
|
103
|
-
* @param {number} index
|
|
104
|
-
*/
|
|
105
|
-
export function domChildReference(
|
|
106
|
-
parentEl: Element,
|
|
107
|
-
parentNode: JxMutableNode,
|
|
108
|
-
index: number,
|
|
109
|
-
): ChildNode | null {
|
|
110
|
-
const text = parentNode.textContent;
|
|
111
|
-
const textOffset = text != null && String(text) !== "" ? 1 : 0;
|
|
112
|
-
return parentEl.childNodes[textOffset + index] ?? null;
|
|
113
|
-
}
|
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
/// <reference lib="dom" />
|
|
2
|
-
/**
|
|
3
|
-
* Component inline edit — extracted from studio.js (Phase 4j). Manages plaintext-only editing on
|
|
4
|
-
* canvas elements in design mode, with slash menu delegation for block insertion.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
canvasPanels,
|
|
9
|
-
childIndex,
|
|
10
|
-
elToPath,
|
|
11
|
-
getNodeAtPath,
|
|
12
|
-
parentElementPath,
|
|
13
|
-
renderOnly,
|
|
14
|
-
updateUi,
|
|
15
|
-
} from "../store";
|
|
16
|
-
import { activeTab } from "../workspace/workspace";
|
|
17
|
-
import type { JxPath } from "../state";
|
|
18
|
-
import {
|
|
19
|
-
mutateInsertNode,
|
|
20
|
-
mutateRemoveNode,
|
|
21
|
-
mutateUpdateProperty,
|
|
22
|
-
transactDoc,
|
|
23
|
-
} from "../tabs/transact";
|
|
24
|
-
import { view } from "../view";
|
|
25
|
-
import { panelMediaToActiveMedia } from "../canvas/canvas-helpers";
|
|
26
|
-
import { dismissSlashMenu, isSlashMenuOpen, showSlashMenu } from "./slash-menu";
|
|
27
|
-
import { renderBlockActionBar } from "../panels/block-action-bar";
|
|
28
|
-
import { defaultDef } from "../panels/shared";
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Initialize the component inline edit module.
|
|
32
|
-
*
|
|
33
|
-
* @param {{
|
|
34
|
-
* findCanvasElement: (
|
|
35
|
-
* path: import("../state").JxPath,
|
|
36
|
-
* canvasEl: HTMLElement,
|
|
37
|
-
* ) => HTMLElement | null;
|
|
38
|
-
* }} _ctx
|
|
39
|
-
*/
|
|
40
|
-
export function initComponentInlineEdit(_ctx: {
|
|
41
|
-
findCanvasElement: (path: JxPath, canvasEl: HTMLElement) => HTMLElement | null;
|
|
42
|
-
}) {
|
|
43
|
-
// Public init hook retained for API symmetry; the ctx it once stored is no longer read here.
|
|
44
|
-
void _ctx;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Enter plaintext inline editing on a canvas element.
|
|
49
|
-
*
|
|
50
|
-
* @param {HTMLElement} el
|
|
51
|
-
* @param {JxPath} path
|
|
52
|
-
*/
|
|
53
|
-
export function enterComponentInlineEdit(el: HTMLElement, path: JxPath) {
|
|
54
|
-
if (view.componentInlineEdit && view.componentInlineEdit.el === el) {
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const node = getNodeAtPath(activeTab.value!.doc.document, path);
|
|
59
|
-
if (!node) {
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const tc = node.textContent;
|
|
64
|
-
if (node.$props && (node.tagName || "").includes("-")) {
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
if (Array.isArray(node.children) && node.children.length > 0) {
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
if (node.children && typeof node.children === "object") {
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (tc && typeof tc === "object") {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
const voids = new Set(["img", "input", "br", "hr", "video", "audio", "source", "embed", "slot"]);
|
|
77
|
-
if (voids.has(node.tagName || "")) {
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
for (const p of canvasPanels) {
|
|
82
|
-
const boxes = p.overlay.querySelectorAll(".overlay-box") as NodeListOf<HTMLElement>;
|
|
83
|
-
for (const box of boxes) {
|
|
84
|
-
box.style.border = "none";
|
|
85
|
-
}
|
|
86
|
-
p.overlayClk.style.pointerEvents = "none";
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
el.contentEditable = "plaintext-only";
|
|
90
|
-
el.style.pointerEvents = "auto";
|
|
91
|
-
el.style.cursor = "text";
|
|
92
|
-
el.style.outline = "1px solid var(--accent, #4f8bc7)";
|
|
93
|
-
el.style.outlineOffset = "-1px";
|
|
94
|
-
el.style.minHeight = "1em";
|
|
95
|
-
|
|
96
|
-
const rawText = typeof tc === "string" ? tc : "";
|
|
97
|
-
el.textContent = rawText;
|
|
98
|
-
|
|
99
|
-
view.componentInlineEdit = {
|
|
100
|
-
el,
|
|
101
|
-
mediaName: canvasPanels.find((p) => p.canvas.contains(el))?.mediaName || null,
|
|
102
|
-
originalText: rawText,
|
|
103
|
-
path,
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
el.focus();
|
|
107
|
-
const sel = window.getSelection();
|
|
108
|
-
const range = document.createRange();
|
|
109
|
-
range.selectNodeContents(el);
|
|
110
|
-
range.collapse(false);
|
|
111
|
-
sel?.removeAllRanges();
|
|
112
|
-
sel?.addRange(range);
|
|
113
|
-
|
|
114
|
-
el.addEventListener("keydown", componentInlineKeydown);
|
|
115
|
-
el.addEventListener("input", componentInlineInput);
|
|
116
|
-
|
|
117
|
-
const outsideHandler = (evt: MouseEvent) => {
|
|
118
|
-
if (!view.componentInlineEdit) {
|
|
119
|
-
document.removeEventListener("mousedown", outsideHandler, true);
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
if (view.componentInlineEdit.el.contains(evt.target as Node)) {
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
if (isSlashMenuOpen()) {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (view.blockActionBarEl && view.blockActionBarEl.contains(evt.target as Node)) {
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
document.removeEventListener("mousedown", outsideHandler, true);
|
|
132
|
-
|
|
133
|
-
let hitMedia = null;
|
|
134
|
-
let hitPath: (string | number)[] | null = null;
|
|
135
|
-
for (const p of canvasPanels) {
|
|
136
|
-
const els = p.canvas.querySelectorAll("*") as NodeListOf<HTMLElement>;
|
|
137
|
-
for (const child of els) {
|
|
138
|
-
child.style.pointerEvents = "auto";
|
|
139
|
-
}
|
|
140
|
-
p.overlayClk.style.display = "none";
|
|
141
|
-
const found = document.elementsFromPoint(evt.clientX, evt.clientY);
|
|
142
|
-
p.overlayClk.style.display = "";
|
|
143
|
-
for (const child of els) {
|
|
144
|
-
child.style.pointerEvents = "none";
|
|
145
|
-
}
|
|
146
|
-
for (const hit of found) {
|
|
147
|
-
if (p.canvas.contains(hit) && hit !== p.canvas) {
|
|
148
|
-
const hitElPath = elToPath.get(hit);
|
|
149
|
-
if (hitElPath) {
|
|
150
|
-
hitPath = hitElPath;
|
|
151
|
-
hitMedia = p.mediaName;
|
|
152
|
-
break;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (hitPath) {
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const { el: editEl, path: editPath, originalText } = view.componentInlineEdit;
|
|
162
|
-
const newText = (editEl.textContent ?? "").trim();
|
|
163
|
-
cleanupComponentInlineEdit(editEl);
|
|
164
|
-
|
|
165
|
-
const isEmpty = !newText;
|
|
166
|
-
const pPath = parentElementPath(editPath);
|
|
167
|
-
|
|
168
|
-
if (hitPath) {
|
|
169
|
-
let hp = hitPath;
|
|
170
|
-
const media = panelMediaToActiveMedia(hitMedia);
|
|
171
|
-
updateUi("pendingInlineEdit", { mediaName: hitMedia, path: hp });
|
|
172
|
-
activeTab.value!.session.ui.activeMedia = media;
|
|
173
|
-
if (isEmpty && pPath) {
|
|
174
|
-
transactDoc(activeTab.value, (t) => {
|
|
175
|
-
mutateRemoveNode(t, editPath);
|
|
176
|
-
const removedIdx = childIndex(editPath) as number;
|
|
177
|
-
const hitIdx = childIndex(hp) as number;
|
|
178
|
-
const hitParent = parentElementPath(hp);
|
|
179
|
-
if (
|
|
180
|
-
hitParent &&
|
|
181
|
-
pPath &&
|
|
182
|
-
hitParent.join("/") === pPath.join("/") &&
|
|
183
|
-
hitIdx > removedIdx
|
|
184
|
-
) {
|
|
185
|
-
hp = [...pPath, "children", hitIdx - 1];
|
|
186
|
-
updateUi("pendingInlineEdit", { mediaName: hitMedia, path: hp });
|
|
187
|
-
}
|
|
188
|
-
t.session.selection = hp;
|
|
189
|
-
});
|
|
190
|
-
} else if (newText !== originalText) {
|
|
191
|
-
transactDoc(activeTab.value, (t) => {
|
|
192
|
-
mutateUpdateProperty(t, editPath, "textContent", newText || undefined);
|
|
193
|
-
t.session.selection = hp;
|
|
194
|
-
});
|
|
195
|
-
} else {
|
|
196
|
-
activeTab.value!.session.selection = hp;
|
|
197
|
-
}
|
|
198
|
-
} else if (isEmpty && pPath) {
|
|
199
|
-
transactDoc(activeTab.value, (t) => mutateRemoveNode(t, editPath));
|
|
200
|
-
} else if (newText !== originalText) {
|
|
201
|
-
transactDoc(activeTab.value, (t) =>
|
|
202
|
-
mutateUpdateProperty(t, editPath, "textContent", newText || undefined),
|
|
203
|
-
);
|
|
204
|
-
} else {
|
|
205
|
-
renderOnly("canvas");
|
|
206
|
-
renderOnly("overlays");
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
document.addEventListener("mousedown", outsideHandler, true);
|
|
210
|
-
view.componentInlineEdit._outsideHandler = outsideHandler;
|
|
211
|
-
|
|
212
|
-
renderBlockActionBar();
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** @param {KeyboardEvent} e */
|
|
216
|
-
function componentInlineKeydown(e: KeyboardEvent) {
|
|
217
|
-
if (isSlashMenuOpen() && ["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.key)) {
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
222
|
-
e.preventDefault();
|
|
223
|
-
splitParagraph();
|
|
224
|
-
} else if (e.key === "Escape") {
|
|
225
|
-
e.preventDefault();
|
|
226
|
-
cancelComponentInlineEdit();
|
|
227
|
-
}
|
|
228
|
-
e.stopPropagation();
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function splitParagraph() {
|
|
232
|
-
if (!view.componentInlineEdit) {
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
const { el, path, mediaName } = view.componentInlineEdit;
|
|
236
|
-
|
|
237
|
-
const sel = el.ownerDocument.defaultView?.getSelection() as Selection | null;
|
|
238
|
-
const fullText = el.textContent || "";
|
|
239
|
-
let offset = fullText.length;
|
|
240
|
-
if (sel && sel.rangeCount) {
|
|
241
|
-
const range = sel.getRangeAt(0);
|
|
242
|
-
const preRange = document.createRange();
|
|
243
|
-
preRange.selectNodeContents(el);
|
|
244
|
-
preRange.setEnd(range.startContainer, range.startOffset);
|
|
245
|
-
offset = preRange.toString().length;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const textBefore = fullText.slice(0, offset);
|
|
249
|
-
const textAfter = fullText.slice(offset);
|
|
250
|
-
|
|
251
|
-
const tag = "p";
|
|
252
|
-
const pPath = parentElementPath(path) as JxPath;
|
|
253
|
-
const idx = childIndex(path) as number;
|
|
254
|
-
if (!pPath) {
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const newDef = { tagName: tag, textContent: textAfter };
|
|
259
|
-
const newPath = [...pPath, "children", idx + 1];
|
|
260
|
-
|
|
261
|
-
cleanupComponentInlineEdit(el);
|
|
262
|
-
|
|
263
|
-
transactDoc(activeTab.value, (t) => {
|
|
264
|
-
mutateUpdateProperty(t, path, "textContent", textBefore || undefined);
|
|
265
|
-
mutateInsertNode(t, pPath, idx + 1, newDef);
|
|
266
|
-
t.session.selection = newPath;
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
updateUi("pendingInlineEdit", { mediaName, path: newPath });
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function cancelComponentInlineEdit() {
|
|
273
|
-
if (!view.componentInlineEdit) {
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
const { el } = view.componentInlineEdit;
|
|
277
|
-
cleanupComponentInlineEdit(el);
|
|
278
|
-
renderOnly("canvas");
|
|
279
|
-
renderOnly("overlays");
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** @param {HTMLElement} el */
|
|
283
|
-
function cleanupComponentInlineEdit(el: HTMLElement) {
|
|
284
|
-
el.removeEventListener("keydown", componentInlineKeydown);
|
|
285
|
-
el.removeEventListener("input", componentInlineInput);
|
|
286
|
-
dismissSlashMenu();
|
|
287
|
-
el.removeAttribute("contenteditable");
|
|
288
|
-
el.style.cursor = "";
|
|
289
|
-
el.style.outline = "";
|
|
290
|
-
el.style.outlineOffset = "";
|
|
291
|
-
el.style.minHeight = "";
|
|
292
|
-
el.style.pointerEvents = "";
|
|
293
|
-
|
|
294
|
-
if (view.componentInlineEdit?._outsideHandler) {
|
|
295
|
-
document.removeEventListener("mousedown", view.componentInlineEdit._outsideHandler, true);
|
|
296
|
-
}
|
|
297
|
-
view.componentInlineEdit = null;
|
|
298
|
-
|
|
299
|
-
for (const p of canvasPanels) {
|
|
300
|
-
p.overlay.style.display = "";
|
|
301
|
-
p.overlayClk.style.pointerEvents = "";
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// ─── Component-mode slash commands ──────────────────────────────────────────
|
|
306
|
-
|
|
307
|
-
function componentInlineInput() {
|
|
308
|
-
if (!view.componentInlineEdit) {
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
const { el, originalText } = view.componentInlineEdit;
|
|
312
|
-
const text = el.textContent || "";
|
|
313
|
-
|
|
314
|
-
if (originalText === "" && text.startsWith("/")) {
|
|
315
|
-
const filter = text.slice(1).toLowerCase();
|
|
316
|
-
showSlashMenu(el, filter, { onSelect: handleComponentSlashSelect });
|
|
317
|
-
} else {
|
|
318
|
-
dismissSlashMenu();
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** @param {{ tag: string; label: string; description: string }} cmd */
|
|
323
|
-
function handleComponentSlashSelect(cmd: { tag: string; label: string; description: string }) {
|
|
324
|
-
if (!view.componentInlineEdit) {
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
const { el, path, mediaName } = view.componentInlineEdit;
|
|
328
|
-
const pPath = parentElementPath(path);
|
|
329
|
-
const idx = childIndex(path) as number;
|
|
330
|
-
if (!pPath) {
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
cleanupComponentInlineEdit(el);
|
|
335
|
-
|
|
336
|
-
const newDef = defaultDef(cmd.tag);
|
|
337
|
-
const newPath = [...pPath, "children", idx];
|
|
338
|
-
|
|
339
|
-
transactDoc(activeTab.value, (t) => {
|
|
340
|
-
mutateRemoveNode(t, path);
|
|
341
|
-
mutateInsertNode(t, pPath, idx, newDef);
|
|
342
|
-
t.session.selection = newPath;
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
const hasText = newDef.textContent != null;
|
|
346
|
-
if (hasText) {
|
|
347
|
-
updateUi("pendingInlineEdit", { mediaName, path: newPath });
|
|
348
|
-
}
|
|
349
|
-
}
|