@quillmark/svelte 0.0.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 (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +58 -0
  3. package/README.md +241 -0
  4. package/THEMING.md +153 -0
  5. package/dist/core/address.d.ts +56 -0
  6. package/dist/core/address.js +95 -0
  7. package/dist/core/bloom.d.ts +37 -0
  8. package/dist/core/bloom.js +116 -0
  9. package/dist/core/codec/anchor.d.ts +19 -0
  10. package/dist/core/codec/anchor.js +36 -0
  11. package/dist/core/codec/code.d.ts +12 -0
  12. package/dist/core/codec/code.js +118 -0
  13. package/dist/core/codec/decode.d.ts +24 -0
  14. package/dist/core/codec/decode.js +270 -0
  15. package/dist/core/codec/encode.d.ts +71 -0
  16. package/dist/core/codec/encode.js +480 -0
  17. package/dist/core/codec/field.d.ts +171 -0
  18. package/dist/core/codec/field.js +493 -0
  19. package/dist/core/codec/index.d.ts +22 -0
  20. package/dist/core/codec/index.js +43 -0
  21. package/dist/core/codec/inputrules.d.ts +7 -0
  22. package/dist/core/codec/inputrules.js +168 -0
  23. package/dist/core/codec/islands.d.ts +36 -0
  24. package/dist/core/codec/islands.js +82 -0
  25. package/dist/core/codec/keymap.d.ts +13 -0
  26. package/dist/core/codec/keymap.js +29 -0
  27. package/dist/core/codec/lists.d.ts +22 -0
  28. package/dist/core/codec/lists.js +104 -0
  29. package/dist/core/codec/marks.d.ts +37 -0
  30. package/dist/core/codec/marks.js +74 -0
  31. package/dist/core/codec/positions.d.ts +17 -0
  32. package/dist/core/codec/positions.js +108 -0
  33. package/dist/core/codec/prose.css +458 -0
  34. package/dist/core/codec/reconcile.d.ts +26 -0
  35. package/dist/core/codec/reconcile.js +64 -0
  36. package/dist/core/codec/schema.d.ts +13 -0
  37. package/dist/core/codec/schema.js +155 -0
  38. package/dist/core/codec/slash.d.ts +79 -0
  39. package/dist/core/codec/slash.js +0 -0
  40. package/dist/core/codec/table-view.d.ts +50 -0
  41. package/dist/core/codec/table-view.js +928 -0
  42. package/dist/core/codec/table.d.ts +85 -0
  43. package/dist/core/codec/table.js +224 -0
  44. package/dist/core/errors.d.ts +73 -0
  45. package/dist/core/errors.js +24 -0
  46. package/dist/core/index.d.ts +5 -0
  47. package/dist/core/index.js +19 -0
  48. package/dist/core/lifecycle.d.ts +7 -0
  49. package/dist/core/lifecycle.js +21 -0
  50. package/dist/core/rebind.svelte.d.ts +18 -0
  51. package/dist/core/rebind.svelte.js +44 -0
  52. package/dist/core/teardown.d.ts +20 -0
  53. package/dist/core/teardown.js +37 -0
  54. package/dist/core/theme.css +276 -0
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +9 -0
  57. package/dist/preset/index.css +10 -0
  58. package/dist/preset/recipes.css +188 -0
  59. package/dist/preset/scale.css +97 -0
  60. package/dist/preview/Preview.svelte +118 -0
  61. package/dist/preview/Preview.svelte.d.ts +39 -0
  62. package/dist/preview/bridge.d.ts +13 -0
  63. package/dist/preview/bridge.js +88 -0
  64. package/dist/preview/controller.d.ts +40 -0
  65. package/dist/preview/controller.js +141 -0
  66. package/dist/preview/geometry.d.ts +37 -0
  67. package/dist/preview/geometry.js +45 -0
  68. package/dist/preview/index.d.ts +5 -0
  69. package/dist/preview/index.js +14 -0
  70. package/dist/preview/overlay.d.ts +10 -0
  71. package/dist/preview/overlay.js +82 -0
  72. package/dist/preview/paint.d.ts +17 -0
  73. package/dist/preview/paint.js +262 -0
  74. package/dist/preview/strings.d.ts +14 -0
  75. package/dist/preview/strings.js +22 -0
  76. package/dist/visual/ArrayField.svelte +362 -0
  77. package/dist/visual/ArrayField.svelte.d.ts +24 -0
  78. package/dist/visual/BooleanField.svelte +88 -0
  79. package/dist/visual/BooleanField.svelte.d.ts +20 -0
  80. package/dist/visual/Card.svelte +849 -0
  81. package/dist/visual/Card.svelte.d.ts +43 -0
  82. package/dist/visual/CardControls.svelte +92 -0
  83. package/dist/visual/CardControls.svelte.d.ts +11 -0
  84. package/dist/visual/DateField.svelte +203 -0
  85. package/dist/visual/DateField.svelte.d.ts +28 -0
  86. package/dist/visual/DiagnosticList.svelte +50 -0
  87. package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
  88. package/dist/visual/EnumField.svelte +169 -0
  89. package/dist/visual/EnumField.svelte.d.ts +25 -0
  90. package/dist/visual/Field.svelte +270 -0
  91. package/dist/visual/Field.svelte.d.ts +37 -0
  92. package/dist/visual/FieldHint.svelte +185 -0
  93. package/dist/visual/FieldHint.svelte.d.ts +13 -0
  94. package/dist/visual/FieldLabel.svelte +141 -0
  95. package/dist/visual/FieldLabel.svelte.d.ts +19 -0
  96. package/dist/visual/FormatPopover.svelte +396 -0
  97. package/dist/visual/FormatPopover.svelte.d.ts +9 -0
  98. package/dist/visual/NumberField.svelte +72 -0
  99. package/dist/visual/NumberField.svelte.d.ts +19 -0
  100. package/dist/visual/ObjectField.svelte +131 -0
  101. package/dist/visual/ObjectField.svelte.d.ts +17 -0
  102. package/dist/visual/ProseArrayElement.svelte +98 -0
  103. package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
  104. package/dist/visual/ProseField.svelte +213 -0
  105. package/dist/visual/ProseField.svelte.d.ts +50 -0
  106. package/dist/visual/SlashMenu.svelte +126 -0
  107. package/dist/visual/SlashMenu.svelte.d.ts +14 -0
  108. package/dist/visual/TextField.svelte +63 -0
  109. package/dist/visual/TextField.svelte.d.ts +23 -0
  110. package/dist/visual/TipsCard.svelte +191 -0
  111. package/dist/visual/TipsCard.svelte.d.ts +13 -0
  112. package/dist/visual/VisualEditor.svelte +96 -0
  113. package/dist/visual/VisualEditor.svelte.d.ts +14 -0
  114. package/dist/visual/VisualEditorInner.svelte +1032 -0
  115. package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
  116. package/dist/visual/controls.css +272 -0
  117. package/dist/visual/diagnostics.d.ts +52 -0
  118. package/dist/visual/diagnostics.js +83 -0
  119. package/dist/visual/domid.d.ts +21 -0
  120. package/dist/visual/domid.js +36 -0
  121. package/dist/visual/ext.d.ts +19 -0
  122. package/dist/visual/ext.js +29 -0
  123. package/dist/visual/icons/Icon.svelte +39 -0
  124. package/dist/visual/icons/Icon.svelte.d.ts +12 -0
  125. package/dist/visual/icons/nodes.d.ts +103 -0
  126. package/dist/visual/icons/nodes.js +55 -0
  127. package/dist/visual/index.d.ts +9 -0
  128. package/dist/visual/index.js +24 -0
  129. package/dist/visual/motion.d.ts +18 -0
  130. package/dist/visual/motion.js +53 -0
  131. package/dist/visual/props.d.ts +77 -0
  132. package/dist/visual/props.js +1 -0
  133. package/dist/visual/signals.d.ts +59 -0
  134. package/dist/visual/signals.js +1 -0
  135. package/dist/visual/strings.d.ts +123 -0
  136. package/dist/visual/strings.js +80 -0
  137. package/dist/visual/structure.d.ts +224 -0
  138. package/dist/visual/structure.js +270 -0
  139. package/dist/visual/synced.svelte.d.ts +15 -0
  140. package/dist/visual/synced.svelte.js +34 -0
  141. package/dist/visual/tips.d.ts +24 -0
  142. package/dist/visual/tips.js +50 -0
  143. package/package.json +102 -0
@@ -0,0 +1,118 @@
1
+ <!--
2
+ `@quillmark/svelte/preview`'s Svelte wrapper: mounts `createPreview` over a
3
+ container div on mount and tears it down on unmount. No logic beyond wiring;
4
+ paint.ts/overlay.ts/bridge.ts/controller.ts own the behavior. Exposes the
5
+ `PreviewController` verbs as instance methods (`bind:this`) for a consumer
6
+ that drives `refresh`/`scrollToField`/`focusPosition`/`setZoom` imperatively
7
+ (e.g. after `session.apply` elsewhere); pure passthrough, no added logic.
8
+ -->
9
+ <script lang="ts">
10
+ import { onMount } from 'svelte';
11
+ import { createPreview, type PreviewController } from './controller.js';
12
+ import { guardRebind } from '../core/rebind.svelte.js';
13
+ import type { PreviewStringsInput } from './strings.js';
14
+ import type { LiveSession, ContentHit, ChangeSet } from '@quillmark/wasm';
15
+ import type { DocPath, Place } from '../core/address.js';
16
+ import type { EditorErrorHandler } from '../core/errors.js';
17
+
18
+ /**
19
+ * REMOUNT CONTRACT. `createPreview` binds once in `onMount`; a later change to any
20
+ * prop it closed over (`session`, `margin`, `overlays`, `onCaretPick`, `onError`,
21
+ * `strings`) is NOT observed, and each reports `rebind-ignored` when swapped. Swap
22
+ * the session by REMOUNTING (`{#key session}`, as the playground does); drive
23
+ * in-place edits through the `refresh(change)` method, not a prop change.
24
+ *
25
+ * `onError` is itself once-bound, so a swapped handler means the report of its own
26
+ * swap reaches the handler it replaced. `class` and `style` are the exceptions:
27
+ * they land on the root element Svelte owns, so they stay live.
28
+ */
29
+ interface Props {
30
+ session: LiveSession;
31
+ /** Appended to the root's own class: the surface is a mounted element the
32
+ * consumer positions, so it needs a handle for layout it owns. */
33
+ class?: string;
34
+ /** Merged onto the root. Free: theming lands on `data-qm-root` (core/theme.css),
35
+ * not this attribute. */
36
+ style?: string;
37
+ margin?: number;
38
+ overlays?: boolean;
39
+ onCaretPick?: (hit: ContentHit) => void;
40
+ /** A page paint the backend refused; the error message state shows either way. */
41
+ onError?: EditorErrorHandler;
42
+ /** The message-state wording, keyed and partial (`PreviewStrings`). */
43
+ strings?: PreviewStringsInput;
44
+ }
45
+
46
+ let {
47
+ session,
48
+ margin,
49
+ overlays,
50
+ onCaretPick,
51
+ onError,
52
+ strings,
53
+ class: className,
54
+ style
55
+ }: Props = $props();
56
+
57
+ let containerEl: HTMLDivElement | undefined = $state();
58
+ let controller: PreviewController | undefined;
59
+
60
+ guardRebind(
61
+ () => ({ session, margin, overlays, onCaretPick, onError, strings }),
62
+ 'Remount the preview ({#key session}) to rebind.'
63
+ );
64
+
65
+ onMount(() => {
66
+ if (!containerEl) return;
67
+ controller = createPreview(session, {
68
+ container: containerEl,
69
+ margin,
70
+ overlays,
71
+ onCaretPick,
72
+ onError,
73
+ strings
74
+ });
75
+ return () => {
76
+ controller?.destroy();
77
+ controller = undefined;
78
+ };
79
+ });
80
+
81
+ export function refresh(change: ChangeSet): void {
82
+ controller?.refresh(change);
83
+ }
84
+ export function scrollToField(field: DocPath): void {
85
+ controller?.scrollToField(field);
86
+ }
87
+ /** Takes the editor's `onCaretMove` payload: `onCaretMove={preview.focusPosition}`. */
88
+ export function focusPosition(at: Place): void {
89
+ controller?.focusPosition(at);
90
+ }
91
+ export function setZoom(scale: number): void {
92
+ controller?.setZoom(scale);
93
+ }
94
+ </script>
95
+
96
+ <div bind:this={containerEl} class="qm-preview {className ?? ''}" {style} data-qm-root></div>
97
+
98
+ <style>
99
+ /* A DETACHED root: the preview is not a descendant of the editor, so it carries
100
+ `data-qm-root` for the page/overlay rungs paint.ts and overlay.ts read
101
+ (core/theme.css).
102
+
103
+ The DESK as well as the paper: a sheet is at `--_qm-surface` (paint.ts), so the
104
+ tone behind it is one rung raised and the gutter is the margin the sheet floats
105
+ in. Both are the surface's own, so a bare `<div>` is a mounting site. The
106
+ padding is safe against the paint loop: a slot is a `width: 100%` child and
107
+ measures the CONTENT box, and the container's own `clientWidth` is read only as
108
+ a change detector, which a constant inset does not disturb. */
109
+ .qm-preview {
110
+ box-sizing: border-box;
111
+ position: relative;
112
+ width: 100%;
113
+ height: 100%;
114
+ overflow-y: auto;
115
+ padding: var(--_qm-space-4);
116
+ background: var(--_qm-surface-raised);
117
+ }
118
+ </style>
@@ -0,0 +1,39 @@
1
+ import type { PreviewStringsInput } from './strings.js';
2
+ import type { LiveSession, ContentHit, ChangeSet } from '@quillmark/wasm';
3
+ import type { DocPath, Place } from '../core/address.js';
4
+ import type { EditorErrorHandler } from '../core/errors.js';
5
+ /**
6
+ * REMOUNT CONTRACT. `createPreview` binds once in `onMount`; a later change to any
7
+ * prop it closed over (`session`, `margin`, `overlays`, `onCaretPick`, `onError`,
8
+ * `strings`) is NOT observed, and each reports `rebind-ignored` when swapped. Swap
9
+ * the session by REMOUNTING (`{#key session}`, as the playground does); drive
10
+ * in-place edits through the `refresh(change)` method, not a prop change.
11
+ *
12
+ * `onError` is itself once-bound, so a swapped handler means the report of its own
13
+ * swap reaches the handler it replaced. `class` and `style` are the exceptions:
14
+ * they land on the root element Svelte owns, so they stay live.
15
+ */
16
+ interface Props {
17
+ session: LiveSession;
18
+ /** Appended to the root's own class: the surface is a mounted element the
19
+ * consumer positions, so it needs a handle for layout it owns. */
20
+ class?: string;
21
+ /** Merged onto the root. Free: theming lands on `data-qm-root` (core/theme.css),
22
+ * not this attribute. */
23
+ style?: string;
24
+ margin?: number;
25
+ overlays?: boolean;
26
+ onCaretPick?: (hit: ContentHit) => void;
27
+ /** A page paint the backend refused; the error message state shows either way. */
28
+ onError?: EditorErrorHandler;
29
+ /** The message-state wording, keyed and partial (`PreviewStrings`). */
30
+ strings?: PreviewStringsInput;
31
+ }
32
+ declare const Preview: import("svelte").Component<Props, {
33
+ refresh: (change: ChangeSet) => void;
34
+ scrollToField: (field: DocPath) => void;
35
+ focusPosition: (at: Place) => void;
36
+ setZoom: (scale: number) => void;
37
+ }, "">;
38
+ type Preview = ReturnType<typeof Preview>;
39
+ export default Preview;
@@ -0,0 +1,13 @@
1
+ import type { LiveSession, ContentHit } from '@quillmark/wasm';
2
+ import type { PageSlot } from './paint.js';
3
+ export interface BridgeController {
4
+ /** Scroll `field`'s first content box to centre (a no-op if it has none). */
5
+ scrollToField(field: string): void;
6
+ /**
7
+ * Bring the caret rect at `field`/`pos` into view (a no-op off-content, and a
8
+ * no-op when it is already clear of the fold).
9
+ */
10
+ focusPosition(field: string, pos: number): void;
11
+ destroy(): void;
12
+ }
13
+ export declare function createBridge(session: LiveSession, container: HTMLElement, slots: readonly PageSlot[], onCaretPick: ((hit: ContentHit) => void) | undefined): BridgeController;
@@ -0,0 +1,88 @@
1
+ import { rectToPercent, clickToPdfPt, applyPercentRect } from './geometry.js';
2
+ export function createBridge(session, container, slots, onCaretPick) {
3
+ const unlisten = [];
4
+ if (onCaretPick) {
5
+ for (const { page, el } of slots) {
6
+ const handleClick = (ev) => {
7
+ // Re-read through the live array: a same-count `refresh` swaps the
8
+ // slot objects to re-cache `size`, and the click math must use the
9
+ // current compile's PageSize, not the one captured at bridge build.
10
+ const slot = slots[page];
11
+ if (!slot)
12
+ return;
13
+ const box = slot.el.getBoundingClientRect();
14
+ const px = ev.clientX - box.left;
15
+ const py = ev.clientY - box.top;
16
+ const pt = clickToPdfPt(px, py, box.width, box.height, slot.size);
17
+ const hit = session.positionAt(slot.page, pt.x, pt.y);
18
+ if (hit)
19
+ onCaretPick(hit);
20
+ };
21
+ el.addEventListener('click', handleClick);
22
+ unlisten.push(() => el.removeEventListener('click', handleClick));
23
+ }
24
+ }
25
+ // Where the % rect `pct` on `slot` currently sits, in the scrollport's own
26
+ // coordinates: a throwaway marker, measured and removed. Absolutely positioned,
27
+ // so it displaces nothing and no layout survives the read.
28
+ function measure(slot, pct) {
29
+ const marker = document.createElement('div');
30
+ applyPercentRect(marker, pct);
31
+ slot.el.appendChild(marker);
32
+ const rect = marker.getBoundingClientRect();
33
+ marker.remove();
34
+ return rect;
35
+ }
36
+ // Centre `target` vertically in the scrollport; the cross axis takes the minimum
37
+ // trip instead, since the container is a vertical scroller and only a page wider
38
+ // than the port has anything to move there.
39
+ function centre(target) {
40
+ const port = container.getBoundingClientRect();
41
+ container.scrollTop += target.top + target.height / 2 - (port.top + port.height / 2);
42
+ if (target.left < port.left)
43
+ container.scrollLeft += target.left - port.left;
44
+ else if (target.right > port.right)
45
+ container.scrollLeft += target.right - port.right;
46
+ }
47
+ // Clear of the fold is the target's OWN height of clearance at each edge, not bare
48
+ // intersection: a caret rect flush against one is visible and unusable, and the
49
+ // next line typed lands past it. Derived from the target rather than a margin
50
+ // dial, so it scales with zoom exactly as the caret does.
51
+ function clearOfTheFold(target) {
52
+ const port = container.getBoundingClientRect();
53
+ return target.top - target.height >= port.top && target.bottom + target.height <= port.bottom;
54
+ }
55
+ return {
56
+ scrollToField(field) {
57
+ const box = session.fieldBoxes(field)[0];
58
+ if (!box)
59
+ return;
60
+ const slot = slots[box.page];
61
+ if (!slot)
62
+ return;
63
+ // A discrete act ("show me this field"), so it centres every time: the
64
+ // asymmetry the bloom draws between a continuous signal and a one-shot one.
65
+ centre(measure(slot, rectToPercent(box.rect, slot.size)));
66
+ },
67
+ focusPosition(field, pos) {
68
+ const region = session.locate(field, pos);
69
+ if (!region)
70
+ return;
71
+ const slot = slots[region.page];
72
+ if (!slot)
73
+ return;
74
+ // The CONTINUOUS hop: one call per keystroke and per arrow key, so it moves
75
+ // the pane only when the caret has left the fold. Centring unconditionally
76
+ // takes the scrollport back from the user on every one of them, including
77
+ // the ones that changed nothing about where the caret already was.
78
+ const target = measure(slot, rectToPercent(region.rect, slot.size));
79
+ if (clearOfTheFold(target))
80
+ return;
81
+ centre(target);
82
+ },
83
+ destroy() {
84
+ for (const off of unlisten)
85
+ off();
86
+ }
87
+ };
88
+ }
@@ -0,0 +1,40 @@
1
+ import type { LiveSession, ChangeSet, ContentHit } from '@quillmark/wasm';
2
+ import type { DocPath, Place } from '../core/address.js';
3
+ import { type EditorErrorHandler } from '../core/errors.js';
4
+ import { type PreviewStringsInput } from './strings.js';
5
+ export interface PreviewOptions {
6
+ /** The element the preview mounts into; becomes the scroll viewport. */
7
+ container: HTMLElement;
8
+ /** Pages kept painted beyond the visible band. Default 1. */
9
+ margin?: number;
10
+ /** Draw field-box overlays. Default true. */
11
+ overlays?: boolean;
12
+ /** A click resolved to a content position; the hook does not fire off-ink. */
13
+ onCaretPick?(hit: ContentHit): void;
14
+ /** A page paint the backend refused ({@link EditorErrorHandler}). The preview
15
+ * shows its error message state either way; this routes it to an app's sink. */
16
+ onError?: EditorErrorHandler;
17
+ /** The three message-state strings, keyed and partial: unset keys take the
18
+ * package's English. */
19
+ strings?: PreviewStringsInput;
20
+ }
21
+ export interface PreviewController {
22
+ /** Repaint `dirtyPages ∩ visible` and re-read geometry; the only apply-driven hop. */
23
+ refresh(change: ChangeSet): void;
24
+ /** Scroll `field`'s first box into view and bloom it. */
25
+ scrollToField(field: DocPath): void;
26
+ /**
27
+ * Bring the caret at `at` into view and bloom its field on arrival. Both halves
28
+ * are change-guarded: the pane moves only when the caret has left the fold, the
29
+ * bloom fires only on a change of address.
30
+ *
31
+ * Takes the editor's own `onCaretMove` payload, so the editor→preview half of
32
+ * the bridge is `onCaretMove={preview.focusPosition}` and translates nothing. A
33
+ * `ContentHit` carries both members and fits here too.
34
+ */
35
+ focusPosition(at: Place): void;
36
+ /** Fold a density multiplier into every future paint (crispness, not layout). */
37
+ setZoom(scale: number): void;
38
+ destroy(): void;
39
+ }
40
+ export declare function createPreview(session: LiveSession, opts: PreviewOptions): PreviewController;
@@ -0,0 +1,141 @@
1
+ import { reportError, errorMessage } from '../core/errors.js';
2
+ import { createPaintLoop } from './paint.js';
3
+ import { createOverlay } from './overlay.js';
4
+ import { createBridge } from './bridge.js';
5
+ import { mergePreviewStrings } from './strings.js';
6
+ const CONTAINER_CLASS = 'qm-preview';
7
+ // The message states share one element; each carries `MESSAGE_CLASS` plus a
8
+ // state class so a consumer (and the tests) can target them. `EMPTY_CLASS` is
9
+ // the zero-page hook consumers and tests target.
10
+ const MESSAGE_CLASS = 'qm-preview-message';
11
+ const EMPTY_CLASS = 'qm-preview-empty';
12
+ const UNSUPPORTED_CLASS = 'qm-preview-unsupported';
13
+ const ERROR_CLASS = 'qm-preview-error';
14
+ export function createPreview(session, opts) {
15
+ const container = opts.container;
16
+ const margin = opts.margin ?? 1;
17
+ const overlaysEnabled = opts.overlays ?? true;
18
+ const t = mergePreviewStrings(opts.strings);
19
+ container.classList.add(CONTAINER_CLASS);
20
+ // The full-container message slot, shared by every non-paint state: the empty
21
+ // seed / drop-to-zero, a compile that cannot paint (`supportsCanvas` false),
22
+ // and a paint that threw. One element, restamped; these states are mutually
23
+ // exclusive, and the zero-page case keeps its `qm-preview-empty` hook.
24
+ let message;
25
+ function showMessage(text, state) {
26
+ if (!message) {
27
+ message = document.createElement('div');
28
+ container.appendChild(message);
29
+ }
30
+ message.className = `${MESSAGE_CLASS} ${state}`;
31
+ message.textContent = text;
32
+ }
33
+ function hideMessage() {
34
+ message?.remove();
35
+ message = undefined;
36
+ }
37
+ // The paint loop is safe at any page count: zero pages reconciles to zero
38
+ // slots and never calls the `paint`/`pageSize` verbs the boundary refuses
39
+ // there. A paint that unexpectedly throws is caught per-slot and surfaced
40
+ // through the shared message rather than aborting the observer callback
41
+ // mid-sweep (runtime.d.ts: even a `supportsCanvas` compile can hit a paint
42
+ // the boundary refuses; brittle to leave uncaught).
43
+ const paintLoop = createPaintLoop(session, container, margin, (page, err) => {
44
+ reportError(opts.onError, {
45
+ code: 'paint-failed',
46
+ severity: 'error',
47
+ message: `painting page ${page} failed: ${errorMessage(err)}`,
48
+ cause: err,
49
+ page
50
+ });
51
+ showMessage(t.renderFailed, ERROR_CLASS);
52
+ });
53
+ // overlay/bridge query geometry at build (`session.regions()`), so they are
54
+ // held until slots exist; (re)built by `render` when a compile is paintable.
55
+ let overlay;
56
+ let bridge;
57
+ // overlay/bridge live and die with the slot set. Rebuilt (not patched) whenever
58
+ // the count changes: the slots array is a stable identity but its members are
59
+ // swapped on reconcile, and both attach DOM/listeners per slot.
60
+ function attach() {
61
+ overlay = overlaysEnabled ? createOverlay(session, paintLoop.slots) : undefined;
62
+ bridge = createBridge(session, container, paintLoop.slots, opts.onCaretPick);
63
+ }
64
+ function detach() {
65
+ overlay?.destroy();
66
+ bridge?.destroy();
67
+ overlay = undefined;
68
+ bridge = undefined;
69
+ }
70
+ // Reflect one compile's paintability into the DOM: page slots + overlay/bridge
71
+ // when there is something to paint, the shared message otherwise. Called at
72
+ // construction and after every `apply`, so `supportsCanvas` is re-read per
73
+ // compile (runtime.d.ts: re-check after `open`) and a 0-page or non-canvas
74
+ // compile that later gains paintable pages recovers; the check spans the
75
+ // paint capability generally, not just the page count.
76
+ function render(pageCount, dirtyPages) {
77
+ if (!session.supportsCanvas || pageCount === 0) {
78
+ // Nothing paintable: collapse slots, drop geometry attachments, say why.
79
+ // A 0-page compile is a recoverable empty; a compile with pages the
80
+ // boundary cannot raster is a genuine unsupported.
81
+ paintLoop.refresh([], 0);
82
+ detach();
83
+ showMessage(pageCount === 0 ? t.noPages : t.unsupported, pageCount === 0 ? EMPTY_CLASS : UNSUPPORTED_CLASS);
84
+ return;
85
+ }
86
+ // Read the count against the current slots BEFORE reconcile moves it, and
87
+ // clear any prior message BEFORE painting so a paint that throws mid-refresh
88
+ // leaves its error surfaced rather than have `hideMessage` clobber it.
89
+ const countChanged = pageCount !== paintLoop.slots.length;
90
+ hideMessage();
91
+ paintLoop.refresh(dirtyPages, pageCount);
92
+ if (countChanged || !bridge) {
93
+ // The slot set moved under overlay/bridge's feet (pages added/removed,
94
+ // or we are leaving a message state): rebuild both against the
95
+ // reconciled slots rather than patch a stale snapshot in place.
96
+ detach();
97
+ attach();
98
+ }
99
+ else {
100
+ // Box positions can still shift within the same page set (text reflow),
101
+ // so geometry is always re-read, not just dirty pages.
102
+ overlay?.refresh();
103
+ }
104
+ }
105
+ render(session.pageCount, []);
106
+ // The last place the editor put the caret, re-located after every recompile.
107
+ // `session.locate` answers against the LAST COMPILED layout and a consumer
108
+ // debounces `update`, so a caret typed past that layout's content is off-content
109
+ // for the whole burst: `focusPosition` no-ops and the pane sits still, and a
110
+ // caret event is otherwise the only thing that asks again. The re-locate is the
111
+ // preview's, not the consumer's: the staleness sits between two session queries
112
+ // this module owns both ends of.
113
+ let followed;
114
+ return {
115
+ refresh(change) {
116
+ render(change.pageCount, change.dirtyPages);
117
+ // Guarded like any other caret hop, so a caret already clear of the fold
118
+ // leaves a pane the user scrolled where they left it.
119
+ if (followed)
120
+ bridge?.focusPosition(followed.field, followed.pos);
121
+ },
122
+ scrollToField(field) {
123
+ bridge?.scrollToField(field);
124
+ overlay?.flashField(field);
125
+ },
126
+ focusPosition(at) {
127
+ followed = at;
128
+ bridge?.focusPosition(at.field, at.pos);
129
+ overlay?.flashField(at.field);
130
+ },
131
+ setZoom(scale) {
132
+ paintLoop.setDensityZoom(scale);
133
+ },
134
+ destroy() {
135
+ detach();
136
+ paintLoop.destroy();
137
+ hideMessage();
138
+ container.classList.remove(CONTAINER_CLASS);
139
+ }
140
+ };
141
+ }
@@ -0,0 +1,37 @@
1
+ import type { PageSize } from '@quillmark/wasm';
2
+ /** A page rect in PDF points, `[x0, y0, x1, y1]`, bottom-left origin (`FieldRegion.rect`'s shape). */
3
+ export type PdfRect = readonly [number, number, number, number];
4
+ /** A rect as CSS percent of the page box (top-left origin; Y is flipped from PDF-pt). */
5
+ export interface PercentRect {
6
+ left: number;
7
+ top: number;
8
+ width: number;
9
+ height: number;
10
+ }
11
+ /** A point in PDF points, bottom-left origin (the space `positionAt`/`fieldAt` read). */
12
+ export interface PdfPoint {
13
+ x: number;
14
+ y: number;
15
+ }
16
+ /**
17
+ * Forward: a PDF-pt rect -> CSS % of the page box. Percent positioning tracks
18
+ * the page's displayed size across DPR and container resize for free; this is
19
+ * what the overlay draws (overlay.ts).
20
+ */
21
+ export declare function rectToPercent(rect: PdfRect, pageSize: PageSize): PercentRect;
22
+ /**
23
+ * Position `el` absolutely at `pct` (the % rect this module produces); the one
24
+ * `PercentRect` → `element.style` write, shared by the overlay's field boxes and
25
+ * the bridge's scroll marker so the positioning convention lives in one place.
26
+ * The caller sets any box chrome (border, background) on top.
27
+ */
28
+ export declare function applyPercentRect(el: HTMLElement, pct: PercentRect): void;
29
+ /**
30
+ * Inverse: a click at CSS px `(px, py)` within a page element of CSS size
31
+ * `(cssW, cssH)` -> PDF-pt. The EXACT inverse of {@link rectToPercent}: the
32
+ * fraction `rectToPercent` would place a point's box corner at is the same
33
+ * fraction this reads back from `px/cssW`, `py/cssH`; DPR-independent, since
34
+ * the page element's CSS size (not its canvas backing store) is the only input.
35
+ * This is what the click bridge feeds `session.positionAt`/`fieldAt` (bridge.ts).
36
+ */
37
+ export declare function clickToPdfPt(px: number, py: number, cssW: number, cssH: number, pageSize: PageSize): PdfPoint;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Forward: a PDF-pt rect -> CSS % of the page box. Percent positioning tracks
3
+ * the page's displayed size across DPR and container resize for free; this is
4
+ * what the overlay draws (overlay.ts).
5
+ */
6
+ export function rectToPercent(rect, pageSize) {
7
+ const [x0, y0, x1, y1] = rect;
8
+ const { widthPt, heightPt } = pageSize;
9
+ return {
10
+ left: (x0 / widthPt) * 100,
11
+ top: (1 - y1 / heightPt) * 100,
12
+ width: ((x1 - x0) / widthPt) * 100,
13
+ height: ((y1 - y0) / heightPt) * 100
14
+ };
15
+ }
16
+ /**
17
+ * Position `el` absolutely at `pct` (the % rect this module produces); the one
18
+ * `PercentRect` → `element.style` write, shared by the overlay's field boxes and
19
+ * the bridge's scroll marker so the positioning convention lives in one place.
20
+ * The caller sets any box chrome (border, background) on top.
21
+ */
22
+ export function applyPercentRect(el, pct) {
23
+ Object.assign(el.style, {
24
+ position: 'absolute',
25
+ left: `${pct.left}%`,
26
+ top: `${pct.top}%`,
27
+ width: `${pct.width}%`,
28
+ height: `${pct.height}%`
29
+ });
30
+ }
31
+ /**
32
+ * Inverse: a click at CSS px `(px, py)` within a page element of CSS size
33
+ * `(cssW, cssH)` -> PDF-pt. The EXACT inverse of {@link rectToPercent}: the
34
+ * fraction `rectToPercent` would place a point's box corner at is the same
35
+ * fraction this reads back from `px/cssW`, `py/cssH`; DPR-independent, since
36
+ * the page element's CSS size (not its canvas backing store) is the only input.
37
+ * This is what the click bridge feeds `session.positionAt`/`fieldAt` (bridge.ts).
38
+ */
39
+ export function clickToPdfPt(px, py, cssW, cssH, pageSize) {
40
+ const { widthPt, heightPt } = pageSize;
41
+ return {
42
+ x: (px / cssW) * widthPt,
43
+ y: heightPt - (py / cssH) * heightPt
44
+ };
45
+ }
@@ -0,0 +1,5 @@
1
+ export { createPreview } from './controller.js';
2
+ export type { PreviewOptions, PreviewController } from './controller.js';
3
+ export { default as Preview } from './Preview.svelte';
4
+ export { DEFAULT_PREVIEW_STRINGS } from './strings.js';
5
+ export type { PreviewStrings, PreviewStringsInput } from './strings.js';
@@ -0,0 +1,14 @@
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`, which walks the subpath's import graph.
8
+ export { createPreview } from './controller.js';
9
+ export { default as Preview } from './Preview.svelte';
10
+ // What the preview SAYS when there is nothing to paint. Keyed and partial; the
11
+ // English is exported so a consumer composes against it rather than restating it.
12
+ // The merge is the surface's own: a consumer hands `strings` to a prop, never
13
+ // merges one.
14
+ export { DEFAULT_PREVIEW_STRINGS } from './strings.js';
@@ -0,0 +1,10 @@
1
+ import type { LiveSession } from '@quillmark/wasm';
2
+ import type { PageSlot } from './paint.js';
3
+ export interface OverlayController {
4
+ /** Re-read geometry and rebuild every box; call after a layout-affecting refresh. */
5
+ refresh(): void;
6
+ /** Bloom `field`'s boxes: transient, and a no-op when `field` is already the marked one. */
7
+ flashField(field: string): void;
8
+ destroy(): void;
9
+ }
10
+ export declare function createOverlay(session: LiveSession, slots: readonly PageSlot[]): OverlayController;
@@ -0,0 +1,82 @@
1
+ import { bloom, bloomTiming, primeWash } from '../core/bloom.js';
2
+ import { rectToPercent, applyPercentRect } from './geometry.js';
3
+ const FIELD_ATTR = 'data-qm-field';
4
+ export function createOverlay(session, slots) {
5
+ const layers = slots.map((slot) => {
6
+ const layer = document.createElement('div');
7
+ layer.className = 'qm-overlay-layer';
8
+ Object.assign(layer.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
9
+ slot.el.appendChild(layer);
10
+ return layer;
11
+ });
12
+ // The marked address, and with it the bloom's start. ONE variable does both jobs:
13
+ // the change guard has to outlive the decay (a caret move after the wash ended
14
+ // must not re-mark the same field), and `bloom` refuses an `elapsed` past the
15
+ // decay, so a spent flash is inert on every later rebuild and never needs
16
+ // clearing. The editor→preview signal is continuous (every caret move, so every
17
+ // keystroke), and only a change of address is an event; marking the field being
18
+ // typed into is noise the recompile already answers, since the changed text
19
+ // repaints under the caret 120ms later.
20
+ let flash;
21
+ /** field → its boxes, rebuilt with them. The bloom's only lookup. */
22
+ let byField = new Map();
23
+ // Start or RESUME the bloom on whatever boxes exist for it now: one path, because
24
+ // starting is resuming at ~0. A field surfaces several boxes and they share
25
+ // `startedAt`, so they bloom in step rather than shimmering unevenly.
26
+ function applyFlash() {
27
+ const els = flash && byField.get(flash.field);
28
+ if (!flash || !els?.length)
29
+ return;
30
+ const elapsed = performance.now() - flash.startedAt;
31
+ const timing = bloomTiming(els[0]);
32
+ for (const el of els)
33
+ bloom(el, elapsed, timing);
34
+ }
35
+ // Field names come from `regions()` (the only session query that enumerates
36
+ // them; Preview carries no schema); the boxes themselves come from
37
+ // `fieldBoxes(field)`, which is content-only and `[]` for a scalar-reference
38
+ // or widget field, so a nameless-of-content field contributes no boxes.
39
+ function build() {
40
+ for (const layer of layers)
41
+ layer.replaceChildren();
42
+ byField = new Map();
43
+ const fields = new Set();
44
+ for (const region of session.regions())
45
+ fields.add(region.field);
46
+ for (const field of fields) {
47
+ const els = [];
48
+ for (const box of session.fieldBoxes(field)) {
49
+ const layer = layers[box.page];
50
+ const slot = slots[box.page];
51
+ if (!layer || !slot)
52
+ continue;
53
+ const pct = rectToPercent(box.rect, slot.size);
54
+ const el = document.createElement('div');
55
+ el.className = 'qm-field-box';
56
+ el.setAttribute(FIELD_ATTR, field);
57
+ applyPercentRect(el, pct);
58
+ el.style.borderRadius = 'var(--_qm-radius-inner)';
59
+ primeWash(el);
60
+ layer.appendChild(el);
61
+ els.push(el);
62
+ }
63
+ if (els.length)
64
+ byField.set(field, els);
65
+ }
66
+ applyFlash();
67
+ }
68
+ build();
69
+ return {
70
+ refresh: build,
71
+ flashField(field) {
72
+ if (field === flash?.field)
73
+ return;
74
+ flash = { field, startedAt: performance.now() };
75
+ applyFlash();
76
+ },
77
+ destroy() {
78
+ for (const layer of layers)
79
+ layer.remove();
80
+ }
81
+ };
82
+ }
@@ -0,0 +1,17 @@
1
+ import type { LiveSession, PageSize } from '@quillmark/wasm';
2
+ /** One page's DOM slot: the box overlay.ts/bridge.ts position against, plus its cached geometry. */
3
+ export interface PageSlot {
4
+ readonly page: number;
5
+ readonly size: PageSize;
6
+ readonly el: HTMLElement;
7
+ }
8
+ export interface PaintLoop {
9
+ /** Live view of the current per-page slots; same array identity for the loop's life. */
10
+ readonly slots: readonly PageSlot[];
11
+ /** Reconcile slot count to `pageCount`, re-cache geometry, repaint mounted `dirtyPages`. */
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;
15
+ destroy(): void;
16
+ }
17
+ export declare function createPaintLoop(session: LiveSession, container: HTMLElement, margin: number, onPaintError?: (page: number, err: unknown) => void): PaintLoop;