@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
package/src/tabs/tab.ts
CHANGED
|
@@ -16,6 +16,12 @@ import type { JxDocOp } from "./patch-ops";
|
|
|
16
16
|
export interface TabUi {
|
|
17
17
|
rightTab: string;
|
|
18
18
|
canvasMode: string;
|
|
19
|
+
/** Preview toggle — composes with an edit/design canvasMode; the effective mode becomes "preview". */
|
|
20
|
+
preview: boolean;
|
|
21
|
+
/** Show elements inherited from the page's layout (pages with an effective layout only). */
|
|
22
|
+
showLayout: boolean;
|
|
23
|
+
/** Chosen literal values for dynamic route params (e.g. { sku: "mini-trencher" }). */
|
|
24
|
+
previewParams: Record<string, string>;
|
|
19
25
|
zoom: number;
|
|
20
26
|
activeMedia: string | null;
|
|
21
27
|
activeSelector: string | null;
|
|
@@ -79,7 +85,10 @@ export interface Tab {
|
|
|
79
85
|
ui: TabUi;
|
|
80
86
|
canvas: {
|
|
81
87
|
status: string;
|
|
82
|
-
|
|
88
|
+
// A serializable snapshot of the iframe's resolved `$defs` (data-source values), posted over
|
|
89
|
+
// The bridge as a `dataScope` message and read by the data-explorer panel. Plain data now —
|
|
90
|
+
// The old live `EffectScope` (with `.stop()`) moved into the iframe realm with buildScope.
|
|
91
|
+
scope: Record<string, unknown> | null;
|
|
83
92
|
error: string | null;
|
|
84
93
|
pendingInlineEdit: InlineEditDef | null;
|
|
85
94
|
};
|
|
@@ -92,9 +101,10 @@ export interface Tab {
|
|
|
92
101
|
|
|
93
102
|
/**
|
|
94
103
|
* @param {string} canvasMode — initial canvas mode (the tab's first allowed mode)
|
|
104
|
+
* @param {boolean} preview — initial preview-toggle state
|
|
95
105
|
* @returns {TabUi}
|
|
96
106
|
*/
|
|
97
|
-
function createDefaultUi(canvasMode: string) {
|
|
107
|
+
function createDefaultUi(canvasMode: string, preview = false) {
|
|
98
108
|
return {
|
|
99
109
|
activeMedia: null,
|
|
100
110
|
activeSelector: null,
|
|
@@ -109,8 +119,11 @@ function createDefaultUi(canvasMode: string) {
|
|
|
109
119
|
gitStatus: null,
|
|
110
120
|
inspectorSections: {},
|
|
111
121
|
pendingInlineEdit: null,
|
|
122
|
+
preview,
|
|
123
|
+
previewParams: {},
|
|
112
124
|
rightTab: "properties",
|
|
113
125
|
settingsTab: "stylebook",
|
|
126
|
+
showLayout: true,
|
|
114
127
|
styleFilter: "",
|
|
115
128
|
styleFilterActive: false,
|
|
116
129
|
styleSections: {},
|
|
@@ -165,7 +178,10 @@ export function createTab({
|
|
|
165
178
|
const resolvedModes = capabilities?.modes ?? inferModes(documentPath, sourceFormat);
|
|
166
179
|
// A tab opens in its first allowed mode — never one the toolbar would disable.
|
|
167
180
|
// Formats author mode order so the default comes first (edit, stylebook, etc.).
|
|
168
|
-
|
|
181
|
+
// "preview" is a per-tab toggle rather than a base mode: a preview-first format opens
|
|
182
|
+
// In its first non-preview mode with the toggle already on.
|
|
183
|
+
const initialCanvasMode = resolvedModes.find((m) => m !== "preview") ?? "edit";
|
|
184
|
+
const initialPreview = resolvedModes[0] === "preview";
|
|
169
185
|
|
|
170
186
|
const tab = scope.run(() => ({
|
|
171
187
|
capabilities: { modes: resolvedModes },
|
|
@@ -196,7 +212,7 @@ export function createTab({
|
|
|
196
212
|
documentStack: [],
|
|
197
213
|
hover: null,
|
|
198
214
|
selection: null,
|
|
199
|
-
ui: createDefaultUi(initialCanvasMode),
|
|
215
|
+
ui: createDefaultUi(initialCanvasMode, initialPreview),
|
|
200
216
|
}),
|
|
201
217
|
})) as unknown as Tab;
|
|
202
218
|
|
|
@@ -204,6 +220,9 @@ export function createTab({
|
|
|
204
220
|
}
|
|
205
221
|
|
|
206
222
|
/**
|
|
223
|
+
* Allowed modes for a document. "preview" in a format's mode list means the preview toggle is
|
|
224
|
+
* Available for the tab (it is not a base canvas mode the toolbar switches to).
|
|
225
|
+
*
|
|
207
226
|
* @param {string | null | undefined} documentPath
|
|
208
227
|
* @param {string | null} sourceFormat
|
|
209
228
|
* @returns {string[]}
|
package/src/tabs/transact.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { toRaw } from "../reactivity";
|
|
3
3
|
import { jsonClone } from "../utils/studio-utils";
|
|
4
4
|
import { childIndex, getNodeAtPath, isAncestor, parentElementPath, pathsEqual } from "../state";
|
|
5
|
+
import { applyDocOpToDoc, childArray, cloneValue } from "./doc-op-apply";
|
|
5
6
|
import {
|
|
6
7
|
beginRecording,
|
|
7
8
|
endRecording,
|
|
@@ -33,11 +34,6 @@ function patchHistoryEnabled() {
|
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
/** Deep-clone a recorded value (undefined passes through; reactive proxies are read through). */
|
|
37
|
-
function cloneValue<T>(v: T): T {
|
|
38
|
-
return v === undefined || v === null ? v : (jsonClone(v as object) as T);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
37
|
/** Forward/inverse pair for a single-key change on the node at path. */
|
|
42
38
|
function setKeyPair(path: JxPath, key: string, before: unknown, after: unknown): JxDocOpPair {
|
|
43
39
|
return {
|
|
@@ -81,20 +77,6 @@ export type JxNodeValue =
|
|
|
81
77
|
| JxEventBinding
|
|
82
78
|
| undefined;
|
|
83
79
|
|
|
84
|
-
/**
|
|
85
|
-
* The editable children array of a node, created when absent. Mapped-array children (`$prototype:
|
|
86
|
-
* "Array"`) cannot be index-mutated — fail loudly instead of corrupting.
|
|
87
|
-
*/
|
|
88
|
-
function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
|
|
89
|
-
if (!node.children) {
|
|
90
|
-
node.children = [];
|
|
91
|
-
}
|
|
92
|
-
if (!Array.isArray(node.children)) {
|
|
93
|
-
throw new TypeError("Cannot insert into mapped-array children; edit the map template instead");
|
|
94
|
-
}
|
|
95
|
-
return node.children;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
80
|
// ─── Transactional layer ─────────────────────────────────────────────────────
|
|
99
81
|
|
|
100
82
|
/**
|
|
@@ -146,7 +128,7 @@ export function transactDoc(
|
|
|
146
128
|
|
|
147
129
|
if (verdict.patchable) {
|
|
148
130
|
try {
|
|
149
|
-
consumer!.apply(tab, record.ops);
|
|
131
|
+
consumer!.apply(tab, record.ops, record);
|
|
150
132
|
} catch (error) {
|
|
151
133
|
consumer!.escalate(
|
|
152
134
|
`patch-apply-failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -154,7 +136,7 @@ export function transactDoc(
|
|
|
154
136
|
}
|
|
155
137
|
}
|
|
156
138
|
|
|
157
|
-
if (!skipHistory) {
|
|
139
|
+
if (!skipHistory && !_batchTab) {
|
|
158
140
|
pushHistoryEntry(tab, raw, record, selectionBefore);
|
|
159
141
|
}
|
|
160
142
|
|
|
@@ -248,54 +230,6 @@ export function transact(
|
|
|
248
230
|
/** Document-level keys whose changes require a full scope/panel rebuild on the canvas. */
|
|
249
231
|
const DOC_META_KEYS = new Set(["state", "$media", "$head", "$elements", "imports", "$layout"]);
|
|
250
232
|
|
|
251
|
-
/**
|
|
252
|
-
* Apply a replayable doc op to a bare document tree (history replay — no canvas recording).
|
|
253
|
-
*
|
|
254
|
-
* @param {JxMutableNode} doc
|
|
255
|
-
* @param {JxDocOp} op
|
|
256
|
-
*/
|
|
257
|
-
function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp) {
|
|
258
|
-
switch (op.op) {
|
|
259
|
-
case "set-key": {
|
|
260
|
-
const node = getNodeAtPath(doc, op.path);
|
|
261
|
-
if (!node) {
|
|
262
|
-
throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
|
|
263
|
-
}
|
|
264
|
-
if (op.value === undefined) {
|
|
265
|
-
delete node[op.key];
|
|
266
|
-
} else {
|
|
267
|
-
node[op.key] = cloneValue(op.value) as JxNodeValue;
|
|
268
|
-
}
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
case "insert-child": {
|
|
272
|
-
const parent = getNodeAtPath(doc, op.parentPath);
|
|
273
|
-
childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
case "remove-child": {
|
|
277
|
-
const parent = getNodeAtPath(doc, op.parentPath);
|
|
278
|
-
childArray(parent).splice(op.index, 1);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
case "set-child": {
|
|
282
|
-
const parent = getNodeAtPath(doc, op.parentPath);
|
|
283
|
-
childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
case "move-child": {
|
|
287
|
-
const fromParent = getNodeAtPath(doc, op.fromParentPath);
|
|
288
|
-
const toParent = getNodeAtPath(doc, op.toParentPath);
|
|
289
|
-
const [node] = childArray(fromParent).splice(op.fromIndex, 1);
|
|
290
|
-
childArray(toParent).splice(op.toIndex, 0, node!);
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
default: {
|
|
294
|
-
throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
233
|
/**
|
|
300
234
|
* Apply a doc op to the live document AND record the matching canvas patch op, so undo/redo
|
|
301
235
|
* transactions patch the canvas surgically like any other edit.
|
|
@@ -351,6 +285,36 @@ function applyDocOp(tab: Tab, op: JxDocOp) {
|
|
|
351
285
|
}
|
|
352
286
|
}
|
|
353
287
|
|
|
288
|
+
// ─── Batch (group multiple mutations into one undo step) ────────────────────
|
|
289
|
+
|
|
290
|
+
let _batchTab: Tab | null = null;
|
|
291
|
+
|
|
292
|
+
export function beginBatch(tab: Tab | null) {
|
|
293
|
+
_batchTab = tab;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function endBatch() {
|
|
297
|
+
if (_batchTab) {
|
|
298
|
+
const raw = toRaw(_batchTab.doc.document);
|
|
299
|
+
const snapshot = {
|
|
300
|
+
document: jsonClone(raw),
|
|
301
|
+
selection: _batchTab.session.selection ? [..._batchTab.session.selection] : null,
|
|
302
|
+
};
|
|
303
|
+
const truncated = _batchTab.history.snapshots.slice(0, _batchTab.history.index + 1);
|
|
304
|
+
truncated.push(snapshot);
|
|
305
|
+
if (truncated.length > HISTORY_LIMIT) {
|
|
306
|
+
truncated.shift();
|
|
307
|
+
}
|
|
308
|
+
_batchTab.history.snapshots = truncated;
|
|
309
|
+
_batchTab.history.index = truncated.length - 1;
|
|
310
|
+
}
|
|
311
|
+
_batchTab = null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function isBatching(): boolean {
|
|
315
|
+
return _batchTab !== null;
|
|
316
|
+
}
|
|
317
|
+
|
|
354
318
|
// ─── Undo / Redo ─────────────────────────────────────────────────────────────
|
|
355
319
|
|
|
356
320
|
/** Restore a materialized snapshot state (full-render path). */
|
package/src/types.ts
CHANGED
|
@@ -126,6 +126,13 @@ export interface RenameResult {
|
|
|
126
126
|
export interface StudioPlatform {
|
|
127
127
|
id: string;
|
|
128
128
|
projectRoot: string;
|
|
129
|
+
/**
|
|
130
|
+
* URL of the canvas iframe document. Optional: when set (chromium serves it from the project
|
|
131
|
+
* server under the studio namespace), the iframe host uses it; otherwise the host falls back to
|
|
132
|
+
* the default dev-server path. Not keyed on `id` — chromium and electrobun both report `id:
|
|
133
|
+
* "desktop"`, but only chromium sets this.
|
|
134
|
+
*/
|
|
135
|
+
canvasUrl?: string;
|
|
129
136
|
activate: (root?: string) => Promise<void>;
|
|
130
137
|
openProject: () => Promise<{
|
|
131
138
|
config: ProjectConfig;
|
|
@@ -190,6 +197,13 @@ export interface StudioPlatform {
|
|
|
190
197
|
/** Invoke a format capability (parse/serialize) — { format, action, source?, doc?, options? }. */
|
|
191
198
|
formatAction?: (payload: Record<string, unknown>) => Promise<unknown>;
|
|
192
199
|
fetchPluginSchema: (src: string, prototype?: string, base?: string) => Promise<unknown>;
|
|
200
|
+
/**
|
|
201
|
+
* Resolve a class-prototype config through the backend's `/__jx_resolve__` pipeline (with the
|
|
202
|
+
* project's content types loaded) and return the parsed result. Used by the tab-bar's dynamic
|
|
203
|
+
* route-param picker to enumerate ContentCollection entries. Optional: platforms without a
|
|
204
|
+
* resolve backend omit it and the picker falls back to a plain fetch.
|
|
205
|
+
*/
|
|
206
|
+
resolveClass?: (body: Record<string, unknown>) => Promise<unknown>;
|
|
193
207
|
gitStatus: () => Promise<GitStatusResult>;
|
|
194
208
|
gitBranches: () => Promise<GitBranchesResult>;
|
|
195
209
|
gitLog: (limit?: number) => Promise<GitLogEntry[]>;
|
|
@@ -214,12 +228,8 @@ export interface StudioPlatform {
|
|
|
214
228
|
adapter?: string;
|
|
215
229
|
directory: string;
|
|
216
230
|
}) => Promise<{ root: string; config: ProjectConfig }>;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
aiSendMessage: (id: string, message: string) => Promise<void>;
|
|
220
|
-
aiStreamUrl: (id: string) => string | Promise<string>;
|
|
221
|
-
aiStopSession: (id: string) => Promise<void>;
|
|
222
|
-
aiDeleteSession: (id: string) => Promise<void>;
|
|
231
|
+
/** Stack B AI assistant: URL of the OpenAI-compatible SSE chat proxy (`/__studio/ai/chat`). */
|
|
232
|
+
aiChatUrl: () => string | Promise<string>;
|
|
223
233
|
// ─── Multi-window (desktop only; undefined on dev-server) ───────────────────
|
|
224
234
|
/** Open a project in a new window, focusing an existing window if it is already open. */
|
|
225
235
|
openProjectInNewWindow?: (root: string) => Promise<void>;
|
|
@@ -256,18 +266,11 @@ export interface CanvasPanel {
|
|
|
256
266
|
mediaName: string;
|
|
257
267
|
element: HTMLElement;
|
|
258
268
|
canvas: HTMLElement;
|
|
259
|
-
overlay: HTMLElement;
|
|
260
|
-
overlayClk: HTMLElement;
|
|
261
269
|
viewport: HTMLElement;
|
|
262
270
|
scrollContainer: HTMLElement;
|
|
263
|
-
dropLine: HTMLElement;
|
|
264
271
|
_width: number | null;
|
|
265
272
|
/** True when the panel's DOM reflects the current document via a successful live render. */
|
|
266
273
|
ready: boolean;
|
|
267
|
-
/** Breakpoints active for this panel's width (persisted for surgical patch re-application). */
|
|
268
|
-
activeBreakpoints: Set<string> | null;
|
|
269
|
-
/** Render context captured from the last successful live render (null until then). */
|
|
270
|
-
liveCtx: PanelLiveCtx | null;
|
|
271
274
|
/**
|
|
272
275
|
* Effect scope owning the reactive effects created while rendering this panel's content
|
|
273
276
|
* (including child scopes from surgical subtree renders). Stopped when panels are rebuilt.
|
|
@@ -275,17 +278,6 @@ export interface CanvasPanel {
|
|
|
275
278
|
renderScope: { stop: () => void; run: <T>(fn: () => T) => T | undefined } | null;
|
|
276
279
|
}
|
|
277
280
|
|
|
278
|
-
/** Per-panel context persisted by a successful live render so patches can re-render subtrees. */
|
|
279
|
-
export interface PanelLiveCtx {
|
|
280
|
-
scope: Record<string, unknown>;
|
|
281
|
-
canvasMode: string;
|
|
282
|
-
layoutWrapped: boolean;
|
|
283
|
-
pageContentPrefix: (string | number)[] | null;
|
|
284
|
-
pageContentOffset: number;
|
|
285
|
-
arrayPaths: Set<string>;
|
|
286
|
-
pathMapper: (created: Node, path: (string | number)[], def: unknown) => void;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
281
|
export interface DocumentStackEntry {
|
|
290
282
|
document: JxMutableNode;
|
|
291
283
|
documentPath: string | null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jx brand theme fragment.
|
|
3
|
+
*
|
|
4
|
+
* Registered as the Spectrum 'app' fragment (see spectrum.ts), so it is adopted into every
|
|
5
|
+
* <sp-theme> shadow root _after_ the system/color/scale fragments and wins the :host cascade. It
|
|
6
|
+
* rebrands the stock Spectrum dark theme by overriding only the palette `-rgb` triplets: every
|
|
7
|
+
* derived token in the published theme CSS (accent, focus ring, background layers, alpha-composed
|
|
8
|
+
* tints) resolves through `rgba(var(--spectrum-*-rgb))`, so the whole theme follows coherently.
|
|
9
|
+
*
|
|
10
|
+
* Canonical brand values: sites/jxsuite.com/project.json (accent #3b82f6, accent-hover #60a5fa,
|
|
11
|
+
* neutral surfaces #0a0a0a / #111111 / #1a1a1a / #222222). Intermediate stops interpolate along the
|
|
12
|
+
* Tailwind blue/zinc ramps the brand palette derives from.
|
|
13
|
+
*
|
|
14
|
+
* Dark-theme stop order: gray 50 (darkest) -> 900 (lightest); blue 100 (darkest) -> 1400
|
|
15
|
+
* (lightest). Semantic anchors under this ramp:
|
|
16
|
+
*
|
|
17
|
+
* - Accent button fill = accent-500 (white text ~5.2:1)
|
|
18
|
+
* - Focus ring = blue-800 (brand accent-hover)
|
|
19
|
+
* - Studio --accent = accent-700 (exact brand blue)
|
|
20
|
+
* - Accent content/visual = accent-900 (soft on-dark tint)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { css } from "@spectrum-web-components/base";
|
|
24
|
+
|
|
25
|
+
export const jxTheme = css`
|
|
26
|
+
:host {
|
|
27
|
+
/* Neutral (gray) ramp — Jx near-black surfaces and zinc text tones */
|
|
28
|
+
--spectrum-gray-50-rgb: 10, 10, 10; /* #0a0a0a bg-primary / base */
|
|
29
|
+
--spectrum-gray-75-rgb: 17, 17, 17; /* #111111 bg-secondary / layer-1 */
|
|
30
|
+
--spectrum-gray-100-rgb: 26, 26, 26; /* #1a1a1a surface / layer-2 */
|
|
31
|
+
--spectrum-gray-200-rgb: 34, 34, 34; /* #222222 border / surface-hover */
|
|
32
|
+
--spectrum-gray-300-rgb: 63, 63, 70; /* #3f3f46 strong border */
|
|
33
|
+
--spectrum-gray-400-rgb: 82, 82, 91; /* #52525b component border */
|
|
34
|
+
--spectrum-gray-500-rgb: 113, 113, 122; /* #71717a text-muted */
|
|
35
|
+
--spectrum-gray-600-rgb: 161, 161, 170; /* #a1a1aa text-secondary */
|
|
36
|
+
--spectrum-gray-700-rgb: 212, 212, 216; /* #d4d4d8 subtle text */
|
|
37
|
+
--spectrum-gray-800-rgb: 228, 228, 231; /* #e4e4e7 primary text */
|
|
38
|
+
--spectrum-gray-900-rgb: 250, 250, 250; /* #fafafa ink / headings */
|
|
39
|
+
|
|
40
|
+
/* Blue ramp — drives accent, informative, and the focus indicator */
|
|
41
|
+
--spectrum-blue-100-rgb: 23, 37, 84; /* #172554 */
|
|
42
|
+
--spectrum-blue-200-rgb: 30, 58, 138; /* #1e3a8a */
|
|
43
|
+
--spectrum-blue-300-rgb: 30, 64, 175; /* #1e40af */
|
|
44
|
+
--spectrum-blue-400-rgb: 29, 78, 216; /* #1d4ed8 */
|
|
45
|
+
--spectrum-blue-500-rgb: 37, 99, 235; /* #2563eb accent button fill */
|
|
46
|
+
--spectrum-blue-600-rgb: 48, 112, 241; /* #3070f1 */
|
|
47
|
+
--spectrum-blue-700-rgb: 59, 130, 246; /* #3b82f6 brand accent */
|
|
48
|
+
--spectrum-blue-800-rgb: 96, 165, 250; /* #60a5fa accent-hover / focus */
|
|
49
|
+
--spectrum-blue-900-rgb: 147, 197, 253; /* #93c5fd soft on-dark accent */
|
|
50
|
+
--spectrum-blue-1000-rgb: 191, 219, 254; /* #bfdbfe */
|
|
51
|
+
--spectrum-blue-1100-rgb: 219, 234, 254; /* #dbeafe */
|
|
52
|
+
--spectrum-blue-1200-rgb: 239, 246, 255; /* #eff6ff */
|
|
53
|
+
--spectrum-blue-1300-rgb: 248, 250, 252; /* #f8fafc */
|
|
54
|
+
--spectrum-blue-1400-rgb: 255, 255, 255; /* #ffffff */
|
|
55
|
+
|
|
56
|
+
/* Brand font stacks. Sans drops adobe-clean so Adobe Clean never renders
|
|
57
|
+
on machines that have it installed; mono leads with the vendored
|
|
58
|
+
JetBrains Mono (see index.html @font-face). */
|
|
59
|
+
--spectrum-sans-font-family-stack: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
60
|
+
--spectrum-code-font-family-stack:
|
|
61
|
+
"JetBrains Mono", "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
|
|
62
|
+
}
|
|
63
|
+
`;
|
package/src/ui/media-picker.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { ref } from "lit-html/directives/ref.js";
|
|
|
12
12
|
import { getPlatform } from "../platform";
|
|
13
13
|
import { debouncedStyleCommit, renderOnly } from "../store";
|
|
14
14
|
import { getLayerSlot } from "./layers";
|
|
15
|
+
import { rectOf } from "../utils/geometry";
|
|
16
|
+
import { loopbackAssetSrc } from "../canvas/canvas-origin";
|
|
15
17
|
|
|
16
18
|
// ─── Media file cache ────────────────────────────────────────────────────────
|
|
17
19
|
|
|
@@ -142,7 +144,7 @@ function onPopoverOutsideClick(e: MouseEvent) {
|
|
|
142
144
|
|
|
143
145
|
function renderMediaPickerPopover() {
|
|
144
146
|
const host = getLayerSlot("popover", "media-picker");
|
|
145
|
-
const rect = _popoverAnchorEl
|
|
147
|
+
const rect = _popoverAnchorEl ? rectOf(_popoverAnchorEl) : undefined;
|
|
146
148
|
if (!rect) {
|
|
147
149
|
return;
|
|
148
150
|
}
|
|
@@ -210,7 +212,7 @@ function renderMediaPickerPopover() {
|
|
|
210
212
|
${m.isImage
|
|
211
213
|
? html`<img
|
|
212
214
|
slot="icon"
|
|
213
|
-
src=${m.path}
|
|
215
|
+
src=${loopbackAssetSrc(m.path)}
|
|
214
216
|
alt=""
|
|
215
217
|
style="width:24px;height:24px;object-fit:cover;border-radius:var(--spectrum-corner-radius-75, 2px)"
|
|
216
218
|
/>`
|
|
@@ -232,7 +234,7 @@ function renderMediaPickerPopover() {
|
|
|
232
234
|
// Fine-tune position after render using actual measured dimensions
|
|
233
235
|
requestAnimationFrame(() => {
|
|
234
236
|
if (_popoverEl) {
|
|
235
|
-
const popoverRect = _popoverEl
|
|
237
|
+
const popoverRect = rectOf(_popoverEl);
|
|
236
238
|
let adjLeft = popoverRect.left;
|
|
237
239
|
let adjTop = popoverRect.top;
|
|
238
240
|
let needsAdjust = false;
|
|
@@ -304,7 +306,7 @@ export function renderMediaPicker(prop: string, value: string, onCommit: (val: s
|
|
|
304
306
|
return html`
|
|
305
307
|
<div class="media-picker">
|
|
306
308
|
${isImage && currentValue
|
|
307
|
-
? html`<img class="media-picker-thumb" src=${currentValue} alt="" />`
|
|
309
|
+
? html`<img class="media-picker-thumb" src=${loopbackAssetSrc(currentValue)} alt="" />`
|
|
308
310
|
: nothing}
|
|
309
311
|
<sp-textfield
|
|
310
312
|
size="s"
|
package/src/ui/spectrum.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { Theme } from "@spectrum-web-components/theme/src/Theme.js";
|
|
|
11
11
|
import themeSpectrumCSS from "@spectrum-web-components/theme/src/theme.css.js";
|
|
12
12
|
import themeDarkCSS from "@spectrum-web-components/theme/src/theme-dark.css.js";
|
|
13
13
|
import scaleMediumCSS from "@spectrum-web-components/theme/src/scale-medium.css.js";
|
|
14
|
+
import { jxTheme } from "./jx-theme";
|
|
14
15
|
import { Tabs } from "@spectrum-web-components/tabs/src/Tabs.js";
|
|
15
16
|
import { Tab } from "@spectrum-web-components/tabs/src/Tab.js";
|
|
16
17
|
import { TabPanel } from "@spectrum-web-components/tabs/src/TabPanel.js";
|
|
@@ -296,5 +297,9 @@ for (const [tag, ctor] of components as [string, CustomElementConstructor][]) {
|
|
|
296
297
|
Theme.registerThemeFragment("spectrum", "system", themeSpectrumCSS);
|
|
297
298
|
Theme.registerThemeFragment("dark", "color", themeDarkCSS);
|
|
298
299
|
Theme.registerThemeFragment("medium", "scale", scaleMediumCSS);
|
|
300
|
+
/* Jx brand overrides. The 'app' kind must be registered under the literal
|
|
301
|
+
name "app" and, registered last, is adopted after the fragments above so
|
|
302
|
+
its :host declarations win the cascade (see src/ui/jx-theme.ts). */
|
|
303
|
+
Theme.registerThemeFragment("app", "app", jxTheme);
|
|
299
304
|
|
|
300
305
|
export { components };
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
/// <reference lib="dom.iterable" />
|
|
3
3
|
/** Canvas media/breakpoint utilities — pure functions extracted for testability. */
|
|
4
4
|
|
|
5
|
-
import type { JxStyle } from "@jxsuite/schema/types";
|
|
6
|
-
|
|
7
5
|
/**
|
|
8
6
|
* Classify $media entries into size breakpoints (get a canvas each) and feature queries (rendered
|
|
9
7
|
* as toolbar toggles).
|
|
@@ -63,138 +61,3 @@ export function activeBreakpointsForWidth(
|
|
|
63
61
|
}
|
|
64
62
|
return active;
|
|
65
63
|
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Apply styles to a canvas element, including active media overrides. Base (flat) styles applied
|
|
69
|
-
* first, then matching media overrides in source order.
|
|
70
|
-
*
|
|
71
|
-
* @param {HTMLElement} el
|
|
72
|
-
* @param {JxStyle | null | undefined} styleDef
|
|
73
|
-
* @param {Set<string>} activeBreakpoints
|
|
74
|
-
* @param {Record<string, boolean>} featureToggles
|
|
75
|
-
*/
|
|
76
|
-
export function applyCanvasStyle(
|
|
77
|
-
el: HTMLElement,
|
|
78
|
-
styleDef: JxStyle | null | undefined,
|
|
79
|
-
activeBreakpoints: Set<string>,
|
|
80
|
-
featureToggles: Record<string, boolean>,
|
|
81
|
-
) {
|
|
82
|
-
if (!styleDef || typeof styleDef !== "object") {
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
for (const [prop, val] of Object.entries(styleDef)) {
|
|
86
|
-
if (typeof val === "string" || typeof val === "number") {
|
|
87
|
-
try {
|
|
88
|
-
if (prop.startsWith("--")) {
|
|
89
|
-
el.style.setProperty(prop, String(val));
|
|
90
|
-
} else {
|
|
91
|
-
(el.style as unknown as Record<string, unknown>)[prop] = val;
|
|
92
|
-
}
|
|
93
|
-
} catch {}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
for (const [key, val] of Object.entries(styleDef)) {
|
|
97
|
-
if (!key.startsWith("@") || typeof val !== "object" || val === null) {
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
const mediaName = key.slice(1);
|
|
101
|
-
if (mediaName === "--") {
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
if (activeBreakpoints.has(mediaName) || featureToggles[mediaName]) {
|
|
105
|
-
for (const [prop, v] of Object.entries(/** @type {Record<string, unknown>} */ val)) {
|
|
106
|
-
if (typeof v === "string" || typeof v === "number") {
|
|
107
|
-
try {
|
|
108
|
-
if (prop.startsWith("--")) {
|
|
109
|
-
el.style.setProperty(prop, String(v));
|
|
110
|
-
} else {
|
|
111
|
-
(el.style as unknown as Record<string, unknown>)[prop] = v;
|
|
112
|
-
}
|
|
113
|
-
} catch {}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Scan stylesheets for @media rules matching active breakpoints, collecting the CSS declarations
|
|
122
|
-
* that should be applied as inline overrides per data-jx element.
|
|
123
|
-
*
|
|
124
|
-
* Returns a Map of data-jx uid → Map of CSS property → value.
|
|
125
|
-
*
|
|
126
|
-
* @param {Iterable<CSSStyleSheet>} styleSheets
|
|
127
|
-
* @param {Set<string>} activeBreakpoints
|
|
128
|
-
* @returns {Map<string, Map<string, string>>}
|
|
129
|
-
*/
|
|
130
|
-
export function collectMediaOverrides(
|
|
131
|
-
styleSheets: Iterable<CSSStyleSheet>,
|
|
132
|
-
activeBreakpoints: Set<string>,
|
|
133
|
-
) {
|
|
134
|
-
const overrides = new Map<string, Map<string, string>>();
|
|
135
|
-
if (activeBreakpoints.size === 0) {
|
|
136
|
-
return overrides;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
for (const sheet of styleSheets) {
|
|
140
|
-
/** @type {CSSRuleList | null} */
|
|
141
|
-
let rules;
|
|
142
|
-
try {
|
|
143
|
-
rules = sheet.cssRules;
|
|
144
|
-
} catch {
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
if (!rules) {
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
for (const rule of rules) {
|
|
151
|
-
if (!(rule instanceof CSSMediaRule)) {
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
if (!activeBreakpoints.has(rule.conditionText)) {
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
for (const mediaRule of rule.cssRules) {
|
|
158
|
-
if (!(mediaRule instanceof CSSStyleRule)) {
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
const selector = mediaRule.selectorText;
|
|
162
|
-
const jxMatch = selector.match(/\[data-jx="([^"]+)"\]/);
|
|
163
|
-
if (!jxMatch) {
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
const [, uid] = jxMatch;
|
|
167
|
-
if (!overrides.has(uid!)) {
|
|
168
|
-
overrides.set(uid!, new Map());
|
|
169
|
-
}
|
|
170
|
-
const props = overrides.get(uid!) as Map<string, string>;
|
|
171
|
-
for (const prop of mediaRule.style) {
|
|
172
|
-
props.set(prop, mediaRule.style.getPropertyValue(prop));
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return overrides;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Apply collected media overrides to elements within a canvas.
|
|
182
|
-
*
|
|
183
|
-
* @param {Element} canvasEl
|
|
184
|
-
* @param {Map<string, Map<string, string>>} overrides
|
|
185
|
-
*/
|
|
186
|
-
export function applyOverridesToCanvas(
|
|
187
|
-
canvasEl: Element,
|
|
188
|
-
overrides: Map<string, Map<string, string>>,
|
|
189
|
-
) {
|
|
190
|
-
for (const [uid, props] of overrides) {
|
|
191
|
-
const els = canvasEl.querySelectorAll(`[data-jx="${uid}"]`);
|
|
192
|
-
for (const el of els) {
|
|
193
|
-
for (const [prop, val] of props) {
|
|
194
|
-
try {
|
|
195
|
-
(el as HTMLElement).style.setProperty(prop, val);
|
|
196
|
-
} catch {}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
@@ -10,6 +10,19 @@ import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
|
10
10
|
const mediaTags = new Set(["img", "video", "source", "iframe", "audio"]);
|
|
11
11
|
const TRANSPARENT_PX =
|
|
12
12
|
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
13
|
+
// A neutral, visible gray placeholder for image-like bindings in edit mode (e.g. a component `image`
|
|
14
|
+
// Prop bound to ${...}). The component renders <img src={prop}> internally, so a transparent pixel
|
|
15
|
+
// Would collapse to nothing — a gray SVG box reads as an intentional placeholder instead.
|
|
16
|
+
const PLACEHOLDER_IMG =
|
|
17
|
+
"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='32'%20height='32'%3E%3Crect%20width='32'%20height='32'%20fill='%23d0d0d0'/%3E%3C/svg%3E";
|
|
18
|
+
|
|
19
|
+
// Prop/attribute names that feed an <img>/background, so a ${...} binding would otherwise render a
|
|
20
|
+
// Broken image in edit mode. Matched by exact name or common camelCase suffix (featuredImage, heroBg).
|
|
21
|
+
const IMAGE_KEY_RE =
|
|
22
|
+
/(src|poster|image|bg|background|icon|logo|photo|avatar|thumbnail|cover|banner)$/i;
|
|
23
|
+
function isImageUrlKey(key: string): boolean {
|
|
24
|
+
return IMAGE_KEY_RE.test(key);
|
|
25
|
+
}
|
|
13
26
|
|
|
14
27
|
const textTags = new Set([
|
|
15
28
|
"p",
|
|
@@ -197,8 +210,15 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
|
|
|
197
210
|
const propsOut: Record<string, unknown> = {};
|
|
198
211
|
for (const [pk, pv] of Object.entries(v)) {
|
|
199
212
|
if (typeof pv === "string" && pv.includes("${")) {
|
|
200
|
-
|
|
201
|
-
|
|
213
|
+
// Image-like prop → neutral placeholder (the component renders it as an <img src>); link
|
|
214
|
+
// Targets → inert ""; everything else → the readable ❪ expr ❫ binding text.
|
|
215
|
+
if (isImageUrlKey(pk)) {
|
|
216
|
+
propsOut[pk] = PLACEHOLDER_IMG;
|
|
217
|
+
} else if (pk === "href" || pk === "action") {
|
|
218
|
+
propsOut[pk] = "";
|
|
219
|
+
} else {
|
|
220
|
+
propsOut[pk] = templateToEditDisplay(pv);
|
|
221
|
+
}
|
|
202
222
|
} else if (pv && typeof pv === "object" && (pv as Record<string, unknown>).$ref) {
|
|
203
223
|
const ref = (pv as Record<string, unknown>).$ref as string;
|
|
204
224
|
const label = ref.startsWith("#/state/") ? ref.slice(8) : ref;
|
|
@@ -238,7 +258,7 @@ export function prepareForEditMode(node: JxMutableNode): JxMutableNode {
|
|
|
238
258
|
border: "1px dashed color-mix(in srgb, var(--danger) 40%, transparent)",
|
|
239
259
|
borderRadius: "4px",
|
|
240
260
|
color: "var(--danger)",
|
|
241
|
-
fontFamily: "'SF Mono', 'Fira Code', monospace",
|
|
261
|
+
fontFamily: "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace",
|
|
242
262
|
fontSize: "11px",
|
|
243
263
|
fontStyle: "italic",
|
|
244
264
|
padding: "6px 10px",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Geometry read funnel — the single place the studio reads element bounds and does point hit-tests.
|
|
4
|
+
*
|
|
5
|
+
* Centralizing these reads is a seam for the iframe-canvas migration: today every call delegates
|
|
6
|
+
* straight to the DOM, but routing them through one module lets later phases redirect
|
|
7
|
+
* CANVAS-element geometry to rects posted out of the iframe (which the parent can't measure
|
|
8
|
+
* directly) without touching dozens of call sites. A test (`geometry.test.ts`) asserts no raw
|
|
9
|
+
* `getBoundingClientRect`/`elementFromPoint`/`elementsFromPoint` calls exist elsewhere in `src`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Bounding rect of an element, in viewport coordinates. */
|
|
13
|
+
export function rectOf(el: Element): DOMRect {
|
|
14
|
+
return el.getBoundingClientRect();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bounding rect of a Range, in viewport coordinates. Lives here (the one allowlisted home for
|
|
19
|
+
* `getBoundingClientRect`) so the `geometry.test.ts` textual guard passes — the iframe-side
|
|
20
|
+
* selection snapshot funnels its caret/selection geometry through this, never calling
|
|
21
|
+
* `range.getBoundingClientRect()` directly.
|
|
22
|
+
*/
|
|
23
|
+
export function rectOfRange(r: Range): DOMRect {
|
|
24
|
+
return r.getBoundingClientRect();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Topmost element at a viewport point, in `root` (defaults to the global document). */
|
|
28
|
+
export function elementAtPoint(
|
|
29
|
+
x: number,
|
|
30
|
+
y: number,
|
|
31
|
+
root: Document | ShadowRoot = document,
|
|
32
|
+
): Element | null {
|
|
33
|
+
return root.elementFromPoint(x, y);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** All elements at a viewport point, front-to-back, in `root` (defaults to the global document). */
|
|
37
|
+
export function elementsAtPoint(
|
|
38
|
+
x: number,
|
|
39
|
+
y: number,
|
|
40
|
+
root: Document | ShadowRoot = document,
|
|
41
|
+
): Element[] {
|
|
42
|
+
return root.elementsFromPoint(x, y);
|
|
43
|
+
}
|