@quillmark/svelte 0.21.0 → 0.23.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.
Files changed (69) hide show
  1. package/NOTICE +6 -7
  2. package/README.md +1 -1
  3. package/dist/core/address.d.ts +6 -1
  4. package/dist/core/address.js +12 -3
  5. package/dist/core/bloom.d.ts +9 -4
  6. package/dist/core/bloom.js +18 -49
  7. package/dist/core/codec/atoms.d.ts +1 -1
  8. package/dist/core/codec/atoms.js +1 -1
  9. package/dist/core/codec/blocks.js +4 -2
  10. package/dist/core/codec/cap.d.ts +21 -0
  11. package/dist/core/codec/cap.js +63 -0
  12. package/dist/core/codec/code.d.ts +1 -1
  13. package/dist/core/codec/code.js +1 -1
  14. package/dist/core/codec/decode.d.ts +5 -5
  15. package/dist/core/codec/decode.js +18 -12
  16. package/dist/core/codec/encode.js +6 -1
  17. package/dist/core/codec/field.d.ts +22 -10
  18. package/dist/core/codec/field.js +54 -18
  19. package/dist/core/codec/index.d.ts +3 -1
  20. package/dist/core/codec/index.js +3 -2
  21. package/dist/core/codec/inputrules.js +1 -1
  22. package/dist/core/codec/lists.d.ts +3 -2
  23. package/dist/core/codec/lists.js +21 -5
  24. package/dist/core/codec/nesting.d.ts +20 -0
  25. package/dist/core/codec/nesting.js +62 -0
  26. package/dist/core/codec/prose.css +74 -20
  27. package/dist/core/codec/schema.d.ts +2 -2
  28. package/dist/core/codec/schema.js +16 -7
  29. package/dist/core/codec/table-copy.d.ts +17 -0
  30. package/dist/core/codec/table-copy.js +84 -0
  31. package/dist/core/codec/table-view.d.ts +1 -7
  32. package/dist/core/codec/table-view.js +70 -119
  33. package/dist/core/codec/table.d.ts +2 -9
  34. package/dist/core/codec/table.js +10 -19
  35. package/dist/core/codec/tasks.d.ts +19 -0
  36. package/dist/core/codec/tasks.js +140 -0
  37. package/dist/core/errors.d.ts +2 -2
  38. package/dist/visual/ArrayField.svelte +103 -63
  39. package/dist/visual/ArrayField.svelte.d.ts +0 -2
  40. package/dist/visual/Card.svelte +9 -3
  41. package/dist/visual/Card.svelte.d.ts +2 -2
  42. package/dist/visual/Field.svelte +33 -17
  43. package/dist/visual/Field.svelte.d.ts +4 -2
  44. package/dist/visual/FieldLabel.svelte +19 -33
  45. package/dist/visual/FieldLabel.svelte.d.ts +4 -2
  46. package/dist/visual/FormatPopover.svelte +54 -73
  47. package/dist/visual/MatrixField.svelte +18 -12
  48. package/dist/visual/NumberField.svelte +32 -3
  49. package/dist/visual/ObjectField.svelte +50 -22
  50. package/dist/visual/ObjectField.svelte.d.ts +6 -0
  51. package/dist/visual/ProseField.svelte +20 -14
  52. package/dist/visual/ProseField.svelte.d.ts +8 -2
  53. package/dist/visual/ProseValue.svelte +30 -19
  54. package/dist/visual/ProseValue.svelte.d.ts +7 -4
  55. package/dist/visual/TextField.svelte +3 -2
  56. package/dist/visual/TipsCard.svelte +5 -2
  57. package/dist/visual/VariantField.svelte +1 -1
  58. package/dist/visual/VariantField.svelte.d.ts +1 -1
  59. package/dist/visual/VisualEditorInner.svelte +13 -13
  60. package/dist/visual/controls.css +19 -1
  61. package/dist/visual/icons/nodes.d.ts +0 -21
  62. package/dist/visual/icons/nodes.js +1 -7
  63. package/dist/visual/leaves.d.ts +12 -9
  64. package/dist/visual/leaves.js +19 -0
  65. package/dist/visual/strings.d.ts +16 -5
  66. package/dist/visual/strings.js +17 -9
  67. package/dist/visual/structure.d.ts +26 -32
  68. package/dist/visual/structure.js +71 -53
  69. package/package.json +3 -3
@@ -27,7 +27,7 @@
27
27
  ResolvedField
28
28
  } from '@quillmark/wasm';
29
29
  import type { EditorErrorHandler } from '../core/errors.js';
