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