@quillmark/svelte 0.23.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 (68) hide show
  1. package/README.md +7 -8
  2. package/THEMING.md +1 -1
  3. package/dist/core/codec/blocks.d.ts +23 -6
  4. package/dist/core/codec/blocks.js +163 -38
  5. package/dist/core/codec/code.js +3 -8
  6. package/dist/core/codec/decode.d.ts +2 -1
  7. package/dist/core/codec/decode.js +19 -11
  8. package/dist/core/codec/field.d.ts +6 -3
  9. package/dist/core/codec/field.js +12 -4
  10. package/dist/core/codec/index.d.ts +2 -0
  11. package/dist/core/codec/index.js +4 -0
  12. package/dist/core/codec/inputrules.js +40 -10
  13. package/dist/core/codec/islands.d.ts +4 -1
  14. package/dist/core/codec/islands.js +43 -16
  15. package/dist/core/codec/keymap.js +19 -1
  16. package/dist/core/codec/line-paste.d.ts +7 -0
  17. package/dist/core/codec/line-paste.js +36 -0
  18. package/dist/core/codec/list-move.d.ts +6 -0
  19. package/dist/core/codec/list-move.js +30 -0
  20. package/dist/core/codec/list-paste.d.ts +2 -0
  21. package/dist/core/codec/list-paste.js +142 -0
  22. package/dist/core/codec/list-toggle.d.ts +4 -0
  23. package/dist/core/codec/list-toggle.js +170 -0
  24. package/dist/core/codec/lists.d.ts +22 -15
  25. package/dist/core/codec/lists.js +404 -97
  26. package/dist/core/codec/markdown-paste.d.ts +13 -3
  27. package/dist/core/codec/markdown-paste.js +70 -6
  28. package/dist/core/codec/nesting.d.ts +5 -1
  29. package/dist/core/codec/nesting.js +1 -1
  30. package/dist/core/codec/prose.css +36 -20
  31. package/dist/core/codec/slash.d.ts +2 -1
  32. package/dist/core/codec/slash.js +0 -0
  33. package/dist/core/codec/touching.d.ts +12 -0
  34. package/dist/core/codec/touching.js +33 -0
  35. package/dist/core/codec/undo.d.ts +5 -0
  36. package/dist/core/codec/undo.js +21 -0
  37. package/dist/index.js +1 -1
  38. package/dist/preview/Preview.svelte +88 -13
  39. package/dist/preview/Preview.svelte.d.ts +2 -2
  40. package/dist/preview/bridge.js +8 -2
  41. package/dist/preview/controller.d.ts +8 -4
  42. package/dist/preview/controller.js +10 -2
  43. package/dist/preview/index.js +1 -1
  44. package/dist/preview/paint.d.ts +6 -2
  45. package/dist/preview/paint.js +67 -56
  46. package/dist/preview/strings.d.ts +2 -0
  47. package/dist/preview/strings.js +4 -2
  48. package/dist/preview/viewport.d.ts +10 -0
  49. package/dist/preview/viewport.js +271 -0
  50. package/dist/preview/zoom.d.ts +41 -0
  51. package/dist/preview/zoom.js +52 -0
  52. package/dist/visual/ArrayField.svelte +24 -4
  53. package/dist/visual/Card.svelte +5 -4
  54. package/dist/visual/DateField.svelte +26 -13
  55. package/dist/visual/Field.svelte +2 -2
  56. package/dist/visual/FormatPopover.svelte +41 -2
  57. package/dist/visual/ObjectField.svelte +4 -7
  58. package/dist/visual/ObjectField.svelte.d.ts +2 -2
  59. package/dist/visual/ProseValue.svelte +35 -3
  60. package/dist/visual/ProseValue.svelte.d.ts +7 -0
  61. package/dist/visual/VisualEditorInner.svelte +5 -11
  62. package/dist/visual/icons/nodes.d.ts +26 -0
  63. package/dist/visual/icons/nodes.js +16 -0
  64. package/dist/visual/strings.d.ts +6 -4
  65. package/dist/visual/strings.js +9 -3
  66. package/dist/visual/structure.d.ts +22 -10
  67. package/dist/visual/structure.js +35 -11
  68. package/package.json +2 -2
@@ -4,26 +4,88 @@
4
4
  // the paste stores what the source editor stores of the same text. Text holding none stays
5
5
  // literal: a sentence with a dash in it, or a `**` with no block around it, keeps every
6
6
  // character.