30
- import { seatValueLeaves, type LandingBox, type LeafRegistry } from './leaves.js';
30
+ import { seatValueLeaves, stepsTo, type LandingBox, type LeafRegistry } from './leaves.js';
31
31
  import type { FieldModel, FieldSpan } from './structure.js';
32
32
  import {
33
33
  VARIANT_DISCRIMINANT,
@@ -40,6 +40,7 @@
40
40
  printedText,
41
41
  renderedBoolean,
42
42
  stringifyGhost,
43
+ textCap,
43
44
  titleText
44
45
  } from './structure.js';
45
46
  import { wording } from './strings.js';
@@ -82,7 +83,9 @@
82
83
  optionAllowed?: (value: string) => boolean;
83
84
  /** How a refused option draws: greyed (default) or left out. */
84
85
  enumDisallowed?: 'hide' | 'disable';
85
- onFocus?: (addr: Addr) => void;
86
+ /** A focus inside the field: its address, and the steps down to the cell holding
87
+ * the focus ({@link stepsTo}), `[]` on the field's own control. */
88
+ onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
86
89
  onCaretMove?: (addr: Addr, pos: number) => void;
87
90
  onChange?: (addr: Addr) => void;
88
91
  onError?: EditorErrorHandler;
@@ -161,6 +164,14 @@
161
164
  // reference must vanish with it: `aria-describedby` pointing at nothing describes
162
165
  // nothing, and silently.
163
166
  const describedBy = $derived(field.description ? domIds.description : undefined);
167
+ // A capped prose leaf reports its count, and the count describes it too: a keystroke
168
+ // the cap refuses changes nothing else a reader can perceive.
169
+ let chars = $state<number>();
170
+ const cap = $derived(field.control === 'prose' ? textCap(field.schema) : undefined);
171
+ const countId = $derived(`${domIds.control}-count`);
172
+ const proseDescribedBy = $derived(
173
+ cap ? [describedBy, countId].filter(Boolean).join(' ') : describedBy
174
+ );
164
175
  // Two controls own their label row, the add chip and the held count sharing it.
165
176
  const ownsLabel = $derived(field.control === 'array' || field.control === 'matrix');
166
177
  const flat = $derived(flatObject(field.schema));
@@ -221,7 +232,7 @@
221
232
 
222
233
  /**
223
234
  * This field's landing handle. The wrapper is the bloom host rather than the
224
- * control: `bloomInside` appends an inset child and an `<input>` holds none. The
235
+ * control: the wash is a box's `::after`, which an `<input>` draws none of. The
225
236
  * label is outside it, and stays out of the wash: an arrival marks where the caret
226
237
  * landed, which is the control. A control that owns its label (`ownsLabel`: the array
227
238
  * and the matrix) holds that row inside itself, so it names its own box
@@ -251,7 +262,8 @@
251
262
  }
252
263
 
253
264
  // A by-value prose leaf anywhere inside this field's containers is reached under
254
- // this field's key: the active address names the field, never the element or cell.
265
+ // this field's key: the registry and the active address name the field, never the
266
+ // element or cell.
255
267
  seatValueLeaves({
256
268
  focus: (leaf) => leaves?.focusValue(leafKey, leaf),
257
269
  release: (leaf) => leaves?.releaseValue(leafKey, leaf)
@@ -261,11 +273,16 @@
261
273
  * A form control has no controller to report its focus through, so the wrapper
262
274
  * reports: `focusin` bubbles, so one handler covers a plain input, an array's N
263
275
  * elements and an object's properties alike, and the active leaf names a scalar
264
- * field the way it names a prose one. A prose leaf reports through its own
276
+ * field the way it names a prose one, down to the element or cell the focus is in. A prose leaf reports through its own
265
277
  * controller — the source that drives its caret signals too — and is excluded here
266
278
  * rather than counted twice.
267
279
  */
268
- const reportFocus = $derived(field.control === 'prose' ? undefined : () => onFocus?.(addr));
280
+ const reportFocus = $derived(
281
+ field.control === 'prose'
282
+ ? undefined
283
+ : (e: FocusEvent & { currentTarget: HTMLElement }) =>
284
+ onFocus?.(addr, stepsTo(e.target, e.currentTarget))
285
+ );
269
286
  </script>
270
287
 
271
288
  <div class={['qm-field', { cell: span === 'cell' }]}>
@@ -276,8 +293,9 @@
276
293
  id={domIds.label}
277
294
  descriptionId={domIds.description}
278
295
  {onActivate}
279
- required={field.required}
280
296
  description={field.description}
