@quillmark/svelte 0.6.0 → 0.7.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 (37) hide show
  1. package/THEMING.md +1 -1
  2. package/dist/core/codec/field.d.ts +15 -4
  3. package/dist/core/codec/field.js +13 -10
  4. package/dist/core/codec/index.d.ts +1 -1
  5. package/dist/core/codec/index.js +1 -1
  6. package/dist/core/codec/table-view.d.ts +4 -0
  7. package/dist/core/codec/table-view.js +10 -1
  8. package/dist/core/theme.css +14 -9
  9. package/dist/preset/recipes.css +15 -4
  10. package/dist/preview/bridge.d.ts +23 -0
  11. package/dist/preview/bridge.js +92 -35
  12. package/dist/preview/controller.d.ts +6 -5
  13. package/dist/preview/controller.js +13 -7
  14. package/dist/preview/index.js +3 -2
  15. package/dist/preview/paint.js +9 -1
  16. package/dist/visual/ArrayField.svelte +30 -23
  17. package/dist/visual/ArrayField.svelte.d.ts +9 -7
  18. package/dist/visual/Card.svelte +88 -45
  19. package/dist/visual/EnumField.svelte +4 -2
  20. package/dist/visual/Field.svelte +22 -2
  21. package/dist/visual/ObjectField.svelte +95 -75
  22. package/dist/visual/ObjectField.svelte.d.ts +7 -3
  23. package/dist/visual/ProseValue.svelte +165 -0
  24. package/dist/visual/ProseValue.svelte.d.ts +33 -0
  25. package/dist/visual/SlashMenu.svelte +7 -0
  26. package/dist/visual/VariantField.svelte +11 -6
  27. package/dist/visual/VariantField.svelte.d.ts +5 -1
  28. package/dist/visual/VisualEditorInner.svelte +29 -11
  29. package/dist/visual/hold.d.ts +17 -0
  30. package/dist/visual/hold.js +39 -0
  31. package/dist/visual/index.js +1 -1
  32. package/dist/visual/motion.d.ts +9 -0
  33. package/dist/visual/motion.js +10 -2
  34. package/dist/visual/strings.d.ts +4 -3
  35. package/package.json +2 -2
  36. package/dist/visual/ProseArrayElement.svelte +0 -139
  37. package/dist/visual/ProseArrayElement.svelte.d.ts +0 -25
package/THEMING.md CHANGED
@@ -173,7 +173,7 @@ An app that mounts an editor beside a preview over one session needs a screen to
173
173
  | `.qm-switch` | The band that says which track is showing, and nothing at the widths where both are. Its controls read as even halves and stand at the touch floor (`--qmh-tap`, 44px); depth past that is yours. |
174
174
  | `.qm-frame` | The mounting site: an edge, a corner, and the clip that holds a surface to them. What is inside it is the surface's own. |
175
175
 
176
- The narrow shape is one mount at a time, not two of them stacked: half a narrow viewport is under the width either surface reads at. The track you are not showing is `display: none`, so it leaves the tab order, hit-testing and the accessibility tree together: you need no `inert` on it, and no breakpoint in your JS, since the only state you hold is which of the two you last asked for. Keep both mounted: the hidden pane keeps its pixels, its caret and its scroll, and repaints itself when it comes back. A click that lands in the hidden pane is the one thing the shape adds to your wiring, so send the caret and reveal the track together, or the hit goes somewhere the reader cannot see.
176
+ The narrow shape is one mount at a time, not two of them stacked: half a narrow viewport is under the width either surface reads at. The track you are not showing is `display: none`, so it leaves the tab order, hit-testing and the accessibility tree together: you need no `inert` on it, and no breakpoint in your JS, since the only state you hold is which of the two you last asked for. Keep both mounted: the hidden pane keeps its pixels, its caret and its scroll, and comes back to them — it rasters nothing at a box it has none of, and repaints only where it returns to a box of another width. A preview scroll asked for while it was hidden is held and run on the way back, so it opens at the caret rather than several edits behind it. A click that lands in the hidden pane is the one thing the shape adds to your wiring, so send the caret and reveal the track together, or the hit goes somewhere the reader cannot see.
177
177
 
178
178
  The shape is shared; the look of a band is not. Your wordmark, your nav and the depth of your head are the most app-specific things on the screen, and a preset that drew them would hand every tool built on this the same face, so they read `--qmh-*` like everything else and you write the rules. `.qm-split`'s gap is a default rather than a fact about splitting: close it to `0` and carry a hairline on one pane if you want the two mounts to meet instead of stand apart.
