@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
@@ -14,50 +14,29 @@ export interface PdfPoint {
14
14
  y: number;
15
15
  }
16
16
  /**
17
- * An address's boxes: `fieldBoxes`'s union, else every `regions()` rect at or under it.
18
- * What the scroll reads, so an address `regions()` names is never one the preview
19
- * cannot place (PREVIEW.md §"Two responsibilities").
17
+ * An address's boxes: `fieldBoxes`'s union, else every `regions()` rect at or under it
18
+ * (PREVIEW.md §"Two responsibilities"). `fieldBoxes` answers `[]` for a scalar reference
19
+ * the plate places without tracking its content and for an array itself; the fallback
20
+ * takes the address and its descendants, matched at a path boundary (`.` or `[`):
21
+ * `main.keywords` matches `main.keywords[0]`, not `main.keywords_note`.
20
22
  *
21
- * `fieldBoxes` answers `[]` for a scalar reference the plate places without tracking
22
- * its content and for an array itself; runtime.d.ts says what such a field's box is, a
23
- * single `regions()` rect. The second rung takes an address and its descendants
24
- * together, since a container holds both: a plate composing an array's presentation
25
- * regions the array on the ink it composed — a separator, a heading — where each
26
- * element regions the ink it carries itself, and the field is placed at all of it. The
27
- * boundary character (`.` for a nested field, `[` for an array element) keeps the
28
- * prefix a path boundary: `main.keywords` matches `main.keywords[0]`, not
29
- * `main.keywords_note`.
30
- *
31
- * `regions` is a thunk because the first rung answers without it, and taking it
32
- * marshals every placement in the compile across the WASM boundary.
23
+ * `regions` is a thunk because taking it marshals every placement across the WASM boundary.
33
24
  */
34
25
  export declare function boxesForField(field: string, boxes: readonly FieldRegion[], regions: () => readonly FieldRegion[]): readonly FieldRegion[];
35
26
  /**
36
- * Forward: a PDF-pt rect -> CSS % of the page box. Percent positioning tracks
37
- * the page's displayed size across DPR and container resize for free, so the
38
- * scroll's measurement needs no zoom or DPR term of its own (bridge.ts).
27
+ * Forward: a PDF-pt rect -> CSS % of the page box, which tracks the displayed size without a zoom
28
+ * or DPR term.
39
29
  */
40
30
  export declare function rectToPercent(rect: PdfRect, pageSize: PageSize): PercentRect;
41
- /**
42
- * Position `el` absolutely at `pct` (the % rect this module produces): the one
43
- * `PercentRect` → `element.style` write, so the positioning convention lives beside
44
- * the transform that produces it rather than at the caller placing a marker.
45
- */
31
+ /** Position `el` absolutely at `pct`. */
46
32
  export declare function applyPercentRect(el: HTMLElement, pct: PercentRect): void;
47
33
  /**
48
- * A CSS-px length on a page element of CSS width `cssW` -> the PDF-pt length it spans.
49
- * Width alone, because that is the scale the slot is drawn at: `paint.ts` sizes a slot
50
- * by width under a locked aspect ratio, so {@link clickToPdfPt}'s two ratios are one
51
- * number and a length has one to convert through. What the click bridge reads its
52
- * pointer slack in points through.
34
+ * A CSS-px length on a page element of CSS width `cssW` -> PDF-pt. Width alone, since `paint.ts`
35
+ * locks the aspect ratio.
53
36
  */
54
37
  export declare function pxToPt(px: number, cssW: number, pageSize: PageSize): number;
55
38
  /**
56
- * Inverse: a click at CSS px `(px, py)` within a page element of CSS size
57
- * `(cssW, cssH)` -> PDF-pt. The exact inverse of {@link rectToPercent}: the
58
- * fraction `rectToPercent` would place a point's box corner at is the same
59
- * fraction this reads back from `px/cssW`, `py/cssH`; DPR-independent, since
60
- * the page element's CSS size (not its canvas backing store) is the only input.
61
- * This is what the click bridge feeds `session.positionAt`/`fieldAt` (bridge.ts).
39
+ * Inverse of {@link rectToPercent}: a click at CSS px `(px, py)` within a page element of CSS size
40
+ * `(cssW, cssH)` -> PDF-pt.
62
41
  */
