@jxsuite/studio 2.0.0 → 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.
Files changed (419) hide show
  1. package/canvas.html +21 -0
  2. package/dist/chunks/Overlay.dev-qfjzkms8.js +23 -0
  3. package/dist/chunks/{Overlay.dev-cszx7yn5.js.map → Overlay.dev-qfjzkms8.js.map} +1 -1
  4. package/dist/chunks/{adapter-z8fkmbmv.js → adapter-qfnesn5v.js} +2 -2
  5. package/dist/chunks/{cf-settings-mmyv436d.js → cf-settings-73sh5z4p.js} +3 -3
  6. package/dist/chunks/{components-0hzentwq.js → components-2bpqp4mq.js} +5 -7
  7. package/dist/chunks/{components-0hzentwq.js.map → components-2bpqp4mq.js.map} +1 -1
  8. package/dist/chunks/content-source-34gnkbnn.js +38 -0
  9. package/dist/chunks/content-source-34gnkbnn.js.map +9 -0
  10. package/dist/chunks/convert-to-repeater-t8kxre5s.js +258 -0
  11. package/dist/chunks/convert-to-repeater-t8kxre5s.js.map +10 -0
  12. package/dist/chunks/extension-sections-6mqydz35.js +301 -0
  13. package/dist/chunks/extension-sections-6mqydz35.js.map +11 -0
  14. package/dist/chunks/files-8kn321h1.js +70 -0
  15. package/dist/chunks/files-8kn321h1.js.map +9 -0
  16. package/dist/chunks/{format-host-2c188n41.js → format-host-mkwsmt8d.js} +3 -3
  17. package/dist/chunks/git-panel-tjbr191d.js +51 -0
  18. package/dist/chunks/git-panel-tjbr191d.js.map +9 -0
  19. package/dist/chunks/{index-baeg2x3y.js → index-7pxchcce.js} +4 -3
  20. package/dist/chunks/{index-baeg2x3y.js.map → index-7pxchcce.js.map} +3 -3
  21. package/dist/chunks/{jx-validate-qx8degj7.js → jx-validate-v6qgknrs.js} +3 -3
  22. package/dist/chunks/layers-panel-87pb25km.js +58 -0
  23. package/dist/chunks/layers-panel-87pb25km.js.map +9 -0
  24. package/dist/chunks/{monaco-setup-zjdp9zdw.js → monaco-setup-e0yh25j4.js} +4 -75
  25. package/dist/chunks/{monaco-setup-zjdp9zdw.js.map → monaco-setup-e0yh25j4.js.map} +3 -4
  26. package/dist/chunks/publish-panel-m0fcgdrt.js +21 -0
  27. package/dist/chunks/publish-panel-m0fcgdrt.js.map +9 -0
  28. package/dist/chunks/quick-search-rhs6cbsb.js +53 -0
  29. package/dist/chunks/quick-search-rhs6cbsb.js.map +9 -0
  30. package/dist/chunks/right-panel-zpdxd98t.js +37 -0
  31. package/dist/chunks/right-panel-zpdxd98t.js.map +9 -0
  32. package/dist/chunks/section-registry-zmckaxf4.js +30 -0
  33. package/dist/chunks/section-registry-zmckaxf4.js.map +9 -0
  34. package/dist/chunks/sp-overlay.dev-9ran0sfq.js +18 -0
  35. package/dist/chunks/sp-overlay.dev-9ran0sfq.js.map +9 -0
  36. package/dist/chunks/sp-popover.dev-rebmdncr.js +9 -0
  37. package/dist/chunks/sp-popover.dev-rebmdncr.js.map +9 -0
  38. package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js +15 -0
  39. package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js.map +9 -0
  40. package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js +15 -0
  41. package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js.map +9 -0
  42. package/dist/chunks/{sp-tray.dev-pb2fsd65.js → sp-tray.dev-ey59vfh7.js} +10 -8
  43. package/dist/chunks/{sp-tray.dev-pb2fsd65.js.map → sp-tray.dev-ey59vfh7.js.map} +2 -2
  44. package/dist/chunks/{studio-9qr6fq7p.js → studio-0rqqhrdy.js} +10 -378
  45. package/dist/chunks/{studio-9qr6fq7p.js.map → studio-0rqqhrdy.js.map} +4 -14
  46. package/dist/chunks/{studio-5x6tqqec.js → studio-2sj1jj3v.js} +10 -8
  47. package/dist/chunks/{studio-5x6tqqec.js.map → studio-2sj1jj3v.js.map} +2 -2
  48. package/dist/chunks/{studio-h3fw0wn8.js → studio-3fk1ac9z.js} +6 -4
  49. package/dist/chunks/{studio-h3fw0wn8.js.map → studio-3fk1ac9z.js.map} +2 -2
  50. package/dist/chunks/studio-41exgzhq.js +6690 -0
  51. package/dist/chunks/studio-41exgzhq.js.map +38 -0
  52. package/dist/chunks/{studio-7v2bpweq.js → studio-4mqb9a0j.js} +6 -4
  53. package/dist/chunks/{studio-7v2bpweq.js.map → studio-4mqb9a0j.js.map} +2 -2
  54. package/dist/chunks/studio-50k77cca.js +1787 -0
  55. package/dist/chunks/studio-50k77cca.js.map +18 -0
  56. package/dist/chunks/{studio-56v3zwk6.js → studio-6a0h7sne.js} +10 -8
  57. package/dist/chunks/{studio-56v3zwk6.js.map → studio-6a0h7sne.js.map} +2 -2
  58. package/dist/chunks/studio-6f0am8f2.js +53 -0
  59. package/dist/chunks/studio-6f0am8f2.js.map +10 -0
  60. package/dist/chunks/studio-6nmjc5qk.js +7002 -0
  61. package/dist/chunks/studio-6nmjc5qk.js.map +32 -0
  62. package/dist/chunks/studio-7afd21nw.js +67 -0
  63. package/dist/chunks/studio-7afd21nw.js.map +10 -0
  64. package/dist/chunks/{studio-xze4zrs1.js → studio-7bmdvzne.js} +2 -2
  65. package/dist/chunks/studio-7bmdvzne.js.map +10 -0
  66. package/dist/chunks/studio-8jpzqk5s.js +128 -0
  67. package/dist/chunks/studio-8jpzqk5s.js.map +10 -0
  68. package/dist/chunks/studio-94m77tgk.js +1482 -0
  69. package/dist/chunks/studio-94m77tgk.js.map +13 -0
  70. package/dist/chunks/studio-9je6w5sq.js +1000 -0
  71. package/dist/chunks/studio-9je6w5sq.js.map +16 -0
  72. package/dist/chunks/{studio-w7c6kvk7.js → studio-a7pzveq1.js} +2 -2
  73. package/dist/chunks/{studio-97qcgvdn.js → studio-adn0z1k3.js} +14 -8
  74. package/dist/chunks/{studio-97qcgvdn.js.map → studio-adn0z1k3.js.map} +3 -3
  75. package/dist/chunks/{studio-kr88ryr7.js → studio-bsxxx3x2.js} +17 -15
  76. package/dist/chunks/{studio-kr88ryr7.js.map → studio-bsxxx3x2.js.map} +7 -6
  77. package/dist/chunks/studio-bw5dz6km.js +608 -0
  78. package/dist/chunks/studio-bw5dz6km.js.map +21 -0
  79. package/dist/chunks/studio-c6s5getc.js +4232 -0
  80. package/dist/chunks/studio-c6s5getc.js.map +39 -0
  81. package/dist/chunks/studio-cjm7fdjf.js +451 -0
  82. package/dist/chunks/studio-cjm7fdjf.js.map +14 -0
  83. package/dist/chunks/{studio-cx91mnh8.js → studio-ckwz4d3x.js} +53 -53
  84. package/dist/chunks/{studio-cx91mnh8.js.map → studio-ckwz4d3x.js.map} +5 -5
  85. package/dist/chunks/studio-dg7zyt5x.js +28 -0
  86. package/dist/chunks/studio-dg7zyt5x.js.map +10 -0
  87. package/dist/chunks/studio-dzcyvvvz.js +166 -0
  88. package/dist/chunks/studio-dzcyvvvz.js.map +10 -0
  89. package/dist/chunks/{studio-7kqe41sa.js → studio-evkr5d3b.js} +15 -7
  90. package/dist/chunks/{studio-7kqe41sa.js.map → studio-evkr5d3b.js.map} +5 -4
  91. package/dist/chunks/studio-f12h6177.js +411 -0
  92. package/dist/chunks/studio-f12h6177.js.map +13 -0
  93. package/dist/chunks/studio-hbp1s8m9.js +1817 -0
  94. package/dist/chunks/studio-hbp1s8m9.js.map +14 -0
  95. package/dist/chunks/studio-k2sxjph5.js +15295 -0
  96. package/dist/chunks/studio-k2sxjph5.js.map +235 -0
  97. package/dist/chunks/studio-myje51t3.js +385 -0
  98. package/dist/chunks/studio-myje51t3.js.map +12 -0
  99. package/dist/chunks/studio-na6rwzhw.js +353 -0
  100. package/dist/chunks/studio-na6rwzhw.js.map +11 -0
  101. package/dist/chunks/{studio-4k0m4fmp.js → studio-p471gg5n.js} +126 -3
  102. package/dist/chunks/studio-p471gg5n.js.map +14 -0
  103. package/dist/chunks/{studio-9bjwf0d4.js → studio-rgwcpast.js} +5 -46
  104. package/dist/chunks/{studio-9bjwf0d4.js.map → studio-rgwcpast.js.map} +4 -5
  105. package/dist/chunks/{studio-hhj92gcr.js → studio-s519zc99.js} +3 -3
  106. package/dist/chunks/studio-sas7mt3k.js +75 -0
  107. package/dist/chunks/studio-sas7mt3k.js.map +10 -0
  108. package/dist/chunks/studio-t5wqwzdh.js +74 -0
  109. package/dist/chunks/studio-t5wqwzdh.js.map +10 -0
  110. package/dist/chunks/{studio-9qpspwfn.js → studio-temv5bw4.js} +5 -5
  111. package/dist/chunks/{studio-9qpspwfn.js.map → studio-temv5bw4.js.map} +1 -1
  112. package/dist/chunks/{studio-qkvt37c0.js → studio-tr20m8g8.js} +669 -18
  113. package/dist/chunks/studio-tr20m8g8.js.map +9 -0
  114. package/dist/chunks/{studio-ygzav7z5.js → studio-v96qfnww.js} +5514 -4291
  115. package/dist/chunks/studio-v96qfnww.js.map +54 -0
  116. package/dist/chunks/studio-vcetc91k.js +659 -0
  117. package/dist/chunks/studio-vcetc91k.js.map +10 -0
  118. package/dist/chunks/{studio-n9zmpxjg.js → studio-vmymjefp.js} +2972 -4937
  119. package/dist/chunks/studio-vmymjefp.js.map +98 -0
  120. package/dist/chunks/{studio-bghqfmea.js → studio-wwseqyw0.js} +2 -2
  121. package/dist/chunks/studio-x9hmy5kr.js +390 -0
  122. package/dist/chunks/studio-x9hmy5kr.js.map +10 -0
  123. package/dist/chunks/studio-xa0ce0n7.js +335 -0
  124. package/dist/chunks/studio-xa0ce0n7.js.map +10 -0
  125. package/dist/chunks/studio-xm3dbxvn.js +378 -0
  126. package/dist/chunks/studio-xm3dbxvn.js.map +19 -0
  127. package/dist/chunks/{studio-nqw433kv.js → studio-xmvbhg31.js} +2 -2
  128. package/dist/chunks/studio-zv37yrfa.js +1054 -0
  129. package/dist/chunks/studio-zv37yrfa.js.map +13 -0
  130. package/dist/chunks/{ws-client-q7hkz3j9.js → ws-client-654ts66j.js} +6 -4
  131. package/dist/chunks/ws-client-654ts66j.js.map +11 -0
  132. package/dist/iframe-entry.js +668 -230
  133. package/dist/studio.js +23711 -35185
  134. package/index.html +35 -4685
  135. package/package.json +17 -15
  136. package/src/about/about-modal.ts +42 -11
  137. package/src/browse/library-commands.ts +240 -0
  138. package/src/browse/library-layouts.ts +369 -0
  139. package/src/browse/library-model.ts +396 -0
  140. package/src/browse/library-pane.ts +1049 -0
  141. package/src/browse/library-preview.ts +252 -0
  142. package/src/browse/library-source.ts +150 -0
  143. package/src/canvas/canvas-helpers.ts +33 -9
  144. package/src/canvas/canvas-live-render.ts +45 -29
  145. package/src/canvas/canvas-patcher.ts +151 -31
  146. package/src/canvas/canvas-perf.ts +46 -7
  147. package/src/canvas/canvas-render.ts +1092 -220
  148. package/src/canvas/canvas-surface.ts +240 -0
  149. package/src/canvas/canvas-utils.ts +1166 -124
  150. package/src/canvas/iframe-drop.ts +2 -1
  151. package/src/canvas/iframe-entry.ts +241 -4
  152. package/src/canvas/iframe-host.ts +1226 -143
  153. package/src/canvas/iframe-inline-edit.ts +31 -0
  154. package/src/canvas/iframe-interaction.ts +76 -16
  155. package/src/canvas/iframe-keys.ts +107 -45
  156. package/src/canvas/iframe-overlay.ts +38 -1
  157. package/src/canvas/iframe-protocol.ts +137 -2
  158. package/src/canvas/iframe-render.ts +70 -2
  159. package/src/canvas/iframe-slash.ts +6 -0
  160. package/src/canvas/path-mapping.ts +56 -6
  161. package/src/canvas/surface-registry.ts +292 -0
  162. package/src/collab/collab-commands.ts +244 -0
  163. package/src/collab/collab-session.ts +198 -32
  164. package/src/collab/collab-state.ts +28 -2
  165. package/src/collab/presence-chips.ts +100 -9
  166. package/src/commands/active-registry.ts +42 -0
  167. package/src/commands/app-commands.ts +176 -0
  168. package/src/commands/budget.ts +158 -0
  169. package/src/commands/command-args.ts +253 -0
  170. package/src/commands/context.ts +387 -0
  171. package/src/commands/defaults.ts +580 -0
  172. package/src/commands/keymap.ts +571 -0
  173. package/src/commands/levels.ts +345 -0
  174. package/src/commands/live-context.ts +193 -0
  175. package/src/commands/reference.ts +227 -0
  176. package/src/commands/registry.ts +293 -0
  177. package/src/component-props.ts +3 -2
  178. package/src/content/draft-state.ts +106 -0
  179. package/src/content/entry-commands.ts +282 -0
  180. package/src/content/entry-editor.ts +327 -0
  181. package/src/content/entry-fields.ts +116 -0
  182. package/src/content/entry-model.ts +175 -0
  183. package/src/editor/canvas-context-menu.ts +17 -11
  184. package/src/editor/canvas-slash-bridge.ts +11 -4
  185. package/src/editor/context-menu.ts +804 -204
  186. package/src/editor/convert-to-component.ts +31 -10
  187. package/src/editor/convert-to-repeater.ts +3 -2
  188. package/src/editor/inline-edit-apply.ts +5 -5
  189. package/src/editor/inline-edit.ts +86 -50
  190. package/src/editor/insert-zone-action.ts +1 -1
  191. package/src/editor/shortcuts.ts +1043 -329
  192. package/src/editor/slash-menu.ts +1 -0
  193. package/src/files/elements.ts +166 -0
  194. package/src/files/file-ops.ts +178 -31
  195. package/src/files/files.ts +898 -268
  196. package/src/files/fs-events.ts +21 -0
  197. package/src/files/media-meta.ts +203 -0
  198. package/src/files/media-paths.ts +141 -0
  199. package/src/files/media-upload.ts +7 -3
  200. package/src/files/media-usage.ts +189 -0
  201. package/src/github/github-auth.ts +235 -33
  202. package/src/github/github-publish.ts +80 -21
  203. package/src/grid/grid-controller.ts +152 -12
  204. package/src/grid/grid-layout.ts +225 -20
  205. package/src/grid/grid-open.ts +120 -0
  206. package/src/grid/grid-panel.ts +554 -39
  207. package/src/grid/grid-source.ts +27 -1
  208. package/src/grid/redirects-grid.ts +599 -0
  209. package/src/grid/redirects.ts +398 -0
  210. package/src/grid/sources/content-source.ts +108 -2
  211. package/src/i18n/i18n-commands.ts +322 -0
  212. package/src/new-project/add-repo-modal.ts +8 -10
  213. package/src/new-project/import-tab.ts +34 -9
  214. package/src/new-project/location-fields.ts +9 -1
  215. package/src/new-project/new-project-modal.ts +270 -262
  216. package/src/packages/jxsuite-update.ts +97 -48
  217. package/src/packages/pull-package-sync.ts +18 -9
  218. package/src/page-params.ts +2 -1
  219. package/src/panels/activity-bar.ts +152 -91
  220. package/src/panels/activity-panel.ts +481 -0
  221. package/src/panels/ai-chat/chat-markdown.ts +8 -3
  222. package/src/panels/ai-chat/chat-view.ts +245 -18
  223. package/src/panels/ai-chat/composer.ts +31 -5
  224. package/src/panels/ai-chat/sessions-view.ts +17 -10
  225. package/src/panels/ai-panel.ts +279 -120
  226. package/src/panels/block-action-bar.ts +1115 -224
  227. package/src/panels/bottom-dock.ts +337 -0
  228. package/src/panels/canvas-dnd-bridge.ts +37 -8
  229. package/src/panels/chat-panel.ts +29 -12
  230. package/src/panels/data-explorer.ts +254 -112
  231. package/src/panels/data-grid.ts +10 -4
  232. package/src/panels/dnd.ts +28 -51
  233. package/src/panels/editors.ts +434 -107
  234. package/src/panels/elements-panel.ts +66 -2
  235. package/src/panels/empty-state.ts +112 -0
  236. package/src/panels/events-panel.ts +596 -82
  237. package/src/panels/formula-workspace.ts +408 -218
  238. package/src/panels/frontmatter-fields.ts +51 -10
  239. package/src/panels/frontmatter-panel.ts +357 -79
  240. package/src/panels/git-panel.ts +375 -164
  241. package/src/panels/head-panel.ts +791 -85
  242. package/src/panels/i18n-panel.ts +592 -0
  243. package/src/panels/imports-panel.ts +95 -85
  244. package/src/panels/jump-bar.ts +584 -0
  245. package/src/panels/layers-panel.ts +1106 -328
  246. package/src/panels/left-panel.ts +109 -204
  247. package/src/panels/navigator-panels.ts +131 -0
  248. package/src/panels/overlays.ts +7 -2
  249. package/src/panels/pane-context.ts +1477 -0
  250. package/src/panels/pane-grid.ts +406 -0
  251. package/src/panels/panel-registry.ts +340 -0
  252. package/src/panels/panel-scheduler.ts +75 -0
  253. package/src/panels/problems-panel.ts +234 -0
  254. package/src/panels/properties-panel.ts +946 -1139
  255. package/src/panels/provenance.ts +258 -0
  256. package/src/panels/quick-search.ts +791 -122
  257. package/src/panels/right-panel.ts +228 -102
  258. package/src/panels/seo-modal.ts +395 -0
  259. package/src/panels/settings-pane.ts +203 -0
  260. package/src/panels/signals-panel.ts +354 -137
  261. package/src/panels/statement-editor.ts +41 -25
  262. package/src/panels/statusbar.ts +342 -106
  263. package/src/panels/style-panel.ts +974 -463
  264. package/src/panels/style-utils.ts +3 -3
  265. package/src/panels/stylebook-doc.ts +4 -2
  266. package/src/panels/stylebook-layers-panel.ts +3 -2
  267. package/src/panels/stylebook-panel.ts +51 -26
  268. package/src/panels/tab-strip.ts +1058 -78
  269. package/src/panels/target-line.ts +274 -0
  270. package/src/panels/toolbar.ts +631 -484
  271. package/src/panels/welcome-screen.ts +301 -198
  272. package/src/platform-errors.ts +26 -3
  273. package/src/platform.ts +96 -2
  274. package/src/platforms/cloud.ts +71 -8
  275. package/src/platforms/devserver.ts +92 -29
  276. package/src/publish/deploy-checklist.ts +383 -0
  277. package/src/publish/pages-service.ts +10 -1
  278. package/src/publish/publish-commands.ts +218 -0
  279. package/src/publish/publish-panel.ts +110 -16
  280. package/src/recent-projects.ts +3 -2
  281. package/src/services/a11y-report.ts +505 -0
  282. package/src/services/ai-models.ts +75 -2
  283. package/src/services/ai-project-tools.ts +29 -10
  284. package/src/services/ai-system-prompt.ts +53 -14
  285. package/src/services/ai-tools.ts +39 -3
  286. package/src/services/ai-writes.ts +154 -0
  287. package/src/services/announce.ts +97 -0
  288. package/src/services/automation.ts +424 -175
  289. package/src/services/cem-export.ts +9 -4
  290. package/src/services/clock.ts +61 -0
  291. package/src/services/document-assistant.ts +36 -8
  292. package/src/services/idle.ts +179 -0
  293. package/src/services/import-client.ts +26 -0
  294. package/src/services/monaco-buffer.ts +627 -0
  295. package/src/services/monaco-lazy.ts +36 -0
  296. package/src/services/notify.ts +271 -0
  297. package/src/services/profile.ts +155 -0
  298. package/src/services/references.ts +0 -0
  299. package/src/services/token-lint.ts +3 -2
  300. package/src/services/tool-executor.ts +53 -7
  301. package/src/services/trusted-types.ts +123 -0
  302. package/src/settings/contexts-section.ts +427 -0
  303. package/src/settings/contributed-section.ts +149 -11
  304. package/src/settings/css-vars-editor.ts +306 -349
  305. package/src/settings/defs-editor.ts +93 -34
  306. package/src/settings/dependencies-editor.ts +7 -7
  307. package/src/settings/extension-sections.ts +37 -5
  308. package/src/settings/general-settings.ts +204 -121
  309. package/src/settings/locales-section.ts +347 -0
  310. package/src/settings/preferences-accounts.ts +140 -0
  311. package/src/settings/preferences-dialog.ts +620 -0
  312. package/src/settings/preferences-keymap.ts +270 -0
  313. package/src/settings/project-sections.ts +260 -0
  314. package/src/settings/section-registry.ts +169 -0
  315. package/src/settings/settings-document.ts +385 -0
  316. package/src/shell.ts +1549 -0
  317. package/src/site-context.ts +45 -20
  318. package/src/state.ts +14 -275
  319. package/src/store.ts +88 -43
  320. package/src/studio.ts +790 -346
  321. package/src/style/live-preview.ts +36 -0
  322. package/src/style/project-styles.ts +349 -0
  323. package/src/style/token-ref.ts +123 -0
  324. package/src/tabs/patch-ops.ts +16 -4
  325. package/src/tabs/project-config.ts +416 -0
  326. package/src/tabs/selection.ts +323 -0
  327. package/src/tabs/tab.ts +182 -47
  328. package/src/tabs/transact.ts +209 -28
  329. package/src/types.ts +68 -13
  330. package/src/ui/ai-credentials-form.ts +39 -35
  331. package/src/ui/ai-managed-connect.ts +113 -0
  332. package/src/ui/dynamic-slot.ts +224 -81
  333. package/src/ui/expression-editor.ts +71 -63
  334. package/src/ui/field-input.ts +4 -6
  335. package/src/ui/field-row.ts +215 -38
  336. package/src/ui/form-controls.ts +194 -7
  337. package/src/ui/layers.ts +492 -69
  338. package/src/ui/media-picker.ts +115 -25
  339. package/src/ui/panel-resize.ts +109 -91
  340. package/src/ui/progress-modal.ts +131 -50
  341. package/src/ui/regions.ts +462 -0
  342. package/src/ui/schema-form.ts +229 -45
  343. package/src/ui/spectrum.ts +24 -8
  344. package/src/ui/timing.ts +49 -0
  345. package/src/ui/value-selector.ts +20 -0
  346. package/src/ui/value-source.ts +348 -0
  347. package/src/ui/virtual-window.ts +400 -0
  348. package/src/utils/edit-display.ts +3 -2
  349. package/src/utils/inherited-style.ts +67 -37
  350. package/src/view.ts +64 -78
  351. package/src/workspace/pane-derive.ts +1258 -0
  352. package/src/workspace/session.ts +317 -0
  353. package/src/workspace/workspace.ts +1345 -23
  354. package/styles/canvas.css +653 -0
  355. package/styles/forced-colors.css +106 -0
  356. package/styles/inspector.css +1454 -0
  357. package/styles/overlays.css +2704 -0
  358. package/styles/panels.css +1701 -0
  359. package/styles/shell.css +1423 -0
  360. package/styles/tokens.css +151 -0
  361. package/dist/chunks/Overlay.dev-cszx7yn5.js +0 -22
  362. package/dist/chunks/extension-sections-qct0gt8f.js +0 -531
  363. package/dist/chunks/extension-sections-qct0gt8f.js.map +0 -12
  364. package/dist/chunks/files-02zn5y24.js +0 -50
  365. package/dist/chunks/files-02zn5y24.js.map +0 -9
  366. package/dist/chunks/git-panel-mbb0y0hz.js +0 -28
  367. package/dist/chunks/git-panel-mbb0y0hz.js.map +0 -9
  368. package/dist/chunks/layers-panel-9qekmqk1.js +0 -26
  369. package/dist/chunks/layers-panel-9qekmqk1.js.map +0 -9
  370. package/dist/chunks/settings-modal-gj0hrah4.js +0 -29
  371. package/dist/chunks/settings-modal-gj0hrah4.js.map +0 -9
  372. package/dist/chunks/sp-overlay.dev-earn5as8.js +0 -17
  373. package/dist/chunks/sp-overlay.dev-earn5as8.js.map +0 -9
  374. package/dist/chunks/sp-popover.dev-frxtfejn.js +0 -8
  375. package/dist/chunks/sp-popover.dev-frxtfejn.js.map +0 -9
  376. package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js +0 -14
  377. package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js.map +0 -9
  378. package/dist/chunks/sp-tooltip.dev-a9fywesm.js +0 -21
  379. package/dist/chunks/sp-tooltip.dev-a9fywesm.js.map +0 -10
  380. package/dist/chunks/studio-4k0m4fmp.js.map +0 -13
  381. package/dist/chunks/studio-568nx4t5.js +0 -469
  382. package/dist/chunks/studio-568nx4t5.js.map +0 -14
  383. package/dist/chunks/studio-9smfzqhc.js +0 -19
  384. package/dist/chunks/studio-9smfzqhc.js.map +0 -10
  385. package/dist/chunks/studio-asvf0j9e.js +0 -429
  386. package/dist/chunks/studio-asvf0j9e.js.map +0 -13
  387. package/dist/chunks/studio-d77qm3pk.js +0 -3427
  388. package/dist/chunks/studio-d77qm3pk.js.map +0 -26
  389. package/dist/chunks/studio-j6gqyjf4.js +0 -1854
  390. package/dist/chunks/studio-j6gqyjf4.js.map +0 -18
  391. package/dist/chunks/studio-n9zmpxjg.js.map +0 -115
  392. package/dist/chunks/studio-qkvt37c0.js.map +0 -9
  393. package/dist/chunks/studio-rq0vxb19.js +0 -3744
  394. package/dist/chunks/studio-rq0vxb19.js.map +0 -22
  395. package/dist/chunks/studio-wd6ewdvd.js +0 -853
  396. package/dist/chunks/studio-wd6ewdvd.js.map +0 -12
  397. package/dist/chunks/studio-xq0t7n5t.js +0 -140
  398. package/dist/chunks/studio-xq0t7n5t.js.map +0 -10
  399. package/dist/chunks/studio-xze4zrs1.js.map +0 -10
  400. package/dist/chunks/studio-ygzav7z5.js.map +0 -46
  401. package/dist/chunks/studio-zmy7241e.js +0 -399
  402. package/dist/chunks/studio-zmy7241e.js.map +0 -12
  403. package/dist/chunks/ws-client-q7hkz3j9.js.map +0 -11
  404. package/src/browse/browse-modal.ts +0 -69
  405. package/src/browse/browse.ts +0 -904
  406. package/src/canvas/canvas-diff.ts +0 -216
  407. package/src/canvas/nested-site-style.ts +0 -58
  408. package/src/new-project/design-fields.ts +0 -264
  409. package/src/new-project/templates.ts +0 -61
  410. package/src/panels/tab-bar.ts +0 -548
  411. package/src/settings/settings-modal.ts +0 -203
  412. /package/dist/chunks/{adapter-z8fkmbmv.js.map → adapter-qfnesn5v.js.map} +0 -0
  413. /package/dist/chunks/{cf-settings-mmyv436d.js.map → cf-settings-73sh5z4p.js.map} +0 -0
  414. /package/dist/chunks/{format-host-2c188n41.js.map → format-host-mkwsmt8d.js.map} +0 -0
  415. /package/dist/chunks/{jx-validate-qx8degj7.js.map → jx-validate-v6qgknrs.js.map} +0 -0
  416. /package/dist/chunks/{studio-w7c6kvk7.js.map → studio-a7pzveq1.js.map} +0 -0
  417. /package/dist/chunks/{studio-hhj92gcr.js.map → studio-s519zc99.js.map} +0 -0
  418. /package/dist/chunks/{studio-bghqfmea.js.map → studio-wwseqyw0.js.map} +0 -0
  419. /package/dist/chunks/{studio-nqw433kv.js.map → studio-xmvbhg31.js.map} +0 -0
