@quillmark/svelte 0.24.0 → 0.25.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 (55) hide show
  1. package/THEMING.md +1 -1
  2. package/dist/core/codec/blocks.d.ts +23 -6
  3. package/dist/core/codec/blocks.js +163 -38
  4. package/dist/core/codec/code.js +3 -8
  5. package/dist/core/codec/field.d.ts +4 -2
  6. package/dist/core/codec/field.js +12 -4
  7. package/dist/core/codec/index.d.ts +2 -0
  8. package/dist/core/codec/index.js +4 -0
  9. package/dist/core/codec/inputrules.js +40 -10
  10. package/dist/core/codec/keymap.js +19 -1
  11. package/dist/core/codec/line-paste.d.ts +7 -0
  12. package/dist/core/codec/line-paste.js +36 -0
  13. package/dist/core/codec/list-move.d.ts +6 -0
  14. package/dist/core/codec/list-move.js +30 -0
  15. package/dist/core/codec/list-paste.d.ts +2 -0
  16. package/dist/core/codec/list-paste.js +142 -0
  17. package/dist/core/codec/list-toggle.d.ts +4 -0
  18. package/dist/core/codec/list-toggle.js +170 -0
  19. package/dist/core/codec/lists.d.ts +22 -15
  20. package/dist/core/codec/lists.js +404 -97
  21. package/dist/core/codec/markdown-paste.d.ts +9 -0
  22. package/dist/core/codec/markdown-paste.js +63 -2
  23. package/dist/core/codec/nesting.d.ts +5 -1
  24. package/dist/core/codec/nesting.js +1 -1
  25. package/dist/core/codec/prose.css +21 -20
  26. package/dist/core/codec/slash.d.ts +2 -1
  27. package/dist/core/codec/slash.js +0 -0
  28. package/dist/core/codec/touching.d.ts +12 -0
  29. package/dist/core/codec/touching.js +33 -0
  30. package/dist/core/codec/undo.d.ts +5 -0
  31. package/dist/core/codec/undo.js +21 -0
  32. package/dist/preview/Preview.svelte +88 -13
  33. package/dist/preview/Preview.svelte.d.ts +2 -2
  34. package/dist/preview/bridge.js +8 -2
  35. package/dist/preview/controller.d.ts +8 -4
  36. package/dist/preview/controller.js +10 -2
  37. package/dist/preview/index.js +1 -1
  38. package/dist/preview/paint.d.ts +6 -2
  39. package/dist/preview/paint.js +67 -56
  40. package/dist/preview/strings.d.ts +2 -0
  41. package/dist/preview/strings.js +4 -2
  42. package/dist/preview/viewport.d.ts +10 -0
  43. package/dist/preview/viewport.js +271 -0
  44. package/dist/preview/zoom.d.ts +41 -0
  45. package/dist/preview/zoom.js +52 -0
  46. package/dist/visual/ArrayField.svelte +24 -4
  47. package/dist/visual/DateField.svelte +26 -13
  48. package/dist/visual/FormatPopover.svelte +41 -2
  49. package/dist/visual/ProseValue.svelte +35 -3
  50. package/dist/visual/ProseValue.svelte.d.ts +7 -0
  51. package/dist/visual/icons/nodes.d.ts +26 -0
  52. package/dist/visual/icons/nodes.js +16 -0
  53. package/dist/visual/strings.d.ts +4 -2
  54. package/dist/visual/strings.js +9 -3
  55. package/package.json +1 -1