179
179
 
@@ -135,13 +135,24 @@ export interface LeafViews {
135
135
  /** The canonical empty `Content`: one empty `para` line. The empty a prose
136
136
  * leaf (or an array's prose element) seeds from. */
137
137
  export declare function emptyContent(): Content;
138
+ /** The naming attributes for the `contenteditable`, shared by every prose leaf
139
+ * (`ProseValue` takes it too, so an addressed leaf and a by-value one name their
140
+ * regions by one rule). `aria-labelledby` supersedes `aria-label`: a leaf carrying
141
+ * both is the ambiguity where implementations disagree about which wins, and
142
+ * `undefined` is returned whole when a leaf takes neither, since ProseMirror reads
143
+ * the absence, not an empty object. */
144
+ export declare function proseAttributes(opts: {
145
+ label?: string;
146
+ labelledBy?: string;
147
+ describedBy?: string;
148
+ }): Record<string, string> | undefined;
138
149
  export declare function createField(opts: CreateFieldOpts): FieldController;
139
150
  /**
140
151
  * The prose-leaf plugin stack (VISUAL_EDITOR §Surface): shared by
141
- * {@link createField} and the array-element inline editor (`ProseArrayElement`),
142
- * so the two never fork the keymap/plugin ordering. History first, then any
143
- * leaf-specific plugins (`afterHistory`: the addressed leaf passes its
144
- * anchor-position plugin; a by-value array element passes none), the
152
+ * {@link createField} and the by-value inline editor (`ProseValue`), so the two
153
+ * never fork the keymap/plugin ordering. History first, then any leaf-specific
154
+ * plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
155
+ * a by-value leaf passes none), the
145
156
  * markdown-shorthand input rules, then the field keymap over the base keymap, and
146
157
  * last the two that answer to a caret or a selection beside a block: the gap cursor
147
158
  * and {@link pastAtomPlugin}.
@@ -89,11 +89,13 @@ function opsCommittable(doc, addr) {
89
89
  const stored = doc.getStored(addr);
90
90
  return stored !== null && typeof stored === 'object';
91
91
  }
92
- /** The naming attributes for the `contenteditable`. `aria-labelledby` supersedes
93
- * `aria-label`: a leaf carrying both is the ambiguity where implementations
94
- * disagree about which wins, and `undefined` is returned whole when a leaf takes
95
- * neither, since ProseMirror reads the absence, not an empty object. */
96
- function proseAttributes(opts) {
92
+ /** The naming attributes for the `contenteditable`, shared by every prose leaf
93
+ * (`ProseValue` takes it too, so an addressed leaf and a by-value one name their
94
+ * regions by one rule). `aria-labelledby` supersedes `aria-label`: a leaf carrying
95
+ * both is the ambiguity where implementations disagree about which wins, and
96
+ * `undefined` is returned whole when a leaf takes neither, since ProseMirror reads
97
+ * the absence, not an empty object. */
98
+ export function proseAttributes(opts) {
97
99
  const attrs = {};
98
100
  if (opts.labelledBy)
99
101
  attrs['aria-labelledby'] = opts.labelledBy;
@@ -161,7 +163,8 @@ export function createField(opts) {
161
163
  // A focus event does not bubble, so the leaf's own `focus` handler
162
164
  // never fires for a cell: without this the active address would not
163
165
  // follow a caret clicked straight into a table.
164
- onCellFocus: () => opts.onFocus?.(addr)
166
+ onCellFocus: () => opts.onFocus?.(addr),
167
+ clearance
165
168
  })
166
169
  },
167
170
  dispatchTransaction: (tr) => {
@@ -385,10 +388,10 @@ export function createField(opts) {
385
388
  }
386
389
  /**
387
390
  * The prose-leaf plugin stack (VISUAL_EDITOR §Surface): shared by
388
- * {@link createField} and the array-element inline editor (`ProseArrayElement`),
389
- * so the two never fork the keymap/plugin ordering. History first, then any
390
- * leaf-specific plugins (`afterHistory`: the addressed leaf passes its
391
- * anchor-position plugin; a by-value array element passes none), the
391
+ * {@link createField} and the by-value inline editor (`ProseValue`), so the two
392
+ * never fork the keymap/plugin ordering. History first, then any leaf-specific
393
+ * plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
394
+ * a by-value leaf passes none), the
392
395
  * markdown-shorthand input rules, then the field keymap over the base keymap, and
393
396
  * last the two that answer to a caret or a selection beside a block: the gap cursor
394
397
  * and {@link pastAtomPlugin}.
@@ -3,7 +3,7 @@ import 'prosemirror-gapcursor/style/gapcursor.css';
3
3
  import './prose.css';
4
4
  export { rangeAnchor } from './anchor.js';
5
5
  export type { RangeAnchor } from './anchor.js';
6
- export { createField, emptyContent, proseLeafPlugins } from './field.js';
6
+ export { createField, emptyContent, proseAttributes, proseLeafPlugins } from './field.js';
7
7
  export type { CreateFieldOpts, FieldController, LeafViews } from './field.js';
8
8
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
9
9
  export type { TableChromeStrings } from './table-view.js';
@@ -13,7 +13,7 @@ import './prose.css';
13
13
  // selection (`visual/FormatPopover.svelte`), so it is reached off-barrel.
14
14
  export { rangeAnchor } from './anchor.js';
15
15
  // The prose leaf.
16
- export { createField, emptyContent, proseLeafPlugins } from './field.js';
16
+ export { createField, emptyContent, proseAttributes, proseLeafPlugins } from './field.js';
17
17
  // The table island's chrome vocabulary: the visual tier's `strings` set extends it,
18
18
  // so the island's wording is overridden beside every other key.
19
19
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
@@ -40,6 +40,10 @@ export interface TableViewDeps {
40
40
  /** A cell took focus: the leaf's own `focus` handler never fires for one (a focus
41
41
  * event does not bubble), so the active address would not follow the caret. */
42
42
  onCellFocus: () => void;
43
+ /** The clearance a revealed caret keeps, in the leaf's own line box (`field.ts`), which
44
+ * is a cell's line box too. `undefined` where the derivation is out of reach (jsdom),
45
+ * which is PM's 5px default. */
46
+ clearance: number | undefined;
43
47
  }
44
48
  /** The `island_block` node view: a table island's editing surface, and the literal
45
49
  * placeholder for every other island type. */
@@ -869,6 +869,11 @@ class TableIslandView {
869
869
  plugins: cellPlugins(this.cellKeys(r, c))
870
870
  }),
