@jxsuite/studio 6.1.0 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/dist/chunks/2020-va2wwra4.js +4 -0
  2. package/dist/chunks/adapter-f93pafd7.js +4 -0
  3. package/dist/chunks/{cf-account-picker-n8hr38v7.js → cf-account-picker-af1h381v.js} +3 -3
  4. package/dist/chunks/cf-settings-125tdm0w.js +4 -0
  5. package/dist/chunks/components-ax2f1fnn.js +4 -0
  6. package/dist/chunks/content-source-108aqtk1.js +4 -0
  7. package/dist/chunks/{convert-to-repeater-7wns8kf1.js → convert-to-repeater-ry5rmppr.js} +3 -3
  8. package/dist/chunks/editor-pfgdmkb0.js +4 -0
  9. package/dist/chunks/entry-commands-t0k1q0kx.js +4 -0
  10. package/dist/chunks/{extension-sections-na7rj3nr.js → extension-sections-6mwh2hf8.js} +3 -3
  11. package/dist/chunks/file-ops-meej5mqf.js +4 -0
  12. package/dist/chunks/files-44vp8a2s.js +4 -0
  13. package/dist/chunks/format-host-7q1petb9.js +4 -0
  14. package/dist/chunks/fs-events-h2mwnxaz.js +4 -0
  15. package/dist/chunks/git-panel-zngbymvd.js +4 -0
  16. package/dist/chunks/{grid-view-7tt4bxw1.js → grid-view-sx9vynsp.js} +15 -15
  17. package/dist/chunks/icons-p44xhjf0.js +4 -0
  18. package/dist/chunks/iframe-host-qk3ycq87.js +4 -0
  19. package/dist/chunks/{index-gybvv7a6.js → index-2veqmnhr.js} +3 -3
  20. package/dist/chunks/index-wkyfjeqz.js +4 -0
  21. package/dist/chunks/{javascript-rnvdp3k4.js → javascript-8nbvs12s.js} +3 -3
  22. package/dist/chunks/jsonMode-s8kt9dsj.js +13 -0
  23. package/dist/chunks/jx-validate-psgd7vsx.js +4 -0
  24. package/dist/chunks/layers-panel-1kq2jyfk.js +4 -0
  25. package/dist/chunks/layers-xd92fg8d.js +4 -0
  26. package/dist/chunks/lit-html-ac4cwwr4.js +4 -0
  27. package/dist/chunks/media-picker-0svzkdyb.js +4 -0
  28. package/dist/chunks/menu-5c55ea3d.js +4 -0
  29. package/dist/chunks/{monaco-binding-2jnhjjvr.js → monaco-binding-z6r70pdt.js} +3 -3
  30. package/dist/chunks/monaco-buffer-fz6gq5y7.js +4 -0
  31. package/dist/chunks/monaco-setup-q4axkgj7.js +4 -0
  32. package/dist/chunks/notify-0xgtn8q1.js +4 -0
  33. package/dist/chunks/platform-v1qctbe6.js +4 -0
  34. package/dist/chunks/publish-panel-ym1fmgq5.js +4 -0
  35. package/dist/chunks/quick-search-a54e6t5n.js +4 -0
  36. package/dist/chunks/references-gmqp82fs.js +4 -0
  37. package/dist/chunks/right-panel-v7t9awbk.js +4 -0
  38. package/dist/chunks/section-registry-vhyf3zsr.js +4 -0
  39. package/dist/chunks/studio-0bhx5hz6.js +5 -0
  40. package/dist/chunks/studio-0k1ddnyy.js +6 -0
  41. package/dist/chunks/studio-1b04pd0j.js +64 -0
  42. package/dist/chunks/{studio-5g0ag7c0.js → studio-1q1sh2ax.js} +3 -3
  43. package/dist/chunks/studio-4dwbj6gb.js +5 -0
  44. package/dist/chunks/studio-4tanh828.js +5 -0
  45. package/dist/chunks/{studio-vzacdfsc.js → studio-55h4mstj.js} +3 -3
  46. package/dist/chunks/studio-5tnvjkz4.js +5 -0
  47. package/dist/chunks/{studio-rggdb3hm.js → studio-60dqgb0c.js} +4 -4
  48. package/dist/chunks/studio-61dp09qg.js +9 -0
  49. package/dist/chunks/studio-63epcrj2.js +5 -0
  50. package/dist/chunks/studio-74k29dy9.js +5 -0
  51. package/dist/chunks/{studio-qvasen3f.js → studio-7vg6jcd3.js} +4 -4
  52. package/dist/chunks/studio-7zx31knw.js +9 -0
  53. package/dist/chunks/studio-8bxk97km.js +5 -0
  54. package/dist/chunks/{studio-anyrgzvb.js → studio-8grwxtqk.js} +4 -4
  55. package/dist/chunks/studio-933e30n4.js +5 -0
  56. package/dist/chunks/{studio-h0xgswew.js → studio-98wnwhbg.js} +4 -4
  57. package/dist/chunks/studio-9mccjwjh.js +14 -0
  58. package/dist/chunks/studio-a2ke1e4t.js +5 -0
  59. package/dist/chunks/studio-e3a2a6v3.js +4 -0
  60. package/dist/chunks/{studio-vk9ezads.js → studio-e81q1vb9.js} +4 -4
  61. package/dist/chunks/{studio-z8w183sh.js → studio-epgrmqqm.js} +4 -4
  62. package/dist/chunks/studio-g1shz58g.js +5 -0
  63. package/dist/chunks/studio-gf0fz03c.js +5 -0
  64. package/dist/chunks/studio-gjtpe62q.js +6 -0
  65. package/dist/chunks/{studio-y78s8r2n.js → studio-gkskhg7m.js} +4 -4
  66. package/dist/chunks/studio-gx4dnjzd.js +5 -0
  67. package/dist/chunks/{studio-pxg4ap1e.js → studio-h46vtzk8.js} +4 -4
  68. package/dist/chunks/studio-j03zga3y.js +9 -0
  69. package/dist/chunks/{studio-ar8jcf2y.js → studio-jhpxeckz.js} +4 -4
  70. package/dist/chunks/{studio-w4c0xmrw.js → studio-k01r7ykp.js} +4 -4
  71. package/dist/chunks/{studio-cpxkgjrj.js → studio-kggey1jt.js} +4 -4
  72. package/dist/chunks/{studio-zxp13sss.js → studio-kk7h30ne.js} +6 -6
  73. package/dist/chunks/{studio-ecaz25vk.js → studio-kt1zqney.js} +4 -4
  74. package/dist/chunks/{studio-zygnmm01.js → studio-m3cwhvye.js} +4 -4
  75. package/dist/chunks/{studio-2sqcndak.js → studio-mcayt6hz.js} +4 -4
  76. package/dist/chunks/{studio-qqz8e5gk.js → studio-myjcghv3.js} +4 -4
  77. package/dist/chunks/studio-n8bxwc3y.js +4 -0
  78. package/dist/chunks/{studio-wfjqhhbk.js → studio-ngm8gvpb.js} +4 -4
  79. package/dist/chunks/{studio-zscd9ty4.js → studio-nj5a01jy.js} +196 -196
  80. package/dist/chunks/{studio-3bpdkq2s.js → studio-pcgz8heb.js} +4 -4
  81. package/dist/chunks/{studio-v2bvmbjj.js → studio-pre0skq6.js} +4 -4
  82. package/dist/chunks/{studio-ghkb32r8.js → studio-qcsq0t15.js} +4 -4
  83. package/dist/chunks/studio-qkstvve9.js +5 -0
  84. package/dist/chunks/studio-qv1pwd3c.js +5 -0
  85. package/dist/chunks/{studio-bmn4mn9j.js → studio-rc6dvxjb.js} +5 -5
  86. package/dist/chunks/{studio-qbj760tx.js → studio-rv11kfwa.js} +7 -7
  87. package/dist/chunks/studio-s8dmmz66.js +5 -0
  88. package/dist/chunks/studio-sp5xzbj4.js +6 -0
  89. package/dist/chunks/{studio-tyasmc39.js → studio-szsjbj0g.js} +4 -4
  90. package/dist/chunks/studio-t1xzawhr.js +16 -0
  91. package/dist/chunks/{studio-skprc6ef.js → studio-t6b6qe31.js} +4 -4
  92. package/dist/chunks/{studio-ghrhy2y7.js → studio-tdcprbqb.js} +4 -4
  93. package/dist/chunks/{studio-txjjzrsm.js → studio-vrmcjf9g.js} +5 -5
  94. package/dist/chunks/studio-vyc0b4he.js +10 -0
  95. package/dist/chunks/{studio-5g4t8v6w.js → studio-w0cgf6jg.js} +4 -4
  96. package/dist/chunks/{studio-acdksxx6.js → studio-w3hskzqk.js} +4 -4
  97. package/dist/chunks/{studio-0x3jyb8r.js → studio-wszjyh0j.js} +4 -4
  98. package/dist/chunks/studio-xgrgcdyh.js +9 -0
  99. package/dist/chunks/{studio-qpqa7vk3.js → studio-xz2yxfcb.js} +4 -4
  100. package/dist/chunks/{studio-77kt594t.js → studio-y6n42tgs.js} +4 -4
  101. package/dist/chunks/{studio-19x1c75w.js → studio-yb560vxr.js} +4 -4
  102. package/dist/chunks/{studio-wgh5kxpj.js → studio-yn9f8vtx.js} +4 -4
  103. package/dist/chunks/{studio-a2138x5k.js → studio-yw05w8f0.js} +5 -5
  104. package/dist/chunks/{studio-h9c4p2tx.js → studio-z16sgavc.js} +4 -4
  105. package/dist/chunks/studio-zey7zsdw.js +12 -0
  106. package/dist/chunks/{studio-g7nse7zb.js → studio-zyqn40w4.js} +4 -4
  107. package/dist/chunks/tsMode-2frah6h1.js +14 -0
  108. package/dist/chunks/workspace-zt81g1ms.js +4 -0
  109. package/dist/chunks/ws-client-n2bh4hkf.js +4 -0
  110. package/dist/iframe-entry.js +13 -13
  111. package/dist/manifest.json +62 -62
  112. package/dist/studio.js +77 -77
  113. package/dist/workers/ts.worker.js +3 -3
  114. package/package.json +10 -10
  115. package/src/canvas/canvas-render.ts +4 -1
  116. package/src/canvas/canvas-utils.ts +73 -15
  117. package/src/canvas/iframe-host.ts +67 -21
  118. package/src/canvas/iframe-inline-edit.ts +22 -4
  119. package/src/canvas/iframe-interaction.ts +7 -1
  120. package/src/canvas/iframe-protocol.ts +3 -0
  121. package/src/canvas/iframe-render.ts +5 -2
  122. package/src/commands/defaults.ts +4 -1
  123. package/src/commands/live-context.ts +3 -1
  124. package/src/editor/shortcuts.ts +55 -27
  125. package/src/files/media-paths.ts +3 -1
  126. package/src/panels/editors.ts +3 -1
  127. package/src/panels/style-panel.ts +36 -11
  128. package/src/platforms/devserver.ts +47 -12
  129. package/src/studio.ts +14 -2
  130. package/src/surfaces/library-pane.ts +6 -0
  131. package/src/surfaces/rail.json +126 -73
  132. package/src/surfaces/rail.ts +31 -5
  133. package/src/tabs/typing.ts +31 -0
  134. package/src/utils/edit-display.ts +30 -1
  135. package/src/workspace/workspace.ts +7 -0
  136. package/dist/chunks/2020-nkxms0ay.js +0 -4
  137. package/dist/chunks/adapter-m948ab5f.js +0 -4
  138. package/dist/chunks/cf-settings-356xdtne.js +0 -4
  139. package/dist/chunks/components-yk3t844e.js +0 -4
  140. package/dist/chunks/content-source-bawmyc09.js +0 -4
  141. package/dist/chunks/editor-6sc8nacq.js +0 -4
  142. package/dist/chunks/entry-commands-8ay9fh81.js +0 -4
  143. package/dist/chunks/file-ops-gye0qw56.js +0 -4
  144. package/dist/chunks/files-dmc2xvcq.js +0 -4
  145. package/dist/chunks/format-host-t76b53f8.js +0 -4
  146. package/dist/chunks/fs-events-cg4tpz06.js +0 -4
  147. package/dist/chunks/git-panel-yef7bshy.js +0 -4
  148. package/dist/chunks/icons-qbh8akb1.js +0 -4
  149. package/dist/chunks/iframe-host-tmz30gfp.js +0 -4
  150. package/dist/chunks/index-nag78a9h.js +0 -4
  151. package/dist/chunks/jsonMode-zhwsprqk.js +0 -13
  152. package/dist/chunks/jx-validate-3gbp5nkn.js +0 -4
  153. package/dist/chunks/layers-fvz99859.js +0 -4
  154. package/dist/chunks/layers-panel-gchmbf9p.js +0 -4
  155. package/dist/chunks/lit-html-9agdspkt.js +0 -4
  156. package/dist/chunks/media-picker-rhjvyy00.js +0 -4
  157. package/dist/chunks/menu-2e4y9j7b.js +0 -4
  158. package/dist/chunks/monaco-buffer-rp21359g.js +0 -4
  159. package/dist/chunks/monaco-setup-vhh7gayr.js +0 -4
  160. package/dist/chunks/notify-h3www560.js +0 -4
  161. package/dist/chunks/platform-hz34wcd7.js +0 -4
  162. package/dist/chunks/publish-panel-exsmjzyj.js +0 -4
  163. package/dist/chunks/quick-search-pgaqeh0m.js +0 -4
  164. package/dist/chunks/references-2cgdxnb8.js +0 -4
  165. package/dist/chunks/right-panel-jf856w5w.js +0 -4
  166. package/dist/chunks/section-registry-jnf8r5gy.js +0 -4
  167. package/dist/chunks/studio-0f55h9ye.js +0 -5
  168. package/dist/chunks/studio-0sqdhnew.js +0 -12
  169. package/dist/chunks/studio-3aey7dd3.js +0 -5
  170. package/dist/chunks/studio-47y6fbpf.js +0 -5
  171. package/dist/chunks/studio-4m3826v2.js +0 -5
  172. package/dist/chunks/studio-4nx2dj1g.js +0 -4
  173. package/dist/chunks/studio-4xv5s47r.js +0 -9
  174. package/dist/chunks/studio-5py436sf.js +0 -5
  175. package/dist/chunks/studio-8f87n763.js +0 -10
  176. package/dist/chunks/studio-8mb00b84.js +0 -5
  177. package/dist/chunks/studio-8p51gq2h.js +0 -6
  178. package/dist/chunks/studio-954ds834.js +0 -5
  179. package/dist/chunks/studio-9ft45c83.js +0 -9
  180. package/dist/chunks/studio-a9yzstqj.js +0 -5
  181. package/dist/chunks/studio-e7xxa0sj.js +0 -5
  182. package/dist/chunks/studio-fzdywr6p.js +0 -9
  183. package/dist/chunks/studio-g5fmf0c1.js +0 -5
  184. package/dist/chunks/studio-geq1ea6k.js +0 -14
  185. package/dist/chunks/studio-grawhey1.js +0 -5
  186. package/dist/chunks/studio-jyzj5cxs.js +0 -5
  187. package/dist/chunks/studio-mgx8yhnz.js +0 -64
  188. package/dist/chunks/studio-mpb04y1d.js +0 -6
  189. package/dist/chunks/studio-n17mty64.js +0 -5
  190. package/dist/chunks/studio-nwh2eqsk.js +0 -6
  191. package/dist/chunks/studio-q1h883bt.js +0 -16
  192. package/dist/chunks/studio-q4tv82h7.js +0 -9
  193. package/dist/chunks/studio-sghy936h.js +0 -4
  194. package/dist/chunks/studio-tt2nactf.js +0 -5
  195. package/dist/chunks/studio-xasj0796.js +0 -5
  196. package/dist/chunks/tsMode-gr0m4dzr.js +0 -14
  197. package/dist/chunks/workspace-m15qtcqp.js +0 -4
  198. package/dist/chunks/ws-client-nf61frr1.js +0 -4
