@quillmark/svelte 0.26.2 → 0.28.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 (97) hide show
  1. package/NOTICE +4 -4
  2. package/README.md +26 -8
  3. package/THEMING.md +40 -4
  4. package/dist/core/address.d.ts +35 -64
  5. package/dist/core/address.js +35 -55
  6. package/dist/core/bloom.d.ts +4 -10
  7. package/dist/core/bloom.js +14 -27
  8. package/dist/core/codec/blocks.d.ts +41 -43
  9. package/dist/core/codec/blocks.js +97 -130
  10. package/dist/core/codec/code.d.ts +7 -10
  11. package/dist/core/codec/code.js +37 -46
  12. package/dist/core/codec/decode.d.ts +19 -23
  13. package/dist/core/codec/decode.js +76 -111
  14. package/dist/core/codec/encode.d.ts +11 -15
  15. package/dist/core/codec/encode.js +60 -67
  16. package/dist/core/codec/field.d.ts +62 -95
  17. package/dist/core/codec/field.js +107 -179
  18. package/dist/core/codec/islands.d.ts +33 -41
  19. package/dist/core/codec/islands.js +76 -86
  20. package/dist/core/codec/lists.d.ts +17 -26
  21. package/dist/core/codec/lists.js +117 -157
  22. package/dist/core/codec/marks.d.ts +4 -3
  23. package/dist/core/codec/marks.js +8 -3
  24. package/dist/core/codec/prose.css +84 -46
  25. package/dist/core/codec/schema.d.ts +18 -23
  26. package/dist/core/codec/schema.js +84 -76
  27. package/dist/core/codec/slash.d.ts +42 -48
  28. package/dist/core/codec/slash.js +0 -0
  29. package/dist/core/codec/table-copy.d.ts +4 -3
  30. package/dist/core/codec/table-copy.js +25 -7
  31. package/dist/core/codec/table-view.d.ts +12 -12
  32. package/dist/core/codec/table-view.js +460 -383
  33. package/dist/core/codec/table.d.ts +94 -61
  34. package/dist/core/codec/table.js +331 -83
  35. package/dist/core/codec/tasks.d.ts +6 -0
  36. package/dist/core/codec/tasks.js +30 -3
  37. package/dist/core/errors.d.ts +15 -30
  38. package/dist/core/errors.js +2 -6
  39. package/dist/core/index.js +7 -12
  40. package/dist/core/lifecycle.d.ts +7 -16
  41. package/dist/core/lifecycle.js +10 -21
  42. package/dist/core/rebind.svelte.d.ts +10 -20
  43. package/dist/core/rebind.svelte.js +15 -27
  44. package/dist/core/rungs.d.ts +14 -11
  45. package/dist/core/rungs.js +17 -19
  46. package/dist/core/teardown.d.ts +4 -6
  47. package/dist/core/teardown.js +7 -10
  48. package/dist/core/theme.css +74 -54
  49. package/dist/index.js +3 -8
  50. package/dist/preset/app.css +71 -0
  51. package/dist/preset/index.css +8 -7
  52. package/dist/preset/recipes.css +19 -164
  53. package/dist/preset/scale.css +28 -18
  54. package/dist/preset/shell.css +157 -0
  55. package/dist/preview/Preview.svelte +8 -0
  56. package/dist/preview/bridge.d.ts +18 -22
  57. package/dist/preview/bridge.js +29 -58
  58. package/dist/preview/controller.d.ts +21 -29
  59. package/dist/preview/controller.js +14 -39
  60. package/dist/preview/geometry.d.ts +13 -34
  61. package/dist/preview/geometry.js +13 -34
  62. package/dist/preview/index.js +4 -16
  63. package/dist/preview/paint.d.ts +3 -3
  64. package/dist/preview/paint.js +28 -68
  65. package/dist/preview/strings.d.ts +1 -1
  66. package/dist/preview/strings.js +2 -14
  67. package/dist/preview/viewport.js +20 -15
  68. package/dist/preview/zoom.d.ts +21 -22
  69. package/dist/preview/zoom.js +53 -30
  70. package/dist/visual/ArrayField.svelte +77 -16
  71. package/dist/visual/BooleanField.svelte +28 -15
  72. package/dist/visual/Card.svelte +66 -51
  73. package/dist/visual/CardControls.svelte +5 -11
  74. package/dist/visual/DateField.svelte +20 -4
  75. package/dist/visual/EnumField.svelte +3 -0
  76. package/dist/visual/FieldHint.svelte +2 -1
  77. package/dist/visual/FormatPopover.svelte +82 -15
  78. package/dist/visual/FormatPopover.svelte.d.ts +4 -0
  79. package/dist/visual/MatrixAdd.svelte +30 -12
  80. package/dist/visual/MatrixField.svelte +24 -16
  81. package/dist/visual/NumberField.svelte +7 -0
  82. package/dist/visual/ObjectField.svelte +4 -4
  83. package/dist/visual/ProseField.svelte +0 -5
  84. package/dist/visual/TipsCard.svelte +11 -10
  85. package/dist/visual/VariantField.svelte +3 -1
  86. package/dist/visual/VisualEditorInner.svelte +19 -3
  87. package/dist/visual/controls.css +7 -2
  88. package/dist/visual/fonts.d.ts +18 -0
  89. package/dist/visual/fonts.js +59 -0
  90. package/dist/visual/icons/nodes.d.ts +0 -5
  91. package/dist/visual/icons/nodes.js +0 -4
  92. package/dist/visual/props.d.ts +14 -1
  93. package/dist/visual/strings.d.ts +7 -0
  94. package/dist/visual/strings.js +5 -2
  95. package/dist/visual/structure.d.ts +43 -87
  96. package/dist/visual/structure.js +42 -83
  97. package/package.json +2 -2
