@quillmark/svelte 0.2.0 → 0.3.1

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 (119) hide show
  1. package/NOTICE +6 -6
  2. package/README.md +3 -3
  3. package/THEMING.md +12 -8
  4. package/dist/core/address.d.ts +7 -7
  5. package/dist/core/address.js +6 -6
  6. package/dist/core/bloom.d.ts +3 -32
  7. package/dist/core/bloom.js +19 -51
  8. package/dist/core/codec/anchor.d.ts +1 -1
  9. package/dist/core/codec/anchor.js +1 -1
  10. package/dist/core/codec/atoms.d.ts +10 -0
  11. package/dist/core/codec/atoms.js +78 -0
  12. package/dist/core/codec/clipboard.d.ts +59 -0
  13. package/dist/core/codec/clipboard.js +202 -0
  14. package/dist/core/codec/code.d.ts +1 -1
  15. package/dist/core/codec/code.js +4 -4
  16. package/dist/core/codec/decode.d.ts +1 -1
  17. package/dist/core/codec/decode.js +8 -8
  18. package/dist/core/codec/encode.d.ts +4 -4
  19. package/dist/core/codec/encode.js +10 -10
  20. package/dist/core/codec/field.d.ts +21 -26
  21. package/dist/core/codec/field.js +33 -32
  22. package/dist/core/codec/index.js +5 -8
  23. package/dist/core/codec/inputrules.js +56 -18
  24. package/dist/core/codec/islands.d.ts +11 -7
  25. package/dist/core/codec/islands.js +16 -10
  26. package/dist/core/codec/keymap.d.ts +5 -6
  27. package/dist/core/codec/keymap.js +8 -7
  28. package/dist/core/codec/lists.d.ts +3 -3
  29. package/dist/core/codec/lists.js +32 -13
  30. package/dist/core/codec/marks.d.ts +1 -1
  31. package/dist/core/codec/marks.js +1 -1
  32. package/dist/core/codec/positions.js +1 -1
  33. package/dist/core/codec/prose.css +235 -221
  34. package/dist/core/codec/schema.js +4 -4
  35. package/dist/core/codec/slash.d.ts +39 -41
  36. package/dist/core/codec/slash.js +0 -0
  37. package/dist/core/codec/table-view.d.ts +12 -16
  38. package/dist/core/codec/table-view.js +488 -292
  39. package/dist/core/codec/table.d.ts +42 -15
  40. package/dist/core/codec/table.js +70 -26
  41. package/dist/core/errors.d.ts +5 -5
  42. package/dist/core/index.js +2 -2
  43. package/dist/core/lifecycle.js +1 -1
  44. package/dist/core/rebind.svelte.d.ts +2 -2
  45. package/dist/core/rebind.svelte.js +4 -4
  46. package/dist/core/teardown.d.ts +1 -1
  47. package/dist/core/teardown.js +1 -1
  48. package/dist/core/theme.css +170 -64
  49. package/dist/preset/index.css +1 -1
  50. package/dist/preset/recipes.css +31 -29
  51. package/dist/preset/scale.css +37 -26
  52. package/dist/preview/Preview.svelte +17 -27
  53. package/dist/preview/Preview.svelte.d.ts +4 -5
  54. package/dist/preview/bridge.js +6 -6
  55. package/dist/preview/controller.d.ts +8 -10
  56. package/dist/preview/controller.js +19 -44
  57. package/dist/preview/geometry.d.ts +8 -10
  58. package/dist/preview/geometry.js +8 -10
  59. package/dist/preview/index.js +4 -4
  60. package/dist/preview/paint.d.ts +1 -1
  61. package/dist/preview/paint.js +17 -19
  62. package/dist/visual/ArrayField.svelte +80 -66
  63. package/dist/visual/ArrayField.svelte.d.ts +1 -1
  64. package/dist/visual/BooleanField.svelte +8 -6
  65. package/dist/visual/BooleanField.svelte.d.ts +2 -2
  66. package/dist/visual/Card.svelte +160 -329
  67. package/dist/visual/Card.svelte.d.ts +2 -7
  68. package/dist/visual/CardControls.svelte +1 -1
  69. package/dist/visual/DateField.svelte +19 -19
  70. package/dist/visual/DateField.svelte.d.ts +3 -3
  71. package/dist/visual/DiagnosticList.svelte +1 -1
  72. package/dist/visual/EnumField.svelte +16 -16
  73. package/dist/visual/EnumField.svelte.d.ts +2 -2
  74. package/dist/visual/Field.svelte +128 -115
  75. package/dist/visual/Field.svelte.d.ts +1 -1
  76. package/dist/visual/FieldHint.svelte +24 -42
  77. package/dist/visual/FieldHint.svelte.d.ts +1 -1
  78. package/dist/visual/FieldLabel.svelte +8 -8
  79. package/dist/visual/FormatPopover.svelte +80 -35
  80. package/dist/visual/NumberField.svelte +5 -5
  81. package/dist/visual/NumberField.svelte.d.ts +1 -1
  82. package/dist/visual/ObjectField.svelte +9 -9
  83. package/dist/visual/ObjectField.svelte.d.ts +1 -1
  84. package/dist/visual/ProseArrayElement.svelte +11 -13
  85. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -1
  86. package/dist/visual/ProseField.svelte +53 -87
  87. package/dist/visual/ProseField.svelte.d.ts +13 -19
  88. package/dist/visual/SlashMenu.svelte +42 -16
  89. package/dist/visual/TextField.svelte +5 -5
  90. package/dist/visual/TextField.svelte.d.ts +1 -1
  91. package/dist/visual/TipsCard.svelte +68 -56
  92. package/dist/visual/VisualEditor.svelte +6 -6
  93. package/dist/visual/VisualEditorInner.svelte +203 -290
  94. package/dist/visual/controls.css +74 -30
  95. package/dist/visual/diagnostics.d.ts +5 -5
  96. package/dist/visual/diagnostics.js +3 -3
  97. package/dist/visual/domid.js +3 -3
  98. package/dist/visual/ext.d.ts +1 -1
  99. package/dist/visual/ext.js +1 -1
  100. package/dist/visual/icons/nodes.d.ts +19 -0
  101. package/dist/visual/icons/nodes.js +13 -1
  102. package/dist/visual/index.js +4 -4
  103. package/dist/visual/leaves.d.ts +3 -3
  104. package/dist/visual/leaves.js +1 -1
  105. package/dist/visual/links.d.ts +24 -0
  106. package/dist/visual/links.js +67 -0
  107. package/dist/visual/props.d.ts +8 -8
  108. package/dist/visual/signals.d.ts +6 -6
  109. package/dist/visual/strings.d.ts +14 -19
  110. package/dist/visual/strings.js +5 -7
  111. package/dist/visual/structure.d.ts +24 -17
  112. package/dist/visual/structure.js +8 -8
  113. package/dist/visual/synced.svelte.d.ts +1 -1
  114. package/dist/visual/synced.svelte.js +4 -4
  115. package/dist/visual/tips.d.ts +2 -2
  116. package/dist/visual/tips.js +3 -3
  117. package/package.json +2 -2
  118. package/dist/preview/overlay.d.ts +0 -11
  119. package/dist/preview/overlay.js +0 -97