@@ -1,17 +1,33 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Block action bar — extracted from studio.js (Phase 4h). Floating toolbar above selected elements
4
- * with parent selector, move arrows, drag handle, component actions, and inline formatting.
3
+ * Block action bar — the floating toolbar above the selected element.
4
+ *
5
+ * The bar is a RENDERING of the command registry (plan §3.2 region ⑩, §5.5): its verb cluster is
6
+ * `registry.forPlacement("blockbar")`, sliced at {@link BLOCKBAR_MAX_ITEMS} with the remainder
7
+ * behind a `⋮` overflow menu. Every button's accessible name is its record's `title` and its
8
+ * tooltip carries `keymap.formatBinding(id)`, so no action is named twice.
9
+ *
10
+ * `studio-ui-guidelines.md` §8.6 is normative about the shape: **ONE shape.** The bar does not
11
+ * rearrange itself when the author starts typing, and a control that cannot act is DISABLED, not
12
+ * removed — a toolbar whose buttons move under the cursor is worse than one with a greyed button.
13
+ * That is why the parent selector, the drag handle and every verb render unconditionally and take
14
+ * their disabled state (and its one-sentence reason) from the record's own `enablement`.
15
+ *
16
+ * This module is also the single definition site for the structural selection verbs — move
17
+ * up/down/in/out and the component pair — because their implementations live here.
18
+ * {@link registerSelectionCommands} is what a host bootstrap calls to put them in the app-wide
19
+ * registry; until one exists, {@link selectionCommandRegistry} builds the surface's own.
5
20
  */
