@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.
@@ -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
- // state class so a consumer (and the tests) can target them. `EMPTY_CLASS` is
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
- // 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.
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
- // 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;
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
- // 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.
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
- // 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.
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
- const found = bridge?.scrollToField(field) ?? false;
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. The one rule the overlay and the scroll both read, so
19
- * an address `regions()` names never draws nothing (PREVIEW.md §"Three
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; this is
34
- * what the overlay draws (overlay.ts).
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); the one
39
- * `PercentRect` → `element.style` write, shared by the overlay's field boxes and
40
- * the bridge's scroll marker so the positioning convention lives in one place.
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
  /**
@@ -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. The one rule the overlay and the scroll both read, so
4
- * an address `regions()` names never draws nothing (PREVIEW.md §"Three
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; this is
24
- * what the overlay draws (overlay.ts).
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); the one
38
- * `PercentRect` → `element.style` write, shared by the overlay's field boxes and
39
- * the bridge's scroll marker so the positioning convention lives in one place.
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, {
@@ -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 and the overlay read through `var()`.
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
@@ -1,5 +1,5 @@
1
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. */
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;
@@ -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 overlay's no-ink-at-rest rule
40
- // untouched, since a boundary that is not painted is not a mark on the page
41
- // (PREVIEW §Overlay). `border-box` costs nothing here and keeps a slot a
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 overlay/click transform assumes box shape matches PageSize.
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
- // overlay/click math silently drifts off of: the box tracks the container, the
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).
@@ -1,8 +1,7 @@
1
1
  <!--
2
- One card block: the header (composable cards only), the field list grouped by
3
- `ui.group`/`ui.compact`, and the body prose leaf. `main` is headerless with no
4
- controls. Every prose leaf takes a parent-built live address, so a card reorder
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. Take focus manually and suppress that caret; a press while
95
- // already focused stays normal, so clicking mid-title to place the caret still works.
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; preventDefault keeps it
104
- // from starting a text selection on the wrapper. A press on the input itself is left
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
- // Ephemeral session state, seeded once from the card's shape. Card is keyed by stable
140
- // id, so this survives the VisualEditor re-derive that reassigns `card` and resets
141
- // only on a remount. Not reconciled to later section changes: a retype is the one
142
- // reshape, and the open group stays where it still exists.
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`, if this card has one: a caret placed in a
166
- * collapsed panel does not land at all. `VisualEditor.setCaret` calls this on every
167
- * card and then waits a flush before landing, since `inert` clears when the panel
168
- * renders open, not when `expanded` is assigned.
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
- <!-- Every field the card has, ungrouped and grouped alike, inside one element
236
- whose top and bottom edges are the bracket's two horizontals. Anchored here
237
- rather than on the header and on the accordion so an open section's vertical
238
- reaches them, and so a card with no groups still terminates above its body. An
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, and the tone is the whole of what makes it one: the card is the base
399
- plane and the column behind it is the sunken one (ARCHITECTURE §Styling). The inset
400
- is the same rung as the gap, so what separates the header from the fields is what
401
- separates the fields from the card's edge. */
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-title);
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 one card that draws an edge at all: an ordinary card is a plane, so a stroke
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: var(--_qm-border-width) dashed var(--_qm-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-title);
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);
@@ -22,7 +22,6 @@ interface Props {
22
22
  card: CardModel;
23
23
  doc: Document;
24
24
  quill: Quill;
25
- index: number;
26
25
  isFirst: boolean;
27
26
  isLast: boolean;
28
27
  kinds: string[];
@@ -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
- Nothing here watches scroll. `sync` decides whether the surface is up and what
23
- its buttons read, which a scroll changes neither of; where it sits is the
24
- anchor's own question, and the anchor answers it by measuring when floating-ui
25
- asks rather than by being handed a rect (`codec/anchor.ts`).
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
- let activeMarks = $state<Record<string, boolean>>({});
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
- const marks: Record<string, boolean> = {};
145
- for (const m of MARKS) {
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 activeMarks.link}
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); what is here is this surface's own. The border is
412
- drawn transparent rather than absent so the active inversion adds no width and
413
- shifts no glyph, and the ink is stated because the family deliberately declares
414
- none: its callers disagree, and a popover over content reads at the card's. */
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);