@@ -1,13 +1,13 @@
1
1
  <!--
2
- The schema `description` as a themed floating surface: the second that is READ
2
+ The schema `description` as a themed floating surface: the second that is read
3
3
  rather than picked from, so it shares {@link FormatPopover}'s translucent recipe
4
4
  and portals into the nearest `[data-qm-root]` like every
5
5
  other floating surface, so a consumer's dials still reach it.
6
6
 
7
- It replaces the native `title` tooltip, which reached no dial, waited ~1s, could
8
- not be dismissed, and (the part that made it a hole rather than a blemish)
9
- DOES NOT EXIST ON TOUCH. Guidance the reference quill puts on nearly every field
10
- was, on a tablet, reachable only through a screen reader.
7
+ Not the native `title` tooltip: that reaches no dial, waits ~1s, cannot be
8
+ dismissed, and (the part that makes it a hole rather than a blemish) does not exist
9
+ on touch, which leaves guidance the reference quill puts on nearly every field
10
+ reachable on a tablet only through a screen reader.
11
11
 
12
12
  Floating rather than inline under the label: the reference quill's descriptions run
13
13
  to a median of ~110 characters and past 250 at the tail: permanently in flow that
@@ -15,14 +15,14 @@
15
15
  to avoid. Measured, not assumed; a quill whose descriptions are all one short line
16
16
  would be better served inline.
17
17
 
18
- THE POPOVER IS CHROME, NOT THE ANNOUNCEMENT. Its content is `aria-hidden`: the
18
+ The popover is chrome, not the announcement. Its content is `aria-hidden`: the
19
19
  description reaches assistive tech through the always-present parked node
20
20
  {@link FieldLabel} renders and the `aria-describedby` the control carries, which
21
21
  holds whether the surface is open or not. So no reader depends on a hover.
22
22
 
23
23
  Three ways in, one per input modality: hover (pointer), focus (keyboard), tap
24
24
  (touch); wired here rather than taken from `Tooltip`, whose trigger returns early
25
- on `pointerType === 'touch'` and closes on click (the exact gap being fixed).
25
+ on `pointerType === 'touch'` and closes on click.
26
26
  -->
27
27
  <script lang="ts">
28
28
  import { Popover } from 'bits-ui';
@@ -35,14 +35,14 @@
35
35
  * do not all announce alike. */
36
36
  label: string;
37
37
  /** The parked description node; the trigger points at it too, so a reader
38
- * that lands HERE announces the guidance rather than only the field's own. */
38
+ * that lands here announces the guidance rather than only the field's own. */
39
39
  describedBy?: string;
40
40
  }
41
41
  let { description, label, describedBy }: Props = $props();
42
42
 
43
43
  let open = $state(false);
44
44
  let triggerEl = $state<HTMLButtonElement | undefined>(undefined);
45
- /** The root to portal INTO: `document.body` would escape the consumer's dials
45
+ /** The root to portal into: `document.body` would escape the consumer's dials
46
46
  * along with the editor's subtree. `undefined` falls back to bits-ui's default. */
47
47
  const portalTarget = $derived(triggerEl?.closest<HTMLElement>('[data-qm-root]') ?? undefined);
48
48
 
@@ -51,7 +51,7 @@
51
51
  let beside = $state(false);
52
52
 