@@ -563,6 +563,9 @@ export type IframeToParent =
563
563
  // Inline editing started in the iframe (the parent shows the format toolbar from here). `prop`
564
564
  // Names the component-instance prop when the session edits prop-bound text (plain sessions).
565
565
  | { kind: "editStart"; path: (string | number)[]; prop?: string }
566
+ // The first keystroke of a typing burst, ahead of the idle tick's commit: the parent marks the tab
567
+ // Dirty now, so Save is enabled the instant there is text a save would keep.
568
+ | { kind: "editInput" }
566
569
  // Committed inline-edit content (rich `children` else `textContent`) for the parent to persist.
567
570
  | {
568
571
  kind: "editCommit";
@@ -1018,8 +1018,11 @@ export async function renderResolvedDocument(opts: {
1018
1018
  // Sections feeding back into an ever-growing height. Set every render (the iframe always wants it).
1019
1019
  setCanvasViewportTranspose(true);
1020
1020
  // De-link `<a href>` in design/edit so clicks select the anchor instead of navigating the iframe;
1021
- // Preview keeps real links live (mirrors the server-function gate above).
1022
- setCanvasDelinkAnchors(opts.mode !== "preview");
1021
+ // Preview keeps real links live (mirrors the server-function gate above). So does Project Styles:
1022
+ // An `<a>` without `href` is not a link, so `:link`, `:any-link` and the UA's link colour could
1023
+ // Never match its specimen, and a project could not see the link styles it was setting there.
1024
+ // Its clicks are refused in the frame instead (`iframe-interaction.ts`).
1025
+ setCanvasDelinkAnchors(opts.mode !== "preview" && opts.mode !== "stylebook");
1023
1026
  /* De-popover in the same modes and for the same reason: an OPEN popover is in the top layer,
1024
1027
  whose containing block is the viewport — a fiction here, since the frame is sized to its own
1025
1028
  content — and which contributes to no ancestor's scrollable overflow, so the artboard could
@@ -236,7 +236,10 @@ export function defaultCommands(deps: CommandDeps): AnyCommand[] {
236
236
  group: "1_file",
237
237
  undo: "none",
238
238
  when: documentOpen,
239
- requires: "an open document",
239
+ // Nothing to save is a disabled button, not a no-op one. A disabled chord is still claimed,
240
+ // So ⌘S on a clean document never falls through to the browser's own Save Page.
241
+ enablement: (ctx) => ctx.document.dirty,
242
+ requires: "unsaved changes",
240
243
  run: () => deps.saveDocument(),
241
244
  },
242
245
  {
@@ -32,6 +32,7 @@ import { shell } from "../shell";
32
32
  import { activeTab, workspace } from "../workspace/workspace";
33
33
  import { primarySelection } from "../tabs/selection";
34
34
  import { canRedo, canUndo } from "../tabs/transact";
35
+ import { hasTyping } from "../tabs/typing";
35
36
  import { collabState } from "../collab/collab-state";
36
37
 
37
38
  import { canvasViewForMode, editorKindForMode, emptyContext } from "./context";
@@ -133,7 +134,8 @@ export function createLiveContext(sources: LiveContextSources): () => CommandCon
133
134
  ctx.git.dirtyCount = shell.git.status?.files.length ?? 0;
134
135
 
135
136
  ctx.document.open = tab !== null;
136
- ctx.document.dirty = tab?.doc.dirty === true;
137
+ // Typing still in a source buffer is unsaved work too, though the document has not seen it.
138
+ ctx.document.dirty = tab ? tab.doc.dirty === true || hasTyping(tab.id) : false;
137
139
  ctx.document.mode = tab?.doc.mode ?? "";
138
140
  ctx.document.canUndo = tab ? canUndo(tab) : false;
139
141
  ctx.document.canRedo = tab ? canRedo(tab) : false;
@@ -51,16 +51,16 @@ import {
51
51
  } from "../tabs/transact";
52
52
  import {
53
53
  applyEditZoom,
54
- markExplicitZoom,
55
54
  requestEditZoom,
56
55
  setEditZoom,
56
+ setUserZoom,
57
+ stageZoom,
57
58
  } from "../canvas/canvas-utils";
58
59
  import { openQuickSearch } from "../panels/quick-search";
59
60
  import { inspectorTab } from "../panels/right-panel";
60
61
  import { requestClose } from "../panels/tab-strip";
61
62
  import { layerHost } from "../ui/layers";
62
63
  import { openDialogSurface } from "../surfaces/dialog";
63
- import { rectOf } from "../utils/geometry";
64
64
  import {
65
65
  DOCK_IDS,
66
66
  requireNavigatorPanelId,
@@ -78,6 +78,8 @@ import { hasElementSelection, hasSelection, inCanvas, keyScopeStack } from "../c
78
78
  import { defaultCommands } from "../commands/defaults";
79
79
  import { setActiveRegistry } from "../commands/active-registry";
80
80
  import { runReported } from "../commands/run-reported";
81
+ import { pointerAnchor } from "../canvas/iframe-host";
82
+ import type { PointerAnchor } from "../canvas/iframe-host";
81
83
  import type { CommandContext } from "../commands/context";
82
84
  import type { DockId as CommandDockId } from "../commands/defaults";
83
85
  import type { AnyCommand, CommandRegistry } from "../commands/registry";
@@ -314,11 +316,15 @@ function redoDocument(): void {
314
316
 
315
317
  // ─── Canvas zoom ──────────────────────────────────────────────────────────────
316
318
 
317
- /** Design-mode zoom bounds. Edit mode clamps inside `canvas-utils`. */
318
- const ZOOM_MIN = 0.05;
319
- const ZOOM_MAX = 5;
319
+ /** One chord step. Both zooms clamp inside `canvas-utils`. */
320
320
  const ZOOM_STEP = 1.2;
321
321
 
322
+ /** How long after an Edit zoom step its anchor is measured again: the zoom's frame, then a reflow. */
323
+ const EDIT_ZOOM_SETTLE_MS = 34;
324
+
325
+ /** How long an Edit zoom burst must be idle before its anchor is let go. */
326
+ const EDIT_ZOOM_RELEASE_MS = 300;
327
+
322
328
  /** Whether the zoom verbs address the reflowing content zoom rather than the artboard transform. */
323
329
  function isContentZoom(ctx: CommandContext): boolean {
324
330
  return ctx.canvas.view === "edit";
@@ -346,8 +352,8 @@ function zoomBy(ctx: CommandContext, factor: number, redraw: () => void): void {
346
352
  setEditZoom((tab.session.ui.editZoom ?? 1) * factor);
347
353
  return;
348
354
  }
349
- const next = (tab.session.ui.zoom ?? 1) * factor;
350
- tab.session.ui.zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, next));
355
+ // Around the stage's middle, like the zoom pod's buttons.
356
+ setUserZoom(stageZoom() * factor);
351
357
  redraw();
352
358
  }
353
359
 
@@ -1074,6 +1080,26 @@ let _stageContext: StageContext | null = null;
1074
1080
  */
1075
1081
  export function installStageGestures(surface: CanvasSurface): () => void {
1076
1082
  const canvasWrap = surface.wrap;
1083
+ /* An Edit zoom burst's anchor: the element under the cursor when the burst began. The frame
1084
+ reflows at every step, so after each one the element is measured again and the scroller takes
1085
+ back whatever it moved; the anchor is let go once the burst has been idle a while. */
1086
+ let zoomAnchor: PointerAnchor | null = null;
1087
+ let zoomRelease: ReturnType<typeof setTimeout> | undefined;
1088
+ let zoomCorrecting = false;
1089
+ const correctEditZoom = () => {
1090
+ const anchor = zoomAnchor;
1091
+ if (!anchor || zoomCorrecting) {
1092
+ return;
1093
+ }
1094
+ zoomCorrecting = true;
1095
+ void anchor.settle().then((top) => {
1096
+ zoomCorrecting = false;
1097
+ const sc = canvasWrap.querySelector<HTMLElement>('[part="edit-canvas"]');
1098
+ if (top !== null && sc) {
1099
+ sc.scrollTop += top - anchor.top;
1100
+ }
1101
+ });
1102
+ };
1077
1103
  const getContext = () => _stageContext?.(surface) ?? stageless();
1078
1104
  const controller = new AbortController();
1079
1105
  const { signal } = controller;
@@ -1093,7 +1119,16 @@ export function installStageGestures(surface: CanvasSurface): () => void {
1093
1119
  if (e.ctrlKey || e.metaKey) {
1094
1120
  e.preventDefault();
1095
1121
  const editZoom = paneTab?.session.ui.editZoom ?? 1;
1096
- requestEditZoom(editZoom * (1 + -e.deltaY * 0.005), surface);
1122
+ const canvasEl = surface.panels[0]?.canvas;
1123
+ zoomAnchor ??= canvasEl ? pointerAnchor(canvasEl) : null;
1124
+ requestEditZoom(editZoom * (1 + -e.deltaY * 0.005), surface, e.clientY);
1125
+ // Measured after the zoom's own frame has applied, and the frame has had one to reflow.
1126
+ setTimeout(correctEditZoom, EDIT_ZOOM_SETTLE_MS);
1127
+ clearTimeout(zoomRelease);
1128
+ zoomRelease = setTimeout(() => {
1129
+ correctEditZoom();
1130
+ zoomAnchor = null;
1131
+ }, EDIT_ZOOM_RELEASE_MS);
1097
1132
  return;
1098
1133
  }
1099
1134
  /* The stage's own scroller, by the `part` the stage document draws it with — the same
@@ -1125,24 +1160,14 @@ export function installStageGestures(surface: CanvasSurface): () => void {
1125
1160
  }
1126
1161
  e.preventDefault();
1127
1162
  if (e.ctrlKey || e.metaKey) {
1128
- // Zoom towards cursor
1129
- const rect = rectOf(canvasWrap);
1130
- const cursorX = e.clientX - rect.left;
1131
- const cursorY = e.clientY - rect.top;
1132
- /* THIS pane's tab, not the focused pane's. Both reads were `activeTab`, so a wheel over
1133
- the unfocused stage magnified a document on the other side of the splitter. */
1134
- const oldZoom = paneTab?.session.ui.zoom ?? 1;
1135
- const delta = -e.deltaY * 0.005;
1136
- const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, oldZoom * (1 + delta)));
1137
- const ratio = newZoom / oldZoom;
1138
- // Adjust pan so the point under cursor stays stationary
1139
- setPan(cursorX - (cursorX - panX) * ratio, cursorY - (cursorY - panY) * ratio);
1140
- if (paneTab) {
1141
- paneTab.session.ui.zoom = newZoom;
1142
- }
1143
- // The author chose this zoom, so re-entering Design keeps it instead of auto-fitting.
1144
- // On THIS stage: the declared fit is a fact about the document under the cursor.
1145
- markExplicitZoom(surface);
1163
+ /* Zoom toward the cursor, on THIS stage (a lens pane carries its own scale). The author
1164
+ chose this zoom, so it is declared as the document's fit and re-entering Design keeps
1165
+ it instead of auto-fitting. */
1166
+ setUserZoom(stageZoom(surface) * (1 + -e.deltaY * 0.005), surface, {
1167
+ clientX: e.clientX,
1168
+ clientY: e.clientY,
1169
+ });
1170
+ return;
1146
1171
  } else if (e.shiftKey) {
1147
1172
  // Shift+scroll = horizontal pan
1148
1173
  setPan(panX - e.deltaY, panY);
@@ -1206,7 +1231,10 @@ export function installStageGestures(surface: CanvasSurface): () => void {
1206
1231
  { signal },
1207
1232
  );
1208
1233
 
1209
- return () => controller.abort();
1234
+ return () => {
1235
+ clearTimeout(zoomRelease);
1236
+ controller.abort();
1237
+ };
1210
1238
  }