871
871
  attributes: { 'aria-label': name, class: 'qm-table-cell-editor' },
872
+ // A cell is one of the leaf's lines, so a reveal here keeps the leaf's
873
+ // clearance: PM's 5px default is the caret visible and unusable that rung
874
+ // exists to refuse (`field.ts`).
875
+ scrollThreshold: this.deps.clearance,
876
+ scrollMargin: this.deps.clearance,
872
877
  dispatchTransaction: (tr) => {
873
878
  const next = view.state.apply(tr);
874
879
  view.updateState(next);
@@ -928,8 +933,12 @@ class TableIslandView {
928
933
  if (!mounted)
929
934
  return;
930
935
  const { view } = mounted;
936
+ // Flagged, for the reason the leaf's own landing is (`field.ts`, `setCaret`): PM
937
+ // focuses with `preventScroll`, so an unflagged dispatch lands the caret where no
938
+ // scroller has moved to — a Tab past the right edge of the horizontal scroller a
939
+ // wide table lives in, or the row an Enter on the last one appends below the fold.
931
940
  view.focus();
932
- view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc)));
941
+ view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc)).scrollIntoView());
933
942
  }
934
943
  /**
935
944
  * A cell's keys. Traversal is the island's link in the leaf's chain
@@ -16,8 +16,8 @@
16
16
  the package claims `sideEffects: true`. Pruned, every rung below is undefined,
17
17
  every `var()` reading one is invalid at computed-value time, and the declaration
18
18
  holding it drops: a control with no border, no background and no padding, under
19
- chrome that still reads as intact. `check:bundle` holds both ends, the claim and
20
- the built bytes.
19
+ chrome that still reads as intact. `check:bundle` reads the built bytes for it;
20
+ the claim in the manifest is review's.
21
21
 
22
22
  Every value stays a `var()` reference, so the dials resolve against the cascade
23
23
  at each root: a consumer's `[data-theme="dark"]` ancestor rule flows in
@@ -232,9 +232,8 @@
232
232
  Offset-free is the whole of it: an offset states where the light is and no pole
233
233
  carries that, where a halo states occlusion. Toward black at both poles for the
234
234
  same reason — a crevice is darker than what surrounds it in a dark room too — so
235
- the cue means one thing whichever way the scale runs. Spent only on what portals;
236
- `check:style` holds the offsets at zero here and the rung as the only shadow
237
- anywhere. */
235
+ the cue means one thing whichever way the scale runs. Spent only on what portals,
236
+ and the one shadow any surface casts, which is review's to hold. */
238
237
  --_qm-lift: 0 0 12px color-mix(in oklab, #000 16%, transparent);
239
238
  /* The recede ladder: a hover-revealed or disabled affordance at rest, and a
240
239
  muted one. Opacity recedes toward whatever is behind, so these invert
@@ -282,12 +281,12 @@
282
281
 
283
282
  /* ── Geometry ──────────────────────────────────────────────────────────*/
284
283
  /* One stroke width for every edge the chrome draws: a card's, a control's,
285
- and the metadata bracket's three rules alike. A threshold rather than a
284
+ and the verticals nested inside it alike. A threshold rather than a
286
285
  rhythm choice, like the tap floor: a hairline is a hairline at any
287
286
  `--qm-space`, so it does not derive from a dial. It exists as a rung because
288
- the bracket's whole claim is that its vertical and its horizontals are the
289
- same stroke, and `check:style`'s border-width axis can only hold that
290
- against a token. */
287
+ the nesting ladder's whole claim is that a card's edge, a section's vertical
288
+ and a subform's are one stroke at three depths, and `check:style`'s
289
+ border-width axis can only hold that against a token. */
291
290
  --_qm-border-width: 1px;
292
291
  --_qm-radius: var(--qm-radius, 8px);
293
292
  /* The interior corner, and half is the relationship rather than the number: a
@@ -310,6 +309,12 @@
310
309
  it has taken the line box, and the horizontal is how far in the text starts,
311
310
  which nothing else fixes. */
312
311
  --_qm-inset-control: var(--_qm-space) var(--_qm-space-3);
312
+ /* The nesting step: the distance from one structural stroke to the next, and what
313
+ makes the depths read as a ladder rather than as indents that happen to look
314
+ alike. A rung because the boxes holding that distance are in two components and
315
+ `check:style`'s rhythm axis sees each of them read a rung, never that they read
316
+ the same one — the case `--_qm-inset-control` above is cut for. */
317
+ --_qm-nest: var(--_qm-space-3);
313
318
  /* The scroll tail: dead space under the last card, so it can be read at the
314
319
  middle of a pane rather than at its bottom edge. A fraction of the viewport
315
320
  rather than a step on the ramp, because what it answers is how far the eye
@@ -50,11 +50,19 @@
50
50
  root scroller counting the overflow its panes clip, and a wheel over the chrome drags
51
51
  the whole app off the top. Three bands (a head, the body, an optional foot), and the
52
52
  body takes what is left and may shrink below its content, so the panes in it own
53
- their overflow and hold still while their contents move. */
53
+ their overflow and hold still while their contents move.
54
+
55
+ The one column is stated for the same reason the rows are: an implicit track is `auto`,
56
+ which is sized by what a band holds, and a `.qm-split` under one contributes its items'
57
+ max-content through its `fr` tracks. The shell would take the width of the widest thing
58
+ mounted in it instead of the viewport's, and every band would be that wide with the
59
+ second pane past the edge. A host holding its content to a maximum never sees it; one
60
+ spending the whole screen sees it on the first surface it mounts. */
54
61
  .qm-workspace {
55
62
  position: fixed;
56
63
  inset: 0;
57
64
  display: grid;
65
+ grid-template-columns: minmax(0, 1fr);
58
66
  grid-template-rows: auto minmax(0, 1fr) auto;
59
67
  overflow: hidden;
60
68
  }
@@ -115,9 +123,12 @@ html:has(.qm-workspace) {
115
123
  pane needs no `inert` and the host needs no breakpoint in JS. Both stay mounted (a
116
124
  preview binds its handle once, an editor holds a caret, a history and a scroll), so
117
125
  the switch is presentational, never conditional. A pane hidden this way keeps the
118
- pixels it had (the paint loop's band sweep no-ops on an empty visible set) and repaints
119
- from its live box when it comes back, which its resize observer sees as a width moving
120
- off zero.
126
+ pixels it had: the paint loop's band sweep no-ops on an empty visible set, and its
127
+ resize observer holds the last real width rather than rastering at the 0×0 box a hidden
128
+ pane reports, so what comes back at the width it left needs no paint at all. The
129
+ preview's caret follow spans the same window — a trip asked for against a pane with no
130
+ box is held and run when it has one (`preview/bridge.ts`), the pane opening at the
131
+ caret rather than several edits behind it.
121
132
 
122
133
  Height is the second clause because a landscape phone passes the first one: it clears
123
134
  the width with room for two tracks and has no body left to put them in. A desktop window
@@ -1,6 +1,8 @@
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
6
  export interface BridgeController {
5
7
  /** Scroll `field`'s first box to centre; `false` when this compile places none
6
8
  * for that address, which is the whole of what the preview can say about it. */
@@ -12,4 +14,25 @@ export interface BridgeController {
12
14
  focusPosition(field: string, pos: number): void;
13
15
  destroy(): void;
14
16
  }
17
+ /** The vertical extent the fold test reads; a `DOMRect` is one. */
18
+ export interface VerticalSpan {
19
+ top: number;
20
+ bottom: number;
21
+ height: number;
22
+ }
23
+ /**
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.
36
+ */
37
+ export declare function clearOfTheFold(target: VerticalSpan, port: VerticalSpan): boolean;
15
38
  export declare function createBridge(session: LiveSession, container: HTMLElement, slots: readonly PageSlot[], onPick: ((at: Landing) => void) | undefined): BridgeController;
@@ -1,4 +1,34 @@
1
1
  import { boxesForField, rectToPercent, clickToPdfPt, 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. */
6
+ function hasBox(port) {
7
+ return port.width > 0 && port.height > 0;
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. */
11
+ const MIN_CLEARANCE = 8;
12
+ /**
13
+ * Whether `target` sits clear of `port`'s edges by the fold's clearance, which is the
14
+ * target's own height: a caret rect flush against an edge is visible and unusable, and
15
+ * the next line typed lands past it. Derived from the target rather than a margin dial,
16
+ * so it scales with zoom exactly as the caret does, and bounded at both ends so the
17
+ * answer is reachable across the whole range of targets a compile locates.
18
+ *
19
+ * The floor is what a zero-height rect gets, which bare intersection would score as
20
+ * clear at either edge. The cap is a third of the room the port has to spare, so a
21
+ * target too tall for its own height of clearance — a located line at zoom, a short
22
+ * split track — passes once centred and holds a band around it, rather than being
23
+ * re-centred by every keystroke. A target taller than the port is clear while it covers
24
+ * the port: no scroll shows more of it.
25
+ */
26
+ export function clearOfTheFold(target, port) {
27
+ if (target.height >= port.height)
28
+ return target.top <= port.top && target.bottom >= port.bottom;
29
+ const clearance = Math.min(Math.max(target.height, MIN_CLEARANCE), (port.height - target.height) / 3);
30
+ return target.top - clearance >= port.top && target.bottom + clearance <= port.bottom;
31
+ }
2
32
  export function createBridge(session, container, slots, onPick) {
3
33
  const unlisten = [];
4
34
  // Two rungs: `positionAt` over span-tracked content, `fieldAt` over every placement
@@ -53,43 +83,70 @@ export function createBridge(session, container, slots, onPick) {
53
83
  else if (target.right > port.right)
54
84
  container.scrollLeft += target.right - port.right;
55
85
  }
56
- // Clear of the fold is the target's own height of clearance at each edge, not bare
57
- // intersection: a caret rect flush against one is visible and unusable, and the
58
- // next line typed lands past it. Derived from the target rather than a margin
59
- // dial, so it scales with zoom exactly as the caret does.
60
- function clearOfTheFold(target) {
61
- const port = container.getBoundingClientRect();
62
- return target.top - target.height >= port.top && target.bottom + target.height <= port.bottom;
86
+ // The trip a pane with no box could not run, held for the box coming back. One, and
87
+ // the last one asked for: a caret hop supersedes the hop before it on a showing pane
88
+ // too.
89
+ let held;
90
+ // The re-assert. Nothing else asks on the way back: `refresh` re-asks only on a
91
+ // recompile, and the reader who has just tapped Preview is not typing, so the pane
92
+ // would open where the caret was several edits ago. Guarded for jsdom, which ships no
93
+ // ResizeObserver and lays out nothing for one to report.
94
+ if (typeof ResizeObserver !== 'undefined') {
95
+ const observer = new ResizeObserver(() => {
96
+ if (!held || !hasBox(container.getBoundingClientRect()))
97
+ return;
98
+ const trip = held;
99
+ held = undefined;
100
+ trip();
101
+ });
102
+ observer.observe(container);
103
+ unlisten.push(() => observer.disconnect());
63
104
  }
64
- return {
65
- scrollToField(field) {
66
- const box = boxesForField(field, session.fieldBoxes(field), session.regions())[0];
67
- if (!box)
68
- return false;
69
- const slot = slots[box.page];
70
- if (!slot)
71
- return false;
72
- // A discrete act ("show me this field"), so it centres every time, unlike
73
- // the continuous hop below.
74
- centre(measure(slot, rectToPercent(box.rect, slot.size)));
105
+ function scrollToField(field) {
106
+ const box = boxesForField(field, session.fieldBoxes(field), session.regions())[0];
107
+ if (!box)
108
+ return false;
109
+ const slot = slots[box.page];
110
+ if (!slot)
111
+ return false;
112
+ // The address is placed, which is the whole of what the `boolean` answers; a pane
113
+ // with no box yet is not a second no.
114
+ if (!hasBox(container.getBoundingClientRect())) {
115
+ held = () => void scrollToField(field);
75
116
  return true;
76
- },
77
- focusPosition(field, pos) {
78
- const region = session.locate(field, pos);
79
- if (!region)
80
- return;
81
- const slot = slots[region.page];
82
- if (!slot)
83
- return;
84
- // The continuous hop: one call per keystroke and per arrow key, so it moves
85
- // the pane only when the caret has left the fold. Centring unconditionally
86
- // takes the scrollport back from the user on every one of them, including
87
- // the ones that changed nothing about where the caret already was.
88
- const target = measure(slot, rectToPercent(region.rect, slot.size));
89
- if (clearOfTheFold(target))
90
- return;
91
- centre(target);
92
- },
117
+ }
118
+ // A discrete act ("show me this field"), so it centres every time, unlike
119
+ // the continuous hop below.
120
+ centre(measure(slot, rectToPercent(box.rect, slot.size)));
121
+ return true;
122
+ }
123
+ function focusPosition(field, pos) {
124
+ const region = session.locate(field, pos);
125
+ if (!region)
126
+ return;
127
+ const slot = slots[region.page];
128
+ if (!slot)
129
+ return;
130
+ // Held before the guard below, which a pane measuring zero answers "already clear"
131
+ // for every keystroke of (`hasBox`): the follow would die silently for as long as
132
+ // a track is hidden. What is held is the request, not the trip, so it re-locates
133
+ // against whatever compile is current when the pane comes back.
134
+ if (!hasBox(container.getBoundingClientRect())) {
135
+ held = () => focusPosition(field, pos);
136
+ return;
137
+ }
138
+ // The continuous hop: one call per keystroke and per arrow key, so it moves
139
+ // the pane only when the caret has left the fold. Centring unconditionally
140
+ // takes the scrollport back from the user on every one of them, including
141
+ // the ones that changed nothing about where the caret already was.
142
+ const target = measure(slot, rectToPercent(region.rect, slot.size));
143
+ if (clearOfTheFold(target, container.getBoundingClientRect()))
144
+ return;
145
+ centre(target);
146
+ }
147
+ return {
148
+ scrollToField,
149
+ focusPosition,
93
150
  destroy() {
94
151
  for (const off of unlisten)
95
152
  off();
@@ -23,10 +23,11 @@ export interface PreviewController {
23
23
  * apply-driven hop. */
24
24
  refresh(change: ChangeSet): void;
25
25
  /**
26
- * Scroll `field`'s first box into view. `false` when this compile places nothing
27
- * at that address — an answer, not a failure: the plate places plenty it does not
28
- * track, and the preview carries no schema to tell that from a field the host
29
- * misnamed. The editor's `focusField` is what distinguishes them.
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.
30
31
  */
31
32
  scrollToField(field: DocPath): boolean;
32
33
  /**
@@ -40,7 +41,7 @@ export interface PreviewController {
40
41
  focusPosition(at: Place): void;
41
42
  /**
42
43
  * End the follow: the pane stays where the user left it until the next
43
- * `focusPosition`.
44
+ * `focusPosition`. A `scrollToField` that placed its target ends one too.
44
45
  *
45
46
  * The editor's `onActiveLeafChange` is the signal, firing for a form control as for
46
47
  * a prose leaf: `onActiveLeafChange={preview.endFollow}`. A control has no caret
@@ -84,12 +84,12 @@ export function createPreview(session, opts) {
84
84
  }
85
85
  render(session.pageCount, []);
86
86
  // The last place the editor put the caret, re-located after every recompile and
87
- // dropped on a focus change. `session.locate` answers against the last compiled
88
- // layout and a consumer debounces `update`, so a caret typed past that layout's
89
- // content is off-content for the whole burst: `focusPosition` no-ops and the pane
90
- // sits still, and a caret event is otherwise the only thing that asks again. The
91
- // re-locate is the preview's, not the consumer's: the staleness sits between two
92
- // session queries this module owns both ends of.
87
+ // dropped on a focus change or a discrete hop. `session.locate` answers against the
88
+ // last compiled layout and a consumer debounces `update`, so a caret typed past that
89
+ // layout's content is off-content for the whole burst: `focusPosition` no-ops and
90
+ // the pane sits still, and a caret event is otherwise the only thing that asks
91
+ // again. The re-locate is the preview's, not the consumer's: the staleness sits
92
+ // between two session queries this module owns both ends of.
93
93
  let followed;
94
94
  return {
95
95
  refresh(change) {
@@ -100,7 +100,13 @@ export function createPreview(session, opts) {
100
100
  bridge?.focusPosition(followed.field, followed.pos);
101
101
  },
102
102
  scrollToField(field) {
103
- return bridge?.scrollToField(field) ?? false;
103
+ const placed = bridge?.scrollToField(field) ?? false;
104
+ // The discrete hop outranks the follow: a caret re-asserted at the next
105
+ // recompile would pull the pane straight off the field a host just asked
106
+ // for. A hop that placed nothing moved nothing, so the follow stands.
107
+ if (placed)
108
+ followed = undefined;
109
+ return placed;
104
110
  },
105
111
  focusPosition(at) {
106
112
  followed = at;
@@ -4,13 +4,14 @@
4
4
  // land here. Reserved-package invariant: this subpath imports no editor-side code
5
5
  // (the codec, the VisualEditor) so a `@quillmark/preview` promotion stays a
6
6
  // re-export, not a refactor: it may reach `/core` (the shared WASM boundary) and
7
- // nothing else. Enforced by `check:deps`, which walks the subpath's import graph.
7
+ // nothing else. Enforced by `check:deps`, at the subpath's edge: a relative hop past a
8
+ // module directly in `core/` is what pulls the codec, and all of ProseMirror with it.
8
9
  // The theme derivation, which the page slots read through `var()`.
9
10
  // Imported at the barrel because a subpath is what a consumer gets: this surface
10
11
  // reaches modules inside `core/` and never its entry, so a sheet hanging off that
11
12
  // entry arrives only for a consumer importing `/core` for some other reason. The
12
13
  // sheet rather than the entry, because the barrel needs the derivation and not
13
- // `init`. `check:deps` (rule 4) walks this file's own graph and holds the reach.
14
+ // `init`. `check:deps` holds the reach.
14
15
  import '../core/theme.css';
15
16
  export { createPreview } from './controller.js';
16
17
  export { default as Preview } from './Preview.svelte';
@@ -192,9 +192,17 @@ onPaintError) {
192
192
  return;
193
193
  rafId = requestAnimationFrame(() => {
194
194
  rafId = 0;
195
+ const width = container.clientWidth;
196
+ // A pane the shell has hidden reports a 0×0 box, which is a width to paint
197
+ // nothing at rather than a width that moved: `paintSlot` would take its
198
+ // `clientWidth || widthPt` fallback and freeze every mounted canvas at the 1×
199
+ // raster. The last real width and any pending force are held instead — the
200
+ // observer fires again when the pane has a box, and a return at that same
201
+ // width needs no raster, the pixels being the ones it was hidden with.
202
+ if (width === 0)
203
+ return;
195
204
  const forced = forcePending;
196
205
  forcePending = false;
197
- const width = container.clientWidth;
198
206
  if (!forced && width === lastWidth)
199
207
  return;
200
208
  lastWidth = width;