7
+ //
8
+ // A list typed in an email or a memo draft marks its items with what markdown does not
9
+ // spell: a bullet character, a letter, a number in parentheses. Those lines are items too,
10
+ // nested by the outline the memo writes them in (`1.`, `a.`, `(1)`, `(a)`): each kind of
11
+ // marker is a level, the first line of a kind opening one under the level above.
7
12
  import { Slice } from 'prosemirror-model';
8
13
  import { Plugin } from 'prosemirror-state';
9
14
  import { core } from '../lifecycle.js';
10
- import { decode } from './decode.js';
15
+ import { decode, fitsLeaf } from './decode.js';
11
16
  import { blockSchema } from './schema.js';
17
+ /** A line opening an item: its indent, then the marker as markdown spells one (`-`, `*`,
18
+ * `+`, `1.`, `1)`), or as a plain-text list does (`•` and the bullet characters word
19
+ * processors write as text, a lowercase letter closed by `.` or `)`, and a number or a
20
+ * lowercase letter in parentheses). A capital is a name's initial as often as a marker,
21
+ * so `J. Doe` is not an item. */
22
+ const ITEM = /^([ \t]*)(?:([-*+])|(\d{1,9})[.)]|([•◦▪▫‣⁃●○■□·])|([a-z])[.)]|\((\d{1,9})\)|\(([a-z])\))[ \t]+(?=\S)/;
23
+ /** The markers plain text writes and markdown does not. */
24
+ const LOOSE_ITEM = /^[ \t]*(?:[•◦▪▫‣⁃●○■□·]|[a-z][.)]|\((?:\d{1,9}|[a-z])\))[ \t]+\S/m;
12
25
  /** A line opening a block markdown spells and prose does not: a bullet or an ordered item at
13
26
  * any indent, and within CommonMark's three spaces an ATX heading, a quote, a fence, or a
14
- * pipe table's delimiter row. */
27
+ * pipe table's delimiter row; or an item a plain-text list marks. */
15
28
  const BLOCK_HEAD = new RegExp([
16
29
  String.raw `^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]`,
30
+ LOOSE_ITEM.source,
17
31
  String.raw `^ {0,3}(?:#{1,6}|>)(?:[ \t]|$)`,
18
32
  '^ {0,3}(?:```|~~~)',
19
33
  String.raw `^ {0,3}(?=[^\n]*\|)\|?(?:[ \t]*:?-+:?[ \t]*\|)*[ \t]*:?-+:?[ \t]*\|?[ \t]*$`
20
34
  ].join('|'), 'm');