297
+ count={cap && chars != null ? t.strings.textCount(chars, cap.max) : undefined}
298
+ {countId}
281
299
  />
282
300
  {/if}
283
301
  <!-- The control and what hangs under it, in one grid cell: the diagnostics stack
@@ -285,7 +303,7 @@
285
303
  rule below holds why). One markup for both spans; a field owning its row nests a
286
304
  level and measures the same. -->
287
305
  <div class="qm-field-stack">
288
- <div class="qm-field-control" {@attach registerControl} onfocusin={reportFocus}>
306
+ <div class="qm-field-control qm-wash" {@attach registerControl} onfocusin={reportFocus}>
289
307
  {#if field.control === 'prose'}
290
308
  <ProseField
291
309
  {quill}
@@ -296,9 +314,10 @@
296
314
  plaintext={field.plaintext}
297
315
  fallback={printed ? (ghost as Content) : undefined}
298
316
  placeholder={none}
299
- placeholderUntilEdit
317
+ emptyUnsets={optional}
300
318
  labelledBy={domIds.label}
301
- {describedBy}
319
+ describedBy={proseDescribedBy}
320
+ cap={cap && { ...cap, onCount: (n) => (chars = n) }}
302
321
  {leafKey}
303
322
  {onFocus}
304
323
  {onCaretMove}
@@ -374,7 +393,6 @@
374
393
  layout={arrayLayout(field.schema)}
375
394
  max={field.schema.max}
376
395
  label={field.label}
377
- required={field.required}
378
396
  description={field.description}
379
397
  labelId={domIds.label}
380
398
  descriptionId={domIds.description}
@@ -389,6 +407,7 @@
389
407
  value={value as Record<string, unknown> | undefined}
390
408
  properties={field.schema.properties}
391
409
  {flat}
410
+ placed
392
411
  label={field.label}
393
412
  idBase={domIds.control}
394
413
  labelledBy={domIds.label}
@@ -476,13 +495,10 @@
476
495
  gap: var(--_qm-space);
477
496
  min-width: 0;
478
497
  }
479
- /* Positioned for the arrival wash a landing inserts (`core/bloom.ts`), the way
480
- `.qm-prose` is: an inset child over the control, since an `<input>` takes none.
481
- The radius is the control's own, so the wash's corners are the box's rather than
482
- square over a rounded one; on an array or a subform it bounds a group, where
483
- there is no single box for it to disagree with. */
498
+ /* The arrival wash's host (`.qm-wash`). The radius is the control's own, so the
499
+ wash's corners are the box's rather than square over a rounded one; on an array or
500
+ a subform it bounds a group, where there is no single box for it to disagree with. */
484
501
  .qm-field-control {
485
- position: relative;
486
502
  border-radius: var(--_qm-radius-inner);
487
503
  }
488
504
  </style>
@@ -1,4 +1,4 @@
1
- import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
1
+ import type { Document, Quill, Addr, Diagnostic, PathStep, ResolvedField } from '@quillmark/wasm';
2
2
  import type { EditorErrorHandler } from '../core/errors.js';
3
3
  import { type LeafRegistry } from './leaves.js';
4
4
  import type { FieldModel, FieldSpan } from './structure.js';