53
53
  /** Whether this description sets one line. Off a probe rather than the surface
54
- * itself, since the side is a prop the surface is positioned FROM, and a side chosen
54
+ * itself, since the side is a prop the surface is positioned from, and a side chosen
55
55
  * after the fact moves a surface the reader is already looking at. The probe wears
56
56
  * the surface's own class inside the portal target, so it wraps to the same measure
57
57
  * under the same dials; `width: max-content` keeps that measure the class's rather
@@ -80,15 +80,15 @@
80
80
  }
81
81
  </script>
82
82
 
83
- <!-- The trigger is OURS, not `Popover.Trigger`, and the surface anchors to it:
83
+ <!-- The trigger is ours, not `Popover.Trigger`, and the surface anchors to it:
84
84
  the same shape {@link FormatPopover} uses. The primitive's trigger owns a click
85
- TOGGLE, which fights every one of the three openings below: a mouse click after
85
+ toggle, which fights every one of the three openings below: a mouse click after
86
86
  a hover-open would close, and on touch the focus that precedes the click opens
87
87
  it just in time for the click to close it again. -->
88
88
  <button
89
89
  bind:this={triggerEl}
90
90
  type="button"
91
- class="qm-field-hint qm-icon-btn qm-focus-ring"
91
+ class="qm-field-hint qm-icon-btn qm-focus-ring qm-tap-floor"
92
92
  aria-label="{label} guidance"
93
93
  aria-expanded={open}
94
94
  aria-describedby={describedBy}
@@ -99,7 +99,7 @@
99
99
  if (e.pointerType !== 'touch') open = false;
100
100
  }}
101
101
  onpointerup={(e) => {
102
- // Touch only, and a TOGGLE: the one modality with no hover to leave and no
102
+ // Touch only, and a toggle: the one modality with no hover to leave and no
103
103
  // blur on the way out. A mouse press falls through to hover, which already
104
104
  // has it open and will close it on the way off.
105
105
  if (e.pointerType === 'touch') {
@@ -116,18 +116,18 @@
116
116
  onblur={() => (open = false)}
117
117
  onkeydown={(e) => {
118
118
  if (e.key === 'Escape' && open) {
119
- // Dismissable, which the `title` tooltip never was. Stopped here so the
120
- // key does not travel on to a card- or editor-level Escape.
119
+ // Stopped here so the key does not travel on to a card- or editor-level
120
+ // Escape.
121
121
  e.stopPropagation();
122
122
  open = false;
123
123
  }
124
124
  }}><Icon name="info" size={13} /></button
125
125
  >
126
126
 
127
- <!-- PLACEMENT IS THE DESCRIPTION'S HEIGHT. One line rides the label rung beside its own
127
+ <!-- Placement is the description'S height. One line rides the label rung beside its own
128
128
  glyph, where the overhang past the rung's line box falls in the label-to-control gaps.
129
- Anything taller stands over the field: beside the glyph it would hang onto THE CONTROL
130
- IT DESCRIBES, the one overlap a reader cannot clear by reaching for what is hidden,
129
+ Anything taller stands over the field: beside the glyph it would hang onto the control
130
+ it describes, the one overlap a reader cannot clear by reaching for what is hidden,
131
131
  since a pointer leaving the trigger closes the surface. Over the field it covers a
132
132
  neighbour's control instead, which reaching for it dismisses. -->
133
133
  <!-- `align="center"` for the rung, `"start"` over the field: centred, a surface taller
@@ -173,18 +173,15 @@
173
173
  <style>
174
174
  /* Chrome, type and ring are the glyph-button family's (`.qm-icon-btn` +
175
175
  `.qm-focus-ring`, controls.css): this trigger is one more glyph button and
176
- assembles none of that itself. What it does NOT take is the family's tap floor
176
+ assembles none of that itself. What it does not take is the family's tap floor
177
177
  and the fill that floor sizes, because this is the one glyph button that sits in
178
- a LINE OF TEXT rather than in a row of its own: the floor is half again the label
178
+ a line of text rather than in a row of its own: the floor is half again the label
179
179
  rung's line box, so a target-sized paint box overhangs the line at each end by
180
180
  most of the field's label-to-control gap: the hover fill landing on the input's
181
181
  top border, the focus ring crossing it.
182
182
 
183
- So the box is the GLYPH and the target is the `::after` below. A target is a
184
- region of the screen, not a paint box, and separating them is what lets both be
185
- correct at once: the row keeps the line box's height and the press keeps the
186
- floor. Every other glyph button has a row to itself and keeps the floor as its
187
- box.
183
+ So the box is the glyph and the target is `.qm-tap-floor`'s (controls.css), which
184
+ carries that split and the giving-up of the family floor together.
188
185
 
189
186
  It takes no fill either: the ink step below says hover, and the surface itself
190
187
  opens on it. */
@@ -193,30 +190,15 @@
193
190
  color: var(--_qm-ink-ghost);
194
191
  /* Not the family's `pointer`: the glyph raises guidance, it does not act. */
195
192
  cursor: help;
196
- position: relative;
197
- min-width: 0;
198
- min-height: 0;
199
193
  padding: 0;
200
194
  background: none;
201
195
  }