1211
1239
 
1212
1240
  /** The context a stage answers with before the bootstrap has published a reader. */
@@ -134,5 +134,7 @@ export function previewFileSrc(path: string): string {
134
134
  if (space === "repo") {
135
135
  return withVersion(`${fileBaseUrl}${encodeProjectPath(normalized)}`, versionOf(normalized));
136
136
  }
137
- return loopbackAssetSrc(mediaSiteUrl(normalized));
137
+ /* Encoded per segment for the `src` only. `mediaSiteUrl` is also what an author's document is
138
+ given, and stays as typed; a `#` or `?` in a filename would cut this URL short. */
139
+ return loopbackAssetSrc(`/${encodeProjectPath(mediaSiteUrl(normalized).slice(1))}`);
138
140
  }
@@ -52,6 +52,7 @@ import { globalEntries, namedFormulaEntries } from "../ui/formula-catalog";
52
52
  import { notify } from "../services/notify";
53
53
 
54
54
  import type { BufferWrites } from "../services/monaco-buffer";
55
+ import { noteTyping } from "../tabs/typing";
55
56
  import type { OxLintDiagnostic } from "../services/code-services";
56
57
  import type { JxPrototypeDef } from "@jxsuite/schema/types";
57
58
  import type { JxPath } from "../state";
