@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.
Files changed (94) hide show
  1. package/dist/iframe-entry.js +6230 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +22791 -13709
  4. package/dist/studio.js.map +181 -74
  5. package/package.json +12 -7
  6. package/src/browse/browse.ts +11 -4
  7. package/src/canvas/canvas-helpers.ts +2 -56
  8. package/src/canvas/canvas-live-render.ts +102 -435
  9. package/src/canvas/canvas-origin.ts +66 -0
  10. package/src/canvas/canvas-patcher.ts +63 -403
  11. package/src/canvas/canvas-render.ts +70 -212
  12. package/src/canvas/canvas-utils.ts +37 -65
  13. package/src/canvas/iframe-channel.ts +154 -0
  14. package/src/canvas/iframe-drop.ts +484 -0
  15. package/src/canvas/iframe-entry.ts +600 -0
  16. package/src/canvas/iframe-host.ts +1373 -0
  17. package/src/canvas/iframe-inline-edit.ts +367 -0
  18. package/src/canvas/iframe-insert.ts +164 -0
  19. package/src/canvas/iframe-interaction.ts +176 -0
  20. package/src/canvas/iframe-keys.ts +85 -0
  21. package/src/canvas/iframe-overlay.ts +218 -0
  22. package/src/canvas/iframe-patch.ts +363 -0
  23. package/src/canvas/iframe-protocol.ts +361 -0
  24. package/src/canvas/iframe-render.ts +458 -0
  25. package/src/canvas/iframe-slash.ts +114 -0
  26. package/src/canvas/iframe-subtree.ts +113 -0
  27. package/src/canvas/path-mapping.ts +86 -0
  28. package/src/canvas/serialize-scope.ts +65 -0
  29. package/src/editor/canvas-context-menu.ts +40 -0
  30. package/src/editor/canvas-slash-bridge.ts +21 -0
  31. package/src/editor/context-menu.ts +2 -1
  32. package/src/editor/inline-edit-apply.ts +183 -0
  33. package/src/editor/inline-edit.ts +99 -21
  34. package/src/editor/inline-link.ts +89 -0
  35. package/src/editor/insert-zone-action.ts +35 -0
  36. package/src/editor/merge-tags.ts +26 -2
  37. package/src/editor/repeater-scope.ts +144 -0
  38. package/src/editor/shortcuts.ts +14 -28
  39. package/src/editor/slash-menu.ts +73 -42
  40. package/src/files/files.ts +2 -1
  41. package/src/page-params.ts +383 -0
  42. package/src/panels/ai-panel.ts +386 -328
  43. package/src/panels/block-action-bar.ts +296 -138
  44. package/src/panels/canvas-dnd-bridge.ts +397 -0
  45. package/src/panels/component-preview.ts +56 -0
  46. package/src/panels/dnd.ts +41 -17
  47. package/src/panels/drag-ghost.ts +62 -0
  48. package/src/panels/editors.ts +1 -1
  49. package/src/panels/overlays.ts +10 -125
  50. package/src/panels/properties-panel.ts +210 -0
  51. package/src/panels/right-panel.ts +0 -2
  52. package/src/panels/signals-panel.ts +136 -22
  53. package/src/panels/stylebook-doc.ts +373 -0
  54. package/src/panels/stylebook-panel.ts +46 -689
  55. package/src/panels/tab-bar.ts +159 -13
  56. package/src/panels/toolbar.ts +3 -2
  57. package/src/platforms/devserver.ts +18 -47
  58. package/src/services/ai-settings.ts +107 -0
  59. package/src/services/ai-system-prompt.ts +617 -0
  60. package/src/services/ai-tools.ts +854 -0
  61. package/src/services/context-manager.ts +200 -0
  62. package/src/services/document-assistant.ts +183 -0
  63. package/src/services/jx-validate.ts +65 -0
  64. package/src/services/monaco-setup.ts +12 -0
  65. package/src/services/render-critic.ts +75 -0
  66. package/src/services/token-lint.ts +140 -0
  67. package/src/services/tool-executor.ts +156 -0
  68. package/src/settings/css-vars-editor.ts +2 -2
  69. package/src/state.ts +29 -0
  70. package/src/store.ts +4 -62
  71. package/src/studio.ts +90 -40
  72. package/src/tabs/doc-op-apply.ts +89 -0
  73. package/src/tabs/patch-ops.ts +6 -2
  74. package/src/tabs/tab.ts +23 -4
  75. package/src/tabs/transact.ts +33 -69
  76. package/src/types.ts +16 -24
  77. package/src/ui/jx-theme.ts +63 -0
  78. package/src/ui/media-picker.ts +6 -4
  79. package/src/ui/spectrum.ts +5 -0
  80. package/src/utils/canvas-media.ts +0 -137
  81. package/src/utils/edit-display.ts +23 -3
  82. package/src/utils/geometry.ts +43 -0
  83. package/src/utils/link-target.ts +93 -0
  84. package/src/utils/strip-events.ts +54 -0
  85. package/src/view.ts +0 -23
  86. package/src/workspace/workspace.ts +14 -1
  87. package/src/canvas/canvas-subtree-render.ts +0 -113
  88. package/src/editor/component-inline-edit.ts +0 -349
  89. package/src/editor/content-inline-edit.ts +0 -207
  90. package/src/editor/insertion-helper.ts +0 -308
  91. package/src/panels/canvas-dnd.ts +0 -329
  92. package/src/panels/panel-events.ts +0 -306
  93. package/src/panels/preview-render.ts +0 -132
  94. package/src/panels/pseudo-preview.ts +0 -75
