@jxsuite/studio 0.8.0 → 0.10.1

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.
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Canvas render — extracted from studio.js (Phase 4o). Multi-mode canvas rendering orchestrator:
3
+ * dispatches to manage/settings/source/edit/design/preview rendering paths.
4
+ */
5
+
6
+ import { html, render as litRender, nothing } from "lit-html";
7
+ import { ref } from "lit-html/directives/ref.js";
8
+ import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
9
+
10
+ import { canvasWrap, canvasPanels, updateCanvas } from "../store.js";
11
+ import { view } from "../view.js";
12
+ import {
13
+ canvasPanelTemplate,
14
+ applyTransform,
15
+ observeCenterUntilStable,
16
+ renderZoomIndicator,
17
+ resetZoomIndicator,
18
+ updateActivePanelHeaders,
19
+ } from "./canvas-utils.js";
20
+ import { effectiveZoom, overlayBoxDescriptor } from "./canvas-helpers.js";
21
+ import {
22
+ parseMediaEntries,
23
+ activeBreakpointsForWidth,
24
+ collectMediaOverrides,
25
+ applyOverridesToCanvas,
26
+ } from "../utils/canvas-media.js";
27
+ import { getEffectiveMedia } from "../site-context.js";
28
+ import { renderCanvasLive } from "./canvas-live-render.js";
29
+ import { renderCanvasNode } from "../panels/preview-render.js";
30
+ import { registerPanelDnD } from "../panels/canvas-dnd.js";
31
+ import { registerPanelEvents } from "../panels/panel-events.js";
32
+ import { updateForcedPseudoPreview } from "../panels/pseudo-preview.js";
33
+ import { renderStylebookMode } from "../panels/stylebook-panel.js";
34
+ import { dismissLinkPopover, dismissBlockActionBar } from "../panels/block-action-bar.js";
35
+ import { dismissContextMenu } from "../editor/context-menu.js";
36
+ import { dismissSlashMenu } from "../editor/slash-menu.js";
37
+ import { renderBrowse } from "../browse/browse.js";
38
+ import { renderFunctionEditor } from "../panels/editors.js";
39
+ import { mediaDisplayName } from "../panels/shared.js";
40
+ import { statusMessage } from "../panels/statusbar.js";
41
+ import * as overlaysPanel from "../panels/overlays.js";
42
+
43
+ /** @type {any} */
44
+ let _ctx = null;
45
+
46
+ /**
47
+ * Initialize the canvas render module.
48
+ *
49
+ * @param {{
50
+ * getCanvasMode: () => string;
51
+ * setCanvasMode: (mode: string) => void;
52
+ * getState: () => any;
53
+ * update: (s: any) => void;
54
+ * openFileFromTree: (path: string) => void;
55
+ * exportFile: () => void;
56
+ * }} ctx
57
+ */
58
+ export function initCanvasRender(ctx) {
59
+ _ctx = ctx;
60
+ }
61
+
62
+ export function renderCanvas() {
63
+ const S = _ctx.getState();
64
+ const canvasMode = _ctx.getCanvasMode();
65
+
66
+ // Advance render generation so stale async renders from the previous cycle bail out
67
+ ++view.renderGeneration;
68
+
69
+ // Always clear Lit's internal state so it builds fresh DOM. Stale async
70
+ // renderCanvasLive calls from a previous cycle can corrupt nested ChildPart
71
+ // markers (Comment nodes inside panzoom-wrap) in ways the root-only
72
+ // ensureLitState check cannot detect.
73
+ // @ts-ignore
74
+ if (canvasWrap["_$litPart$"]) {
75
+ canvasWrap.textContent = "";
76
+ // @ts-ignore
77
+ delete canvasWrap["_$litPart$"];
78
+ }
79
+
80
+ // Function editor mode: editing a function body in Monaco (JS)
81
+ if (S.ui.editingFunction) {
82
+ renderFunctionEditor();
83
+ return;
84
+ }
85
+
86
+ // Dispose function editor if switching away
87
+ if (view.functionEditor) {
88
+ view.functionEditor.dispose();
89
+ view.functionEditor = null;
90
+ }
91
+
92
+ // Source mode: update existing Monaco editor without recreating
93
+ if (canvasMode === "source" && view.monacoEditor) {
94
+ const jsonStr = JSON.stringify(S.document, null, 2);
95
+ const currentVal = view.monacoEditor.getValue();
96
+ if (currentVal !== jsonStr) {
97
+ view.monacoEditor._ignoreNextChange = true;
98
+ view.monacoEditor.setValue(jsonStr);
99
+ }
100
+ return;
101
+ }
102
+
103
+ // Detect whether this is a mode transition or a content-only re-render
104
+ const modeChanged = canvasMode !== view.prevCanvasMode;
105
+ view.prevCanvasMode = canvasMode;
106
+
107
+ // DnD handlers are registered on inner canvas elements that get replaced on every
108
+ // content render, so always clean them up.
109
+ for (const fn of view.canvasDndCleanups) fn();
110
+ view.canvasDndCleanups = [];
111
+
112
+ // Panel event handlers (click, dblclick, etc.) capture closures over panel references.
113
+ // Always re-register to keep closures fresh across document switches.
114
+ for (const fn of view.canvasEventCleanups) fn();
115
+ view.canvasEventCleanups = [];
116
+
117
+ // Panel JS objects are cheap — always clear and repopulate from templates.
118
+ // The actual DOM elements are preserved by Lit's diffing on content-only re-renders.
119
+ canvasPanels.length = 0;
120
+
121
+ if (modeChanged) {
122
+ // Full teardown on mode transitions — new panel structure needed
123
+ if (view.centerObserver) {
124
+ view.centerObserver.disconnect();
125
+ view.centerObserver = null;
126
+ }
127
+
128
+ // Dispose Monaco editor if switching away from source mode
129
+ if (view.monacoEditor) {
130
+ view.monacoEditor.dispose();
131
+ view.monacoEditor = null;
132
+ }
133
+
134
+ litRender(nothing, canvasWrap);
135
+ view.panzoomWrap = null;
136
+ // Reset inline style overrides from other modes
137
+ canvasWrap.style.padding = "";
138
+ canvasWrap.style.alignItems = "";
139
+ canvasWrap.style.display = "";
140
+ canvasWrap.style.overflow = "";
141
+ canvasWrap.style.overflow = "";
142
+
143
+ // Clear zoom indicator (only re-rendered by design/preview/stylebook)
144
+ resetZoomIndicator();
145
+
146
+ // Dismiss open popovers/toolbars that are no longer relevant
147
+ dismissBlockActionBar();
148
+ dismissLinkPopover();
149
+ dismissContextMenu();
150
+ dismissSlashMenu();
151
+ }
152
+
153
+ // Manage mode: project-level file browser table
154
+ if (canvasMode === "manage") {
155
+ canvasWrap.style.padding = "0";
156
+ canvasWrap.style.overflow = "auto";
157
+ renderBrowse(canvasWrap, {
158
+ openFile: (/** @type {string} */ path) => {
159
+ _ctx.setCanvasMode("edit");
160
+ _ctx.openFileFromTree(path);
161
+ },
162
+ });
163
+ return;
164
+ }
165
+
166
+ // Settings mode: render element catalog with panzoom surface
167
+ if (canvasMode === "settings") {
168
+ renderStylebookMode({
169
+ canvasPanelTemplate,
170
+ applyTransform,
171
+ observeCenterUntilStable,
172
+ renderZoomIndicator,
173
+ updateActivePanelHeaders,
174
+ overlayBoxDescriptor,
175
+ effectiveZoom,
176
+ });
177
+ return;
178
+ }
179
+
180
+ // Source mode: create Monaco editor instead of canvas
181
+ if (canvasMode === "source") {
182
+ canvasWrap.style.padding = "0";
183
+ canvasWrap.style.display = "block";
184
+ /** @type {HTMLDivElement | null} */
185
+ let editorContainer = null;
186
+ litRender(
187
+ html`<div class="source-wrap">
188
+ <div class="source-toolbar">
189
+ <sp-action-button size="s" @click=${_ctx.exportFile}>
190
+ <sp-icon-export slot="icon"></sp-icon-export>
191
+ Export
192
+ </sp-action-button>
193
+ </div>
194
+ <div
195
+ class="source-editor"
196
+ ${ref((el) => {
197
+ if (el) editorContainer = /** @type {HTMLDivElement} */ (el);
198
+ })}
199
+ ></div>
200
+ </div>`,
201
+ canvasWrap,
202
+ );
203
+
204
+ const jsonStr = JSON.stringify(S.document, null, 2);
205
+ view.monacoEditor = monaco.editor.create(/** @type {any} */ (editorContainer), {
206
+ value: jsonStr,
207
+ language: "json",
208
+ theme: "vs-dark",
209
+ automaticLayout: true,
210
+ minimap: { enabled: false },
211
+ fontSize: 12,
212
+ fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
213
+ lineNumbers: "on",
214
+ scrollBeyondLastLine: false,
215
+ wordWrap: "on",
216
+ tabSize: 2,
217
+ });
218
+
219
+ // Debounced sync back to state
220
+ /** @type {any} */
221
+ let debounce;
222
+ view.monacoEditor.onDidChangeModelContent(() => {
223
+ if (view.monacoEditor._ignoreNextChange) {
224
+ view.monacoEditor._ignoreNextChange = false;
225
+ return;
226
+ }
227
+ clearTimeout(debounce);
228
+ debounce = setTimeout(() => {
229
+ try {
230
+ const parsed = JSON.parse(view.monacoEditor.getValue());
231
+ _ctx.update({ ..._ctx.getState(), document: parsed, dirty: true });
232
+ } catch {
233
+ // Invalid JSON — don't update state
234
+ }
235
+ }, 600);
236
+ });
237
+ return;
238
+ }
239
+
240
+ // Edit (content) mode — centered column, no panzoom, always 100%
241
+ if (canvasMode === "edit") {
242
+ if (modeChanged) {
243
+ canvasWrap.style.padding = "0";
244
+ canvasWrap.style.overflow = "hidden";
245
+
246
+ // Remove zoom indicator left over from design/preview mode
247
+ resetZoomIndicator();
248
+ }
249
+
250
+ const { tpl: panelTpl, panel } = canvasPanelTemplate(null, null, true);
251
+ const editTpl = html`
252
+ <div class="content-edit-canvas">
253
+ <div class="content-edit-column">${panelTpl}</div>
254
+ </div>
255
+ `;
256
+ litRender(editTpl, canvasWrap);
257
+ canvasPanels.push(panel);
258
+ renderCanvasIntoPanel(panel, new Set(), S.ui.featureToggles);
259
+ return;
260
+ }
261
+
262
+ // Normal canvas mode (design / preview) — set up panzoom surface
263
+ if (modeChanged) {
264
+ canvasWrap.style.padding = "0";
265
+ canvasWrap.style.overflow = "hidden";
266
+ }
267
+
268
+ const {
269
+ sizeBreakpoints,
270
+ featureQueries: _featureQueries,
271
+ baseWidth,
272
+ } = parseMediaEntries(getEffectiveMedia(S.document.$media));
273
+ const hasMedia = sizeBreakpoints.length > 0;
274
+ const featureToggles = S.ui.featureToggles;
275
+
276
+ // Create panzoom wrapper (the element that gets transformed)
277
+ if (!hasMedia) {
278
+ // Single panel — use baseWidth if a custom one is defined, otherwise full-width
279
+ const effectiveMedia = getEffectiveMedia(S.document.$media);
280
+ const hasBaseWidth = effectiveMedia && effectiveMedia["--"];
281
+ const label = hasBaseWidth ? `${mediaDisplayName("--")} (${baseWidth}px)` : null;
282
+ const { tpl: panelTpl, panel } = canvasPanelTemplate(
283
+ hasBaseWidth ? "base" : null,
284
+ label,
285
+ !hasBaseWidth,
286
+ hasBaseWidth ? baseWidth : undefined,
287
+ );
288
+ litRender(
289
+ html`
290
+ <div
291
+ class="panzoom-wrap"
292
+ style="transform-origin:0 0"
293
+ ${ref((el) => {
294
+ if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
295
+ })}
296
+ >
297
+ ${panelTpl}
298
+ </div>
299
+ `,
300
+ canvasWrap,
301
+ );
302
+ canvasPanels.push(panel);
303
+ renderCanvasIntoPanel(panel, new Set(), featureToggles);
304
+ applyTransform();
305
+ if (modeChanged) {
306
+ observeCenterUntilStable();
307
+ }
308
+ renderZoomIndicator();
309
+ return;
310
+ }
311
+
312
+ // Build all panels: base first, then breakpoints in declared order (ascending for min-width,
313
+ // descending for max-width — matching the direction of the design's media queries).
314
+ const allPanelDefs = [
315
+ {
316
+ name: "base",
317
+ displayName: mediaDisplayName("--"),
318
+ width: baseWidth,
319
+ activeSet: activeBreakpointsForWidth(sizeBreakpoints, baseWidth),
320
+ },
321
+ ];
322
+ for (const bp of sizeBreakpoints) {
323
+ allPanelDefs.push({
324
+ name: bp.name,
325
+ displayName: mediaDisplayName(bp.name),
326
+ width: bp.width,
327
+ activeSet: activeBreakpointsForWidth(sizeBreakpoints, bp.width),
328
+ });
329
+ }
330
+
331
+ /** @type {{ tpl: any; panel: any; activeSet: any }[]} */
332
+ const panelEntries = allPanelDefs.map((def) => {
333
+ const label = `${def.displayName} (${def.width}px)`;
334
+ const { tpl, panel } = canvasPanelTemplate(def.name, label, false, def.width);
335
+ return { tpl, panel, activeSet: def.activeSet };
336
+ });
337
+
338
+ litRender(
339
+ html`
340
+ <div
341
+ class="panzoom-wrap"
342
+ style="transform-origin:0 0"
343
+ ${ref((el) => {
344
+ if (el) view.panzoomWrap = /** @type {HTMLDivElement} */ (el);
345
+ })}
346
+ >
347
+ ${panelEntries.map((e) => e.tpl)}
348
+ </div>
349
+ `,
350
+ canvasWrap,
351
+ );
352
+
353
+ for (const { panel, activeSet } of panelEntries) {
354
+ canvasPanels.push(panel);
355
+ renderCanvasIntoPanel(panel, activeSet, featureToggles);
356
+ }
357
+
358
+ // Highlight active panel header
359
+ updateActivePanelHeaders();
360
+
361
+ // Apply current zoom + pan transform
362
+ applyTransform();
363
+ if (modeChanged) {
364
+ observeCenterUntilStable();
365
+ }
366
+
367
+ // Floating zoom indicator
368
+ renderZoomIndicator();
369
+ }
370
+
371
+ /**
372
+ * Render document into a single canvas panel. Tries runtime rendering first, falls back to
373
+ * structural preview.
374
+ *
375
+ * @param {any} panel
376
+ * @param {any} activeBreakpoints
377
+ * @param {any} featureToggles
378
+ */
379
+ function renderCanvasIntoPanel(panel, activeBreakpoints, featureToggles) {
380
+ const gen = view.renderGeneration;
381
+ const S = _ctx.getState();
382
+ renderCanvasLive(gen, S.document, panel.canvas).then((/** @type {any} */ scope) => {
383
+ // Skip post-render setup if a newer render has started
384
+ if (gen !== view.renderGeneration) return;
385
+ if (scope) {
386
+ updateCanvas({ status: "ready", scope, error: null });
387
+ applyCanvasMediaOverrides(panel.canvas, activeBreakpoints);
388
+ statusMessage("Runtime render OK", 1500);
389
+ } else {
390
+ // Fallback to structural preview
391
+ updateCanvas({ status: "ready", scope: null, error: null });
392
+ renderCanvasNode(
393
+ _ctx.getState().document,
394
+ [],
395
+ panel.canvas,
396
+ activeBreakpoints,
397
+ featureToggles,
398
+ );
399
+ }
400
+ registerPanelDnD(panel);
401
+ registerPanelEvents(panel);
402
+ renderOverlays();
403
+ updateForcedPseudoPreview();
404
+ });
405
+ }
406
+
407
+ /**
408
+ * Apply media query overrides as inline styles on matching canvas elements. Needed because the
409
+ * runtime renders base styles as inline — @media CSS rules in the injected stylesheet can't win
410
+ * against inline specificity.
411
+ *
412
+ * @param {Element} canvasEl
413
+ * @param {Set<string>} activeBreakpoints
414
+ */
415
+ function applyCanvasMediaOverrides(canvasEl, activeBreakpoints) {
416
+ if (!activeBreakpoints.size) return;
417
+ const S = _ctx.getState();
418
+ const docMedia = getEffectiveMedia(S.document.$media || {});
419
+ const validBreakpoints = new Set();
420
+ for (const name of activeBreakpoints) {
421
+ if (docMedia[name]) validBreakpoints.add(name);
422
+ }
423
+ const overrides = collectMediaOverrides(document.styleSheets, validBreakpoints);
424
+ applyOverridesToCanvas(canvasEl, overrides);
425
+ }
426
+
427
+ export function renderOverlays() {
428
+ overlaysPanel.render();
429
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Canvas panel utilities — extracted from studio.js (Phase 4l). Panzoom infrastructure: panel DOM
3
+ * template creation, centering, transform application, zoom indicator, and fit-to-screen.
4
+ */
5
+
6
+ import { html, render as litRender, nothing } from "lit-html";
7
+ import { ref } from "lit-html/directives/ref.js";
8
+ import { styleMap } from "lit-html/directives/style-map.js";
9
+ import { ifDefined } from "lit-html/directives/if-defined.js";
10
+
11
+ import { getState, renderOnly, updateUi, canvasWrap, canvasPanels } from "../store.js";
12
+ import { ensureLitState } from "../panels/shared.js";
13
+ import { view } from "../view.js";
14
+
15
+ /** @type {any} */
16
+ let _ctx = null;
17
+
18
+ let zoomIndicatorHost = document.createElement("div");
19
+ zoomIndicatorHost.style.display = "contents";
20
+ document.body.appendChild(zoomIndicatorHost);
21
+
22
+ /**
23
+ * Initialize the canvas utils module.
24
+ *
25
+ * @param {{
26
+ * getCanvasMode: () => string;
27
+ * getZoom: () => number;
28
+ * setZoomDirect: (zoom: number) => void;
29
+ * renderStylebookOverlays: () => void;
30
+ * }} ctx
31
+ */
32
+ export function initCanvasUtils(ctx) {
33
+ _ctx = ctx;
34
+ }
35
+
36
+ /**
37
+ * Create the DOM structure for a single canvas panel.
38
+ *
39
+ * @param {any} mediaName
40
+ * @param {any} label
41
+ * @param {any} fullWidth
42
+ * @param {any} width
43
+ */
44
+ export function canvasPanelTemplate(mediaName, label, fullWidth, width = null) {
45
+ /**
46
+ * @type {{
47
+ * mediaName: any;
48
+ * element: Element | null;
49
+ * canvas: Element | null;
50
+ * overlay: Element | null;
51
+ * overlayClk: Element | null;
52
+ * viewport: Element | null;
53
+ * dropLine: Element | null;
54
+ * _width: any;
55
+ * }}
56
+ */
57
+ const panel = {
58
+ mediaName,
59
+ element: null,
60
+ canvas: null,
61
+ overlay: null,
62
+ overlayClk: null,
63
+ viewport: null,
64
+ dropLine: null,
65
+ _width: width || null,
66
+ };
67
+ const tpl = html`
68
+ <div
69
+ class=${`canvas-panel${fullWidth ? " full-width" : ""}`}
70
+ data-media=${ifDefined(mediaName !== null ? mediaName : undefined)}
71
+ ${ref((el) => {
72
+ if (el) panel.element = el;
73
+ })}
74
+ >
75
+ ${label
76
+ ? html`
77
+ <div
78
+ class="canvas-panel-header"
79
+ @click=${() => {
80
+ updateUi("activeMedia", mediaName === "base" ? null : mediaName);
81
+ }}
82
+ >
83
+ ${label}
84
+ </div>
85
+ `
86
+ : nothing}
87
+ <div
88
+ class="canvas-panel-viewport"
89
+ style=${styleMap({ width: width && !fullWidth ? `${width}px` : "" })}
90
+ ${ref((el) => {
91
+ if (el) panel.viewport = el;
92
+ })}
93
+ >
94
+ <div
95
+ class="canvas-panel-canvas"
96
+ style=${styleMap({ width: width ? `${width}px` : "" })}
97
+ ${ref((el) => {
98
+ if (el) panel.canvas = el;
99
+ })}
100
+ ></div>
101
+ <div
102
+ class="canvas-panel-overlay"
103
+ ${ref((el) => {
104
+ if (el) panel.overlay = el;
105
+ })}
106
+ >
107
+ <div
108
+ class="canvas-drop-indicator"
109
+ style="display:none"
110
+ ${ref((el) => {
111
+ if (el) panel.dropLine = el;
112
+ })}
113
+ ></div>
114
+ </div>
115
+ <div
116
+ class="canvas-panel-click"
117
+ ${ref((el) => {
118
+ if (el) panel.overlayClk = el;
119
+ })}
120
+ ></div>
121
+ </div>
122
+ </div>
123
+ `;
124
+ return { tpl, panel };
125
+ }
126
+
127
+ /** Center canvas in viewport. */
128
+ export function centerCanvas() {
129
+ if (!view.panzoomWrap) return;
130
+ const wrapWidth = canvasWrap.clientWidth;
131
+ const wrapHeight = canvasWrap.clientHeight;
132
+ const contentWidth = view.panzoomWrap.scrollWidth;
133
+ const contentHeight = view.panzoomWrap.scrollHeight;
134
+ const zoom = _ctx.getZoom();
135
+ const scaledWidth = contentWidth * zoom;
136
+ const scaledHeight = contentHeight * zoom;
137
+ view.panX = Math.max(16, (wrapWidth - scaledWidth) / 2);
138
+ const verticalCenter = (wrapHeight - scaledHeight) / 2;
139
+ view.panY = verticalCenter > 16 ? verticalCenter : 16;
140
+ }
141
+
142
+ /**
143
+ * Attach a ResizeObserver to view.panzoomWrap that re-centers until the user pans. Handles async
144
+ * content (runtime rendering, data fetching) that changes layout after initial paint.
145
+ */
146
+ export function observeCenterUntilStable() {
147
+ if (view.centerObserver) {
148
+ view.centerObserver.disconnect();
149
+ view.centerObserver = null;
150
+ }
151
+ if (!view.panzoomWrap) return;
152
+ view.needsCenter = true;
153
+ view.centerObserver = new ResizeObserver(() => {
154
+ if (!view.needsCenter) {
155
+ view.centerObserver?.disconnect();
156
+ view.centerObserver = null;
157
+ return;
158
+ }
159
+ centerCanvas();
160
+ applyTransform();
161
+ });
162
+ view.centerObserver.observe(view.panzoomWrap);
163
+ centerCanvas();
164
+ }
165
+
166
+ /** Apply the current zoom + pan transform to the panzoom wrapper. */
167
+ export function applyTransform() {
168
+ if (!view.panzoomWrap) return;
169
+ const zoom = _ctx.getZoom();
170
+ view.panzoomWrap.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`;
171
+ const label = document.querySelector(".zoom-indicator-label");
172
+ if (label) label.textContent = `${Math.round(zoom * 100)}%`;
173
+ renderOnly("overlays");
174
+ if (_ctx.getCanvasMode() === "settings") _ctx.renderStylebookOverlays();
175
+ }
176
+
177
+ /** Calculate zoom + pan to fit all panels within the viewport. */
178
+ export function fitToScreen() {
179
+ if (!view.panzoomWrap) return;
180
+ const wrapWidth = canvasWrap.clientWidth;
181
+ const wrapHeight = canvasWrap.clientHeight;
182
+ const gap = 24;
183
+ const padding = 32;
184
+ let totalPanelWidth = 0;
185
+ for (const p of canvasPanels) {
186
+ totalPanelWidth += p._width || 800;
187
+ }
188
+ totalPanelWidth += gap * Math.max(0, canvasPanels.length - 1) + padding;
189
+
190
+ const zoom = _ctx.getZoom();
191
+ const wrapRect = view.panzoomWrap.getBoundingClientRect();
192
+ const unscaledHeight = wrapRect.height / zoom;
193
+ const maxPanelHeight = unscaledHeight + padding;
194
+
195
+ const fitZoomW = wrapWidth / totalPanelWidth;
196
+ const fitZoomH = wrapHeight / maxPanelHeight;
197
+ const fitZoom = Math.min(5.0, Math.max(0.05, Math.min(fitZoomW, fitZoomH)));
198
+
199
+ _ctx.setZoomDirect(fitZoom);
200
+
201
+ const scaledWidth = totalPanelWidth * fitZoom;
202
+ const scaledHeight = maxPanelHeight * fitZoom;
203
+ view.panX = Math.max(0, (wrapWidth - scaledWidth) / 2);
204
+ view.panY = Math.max(0, (wrapHeight - scaledHeight) / 2);
205
+ applyTransform();
206
+ }
207
+
208
+ /** Reset the zoom indicator (clear its content). Called when switching to non-panzoom modes. */
209
+ export function resetZoomIndicator() {
210
+ ensureLitState(zoomIndicatorHost);
211
+ litRender(nothing, zoomIndicatorHost);
212
+ }
213
+
214
+ /**
215
+ * Render the floating zoom indicator at the bottom center of canvas-wrap. Uses position: fixed,
216
+ * computed from canvas-wrap bounds.
217
+ */
218
+ export function renderZoomIndicator() {
219
+ const zoom = _ctx.getZoom();
220
+ if (!zoomIndicatorHost.isConnected) {
221
+ document.body.appendChild(zoomIndicatorHost);
222
+ }
223
+ ensureLitState(zoomIndicatorHost);
224
+ litRender(
225
+ html`
226
+ <div class="zoom-indicator">
227
+ <span class="zoom-indicator-label">${Math.round(zoom * 100)}%</span>
228
+ <sp-action-button
229
+ quiet
230
+ size="s"
231
+ class="zoom-fit-btn"
232
+ title="Fit to screen"
233
+ @click=${fitToScreen}
234
+ >
235
+ <svg
236
+ width="14"
237
+ height="14"
238
+ viewBox="0 0 16 16"
239
+ fill="none"
240
+ stroke="currentColor"
241
+ stroke-width="1.5"
242
+ >
243
+ <rect x="2" y="2" width="12" height="12" rx="1" />
244
+ <path d="M2 6h12M6 2v12" />
245
+ </svg>
246
+ </sp-action-button>
247
+ </div>
248
+ `,
249
+ zoomIndicatorHost,
250
+ );
251
+ positionZoomIndicator();
252
+ }
253
+
254
+ /** Position the zoom indicator relative to canvas-wrap bounds. */
255
+ export function positionZoomIndicator() {
256
+ const indicator = /** @type {HTMLElement | null} */ (document.querySelector(".zoom-indicator"));
257
+ if (!indicator) return;
258
+ const rect = canvasWrap.getBoundingClientRect();
259
+ indicator.style.left = `${rect.left + rect.width / 2}px`;
260
+ indicator.style.top = `${rect.bottom - 32}px`;
261
+ indicator.style.transform = "translateX(-50%)";
262
+ }
263
+
264
+ /** Toggle "active" class on canvas panel headers based on activeMedia. */
265
+ export function updateActivePanelHeaders() {
266
+ const S = getState();
267
+ for (const p of canvasPanels) {
268
+ const header = p.element.querySelector(".canvas-panel-header");
269
+ if (header) {
270
+ const isActive =
271
+ (S.ui.activeMedia === null && p.mediaName === "base") ||
272
+ (S.ui.activeMedia === null && p.mediaName === null) ||
273
+ S.ui.activeMedia === p.mediaName;
274
+ header.classList.toggle("active", isActive);
275
+ }
276
+ }
277
+ }