@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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Inline link + template-token application for contenteditable editing.
|
|
4
|
+
*
|
|
5
|
+
* Realm-agnostic: every DOM access uses ambient `window`/`document`, so bundled into the canvas
|
|
6
|
+
* iframe these operate on the iframe realm where the edited DOM (and its Selection) live. The
|
|
7
|
+
* parent format toolbar never calls these — it posts an `applyFormat` intent and the iframe applies
|
|
8
|
+
* it here (Phase 4b-2).
|
|
9
|
+
*
|
|
10
|
+
* `document.execCommand` is uncoverable under happy-dom (no implementation), so it is wrapped in a
|
|
11
|
+
* one-line shim {@link exec} that the branch logic calls; tests stub `document.execCommand` and
|
|
12
|
+
* assert at the stub level (documented as stub-level, not behavioral).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { normalizeInlineContent } from "./inline-format";
|
|
16
|
+
|
|
17
|
+
/** The one uncoverable call, isolated so the surrounding branch logic stays unit-testable. */
|
|
18
|
+
const exec = (cmd: string, val?: string): boolean => document.execCommand(cmd, false, val);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read the link state of the current selection relative to `root`: walk the selection's anchor node
|
|
22
|
+
* up to `root` looking for an `<a>`. Returns the author-entered `href` attribute (NOT the resolved
|
|
23
|
+
* `.href` property), so relative/templated URLs round-trip unchanged.
|
|
24
|
+
*/
|
|
25
|
+
export function linkStateForSelection(root: HTMLElement): { active: boolean; href: string | null } {
|
|
26
|
+
const sel = window.getSelection();
|
|
27
|
+
if (!sel || !sel.rangeCount) {
|
|
28
|
+
return { active: false, href: null };
|
|
29
|
+
}
|
|
30
|
+
let node: Node | null = sel.anchorNode;
|
|
31
|
+
while (node && node !== root) {
|
|
32
|
+
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "a") {
|
|
33
|
+
return { active: true, href: (node as Element).getAttribute("href") };
|
|
34
|
+
}
|
|
35
|
+
node = node.parentNode;
|
|
36
|
+
}
|
|
37
|
+
return { active: false, href: null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Find the `<a>` that contains the selection anchor (up to `root`), if any.
|
|
42
|
+
*
|
|
43
|
+
* @param {HTMLElement} root
|
|
44
|
+
* @returns {HTMLAnchorElement | null}
|
|
45
|
+
*/
|
|
46
|
+
function anchorInSelection(root: HTMLElement): HTMLAnchorElement | null {
|
|
47
|
+
const sel = window.getSelection();
|
|
48
|
+
if (!sel || !sel.rangeCount) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
let node: Node | null = sel.anchorNode;
|
|
52
|
+
while (node && node !== root) {
|
|
53
|
+
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName.toLowerCase() === "a") {
|
|
54
|
+
return node as HTMLAnchorElement;
|
|
55
|
+
}
|
|
56
|
+
node = node.parentNode;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Apply (or remove) a link on the current selection within `root`. With an existing `<a>` in the
|
|
63
|
+
* selection: rewrite its `href`, or unwrap it when `href` is null/empty. Otherwise create a link
|
|
64
|
+
* via `execCommand("createLink")`. Always normalizes the inline content afterwards.
|
|
65
|
+
*/
|
|
66
|
+
export function applyLink(root: HTMLElement, href: string | null): void {
|
|
67
|
+
const existing = anchorInSelection(root);
|
|
68
|
+
if (existing) {
|
|
69
|
+
if (href) {
|
|
70
|
+
existing.setAttribute("href", href);
|
|
71
|
+
} else {
|
|
72
|
+
// Unwrap: replace the anchor with its children, preserving the text.
|
|
73
|
+
const frag = document.createDocumentFragment();
|
|
74
|
+
while (existing.firstChild) {
|
|
75
|
+
frag.append(existing.firstChild);
|
|
76
|
+
}
|
|
77
|
+
existing.replaceWith(frag);
|
|
78
|
+
}
|
|
79
|
+
} else if (href) {
|
|
80
|
+
exec("createLink", href);
|
|
81
|
+
}
|
|
82
|
+
normalizeInlineContent(root);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Insert a `${token}` template expression at the caret inside `root` (joins the native undo stack). */
|
|
86
|
+
export function insertTemplateToken(root: HTMLElement, token: string): void {
|
|
87
|
+
root.focus();
|
|
88
|
+
exec("insertText", `\${${token}}`);
|
|
89
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Insert-zone-action.ts — the click handler for the cross-origin insertion "+" affordance.
|
|
4
|
+
*
|
|
5
|
+
* Extracted verbatim from the studio.ts bootstrap so it can carry its own unit test (the inline
|
|
6
|
+
* arrow form added two uncovered anonymous functions to the hard-to-test bootstrap). Registered by
|
|
7
|
+
* name via `setInsertZoneClickHandler(runInsertZoneAction)` in studio.ts.
|
|
8
|
+
*
|
|
9
|
+
* The cross-origin insertion "+" (drawn by the host from posted zones) runs the parent-realm
|
|
10
|
+
* slash-menu → mutateInsertNode flow on click. The host captured the zone (parentPath + index); the
|
|
11
|
+
* new node is selected at its inserted path. Salvaged from the orphaned insertion-helper.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { showSlashMenu } from "./slash-menu";
|
|
15
|
+
import { defaultDef } from "../panels/shared";
|
|
16
|
+
import { mutateInsertNode, transactDoc } from "../tabs/transact";
|
|
17
|
+
import { activeTab } from "../workspace/workspace";
|
|
18
|
+
import type { InsertZone } from "../canvas/iframe-protocol";
|
|
19
|
+
import type { JxPath } from "../state";
|
|
20
|
+
|
|
21
|
+
export function runInsertZoneAction(btn: HTMLElement, zone: InsertZone): void {
|
|
22
|
+
showSlashMenu(btn, "", {
|
|
23
|
+
onSelect: (cmd) => {
|
|
24
|
+
const def = defaultDef(cmd.tag);
|
|
25
|
+
const insertPath = zone.insertParentPath as JxPath;
|
|
26
|
+
const idx = zone.index;
|
|
27
|
+
const newPath = [...insertPath, "children", idx];
|
|
28
|
+
transactDoc(activeTab.value, (t) => {
|
|
29
|
+
mutateInsertNode(t, insertPath, idx, def);
|
|
30
|
+
t.session.selection = newPath;
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
showFilter: true,
|
|
34
|
+
});
|
|
35
|
+
}
|
package/src/editor/merge-tags.ts
CHANGED
|
@@ -74,8 +74,8 @@ function walk(value: unknown, prefix: string, category: string, depth: number, o
|
|
|
74
74
|
*
|
|
75
75
|
* @param state - The document's `state` definitions (`tab.doc.document.state`), used for names and
|
|
76
76
|
* classification (functions are skipped — they are not text-insertable values).
|
|
77
|
-
* @param scope - The live resolved scope
|
|
78
|
-
*
|
|
77
|
+
* @param scope - The live resolved scope for type/preview hints and the nested-property walk. May
|
|
78
|
+
* be null (the iframe canvas holds the render scope, so the parent passes null).
|
|
79
79
|
* @param localScope - The editing element's recorded render scope (`elToScope.get(el)`). When it
|
|
80
80
|
* carries a `$map` (repeater) context, `item` / `item.*` / `index` tags are appended.
|
|
81
81
|
*/
|
|
@@ -118,3 +118,27 @@ export function buildMergeTags(
|
|
|
118
118
|
|
|
119
119
|
return out;
|
|
120
120
|
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build repeater-scope merge tags from a flat list of pre-resolved tokens (e.g. `item`, `index`,
|
|
124
|
+
* `item.data.title`). Unlike {@link buildMergeTags}'s live-scope branch, these carry no live value —
|
|
125
|
+
* in edit mode the iframe holds no `$map`, so fields are resolved parent-side from schema and the
|
|
126
|
+
* hint is empty. `item` and `index` are guaranteed first; the list is deduped preserving order.
|
|
127
|
+
*
|
|
128
|
+
* @param tokens - Insertion tokens (see `resolveRepeaterItemFields`); typically leads
|
|
129
|
+
* `item`/`index`.
|
|
130
|
+
* @returns One {@link MergeTag} per unique token, `category: "repeater"`, `label === token`.
|
|
131
|
+
*/
|
|
132
|
+
export function buildRepeaterTagsFromFields(tokens: string[]): MergeTag[] {
|
|
133
|
+
const ordered = ["item", "index", ...tokens];
|
|
134
|
+
const seen = new Set<string>();
|
|
135
|
+
const out: MergeTag[] = [];
|
|
136
|
+
for (const token of ordered) {
|
|
137
|
+
if (!token || seen.has(token)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
seen.add(token);
|
|
141
|
+
out.push({ category: "repeater", hint: "", label: token, token });
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repeater scope resolution — enumerate the local `item` / `index` / field tokens available when
|
|
3
|
+
* the author inline-edits text INSIDE a repeater (`$prototype: "Array"`) on the design canvas.
|
|
4
|
+
*
|
|
5
|
+
* There is no live `$map` in edit mode: `prepareForEditMode` renders a mapped array as a
|
|
6
|
+
* `repeater-perimeter` wrapping one glyph-ified template instance, so `renderMappedArrayInto`
|
|
7
|
+
* (which sets `child.$map`) never runs. Instead we resolve field names PARENT-SIDE from the
|
|
8
|
+
* repeater's binding + the project's content-type / state schema, keyed off the selected element's
|
|
9
|
+
* document path (which carries a real `"map"` segment — see canvas/path-mapping.ts
|
|
10
|
+
* `classifyRenderNode`).
|
|
11
|
+
*
|
|
12
|
+
* Pure and DOM-free: deps limited to `@jxsuite/schema` guards/types + {@link getNodeAtPath}.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isRef } from "@jxsuite/schema/guards";
|
|
16
|
+
import { getNodeAtPath } from "../state";
|
|
17
|
+
|
|
18
|
+
import type { JxPath } from "../state";
|
|
19
|
+
import type {
|
|
20
|
+
JxMappedArray,
|
|
21
|
+
JxMutableNode,
|
|
22
|
+
ProjectConfig,
|
|
23
|
+
ContentTypeSchema,
|
|
24
|
+
} from "@jxsuite/schema/types";
|
|
25
|
+
|
|
26
|
+
/** Cap on how many inferred/declared fields we enumerate (mirrors merge-tags BREADTH_CAP breadth). */
|
|
27
|
+
const FIELD_CAP = 50;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Find the repeater (`$prototype: "Array"`) that encloses the node at `path`. The stamper rewrites
|
|
31
|
+
* an edit-mode perimeter render path back to `[...P, "map", …]` where `P` is the repeater's own doc
|
|
32
|
+
* path, so an element inside a repeater carries a real-doc path containing a `"map"` segment. We
|
|
33
|
+
* take the LAST `"map"` (innermost repeater wins for nesting) and resolve the node at the prefix
|
|
34
|
+
* before it.
|
|
35
|
+
*
|
|
36
|
+
* @param doc - The tab's document root (`tab.doc.document`).
|
|
37
|
+
* @param path - The selected element's document path.
|
|
38
|
+
* @returns The enclosing `JxMappedArray` node, or `null` when the path is not inside a repeater.
|
|
39
|
+
*/
|
|
40
|
+
export function findEnclosingRepeater(
|
|
41
|
+
doc: JxMutableNode,
|
|
42
|
+
path: JxPath | null | undefined,
|
|
43
|
+
): JxMappedArray | null {
|
|
44
|
+
if (!Array.isArray(path) || path.length === 0) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const mapIdx = path.lastIndexOf("map");
|
|
48
|
+
if (mapIdx <= 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const node = getNodeAtPath(doc, path.slice(0, mapIdx)) as
|
|
52
|
+
| (Record<string, unknown> & JxMappedArray)
|
|
53
|
+
| undefined;
|
|
54
|
+
return node && node.$prototype === "Array" ? (node as JxMappedArray) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Strip a `#/state/<name>` JSON-Pointer ref to its bare state name (null when it is not one). */
|
|
58
|
+
function refToStateName(ref: unknown): string | null {
|
|
59
|
+
if (!isRef(ref)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const { $ref: target } = ref;
|
|
63
|
+
return target.startsWith("#/state/") ? target.slice("#/state/".length) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Cap + $-filter a field-name list to a stable, author-facing subset. */
|
|
67
|
+
function safeFields(names: string[]): string[] {
|
|
68
|
+
return names.filter((k) => !k.startsWith("$")).slice(0, FIELD_CAP);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the local scope tokens for text edited inside `arrayNode`. Always leads with `item` and
|
|
73
|
+
* `index`; appends field tokens resolved from the repeater's binding:
|
|
74
|
+
*
|
|
75
|
+
* - **Content collection** — `items` refs a `{ contentType }` state def. Item shape is `{ id, data: {
|
|
76
|
+
* …fields }, body }`, so fields come from
|
|
77
|
+
* `projectConfig.contentTypes[contentType].schema.properties` and emit as `item.data.<f>` (the
|
|
78
|
+
* `.data.` nesting is LOAD-BEARING — runtime templates use `${item.data.title}`), plus `item.id`
|
|
79
|
+
* / `item.body`.
|
|
80
|
+
* - **State array** — `items` refs a state def that is an array of objects. Fields come from a
|
|
81
|
+
* declared `items.properties`, else are inferred from a sample `default[0]`. Emitted FLAT
|
|
82
|
+
* (`item.<f>`, no `.data.`).
|
|
83
|
+
* - **Inline array** — `items` is a literal array; fields inferred from `items[0]` object keys.
|
|
84
|
+
* - **Fallback** — anything unresolvable yields exactly `["item", "index"]`.
|
|
85
|
+
*
|
|
86
|
+
* @param arrayNode - The repeater node from {@link findEnclosingRepeater}.
|
|
87
|
+
* @param state - The document's `state` definitions (`tab.doc.document.state`).
|
|
88
|
+
* @param projectConfig - Parsed `project.json` (`projectState?.projectConfig`).
|
|
89
|
+
* @returns Token strings, e.g. `["item", "index", "item.id", "item.body", "item.data.title"]`.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveRepeaterItemFields(
|
|
92
|
+
arrayNode: JxMappedArray,
|
|
93
|
+
state: Record<string, unknown> | null | undefined,
|
|
94
|
+
projectConfig: ProjectConfig | null | undefined,
|
|
95
|
+
): string[] {
|
|
96
|
+
const base = ["item", "index"];
|
|
97
|
+
const { items } = arrayNode as { items?: unknown };
|
|
98
|
+
const defs = state ?? {};
|
|
99
|
+
|
|
100
|
+
const stateName = refToStateName(items);
|
|
101
|
+
if (stateName) {
|
|
102
|
+
const def = defs[stateName] as Record<string, unknown> | undefined;
|
|
103
|
+
if (def && typeof def === "object") {
|
|
104
|
+
// (a) Content collection — `{ contentType: "<name>" }`. Import-aliased `$prototype` still
|
|
105
|
+
// Matches because we detect on the `contentType` string, not the prototype name.
|
|
106
|
+
if (typeof def.contentType === "string") {
|
|
107
|
+
const ct = projectConfig?.contentTypes?.[def.contentType];
|
|
108
|
+
const schema = ct?.schema as ContentTypeSchema | undefined;
|
|
109
|
+
const props = schema?.properties;
|
|
110
|
+
const out = [...base, "item.id", "item.body"];
|
|
111
|
+
if (props && typeof props === "object") {
|
|
112
|
+
for (const f of safeFields(Object.keys(props))) {
|
|
113
|
+
out.push(`item.data.${f}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// (b) State array — declared object item-schema, or inferred from a sample default[0].
|
|
120
|
+
const itemsSchema = def.items as Record<string, unknown> | undefined;
|
|
121
|
+
const declared = itemsSchema?.properties as Record<string, unknown> | undefined;
|
|
122
|
+
if (declared && typeof declared === "object") {
|
|
123
|
+
return [...base, ...safeFields(Object.keys(declared)).map((f) => `item.${f}`)];
|
|
124
|
+
}
|
|
125
|
+
const defaults = def.default as unknown[] | undefined;
|
|
126
|
+
const sample: unknown = Array.isArray(defaults) ? defaults[0] : undefined;
|
|
127
|
+
if (sample && typeof sample === "object" && !Array.isArray(sample)) {
|
|
128
|
+
return [...base, ...safeFields(Object.keys(sample)).map((f) => `item.${f}`)];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return base;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// (c) Inline literal array — infer fields from the first object element.
|
|
135
|
+
if (Array.isArray(items)) {
|
|
136
|
+
const [first] = items as unknown[];
|
|
137
|
+
if (first && typeof first === "object" && !Array.isArray(first)) {
|
|
138
|
+
return [...base, ...safeFields(Object.keys(first)).map((f) => `item.${f}`)];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// (d) Fallback — unresolvable binding, primitive/empty items, or missing schema.
|
|
143
|
+
return base;
|
|
144
|
+
}
|
package/src/editor/shortcuts.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { isEditing, stopEditing } from "./inline-edit";
|
|
|
20
20
|
import { copyNode, cutNode, pasteNode } from "./context-menu";
|
|
21
21
|
import { openQuickSearch } from "../panels/quick-search";
|
|
22
22
|
import { showConfirmDialog } from "../ui/layers";
|
|
23
|
+
import { rectOf } from "../utils/geometry";
|
|
23
24
|
|
|
24
25
|
import type { JxPath } from "../state";
|
|
25
26
|
|
|
@@ -33,10 +34,8 @@ import type { JxPath } from "../state";
|
|
|
33
34
|
* setPan: (x: number, y: number) => void;
|
|
34
35
|
* applyTransform: () => void;
|
|
35
36
|
* positionZoomIndicator: () => void;
|
|
36
|
-
* componentInlineEdit: object | null;
|
|
37
37
|
* saveFile: () => void;
|
|
38
38
|
* openProject: () => void;
|
|
39
|
-
* enterEditOnPath: (path: JxPath) => void;
|
|
40
39
|
* }} getContext
|
|
41
40
|
*/
|
|
42
41
|
export function initShortcuts(
|
|
@@ -47,10 +46,8 @@ export function initShortcuts(
|
|
|
47
46
|
setPan: (x: number, y: number) => void;
|
|
48
47
|
applyTransform: () => void;
|
|
49
48
|
positionZoomIndicator: () => void;
|
|
50
|
-
componentInlineEdit: Record<string, unknown> | null;
|
|
51
49
|
saveFile: () => void;
|
|
52
50
|
openProject: () => void;
|
|
53
|
-
enterEditOnPath: (path: JxPath) => void;
|
|
54
51
|
},
|
|
55
52
|
) {
|
|
56
53
|
// Wheel handler: Ctrl+Scroll = zoom (cursor-centered), plain scroll = pan
|
|
@@ -58,8 +55,16 @@ export function initShortcuts(
|
|
|
58
55
|
"wheel",
|
|
59
56
|
(e: WheelEvent) => {
|
|
60
57
|
const { canvasMode, panX, panY, setPan, applyTransform } = getContext();
|
|
61
|
-
// Edit (content) mode:
|
|
58
|
+
// Edit (content) mode: scroll the edit-mode container ourselves. The canvas iframe is sized to
|
|
59
|
+
// Its content (no internal scroll) and a cross-origin OOPIF doesn't bubble wheel to the parent,
|
|
60
|
+
// So the wheel reaches us forwarded (or over the canvas chrome) but never triggers native scroll.
|
|
62
61
|
if (canvasMode === "edit") {
|
|
62
|
+
const sc = canvasWrap.querySelector<HTMLElement>(".content-edit-canvas");
|
|
63
|
+
if (sc) {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
sc.scrollTop += e.deltaY;
|
|
66
|
+
sc.scrollLeft += e.deltaX;
|
|
67
|
+
}
|
|
63
68
|
return;
|
|
64
69
|
}
|
|
65
70
|
// Manage mode: browse table handles its own scrolling
|
|
@@ -69,7 +74,7 @@ export function initShortcuts(
|
|
|
69
74
|
e.preventDefault();
|
|
70
75
|
if (e.ctrlKey || e.metaKey) {
|
|
71
76
|
// Zoom towards cursor
|
|
72
|
-
const rect = canvasWrap
|
|
77
|
+
const rect = rectOf(canvasWrap);
|
|
73
78
|
const cursorX = e.clientX - rect.left;
|
|
74
79
|
const cursorY = e.clientY - rect.top;
|
|
75
80
|
const oldZoom = activeTab.value?.session.ui.zoom ?? 1;
|
|
@@ -124,15 +129,7 @@ export function initShortcuts(
|
|
|
124
129
|
window.addEventListener("resize", () => getContext().positionZoomIndicator());
|
|
125
130
|
|
|
126
131
|
document.addEventListener("keydown", (e) => {
|
|
127
|
-
const {
|
|
128
|
-
canvasMode,
|
|
129
|
-
setPan,
|
|
130
|
-
applyTransform,
|
|
131
|
-
componentInlineEdit,
|
|
132
|
-
saveFile,
|
|
133
|
-
openProject,
|
|
134
|
-
enterEditOnPath,
|
|
135
|
-
} = getContext();
|
|
132
|
+
const { canvasMode, setPan, applyTransform, saveFile, openProject } = getContext();
|
|
136
133
|
const tab = activeTab.value;
|
|
137
134
|
const mod = e.ctrlKey || e.metaKey;
|
|
138
135
|
|
|
@@ -163,18 +160,6 @@ export function initShortcuts(
|
|
|
163
160
|
}
|
|
164
161
|
return;
|
|
165
162
|
}
|
|
166
|
-
if (componentInlineEdit) {
|
|
167
|
-
if (mod && e.key === "s") {
|
|
168
|
-
e.preventDefault();
|
|
169
|
-
stopEditing();
|
|
170
|
-
saveFile();
|
|
171
|
-
}
|
|
172
|
-
if (mod && e.key === "w") {
|
|
173
|
-
e.preventDefault();
|
|
174
|
-
}
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
163
|
if (mod) {
|
|
179
164
|
switch (e.key) {
|
|
180
165
|
case "w": {
|
|
@@ -305,7 +290,8 @@ export function initShortcuts(
|
|
|
305
290
|
mutateInsertNode(t, pp, idx + 1, { tagName: "p", textContent: "" });
|
|
306
291
|
t.session.selection = newPath;
|
|
307
292
|
});
|
|
308
|
-
|
|
293
|
+
// The iframe canvas re-enters inline edit for the freshly-selected node via its own
|
|
294
|
+
// Posted enterEdit flow, so no parent-side enterEditOnPath is needed here.
|
|
309
295
|
}
|
|
310
296
|
break;
|
|
311
297
|
}
|
package/src/editor/slash-menu.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { html, render as litRender, nothing } from "lit-html";
|
|
12
12
|
import { ref } from "lit-html/directives/ref.js";
|
|
13
13
|
import { getLayerSlot } from "../ui/layers";
|
|
14
|
+
import { rectOf } from "../utils/geometry";
|
|
14
15
|
|
|
15
16
|
interface SlashCommand {
|
|
16
17
|
label: string;
|
|
@@ -40,16 +41,26 @@ const SLASH_COMMANDS = [
|
|
|
40
41
|
|
|
41
42
|
// ─── State ────────────────────────────────────────────────────────────────────
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
/** Callbacks a caller passes to {@link showSlashMenu}/{@link showSlashMenuAtRect}. */
|
|
45
|
+
export interface SlashMenuCallbacks {
|
|
44
46
|
onSelect: (cmd: SlashCommand) => void;
|
|
47
|
+
/** Fired whenever the menu closes (outside click, Escape, no matches, and just before select). */
|
|
48
|
+
onDismiss?: () => void;
|
|
45
49
|
showFilter?: boolean;
|
|
46
50
|
commands?: SlashCommand[];
|
|
47
|
-
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The anchor geometry the menu positions from (a DOMRect satisfies this). */
|
|
54
|
+
export interface SlashMenuAnchorRect {
|
|
55
|
+
left: number;
|
|
56
|
+
bottom: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let callbacks: SlashMenuCallbacks | null = null;
|
|
48
60
|
let activeIdx = 0;
|
|
49
61
|
let filteredItems: SlashCommand[] = [];
|
|
50
62
|
let open = false;
|
|
51
|
-
let
|
|
52
|
-
let _anchorRect: DOMRect | null = null;
|
|
63
|
+
let _anchorRect: SlashMenuAnchorRect | null = null;
|
|
53
64
|
let _filterEl: HTMLInputElement | null = null;
|
|
54
65
|
let _popoverEl: HTMLElement | null = null;
|
|
55
66
|
|
|
@@ -70,24 +81,33 @@ export function isSlashMenuOpen() {
|
|
|
70
81
|
*
|
|
71
82
|
* @param {HTMLElement} anchorEl — the element being edited (for positioning)
|
|
72
83
|
* @param {string} filter — current typed filter text (after the "/")
|
|
73
|
-
* @param {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
* @param {SlashMenuCallbacks} cbs
|
|
85
|
+
*/
|
|
86
|
+
export function showSlashMenu(anchorEl: HTMLElement, filter: string, cbs: SlashMenuCallbacks) {
|
|
87
|
+
showAt(rectOf(anchorEl), filter, cbs);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Show (or update) the slash menu anchored below a PARENT-VIEWPORT rect — for callers with no
|
|
92
|
+
* anchor element in this realm (the canvas iframe posts the edited element's rect across the bridge
|
|
93
|
+
* and the host converts it).
|
|
94
|
+
*
|
|
95
|
+
* @param {SlashMenuAnchorRect} rect
|
|
96
|
+
* @param {string} filter
|
|
97
|
+
* @param {SlashMenuCallbacks} cbs
|
|
78
98
|
*/
|
|
79
|
-
export function
|
|
80
|
-
|
|
99
|
+
export function showSlashMenuAtRect(
|
|
100
|
+
rect: SlashMenuAnchorRect,
|
|
81
101
|
filter: string,
|
|
82
|
-
cbs:
|
|
83
|
-
onSelect: (cmd: SlashCommand) => void;
|
|
84
|
-
showFilter?: boolean;
|
|
85
|
-
commands?: SlashCommand[];
|
|
86
|
-
},
|
|
102
|
+
cbs: SlashMenuCallbacks,
|
|
87
103
|
) {
|
|
104
|
+
showAt(rect, filter, cbs);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Shared body of the two show entry points. */
|
|
108
|
+
function showAt(rect: SlashMenuAnchorRect, filter: string, cbs: SlashMenuCallbacks) {
|
|
88
109
|
callbacks = cbs;
|
|
89
|
-
|
|
90
|
-
_anchorRect = anchorEl.getBoundingClientRect();
|
|
110
|
+
_anchorRect = rect;
|
|
91
111
|
|
|
92
112
|
const source = cbs.commands || SLASH_COMMANDS;
|
|
93
113
|
filteredItems = filter
|
|
@@ -103,7 +123,7 @@ export function showSlashMenu(
|
|
|
103
123
|
|
|
104
124
|
activeIdx = 0;
|
|
105
125
|
|
|
106
|
-
render(
|
|
126
|
+
render(cbs.showFilter || false);
|
|
107
127
|
|
|
108
128
|
if (!open) {
|
|
109
129
|
open = true;
|
|
@@ -126,9 +146,9 @@ export function dismissSlashMenu() {
|
|
|
126
146
|
if (!open) {
|
|
127
147
|
return;
|
|
128
148
|
}
|
|
149
|
+
const cbs = callbacks;
|
|
129
150
|
open = false;
|
|
130
151
|
callbacks = null;
|
|
131
|
-
_anchorEl = null;
|
|
132
152
|
_anchorRect = null;
|
|
133
153
|
_filterEl = null;
|
|
134
154
|
_popoverEl = null;
|
|
@@ -136,16 +156,19 @@ export function dismissSlashMenu() {
|
|
|
136
156
|
document.removeEventListener("keydown", onKeydown, true);
|
|
137
157
|
document.removeEventListener("mousedown", onOutsideClick, true);
|
|
138
158
|
litRender(nothing, getHost());
|
|
159
|
+
// After teardown so a re-entrant show from the callback sees a closed menu. select() relies on
|
|
160
|
+
// This ordering too: dismiss (→ onDismiss) fires BEFORE onSelect.
|
|
161
|
+
cbs?.onDismiss?.();
|
|
139
162
|
}
|
|
140
163
|
|
|
141
164
|
// ─── Internal ─────────────────────────────────────────────────────────────────
|
|
142
165
|
|
|
143
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
166
|
+
/** @param {boolean} showFilter */
|
|
167
|
+
function render(showFilter: boolean) {
|
|
168
|
+
const rect = _anchorRect;
|
|
169
|
+
if (!rect) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
149
172
|
|
|
150
173
|
litRender(
|
|
151
174
|
html`
|
|
@@ -224,9 +247,7 @@ function onFilterInput(e: Event) {
|
|
|
224
247
|
: source;
|
|
225
248
|
|
|
226
249
|
activeIdx = 0;
|
|
227
|
-
|
|
228
|
-
render(_anchorEl, true);
|
|
229
|
-
}
|
|
250
|
+
render(true);
|
|
230
251
|
|
|
231
252
|
// Re-focus input after re-render
|
|
232
253
|
requestAnimationFrame(() => {
|
|
@@ -238,17 +259,21 @@ function onFilterInput(e: Event) {
|
|
|
238
259
|
});
|
|
239
260
|
}
|
|
240
261
|
|
|
241
|
-
/**
|
|
242
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Drive the open menu with a navigation key. The canvas-iframe bridge calls this DIRECTLY (the key
|
|
264
|
+
* was pressed in the iframe realm — a synthetic keydown redispatch on this document would lose the
|
|
265
|
+
* capture-first + stopPropagation semantics the menu relies on to shield other handlers).
|
|
266
|
+
*
|
|
267
|
+
* @param {string} key — "ArrowDown" | "ArrowUp" | "Enter" | "Escape"
|
|
268
|
+
*/
|
|
269
|
+
export function handleSlashMenuKey(key: string): void {
|
|
243
270
|
if (!open) {
|
|
244
271
|
return;
|
|
245
272
|
}
|
|
246
273
|
|
|
247
274
|
const items = getHost().querySelectorAll("sp-menu-item:not([disabled])") as NodeListOf<Element>;
|
|
248
275
|
|
|
249
|
-
if (
|
|
250
|
-
e.preventDefault();
|
|
251
|
-
e.stopPropagation();
|
|
276
|
+
if (key === "ArrowDown") {
|
|
252
277
|
if (items.length === 0) {
|
|
253
278
|
return;
|
|
254
279
|
}
|
|
@@ -256,9 +281,7 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
256
281
|
activeIdx = (activeIdx + 1) % items.length;
|
|
257
282
|
items[activeIdx]?.setAttribute("focused", "");
|
|
258
283
|
items[activeIdx]?.scrollIntoView({ block: "nearest" });
|
|
259
|
-
} else if (
|
|
260
|
-
e.preventDefault();
|
|
261
|
-
e.stopPropagation();
|
|
284
|
+
} else if (key === "ArrowUp") {
|
|
262
285
|
if (items.length === 0) {
|
|
263
286
|
return;
|
|
264
287
|
}
|
|
@@ -266,16 +289,24 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
266
289
|
activeIdx = (activeIdx - 1 + items.length) % items.length;
|
|
267
290
|
items[activeIdx]?.setAttribute("focused", "");
|
|
268
291
|
items[activeIdx]?.scrollIntoView({ block: "nearest" });
|
|
269
|
-
} else if (
|
|
270
|
-
e.preventDefault();
|
|
271
|
-
e.stopPropagation();
|
|
292
|
+
} else if (key === "Enter") {
|
|
272
293
|
const cmd = filteredItems[activeIdx];
|
|
273
294
|
if (cmd) {
|
|
274
295
|
select(cmd);
|
|
275
296
|
}
|
|
276
|
-
} else if (
|
|
297
|
+
} else if (key === "Escape") {
|
|
298
|
+
dismissSlashMenu();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @param {KeyboardEvent} e */
|
|
303
|
+
function onKeydown(e: KeyboardEvent) {
|
|
304
|
+
if (!open) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.key)) {
|
|
277
308
|
e.preventDefault();
|
|
278
309
|
e.stopPropagation();
|
|
279
|
-
|
|
310
|
+
handleSlashMenuKey(e.key);
|
|
280
311
|
}
|
|
281
312
|
}
|
package/src/files/files.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { statusMessage } from "../panels/statusbar";
|
|
|
18
18
|
import { loadComponentRegistry } from "./components";
|
|
19
19
|
import { ensureDependenciesInstalled } from "../packages/ensure-deps";
|
|
20
20
|
import { maybePromptJxsuiteUpdate } from "../packages/jxsuite-update";
|
|
21
|
+
import { autoSyncProjectOnOpen } from "../packages/pull-package-sync";
|
|
21
22
|
import { markLocalMutation } from "./fs-events";
|
|
22
23
|
import {
|
|
23
24
|
draggable,
|
|
@@ -41,6 +42,7 @@ import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
|
41
42
|
import type { StudioState } from "../state.js";
|
|
42
43
|
import type { Tab } from "../tabs/tab.js";
|
|
43
44
|
import type { RenameResult } from "../types";
|
|
45
|
+
import { rectOf } from "../utils/geometry";
|
|
44
46
|
|
|
45
47
|
// ─── File icon map ────────────────────────────────────────────────────────────
|
|
46
48
|
|
|
@@ -96,6 +98,7 @@ export async function loadProject() {
|
|
|
96
98
|
|
|
97
99
|
if (info.isSiteProject) {
|
|
98
100
|
addRecentProject(requireProjectState().name, meta.root);
|
|
101
|
+
await autoSyncProjectOnOpen();
|
|
99
102
|
await ensureDependenciesInstalled();
|
|
100
103
|
await loadDirectory(".");
|
|
101
104
|
await loadComponentRegistry();
|
|
@@ -154,6 +157,7 @@ export async function openProject({
|
|
|
154
157
|
selectedPath: null,
|
|
155
158
|
});
|
|
156
159
|
|
|
160
|
+
await autoSyncProjectOnOpen();
|
|
157
161
|
await ensureDependenciesInstalled();
|
|
158
162
|
await loadDirectory(".");
|
|
159
163
|
await loadComponentRegistry();
|
|
@@ -738,7 +742,7 @@ function showFileContextMenu(
|
|
|
738
742
|
}
|
|
739
743
|
requestAnimationFrame(() => {
|
|
740
744
|
const popover = el as HTMLElement;
|
|
741
|
-
const menuRect = popover
|
|
745
|
+
const menuRect = rectOf(popover);
|
|
742
746
|
if (x + menuRect.width > window.innerWidth) {
|
|
743
747
|
x = window.innerWidth - menuRect.width - 4;
|
|
744
748
|
}
|