35
+ /**
36
+ * `text` with every item line spelled as markdown, where some line marks its item as
37
+ * markdown does not; otherwise `text` as it stands. A line's level is the open one of its
38
+ * kind at its indent, give or take a column, the levels below it closing; a marker no open
39
+ * level holds opens one beneath the deepest. Markdown nests by indent alone, so each line
40
+ * is indented past the markers of the levels above it. A letter's ordinal is its place in
41
+ * the alphabet. Lines in a fence and lines opening no item stand as they are.
42
+ */
43
+ export function plainTextList(text) {
44
+ if (!LOOSE_ITEM.test(text))
45
+ return text;
46
+ const levels = [];
47
+ let fence = false;
48
+ let prev = '';
49
+ return text
50
+ .split(/\r\n?|\n/)
51
+ .map((line) => {
52
+ const above = prev;
53
+ prev = line;
54
+ if (/^ {0,3}(?:```|~~~)/.test(line))
55
+ fence = !fence;
56
+ const m = fence ? null : ITEM.exec(line);
57
+ if (!m)
58
+ return line;
59
+ const [head, lead, dash, num, bullet, alpha, pnum, palpha] = m;
60
+ const indent = lead.replace(/\t/g, ' ').length;
61
+ const kind = dash ?? bullet ?? String(m.slice(2).findIndex(Boolean));
62
+ const letter = alpha ?? palpha;
63
+ const ordinal = letter ? letter.charCodeAt(0) - 96 : Number(num ?? pnum);
64
+ const marker = dash || bullet ? '- ' : `${ordinal}. `;
65
+ let depth = levels.findLastIndex((l) => l.kind === kind && Math.abs(l.indent - indent) <= 1);
66
+ const opens = depth < 0;
67
+ if (opens) {
68
+ while (levels.length && levels.at(-1).indent > indent + 1)
69
+ levels.pop();
70
+ depth = levels.length;
71
+ }
72
+ levels.length = depth;
73
+ levels.push({ kind, indent, width: marker.length });
74
+ const pad = levels.slice(0, depth).reduce((sum, l) => sum + l.width, 0);
75
+ // Only a list counting from 1 interrupts the line above it; a blank line lets
76
+ // any other open where it stands.
77
+ const gap = opens && ordinal !== 1 && marker !== '- ' && above.trim() ? '\n' : '';
78
+ return gap + ' '.repeat(pad) + marker + line.slice(head.length);
79
+ })
80
+ .join('\n');
81
+ }
21
82
  /**
22
83
  * A block leaf's markdown paste, consulted after the table paste: ProseMirror hands text
23
84
  * over only where the clipboard holds no HTML, whose DOM parse reads the source's own
24
- * structure, so HTML wins. A paste as plain text keeps the text. An island the parse
25
- * builds arrives under the id `importMarkdown` minted, which the paste's island pass
26
- * re-mints where the field holds it (`islands.ts`).
85
+ * structure, so HTML wins. A paste as plain text keeps the text, and so does a parse
86
+ * building a construct no leaf edits (`fitsLeaf`). An island the parse builds arrives
87
+ * under the id `importMarkdown` minted, which the paste's island pass re-mints where the
88
+ * field holds it (`islands.ts`).
27
89
  */
28
90
  export function markdownPastePlugin() {
29
91
  return new Plugin({
@@ -36,12 +98,14 @@ export function markdownPastePlugin() {
36
98
  const { importMarkdown } = core();
37
99
  let rt;
38
100
  try {
39
- rt = importMarkdown(text).content;
101
+ rt = importMarkdown(plainTextList(text)).content;
40
102
  }
41
103
  catch {
42
104
  // Nesting past the store's depth is refused; the literal text holds it.
43
105
  return null;
44
106
  }
107
+ if (!fitsLeaf(rt, { inline: false, plaintext: false, islands: true }))
108
+ return null;
45
109
  return new Slice(decode(rt, blockSchema).content, 0, 0);
46
110
  })
47
111
  }
@@ -1,5 +1,5 @@
1
1
  import type { Node as PMNode } from 'prosemirror-model';
2
- import { Plugin } from 'prosemirror-state';
2
+ import { Plugin, type Transaction } from 'prosemirror-state';
3
3
  /** The store's bound on the containers around one line: a commit nesting past it is
4
4
  * refused, and `@quillmark/wasm` exports no constant to read it from. */
5
5
  export declare const MAX_NESTING_DEPTH = 12;
@@ -10,6 +10,10 @@ export declare function nestingDepth(doc: PMNode): number;
10
10
  /** Whether `doc` nests within {@link MAX_NESTING_DEPTH}: what a command that nests asks
11
11
  * of the doc it would dispatch. */
12
12
  export declare function withinNesting(doc: PMNode): boolean;
13
+ /** Whether a step of `tr` can move a block under another container: any replace but
14
+ * inline content inside one textblock, which is every keystroke's. Mark and attribute
15
+ * steps move no node. */
16
+ export declare function mayNest(tr: Transaction): boolean;
13
17
  /**
14
18
  * Refuses a transaction leaving `doc` past {@link MAX_NESTING_DEPTH}, which the commit
15
19
  * would lose: a paste or a drop into a deep item, whose slice the import bounded alone.
@@ -28,7 +28,7 @@ export function withinNesting(doc) {
28
28
  /** Whether a step of `tr` can move a block under another container: any replace but
29
29
  * inline content inside one textblock, which is every keystroke's. Mark and attribute
30
30
  * steps move no node. */
31
- function mayNest(tr) {
31
+ export function mayNest(tr) {
32
32
  return tr.steps.some((step, i) => {
33
33
  if (step instanceof ReplaceAroundStep)
34
34
  return true;
@@ -56,26 +56,30 @@ li.ProseMirror-selectednode::after {
56
56
 
57
57
  /* One rhythm between sibling blocks, in place of the UA's collapsed pair. Scoped by
58
58
  parent rather than by tag, which is what keeps it true as the schema grows: the
59
- containers below are every node whose content is `block+` (`schema.ts`), so
60
- their children are blocks by definition, whatever kinds exist. `ul`/`ol` are
61
- deliberately absent: a run of list items is tight, and the gap before a list is
62
- the list's, not each item's.
63
-
64
- An item is the exception to its own container. A list nested under an item is
65
- part of the run that omission already keeps tight — `a. …` under `1. …` is the
66
- next line of one list, not a new block after a paragraph — so an item's children
67
- take the gap only up to the point where one of them is a list. Written as its own
68
- arm rather than as a `:not()` over the shared one, because the exception is the
69
- item's: a list after a paragraph inside a blockquote is an ordinary sibling pair
70
- and keeps the gap.
59
+ containers below are every node whose children are blocks (`schema.ts`), whatever
60
+ kinds exist; a task item holds its blocks in a body past its checkbox (`tasks.ts`),
61
+ so the body is the container rather than the item. A list and an item are
62
+ containers like any other: an item stands off its neighbours as a paragraph does, a
63
+ list nested under an item stands off the item's text, and an item's unnumbered line
64
+ has the gap below it that the next item draws above, where a tight run would read it
65
+ as the head of the next item.
71
66
 
72
67
  A line box is ~22px at the paper size and the body leading, so the rung is a
73
68
  little over half a line: enough that a break reads as a break, short of the full
74
69
  blank line that doubles the leading and makes a short document read as a list of
75
70
  fragments. */
76
71
  .ProseMirror > * + *,
77
- .ProseMirror :is(blockquote, [data-qm-unknown-container]) > * + *,
78
- .ProseMirror li > * + :not(ul, ol) {
72
+ .ProseMirror
73
+ :is(
74
+ blockquote,
75
+ ul,
76
+ ol,
77
+ li:not([data-qm-checked]),
78
+ [data-qm-task-body],
79
+ [data-qm-unknown-container]
80
+ )
81
+ > *
82
+ + * {
79
83
  margin-top: var(--_qm-space-3);
80
84
  }
81
85
 
@@ -162,6 +166,21 @@ li.ProseMirror-selectednode::after {
162
166
  pointer-events: none;
163
167
  }
164
168
 
169
+ /* A footnote and an equation are read, not edited (CODEC §Decode), and draw as what
170
+ their markdown writes: a footnote its reference mark, numbered in reading order off a
171
+ counter each leaf resets, its note the marker's title (`islands.ts`); an equation its
172
+ TeX between its dollars, in the code face. */
173
+ .ProseMirror {
174
+ counter-reset: qm-footnote;
175
+ }
176
+ .ProseMirror sup[data-qm-island='footnote']::after {
177
+ counter-increment: qm-footnote;
178
+ content: counter(qm-footnote);
179
+ }
180
+ .ProseMirror [data-qm-island='math'] {
181
+ font-family: var(--_qm-font-mono);
182
+ }
183
+
165
184
  /* A link takes the surface's ink (the UA's blue is the `#8cf` defect again) and a
166
185
  dotted underline. Dotted rather than solid because `underline` is a mark in the
167
186
  same schema, and a solid rule renders an authored `<u>` and a link identically in a
@@ -185,9 +204,6 @@ li.ProseMirror-selectednode::after {
185
204
 
186
205
  /* The UA's 40px list indent is the one box the reset above does not take (it is
187
206
  padding, not margin), and it is a length nothing chose. A rung replaces it.
188
- Per-item spacing is untouched, which is the point of putting this on the list
189
- rather than the item: the sibling-gap rule deliberately omits `ul`/`ol`, and
190
- padding on the container cannot reintroduce what it withholds.
191
207
 
192
208
  The markers are the UA's, reclaimed rather than inherited. `disc` and `decimal`
193
209
  are right and this restates neither: a host reset (Tailwind's preflight sets
@@ -354,9 +370,9 @@ li.ProseMirror-selectednode::after {
354
370
  absorbs the gap, at both ends. A negative margin would hang the band over the
355
371
  neighbouring block, and an `opacity: 0` control is still hit-tested, so it would take
356
372
  that block's presses. The `.ProseMirror` qualifier is what makes the override hold
357
- wherever the island lands: two of the gap rule's arms name a container, so a bare
358
- class loses to them for an island inside a list item or a quote, both of which the
359
- schema admits. */
373
+ wherever the island lands: the gap rule's container arm outweighs a bare class, and
374
+ loses to this one on order alone, for an island inside a list item or a quote, both
375
+ of which the schema admits. */
360
376
  .ProseMirror .qm-table-island {
361
377
  margin-top: 0;
362
378
  margin-left: calc(var(--_qm-space-3) * -1);
@@ -32,7 +32,8 @@ export declare const DEFAULT_SLASH_STRINGS: SlashStrings;
32
32
  * by, each asked whether it would fire here. A row that declines when picked is worse
33
33
  * than an absent one, and asking the command rather than restating its guards is what
34
34
  * keeps the menu from drifting off what a pick does. So the offers narrow as the caret
35
- * moves: the lists and the island alone mid-paragraph, no list at an item's head.
35
+ * moves: the lists and the island alone mid-paragraph, and at an item's head only the
36
+ * list of the other kind, which retypes the item's list.
36
37
  *
37
38
  * `state` is the state a pick leaves behind — the run already consumed — since that is
38
39
  * the caret every command answers for (`blocks.ts`).
Binary file
@@ -0,0 +1,12 @@
1
+ import type { Node as PMNode } from 'prosemirror-model';
2
+ import type { Transaction } from 'prosemirror-state';
3
+ /** Whether two lists of one kind meet at `pos`. */
4
+ export declare function touching(doc: PMNode, pos: number): boolean;
5
+ /** Join the two lists of one kind meeting at `pos`, the first's `start` counting for
6
+ * both. */
7
+ export declare function joinLists(tr: Transaction, pos: number): boolean;
8
+ /** Join the list at `pos` to a list of its kind touching it on either side. */
9
+ export declare function joinAround(tr: Transaction, pos: number): void;
10
+ /** Join at every seam of `seams` that two lists of one kind meet at, the last first so
11
+ * each join leaves the positions before it standing. */
12
+ export declare function joinAll(tr: Transaction, seams: Iterable<number>): void;
@@ -0,0 +1,33 @@
1
+ import { canJoin } from 'prosemirror-transform';
2
+ function isList(node) {
3
+ return node.firstChild?.type === node.type.schema.nodes.list_item;
4
+ }
5
+ /** Whether two lists of one kind meet at `pos`. */
6
+ export function touching(doc, pos) {
7
+ const $pos = doc.resolve(pos);
8
+ const before = $pos.nodeBefore;
9
+ const after = $pos.nodeAfter;
10
+ return !!before && !!after && before.type === after.type && isList(before);
11
+ }
12
+ /** Join the two lists of one kind meeting at `pos`, the first's `start` counting for
13
+ * both. */
14
+ export function joinLists(tr, pos) {
15
+ if (!touching(tr.doc, pos) || !canJoin(tr.doc, pos))
16
+ return false;
17
+ tr.join(pos);
18
+ return true;
19
+ }
20
+ /** Join the list at `pos` to a list of its kind touching it on either side. */
21
+ export function joinAround(tr, pos) {
22
+ const node = tr.doc.nodeAt(pos);
23
+ if (!node)
24
+ return;
25
+ joinLists(tr, pos + node.nodeSize);
26
+ joinLists(tr, pos);
27
+ }
28
+ /** Join at every seam of `seams` that two lists of one kind meet at, the last first so
29
+ * each join leaves the positions before it standing. */
30
+ export function joinAll(tr, seams) {
31
+ for (const seam of [...new Set(seams)].sort((a, b) => b - a))
32
+ joinLists(tr, seam);
33
+ }
@@ -0,0 +1,5 @@
1
+ import { Plugin } from 'prosemirror-state';
2
+ /** Marks each block-moving transaction as the start of an undo step. `filterTransaction`
3
+ * is the one hook that sees a transaction before history's `apply` does; it passes
4
+ * every one. A transaction a plugin appended stays in its root's step. */
5
+ export declare function undoStepPlugin(): Plugin;
@@ -0,0 +1,21 @@
1
+ // Undo steps. History groups a run of adjacent edits under its delay, so a burst of
2
+ // typing with a list change in it would undo as one step. A transaction that moves a
3
+ // block (`mayNest`: a split, a join, a wrap, a lift) opens a step of its own, the typing
4
+ // after it joining that step.
5
+ import { closeHistory, isHistoryTransaction } from 'prosemirror-history';
6
+ import { Plugin } from 'prosemirror-state';
7
+ import { mayNest } from './nesting.js';
8
+ /** Marks each block-moving transaction as the start of an undo step. `filterTransaction`
9
+ * is the one hook that sees a transaction before history's `apply` does; it passes
10
+ * every one. A transaction a plugin appended stays in its root's step. */
11
+ export function undoStepPlugin() {
12
+ return new Plugin({
13
+ filterTransaction: (tr) => {
14
+ if (!tr.docChanged || isHistoryTransaction(tr) || tr.getMeta('appendedTransaction'))
15
+ return true;
16
+ if (mayNest(tr))
17
+ closeHistory(tr);
18
+ return true;
19
+ }
20
+ });
21
+ }
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // `@quillmark/svelte` root: the framework-free substrate.
1
+ // `@quillmark/svelte` root: the substrate, free of any UI framework.
2
2
  //
3
3
  // The headline surfaces mount from their own subpaths (`@quillmark/svelte/preview`,
4
4
  // `/visual`) so a bundler pulls only what an entry reaches; the root
@@ -38,7 +38,7 @@
38
38
  onPick?: (at: Landing) => void;
39
39
  /** A page paint the backend refused; the error message state shows either way. */
40
40
  onError?: EditorErrorHandler;
41
- /** The message-state wording, keyed and partial (`PreviewStrings`). */
41
+ /** The surface's wording, keyed and partial (`PreviewStrings`). */
42
42
  strings?: PreviewStringsInput;
43
43
  }
44
44
 
@@ -78,8 +78,9 @@
78
78
  export function endFollow(): void {
79
79
  controller?.endFollow();
80
80
  }
81
- export function setZoom(scale: number): void {
82
- controller?.setZoom(scale);
81
+ /** A fraction of actual size; `undefined` is the default fit ({@link PreviewController}). */
82
+ export function setZoom(zoom: number | undefined): void {
83
+ controller?.setZoom(zoom);
83
84
  }
84
85
  </script>
85
86
 
@@ -93,25 +94,99 @@
93
94
  tone behind it is the sunken rung and the gutter is the margin the sheet floats
94
95
  in. Paper is the brighter plane at both poles, exactly as a card is over the
95
96
  editor's own column, so the two panes step the same way. Both are
96
- the surface's own, so a bare `<div>` is a mounting site. The
97
- padding is safe against the paint loop: a slot is a `width: 100%` child and
98
- measures the content box, and the container's own `clientWidth` is read only as
99
- a change detector, which a constant inset does not disturb.
97
+ the surface's own, so a bare `<div>` is a mounting site. The viewport fits the
98
+ pages to the content box, padding off.
99
+
100
+ A flex column, so the zoom pill's dock can take the space under pages shorter than
101
+ the pane and stand at its corner (viewport.ts). The gutter is reserved, so a
102
+ scrollbar arriving with a taller zoom narrows nothing the default fits against.
103
+ Scroll anchoring is off because the viewport holds the reader's place itself, on
104
+ every zoom and re-fit, and the browser's correction would land on top of it.
105
+ `touch-action` leaves a pinch to the viewport, which zooms the pages rather than
106
+ the host's page.
100
107
 
101
108
  `:global(:where(…))` puts this promised class at zero rank (ARCHITECTURE §Styling). */
102
109
  :global(:where(.qm-preview)) {
103
110
  box-sizing: border-box;
104
111
  position: relative;
112
+ display: flex;
113
+ flex-direction: column;
105
114
  width: 100%;
106
- /* The width is the host's, never the document's, which is the same boundary the
107
- editor states (`visual/VisualEditorInner.svelte` §`.qm-editor`): a mount standing
108
- in a column that floors at what it holds is asked for nothing the sheets in it
109
- are. `inline-size`, not `size`: the column is as tall as the pages painted in
110
- it. */
115
+ /* The pane's width is the host's, never the document's, which is the same boundary
116
+ the editor states (`visual/VisualEditorInner.svelte` §`.qm-editor`): a page zoomed
117
+ wider than the pane scrolls inside it rather than widening it. `inline-size`, not
118
+ `size`: the column is as tall as the pages painted in it. */
111
119
  contain: inline-size;
112
120
  height: 100%;
113
- overflow-y: auto;
121
+ overflow: auto;
122
+ scrollbar-gutter: stable;
123
+ overflow-anchor: none;
124
+ touch-action: pan-x pan-y;
114
125
  padding: var(--_qm-space-4);
115
126
  background: var(--_qm-surface-sunken);
116
127
  }
128
+
129
+ /* Sticky to the pane's bottom edge and stretched across it, so the pill's corner is
130
+ the pane's however the pages scroll; zero height, so it displaces nothing. */
131
+ :global(:where(.qm-preview-dock)) {
132
+ order: 1;
133
+ flex: none;
134
+ position: sticky;
135
+ bottom: 0;
136
+ inset-inline-start: 0;
137
+ height: 0;
138
+ margin-top: auto;
139
+ }
140
+
141
+ /* Floats over the desk, so it lifts the way a floating surface does (ARCHITECTURE
142
+ §Styling), and says only the zoom: the label never changes. Tabular figures and a
143
+ floor as wide as `100%`, so a third digit never shifts its edge. It arrives at once and leaves on the reveal, so a glance at
144
+ it after a gesture still finds it. */
145
+ :global(:where(.qm-preview-zoom)) {
146
+ position: absolute;
147
+ inset-inline-end: 0;
148
+ bottom: 0;
149
+ display: inline-flex;
150
+ align-items: center;
151
+ justify-content: center;
152
+ min-width: calc(4ch + 2 * var(--_qm-space-2));
153
+ min-height: var(--_qm-tap-min);
154
+ padding: 0 var(--_qm-space-2);
155
+ border: var(--_qm-border-width) solid var(--_qm-border);
156
+ border-radius: var(--_qm-radius-pill);
157
+ background: var(--_qm-surface-popover);
158
+ backdrop-filter: blur(var(--_qm-blur));
159
+ box-shadow: var(--_qm-lift);
160
+ color: var(--_qm-ink-label);
161
+ font-family: var(--_qm-font);
162
+ font-size: var(--_qm-text-label);
163
+ font-variant-numeric: tabular-nums;
164
+ cursor: pointer;
165
+ user-select: none;
166
+ transition:
167
+ opacity var(--_qm-duration-fast) var(--_qm-ease-reverse),
168
+ color var(--_qm-duration-fast) var(--_qm-ease-reverse);
169
+ }
170
+ :global(:where(.qm-preview-zoom:hover)) {
171
+ color: var(--_qm-ink);
172
+ }
173
+ :global(:where(.qm-preview-zoom:active)) {
174
+ background: var(--_qm-surface-hover);
175
+ }
176
+ :global(:where(.qm-preview-zoom[data-idle])) {
177
+ opacity: 0;
178
+ pointer-events: none;
179
+ transition: opacity var(--_qm-reveal);
180
+ }
181
+ :global(:where(.qm-preview-zoom:focus-visible)) {
182
+ opacity: 1;
183
+ pointer-events: auto;
184
+ outline: var(--_qm-ring);
185
+ outline-offset: var(--_qm-ring-offset);
186
+ }
187
+ @media (prefers-reduced-motion: reduce) {
188
+ :global(:where(.qm-preview-zoom, .qm-preview-zoom[data-idle])) {
189
+ transition: none;
190
+ }
191
+ }
117
192
  </style>
@@ -25,7 +25,7 @@ interface Props {
25
25
  onPick?: (at: Landing) => void;
26
26
  /** A page paint the backend refused; the error message state shows either way. */
27
27
  onError?: EditorErrorHandler;
28
- /** The message-state wording, keyed and partial (`PreviewStrings`). */
28
+ /** The surface's wording, keyed and partial (`PreviewStrings`). */
29
29
  strings?: PreviewStringsInput;
30
30
  }
31
31
  declare const Preview: import("svelte").Component<Props, {
@@ -33,7 +33,7 @@ declare const Preview: import("svelte").Component<Props, {
33
33
  scrollToField: (field: DocPath) => boolean;
34
34
  focusPosition: (at: Place) => void;
35
35
  endFollow: () => void;
36
- setZoom: (scale: number) => void;
36
+ setZoom: (zoom: number | undefined) => void;
37
37
  }, "">;
38
38
  type Preview = ReturnType<typeof Preview>;
39
39
  export default Preview;
@@ -86,6 +86,9 @@ export function createBridge(session, container, slots, onPick, held = {}) {
86
86
  // and this one moves the container's box, and one forced layout serves the whole hop.
87
87
  function centre(target, port) {
88
88
  container.scrollTop += target.top + target.height / 2 - (port.top + port.height / 2);
89
+ reveal(target, port);
90
+ }
91
+ function reveal(target, port) {
89
92
  if (target.left < port.left)
90
93
  container.scrollLeft += target.left - port.left;
91
94
  else if (target.right > port.right)
@@ -145,10 +148,13 @@ export function createBridge(session, container, slots, onPick, held = {}) {
145
148
  // the pane only when the caret has left the fold. Centring unconditionally
146
149
  // takes the scrollport back from the user on every one of them, including
147
150
  // the ones that changed nothing about where the caret already was.
151
+ // Across, a page zoomed wider than the pane has a caret to keep in view too, and
152
+ // the least trip does it without moving the line the reader is on.
148
153
  const target = measure(slot, rectToPercent(region.rect, slot.size));
149
154
  if (clearOfTheFold(target, port))
150
- return;
151
- centre(target, port);
155
+ reveal(target, port);
156
+ else
157
+ centre(target, port);
152
158
  }
153
159
  return {
154
160
  scrollToField,
@@ -14,8 +14,8 @@ export interface PreviewOptions {
14
14
  /** A page paint the backend refused ({@link EditorErrorHandler}). The preview
15
15
  * shows its error message state either way; this routes it to an app's sink. */
16
16
  onError?: EditorErrorHandler;
17
- /** The three message-state strings, keyed and partial: unset keys take the
18
- * package's English. */
17
+ /** The surface's strings, keyed and partial: unset keys take the package's
18
+ * English. */
19
19
  strings?: PreviewStringsInput;
20
20
  }
21
21
  export interface PreviewController {
@@ -50,8 +50,12 @@ export interface PreviewController {
50
50
  * own next caret.
51
51
  */
52
52
  endFollow(): void;
53
- /** Fold a density multiplier into every future paint (crispness, not layout). */
54
- setZoom(scale: number): void;
53
+ /**
54
+ * Draw every page at `zoom`, a fraction of actual size (`1` is 100%), held between
55
+ * the pane's floor and 200%; `undefined` is the default, the widest page fitting the
56
+ * pane's width up to 125%. The reader's own gestures move the same zoom.
57
+ */
58
+ setZoom(zoom: number | undefined): void;
55
59
  destroy(): void;
56
60
  }
57
61
  export declare function createPreview(session: LiveSession, opts: PreviewOptions): PreviewController;
@@ -1,6 +1,7 @@
1
1
  import { reportError, errorMessage } from '../core/errors.js';
2
2
  import { createPaintLoop } from './paint.js';
3
3
  import { createBridge } from './bridge.js';
4
+ import { createViewport } from './viewport.js';
4
5
  import { mergePreviewStrings } from './strings.js';
5
6
  const CONTAINER_CLASS = 'qm-preview';
6
7
  // The message states share one element; each carries `MESSAGE_CLASS` plus a state
@@ -82,7 +83,11 @@ export function createPreview(session, opts) {
82
83
  attach();
83
84
  }
84
85
  }
86
+ // Built before the first bridge, so its ResizeObserver is the earlier one and a pane
87
+ // shown again re-fits before the bridge runs the trip it held at the old scale.
88
+ const viewport = createViewport(container, paintLoop.slots, paintLoop.setScale, t.resetZoom);
85
89
  render(session.pageCount, []);
90
+ viewport.update();
86
91
  // The last place the editor put the caret, re-located after every recompile and
87
92
  // dropped on a focus change or a discrete hop. `session.locate` answers against the
88
93
  // last compiled layout and a consumer debounces `update`, so a caret typed past that
@@ -94,6 +99,8 @@ export function createPreview(session, opts) {
94
99
  return {
95
100
  refresh(change) {
96
101
  render(change.pageCount, change.dirtyPages);
102
+ // The default fits the widest page, which a recompile can move.
103
+ viewport.update();
97
104
  // Guarded like any other caret hop, so a caret already clear of the fold
98
105
  // leaves a pane the user scrolled where they left it.
99
106
  if (followed)
@@ -115,11 +122,12 @@ export function createPreview(session, opts) {
115
122
  endFollow() {
116
123
  followed = undefined;
117
124
  },
118
- setZoom(scale) {
119
- paintLoop.setDensityZoom(scale);
125
+ setZoom(zoom) {
126
+ viewport.setZoom(zoom);
120
127
  },
121
128
  destroy() {
122
129
  detach();
130
+ viewport.destroy();
123
131
  paintLoop.destroy();
124
132
  hideMessage();
125
133
  container.classList.remove(CONTAINER_CLASS);
@@ -13,7 +13,7 @@
13
13
  import '../core/theme.css';
14
14
  export { createPreview } from './controller.js';
15
15
  export { default as Preview } from './Preview.svelte';
16
- // What the preview says when there is nothing to paint. Keyed and partial; the
16
+ // What the preview says: its message states and the zoom pill's name. Keyed and partial; the
17
17
  // English is exported so a consumer composes against it rather than restating it.
18
18
  // The merge is the surface's own: a consumer hands `strings` to a prop, never
19
19
  // merges one.
@@ -10,8 +10,12 @@ export interface PaintLoop {
10
10
  readonly slots: readonly PageSlot[];
11
11
  /** Reconcile slot count to `pageCount`, re-cache geometry, repaint mounted `dirtyPages`. */
12
12
  refresh(dirtyPages: readonly number[], pageCount: number): void;
13
- /** Fold a density multiplier into every future paint; repaints mounted pages now. */
14
- setDensityZoom(zoom: number): void;
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.
17
+ */
18
+ setScale(scale: number, settle?: number): void;
15
19
  destroy(): void;
16
20
  }
17
21
  export declare function createPaintLoop(session: LiveSession, container: HTMLElement, margin: number, onPaintError?: (page: number, err: unknown) => void): PaintLoop;