@@ -5,7 +5,6 @@
5
5
  */
6
6
 
7
7
  import { html, render as litRender, nothing } from "lit-html";
8
- import { errorMessage } from "@jxsuite/schema/parse";
9
8
  import { ref } from "lit-html/directives/ref.js";
10
9
  import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";
11
10
 
@@ -24,23 +23,16 @@ import {
24
23
  resetZoomIndicator,
25
24
  updateActivePanelHeaders,
26
25
  } from "./canvas-utils";
27
- import { effectiveZoom, findCanvasElement, overlayBoxDescriptor } from "./canvas-helpers";
26
+ import { parseMediaEntries } from "../utils/canvas-media";
27
+ import { getEffectiveMedia, getEffectiveStyle } from "../site-context";
28
28
  import {
29
- activeBreakpointsForWidth,
30
- applyOverridesToCanvas,
31
- collectMediaOverrides,
32
- parseMediaEntries,
33
- } from "../utils/canvas-media";
34
- import { getEffectiveMedia } from "../site-context";
35
- import { renderCanvasLive } from "./canvas-live-render";
29
+ commitActiveEditSession,
30
+ mountIframeCanvas,
31
+ postStyleUpdateToStylebookHosts,
32
+ } from "./iframe-host";
36
33
  import { canvasPerf } from "./canvas-perf";
37
- import { renderCanvasNode } from "../panels/preview-render";
38
- import { registerPanelDnD } from "../panels/canvas-dnd";
39
- import { registerPanelEvents } from "../panels/panel-events";
40
- import { computeDocumentDiff } from "./canvas-diff";
41
- import { updateForcedPseudoPreview } from "../panels/pseudo-preview";
42
- import { enterComponentInlineEdit } from "../editor/component-inline-edit";
43
- import { refreshStylebookStyles, renderStylebookMode } from "../panels/stylebook-panel";
34
+ import { renderStylebookMode } from "../panels/stylebook-panel";
35
+ import { transposeStylebookStyle } from "../panels/stylebook-doc";
44
36
  import { dismissBlockActionBar, dismissLinkPopover } from "../panels/block-action-bar";
45
37
  import { dismissContextMenu } from "../editor/context-menu";
46
38
  import { dismissSlashMenu } from "../editor/slash-menu";
@@ -49,8 +41,7 @@ import { mediaDisplayName } from "../panels/shared";
49
41
  import { statusMessage } from "../panels/statusbar";
50
42
  import * as overlaysPanel from "../panels/overlays";
51
43
 
52
- import type { CanvasPanel } from "../panels/canvas-dnd";
53
- import type { GitDiffState, InlineEditDef } from "../types";
44
+ import type { CanvasPanel, GitDiffState } from "../types";
54
45
  import type { JxMutableNode } from "@jxsuite/schema/types";
55
46
  import type { Tab } from "../tabs/tab.js";
56
47
 
@@ -201,7 +192,11 @@ export function renderCanvas() {
201
192
  mode: tab.doc.mode,
202
193
  ui: tab.session.ui,
203
194
  };
204
- const canvasMode = ctx.getCanvasMode();
195
+ // Base mode drives the host surface (panel structure, panzoom vs centered column). The preview
196
+ // Toggle changes only what the iframe renders — resolveCanvasDocument reads the effective mode
197
+ // Via its own getCanvasMode. Flipping the toggle therefore re-renders panels in place (no
198
+ // ModeChanged teardown, no pan reset).
199
+ const { canvasMode } = S.ui;
205
200
 
206
201
  // Advance render generation so stale async renders from the previous cycle bail out
207
202
  view.renderGeneration += 1;