63
42
  export declare function clickToPdfPt(px: number, py: number, cssW: number, cssH: number, pageSize: PageSize): PdfPoint;
@@ -1,20 +1,11 @@
1
1
  /**
2
- * An address's boxes: `fieldBoxes`'s union, else every `regions()` rect at or under it.
3
- * What the scroll reads, so an address `regions()` names is never one the preview
4
- * cannot place (PREVIEW.md §"Two responsibilities").
2
+ * An address's boxes: `fieldBoxes`'s union, else every `regions()` rect at or under it
3
+ * (PREVIEW.md §"Two responsibilities"). `fieldBoxes` answers `[]` for a scalar reference
4
+ * the plate places without tracking its content and for an array itself; the fallback
5
+ * takes the address and its descendants, matched at a path boundary (`.` or `[`):
6
+ * `main.keywords` matches `main.keywords[0]`, not `main.keywords_note`.
5
7
  *
6
- * `fieldBoxes` answers `[]` for a scalar reference the plate places without tracking
7
- * its content and for an array itself; runtime.d.ts says what such a field's box is, a
8
- * single `regions()` rect. The second rung takes an address and its descendants
9
- * together, since a container holds both: a plate composing an array's presentation
10
- * regions the array on the ink it composed — a separator, a heading — where each
11
- * element regions the ink it carries itself, and the field is placed at all of it. The
12
- * boundary character (`.` for a nested field, `[` for an array element) keeps the
13
- * prefix a path boundary: `main.keywords` matches `main.keywords[0]`, not
14
- * `main.keywords_note`.
15
- *
16
- * `regions` is a thunk because the first rung answers without it, and taking it
17
- * marshals every placement in the compile across the WASM boundary.
8
+ * `regions` is a thunk because taking it marshals every placement across the WASM boundary.
18
9
  */