@@ -22,8 +22,12 @@ onPaintError) {
22
22
  const slots = [];
23
23
  const pageByEl = new Map();
24
24
  const canvases = new Map();
25
+ // The raster scale each mounted canvas was last painted at, so a repaint skips one
26
+ // already sharp at the scale it would take.
27
+ const rastered = new Map();
25
28
  const visible = new Set();
26
- let zoom = 1;
29
+ // Unset until the pane has had a box to fit: a slot fills the pane's width meanwhile.
30
+ let scale;
27
31
  const observer = new IntersectionObserver((entries) => {
28
32
  for (const entry of entries) {
29
33
  const idx = pageByEl.get(entry.target);
@@ -52,25 +56,44 @@ onPaintError) {
52
56
  // host (`core/theme.css`).
53
57
  el.className = 'qm-page-slot';
54
58
  // The page's index, in the DOM. `pageByEl` is this module's and a consumer
55
- // drawing an overlay of its own needs the number: position among siblings is
59
+ // drawing an overlay of its own needs the number: position among the slots is
56
60
  // the only handle without it, which is right today and is not a contract.
57
61
  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.
58
66
  Object.assign(el.style, {
59
67
  position: 'relative',
60
68
  boxSizing: 'border-box',
61
- width: '100%',
69
+ flex: 'none',
70
+ marginInline: 'auto',
62
71
  aspectRatio: `${size.widthPt} / ${size.heightPt}`,
63
72
  background: 'var(--_qm-surface)'
64
73
  });
74
+ sizeSlot(el, size);
65
75
  if (page > 0)
66
76
  el.style.marginTop = PAGE_GAP;
67
- container.appendChild(el);
77
+ // After the last slot rather than at the end: the zoom pill's dock is a child too.
78
+ const last = slots.at(-1)?.el;
79
+ if (last)
80
+ last.after(el);
81
+ else
82
+ container.prepend(el);
68
83
  pageByEl.set(el, page);
69
84
  observer.observe(el);
70
85
  return { page, size, el };
71
86
  }
87
+ function sizeSlot(el, size) {
88
+ el.style.width = scale === undefined ? '100%' : `${size.widthPt * scale}px`;
89
+ }
90
+ // Device px per PDF pt: the layout scale, else the slot's own width, times density.
91
+ function rasterScale(slot) {
92
+ const layoutScale = scale ?? (slot.el.clientWidth || slot.size.widthPt) / slot.size.widthPt;
93
+ return layoutScale * Math.max(window.devicePixelRatio || 1, MIN_DENSITY);
94
+ }
72
95
  // Rasterize `slot` into its canvas (creating the canvas if this is its first
73
- // paint since mounting), at the slot's own current CSS width.
96
+ // paint since mounting).
74
97
  function paintSlot(slot) {
75
98
  let canvas = canvases.get(slot.page);
76
99
  if (!canvas) {
@@ -93,10 +116,10 @@ onPaintError) {
93
116
  slot.el.insertBefore(canvas, slot.el.firstChild);
94
117
  canvases.set(slot.page, canvas);
95
118
  }
96
- const layoutScale = (slot.el.clientWidth || slot.size.widthPt) / slot.size.widthPt;
97
- const densityScale = Math.max(window.devicePixelRatio || 1, MIN_DENSITY) * zoom;
119
+ const at = rasterScale(slot);
98
120
  try {
99
- session.paint(ctx, slot.page, layoutScale * densityScale);
121
+ session.paint(ctx, slot.page, at);
122
+ rastered.set(slot.page, at);
100
123
  }
101
124
  catch (err) {
102
125
  // A paint that throws must not abort the band loop; `updateBand` runs in
@@ -114,6 +137,7 @@ onPaintError) {
114
137
  return;
115
138
  canvas.remove();
116
139
  canvases.delete(page);
140
+ rastered.delete(page);
117
141
  }
118
142
  // Mount pages newly inside [min(visible)-margin, max(visible)+margin], unmount
119
143
  // pages that fell outside it. Only touches pages changing band membership;
@@ -163,73 +187,56 @@ onPaintError) {
163
187
  // can change a page's dimensions without changing the count, and every
164
188
  // %-space click/scroll transform assumes box shape matches PageSize.
165
189
  slots[i].el.style.aspectRatio = `${size.widthPt} / ${size.heightPt}`;
190
+ sizeSlot(slots[i].el, size);
166
191
  }
167
192
  }
168
193
  // Slots are built on the first `refresh` (the controller calls it immediately),
169
194
  // not here: reconcile → `session.pageSize` must run after the controller's
170
195
  // `pageCount` gate, since `pageSize` throws for a page the count excludes.
171
196
  // ── Keep mounted rasters sharp ──────────────────────────────────────────────
172
- // A canvas stretches with its slot, so a page that stays mounted while the
173
- // container's CSS width or `devicePixelRatio` shifts keeps its place and goes
174
- // soft. Two observers repaint mounted pages from their current box; canvases
175
- // are `position:absolute`, so a repaint can't feed back into layout (and thus
176
- // can't re-trigger the resize observer).
177
- // Repaint mounted pages (all, or a given subset) from their live geometry: the
178
- // one path resize, DPR, and zoom share; each `paintSlot` re-reads the slot's
179
- // current `clientWidth`. A no-op for an unmounted page.
180
- function repaint(pages) {
181
- for (const page of pages ?? canvases.keys()) {
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.
202
+ function repaint() {
203
+ for (const page of canvases.keys()) {
182
204
  const slot = slots[page];
183
- if (slot && canvases.has(page))
205
+ if (slot && rastered.get(page) !== rasterScale(slot))
184
206
  paintSlot(slot);
185
207
  }
186
208
  }
187
- // Coalesce a burst of observer callbacks into one repaint on the next frame.
188
- // `force` (a DPR change) bypasses the width guard, since a DPR change moves no
189
- // CSS-px width; a plain resize repaints only when the width actually moved, so
190
- // a height-only or no-op tick costs nothing.
209
+ // One repaint for a burst: on the next frame, or once `settle` passes with no later
210
+ // call, a settle superseding a frame not yet run.
191
211
  let rafId = 0;
192
- let lastWidth = container.clientWidth;
193
- let forcePending = false;
194
- function scheduleRepaint(force) {
195
- if (force)
196
- forcePending = true;
212
+ let settleId;
213
+ function scheduleRepaint(settle = 0) {
214
+ clearTimeout(settleId);
215
+ settleId = undefined;
216
+ if (settle > 0) {
217
+ cancelAnimationFrame(rafId);
218
+ rafId = 0;
219
+ settleId = setTimeout(() => {
220
+ settleId = undefined;
221
+ repaint();
222
+ }, settle);
223
+ return;
224
+ }
197
225
  if (rafId)
198
226
  return;
199
227
  rafId = requestAnimationFrame(() => {
200
228
  rafId = 0;
201
- const width = container.clientWidth;
202
- // A pane the shell has hidden reports a 0×0 box, which is a width to paint
203
- // nothing at rather than a width that moved: `paintSlot` would take its
204
- // `clientWidth || widthPt` fallback and freeze every mounted canvas at the 1×
205
- // raster. The last real width and any pending force are held instead — the
206
- // observer fires again when the pane has a box, and a return at that same
207
- // width needs no raster, the pixels being the ones it was hidden with.
208
- if (width === 0)
209
- return;
210
- const forced = forcePending;
211
- forcePending = false;
212
- if (!forced && width === lastWidth)
213
- return;
214
- lastWidth = width;
215
229
  repaint();
216
230
  });
217
231
  }
218
- // Container CSS-width changes (drag the pane wider, resize the window). Guarded
219
- // for jsdom, which ships no ResizeObserver; the unit suite drives no resize, so
220
- // skipping it there is correct; a real browser takes the observed path.
221
- const resizeObserver = typeof ResizeObserver === 'undefined'
222
- ? undefined
223
- : new ResizeObserver(() => scheduleRepaint(false));
224
- resizeObserver?.observe(container);
225
232
  // `devicePixelRatio` changes (window dragged to a different-DPI monitor, browser
226
- // zoom) leave CSS-px width untouched, so the ResizeObserver never sees them;
227
- // watch DPR directly. A `(resolution: …dppx)` query pins the current ratio and
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
228
235
  // is therefore one-shot: re-arm it against the new ratio on each change. Guarded
229
236
  // for environments (jsdom) without `matchMedia`.
230
237
  let dprQuery;
231
238
  function onDprChange() {
232
- scheduleRepaint(true);
239
+ scheduleRepaint();
233
240
  armDprListener();
234
241
  }
235
242
  function armDprListener() {
@@ -257,14 +264,18 @@ onPaintError) {
257
264
  paintSlot(slot);
258
265
  }
259
266
  },
260
- setDensityZoom(z) {
261
- zoom = z;
262
- repaint();
267
+ setScale(next, settle) {
268
+ if (next === scale)
269
+ return;
270
+ scale = next;
271
+ for (const slot of slots)
272
+ sizeSlot(slot.el, slot.size);
273
+ scheduleRepaint(settle);
263
274
  },
264
275
  destroy() {
265
276
  if (rafId)
266
277
  cancelAnimationFrame(rafId);
267
- resizeObserver?.disconnect();
278
+ clearTimeout(settleId);
268
279
  dprQuery?.removeEventListener('change', onDprChange);
269
280
  observer.disconnect();
270
281
  for (const page of [...canvases.keys()])
@@ -3,6 +3,8 @@ export interface PreviewStrings {
3
3
  renderFailed: string;
4
4
  /** A compile with zero pages: recoverable, and the commonest state of a fresh seed. */
5
5
  noPages: string;
6
+ /** The zoom pill's action, after the percentage it shows. */
7
+ resetZoom: string;
6
8
  }
7
9
  /** The package's English. Every key, so the merge below is total. */
8
10
  export declare const DEFAULT_PREVIEW_STRINGS: PreviewStrings;
@@ -1,5 +1,6 @@
1
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.
2
+ // message states that stand in when there is nothing to paint, and the name a screen
3
+ // reader gives the zoom pill.
3
4
  //
4
5
  // Separate from the visual surface's set rather than pooled with it: `/preview`
5
6
  // reaches `/core` and nothing editor-side, which is what lets it promote to its own
@@ -13,7 +14,8 @@
13
14
  /** The package's English. Every key, so the merge below is total. */
14
15
  export const DEFAULT_PREVIEW_STRINGS = {
15
16
  renderFailed: 'Preview failed to render.',
16
- noPages: 'No pages to preview.'
17
+ noPages: 'No pages to preview.',
18
+ resetZoom: 'reset zoom'
17
19
  };
18
20
  /** Fill a partial override to the whole key set. */
19
21
  export function mergePreviewStrings(custom) {
@@ -0,0 +1,10 @@
1
+ import type { PageSlot } from './paint.js';
2
+ export interface Viewport {
3
+ /** Re-fit: the pane's box, or the pages' sizes after a recompile, moved. */
4
+ update(): void;
5
+ /** A fraction of actual size, held against the pane's floor; `undefined` is the
6
+ * default fit. */
7
+ setZoom(zoom: number | undefined): void;
8
+ destroy(): void;
9
+ }
10
+ export declare function createViewport(container: HTMLElement, slots: readonly PageSlot[], setScale: (scale: number, settle?: number) => void, resetLabel: string): Viewport;
@@ -0,0 +1,271 @@
1
+ import { ACTUAL, clampZoom, fitZoom, floorZoom, glide, stepZoom } from './zoom.js';
2
+ /** How long the pill stays after the zoom last moved, or the pointer last came near. */
3
+ const IDLE_MS = 1200;
4
+ /** How long a continuous gesture rests before its pages re-raster at the scale it
5
+ * reached: a raster per event would cost a full paint of every mounted page each one. */
6
+ const SETTLE_MS = 150;
7
+ /** How near the pill, in CSS px, the pointer brings it back. */
8
+ const CORNER_REACH_PX = 64;
9
+ /** What a wheel delta multiplies the zoom by, per unit of each `deltaMode`: a mouse
10
+ * notch is about 100 px or 3 lines, which this lands near a fifth either way. */
11
+ const WHEEL_RATE = [0.0025, 0.08, 0.25];
12
+ /** The pixel delta under which a ctrl-wheel event is a pinch rather than a notch, and the
13
+ * rate that inverts the pinch's encoding. */
14
+ const PINCH_DELTA = 50;
15
+ const PINCH_RATE = 0.01;
16
+ const px = (value) => parseFloat(value) || 0;
17
+ export function createViewport(container, slots, setScale, resetLabel) {
18
+ let zoom;
19
+ // The continuous gesture under way; a discrete act or a zoom it did not produce ends it.
20
+ let run;
21
+ let pointer;
22
+ // Sticky in the desk's flex column, last by `order` and pushed to the bottom by an
23
+ // auto margin, so the pill sits at the pane's corner however short or wide the
24
+ // pages are (`Preview.svelte`).
25
+ const dock = document.createElement('div');
26
+ dock.className = 'qm-preview-dock';
27
+ const pill = document.createElement('button');
28
+ pill.type = 'button';
29
+ pill.className = 'qm-preview-zoom';
30
+ dock.appendChild(pill);
31
+ const ownsTabIndex = !container.hasAttribute('tabindex');
32
+ if (ownsTabIndex)
33
+ container.tabIndex = 0;
34
+ function pane() {
35
+ const style = getComputedStyle(container);
36
+ const width = container.clientWidth - px(style.paddingLeft) - px(style.paddingRight);
37
+ const height = container.clientHeight - px(style.paddingTop) - px(style.paddingBottom);
38
+ // A pane the shell has hidden measures 0×0, which is no box to fit: the scale it
39
+ // was hidden at stands, so a return to the same box re-rasters nothing.
40
+ return width > 0 && height > 0 ? { width, height } : undefined;
41
+ }
42
+ // What the pages are drawn at, the zoom held against this pane's floor, and the floor.
43
+ function bounds() {
44
+ const box = pane();
45
+ if (!box || !slots.length)
46
+ return undefined;
47
+ const sizes = slots.map((s) => s.size);
48
+ const floor = floorZoom(sizes, box);
49
+ const current = zoom === undefined ? fitZoom(sizes, box) : clampZoom(zoom, floor);
50
+ return { current, floor };
51
+ }
52
+ function apply(settle) {
53
+ const b = bounds();
54
+ if (!b)
55
+ return;
56
+ setScale(b.current * ACTUAL, settle);
57
+ if (zoom === undefined) {
58
+ if (dock.contains(document.activeElement))
59
+ container.focus({ preventScroll: true });
60
+ dock.remove();
61
+ return;
62
+ }
63
+ const pct = `${Math.round(b.current * 100)}%`;
64
+ pill.textContent = pct;
65
+ pill.setAttribute('aria-label', `${pct}, ${resetLabel}`);
66
+ if (dock.isConnected)
67
+ return;
68
+ container.appendChild(dock);
69
+ wake();
70
+ }
71
+ // A pill under the pointer stays: hidden, it lets the click through to the page.
72
+ let idle;
73
+ function wake() {
74
+ delete pill.dataset.idle;
75
+ clearTimeout(idle);
76
+ idle = setTimeout(() => {
77
+ if (pill.matches(':hover'))
78
+ wake();
79
+ else
80
+ pill.dataset.idle = '';
81
+ }, IDLE_MS);
82
+ }
83
+ // The page under `y`, or the nearest one: the point a zoom holds still is a fraction
84
+ // of a page, since the gaps between pages do not scale with them.
85
+ function pageAt(y) {
86
+ for (const { el } of slots)
87
+ if (el.getBoundingClientRect().bottom >= y)
88
+ return el;
89
+ return slots.at(-1)?.el;
90
+ }
91
+ // Run `act`, then scroll so the point at `x`, `y` is over the same place on its page.
92
+ function holding(x, y, act) {
93
+ const page = pageAt(y);
94
+ const before = page?.getBoundingClientRect();
95
+ act();
96
+ if (!page || !before?.width || !before.height)
97
+ return;
98
+ const after = page.getBoundingClientRect();
99
+ container.scrollLeft += after.left + ((x - before.left) / before.width) * after.width - x;
100
+ container.scrollTop += after.top + ((y - before.top) / before.height) * after.height - y;
101
+ }
102
+ function zoomTo(next, x, y, settle) {
103
+ holding(x, y, () => {
104
+ zoom = next;
105
+ apply(settle);
106
+ });
107
+ if (zoom !== undefined)
108
+ wake();
109
+ }
110
+ // A re-fit holds the line at the pane's top edge, so a dragged divider leaves the
111
+ // reader where they were. The desk turns the browser's own scroll anchoring off
112
+ // (`Preview.svelte`), which would correct the same move a second time.
113
+ function refit() {
114
+ if (!container.scrollTop && !container.scrollLeft)
115
+ return apply();
116
+ const r = container.getBoundingClientRect();
117
+ holding(r.left + r.width / 2, r.top, () => apply());
118
+ }
119
+ function centre() {
120
+ const r = container.getBoundingClientRect();
121
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
122
+ }
123
+ // The pointer while it is over the pane, else the pane's centre.
124
+ const anchor = () => pointer ?? centre();
125
+ function step(next, at = anchor()) {
126
+ const b = bounds();
127
+ if (!b)
128
+ return;
129
+ run = undefined;
130
+ zoomTo(next(b), at.x, at.y);
131
+ }
132
+ // About the pane's centre, the pointer being on the pill.
133
+ const reset = () => step(() => undefined, centre());
134
+ // The raw zoom a gesture starting now carries from: the run's own while it is still
135
+ // the one on screen.
136
+ function from(b) {
137
+ return run && run.shown === b.current ? run : { raw: b.current, shown: b.current };
138
+ }
139
+ function glideTo(raw, x, y) {
140
+ const b = bounds();
141
+ if (!b)
142
+ return;
143
+ const start = from(b);
144
+ run = glide(start, raw(start), b.floor);
145
+ zoomTo(run.shown, x, y, SETTLE_MS);
146
+ }
147
+ // Ctrl-wheel is a mouse's zoom, and the trackpad pinch Chromium and Firefox send as
148
+ // `deltaY = -100 ln(scale)`, in deltas smaller than a notch's: those track the fingers.
149
+ function onWheel(e) {
150
+ if (!(e.ctrlKey || e.metaKey))
151
+ return;
152
+ e.preventDefault();
153
+ const pinched = e.deltaMode === 0 && Math.abs(e.deltaY) < PINCH_DELTA;
154
+ const factor = Math.exp(-e.deltaY * (pinched ? PINCH_RATE : WHEEL_RATE[e.deltaMode]));
155
+ glideTo((g) => g.raw * factor, e.clientX, e.clientY);
156
+ }
157
+ // The keys reach the pane under the pointer as well as the focused one: a click on a
158
+ // page lands its caret in the editor, taking the focus with it.
159
+ // The first pane to take a key claims it, so two previews never both step, and a key
160
+ // a host's own handler took is left to it.
161
+ function onKeyDown(e) {
162
+ if (!(e.ctrlKey || e.metaKey) || e.altKey || e.defaultPrevented || e.isComposing)
163
+ return;
164
+ if (!pointer && !container.contains(document.activeElement))
165
+ return;
166
+ const dir = e.key === '=' || e.key === '+' ? 1 : e.key === '-' || e.key === '_' ? -1 : 0;
167
+ if ((!dir && e.key !== '0') || !bounds())
168
+ return;
169
+ e.preventDefault();
170
+ step((b) => (dir ? stepZoom(b.current, dir, b.floor) : undefined));
171
+ }
172
+ // A two-finger pinch on a touch screen. The browser's own page zoom is held off by the
173
+ // desk's `touch-action` and by cancelling the move, which also stops the pan a second
174
+ // finger would otherwise start.
175
+ let pinch;
176
+ const spread = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
177
+ function onTouchStart(e) {
178
+ if (e.touches.length !== 2)
179
+ return;
180
+ const b = bounds();
181
+ if (b)
182
+ pinch = { spread: spread(e.touches), raw: from(b).raw };
183
+ }
184
+ function onTouchMove(e) {
185
+ if (!pinch || e.touches.length !== 2)
186
+ return;
187
+ e.preventDefault();
188
+ const [a, b] = [e.touches[0], e.touches[1]];
189
+ const ratio = spread(e.touches) / pinch.spread;
190
+ const start = pinch.raw;
191
+ glideTo(() => start * ratio, (a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2);
192
+ }
193
+ function onTouchEnd(e) {
194
+ if (e.touches.length < 2)
195
+ pinch = undefined;
196
+ }
197
+ // Safari's trackpad pinch. iOS sends these beside the touches, which are the ones read
198
+ // there; either way the page zoom is cancelled.
199
+ let gesture;
200
+ function onGestureStart(e) {
201
+ e.preventDefault();
202
+ const b = bounds();
203
+ gesture = pinch || !b ? undefined : from(b).raw;
204
+ }
205
+ function onGestureChange(e) {
206
+ e.preventDefault();
207
+ if (gesture === undefined || pinch)
208
+ return;
209
+ const { scale, clientX, clientY } = e;
210
+ const start = gesture;
211
+ const at = Number.isFinite(clientX + clientY) ? { x: clientX, y: clientY } : anchor();
212
+ glideTo(() => start * scale, at.x, at.y);
213
+ }
214
+ function onGestureEnd(e) {
215
+ e.preventDefault();
216
+ gesture = undefined;
217
+ }
218
+ function onPointerMove(e) {
219
+ if (e.pointerType === 'touch')
220
+ return;
221
+ pointer = { x: e.clientX, y: e.clientY };
222
+ if (!dock.isConnected)
223
+ return;
224
+ const r = pill.getBoundingClientRect();
225
+ if (e.clientX >= r.left - CORNER_REACH_PX && e.clientY >= r.top - CORNER_REACH_PX)
226
+ wake();
227
+ }
228
+ function onPointerLeave() {
229
+ pointer = undefined;
230
+ }
231
+ const listeners = [
232
+ [container, 'wheel', onWheel, { passive: false }],
233
+ [window, 'keydown', onKeyDown],
234
+ [container, 'touchstart', onTouchStart, { passive: true }],
235
+ [container, 'touchmove', onTouchMove, { passive: false }],
236
+ [container, 'touchend', onTouchEnd],
237
+ [container, 'touchcancel', onTouchEnd],
238
+ [container, 'gesturestart', onGestureStart],
239
+ [container, 'gesturechange', onGestureChange],
240
+ [container, 'gestureend', onGestureEnd],
241
+ [container, 'pointermove', onPointerMove],
242
+ [container, 'pointerleave', onPointerLeave],
243
+ [pill, 'click', reset]
244
+ ];
245
+ for (const [target, type, fn, opts] of listeners)
246
+ target.addEventListener(type, fn, opts);
247
+ // The pane's box moving: a dragged divider, a resized window, a hidden track shown.
248
+ // Guarded for jsdom, which ships no ResizeObserver.
249
+ const resizeObserver = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(() => refit());
250
+ resizeObserver?.observe(container);
251
+ apply();
252
+ return {
253
+ update: refit,
254
+ setZoom(next) {
255
+ const valid = next !== undefined && Number.isFinite(next) && next > 0 ? next : undefined;
256
+ if (bounds())
257
+ step(() => valid);
258
+ else
259
+ zoom = valid;
260
+ },
261
+ destroy() {
262
+ for (const [target, type, fn] of listeners)
263
+ target.removeEventListener(type, fn);
264
+ resizeObserver?.disconnect();
265
+ clearTimeout(idle);
266
+ dock.remove();
267
+ if (ownsTabIndex)
268
+ container.removeAttribute('tabindex');
269
+ }
270
+ };
271
+ }
@@ -0,0 +1,41 @@
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. */
4
+ export declare const ACTUAL: number;
5
+ /** The furthest a gesture zooms in. */
6
+ 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. */
9
+ export declare const STEPS: readonly [0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
10
+ /** The pane's content box, in CSS px. */
11
+ export interface Pane {
12
+ width: number;
13
+ height: number;
14
+ }
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. */
17
+ 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. */
21
+ export declare function floorZoom(pages: readonly PageSize[], pane: Pane): number;
22
+ 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
+ /**
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.
33
+ */
34
+ export interface Glide {
35
+ raw: number;
36
+ shown: number;
37
+ }
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;
@@ -0,0 +1,52 @@
1
+ /** CSS px per PDF pt at 100%: 96 CSS px to the inch over 72 pt, a PDF viewer's actual
2
+ * size. */
3
+ export const ACTUAL = 96 / 72;
4
+ /** The default never draws a page larger than this, so a wide pane shows a sheet at a
5
+ * size print would recognise rather than one stretched to the pane. */
6
+ const FIT_CAP = 1.25;
7
+ /** The furthest a gesture zooms in. */
8
+ export const MAX_ZOOM = 2;
9
+ /** How much of the pane the page still fills, on its limiting axis, at the furthest a
10
+ * gesture zooms out: the whole page in view with a thin margin, and never a stamp. */
11
+ const FLOOR_FILL = 0.9;
12
+ /** What `Mod +` and `Mod −` step through: a subset of the levels a browser's own zoom
13
+ * keys land on, so a step lands somewhere a reader has been before. */
14
+ export const STEPS = [0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
15
+ /** How far past 100% a continuous gesture carries before it leaves it. */
16
+ const DETENT = 0.04;
17
+ /** The default: the widest page fits the pane's width, capped at {@link FIT_CAP}. A
18
+ * portrait-only document lands at the pane's width, as a narrow pane always drew it. */
19
+ export function fitZoom(pages, pane) {
20
+ const widest = Math.max(...pages.map((p) => p.widthPt));
21
+ return Math.min(pane.width / (widest * ACTUAL), FIT_CAP);
22
+ }
23
+ /** The furthest a gesture zooms out: every page fits the pane whole with
24
+ * {@link FLOOR_FILL} of it filled on the page's limiting axis, and never past the
25
+ * default, which on a pane past the cap is already smaller than that. */
26
+ export function floorZoom(pages, pane) {
27
+ const whole = Math.min(...pages.map((p) => Math.min(pane.width / p.widthPt, pane.height / p.heightPt)));
28
+ return Math.min((FLOOR_FILL * whole) / ACTUAL, fitZoom(pages, pane));
29
+ }
30
+ export function clampZoom(zoom, floor) {
31
+ return Math.min(MAX_ZOOM, Math.max(floor, zoom));
32
+ }
33
+ /** The next level in `dir` from `zoom`, skipping levels under `floor`. From between two
34
+ * levels the first press lands on the nearer one in that direction; past the last level
35
+ * above the floor, the floor. Half a percent of slack, so a zoom shown as a level is at
36
+ * it. */
37
+ export function stepZoom(zoom, dir, floor) {
38
+ const next = dir > 0
39
+ ? STEPS.find((s) => s > zoom + 0.005)
40
+ : [...STEPS].reverse().find((s) => s < zoom - 0.005 && s >= floor);
41
+ return clampZoom(next ?? (dir > 0 ? MAX_ZOOM : floor), floor);
42
+ }
43
+ /** One event of a continuous gesture, carrying the run to `raw`: held at 100% when it
44
+ * crosses or lands within {@link DETENT} of it, so one wheel notch across actual size
45
+ * stops there. */
46
+ export function glide(run, raw, floor) {
47
+ let next = clampZoom(raw, floor);
48
+ if ((run.shown - 1) * (next - 1) < 0)
49
+ next = 1;
50
+ const shown = Math.abs(next - 1) <= DETENT ? 1 : next;
51
+ return { raw: next, shown: clampZoom(shown, floor) };
52
+ }
@@ -39,10 +39,10 @@
39
39
  one gesture the row keeps never needs a scroll to reach.
40
40
 
41
41
  Keys carry the list without the mouse: Enter inserts a sibling below and takes the
42
- caret there, Backspace on an empty element removes it and hands focus back up the
43
- list, Alt+↑/↓ anywhere in an `object` row of the list moves it, the nearest row
44
- answering and never the record around a nested one, and is the twin of the handle's
45
- drag. A move is one splice of the ids
42
+ caret there, a paste of several lines into a prose row opens a row per line,
43
+ Backspace on an empty element removes it and hands focus back up the list, Alt+↑/↓
44
+ anywhere in an `object` row of the list moves it, the nearest row answering and never
45
+ the record around a nested one, and is the twin of the handle's drag. A move is one splice of the ids
46
46
  and of the values together, the mechanism insert and remove use, so an element keeps
47
47
  its id for life and no prose leaf inside it remounts; the open row stays open across
48
48
  its own move, and `animate:reorder` holds the moving row as it holds a card.
@@ -307,6 +307,22 @@
307
307
  if (control === 'object' && !table) open.add(id);
308
308
  void focusAfterFlush(id, column);
309
309
  }
310
+ /** Element `k` as `row` and `below` as new elements after it, one commit: a paste of
311
+ * several lines into a row, each line past the first a row as Enter would open it.
312
+ * The caret ends where the paste does, at the end of the last. */
313
+ function spill(k: number, row: Content, below: Content[]): void {
314
+ const at = k + 1;
315
+ const fresh = seq.take(below.length);
316
+ ids = [...ids.slice(0, at), ...fresh, ...ids.slice(at)];
317
+ const next = arr.slice();
318
+ next[k] = row;
319
+ next.splice(at, 0, ...below);
320
+ onCommit(next);
321
+ void (async () => {
322
+ if (!(await span.resumes(tick()))) return;
323
+ els[fresh.at(-1)!]?.setCaret?.(Number.MAX_SAFE_INTEGER);
324
+ })();
325
+ }
310
326
  function add(): void {
311
327
  insertAfter(ids.length - 1);
312
328
  }
@@ -804,6 +820,10 @@
804
820
  cap={textCap(items)}
805
821
  label={rowName(k)}
806
822
  onChange={(rt) => commitElement(k, rt)}
823
+ lines={{
824
+ room: () => (max == null ? Infinity : max - arr.length),
825
+ onLines: (rt, below) => spill(k, rt, below)
826
+ }}
807
827
  onKey={(e) => onElementKey(e, k)}
808
828
  />
809
829
  {:else}