@@ -0,0 +1,157 @@
1
+ /* The shell: the screen an app mounting two surfaces over one session is, drawn once
2
+ (THEMING §"The shell, if you are building a tool"). `recipes.css` dresses the page a
3
+ surface sits on; this is what it is mounted in, a tool where `.qm-page` is a document.
4
+
5
+ Every selector here computes at specificity zero, as the surfaces' own do: a host's
6
+ rule on the same element wins whatever order the sheets land in, and an app whose
7
+ stylesheet someone else writes (quillkit's app client, under an author's `app.css`)
8
+ needs no knowledge of which of these the client happened to set. `check:style` holds
9
+ the rank. This file mints nothing. */
10
+
11
+ /* Pinned to the viewport rather than sized to it: a page-height box still leaves the
12
+ root scroller counting the overflow its panes clip, and a wheel over the chrome drags
13
+ the whole app off the top. Three bands (a head, the body, an optional foot), and the
14
+ body takes what is left and may shrink below its content, so the panes in it own
15
+ their overflow and hold still while their contents move.
16
+
17
+ The one column is stated for the same reason the rows are: an implicit track is `auto`,
18
+ which is sized by what a band holds, and a `.qm-split` under one contributes its items'
19
+ max-content through its `fr` tracks. The shell would take the width of the widest thing
20
+ mounted in it instead of the viewport's, and every band would be that wide with the
21
+ second pane past the edge. A host holding its content to a maximum never sees it; one
22
+ spending the whole screen sees it on the first surface it mounts. */
23
+ :where(.qm-workspace) {
24
+ position: fixed;
25
+ inset: 0;
26
+ display: grid;
27
+ grid-template-columns: minmax(0, 1fr);
28
+ grid-template-rows: auto minmax(0, 1fr) auto;
29
+ overflow: hidden;
30
+ }
31
+
32
+ /* A gesture past the end of a pane chains out to the viewport and bounces the empty
33
+ document, which is the last way a pinned shell moves. Conditioned on the workspace, so
34
+ a host's other routes keep a page's own feel. */
35
+ :where(html:has(.qm-workspace)) {
36
+ overscroll-behavior: none;
37
+ }
38
+
39
+ /* A band's line: the row a head or a foot puts its parts on, wrapping, centred. That is
40
+ the whole of it. What a band is made of is the host's: the rule under it, the gutter
41
+ beside it, and the depth of it, because where a band ends and how tall it stands are
42
+ page-shell decisions: a site holding its content to a maximum runs the rule past it at
43
+ its own rhythm, and a one-screen tool has no maximum to hold to. */
44
+ :where(.qm-bar) {
45
+ display: flex;
46
+ flex-wrap: wrap;
47
+ align-items: center;
48
+ gap: var(--qmh-space-2) var(--qmh-space-4);
49
+ }
50
+
51
+ /* Two even tracks for two surfaces, and no resizer, since a divider that can be dragged
52
+ is a control competing with what it divides. Each track may shrink below its content,
53
+ which is what lets the frame in it clip rather than grow.
54
+
55
+ The gap is the endorsed default and not a fact about splitting: two surfaces standing
56
+ apart on a page read as two things, which is what a host showing its instruments wants.
57
+ A host that spends the whole screen on the mounts closes it to nothing and carries a
58
+ hairline on one pane instead: one property, and the tracks are untouched. */
59
+ :where(.qm-split) {
60
+ display: grid;
61
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
62
+ grid-template-rows: minmax(0, 1fr);
63
+ gap: var(--qmh-space-4);
64
+ min-height: 0;
65
+ }
66
+
67
+ /* The band that switches them, and nothing at the widths where both tracks are showing
68
+ and there is nothing to switch. Where it stands is the host's, like any other band; what
69
+ goes in it is a pair of `.qm-control`s, which already carry the on-state a pressed
70
+ control takes. Its visibility is the preset's because the threshold is: a host hiding
71
+ its own tab row writes this file's number a second time, which is the one thing the
72
+ single source is for. */
73
+ :where(.qm-switch) {
74
+ display: none;
75
+ }
76
+
77
+ /* One mount at a time under the threshold, rather than two of them abreast. Half a narrow
78
+ viewport is under the width either surface reads at, so the split stops being one and
79
+ the tracks become views of the same room: the host says which with `data-qm-show`, and
80
+ the attribute names the track rather than what is in it, so nothing here knows an editor
81
+ from a preview. No attribute is the first track.
82
+
83
+ The other track is `display: none` and not a clip, which is the whole of the hiding: it
84
+ leaves the tab order, hit-testing and the accessibility tree in one property, so the
85
+ pane needs no `inert` and the host needs no breakpoint in JS. Both stay mounted (a
86
+ preview binds its handle once, an editor holds a caret, a history and a scroll), so
87
+ the switch is presentational, never conditional. A pane hidden this way keeps the
88
+ pixels it had: the paint loop's band sweep no-ops on an empty visible set, and its
89
+ resize observer holds the last real width rather than rastering at the 0×0 box a hidden
90
+ pane reports, so what comes back at the width it left needs no paint at all. The
91
+ preview's caret follow spans the same window — a trip asked for against a pane with no
92
+ box is held and run when it has one (`preview/bridge.ts`), the pane opening at the
93
+ caret rather than several edits behind it.
94
+
95
+ Height is the second clause because a landscape phone passes the first one: it clears
96
+ the width with room for two tracks and has no body left to put them in. A desktop window
97
+ that short wants one mount for the same reason. */
98
+ @media (width < 50rem), (height < 30rem) {
99
+ :where(.qm-switch) {
100
+ display: flex;
101
+ gap: var(--qmh-space);
102
+ }
103
+
104
+ /* Even halves of the band, at the height a thumb needs. The floor is the recipe's and
105
+ not the host's, because the band exists only at the sizes where the pointer is a
106
+ finger: a host is free to stand the band deeper, and has nothing to gain from
107
+ standing it shallower than the thing it is tapping. */
108
+ :where(.qm-switch > *) {
109
+ flex: 1 1 0;
110
+ min-height: var(--qmh-tap);
111
+ }
112
+
113
+ :where(.qm-split) {
114
+ grid-template-columns: minmax(0, 1fr);
115
+ }
116
+
117
+ :where(.qm-split:not([data-qm-show='2']) > :nth-child(2)),
118
+ :where(.qm-split[data-qm-show='2'] > :nth-child(1)) {
119
+ display: none;
120
+ }
121
+ }
122
+
123
+ /* The mounting site: an edge, a corner, and the clip that holds a surface to them, which
124
+ is all a surface painting its own column needs. No tone and no gutter: those are the
125
+ surface's own (THEMING §"Drop it in"). `border-box`, so a stated height is the height
126
+ it draws.
127
+
128
+ Positioned, because a clip reaches only what it is the containing block for: a
129
+ surface's out-of-flow parts (a parked description, an overlay) resolve against the
130
+ nearest positioned ancestor, and with none between them and the document they land at
131
+ their static position unclipped, leaving the page to scroll past its own foot by
132
+ however far the frame's contents run over.
133
+
134
+ The two minimums are what let a frame in a track clip rather than push: a grid or flex
135
+ item floors at its content, so a mount that paints wider than its column widens the
136
+ column instead. They ride the frame rather than the split's children, which keeps every
137
+ rule in this file landing where the host put a class. Outside a track they are the
138
+ initial value and do nothing. */
139
+ :where(.qm-frame) {
140
+ box-sizing: border-box;
141
+ position: relative;
142
+ min-width: 0;
143
+ min-height: 0;
144
+ border: var(--qmh-border-width) solid var(--qmh-border);
145
+ border-radius: var(--qmh-radius);
146
+ overflow: hidden;
147
+ }
148
+
149
+ /* One track is the whole width under the threshold, so a frame's sides and corners would
150
+ frame the screen rather than a pane. It keeps the rules above and below it, which part
151
+ it from the bands. After the frame, since both rank zero and the later one wins. */
152
+ @media (width < 50rem), (height < 30rem) {
153
+ :where(.qm-frame) {
154
+ border-inline: none;
155
+ border-radius: 0;
156
+ }
157
+ }
@@ -178,6 +178,14 @@
178
178
  pointer-events: none;
