@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.
- package/dist/studio.js +156878 -156742
- package/dist/studio.js.map +156 -145
- package/package.json +2 -2
- package/src/canvas/canvas-helpers.js +121 -0
- package/src/canvas/canvas-live-render.js +286 -0
- package/src/canvas/canvas-render.js +429 -0
- package/src/canvas/canvas-utils.js +277 -0
- package/src/editor/component-inline-edit.js +317 -0
- package/src/editor/content-inline-edit.js +213 -0
- package/src/panels/block-action-bar.js +8 -4
- package/src/panels/canvas-dnd.js +154 -0
- package/src/panels/left-panel.js +1 -2
- package/src/panels/overlays.js +9 -23
- package/src/panels/panel-events.js +260 -0
- package/src/panels/preview-render.js +103 -0
- package/src/panels/pseudo-preview.js +57 -0
- package/src/panels/shared.js +1 -1
- package/src/platform.js +9 -6
- package/src/state.js +9 -1
- package/src/store.js +9 -0
- package/src/studio.js +122 -2349
- package/src/utils/edit-display.js +197 -0
- package/src/view.js +0 -2
|
@@ -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
|
+
}
|