202
- /* The tap floor, out of flow and centred on the glyph: WCAG 2.5.8 measures the
203
- target, and this is it. Unpainted, so it enlarges the press and not the mark, and
204
- out of the accessibility tree by construction: a pseudo-element has no node. */
205
- .qm-field-hint::after {
206
- content: '';
207
- position: absolute;
208
- top: 50%;
209
- left: 50%;
210
- width: var(--_qm-tap-min);
211
- height: var(--_qm-tap-min);
212
- transform: translate(-50%, -50%);
213
- }
214
196
  .qm-field-hint:hover,
215
197
  .qm-field-hint:focus-visible {
216
198
  color: var(--_qm-ink-label);
217
199
  }
218
200
  /* The lift, the translucency, the blur and the scale-in come from
219
- `.qm-popover-surface` (controls.css). This surface's own is a MEASURE: guidance
201
+ `.qm-popover-surface` (controls.css). This surface's own is a measure: guidance
220
202
  is prose, and prose past ~40 characters a line stops being scannable; plus the
221
203
  inset and the meta type rung that keep it a note about a field rather than a
222
204
  second field.
@@ -5,7 +5,7 @@ interface Props {
5
5
  * do not all announce alike. */
6
6
  label: string;
7
7
  /** The parked description node; the trigger points at it too, so a reader
8
- * that lands HERE announces the guidance rather than only the field's own. */
8
+ * that lands here announces the guidance rather than only the field's own. */
9
9
  describedBy?: string;
10
10
  }
11
11
  declare const FieldHint: import("svelte").Component<Props, {}, "">;
@@ -1,26 +1,26 @@
1
1
  <!--
2
2
  A field's label plus its guidance chrome.
3
3
 
4
- IT NAMES THE CONTROL. A real `<label>`, so a click lands the caret in the field:
4
+ It names the control. A real `<label>`, so a click lands the caret in the field:
5
5
  the widest, easiest target in a dense row, and the most conventional affordance a
