@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.
- package/LICENSE +201 -0
- package/NOTICE +58 -0
- package/README.md +241 -0
- package/THEMING.md +153 -0
- package/dist/core/address.d.ts +56 -0
- package/dist/core/address.js +95 -0
- package/dist/core/bloom.d.ts +37 -0
- package/dist/core/bloom.js +116 -0
- package/dist/core/codec/anchor.d.ts +19 -0
- package/dist/core/codec/anchor.js +36 -0
- package/dist/core/codec/code.d.ts +12 -0
- package/dist/core/codec/code.js +118 -0
- package/dist/core/codec/decode.d.ts +24 -0
- package/dist/core/codec/decode.js +270 -0
- package/dist/core/codec/encode.d.ts +71 -0
- package/dist/core/codec/encode.js +480 -0
- package/dist/core/codec/field.d.ts +171 -0
- package/dist/core/codec/field.js +493 -0
- package/dist/core/codec/index.d.ts +22 -0
- package/dist/core/codec/index.js +43 -0
- package/dist/core/codec/inputrules.d.ts +7 -0
- package/dist/core/codec/inputrules.js +168 -0
- package/dist/core/codec/islands.d.ts +36 -0
- package/dist/core/codec/islands.js +82 -0
- package/dist/core/codec/keymap.d.ts +13 -0
- package/dist/core/codec/keymap.js +29 -0
- package/dist/core/codec/lists.d.ts +22 -0
- package/dist/core/codec/lists.js +104 -0
- package/dist/core/codec/marks.d.ts +37 -0
- package/dist/core/codec/marks.js +74 -0
- package/dist/core/codec/positions.d.ts +17 -0
- package/dist/core/codec/positions.js +108 -0
- package/dist/core/codec/prose.css +458 -0
- package/dist/core/codec/reconcile.d.ts +26 -0
- package/dist/core/codec/reconcile.js +64 -0
- package/dist/core/codec/schema.d.ts +13 -0
- package/dist/core/codec/schema.js +155 -0
- package/dist/core/codec/slash.d.ts +79 -0
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.d.ts +50 -0
- package/dist/core/codec/table-view.js +928 -0
- package/dist/core/codec/table.d.ts +85 -0
- package/dist/core/codec/table.js +224 -0
- package/dist/core/errors.d.ts +73 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +19 -0
- package/dist/core/lifecycle.d.ts +7 -0
- package/dist/core/lifecycle.js +21 -0
- package/dist/core/rebind.svelte.d.ts +18 -0
- package/dist/core/rebind.svelte.js +44 -0
- package/dist/core/teardown.d.ts +20 -0
- package/dist/core/teardown.js +37 -0
- package/dist/core/theme.css +276 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -0
- package/dist/preset/index.css +10 -0
- package/dist/preset/recipes.css +188 -0
- package/dist/preset/scale.css +97 -0
- package/dist/preview/Preview.svelte +118 -0
- package/dist/preview/Preview.svelte.d.ts +39 -0
- package/dist/preview/bridge.d.ts +13 -0
- package/dist/preview/bridge.js +88 -0
- package/dist/preview/controller.d.ts +40 -0
- package/dist/preview/controller.js +141 -0
- package/dist/preview/geometry.d.ts +37 -0
- package/dist/preview/geometry.js +45 -0
- package/dist/preview/index.d.ts +5 -0
- package/dist/preview/index.js +14 -0
- package/dist/preview/overlay.d.ts +10 -0
- package/dist/preview/overlay.js +82 -0
- package/dist/preview/paint.d.ts +17 -0
- package/dist/preview/paint.js +262 -0
- package/dist/preview/strings.d.ts +14 -0
- package/dist/preview/strings.js +22 -0
- package/dist/visual/ArrayField.svelte +362 -0
- package/dist/visual/ArrayField.svelte.d.ts +24 -0
- package/dist/visual/BooleanField.svelte +88 -0
- package/dist/visual/BooleanField.svelte.d.ts +20 -0
- package/dist/visual/Card.svelte +849 -0
- package/dist/visual/Card.svelte.d.ts +43 -0
- package/dist/visual/CardControls.svelte +92 -0
- package/dist/visual/CardControls.svelte.d.ts +11 -0
- package/dist/visual/DateField.svelte +203 -0
- package/dist/visual/DateField.svelte.d.ts +28 -0
- package/dist/visual/DiagnosticList.svelte +50 -0
- package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
- package/dist/visual/EnumField.svelte +169 -0
- package/dist/visual/EnumField.svelte.d.ts +25 -0
- package/dist/visual/Field.svelte +270 -0
- package/dist/visual/Field.svelte.d.ts +37 -0
- package/dist/visual/FieldHint.svelte +185 -0
- package/dist/visual/FieldHint.svelte.d.ts +13 -0
- package/dist/visual/FieldLabel.svelte +141 -0
- package/dist/visual/FieldLabel.svelte.d.ts +19 -0
- package/dist/visual/FormatPopover.svelte +396 -0
- package/dist/visual/FormatPopover.svelte.d.ts +9 -0
- package/dist/visual/NumberField.svelte +72 -0
- package/dist/visual/NumberField.svelte.d.ts +19 -0
- package/dist/visual/ObjectField.svelte +131 -0
- package/dist/visual/ObjectField.svelte.d.ts +17 -0
- package/dist/visual/ProseArrayElement.svelte +98 -0
- package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
- package/dist/visual/ProseField.svelte +213 -0
- package/dist/visual/ProseField.svelte.d.ts +50 -0
- package/dist/visual/SlashMenu.svelte +126 -0
- package/dist/visual/SlashMenu.svelte.d.ts +14 -0
- package/dist/visual/TextField.svelte +63 -0
- package/dist/visual/TextField.svelte.d.ts +23 -0
- package/dist/visual/TipsCard.svelte +191 -0
- package/dist/visual/TipsCard.svelte.d.ts +13 -0
- package/dist/visual/VisualEditor.svelte +96 -0
- package/dist/visual/VisualEditor.svelte.d.ts +14 -0
- package/dist/visual/VisualEditorInner.svelte +1032 -0
- package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
- package/dist/visual/controls.css +272 -0
- package/dist/visual/diagnostics.d.ts +52 -0
- package/dist/visual/diagnostics.js +83 -0
- package/dist/visual/domid.d.ts +21 -0
- package/dist/visual/domid.js +36 -0
- package/dist/visual/ext.d.ts +19 -0
- package/dist/visual/ext.js +29 -0
- package/dist/visual/icons/Icon.svelte +39 -0
- package/dist/visual/icons/Icon.svelte.d.ts +12 -0
- package/dist/visual/icons/nodes.d.ts +103 -0
- package/dist/visual/icons/nodes.js +55 -0
- package/dist/visual/index.d.ts +9 -0
- package/dist/visual/index.js +24 -0
- package/dist/visual/motion.d.ts +18 -0
- package/dist/visual/motion.js +53 -0
- package/dist/visual/props.d.ts +77 -0
- package/dist/visual/props.js +1 -0
- package/dist/visual/signals.d.ts +59 -0
- package/dist/visual/signals.js +1 -0
- package/dist/visual/strings.d.ts +123 -0
- package/dist/visual/strings.js +80 -0
- package/dist/visual/structure.d.ts +224 -0
- package/dist/visual/structure.js +270 -0
- package/dist/visual/synced.svelte.d.ts +15 -0
- package/dist/visual/synced.svelte.js +34 -0
- package/dist/visual/tips.d.ts +24 -0
- package/dist/visual/tips.js +50 -0
- 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;
|