6
21
 
7
22
  import { html, render as litRender, nothing } from "lit-html";
23
+ import { displayTagName } from "@jxsuite/schema/guards";
8
24
  import { styleMap } from "lit-html/directives/style-map.js";
9
25
  import { ref } from "lit-html/directives/ref.js";
10
26
  import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
11
27
  import { disableNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview";
12
28
 
13
29
  import {
14
- canvasWrap,
30
+ VOID_ELEMENTS,
15
31
  childIndex,
16
32
  childList,
17
33
  getNodeAtPath,
@@ -19,28 +35,53 @@ import {
19
35
  parentElementPath,
20
36
  } from "../store";
21
37
  import { activeTab } from "../workspace/workspace";
22
- import { mutateMoveNode, mutateUpdateProperty, transactDoc } from "../tabs/transact";
38
+ import { activeCanvasSurface, stageContaining } from "../canvas/canvas-surface";
39
+ import { primarySelection, structuralBatch } from "../tabs/selection";
40
+ import {
41
+ mutateDuplicateNodes,
42
+ mutateMoveNode,
43
+ mutateRemoveNodes,
44
+ mutateUpdateProperty,
45
+ transactDoc,
46
+ } from "../tabs/transact";
23
47
  import { view } from "../view";
24
- import { getInlineActions } from "../editor/inline-edit";
25
- import type { InlineAction } from "../editor/inline-edit";
48
+ import { getInlineActions, isInlineElement } from "../editor/inline-edit";
26
49
  import { buildMergeTags, buildRepeaterTagsFromFields } from "../editor/merge-tags";
27
50
  import { findEnclosingRepeater, resolveRepeaterItemFields } from "../editor/repeater-scope";
28
51
  import { projectState } from "../state";
29
52
  import { componentRegistry } from "../files/components";
30
53
  import { convertToComponent } from "../editor/convert-to-component";
31
54
  import { getEditBarAnchorRect, getEditSnapshot, postApplyFormat } from "../canvas/iframe-host";
32
- import { getLayerSlot, isModalOpen } from "../ui/layers";
55
+ import { getLayerSlot, isModalOpen, renderPopover } from "../ui/layers";
33
56
  import { showSlashMenu } from "../editor/slash-menu";
34
57
  import { getConvertTargets } from "../editor/convert-targets";
35
58
  import { rectOf } from "../utils/geometry";
59
+ import { createCommandRegistry } from "../commands/registry";
60
+ import { editorKindForMode, makeContext } from "../commands/context";
61
+ import { defaultCommands, noopCommandDeps } from "../commands/defaults";
36
62
 
63
+ import type { AnyCommand, CommandRegistry } from "../commands/registry";
64
+ import type { CommandContext } from "../commands/context";
65
+ import type { CommandDeps } from "../commands/defaults";
37
66
  import type { ApplyFormatIntent } from "../canvas/iframe-protocol";
38
67
  import type { JxPath } from "../state";
68
+ import type { JxMutableNode } from "@jxsuite/schema/types";
39
69
  import type { TemplateResult } from "lit-html";
40
70
  import type { SlashCommand } from "../editor/convert-targets.js";
41
71
 
42
- /** The plain format commands (everything an action button posts except link/insertData). */
43
- type FormatCommand = Extract<ApplyFormatIntent, { command: "bold" }>["command"];
72
+ /**
73
+ * The plain format commands — everything an action button posts except link/insertData.
74
+ *
75
+ * This read `Extract<ApplyFormatIntent, { command: "bold" }>["command"]`, which resolves to
76
+ * `never`: `Extract` distributes, and the intent member whose `command` is `"bold" | "italic" | …`
77
+ * is not assignable to `{ command: "bold" }`. Every use of the alias was therefore an unchecked
78
+ * cast, and the compiler agreed to all of them. Subtracting the two members that carry extra fields
79
+ * is the shape that actually names the seven.
80
+ */
81
+ type FormatCommand = Exclude<
82
+ Extract<ApplyFormatIntent, { command: string }>["command"],
83
+ "link" | "insertData"
84
+ >;
44
85
 
45
86
  /**
46
87
  * @type {{
@@ -67,7 +108,13 @@ export function initBlockActionBar(ctx: {
67
108
  }) {
68
109
  _ctx = ctx;
69
110
  if (!_formatShortcutBound) {
70
- document.addEventListener("keydown", handleParentFormatShortcut);
111
+ /* The format chords are gone from here. They were a `keydown` switch on ⌘B/⌘I/⌘`/⌘K that ran
112
+ BESIDE the registry's dispatcher on the same document, with its own modal check and its own
113
+ early return — a second keyboard authority for four verbs that now hold their chords as
114
+ records ({@link formatCommands}). Leaving it in place would fire ⌘K twice, and the second
115
+ call re-mounts the link popover over the URL field the first one opened. */
116
+ // ⌥↑ is the keyboard way INTO the bar. The audit found it unreachable without a mouse.
117
+ document.addEventListener("keydown", handleBlockBarEntryKey);
71
118
  // `scroll` doesn't bubble but IS deliverable capture-phase at the document — one app-lifetime
72
119
  // Listener covers .content-edit-canvas (edit mode), window scrolls, and any future scroll
73
120
  // Container, keeping the position:fixed bar tracking its (scrolling) anchor.
@@ -93,7 +140,7 @@ export function onCanvasScroll(e: Event): void {
93
140
  if (!_ctx || _linkPopoverOpen) {
94
141
  return;
95
142
  }
96
- if (!activeTab.value?.session.selection) {
143
+ if (activeTab.value?.session.selection.length === 0) {
97
144
  return;
98
145
  }
99
146
  const mode = _ctx.getCanvasMode();
@@ -103,8 +150,7 @@ export function onCanvasScroll(e: Event): void {
103
150
  // Only canvas-area scrolls (or the window itself) move the anchor; left/right panel scrolling
104
151
  // Never does.
105
152
  const isCanvasScroll =
106
- e.target === document ||
107
- (canvasWrap != null && e.target instanceof Node && canvasWrap.contains(e.target));
153
+ e.target === document || (e.target instanceof Node && stageContaining(e.target) !== null);
108
154
  if (!isCanvasScroll || _scrollRafPending) {
109
155
  return;
110
156
  }
@@ -151,8 +197,15 @@ function barPosition(anchor: {
151
197
  top: number;
152
198
  height: number;
153
199
  }): { left: number; top: number } | null {
154
- if (canvasWrap) {
155
- const wrap = rectOf(canvasWrap);
200
+ /* Clipped against the stage the CARET is on. The bar anchors to one selection in one document,
201
+ and the bar is one element, so "is the anchor still on screen" is a question about ONE pane's
202
+ stage — and the pane is the FOCUSED one, because that is where the caret is. This asked
203
+ "the stage showing the active tab", which was the same answer only while a tab could be on
204
+ screen in one place; with a document displayed in two panes it picked whichever came first in
205
+ the grid, and clipped the bar against a stage the author is not typing in. */
206
+ const stage = activeCanvasSurface().wrap;
207
+ if (stage) {
208
+ const wrap = rectOf(stage);
156
209
  if (anchor.top + anchor.height < wrap.top || anchor.top > wrap.bottom) {
157
210
  return null;
158
211
  }
@@ -176,46 +229,589 @@ function clampBarToWindow(bar: HTMLElement): void {
176
229
  }
177
230
 
178
231
  /**
179
- * Route Ctrl/Cmd+B/I/`/K to the iframe while an inline-edit session is live but focus is on the
180
- * PARENT (the format toolbar or its link popover) — the keystroke never reaches the iframe's own
181
- * contenteditable handler. When focus is inside the canvas iframe, do nothing (the iframe handles
182
- * it and forwards globals via `forwardKey`). Exported so the unit test can dispatch it directly.
232
+ * The inline-format vocabulary, as records.
233
+ *
234
+ * **What this replaces, and what it revives.** Bold, Italic, Code and Link had chords printed in
235
+ * three places — a tooltip, a spec table, a data file's `shortcut: "Cmd+B"` string that is wrong on
236
+ * every Windows machine — and lived in a hand-written `keydown` switch that returned early whenever
237
+ * focus was inside the canvas iframe, which is exactly when a caret exists. Inside the frame
238
+ * `iframe-keys.ts` refused to forward them "because the engine handles them", and the engine did
239
+ * not: the parent's `editor/inline-edit.ts` keydown listener is attached to the BLOCK while the
240
+ * editing host is the canvas container, and `canvas/editable-actions.ts` rejects the browser's own
241
+ * `formatBold` because Jx owns its markup. So ⌘B in the canvas did nothing at all, in an app whose
242
+ * toolbar advertised it.
243
+ *
244
+ * They are the case UX-REDESIGN-PLAN §5.1 uses to justify two fields, so they are written the way
245
+ * it says: `level: "selection"` — a range inside the selected node is still the selection, and a
246
+ * fifth `range` level would demand a fifth region — with `keyScope: "caret"`, which is what makes
247
+ * the chord live only where a caret is. They were, until this change, the only capability in Studio
248
+ * with a chord and no record.
249
+ *
250
+ * **The set is the schema's, the verbs are the registry's.** `data/elements-meta.json` says which
251
+ * of the eight a given tag accepts (`$inlineActions`), so the bar draws four on an `<h1>` and eight
252
+ * on a `<p>`; what each one is CALLED, which chord it holds and what it does are on the record.
253
+ * That is the same division P5.3 made for value sources — caps derive from the schema, behaviour
254
+ * does not.
255
+ *
256
+ * `when` is {@link CommandContext.caret.inCanvas} and not `caret.active`: the caret stack is live
257
+ * in every parent-realm text field too, and ⌘K firing there would re-mount the link popover over
258
+ * the URL the author is typing into it.
259
+ */
260
+ export function formatCommands(): AnyCommand[] {
261
+ const record = (
262
+ command: FormatCommand,
263
+ title: string,
264
+ icon: string,
265
+ keybinding?: string,
266
+ ): AnyCommand => ({
267
+ category: "Edit",
268
+ group: "6_format",
269
+ icon,
270
+ id: `format.${command}`,
271
+ ...(keybinding === undefined ? {} : { keybinding }),
272
+ keyScope: "caret",
273
+ level: "selection",
274
+ menus: ["blockbar/format", "palette"],
275
+ requires: "a text caret in the canvas",
276
+ run: () => {
277
+ postApplyFormat({ command });
278
+ },
279
+ title,
280
+ undo: "document",
281
+ when: (ctx: CommandContext) => ctx.caret.inCanvas,
282
+ });
283
+
284
+ return [
285
+ record("bold", "Bold", "sp-icon-text-bold", "mod+b"),
286
+ record("italic", "Italic", "sp-icon-text-italic", "mod+i"),
287
+ record("underline", "Underline", "sp-icon-text-underline", "mod+u"),
288
+ record("strikethrough", "Strikethrough", "sp-icon-text-strikethrough"),
289
+ record("superscript", "Superscript", "sp-icon-text-superscript"),
290
+ record("subscript", "Subscript", "sp-icon-text-subscript"),
291
+ record("code", "Code", "sp-icon-code", "mod+`"),
292
+ {
293
+ category: "Edit",
294
+ group: "6_format",
295
+ icon: "sp-icon-link",
296
+ id: "format.link",
297
+ /* ⌘K, at `caret` scope, beside `palette.open`'s global ⌘K. Two scopes may hold one chord —
298
+ that is what a shadowing ladder is — and the caret's wins only while a caret is in the
299
+ page. `commands/registry.ts`'s dispatcher takes the narrowest AVAILABLE claimant, so with
300
+ focus in a panel field (where this record's `when` is false) ⌘K still opens the palette. */
301
+ keybinding: "mod+k",
302
+ keyScope: "caret",
303
+ level: "selection",
304
+ menus: ["blockbar/format", "palette"],
305
+ requires: "a text caret in the canvas",
306
+ run: () => {
307
+ openLinkPopoverFromShortcut();
308
+ },
309
+ title: "Link…",
310
+ undo: "document",
311
+ when: (ctx: CommandContext) => ctx.caret.inCanvas,
312
+ },
313
+ ];
314
+ }
315
+
316
+ // ─── The selection command layer ─────────────────────────────────────────────
317
+
318
+ /** Enough of a tab for the structural verbs. Mirrors `activeTab.value`'s non-null type. */
319
+ type ActiveTab = NonNullable<typeof activeTab.value>;
320
+
321
+ /**
322
+ * The node the selection verbs act on, when it is not simply the selection.
323
+ *
324
+ * The block action bar's target IS the selection. An Outline row's target is the ROW'S node —
325
+ * `PLACEMENT_MATRIX["outline/row"]` says so ("row actions act on the row's node"), and a hovered
326
+ * row is not the selected one. Rather than teach every record a second argument, the surface
327
+ * declares the target for the duration of one synchronous render or one click
328
+ * ({@link withCommandTarget}); the records keep reading one function.
329
+ */
330
+ let _commandTarget: JxPath | null = null;
331
+
332
+ /** The path the selection verbs currently address: an explicit target, else the selection. */
333
+ export function commandTargetPath(): JxPath | null {
334
+ return _commandTarget ?? primarySelection(activeTab.value?.session.selection);
335
+ }
336
+
337
+ /**
338
+ * The paths a BATCH verb addresses — Delete and Duplicate, the two that are meaningful on a set.
339
+ *
340
+ * An explicit target wins outright and is always exactly one path:
341
+ * `PLACEMENT_MATRIX["outline/row"]` says a row action acts on the row's node, and a hovered row is
342
+ * not the selection. With no explicit target this is the whole selection, so `length === 1` hands
343
+ * back the single path the verbs have always received.
344
+ *
345
+ * The move verbs deliberately do NOT use this. "Move six nodes up one slot" has no single answer
346
+ * when they are not siblings, and every one of them is `childIndex` arithmetic against a parent
347
+ * that the previous move just renumbered.
348
+ *
349
+ * @returns {JxPath[]}
350
+ */
351
+ export function commandTargetPaths(): JxPath[] {
352
+ if (_commandTarget) {
353
+ return [_commandTarget];
354
+ }
355
+ return activeTab.value?.session.selection ?? [];
356
+ }
357
+
358
+ /** Run `fn` with `path` as the command target. Synchronous — the target never outlives the call. */
359
+ export function withCommandTarget<T>(path: JxPath, fn: () => T): T {
360
+ const previous = _commandTarget;
361
+ _commandTarget = path;
362
+ try {
363
+ return fn();
364
+ } finally {
365
+ _commandTarget = previous;
366
+ }
367
+ }
368
+
369
+ /** Whether `node` is an instance of a registered project component. */
370
+ function isComponentInstanceNode(node: JxMutableNode | null | undefined): boolean {
371
+ const tag = node?.tagName;
372
+ const literal = displayTagName(tag);
373
+ return Boolean(literal.includes("-") && componentRegistry.some((c) => c.tagName === literal));
374
+ }
375
+
376
+ /**
377
+ * The {@link CommandContext} the selection verbs are evaluated against.
378
+ *
379
+ * Only the groups these records read are populated; everything else keeps `emptyContext`'s honest
380
+ * cold-start value. `isRoot` is `parentElementPath(path) === null`, which is the same test in both
381
+ * document modes: a content root sits at `[]`, an element root at `[]` too, and every movable node
382
+ * has a `[…, "children", i]` tail.
383
+ */
384
+ export function selectionCommandContext(): CommandContext {
385
+ const tab = activeTab.value;
386
+ const path = commandTargetPath();
387
+ const node = tab && path ? getNodeAtPath(tab.doc.document, path) : null;
388
+ return makeContext({
389
+ /* The caret, as this surface knows it. `getEditSnapshot().editing` IS "a caret session is live
390
+ in the canvas" — the same fact `commands/live-context.ts` derives from the bridge for the
391
+ app-wide registry — and the format records are gated on it, so without this line the bar
392
+ would draw its own format cluster permanently hidden. `active` and `inCanvas` are the same
393
+ answer here because this surface has no parent-realm text field to confuse it with. */
394
+ caret: { active: getEditSnapshot().editing, inCanvas: getEditSnapshot().editing },
395
+ // The editor kind is part of the answer, not a default: `makeContext` fills it with "none",
396
+ // Which now reads as "not a canvas" and would hide the bar's own verbs from itself.
397
+ editor: { kind: tab ? editorKindForMode(tab.session.ui.canvasMode) : "none" },
398
+ document: { open: Boolean(tab) },
399
+ selection: {
400
+ count: path ? 1 : 0,
401
+ isComponentInstance: isComponentInstanceNode(node),
402
+ isRoot: path ? parentElementPath(path) === null : false,
403
+ kind: displayTagName(node?.tagName),
404
+ },
405
+ });
406
+ }
407
+
408
+ /** The structural facts a move verb needs. `null` when the target cannot be moved at all. */
409
+ interface StructuralTarget {
410
+ tab: ActiveTab;
411
+ path: JxPath;
412
+ index: number;
413
+ parentPath: JxPath;
414
+ siblings: (JxMutableNode | string)[];
415
+ }
416
+
417
+ /** Resolve the current command target to its parent/index/siblings, or `null` if it has none. */
418
+ function structuralTarget(path: JxPath | null = commandTargetPath()): StructuralTarget | null {
419
+ const tab = activeTab.value;
420
+ if (!tab || !path) {
421
+ return null;
422
+ }
423
+ /* THE SAME CONJUNCT `hasSelection` CARRIES, and for the same reason it gives. Four movers gate on
424
+ this function and none of them asked what editor is open, while their peers Delete and
425
+ Duplicate did — so with Project Settings focused the Outline row menu dropped Delete and
426
+ Duplicate and still rendered Move Up / Move Down / Move Into Previous / Move Out of Parent.
427
+ Those four `transactDoc` element splices straight into `project.json` through the transaction
428
+ log and then rewrite `session.selection`; the write lands and is saved. One surface, two rules,
429
+ and the loose ones were the mutating majority. Asked once, here, because all four share it. */
430
+ if (editorKindForMode(tab.session.ui.canvasMode) !== "canvas") {
431
+ return null;
432
+ }
433
+ const index = childIndex(path);
434
+ const parentPath = parentElementPath(path);
435
+ if (typeof index !== "number" || !parentPath) {
436
+ return null;
437
+ }
438
+ const parentNode = getNodeAtPath(tab.doc.document, parentPath);
439
+ if (!parentNode) {
440
+ return null;
441
+ }
442
+ return { index, parentPath, path, siblings: childList(parentNode), tab };
443
+ }
444
+
445
+ /**
446
+ * Whether `node` can accept a dropped-in sibling — the precondition for "move into previous".
183
447
  *
184
- * @param {KeyboardEvent} e
448
+ * A void element cannot; an element whose only children are inline (a paragraph's `<em>`) is a text
449
+ * block, not a container, and nesting a block inside it produces invalid markup.
185
450
  */
186
- export function handleParentFormatShortcut(e: KeyboardEvent): void {
187
- if (!(e.ctrlKey || e.metaKey) || e.altKey) {
451
+ function isContainerNode(node: JxMutableNode | string | null | undefined): boolean {
452
+ if (!node || typeof node !== "object") {
453
+ return false;
454
+ }
455
+ if (VOID_ELEMENTS.has((displayTagName(node.tagName) || "div").toLowerCase())) {
456
+ return false;
457
+ }
458
+ const { children } = node;
459
+ if (!children) {
460
+ return false;
461
+ }
462
+ if (!Array.isArray(children)) {
463
+ return (children as unknown as Record<string, unknown>).$prototype === "Array";
464
+ }
465
+ return (
466
+ children.length === 0 ||
467
+ children.some((c) => typeof c === "object" && c !== null && !isInlineElement(c, node))
468
+ );
469
+ }
470
+
471
+ function canMoveUp(): boolean {
472
+ const target = structuralTarget();
473
+ return target !== null && target.index > 0;
474
+ }
475
+
476
+ function canMoveDown(): boolean {
477
+ const target = structuralTarget();
478
+ return target !== null && target.index < target.siblings.length - 1;
479
+ }
480
+
481
+ function canMoveIn(): boolean {
482
+ const target = structuralTarget();
483
+ return target !== null && target.index > 0 && isContainerNode(target.siblings[target.index - 1]);
484
+ }
485
+
486
+ function canMoveOut(): boolean {
487
+ const target = structuralTarget();
488
+ return (
489
+ target !== null &&
490
+ parentElementPath(target.parentPath) !== null &&
491
+ typeof childIndex(target.parentPath) === "number"
492
+ );
493
+ }
494
+
495
+ /** Move the target up one slot among its siblings, keeping it selected. */
496
+ function moveTargetUp(): void {
497
+ const target = structuralTarget();
498
+ if (!target || target.index === 0) {
188
499
  return;
189
500
  }
190
- // Same rule as the global shortcuts: a modal surface owns the keyboard, so ⌘B never reformats the
191
- // Document behind an underlay the author cannot click through.
192
- if (isModalOpen() || !getEditSnapshot().editing) {
501
+ const { index, parentPath, path, tab } = target;
502
+ transactDoc(tab, (t) => mutateMoveNode(t, path, parentPath, index - 1));
503
+ tab.session.selection = [[...parentPath, "children", index - 1]];
504
+ }
505
+
506
+ /** Move the target down one slot among its siblings, keeping it selected. */
507
+ function moveTargetDown(): void {
508
+ const target = structuralTarget();
509
+ if (!target || target.index >= target.siblings.length - 1) {
193
510
  return;
194
511
  }
195
- // Focus inside the cross-origin canvas iframe surfaces as the <iframe> element being active.
196
- const active = document.activeElement;
197
- if (active instanceof HTMLIFrameElement && active.classList.contains("jx-canvas-iframe")) {
512
+ const { index, parentPath, path, tab } = target;
513
+ // `index + 2`: mutateMoveNode removes before it inserts, so a same-parent move down needs the
514
+ // Post-removal index of the slot AFTER the next sibling.
515
+ transactDoc(tab, (t) => mutateMoveNode(t, path, parentPath, index + 2));
516
+ tab.session.selection = [[...parentPath, "children", index + 1]];
517
+ }
518
+
519
+ /** Append the target to its previous sibling, making it that element's last child. */
520
+ function moveTargetIn(): void {
521
+ const target = structuralTarget();
522
+ if (!canMoveIn() || !target) {
198
523
  return;
199
524
  }
200
- const key = e.key.toLowerCase();
201
- if (key === "b") {
202
- e.preventDefault();
203
- postApplyFormat({ command: "bold" });
204
- } else if (key === "i") {
205
- e.preventDefault();
206
- postApplyFormat({ command: "italic" });
207
- } else if (key === "`") {
208
- e.preventDefault();
209
- postApplyFormat({ command: "code" });
210
- } else if (key === "k") {
211
- e.preventDefault();
212
- openLinkPopoverFromShortcut();
525
+ const { index, parentPath, path, tab } = target;
526
+ const prevPath = [...parentPath, "children", index - 1];
527
+ const { length } = childList(getNodeAtPath(tab.doc.document, prevPath));
528
+ transactDoc(tab, (t) => mutateMoveNode(t, path, prevPath, length));
529
+ tab.session.selection = [[...prevPath, "children", length]];
530
+ }
531
+
532
+ /** Lift the target out of its parent, landing it directly after that parent. */
533
+ function moveTargetOut(): void {
534
+ const target = structuralTarget();
535
+ if (!target) {
536
+ return;
213
537
  }
538
+ const grandparent = parentElementPath(target.parentPath);
539
+ const parentIndex = childIndex(target.parentPath);
540
+ if (!grandparent || typeof parentIndex !== "number") {
541
+ return;
542
+ }
543
+ transactDoc(target.tab, (t) => mutateMoveNode(t, target.path, grandparent, parentIndex + 1));
544
+ target.tab.session.selection = [[...grandparent, "children", parentIndex + 1]];
214
545
  }
215
546
 
216
- /** Pre-built icon templates for inline format buttons (avoids unsafeStatic) */
217
- const formatIconMap = {
547
+ /**
548
+ * Delete every targeted node, leaving the primary's parent selected (never a dangling path).
549
+ *
550
+ * One transaction, therefore one undo step — a six-node delete that produced six history entries
551
+ * would make ⌘Z a rebuild rather than a reversal.
552
+ *
553
+ * **A delete that removes nothing moves nothing.** The selection jump is the visible half of this
554
+ * verb, and running it over an unchanged document is the app telling the author something happened
555
+ * when nothing did. There are two ways to remove nothing, and each is checked on its own side of
556
+ * the transaction:
557
+ *
558
+ * - `mutateRemoveNodes` removes exactly {@link structuralBatch}, which drops every path that names no
559
+ * splice coordinate — the document element, a repeater's map template, a `$switch` case. A target
560
+ * made only of those filters out entirely, and calling `transactDoc` anyway would ALSO mark the
561
+ * tab dirty and push an undo step that reverses nothing. So an empty batch returns before the
562
+ * transaction rather than after it.
563
+ * - `transactDoc` itself declines while a peer holds source-canonical, and says so by leaving the
564
+ * document root reference — which it otherwise always replaces — exactly as it found it.
565
+ */
566
+ function deleteTarget(): void {
567
+ const tab = activeTab.value;
568
+ const paths = commandTargetPaths();
569
+ const path = commandTargetPath();
570
+ // An empty target and a target of nothing-spliceable are the same answer: `structuralBatch([])`
571
+ // Is `[]`, so one test covers both.
572
+ if (!tab || !path || structuralBatch(paths).length === 0) {
573
+ return;
574
+ }
575
+ const documentBefore = tab.doc.document;
576
+ transactDoc(tab, (t) => mutateRemoveNodes(t, paths));
577
+ if (tab.doc.document === documentBefore) {
578
+ return;
579
+ }
580
+ const parentPath = parentElementPath(path);
581
+ tab.session.selection = parentPath ? [parentPath] : [];
582
+ }
583
+
584
+ /** Duplicate every targeted node in one transaction, selecting the copies. */
585
+ function duplicateTarget(): void {
586
+ const tab = activeTab.value;
587
+ const paths = commandTargetPaths();
588
+ if (!tab || paths.length === 0) {
589
+ return;
590
+ }
591
+ transactDoc(tab, (t) => mutateDuplicateNodes(t, paths));
592
+ }
593
+
594
+ /** Select the target's parent element. */
595
+ function selectParentOfTarget(): void {
596
+ const tab = activeTab.value;
597
+ const path = commandTargetPath();
598
+ const parentPath = path ? parentElementPath(path) : null;
599
+ if (tab && parentPath) {
600
+ tab.session.selection = [parentPath];
601
+ }
602
+ }
603
+
604
+ /** What the structural selection verbs need that this module does not own. */
605
+ export interface SelectionCommandDeps {
606
+ /** Open the component definition behind the selected instance. */
607
+ navigateToComponent: (path: string) => void;
608
+ /** Lift the selection into a new component file. */
609
+ convertToComponent: () => void;
610
+ }
611
+
612
+ /** A target exists at all — the `when` every selection verb shares. */
613
+ const hasTarget = (ctx: CommandContext) => ctx.selection.count > 0;
614
+
615
+ /**
616
+ * The structural selection verbs, defined once.
617
+ *
618
+ * `group` keys are ordering keys, not categories: the four moves sort ahead of `3_structure`
619
+ * (Duplicate, from `commands/defaults.ts`) and `9_danger` (Delete, likewise), which is the order
620
+ * the bar and the Outline rows both render in.
621
+ *
622
+ * No `keybinding` is claimed here on purpose. The chord table is `editor/shortcuts.ts`'s port (plan
623
+ * P2 workstream 4); claiming chords from a panel would decide that port's outcome by accident. The
624
+ * buttons still print the chords of the records that DO carry them.
625
+ */
626
+ export function registerSelectionCommands(
627
+ registry: CommandRegistry,
628
+ deps: SelectionCommandDeps,
629
+ ): void {
630
+ registry.registerAll([
631
+ {
632
+ category: "Selection",
633
+ enablement: canMoveUp,
634
+ group: "1_move_1",
635
+ icon: "sp-icon-arrow-up",
636
+ id: "selection.moveUp",
637
+ keyScope: "canvas",
638
+ level: "selection",
639
+ menus: ["blockbar", "outline/row"],
640
+ requires: "an element with a sibling above it",
641
+ run: () => moveTargetUp(),
642
+ title: "Move Up",
643
+ undo: "document",
644
+ when: hasTarget,
645
+ },
646
+ {
647
+ category: "Selection",
648
+ enablement: canMoveDown,
649
+ group: "1_move_2",
650
+ icon: "sp-icon-arrow-down",
651
+ id: "selection.moveDown",
652
+ keyScope: "canvas",
653
+ level: "selection",
654
+ menus: ["blockbar", "outline/row"],
655
+ requires: "an element with a sibling below it",
656
+ run: () => moveTargetDown(),
657
+ title: "Move Down",
658
+ undo: "document",
659
+ when: hasTarget,
660
+ },
661
+ {
662
+ category: "Selection",
663
+ enablement: canMoveIn,
664
+ group: "1_move_3",
665
+ icon: "sp-icon-arrow-right",
666
+ id: "selection.moveIn",
667
+ keyScope: "canvas",
668
+ level: "selection",
669
+ menus: ["outline/row"],
670
+ requires: "a container directly above the element",
671
+ run: () => moveTargetIn(),
672
+ title: "Move Into Previous",
673
+ undo: "document",
674
+ when: hasTarget,
675
+ },
676
+ {
677
+ category: "Selection",
678
+ enablement: canMoveOut,
679
+ group: "1_move_4",
680
+ icon: "sp-icon-arrow-left",
681
+ id: "selection.moveOut",
682
+ keyScope: "canvas",
683
+ level: "selection",
684
+ menus: ["outline/row"],
685
+ requires: "an element nested inside another",
686
+ run: () => moveTargetOut(),
687
+ title: "Move Out of Parent",
688
+ undo: "document",
689
+ when: hasTarget,
690
+ },
691
+ // Convert / Edit are one slot with two states, not two optional buttons: `when` splits them on
692
+ // "is this a component instance", so exactly one of the pair renders for any selection and the
693
+ // Bar keeps its shape.
694
+ {
695
+ category: "Selection",
696
+ // …and the same conjunct here: Convert to Component starts a project-level flow, so it must
697
+ // Not be reachable from a config node drawn as a layer tree.
698
+ enablement: (ctx) =>
699
+ Boolean(ctx.selection.kind) && !ctx.selection.isRoot && ctx.editor.kind === "canvas",
700
+ group: "4_component",
701
+ icon: "sp-icon-box",
702
+ id: "selection.convertToComponent",
703
+ level: "selection",
704
+ // Three surfaces, ONE record. `editor/context-menu.ts` carried a second `convertToComponent`
705
+ // With the same title and a different `when`, invisible to every registry but its own popover.
706
+ menus: ["blockbar", "context/element", "palette"],
707
+ requires: "an element that is not the document root",
708
+ // Deliberately NOT awaited: `convertToComponent()` resolves when the human answers the name
709
+ // Dialog, and a command whose promise waits on a person is a command nothing automated can
710
+ // Call. `run()` means "start this flow", the same as `settings.open` and `project.new`.
711
+ run: () => {
712
+ void deps.convertToComponent();
713
+ },
714
+ title: "Convert to Component",
715
+ undo: "project",
716
+ when: (ctx) => ctx.selection.count > 0 && !ctx.selection.isComponentInstance,
717
+ },
718
+ {
719
+ category: "Selection",
720
+ group: "4_component",
721
+ icon: "sp-icon-edit",
722
+ id: "selection.editComponent",
723
+ level: "selection",
724
+ menus: ["blockbar", "context/element", "palette"],
725
+ requires: "a component instance",
726
+ run: () => {
727
+ const node = componentOfTarget();
728
+ if (node) {
729
+ deps.navigateToComponent(node);
730
+ }
731
+ },
732
+ title: "Edit Component",
733
+ undo: "none",
734
+ when: (ctx) => ctx.selection.isComponentInstance,
735
+ },
736
+ ] satisfies AnyCommand[]);
737
+ }
738
+
739
+ /** The component file behind the current target, when it is an instance. */
740
+ function componentOfTarget(): string | null {
741
+ const tab = activeTab.value;
742
+ const path = commandTargetPath();
743
+ const node = tab && path ? getNodeAtPath(tab.doc.document, path) : null;
744
+ const entry = componentRegistry.find((c) => c.tagName === node?.tagName);
745
+ return entry?.path ?? null;
746
+ }
747
+
748
+ /** The registry the two selection surfaces render. Injected by a host, or built on first use. */
749
+ let _registry: CommandRegistry | null = null;
750
+
751
+ /**
752
+ * Hand the surfaces the app-wide registry (or `null` to drop back to the surface's own).
753
+ *
754
+ * A host that injects one is responsible for two things: registering
755
+ * {@link registerSelectionCommands}, and building its `getContext` so it honours
756
+ * {@link commandTargetPath} — otherwise an Outline row's buttons report the SELECTION's enablement
757
+ * rather than the row's.
758
+ */
759
+ export function useCommandRegistry(registry: CommandRegistry | null): void {
760
+ _registry = registry;
761
+ }
762
+
763
+ /**
764
+ * The registry the block action bar and the Outline rows render.
765
+ *
766
+ * Until a host bootstrap injects one there is no app-wide registry, so this builds the surface's
767
+ * own: the SELECTION-level slice of `commands/defaults.ts` (which is where Delete, Duplicate and
768
+ * Select Parent are defined, with `blockbar` / `outline/row` already declared) plus the structural
769
+ * verbs above. The other levels are filtered out rather than stubbed — Save is not this surface's
770
+ * to own, and a registry holding a no-op `file.save` would be a lie.
771
+ */
772
+ export function selectionCommandRegistry(): CommandRegistry {
773
+ if (_registry) {
774
+ return _registry;
775
+ }
776
+ const registry = createCommandRegistry({ getContext: selectionCommandContext });
777
+ const deps: CommandDeps = {
778
+ ...noopCommandDeps(),
779
+ deleteSelection: deleteTarget,
780
+ duplicateSelection: duplicateTarget,
781
+ selectParent: selectParentOfTarget,
782
+ };
783
+ registry.registerAll(defaultCommands(deps).filter((command) => command.level === "selection"));
784
+ registerSelectionCommands(registry, {
785
+ convertToComponent: () => convertToComponent(),
786
+ navigateToComponent: (path) => _ctx?.navigateToComponent(path),
787
+ });
788
+ // The format cluster renders from records like every other button on this bar, so the surface's
789
+ // Own registry has to hold them — otherwise `keymap.formatBinding("format.bold")` has nothing to
790
+ // Format and the tooltip goes back to the hardcoded `Cmd+B` this change deleted.
791
+ registry.registerAll(formatCommands());
792
+ _registry = registry;
793
+ return registry;
794
+ }
795
+
796
+ /** How many verb buttons the bar renders before the rest fold into `⋮` (plan §3.2 ⑩). */
797
+ export const BLOCKBAR_MAX_ITEMS = 5;
798
+
799
+ /**
800
+ * Icons for command records, keyed by the record's own `icon`.
801
+ *
802
+ * A record with no icon renders its title as text rather than an unlabelled blank: the surface is
803
+ * allowed to choose how a name is drawn, never what it is.
804
+ */
805
+ const commandIconMap: Record<string, TemplateResult> = {
806
+ "sp-icon-arrow-down": html`<sp-icon-arrow-down slot="icon"></sp-icon-arrow-down>`,
807
+ "sp-icon-arrow-left": html`<sp-icon-arrow-left slot="icon"></sp-icon-arrow-left>`,
808
+ "sp-icon-arrow-right": html`<sp-icon-arrow-right slot="icon"></sp-icon-arrow-right>`,
809
+ "sp-icon-arrow-up": html`<sp-icon-arrow-up slot="icon"></sp-icon-arrow-up>`,
810
+ "sp-icon-box": html`<sp-icon-box slot="icon" size="xs"></sp-icon-box>`,
218
811
  "sp-icon-code": html`<sp-icon-code slot="icon"></sp-icon-code>`,
812
+ "sp-icon-delete": html`<sp-icon-delete slot="icon"></sp-icon-delete>`,
813
+ "sp-icon-duplicate": html`<sp-icon-duplicate slot="icon"></sp-icon-duplicate>`,
814
+ "sp-icon-edit": html`<sp-icon-edit slot="icon" size="xs"></sp-icon-edit>`,
219
815
  "sp-icon-link": html`<sp-icon-link slot="icon"></sp-icon-link>`,
220
816
  "sp-icon-text-bold": html`<sp-icon-text-bold slot="icon"></sp-icon-text-bold>`,
221
817
  "sp-icon-text-italic": html`<sp-icon-text-italic slot="icon"></sp-icon-text-italic>`,
@@ -227,7 +823,44 @@ const formatIconMap = {
227
823
  slot="icon"
228
824
  ></sp-icon-text-superscript>`,
229
825
  "sp-icon-text-underline": html`<sp-icon-text-underline slot="icon"></sp-icon-text-underline>`,
230
- } as Record<string, TemplateResult>;
826
+ };
827
+
828
+ /** The record's icon, or its title drawn as a compact label. */
829
+ export function commandIcon(command: AnyCommand): TemplateResult {
830
+ return (
831
+ (command.icon ? commandIconMap[command.icon] : undefined) ??
832
+ html`<span class="cmd-label">${command.title}</span>`
833
+ );
834
+ }
835
+
836
+ /**
837
+ * The tooltip: the chord when the control can act, the `requires` sentence when it cannot.
838
+ *
839
+ * Both strings come from the record. The accessible name stays the bare `title` either way, so a
840
+ * screen reader announces "Delete", not "Delete, requires an element selection".
841
+ */
842
+ export function commandTooltip(registry: CommandRegistry, command: AnyCommand): string {
843
+ const reason = registry.disabledReason(command.id);
844
+ if (reason !== undefined) {
845
+ return `${command.title} — requires ${reason}`;
846
+ }
847
+ const chord = registry.keymap.formatBinding(command.id);
848
+ return chord ? `${command.title} (${chord})` : command.title;
849
+ }
850
+
851
+ /** Run a command, swallowing the refusal a disabled control should never have produced. */
852
+ export function runCommand(registry: CommandRegistry, id: string, target?: JxPath | null): void {
853
+ const invoke = () => {
854
+ if (registry.isEnabled(id)) {
855
+ void registry.run(id);
856
+ }
857
+ };
858
+ if (target) {
859
+ withCommandTarget(target, invoke);
860
+ } else {
861
+ invoke();
862
+ }
863
+ }
231
864
 
232
865
  /**
233
866
  * Prevent the bar from stealing focus from contenteditable
@@ -267,39 +900,34 @@ function onTagBadgeClick(e: MouseEvent, targets: SlashCommand[], selection: JxPa
267
900
  }
268
901
 
269
902
  /**
270
- * Handle a format-button click. The iframe owns the Selection — link opens the parent popover;
271
- * every other command posts an `applyFormat` intent across the bridge.
903
+ * The parent selector.
272
904
  *
273
- * @param {MouseEvent} e
274
- * @param {InlineAction} action
905
+ * It renders the `selection.selectParent` record — its name, its chord and its handler — but as
906
+ * fixed chrome rather than from `forPlacement("blockbar")`, because that record does not declare
907
+ * the placement. Its disabled state is "the selection has no parent", which the record's own
908
+ * `enablement` (a bare "there is a selection") cannot express yet.
275
909
  */
276
- function onFormatClick(e: MouseEvent, action: InlineAction) {
277
- e.stopPropagation();
278
- if (action.command === "link") {
279
- showLinkPopover((e.target as HTMLElement).closest("sp-action-button") as HTMLElement);
280
- } else if (action.command) {
281
- postApplyFormat({ command: action.command as FormatCommand });
282
- }
283
- }
284
-
285
- function renderParentSelector() {
910
+ function renderParentButton(registry: CommandRegistry) {
286
911
  const tab = activeTab.value;
287
- if (!tab?.session.selection) {
288
- return nothing;
289
- }
290
- const pPath = parentElementPath(tab.session.selection);
291
- if (!pPath) {
292
- return nothing;
293
- }
294
- const parentNode = getNodeAtPath(tab.doc.document, pPath);
912
+ const selection = primarySelection(tab?.session.selection);
913
+ const parentPath = selection ? parentElementPath(selection) : null;
914
+ const parentNode = tab && parentPath ? getNodeAtPath(tab.doc.document, parentPath) : null;
915
+ const command = registry.get("selection.selectParent");
916
+ const name = command?.title ?? "Select Parent";
917
+ const chord = command ? registry.keymap.formatBinding(command.id) : undefined;
918
+ const label = parentNode ? `Select parent: ${nodeLabel(parentNode)}` : name;
295
919
  return html`
296
920
  <sp-action-button
297
921
  size="xs"
298
922
  quiet
299
- title="Select parent: ${nodeLabel(parentNode)}"
923
+ data-toolbar-item
924
+ tabindex="-1"
925
+ aria-label=${label}
926
+ title=${chord ? `${label} (${chord})` : label}
927
+ ?disabled=${!parentPath}
300
928
  @click=${(e: MouseEvent) => {
301
929
  e.stopPropagation();
302
- activeTab.value!.session.selection = pPath;
930
+ runCommand(registry, "selection.selectParent");
303
931
  }}
304
932
  >
305
933
  <sp-icon-back slot="icon"></sp-icon-back>
@@ -307,42 +935,217 @@ function renderParentSelector() {
307
935
  `;
308
936
  }
309
937
 
310
- function renderMoveArrows() {
311
- const tab = activeTab.value;
312
- if (!tab?.session.selection) {
313
- return nothing;
314
- }
315
- const sel = tab.session.selection;
316
- const idx = childIndex(sel) as number;
317
- const pPath = parentElementPath(sel);
318
- const parentNode = pPath ? getNodeAtPath(tab.doc.document, pPath) : null;
319
- const siblings = parentNode ? childList(parentNode) : null;
320
- return html`
321
- <sp-action-button
322
- size="xs"
323
- quiet
324
- title="Move up"
325
- ?disabled=${idx <= 0}
326
- @click=${(e: MouseEvent) => {
327
- e.stopPropagation();
328
- moveSelectionUp();
329
- }}
330
- >
331
- <sp-icon-arrow-up slot="icon"></sp-icon-arrow-up>
332
- </sp-action-button>
333
- <sp-action-button
334
- size="xs"
335
- quiet
336
- title="Move down"
337
- ?disabled=${!siblings || idx >= siblings.length - 1}
338
- @click=${(e: MouseEvent) => {
339
- e.stopPropagation();
340
- moveSelectionDown();
341
- }}
938
+ /** One verb, rendered from its record. */
939
+ function renderCommandButton(registry: CommandRegistry, command: AnyCommand) {
940
+ const disabled = registry.disabledReason(command.id) !== undefined;
941
+ return html`<sp-action-button
942
+ size="xs"
943
+ quiet
944
+ class=${command.destructive ? "bar-cmd bar-cmd--danger" : "bar-cmd"}
945
+ data-toolbar-item
946
+ data-command=${command.id}
947
+ tabindex="-1"
948
+ aria-label=${command.title}
949
+ title=${commandTooltip(registry, command)}
950
+ ?disabled=${disabled}
951
+ @mousedown=${(e: MouseEvent) => e.preventDefault()}
952
+ @click=${(e: MouseEvent) => {
953
+ e.stopPropagation();
954
+ runCommand(registry, command.id);
955
+ }}
956
+ >${commandIcon(command)}</sp-action-button
957
+ >`;
958
+ }
959
+
960
+ /** The `⋮` button and the menu of everything past {@link BLOCKBAR_MAX_ITEMS}. */
961
+ function renderOverflowButton(registry: CommandRegistry, commands: readonly AnyCommand[]) {
962
+ return html`<sp-action-button
963
+ size="xs"
964
+ quiet
965
+ class="bar-overflow"
966
+ data-toolbar-item
967
+ tabindex="-1"
968
+ aria-label="More block actions"
969
+ aria-haspopup="menu"
970
+ title="More block actions"
971
+ @mousedown=${(e: MouseEvent) => e.preventDefault()}
972
+ @click=${(e: MouseEvent) => {
973
+ e.stopPropagation();
974
+ showCommandOverflow(e.currentTarget as HTMLElement, registry, commands);
975
+ }}
976
+ >
977
+ <sp-icon-more slot="icon"></sp-icon-more>
978
+ </sp-action-button>`;
979
+ }
980
+
981
+ /** The open overflow menu, so a second press (or a re-render) closes rather than stacks it. */
982
+ let _overflowHandle: { dismiss: () => void; host: HTMLElement } | null = null;
983
+
984
+ /** Close the block action bar's `⋮` menu if it is open. */
985
+ export function dismissBlockBarOverflow(): void {
986
+ _overflowHandle?.dismiss();
987
+ _overflowHandle = null;
988
+ }
989
+
990
+ /**
991
+ * Show the `⋮` menu under `anchor`. Rows carry the same names, chords and refusals as the buttons.
992
+ *
993
+ * Shared by the block action bar and the Outline rows — one menu is open at a time, so `target`
994
+ * (the Outline row's node) is captured per row and re-applied when a row is chosen.
995
+ */
996
+ export function showCommandOverflow(
997
+ anchor: HTMLElement,
998
+ registry: CommandRegistry,
999
+ commands: readonly AnyCommand[],
1000
+ target: JxPath | null = null,
1001
+ ): void {
1002
+ dismissBlockBarOverflow();
1003
+ const rect = rectOf(anchor);
1004
+ // Resolve every row's name / chord / refusal against the TARGET, not the selection, before the
1005
+ // Template is built — lit evaluates these eagerly, so the wrap has to cover the whole projection.
1006
+ const project = () =>
1007
+ commands.map((command) => ({
1008
+ chord: registry.keymap.formatBinding(command.id),
1009
+ destructive: command.destructive === true,
1010
+ disabled: registry.disabledReason(command.id) !== undefined,
1011
+ id: command.id,
1012
+ title: command.title,
1013
+ tooltip: commandTooltip(registry, command),
1014
+ }));
1015
+ const rows = target ? withCommandTarget(target, project) : project();
1016
+ _overflowHandle = renderPopover(
1017
+ html`<sp-popover
1018
+ open
1019
+ class="bar-overflow-menu"
1020
+ style=${styleMap({
1021
+ left: `${rect.left}px`,
1022
+ position: "fixed",
1023
+ top: `${rect.bottom + 4}px`,
1024
+ zIndex: "101",
1025
+ })}
342
1026
  >
343
- <sp-icon-arrow-down slot="icon"></sp-icon-arrow-down>
344
- </sp-action-button>
345
- `;
1027
+ <sp-menu>
1028
+ ${rows.map(
1029
+ (row) => html`<sp-menu-item
1030
+ ?disabled=${row.disabled}
1031
+ data-command=${row.id}
1032
+ title=${row.tooltip}
1033
+ style=${row.destructive ? "color: var(--danger)" : nothing}
1034
+ @click=${() => {
1035
+ dismissBlockBarOverflow();
1036
+ runCommand(registry, row.id, target);
1037
+ }}
1038
+ >${row.title}${
1039
+ row.chord ? html`<kbd slot="value" class="cmd-chord">${row.chord}</kbd>` : nothing
1040
+ }</sp-menu-item
1041
+ >`,
1042
+ )}
1043
+ </sp-menu>
1044
+ </sp-popover>`,
1045
+ {
1046
+ dismissOnOutsideClick: true,
1047
+ onDismiss: () => {
1048
+ _overflowHandle = null;
1049
+ },
1050
+ },
1051
+ );
1052
+ }
1053
+
1054
+ // ─── Toolbar keyboard model ──────────────────────────────────────────────────
1055
+
1056
+ /** The bar's focusable items, in DOM order, skipping the ones that cannot act. */
1057
+ function toolbarItems(bar: HTMLElement): HTMLElement[] {
1058
+ return [...bar.querySelectorAll<HTMLElement>("[data-toolbar-item]")].filter(
1059
+ (el) => !el.hasAttribute("disabled") && el.getAttribute("aria-disabled") !== "true",
1060
+ );
1061
+ }
1062
+
1063
+ /**
1064
+ * Apply the roving tabindex: exactly one item is in the tab order at a time.
1065
+ *
1066
+ * Called after every render, so the survivor is whichever item still holds focus and otherwise the
1067
+ * first — a toolbar that resets to its first control on every keystroke is not navigable.
1068
+ */
1069
+ export function applyRovingTabindex(bar: HTMLElement): void {
1070
+ const items = toolbarItems(bar);
1071
+ const focused = items.findIndex(
1072
+ (el) => el === document.activeElement || el.contains(document.activeElement),
1073
+ );
1074
+ const active = focused === -1 ? 0 : focused;
1075
+ for (const [index, el] of items.entries()) {
1076
+ el.tabIndex = index === active ? 0 : -1;
1077
+ }
1078
+ }
1079
+
1080
+ /** Move focus to item `index` (wrapping), and make it the one in the tab order. */
1081
+ function focusToolbarItem(bar: HTMLElement, index: number): void {
1082
+ const items = toolbarItems(bar);
1083
+ if (items.length === 0) {
1084
+ return;
1085
+ }
1086
+ const wrapped = ((index % items.length) + items.length) % items.length;
1087
+ for (const [i, el] of items.entries()) {
1088
+ el.tabIndex = i === wrapped ? 0 : -1;
1089
+ }
1090
+ items[wrapped]!.focus();
1091
+ }
1092
+
1093
+ /** Put the keyboard back where it came from — the canvas iframe, else its wrapper. */
1094
+ function returnFocusToCanvas(): void {
1095
+ // The FOCUSED pane's stage: the keyboard came from there and is going back there. Resolving it
1096
+ // From the active tab answered with whichever pane displays that document first.
1097
+ const stage = activeCanvasSurface().wrap;
1098
+ const iframe = stage?.querySelector<HTMLElement>("iframe.jx-canvas-iframe");
1099
+ (iframe ?? stage)?.focus();
1100
+ }
1101
+
1102
+ /** `role="toolbar"` navigation: ←/→ between items, Home/End to the ends, Esc back to the canvas. */
1103
+ export function onToolbarKeydown(e: KeyboardEvent): void {
1104
+ const bar = e.currentTarget as HTMLElement;
1105
+ const items = toolbarItems(bar);
1106
+ const current = items.findIndex(
1107
+ (el) => el === document.activeElement || el.contains(document.activeElement),
1108
+ );
1109
+ if (e.key === "ArrowRight") {
1110
+ e.preventDefault();
1111
+ focusToolbarItem(bar, current + 1);
1112
+ } else if (e.key === "ArrowLeft") {
1113
+ e.preventDefault();
1114
+ focusToolbarItem(bar, current - 1);
1115
+ } else if (e.key === "Home") {
1116
+ e.preventDefault();
1117
+ focusToolbarItem(bar, 0);
1118
+ } else if (e.key === "End") {
1119
+ e.preventDefault();
1120
+ focusToolbarItem(bar, items.length - 1);
1121
+ } else if (e.key === "Escape") {
1122
+ e.preventDefault();
1123
+ dismissBlockBarOverflow();
1124
+ returnFocusToCanvas();
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * ⌥↑ enters the bar from the canvas.
1130
+ *
1131
+ * Bound on the PARENT document, so it fires whenever the parent chrome owns focus. A press from
1132
+ * inside the canvas iframe does not arrive: `canvas/iframe-keys.ts` forwards bare navigation keys
1133
+ * only when the target is not editable, and the canvas root is permanently `contenteditable`.
1134
+ * Exported so a test can dispatch it directly.
1135
+ */
1136
+ export function handleBlockBarEntryKey(e: KeyboardEvent): void {
1137
+ if (e.key !== "ArrowUp" || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
1138
+ return;
1139
+ }
1140
+ if (isModalOpen()) {
1141
+ return;
1142
+ }
1143
+ const bar = view.blockActionBarEl?.querySelector<HTMLElement>(".block-action-bar");
1144
+ if (!bar) {
1145
+ return;
1146
+ }
1147
+ e.preventDefault();
1148
+ focusToolbarItem(bar, 0);
346
1149
  }
347
1150
 
348
1151
  /**
@@ -364,7 +1167,7 @@ function onMergeTagClick(e: MouseEvent) {
364
1167
  // When the caret sits inside a repeater, offer its local scope (item/index + item fields). There is
365
1168
  // No live `$map` in edit mode — the perimeter renders one glyph template — so fields resolve
366
1169
  // Parent-side from schema, keyed off the selected element's doc path (which carries a `map` segment).
367
- const selPath = getEditSnapshot().snapshot?.path ?? tab?.session.selection;
1170
+ const selPath = getEditSnapshot().snapshot?.path ?? primarySelection(tab?.session.selection);
368
1171
  const arrayNode = tab && selPath ? findEnclosingRepeater(tab.doc.document, selPath) : null;
369
1172
  if (arrayNode) {
370
1173
  const tokens = resolveRepeaterItemFields(
@@ -397,11 +1200,90 @@ export function dismissLinkPopover() {
397
1200
 
398
1201
  /** Dismiss the block action bar. */
399
1202
  export function dismissBlockActionBar() {
1203
+ /* A dismiss is the bar's STRUCTURAL teardown — `canvas/canvas-render.ts` calls it when a stage is
1204
+ torn down and again on a mode transition — so it also drops any suppression. The bar it would
1205
+ otherwise be holding down does not exist any more; what comes back is a different one. */
1206
+ _suppressedFor = null;
1207
+ dismissBlockBarOverflow();
400
1208
  if (view.blockActionBarEl) {
401
1209
  litRender(nothing, view.blockActionBarEl);
402
1210
  }
403
1211
  }
404
1212
 
1213
+ /**
1214
+ * The document + selection the bar is currently suppressed FOR, or null while it is free to draw.
1215
+ *
1216
+ * **Why a key and not a boolean.** The bar is `position: fixed` and clamped into the window, so it
1217
+ * can sit over the Document Header card, the pane context bar and the docks; an author working in
1218
+ * the Inspector's Logic tab reported it "blocking a part of the interface that the user needs to
1219
+ * utilize". Hiding it on a chrome pointerdown is half an answer — {@link dismissBlockActionBar}
1220
+ * alone would flash back on the very next repaint, because the bar is re-rendered from the
1221
+ * `toolbarRefresh` seam on every selection snapshot and from `renderOnly("overlays")` on zoom/pan.
1222
+ * The key is what makes the state end by itself: a render for a DIFFERENT selection is the click
1223
+ * that hid the bar having also chosen a new target (an Outline row, a Navigator entry), and the bar
1224
+ * belongs over the new one. {@link releaseBlockActionBar} is the other door, for the click that
1225
+ * chose the same target again.
1226
+ *
1227
+ * Nothing outside this module reads it. "Is the bar suppressed" is not a fact any other surface may
1228
+ * branch on — it is one panel declining to draw itself, and a second reader would make it a mode.
1229
+ */
1230
+ let _suppressedFor: { key: string } | null = null;
1231
+
1232
+ /**
1233
+ * What the bar is suppressed FOR: a path, in a document.
1234
+ *
1235
+ * The tab is half the key because `["children",0]` names a node in every document there has ever
1236
+ * been. Keyed on the path alone, an author who suppressed the bar and then switched tabs — itself a
1237
+ * chrome click — would find it hidden over the new document's first block, which they never clicked
1238
+ * away from. `JSON.stringify` rather than `join` for the same reason one level down: the document
1239
+ * root is `[]` and "nothing selected" is `null`, and those two must not compare equal.
1240
+ */
1241
+ function suppressionKey(tab: ActiveTab | null, path: JxPath | null): string {
1242
+ return JSON.stringify([tab?.id ?? null, path]);
1243
+ }
1244
+
1245
+ /**
1246
+ * Hide the bar until the canvas is in play again — the author pressed a pointer down in parent
1247
+ * chrome, which is a surface the bar may be sitting on top of.
1248
+ *
1249
+ * **The selection is deliberately untouched.** The Inspector edits the selected node, so a click
1250
+ * into it is how the author edits the thing they selected; clearing the selection here would make
1251
+ * the panel they just reached for go blank. Only the bar's own rendering is suspended.
1252
+ *
1253
+ * The `⋮` menu and the link popover go with it, because both are ANCHORED to the bar — the overflow
1254
+ * under its `⋮` button, the popover under its Link button. Left open they would float over the
1255
+ * chrome with nothing under them to belong to, which is the same defect one level down. Neither can
1256
+ * be the cause of this call: {@link isEditChromeTarget} exempts both.
1257
+ */
1258
+ export function suppressBlockActionBar(): void {
1259
+ // Clears any earlier key as its first act; this selection's replaces it below.
1260
+ dismissBlockActionBar();
1261
+ if (_linkPopoverOpen) {
1262
+ dismissLinkPopover();
1263
+ }
1264
+ const tab = activeTab.value;
1265
+ _suppressedFor = { key: suppressionKey(tab, primarySelection(tab?.session.selection)) };
1266
+ }
1267
+
1268
+ /**
1269
+ * Let the bar draw again — a pointer went down in a canvas frame, so the canvas is the surface the
1270
+ * author is working on.
1271
+ *
1272
+ * This is the door the SELECTION cannot open: clicking the already-selected element posts the same
1273
+ * path back, so the key comparison in {@link renderBlockActionBar} still matches and the bar would
1274
+ * stay hidden for as long as the author kept clicking the block they are editing.
1275
+ *
1276
+ * A no-op when nothing is suppressed, so the common case — every canvas pointerdown, suppressed or
1277
+ * not — costs a null check rather than a render.
1278
+ */
1279
+ export function releaseBlockActionBar(): void {
1280
+ if (!_suppressedFor) {
1281
+ return;
1282
+ }
1283
+ _suppressedFor = null;
1284
+ renderBlockActionBar();
1285
+ }
1286
+
405
1287
  /**
406
1288
  * Whether the link popover is open. A snapshot-driven {@link renderBlockActionBar} must NOT
407
1289
  * re-render (and so re-mount) the open popover — typing the URL would re-create the field and lose
@@ -428,6 +1310,7 @@ export function isEditChromeTarget(target: EventTarget | null): boolean {
428
1310
  }
429
1311
  const roots = [
430
1312
  view.blockActionBarEl,
1313
+ _overflowHandle?.host ?? null,
431
1314
  getLayerSlot("popover", "link-popover"),
432
1315
  getLayerSlot("popover", "slash-menu"),
433
1316
  ];
@@ -522,47 +1405,17 @@ function showLinkPopover(anchorBtn: HTMLElement) {
522
1405
  */
523
1406
  export function openLinkPopoverFromShortcut(): void {
524
1407
  const bar = view.blockActionBarEl?.querySelector(".block-action-bar") as HTMLElement | null;
1408
+ /* By RECORD ID, not by rendered text. This queried `sp-action-button[title^="Link"]`, which is
1409
+ the addressing §13's first rule bans in the screenshot manifest — a match on a title the app
1410
+ DERIVES (it now reads "Link… (⌘K)", and on Windows it reads something else again). The id is
1411
+ the input the app accepts. */
525
1412
  const linkBtn =
526
- (bar?.querySelector('sp-action-button[title^="Link"]') as HTMLElement | null) ?? bar;
1413
+ (bar?.querySelector('[data-command-id="format.link"]') as HTMLElement | null) ?? bar;
527
1414
  if (linkBtn) {
528
1415
  showLinkPopover(linkBtn);
529
1416
  }
530
1417
  }
531
1418
 
532
- /** Move the selected node up (swap with previous sibling). */
533
- function moveSelectionUp() {
534
- const tab = activeTab.value;
535
- if (!tab?.session.selection || tab.session.selection.length < 2) {
536
- return;
537
- }
538
- const sel = tab.session.selection;
539
- const idx = childIndex(sel) as number;
540
- if (idx <= 0) {
541
- return;
542
- }
543
- const pPath = parentElementPath(sel) as JxPath;
544
- transactDoc(tab, (t) => mutateMoveNode(t, sel, pPath, idx - 1));
545
- tab.session.selection = [...pPath, "children", idx - 1];
546
- }
547
-
548
- /** Move the selected node down (swap with next sibling). */
549
- function moveSelectionDown() {
550
- const tab = activeTab.value;
551
- if (!tab?.session.selection || tab.session.selection.length < 2) {
552
- return;
553
- }
554
- const sel = tab.session.selection;
555
- const idx = childIndex(sel) as number;
556
- const pPath = parentElementPath(sel) as JxPath;
557
- const parentNode = getNodeAtPath(tab.doc.document, pPath);
558
- const siblings = childList(parentNode);
559
- if (idx >= siblings.length - 1) {
560
- return;
561
- }
562
- transactDoc(tab, (t) => mutateMoveNode(t, sel, pPath, idx + 2));
563
- tab.session.selection = [...pPath, "children", idx + 1];
564
- }
565
-
566
1419
  /** Render the unified block action bar above the selected element. */
567
1420
  export function renderBlockActionBar() {
568
1421
  if (!_ctx) {
@@ -572,6 +1425,19 @@ export function renderBlockActionBar() {
572
1425
  view.blockActionBarEl = getLayerSlot("popover", "block-action-bar");
573
1426
  }
574
1427
 
1428
+ /* A snapshot-driven refresh must not re-mount an open link popover — it would re-create the URL
1429
+ field and lose the caret — so this pass is skipped entirely.
1430
+
1431
+ It sits AHEAD of the drag release below, because it is the one early return that LEAVES THE BAR
1432
+ UP. Releasing and then returning without re-rendering left the ⠿ handle on screen and inert, and
1433
+ nothing re-installs it: `dismissLinkPopover` does not re-render the bar. Any repaint reaches
1434
+ here — `applyTransform` → `renderOnly("overlays")` → this — so a pan, a zoom or a pane resize
1435
+ with the Link popover open silently killed dragging until the next selection change. Every
1436
+ other early return draws `nothing`, so releasing before them is right. */
1437
+ if (_linkPopoverOpen) {
1438
+ return;
1439
+ }
1440
+
575
1441
  if (view.selDragCleanup) {
576
1442
  view.selDragCleanup();
577
1443
  view.selDragCleanup = null;
@@ -580,18 +1446,25 @@ export function renderBlockActionBar() {
580
1446
  const tab = activeTab.value;
581
1447
  const canvasMode = _ctx.getCanvasMode();
582
1448
 
583
- if (!tab?.session.selection || (canvasMode !== "design" && canvasMode !== "edit")) {
584
- litRender(nothing, view.blockActionBarEl);
585
- return;
1449
+ const selection = primarySelection(tab?.session.selection);
1450
+
1451
+ /* Suppressed by a chrome pointerdown, and this pass is the first of the two doors out of it (see
1452
+ {@link _suppressedFor}). A different selection means the click that hid the bar also chose a new
1453
+ target, so the suppression is over; the same selection keeps it, which is what stops the next
1454
+ snapshot- or overlay-driven repaint from flashing the bar back under the author's cursor. */
1455
+ if (_suppressedFor) {
1456
+ if (_suppressedFor.key === suppressionKey(tab, selection)) {
1457
+ litRender(nothing, view.blockActionBarEl);
1458
+ return;
1459
+ }
1460
+ _suppressedFor = null;
586
1461
  }
587
1462
 
588
- // A snapshot-driven refresh must not re-mount an open link popover (it would re-create the URL
589
- // Field and lose the caret) — preserve it by skipping this render pass.
590
- if (_linkPopoverOpen) {
1463
+ if (!tab || !selection || (canvasMode !== "design" && canvasMode !== "edit")) {
1464
+ litRender(nothing, view.blockActionBarEl);
591
1465
  return;
592
1466
  }
593
1467
 
594
- const { selection } = tab.session;
595
1468
  const node = getNodeAtPath(tab.doc.document, selection);
596
1469
  if (!node) {
597
1470
  litRender(nothing, view.blockActionBarEl);
@@ -607,7 +1480,7 @@ export function renderBlockActionBar() {
607
1480
  return;
608
1481
  }
609
1482
 
610
- const tag = (node.tagName ?? "div").toLowerCase();
1483
+ const tag = (displayTagName(node.tagName) || "div").toLowerCase();
611
1484
 
612
1485
  // Inline format state, sourced from the iframe's selection snapshot.
613
1486
  const { editingProp, snapshot } = getEditSnapshot();
@@ -631,7 +1504,7 @@ export function renderBlockActionBar() {
631
1504
 
632
1505
  // Conversion targets for badge click
633
1506
  const isComponent =
634
- node.tagName?.includes("-") &&
1507
+ displayTagName(node.tagName).includes("-") &&
635
1508
  componentRegistry.some((/** @type {{ tagName: string }} */ c) => c.tagName === node.tagName);
636
1509
  const children = childList(node);
637
1510
  const isEmpty =
@@ -644,96 +1517,98 @@ export function renderBlockActionBar() {
644
1517
  const convertTargets = !isComponent && !isRepeater ? getConvertTargets(tag, isEmpty) : [];
645
1518
  const badgeInteractive = convertTargets.length > 0;
646
1519
 
1520
+ // The verb cluster, sliced at the cap. `forPlacement` already dropped the records whose `when` is
1521
+ // False and sorted the rest by group; everything past the cap keeps its name and its chord in the
1522
+ // `⋮` menu rather than being silently unavailable.
1523
+ const registry = selectionCommandRegistry();
1524
+ /* The SET is the schema's, the VERBS are the registry's (see {@link formatCommands}).
1525
+ `$inlineActions` says which of the eight this tag accepts and in what order — four on an `<h1>`,
1526
+ eight on a `<p>` — and each one's name, icon, chord and behaviour come off its record. An action
1527
+ the registry has no record for is dropped rather than drawn as a button that runs nothing, so
1528
+ adding a ninth verb to the data file without a record shows up as a missing button and as a red
1529
+ `tests/block-action-bar.test.ts`, not as a silent no-op. */
1530
+ const formatButtons = actions.flatMap((action) => {
1531
+ const command = registry.get(`format.${action.command}`);
1532
+ return command ? [{ action, command }] : [];
1533
+ });
1534
+ const placed = registry.forPlacement("blockbar");
1535
+ const shown = placed.slice(0, BLOCKBAR_MAX_ITEMS);
1536
+ const overflow = placed.slice(BLOCKBAR_MAX_ITEMS);
1537
+ // The handle is chrome, not a verb, so it renders on every selection; only a node that actually
1538
+ // Sits at a child index can be dragged, and at the root it is a disabled affordance rather than a
1539
+ // Missing one (§8.6: ONE shape).
1540
+ const canDragSelection = structuralTarget(selection) !== null;
1541
+
647
1542
  litRender(
648
1543
  html`
649
1544
  <div
650
1545
  class="block-action-bar"
1546
+ data-jx-region="overlay.menu:block-action-bar"
1547
+ role="toolbar"
1548
+ aria-label="Block actions"
1549
+ aria-orientation="horizontal"
651
1550
  style=${styleMap({ left: `${pos.left}px`, top: `${pos.top}px` })}
652
1551
  @mousedown=${onBarMousedown}
1552
+ @keydown=${onToolbarKeydown}
653
1553
  >
654
- ${selection.length >= 2 ? renderParentSelector() : nothing}
1554
+ ${renderParentButton(registry)}
655
1555
 
656
1556
  <span
657
1557
  class="bar-tag${badgeInteractive ? " bar-tag--interactive" : ""}"
1558
+ role=${badgeInteractive ? "button" : nothing}
1559
+ data-toolbar-item=${badgeInteractive ? "" : nothing}
1560
+ tabindex=${badgeInteractive ? "-1" : nothing}
1561
+ title=${badgeInteractive ? "Change element type" : nothing}
658
1562
  @click=${
659
1563
  badgeInteractive
660
1564
  ? (e: MouseEvent) => onTagBadgeClick(e, convertTargets, selection)
661
1565
  : nothing
662
1566
  }
663
- >${isRepeater ? nodeLabel(node) : node.$id || (node.tagName ?? "div")}${
1567
+ >${isRepeater ? nodeLabel(node) : node.$id || displayTagName(node.tagName) || "div"}${
664
1568
  editingProp ? ` · ${editingProp}` : ""
665
1569
  }</span
666
1570
  >
667
1571
 
668
- ${
669
- selection.length >= 2
670
- ? html`<span
671
- class="bar-drag-handle"
672
- title="Drag to reorder"
673
- ${ref((handleEl) => {
674
- if (!handleEl) {
675
- return;
676
- }
677
- if (view.selDragCleanup) {
678
- view.selDragCleanup();
679
- view.selDragCleanup = null;
680
- }
681
- view.selDragCleanup = draggable({
682
- element: handleEl as HTMLElement,
683
- getInitialData: () => ({
684
- // Snapshot the selection: the live array is a Vue reactive proxy, which
685
- // Structured clone rejects when the src crosses postMessage (DataCloneError
686
- // Killed the whole handle drag), and a live reference would also mutate the
687
- // Retained srcData if the selection changed mid-drag.
688
- path: [...(activeTab.value?.session.selection ?? [])],
689
- type: "tree-node",
690
- }),
691
- onGenerateDragPreview: ({
692
- nativeSetDragImage,
693
- }: {
694
- nativeSetDragImage: ((image: Element, x: number, y: number) => void) | null;
695
- }) => {
696
- // Suppress the native drag image; the cross-frame ghost is the drag affordance.
697
- disableNativeDragPreview({ nativeSetDragImage });
698
- },
699
- });
700
- })}
701
- >⠿</span
702
- >`
703
- : nothing
704
- }
705
- ${selection.length >= 2 ? renderMoveArrows() : nothing}
706
- ${
707
- selection.length >= 2 && node.tagName
708
- ? (() => {
709
- const isComp =
710
- node.tagName.includes("-") &&
711
- componentRegistry.some(
712
- (/** @type {{ tagName: string }} */ c) => c.tagName === node.tagName,
713
- );
714
- if (isComp) {
715
- const comp = componentRegistry.find(
716
- (/** @type {{ tagName: string; path: string }} */ c) =>
717
- c.tagName === node.tagName,
718
- );
719
- return html`<sp-action-button
720
- size="xs"
721
- quiet
722
- title="Edit Component"
723
- @click=${() => _ctx?.navigateToComponent(comp?.path as string)}
724
- ><sp-icon-edit slot="icon" size="xs"></sp-icon-edit
725
- ></sp-action-button>`;
726
- }
727
- return html`<sp-action-button
728
- size="xs"
729
- quiet
730
- title="Convert to Component"
731
- @click=${() => convertToComponent()}
732
- ><sp-icon-box slot="icon" size="xs"></sp-icon-box
733
- ></sp-action-button>`;
734
- })()
735
- : nothing
736
- }
1572
+ <span
1573
+ class="bar-drag-handle${canDragSelection ? "" : " bar-drag-handle--disabled"}"
1574
+ title=${canDragSelection ? "Drag to reorder" : "Drag to reorder — the document root cannot move"}
1575
+ aria-disabled=${canDragSelection ? nothing : "true"}
1576
+ data-toolbar-item
1577
+ tabindex="-1"
1578
+ ${ref((handleEl) => {
1579
+ if (!handleEl || !canDragSelection) {
1580
+ return;
1581
+ }
1582
+ /* No release here: {@link renderBlockActionBar} already released at the top of this
1583
+ pass, and that one — not this — is the load-bearing copy, because it also runs on
1584
+ the passes that return before `litRender` and never reach this ref at all. A second
1585
+ release here could only ever see null, which is why it was dead. */
1586
+ view.selDragCleanup = draggable({
1587
+ element: handleEl as HTMLElement,
1588
+ getInitialData: () => ({
1589
+ // Snapshot the selection: the live array is a Vue reactive proxy, which
1590
+ // Structured clone rejects when the src crosses postMessage (DataCloneError
1591
+ // Killed the whole handle drag), and a live reference would also mutate the
1592
+ // Retained srcData if the selection changed mid-drag.
1593
+ path: [...(primarySelection(activeTab.value?.session.selection) ?? [])],
1594
+ type: "tree-node",
1595
+ }),
1596
+ onGenerateDragPreview: ({
1597
+ nativeSetDragImage,
1598
+ }: {
1599
+ nativeSetDragImage: ((image: Element, x: number, y: number) => void) | null;
1600
+ }) => {
1601
+ // Suppress the native drag image; the cross-frame ghost is the drag affordance.
1602
+ disableNativeDragPreview({ nativeSetDragImage });
1603
+ },
1604
+ });
1605
+ })}
1606
+ >⠿</span
1607
+ >
1608
+
1609
+ <sp-divider size="s" vertical></sp-divider>
1610
+ ${shown.map((command) => renderCommandButton(registry, command))}
1611
+ ${overflow.length > 0 ? renderOverflowButton(registry, overflow) : nothing}
737
1612
  ${
738
1613
  showFormat
739
1614
  ? html`
@@ -745,17 +1620,24 @@ export function renderBlockActionBar() {
745
1620
  selects="multiple"
746
1621
  selected=${activeValues.length > 0 ? JSON.stringify(activeValues) : nothing}
747
1622
  >
748
- ${actions.map(
749
- (action) => html`
1623
+ ${formatButtons.map(
1624
+ ({ action, command }) => html`
750
1625
  <sp-action-button
751
1626
  size="xs"
752
1627
  value=${action.tag}
753
- title="${action.label}${action.shortcut ? ` (${action.shortcut})` : ""}"
1628
+ data-command-id=${command.id}
1629
+ data-toolbar-item
1630
+ tabindex="-1"
1631
+ aria-label=${command.title}
1632
+ title=${commandTooltip(registry, command)}
754
1633
  ?disabled=${formatDisabled && action.command !== "link"}
755
1634
  @mousedown=${(e: MouseEvent) => e.preventDefault()}
756
- @click=${(e: MouseEvent) => onFormatClick(e, action)}
1635
+ @click=${(e: MouseEvent) => {
1636
+ e.stopPropagation();
1637
+ runCommand(registry, command.id);
1638
+ }}
757
1639
  >
758
- ${action.icon ? (formatIconMap[action.icon] ?? nothing) : nothing}
1640
+ ${commandIcon(command)}
759
1641
  </sp-action-button>
760
1642
  `,
761
1643
  )}
@@ -763,6 +1645,10 @@ export function renderBlockActionBar() {
763
1645
  <sp-action-button
764
1646
  size="xs"
765
1647
  quiet
1648
+ data-toolbar-item
1649
+ data-jx-region="overlay.menu:block-action-bar/insertData"
1650
+ tabindex="-1"
1651
+ aria-label="Insert data"
766
1652
  title="Insert data"
767
1653
  @mousedown=${(e: MouseEvent) => e.preventDefault()}
768
1654
  @click=${onMergeTagClick}
@@ -777,6 +1663,11 @@ export function renderBlockActionBar() {
777
1663
  view.blockActionBarEl,
778
1664
  );
779
1665
 
1666
+ const rendered = view.blockActionBarEl.querySelector<HTMLElement>(".block-action-bar");
1667
+ if (rendered) {
1668
+ applyRovingTabindex(rendered);
1669
+ }
1670
+
780
1671
  // Post-render side effects
781
1672
  requestAnimationFrame(() => {
782
1673
  const bar = view.blockActionBarEl?.firstElementChild as HTMLElement | null;