6
- form has. `for` reaches a LABELABLE control (the inputs, the enum trigger, the
6
+ form has. `for` reaches a labelable control (the inputs, the enum trigger, the
7
7
  switch) and the browser does the rest; a control `for` cannot reach (the prose
8
8
  leaf's `contenteditable`, the date field's segments, an array's N inputs) leaves
9
9
  `controlId` unset, points `aria-labelledby` back at this label's own `id`, and
10
10
  hands the click to `onActivate`. Both halves are needed: `for` on a non-labelable
11
- element is inert AND invalid.
11
+ element is inert and invalid.
12
12
 
13
13
  Two non-gating label decorations:
14
14
  • a persistent required `*` when the field has no `default:`: the
15
15
  "Unendorsed"/must_fill set (DOCUMENT_MODEL: no separate `required` axis). Its
16
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
17
+ glyph; inside the `<label>`, so it names the control along with the text
18
18
  ("Subject required") rather than announcing as a node adjacent to it;
19
19
 
20
20
  • the `description` as an info marker raising a themed popover
21
21
  ({@link FieldHint}).
22
22
 
23
- THE DESCRIPTION DESCRIBES, IT DOES NOT NAME. It is parked in an always-present
23
+ The description describes, it does not name. It is parked in an always-present
24
24
  visually-hidden node here and the control carries `aria-describedby`: name then
25
25
  role then description, which is the announcement the markup visually implies. In
26
26
  the name it would instead prepend ~110 characters of guidance (the reference
@@ -77,7 +77,7 @@
77
77
  {/if}
78
78
  </label>
79
79
  {#if description}
80
- <!-- OUTSIDE the `<label>`, both of them: a trigger inside it would put its own
80
+ <!-- Outside the `<label>`, both of them: a trigger inside it would put its own
81
81
  name in the control's, and the parked copy would put all ~110 characters
82
82
  there. A `<button>` in a label is at least safe from the other hazard:
83
83
  the spec makes a label's activation behaviour do nothing for a click
@@ -90,7 +90,7 @@
90
90
 
91
91
  <style>
92
92
  /* The row, not the label, is the field's grid child: `Field`'s subgrid counts
93
- three tracks and the hint sits beside the label rather than under it.
93
+ two tracks and the hint sits beside the label rather than under it.
94
94
 
95
95
  A rung wider than the label's own gap below, so the two separations rank: the
96
96
  required glyph is part of the name and sits at the tighter one, the guidance
@@ -107,7 +107,7 @@
107
107
  align-items: center;
108
108
  gap: var(--_qm-space-half);
109
109
  font-size: var(--_qm-text-label);
110
- font-weight: var(--_qm-weight-label);
110
+ font-weight: var(--_qm-weight-mid);
111
111
  line-height: var(--_qm-leading-tight);
112
112
  color: var(--_qm-ink-label);
113
113
  min-width: 0;
@@ -1,6 +1,6 @@
1
1
  <!--
2
- The formatting selection popover (VISUAL_EDITOR §Chrome). ONE popover,
3
- shell-owned, observing the ACTIVE leaf through
2
+ The formatting selection popover (VISUAL_EDITOR §Chrome). One popover,
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. A non-empty selection in
6
6
  the active leaf raises it over
@@ -11,20 +11,20 @@
11
11
  content. The keymap mirror (Mod-b/i/u) already lives in the codec's keymap
12
12
  (field.ts); this is the pointer affordance for all six marks.
13
13
 
14
- SELECTION OBSERVATION. The view's `dispatchTransaction` is the codec's
14
+ Selection observation. The view's `dispatchTransaction` is the codec's
15
15
  (read-only), so there is no hook to observe transactions from outside.
16
16
  Fallback: a document-level `selectionchange` listener, coalesced to one
17
17
  `requestAnimationFrame`-deferred check (see `deferredSync` below: a bare
18
- microtask is NOT late enough; verified a "select all" gesture fires several
18
+ microtask is not late enough; verified a "select all" gesture fires several
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
22
+ Nothing here watches scroll. `sync` decides whether the surface is up and what
23
23
  its buttons read, which a scroll changes neither of; where it sits is the
24
24
  anchor's own question, and the anchor answers it by measuring when floating-ui
25
25
  asks rather than by being handed a rect (`codec/anchor.ts`).
26
26
 
27
- FOCUS DISCIPLINE. Three failure modes a naive Popover.Content invites:
27
+ Focus discipline. Three failure modes a naive Popover.Content invites:
28
28
  (1) its default `trapFocus` (true) redirects any focus landing outside its
29
29
  DOM back inside; which would make it impossible to click back into
30
30
  the editor to change the selection while the popover is showing. Fixed
@@ -41,7 +41,7 @@
41
41
  the viewport off the clicked field. Fixed by `onCloseAutoFocus`
42
42
  calling `preventDefault`: the intentional returns after mark toggle
43
43
  / link submit go through `view.focus` in `toggle`/`submitLink`.
44
- A mark button ALSO swallows its own `mousedown` (prosemirror-menu's own
44
+ A mark button also swallows its own `mousedown` (prosemirror-menu's own
45
45
  trick): without it, the browser's default mousedown action focuses the
46
46
  button before `click` fires, blurring the editor and collapsing the
47
47
  selection the command is supposed to act on.
@@ -53,7 +53,7 @@
53
53
  // off-tree, so this component renders standalone too.
54
54
  const t = wording();
55
55
  import { toggleMark } from 'prosemirror-commands';
56
- import { TextSelection } from 'prosemirror-state';
56
+ import { TextSelection, type Command } from 'prosemirror-state';
57
57
  import type { EditorView } from 'prosemirror-view';
58
58
  import type { MarkType } from 'prosemirror-model';
59
59
  import { Popover } from 'bits-ui';
@@ -65,6 +65,7 @@
65
65
  type LeafViews,
66
66
  type RangeAnchor
67
67
  } from '../core/codec/index.js';
68
+ import { clearLink, hrefInSelection, normalizeHref, setLink } from './links.js';
68
69
  import './controls.css';
69
70
 
70
71
  type LeafWithView = FieldController & Partial<LeafViews>;
@@ -77,7 +78,9 @@
77
78
 
78
79
  /** Mark size: the shared control-glyph rule for the popover icons. */
79
80
  const GLYPH = 15;
80
- /** The six content formatting marks; `anchor` is a 7th, rendered separately (a decoration toggle, not a PM `toggleMark` (see the button below). Each carries a glyph) the icon *is* the label. */
81
+ /** The six content formatting marks. `anchor` is a seventh, drawn separately below:
82
+ * a decoration toggle rather than a PM `toggleMark`. One string per mark carries both
83
+ * the tooltip and the accessible name; the anchor button spells the two separately. */
81
84
  const MARKS: { name: string; icon: IconName; title: string }[] = $derived([
82
85
  { name: 'strong', icon: 'bold', title: t.strings.formatBold },
83
86
  { name: 'em', icon: 'italic', title: t.strings.formatEmphasis },
@@ -89,23 +92,24 @@
89
92
 
90
93
  let open = $state(false);
91
94
  let activeMarks = $state<Record<string, boolean>>({});
92
- /** Whether the selection is in the FIELD's coordinate space, which an anchor needs
95
+ /** Whether the selection is in the field's coordinate space, which an anchor needs
93
96
  * and a table cell is not (see `sync`). */
94
97
  let anchorAvailable = $state(true);
95
98
  let linkPromptOpen = $state(false);
96
99
  let linkValue = $state('');
100
+ let linkInputEl = $state<HTMLInputElement | undefined>(undefined);
97
101
  let contentEl = $state<HTMLElement | undefined>(undefined);
98
- /** The root to portal INTO: `document.body` escapes the editor's subtree and
102
+ /** The root to portal into: `document.body` escapes the editor's subtree and
99
103
  * the consumer's dials with it, so a pane-scoped palette misses this surface.
100
104
  * Resolved from the active leaf's own DOM, so the popover lands inside whichever
101
105
  * root raised it; `undefined` falls back to bits-ui's `document.body` for a leaf
102
106
  * mounted outside any root. */
103
107
  let portalTarget = $state<HTMLElement | undefined>(undefined);
104
108
 
105
- /** The selection the surface hangs off, live rather than measured (`codec/anchor.ts`): floating-ui re-reads it through every scroll and reflow, so `sync` mints one only when the RANGE moves. A NEW object each time it does, so bits-ui's `watch( => opts.customAnchor.current, …)` sees the change and repositions (a mutated-in-place object would not). */
109
+ /** The selection the surface hangs off, live rather than measured (`codec/anchor.ts`): floating-ui re-reads it through every scroll and reflow, so `sync` mints one only when the range moves. A new object each time it does, so bits-ui's `watch( => opts.customAnchor.current, …)` sees the change and repositions (a mutated-in-place object would not). */
106
110
  let anchor = $state<RangeAnchor | undefined>(undefined);
107
111
 
108
- /** The view the caret is actually in: the leaf's own, or the NESTED cell view of a
112
+ /** The view the caret is actually in: the leaf's own, or the nested cell view of a
109
113
  * table island (`codec/table-view.ts`). The six marks are the inline schema's
110
114
  * too, so a selection in a cell raises the same popover and toggles the same
111
115
  * commands; only `anchor` is withheld (below). */
@@ -114,7 +118,7 @@
114
118
  return leaf?.focusedView?.() ?? leaf?.view;
115
119
  }
116
120
 
117
- /** Recompute open/anchor/active-marks from the active leaf's CURRENT PM selection. */
121
+ /** Recompute open/anchor/active-marks from the active leaf's current PM selection. */
118
122
  function sync(): void {
119
123
  const insidePopover =
120
124
  !!contentEl && !!document.activeElement && contentEl.contains(document.activeElement);
@@ -124,7 +128,7 @@
124
128
  if (insidePopover) return;
125
129
  const leaf = getActiveLeaf() as LeafWithView | undefined;
126
130
  const view = leaf?.focusedView?.() ?? leaf?.view;
127
- // A non-empty TEXT selection. A NODE selection is non-empty too and has nothing
131
+ // A non-empty text selection. A node selection is non-empty too and has nothing
128
132
  // to format: it covers a leaf block (an island, a rule), which is exactly where
129
133
  // Escape out of a table cell lands.
130
134
  const selection = view?.state.selection;
@@ -145,7 +149,7 @@
145
149
  // `anchor` is a decoration, not a PM mark (CODEC §Marks): its active state is
146
150
  // whether the selection covers an identity anchor, read off the leaf in USV.
147
151
  //
148
- // A selection inside a table CELL is not in that coordinate space at all: the
152
+ // A selection inside a table cell is not in that coordinate space at all: the
149
153
  // field's position map holds one `atom` run for the whole island, so there is
150
154
  // no USV offset to mint an anchor at. The button is withheld rather than
151
155
  // disabled, because what it would toggle does not exist there (CODEC §Islands).
@@ -159,8 +163,8 @@
159
163
  }
160
164
 
161
165
  // A burst of DOM events (verified: browser "select all" processing fires
162
- // `selectionchange` several times, transiently EMPTY before landing on the
163
- // final range) must coalesce to ONE settled check, not one `sync()` per
166
+ // `selectionchange` several times, transiently empty before landing on the
167
+ // final range) must coalesce to one settled check, not one `sync()` per
164
168
  // event: reacting to each intermediate state mounts/unmounts
165
169
  // `Popover.Content` repeatedly within a few event-loop turns, which is both
166
170
  // visibly unstable (a moving, momentarily detached click target) and races
@@ -169,7 +173,7 @@
169
173
  // microtask is not late enough: those transient events are themselves
170
174
  // separated by microtasks. `requestAnimationFrame` is: it runs once
171
175
  // per-frame, after the browser has finished dispatching every synchronous
172
- // consequence of the user gesture, so it always reads the FINAL, settled
176
+ // consequence of the user gesture, so it always reads the final, settled
173
177
  // selection. `pending` collapses a burst to a single scheduled callback.
174
178
  let pending = false;
175
179
  let rafHandle = 0;
@@ -202,19 +206,36 @@
202
206
  if (!open) linkPromptOpen = false;
203
207
  });
204
208
 
209
+ // The prompt takes its own focus. Nothing else moves it: open-auto-focus is
210
+ // prevented (focus discipline above), and the prompt replaces the buttons on a
211
+ // surface already open, so without this the URL is typed into the document.
212
+ // Selected rather than merely focused, the input arriving seeded and replacing
213
+ // that value being what the prompt is usually raised for.
214
+ $effect(() => {
215
+ if (linkPromptOpen && linkInputEl) {
216
+ linkInputEl.focus();
217
+ linkInputEl.select();
218
+ }
219
+ });
220
+
205
221
  /** Swallow the button's mousedown so focus/selection never leave the leaf. */
206
222
  function keepFocus(e: MouseEvent): void {
207
223
  e.preventDefault();
208
224
  }
209
225
 
226
+ // A link is a value, so its button raises the prompt rather than toggling — one
227
+ // meaning whether or not the selection carries one, and the seeded prompt is the
228
+ // only place the document's href is legible. Removal is an arm inside the
229
+ // prompt, not a second meaning for the button. The five value-less marks keep
230
+ // the toggle.
210
231
  function toggle(name: string): void {
211
232
  const view = activeLeafView();
212
233
  if (!view) return;
213
234
  const type: MarkType | undefined = view.state.schema.marks[name];
214
235
  if (!type) return;
215
- if (name === 'link' && !activeMarks.link) {
236
+ if (name === 'link') {
237
+ linkValue = hrefInSelection(view.state);
216
238
  linkPromptOpen = true;
217
- linkValue = '';
218
239
  return;
219
240
  }
220
241
  toggleMark(type)(view.state, view.dispatch);
@@ -242,21 +263,31 @@
242
263
  sync();
243
264
  }
244
265
 
245
- function submitLink(): void {
246
- const view = activeLeafView();
247
- const type = view?.state.schema.marks.link;
266
+ /** Every exit from the prompt: lower it, run `cmd` if there is one, and hand the
267
+ * leaf back its focus and the surface its buttons. */
268
+ function closePrompt(cmd?: Command): void {
248
269
  linkPromptOpen = false;
249
- if (!view || !type) return;
250
- const href = linkValue.trim();
251
- if (href) toggleMark(type, { href })(view.state, view.dispatch);
252
- view.focus();
270
+ const view = activeLeafView();
271
+ if (view) {
272
+ cmd?.(view.state, view.dispatch);
273
+ view.focus();
274
+ }
253
275
  sync();
254
276
  }
255
277
 
278
+ /** Apply the typed value, normalized (`links.ts`). A blank value is nothing to
279
+ * apply and only closes the prompt: removal has its own arm, and a second door
280
+ * onto it would be a second thing to keep true. */
281
+ function submitLink(): void {
282
+ closePrompt(setLink(normalizeHref(linkValue)));
283
+ }
284
+
285
+ function removeLink(): void {
286
+ closePrompt(clearLink);
287
+ }
288
+
256
289
  function cancelLink(): void {
257
- linkPromptOpen = false;
258
- activeLeafView()?.focus();
259
- sync();
290
+ closePrompt();
260
291
  }
261
292
  </script>
262
293
 
@@ -271,7 +302,7 @@
271
302
  onOpenAutoFocus={(e: Event) => e.preventDefault()}
272
303
  onCloseAutoFocus={(e: Event) => e.preventDefault()}
273
304
  >
274
- <!-- The `child` snippet, so the surface IS the primitive's content node
305
+ <!-- The `child` snippet, so the surface is the primitive's content node
275
306
  rather than a pill inside it: the dismissal is a CSS animation and the
276
307
  primitive unmounts on the content node's own animations finishing. It
277
308
  also keeps the recipe reachable from this
@@ -299,12 +330,24 @@
299
330
  }}
300
331
  >
301
332
  <input
333
+ bind:this={linkInputEl}
302
334
  class="qm-link-input"
303
335
  type="text"
304
336
  placeholder={t.strings.linkPlaceholder}
337
+ aria-label={t.strings.formatLink}
305
338
  bind:value={linkValue}
306
339
  />
307
340
  <button type="submit" class="qm-icon-btn qm-mark-btn">{t.strings.linkApply}</button>
341
+ <!-- Only where there is a link to remove: an arm that does nothing is
342
+ an arm to read past on every other visit to the prompt. -->
343
+ {#if activeMarks.link}
344
+ <button
345
+ type="button"
346
+ class="qm-icon-btn qm-mark-btn"
347
+ onmousedown={keepFocus}
348
+ onclick={removeLink}>{t.strings.linkRemove}</button
349
+ >
350
+ {/if}
308
351
  <button
309
352
  type="button"
310
353
  class="qm-icon-btn qm-mark-btn"
@@ -349,7 +392,7 @@
349
392
  </Popover.Root>
350
393
 
351
394
  <style>
352
- /* This surface PORTALS out of the leaf's DOM but INTO the nearest `[data-qm-root]`
395
+ /* This surface portals out of the leaf's DOM but into the nearest `[data-qm-root]`
353
396
  (the target is resolved in `sync`), so it inherits the consumer's dials like
354
397
  every other surface. It carries the marker itself too: floating over content is
355
398
  still a detached subtree for the derivation's purposes, and the marker is what
@@ -386,13 +429,15 @@
386
429
  align-items: center;
387
430
  }
388
431
  /* The box's recipe without `.qm-input`, which adds the `width: 100%` that would
389
- stretch this to the popover. */
432
+ stretch this to the popover. The fill is the box's own rung and there is no
433
+ edge, as everywhere else a value is typed (controls.css). */
390
434
  .qm-link-input {
391
435
  font: inherit;
392
436
  font-size: var(--_qm-text-body);
393
437
  padding: var(--_qm-inset-control);
394
- border: var(--_qm-border-width) solid var(--_qm-border);
438
+ border: none;
395
439
  border-radius: var(--_qm-radius-inner);
440
+ background: var(--_qm-surface-well);
396
441
  min-width: 12rem;
397
442
  }
398
443
  </style>
@@ -1,20 +1,20 @@
1
1
  <!--
2
2
  A `number` / `integer` field → numeric input (fixture `font_size` = 11.5).
3
- Commits at `change` (blur/Enter), NOT per keystroke: a partial numeric entry
3
+ Commits at `change` (blur/Enter), not per keystroke: a partial numeric entry
4
4
  (`-`, `1.`, `1e`) is never a document state worth a boundary round-trip, and
5
5
  committing it live flashes a coercion diagnostic + `console.error` on every
6
6
  intermediate prefix, announced by `DiagnosticList`'s `role="status"` live
7
- region. A blank entry commits `undefined`: the UNSET rung of the
7
+ region. A blank entry commits `undefined`: the unset rung of the
8
8
  commitment ladder: the parent removes the field and the engine renders the
9
9
  ghosted `default:`. Settling at `change` also keeps
10
10
  select-all-and-retype from flashing the preview through the default mid-keystroke.
11
11
 
12
- `type="text"`, not `type="number"`: a native number input SANITIZES an
12
+ `type="text"`, not `type="number"`: a native number input sanitizes an
13
13
  invalid string to `""` before the DOM `value` setter even runs (verified:
14
14
  `.value = "abc"` on `type="number"` never lands), which would make a
15
15
  genuinely bad entry untypeable. The commit-time coercion diagnostic
16
16
  (VISUAL_EDITOR §Diagnostics) needs exactly that path reachable through the
17
- UI, so a non-blank entry that fails to parse forwards the RAW STRING to
17
+ UI, so a non-blank entry that fails to parse forwards the raw string to
18
18
  `onCommit` unchanged: the boundary's own `writer.set` coercion is the judge
19
19
  (throws a `QuillmarkError` the parent turns into a field diagnostic), not a
20
20
  client-side guess. `inputmode` keeps the numeric mobile keyboard.
@@ -27,7 +27,7 @@
27
27
  value: number | undefined;
28
28
  integer?: boolean;
29
29
  fallback?: number;
30
- /** Accessible name for an input NOTHING else names: an object property, whose
30
+ /** Accessible name for an input nothing else names: an object property, whose
31
31
  * name is the field label plus the property's. A field's own input takes `id`
32
32
  * instead and is named by the `<label for>` beside it. */
33
33
  label?: string;
@@ -3,7 +3,7 @@ interface Props {
3
3
  value: number | undefined;
4
4
  integer?: boolean;
5
5
  fallback?: number;
6
- /** Accessible name for an input NOTHING else names: an object property, whose
6
+ /** Accessible name for an input nothing else names: an object property, whose
7
7
  * name is the field label plus the property's. A field's own input takes `id`
8
8
  * instead and is named by the `<label for>` beside it. */
9
9
  label?: string;
@@ -1,14 +1,13 @@
1
1
  <!--
2
2
  An `object` field → a nested subform over `properties` (declaration order),
3
- committing the WHOLE object by value on any nested change. SCALAR properties
3
+ committing the whole object by value on any nested change. Scalar properties
4
4
  only: a nested prose/array/object property renders a placeholder instead of
5
5
  recursing.
6
6
 
7
7
  A property's ghosted `default:` is the static schema `sub.default`, not the
8
8
  resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
9
9
  carries no per-property row (an object field resolves as one row whose value is
10
- the whole object). Thread it through `ghostDefault` once resolve exposes
11
- per-property provenance.
10
+ the whole object).
12
11
  -->
13
12
  <script lang="ts">
14
13
  import type { QuillFieldSchema } from '@quillmark/wasm';
@@ -24,7 +23,7 @@
24
23
  properties: Record<string, QuillFieldSchema> | undefined;
25
24
  /** Accessible-name prefix for the property controls. */
26
25
  label?: string;
27
- /** The field label's own id. A subform is a GROUP of controls, not one control
26
+ /** The field label's own id. A subform is a group of controls, not one control
28
27
  * `for` could reach, so the field's label names the set and each property
29
28
  * control keeps its own composed `aria-label`. */
30
29
  labelledBy?: string;
@@ -37,7 +36,7 @@
37
36
  const entries = $derived(Object.entries(properties ?? {}));
38
37
  const obj = $derived((value ?? {}) as Record<string, unknown>);
39
38
 
40
- /** Take the caret: the FIRST property's control, a subform having no single control
39
+ /** Take the caret: the first property's control, a subform having no single control
41
40
  * of its own to land on. Resolved off the DOM rather than a ref per property: every
42
41
  * property is a scalar control the DOM already knows how to focus (the date field's
43
42
  * segments carry `tabindex`, the `literal` separators between them do not), so a
@@ -53,7 +52,7 @@
53
52
  function commitProp(key: string, v: unknown): void {
54
53
  if (v === undefined) {
55
54
  // A nested control cleared (the unset rung): drop the key so the property
56
- // is ABSENT in the committed object (resolving to its own `default:`)
55
+ // is absent in the committed object (resolving to its own `default:`)
57
56
  // rather than an `undefined` hole carried through `writer.set`.
58
57
  const rest = { ...obj };
59
58
  delete rest[key];
@@ -134,9 +133,10 @@
134
133
  }
135
134
  .qm-object-label {
136
135
  font-size: var(--_qm-text-label);
137
- /* Soft (500), a rung under the top-level field label's 600: a nested object
138
- prop reads as secondary. Resolves the label-weight disagreement. */
139
- font-weight: var(--_qm-weight-label);
136
+ /* The chrome weight, the same one the field label above it takes: what says
137
+ this name is nested is the subform's rule and its inset, so a weight step
138
+ here would state it twice and rank two names the ramp does not separate. */
139
+ font-weight: var(--_qm-weight-mid);
140
140
  /* A label is a line, not a passage, so it overrides the root's reading
141
141
  rhythm the way every other label rung does. */
142
142
  line-height: var(--_qm-leading-tight);
@@ -4,7 +4,7 @@ interface Props {
4
4
  properties: Record<string, QuillFieldSchema> | undefined;
5
5
  /** Accessible-name prefix for the property controls. */
6
6
  label?: string;
7
- /** The field label's own id. A subform is a GROUP of controls, not one control
7
+ /** The field label's own id. A subform is a group of controls, not one control
8
8
  * `for` could reach, so the field's label names the set and each property
9
9
  * control keeps its own composed `aria-label`. */
10
10
  labelledBy?: string;