@jxsuite/studio 0.28.4 → 0.29.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/studio.js +6769 -5399
- package/dist/studio.js.map +86 -79
- package/package.json +5 -4
- package/src/browse/browse-modal.ts +2 -2
- package/src/browse/browse.ts +20 -19
- package/src/canvas/canvas-diff.ts +3 -3
- package/src/canvas/canvas-helpers.ts +15 -2
- package/src/canvas/canvas-live-render.ts +168 -86
- package/src/canvas/canvas-patcher.ts +656 -0
- package/src/canvas/canvas-perf.ts +68 -0
- package/src/canvas/canvas-render.ts +135 -23
- package/src/canvas/canvas-subtree-render.ts +113 -0
- package/src/canvas/canvas-utils.ts +4 -0
- package/src/editor/component-inline-edit.ts +4 -35
- package/src/editor/context-menu.ts +92 -74
- package/src/editor/convert-to-component.ts +11 -2
- package/src/editor/convert-to-repeater.ts +4 -5
- package/src/editor/inline-edit.ts +9 -9
- package/src/editor/inline-format.ts +11 -11
- package/src/editor/merge-tags.ts +120 -0
- package/src/editor/shortcuts.ts +4 -4
- package/src/files/components.ts +37 -0
- package/src/files/file-ops.ts +28 -7
- package/src/files/files.ts +25 -21
- package/src/github/github-auth.ts +14 -2
- package/src/github/github-publish.ts +12 -2
- package/src/panels/activity-bar.ts +1 -1
- package/src/panels/ai-panel.ts +67 -39
- package/src/panels/block-action-bar.ts +72 -1
- package/src/panels/canvas-dnd.ts +55 -26
- package/src/panels/data-explorer.ts +5 -3
- package/src/panels/dnd.ts +12 -15
- package/src/panels/editors.ts +5 -23
- package/src/panels/elements-panel.ts +2 -12
- package/src/panels/events-panel.ts +1 -1
- package/src/panels/git-panel.ts +6 -6
- package/src/panels/head-panel.ts +1 -1
- package/src/panels/layers-panel.ts +177 -147
- package/src/panels/preview-render.ts +15 -0
- package/src/panels/properties-panel.ts +16 -27
- package/src/panels/quick-search.ts +2 -2
- package/src/panels/right-panel.ts +2 -6
- package/src/panels/shared.ts +3 -0
- package/src/panels/signals-panel.ts +24 -22
- package/src/panels/statusbar.ts +15 -6
- package/src/panels/style-panel.ts +3 -3
- package/src/panels/style-utils.ts +3 -3
- package/src/panels/stylebook-panel.ts +4 -4
- package/src/panels/tab-bar.ts +198 -0
- package/src/panels/tab-strip.ts +2 -2
- package/src/panels/toolbar.ts +6 -75
- package/src/platforms/devserver.ts +60 -34
- package/src/reactivity.ts +2 -0
- package/src/services/cem-export.ts +23 -2
- package/src/services/code-services.ts +16 -2
- package/src/services/monaco-setup.ts +2 -1
- package/src/settings/content-types-editor.ts +16 -16
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/settings/defs-editor.ts +14 -14
- package/src/settings/general-settings.ts +6 -6
- package/src/settings/head-editor.ts +2 -2
- package/src/site-context.ts +1 -1
- package/src/state.ts +66 -12
- package/src/store.ts +15 -3
- package/src/studio.ts +73 -33
- package/src/tabs/patch-ops.ts +143 -0
- package/src/tabs/tab.ts +15 -1
- package/src/tabs/transact.ts +454 -27
- package/src/types.ts +41 -0
- package/src/ui/color-selector.ts +7 -7
- package/src/ui/icons.ts +0 -30
- package/src/ui/media-picker.ts +2 -2
- package/src/ui/panel-resize.ts +6 -1
- package/src/ui/unit-selector.ts +2 -2
- package/src/ui/value-selector.ts +3 -3
- package/src/utils/canvas-media.ts +6 -6
- package/src/utils/edit-display.ts +125 -83
- package/src/utils/google-fonts.ts +1 -1
- package/src/utils/inherited-style.ts +3 -2
- package/src/utils/studio-utils.ts +1 -1
- package/src/view.ts +4 -1
package/src/types.ts
CHANGED
|
@@ -35,11 +35,18 @@ export interface GitLogEntry {
|
|
|
35
35
|
date: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export interface ComponentSlotMeta {
|
|
39
|
+
name: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
fallback?: (JxMutableNode | string)[];
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
export interface ComponentMeta {
|
|
39
45
|
tagName: string;
|
|
40
46
|
$id?: string | null;
|
|
41
47
|
path: string;
|
|
42
48
|
props?: { name: string; type?: string; default?: JsonValue; [k: string]: unknown }[];
|
|
49
|
+
slots?: ComponentSlotMeta[];
|
|
43
50
|
hasElements?: boolean;
|
|
44
51
|
}
|
|
45
52
|
|
|
@@ -132,6 +139,18 @@ export interface StudioPlatform {
|
|
|
132
139
|
aiStreamUrl: (id: string) => string | Promise<string>;
|
|
133
140
|
aiStopSession: (id: string) => Promise<void>;
|
|
134
141
|
aiDeleteSession: (id: string) => Promise<void>;
|
|
142
|
+
// ─── Multi-window (desktop only; undefined on dev-server) ───────────────────
|
|
143
|
+
/** Open a project in a new window, focusing an existing window if it is already open. */
|
|
144
|
+
openProjectInNewWindow?: (root: string) => Promise<void>;
|
|
145
|
+
/** Open a fresh welcome window. */
|
|
146
|
+
newWindow?: () => Promise<void>;
|
|
147
|
+
/**
|
|
148
|
+
* Point THIS window's backend at a project and return its config. If the project is already open
|
|
149
|
+
* in another window, that window is focused and `deduped` is true (no project is loaded here).
|
|
150
|
+
*/
|
|
151
|
+
setWindowProject?: (root: string) => Promise<{ deduped: boolean; config: ProjectConfig | null }>;
|
|
152
|
+
/** The project root this window's backend is currently bound to. */
|
|
153
|
+
getProjectRoot?: () => Promise<{ root: string | null }>;
|
|
135
154
|
}
|
|
136
155
|
|
|
137
156
|
// ─── Studio Types ───────────────────────────────────────────────────────────
|
|
@@ -146,6 +165,28 @@ export interface CanvasPanel {
|
|
|
146
165
|
scrollContainer: HTMLElement;
|
|
147
166
|
dropLine: HTMLElement;
|
|
148
167
|
_width: number | null;
|
|
168
|
+
/** True when the panel's DOM reflects the current document via a successful live render. */
|
|
169
|
+
ready: boolean;
|
|
170
|
+
/** Breakpoints active for this panel's width (persisted for surgical patch re-application). */
|
|
171
|
+
activeBreakpoints: Set<string> | null;
|
|
172
|
+
/** Render context captured from the last successful live render (null until then). */
|
|
173
|
+
liveCtx: PanelLiveCtx | null;
|
|
174
|
+
/**
|
|
175
|
+
* Effect scope owning the reactive effects created while rendering this panel's content
|
|
176
|
+
* (including child scopes from surgical subtree renders). Stopped when panels are rebuilt.
|
|
177
|
+
*/
|
|
178
|
+
renderScope: { stop: () => void; run: <T>(fn: () => T) => T | undefined } | null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Per-panel context persisted by a successful live render so patches can re-render subtrees. */
|
|
182
|
+
export interface PanelLiveCtx {
|
|
183
|
+
scope: Record<string, unknown>;
|
|
184
|
+
canvasMode: string;
|
|
185
|
+
layoutWrapped: boolean;
|
|
186
|
+
pageContentPrefix: (string | number)[] | null;
|
|
187
|
+
pageContentOffset: number;
|
|
188
|
+
arrayPaths: Set<string>;
|
|
189
|
+
pathMapper: (created: Node, path: (string | number)[], def: unknown) => void;
|
|
149
190
|
}
|
|
150
191
|
|
|
151
192
|
export interface DocumentStackEntry {
|
package/src/ui/color-selector.ts
CHANGED
|
@@ -58,7 +58,7 @@ function resolveColorForDisplay(val: string | number | undefined) {
|
|
|
58
58
|
const m = s.match(/^var\((--[^)]+)\)$/);
|
|
59
59
|
if (m) {
|
|
60
60
|
const style = getEffectiveStyle(activeTab.value?.doc.document?.style);
|
|
61
|
-
const resolved = style?.[m[1]];
|
|
61
|
+
const resolved = style?.[m[1]!];
|
|
62
62
|
if (typeof resolved === "string") {
|
|
63
63
|
return resolved;
|
|
64
64
|
}
|
|
@@ -108,7 +108,7 @@ function matchesColorVar(
|
|
|
108
108
|
// ─── JxColorPopover LitElement ──────────────────────────────────────────────
|
|
109
109
|
|
|
110
110
|
export class JxColorPopover extends LitElement {
|
|
111
|
-
static properties = {
|
|
111
|
+
static override properties = {
|
|
112
112
|
color: { type: String },
|
|
113
113
|
colorVars: { attribute: false },
|
|
114
114
|
displayColor: { attribute: false, type: String },
|
|
@@ -126,12 +126,12 @@ export class JxColorPopover extends LitElement {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/** No shadow DOM — render directly into light DOM for Spectrum theming */
|
|
129
|
-
createRenderRoot() {
|
|
129
|
+
override createRenderRoot() {
|
|
130
130
|
return this;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
/** @param {Map<string, unknown>} changed */
|
|
134
|
-
willUpdate(changed: Map<string, unknown>) {
|
|
134
|
+
override willUpdate(changed: Map<string, unknown>) {
|
|
135
135
|
if (changed.has("color")) {
|
|
136
136
|
const raw = resolveColorForDisplay(this.color);
|
|
137
137
|
if (!raw || raw === "transparent") {
|
|
@@ -175,7 +175,7 @@ export class JxColorPopover extends LitElement {
|
|
|
175
175
|
this.dispatchEvent(new CustomEvent("color-change", { bubbles: true, detail: varRef }));
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
render() {
|
|
178
|
+
override render() {
|
|
179
179
|
return html`
|
|
180
180
|
<div class="color-popover-inner">
|
|
181
181
|
<sp-color-area
|
|
@@ -258,7 +258,7 @@ export function renderColorSelector(
|
|
|
258
258
|
<jx-color-popover
|
|
259
259
|
.color=${value || ""}
|
|
260
260
|
.colorVars=${colorVars}
|
|
261
|
-
@color-change=${(e: CustomEvent) => onChange(e.detail)}
|
|
261
|
+
@color-change=${(e: CustomEvent) => onChange(e.detail as string)}
|
|
262
262
|
></jx-color-popover>
|
|
263
263
|
</sp-popover>
|
|
264
264
|
</sp-overlay>
|
|
@@ -314,7 +314,7 @@ export function renderColorSelector(
|
|
|
314
314
|
<jx-color-popover
|
|
315
315
|
.color=${value || ""}
|
|
316
316
|
.colorVars=${colorVars}
|
|
317
|
-
@color-change=${(e: CustomEvent) => onChange(e.detail)}
|
|
317
|
+
@color-change=${(e: CustomEvent) => onChange(e.detail as string)}
|
|
318
318
|
></jx-color-popover>
|
|
319
319
|
</sp-popover>
|
|
320
320
|
</sp-overlay>
|
package/src/ui/icons.ts
CHANGED
|
@@ -3,36 +3,6 @@
|
|
|
3
3
|
// Uses Spectrum workflow icons where available; custom SVGs for flex-specific concepts.
|
|
4
4
|
|
|
5
5
|
import { html } from "lit";
|
|
6
|
-
import type { TemplateResult } from "lit";
|
|
7
|
-
|
|
8
|
-
// Helper for custom filled-rect icons (alignment/justify diagrams) where no Spectrum match exists
|
|
9
|
-
const _R = (d: TemplateResult) =>
|
|
10
|
-
html`<svg
|
|
11
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
12
|
-
width="16"
|
|
13
|
-
height="16"
|
|
14
|
-
viewBox="0 0 24 24"
|
|
15
|
-
fill="currentColor"
|
|
16
|
-
stroke="none"
|
|
17
|
-
>
|
|
18
|
-
${d}
|
|
19
|
-
</svg>`;
|
|
20
|
-
|
|
21
|
-
// Helper for custom stroke icons
|
|
22
|
-
const _S = (d: TemplateResult) =>
|
|
23
|
-
html`<svg
|
|
24
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
25
|
-
width="16"
|
|
26
|
-
height="16"
|
|
27
|
-
viewBox="0 0 24 24"
|
|
28
|
-
fill="none"
|
|
29
|
-
stroke="currentColor"
|
|
30
|
-
stroke-width="2"
|
|
31
|
-
stroke-linecap="round"
|
|
32
|
-
stroke-linejoin="round"
|
|
33
|
-
>
|
|
34
|
-
${d}
|
|
35
|
-
</svg>`;
|
|
36
6
|
|
|
37
7
|
export const icons = {
|
|
38
8
|
// ─── Arrows — flexDirection ───
|
package/src/ui/media-picker.ts
CHANGED
|
@@ -290,7 +290,7 @@ function showMediaPickerPopover(anchorEl: HTMLElement, onCommit: (val: string) =
|
|
|
290
290
|
*/
|
|
291
291
|
export function renderMediaPicker(prop: string, value: string, onCommit: (val: string) => void) {
|
|
292
292
|
// Kick off async load (won't block render)
|
|
293
|
-
loadMediaCache();
|
|
293
|
+
void loadMediaCache();
|
|
294
294
|
|
|
295
295
|
const currentValue = value || "";
|
|
296
296
|
const isImage = IMAGE_EXTENSIONS.has(
|
|
@@ -318,7 +318,7 @@ export function renderMediaPicker(prop: string, value: string, onCommit: (val: s
|
|
|
318
318
|
quiet
|
|
319
319
|
title="Browse media"
|
|
320
320
|
@click=${(e: MouseEvent) => {
|
|
321
|
-
loadMediaCache();
|
|
321
|
+
void loadMediaCache();
|
|
322
322
|
showMediaPickerPopover(e.currentTarget as HTMLElement, onCommit);
|
|
323
323
|
}}
|
|
324
324
|
>
|
package/src/ui/panel-resize.ts
CHANGED
|
@@ -19,7 +19,12 @@ const root = document.documentElement;
|
|
|
19
19
|
// ─── Restore saved widths & collapse state ──────────────────────────────────
|
|
20
20
|
|
|
21
21
|
try {
|
|
22
|
-
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}")
|
|
22
|
+
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") as {
|
|
23
|
+
left?: number;
|
|
24
|
+
right?: number;
|
|
25
|
+
leftCollapsed?: boolean;
|
|
26
|
+
rightCollapsed?: boolean;
|
|
27
|
+
};
|
|
23
28
|
if (saved.left) {
|
|
24
29
|
root.style.setProperty("--panel-w-left", `${saved.left}px`);
|
|
25
30
|
}
|
package/src/ui/unit-selector.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function renderUnitSelector(
|
|
|
42
42
|
if (isKeyword) {
|
|
43
43
|
displayValue = strVal;
|
|
44
44
|
} else if (match) {
|
|
45
|
-
|
|
45
|
+
displayValue = match[1]!;
|
|
46
46
|
} else if (strVal !== "") {
|
|
47
47
|
const num = Number.parseFloat(strVal);
|
|
48
48
|
displayValue = Number.isNaN(num) ? strVal : String(num);
|
|
@@ -52,7 +52,7 @@ export function renderUnitSelector(
|
|
|
52
52
|
|
|
53
53
|
// Parse placeholder so inherited values display as "500" not "500px"
|
|
54
54
|
const placeholderMatch = placeholder.match(UNIT_RE);
|
|
55
|
-
const numericPlaceholder = placeholderMatch ? placeholderMatch[1] : placeholder || "0";
|
|
55
|
+
const numericPlaceholder = placeholderMatch ? placeholderMatch[1]! : placeholder || "0";
|
|
56
56
|
|
|
57
57
|
const isExpression = isKeyword || (displayValue !== "" && !isNumericVal(displayValue));
|
|
58
58
|
const hasUnits = units.length > 0 || keywords.length > 0;
|
package/src/ui/value-selector.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { live } from "lit/directives/live.js";
|
|
|
22
22
|
type ComboOption = { value: string; label: string; style?: string } | { divider: true };
|
|
23
23
|
|
|
24
24
|
export class JxValueSelector extends LitElement {
|
|
25
|
-
static properties = {
|
|
25
|
+
static override properties = {
|
|
26
26
|
options: { attribute: false },
|
|
27
27
|
placeholder: { type: String },
|
|
28
28
|
size: { type: String },
|
|
@@ -45,7 +45,7 @@ export class JxValueSelector extends LitElement {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** No shadow DOM — render directly into light DOM */
|
|
48
|
-
createRenderRoot() {
|
|
48
|
+
override createRenderRoot() {
|
|
49
49
|
return this;
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -111,7 +111,7 @@ export class JxValueSelector extends LitElement {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
render() {
|
|
114
|
+
override render() {
|
|
115
115
|
if (this._isPicker) {
|
|
116
116
|
return html`
|
|
117
117
|
<sp-picker
|
|
@@ -25,15 +25,15 @@ export function parseMediaEntries(mediaDef?: Record<string, string> | null) {
|
|
|
25
25
|
for (const [name, query] of Object.entries(mediaDef)) {
|
|
26
26
|
if (name === "--") {
|
|
27
27
|
const wm = String(query).match(/^(\d+)\s*px$/);
|
|
28
|
-
baseWidth = wm ? Number.parseFloat(wm[1]) : 320;
|
|
28
|
+
baseWidth = wm ? Number.parseFloat(wm[1]!) : 320;
|
|
29
29
|
continue;
|
|
30
30
|
}
|
|
31
31
|
const minMatch = query.match(/min-width:\s*([\d.]+)px/);
|
|
32
32
|
const maxMatch = query.match(/max-width:\s*([\d.]+)px/);
|
|
33
33
|
if (minMatch) {
|
|
34
|
-
sizes.push({ name, query, type: "min", width: Number.parseFloat(minMatch[1]) });
|
|
34
|
+
sizes.push({ name, query, type: "min", width: Number.parseFloat(minMatch[1]!) });
|
|
35
35
|
} else if (maxMatch) {
|
|
36
|
-
sizes.push({ name, query, type: "max", width: Number.parseFloat(maxMatch[1]) });
|
|
36
|
+
sizes.push({ name, query, type: "max", width: Number.parseFloat(maxMatch[1]!) });
|
|
37
37
|
} else {
|
|
38
38
|
features.push({ name, query });
|
|
39
39
|
}
|
|
@@ -164,10 +164,10 @@ export function collectMediaOverrides(
|
|
|
164
164
|
continue;
|
|
165
165
|
}
|
|
166
166
|
const [, uid] = jxMatch;
|
|
167
|
-
if (!overrides.has(uid)) {
|
|
168
|
-
overrides.set(uid
|
|
167
|
+
if (!overrides.has(uid!)) {
|
|
168
|
+
overrides.set(uid!, new Map());
|
|
169
169
|
}
|
|
170
|
-
const props = overrides.get(uid) as Map<string, string>;
|
|
170
|
+
const props = overrides.get(uid!) as Map<string, string>;
|
|
171
171
|
for (const prop of mediaRule.style) {
|
|
172
172
|
props.set(prop, mediaRule.style.getPropertyValue(prop));
|
|
173
173
|
}
|
|
@@ -11,6 +11,94 @@ const mediaTags = new Set(["img", "video", "source", "iframe", "audio"]);
|
|
|
11
11
|
const TRANSPARENT_PX =
|
|
12
12
|
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
13
13
|
|
|
14
|
+
const textTags = new Set([
|
|
15
|
+
"p",
|
|
16
|
+
"h1",
|
|
17
|
+
"h2",
|
|
18
|
+
"h3",
|
|
19
|
+
"h4",
|
|
20
|
+
"h5",
|
|
21
|
+
"h6",
|
|
22
|
+
"blockquote",
|
|
23
|
+
"li",
|
|
24
|
+
"dt",
|
|
25
|
+
"dd",
|
|
26
|
+
"th",
|
|
27
|
+
"td",
|
|
28
|
+
"span",
|
|
29
|
+
"strong",
|
|
30
|
+
"em",
|
|
31
|
+
"small",
|
|
32
|
+
"mark",
|
|
33
|
+
"code",
|
|
34
|
+
"abbr",
|
|
35
|
+
"q",
|
|
36
|
+
"sub",
|
|
37
|
+
"sup",
|
|
38
|
+
"time",
|
|
39
|
+
"a",
|
|
40
|
+
"button",
|
|
41
|
+
"label",
|
|
42
|
+
"legend",
|
|
43
|
+
"caption",
|
|
44
|
+
"summary",
|
|
45
|
+
"pre",
|
|
46
|
+
"option",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const containerTags = new Set([
|
|
50
|
+
"div",
|
|
51
|
+
"section",
|
|
52
|
+
"article",
|
|
53
|
+
"aside",
|
|
54
|
+
"header",
|
|
55
|
+
"footer",
|
|
56
|
+
"main",
|
|
57
|
+
"nav",
|
|
58
|
+
"figure",
|
|
59
|
+
"figcaption",
|
|
60
|
+
"details",
|
|
61
|
+
"fieldset",
|
|
62
|
+
"form",
|
|
63
|
+
"ul",
|
|
64
|
+
"ol",
|
|
65
|
+
"dl",
|
|
66
|
+
"table",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** All placeholder classes prepareForEditMode may add to an empty element. */
|
|
70
|
+
export const EMPTY_PLACEHOLDER_CLASSES = [
|
|
71
|
+
"empty-text-placeholder",
|
|
72
|
+
"empty-container-placeholder",
|
|
73
|
+
] as const;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The empty-placeholder class prepareForEditMode would add for this node, or null. Used by the
|
|
77
|
+
* canvas patcher to keep placeholder classes in sync when patching text without a full render.
|
|
78
|
+
*
|
|
79
|
+
* @param {JxMutableNode} node
|
|
80
|
+
*/
|
|
81
|
+
export function computeEmptyPlaceholderClass(node: JxMutableNode): string | null {
|
|
82
|
+
if (!node.tagName || node.textContent || node.innerHTML) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
// Layout-originated nodes ($__layout, set by markLayoutNodes) are read-only in page context;
|
|
86
|
+
// Edit affordances like "Click here to add text" don't apply to them.
|
|
87
|
+
if (node.$__layout) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(node.children) && node.children.length > 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (textTags.has(node.tagName)) {
|
|
94
|
+
return "empty-text-placeholder";
|
|
95
|
+
}
|
|
96
|
+
if (containerTags.has(node.tagName)) {
|
|
97
|
+
return "empty-container-placeholder";
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
14
102
|
/**
|
|
15
103
|
* Convert a template string to a displayable expression for edit mode. Replaces ${expr} with ❮ expr
|
|
16
104
|
* ❯ so the runtime renders it as literal text.
|
|
@@ -37,6 +125,27 @@ export function restoreTemplateExpressions(el: HTMLElement) {
|
|
|
37
125
|
}
|
|
38
126
|
}
|
|
39
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Build the edit-mode visual for a mapped array: a `<div class="repeater-perimeter">` wrapping a
|
|
130
|
+
* single prepared instance of the map template. Always one element (an empty perimeter when the
|
|
131
|
+
* template is missing) so the array keeps a 1:1 DOM node at its sibling index. The perimeter is an
|
|
132
|
+
* edit-only device — it is never compiled or rendered in preview.
|
|
133
|
+
*
|
|
134
|
+
* @param {JxMutableNode} arrayObj
|
|
135
|
+
* @returns {Record<string, unknown>}
|
|
136
|
+
*/
|
|
137
|
+
function arrayToPerimeter(arrayObj: JxMutableNode): Record<string, unknown> {
|
|
138
|
+
const template = arrayObj.map;
|
|
139
|
+
return {
|
|
140
|
+
children:
|
|
141
|
+
template && typeof template === "object"
|
|
142
|
+
? [prepareForEditMode(template as JxMutableNode)]
|
|
143
|
+
: [],
|
|
144
|
+
className: "repeater-perimeter",
|
|
145
|
+
tagName: "div",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
40
149
|
/**
|
|
41
150
|
* Prepare a document for edit-mode rendering. Replaces template strings with readable literal text,
|
|
42
151
|
* $prototype:Array with placeholders, and $ref bindings with display labels. Preserves state so the
|
|
@@ -51,7 +160,13 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
|
|
|
51
160
|
}
|
|
52
161
|
if (Array.isArray(node)) {
|
|
53
162
|
// Arrays of nodes round-trip element-wise; the array itself is not a node.
|
|
54
|
-
return node.map((n) => prepareForEditMode(n)) as unknown as JxMutableNode;
|
|
163
|
+
return (node as JxMutableNode[]).map((n) => prepareForEditMode(n)) as unknown as JxMutableNode;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A mapped-array node itself → its edit-mode perimeter (e.g. when the patcher re-renders an
|
|
167
|
+
// Array node directly). Members inside a children array funnel here via the children branch.
|
|
168
|
+
if ((node as Record<string, unknown>).$prototype === "Array") {
|
|
169
|
+
return arrayToPerimeter(node) as unknown as JxMutableNode;
|
|
55
170
|
}
|
|
56
171
|
|
|
57
172
|
const /** @type {Record<string, unknown>} */ obj = node as Record<string, unknown>;
|
|
@@ -95,25 +210,16 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
|
|
|
95
210
|
out[k] = propsOut;
|
|
96
211
|
} else if (k === "children") {
|
|
97
212
|
if (Array.isArray(v)) {
|
|
98
|
-
|
|
213
|
+
// Each member recurses; array pseudo-elements become a single repeater-perimeter element
|
|
214
|
+
// (via the top-level array case) so they stay a 1:1 DOM node among their siblings.
|
|
215
|
+
out.children = v.map((c) => prepareForEditMode(c as JxMutableNode));
|
|
99
216
|
} else if (
|
|
100
217
|
v &&
|
|
101
218
|
typeof v === "object" &&
|
|
102
219
|
(v as Record<string, unknown>).$prototype === "Array"
|
|
103
220
|
) {
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
const template = vObj.map;
|
|
107
|
-
out.children =
|
|
108
|
-
template && typeof template === "object"
|
|
109
|
-
? [
|
|
110
|
-
{
|
|
111
|
-
children: [prepareForEditMode(template as JxMutableNode)],
|
|
112
|
-
className: "repeater-perimeter",
|
|
113
|
-
tagName: "div",
|
|
114
|
-
},
|
|
115
|
-
]
|
|
116
|
-
: [];
|
|
221
|
+
// Legacy whole-children repeater → a single perimeter as the sole child.
|
|
222
|
+
out.children = [prepareForEditMode(v as JxMutableNode)];
|
|
117
223
|
} else {
|
|
118
224
|
out.children = prepareForEditMode(v as JxMutableNode);
|
|
119
225
|
}
|
|
@@ -121,7 +227,7 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
|
|
|
121
227
|
// Replace $switch cases with a placeholder showing the first case or a label
|
|
122
228
|
const caseKeys = Object.keys(v);
|
|
123
229
|
if (caseKeys.length > 0) {
|
|
124
|
-
const firstCase = (v as Record<string, unknown>)[caseKeys[0]];
|
|
230
|
+
const firstCase = (v as Record<string, unknown>)[caseKeys[0]!];
|
|
125
231
|
out.children =
|
|
126
232
|
firstCase && typeof firstCase === "object" && !(firstCase as Record<string, unknown>).$ref
|
|
127
233
|
? [prepareForEditMode(firstCase as JxMutableNode)]
|
|
@@ -216,73 +322,9 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
|
|
|
216
322
|
}
|
|
217
323
|
|
|
218
324
|
// Mark empty elements with placeholder classes for design-mode visibility
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const tag = out.tagName as string;
|
|
223
|
-
const textTags = new Set([
|
|
224
|
-
"p",
|
|
225
|
-
"h1",
|
|
226
|
-
"h2",
|
|
227
|
-
"h3",
|
|
228
|
-
"h4",
|
|
229
|
-
"h5",
|
|
230
|
-
"h6",
|
|
231
|
-
"blockquote",
|
|
232
|
-
"li",
|
|
233
|
-
"dt",
|
|
234
|
-
"dd",
|
|
235
|
-
"th",
|
|
236
|
-
"td",
|
|
237
|
-
"span",
|
|
238
|
-
"strong",
|
|
239
|
-
"em",
|
|
240
|
-
"small",
|
|
241
|
-
"mark",
|
|
242
|
-
"code",
|
|
243
|
-
"abbr",
|
|
244
|
-
"q",
|
|
245
|
-
"sub",
|
|
246
|
-
"sup",
|
|
247
|
-
"time",
|
|
248
|
-
"a",
|
|
249
|
-
"button",
|
|
250
|
-
"label",
|
|
251
|
-
"legend",
|
|
252
|
-
"caption",
|
|
253
|
-
"summary",
|
|
254
|
-
"pre",
|
|
255
|
-
"option",
|
|
256
|
-
]);
|
|
257
|
-
const containerTags = new Set([
|
|
258
|
-
"div",
|
|
259
|
-
"section",
|
|
260
|
-
"article",
|
|
261
|
-
"aside",
|
|
262
|
-
"header",
|
|
263
|
-
"footer",
|
|
264
|
-
"main",
|
|
265
|
-
"nav",
|
|
266
|
-
"figure",
|
|
267
|
-
"figcaption",
|
|
268
|
-
"details",
|
|
269
|
-
"fieldset",
|
|
270
|
-
"form",
|
|
271
|
-
"ul",
|
|
272
|
-
"ol",
|
|
273
|
-
"dl",
|
|
274
|
-
"table",
|
|
275
|
-
]);
|
|
276
|
-
if (textTags.has(tag)) {
|
|
277
|
-
out.className = out.className
|
|
278
|
-
? `${out.className} empty-text-placeholder`
|
|
279
|
-
: "empty-text-placeholder";
|
|
280
|
-
} else if (containerTags.has(tag)) {
|
|
281
|
-
out.className = out.className
|
|
282
|
-
? `${out.className} empty-container-placeholder`
|
|
283
|
-
: "empty-container-placeholder";
|
|
284
|
-
}
|
|
285
|
-
}
|
|
325
|
+
const placeholderClass = computeEmptyPlaceholderClass(out as JxMutableNode);
|
|
326
|
+
if (placeholderClass) {
|
|
327
|
+
out.className = out.className ? `${out.className} ${placeholderClass}` : placeholderClass;
|
|
286
328
|
}
|
|
287
329
|
|
|
288
330
|
// Media elements with missing/dynamic src get a placeholder class
|
|
@@ -61,10 +61,11 @@ export function computeInheritedStyle(
|
|
|
61
61
|
if (name === activeTab) {
|
|
62
62
|
break;
|
|
63
63
|
}
|
|
64
|
-
const selBlock = ((style[`@${name}`] || {}) as Record<string, unknown>)[activeSelector] ||
|
|
64
|
+
const selBlock = (((style[`@${name}`] || {}) as Record<string, unknown>)[activeSelector] ||
|
|
65
|
+
{}) as JxStyle;
|
|
65
66
|
for (const [p, v] of Object.entries(selBlock)) {
|
|
66
67
|
if (typeof v !== "object") {
|
|
67
|
-
inherited[p] = v ?? "";
|
|
68
|
+
inherited[p] = (v as string | number) ?? "";
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
}
|
|
@@ -30,7 +30,7 @@ export function camelToLabel(prop: string) {
|
|
|
30
30
|
|
|
31
31
|
export function toCamelCase(str: string): string {
|
|
32
32
|
return str
|
|
33
|
-
.replaceAll(/[^a-zA-Z0-9]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ""))
|
|
33
|
+
.replaceAll(/[^a-zA-Z0-9]+(.)?/g, (_, c: string | undefined) => (c ? c.toUpperCase() : ""))
|
|
34
34
|
.replace(/^[A-Z]/, (c) => c.toLowerCase());
|
|
35
35
|
}
|
|
36
36
|
|
package/src/view.ts
CHANGED
|
@@ -145,7 +145,10 @@ export function applyPanelCollapse() {
|
|
|
145
145
|
app.classList.toggle("left-collapsed", view.leftPanelCollapsed);
|
|
146
146
|
app.classList.toggle("right-collapsed", view.rightPanelCollapsed);
|
|
147
147
|
try {
|
|
148
|
-
const saved = JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY) || "{}")
|
|
148
|
+
const saved = JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY) || "{}") as Record<
|
|
149
|
+
string,
|
|
150
|
+
unknown
|
|
151
|
+
>;
|
|
149
152
|
saved.leftCollapsed = view.leftPanelCollapsed;
|
|
150
153
|
saved.rightCollapsed = view.rightPanelCollapsed;
|
|
151
154
|
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(saved));
|