19
10
  export function boxesForField(field, boxes, regions) {
20
11
  if (boxes.length)
@@ -22,9 +13,8 @@ export function boxesForField(field, boxes, regions) {
22
13
  return regions().filter((r) => r.field === field || r.field.startsWith(`${field}.`) || r.field.startsWith(`${field}[`));
23
14
  }
24
15
  /**
25
- * Forward: a PDF-pt rect -> CSS % of the page box. Percent positioning tracks
26
- * the page's displayed size across DPR and container resize for free, so the
27
- * scroll's measurement needs no zoom or DPR term of its own (bridge.ts).
16
+ * Forward: a PDF-pt rect -> CSS % of the page box, which tracks the displayed size without a zoom
17
+ * or DPR term.
28
18
  */
29
19
  export function rectToPercent(rect, pageSize) {
30
20
  const [x0, y0, x1, y1] = rect;
@@ -36,11 +26,7 @@ export function rectToPercent(rect, pageSize) {
36
26
  height: ((y1 - y0) / heightPt) * 100
37
27
  };
38
28
  }
39
- /**
40
- * Position `el` absolutely at `pct` (the % rect this module produces): the one
41
- * `PercentRect` → `element.style` write, so the positioning convention lives beside
42
- * the transform that produces it rather than at the caller placing a marker.
43
- */
29
+ /** Position `el` absolutely at `pct`. */
44
30
  export function applyPercentRect(el, pct) {
45
31
  Object.assign(el.style, {
46
32
  position: 'absolute',
@@ -51,22 +37,15 @@ export function applyPercentRect(el, pct) {
51
37
  });
52
38
  }
53
39
  /**
54
- * A CSS-px length on a page element of CSS width `cssW` -> the PDF-pt length it spans.
55
- * Width alone, because that is the scale the slot is drawn at: `paint.ts` sizes a slot
56
- * by width under a locked aspect ratio, so {@link clickToPdfPt}'s two ratios are one
57
- * number and a length has one to convert through. What the click bridge reads its
58
- * pointer slack in points through.
40
+ * A CSS-px length on a page element of CSS width `cssW` -> PDF-pt. Width alone, since `paint.ts`
41
+ * locks the aspect ratio.
59
42
  */
60
43
  export function pxToPt(px, cssW, pageSize) {
61
44
  return (px / cssW) * pageSize.widthPt;
62
45
  }
63
46
  /**
64
- * Inverse: a click at CSS px `(px, py)` within a page element of CSS size
65
- * `(cssW, cssH)` -> PDF-pt. The exact inverse of {@link rectToPercent}: the
66
- * fraction `rectToPercent` would place a point's box corner at is the same
67
- * fraction this reads back from `px/cssW`, `py/cssH`; DPR-independent, since
68
- * the page element's CSS size (not its canvas backing store) is the only input.
69
- * This is what the click bridge feeds `session.positionAt`/`fieldAt` (bridge.ts).
47
+ * Inverse of {@link rectToPercent}: a click at CSS px `(px, py)` within a page element of CSS size
48
+ * `(cssW, cssH)` -> PDF-pt.
70
49
  */
71
50
  export function clickToPdfPt(px, py, cssW, cssH, pageSize) {
72
51
  const { widthPt, heightPt } = pageSize;
@@ -1,20 +1,8 @@
1
- // `@quillmark/svelte/preview`: the live-preview surface.
2
- //
3
- // `createPreview(session)` (vanilla-TS core) + a thin `<Preview>` Svelte wrapper
4
- // land here. Reserved-package invariant: this subpath imports no editor-side code
5
- // (the codec, the VisualEditor) so a `@quillmark/preview` promotion stays a
6
- // re-export, not a refactor: it may reach `/core` (the shared WASM boundary) and
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.
9
- // The theme derivation, which the page slots read through `var()`.
10
- // Imported at the barrel because a subpath is what a consumer gets: this surface
11
- // reaches modules inside `core/` and never its entry, which carries no sheet.
12
- // `check:deps` holds the reach.
1
+ // `@quillmark/svelte/preview`: the live-preview surface. Imports no editor-side code; it may reach
2
+ // `/core` and nothing else (`check:deps`).
3
+ // The theme derivation the page slots read through `var()`; a subpath import reaches `core/`
4
+ // modules, never its entry, which carries no sheet.
13
5
  import '../core/theme.css';
14
6
  export { createPreview } from './controller.js';
15
7
  export { default as Preview } from './Preview.svelte';
16
- // What the preview says: its message states and the zoom pill's name. Keyed and partial; the
17
- // English is exported so a consumer composes against it rather than restating it.
18
- // The merge is the surface's own: a consumer hands `strings` to a prop, never
19
- // merges one.
20
8
  export { DEFAULT_PREVIEW_STRINGS } from './strings.js';
@@ -11,9 +11,9 @@ export interface PaintLoop {
11
11
  /** Reconcile slot count to `pageCount`, re-cache geometry, repaint mounted `dirtyPages`. */
12
12
  refresh(dirtyPages: readonly number[], pageCount: number): void;
13
13
  /**
14
- * Draw every page at `scale` CSS px per PDF pt and repaint the mounted ones: on the
15
- * next frame, or once `settle` has passed without another call, which is what a
16
- * gesture asks for, the stretched raster standing in until it rests.
14
+ * Draw every page at `scale` CSS px per PDF pt and repaint the mounted ones on the next frame,
15
+ * or once `settle` ms pass without another call (a gesture's request, the stretched raster
16
+ * standing in).
17
17
  */
18
18
  setScale(scale: number, settle?: number): void;
19
19
  destroy(): void;
@@ -1,19 +1,13 @@
1
- // Vertical breathing room between stacked pages; cosmetic only, and a rung like
2
- // any other gap: the stack breathes on the same rhythm the cards do.
3
1
  const PAGE_GAP = 'var(--_qm-space-4)';
4
- // The least raster density, in device pixels per CSS pixel. Glyphs rasterize
5
- // unhinted, so a raster at a 1× or fractional ratio (1.25, 1.5; browser zoom)
6
- // is resampled nearly 1:1 and small text smears; a raster at least twice the
7
- // box is downsampled instead, which keeps body text legible.
2
+ // The least raster density, in device pixels per CSS pixel: glyphs rasterize unhinted, so a 1× or
3
+ // fractional raster smears small text where a 2× one downsamples cleanly.
8
4
  const MIN_DENSITY = 2;
9
5
  export function createPaintLoop(session, container, margin,
10
- // Notified when a slot's `session.paint` throws: the loop swallows the throw
11
- // (see `paintSlot`) so one bad page never aborts the band sweep; the caller
12
- // decides how to surface it.
6
+ // Notified when a slot's `session.paint` throws; the throw is swallowed so one bad page never
7
+ // aborts the band sweep.
13
8
  onPaintError) {
14
- // The IntersectionObserver root must be a scrollable ancestor of the slots;
15
- // respect a consumer's own choice if they already set one (computed style, so
16
- // a stylesheet rule counts as a choice, not just an inline style).
9
+ // The IntersectionObserver root must be a scrollable ancestor of the slots; a consumer's own
10
+ // choice stands.
17
11
  const computed = getComputedStyle(container);
18
12
  if (computed.overflowY === 'visible')
19
13
  container.style.overflowY = 'auto';
@@ -42,27 +36,15 @@ onPaintError) {
42
36
  }, { root: container, threshold: 0 });
43
37
  function makeSlot(page) {
44
38
  const size = session.pageSize(page);
45
- // The sheet is a plane and the desk is the one under it: paper sits at
46
- // `--_qm-surface` and the container paints `--_qm-surface-sunken`, so the
47
- // edge is where two tones meet and no stroke is drawn around it
48
- // (ARCHITECTURE §Styling). It leaves the no-ink rule untouched, since a
49
- // boundary that is not painted is not a mark on the page (PREVIEW §"Two
50
- // responsibilities"). `border-box` costs nothing here and keeps a slot a
51
- // consumer insets from measuring wider than its paper.
39
+ // The paper edge is where `--_qm-surface` meets the container's `--_qm-surface-sunken`; no
40
+ // stroke is drawn (ARCHITECTURE §Styling).
52
41
  const el = document.createElement('div');
53
- // Not `.qm-page`: that name is the preset's page baseline, an unlayered rule at
54
- // ordinary specificity a host puts on `<body>`, and a slot wearing it takes that
55
- // rule's `color-scheme` inside a mounted root, which the derivation leaves to the
56
- // host (`core/theme.css`).
42
+ // Not `.qm-page`: that is the preset's page baseline, whose `color-scheme` a slot must not
43
+ // take inside a mounted root (`core/theme.css`).
57
44
  el.className = 'qm-page-slot';
58
- // The page's index, in the DOM. `pageByEl` is this module's and a consumer
59
- // drawing an overlay of its own needs the number: position among the slots is
60
- // the only handle without it, which is right today and is not a contract.
61
45
  el.dataset.page = String(page);
62
- // `flex: none` under the flex column `Preview.svelte` lays the desk out as, which
63
- // would otherwise shrink a page to fit the pane; auto inline margins centre a page
64
- // narrower than the pane and resolve to zero on one wider, so it overflows to the
65
- // scrollable side.
46
+ // `flex: none` keeps the flex column from shrinking a page; auto inline margins centre a
47
+ // narrower page and overflow a wider one to the scrollable side.
66
48
  Object.assign(el.style, {
67
49
  position: 'relative',
68
50
  boxSizing: 'border-box',
@@ -74,7 +56,7 @@ onPaintError) {
74
56
  sizeSlot(el, size);
75
57
  if (page > 0)
76
58
  el.style.marginTop = PAGE_GAP;
77
- // After the last slot rather than at the end: the zoom pill's dock is a child too.
59
+ // After the last slot: the zoom pill's dock is a child too.
78
60
  const last = slots.at(-1)?.el;
79
61
  if (last)
80
62
  last.after(el);
@@ -92,8 +74,6 @@ onPaintError) {
92
74
  const layoutScale = scale ?? (slot.el.clientWidth || slot.size.widthPt) / slot.size.widthPt;
93
75
  return layoutScale * Math.max(window.devicePixelRatio || 1, MIN_DENSITY);
94
76
  }
95
- // Rasterize `slot` into its canvas (creating the canvas if this is its first
96
- // paint since mounting).
97
77
  function paintSlot(slot) {
98
78
  let canvas = canvases.get(slot.page);
99
79
  if (!canvas) {
@@ -107,8 +87,7 @@ onPaintError) {
107
87
  height: '100%'
108
88
  });
109
89
  }
110
- // Contextless canvas: bail before registering, so `updateBand` still sees
111
- // the page as unmounted and retries instead of keeping a blank page.
90
+ // Bail before registering so `updateBand` retries rather than keeping a blank page.
112
91
  const ctx = canvas.getContext('2d');
113
92
  if (!ctx)
114
93
  return;
@@ -122,11 +101,8 @@ onPaintError) {
122
101
  rastered.set(slot.page, at);
123
102
  }
124
103
  catch (err) {
125
- // A paint that throws must not abort the band loop; `updateBand` runs in
126
- // the IntersectionObserver callback and sweeps every entry, so an
127
- // uncaught throw would strand the rest of the band unpainted. Unmount the
128
- // half-mounted canvas (so the page is a retryable unmounted slot, not a
129
- // blank registered one) and surface the failure; the sweep moves on.
104
+ // Unmount the half-mounted canvas so the page stays a retryable unmounted slot, and let
105
+ // the sweep move on.
130
106
  unmountPage(slot.page);
131
107
  onPaintError?.(slot.page, err);
132
108
  }
@@ -139,10 +115,8 @@ onPaintError) {
139
115
  canvases.delete(page);
140
116
  rastered.delete(page);
141
117
  }
142
- // Mount pages newly inside [min(visible)-margin, max(visible)+margin], unmount
143
- // pages that fell outside it. Only touches pages changing band membership;
144
- // an already-mounted page in the band is left alone (paint contract: an idle
145
- // canvas keeps its pixels for free; repainting it here would be pure waste).
118
+ // Mount pages newly inside [min(visible)-margin, max(visible)+margin] and unmount those outside
119
+ // it; a mounted page in the band keeps its pixels.
146
120
  function updateBand() {
147
121
  const painted = new Set();
148
122
  if (visible.size === 0 || slots.length === 0)
@@ -162,10 +136,7 @@ onPaintError) {
162
136
  }
163
137
  return painted;
164
138
  }
165
- // Grow/shrink the slot array to `pageCount` (trailing add/remove; a recompile
166
- // reports the new count, and dirtyPages already covers any reflow), then
167
- // re-cache every page's geometry (cheap report-only reads; simplest way to
168
- // stay correct across any recompile, not just a count change).
139
+ // Grow or shrink the slots to `pageCount`, then re-cache every page's geometry.
169
140
  function reconcile(pageCount) {
170
141
  while (slots.length > pageCount) {
171
142
  const slot = slots.pop();
@@ -183,22 +154,15 @@ onPaintError) {
183
154
  for (let i = 0; i < slots.length; i++) {
184
155
  const size = session.pageSize(i);
185
156
  slots[i] = { ...slots[i], size };
186
- // Keep the page box's shape in step with the re-read size: a recompile
187
- // can change a page's dimensions without changing the count, and every
188
- // %-space click/scroll transform assumes box shape matches PageSize.
157
+ // A recompile can change a page's dimensions without changing the count.
189
158
  slots[i].el.style.aspectRatio = `${size.widthPt} / ${size.heightPt}`;
190
159
  sizeSlot(slots[i].el, size);
191
160
  }
192
161
  }
193
- // Slots are built on the first `refresh` (the controller calls it immediately),
194
- // not here: reconcile → `session.pageSize` must run after the controller's
195
- // `pageCount` gate, since `pageSize` throws for a page the count excludes.
196
- // ── Keep mounted rasters sharp ──────────────────────────────────────────────
197
- // A canvas stretches with its slot, so a page that stays mounted while the scale
198
- // or `devicePixelRatio` shifts keeps its place and goes soft. Both repaint mounted
199
- // pages; canvases are `position:absolute`, so a repaint can't feed back into
200
- // layout.
201
- // Repaint mounted pages whose raster scale moved. A no-op for an unmounted page.
162
+ // Slots are built on the first `refresh`: `session.pageSize` throws for a page the count
163
+ // excludes.
164
+ // Repaint mounted pages whose raster scale moved; a canvas stretches with its slot and goes
165
+ // soft otherwise.
202
166
  function repaint() {
203
167
  for (const page of canvases.keys()) {
204
168
  const slot = slots[page];
@@ -229,11 +193,9 @@ onPaintError) {
229
193
  repaint();
230
194
  });
231
195
  }
232
- // `devicePixelRatio` changes (window dragged to a different-DPI monitor, browser
233
- // zoom) leave the scale untouched, so `setScale` never hears of them; watch DPR
234
- // directly. A `(resolution: …dppx)` query pins the current ratio and
235
- // is therefore one-shot: re-arm it against the new ratio on each change. Guarded
236
- // for environments (jsdom) without `matchMedia`.
196
+ // A `devicePixelRatio` change never reaches `setScale`. A `(resolution: …dppx)` query pins the
197
+ // current ratio, so it is re-armed on each change. Guarded for jsdom, which has no
198
+ // `matchMedia`.
237
199
  let dprQuery;
238
200
  function onDprChange() {
239
201
  scheduleRepaint();
@@ -252,9 +214,7 @@ onPaintError) {
252
214
  },
253
215
  refresh(dirtyPages, pageCount) {
254
216
  reconcile(pageCount);
255
- // Slot count may have moved the band (pages added/removed); a page the
256
- // band just painted is already post-apply, so a second full
257
- // rasterization from the dirty loop would add nothing.
217
+ // A page the band just painted is already post-apply.
258
218
  const painted = updateBand();
259
219
  for (const page of dirtyPages) {
260
220
  if (painted.has(page))
@@ -6,7 +6,7 @@ export interface PreviewStrings {
6
6
  /** The zoom pill's action, after the percentage it shows. */
7
7
  resetZoom: string;
8
8
  }
9
- /** The package's English. Every key, so the merge below is total. */
9
+ /** The package's English. */
10
10
  export declare const DEFAULT_PREVIEW_STRINGS: PreviewStrings;
11
11
  /** A consumer's wording: any subset, the rest the package's. */
12
12
  export type PreviewStringsInput = Partial<PreviewStrings>;
@@ -1,17 +1,5 @@
1
- // Every word the preview says: the surface is a canvas, and its only prose is the
2
- // message states that stand in when there is nothing to paint, and the name a screen
3
- // reader gives the zoom pill.
4
- //
5
- // Separate from the visual surface's set rather than pooled with it: `/preview`
6
- // reaches `/core` and nothing editor-side, which is what lets it promote to its own
7
- // package, and a shared strings module would be an edge back across that line. The
8
- // shape is the same, so a consumer wording both writes the same kind of object
9
- // twice rather than learning two idioms.
10
- //
11
- // A plain options field rather than a context channel, unlike the visual surface's:
12
- // they are read in one module by one function, so there is no tree to make them
13
- // ambient for.
14
- /** The package's English. Every key, so the merge below is total. */
1
+ // Separate from the visual surface's strings: `/preview` reaches `/core` and nothing editor-side.
2
+ /** The package's English. */
15
3
  export const DEFAULT_PREVIEW_STRINGS = {
16
4
  renderFailed: 'Preview failed to render.',
17
5
  noPages: 'No pages to preview.',
@@ -42,15 +42,17 @@ export function createViewport(container, slots, setScale, resetLabel) {
42
42
  // was hidden at stands, so a return to the same box re-rasters nothing.
43
43
  return width > 0 && height > 0 ? { width, height } : undefined;
44
44
  }
45
- // What the pages are drawn at, the zoom held against this pane's floor, and the floor.
45
+ // What the pages are drawn at, the zoom held against this pane's floor, the floor, and
46
+ // the default fit.
46
47
  function bounds() {
47
48
  const box = pane();
48
49
  if (!box || !slots.length)
49
50
  return undefined;
50
51
  const sizes = slots.map((s) => s.size);
51
52
  const floor = floorZoom(sizes, box);
52
- const current = zoom === undefined ? fitZoom(sizes, box) : clampZoom(zoom, floor);
53
- return { current, floor };
53
+ const fit = fitZoom(sizes, box);
54
+ const current = zoom === undefined ? fit : clampZoom(zoom, floor);
55
+ return { current, floor, fit };
54
56
  }
55
57
  function apply(settle) {
56
58
  const b = bounds();
@@ -113,11 +115,11 @@ export function createViewport(container, slots, setScale, resetLabel) {
113
115
  // A re-fit holds the line at the pane's top edge, so a dragged divider leaves the
114
116
  // reader where they were. The desk turns the browser's own scroll anchoring off
115
117
  // (`Preview.svelte`), which would correct the same move a second time.
116
- function refit() {
118
+ function refit(settle) {
117
119
  if (!container.scrollTop && !container.scrollLeft)
118
- return apply();
120
+ return apply(settle);
119
121
  const r = container.getBoundingClientRect();
120
- holding(r.left + r.width / 2, r.top, () => apply());
122
+ holding(r.left + r.width / 2, r.top, () => apply(settle));
121
123
  }
122
124
  function centre() {
123
125
  const r = container.getBoundingClientRect();
@@ -125,27 +127,29 @@ export function createViewport(container, slots, setScale, resetLabel) {
125
127
  }
126
128
  // The pointer while it is over the pane, else the pane's centre.
127
129
  const anchor = () => pointer ?? centre();
130
+ // A zoom landing on the fit is the default again, and follows the pane from there.
131
+ const settled = (next, b) => next === b.fit ? undefined : next;
128
132
  function step(next, at = anchor()) {
129
133
  const b = bounds();
130
134
  if (!b)
131
135
  return;
132
136
  run = undefined;
133
- zoomTo(next(b), at.x, at.y);
137
+ zoomTo(settled(next(b), b), at.x, at.y);
134
138
  }
135
139
  // About the pane's centre, the pointer being on the pill.
136
140
  const reset = () => step(() => undefined, centre());
137
141
  // The raw zoom a gesture starting now carries from: the run's own while it is still
138
142
  // the one on screen.
139
143
  function from(b) {
140
- return run && run.shown === b.current ? run : { raw: b.current, shown: b.current };
144
+ return run && run.shown === b.current ? run : { raw: b.current, shown: b.current, slack: 0 };
141
145
  }
142
146
  function glideTo(raw, x, y) {
143
147
  const b = bounds();
144
148
  if (!b)
145
149
  return;
146
150
  const start = from(b);
147
- run = glide(start, raw(start), b.floor);
148
- zoomTo(run.shown, x, y, SETTLE_MS);
151
+ run = glide(start, raw(start), b.floor, b.fit);
152
+ zoomTo(settled(run.shown, b), x, y, SETTLE_MS);
149
153
  }
150
154
  // Ctrl-wheel is a mouse's zoom, and the trackpad pinch Chromium and Firefox send as
151
155
  // `deltaY = -100 ln(scale)`, in deltas smaller than a notch's: those track the fingers.
@@ -169,7 +173,7 @@ export function createViewport(container, slots, setScale, resetLabel) {
169
173
  if ((!dir && e.key !== '0') || !bounds())
170
174
  return;
171
175
  e.preventDefault();
172
- step((b) => (dir ? stepZoom(b.current, dir, b.floor) : undefined));
176
+ step((b) => (dir ? stepZoom(b.current, dir, b.floor, b.fit) : undefined));
173
177
  }
174
178
  let pinch;
175
179
  // The fingers on this pane, not one resting on the editor beside it.
@@ -270,13 +274,14 @@ export function createViewport(container, slots, setScale, resetLabel) {
270
274
  ];
271
275
  for (const [target, type, fn, opts] of listeners)
272
276
  target.addEventListener(type, fn, opts);
273
- // The pane's box moving: a dragged divider, a resized window, a hidden track shown.
274
- // Guarded for jsdom, which ships no ResizeObserver.
275
- const resizeObserver = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(() => refit());
277
+ // The pane's box moving: a dragged divider, a resized window, a hidden track shown. A
278
+ // drag is a gesture, so its pages re-raster once it rests. Guarded for jsdom, which
279
+ // ships no ResizeObserver.
280
+ const resizeObserver = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(() => refit(SETTLE_MS));
276
281
  resizeObserver?.observe(container);
277
282
  apply();
278
283
  return {
279
- update: refit,
284
+ update: () => refit(),
280
285
  setZoom(next) {
281
286
  const valid = next !== undefined && Number.isFinite(next) && next > 0 ? next : undefined;
282
287
  if (bounds())
@@ -1,41 +1,40 @@
1
1
  import type { PageSize } from '@quillmark/wasm';
2
- /** CSS px per PDF pt at 100%: 96 CSS px to the inch over 72 pt, a PDF viewer's actual
3
- * size. */
2
+ /** CSS px per PDF pt at 100%: 96 CSS px to the inch over 72 pt. */
4
3
  export declare const ACTUAL: number;
5
4
  /** The furthest a gesture zooms in. */
6
5
  export declare const MAX_ZOOM = 2;
7
- /** What `Mod +` and `Mod −` step through: a subset of the levels a browser's own zoom
8
- * keys land on, so a step lands somewhere a reader has been before. */
6
+ /** What `Mod +` and `Mod −` step through. */
9
7
  export declare const STEPS: readonly [0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
10
8
  /** The pane's content box, in CSS px. */
11
9
  export interface Pane {
12
10
  width: number;
13
11
  height: number;
14
12
  }
15
- /** The default: the widest page fits the pane's width, capped at {@link FIT_CAP}. A
16
- * portrait-only document lands at the pane's width, as a narrow pane always drew it. */
13
+ /** The default: the widest page fits the pane's width, capped at {@link FIT_CAP}. */
17
14
  export declare function fitZoom(pages: readonly PageSize[], pane: Pane): number;
18
- /** The furthest a gesture zooms out: every page fits the pane whole with
19
- * {@link FLOOR_FILL} of it filled on the page's limiting axis, and never past the
20
- * default, which on a pane past the cap is already smaller than that. */
15
+ /**
16
+ * The furthest a gesture zooms out: every page fits the pane whole with {@link FLOOR_FILL} filled
17
+ * on its limiting axis, and never past the default.
18
+ */
21
19
  export declare function floorZoom(pages: readonly PageSize[], pane: Pane): number;
22
20
  export declare function clampZoom(zoom: number, floor: number): number;
23
- /** The next level in `dir` from `zoom`, skipping levels under `floor`. From between two
24
- * levels the first press lands on the nearer one in that direction; past the last level
25
- * above the floor, the floor. Half a percent of slack, so a zoom shown as a level is at
26
- * it. */
27
- export declare function stepZoom(zoom: number, dir: 1 | -1, floor: number): number;
28
21
  /**
29
- * A continuous gesture's run: where it would be unsnapped (`raw`), and what it shows.
30
- * The run carries `raw` across events, so the detent at 100% holds a slow gesture
31
- * rather than stopping it: a trackpad's small deltas accumulate past the detent, where
32
- * snapping the shown value alone would pin every later event back to it.
22
+ * The next level in `dir` from `zoom`, skipping levels under `floor`; past the last, the floor.
23
+ * Half a percent of slack, so a zoom shown as a level is at it.
24
+ */
25
+ export declare function stepZoom(zoom: number, dir: 1 | -1, floor: number, fit: number): number;
26
+ /**
27
+ * A continuous gesture's run: the zoom its events asked for (`raw`), what it shows, and while it
28
+ * shows a hold, how far past it the events have carried (`slack`, a log of the ratio).
33
29
  */
34
30
  export interface Glide {
35
31
  raw: number;
36
32
  shown: number;
33
+ slack: number;
37
34
  }
38
- /** One event of a continuous gesture, carrying the run to `raw`: held at 100% when it
39
- * crosses or lands within {@link DETENT} of it, so one wheel notch across actual size
40
- * stops there. */
41
- export declare function glide(run: Glide, raw: number, floor: number): Glide;
35
+ /**
36
+ * One event of a continuous gesture, carrying the run to `raw`. It stops at the first of `fit` or
37
+ * 100% it crosses, and leaves a hold once carried {@link DETENT} past. 100% gives way to a `fit`
38
+ * within the detent of it.
39
+ */
40
+ export declare function glide(run: Glide, raw: number, floor: number, fit: number): Glide;