@jxsuite/studio 2.0.1 → 2.1.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/canvas.html +21 -0
- package/dist/chunks/Overlay.dev-qfjzkms8.js +23 -0
- package/dist/chunks/{Overlay.dev-cszx7yn5.js.map → Overlay.dev-qfjzkms8.js.map} +1 -1
- package/dist/chunks/{adapter-z8fkmbmv.js → adapter-qfnesn5v.js} +2 -2
- package/dist/chunks/{cf-settings-mmyv436d.js → cf-settings-73sh5z4p.js} +3 -3
- package/dist/chunks/{components-799jpvhm.js → components-2bpqp4mq.js} +5 -7
- package/dist/chunks/{components-799jpvhm.js.map → components-2bpqp4mq.js.map} +1 -1
- package/dist/chunks/content-source-34gnkbnn.js +38 -0
- package/dist/chunks/content-source-34gnkbnn.js.map +9 -0
- package/dist/chunks/convert-to-repeater-t8kxre5s.js +258 -0
- package/dist/chunks/convert-to-repeater-t8kxre5s.js.map +10 -0
- package/dist/chunks/extension-sections-6mqydz35.js +301 -0
- package/dist/chunks/extension-sections-6mqydz35.js.map +11 -0
- package/dist/chunks/files-8kn321h1.js +70 -0
- package/dist/chunks/files-8kn321h1.js.map +9 -0
- package/dist/chunks/{format-host-8jxwts2p.js → format-host-mkwsmt8d.js} +3 -3
- package/dist/chunks/git-panel-tjbr191d.js +51 -0
- package/dist/chunks/git-panel-tjbr191d.js.map +9 -0
- package/dist/chunks/{index-baeg2x3y.js → index-7pxchcce.js} +4 -3
- package/dist/chunks/{index-baeg2x3y.js.map → index-7pxchcce.js.map} +3 -3
- package/dist/chunks/{jx-validate-qx8degj7.js → jx-validate-v6qgknrs.js} +3 -3
- package/dist/chunks/layers-panel-87pb25km.js +58 -0
- package/dist/chunks/layers-panel-87pb25km.js.map +9 -0
- package/dist/chunks/{monaco-setup-zjdp9zdw.js → monaco-setup-e0yh25j4.js} +4 -75
- package/dist/chunks/{monaco-setup-zjdp9zdw.js.map → monaco-setup-e0yh25j4.js.map} +3 -4
- package/dist/chunks/publish-panel-m0fcgdrt.js +21 -0
- package/dist/chunks/publish-panel-m0fcgdrt.js.map +9 -0
- package/dist/chunks/quick-search-rhs6cbsb.js +53 -0
- package/dist/chunks/quick-search-rhs6cbsb.js.map +9 -0
- package/dist/chunks/right-panel-zpdxd98t.js +37 -0
- package/dist/chunks/right-panel-zpdxd98t.js.map +9 -0
- package/dist/chunks/section-registry-zmckaxf4.js +30 -0
- package/dist/chunks/section-registry-zmckaxf4.js.map +9 -0
- package/dist/chunks/sp-overlay.dev-9ran0sfq.js +18 -0
- package/dist/chunks/sp-overlay.dev-9ran0sfq.js.map +9 -0
- package/dist/chunks/sp-popover.dev-rebmdncr.js +9 -0
- package/dist/chunks/sp-popover.dev-rebmdncr.js.map +9 -0
- package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js +15 -0
- package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js.map +9 -0
- package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js +15 -0
- package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js.map +9 -0
- package/dist/chunks/{sp-tray.dev-pb2fsd65.js → sp-tray.dev-ey59vfh7.js} +10 -8
- package/dist/chunks/{sp-tray.dev-pb2fsd65.js.map → sp-tray.dev-ey59vfh7.js.map} +2 -2
- package/dist/chunks/{studio-9qr6fq7p.js → studio-0rqqhrdy.js} +10 -378
- package/dist/chunks/{studio-9qr6fq7p.js.map → studio-0rqqhrdy.js.map} +4 -14
- package/dist/chunks/{studio-5x6tqqec.js → studio-2sj1jj3v.js} +10 -8
- package/dist/chunks/{studio-5x6tqqec.js.map → studio-2sj1jj3v.js.map} +2 -2
- package/dist/chunks/{studio-h3fw0wn8.js → studio-3fk1ac9z.js} +6 -4
- package/dist/chunks/{studio-h3fw0wn8.js.map → studio-3fk1ac9z.js.map} +2 -2
- package/dist/chunks/studio-41exgzhq.js +6690 -0
- package/dist/chunks/studio-41exgzhq.js.map +38 -0
- package/dist/chunks/{studio-7v2bpweq.js → studio-4mqb9a0j.js} +6 -4
- package/dist/chunks/{studio-7v2bpweq.js.map → studio-4mqb9a0j.js.map} +2 -2
- package/dist/chunks/studio-50k77cca.js +1787 -0
- package/dist/chunks/studio-50k77cca.js.map +18 -0
- package/dist/chunks/{studio-56v3zwk6.js → studio-6a0h7sne.js} +10 -8
- package/dist/chunks/{studio-56v3zwk6.js.map → studio-6a0h7sne.js.map} +2 -2
- package/dist/chunks/studio-6f0am8f2.js +53 -0
- package/dist/chunks/studio-6f0am8f2.js.map +10 -0
- package/dist/chunks/studio-6nmjc5qk.js +7002 -0
- package/dist/chunks/studio-6nmjc5qk.js.map +32 -0
- package/dist/chunks/studio-7afd21nw.js +67 -0
- package/dist/chunks/studio-7afd21nw.js.map +10 -0
- package/dist/chunks/{studio-xze4zrs1.js → studio-7bmdvzne.js} +2 -2
- package/dist/chunks/studio-7bmdvzne.js.map +10 -0
- package/dist/chunks/studio-8jpzqk5s.js +128 -0
- package/dist/chunks/studio-8jpzqk5s.js.map +10 -0
- package/dist/chunks/studio-94m77tgk.js +1482 -0
- package/dist/chunks/studio-94m77tgk.js.map +13 -0
- package/dist/chunks/studio-9je6w5sq.js +1000 -0
- package/dist/chunks/studio-9je6w5sq.js.map +16 -0
- package/dist/chunks/{studio-w7c6kvk7.js → studio-a7pzveq1.js} +2 -2
- package/dist/chunks/{studio-gs6kqdt1.js → studio-adn0z1k3.js} +14 -8
- package/dist/chunks/{studio-gs6kqdt1.js.map → studio-adn0z1k3.js.map} +3 -3
- package/dist/chunks/{studio-kr88ryr7.js → studio-bsxxx3x2.js} +17 -15
- package/dist/chunks/{studio-kr88ryr7.js.map → studio-bsxxx3x2.js.map} +7 -6
- package/dist/chunks/studio-bw5dz6km.js +608 -0
- package/dist/chunks/studio-bw5dz6km.js.map +21 -0
- package/dist/chunks/studio-c6s5getc.js +4232 -0
- package/dist/chunks/studio-c6s5getc.js.map +39 -0
- package/dist/chunks/studio-cjm7fdjf.js +451 -0
- package/dist/chunks/studio-cjm7fdjf.js.map +14 -0
- package/dist/chunks/{studio-cx91mnh8.js → studio-ckwz4d3x.js} +53 -53
- package/dist/chunks/{studio-cx91mnh8.js.map → studio-ckwz4d3x.js.map} +5 -5
- package/dist/chunks/studio-dg7zyt5x.js +28 -0
- package/dist/chunks/studio-dg7zyt5x.js.map +10 -0
- package/dist/chunks/studio-dzcyvvvz.js +166 -0
- package/dist/chunks/studio-dzcyvvvz.js.map +10 -0
- package/dist/chunks/{studio-7kqe41sa.js → studio-evkr5d3b.js} +15 -7
- package/dist/chunks/{studio-7kqe41sa.js.map → studio-evkr5d3b.js.map} +5 -4
- package/dist/chunks/studio-f12h6177.js +411 -0
- package/dist/chunks/studio-f12h6177.js.map +13 -0
- package/dist/chunks/studio-hbp1s8m9.js +1817 -0
- package/dist/chunks/studio-hbp1s8m9.js.map +14 -0
- package/dist/chunks/studio-k2sxjph5.js +15295 -0
- package/dist/chunks/studio-k2sxjph5.js.map +235 -0
- package/dist/chunks/studio-myje51t3.js +385 -0
- package/dist/chunks/studio-myje51t3.js.map +12 -0
- package/dist/chunks/studio-na6rwzhw.js +353 -0
- package/dist/chunks/studio-na6rwzhw.js.map +11 -0
- package/dist/chunks/{studio-4k0m4fmp.js → studio-p471gg5n.js} +126 -3
- package/dist/chunks/studio-p471gg5n.js.map +14 -0
- package/dist/chunks/{studio-9bjwf0d4.js → studio-rgwcpast.js} +5 -46
- package/dist/chunks/{studio-9bjwf0d4.js.map → studio-rgwcpast.js.map} +4 -5
- package/dist/chunks/{studio-esrewxap.js → studio-s519zc99.js} +3 -3
- package/dist/chunks/studio-sas7mt3k.js +75 -0
- package/dist/chunks/studio-sas7mt3k.js.map +10 -0
- package/dist/chunks/studio-t5wqwzdh.js +74 -0
- package/dist/chunks/studio-t5wqwzdh.js.map +10 -0
- package/dist/chunks/{studio-9qpspwfn.js → studio-temv5bw4.js} +5 -5
- package/dist/chunks/{studio-9qpspwfn.js.map → studio-temv5bw4.js.map} +1 -1
- package/dist/chunks/{studio-qkvt37c0.js → studio-tr20m8g8.js} +669 -18
- package/dist/chunks/studio-tr20m8g8.js.map +9 -0
- package/dist/chunks/{studio-v7jkjqng.js → studio-v96qfnww.js} +5514 -4291
- package/dist/chunks/studio-v96qfnww.js.map +54 -0
- package/dist/chunks/studio-vcetc91k.js +659 -0
- package/dist/chunks/studio-vcetc91k.js.map +10 -0
- package/dist/chunks/{studio-jm0z592w.js → studio-vmymjefp.js} +2972 -4937
- package/dist/chunks/studio-vmymjefp.js.map +98 -0
- package/dist/chunks/{studio-bghqfmea.js → studio-wwseqyw0.js} +2 -2
- package/dist/chunks/studio-x9hmy5kr.js +390 -0
- package/dist/chunks/studio-x9hmy5kr.js.map +10 -0
- package/dist/chunks/studio-xa0ce0n7.js +335 -0
- package/dist/chunks/studio-xa0ce0n7.js.map +10 -0
- package/dist/chunks/studio-xm3dbxvn.js +378 -0
- package/dist/chunks/studio-xm3dbxvn.js.map +19 -0
- package/dist/chunks/{studio-nqw433kv.js → studio-xmvbhg31.js} +2 -2
- package/dist/chunks/studio-zv37yrfa.js +1054 -0
- package/dist/chunks/studio-zv37yrfa.js.map +13 -0
- package/dist/chunks/{ws-client-q7hkz3j9.js → ws-client-654ts66j.js} +6 -4
- package/dist/chunks/ws-client-654ts66j.js.map +11 -0
- package/dist/iframe-entry.js +668 -230
- package/dist/studio.js +21080 -32585
- package/index.html +35 -4685
- package/package.json +17 -15
- package/src/about/about-modal.ts +42 -11
- package/src/browse/library-commands.ts +240 -0
- package/src/browse/library-layouts.ts +369 -0
- package/src/browse/library-model.ts +396 -0
- package/src/browse/library-pane.ts +1049 -0
- package/src/browse/library-preview.ts +252 -0
- package/src/browse/library-source.ts +150 -0
- package/src/canvas/canvas-helpers.ts +33 -9
- package/src/canvas/canvas-live-render.ts +45 -29
- package/src/canvas/canvas-patcher.ts +151 -31
- package/src/canvas/canvas-perf.ts +46 -7
- package/src/canvas/canvas-render.ts +1092 -220
- package/src/canvas/canvas-surface.ts +240 -0
- package/src/canvas/canvas-utils.ts +1166 -124
- package/src/canvas/iframe-drop.ts +2 -1
- package/src/canvas/iframe-entry.ts +241 -4
- package/src/canvas/iframe-host.ts +1226 -143
- package/src/canvas/iframe-inline-edit.ts +31 -0
- package/src/canvas/iframe-interaction.ts +76 -16
- package/src/canvas/iframe-keys.ts +107 -45
- package/src/canvas/iframe-overlay.ts +38 -1
- package/src/canvas/iframe-protocol.ts +137 -2
- package/src/canvas/iframe-render.ts +70 -2
- package/src/canvas/iframe-slash.ts +6 -0
- package/src/canvas/path-mapping.ts +56 -6
- package/src/canvas/surface-registry.ts +292 -0
- package/src/collab/collab-commands.ts +244 -0
- package/src/collab/collab-session.ts +198 -32
- package/src/collab/collab-state.ts +28 -2
- package/src/collab/presence-chips.ts +100 -9
- package/src/commands/active-registry.ts +42 -0
- package/src/commands/app-commands.ts +176 -0
- package/src/commands/budget.ts +158 -0
- package/src/commands/command-args.ts +253 -0
- package/src/commands/context.ts +387 -0
- package/src/commands/defaults.ts +580 -0
- package/src/commands/keymap.ts +571 -0
- package/src/commands/levels.ts +345 -0
- package/src/commands/live-context.ts +193 -0
- package/src/commands/reference.ts +227 -0
- package/src/commands/registry.ts +293 -0
- package/src/component-props.ts +3 -2
- package/src/content/draft-state.ts +106 -0
- package/src/content/entry-commands.ts +282 -0
- package/src/content/entry-editor.ts +327 -0
- package/src/content/entry-fields.ts +116 -0
- package/src/content/entry-model.ts +175 -0
- package/src/editor/canvas-context-menu.ts +17 -11
- package/src/editor/canvas-slash-bridge.ts +11 -4
- package/src/editor/context-menu.ts +804 -204
- package/src/editor/convert-to-component.ts +31 -10
- package/src/editor/convert-to-repeater.ts +3 -2
- package/src/editor/inline-edit-apply.ts +5 -5
- package/src/editor/inline-edit.ts +86 -50
- package/src/editor/insert-zone-action.ts +1 -1
- package/src/editor/shortcuts.ts +1043 -329
- package/src/editor/slash-menu.ts +1 -0
- package/src/files/elements.ts +166 -0
- package/src/files/file-ops.ts +178 -31
- package/src/files/files.ts +898 -268
- package/src/files/fs-events.ts +21 -0
- package/src/files/media-meta.ts +203 -0
- package/src/files/media-paths.ts +141 -0
- package/src/files/media-upload.ts +7 -3
- package/src/files/media-usage.ts +189 -0
- package/src/github/github-auth.ts +235 -33
- package/src/github/github-publish.ts +80 -21
- package/src/grid/grid-controller.ts +152 -12
- package/src/grid/grid-layout.ts +225 -20
- package/src/grid/grid-open.ts +120 -0
- package/src/grid/grid-panel.ts +554 -39
- package/src/grid/grid-source.ts +27 -1
- package/src/grid/redirects-grid.ts +599 -0
- package/src/grid/redirects.ts +398 -0
- package/src/grid/sources/content-source.ts +108 -2
- package/src/i18n/i18n-commands.ts +322 -0
- package/src/new-project/add-repo-modal.ts +8 -10
- package/src/new-project/import-tab.ts +7 -0
- package/src/new-project/location-fields.ts +9 -1
- package/src/new-project/new-project-modal.ts +221 -245
- package/src/packages/jxsuite-update.ts +97 -48
- package/src/packages/pull-package-sync.ts +18 -9
- package/src/page-params.ts +2 -1
- package/src/panels/activity-bar.ts +152 -91
- package/src/panels/activity-panel.ts +481 -0
- package/src/panels/ai-chat/chat-markdown.ts +8 -3
- package/src/panels/ai-chat/chat-view.ts +245 -18
- package/src/panels/ai-chat/composer.ts +31 -5
- package/src/panels/ai-chat/sessions-view.ts +17 -10
- package/src/panels/ai-panel.ts +279 -53
- package/src/panels/block-action-bar.ts +1115 -224
- package/src/panels/bottom-dock.ts +337 -0
- package/src/panels/canvas-dnd-bridge.ts +37 -8
- package/src/panels/chat-panel.ts +29 -12
- package/src/panels/data-explorer.ts +254 -112
- package/src/panels/data-grid.ts +10 -4
- package/src/panels/dnd.ts +28 -51
- package/src/panels/editors.ts +434 -107
- package/src/panels/elements-panel.ts +66 -2
- package/src/panels/empty-state.ts +112 -0
- package/src/panels/events-panel.ts +596 -82
- package/src/panels/formula-workspace.ts +408 -218
- package/src/panels/frontmatter-fields.ts +51 -10
- package/src/panels/frontmatter-panel.ts +357 -79
- package/src/panels/git-panel.ts +375 -164
- package/src/panels/head-panel.ts +791 -85
- package/src/panels/i18n-panel.ts +592 -0
- package/src/panels/imports-panel.ts +95 -85
- package/src/panels/jump-bar.ts +584 -0
- package/src/panels/layers-panel.ts +1106 -328
- package/src/panels/left-panel.ts +109 -204
- package/src/panels/navigator-panels.ts +131 -0
- package/src/panels/overlays.ts +7 -2
- package/src/panels/pane-context.ts +1477 -0
- package/src/panels/pane-grid.ts +406 -0
- package/src/panels/panel-registry.ts +340 -0
- package/src/panels/panel-scheduler.ts +75 -0
- package/src/panels/problems-panel.ts +234 -0
- package/src/panels/properties-panel.ts +946 -1139
- package/src/panels/provenance.ts +258 -0
- package/src/panels/quick-search.ts +791 -122
- package/src/panels/right-panel.ts +228 -102
- package/src/panels/seo-modal.ts +395 -0
- package/src/panels/settings-pane.ts +203 -0
- package/src/panels/signals-panel.ts +354 -137
- package/src/panels/statement-editor.ts +41 -25
- package/src/panels/statusbar.ts +342 -106
- package/src/panels/style-panel.ts +974 -463
- package/src/panels/style-utils.ts +3 -3
- package/src/panels/stylebook-doc.ts +4 -2
- package/src/panels/stylebook-layers-panel.ts +3 -2
- package/src/panels/stylebook-panel.ts +51 -26
- package/src/panels/tab-strip.ts +1058 -78
- package/src/panels/target-line.ts +274 -0
- package/src/panels/toolbar.ts +631 -484
- package/src/panels/welcome-screen.ts +301 -198
- package/src/platform-errors.ts +26 -3
- package/src/platform.ts +96 -2
- package/src/platforms/cloud.ts +71 -8
- package/src/platforms/devserver.ts +92 -29
- package/src/publish/deploy-checklist.ts +383 -0
- package/src/publish/pages-service.ts +10 -1
- package/src/publish/publish-commands.ts +218 -0
- package/src/publish/publish-panel.ts +110 -16
- package/src/recent-projects.ts +3 -2
- package/src/services/a11y-report.ts +505 -0
- package/src/services/ai-models.ts +30 -1
- package/src/services/ai-project-tools.ts +29 -10
- package/src/services/ai-system-prompt.ts +53 -14
- package/src/services/ai-tools.ts +39 -3
- package/src/services/ai-writes.ts +154 -0
- package/src/services/announce.ts +97 -0
- package/src/services/automation.ts +424 -175
- package/src/services/cem-export.ts +9 -4
- package/src/services/clock.ts +61 -0
- package/src/services/document-assistant.ts +36 -8
- package/src/services/idle.ts +179 -0
- package/src/services/import-client.ts +26 -0
- package/src/services/monaco-buffer.ts +627 -0
- package/src/services/monaco-lazy.ts +36 -0
- package/src/services/notify.ts +271 -0
- package/src/services/profile.ts +155 -0
- package/src/services/references.ts +0 -0
- package/src/services/token-lint.ts +3 -2
- package/src/services/tool-executor.ts +53 -7
- package/src/services/trusted-types.ts +123 -0
- package/src/settings/contexts-section.ts +427 -0
- package/src/settings/contributed-section.ts +149 -11
- package/src/settings/css-vars-editor.ts +306 -349
- package/src/settings/defs-editor.ts +93 -34
- package/src/settings/dependencies-editor.ts +7 -7
- package/src/settings/extension-sections.ts +37 -5
- package/src/settings/general-settings.ts +204 -121
- package/src/settings/locales-section.ts +347 -0
- package/src/settings/preferences-accounts.ts +140 -0
- package/src/settings/preferences-dialog.ts +620 -0
- package/src/settings/preferences-keymap.ts +270 -0
- package/src/settings/project-sections.ts +260 -0
- package/src/settings/section-registry.ts +169 -0
- package/src/settings/settings-document.ts +385 -0
- package/src/shell.ts +1549 -0
- package/src/site-context.ts +45 -20
- package/src/state.ts +14 -275
- package/src/store.ts +88 -43
- package/src/studio.ts +790 -346
- package/src/style/live-preview.ts +36 -0
- package/src/style/project-styles.ts +349 -0
- package/src/style/token-ref.ts +123 -0
- package/src/tabs/patch-ops.ts +16 -4
- package/src/tabs/project-config.ts +416 -0
- package/src/tabs/selection.ts +323 -0
- package/src/tabs/tab.ts +182 -47
- package/src/tabs/transact.ts +209 -28
- package/src/types.ts +68 -13
- package/src/ui/ai-credentials-form.ts +39 -35
- package/src/ui/ai-managed-connect.ts +7 -1
- package/src/ui/dynamic-slot.ts +224 -81
- package/src/ui/expression-editor.ts +71 -63
- package/src/ui/field-input.ts +4 -6
- package/src/ui/field-row.ts +215 -38
- package/src/ui/form-controls.ts +194 -7
- package/src/ui/layers.ts +492 -69
- package/src/ui/media-picker.ts +115 -25
- package/src/ui/panel-resize.ts +109 -91
- package/src/ui/progress-modal.ts +131 -50
- package/src/ui/regions.ts +462 -0
- package/src/ui/schema-form.ts +229 -45
- package/src/ui/spectrum.ts +24 -8
- package/src/ui/timing.ts +49 -0
- package/src/ui/value-selector.ts +20 -0
- package/src/ui/value-source.ts +348 -0
- package/src/ui/virtual-window.ts +400 -0
- package/src/utils/edit-display.ts +3 -2
- package/src/utils/inherited-style.ts +67 -37
- package/src/view.ts +64 -78
- package/src/workspace/pane-derive.ts +1258 -0
- package/src/workspace/session.ts +317 -0
- package/src/workspace/workspace.ts +1345 -23
- package/styles/canvas.css +653 -0
- package/styles/forced-colors.css +106 -0
- package/styles/inspector.css +1454 -0
- package/styles/overlays.css +2704 -0
- package/styles/panels.css +1701 -0
- package/styles/shell.css +1423 -0
- package/styles/tokens.css +151 -0
- package/dist/chunks/Overlay.dev-cszx7yn5.js +0 -22
- package/dist/chunks/extension-sections-qr3drkkw.js +0 -531
- package/dist/chunks/extension-sections-qr3drkkw.js.map +0 -12
- package/dist/chunks/files-j8a1t06k.js +0 -50
- package/dist/chunks/files-j8a1t06k.js.map +0 -9
- package/dist/chunks/git-panel-xtq5z81x.js +0 -28
- package/dist/chunks/git-panel-xtq5z81x.js.map +0 -9
- package/dist/chunks/layers-panel-ancn2wpq.js +0 -26
- package/dist/chunks/layers-panel-ancn2wpq.js.map +0 -9
- package/dist/chunks/settings-modal-17ttr96g.js +0 -29
- package/dist/chunks/settings-modal-17ttr96g.js.map +0 -9
- package/dist/chunks/sp-overlay.dev-earn5as8.js +0 -17
- package/dist/chunks/sp-overlay.dev-earn5as8.js.map +0 -9
- package/dist/chunks/sp-popover.dev-frxtfejn.js +0 -8
- package/dist/chunks/sp-popover.dev-frxtfejn.js.map +0 -9
- package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js +0 -14
- package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js.map +0 -9
- package/dist/chunks/sp-tooltip.dev-a9fywesm.js +0 -21
- package/dist/chunks/sp-tooltip.dev-a9fywesm.js.map +0 -10
- package/dist/chunks/studio-0xgs7xrs.js +0 -429
- package/dist/chunks/studio-0xgs7xrs.js.map +0 -13
- package/dist/chunks/studio-4d8hzcz5.js +0 -3427
- package/dist/chunks/studio-4d8hzcz5.js.map +0 -26
- package/dist/chunks/studio-4k0m4fmp.js.map +0 -13
- package/dist/chunks/studio-9smfzqhc.js +0 -19
- package/dist/chunks/studio-9smfzqhc.js.map +0 -10
- package/dist/chunks/studio-jm0z592w.js.map +0 -115
- package/dist/chunks/studio-m2t4mdg5.js +0 -1854
- package/dist/chunks/studio-m2t4mdg5.js.map +0 -18
- package/dist/chunks/studio-n4p11dfy.js +0 -853
- package/dist/chunks/studio-n4p11dfy.js.map +0 -12
- package/dist/chunks/studio-qkvt37c0.js.map +0 -9
- package/dist/chunks/studio-sjh1wt1a.js +0 -3744
- package/dist/chunks/studio-sjh1wt1a.js.map +0 -22
- package/dist/chunks/studio-v7jkjqng.js.map +0 -46
- package/dist/chunks/studio-xq0t7n5t.js +0 -140
- package/dist/chunks/studio-xq0t7n5t.js.map +0 -10
- package/dist/chunks/studio-xze4zrs1.js.map +0 -10
- package/dist/chunks/studio-yfcstdzy.js +0 -399
- package/dist/chunks/studio-yfcstdzy.js.map +0 -12
- package/dist/chunks/studio-zp7jncs8.js +0 -469
- package/dist/chunks/studio-zp7jncs8.js.map +0 -14
- package/dist/chunks/ws-client-q7hkz3j9.js.map +0 -11
- package/src/browse/browse-modal.ts +0 -69
- package/src/browse/browse.ts +0 -904
- package/src/canvas/canvas-diff.ts +0 -216
- package/src/canvas/nested-site-style.ts +0 -58
- package/src/new-project/design-fields.ts +0 -264
- package/src/new-project/templates.ts +0 -61
- package/src/panels/tab-bar.ts +0 -548
- package/src/settings/settings-modal.ts +0 -203
- /package/dist/chunks/{adapter-z8fkmbmv.js.map → adapter-qfnesn5v.js.map} +0 -0
- /package/dist/chunks/{cf-settings-mmyv436d.js.map → cf-settings-73sh5z4p.js.map} +0 -0
- /package/dist/chunks/{format-host-8jxwts2p.js.map → format-host-mkwsmt8d.js.map} +0 -0
- /package/dist/chunks/{jx-validate-qx8degj7.js.map → jx-validate-v6qgknrs.js.map} +0 -0
- /package/dist/chunks/{studio-w7c6kvk7.js.map → studio-a7pzveq1.js.map} +0 -0
- /package/dist/chunks/{studio-esrewxap.js.map → studio-s519zc99.js.map} +0 -0
- /package/dist/chunks/{studio-bghqfmea.js.map → studio-wwseqyw0.js.map} +0 -0
- /package/dist/chunks/{studio-nqw433kv.js.map → studio-xmvbhg31.js.map} +0 -0
|
@@ -11,6 +11,7 @@ import { postMessageChannel } from "./iframe-channel";
|
|
|
11
11
|
import { canvasBaseOrigin } from "./canvas-origin";
|
|
12
12
|
import { getPreviewNavigateHandler } from "./preview-navigate";
|
|
13
13
|
import { resolveCanvasDocument } from "./canvas-live-render";
|
|
14
|
+
import { canvasPerf, recordEscalation, SPAN_PREPARE_RENDER, timeSpanAsync } from "./canvas-perf";
|
|
14
15
|
import {
|
|
15
16
|
applyBlockMerge,
|
|
16
17
|
applyInlineCommit,
|
|
@@ -26,20 +27,23 @@ import { panToParentRect } from "./canvas-utils";
|
|
|
26
27
|
import { clearDragGhost, moveDragGhost } from "../panels/drag-ghost";
|
|
27
28
|
import { applyDropInstruction } from "../panels/dnd";
|
|
28
29
|
import { rectOf } from "../utils/geometry";
|
|
30
|
+
import { notify } from "../services/notify";
|
|
29
31
|
import { effect, effectScope } from "../reactivity";
|
|
32
|
+
import { pathsEqual, projectState, renderOnly } from "../store";
|
|
33
|
+
import { activeTab, focusPane, workspace } from "../workspace/workspace";
|
|
30
34
|
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
} from "../
|
|
39
|
-
import { activeTab, workspace } from "../workspace/workspace";
|
|
35
|
+
paneOfContainer,
|
|
36
|
+
panelHostingCanvas,
|
|
37
|
+
stageContaining,
|
|
38
|
+
tabOfContainer,
|
|
39
|
+
} from "./canvas-surface";
|
|
40
|
+
import { cloneSelection, primarySelection, toggleSelected } from "../tabs/selection";
|
|
41
|
+
import type { JxPath } from "../state";
|
|
42
|
+
import { setLayoutSelection, shell } from "../shell";
|
|
40
43
|
import { formatEditableVerdicts } from "../format/constraints";
|
|
41
44
|
import { formatByName } from "../format/format-host";
|
|
42
45
|
import { collabState } from "../collab/collab-state";
|
|
46
|
+
import { localeDirection } from "@jxsuite/schema/locale";
|
|
43
47
|
import { getPlatform, hasPlatform } from "../platform";
|
|
44
48
|
import type {
|
|
45
49
|
ApplyFormatIntent,
|
|
@@ -53,7 +57,9 @@ import type {
|
|
|
53
57
|
NodeHit,
|
|
54
58
|
ParentToIframe,
|
|
55
59
|
SelectionSnapshot,
|
|
60
|
+
SerializableRect,
|
|
56
61
|
SerializedKey,
|
|
62
|
+
SyncedChord,
|
|
57
63
|
WireDocOp,
|
|
58
64
|
} from "./iframe-protocol";
|
|
59
65
|
import type { IframeChannel } from "./iframe-channel";
|
|
@@ -79,14 +85,40 @@ interface HostState {
|
|
|
79
85
|
ready: boolean;
|
|
80
86
|
pending: ParentToIframe | null;
|
|
81
87
|
overlay: OverlayLayer;
|
|
82
|
-
/**
|
|
88
|
+
/** Primary selected path (mirrors `session.selection`'s last entry), for hover de-dupe. */
|
|
83
89
|
selectionPath: (string | number)[] | null;
|
|
90
|
+
/** The whole selection this host last measured, so a re-measure redraws every box. */
|
|
91
|
+
selectionPaths: JxPath[];
|
|
84
92
|
/** Id of the most recent selection `measure` request, so stale `geometry` replies are dropped. */
|
|
85
93
|
selReqId: number;
|
|
94
|
+
/**
|
|
95
|
+
* The NON-primary selected paths the in-flight selection `measure` covers, serialized. The
|
|
96
|
+
* geometry reply draws a co-selection box for every hit whose path is in this set — a set of one
|
|
97
|
+
* selected node leaves it empty, so nothing but the selection box is ever drawn.
|
|
98
|
+
*/
|
|
99
|
+
coSelectionKeys: Set<string>;
|
|
86
100
|
/** Id of the most recent presence `measure` request (allocated from the selReqId counter). */
|
|
87
101
|
presenceReqId: number;
|
|
88
102
|
/** Serialized peer path → presence box meta for the in-flight presence measure. */
|
|
89
103
|
presenceMeta: Map<string, { color: string; label: string }>;
|
|
104
|
+
/**
|
|
105
|
+
* Whether this host's iframe currently renders a PREVIEW. Preview is the fidelity view, so the
|
|
106
|
+
* host refuses every editing message from it ({@link PREVIEW_BLOCKED}), suppresses the overlay
|
|
107
|
+
* layer, and leaves the iframe at its CSS height so the frame scrolls its own document.
|
|
108
|
+
*
|
|
109
|
+
* Never assign this directly — go through {@link setHostPreview}, which applies the frame sizing
|
|
110
|
+
* the flag implies in the SAME state transition. See {@link applyFrameSizing}.
|
|
111
|
+
*/
|
|
112
|
+
preview: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* The last content height this host's iframe measured and posted, or null before the first
|
|
115
|
+
* measurement. Retained (rather than consumed and dropped) because frame sizing is a function of
|
|
116
|
+
* `preview` too, and the iframe DEDUPES `contentHeight` on an unchanged measurement — so leaving
|
|
117
|
+
* preview cannot rely on a fresh message arriving to restore the content-sized frame.
|
|
118
|
+
*/
|
|
119
|
+
contentHeight: number | null;
|
|
120
|
+
/** Whether the last measured content was a component-definition fragment (drops the 480px floor). */
|
|
121
|
+
contentFragment: boolean;
|
|
90
122
|
/** Whether an inline-edit session is live in this host's iframe (drives the format toolbar). */
|
|
91
123
|
editing: boolean;
|
|
92
124
|
/** The prop a live plain session edits (prop-bound text) — null for rich sessions/none. */
|
|
@@ -137,6 +169,28 @@ interface HostState {
|
|
|
137
169
|
tabId: string | null;
|
|
138
170
|
/** Tab id keyed by render gen for posted-but-unacked renders; adopted on `renderComplete`. */
|
|
139
171
|
pendingTabIds: Map<number, string | null>;
|
|
172
|
+
/**
|
|
173
|
+
* Surgical patches posted to this iframe that have not acked (`patchComplete`/`patchError`).
|
|
174
|
+
*
|
|
175
|
+
* `pendingTabIds` covers full renders because it has to (identity adoption depends on it); a
|
|
176
|
+
* patch carries no identity, so nothing counted it. Both are "the DOM in that frame does not yet
|
|
177
|
+
* reflect what this host has told it", which is the question {@link canvasIdleBlockers} answers.
|
|
178
|
+
*/
|
|
179
|
+
pendingPatches: number;
|
|
180
|
+
/**
|
|
181
|
+
* The frame's own last quiescence report (`{kind: "idle"}`), or null before the first one.
|
|
182
|
+
*
|
|
183
|
+
* Held PER HOST from the start. `shot.ts`'s "Studio's only child frame" is a coin flip the moment
|
|
184
|
+
* P8 adds a second pane, and a global would have to be unpicked exactly then.
|
|
185
|
+
*/
|
|
186
|
+
idle: Omit<Extract<IframeToParent, { kind: "idle" }>, "kind"> | null;
|
|
187
|
+
/**
|
|
188
|
+
* Resolvers for {@link measureCanvasPath} requests, keyed by the `measure` reqId.
|
|
189
|
+
*
|
|
190
|
+
* Allocated from the same `selReqId` counter as the selection and presence measures, so the three
|
|
191
|
+
* can never collide and the `geometry` handler can dispatch on the id alone.
|
|
192
|
+
*/
|
|
193
|
+
pendingMeasures: Map<number, (hits: NodeHit[]) => void>;
|
|
140
194
|
/**
|
|
141
195
|
* A split/insert re-entry deferred until this host's DOM contains the new element: a surgical
|
|
142
196
|
* patch acks (`patchComplete`) at the SAME gen the host already reflects, an escalated full
|
|
@@ -217,15 +271,24 @@ export function sawIframeDragOver(seq: number): boolean {
|
|
|
217
271
|
let evalReqId = 0;
|
|
218
272
|
|
|
219
273
|
/**
|
|
220
|
-
*
|
|
274
|
+
* The panes whose NEXT page render may let automatic `$prototype: "Request"` entries fetch, even in
|
|
221
275
|
* edit/design mode.
|
|
222
276
|
*
|
|
223
277
|
* Those fetches are suppressed outside preview because a full render re-resolves every state entry,
|
|
224
278
|
* so an escalating authoring action would issue a request per render. But the Data activity's
|
|
225
|
-
* Refresh exists to re-fire them on demand — its documented purpose — so it arms
|
|
226
|
-
* next render consumes it. Deliberately one-shot: a subsequent escalation must not inherit
|
|
279
|
+
* Refresh exists to re-fire them on demand — its documented purpose — so it arms a pane and that
|
|
280
|
+
* pane's next render consumes it. Deliberately one-shot: a subsequent escalation must not inherit
|
|
281
|
+
* it.
|
|
282
|
+
*
|
|
283
|
+
* **A `boolean`, then a per-PASS boolean, now a set of panes** — the same fact narrowing each time
|
|
284
|
+
* something else turned out to be able to claim it. Per-host was wrong because one Refresh mounts N
|
|
285
|
+
* artboards and the first swallowed the arm. Per-pass was wrong for the same reason one pane
|
|
286
|
+
* further out: two panes are two passes, both scheduled through rAF and both awaiting inside their
|
|
287
|
+
* mount loop, so whichever reached `preparePassRender` first took an arm the OTHER pane's Refresh
|
|
288
|
+
* had set — the button refreshed the pane nobody had pressed it in, and left its own pane
|
|
289
|
+
* suppressed.
|
|
227
290
|
*/
|
|
228
|
-
|
|
291
|
+
const _autoRequestPanes = new Set<string>();
|
|
229
292
|
|
|
230
293
|
/**
|
|
231
294
|
* Open a preview link's target for real.
|
|
@@ -259,20 +322,35 @@ function openPreviewHref(href: string, state: HostState): void {
|
|
|
259
322
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
260
323
|
}
|
|
261
324
|
|
|
262
|
-
/**
|
|
263
|
-
|
|
264
|
-
|
|
325
|
+
/**
|
|
326
|
+
* Arm one pane's next page render to allow automatic request fetches (Data activity Refresh).
|
|
327
|
+
*
|
|
328
|
+
* @param {string} paneId The pane the Refresh was pressed for — read ONCE by the caller, so the arm
|
|
329
|
+
* and the `renderCanvas` that follows it cannot end up naming different panes.
|
|
330
|
+
*/
|
|
331
|
+
export function allowAutoRequestsOnNextRender(paneId: string): void {
|
|
332
|
+
_autoRequestPanes.add(paneId);
|
|
265
333
|
}
|
|
266
334
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return armed;
|
|
335
|
+
/** Take `paneId`'s arm, if it has one. */
|
|
336
|
+
function consumeAllowAutoRequests(paneId: string): boolean {
|
|
337
|
+
return _autoRequestPanes.delete(paneId);
|
|
271
338
|
}
|
|
272
339
|
|
|
273
340
|
/** Pending eval resolvers keyed by reqId; a timeout or stale reply resolves null. */
|
|
274
341
|
const pendingEvals = new Map<number, (results: EvalExprResult[] | null) => void>();
|
|
275
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Which host each in-flight request belongs to.
|
|
345
|
+
*
|
|
346
|
+
* `pendingEvals` and `pendingFlushes` are keyed by reqId alone, which was enough while nothing ever
|
|
347
|
+
* released a host: an unanswered request simply timed out. A pane that goes away has to settle its
|
|
348
|
+
* OWN requests and nobody else's, and the reqId does not say whose they are.
|
|
349
|
+
*/
|
|
350
|
+
const evalOwners = new Map<number, HostState>();
|
|
351
|
+
|
|
352
|
+
const flushOwners = new Map<number, HostState>();
|
|
353
|
+
|
|
276
354
|
/** How long (ms) the parent waits for an `evalResult` before falling back to the snapshot. */
|
|
277
355
|
export const EVAL_TIMEOUT_MS = 300;
|
|
278
356
|
|
|
@@ -319,10 +397,13 @@ export function requestCanvasEval(
|
|
|
319
397
|
return new Promise((resolve) => {
|
|
320
398
|
const timer = setTimeout(() => {
|
|
321
399
|
pendingEvals.delete(reqId);
|
|
400
|
+
evalOwners.delete(reqId);
|
|
322
401
|
resolve(null);
|
|
323
402
|
}, timeoutMs);
|
|
403
|
+
evalOwners.set(reqId, target);
|
|
324
404
|
pendingEvals.set(reqId, (results) => {
|
|
325
405
|
clearTimeout(timer);
|
|
406
|
+
evalOwners.delete(reqId);
|
|
326
407
|
resolve(results);
|
|
327
408
|
});
|
|
328
409
|
});
|
|
@@ -368,6 +449,7 @@ export function flushCanvasEdits(
|
|
|
368
449
|
const timer = setTimeout(() => {
|
|
369
450
|
for (const id of ids) {
|
|
370
451
|
pendingFlushes.delete(id);
|
|
452
|
+
flushOwners.delete(id);
|
|
371
453
|
}
|
|
372
454
|
resolve();
|
|
373
455
|
}, timeoutMs);
|
|
@@ -384,6 +466,7 @@ export function flushCanvasEdits(
|
|
|
384
466
|
const reqId = flushReqId;
|
|
385
467
|
ids.push(reqId);
|
|
386
468
|
pendingFlushes.set(reqId, settle);
|
|
469
|
+
flushOwners.set(reqId, host);
|
|
387
470
|
host.channel.post({ kind: "flushEdits", reqId });
|
|
388
471
|
}
|
|
389
472
|
});
|
|
@@ -476,6 +559,232 @@ export function hostDragGeometry(host: HostState): {
|
|
|
476
559
|
return { rect, scale };
|
|
477
560
|
}
|
|
478
561
|
|
|
562
|
+
// ─── Quiescence and point resolution ────────────────────────────────────────────
|
|
563
|
+
// Two questions the parent realm could not previously answer about a cross-origin canvas: "has it
|
|
564
|
+
// Settled?" and "where on screen is this node?". Both were answered by the CALLER instead — a sleep
|
|
565
|
+
// And a `Math.abs(scale - 1) < 0.001` guess at whether a fit transform was in play. Both are the
|
|
566
|
+
// Host's own arithmetic; it already does them for the selection overlay and the block action bar.
|
|
567
|
+
|
|
568
|
+
/** A short, stable handle for a host inside a blocker string. */
|
|
569
|
+
function hostLabel(host: HostState): string {
|
|
570
|
+
if (host.stylebook) {
|
|
571
|
+
return "stylebook";
|
|
572
|
+
}
|
|
573
|
+
return host.tabId ?? "unbound";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Everything the canvas is still owed, one human-readable line per outstanding item, PER HOST.
|
|
578
|
+
*
|
|
579
|
+
* Empty means every live frame's DOM reflects what this host told it, its fonts have loaded, no
|
|
580
|
+
* animation is running and no image retry is in flight. Naming the blockers is the whole point:
|
|
581
|
+
* `probe.idle()` rejects with this list, so a slow subsystem identifies itself instead of being
|
|
582
|
+
* answered with `+500 ms` and a wrong capture (plan §13.4).
|
|
583
|
+
*/
|
|
584
|
+
export function canvasIdleBlockers(): string[] {
|
|
585
|
+
const blockers: string[] = [];
|
|
586
|
+
for (const host of liveHosts) {
|
|
587
|
+
if (!host.iframe.isConnected) {
|
|
588
|
+
liveHosts.delete(host);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const at = `canvas[${hostLabel(host)}]`;
|
|
592
|
+
if (!host.ready) {
|
|
593
|
+
blockers.push(`${at}: frame has not handshaked`);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const unacked = [...host.pendingTabIds.keys()];
|
|
597
|
+
if (unacked.length > 0) {
|
|
598
|
+
blockers.push(`${at}: gen ${unacked.join(", ")} unacked`);
|
|
599
|
+
}
|
|
600
|
+
if (host.pendingPatches > 0) {
|
|
601
|
+
blockers.push(`${at}: ${host.pendingPatches} unacked patch(es)`);
|
|
602
|
+
}
|
|
603
|
+
if (host.pendingMeasures.size > 0) {
|
|
604
|
+
blockers.push(`${at}: ${host.pendingMeasures.size} measure(s) in flight`);
|
|
605
|
+
}
|
|
606
|
+
const { idle } = host;
|
|
607
|
+
if (!idle) {
|
|
608
|
+
blockers.push(`${at}: no quiescence report yet`);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (idle.gen !== host.lastRenderedGen) {
|
|
612
|
+
blockers.push(`${at}: quiescence is for gen ${idle.gen}, DOM is gen ${host.lastRenderedGen}`);
|
|
613
|
+
}
|
|
614
|
+
if (!idle.fonts) {
|
|
615
|
+
blockers.push(`${at}: fonts still loading`);
|
|
616
|
+
}
|
|
617
|
+
if (idle.animations > 0) {
|
|
618
|
+
blockers.push(`${at}: ${idle.animations} running animation(s)`);
|
|
619
|
+
}
|
|
620
|
+
if (idle.images > 0) {
|
|
621
|
+
blockers.push(`${at}: ${idle.images} pending image retry(ies)`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (pendingEvals.size > 0) {
|
|
625
|
+
blockers.push(`canvas: ${pendingEvals.size} expression eval(s) in flight`);
|
|
626
|
+
}
|
|
627
|
+
return blockers;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* A node's box in TOP-DOCUMENT coordinates, with `x`/`y` at its centre.
|
|
632
|
+
*
|
|
633
|
+
* The caller gets a point it can act on directly — click it, scroll to it, anchor a popover to it —
|
|
634
|
+
* without knowing that a canvas iframe, a panzoom transform and an edit-zoom scale sit between the
|
|
635
|
+
* document and the screen. P4.6's find-references jump, P8.4's jump bar and collab follow-peer all
|
|
636
|
+
* want exactly this, and so does anything driving Studio from outside.
|
|
637
|
+
*/
|
|
638
|
+
export interface CanvasPoint {
|
|
639
|
+
x: number;
|
|
640
|
+
y: number;
|
|
641
|
+
left: number;
|
|
642
|
+
top: number;
|
|
643
|
+
width: number;
|
|
644
|
+
height: number;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** How long a point request waits for its `geometry` reply before answering null. */
|
|
648
|
+
export const MEASURE_TIMEOUT_MS = 500;
|
|
649
|
+
|
|
650
|
+
/** Frames of an unchanged iframe offset that end a reveal. */
|
|
651
|
+
const PAN_SETTLE_FRAMES = 2;
|
|
652
|
+
|
|
653
|
+
/** Give up on a reveal that never settles rather than await it forever. */
|
|
654
|
+
const PAN_SETTLE_MAX_FRAMES = 40;
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* The live host a document path is addressed in: the one rendering the focused tab, else any ready
|
|
658
|
+
* page host. A stylebook host is never it — its paths decode to TAGS, not document paths.
|
|
659
|
+
*/
|
|
660
|
+
function hostForPath(): HostState | null {
|
|
661
|
+
const tabId = activeTab.value?.id ?? null;
|
|
662
|
+
let fallback: HostState | null = null;
|
|
663
|
+
for (const host of liveHosts) {
|
|
664
|
+
if (!host.iframe.isConnected) {
|
|
665
|
+
liveHosts.delete(host);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (!host.ready || host.stylebook) {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (host.tabId === tabId) {
|
|
672
|
+
return host;
|
|
673
|
+
}
|
|
674
|
+
fallback ??= host;
|
|
675
|
+
}
|
|
676
|
+
return fallback;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Compose the host's own transforms onto an iframe-viewport rect.
|
|
681
|
+
*
|
|
682
|
+
* `scale` is the EMPIRICAL ratio {@link hostDragGeometry} reads fresh from the DOM, so it covers the
|
|
683
|
+
* design-mode panzoom transform and edit-mode content zoom together — which is precisely the
|
|
684
|
+
* distinction a caller cannot make from outside and used to guess at.
|
|
685
|
+
*/
|
|
686
|
+
function pointForRect(host: HostState, rect: SerializableRect): CanvasPoint {
|
|
687
|
+
const { rect: frame, scale } = hostDragGeometry(host);
|
|
688
|
+
const left = frame.left + rect.x * scale;
|
|
689
|
+
const top = frame.top + rect.y * scale;
|
|
690
|
+
const width = rect.width * scale;
|
|
691
|
+
const height = rect.height * scale;
|
|
692
|
+
return { height, left, top, width, x: left + width / 2, y: top + height / 2 };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Ask one host to measure one path and resolve the reply as a top-document point. */
|
|
696
|
+
function measureIn(
|
|
697
|
+
host: HostState,
|
|
698
|
+
path: readonly (string | number)[],
|
|
699
|
+
): Promise<CanvasPoint | null> {
|
|
700
|
+
return new Promise((resolve) => {
|
|
701
|
+
host.selReqId += 1;
|
|
702
|
+
const reqId = host.selReqId;
|
|
703
|
+
const timer = setTimeout(() => {
|
|
704
|
+
host.pendingMeasures.delete(reqId);
|
|
705
|
+
resolve(null);
|
|
706
|
+
}, MEASURE_TIMEOUT_MS);
|
|
707
|
+
host.pendingMeasures.set(reqId, (hits) => {
|
|
708
|
+
clearTimeout(timer);
|
|
709
|
+
const [hit] = hits;
|
|
710
|
+
resolve(hit ? pointForRect(host, hit.rect) : null);
|
|
711
|
+
});
|
|
712
|
+
// A plain copy: `session.selection` is a reactive proxy and only serializable values cross.
|
|
713
|
+
host.channel.post({ kind: "measure", paths: [[...path]], reqId });
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Where the node at `path` is on screen right now, or null when no canvas can answer.
|
|
719
|
+
*
|
|
720
|
+
* Read-only: it measures, it does not move anything. {@link revealCanvasPath} is the half that does.
|
|
721
|
+
*/
|
|
722
|
+
export function canvasPointAt(path: readonly (string | number)[]): Promise<CanvasPoint | null> {
|
|
723
|
+
const host = hostForPath();
|
|
724
|
+
return host ? measureIn(host, path) : Promise.resolve(null);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Resolve once the iframe's offset has stopped moving.
|
|
729
|
+
*
|
|
730
|
+
* The iframe's own top is the right thing to watch precisely because it does not care HOW the pane
|
|
731
|
+
* moved: a Design pan is a 250ms rAF tween of `view.panY`, an Edit reveal is one synchronous
|
|
732
|
+
* `scrollTop` write, and both show up here as the frame's on-screen offset changing and then
|
|
733
|
+
* holding still.
|
|
734
|
+
*/
|
|
735
|
+
function panSettled(host: HostState): Promise<void> {
|
|
736
|
+
return new Promise((resolve) => {
|
|
737
|
+
let last = Number.NaN;
|
|
738
|
+
let stable = 0;
|
|
739
|
+
let frames = 0;
|
|
740
|
+
const tick = () => {
|
|
741
|
+
const { top } = rectOf(host.iframe);
|
|
742
|
+
stable = top === last ? stable + 1 : 0;
|
|
743
|
+
last = top;
|
|
744
|
+
frames += 1;
|
|
745
|
+
if (stable >= PAN_SETTLE_FRAMES || frames >= PAN_SETTLE_MAX_FRAMES) {
|
|
746
|
+
resolve();
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
requestAnimationFrame(tick);
|
|
750
|
+
};
|
|
751
|
+
requestAnimationFrame(tick);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Bring the node at `path` into view and answer where it landed.
|
|
757
|
+
*
|
|
758
|
+
* The move is the same {@link panToParentRect} the stylebook's pan-to-card uses, and the second
|
|
759
|
+
* measure is not belt-and-braces: the point BEFORE the move is not the point a caller can act on.
|
|
760
|
+
*
|
|
761
|
+
* `panToParentRect` is what makes this work on BOTH surfaces — it pans a panzoom stage and scrolls
|
|
762
|
+
* a scrolling one. While it only panned, this function was a measured no-op in Edit mode: it
|
|
763
|
+
* returned the node's original, off-screen point, and `runInput`'s caret step then clicked a point
|
|
764
|
+
* outside the pane and selected nothing.
|
|
765
|
+
*/
|
|
766
|
+
export async function revealCanvasPath(
|
|
767
|
+
path: readonly (string | number)[],
|
|
768
|
+
): Promise<CanvasPoint | null> {
|
|
769
|
+
const host = hostForPath();
|
|
770
|
+
if (!host) {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
const before = await measureIn(host, path);
|
|
774
|
+
if (!before) {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
/* Pan the stage the MEASUREMENT came from. `hostForPath` prefers the host rendering the focused
|
|
778
|
+
tab but falls back to any ready page host, and the pan defaulted to `activeCanvasSurface()` —
|
|
779
|
+
so on the fallback the reveal measured in one pane and scrolled the other, then re-measured a
|
|
780
|
+
node that had not moved. `undefined` keeps the default for a host no pane claims (a detached
|
|
781
|
+
artboard in a test). */
|
|
782
|
+
const surface = panelHostingCanvas(host.canvasEl)?.surface;
|
|
783
|
+
panToParentRect({ height: before.height, top: before.top }, surface);
|
|
784
|
+
await panSettled(host);
|
|
785
|
+
return measureIn(host, path);
|
|
786
|
+
}
|
|
787
|
+
|
|
479
788
|
/**
|
|
480
789
|
* Pin the `.canvas-panel-viewport` (the white "page" surface, the canvas element's parent) to the
|
|
481
790
|
* SCALED content height when edit-mode content zoom is active. A `transform: scale()` on the canvas
|
|
@@ -495,6 +804,69 @@ function syncEditZoomViewportHeight(state: HostState): void {
|
|
|
495
804
|
viewport.style.height = scale === 1 ? "" : `${state.iframe.offsetHeight * scale}px`;
|
|
496
805
|
}
|
|
497
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Write the iframe's box from the host's CURRENT state — the render mode and the last content
|
|
809
|
+
* measurement — and nothing else. Frame sizing is derived, never incremental, because the two
|
|
810
|
+
* inputs arrive on different clocks: `preview` flips synchronously inside a mount, the measurement
|
|
811
|
+
* arrives asynchronously over the channel, and the iframe DEDUPES a repeated measurement (so "the
|
|
812
|
+
* next `contentHeight` will fix it" is false). Deriving both branches from one function called at
|
|
813
|
+
* every transition of either input is what makes the two impossible to disagree.
|
|
814
|
+
*
|
|
815
|
+
* Preview keeps the iframe a REAL VIEWPORT: it stays at the CSS height the preview stage gives it
|
|
816
|
+
* and scrolls its own document, so `position:sticky`, scroll-driven animation and
|
|
817
|
+
* IntersectionObserver reveals fire exactly as they will in production. Growing the frame to its
|
|
818
|
+
* content height — what every editing mode needs, so the parent overlay can reach every node — is
|
|
819
|
+
* what stopped all three from ever firing in the one view whose job is fidelity.
|
|
820
|
+
*
|
|
821
|
+
* The cssText's 480px `min-height` is a pre-measurement floor so an empty/short PAGE stays a usable
|
|
822
|
+
* canvas; a component DEFINITION (fragment) instead hugs its content, so drop the floor for it once
|
|
823
|
+
* measured (else a short component leaves dead space below — pages keep the floor and stay tall via
|
|
824
|
+
* `#jx-canvas-root`).
|
|
825
|
+
*/
|
|
826
|
+
function applyFrameSizing(state: HostState): void {
|
|
827
|
+
if (state.preview) {
|
|
828
|
+
state.iframe.style.height = "100%";
|
|
829
|
+
state.iframe.style.minHeight = "0px";
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (state.contentHeight === null) {
|
|
833
|
+
// Nothing measured yet — the cssText defaults (height:100%; min-height:480px) stand.
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
state.iframe.style.height = `${state.contentHeight}px`;
|
|
837
|
+
state.iframe.style.minHeight = state.contentFragment ? "0px" : "480px";
|
|
838
|
+
syncEditZoomViewportHeight(state);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Adopt a render mode's preview-ness as ONE state transition: the flag, the overlay suppression,
|
|
843
|
+
* the dropped pending edit and the frame box all move together. Mounts must call this rather than
|
|
844
|
+
* assigning `state.preview` — a mount resolves its document asynchronously, so between the mode
|
|
845
|
+
* changing and the flag landing the host would otherwise answer a `contentHeight` with the previous
|
|
846
|
+
* mode's sizing rule and never get a second chance to correct it.
|
|
847
|
+
*/
|
|
848
|
+
function setHostPreview(state: HostState, preview: boolean): void {
|
|
849
|
+
state.preview = preview;
|
|
850
|
+
state.overlay.setSuppressed(preview);
|
|
851
|
+
if (preview) {
|
|
852
|
+
state.pendingEnterEdit = null;
|
|
853
|
+
}
|
|
854
|
+
applyFrameSizing(state);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Declare, SYNCHRONOUSLY, which kind of render `canvasEl`'s host is about to receive.
|
|
859
|
+
*
|
|
860
|
+
* {@link mountIframeCanvas} resolves its document asynchronously, so the mode it will post is not
|
|
861
|
+
* known to the host until an await has passed. The renderer knows it before the await — it is the
|
|
862
|
+
* mode it just built the surface for — so it says so here, and the host's frame box moves with the
|
|
863
|
+
* flag rather than trailing it. Without this the `contentHeight` the iframe posts during the
|
|
864
|
+
* resolve is answered under the OUTGOING mode's sizing rule.
|
|
865
|
+
*/
|
|
866
|
+
export function adoptCanvasPreviewMode(canvasEl: HTMLElement, preview: boolean): void {
|
|
867
|
+
setHostPreview(ensureHost(canvasEl), preview);
|
|
868
|
+
}
|
|
869
|
+
|
|
498
870
|
/** Post a parent→iframe drag message to `host`'s channel (the coordinator builds it purely). */
|
|
499
871
|
export function postDragMessage(host: HostState, msg: ParentToIframe): void {
|
|
500
872
|
if (!host.iframe.isConnected) {
|
|
@@ -642,6 +1014,8 @@ function registerCanvasGutterDrop(canvasEl: HTMLElement, host: () => HostState |
|
|
|
642
1014
|
export interface CanvasSlashRequest {
|
|
643
1015
|
rect: { left: number; top: number; bottom: number; width: number; height: number };
|
|
644
1016
|
filter: string;
|
|
1017
|
+
/** Whether the menu draws its own filter field — see the `slashShow` message for why. */
|
|
1018
|
+
showFilter?: boolean;
|
|
645
1019
|
onSelect: (cmd: SlashCommand) => void;
|
|
646
1020
|
onDismiss: () => void;
|
|
647
1021
|
}
|
|
@@ -724,6 +1098,22 @@ function resolveStylebookTag(
|
|
|
724
1098
|
*/
|
|
725
1099
|
let activeEditHost: HostState | null = null;
|
|
726
1100
|
|
|
1101
|
+
/**
|
|
1102
|
+
* Whether a text caret is live in a canvas iframe — the parent realm's only honest answer to "is
|
|
1103
|
+
* the author typing right now".
|
|
1104
|
+
*
|
|
1105
|
+
* The editing session runs INSIDE the cross-origin canvas frame, so `editor/inline-edit.ts`'s
|
|
1106
|
+
* `isEditing()` — a module-local `activeEl` in the PARENT bundle — is permanently false here. The
|
|
1107
|
+
* bridge already carries the truth: `editStart` opens a session, `selectionChanged` proves the
|
|
1108
|
+
* caret is still live in it, `editEnd` closes it. Derived rather than stored per host, so a frame
|
|
1109
|
+
* torn down mid-session (a mode switch, a closed tab) can never latch the flag on and go on
|
|
1110
|
+
* stealing ⌘C from a caret that no longer exists.
|
|
1111
|
+
*/
|
|
1112
|
+
export function isCaretActive(): boolean {
|
|
1113
|
+
const host = activeEditHost;
|
|
1114
|
+
return Boolean(host?.editing && host.iframe.isConnected);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
727
1117
|
/** Injected toolbar re-render (set by studio.ts → renderBlockActionBar); avoids a panel→host cycle. */
|
|
728
1118
|
let toolbarRefresh: (() => void) | null = null;
|
|
729
1119
|
|
|
@@ -732,13 +1122,34 @@ export function setToolbarRefresh(fn: () => void): void {
|
|
|
732
1122
|
toolbarRefresh = fn;
|
|
733
1123
|
}
|
|
734
1124
|
|
|
1125
|
+
/** Injected "a pointer went down in a canvas" signal (studio.ts → releaseBlockActionBar). */
|
|
1126
|
+
let canvasPointerDownHandler: (() => void) | null = null;
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Register what runs when a canvas frame reports a pointerdown. Pass `null` to unregister.
|
|
1130
|
+
*
|
|
1131
|
+
* Injected rather than imported for the reason {@link setToolbarRefresh} gives — this module
|
|
1132
|
+
* deliberately knows nothing about panels — and it exists as its own seam because
|
|
1133
|
+
* {@link focusHostPane} cannot serve as one: `focusPane` returns early when the pane already has
|
|
1134
|
+
* focus, which is the ordinary case (clicking around inside the pane you are already in) and
|
|
1135
|
+
* precisely the case the one subscriber, the block action bar's suppression release, is for.
|
|
1136
|
+
*/
|
|
1137
|
+
export function setCanvasPointerDownHandler(fn: (() => void) | null): void {
|
|
1138
|
+
canvasPointerDownHandler = fn;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
735
1141
|
let selectionWatch: { stop: () => void } | null = null;
|
|
736
1142
|
|
|
737
1143
|
/** Full-render escalation, injected by studio init (a patchError can't apply surgically). */
|
|
738
|
-
let patchEscalation: (() => void) | null = null;
|
|
1144
|
+
let patchEscalation: ((paneId: string) => void) | null = null;
|
|
739
1145
|
|
|
740
|
-
/**
|
|
741
|
-
|
|
1146
|
+
/**
|
|
1147
|
+
* Register the full-render fallback the host invokes when the iframe reports a `patchError`.
|
|
1148
|
+
*
|
|
1149
|
+
* Takes the PANE whose frame reported it: only that stage's DOM has fallen behind its document, and
|
|
1150
|
+
* re-rendering the other pane would reload iframes that applied their patch perfectly well.
|
|
1151
|
+
*/
|
|
1152
|
+
export function setIframePatchEscalation(fn: (paneId: string) => void): void {
|
|
742
1153
|
patchEscalation = fn;
|
|
743
1154
|
}
|
|
744
1155
|
|
|
@@ -748,12 +1159,20 @@ export function setIframePatchEscalation(fn: () => void): void {
|
|
|
748
1159
|
* edit into its shadow doc. Returns how many hosts received it; the caller escalates to a full
|
|
749
1160
|
* render when that's zero (no host could apply the edit in place, so the suppressed full render
|
|
750
1161
|
* must run after all).
|
|
1162
|
+
*
|
|
1163
|
+
* **There is no `gen` parameter, and there cannot be one.** The generation a frame checks a patch
|
|
1164
|
+
* against belongs to the STAGE that frame is mounted on, and this loop deliberately spans stages:
|
|
1165
|
+
* one document displayed in two panes is two hosts with two independent `renderGeneration`s. A
|
|
1166
|
+
* single number for a multi-pane fan-out is not a parameter needing a better value — it is the bug.
|
|
1167
|
+
* Whichever pane had rendered more recently held the higher `renderedGen`, and `iframe-entry.ts`
|
|
1168
|
+
* dropped the patch there in silence, leaving a wrong picture on screen with `__jxCanvasPerf`
|
|
1169
|
+
* reporting a clean surgical apply. Each host's own generation is resolved inside the loop
|
|
1170
|
+
* instead.
|
|
1171
|
+
*
|
|
1172
|
+
* `host` is deliberately not a pane-scoped parameter name (see `PANE_PARAM_NAMES`), and the body
|
|
1173
|
+
* reads no focus: `panelHostingCanvas` resolves the stage from the host's own element.
|
|
751
1174
|
*/
|
|
752
|
-
export function postPatchToHosts(
|
|
753
|
-
forwardOps: WireDocOp[],
|
|
754
|
-
gen: number,
|
|
755
|
-
tabId: string | null,
|
|
756
|
-
): number {
|
|
1175
|
+
export function postPatchToHosts(forwardOps: WireDocOp[], tabId: string | null): number {
|
|
757
1176
|
let posted = 0;
|
|
758
1177
|
for (const host of liveHosts) {
|
|
759
1178
|
if (!host.iframe.isConnected) {
|
|
@@ -761,6 +1180,23 @@ export function postPatchToHosts(
|
|
|
761
1180
|
continue;
|
|
762
1181
|
}
|
|
763
1182
|
if (host.ready && host.tabId === tabId) {
|
|
1183
|
+
/* NO FALLBACK GENERATION, and `?? 0` was the one bug this whole function exists to end.
|
|
1184
|
+
A host whose stage cannot be resolved — still `ready` and connected while its surface's
|
|
1185
|
+
panel list is between mutations across one of `renderCanvasImpl`'s awaits — was posted
|
|
1186
|
+
gen `0`. `0 < renderedGen` is the frame's `patch-behind-render` branch, whose `patchError`
|
|
1187
|
+
is handled by resolving the surface with THIS SAME failing lookup: `patchEscalation` was
|
|
1188
|
+
never called. Meanwhile `posted` had been incremented, so the caller did not throw, and
|
|
1189
|
+
`markConsumed` had already suppressed the full render. An edit disappeared with no counter
|
|
1190
|
+
moving. A stage we cannot name is a stage we cannot patch: skip it, let `posted` fall, and
|
|
1191
|
+
let the caller's `no-ready-iframe-host` escalate the whole batch. The escalation counter
|
|
1192
|
+
moves HERE too, because "nothing was posted and nothing was wrong" and "nothing was posted
|
|
1193
|
+
because we could not name the stage" are the two answers this must never confuse. */
|
|
1194
|
+
const stage = panelHostingCanvas(host.canvasEl)?.surface;
|
|
1195
|
+
if (!stage) {
|
|
1196
|
+
recordEscalation("host-stage-unresolved");
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
const gen = stage.renderGeneration;
|
|
764
1200
|
// Only the host that originated this edit already has the DOM the patch describes. A
|
|
765
1201
|
// Split-view panel on the same document did NOT type it and must render normally.
|
|
766
1202
|
const echoPaths = echoOrigin?.host === host ? echoOrigin.paths : undefined;
|
|
@@ -769,6 +1205,7 @@ export function postPatchToHosts(
|
|
|
769
1205
|
? { echoPaths, forwardOps, gen, kind: "patch" }
|
|
770
1206
|
: { forwardOps, gen, kind: "patch" },
|
|
771
1207
|
);
|
|
1208
|
+
host.pendingPatches += 1;
|
|
772
1209
|
posted += 1;
|
|
773
1210
|
}
|
|
774
1211
|
}
|
|
@@ -812,7 +1249,70 @@ function hostTab(state: HostState): Tab | null {
|
|
|
812
1249
|
return state.tabId ? (workspace.tabs.get(state.tabId) ?? null) : null;
|
|
813
1250
|
}
|
|
814
1251
|
|
|
815
|
-
/**
|
|
1252
|
+
/**
|
|
1253
|
+
* The tab a drag TARGET is showing — the one public reader on the opaque {@link DragHost} handle.
|
|
1254
|
+
*
|
|
1255
|
+
* `panels/canvas-dnd-bridge.ts` binds a session to the host under the cursor and then had nothing
|
|
1256
|
+
* to ask it, so the one fact it needed about the drag — what the dragged node is CALLED — came from
|
|
1257
|
+
* `activeTab`. The bridge is not supposed to read the handle's fields (that is what "opaque"
|
|
1258
|
+
* means), so the answer is a function rather than a widened type.
|
|
1259
|
+
*/
|
|
1260
|
+
export function dragHostTab(host: DragHost): Tab | null {
|
|
1261
|
+
return hostTab(host);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Put the keyboard in the pane whose artboard was just clicked.
|
|
1266
|
+
*
|
|
1267
|
+
* `panels/pane-grid.ts` moves the pane focus on a `pointerdown` anywhere in a cell, and that
|
|
1268
|
+
* listener cannot see this click: the canvas is a cross-origin `<iframe>`, so a pointer event
|
|
1269
|
+
* inside it is delivered in the frame's own realm and never surfaces in the parent's. The `hit` and
|
|
1270
|
+
* `layoutHit` messages ARE that pointerdown, re-posted across the channel, so this is the seam.
|
|
1271
|
+
*
|
|
1272
|
+
* The pane comes from the artboard the message arrived through — `panelHostingCanvas` is the same
|
|
1273
|
+
* route the breakpoint and the escalation already take — never from the focus, which is the thing
|
|
1274
|
+
* being corrected. `focusPane` is a no-op when the pane already has focus, so the common case
|
|
1275
|
+
* (clicking around in the pane you are already in) costs one map lookup.
|
|
1276
|
+
*/
|
|
1277
|
+
function focusHostPane(state: HostState): void {
|
|
1278
|
+
const paneId = panelHostingCanvas(state.canvasEl)?.surface.paneId;
|
|
1279
|
+
if (paneId) {
|
|
1280
|
+
focusPane(paneId);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/** No selection. A shared frozen empty, so the "this host shows nothing" path allocates nothing. */
|
|
1285
|
+
const NO_SELECTION: readonly JxPath[] = Object.freeze([]);
|
|
1286
|
+
|
|
1287
|
+
/** The tab each pane is showing, by tab id. Reading it inside an effect tracks every pane. */
|
|
1288
|
+
function shownSelections(): Map<string, readonly JxPath[]> {
|
|
1289
|
+
const byTab = new Map<string, readonly JxPath[]>();
|
|
1290
|
+
for (const pane of workspace.panes) {
|
|
1291
|
+
const tab = pane.activeTabId ? workspace.tabs.get(pane.activeTabId) : null;
|
|
1292
|
+
if (!tab) {
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
const sel = (tab.session.selection ?? []) as readonly JxPath[];
|
|
1296
|
+
// Track the selection deeply enough that a change WITHIN the set re-measures: the effect
|
|
1297
|
+
// Reads every path, not just the array reference.
|
|
1298
|
+
void sel.map((path) => path.join("/")).join("|");
|
|
1299
|
+
byTab.set(tab.id, sel);
|
|
1300
|
+
}
|
|
1301
|
+
return byTab;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Lazily start one reactive watcher that re-measures each host's OWN document's selection.
|
|
1306
|
+
*
|
|
1307
|
+
* **Every other fan-out in this module filters by `host.tabId`** — `postPatchToHosts`,
|
|
1308
|
+
* `requestCanvasEval`, `flushCanvasEdits` — and this one did not. It read `activeTab` and posted
|
|
1309
|
+
* the result to every entry in `liveHosts`, so with two panes open the focused pane's selection
|
|
1310
|
+
* paths were measured inside the OTHER pane's frame, against a document that does not contain them,
|
|
1311
|
+
* and written over that host's own `selectionPath`/`selectionPaths`. The reverse cost more:
|
|
1312
|
+
* `requestSelection`'s `if (!primary)` branch clears the overlay, so the focused pane merely having
|
|
1313
|
+
* nothing selected erased the side pane's box — the unfocused pane could never show a selection at
|
|
1314
|
+
* all.
|
|
1315
|
+
*/
|
|
816
1316
|
function ensureSelectionWatch(): void {
|
|
817
1317
|
if (selectionWatch) {
|
|
818
1318
|
return;
|
|
@@ -820,12 +1320,12 @@ function ensureSelectionWatch(): void {
|
|
|
820
1320
|
const scope = effectScope(true);
|
|
821
1321
|
scope.run(() => {
|
|
822
1322
|
effect(() => {
|
|
823
|
-
const
|
|
1323
|
+
const byTab = shownSelections();
|
|
824
1324
|
// Track the stylebook selection too: stylebook hosts measure the selected TAG's card
|
|
825
1325
|
// (session.selection is deliberately [] in stylebook mode).
|
|
826
|
-
void
|
|
1326
|
+
void shell.stylebook.selection;
|
|
827
1327
|
for (const host of liveHosts) {
|
|
828
|
-
requestSelection(host,
|
|
1328
|
+
requestSelection(host, (host.tabId ? byTab.get(host.tabId) : null) ?? NO_SELECTION);
|
|
829
1329
|
}
|
|
830
1330
|
});
|
|
831
1331
|
});
|
|
@@ -844,8 +1344,14 @@ function ensurePresenceWatch(): void {
|
|
|
844
1344
|
const scope = effectScope(true);
|
|
845
1345
|
scope.run(() => {
|
|
846
1346
|
effect(() => {
|
|
847
|
-
|
|
848
|
-
|
|
1347
|
+
// EVERY shown tab's roster, for the reason {@link ensureSelectionWatch} gives: a peer moving
|
|
1348
|
+
// Their cursor in the side pane's document is a repaint the side pane owes, and tracking only
|
|
1349
|
+
// The focused tab left the other pane's presence boxes frozen at whatever it last drew.
|
|
1350
|
+
for (const pane of workspace.panes) {
|
|
1351
|
+
const tab = pane.activeTabId ? workspace.tabs.get(pane.activeTabId) : null;
|
|
1352
|
+
if (!tab) {
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
849
1355
|
// Track the roster deeply enough that selection moves re-trigger.
|
|
850
1356
|
const { peers } = collabState(tab);
|
|
851
1357
|
void peers.map((peer) => JSON.stringify(peer.state.structuralSelection ?? null)).join("|");
|
|
@@ -869,7 +1375,10 @@ function requestPresence(host: HostState): void {
|
|
|
869
1375
|
if (host.stylebook || !host.ready || !host.iframe.isConnected) {
|
|
870
1376
|
return;
|
|
871
1377
|
}
|
|
872
|
-
|
|
1378
|
+
// THIS host's document, not the focused pane's — the peer boxes drawn in a frame have to be the
|
|
1379
|
+
// Peers looking at what that frame is showing, and `peer.state.focusedPath` is compared against
|
|
1380
|
+
// It below.
|
|
1381
|
+
const tab = hostTab(host);
|
|
873
1382
|
const peers = tab ? collabState(tab).peers : [];
|
|
874
1383
|
host.presenceMeta.clear();
|
|
875
1384
|
const paths: (string | number)[][] = [];
|
|
@@ -878,12 +1387,17 @@ function requestPresence(host: HostState): void {
|
|
|
878
1387
|
if (!structuralSelection || peer.state.focusedPath !== tab?.documentPath) {
|
|
879
1388
|
continue;
|
|
880
1389
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1390
|
+
// A peer publishes their whole selection SET (§6.5). Every path gets its own box under the
|
|
1391
|
+
// Same name and colour — the meta map is keyed by path, so a peer selecting six nodes draws
|
|
1392
|
+
// Six boxes and a peer selecting one draws exactly the one box it always did.
|
|
1393
|
+
for (const path of structuralSelection) {
|
|
1394
|
+
const copy = [...path];
|
|
1395
|
+
paths.push(copy);
|
|
1396
|
+
host.presenceMeta.set(JSON.stringify(copy), {
|
|
1397
|
+
color: peer.state.user.color,
|
|
1398
|
+
label: peer.state.user.name ?? peer.state.user.login,
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
887
1401
|
}
|
|
888
1402
|
if (paths.length === 0) {
|
|
889
1403
|
host.presenceReqId = -1;
|
|
@@ -895,28 +1409,44 @@ function requestPresence(host: HostState): void {
|
|
|
895
1409
|
host.channel.post({ kind: "measure", paths, reqId: host.presenceReqId });
|
|
896
1410
|
}
|
|
897
1411
|
|
|
898
|
-
/**
|
|
899
|
-
|
|
1412
|
+
/**
|
|
1413
|
+
* Track the selection on a host and ask its iframe to measure it (or clear the boxes when empty).
|
|
1414
|
+
*
|
|
1415
|
+
* The PRIMARY path is posted first and drawn as the selection box; the rest are drawn as
|
|
1416
|
+
* co-selection boxes from the same reply, so one round trip covers the whole set. With one path
|
|
1417
|
+
* selected the request is byte-identical to what it always was — one path in, one box out.
|
|
1418
|
+
*/
|
|
1419
|
+
function requestSelection(host: HostState, sel: readonly JxPath[]): void {
|
|
900
1420
|
if (host.stylebook) {
|
|
901
1421
|
requestStylebookSelection(host);
|
|
902
1422
|
return;
|
|
903
1423
|
}
|
|
904
|
-
|
|
1424
|
+
const primary = primarySelection(sel);
|
|
1425
|
+
host.selectionPath = primary;
|
|
1426
|
+
host.selectionPaths = sel as JxPath[];
|
|
905
1427
|
if (!host.iframe.isConnected) {
|
|
906
1428
|
liveHosts.delete(host);
|
|
907
1429
|
return;
|
|
908
1430
|
}
|
|
909
|
-
if (!
|
|
1431
|
+
if (!primary) {
|
|
1432
|
+
host.coSelectionKeys.clear();
|
|
910
1433
|
host.overlay.setSelection(null);
|
|
1434
|
+
host.overlay.setCoSelection([]);
|
|
911
1435
|
return;
|
|
912
1436
|
}
|
|
913
1437
|
if (!host.ready) {
|
|
914
1438
|
return;
|
|
915
1439
|
}
|
|
916
|
-
host.selReqId += 1;
|
|
917
1440
|
// Post a plain copy: `session.selection` is a reactive proxy, and only serializable values may
|
|
918
1441
|
// Cross the postMessage boundary.
|
|
919
|
-
|
|
1442
|
+
const others = cloneSelection(sel.slice(0, -1));
|
|
1443
|
+
host.coSelectionKeys = new Set(others.map((path) => JSON.stringify(path)));
|
|
1444
|
+
host.selReqId += 1;
|
|
1445
|
+
host.channel.post({
|
|
1446
|
+
kind: "measure",
|
|
1447
|
+
paths: [[...primary], ...others],
|
|
1448
|
+
reqId: host.selReqId,
|
|
1449
|
+
});
|
|
920
1450
|
}
|
|
921
1451
|
|
|
922
1452
|
/**
|
|
@@ -929,7 +1459,7 @@ function requestStylebookSelection(host: HostState): void {
|
|
|
929
1459
|
liveHosts.delete(host);
|
|
930
1460
|
return;
|
|
931
1461
|
}
|
|
932
|
-
const tag =
|
|
1462
|
+
const tag = shell.stylebook.selection;
|
|
933
1463
|
const cardPath = tag ? host.stylebook?.tagToCardPath.get(tag) : undefined;
|
|
934
1464
|
if (!tag || !cardPath) {
|
|
935
1465
|
host.overlay.setSelection(null);
|
|
@@ -948,6 +1478,79 @@ function requestStylebookSelection(host: HostState): void {
|
|
|
948
1478
|
*/
|
|
949
1479
|
const DEFAULT_CANVAS_URL = "/packages/studio/canvas.html";
|
|
950
1480
|
|
|
1481
|
+
/**
|
|
1482
|
+
* Release one host: its channel, its overlay, its frame, and everything awaiting a reply from it.
|
|
1483
|
+
*
|
|
1484
|
+
* A settled-with-nothing reply rather than a dropped promise, because every caller of
|
|
1485
|
+
* `requestCanvasEval` / `flushCanvasEdits` / a measure is awaiting one: dropping the entry would
|
|
1486
|
+
* hang a save on a frame that no longer exists, and `canvasIdleBlockers()` would go on naming it.
|
|
1487
|
+
*/
|
|
1488
|
+
function releaseHost(host: HostState): void {
|
|
1489
|
+
host.channel.dispose();
|
|
1490
|
+
for (const [reqId, resolve] of pendingEvals) {
|
|
1491
|
+
if (evalOwners.get(reqId) === host) {
|
|
1492
|
+
pendingEvals.delete(reqId);
|
|
1493
|
+
evalOwners.delete(reqId);
|
|
1494
|
+
resolve(null);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
for (const [reqId, settle] of pendingFlushes) {
|
|
1498
|
+
if (flushOwners.get(reqId) === host) {
|
|
1499
|
+
pendingFlushes.delete(reqId);
|
|
1500
|
+
flushOwners.delete(reqId);
|
|
1501
|
+
settle();
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
for (const [, resolve] of host.pendingMeasures) {
|
|
1505
|
+
resolve([]);
|
|
1506
|
+
}
|
|
1507
|
+
host.pendingMeasures.clear();
|
|
1508
|
+
host.pendingTabIds.clear();
|
|
1509
|
+
cancelInsertHide(host);
|
|
1510
|
+
/* The module-level pointer at THIS host goes with it. Everything else here was already released
|
|
1511
|
+
and `activeEditHost` was not, so unsplitting a pane with a live inline-edit caret left the
|
|
1512
|
+
parent realm believing it was still editing: the format toolbar stayed up anchored to a
|
|
1513
|
+
detached frame, and `commitActiveEditSession()` posted `endEdit` through
|
|
1514
|
+
`iframe.contentWindow?.postMessage` on a removed frame — an optional-chained silent no-op, so
|
|
1515
|
+
the edit was lost without a word. Cleared here rather than guarded at each reader, because
|
|
1516
|
+
"the host that owns the edit session" cannot be a host that no longer exists. */
|
|
1517
|
+
if (activeEditHost === host) {
|
|
1518
|
+
activeEditHost = null;
|
|
1519
|
+
}
|
|
1520
|
+
host.overlay.root.remove();
|
|
1521
|
+
host.iframe.remove();
|
|
1522
|
+
hosts.delete(host.canvasEl);
|
|
1523
|
+
liveHosts.delete(host);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Release every canvas host mounted under `root`, and say how many there were.
|
|
1528
|
+
*
|
|
1529
|
+
* The one NON-lazy path out of {@link liveHosts}. Eleven sites prune a disconnected host when they
|
|
1530
|
+
* happen to walk the set, which is enough to stop a dead frame being posted to and is not enough to
|
|
1531
|
+
* release it: `iframe-channel.ts` adds a `window` "message" listener that only `dispose()` removes,
|
|
1532
|
+
* and the sole parent-side `dispose()` was the URL-change rebuild in {@link ensureHost}. So a closed
|
|
1533
|
+
* pane — or any mode transition, which detaches every artboard — left one live listener and one
|
|
1534
|
+
* overlay subtree per frame, for the life of the window.
|
|
1535
|
+
*
|
|
1536
|
+
* @param {HTMLElement} root
|
|
1537
|
+
* @returns {number} How many hosts were released.
|
|
1538
|
+
*/
|
|
1539
|
+
export function releaseCanvasHosts(root: HTMLElement): number {
|
|
1540
|
+
let released = 0;
|
|
1541
|
+
for (const host of new Set(liveHosts)) {
|
|
1542
|
+
if (root.contains(host.canvasEl)) {
|
|
1543
|
+
releaseHost(host);
|
|
1544
|
+
released += 1;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
return released;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/* There is no `liveCanvasHostCount()`. A count the app never reads is a function reachable from
|
|
1551
|
+
nothing, which `tests/reachability.test.ts` refuses on principle — and the honest measure was
|
|
1552
|
+
already there: {@link releaseCanvasHosts} RETURNS how many it released. */
|
|
1553
|
+
|
|
951
1554
|
function ensureHost(canvasEl: HTMLElement): HostState {
|
|
952
1555
|
// Read the platform's canvasUrl when one is registered; otherwise fall back to the default. The
|
|
953
1556
|
// Dev server leaves it unset, and some tests mount without a platform registered.
|
|
@@ -960,9 +1563,7 @@ function ensureHost(canvasEl: HTMLElement): HostState {
|
|
|
960
1563
|
// The platform's loopback canvasUrl arrived after this host was built with the default URL
|
|
961
1564
|
// (electrobun resolves it async over RPC) — tear the early iframe down and rebuild against the
|
|
962
1565
|
// Right cross-origin origin.
|
|
963
|
-
existing
|
|
964
|
-
liveHosts.delete(existing);
|
|
965
|
-
hosts.delete(canvasEl);
|
|
1566
|
+
releaseHost(existing);
|
|
966
1567
|
}
|
|
967
1568
|
// ParentOrigin is the parent's real origin, passed into the iframe URL so the cross-origin iframe
|
|
968
1569
|
// Can target a postMessage back at it.
|
|
@@ -974,6 +1575,9 @@ function ensureHost(canvasEl: HTMLElement): HostState {
|
|
|
974
1575
|
const iframeOrigin = new URL(canvasUrl, location.href).origin;
|
|
975
1576
|
const iframe = document.createElement("iframe");
|
|
976
1577
|
iframe.className = "jx-canvas-iframe";
|
|
1578
|
+
// Without a title the whole canvas is an unlabelled frame to assistive tech — and the canvas is
|
|
1579
|
+
// The artefact, not chrome.
|
|
1580
|
+
iframe.title = "Canvas — live page render";
|
|
977
1581
|
iframe.style.cssText =
|
|
978
1582
|
"width:100%;min-height:480px;height:100%;border:0;display:block;background:#fff";
|
|
979
1583
|
// Preserve any query already on canvasUrl (e.g. electrobun's ?win=7) and append the token (always)
|
|
@@ -998,7 +1602,7 @@ function ensureHost(canvasEl: HTMLElement): HostState {
|
|
|
998
1602
|
}
|
|
999
1603
|
const overlay = createOverlayLayer(document);
|
|
1000
1604
|
canvasEl.replaceChildren(iframe, overlay.root);
|
|
1001
|
-
registerCanvasGutterDrop(canvasEl, () =>
|
|
1605
|
+
registerCanvasGutterDrop(canvasEl, () => hostForCanvas(canvasEl));
|
|
1002
1606
|
const channel = postMessageChannel<ParentToIframe, IframeToParent>({
|
|
1003
1607
|
acceptOrigin: iframeOrigin,
|
|
1004
1608
|
source: window,
|
|
@@ -1015,11 +1619,14 @@ function ensureHost(canvasEl: HTMLElement): HostState {
|
|
|
1015
1619
|
canvasEl,
|
|
1016
1620
|
canvasUrl,
|
|
1017
1621
|
channel,
|
|
1622
|
+
contentFragment: false,
|
|
1623
|
+
contentHeight: null,
|
|
1018
1624
|
editing: false,
|
|
1019
1625
|
editingProp: null,
|
|
1020
1626
|
iframe,
|
|
1021
1627
|
insertHideTimer: null,
|
|
1022
1628
|
insertHover: false,
|
|
1629
|
+
coSelectionKeys: new Set<string>(),
|
|
1023
1630
|
insertZone: null,
|
|
1024
1631
|
lastRenderedGen: -1,
|
|
1025
1632
|
lastSelectionRect: null,
|
|
@@ -1027,12 +1634,17 @@ function ensureHost(canvasEl: HTMLElement): HostState {
|
|
|
1027
1634
|
overlay,
|
|
1028
1635
|
panReqId: -1,
|
|
1029
1636
|
pending: null,
|
|
1637
|
+
idle: null,
|
|
1030
1638
|
pendingEnterEdit: null,
|
|
1639
|
+
pendingMeasures: new Map(),
|
|
1640
|
+
pendingPatches: 0,
|
|
1031
1641
|
pendingTabIds: new Map(),
|
|
1032
1642
|
presenceMeta: new Map(),
|
|
1033
1643
|
presenceReqId: -1,
|
|
1644
|
+
preview: false,
|
|
1034
1645
|
ready: false,
|
|
1035
1646
|
selectionPath: null,
|
|
1647
|
+
selectionPaths: [],
|
|
1036
1648
|
selReqId: 0,
|
|
1037
1649
|
snapshot: null,
|
|
1038
1650
|
stylebook: null,
|
|
@@ -1125,31 +1737,95 @@ function onInsertButtonClick(state: HostState, e: MouseEvent): void {
|
|
|
1125
1737
|
insertZoneClickHandler?.(state.overlay.insertButton, zone);
|
|
1126
1738
|
}
|
|
1127
1739
|
|
|
1740
|
+
/**
|
|
1741
|
+
* Messages a PREVIEW render is not allowed to act on. Preview must behave like the shipped page:
|
|
1742
|
+
* clicking selects nothing, nothing is outlined, there is no insertion "+", the browser's own
|
|
1743
|
+
* context menu stands, nothing can be dropped into it, and no text can be typed into it.
|
|
1744
|
+
*
|
|
1745
|
+
* The frame withholds most of these itself ({@link file://./iframe-interaction.ts}'s mode gate),
|
|
1746
|
+
* but the canvas bundle ships prebuilt in `dist/`, so the host refuses them independently rather
|
|
1747
|
+
* than trusting the frame's build to be current. Cleanup messages (`dragEnd`, `fileDragLeave`) are
|
|
1748
|
+
* deliberately NOT blocked — they only tear affordances down.
|
|
1749
|
+
*/
|
|
1750
|
+
const PREVIEW_BLOCKED: ReadonlySet<IframeToParent["kind"]> = new Set([
|
|
1751
|
+
"contextMenu",
|
|
1752
|
+
"dragOver",
|
|
1753
|
+
"dropResult",
|
|
1754
|
+
"editCommit",
|
|
1755
|
+
"editCommitProp",
|
|
1756
|
+
"editInsert",
|
|
1757
|
+
"editMerge",
|
|
1758
|
+
"editRangeReplace",
|
|
1759
|
+
"editSplit",
|
|
1760
|
+
"editStart",
|
|
1761
|
+
"fileDragOver",
|
|
1762
|
+
"fileDrop",
|
|
1763
|
+
"hit",
|
|
1764
|
+
"hover",
|
|
1765
|
+
"insertZones",
|
|
1766
|
+
"layoutHit",
|
|
1767
|
+
"nativeDragEnter",
|
|
1768
|
+
]);
|
|
1769
|
+
|
|
1128
1770
|
/** Handle a message the iframe posted back: ready handshake, pointer hit/hover, measured geometry. */
|
|
1129
1771
|
function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
1772
|
+
if (state.preview && PREVIEW_BLOCKED.has(msg.kind)) {
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1130
1775
|
switch (msg.kind) {
|
|
1131
1776
|
case "ready": {
|
|
1132
1777
|
state.ready = true;
|
|
1778
|
+
/* The chord table first, before the pending render: a frame that has painted a page the
|
|
1779
|
+
author can click into must already know which keystrokes to hand back. */
|
|
1780
|
+
const table = keymapSource?.();
|
|
1781
|
+
if (table) {
|
|
1782
|
+
state.channel.post({ chords: table.chords, kind: "keymap", mac: table.mac });
|
|
1783
|
+
}
|
|
1133
1784
|
if (state.pending) {
|
|
1134
1785
|
state.channel.post(state.pending);
|
|
1135
1786
|
state.pending = null;
|
|
1136
1787
|
}
|
|
1137
1788
|
// Re-measure the current selection now that the iframe can answer.
|
|
1138
|
-
requestSelection(state, state.
|
|
1789
|
+
requestSelection(state, state.selectionPaths);
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
case "paneFocus": {
|
|
1793
|
+
/* The frame says only that a pointer went down in it. Every mode posts this, including
|
|
1794
|
+
preview — see the message's own docstring for the two holes in `hit` that it closes. */
|
|
1795
|
+
focusHostPane(state);
|
|
1796
|
+
// …and "a pointer went down in a canvas" is exactly what un-hides the block action bar: the
|
|
1797
|
+
// Author is back on the surface it belongs to. Harmless in preview, where the bar draws
|
|
1798
|
+
// Nothing anyway.
|
|
1799
|
+
canvasPointerDownHandler?.();
|
|
1139
1800
|
return;
|
|
1140
1801
|
}
|
|
1141
1802
|
case "hit": {
|
|
1803
|
+
/* A click in a canvas is a click in a PANE, and the parent realm never saw it. See
|
|
1804
|
+
`focusHostPane`; it goes first so everything below writes into the pane the person is now
|
|
1805
|
+
in rather than starting a selection the Inspector will not show. `paneFocus` has usually
|
|
1806
|
+
beaten it here, and both stay: the canvas bundle ships prebuilt, so a frame whose build
|
|
1807
|
+
predates `paneFocus` must still focus its pane on a click that lands on a node. */
|
|
1808
|
+
focusHostPane(state);
|
|
1809
|
+
// Both, for the same reason and the same cost: the release is a no-op unless the bar is
|
|
1810
|
+
// Actually suppressed, so the pair of calls a modern frame makes is one null check.
|
|
1811
|
+
canvasPointerDownHandler?.();
|
|
1812
|
+
// Selecting a real document node retires any layout selection — the two are alternatives, and
|
|
1813
|
+
// A stale layout panel next to a fresh element selection would name the wrong thing.
|
|
1814
|
+
setLayoutSelection(null);
|
|
1142
1815
|
// The clicked panel becomes the ACTIVE panel (same as clicking its header): getActivePanel(),
|
|
1143
1816
|
// Header highlighting, and the style panel's breakpoint context all follow the click — and the
|
|
1144
1817
|
// Block action bar anchors to the panel the selection was actually made in, not panel 0.
|
|
1145
1818
|
let panelMedia: string | null = null;
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1819
|
+
const clicked = panelHostingCanvas(state.canvasEl)?.panel;
|
|
1820
|
+
if (clicked && !clicked.mediaName?.startsWith("git-diff")) {
|
|
1821
|
+
panelMedia = panelMediaToActiveMedia(clicked.mediaName);
|
|
1822
|
+
// The breakpoint belongs to the tab THIS host renders, resolved the same way every other
|
|
1823
|
+
// Doc-touching message in this switch resolves it. `updateUi` writes to `activeTab`, which
|
|
1824
|
+
// Is the focused pane's tab — so a click in an unfocused pane set the wrong document's
|
|
1825
|
+
// Breakpoint, and the Style panel then edited a compound block the person never opened.
|
|
1826
|
+
const hitTab = hostTab(state);
|
|
1827
|
+
if (hitTab) {
|
|
1828
|
+
hitTab.session.ui.activeMedia = panelMedia;
|
|
1153
1829
|
}
|
|
1154
1830
|
}
|
|
1155
1831
|
// A canvas left-click closes an open context menu — its parent-realm outside-click listener
|
|
@@ -1171,10 +1847,16 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1171
1847
|
return;
|
|
1172
1848
|
}
|
|
1173
1849
|
// A click in the canvas selects the node; the selection watcher redraws the box via `measure`.
|
|
1850
|
+
// Ctrl/Cmd-click ACCUMULATES instead — the same modifier the Outline uses, so the two
|
|
1851
|
+
// Surfaces answer the same gesture. Without the modifier this is a plain replace, which is
|
|
1852
|
+
// What every canvas click has always been.
|
|
1174
1853
|
state.selectionPath = msg.hit.path;
|
|
1175
|
-
|
|
1854
|
+
state.selectionPaths = [msg.hit.path];
|
|
1855
|
+
const tab = hostTab(state);
|
|
1176
1856
|
if (tab) {
|
|
1177
|
-
tab.session.selection = msg.
|
|
1857
|
+
tab.session.selection = msg.additive
|
|
1858
|
+
? toggleSelected(tab.session.selection, msg.hit.path)
|
|
1859
|
+
: [msg.hit.path];
|
|
1178
1860
|
}
|
|
1179
1861
|
// Draw immediately from the posted rect for snappiness (the measure round-trip confirms it).
|
|
1180
1862
|
{
|
|
@@ -1184,11 +1866,36 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1184
1866
|
}
|
|
1185
1867
|
return;
|
|
1186
1868
|
}
|
|
1869
|
+
case "layoutHit": {
|
|
1870
|
+
// A click on layout chrome — a node with no page-document path, so it can never become a
|
|
1871
|
+
// `session.selection`. Before this it selected nothing and posted nothing, which is what made
|
|
1872
|
+
// The first click a new user makes (the site name in the header) appear to do nothing at all.
|
|
1873
|
+
// Specimen catalogs have no layout, so the stylebook host ignores it.
|
|
1874
|
+
if (state.stylebook) {
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
// Layout chrome is still canvas, and clicking it is still a click in a pane.
|
|
1878
|
+
focusHostPane(state);
|
|
1879
|
+
canvasContextMenuHandler?.dismiss();
|
|
1880
|
+
setLayoutSelection(msg.hit);
|
|
1881
|
+
// Mutually exclusive with a document selection: the inspector renders one panel or the other.
|
|
1882
|
+
state.selectionPath = null;
|
|
1883
|
+
state.selectionPaths = [];
|
|
1884
|
+
const tab = hostTab(state);
|
|
1885
|
+
if (tab) {
|
|
1886
|
+
tab.session.selection = [];
|
|
1887
|
+
}
|
|
1888
|
+
const rect = canvasRectToParent(msg.hit.rect);
|
|
1889
|
+
state.overlay.setSelection(rect, `LAYOUT · ${msg.hit.layoutFile}`);
|
|
1890
|
+
state.lastSelectionRect = rect;
|
|
1891
|
+
renderOnly("rightPanel");
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1187
1894
|
case "hover": {
|
|
1188
1895
|
if (state.stylebook) {
|
|
1189
1896
|
// Decode to a tag; suppress the box when hovering the selected tag (legacy parity).
|
|
1190
1897
|
const tag = msg.hit ? resolveStylebookTag(state.stylebook.pathToTag, msg.hit.path) : null;
|
|
1191
|
-
const selected =
|
|
1898
|
+
const selected = shell.stylebook.selection;
|
|
1192
1899
|
if (msg.hit && tag && tag !== selected) {
|
|
1193
1900
|
state.overlay.setHover(canvasRectToParent(msg.hit.rect));
|
|
1194
1901
|
} else {
|
|
@@ -1217,6 +1924,15 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1217
1924
|
return;
|
|
1218
1925
|
}
|
|
1219
1926
|
case "geometry": {
|
|
1927
|
+
// A `measureCanvasPath` caller is waiting on this exact id — answer it and stop. Checked
|
|
1928
|
+
// FIRST because these ids come from the same counter as the two overlay measures below, so
|
|
1929
|
+
// Falling through would let a point request repaint the selection box.
|
|
1930
|
+
const awaiting = state.pendingMeasures.get(msg.reqId);
|
|
1931
|
+
if (awaiting) {
|
|
1932
|
+
state.pendingMeasures.delete(msg.reqId);
|
|
1933
|
+
awaiting(msg.hits);
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1220
1936
|
// Remote-presence reply: draw one colored box per measured peer selection.
|
|
1221
1937
|
if (msg.reqId === state.presenceReqId) {
|
|
1222
1938
|
state.presenceReqId = -1;
|
|
@@ -1242,12 +1958,29 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1242
1958
|
return;
|
|
1243
1959
|
}
|
|
1244
1960
|
if (msg.reqId === state.selReqId) {
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1961
|
+
// A stylebook host measures a SPECIMEN path — a card in a generated catalogue, which is
|
|
1962
|
+
// Not a `session.selection` path at all — so its one hit is its one box, as it always was.
|
|
1963
|
+
// Everywhere else the primary is chosen by PATH rather than by position, because a path
|
|
1964
|
+
// The iframe could not resolve is simply absent from `hits`.
|
|
1965
|
+
const primaryKey = JSON.stringify(state.selectionPath);
|
|
1966
|
+
const co: OverlayPlacement[] = [];
|
|
1967
|
+
let rect: ParentRect | null = null;
|
|
1968
|
+
if (state.stylebook) {
|
|
1969
|
+
const [hit] = msg.hits;
|
|
1970
|
+
rect = hit ? canvasRectToParent(hit.rect) : null;
|
|
1971
|
+
} else {
|
|
1972
|
+
for (const hit of msg.hits) {
|
|
1973
|
+
const key = JSON.stringify(hit.path);
|
|
1974
|
+
if (key === primaryKey) {
|
|
1975
|
+
rect = canvasRectToParent(hit.rect);
|
|
1976
|
+
} else if (state.coSelectionKeys.has(key)) {
|
|
1977
|
+
co.push(canvasRectToParent(hit.rect));
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
const sbTag = state.stylebook ? shell.stylebook.selection : null;
|
|
1250
1982
|
state.overlay.setSelection(rect, sbTag ? `<${sbTag}>` : null);
|
|
1983
|
+
state.overlay.setCoSelection(co);
|
|
1251
1984
|
if (rect) {
|
|
1252
1985
|
state.lastSelectionRect = rect;
|
|
1253
1986
|
}
|
|
@@ -1263,6 +1996,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1263
1996
|
return;
|
|
1264
1997
|
}
|
|
1265
1998
|
pendingEvals.delete(msg.reqId);
|
|
1999
|
+
evalOwners.delete(msg.reqId);
|
|
1266
2000
|
resolve(msg.gen === state.lastRenderedGen ? msg.results : null);
|
|
1267
2001
|
return;
|
|
1268
2002
|
}
|
|
@@ -1283,12 +2017,39 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1283
2017
|
}
|
|
1284
2018
|
case "patchComplete": {
|
|
1285
2019
|
// A patch never re-targets the host (tabId untouched) — only the DOM/geometry changed.
|
|
2020
|
+
state.pendingPatches = Math.max(0, state.pendingPatches - 1);
|
|
1286
2021
|
onDomUpdated(state, msg.gen);
|
|
1287
2022
|
return;
|
|
1288
2023
|
}
|
|
2024
|
+
case "idle": {
|
|
2025
|
+
// The frame's own quiescence report. Held, not acted on: it is read by
|
|
2026
|
+
// {@link canvasIdleBlockers} when something asks whether Studio has settled.
|
|
2027
|
+
state.idle = {
|
|
2028
|
+
animations: msg.animations,
|
|
2029
|
+
fonts: msg.fonts,
|
|
2030
|
+
gen: msg.gen,
|
|
2031
|
+
images: msg.images,
|
|
2032
|
+
};
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
1289
2035
|
case "renderError": {
|
|
1290
2036
|
// The render never landed — its pending identity must not be adopted by a later ack.
|
|
1291
2037
|
state.pendingTabIds.delete(msg.gen);
|
|
2038
|
+
// …and no `dataScope` will follow it, so a Refresh waiting on one must stop waiting. Without
|
|
2039
|
+
// This the button spins forever on the one failure it most needs to report.
|
|
2040
|
+
const failed = hostTab(state);
|
|
2041
|
+
if (failed) {
|
|
2042
|
+
failed.session.canvas.refreshing = false;
|
|
2043
|
+
renderOnly("leftPanel");
|
|
2044
|
+
}
|
|
2045
|
+
// …and the author is told. `msg.message` was read NOWHERE: the canvas would go blank or stale
|
|
2046
|
+
// And the one string that said why was deleted with the pending id. Keyed on the host, so a
|
|
2047
|
+
// Render loop that fails every generation is one problem rather than sixty.
|
|
2048
|
+
notify.error("The page could not be rendered.", {
|
|
2049
|
+
detail: msg.message,
|
|
2050
|
+
key: `canvas.render:${hostLabel(state)}`,
|
|
2051
|
+
source: "Canvas",
|
|
2052
|
+
});
|
|
1292
2053
|
return;
|
|
1293
2054
|
}
|
|
1294
2055
|
case "dataScope": {
|
|
@@ -1300,20 +2061,25 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1300
2061
|
if (msg.gen !== state.lastRenderedGen) {
|
|
1301
2062
|
return;
|
|
1302
2063
|
}
|
|
1303
|
-
|
|
2064
|
+
// Onto the tab this host rendered, not the focused one: the snapshot describes THAT
|
|
2065
|
+
// Document's `$defs`, and `updateCanvas` would have filed a background pane's data under the
|
|
2066
|
+
// Foreground pane's tab — the Data panel then explains a document that is not on screen.
|
|
2067
|
+
const scoped = hostTab(state);
|
|
2068
|
+
if (scoped) {
|
|
2069
|
+
scoped.session.canvas.scope = msg.scope;
|
|
2070
|
+
// The answer a pending Refresh was waiting for. Clearing it HERE rather than on a timer is
|
|
2071
|
+
// The whole point: a fetch that takes two seconds keeps saying so for two seconds.
|
|
2072
|
+
scoped.session.canvas.refreshing = false;
|
|
2073
|
+
}
|
|
1304
2074
|
renderOnly("leftPanel");
|
|
1305
2075
|
return;
|
|
1306
2076
|
}
|
|
1307
2077
|
case "contentHeight": {
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
// Component leaves dead space below — pages keep the floor and stay tall via #jx-canvas-root).
|
|
1314
|
-
state.iframe.style.height = `${msg.height}px`;
|
|
1315
|
-
state.iframe.style.minHeight = msg.fragment ? "0px" : "480px";
|
|
1316
|
-
syncEditZoomViewportHeight(state);
|
|
2078
|
+
// The measurement is STATE, not an instruction: record it, then re-derive the frame box from
|
|
2079
|
+
// (preview flag, last measurement) so the two can never disagree. See applyFrameSizing.
|
|
2080
|
+
state.contentHeight = msg.height;
|
|
2081
|
+
state.contentFragment = msg.fragment;
|
|
2082
|
+
applyFrameSizing(state);
|
|
1317
2083
|
return;
|
|
1318
2084
|
}
|
|
1319
2085
|
case "dragOver": {
|
|
@@ -1407,7 +2173,21 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1407
2173
|
}
|
|
1408
2174
|
case "patchError": {
|
|
1409
2175
|
// The iframe couldn't apply the edit surgically — fall back to a full render of the live doc.
|
|
1410
|
-
|
|
2176
|
+
// The patch is no longer outstanding either way; a counter that only went up would wedge
|
|
2177
|
+
// Every later idle() behind a message the host already handled.
|
|
2178
|
+
state.pendingPatches = Math.max(0, state.pendingPatches - 1);
|
|
2179
|
+
/* An UNRESOLVABLE stage escalates nothing, and that is now a complete answer rather than a
|
|
2180
|
+
hole. It used to be half of finding 9: `postPatchToHosts` posted a fabricated gen `0` to a
|
|
2181
|
+
host whose stage it could not name, the frame answered `patch-behind-render`, and this
|
|
2182
|
+
same failing lookup swallowed the escalation while `markConsumed` had already suppressed
|
|
2183
|
+
the pane's full render. The post side refuses to post at all now, so reaching here means
|
|
2184
|
+
the panel list was replaced BETWEEN the post and the ack — and that replacement is itself
|
|
2185
|
+
a newer full render of this stage, which is why scheduling one would rebuild a stage that
|
|
2186
|
+
is not stale. See the two tests either side of this in `iframe-host.test.ts`. */
|
|
2187
|
+
const failed = panelHostingCanvas(state.canvasEl)?.surface;
|
|
2188
|
+
if (failed) {
|
|
2189
|
+
patchEscalation?.(failed.paneId);
|
|
2190
|
+
}
|
|
1411
2191
|
return;
|
|
1412
2192
|
}
|
|
1413
2193
|
case "forwardKey": {
|
|
@@ -1447,6 +2227,16 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1447
2227
|
}
|
|
1448
2228
|
state.lastSnapshotSeq = msg.seq;
|
|
1449
2229
|
state.snapshot = msg;
|
|
2230
|
+
/* A snapshot posts ONLY from a live session (the frame's `onSelectionChange` returns early
|
|
2231
|
+
when nothing is being edited), so it is independent proof that a caret is alive in this
|
|
2232
|
+
host — the third of the three messages {@link isCaretActive} is derived from. Adopting the
|
|
2233
|
+
host here recovers the flag if the `editStart` that opened the session never landed, and it
|
|
2234
|
+
cannot resurrect a finished one: the frame stops posting snapshots before it posts
|
|
2235
|
+
`editEnd`, and the channel is FIFO. */
|
|
2236
|
+
if (!state.editing) {
|
|
2237
|
+
state.editing = true;
|
|
2238
|
+
activeEditHost = state;
|
|
2239
|
+
}
|
|
1450
2240
|
toolbarRefresh?.();
|
|
1451
2241
|
return;
|
|
1452
2242
|
}
|
|
@@ -1454,6 +2244,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1454
2244
|
const done = pendingFlushes.get(msg.reqId);
|
|
1455
2245
|
if (done) {
|
|
1456
2246
|
pendingFlushes.delete(msg.reqId);
|
|
2247
|
+
flushOwners.delete(msg.reqId);
|
|
1457
2248
|
done();
|
|
1458
2249
|
}
|
|
1459
2250
|
return;
|
|
@@ -1522,6 +2313,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1522
2313
|
const top = msg.rect.y * scale + ifr.top;
|
|
1523
2314
|
canvasSlashHandler?.show({
|
|
1524
2315
|
filter: msg.filter,
|
|
2316
|
+
...(msg.showFilter === true ? { showFilter: true } : {}),
|
|
1525
2317
|
onDismiss: () => state.channel.post({ kind: "slashDismissed" }),
|
|
1526
2318
|
onSelect: (cmd) => state.channel.post({ cmd: { ...cmd }, kind: "slashSelect" }),
|
|
1527
2319
|
rect: {
|
|
@@ -1550,6 +2342,15 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1550
2342
|
if (state.stylebook) {
|
|
1551
2343
|
return; // The doc context menu's actions are meaningless for specimen paths.
|
|
1552
2344
|
}
|
|
2345
|
+
/* A right-click is a click, and it is the ONLY message it delivers: `contextmenu` does not
|
|
2346
|
+
fire `click`, so `hit` — where the other half of this seam focuses the pane — never
|
|
2347
|
+
arrives. Without this line the menu was built against the FOCUSED document while pointing
|
|
2348
|
+
at a node in this one: `editor/context-menu.ts` writes `tab.session.selection = [path]`
|
|
2349
|
+
before it decides which rows to show, so right-clicking the side pane moved the PRIMARY
|
|
2350
|
+
pane's selection to a path from a different document, and Duplicate/Delete/Wrap then
|
|
2351
|
+
operated there. When the focused document had no such path the menu returned early and the
|
|
2352
|
+
right-click did nothing at all, silently. */
|
|
2353
|
+
focusHostPane(state);
|
|
1553
2354
|
// A canvas right-click — convert to parent-viewport coords and show the Jx element menu.
|
|
1554
2355
|
const { rect: ifr, scale } = hostDragGeometry(state);
|
|
1555
2356
|
canvasContextMenuHandler?.show({
|
|
@@ -1588,16 +2389,20 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
|
|
|
1588
2389
|
*/
|
|
1589
2390
|
function onDomUpdated(state: HostState, gen: number): void {
|
|
1590
2391
|
state.lastRenderedGen = gen;
|
|
1591
|
-
requestSelection(state, state.
|
|
2392
|
+
requestSelection(state, state.selectionPaths);
|
|
1592
2393
|
requestPresence(state);
|
|
1593
2394
|
hideInsertZoneNow(state);
|
|
1594
2395
|
if (state.pendingEnterEdit && gen >= state.pendingEnterEdit.minGen) {
|
|
1595
2396
|
const { offset, path } = state.pendingEnterEdit;
|
|
1596
2397
|
state.pendingEnterEdit = null;
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
2398
|
+
/* Re-enter only when this host STILL SHOWS THE TAB IT OWES THE CARET TO — which is a question
|
|
2399
|
+
about this host's pane, not about the focus. A background tab's iframe renders a different
|
|
2400
|
+
document, so re-entering there would grab the wrong element; the commit itself already landed
|
|
2401
|
+
in the right tab via `hostTab` routing.
|
|
2402
|
+
This asked `workspace.activeTabId` for two phases, which is the same question only while
|
|
2403
|
+
there is one stage. With two, the side pane could be displaying its tab, holding a caret it
|
|
2404
|
+
owed, and drop it because the PRIMARY had focus. */
|
|
2405
|
+
if (state.tabId !== null && state.tabId === tabOfContainer(state.canvasEl)?.id) {
|
|
1601
2406
|
reenterEdit(state, path, offset);
|
|
1602
2407
|
}
|
|
1603
2408
|
}
|
|
@@ -1638,7 +2443,16 @@ function redispatchWheel(
|
|
|
1638
2443
|
msg: Extract<IframeToParent, { kind: "forwardWheel" }>,
|
|
1639
2444
|
): void {
|
|
1640
2445
|
const { rect, scale } = hostDragGeometry(state);
|
|
1641
|
-
|
|
2446
|
+
/* The wheel goes back to the stage this FRAME is mounted on, not to "the canvas".
|
|
2447
|
+
`canvasWrap` was the focused pane's stage, so a wheel forwarded out of an unfocused pane's
|
|
2448
|
+
iframe panned the other pane. Resolved from the DOM rather than from the panel bookkeeping,
|
|
2449
|
+
because the frame is physically inside exactly one stage whether or not a pass has recorded an
|
|
2450
|
+
artboard for it yet. */
|
|
2451
|
+
const stage = stageContaining(state.canvasEl)?.wrap;
|
|
2452
|
+
if (!stage) {
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
stage.dispatchEvent(
|
|
1642
2456
|
new WheelEvent("wheel", {
|
|
1643
2457
|
bubbles: true,
|
|
1644
2458
|
cancelable: true,
|
|
@@ -1679,8 +2493,154 @@ function drawHover(state: HostState, hit: NodeHit | null): void {
|
|
|
1679
2493
|
}
|
|
1680
2494
|
|
|
1681
2495
|
/**
|
|
1682
|
-
*
|
|
1683
|
-
*
|
|
2496
|
+
* The part of a `render` message that is a fact about the DOCUMENT rather than about a host: the
|
|
2497
|
+
* resolved and wire-serialized document, its base, the path-mapper context, the mode and the site
|
|
2498
|
+
* style. Everything a host contributes — its width, its preview flag, its tab-id bookkeeping —
|
|
2499
|
+
* stays outside.
|
|
2500
|
+
*/
|
|
2501
|
+
type PreparedRender = Omit<
|
|
2502
|
+
Extract<ParentToIframe, { kind: "render" }>,
|
|
2503
|
+
"colorScheme" | "gen" | "kind"
|
|
2504
|
+
>;
|
|
2505
|
+
|
|
2506
|
+
/** One render pass's prepared payloads, by document IDENTITY. */
|
|
2507
|
+
type PreparedDocs = Map<
|
|
2508
|
+
JxMutableNode,
|
|
2509
|
+
{ tabId: string | null; viewTabId: string | null; payload: Promise<PreparedRender> }
|
|
2510
|
+
>;
|
|
2511
|
+
|
|
2512
|
+
/**
|
|
2513
|
+
* How many render passes may be prepared at once. Two panes, each mid-pass, plus slack.
|
|
2514
|
+
*
|
|
2515
|
+
* A ceiling rather than "however many are in flight", because nothing tells this module when a pass
|
|
2516
|
+
* ENDS: the last artboard of a pass is indistinguishable from the first artboard of the next one. A
|
|
2517
|
+
* small LRU is the honest bound — a pass whose artboards are still arriving is always among the
|
|
2518
|
+
* most recently touched, and anything older cannot be reused by anyone.
|
|
2519
|
+
*/
|
|
2520
|
+
const PREPARED_PASS_LIMIT = 4;
|
|
2521
|
+
|
|
2522
|
+
/**
|
|
2523
|
+
* The prepared payload for every render pass in flight, keyed by generation.
|
|
2524
|
+
*
|
|
2525
|
+
* A design-mode canvas draws one artboard per breakpoint, and every one of them renders the SAME
|
|
2526
|
+
* document at a different viewport width. Resolving it per artboard meant N layout merges, N
|
|
2527
|
+
* edit-mode transforms, 2N whole-document JSON round trips — and, on a dynamic-route page, N
|
|
2528
|
+
* backend round trips, because `resolveParamBoundState` POSTs each param-bound state entry to
|
|
2529
|
+
* `/__jx_resolve__` and that ran once per host. The pass resolves once and every host is fed from
|
|
2530
|
+
* the result.
|
|
2531
|
+
*
|
|
2532
|
+
* **It was ONE pass, and a second stage is what made that wrong.** The slot was replaced whenever
|
|
2533
|
+
* `gen` differed, on the reasoning that "a render pass is exactly one generation, so the map is
|
|
2534
|
+
* dropped whole when the next pass starts". That holds while a pass is the only thing running. Both
|
|
2535
|
+
* stages schedule through rAF and `mountIframeCanvas` awaits inside the loop, so two passes
|
|
2536
|
+
* INTERLEAVE at every await: pane A's second artboard came back to a slot pane B had just claimed,
|
|
2537
|
+
* re-resolved and re-serialized a document that had been prepared moments earlier, and evicted pane
|
|
2538
|
+
* B's in turn. Three preparations for three artboards — the fan-out this cache exists to remove,
|
|
2539
|
+
* restored exactly by the second pane.
|
|
2540
|
+
*
|
|
2541
|
+
* Within a generation the key is document IDENTITY, because git-diff mounts two different documents
|
|
2542
|
+
* under one generation and each gets its own entry. `tabId` rides along because `editableTags` is
|
|
2543
|
+
* derived from it, and the VIEW tab's id beside it because the whole resolution is derived from
|
|
2544
|
+
* THAT — the document path, the layout toggle, the preview params and the mode. A mismatch in
|
|
2545
|
+
* either re-prepares rather than reusing. `tabId` alone would not do: an override render nulls it,
|
|
2546
|
+
* so two git-diff documents would agree on `null` while being views of different tabs.
|
|
2547
|
+
*/
|
|
2548
|
+
const preparedPasses = new Map<number, PreparedDocs>();
|
|
2549
|
+
|
|
2550
|
+
/**
|
|
2551
|
+
* This generation's prepared documents, created on first mention and touched to the front.
|
|
2552
|
+
*
|
|
2553
|
+
* Insertion order is the LRU order: re-inserting on every access is what keeps two interleaved
|
|
2554
|
+
* passes both live no matter how long they alternate, while a pass nobody has touched for four
|
|
2555
|
+
* generations falls off the end.
|
|
2556
|
+
*/
|
|
2557
|
+
function preparedDocsFor(gen: number): PreparedDocs {
|
|
2558
|
+
const existing = preparedPasses.get(gen);
|
|
2559
|
+
if (existing) {
|
|
2560
|
+
preparedPasses.delete(gen);
|
|
2561
|
+
preparedPasses.set(gen, existing);
|
|
2562
|
+
return existing;
|
|
2563
|
+
}
|
|
2564
|
+
const fresh: PreparedDocs = new Map();
|
|
2565
|
+
preparedPasses.set(gen, fresh);
|
|
2566
|
+
for (const stale of preparedPasses.keys()) {
|
|
2567
|
+
if (preparedPasses.size <= PREPARED_PASS_LIMIT) {
|
|
2568
|
+
break;
|
|
2569
|
+
}
|
|
2570
|
+
preparedPasses.delete(stale);
|
|
2571
|
+
}
|
|
2572
|
+
return fresh;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
/**
|
|
2576
|
+
* Resolve + serialize `doc` for the wire, once per render pass.
|
|
2577
|
+
*
|
|
2578
|
+
* Returns the SAME promise to every host in the pass, so the second artboard awaits the first one's
|
|
2579
|
+
* work instead of repeating it. The payload objects it yields are shared by reference across the
|
|
2580
|
+
* messages posted to each host: `postMessage` structured-clones on the way into each frame, so
|
|
2581
|
+
* every iframe still folds patches into a shadow doc that is its own. Nothing may mutate a
|
|
2582
|
+
* `PreparedRender` after it is built.
|
|
2583
|
+
*/
|
|
2584
|
+
function preparePassRender(
|
|
2585
|
+
gen: number,
|
|
2586
|
+
doc: JxMutableNode,
|
|
2587
|
+
tabId: string | null,
|
|
2588
|
+
viewTab: Tab | null,
|
|
2589
|
+
paneId: string,
|
|
2590
|
+
): Promise<PreparedRender> {
|
|
2591
|
+
const byDoc = preparedDocsFor(gen);
|
|
2592
|
+
const viewTabId = viewTab?.id ?? null;
|
|
2593
|
+
const cached = byDoc.get(doc);
|
|
2594
|
+
if (cached && cached.tabId === tabId && cached.viewTabId === viewTabId) {
|
|
2595
|
+
return cached.payload;
|
|
2596
|
+
}
|
|
2597
|
+
// One-shot per PANE's pass, not per host and not globally. `allowAutoRequestsOnNextRender` arms
|
|
2598
|
+
// "the next render of pane X": consuming it inside the per-host mount meant whichever artboard
|
|
2599
|
+
// Mounted first swallowed it and the rest re-rendered with automatic `Request` entries still
|
|
2600
|
+
// Suppressed, and consuming a GLOBAL here meant whichever PANE's pass got here first did the
|
|
2601
|
+
// Same to the other one. `gen` comes from one monotonic counter shared by every surface, so a
|
|
2602
|
+
// Pass belongs to exactly one pane and this consume is that pane's.
|
|
2603
|
+
const allowAutoRequests = consumeAllowAutoRequests(paneId);
|
|
2604
|
+
const payload = timeSpanAsync(SPAN_PREPARE_RENDER, async (): Promise<PreparedRender> => {
|
|
2605
|
+
canvasPerf.renderPreparations += 1;
|
|
2606
|
+
const resolved = await resolveCanvasDocument(doc, viewTab);
|
|
2607
|
+
// The doc must be structured-cloneable to cross postMessage. A Jx document is JSON by contract,
|
|
2608
|
+
// So a JSON round-trip (NOT structuredClone, which would throw) drops residual functions /
|
|
2609
|
+
// Reactive proxy artifacts that would otherwise raise DataCloneError and silently drop the
|
|
2610
|
+
// Entire message.
|
|
2611
|
+
// oxlint-disable-next-line unicorn/prefer-structured-clone
|
|
2612
|
+
const cloneableDoc = JSON.parse(JSON.stringify(resolved.renderDoc)) as unknown;
|
|
2613
|
+
// The RAW page doc (forward-op + data-jx-path coordinate space) crosses as the shadow doc.
|
|
2614
|
+
// oxlint-disable-next-line unicorn/prefer-structured-clone
|
|
2615
|
+
const cloneableShadow = JSON.parse(JSON.stringify(doc)) as unknown;
|
|
2616
|
+
// Which tags can hold a caret depends on the document's vocabulary, so the format's verdicts
|
|
2617
|
+
// Ride with the render rather than being baked into the frame. Absent for a native document,
|
|
2618
|
+
// Where the studio's own element metadata answers on its own.
|
|
2619
|
+
// Resolve from THIS render's tab, not any host's `tabId` — that is only adopted when a render is
|
|
2620
|
+
// Acknowledged, so at post time it still names the previous render's document (null on first
|
|
2621
|
+
// Mount, which is exactly the render that matters).
|
|
2622
|
+
const renderTab = tabId ? (workspace.tabs.get(tabId) ?? null) : null;
|
|
2623
|
+
const formatElements = formatByName(renderTab?.doc.sourceFormat)?.studio?.elements;
|
|
2624
|
+
const editableTags = formatElements ? formatEditableVerdicts(formatElements) : undefined;
|
|
2625
|
+
return {
|
|
2626
|
+
doc: cloneableDoc,
|
|
2627
|
+
docBase: resolved.docBase ?? `${canvasBaseOrigin()}/`,
|
|
2628
|
+
mapperCtx: resolved.mapperCtx,
|
|
2629
|
+
mode: resolved.mapperCtx.canvasMode as CanvasMode,
|
|
2630
|
+
shadowDoc: cloneableShadow,
|
|
2631
|
+
siteStyle: resolved.siteStyle,
|
|
2632
|
+
...(editableTags ? { editableTags } : {}),
|
|
2633
|
+
...(allowAutoRequests ? { allowAutoRequests: true } : {}),
|
|
2634
|
+
};
|
|
2635
|
+
});
|
|
2636
|
+
byDoc.set(doc, { payload, tabId, viewTabId });
|
|
2637
|
+
return payload;
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
/**
|
|
2641
|
+
* Render `doc` into the iframe canvas mounted in `canvasEl`: resolve the document once for the
|
|
2642
|
+
* whole render pass ({@link preparePassRender}) and post it to this host (queued until the iframe
|
|
2643
|
+
* is `ready`).
|
|
1684
2644
|
*
|
|
1685
2645
|
* `widthPx` makes the panel's breakpoint width an EXPLICIT lever on the iframe element itself (only
|
|
1686
2646
|
* `style.width` is touched — the rest of cssText, incl. `min-height:480px; height:100%`, is kept).
|
|
@@ -1694,6 +2654,15 @@ function drawHover(state: HostState, hit: NodeHit | null): void {
|
|
|
1694
2654
|
* git-diff, whose iframes must never route doc mutations anywhere). It is recorded against `gen`
|
|
1695
2655
|
* and adopted into `host.tabId` only when the iframe acks the render — see
|
|
1696
2656
|
* {@link HostState.tabId}.
|
|
2657
|
+
*
|
|
2658
|
+
* `viewTab` is a DIFFERENT question and that is why it is a second parameter: `tabId` asks "where
|
|
2659
|
+
* do this frame's mutations go", `viewTab` asks "whose document path, layout toggle, preview params
|
|
2660
|
+
* and canvas mode resolve it". They diverge for exactly the override case — a git-diff render has
|
|
2661
|
+
* no mutation target but is still a view OF the tab whose diff it is, and its `docBase` and edit
|
|
2662
|
+
* transform must come from that tab. The default derives it from the element, the way
|
|
2663
|
+
* `paneOfContainer` does everywhere else stage content is handed a host and nothing else; the one
|
|
2664
|
+
* production caller (`canvas-render.ts`) passes `tabOfPane(surface.paneId)` explicitly, because it
|
|
2665
|
+
* has already resolved it to pick the document.
|
|
1697
2666
|
*/
|
|
1698
2667
|
export async function mountIframeCanvas(
|
|
1699
2668
|
gen: number,
|
|
@@ -1701,6 +2670,7 @@ export async function mountIframeCanvas(
|
|
|
1701
2670
|
canvasEl: HTMLElement,
|
|
1702
2671
|
widthPx?: number | null,
|
|
1703
2672
|
tabId: string | null = null,
|
|
2673
|
+
viewTab: Tab | null = tabOfContainer(canvasEl),
|
|
1704
2674
|
): Promise<void> {
|
|
1705
2675
|
const state = ensureHost(canvasEl);
|
|
1706
2676
|
state.pendingTabIds.set(gen, tabId);
|
|
@@ -1711,41 +2681,32 @@ export async function mountIframeCanvas(
|
|
|
1711
2681
|
// Own `latestGen`), so the parent must NOT gate on `view.renderGeneration`: during boot many
|
|
1712
2682
|
// Renders fire and the generation is usually stale by the time resolution finishes, which would
|
|
1713
2683
|
// Otherwise drop every post.
|
|
1714
|
-
const
|
|
1715
|
-
// The doc must be structured-cloneable to cross postMessage. A Jx document is JSON by contract, so
|
|
1716
|
-
// A JSON round-trip (NOT structuredClone, which would throw) drops residual functions / reactive
|
|
1717
|
-
// Proxy artifacts that would otherwise raise DataCloneError and silently drop the entire message.
|
|
1718
|
-
// oxlint-disable-next-line unicorn/prefer-structured-clone
|
|
1719
|
-
const cloneableDoc = JSON.parse(JSON.stringify(resolved.renderDoc)) as unknown;
|
|
1720
|
-
// The RAW page doc (forward-op + data-jx-path coordinate space) crosses as the iframe's shadow doc.
|
|
1721
|
-
// oxlint-disable-next-line unicorn/prefer-structured-clone
|
|
1722
|
-
const cloneableShadow = JSON.parse(JSON.stringify(doc)) as unknown;
|
|
1723
|
-
// Which tags can hold a caret depends on the document's vocabulary, so the format's verdicts ride
|
|
1724
|
-
// With the render rather than being baked into the frame. Absent for a native document, where the
|
|
1725
|
-
// Studio's own element metadata answers on its own.
|
|
1726
|
-
// Resolve from THIS render's tab, not `state.tabId` — that is only adopted when the render is
|
|
1727
|
-
// Acknowledged, so at post time it still names the previous render's document (null on first
|
|
1728
|
-
// Mount, which is exactly the render that matters).
|
|
1729
|
-
const renderTab = tabId ? (workspace.tabs.get(tabId) ?? null) : null;
|
|
1730
|
-
const formatElements = formatByName(renderTab?.doc.sourceFormat)?.studio?.elements;
|
|
1731
|
-
const editableTags = formatElements ? formatEditableVerdicts(formatElements) : undefined;
|
|
2684
|
+
const prepared = await preparePassRender(gen, doc, tabId, viewTab, paneOfContainer(canvasEl));
|
|
1732
2685
|
const message: ParentToIframe = {
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
2686
|
+
...prepared,
|
|
2687
|
+
// Per-TAB, and read at POST time so a scheme flip that raced the shared resolution is not
|
|
2688
|
+
// Baked into the payload every host shares. `viewTab` is this artboard's tab — defaulted from
|
|
2689
|
+
// `tabOfContainer(canvasEl)`, the same route the rest of this mount takes.
|
|
2690
|
+
colorScheme: schemeWireFor(viewTab),
|
|
1736
2691
|
gen,
|
|
1737
2692
|
kind: "render",
|
|
1738
|
-
mapperCtx: resolved.mapperCtx,
|
|
1739
|
-
mode: resolved.mapperCtx.canvasMode as CanvasMode,
|
|
1740
|
-
shadowDoc: cloneableShadow,
|
|
1741
|
-
siteStyle: resolved.siteStyle,
|
|
1742
|
-
...(editableTags ? { editableTags } : {}),
|
|
1743
|
-
...(consumeAllowAutoRequests() ? { allowAutoRequests: true } : {}),
|
|
1744
2693
|
};
|
|
1745
|
-
//
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
2694
|
+
// Preview is the fidelity view: no editing messages are honoured from it, no overlay is painted
|
|
2695
|
+
// Over it, and the frame stays viewport-sized so it scrolls for real. A mode switch to preview
|
|
2696
|
+
// Mid-split must likewise not start an edit session in the preview render. The flag and the frame
|
|
2697
|
+
// Box move together (setHostPreview) — this assignment sits AFTER an await, so any contentHeight
|
|
2698
|
+
// That landed while the document resolved was answered under the previous mode's rule.
|
|
2699
|
+
setHostPreview(state, message.mode === "preview");
|
|
2700
|
+
deliverRender(state, message);
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
/**
|
|
2704
|
+
* Hand a host its render — straight down the channel when the iframe is up, queued as `pending`
|
|
2705
|
+
* until it says `ready`. Counted either way: the message is this pass's work for this host whether
|
|
2706
|
+
* or not the frame has finished loading.
|
|
2707
|
+
*/
|
|
2708
|
+
function deliverRender(state: HostState, message: ParentToIframe): void {
|
|
2709
|
+
canvasPerf.hostRenderPosts += 1;
|
|
1749
2710
|
if (state.ready) {
|
|
1750
2711
|
state.channel.post(message);
|
|
1751
2712
|
} else {
|
|
@@ -1773,6 +2734,7 @@ export function mountStylebookCanvas(
|
|
|
1773
2734
|
const state = ensureHost(canvasEl);
|
|
1774
2735
|
state.pendingTabIds.set(gen, null);
|
|
1775
2736
|
state.stylebook = { pathToTag: generated.pathToTag, tagToCardPath: generated.tagToCardPath };
|
|
2737
|
+
setHostPreview(state, false);
|
|
1776
2738
|
state.pendingEnterEdit = null;
|
|
1777
2739
|
state.iframe.style.width = widthPx ? `${widthPx}px` : "100%";
|
|
1778
2740
|
// Two independent plain clones: the iframe renders `doc` and folds styleUpdates into `shadowDoc`
|
|
@@ -1782,7 +2744,9 @@ export function mountStylebookCanvas(
|
|
|
1782
2744
|
// oxlint-disable-next-line unicorn/prefer-structured-clone
|
|
1783
2745
|
const cloneableShadow = JSON.parse(JSON.stringify(generated.doc)) as unknown;
|
|
1784
2746
|
const message: ParentToIframe = {
|
|
1785
|
-
|
|
2747
|
+
// The specimen carries no tab identity, but the STAGE it is mounted into does: a stylebook is
|
|
2748
|
+
// Opened as a tab like anything else, and its `previewColorScheme` is that tab's.
|
|
2749
|
+
colorScheme: schemeWireFor(tabOfContainer(canvasEl)),
|
|
1786
2750
|
doc: cloneableDoc,
|
|
1787
2751
|
docBase: `${canvasBaseOrigin()}/`,
|
|
1788
2752
|
gen,
|
|
@@ -1800,16 +2764,26 @@ export function mountStylebookCanvas(
|
|
|
1800
2764
|
// SiteStyle too would double-apply it.
|
|
1801
2765
|
siteStyle: null,
|
|
1802
2766
|
};
|
|
1803
|
-
|
|
1804
|
-
state.channel.post(message);
|
|
1805
|
-
} else {
|
|
1806
|
-
state.pending = message;
|
|
1807
|
-
}
|
|
2767
|
+
deliverRender(state, message);
|
|
1808
2768
|
}
|
|
1809
2769
|
|
|
1810
|
-
/**
|
|
1811
|
-
|
|
1812
|
-
|
|
2770
|
+
/**
|
|
2771
|
+
* A TAB's forced preview scheme as wire data (auto → null).
|
|
2772
|
+
*
|
|
2773
|
+
* Takes its tab, and that is the whole fix. It was `activeSchemeWire()` — zero-argument, reading
|
|
2774
|
+
* `activeTab` — and both mounts call it while holding the artboard they are rendering INTO, so
|
|
2775
|
+
* every render of the side pane posted the FOCUSED tab's scheme over its own: a side tab set to
|
|
2776
|
+
* Dark rendered light whenever an Auto tab had the keyboard, and a side tab set to Auto rendered
|
|
2777
|
+
* dark whenever a Dark one did. Its own control went on saying what it had been set to, because the
|
|
2778
|
+
* record it reads is the one nobody wrote.
|
|
2779
|
+
*
|
|
2780
|
+
* This is verbatim the defect {@link postColorSchemeToLiveHosts} was given a `root` for. The PUSH
|
|
2781
|
+
* path was scoped and the RENDER path was not, and the per-pane effect in `studio.ts` only re-runs
|
|
2782
|
+
* when the scheme CHANGES — so nothing repaired the pane afterwards, and any later re-render (a
|
|
2783
|
+
* document edit, a breakpoint change, a mode switch) silently reverted it again.
|
|
2784
|
+
*/
|
|
2785
|
+
function schemeWireFor(tab: Tab | null): "light" | "dark" | null {
|
|
2786
|
+
const s = tab?.session.ui.previewColorScheme;
|
|
1813
2787
|
return s === "light" || s === "dark" ? s : null;
|
|
1814
2788
|
}
|
|
1815
2789
|
|
|
@@ -1817,18 +2791,59 @@ function activeSchemeWire(): "light" | "dark" | null {
|
|
|
1817
2791
|
* Flip the color-scheme preview on every ready host (page and stylebook alike — both render
|
|
1818
2792
|
* scheme-aware CSS). A pure attribute write iframe-side: no render, no patch, no gen.
|
|
1819
2793
|
*/
|
|
1820
|
-
export function postColorSchemeToLiveHosts(
|
|
2794
|
+
export function postColorSchemeToLiveHosts(
|
|
2795
|
+
scheme: "light" | "dark" | null,
|
|
2796
|
+
root?: HTMLElement | null,
|
|
2797
|
+
): void {
|
|
1821
2798
|
for (const host of liveHosts) {
|
|
1822
2799
|
if (!host.iframe.isConnected) {
|
|
1823
2800
|
liveHosts.delete(host);
|
|
1824
2801
|
continue;
|
|
1825
2802
|
}
|
|
2803
|
+
/* Scoped to one stage when the caller names one. The preview scheme is a per-TAB choice
|
|
2804
|
+
(`session.ui.previewColorScheme`), so with two live hosts an unscoped post flipped the other
|
|
2805
|
+
pane's document to a scheme nobody had asked it for — and left its own control still saying
|
|
2806
|
+
"Auto", because the record it reads belongs to the tab that was never changed. */
|
|
2807
|
+
if (root && !root.contains(host.iframe)) {
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
1826
2810
|
if (host.ready) {
|
|
1827
2811
|
host.channel.post({ kind: "setColorScheme", scheme });
|
|
1828
2812
|
}
|
|
1829
2813
|
}
|
|
1830
2814
|
}
|
|
1831
2815
|
|
|
2816
|
+
/**
|
|
2817
|
+
* Flip the artboard's language on every ready host — `lang` and `dir`, and nothing else.
|
|
2818
|
+
*
|
|
2819
|
+
* This is what makes `i18n.switchLocale` a rendering context rather than a chip. Jx has no message
|
|
2820
|
+
* catalogue, so the text does not change; the direction does, and a layout that only mirrors in
|
|
2821
|
+
* production is a layout an author cannot check before shipping it.
|
|
2822
|
+
*
|
|
2823
|
+
* SCOPED to one stage when the caller names one, for the reason {@link postColorSchemeToLiveHosts}
|
|
2824
|
+
* carries the same parameter: the preview locale is a per-TAB choice, so with two live hosts an
|
|
2825
|
+
* unscoped post redraws the other pane's document in a language nobody asked it for — and leaves
|
|
2826
|
+
* its own control still naming the language it was never moved off.
|
|
2827
|
+
*
|
|
2828
|
+
* `dir` is computed here and sent with the tag: `localeDirection` is CLDR's answer through
|
|
2829
|
+
* `Intl.Locale`, and a frame deriving it again is a second copy of the RTL script list to keep.
|
|
2830
|
+
*/
|
|
2831
|
+
export function postLocaleToLiveHosts(locale: string | null, root?: HTMLElement | null): void {
|
|
2832
|
+
const dir = localeDirection(locale);
|
|
2833
|
+
for (const host of liveHosts) {
|
|
2834
|
+
if (!host.iframe.isConnected) {
|
|
2835
|
+
liveHosts.delete(host);
|
|
2836
|
+
continue;
|
|
2837
|
+
}
|
|
2838
|
+
if (root && !root.contains(host.iframe)) {
|
|
2839
|
+
continue;
|
|
2840
|
+
}
|
|
2841
|
+
if (host.ready) {
|
|
2842
|
+
host.channel.post({ dir, kind: "setLocale", locale });
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
|
|
1832
2847
|
/**
|
|
1833
2848
|
* Push the project's current site style to every ready PAGE host as an in-place sheet replace (live
|
|
1834
2849
|
* design-token editing — stylebook hosts pre-merge site style into the specimen doc and are
|
|
@@ -1877,7 +2892,7 @@ export function postStyleUpdateToStylebookHosts(style: Record<string, unknown>):
|
|
|
1877
2892
|
if (host.ready && host.stylebook) {
|
|
1878
2893
|
host.channel.post({ gen: host.lastRenderedGen, kind: "styleUpdate", style: cloneable });
|
|
1879
2894
|
posted += 1;
|
|
1880
|
-
requestSelection(host, host.
|
|
2895
|
+
requestSelection(host, host.selectionPaths);
|
|
1881
2896
|
}
|
|
1882
2897
|
}
|
|
1883
2898
|
return posted;
|
|
@@ -1889,8 +2904,7 @@ export function postStyleUpdateToStylebookHosts(style: Record<string, unknown>):
|
|
|
1889
2904
|
* converted parent-viewport rect (see the `geometry` handler's panReqId branch).
|
|
1890
2905
|
*/
|
|
1891
2906
|
export function panToStylebookTag(tag: string): void {
|
|
1892
|
-
const
|
|
1893
|
-
const host = panel ? (hosts.get(panel.canvas as HTMLElement) ?? null) : null;
|
|
2907
|
+
const host = hostForActivePanel();
|
|
1894
2908
|
if (!host?.stylebook || !host.ready) {
|
|
1895
2909
|
return;
|
|
1896
2910
|
}
|
|
@@ -1905,27 +2919,79 @@ export function panToStylebookTag(tag: string): void {
|
|
|
1905
2919
|
|
|
1906
2920
|
// ─── Format-toolbar bridge (Phase 4b-2) ─────────────────────────────────────────
|
|
1907
2921
|
|
|
1908
|
-
/** The host whose iframe currently owns the inline-edit session (or null). */
|
|
1909
|
-
export function getActiveEditHost(): HostState | null {
|
|
1910
|
-
return activeEditHost;
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1913
2922
|
/** The current edit session's editing flag + latest selection snapshot, for the parent toolbar. */
|
|
1914
2923
|
export function getEditSnapshot(): {
|
|
1915
2924
|
editing: boolean;
|
|
1916
2925
|
editingProp: string | null;
|
|
1917
2926
|
snapshot: SelectionSnapshot | null;
|
|
1918
2927
|
} {
|
|
1919
|
-
|
|
2928
|
+
/* The same connectivity guard {@link isCaretActive} has, and for the same reason: a session
|
|
2929
|
+
belongs to a frame, and a frame that has left the document has no caret in it. `releaseHost`
|
|
2930
|
+
clears `activeEditHost`, so this is the second line rather than the first — it covers the frame
|
|
2931
|
+
detached by something that never routed through a release (a `hardClearCanvasWrap`, a mode
|
|
2932
|
+
transition mid-session), where an unguarded `editing: true` keeps the format toolbar on screen
|
|
2933
|
+
anchored to nothing. */
|
|
2934
|
+
const host = activeEditHost;
|
|
2935
|
+
if (!host || !host.iframe.isConnected) {
|
|
1920
2936
|
return { editing: false, editingProp: null, snapshot: null };
|
|
1921
2937
|
}
|
|
1922
2938
|
return {
|
|
1923
|
-
editing:
|
|
1924
|
-
editingProp:
|
|
1925
|
-
snapshot:
|
|
2939
|
+
editing: host.editing,
|
|
2940
|
+
editingProp: host.editingProp,
|
|
2941
|
+
snapshot: host.snapshot,
|
|
1926
2942
|
};
|
|
1927
2943
|
}
|
|
1928
2944
|
|
|
2945
|
+
/**
|
|
2946
|
+
* The chord table every live canvas frame forwards against, and the source it is computed from.
|
|
2947
|
+
*
|
|
2948
|
+
* `setKeymapSource` is called once at bootstrap with the app's registry, and again by nothing:
|
|
2949
|
+
* {@link publishKeymap} re-reads it, so a rebinding in Preferences reaches the canvas by the host
|
|
2950
|
+
* calling `publishKeymap()` rather than by anyone holding a stale copy.
|
|
2951
|
+
*/
|
|
2952
|
+
let keymapSource: (() => { mac: boolean; chords: readonly SyncedChord[] }) | null = null;
|
|
2953
|
+
|
|
2954
|
+
/**
|
|
2955
|
+
* Tell the host how to read the live chord table.
|
|
2956
|
+
*
|
|
2957
|
+
* Injected rather than imported so this module keeps its one direction of dependency: the canvas
|
|
2958
|
+
* host knows nothing about the command registry, and the bootstrap that owns both wires them.
|
|
2959
|
+
*/
|
|
2960
|
+
export function setKeymapSource(
|
|
2961
|
+
source: () => { mac: boolean; chords: readonly SyncedChord[] },
|
|
2962
|
+
onChange?: (listener: () => void) => () => void,
|
|
2963
|
+
): void {
|
|
2964
|
+
keymapSource = source;
|
|
2965
|
+
// A copy with no invalidation is a second authority that drifts. `onChange` fires when the user
|
|
2966
|
+
// Rebinds a key, and reposting is what makes Preferences › Keyboard reach inside the canvas —
|
|
2967
|
+
// Which three hand-written lists in `iframe-keys.ts` could never do.
|
|
2968
|
+
onChange?.(() => publishKeymap());
|
|
2969
|
+
publishKeymap();
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
/**
|
|
2973
|
+
* Send the current chord table to every live frame.
|
|
2974
|
+
*
|
|
2975
|
+
* Called on a frame's `ready` (so a newly mounted canvas is never guessing) and whenever the keymap
|
|
2976
|
+
* changes (so rebinding ⌘B in Preferences rebinds it inside the page too — the thing three
|
|
2977
|
+
* hand-written lists in `iframe-keys.ts` could never do).
|
|
2978
|
+
*/
|
|
2979
|
+
export function publishKeymap(): void {
|
|
2980
|
+
const table = keymapSource?.();
|
|
2981
|
+
if (!table) {
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
for (const host of liveHosts) {
|
|
2985
|
+
if (!host.iframe.isConnected) {
|
|
2986
|
+
liveHosts.delete(host);
|
|
2987
|
+
continue;
|
|
2988
|
+
}
|
|
2989
|
+
if (host.ready) {
|
|
2990
|
+
host.channel.post({ chords: table.chords, kind: "keymap", mac: table.mac });
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
|
|
1929
2995
|
/** Post an `applyFormat` intent to the active edit host's iframe (no-op when none/not ready). */
|
|
1930
2996
|
export function postApplyFormat(intent: ApplyFormatIntent): void {
|
|
1931
2997
|
const host = activeEditHost;
|
|
@@ -1935,6 +3001,21 @@ export function postApplyFormat(intent: ApplyFormatIntent): void {
|
|
|
1935
3001
|
host.channel.post({ intent, kind: "applyFormat" });
|
|
1936
3002
|
}
|
|
1937
3003
|
|
|
3004
|
+
/**
|
|
3005
|
+
* Ask the active edit host's iframe to open the slash menu at its caret.
|
|
3006
|
+
*
|
|
3007
|
+
* The same shape and the same guard as {@link postApplyFormat}: the caret lives in the other realm,
|
|
3008
|
+
* so a command in this one can only ask. `activeEditHost` is null when no session is live, which is
|
|
3009
|
+
* exactly when `insert.openSlashMenu` refuses.
|
|
3010
|
+
*/
|
|
3011
|
+
export function postOpenSlash(): void {
|
|
3012
|
+
const host = activeEditHost;
|
|
3013
|
+
if (!host || !host.ready) {
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
host.channel.post({ kind: "openSlash" });
|
|
3017
|
+
}
|
|
3018
|
+
|
|
1938
3019
|
/**
|
|
1939
3020
|
* Ask the active edit host's iframe to commit-and-end its inline-edit session (no-op when none).
|
|
1940
3021
|
* The parent calls this when intent leaves the edit surface in the PARENT realm — a tab switch, a
|
|
@@ -1944,7 +3025,9 @@ export function postApplyFormat(intent: ApplyFormatIntent): void {
|
|
|
1944
3025
|
*/
|
|
1945
3026
|
export function commitActiveEditSession(): void {
|
|
1946
3027
|
const host = activeEditHost;
|
|
1947
|
-
|
|
3028
|
+
// `isConnected` too: posting into a removed frame is an optional-chained no-op inside the
|
|
3029
|
+
// Channel, so the caller is told the commit was requested when nothing received it.
|
|
3030
|
+
if (host?.editing && host.ready && host.iframe.isConnected) {
|
|
1948
3031
|
host.channel.post({ kind: "endEdit" });
|
|
1949
3032
|
}
|
|
1950
3033
|
}
|
|
@@ -1952,7 +3035,7 @@ export function commitActiveEditSession(): void {
|
|
|
1952
3035
|
/** The live host backing the active panel's canvas (for non-edit selection-bar positioning). */
|
|
1953
3036
|
function hostForActivePanel(): HostState | null {
|
|
1954
3037
|
const panel = getActivePanel();
|
|
1955
|
-
return panel ? (
|
|
3038
|
+
return panel ? hostForCanvas(panel.canvas as HTMLElement) : null;
|
|
1956
3039
|
}
|
|
1957
3040
|
|
|
1958
3041
|
/**
|