@@ -252,21 +247,32 @@ export function renderCanvas() {
252
247
  return;
253
248
  }
254
249
 
255
- // Stylebook fast-path: re-apply styles without rebuilding DOM
250
+ // Stylebook fast-path: a style edit re-applies IN PLACE via the bridge (the iframe re-runs the
251
+ // Runtime's style applier on the specimen root — real @media, no re-render, no iframe reload).
252
+ // Filter/Customized changes fall through to the full rebuild (they change which specimens exist),
253
+ // As does a zero-host post (no stylebook iframe live yet).
256
254
  if (canvasMode === "stylebook" && !modeChanged) {
257
255
  const curFilter = tab.session.ui.stylebookFilter || "";
258
256
  const curCustomized = Boolean(tab.session.ui.stylebookCustomizedOnly);
259
257
  const filterChanged =
260
258
  curFilter !== _prevStylebookFilter || curCustomized !== _prevStylebookCustomizedOnly;
261
259
  if (!filterChanged) {
262
- refreshStylebookStyles();
263
- return;
260
+ const style = transposeStylebookStyle(getEffectiveStyle(tab.doc.document?.style));
261
+ if (postStyleUpdateToStylebookHosts(style as Record<string, unknown>) > 0) {
262
+ return;
263
+ }
264
264
  }
265
265
  }
266
266
 
267
267
  // Detect whether this is a mode transition or a content-only re-render
268
268
  view.prevCanvasMode = canvasMode;
269
269
 
270
+ // Best-effort commit of a live inline-edit session BEFORE lit rebuilds the panel DOM. Covers
271
+ // Keyboard-driven tab switches / mode changes where no parent pointerdown preceded the render —
272
+ // The endEdit posts ahead of the new render on the FIFO channel, so the resulting editCommit
273
+ // Still routes to the tab the session belonged to (the host's tabId flips only on renderComplete).
274
+ commitActiveEditSession();
275
+
270
276
  // DnD handlers are registered on inner canvas elements that get replaced on every
271
277
  // Content render, so always clean them up.