@@ -500,8 +501,9 @@ async function mountFunctionEditor(
500
501
  // Stated as a fact rather than left to be read off the timer, because the timer is only ONE of
501
502
  // The ways a buffer gets ahead and the other one is invisible to it. `markTyped` rather than
502
503
  // `markAhead`: this text is the AUTHOR'S, so it is also the text a close would destroy, and
503
- // Format-on-open's (below) is not.
504
+ // Format-on-open's (below) is not. Noted for Save too, so it is enabled ahead of the commit.
504
505
  writes.markTyped();
506
+ noteTyping(tab.id);
505
507
 
506
508
  writes.arm(BUFFER_COMMIT, 500, () => {
507
509
  editor._commitBody?.(editor.getValue());
@@ -1471,13 +1471,32 @@ function buildEditor(
1471
1471
  match, and a menu that offers unmatchable states is a menu people stop reading. What the
1472
1472
  element already DECLARES and the active selector are unioned in after, so nothing an author has
1473
1473
  written can drop out of it. */
1474
- const selectorOptions = [
1475
- ...new Set([
1476
- ...selectorsForNode(node),
1477
- ...declaredSelectors,
1478
- ...(activeSelector ? [activeSelector] : []),
1479
- ]),
1480
- ];
1474
+ /* In Project Styles the coordinate is a TAG PATH (`a`, `table th`) and its states nest under it
1475
+ (`a :hover`). The menu offered the ROOT's states instead, and picking one replaced the tag, so
1476
+ `:hover` styled the document element and a project had no way to write `a:hover` at all. Here
1477
+ the states are the tag's own, spelled as the path they write. */
1478
+ const stylebookPath =
1479
+ stylebookSelector && isTagPath(stylebookSelector) ? stylebookSelector : null;
1480
+ const stylebookLeaf = stylebookPath?.split(" ").at(-1) ?? null;
1481
+ const stylebookDeclared = stylebookPath
1482
+ ? Object.keys(resolveNestedTagStyle(style, stylebookPath)).filter((k) => isNestedSelector(k))
1483
+ : [];
1484
+ const selectorOptions = stylebookPath
1485
+ ? [
1486
+ ...new Set([
1487
+ ...[...selectorsForNode({ tagName: stylebookLeaf ?? "div" }), ...stylebookDeclared].map(
1488
+ (state) => `${stylebookPath} ${state}`,
1489
+ ),
1490
+ ...(activeSelector && activeSelector !== stylebookPath ? [activeSelector] : []),
1491
+ ]),
1492
+ ]
1493
+ : [
1494
+ ...new Set([
1495
+ ...selectorsForNode(node),
1496
+ ...declaredSelectors,
1497
+ ...(activeSelector ? [activeSelector] : []),
1498
+ ]),
1499
+ ];
1481
1500
 
1482
1501
  // ── The Target Line (§6.2) ─────────────────────────────────────────────────
1483
1502
  const stylebookTag = stylebookTagOf(stylebookSelector);
@@ -1486,15 +1505,19 @@ function buildEditor(
1486
1505
  setTargetLine({
1487
1506
  segments: targetSegments(elementLabel, mediaTab, mediaNames.length > 0, schemeLayer),
1488
1507
  selector: {
1489
- value: activeSelector,
1508
+ // The bare tag path IS the base rule in Project Styles, so it reads as the base entry.
1509
+ value: stylebookPath && activeSelector === stylebookPath ? null : activeSelector,
1490
1510
  options: selectorOptions,
1491
- declared: declaredSelectors,
1511
+ declared: stylebookPath
1512
+ ? new Set(stylebookDeclared.map((state) => `${stylebookPath} ${state}`))
1513
+ : declaredSelectors,
1492
1514
  onSelect: (value) => {
1493
1515
  const current = activeTab.value;
1494
1516
  if (!current) {
1495
1517
  return;
1496
1518
  }
1497
- current.session.ui.activeSelector = value;
1519
+ // Clearing the state in Project Styles returns to the tag, never to the document root.
1520
+ current.session.ui.activeSelector = value ?? stylebookPath;
1498
1521
  /* §6.2's own rule: a control that selects a rendering context has to change the rendering,
1499
1522
  or it is a control over a label. `:hover` gets away with not doing this because you can
1500
1523
  hover the element; `:popover-open` cannot, because a closed popover is not on the screen
@@ -1523,7 +1546,9 @@ function buildEditor(
1523
1546
  isNestedSelector(v.trim()) ? "" : 'A selector must start with ":", ".", "&" or "[".',
1524
1547
  }).then((value) => {
1525
1548
  if (value && activeTab.value) {
1526
- activeTab.value.session.ui.activeSelector = value.trim();
1549
+ activeTab.value.session.ui.activeSelector = stylebookPath
1550
+ ? `${stylebookPath} ${value.trim()}`
1551
+ : value.trim();
1527
1552
  }
1528
1553
  });
1529
1554
  },
@@ -98,6 +98,50 @@ export function createDevServerPlatform() {
98
98
  */
99
99
  let _collabConnection: Promise<WsCollabConnection> | null = null;
100
100
 
101
+ /**
102
+ * Bind the server to `root` — the binding its rootless routes and its static fallback (every
103
+ * media thumbnail, every canvas image) resolve against.
104
+ *
105
+ * **The binding is one per SERVER, not per tab.** So an empty root posts nothing: a Studio tab
106
+ * opened with no project used to post `{ root: "" }` at boot, which unbound the server for every
107
+ * other tab and turned their images into 404s. And a tab re-claims it (see
108
+ * {@link reclaimBinding}) whenever it could have been taken.
109
+ */
110
+ async function postActivate(root: string): Promise<void> {
111
+ if (!root) {
112
+ return;
113
+ }
114
+ const res = await fetch("/__studio/activate", {
115
+ body: JSON.stringify({ root }),
116
+ headers: { "Content-Type": "application/json" },
117
+ method: "POST",
118
+ });
119
+ if (!res.ok) {
120
+ const body = (await readJson<ErrorBody>(res).catch(() => ({}))) as ErrorBody;
121
+ throw new Error(
122
+ `Could not open ${root}: ${problemDetail(body) ?? `activation failed (${res.status})`}`,
123
+ );
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Re-bind the server to this tab's project. Two events can take the binding away without this tab
129
+ * hearing of it: another Studio tab activating ITS project, and a restarted `bun run dev`, which
130
+ * comes back bound to nothing. So a tab re-claims it when the author comes back to it (focus) and
131
+ * when the reload stream reconnects (a restart). Only a tab with a project claims anything.
132
+ */
133
+ function reclaimBinding(): void {
134
+ if (!_projectRoot) {
135
+ return;
136
+ }
137
+ _activation = postActivate(_projectRoot).catch((error: unknown) => {
138
+ console.error("Project re-activation failed:", error);
139
+ });
140
+ }
141
+ if (typeof window !== "undefined") {
142
+ window.addEventListener("focus", reclaimBinding);
143
+ }
144
+
101
145
  /**
102
146
  * Prefix a project-relative path with the active project root for server API calls.
103
147
  *
@@ -162,18 +206,7 @@ export function createDevServerPlatform() {
162
206
  * @param {string} [root]
163
207
  */
164
208
  async activate(root?: string) {
165
- const r = root ?? _projectRoot;
166
- const res = await fetch("/__studio/activate", {
167
- body: JSON.stringify({ root: r }),
168
- headers: { "Content-Type": "application/json" },
169
- method: "POST",
170
- });
171
- if (!res.ok) {
172
- const body = (await readJson<ErrorBody>(res).catch(() => ({}))) as ErrorBody;
173
- throw new Error(
174
- `Could not open ${r}: ${problemDetail(body) ?? `activation failed (${res.status})`}`,
175
- );
176
- }
209
+ await postActivate(root ?? _projectRoot);
177
210
  },
178
211
 
179
212
  // ─── Project opening ──────────────────────────────────────────────────
@@ -514,6 +547,8 @@ export function createDevServerPlatform() {
514
547
  es.addEventListener("open", () => {
515
548
  opens += 1;
516
549
  if (opens > 1) {
550
+ // A reconnect is usually a restarted server, which comes back bound to no project.
551
+ reclaimBinding();
517
552
  options?.onResync?.("reconnect");
518
553
  }
519
554
  });
package/src/studio.ts CHANGED
@@ -200,7 +200,8 @@ import * as chatPanelMod from "./panels/chat-panel";
200
200
  import { setProjectAdopter } from "./services/project-adoption";
201
201
  import { setImportHandoff } from "./services/import-seed";
202
202
  import { watchConnection } from "./services/connection";
203
- import { tabBufferUnsaved } from "./services/monaco-buffer";
203
+ import { commitTabBuffers, tabBufferUnsaved } from "./services/monaco-buffer";
204
+ import { clearTyping } from "./tabs/typing";
204
205
  import * as leftPanelMod from "./panels/left-panel";
205
206
  import * as tabStrip from "./panels/tab-strip";
206
207
  import * as paneContext from "./panels/pane-context";
@@ -1492,7 +1493,18 @@ registerStudioCommands(
1492
1493
  // Whether the bytes landed, and a hook that neither supplies one nor reads the answer must not
1493
1494
  // Silently forward its own first argument as the tab to save.
1494
1495
  saveDocument: async () => {
1495
- await saveFile();
1496
+ const tab = activeTab.value;
1497
+ if (!tab) {
1498
+ return;
1499
+ }
1500
+ // What a source buffer holds goes into the document first, or ⌘S inside the commit's
1501
+ // Debounce would save the text from before the last keystrokes.
1502
+ await commitTabBuffers(tab);
1503
+ await saveFile(tab);
1504
+ // Text that would not parse is still only in the buffer, so it is still unsaved.
1505
+ if (!tabBufferUnsaved(tab)) {
1506
+ clearTyping(tab.id);
1507
+ }
1496
1508
  },
1497
1509
  },
1498
1510
  stageContext,
@@ -30,6 +30,7 @@
30
30
 
31
31
  import { reactive } from "../reactivity";
32
32
  import { mountSurface, registerSurface } from "../ui/surface";
33
+ import { placeholderBrokenImages } from "../utils/edit-display";
33
34
  import libraryDoc from "./library-pane.json";
34
35
  import type { JxDocument, JxElement } from "@jxsuite/schema/types";
35
36
  import type { JxScope } from "@jxsuite/runtime/types";
@@ -267,6 +268,10 @@ export function mountLibrarySurface(
267
268
  actions: LibraryActions,
268
269
  ): LibrarySurfaceHandle {
269
270
  host.replaceChildren();
271
+ // A file that will not load, or a preview image with nothing to bind, draws a placeholder rather
272
+ // Than the browser's broken-image glyph.
273
+ const listening = new AbortController();
274
+ placeholderBrokenImages(host, listening.signal);
270
275
  const scope = reactive<LibraryScope>({
271
276
  bannerState: "hidden",
272
277
  bannerText: "",
@@ -355,6 +360,7 @@ export function mountLibrarySurface(
355
360
  },
356
361
  dispose() {
357
362
  disposed = true;
363
+ listening.abort();
358
364
  mounted?.dispose();
359
365
  mounted = null;
360
366
  },