@quillmark/svelte 0.3.1 → 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.
@@ -8,11 +8,11 @@ export interface RangeAnchor {
8
8
  /**
9
9
  * A virtual anchor over one PM range, measured when asked rather than when made.
10
10
  *
11
- * The measure is total. `coordsAtPos` reads layout, and layout can be gone: a
12
- * position the document has since invalidated, a leaf inside a collapsed section, a
13
- * view torn down between a scroll firing and the measure running. `autoUpdate` calls
14
- * this at moments none of its callers choose, so an unmeasurable anchor returns the
15
- * last rect that measured rather than throwing out of floating-ui's positioning pass.
11
+ * The measure is total. It reads layout, and layout can be gone: a position the
12
+ * document has since invalidated, a leaf inside a collapsed section, a view torn down
13
+ * between a scroll firing and the measure running. `autoUpdate` calls this at moments
14
+ * none of its callers choose, so an unmeasurable anchor returns the last rect that
15
+ * measured rather than throwing out of floating-ui's positioning pass.
16
16
  *
17
17
  * `from === to` is a caret, which is what the slash menu hangs off.
18
18
  */
@@ -1,11 +1,42 @@
1
+ function union(a, b) {
2
+ return {
3
+ left: Math.min(a.left, b.left),
4
+ top: Math.min(a.top, b.top),
5
+ right: Math.max(a.right, b.right),
6
+ bottom: Math.max(a.bottom, b.bottom)
7
+ };
8
+ }
9
+ function caretBox(view, pos) {
10
+ const { left, top, right, bottom } = view.coordsAtPos(pos);
11
+ return { left, top, right, bottom };
12
+ }
13
+ /**
14
+ * The box the highlight paints, and not the two ends' caret boxes: a selection that
15
+ * wraps has its head and its anchor on opposite sides of the block, so their union
16
+ * names a rectangle no text is under.
17
+ *
18
+ * A range measuring to nothing falls back to that union. A leaf the view draws no text
19
+ * for has no painted box, and an approximate anchor beats one of zero.
20
+ */
21
+ function selectionBox(view, from, to) {
22
+ const range = document.createRange();
23
+ const start = view.domAtPos(from);
24
+ const end = view.domAtPos(to);
25
+ range.setStart(start.node, start.offset);
26
+ range.setEnd(end.node, end.offset);
27
+ const { left, top, right, bottom, width, height } = range.getBoundingClientRect();
28
+ if (width || height)
29
+ return { left, top, right, bottom };
30
+ return union(caretBox(view, from), caretBox(view, to));
31
+ }
1
32
  /**
2
33
  * A virtual anchor over one PM range, measured when asked rather than when made.
3
34
  *
4
- * The measure is total. `coordsAtPos` reads layout, and layout can be gone: a
5
- * position the document has since invalidated, a leaf inside a collapsed section, a
6
- * view torn down between a scroll firing and the measure running. `autoUpdate` calls
7
- * this at moments none of its callers choose, so an unmeasurable anchor returns the
8
- * last rect that measured rather than throwing out of floating-ui's positioning pass.
35
+ * The measure is total. It reads layout, and layout can be gone: a position the
36
+ * document has since invalidated, a leaf inside a collapsed section, a view torn down
37
+ * between a scroll firing and the measure running. `autoUpdate` calls this at moments
38
+ * none of its callers choose, so an unmeasurable anchor returns the last rect that
39
+ * measured rather than throwing out of floating-ui's positioning pass.
9
40
  *
10
41
  * `from === to` is a caret, which is what the slash menu hangs off.
11
42
  */
@@ -18,14 +49,7 @@ export function rangeAnchor(view, from, to) {
18
49
  contextElement: view.dom,
19
50
  getBoundingClientRect: () => {
20
51
  try {
21
- const a = view.coordsAtPos(from);
22
- const b = view.coordsAtPos(to);
23
- last = {
24
- left: Math.min(a.left, b.left),
25
- top: Math.min(a.top, b.top),
26
- right: Math.max(a.right, b.right),
27
- bottom: Math.max(a.bottom, b.bottom)
28
- };
52
+ last = from === to ? caretBox(view, from) : selectionBox(view, from, to);
29
53
  }
30
54
  catch {
31
55
  // unmeasurable: keep the last good rect
@@ -184,16 +184,20 @@
184
184
  draft (VISUAL_EDITOR §"Card operations"). A literal rather than a dial
185
185
  because there is nothing left to turn: the rungs under it mix over
186
186
  the surface and the ink a consumer already tuned, so the card follows a
187
- brand untold. The mixes are faint on purpose: a warmth, not a badge. */
187
+ brand untold. The mixes are faint on purpose: a warmth, not a badge.
188
+
189
+ The one group mixed in sRGB. oklab holds perceived lightness across a mix and
190
+ spends chroma to do it, which is right where both endpoints are grey and wrong
191
+ for a hue: at either pole it lands the ink a channel short of warm. */
188
192
  --_qm-tips: #e88c0a;
189
- --_qm-tips-surface: color-mix(in oklab, var(--_qm-surface), var(--_qm-tips) 4%);
190
- --_qm-tips-ink: color-mix(in oklab, var(--_qm-ink), var(--_qm-tips) 24%);
193
+ --_qm-tips-surface: color-mix(in srgb, var(--_qm-surface), var(--_qm-tips) 3%);
194
+ --_qm-tips-ink: color-mix(in srgb, var(--_qm-ink), var(--_qm-tips) 24%);
191
195
  /* The ink and the amber stepped up, and the pair is spent on the two runs that
192
196
  are not the sentence: a token in a tip, and the foot's control under the
193
197
  pointer. One rung each rather than one per run, a chip and a hover four
194
198
  percent apart being a number with no second decision under it. */
195
- --_qm-tips-ink-warm: color-mix(in oklab, var(--_qm-ink), var(--_qm-tips) 45%);
196
- --_qm-tips-fill: color-mix(in oklab, var(--_qm-tips) 12%, transparent);
199
+ --_qm-tips-ink-warm: color-mix(in srgb, var(--_qm-ink), var(--_qm-tips) 45%);
200
+ --_qm-tips-fill: color-mix(in srgb, var(--_qm-tips) 12%, transparent);
197
201
  /* The typewriter, the half of the slip the amber cannot carry: a modern mono
198
202
  reads as code where a Courier reads as typed. Its own rung rather than
199
203
  `--_qm-font-mono`, the chrome's readout face, which a consumer turns for
@@ -390,11 +390,12 @@
390
390
  {/snippet}
391
391
 
392
392
  <style>
393
- /* An island, and the tone is the whole of what makes it one: the card is the base
394
- plane and the column behind it is the sunken one (ARCHITECTURE §Styling). The inset
395
- is the same rung as the gap, so what separates the header from the fields is what
396
- 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. */
397
397
  .qm-card {
398
+ border: var(--_qm-border-width) solid var(--_qm-border);
398
399
  border-radius: var(--_qm-radius);
399
400
  padding: var(--_qm-space-3);
400
401
  background: var(--_qm-surface);
@@ -639,10 +640,9 @@
639
640
  flex-direction: column;
640
641
  gap: var(--_qm-space);
641
642
  }
642
- /* The one card that draws an edge at all: an ordinary card is a plane, so a stroke
643
- 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. */
644
644
  .qm-card.qm-unschemable {
645
- border: var(--_qm-border-width) dashed var(--_qm-border);
645
+ border-style: dashed;
646
646
  }
647
647
  /* One typographic role with the editable title above. */
648
648
  .qm-card-title-static {
@@ -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);
@@ -167,6 +167,11 @@
167
167
  .qm-prose-block:not(.qm-control-box) {
168
168
  font-size: var(--_qm-text-paper);
169
169
  }
170
+ /* Paper's margin, which a field has none of. On the editable, so a press in the strip
171
+ lands a caret; carried by the card's inset it would be dead width. */
172
+ .qm-prose-block:not(.qm-control-box) :global(.ProseMirror) {
173
+ padding-inline: var(--_qm-space-2);
174
+ }
170
175
  /* The caret is the body's focus indicator: a ring around the one surface in a card
171
176
  that is paper reads as form chrome. So the contenteditable's own outline is dropped
172
177
  everywhere, and a boxed leaf takes the shared ring on its wrapper instead
@@ -95,19 +95,25 @@
95
95
  /* Attached to `main`, not a block beside it: a square top pulled up by the radius,
96
96
  so its background fills the notches `main`'s rounded bottom corners cut and `main`
97
97
  paints over the rest. The two read as one block, which is what document-level
98
- guidance is: the main card's, not a card of its own. Still in-flow, no edge, no
99
- lift.
98
+ guidance is: the main card's, not a card of its own. Still in-flow, no lift.
99
+
100
+ It takes the card's hairline, and the tuck makes the two outlines one: the slip's top
101
+ stroke and the tops of its sides run under `main`, so what shows starts at the card's
102
+ bottom corners.
100
103
 
101
104
  The bottom corners take the inner rung where the card above takes the outer: a slip
102
105
  is cut crisper than the thing it is tucked under, at every setting of the dial.
103
106
 
104
107
  The insets are asymmetric so the ink is not: the top takes the tuck back, and the
105
108
  bottom gives up a rung to the foot's control, whose box stands on the tap floor
106
- rather than on its own one-line label. */
109
+ rather than on its own one-line label. The horizontal is the card's inset plus the
110
+ body's margin (ProseField), so the tip starts under the prose rather than under the
111
+ fields. */
107
112
  .qm-tips {
108
113
  margin-top: calc(var(--_qm-radius) * -1);
114
+ border: var(--_qm-border-width) solid var(--_qm-border);
109
115
  border-radius: 0 0 var(--_qm-radius-inner) var(--_qm-radius-inner);
110
- padding: calc(var(--_qm-space-3) + var(--_qm-radius)) var(--_qm-space-3) var(--_qm-space-2);
116
+ padding: calc(var(--_qm-space-3) + var(--_qm-radius)) var(--_qm-space-5) var(--_qm-space-2);
111
117
  background: var(--_qm-tips-surface);
112
118
  display: flex;
113
119
  flex-direction: column;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/svelte",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Editor, live-preview and source surfaces over a Quillmark WASM session.",
5
5
  "repository": {
6
6
  "type": "git",