@@ -27,7 +27,9 @@ interface Props {
27
27
  optionAllowed?: (value: string) => boolean;
28
28
  /** How a refused option draws: greyed (default) or left out. */
29
29
  enumDisallowed?: 'hide' | 'disable';
30
- onFocus?: (addr: Addr) => void;
30
+ /** A focus inside the field: its address, and the steps down to the cell holding
31
+ * the focus ({@link stepsTo}), `[]` on the field's own control. */
32
+ onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
31
33
  onCaretMove?: (addr: Addr, pos: number) => void;
32
34
  onChange?: (addr: Addr) => void;
33
35
  onError?: EditorErrorHandler;
@@ -10,15 +10,8 @@
10
10
  hands the click to `onActivate`. Both halves are needed: `for` on a non-labelable
11
11
  element is inert and invalid.
12
12
 
13
- Two non-gating label decorations:
14
- • a persistent required `*` for a field the schema obliges — one that declares no
15
- `default:` (DOCUMENT_MODEL). Its
16
- accessible name is "required", so a screen reader announces the word, not the
17
- glyph; inside the `<label>`, so it names the control along with the text
18
- ("Subject required") rather than announcing as a node adjacent to it;
19
-
20
- • the `description` as an info marker raising a themed popover
21
- ({@link FieldHint}).
13
+ One non-gating label decoration: the `description` as an info marker raising a
14
+ themed popover ({@link FieldHint}).
22
15
 
23
16
  The description describes, it does not name. It is parked in an always-present
24
17
  visually-hidden node here and the control carries `aria-describedby`: name then
@@ -32,11 +25,6 @@
32
25
  control's label decorates the same way.
33
26
  -->
34
27
  <script lang="ts">
35
- import { wording } from './strings.js';
36
-
37
- // The surface's words, ambient from the editor root; the package's English
38
- // off-tree, so this component renders standalone too.
39
- const t = wording();
40
28
  import FieldHint from './FieldHint.svelte';
41
29
 
42
30
  interface Props {
@@ -50,12 +38,15 @@
50
38
  descriptionId?: string;
51
39
  /** Focus handoff for a control `for` cannot reach; a label click calls it. */
52
40
  onActivate?: () => void;
53
- /** No-default field → a persistent required `*`. */
54
- required?: boolean;
55
41
  /** Schema `description`, or undefined: the affordance renders only when set. */
56
42
  description?: string;
43
+ /** A capped field's character count, drawn at the row's end. */
44
+ count?: string;
45
+ /** The count's own id, for the control's `aria-describedby`. */
46
+ countId?: string;
57
47
  }
58
- let { label, controlId, id, descriptionId, onActivate, required, description }: Props = $props();
48
+ let { label, controlId, id, descriptionId, onActivate, description, count, countId }: Props =
49
+ $props();
59
50
  </script>
60
51
 
61
52
  <div class="qm-field-label-row">
@@ -71,9 +62,6 @@
71
62
  onclick={onActivate}
72
63
  >
73
64
  <span>{label}</span>
74
- {#if required}
75
- <span class="qm-field-required" aria-label={t.strings.fieldRequired}>*</span>
76
- {/if}
77
65
  </label>
78
66
  {#if description}
79
67
  <!-- Outside the `<label>`, both of them: a trigger inside it would put its own
@@ -85,16 +73,17 @@
85
73
  <FieldHint {description} {label} describedBy={descriptionId} />
86
74
  <span class="qm-visually-hidden" id={descriptionId}>{description}</span>
87
75
  {/if}
76
+ {#if count != null}
77
+ <span class="qm-field-count" id={countId}>{count}</span>
78
+ {/if}
88
79
  </div>
89
80
 
90
81
  <style>
91
82
  /* The row, not the label, is the field's grid child: `Field`'s subgrid counts
92
83
  two tracks and the hint sits beside the label rather than under it.
93
84
 
94
- A rung wider than the label's own gap below, so the two separations rank: the
95
- required glyph is part of the name and sits at the tighter one, the guidance
96
- marker is a second affordance beside it. The marker's box is its glyph
97
- (`FieldHint`), so this gap is the whole of what stands between them. */
85
+ The marker's box is its glyph (`FieldHint`), so this gap is the whole of what
86
+ stands between it and the label. */
98
87
  .qm-field-label-row {
99
88
  display: flex;
100
89
  align-items: center;
@@ -104,26 +93,23 @@
104
93
  .qm-field-label {
105
94
  display: inline-flex;
106
95
  align-items: center;
107
- gap: var(--_qm-space-half);
108
96
  font-size: var(--_qm-text-label);
109
97
  font-weight: var(--_qm-weight-mid);
110
98
  line-height: var(--_qm-leading-tight);
111
99
  color: var(--_qm-ink-label);
112
100
  min-width: 0;
113
101
  }
102
+ .qm-field-count {
103
+ margin-inline-start: auto;
104
+ font-size: var(--_qm-text-label);
105
+ color: var(--_qm-ink-label);
106
+ font-variant-numeric: tabular-nums;
107
+ }
114
108
  /* A label that lands focus says so. Only when it has somewhere to land: an
115
109
  `ObjectField` property's label names a subform, not one control. */
116
110
  .qm-field-label.targeted {
117
111
  cursor: pointer;
118
112
  }
119
- /* Required marker: the label's own ink, not an alarm; required-ness is guidance,
120
- the document still edits and renders unmet, and the danger hue is a diagnostic's. */
121
- .qm-field-required {
122
- display: inline-flex;
123
- align-items: center;
124
- color: var(--_qm-ink-label);
125
- line-height: 1;
126
- }
127
113
  /* The parked description: in the accessibility tree, out of the layout, and never
128
114
  a focus stop. Clipped rather than `display: none` / `hidden`, which take a node
129
115
  out of the tree entirely and would make the `aria-describedby` dangle. Out of
@@ -9,10 +9,12 @@ interface Props {
9
9
  descriptionId?: string;
10
10
  /** Focus handoff for a control `for` cannot reach; a label click calls it. */
11
11
  onActivate?: () => void;
12
- /** No-default field → a persistent required `*`. */
13
- required?: boolean;
14
12
  /** Schema `description`, or undefined: the affordance renders only when set. */
15
13
  description?: string;
14
+ /** A capped field's character count, drawn at the row's end. */
15
+ count?: string;
16
+ /** The count's own id, for the control's `aria-describedby`. */
17
+ countId?: string;
16
18
  }
17
19
  declare const FieldLabel: import("svelte").Component<Props, {}, "">;
18
20
  type FieldLabel = ReturnType<typeof FieldLabel>;
@@ -3,15 +3,16 @@
3
3
  shell-owned, observing the active leaf through
4
4
  `getActiveLeaf`: the VisualEditor's accessor over its `leaves` registry, and
5
5
  the whole of what this surface knows about the editor. The active leaf is a
6
- field's or a by-value one (an array's element, a subform's cell): its views either
7
- way, its controller only for a field's. A non-empty selection in the active leaf
6
+ field's or a by-value one (an array's element, a subform's cell), reached through
7
+ its views. A non-empty selection in the active leaf
8
8
  raises it over that leaf's selection rect; each button dispatches a PM `toggleMark`
9
9
  command straight at the leaf's `EditorView`, whose own `dispatchTransaction` commits
10
10
  the result as it commits a keystroke: a field's lowers it to `markOps` through
11
11
  `applyChange` (field.ts), a by-value leaf's hands the re-encoded value to its parent
12
12
  (`ProseValue`). This component never touches the content. The keymap mirror
13
- (Mod-b/i/u) already lives in the codec's keymap (field.ts); this is the pointer
14
- affordance for all six marks.
13
+ (Mod-b/i/u) lives in the codec's keymap (field.ts); this is the pointer
14
+ affordance for all six marks. Mod-K is this surface's own key, the prompt it raises
15
+ being this surface's: a document `keydown` the leaf's keymap left unclaimed.
15
16
 
16
17
  Selection observation. The view's `dispatchTransaction` is the leaf's
17
18
  (read-only), so there is no hook to observe transactions from outside.
@@ -64,6 +65,7 @@
64
65
  // off-tree, so this component renders standalone too.
65
66
  const t = wording();
66
67
  import { toggleMark } from 'prosemirror-commands';
68
+ import { keydownHandler } from 'prosemirror-keymap';
67
69
  import { TextSelection, type Command } from 'prosemirror-state';
68
70
  import type { EditorView } from 'prosemirror-view';
69
71
  import type { MarkType } from 'prosemirror-model';
@@ -83,9 +85,8 @@
83
85
  let { getActiveLeaf }: Props = $props();
84
86
 
85
87
  const SETTLE_MS = 450;
86
- /** The six content formatting marks. `anchor` is a seventh, drawn separately below:
87
- * a decoration toggle rather than a PM `toggleMark`. One string per mark carries both
88
- * the tooltip and the accessible name; the anchor button spells the two separately. */
88
+ /** The six content formatting marks. `anchor` is withheld: it draws nothing until
89
+ * comments do. One string per mark carries both the tooltip and the accessible name. */
89
90
  const MARKS: { name: string; icon: IconName; title: string }[] = $derived([
90
91
  { name: 'strong', icon: 'bold', title: t.strings.formatBold },
91
92
  { name: 'em', icon: 'italic', title: t.strings.formatEmphasis },
@@ -98,13 +99,14 @@
98
99
  let open = $state(false);
99
100
  /** A pointer is down outside this surface: the selection is still being made. */
100
101
  let pressed = false;
101
- /** Whether an anchor can be minted over the selection: in a field's own view, never
102
- * in a table cell or a by-value leaf (see `sync`). */
103
- let anchorAvailable = $state(true);
104
102
  let linkPromptOpen = $state(false);
105
103
  /** Whether the selection carried a link when the prompt was raised. */
106
104
  let linkPresent = $state(false);
107
105
  let linkValue = $state('');
106
+ /** The view the prompt was raised over. The prompt takes the focus, and a cell view
107
+ * without it is no longer the leaf's `focusedView`. */
108
+ let promptView: EditorView | undefined;
109
+ let promptInput = $state<HTMLInputElement | undefined>(undefined);
108
110
  let contentEl = $state<HTMLElement | undefined>(undefined);
109
111
  /** The root to portal into: `document.body` escapes the editor's subtree and
110
112
  * the consumer's dials with it, so a pane-scoped palette misses this surface.
@@ -119,7 +121,7 @@
119
121
  /** The view the caret is actually in: the leaf's own, or the nested cell view of a
120
122
  * table island (`codec/table-view.ts`). The six marks are the inline schema's
121
123
  * too, so a selection in a cell raises the same popover and toggles the same
122
- * commands; only `anchor` is withheld (below). */
124
+ * commands. */
123
125
  function activeLeafView(): EditorView | undefined {
124
126
  return getActiveLeaf()?.views.focusedView();
125
127
  }
@@ -133,18 +135,16 @@
133
135
  // returns early, so a surface frozen here still tracks the selection it covers.
134
136
  if (insidePopover) return;
135
137
  if (pressed) return;
136
- const leaf = getActiveLeaf();
137
- const view = leaf?.views.focusedView();
138
+ const view = activeLeafView();
138
139
  // A non-empty text selection. A node selection is non-empty too and has nothing
139
140
  // to format: it covers a leaf block (an island, a rule), which is exactly where
140
141
  // Escape out of a table cell lands.
141
142
  const selection = view?.state.selection;
142
143
  const formattable = selection instanceof TextSelection && !selection.empty;
143
- // …in a leaf that has formatting at all. A `plaintext` leaf declares no mark
144
- // types (`plaintextSchema`, `plainSchema`), so this surface withholds itself over
145
- // one entirely, the way it never rises over the text input a `string` field draws:
146
- // the value is literal text, and a mark on it is a value the boundary refuses to
147
- // coerce back.
144
+ // …in a leaf that has formatting at all. A `text` leaf declares no mark types
145
+ // (`plaintextSchema`, `plainSchema`), so this surface withholds itself over one
146
+ // entirely, the way it never rises over a form control: the value is literal
147
+ // text, and a mark on it is a value the boundary refuses to coerce back.
148
148
  if (
149
149
  !view ||
150
150
  !view.hasFocus() ||
@@ -163,13 +163,6 @@
163
163
  portalTarget = view.dom.closest<HTMLElement>('[data-qm-root]') ?? undefined;
164
164
  const { from, to } = view.state.selection;
165
165
  anchor = rangeAnchor(view, from, to);
166
- // An anchor is minted through a field's controller, which a by-value leaf has
167
- // none of: its anchors are dropped on the parent's value write. A selection inside
168
- // a table cell is not in the field's coordinate space at all: the field's position
169
- // map holds one `atom` run for the whole island, so there is no USV offset to mint
170
- // an anchor at. The button is withheld rather than disabled, because what it would
171
- // toggle does not exist there (CODEC §Islands).
172
- anchorAvailable = !!leaf?.controller && view === leaf.views.view;
173
166
  open = true;
174
167
  }
175
168
 
@@ -266,6 +259,7 @@
266
259
  if (name === 'link') {
267
260
  linkValue = hrefInSelection(view.state);
268
261
  linkPresent = linkValue.length > 0;
262
+ promptView = view;
269
263
  linkPromptOpen = true;
270
264
  return;
271
265
  }
@@ -274,43 +268,12 @@
274
268
  sync();
275
269
  }
276
270
 
277
- /**
278
- * A fresh anchor id: 128 random bits as hex, the id being opaque to the
279
- * boundary and needing only to be unique. `crypto.randomUUID` is
280
- * secure-context-only, and absent on a page served over plain `http` to
281
- * something other than localhost; `getRandomValues` is there either way.
282
- */
283
- function mintAnchorId(): string {
284
- return [...crypto.getRandomValues(new Uint8Array(16))]
285
- .map((b) => b.toString(16).padStart(2, '0'))
286
- .join('');
287
- }
288
-
289
- /**
290
- * Toggle an identity anchor over the selection: the `anchor`
291
- * button's answer to a formatting toggle. If the selection already covers
292
- * anchors, remove them; else insert one at its start with a freshly-minted
293
- * unique id (the caller-supplied, invariant id the 0.97 policy settles).
294
- * Zero-width and glyph-less (CODEC §Marks): the identity handle persists in
295
- * the content, its chrome awaiting comment-thread UX.
296
- */
297
- function toggleAnchor(): void {
298
- const leaf = getActiveLeaf()?.controller;
299
- const view = activeLeafView();
300
- if (!leaf || !view || view.state.selection.empty) return;
301
- const { from, to } = leaf.selectionRange();
302
- const covered = leaf.anchorsInRange(from, to);
303
- if (covered.length) covered.forEach((id) => leaf.removeAnchor(id));
304
- else leaf.insertAnchor(mintAnchorId(), from);
305
- view.focus();
306
- sync();
307
- }
308
-
309
271
  /** Every exit from the prompt: lower it, run `cmd` if there is one, and hand the
310
272
  * leaf back its focus and the surface its buttons. */
311
273
  function closePrompt(cmd?: Command): void {
312
274
  linkPromptOpen = false;
313
- const view = activeLeafView();
275
+ const view = promptView && !promptView.isDestroyed ? promptView : activeLeafView();
276
+ promptView = undefined;
314
277
  if (view) {
315
278
  cmd?.(view.state, view.dispatch);
316
279
  view.focus();
@@ -325,6 +288,30 @@
325
288
  closePrompt(setLink(normalizeHref(linkValue)));
326
289
  }
327
290
 
291
+ /** Mod-K is a press of the Link button: claimed only where the surface would stand
292
+ * over the selection, and a second press while the prompt is up hands it the focus. */
293
+ function raiseLink(view: EditorView): boolean {
294
+ const inside = !!contentEl?.contains(document.activeElement);
295
+ if (inside && linkPromptOpen) return true;
296
+ if (!inside && !view.hasFocus()) return false;
297
+ sync();
298
+ if (!open) return false;
299
+ const reraised = linkPromptOpen;
300
+ toggle('link');
301
+ if (reraised && promptInput) focusPrompt(promptInput);
302
+ return true;
303
+ }
304
+
305
+ const linkKey = keydownHandler({
306
+ 'Mod-k': (_state, _dispatch, view) => !!view && raiseLink(view)
307
+ });
308
+
309
+ function onKeyDown(e: KeyboardEvent): void {
310
+ if (e.defaultPrevented) return;
311
+ const view = activeLeafView();
312
+ if (view && linkKey(view, e)) e.preventDefault();
313
+ }
314
+
328
315
  function removeLink(): void {
329
316
  closePrompt(clearLink);
330
317
  }
@@ -338,6 +325,7 @@
338
325
  it does with the event. `pointercancel` is the release a gesture the browser takes
339
326
  over gets. -->
340
327
  <svelte:document
328
+ onkeydown={onKeyDown}
341
329
  onselectionchange={deferredSync}
342
330
  onfocusout={deferredSync}
343
331
  onpointerdowncapture={onPointerDown}
@@ -391,26 +379,29 @@
391
379
  >
392
380
  <input
393
381
  {@attach focusPrompt}
394
- class="qm-link-input"
382
+ bind:this={promptInput}
383
+ class="qm-link-input qm-focus-ring"
395
384
  type="text"
396
385
  placeholder={t.strings.linkPlaceholder}
397
386
  aria-label={t.strings.formatLink}
398
387
  bind:value={linkValue}
399
388
  />
400
- <button type="submit" class="qm-icon-btn qm-mark-btn">{t.strings.linkApply}</button>
389
+ <button type="submit" class="qm-icon-btn qm-mark-btn qm-focus-ring"
390
+ >{t.strings.linkApply}</button
391
+ >
401
392
  <!-- Only where there is a link to remove: an arm that does nothing is
402
393
  an arm to read past on every other visit to the prompt. -->
403
394
  {#if linkPresent}
404
395
  <button
405
396
  type="button"
406
- class="qm-icon-btn qm-mark-btn"
397
+ class="qm-icon-btn qm-mark-btn qm-focus-ring"
407
398
  onmousedown={keepFocus}
408
399
  onclick={removeLink}>{t.strings.linkRemove}</button
409
400
  >
410
401
  {/if}
411
402
  <button
412
403
  type="button"
413
- class="qm-icon-btn qm-mark-btn"
404
+ class="qm-icon-btn qm-mark-btn qm-focus-ring"
414
405
  onmousedown={keepFocus}
415
406
  onclick={cancelLink}>{t.strings.linkCancel}</button
416
407
  >
@@ -423,23 +414,13 @@
423
414
  {#each MARKS as m (m.name)}
424
415
  <button
425
416
  type="button"
426
- class="qm-icon-btn qm-mark-btn"
417
+ class="qm-icon-btn qm-mark-btn qm-focus-ring"
427
418
  title={m.title}
428
419
  aria-label={m.title}
429
420
  onmousedown={keepFocus}
430
421
  onclick={() => toggle(m.name)}><Icon name={m.icon} /></button
431
422
  >
432
423
  {/each}
433
- {#if anchorAvailable}
434
- <button
435
- type="button"
436
- class="qm-icon-btn qm-mark-btn"
437
- title={t.strings.formatAnchorTitle}
438
- aria-label={t.strings.formatAnchor}
439
- onmousedown={keepFocus}
440
- onclick={toggleAnchor}><Icon name="hash" /></button
441
- >
442
- {/if}
443
424
  </div>
444
425
  {/if}
445
426
  </div>
@@ -53,7 +53,7 @@
53
53
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
54
54
  import type { LandingBox } from './leaves.js';
55
55
  import { propertyDomIds } from './domid.js';
56
- import { bloomInside } from '../core/bloom.js';
56
+ import { bloom } from '../core/bloom.js';
57
57
  import Icon from './icons/Icon.svelte';
58
58
  import FieldLabel from './FieldLabel.svelte';
59
59
  import ObjectField from './ObjectField.svelte';
@@ -215,7 +215,7 @@
215
215
  // the box stays where the next item is typed.
216
216
  const first = Object.keys(schema.properties ?? {})[0];
217
217
  if (first !== undefined) ref(colEls, id)?.focusPath([first]);
218
- if (box) bloomInside(box);
218
+ if (box) bloom(box);
219
219
  }
220
220
 
221
221
  /** Take the caret: the first item's control, else the add box. */
@@ -276,13 +276,17 @@
276
276
  </span>
277
277
  </div>
278
278
  <div
279
- class={['qm-matrix-roster', { 'qm-tracks': compact, compact }]}
279
+ class={['qm-matrix-roster qm-wash', { 'qm-tracks': compact, compact }]}
280
280
  data-default={defaulted ? '' : undefined}
281
281
  bind:this={rosterEl}
282
282
  >
283
283
  {#each members as m (m.id)}
284
284
  {@const on = held(m.id)}
285
- <div class={['qm-member', { held: on }]} bind:this={memberEls[m.id]}>
285
+ <div
286
+ class={['qm-member qm-wash', { held: on }]}
287
+ data-qm-prop={m.id}
288
+ bind:this={memberEls[m.id]}
289
+ >
286
290
  <div class="qm-member-head">
287
291
  <!-- A real checkbox with its face drawn here: the UA's face is shadow DOM no
288
292
  dial reaches, so the input is `appearance: none` and the box and the
@@ -306,7 +310,11 @@
306
310
  {/each}
307
311
  {#each added as item, k (item.id)}
308
312
  {@const name = item.title || t.strings.matrixItemTitle(label ?? '', k + 1)}
309
- <div class="qm-member held added" bind:this={memberEls[item.id]}>
313
+ <div
314
+ class="qm-member qm-wash held added"
315
+ data-qm-prop={item.id}
316
+ bind:this={memberEls[item.id]}
317
+ >
310
318
  <div class="qm-member-head">
311
319
  <!-- Held by being present: the tick is fixed, and the remove is its untick. -->
312
320
  <span class="qm-tick" aria-hidden="true">
@@ -317,6 +325,7 @@
317
325
  class="qm-input qm-focus-ring qm-member-title-input"
318
326
  type="text"
319
327
  id={itemTitleId(item.id)}
328
+ data-qm-prop={MATRIX_TITLE}
320
329
  aria-label={t.strings.matrixItemTitle(label ?? '', k + 1)}
321
330
  value={item.title}
322
331
  bind:this={titleEls[item.id]}
@@ -391,11 +400,9 @@
391
400
  color: var(--_qm-ink-label);
392
401
  font-variant-numeric: tabular-nums;
393
402
  }
394
- /* The roster, one member to a row in declaration order. Positioned for the wash's
395
- inset child (`washBox`), and rounded to the rung a row's box draws, as an array's
396
- rows are. */
403
+ /* The roster, one member to a row in declaration order, rounded for the wash
404
+ (`washBox`) to the rung a row's box draws, as an array's rows are. */
397
405
  .qm-matrix-roster {
398
- position: relative;
399
406
  display: flex;
400
407
  flex-direction: column;
401
408
  gap: var(--_qm-space);
@@ -411,10 +418,9 @@
411
418
  align-items: start;
412
419
  column-gap: var(--_qm-space-2);
413
420
  }
414
- /* A member is a row: positioned for the wash a landing on it blooms, rounded to the
415
- rung the rows around it draw. */
421
+ /* A member is a row, rounded for the wash a landing on it blooms to the rung the
422
+ rows around it draw. */
416
423
  .qm-member {
417
- position: relative;
418
424
  display: flex;
419
425
  flex-direction: column;
420
426
  gap: var(--_qm-space);