272
278
  for (const fn of view.canvasDndCleanups) {
@@ -315,7 +321,7 @@ export function renderCanvas() {
315
321
  canvasWrap.style.overflow = "";
316
322
  canvasWrap.style.overflow = "";
317
323
 
318
- // Clear zoom indicator (only re-rendered by design/preview/stylebook)
324
+ // Clear zoom indicator (only re-rendered by design/stylebook)
319
325
  resetZoomIndicator();
320
326
 
321
327
  // Dismiss open popovers/toolbars that are no longer relevant
@@ -332,9 +338,7 @@ export function renderCanvas() {
332
338
  renderStylebookMode({
333
339
  applyTransform,
334
340
  canvasPanelTemplate,
335
- effectiveZoom,
336
341
  observeCenterUntilStable,
337
- overlayBoxDescriptor,
338
342
  renderZoomIndicator,
339
343
  updateActivePanelHeaders,
340
344
  });
@@ -377,7 +381,7 @@ export function renderCanvas() {
377
381
  });
378
382
  view.monacoEditor = monaco.editor.create(editorContainer as unknown as HTMLElement, {
379
383
  automaticLayout: true,
380
- fontFamily: "'SF Mono', 'Fira Code', 'Consolas', monospace",
384
+ fontFamily: "'JetBrains Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace",
381
385
  fontSize: 12,
382
386
  lineNumbers: "on",
383
387
  minimap: { enabled: false },
@@ -506,14 +510,12 @@ export function renderCanvas() {
506
510
  ]).then(([originalDoc, currentDoc]) => {
507
511
  renderCanvasIntoPanel(
508
512
  origPanel as unknown as CanvasPanel,
509
- new Set<string>(),
510
513
  featureToggles,
511
514
  originalDoc,
512
515
  gitDiffState,
513
516
  );
514
517
  renderCanvasIntoPanel(
515
518
  currPanel as unknown as CanvasPanel,
516
- new Set<string>(),
517
519
  featureToggles,
518
520
  currentDoc,
519
521
  gitDiffState,
@@ -534,12 +536,17 @@ export function renderCanvas() {
534
536
  canvasWrap.style.padding = "0";
535
537
  canvasWrap.style.overflow = "hidden";
536
538
 
537
- // Remove zoom indicator left over from design/preview mode
539
+ // Remove zoom indicator left over from design mode
538
540
  resetZoomIndicator();
539
541
  }
540
542
 
541
543
  const { baseWidth } = parseMediaEntries(getEffectiveMedia(S.document.$media));
542
544
  const { tpl: panelTpl, panel } = canvasPanelTemplate(null, null, true);
545
+ // A component-definition doc (root tag is a custom element) is a fragment, not a page: it should
546
+ // Hug its content rather than have the column fill+stretch to the viewport (dead scroll space).
547
+ const rootTag = (S.document as { tagName?: unknown }).tagName;
548
+ const isComponentDoc = typeof rootTag === "string" && rootTag.includes("-");
549
+ const columnClass = isComponentDoc ? "content-edit-column is-component" : "content-edit-column";
543
550
  const editTpl = html`
544
551
  <div
545
552
  class="content-edit-canvas"
@@ -547,16 +554,16 @@ export function renderCanvas() {
547
554
  panel.scrollContainer = (el as HTMLElement) || null;
548
555
  })}
549
556
  >
550
- <div class="content-edit-column" style="max-width:${baseWidth}px">${panelTpl}</div>
557
+ <div class=${columnClass} style="max-width:${baseWidth}px">${panelTpl}</div>
551
558
  </div>
552
559
  `;
553
560
  litRender(editTpl, canvasWrap);
554
561
  canvasPanels.push(panel as unknown as CanvasPanel);
555
- renderCanvasIntoPanel(panel as unknown as CanvasPanel, new Set<string>(), S.ui.featureToggles);
562
+ renderCanvasIntoPanel(panel as unknown as CanvasPanel, S.ui.featureToggles);
556
563
  return;
557
564
  }
558
565
 
559
- // Normal canvas mode (design / preview) — set up panzoom surface
566
+ // Design mode (also hosts preview-toggle renders) — set up panzoom surface
560
567
  if (modeChanged) {
561
568
  canvasWrap.style.padding = "0";
562
569
  canvasWrap.style.overflow = "hidden";
@@ -599,7 +606,7 @@ export function renderCanvas() {
599
606
  canvasWrap,
600
607
  );
601
608
  canvasPanels.push(panel as unknown as CanvasPanel);
602
- renderCanvasIntoPanel(panel as unknown as CanvasPanel, new Set<string>(), featureToggles);
609
+ renderCanvasIntoPanel(panel as unknown as CanvasPanel, featureToggles);
603
610
  applyTransform();
604
611
  if (modeChanged) {
605
612
  observeCenterUntilStable();
@@ -612,7 +619,6 @@ export function renderCanvas() {
612
619
  // Descending for max-width — matching the direction of the design's media queries).
613
620
  const allPanelDefs = [
614
621
  {
615
- activeSet: activeBreakpointsForWidth(sizeBreakpoints, baseWidth),
616
622
  displayName: mediaDisplayName("--"),
617
623
  name: "base",
618
624
  width: baseWidth,
@@ -620,7 +626,6 @@ export function renderCanvas() {
620
626
  ];
621
627
  for (const bp of sizeBreakpoints) {
622
628
  allPanelDefs.push({
623
- activeSet: activeBreakpointsForWidth(sizeBreakpoints, bp.width),
624
629
  displayName: mediaDisplayName(bp.name),
625
630
  name: bp.name,
626
631
  width: bp.width,
@@ -630,7 +635,7 @@ export function renderCanvas() {
630
635
  const panelEntries = allPanelDefs.map((def) => {
631
636
  const label = `${def.displayName} (${def.width}px)`;
632
637
  const { tpl, panel } = canvasPanelTemplate(def.name, label, false, def.width);
633
- return { activeSet: def.activeSet, panel, tpl };
638
+ return { panel, tpl };
634
639
  });
635
640
 
636
641
  litRender(
@@ -651,13 +656,13 @@ export function renderCanvas() {
651
656
  );
652
657
 
653
658
  for (let i = 0; i < panelEntries.length; i++) {
654
- const { panel, activeSet } = panelEntries[i]!;
659
+ const { panel } = panelEntries[i]!;
655
660
  const p = panel as CanvasPanel;
656
661
  canvasPanels.push(p);
657
662
  if (i === 0) {
658
- renderCanvasIntoPanel(p, activeSet, featureToggles);
663
+ renderCanvasIntoPanel(p, featureToggles);
659
664
  } else {
660
- setTimeout(() => renderCanvasIntoPanel(p, activeSet, featureToggles), 0);
665
+ setTimeout(() => renderCanvasIntoPanel(p, featureToggles), 0);
661
666
  }
662
667
  }
663
668
 
@@ -675,23 +680,24 @@ export function renderCanvas() {
675
680
  }
676
681
 
677
682
  /**
678
- * Render document into a single canvas panel. Tries runtime rendering first, falls back to
679
- * structural preview.
683
+ * Render a document into a single canvas panel via the iframe canvas: the document renders inside a
684
+ * same-runtime iframe served from the real origin (the iframe holds the render context, stamps
685
+ * `data-jx-path`/`data-jx-layout`, and draws its own overlays). Git-diff/preview overrides render
686
+ * but stay un-patchable (`ready` only flips for the real tab document).
680
687
  *
681
688
  * @param {CanvasPanel} panel
682
- * @param {Set<string>} activeBreakpoints
683
- * @param {Record<string, boolean>} featureToggles
689
+ * @param {Record<string, boolean>} _featureToggles - Accepted for call-site symmetry (the iframe
690
+ * render needs no structural-preview fallback); unused.
684
691
  * @param {JxMutableNode | null} [docOverride] - Optional document to render (for diff mode). Uses
685
692
  * active tab doc if not provided.
686
- * @param {GitDiffState | null} [gitDiffState] - Optional diff state. If provided, computes and
687
- * applies diff highlighting.
693
+ * @param {GitDiffState | null} [_gitDiffState] - Accepted for call-site symmetry; the iframe path
694
+ * does not apply parent-side diff highlighting.
688
695
  */
689
696
  function renderCanvasIntoPanel(
690
697
  panel: CanvasPanel,
691
- activeBreakpoints: Set<string>,
692
- featureToggles: Record<string, boolean>,
698
+ _featureToggles: Record<string, boolean>,
693
699
  docOverride: JxMutableNode | null = null,
694
- gitDiffState: GitDiffState | null = null,
700
+ _gitDiffState: GitDiffState | null = null,
695
701
  ) {
696
702
  const gen = view.renderGeneration;
697
703
  const tab = activeTab.value;
@@ -700,179 +706,31 @@ function renderCanvasIntoPanel(
700
706
 
701
707
  canvasPerf.panelRenders += 1;
702
708
  panel.ready = false;
703
- panel.liveCtx = null;
704
- panel.activeBreakpoints = activeBreakpoints;
705
709
 
706
- renderCanvasLive(gen, docToRender, canvas, panel)
707
- .then((scope: Record<string, unknown> | null) => {
708
- // Skip post-render setup if a newer render has started
709
- if (gen !== view.renderGeneration) {
710
- return;
711
- }
712
- if (scope) {
713
- updateCanvas({ error: null, scope, status: "ready" });
714
- applyCanvasMediaOverrides(canvas, activeBreakpoints);
715
- statusMessage("Runtime render OK", 1500);
716
- // Panel is patchable only when the live runtime path rendered the real document
710
+ // Overrides (git-diff docs) mount with a null tab identity: their iframes must never route doc
711
+ // Mutations anywhere. The real doc carries its tab id so edit/drop messages route to THAT tab.
712
+ void mountIframeCanvas(
713
+ gen,
714
+ docToRender,
715
+ canvas,
716
+ panel._width,
717
+ docOverride ? null : (tab?.id ?? null),
718
+ )
719
+ .then(() => {
720
+ if (gen === view.renderGeneration) {
721
+ // Mark the panel patchable once the real document is mounted (not a diff/preview override)
722
+ // So classifyOps admits surgical patches; the iframe holds the render context, so this
723
+ // Panel needs no parent-side render scope.
717
724
  panel.ready = !docOverride;
718
- scheduleStyleTagSweep();
719
-
720
- // Apply diff highlighting if in git-diff mode
721
- if (gitDiffState && docOverride) {
722
- // Determine which document is original and which is current
723
- const isOriginal = docOverride === (gitDiffState.originalDoc || gitDiffState.original);
724
- const _tab = activeTab.value;
725
- const origDoc = isOriginal ? docOverride : gitDiffState.currentDoc || _tab?.doc.document;
726
- const currDoc = isOriginal ? gitDiffState.currentDoc || _tab?.doc.document : docOverride;
727
-
728
- const { byPath: diffMap } = computeDocumentDiff(origDoc, currDoc);
729
-
730
- // Can't iterate WeakMap, so apply styling by walking the canvas
731
- const { elToPath } = scope;
732
- if (elToPath instanceof WeakMap) {
733
- applyDiffHighlightToCanvas(canvas, diffMap);
734
- }
735
- }
736
- } else {
737
- // Fallback to structural preview
738
725
  updateCanvas({ error: null, scope: null, status: "ready" });
739
- canvas.innerHTML = "";
740
- renderCanvasNode(docToRender, [], canvas, activeBreakpoints, featureToggles);
741
- }
742
- try {
743
- registerPanelDnD(panel as unknown as CanvasPanel);
744
- } catch (dndError) {
745
- console.warn("registerPanelDnD failed:", errorMessage(dndError));
746
- }
747
- registerPanelEvents(panel as unknown as CanvasPanel);
748
- renderOverlays();
749
- updateForcedPseudoPreview();
750
-
751
- // Process pending inline edit when canvas becomes ready
752
- const currentTab = activeTab.value;
753
- if (currentTab?.session.ui?.pendingInlineEdit) {
754
- const { path, mediaName: mn } = currentTab.session.ui.pendingInlineEdit as InlineEditDef;
755
- currentTab.session.ui.pendingInlineEdit = null;
756
- const targetPanel = canvasPanels.find((p) => p.mediaName === mn) || canvasPanels[0];
757
- if (targetPanel) {
758
- const el = findCanvasElement(path, targetPanel.canvas);
759
- if (el) {
760
- enterComponentInlineEdit(el, path);
761
- }
762
- }
726
+ statusMessage("Iframe render OK", 1500);
763
727
  }
764
728
  })
765
729
  .catch((error: unknown) => {
766
- if (gen !== view.renderGeneration) {
767
- return;
768
- }
769
- console.warn("renderCanvasLive rejected:", error instanceof Error ? error.message : error);
770
- updateCanvas({ error: null, scope: null, status: "ready" });
771
- canvas.innerHTML = "";
772
- renderCanvasNode(docToRender, [], canvas, activeBreakpoints, featureToggles);
773
- try {
774
- registerPanelDnD(panel as unknown as CanvasPanel);
775
- } catch (dndError) {
776
- console.warn("registerPanelDnD failed:", errorMessage(dndError));
777
- }
778
- registerPanelEvents(panel as unknown as CanvasPanel);
779
- renderOverlays();
780
- updateForcedPseudoPreview();
730
+ console.warn("mountIframeCanvas failed:", error instanceof Error ? error.message : error);
781
731
  });
782
732
  }
783
733
 
784
- /**
785
- * Apply diff highlighting to canvas elements based on elToPath mapping. Walks the canvas DOM and
786
- * applies classes based on diff status.
787
- *
788
- * @param {HTMLElement} canvas
789
- * @param {Map<string, "added" | "removed" | "modified">} diffMap
790
- */
791
- function applyDiffHighlightToCanvas(
792
- canvas: HTMLElement,
793
- diffMap: Map<string, "added" | "removed" | "modified">,
794
- ) {
795
- if (!diffMap || diffMap.size === 0) {
796
- return;
797
- }
798
-
799
- // Walk all elements in canvas and check their data attributes or other markers
800
- const walkCanvas = (el: HTMLElement, /** @type {string} */ path = "") => {
801
- const pathKey = path || "/";
802
-
803
- if (diffMap.has(pathKey)) {
804
- const status = diffMap.get(pathKey);
805
- if (status === "added") {
806
- el.classList.add("element-diff-added");
807
- } else if (status === "removed") {
808
- el.classList.add("element-diff-removed");
809
- } else if (status === "modified") {
810
- el.classList.add("element-diff-modified");
811
- }
812
- }
813
-
814
- // Check for child elements (heuristic: children array markers)
815
- let childIdx = 0;
816
- for (const child of el.children) {
817
- const childPath =
818
- pathKey === "/" ? `children/${childIdx}` : `${pathKey}/children/${childIdx}`;
819
- walkCanvas(child as HTMLElement, childPath);
820
- childIdx += 1;
821
- }
822
- };
823
-
824
- walkCanvas(canvas, "");
825
- }
826
-
827
- /**
828
- * Apply media query overrides as inline styles on matching canvas elements. Needed because the
829
- * runtime renders base styles as inline — @media CSS rules in the injected stylesheet can't win
830
- * against inline specificity.
831
- *
832
- * @param {Element} canvasEl
833
- * @param {Set<string>} activeBreakpoints
834
- */
835
- export function applyCanvasMediaOverrides(canvasEl: Element, activeBreakpoints: Set<string>) {
836
- if (activeBreakpoints.size === 0) {
837
- return;
838
- }
839
- const tab = activeTab.value;
840
- if (!tab) {
841
- return;
842
- }
843
- const docMedia = getEffectiveMedia(tab.doc.document.$media || {});
844
- // Build a set of CSS condition texts that match active breakpoints
845
- const activeConditions = new Set<string>();
846
- for (const name of activeBreakpoints) {
847
- if (docMedia[name]) {
848
- activeConditions.add(docMedia[name]);
849
- }
850
- }
851
- const overrides = collectMediaOverrides(document.styleSheets, activeConditions);
852
- applyOverridesToCanvas(canvasEl, overrides);
853
- }
854
-
855
734
  export function renderOverlays() {
856
735
  overlaysPanel.render();
857
736
  }
858
-
859
- // ─── Style-tag hygiene ────────────────────────────────────────────────────────
860
-
861
- let _sweepTimer: ReturnType<typeof setTimeout> | undefined;
862
-
863
- /**
864
- * Remove scoped <style> tags whose owner elements are no longer in the document. The runtime emits
865
- * one tag per styled element (nested selectors / media rules) and full re-renders orphan the
866
- * previous tree's tags. Debounced so in-flight panel renders finish attaching first.
867
- */
868
- function scheduleStyleTagSweep() {
869
- clearTimeout(_sweepTimer);
870
- _sweepTimer = setTimeout(() => {
871
- for (const tag of document.head.querySelectorAll("style[data-jx-owner]")) {
872
- const uid = (tag as HTMLElement).dataset.jxOwner;
873
- if (uid && !document.querySelector(`[data-jx="${uid}"]`)) {
874
- tag.remove();
875
- }
876
- }
877
- }, 250);
878
- }
@@ -16,13 +16,13 @@ import { activeTab } from "../workspace/workspace";
16
16
  import { view } from "../view";
17
17
  import { getLayerSlot } from "../ui/layers";
18
18
  import { findCanvasElement, getActivePanel, panelMediaToActiveMedia } from "./canvas-helpers";
19
+ import { rectOf } from "../utils/geometry";
19
20
  import type { TemplateResult } from "lit-html";
20
21
 
21
22
  let _ctx: {
22
23
  getCanvasMode: () => string;
23
24
  getZoom: () => number;
24
25
  setZoomDirect: (zoom: number) => void;
25
- renderStylebookOverlays: () => void;
26
26
  };
27
27
 
28
28
  let _zoomIndicatorEl: HTMLElement | null = null;
@@ -34,14 +34,12 @@ let _zoomIndicatorEl: HTMLElement | null = null;
34
34
  * getCanvasMode: () => string;
35
35
  * getZoom: () => number;
36
36
  * setZoomDirect: (zoom: number) => void;
37
- * renderStylebookOverlays: () => void;
38
37
  * }} ctx
39
38
  */
40
39
  export function initCanvasUtils(ctx: {
41
40
  getCanvasMode: () => string;
42
41
  getZoom: () => number;
43
42
  setZoomDirect: (zoom: number) => void;
44
- renderStylebookOverlays: () => void;
45
43
  }) {
46
44
  _ctx = ctx;
47
45
  }
@@ -64,14 +62,9 @@ export function canvasPanelTemplate(
64
62
  // Which lit runs synchronously during render — before any consumer reads them.
65
63
  const panel = {
66
64
  _width: width || null,
67
- activeBreakpoints: null,
68
65
  canvas: null,
69
- dropLine: null,
70
66
  element: null,
71
- liveCtx: null,
72
67
  mediaName: mediaName || "",
73
- overlay: null,
74
- overlayClk: null,
75
68
  ready: false,
76
69
  renderScope: null,
77
70
  scrollContainer: null,
@@ -117,32 +110,6 @@ export function canvasPanelTemplate(
117
110
  }
118
111
  })}
119
112
  ></div>
120
- <div
121
- class="canvas-panel-overlay"
122
- ${ref((el) => {
123
- if (el) {
124
- panel.overlay = el as HTMLElement;
125
- }
126
- })}
127
- >
128
- <div
129
- class="canvas-drop-indicator"
130
- style="display:none"
131
- ${ref((el) => {
132
- if (el) {
133
- panel.dropLine = el as HTMLElement;
134
- }
135
- })}
136
- ></div>
137
- </div>
138
- <div
139
- class="canvas-panel-click"
140
- ${ref((el) => {
141
- if (el) {
142
- panel.overlayClk = el as HTMLElement;
143
- }
144
- })}
145
- ></div>
146
113
  </div>
147
114
  </div>
148
115
  `;
@@ -196,10 +163,9 @@ export function applyTransform() {
196
163
  const zoom = _ctx.getZoom();
197
164
  view.panzoomWrap.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`;
198
165
  renderZoomIndicator();
166
+ // Overlays live INSIDE the scaled panzoom-wrap (iframe hosts draw there), so no per-mode redraw
167
+ // Is needed here — the flush only re-anchors the fixed block-action-bar.
199
168
  renderOnly("overlays");
200
- if (_ctx.getCanvasMode() === "stylebook") {
201
- _ctx.renderStylebookOverlays();
202
- }
203
169
  }
204
170
 
205
171
  /** Calculate zoom + pan to fit all panels within the viewport. */
@@ -218,7 +184,7 @@ export function fitToScreen() {
218
184
  totalPanelWidth += gap * Math.max(0, canvasPanels.length - 1) + padding;
219
185
 
220
186
  const zoom = _ctx.getZoom();
221
- const wrapRect = view.panzoomWrap.getBoundingClientRect();
187
+ const wrapRect = rectOf(view.panzoomWrap);
222
188
  const unscaledHeight = wrapRect.height / zoom;
223
189
  const maxPanelHeight = unscaledHeight + padding;
224
190
 
@@ -258,8 +224,8 @@ export function resetZoomIndicator() {
258
224
  * @param {{ scrollContainer?: HTMLElement | null }} [panel]
259
225
  */
260
226
  function _panToEl(el: HTMLElement, panel?: { scrollContainer?: HTMLElement | null }) {
261
- const wrapRect = canvasWrap.getBoundingClientRect();
262
- const elRect = el.getBoundingClientRect();
227
+ const wrapRect = rectOf(canvasWrap);
228
+ const elRect = rectOf(el);
263
229
  const elCenterY = elRect.top + elRect.height / 2 - wrapRect.top;
264
230
  const vpCenterY = wrapRect.height / 2;
265
231
  const offsetY = vpCenterY - elCenterY;
@@ -270,23 +236,39 @@ function _panToEl(el: HTMLElement, panel?: { scrollContainer?: HTMLElement | nul
270
236
  top: panel.scrollContainer.scrollTop - offsetY,
271
237
  });
272
238
  } else {
273
- const startY = view.panY;
274
- const targetY = startY + offsetY;
275
- const start = performance.now();
276
- const duration = 250;
277
- const step = (now: number) => {
278
- const t = Math.min((now - start) / duration, 1);
279
- const ease = t * (2 - t);
280
- view.panY = startY + (targetY - startY) * ease;
281
- applyTransform();
282
- if (t < 1) {
283
- requestAnimationFrame(step);
284
- }
285
- };
286
- requestAnimationFrame(step);
239
+ animatePanBy(offsetY);
287
240
  }
288
241
  }
289
242
 
243
+ /**
244
+ * Pan the panzoom canvas vertically so a PARENT-VIEWPORT rect is centered — for callers whose
245
+ * target lives inside an iframe (no parent DOM element to measure; the host converts the measured
246
+ * iframe rect and passes it here).
247
+ */
248
+ export function panToParentRect(rect: { top: number; height: number }) {
249
+ const wrapRect = rectOf(canvasWrap);
250
+ const elCenterY = rect.top + rect.height / 2 - wrapRect.top;
251
+ animatePanBy(wrapRect.height / 2 - elCenterY);
252
+ }
253
+
254
+ /** Animate `view.panY` by `offsetY` with the shared 250ms ease-out. */
255
+ function animatePanBy(offsetY: number) {
256
+ const startY = view.panY;
257
+ const targetY = startY + offsetY;
258
+ const start = performance.now();
259
+ const duration = 250;
260
+ const step = (now: number) => {
261
+ const t = Math.min((now - start) / duration, 1);
262
+ const ease = t * (2 - t);
263
+ view.panY = startY + (targetY - startY) * ease;
264
+ applyTransform();
265
+ if (t < 1) {
266
+ requestAnimationFrame(step);
267
+ }
268
+ };
269
+ requestAnimationFrame(step);
270
+ }
271
+
290
272
  /**
291
273
  * Pan the canvas vertically so the element at `path` is centered in the viewport.
292
274
  *
@@ -304,16 +286,6 @@ export function panToElement(path: (string | number)[]) {
304
286
  _panToEl(el, panel);
305
287
  }
306
288
 
307
- /**
308
- * Pan the canvas vertically to center a specific DOM element (e.g. stylebook elements).
309
- *
310
- * @param {HTMLElement} el
311
- */
312
- export function panToCanvasEl(el: HTMLElement) {
313
- const panel = getActivePanel();
314
- _panToEl(el, panel ?? undefined);
315
- }
316
-
317
289
  /**
318
290
  * Render the floating zoom indicator at the bottom center of canvas-wrap. Uses position: fixed,
319
291
  * computed from canvas-wrap bounds.
@@ -368,7 +340,7 @@ export function positionZoomIndicator() {
368
340
  if (!_zoomIndicatorEl) {
369
341
  return;
370
342
  }
371
- const rect = canvasWrap.getBoundingClientRect();
343
+ const rect = rectOf(canvasWrap);
372
344
  _zoomIndicatorEl.style.left = `${rect.left + rect.width / 2}px`;
373
345
  _zoomIndicatorEl.style.top = `${rect.bottom - 32}px`;
374
346
  _zoomIndicatorEl.style.transform = "translateX(-50%)";