@quillmark/svelte 0.3.0 → 0.3.2
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/NOTICE +6 -6
- package/README.md +1 -1
- package/THEMING.md +1 -1
- package/dist/core/bloom.d.ts +1 -30
- package/dist/core/bloom.js +13 -45
- package/dist/core/codec/anchor.d.ts +5 -5
- package/dist/core/codec/anchor.js +37 -13
- package/dist/core/codec/prose.css +5 -6
- package/dist/core/theme.css +39 -19
- package/dist/preset/scale.css +4 -4
- package/dist/preview/Preview.svelte +6 -18
- package/dist/preview/Preview.svelte.d.ts +2 -3
- package/dist/preview/bridge.js +2 -2
- package/dist/preview/controller.d.ts +8 -10
- package/dist/preview/controller.js +16 -41
- package/dist/preview/geometry.d.ts +7 -9
- package/dist/preview/geometry.js +7 -9
- package/dist/preview/index.js +1 -1
- package/dist/preview/paint.d.ts +1 -1
- package/dist/preview/paint.js +5 -5
- package/dist/visual/Card.svelte +40 -35
- package/dist/visual/Card.svelte.d.ts +0 -1
- package/dist/visual/FormatPopover.svelte +58 -37
- package/dist/visual/ProseField.svelte +22 -20
- package/dist/visual/ProseField.svelte.d.ts +1 -2
- package/dist/visual/TipsCard.svelte +56 -53
- package/dist/visual/VisualEditorInner.svelte +126 -206
- package/dist/visual/icons/nodes.d.ts +16 -0
- package/dist/visual/icons/nodes.js +11 -0
- package/package.json +2 -2
- package/dist/preview/overlay.d.ts +0 -11
- package/dist/preview/overlay.js +0 -97
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { reportError, errorMessage } from '../core/errors.js';
|
|
2
2
|
import { createPaintLoop } from './paint.js';
|
|
3
|
-
import { createOverlay } from './overlay.js';
|
|
4
3
|
import { createBridge } from './bridge.js';
|
|
5
4
|
import { mergePreviewStrings } from './strings.js';
|
|
6
5
|
const CONTAINER_CLASS = 'qm-preview';
|
|
7
|
-
// The message states share one element; each carries `MESSAGE_CLASS` plus a
|
|
8
|
-
//
|
|
9
|
-
// the zero-page hook consumers and tests target.
|
|
6
|
+
// The message states share one element; each carries `MESSAGE_CLASS` plus a state
|
|
7
|
+
// class, the hooks a consumer and the tests target.
|
|
10
8
|
const MESSAGE_CLASS = 'qm-preview-message';
|
|
11
9
|
const EMPTY_CLASS = 'qm-preview-empty';
|
|
12
10
|
const UNSUPPORTED_CLASS = 'qm-preview-unsupported';
|
|
@@ -14,13 +12,10 @@ const ERROR_CLASS = 'qm-preview-error';
|
|
|
14
12
|
export function createPreview(session, opts) {
|
|
15
13
|
const container = opts.container;
|
|
16
14
|
const margin = opts.margin ?? 1;
|
|
17
|
-
const overlaysEnabled = opts.overlays ?? true;
|
|
18
15
|
const t = mergePreviewStrings(opts.strings);
|
|
19
16
|
container.classList.add(CONTAINER_CLASS);
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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.
|
|
17
|
+
// One element, restamped: the non-paint states (empty, unsupported, a paint that
|
|
18
|
+
// threw) are mutually exclusive.
|
|
24
19
|
let message;
|
|
25
20
|
function showMessage(text, state) {
|
|
26
21
|
if (!message) {
|
|
@@ -50,34 +45,26 @@ export function createPreview(session, opts) {
|
|
|
50
45
|
});
|
|
51
46
|
showMessage(t.renderFailed, ERROR_CLASS);
|
|
52
47
|
});
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
48
|
+
// The bridge lives and dies with the slot set, so it is held until `render` finds
|
|
49
|
+
// a compile paintable. Rebuilt rather than patched whenever the count changes: the
|
|
50
|
+
// slots array is a stable identity but its members are swapped on reconcile, and
|
|
51
|
+
// the listeners are per slot.
|
|
56
52
|
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
53
|
function attach() {
|
|
61
|
-
overlay = overlaysEnabled ? createOverlay(session, paintLoop.slots) : undefined;
|
|
62
54
|
bridge = createBridge(session, container, paintLoop.slots, opts.onPick);
|
|
63
55
|
}
|
|
64
56
|
function detach() {
|
|
65
|
-
overlay?.destroy();
|
|
66
57
|
bridge?.destroy();
|
|
67
|
-
overlay = undefined;
|
|
68
58
|
bridge = undefined;
|
|
69
59
|
}
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// compile that later gains paintable pages recovers; the check spans the
|
|
75
|
-
// paint capability generally, not just the page count.
|
|
60
|
+
// Called at construction and after every `apply`, so `supportsCanvas` is re-read
|
|
61
|
+
// per compile (runtime.d.ts: re-check after `open`) and a 0-page or non-canvas
|
|
62
|
+
// compile that later gains paintable pages recovers; the check spans the paint
|
|
63
|
+
// capability generally, not just the page count.
|
|
76
64
|
function render(pageCount, dirtyPages) {
|
|
77
65
|
if (!session.supportsCanvas || pageCount === 0) {
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// boundary cannot raster is a genuine unsupported.
|
|
66
|
+
// A 0-page compile is a recoverable empty; pages the boundary cannot
|
|
67
|
+
// raster are a genuine unsupported.
|
|
81
68
|
paintLoop.refresh([], 0);
|
|
82
69
|
detach();
|
|
83
70
|
showMessage(pageCount === 0 ? t.noPages : t.unsupported, pageCount === 0 ? EMPTY_CLASS : UNSUPPORTED_CLASS);
|
|
@@ -89,18 +76,11 @@ export function createPreview(session, opts) {
|
|
|
89
76
|
const countChanged = pageCount !== paintLoop.slots.length;
|
|
90
77
|
hideMessage();
|
|
91
78
|
paintLoop.refresh(dirtyPages, pageCount);
|
|
79
|
+
// The slot set moved under the bridge's feet, or we are leaving a message state.
|
|
92
80
|
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
81
|
detach();
|
|
97
82
|
attach();
|
|
98
83
|
}
|
|
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
84
|
}
|
|
105
85
|
render(session.pageCount, []);
|
|
106
86
|
// The last place the editor put the caret, re-located after every recompile.
|
|
@@ -120,16 +100,11 @@ export function createPreview(session, opts) {
|
|
|
120
100
|
bridge?.focusPosition(followed.field, followed.pos);
|
|
121
101
|
},
|
|
122
102
|
scrollToField(field) {
|
|
123
|
-
|
|
124
|
-
// Marked either way: a compile that gains the address later blooms it on the
|
|
125
|
-
// rebuild, since the flash carries its own start time (`core/bloom.ts`).
|
|
126
|
-
overlay?.flashField(field);
|
|
127
|
-
return found;
|
|
103
|
+
return bridge?.scrollToField(field) ?? false;
|
|
128
104
|
},
|
|
129
105
|
focusPosition(at) {
|
|
130
106
|
followed = at;
|
|
131
107
|
bridge?.focusPosition(at.field, at.pos);
|
|
132
|
-
overlay?.flashField(at.field);
|
|
133
108
|
},
|
|
134
109
|
setZoom(scale) {
|
|
135
110
|
paintLoop.setDensityZoom(scale);
|
|
@@ -15,9 +15,8 @@ export interface PdfPoint {
|
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
17
|
* An address's boxes: `fieldBoxes`'s union, else its own `regions()` rects, else the
|
|
18
|
-
* rects of everything under it.
|
|
19
|
-
*
|
|
20
|
-
* responsibilities").
|
|
18
|
+
* rects of everything under it. What the scroll reads, so an address `regions()`
|
|
19
|
+
* names is never one the preview cannot place (PREVIEW.md §"Two responsibilities").
|
|
21
20
|
*
|
|
22
21
|
* `fieldBoxes` answers `[]` for a scalar reference the plate places without tracking
|
|
23
22
|
* its content and for a `richtext[]` element; runtime.d.ts says what such a field's
|
|
@@ -30,15 +29,14 @@ export interface PdfPoint {
|
|
|
30
29
|
export declare function boxesForField(field: string, boxes: readonly FieldRegion[], regions: readonly FieldRegion[]): readonly FieldRegion[];
|
|
31
30
|
/**
|
|
32
31
|
* Forward: a PDF-pt rect -> CSS % of the page box. Percent positioning tracks
|
|
33
|
-
* the page's displayed size across DPR and container resize for free
|
|
34
|
-
*
|
|
32
|
+
* the page's displayed size across DPR and container resize for free, so the
|
|
33
|
+
* scroll's measurement needs no zoom or DPR term of its own (bridge.ts).
|
|
35
34
|
*/
|
|
36
35
|
export declare function rectToPercent(rect: PdfRect, pageSize: PageSize): PercentRect;
|
|
37
36
|
/**
|
|
38
|
-
* Position `el` absolutely at `pct` (the % rect this module produces)
|
|
39
|
-
* `PercentRect` → `element.style` write,
|
|
40
|
-
* the
|
|
41
|
-
* The caller sets any box chrome (border, background) on top.
|
|
37
|
+
* Position `el` absolutely at `pct` (the % rect this module produces): the one
|
|
38
|
+
* `PercentRect` → `element.style` write, so the positioning convention lives beside
|
|
39
|
+
* the transform that produces it rather than at the caller placing a marker.
|
|
42
40
|
*/
|
|
43
41
|
export declare function applyPercentRect(el: HTMLElement, pct: PercentRect): void;
|
|
44
42
|
/**
|
package/dist/preview/geometry.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* An address's boxes: `fieldBoxes`'s union, else its own `regions()` rects, else the
|
|
3
|
-
* rects of everything under it.
|
|
4
|
-
*
|
|
5
|
-
* responsibilities").
|
|
3
|
+
* rects of everything under it. What the scroll reads, so an address `regions()`
|
|
4
|
+
* names is never one the preview cannot place (PREVIEW.md §"Two responsibilities").
|
|
6
5
|
*
|
|
7
6
|
* `fieldBoxes` answers `[]` for a scalar reference the plate places without tracking
|
|
8
7
|
* its content and for a `richtext[]` element; runtime.d.ts says what such a field's
|
|
@@ -20,8 +19,8 @@ export function boxesForField(field, boxes, regions) {
|
|
|
20
19
|
}
|
|
21
20
|
/**
|
|
22
21
|
* Forward: a PDF-pt rect -> CSS % of the page box. Percent positioning tracks
|
|
23
|
-
* the page's displayed size across DPR and container resize for free
|
|
24
|
-
*
|
|
22
|
+
* the page's displayed size across DPR and container resize for free, so the
|
|
23
|
+
* scroll's measurement needs no zoom or DPR term of its own (bridge.ts).
|
|
25
24
|
*/
|
|
26
25
|
export function rectToPercent(rect, pageSize) {
|
|
27
26
|
const [x0, y0, x1, y1] = rect;
|
|
@@ -34,10 +33,9 @@ export function rectToPercent(rect, pageSize) {
|
|
|
34
33
|
};
|
|
35
34
|
}
|
|
36
35
|
/**
|
|
37
|
-
* Position `el` absolutely at `pct` (the % rect this module produces)
|
|
38
|
-
* `PercentRect` → `element.style` write,
|
|
39
|
-
* the
|
|
40
|
-
* The caller sets any box chrome (border, background) on top.
|
|
36
|
+
* Position `el` absolutely at `pct` (the % rect this module produces): the one
|
|
37
|
+
* `PercentRect` → `element.style` write, so the positioning convention lives beside
|
|
38
|
+
* the transform that produces it rather than at the caller placing a marker.
|
|
41
39
|
*/
|
|
42
40
|
export function applyPercentRect(el, pct) {
|
|
43
41
|
Object.assign(el.style, {
|
package/dist/preview/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// (the codec, the VisualEditor) so a `@quillmark/preview` promotion stays a
|
|
6
6
|
// re-export, not a refactor: it may reach `/core` (the shared WASM boundary) and
|
|
7
7
|
// nothing else. Enforced by `check:deps`, which walks the subpath's import graph.
|
|
8
|
-
// The theme derivation, which the page slots
|
|
8
|
+
// The theme derivation, which the page slots read through `var()`.
|
|
9
9
|
// Imported at the barrel because a subpath is what a consumer gets: this surface
|
|
10
10
|
// reaches modules inside `core/` and never its entry, so a sheet hanging off that
|
|
11
11
|
// entry arrives only for a consumer importing `/core` for some other reason. The
|
package/dist/preview/paint.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LiveSession, PageSize } from '@quillmark/wasm';
|
|
2
|
-
/** One page's DOM slot: the box
|
|
2
|
+
/** One page's DOM slot: the box bridge.ts measures against, plus its cached geometry. */
|
|
3
3
|
export interface PageSlot {
|
|
4
4
|
readonly page: number;
|
|
5
5
|
readonly size: PageSize;
|
package/dist/preview/paint.js
CHANGED
|
@@ -36,9 +36,9 @@ onPaintError) {
|
|
|
36
36
|
// The sheet is a plane and the desk is the one under it: paper sits at
|
|
37
37
|
// `--_qm-surface` and the container paints `--_qm-surface-sunken`, so the
|
|
38
38
|
// edge is where two tones meet and no stroke is drawn around it
|
|
39
|
-
// (ARCHITECTURE §Styling). It leaves the
|
|
40
|
-
//
|
|
41
|
-
//
|
|
39
|
+
// (ARCHITECTURE §Styling). It leaves the no-ink rule untouched, since a
|
|
40
|
+
// boundary that is not painted is not a mark on the page (PREVIEW §"Two
|
|
41
|
+
// responsibilities"). `border-box` costs nothing here and keeps a slot a
|
|
42
42
|
// consumer insets from measuring wider than its paper.
|
|
43
43
|
const el = document.createElement('div');
|
|
44
44
|
el.className = 'qm-page';
|
|
@@ -151,7 +151,7 @@ onPaintError) {
|
|
|
151
151
|
slots[i] = { ...slots[i], size };
|
|
152
152
|
// Keep the page box's shape in step with the re-read size: a recompile
|
|
153
153
|
// can change a page's dimensions without changing the count, and every
|
|
154
|
-
// %-space
|
|
154
|
+
// %-space click/scroll transform assumes box shape matches PageSize.
|
|
155
155
|
slots[i].el.style.aspectRatio = `${size.widthPt} / ${size.heightPt}`;
|
|
156
156
|
}
|
|
157
157
|
}
|
|
@@ -164,7 +164,7 @@ onPaintError) {
|
|
|
164
164
|
// Every paint freezes `canvas.style.width/height` to the box width at that
|
|
165
165
|
// paint (paintSlot). A page that stays mounted while the container's CSS width
|
|
166
166
|
// or `devicePixelRatio` shifts would otherwise keep a stale raster the %-space
|
|
167
|
-
//
|
|
167
|
+
// click/scroll math silently drifts off of: the box tracks the container, the
|
|
168
168
|
// ink does not. Two observers close the gap by repainting mounted pages from
|
|
169
169
|
// their current box; canvases are `position:absolute`, so a repaint can't feed
|
|
170
170
|
// back into layout (and thus can't re-trigger the resize observer).
|
package/dist/visual/Card.svelte
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
One card block
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
re-targets its commits without a remount.
|
|
2
|
+
One card block, its fields grouped by `ui.group`/`ui.compact`. `main` is headerless
|
|
3
|
+
and takes no controls. Every prose leaf takes a parent-built live address, so a card
|
|
4
|
+
reorder re-targets its commits without a remount.
|
|
6
5
|
-->
|
|
7
6
|
<script lang="ts">
|
|
8
7
|
import { wording } from './strings.js';
|
|
@@ -43,7 +42,6 @@
|
|
|
43
42
|
card: CardModel;
|
|
44
43
|
doc: Document;
|
|
45
44
|
quill: Quill;
|
|
46
|
-
index: number;
|
|
47
45
|
isFirst: boolean;
|
|
48
46
|
isLast: boolean;
|
|
49
47
|
kinds: string[];
|
|
@@ -61,7 +59,6 @@
|
|
|
61
59
|
card,
|
|
62
60
|
doc,
|
|
63
61
|
quill,
|
|
64
|
-
index,
|
|
65
62
|
isFirst,
|
|
66
63
|
isLast,
|
|
67
64
|
kinds,
|
|
@@ -91,8 +88,8 @@
|
|
|
91
88
|
(e.currentTarget as HTMLInputElement).select();
|
|
92
89
|
}
|
|
93
90
|
// A press on an unfocused title would place a caret on mouseup, collapsing the
|
|
94
|
-
// focus-time select-all.
|
|
95
|
-
//
|
|
91
|
+
// focus-time select-all. A press while already focused stays normal, so clicking
|
|
92
|
+
// mid-title to place the caret still works.
|
|
96
93
|
function onTitleMousedown(e: MouseEvent): void {
|
|
97
94
|
const el = e.currentTarget as HTMLInputElement;
|
|
98
95
|
if (document.activeElement !== el) {
|
|
@@ -100,9 +97,8 @@
|
|
|
100
97
|
el.focus();
|
|
101
98
|
}
|
|
102
99
|
}
|
|
103
|
-
// A press on the region's empty width enters the title edit
|
|
104
|
-
//
|
|
105
|
-
// to `onTitleMousedown`, which owns the already-focused caret-placement case.
|
|
100
|
+
// A press on the region's empty width enters the title edit, and starts no text
|
|
101
|
+
// selection on the wrapper. A press on the input itself is `onTitleMousedown`'s.
|
|
106
102
|
function onRenameMousedown(e: MouseEvent): void {
|
|
107
103
|
const input = (e.currentTarget as HTMLElement).querySelector('input');
|
|
108
104
|
if (!input || e.target === input) return;
|
|
@@ -136,10 +132,10 @@
|
|
|
136
132
|
// paint as a single stroke of twice the width.
|
|
137
133
|
const hasMeta = $derived(ungrouped.length > 0 || grouped.length > 0);
|
|
138
134
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
135
|
+
// Seeded once from the card's shape. Card is keyed by stable id, so this survives the
|
|
136
|
+
// VisualEditor re-derive that reassigns `card` and resets only on a remount. Not
|
|
137
|
+
// reconciled to later section changes: a retype is the one reshape, and the open
|
|
138
|
+
// group stays where it still exists.
|
|
143
139
|
// svelte-ignore state_referenced_locally
|
|
144
140
|
let expanded = $state<string | null>(initialExpandedGroup(card.sections));
|
|
145
141
|
let headers = $state<Record<string, HTMLButtonElement | undefined>>({});
|
|
@@ -162,10 +158,10 @@
|
|
|
162
158
|
}
|
|
163
159
|
|
|
164
160
|
/**
|
|
165
|
-
* Open the group holding leaf `key
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
161
|
+
* Open the group holding leaf `key`: a caret placed in a collapsed panel does not
|
|
162
|
+
* land at all. `VisualEditor.setCaret` calls this on every card and then waits a
|
|
163
|
+
* flush before landing, since `inert` clears when the panel renders open, not when
|
|
164
|
+
* `expanded` is assigned.
|
|
169
165
|
*
|
|
170
166
|
* A call, not a prop: a reveal is an event, and modelling it as state needs a
|
|
171
167
|
* fresh-identity wrapper to re-fire and an `untrack` to keep the per-keystroke
|
|
@@ -232,11 +228,10 @@
|
|
|
232
228
|
{/if}
|
|
233
229
|
|
|
234
230
|
<div class="qm-card-body">
|
|
235
|
-
<!--
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
empty wrapper between the two rules would collapse them onto each other. -->
|
|
231
|
+
<!-- One element whose top and bottom edges are the bracket's two horizontals.
|
|
232
|
+
Anchored here rather than on the header and on the accordion so an open section's
|
|
233
|
+
vertical reaches them, and so a card with no groups still terminates above its
|
|
234
|
+
body. -->
|
|
240
235
|
{#if hasMeta}
|
|
241
236
|
<div
|
|
242
237
|
class="qm-card-meta"
|
|
@@ -395,11 +390,12 @@
|
|
|
395
390
|
{/snippet}
|
|
396
391
|
|
|
397
392
|
<style>
|
|
398
|
-
/* An island
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
393
|
+
/* An island: the card is the base plane, the column behind it the sunken one, and the
|
|
394
|
+
hairline closes it (ARCHITECTURE §Styling). The inset is the same rung as the gap, so
|
|
395
|
+
what separates the header from the fields is what separates the fields from the
|
|
396
|
+
card's edge. */
|
|
402
397
|
.qm-card {
|
|
398
|
+
border: var(--_qm-border-width) solid var(--_qm-border);
|
|
403
399
|
border-radius: var(--_qm-radius);
|
|
404
400
|
padding: var(--_qm-space-3);
|
|
405
401
|
background: var(--_qm-surface);
|
|
@@ -442,13 +438,17 @@
|
|
|
442
438
|
}
|
|
443
439
|
/* Autosize: the ::after mirrors the text into the same grid cell as the input, so the
|
|
444
440
|
overlaid input tracks its content width. Both carry the same nothing for a box model
|
|
445
|
-
and inherit the same type tokens, which is what makes the two measure alike.
|
|
441
|
+
and inherit the same type tokens, which is what makes the two measure alike.
|
|
442
|
+
|
|
443
|
+
The type is the group header's, down to the ink and its hover step below: a card
|
|
444
|
+
title and a section header both name a block of fields, so they read as one register
|
|
445
|
+
and what ranks them is position rather than a size step. */
|
|
446
446
|
.qm-card-title-sizer {
|
|
447
447
|
display: inline-grid;
|
|
448
448
|
align-items: center;
|
|
449
449
|
min-width: 0;
|
|
450
450
|
max-width: 100%;
|
|
451
|
-
font-size: var(--_qm-text-
|
|
451
|
+
font-size: var(--_qm-text-body);
|
|
452
452
|
font-weight: var(--_qm-weight-mid);
|
|
453
453
|
line-height: var(--_qm-leading-tight);
|
|
454
454
|
font-family: inherit;
|
|
@@ -620,11 +620,17 @@
|
|
|
620
620
|
The clip box takes the inline inset, which costs a shut panel no height and holds the
|
|
621
621
|
queried width still. The block inset goes on the content, where a `0fr` track clips
|
|
622
622
|
it: declared one box out it collapses the content but not itself, and stands under a
|
|
623
|
-
shut header as dead space. Neither is animated, and neither is qualified by open.
|
|
623
|
+
shut header as dead space. Neither is animated, and neither is qualified by open.
|
|
624
|
+
|
|
625
|
+
A field ends on the clip box at the end edge, where no inset hides its ring the way
|
|
626
|
+
the start edge's does. The pad carries the clip past it and the margin takes the same
|
|
627
|
+
distance back, so the width the fields are packed and queried at is unmoved. */
|
|
624
628
|
.qm-group-panel-inner {
|
|
625
629
|
min-height: 0;
|
|
626
630
|
overflow: hidden;
|
|
627
631
|
padding-inline-start: var(--_qm-space-4);
|
|
632
|
+
padding-inline-end: var(--_qm-ring-reach);
|
|
633
|
+
margin-inline-end: calc(-1 * var(--_qm-ring-reach));
|
|
628
634
|
}
|
|
629
635
|
.qm-group-panel-inner > .qm-fields {
|
|
630
636
|
padding-block: var(--_qm-space-2);
|
|
@@ -634,14 +640,13 @@
|
|
|
634
640
|
flex-direction: column;
|
|
635
641
|
gap: var(--_qm-space);
|
|
636
642
|
}
|
|
637
|
-
/* The
|
|
638
|
-
around this one is a state rather than heavier chrome. */
|
|
643
|
+
/* The state is in the stroke's pattern, every card being closed by one already. */
|
|
639
644
|
.qm-card.qm-unschemable {
|
|
640
|
-
border:
|
|
645
|
+
border-style: dashed;
|
|
641
646
|
}
|
|
642
647
|
/* One typographic role with the editable title above. */
|
|
643
648
|
.qm-card-title-static {
|
|
644
|
-
font-size: var(--_qm-text-
|
|
649
|
+
font-size: var(--_qm-text-body);
|
|
645
650
|
font-weight: var(--_qm-weight-mid);
|
|
646
651
|
line-height: var(--_qm-leading-tight);
|
|
647
652
|
color: var(--_qm-ink-label);
|
|
@@ -19,10 +19,15 @@
|
|
|
19
19
|
transient `selectionchange` events first). `focusout` keeps the popover honest
|
|
20
20
|
when focus leaves the leaf.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
A press holds it down, which is what makes the surface answer a selection rather
|
|
23
|
+
than a pointer: a drag fires `selectionchange` on every frame, so a surface driven
|
|
24
|
+
by that alone rises on the first character and rides the cursor across the text it
|
|
25
|
+
is covering. Keyboard selection holds nothing, so a Shift-arrow syncs as it fires.
|
|
26
|
+
|
|
27
|
+
Nothing here watches scroll. `sync` decides whether the surface is up, which a
|
|
28
|
+
scroll does not change; where it sits is the anchor's own question, and the anchor
|
|
29
|
+
answers it by measuring when floating-ui asks rather than by being handed a rect
|
|
30
|
+
(`codec/anchor.ts`).
|
|
26
31
|
|
|
27
32
|
Focus discipline. Three failure modes a naive Popover.Content invites:
|
|
28
33
|
(1) its default `trapFocus` (true) redirects any focus landing outside its
|
|
@@ -76,7 +81,6 @@
|
|
|
76
81
|
}
|
|
77
82
|
let { getActiveLeaf }: Props = $props();
|
|
78
83
|
|
|
79
|
-
/** Mark size: the shared control-glyph rule for the popover icons. */
|
|
80
84
|
const GLYPH = 15;
|
|
81
85
|
/** The six content formatting marks. `anchor` is a seventh, drawn separately below:
|
|
82
86
|
* a decoration toggle rather than a PM `toggleMark`. One string per mark carries both
|
|
@@ -91,11 +95,14 @@
|
|
|
91
95
|
]);
|
|
92
96
|
|
|
93
97
|
let open = $state(false);
|
|
94
|
-
|
|
98
|
+
/** A pointer is down outside this surface: the selection is still being made. */
|
|
99
|
+
let pressed = false;
|
|
95
100
|
/** Whether the selection is in the field's coordinate space, which an anchor needs
|
|
96
101
|
* and a table cell is not (see `sync`). */
|
|
97
102
|
let anchorAvailable = $state(true);
|
|
98
103
|
let linkPromptOpen = $state(false);
|
|
104
|
+
/** Whether the selection carried a link when the prompt was raised. */
|
|
105
|
+
let linkPresent = $state(false);
|
|
99
106
|
let linkValue = $state('');
|
|
100
107
|
let linkInputEl = $state<HTMLInputElement | undefined>(undefined);
|
|
101
108
|
let contentEl = $state<HTMLElement | undefined>(undefined);
|
|
@@ -118,7 +125,6 @@
|
|
|
118
125
|
return leaf?.focusedView?.() ?? leaf?.view;
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
/** Recompute open/anchor/active-marks from the active leaf's current PM selection. */
|
|
122
128
|
function sync(): void {
|
|
123
129
|
const insidePopover =
|
|
124
130
|
!!contentEl && !!document.activeElement && contentEl.contains(document.activeElement);
|
|
@@ -126,6 +132,7 @@
|
|
|
126
132
|
// Costs nothing to hold: the anchor minted below goes on measuring while this
|
|
127
133
|
// returns early, so a surface frozen here still tracks the selection it covers.
|
|
128
134
|
if (insidePopover) return;
|
|
135
|
+
if (pressed) return;
|
|
129
136
|
const leaf = getActiveLeaf() as LeafWithView | undefined;
|
|
130
137
|
const view = leaf?.focusedView?.() ?? leaf?.view;
|
|
131
138
|
// A non-empty text selection. A node selection is non-empty too and has nothing
|
|
@@ -133,7 +140,7 @@
|
|
|
133
140
|
// Escape out of a table cell lands.
|
|
134
141
|
const selection = view?.state.selection;
|
|
135
142
|
const formattable = selection instanceof TextSelection && !selection.empty;
|
|
136
|
-
if (!view || !view.hasFocus() || !formattable) {
|
|
143
|
+
if (!view || !view.hasFocus() || !formattable || !hasText(view)) {
|
|
137
144
|
open = false;
|
|
138
145
|
linkPromptOpen = false;
|
|
139
146
|
return;
|
|
@@ -141,27 +148,21 @@
|
|
|
141
148
|
portalTarget = view.dom.closest<HTMLElement>('[data-qm-root]') ?? undefined;
|
|
142
149
|
const { from, to } = view.state.selection;
|
|
143
150
|
anchor = rangeAnchor(view, from, to);
|
|
144
|
-
|
|
145
|
-
for
|
|
146
|
-
const type = view.state.schema.marks[m.name];
|
|
147
|
-
marks[m.name] = !!type && view.state.doc.rangeHasMark(from, to, type);
|
|
148
|
-
}
|
|
149
|
-
// `anchor` is a decoration, not a PM mark (CODEC §Marks): its active state is
|
|
150
|
-
// whether the selection covers an identity anchor, read off the leaf in USV.
|
|
151
|
-
//
|
|
152
|
-
// A selection inside a table cell is not in that coordinate space at all: the
|
|
153
|
-
// field's position map holds one `atom` run for the whole island, so there is
|
|
151
|
+
// A selection inside a table cell is not in the field's coordinate space at all:
|
|
152
|
+
// the field's position map holds one `atom` run for the whole island, so there is
|
|
154
153
|
// no USV offset to mint an anchor at. The button is withheld rather than
|
|
155
154
|
// disabled, because what it would toggle does not exist there (CODEC §Islands).
|
|
156
155
|
anchorAvailable = !!leaf && view === leaf.view;
|
|
157
|
-
if (leaf && anchorAvailable) {
|
|
158
|
-
const sel = leaf.selectionRange();
|
|
159
|
-
marks.anchor = leaf.anchorsInRange(sel.from, sel.to).length > 0;
|
|
160
|
-
}
|
|
161
|
-
activeMarks = marks;
|
|
162
156
|
open = true;
|
|
163
157
|
}
|
|
164
158
|
|
|
159
|
+
/** Whitespace alone is a selection the pointer makes by accident, at the end of a
|
|
160
|
+
* line or between two words, and every mark over it is invisible. */
|
|
161
|
+
function hasText(view: EditorView): boolean {
|
|
162
|
+
const { from, to } = view.state.selection;
|
|
163
|
+
return view.state.doc.textBetween(from, to, ' ', ' ').trim().length > 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
165
166
|
// A burst of DOM events (verified: browser "select all" processing fires
|
|
166
167
|
// `selectionchange` several times, transiently empty before landing on the
|
|
167
168
|
// final range) must coalesce to one settled check, not one `sync()` per
|
|
@@ -186,12 +187,33 @@
|
|
|
186
187
|
});
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
function onPointerDown(e: PointerEvent): void {
|
|
191
|
+
// A press on the surface is not a selection gesture, and lowering it here would
|
|
192
|
+
// take the button out from under the pointer before its click.
|
|
193
|
+
if (contentEl?.contains(e.target as Node)) return;
|
|
194
|
+
pressed = true;
|
|
195
|
+
open = false;
|
|
196
|
+
}
|
|
197
|
+
function onPointerUp(): void {
|
|
198
|
+
if (!pressed) return;
|
|
199
|
+
pressed = false;
|
|
200
|
+
deferredSync();
|
|
201
|
+
}
|
|
202
|
+
|
|
189
203
|
$effect(() => {
|
|
190
204
|
document.addEventListener('selectionchange', deferredSync);
|
|
191
205
|
document.addEventListener('focusout', deferredSync);
|
|
206
|
+
// Capture, so the gesture is seen whatever a view in the middle of it does with
|
|
207
|
+
// the event. `pointercancel` is the release a gesture the browser takes over gets.
|
|
208
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
209
|
+
document.addEventListener('pointerup', onPointerUp, true);
|
|
210
|
+
document.addEventListener('pointercancel', onPointerUp, true);
|
|
192
211
|
return () => {
|
|
193
212
|
document.removeEventListener('selectionchange', deferredSync);
|
|
194
213
|
document.removeEventListener('focusout', deferredSync);
|
|
214
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
215
|
+
document.removeEventListener('pointerup', onPointerUp, true);
|
|
216
|
+
document.removeEventListener('pointercancel', onPointerUp, true);
|
|
195
217
|
// A burst-coalescing rAF may still be in flight at teardown; cancel it so
|
|
196
218
|
// `sync()` never runs against the destroyed component.
|
|
197
219
|
if (pending) cancelAnimationFrame(rafHandle);
|
|
@@ -235,6 +257,7 @@
|
|
|
235
257
|
if (!type) return;
|
|
236
258
|
if (name === 'link') {
|
|
237
259
|
linkValue = hrefInSelection(view.state);
|
|
260
|
+
linkPresent = linkValue.length > 0;
|
|
238
261
|
linkPromptOpen = true;
|
|
239
262
|
return;
|
|
240
263
|
}
|
|
@@ -293,8 +316,14 @@
|
|
|
293
316
|
|
|
294
317
|
<Popover.Root bind:open>
|
|
295
318
|
<Popover.Portal to={portalTarget}>
|
|
319
|
+
<!-- A flip is measured against the root the surface portals into, which is the box
|
|
320
|
+
that clips it where the consumer scrolls one (`.qm-pane`). The default boundary is
|
|
321
|
+
the viewport, which knows nothing of that: a selection on the pane's first visible
|
|
322
|
+
line raises the surface into the room above, where the pane's `overflow` cuts
|
|
323
|
+
it. -->
|
|
296
324
|
<Popover.Content
|
|
297
325
|
customAnchor={anchor ?? null}
|
|
326
|
+
collisionBoundary={portalTarget ?? []}
|
|
298
327
|
side="top"
|
|
299
328
|
align="center"
|
|
300
329
|
trapFocus={false}
|
|
@@ -340,7 +369,7 @@
|
|
|
340
369
|
<button type="submit" class="qm-icon-btn qm-mark-btn">{t.strings.linkApply}</button>
|
|
341
370
|
<!-- Only where there is a link to remove: an arm that does nothing is
|
|
342
371
|
an arm to read past on every other visit to the prompt. -->
|
|
343
|
-
{#if
|
|
372
|
+
{#if linkPresent}
|
|
344
373
|
<button
|
|
345
374
|
type="button"
|
|
346
375
|
class="qm-icon-btn qm-mark-btn"
|
|
@@ -364,7 +393,6 @@
|
|
|
364
393
|
<button
|
|
365
394
|
type="button"
|
|
366
395
|
class="qm-icon-btn qm-mark-btn"
|
|
367
|
-
class:active={activeMarks[m.name]}
|
|
368
396
|
title={m.title}
|
|
369
397
|
aria-label={m.title}
|
|
370
398
|
onmousedown={keepFocus}
|
|
@@ -375,7 +403,6 @@
|
|
|
375
403
|
<button
|
|
376
404
|
type="button"
|
|
377
405
|
class="qm-icon-btn qm-mark-btn"
|
|
378
|
-
class:active={activeMarks.anchor}
|
|
379
406
|
title={t.strings.formatAnchorTitle}
|
|
380
407
|
aria-label={t.strings.formatAnchor}
|
|
381
408
|
onmousedown={keepFocus}
|
|
@@ -408,21 +435,15 @@
|
|
|
408
435
|
gap: var(--_qm-space-half);
|
|
409
436
|
}
|
|
410
437
|
/* Chrome, type, target floor, hover fill and disabled recede come from
|
|
411
|
-
`.qm-icon-btn` (controls.css)
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
438
|
+
`.qm-icon-btn` (controls.css). The ink is stated because the family deliberately
|
|
439
|
+
declares none: its callers disagree, and a popover over content reads at the card's.
|
|
440
|
+
|
|
441
|
+
No state for a mark the selection already carries: a run may carry one over part of
|
|
442
|
+
itself, which a two-state button has no third face for. */
|
|
415
443
|
.qm-mark-btn {
|
|
416
|
-
border: var(--_qm-border-width) solid transparent;
|
|
417
444
|
padding: var(--_qm-space) var(--_qm-space-2);
|
|
418
445
|
color: var(--_qm-ink);
|
|
419
446
|
}
|
|
420
|
-
/* Active is an ink inversion, the one state on this surface a hover fill cannot
|
|
421
|
-
say: a mark already carried is not a mark under the pointer. */
|
|
422
|
-
.qm-mark-btn.active {
|
|
423
|
-
background: var(--_qm-ink);
|
|
424
|
-
color: var(--_qm-surface);
|
|
425
|
-
}
|
|
426
447
|
.qm-link-prompt {
|
|
427
448
|
display: flex;
|
|
428
449
|
gap: var(--_qm-space);
|