179
179
  transition: opacity var(--_qm-reveal);
180
180
  }
181
+ /* A screen with no hover has no pointer to bring it back, and the pill is the way back
182
+ to the fit there, so it stays. */
183
+ @media (hover: none) {
184
+ :global(:where(.qm-preview-zoom[data-idle])) {
185
+ opacity: 1;
186
+ pointer-events: auto;
187
+ }
188
+ }
181
189
  :global(:where(.qm-preview-zoom:focus-visible)) {
182
190
  opacity: 1;
183
191
  pointer-events: auto;
@@ -1,15 +1,18 @@
1
1
  import type { LiveSession } from '@quillmark/wasm';
2
2
  import type { Landing } from '../core/address.js';
3
3
  import type { PageSlot } from './paint.js';
4
- /** Both trips are held while the pane has no box to measure against, and the last one
5
- * held runs when it has one again. */
4
+ /**
5
+ * Both trips are held while the pane has no box to measure against; the last one held runs when it
6
+ * has one again.
7
+ */
6
8
  export interface BridgeController {
7
- /** Scroll `field`'s first box to centre; `false` when this compile places none
8
- * for that address, which is the whole of what the preview can say about it. */
9
+ /**
10
+ * Scroll `field`'s first box to centre; `false` when this compile places none for that address.
11
+ */
9
12
  scrollToField(field: string): boolean;
10
13
  /**
11
- * Bring the caret rect at `field`/`pos` into view (a no-op off-content, and a
12
- * no-op when it is already clear of the fold).
14
+ * Bring the caret rect at `field`/`pos` into view; a no-op off-content or when already clear of
15
+ * the fold.
13
16
  */
14
17
  focusPosition(field: string, pos: number): void;
15
18
  destroy(): void;
@@ -21,24 +24,17 @@ export interface VerticalSpan {
21
24
  height: number;
22
25
  }
23
26
  /**
24
- * Whether `target` sits clear of `port`'s edges by the fold's clearance, which is the
25
- * target's own height: a caret rect flush against an edge is visible and unusable, and
26
- * the next line typed lands past it. Derived from the target rather than a margin dial,
27
- * so it scales with zoom exactly as the caret does, and bounded at both ends so the
28
- * answer is reachable across the whole range of targets a compile locates.
29
- *
30
- * The floor is what a zero-height rect gets, which bare intersection would score as
31
- * clear at either edge. The cap is a third of the room the port has to spare, so a
32
- * target too tall for its own height of clearance — a located line at zoom, a short
33
- * split track — passes once centred and holds a band around it, rather than being
34
- * re-centred by every keystroke. A target taller than the port is clear while it covers
35
- * the port: no scroll shows more of it.
27
+ * Whether `target` sits clear of `port`'s edges by the fold's clearance, which is the target's own
28
+ * height, so it scales with zoom as the caret does. The floor is {@link MIN_CLEARANCE}, for a
29
+ * zero-height rect. The cap is a third of the room the port has to spare, so a target too tall for
30
+ * its own clearance passes once centred rather than being re-centred by every keystroke. A target
31
+ * taller than the port is clear while it covers the port.
36
32
  */
37
33
  export declare function clearOfTheFold(target: VerticalSpan, port: VerticalSpan): boolean;
38
- /** The trip a pane with no box could not run, held for the box coming back. One, and the
39
- * last one asked for: a caret hop supersedes the hop before it on a showing pane too.
40
- * A cell rather than closure state, so a bridge rebuilt under a changed page count
41
- * inherits what the one it replaced was holding (`controller.ts`). */
34
+ /**
35
+ * The last trip a pane with no box could not run. A cell so a rebuilt bridge inherits it
36
+ * (`controller.ts`).
37
+ */
42
38
  export interface HeldTrip {
43
39
  run?: () => void;
44
40
  }
@@ -1,32 +1,24 @@
1
1
  import { boxesForField, rectToPercent, clickToPdfPt, pxToPt, applyPercentRect } from './geometry.js';
2
- /** A pane hidden by the shell's narrow switch (`preset/recipes.css`) measures 0×0, and so
3
- * does every box on it: the clearance arithmetic reads that as a caret clear of both
4
- * edges, and a centring computes its trip out of zeros. Neither is an answer about where
5
- * the caret is. */
2
+ /**
3
+ * A pane hidden by the shell's narrow switch (`preset/shell.css`) measures 0×0, as does every box
4
+ * on it, which the clearance arithmetic would read as a caret clear of both edges.
5
+ */
6
6
  function hasBox(port) {
7
7
  return port.width > 0 && port.height > 0;
8
8
  }
9
- /** What a rect reporting no height clears an edge by, in CSS px: a caret's own at the
10
- * smallest zoom a page still reads at. */
9
+ /** What a rect reporting no height clears an edge by, in CSS px. */
11
10
  const MIN_CLEARANCE = 8;
12
- /** What a click may miss a field's ink by and still land on it, in CSS px of the page as
13
- * drawn, so it converts per click at the scale the slot is showing. Sized as the
14
- * pointer's imprecision and not as the leading it partly closes, which is a trade
15
- * PREVIEW.md §"Click bridge" carries. */
11
+ /**
12
+ * What a click may miss a field's ink by and still land on it, in CSS px of the page as drawn
13
+ * (PREVIEW.md §"Click bridge").
14
+ */
16
15
  const CLICK_SLACK_PX = 2;
17
16
  /**
18
- * Whether `target` sits clear of `port`'s edges by the fold's clearance, which is the
19
- * target's own height: a caret rect flush against an edge is visible and unusable, and
20
- * the next line typed lands past it. Derived from the target rather than a margin dial,
21
- * so it scales with zoom exactly as the caret does, and bounded at both ends so the
22
- * answer is reachable across the whole range of targets a compile locates.
23
- *
24
- * The floor is what a zero-height rect gets, which bare intersection would score as
25
- * clear at either edge. The cap is a third of the room the port has to spare, so a
26
- * target too tall for its own height of clearance — a located line at zoom, a short
27
- * split track — passes once centred and holds a band around it, rather than being
28
- * re-centred by every keystroke. A target taller than the port is clear while it covers
29
- * the port: no scroll shows more of it.
17
+ * Whether `target` sits clear of `port`'s edges by the fold's clearance, which is the target's own
18
+ * height, so it scales with zoom as the caret does. The floor is {@link MIN_CLEARANCE}, for a
19
+ * zero-height rect. The cap is a third of the room the port has to spare, so a target too tall for
20
+ * its own clearance passes once centred rather than being re-centred by every keystroke. A target
21
+ * taller than the port is clear while it covers the port.
30
22
  */
31
23
  export function clearOfTheFold(target, port) {
32
24
  if (target.height >= port.height)
@@ -36,19 +28,13 @@ export function clearOfTheFold(target, port) {
36
28
  }
37
29
  export function createBridge(session, container, slots, onPick, held = {}) {
38
30
  const unlisten = [];
39
- // Two rungs: `positionAt` over span-tracked content, `fieldAt` over every placement
40
- // the compile tracks, a strict superset of it (PREVIEW.md §"Click bridge" carries
41
- // the measurement). The second fires where a field is placed without its content
42
- // being tracked, and the pick carries no `pos` there: a fabricated `0` would be an
43
- // invented offset wearing a real one's type. No third rung hit-tests `regions()`,
44
- // whose rects bound ink the field does not fill; the slack both rungs carry is what
45
- // that rung would have been reached for.
31
+ // Two rungs: `positionAt` over span-tracked content, then `fieldAt` over every tracked
32
+ // placement (PREVIEW.md §"Click bridge"). The second carries no `pos`: a fabricated `0` would
33
+ // be an invented offset.
46
34
  if (onPick) {
47
35
  for (const { page, el } of slots) {
48
36
  const handleClick = (ev) => {
49
- // Re-read through the live array: a same-count `refresh` swaps the
50
- // slot objects to re-cache `size`, and the click math must use the
51
- // current compile's PageSize, not the one captured at bridge build.
37
+ // Re-read through the live array: a refresh swaps slot objects to re-cache `size`.
52
38
  const slot = slots[page];
53
39
  if (!slot)
54
40
  return;
@@ -68,9 +54,8 @@ export function createBridge(session, container, slots, onPick, held = {}) {
68
54
  unlisten.push(() => el.removeEventListener('click', handleClick));
69
55
  }
70
56
  }
71
- // Where the % rect `pct` on `slot` currently sits, in the scrollport's own
72
- // coordinates: a throwaway marker, measured and removed. Absolutely positioned,
73
- // so it displaces nothing and no layout survives the read.
57
+ // Where the % rect `pct` on `slot` currently sits: a throwaway absolute marker, measured and
58
+ // removed.
74
59
  function measure(slot, pct) {
75
60
  const marker = document.createElement('div');
76
61
  applyPercentRect(marker, pct);
@@ -79,11 +64,7 @@ export function createBridge(session, container, slots, onPick, held = {}) {
79
64
  marker.remove();
80
65
  return rect;
81
66
  }
82
- // Centre `target` vertically in `port`, the scrollport's box; the cross axis takes the
83
- // minimum trip instead, since the container is a vertical scroller and only a page
84
- // wider than the port has anything to move there. `port` is the caller's own read: the
85
- // marker `measure` places is absolute inside the slot, so nothing between that read
86
- // and this one moves the container's box, and one forced layout serves the whole hop.
67
+ // Centre `target` vertically in `port`; the cross axis takes the minimum trip.
87
68
  function centre(target, port) {
88
69
  container.scrollTop += target.top + target.height / 2 - (port.top + port.height / 2);
89
70
  reveal(target, port);
@@ -94,10 +75,8 @@ export function createBridge(session, container, slots, onPick, held = {}) {
94
75
  else if (target.right > port.right)
95
76
  container.scrollLeft += target.right - port.right;
96
77
  }
97
- // The re-assert. Nothing else asks on the way back: `refresh` re-asks only on a
98
- // recompile, and the reader who has just tapped Preview is not typing, so the pane
99
- // would open where the caret was several edits ago. Guarded for jsdom, which ships no
100
- // ResizeObserver and lays out nothing for one to report.
78
+ // Runs the held trip once the pane has a box again. Guarded for jsdom, which has no
79
+ // ResizeObserver.
101
80
  if (typeof ResizeObserver !== 'undefined') {
102
81
  const observer = new ResizeObserver(() => {
103
82
  if (!held.run || !hasBox(container.getBoundingClientRect()))
@@ -117,14 +96,12 @@ export function createBridge(session, container, slots, onPick, held = {}) {
117
96
  if (!slot)
118
97
  return false;
119
98
  const port = container.getBoundingClientRect();
120
- // The address is placed, which is the whole of what the `boolean` answers; a pane
121
- // with no box yet is not a second no.
99
+ // Placed, so `true`; a pane with no box yet is not a no.
122
100
  if (!hasBox(port)) {
123
101
  held.run = () => void scrollToField(field);
124
102
  return true;
125
103
  }
126
- // A discrete act ("show me this field"), so it centres every time, unlike
127
- // the continuous hop below.
104
+ // A discrete act, so it centres every time.
128
105
  centre(measure(slot, rectToPercent(box.rect, slot.size)), port);
129
106
  return true;
130
107
  }
@@ -135,21 +112,15 @@ export function createBridge(session, container, slots, onPick, held = {}) {
135
112
  const slot = slots[region.page];
136
113
  if (!slot)
137
114
  return;
138
- // Held before the guard below, which a pane measuring zero answers "already clear"
139
- // for every keystroke of (`hasBox`): the follow would die silently for as long as
140
- // a track is hidden. What is held is the request, not the trip, so it re-locates
141
- // against whatever compile is current when the pane comes back.
115
+ // Held before the fold test, which would call a zero-sized pane clear. The request is held,
116
+ // so it re-locates against the compile current on return.
142
117
  const port = container.getBoundingClientRect();
143
118
  if (!hasBox(port)) {
144
119
  held.run = () => focusPosition(field, pos);
145
120
  return;
146
121
  }
147
- // The continuous hop: one call per keystroke and per arrow key, so it moves
148
- // the pane only when the caret has left the fold. Centring unconditionally
149
- // takes the scrollport back from the user on every one of them, including
150
- // the ones that changed nothing about where the caret already was.
151
- // Across, a page zoomed wider than the pane has a caret to keep in view too, and
152
- // the least trip does it without moving the line the reader is on.
122
+ // Called per keystroke, so it moves the pane only when the caret has left the fold; across,
123
+ // the least trip.
153
124
  const target = measure(slot, rectToPercent(region.rect, slot.size));
154
125
  if (clearOfTheFold(target, port))
155
126
  reveal(target, port);
@@ -7,15 +7,18 @@ export interface PreviewOptions {
7
7
  container: HTMLElement;
8
8
  /** Pages kept painted beyond the visible band. Default 1. */
9
9
  margin?: number;
10
- /** A click resolved to an address ({@link Landing}): a caret where the compile
11
- * tracks the content under the point, the field alone where it tracks only the
12
- * placement. The hook does not fire where the compile tracks neither. */
10
+ /**
11
+ * A click resolved to an address ({@link Landing}): a caret where the compile tracks the
12
+ * content under the point, the field alone where it tracks only the placement. Does not fire
13
+ * where it tracks neither.
14
+ */
13
15
  onPick?(at: Landing): void;
14
- /** A page paint the backend refused ({@link EditorErrorHandler}). The preview
15
- * shows its error message state either way; this routes it to an app's sink. */
16
+ /**
17
+ * A page paint the backend refused ({@link EditorErrorHandler}); the error message state shows
18
+ * either way.
19
+ */
16
20
  onError?: EditorErrorHandler;
17
- /** The surface's strings, keyed and partial: unset keys take the package's
18
- * English. */
21
+ /** The surface's strings; unset keys take the package's English. */
19
22
  strings?: PreviewStringsInput;
20
23
  }
21
24
  export interface PreviewController {
@@ -23,37 +26,26 @@ export interface PreviewController {
23
26
  * apply-driven hop. */
24
27
  refresh(change: ChangeSet): void;
25
28
  /**
26
- * Scroll `field`'s first box into view, and end any follow it outranks. `false`
27
- * when this compile places nothing at that address — an answer, not a failure: the
28
- * plate places plenty it does not track, and the preview carries no schema to tell
29
- * that from a field the host misnamed. The editor's `focusField` is what
30
- * distinguishes them.
29
+ * Scroll `field`'s first box into view, and end any follow it outranks. `false` when this
30
+ * compile places nothing at that address; the preview has no schema to tell that from a
31
+ * misnamed field.
31
32
  */
32
33
  scrollToField(field: DocPath): boolean;
33
34
  /**
34
- * Bring the caret at `at` into view: the pane moves only when the caret has left
35
- * the fold.
36
- *
37
- * Takes the editor's own `onCaretMove` payload, so the editor→preview half of
38
- * the bridge is `onCaretMove={preview.focusPosition}` and translates nothing. A
39
- * `ContentHit` carries both members and fits here too.
35
+ * Bring the caret at `at` into view; the pane moves only when the caret has left the fold.
36
+ * Takes the editor's `onCaretMove` payload, so `onCaretMove={preview.focusPosition}` wires it.
40
37
  */
41
38
  focusPosition(at: Place): void;
42
39
  /**
43
- * End the follow: the pane stays where the user left it until the next
44
- * `focusPosition`. A `scrollToField` that placed its target ends one too.
45
- *
46
- * The editor's `onActiveLeafChange` is the signal, firing for a form control as for
47
- * a prose leaf: `onActiveLeafChange={preview.endFollow}`. A control has no caret
48
- * coordinate to report, so unwired the follow keeps naming the leaf the focus left
49
- * and every recompile pulls the pane back there. A prose leaf restarts it with its
50
- * own next caret.
40
+ * End the follow: the pane stays where the user left it until the next `focusPosition`. Wire
41
+ * `onActiveLeafChange={preview.endFollow}`; a form control reports no caret, so unwired the
42
+ * follow keeps pulling the pane back to the leaf the focus left.
51
43
  */
52
44
  endFollow(): void;
53
45
  /**
54
- * Draw every page at `zoom`, a fraction of actual size (`1` is 100%), held between
55
- * the pane's floor and 200%; `undefined` is the default, the widest page fitting the
56
- * pane's width up to 125%. The reader's own gestures move the same zoom.
46
+ * Draw every page at `zoom`, a fraction of actual size (`1` is 100%), held between the pane's
47
+ * floor and 200%; `undefined` is the default, the widest page fitting the pane's width up to
48
+ * 125%.
57
49
  */
58
50
  setZoom(zoom: number | undefined): void;
59
51
  destroy(): void;
@@ -4,8 +4,6 @@ import { createBridge } from './bridge.js';
4
4
  import { createViewport } from './viewport.js';
5
5
  import { mergePreviewStrings } from './strings.js';
6
6
  const CONTAINER_CLASS = 'qm-preview';
7
- // The message states share one element; each carries `MESSAGE_CLASS` plus a state
8
- // class, the hooks a consumer and the tests target.
9
7
  const MESSAGE_CLASS = 'qm-preview-message';
10
8
  const EMPTY_CLASS = 'qm-preview-empty';
11
9
  const ERROR_CLASS = 'qm-preview-error';
@@ -14,8 +12,7 @@ export function createPreview(session, opts) {
14
12
  const margin = opts.margin ?? 1;
15
13
  const t = mergePreviewStrings(opts.strings);
16
14
  container.classList.add(CONTAINER_CLASS);
17
- // One element, restamped: the non-paint states (empty, a paint that
18
- // threw) are mutually exclusive.
15
+ // One element, restamped: the message states are mutually exclusive.
19
16
  let message;
20
17
  function showMessage(text, state) {
21
18
  if (!message) {
@@ -29,12 +26,8 @@ export function createPreview(session, opts) {
29
26
  message?.remove();
30
27
  message = undefined;
31
28
  }
32
- // The paint loop is safe at any page count: zero pages reconciles to zero
33
- // slots and never calls the `paint`/`pageSize` verbs the boundary refuses
34
- // there. A paint that unexpectedly throws is caught per-slot and surfaced
35
- // through the shared message rather than aborting the observer callback
36
- // mid-sweep (a paint is the one surface that can still refuse a counted page;
37
- // brittle to leave uncaught).
29
+ // A paint that throws is caught per slot and surfaced through the message rather than aborting
30
+ // the observer callback mid-sweep.
38
31
  const paintLoop = createPaintLoop(session, container, margin, (page, err) => {
39
32
  reportError(opts.onError, {
40
33
  code: 'paint-failed',
@@ -45,14 +38,10 @@ export function createPreview(session, opts) {
45
38
  });
46
39
  showMessage(t.renderFailed, ERROR_CLASS);
47
40
  });
48
- // The bridge lives and dies with the slot set, so it is held until `render` finds
49
- // a compile paintable. Rebuilt rather than patched whenever the count changes: the
50
- // slots array is a stable identity but its members are swapped on reconcile, and
51
- // the listeners are per slot.
41
+ // Rebuilt whenever the page count changes: the listeners are per slot and slots are swapped on
42
+ // reconcile.
52
43
  let bridge;
53
- // Outlives that rebuild, so a trip held for a pane with no box crosses it. Only the
54
- // follow is re-asserted after a recompile, and a `scrollToField` the pane could not
55
- // run has nothing else that would ask again for it.
44
+ // Outlives the bridge rebuild, so a trip held for a pane with no box crosses it.
56
45
  const held = {};
57
46
  function attach() {
58
47
  bridge = createBridge(session, container, paintLoop.slots, opts.onPick, held);
@@ -61,9 +50,6 @@ export function createPreview(session, opts) {
61
50
  bridge?.destroy();
62
51
  bridge = undefined;
63
52
  }
64
- // Called at construction and after every `apply`, so a 0-page compile that later
65
- // gains pages recovers. A counted page is the whole condition: every backend
66
- // paints, and `paint`/`pageSize` refuse only a page the count excludes.
67
53
  function render(pageCount, dirtyPages) {
68
54
  if (pageCount === 0) {
69
55
  paintLoop.refresh([], 0);
@@ -71,46 +57,35 @@ export function createPreview(session, opts) {
71
57
  showMessage(t.noPages, EMPTY_CLASS);
72
58
  return;
73
59
  }
74
- // Read the count against the current slots before reconcile moves it, and
75
- // clear any prior message before painting so a paint that throws mid-refresh
76
- // leaves its error surfaced rather than have `hideMessage` clobber it.
60
+ // Clear the message before painting so an error from this paint is not clobbered.
77
61
  const countChanged = pageCount !== paintLoop.slots.length;
78
62
  hideMessage();
79
63
  paintLoop.refresh(dirtyPages, pageCount);
80
- // The slot set moved under the bridge's feet, or we are leaving a message state.
81
64
  if (countChanged || !bridge) {
82
65
  detach();
83
66
  attach();
84
67
  }
85
68
  }
86
- // Built before the first bridge, so its ResizeObserver is the earlier one and a pane
87
- // shown again re-fits before the bridge runs the trip it held at the old scale.
69
+ // Built before the first bridge so its ResizeObserver fires first: a re-shown pane re-fits
70
+ // before the bridge runs a held trip.
88
71
  const viewport = createViewport(container, paintLoop.slots, paintLoop.setScale, t.resetZoom);
89
72
  render(session.pageCount, []);
90
73
  viewport.update();
91
- // The last place the editor put the caret, re-located after every recompile and
92
- // dropped on a focus change or a discrete hop. `session.locate` answers against the
93
- // last compiled layout and a consumer debounces `update`, so a caret typed past that
94
- // layout's content is off-content for the whole burst: `focusPosition` no-ops and
95
- // the pane sits still, and a caret event is otherwise the only thing that asks
96
- // again. The re-locate is the preview's, not the consumer's: the staleness sits
97
- // between two session queries this module owns both ends of.
74
+ // The last caret the editor reported, re-located after every recompile: `session.locate`
75
+ // answers against the last compiled layout, so a caret typed past it no-ops until the recompile
76
+ // lands.
98
77
  let followed;
99
78
  return {
100
79
  refresh(change) {
101
80
  render(change.pageCount, change.dirtyPages);
102
- // The default fits the widest page, which a recompile can move.
103
81
  viewport.update();
104
- // Guarded like any other caret hop, so a caret already clear of the fold
105
- // leaves a pane the user scrolled where they left it.
106
82
  if (followed)
107
83
  bridge?.focusPosition(followed.field, followed.pos);
108
84
  },
109
85
  scrollToField(field) {
110
86
  const placed = bridge?.scrollToField(field) ?? false;
111
- // The discrete hop outranks the follow: a caret re-asserted at the next
112
- // recompile would pull the pane straight off the field a host just asked
113
- // for. A hop that placed nothing moved nothing, so the follow stands.
87
+ // The hop outranks the follow, which would otherwise pull the pane back at the next
88
+ // recompile.
114
89
  if (placed)
115
90
  followed = undefined;
116
91
  return placed;