@quillmark/svelte 0.2.0 → 0.3.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 (116) hide show
  1. package/README.md +2 -2
  2. package/THEMING.md +11 -7
  3. package/dist/core/address.d.ts +7 -7
  4. package/dist/core/address.js +6 -6
  5. package/dist/core/bloom.d.ts +5 -5
  6. package/dist/core/bloom.js +8 -8
  7. package/dist/core/codec/anchor.d.ts +1 -1
  8. package/dist/core/codec/anchor.js +1 -1
  9. package/dist/core/codec/atoms.d.ts +10 -0
  10. package/dist/core/codec/atoms.js +78 -0
  11. package/dist/core/codec/clipboard.d.ts +59 -0
  12. package/dist/core/codec/clipboard.js +202 -0
  13. package/dist/core/codec/code.d.ts +1 -1
  14. package/dist/core/codec/code.js +4 -4
  15. package/dist/core/codec/decode.d.ts +1 -1
  16. package/dist/core/codec/decode.js +8 -8
  17. package/dist/core/codec/encode.d.ts +4 -4
  18. package/dist/core/codec/encode.js +10 -10
  19. package/dist/core/codec/field.d.ts +21 -26
  20. package/dist/core/codec/field.js +33 -32
  21. package/dist/core/codec/index.js +5 -8
  22. package/dist/core/codec/inputrules.js +56 -18
  23. package/dist/core/codec/islands.d.ts +11 -7
  24. package/dist/core/codec/islands.js +16 -10
  25. package/dist/core/codec/keymap.d.ts +5 -6
  26. package/dist/core/codec/keymap.js +8 -7
  27. package/dist/core/codec/lists.d.ts +3 -3
  28. package/dist/core/codec/lists.js +32 -13
  29. package/dist/core/codec/marks.d.ts +1 -1
  30. package/dist/core/codec/marks.js +1 -1
  31. package/dist/core/codec/positions.js +1 -1
  32. package/dist/core/codec/prose.css +230 -215
  33. package/dist/core/codec/schema.js +4 -4
  34. package/dist/core/codec/slash.d.ts +39 -41
  35. package/dist/core/codec/slash.js +0 -0
  36. package/dist/core/codec/table-view.d.ts +12 -16
  37. package/dist/core/codec/table-view.js +488 -292
  38. package/dist/core/codec/table.d.ts +42 -15
  39. package/dist/core/codec/table.js +70 -26
  40. package/dist/core/errors.d.ts +5 -5
  41. package/dist/core/index.js +2 -2
  42. package/dist/core/lifecycle.js +1 -1
  43. package/dist/core/rebind.svelte.d.ts +2 -2
  44. package/dist/core/rebind.svelte.js +4 -4
  45. package/dist/core/teardown.d.ts +1 -1
  46. package/dist/core/teardown.js +1 -1
  47. package/dist/core/theme.css +144 -54
  48. package/dist/preset/index.css +1 -1
  49. package/dist/preset/recipes.css +31 -29
  50. package/dist/preset/scale.css +36 -25
  51. package/dist/preview/Preview.svelte +12 -10
  52. package/dist/preview/Preview.svelte.d.ts +3 -3
  53. package/dist/preview/bridge.js +4 -4
  54. package/dist/preview/controller.js +3 -3
  55. package/dist/preview/geometry.d.ts +2 -2
  56. package/dist/preview/geometry.js +2 -2
  57. package/dist/preview/index.js +3 -3
  58. package/dist/preview/overlay.d.ts +1 -1
  59. package/dist/preview/overlay.js +3 -3
  60. package/dist/preview/paint.js +15 -17
  61. package/dist/visual/ArrayField.svelte +80 -66
  62. package/dist/visual/ArrayField.svelte.d.ts +1 -1
  63. package/dist/visual/BooleanField.svelte +8 -6
  64. package/dist/visual/BooleanField.svelte.d.ts +2 -2
  65. package/dist/visual/Card.svelte +151 -325
  66. package/dist/visual/Card.svelte.d.ts +2 -6
  67. package/dist/visual/CardControls.svelte +1 -1
  68. package/dist/visual/DateField.svelte +19 -19
  69. package/dist/visual/DateField.svelte.d.ts +3 -3
  70. package/dist/visual/DiagnosticList.svelte +1 -1
  71. package/dist/visual/EnumField.svelte +16 -16
  72. package/dist/visual/EnumField.svelte.d.ts +2 -2
  73. package/dist/visual/Field.svelte +128 -115
  74. package/dist/visual/Field.svelte.d.ts +1 -1
  75. package/dist/visual/FieldHint.svelte +24 -42
  76. package/dist/visual/FieldHint.svelte.d.ts +1 -1
  77. package/dist/visual/FieldLabel.svelte +8 -8
  78. package/dist/visual/FormatPopover.svelte +80 -35
  79. package/dist/visual/NumberField.svelte +5 -5
  80. package/dist/visual/NumberField.svelte.d.ts +1 -1
  81. package/dist/visual/ObjectField.svelte +9 -9
  82. package/dist/visual/ObjectField.svelte.d.ts +1 -1
  83. package/dist/visual/ProseArrayElement.svelte +11 -13
  84. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -1
  85. package/dist/visual/ProseField.svelte +53 -84
  86. package/dist/visual/ProseField.svelte.d.ts +12 -17
  87. package/dist/visual/SlashMenu.svelte +42 -16
  88. package/dist/visual/TextField.svelte +5 -5
  89. package/dist/visual/TextField.svelte.d.ts +1 -1
  90. package/dist/visual/TipsCard.svelte +43 -28
  91. package/dist/visual/VisualEditor.svelte +6 -6
  92. package/dist/visual/VisualEditorInner.svelte +122 -129
  93. package/dist/visual/controls.css +74 -30
  94. package/dist/visual/diagnostics.d.ts +5 -5
  95. package/dist/visual/diagnostics.js +3 -3
  96. package/dist/visual/domid.js +3 -3
  97. package/dist/visual/ext.d.ts +1 -1
  98. package/dist/visual/ext.js +1 -1
  99. package/dist/visual/icons/nodes.d.ts +3 -0
  100. package/dist/visual/icons/nodes.js +2 -1
  101. package/dist/visual/index.js +4 -4
  102. package/dist/visual/leaves.d.ts +3 -3
  103. package/dist/visual/leaves.js +1 -1
  104. package/dist/visual/links.d.ts +24 -0
  105. package/dist/visual/links.js +67 -0
  106. package/dist/visual/props.d.ts +8 -8
  107. package/dist/visual/signals.d.ts +6 -6
  108. package/dist/visual/strings.d.ts +14 -19
  109. package/dist/visual/strings.js +5 -7
  110. package/dist/visual/structure.d.ts +24 -17
  111. package/dist/visual/structure.js +8 -8
  112. package/dist/visual/synced.svelte.d.ts +1 -1
  113. package/dist/visual/synced.svelte.js +4 -4
  114. package/dist/visual/tips.d.ts +2 -2
  115. package/dist/visual/tips.js +3 -3
  116. package/package.json +1 -1
package/README.md CHANGED
@@ -149,7 +149,7 @@ The package ships English and a seam to replace it. `strings` is keyed and **par
149
149
  {quill}
150
150
  strings={{
151
151
  cardDelete: 'Supprimer la carte',
152
- addCardOfKind: (kind) => `Ajouter : ${kind}`
152
+ addCard: '+ Ajouter une carte'
153
153
  }}
154
154
  formatDiagnostic={(d) =>
155
155
  d.code === 'validation::must_fill' ? `${d.path} est obligatoire` : undefined}
@@ -200,7 +200,7 @@ onPick: (at) => visualEditor.setCaret(at);
200
200
  onCaretMove: preview.focusPosition;
201
201
  ```
202
202
 
203
- A pick carries a caret where the compile tracks the content under the point, and the field alone where it tracks only the placement — a scalar the plate prints without tracking. `setCaret` takes both: an absent `pos` reveals and focuses the field, which is the whole of what a click on plate-placed ink can mean. The address may name an array ELEMENT (`main.references.0`), which lands on that row.
203
+ A pick carries a caret where the compile tracks the content under the point, and the field alone where it tracks only the placement — a scalar the plate prints without tracking. `setCaret` takes both: an absent `pos` reveals and focuses the field, which is the whole of what a click on plate-placed ink can mean. The address may name an array element (`main.references.0`), which lands on that row.
204
204
 
205
205
  A consumer holding an `Addr` of its own maps it with `fieldPathForAddr` (from `@quillmark/svelte/core`). The playground app's split-pane route is the full reference shell: one session, both bridge directions, the preview following edits, and diagnostics routed inline.
206
206
 
package/THEMING.md CHANGED
@@ -41,7 +41,9 @@ The whole contract is **ten CSS custom properties**. They are dials, not a palet
41
41
 
42
42
  ### Two of them are not branding
43
43
 
44
- `--qm-bg` and `--qm-fg` are the two a themed host sets before any brand decision. Their defaults are a self-consistent neutral for a bare mounting site, not a guess at your page: an app that paints its own plate and leaves them mounts one plate inside another. Two tones a few percent apart read as a fault rather than as a layer, so meet your page's tone exactly or stand far enough off it to be a layer.
44
+ `--qm-bg` and `--qm-fg` are the two a themed host sets before any brand decision. Their defaults are a self-consistent neutral for a bare mounting site, not a guess at your page.
45
+
46
+ `--qm-bg` is the **card**, not the column behind it: the plane a document is written on, which is also the sheet the preview paints. Everything else is a step off it, and the column the cards sit in is a step down, so a mounted editor draws its own ground and the cards read as islands on it. Give it the tone you want a card to be — white, in most light themes — and let the column fall out. If you want the column to meet your page instead, set a `background` of your own on `.qm-editor`; your CSS is unlayered and wins.
45
47
 
46
48
  The poles take a colour, not `transparent` or `inherit`: the raised surfaces, the borders and the muted inks are each a `color-mix` against `--qm-bg`, so a transparent pole makes every one of them translucent instead of letting your page through. Hand over the value your page is painted with and the derivation steps off it.
47
49
 
@@ -49,8 +51,8 @@ The poles take a colour, not `transparent` or `inherit`: the raised surfaces, th
49
51
 
50
52
  | Token | Default | What it sets |
51
53
  | ---------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
52
- | `--qm-bg` | `#fff` / `#14171c` | Base surface. Cards, fields, the painted page, the popover, and the column behind them step off it. |
53
- | `--qm-fg` | `#1a1a1a` / `#e8eaed` | Base ink. Body text, labels, and borders step off it. |
54
+ | `--qm-bg` | `#fff` / `#272729` | Base surface: a card, and the preview's sheet. Fields, the popover and the column behind the cards each step off it. |
55
+ | `--qm-fg` | `#1c1c1c` / `#d7dadc` | Base ink. Body text, labels, and borders step off it. |
54
56
  | `--qm-accent` | `#2563eb` | Focus rings, active marks, the preview's active field box. |
55
57
  | `--qm-danger` | `#c5221f` | Error diagnostics, the required marker, the delete glyph. |
56
58
  | `--qm-warning` | `#b25000` | Warning diagnostics. |
@@ -76,7 +78,9 @@ Declared before our sheet it fixes the order outright; declared after, `qm` is a
76
78
 
77
79
  ### The surface follows your colour scheme
78
80
 
79
- Surfaces step `bg → fg` and ink steps `fg → bg`, mixed in **oklab**, so inverting the two poles inverts the whole scale, including borders and the popover's translucent fill.
81
+ Ink steps `fg → bg` and the borders and hover fills step `bg → fg`, mixed in **oklab**, so inverting the two poles inverts all of them together.
82
+
83
+ **Planes are the exception**, and deliberately: the raised plane is the brighter one in both schemes, which is what makes a card read as a card rather than as a differently-tinted page. So the column under the cards sinks from `--qm-bg` toward shadow and a floating surface rises from it toward light, and neither reads the ink pole. Each direction takes a step sized to the scheme it is in, which is why pinning a dark palette while your `color-scheme` still says light leaves the column barely off its cards — the one case §"Make it yours" tells you to pin the scheme alongside the poles.
80
84
 
81
85
  Which way the poles default is **your `color-scheme`**, not the operating system's. Declare the scheme your app is in and the surface lands on it, along with native chrome, which reads the same property:
82
86
 
@@ -137,7 +141,7 @@ It carries a `--qmh-*` scale for the page, derived from the same ten dials and c
137
141
 
138
142
  ### The shell, if you are building a tool
139
143
 
140
- An app that mounts an editor beside a preview over one session needs a screen to put them in, and the SHAPE of that screen is the same every time. Five more classes are that shape, so what you write is the wiring rather than the grid:
144
+ An app that mounts an editor beside a preview over one session needs a screen to put them in, and the shape of that screen is the same every time. Five more classes are that shape, so what you write is the wiring rather than the grid:
141
145
 
142
146
  ```html
143
147
  <div class="qm-workspace">
@@ -169,9 +173,9 @@ An app that mounts an editor beside a preview over one session needs a screen to
169
173
  | `.qm-switch` | The band that says which track is showing, and nothing at the widths where both are. Its controls read as even halves and stand at the touch floor (`--qmh-tap`, 44px); depth past that is yours. |
170
174
  | `.qm-frame` | The mounting site: an edge, a corner, and the clip that holds a surface to them. What is inside it is the surface's own. |
171
175
 
172
- The narrow shape is one mount at a time, not two of them stacked: half a narrow viewport is under the width either surface reads at. The track you are not showing is `display: none`, so it leaves the tab order, hit-testing and the accessibility tree together: you need no `inert` on it, and no breakpoint in your JS, since the only state you hold is which of the two you last asked for. Keep both MOUNTED: the hidden pane keeps its pixels, its caret and its scroll, and repaints itself when it comes back. A click that lands in the hidden pane is the one thing the shape adds to your wiring, so send the caret and reveal the track together, or the hit goes somewhere the reader cannot see.
176
+ The narrow shape is one mount at a time, not two of them stacked: half a narrow viewport is under the width either surface reads at. The track you are not showing is `display: none`, so it leaves the tab order, hit-testing and the accessibility tree together: you need no `inert` on it, and no breakpoint in your JS, since the only state you hold is which of the two you last asked for. Keep both mounted: the hidden pane keeps its pixels, its caret and its scroll, and repaints itself when it comes back. A click that lands in the hidden pane is the one thing the shape adds to your wiring, so send the caret and reveal the track together, or the hit goes somewhere the reader cannot see.
173
177
 
174
- The shape is shared; the LOOK of a band is not. Your wordmark, your nav and the depth of your head are the most app-specific things on the screen, and a preset that drew them would hand every tool built on this the same face, so they read `--qmh-*` like everything else and you write the rules. `.qm-split`'s gap is a default rather than a fact about splitting: close it to `0` and carry a hairline on one pane if you want the two mounts to meet instead of stand apart.
178
+ The shape is shared; the look of a band is not. Your wordmark, your nav and the depth of your head are the most app-specific things on the screen, and a preset that drew them would hand every tool built on this the same face, so they read `--qmh-*` like everything else and you write the rules. `.qm-split`'s gap is a default rather than a fact about splitting: close it to `0` and carry a hairline on one pane if you want the two mounts to meet instead of stand apart.
175
179
 
176
180
  It is **unlayered**, so your own rules beat it by ordinary precedence, exactly as they beat the surfaces. It is also opt-in and side-effect free until imported: nothing that styles your document arrives with a component.
177
181
 
@@ -1,7 +1,7 @@
1
1
  import type { Addr, HitGranularity } from '@quillmark/wasm';
2
2
  /**
3
3
  * A canonical field address: `main.<field>` / `main.body` /
4
- * `cards.<kind>[<i>].<field>`, cards keyed by ABSOLUTE document index.
4
+ * `cards.<kind>[<i>].<field>`, cards keyed by absolute document index.
5
5
  *
6
6
  * The grammar `parseDocPath` / `formatDocPath` speak, and the one
7
7
  * `Diagnostic.path`, `ContentHit.field`, `FieldRegion.field` and
@@ -27,7 +27,7 @@ export interface Place {
27
27
  * Where a preview click landed. What the preview surfaces and what the editor's
28
28
  * `setCaret` takes.
29
29
  *
30
- * **An absent `pos` is the PLACEMENT rung**, not a caret at zero: the click resolved
30
+ * **An absent `pos` is the placement rung**, not a caret at zero: the click resolved
31
31
  * a field the plate places without tracking its content (`fieldAt` answers,
32
32
  * `positionAt` does not), so there is no offset and the landing is a focus. A `Place`
33
33
  * is a landing carrying its caret, and a `ContentHit` is one too.
@@ -43,7 +43,7 @@ export interface Landing {
43
43
  /**
44
44
  * A field's canonical `DocPath`, or `undefined` when `addr.card` is outside the live
45
45
  * `kinds` array (a stale address: drop it rather than mis-target). A field-less card
46
- * addresses its BODY, which is the leaf `{card: i}` names.
46
+ * addresses its body, which is the leaf `{card: i}` names.
47
47
  *
48
48
  * - `{}` → `"main.body"`
49
49
  * - `{field}` → `"main.<field>"`
@@ -55,7 +55,7 @@ export interface Landing {
55
55
  */
56
56
  export declare function fieldPathForAddr(addr: Addr, kinds: readonly string[]): DocPath | undefined;
57
57
  /**
58
- * A CARD's own path (`cards.<kind>[i]`), not a leaf's: what a structure op names,
58
+ * A card's own path (`cards.<kind>[i]`), not a leaf's: what a structure op names,
59
59
  * where the change is the card rather than anything inside it. `undefined` for an
60
60
  * index outside `kinds`.
61
61
  */
@@ -72,7 +72,7 @@ export declare function cardPath(index: number, kinds: readonly string[]): DocPa
72
72
  */
73
73
  export declare function addrForFieldPath(path: DocPath): Addr | undefined;
74
74
  /**
75
- * An ARRAY ELEMENT's address, split. `main.references.0` and `main.references[0]`
75
+ * An array element's address, split. `main.references.0` and `main.references[0]`
76
76
  * both land here; anything {@link addrForFieldPath} can name, and anything whose
77
77
  * trailing segment is not an index, does not.
78
78
  *
@@ -80,11 +80,11 @@ export declare function addrForFieldPath(path: DocPath): Addr | undefined;
80
80
  * and `positionAt` emit `main.references.0`, which `parseDocPath` reads as a field
81
81
  * literally named `"0"`, while `formatDocPath` spells the index segment
82
82
  * `main.references[0]`. A trailing all-digits field name is therefore an index that
83
- * lost its brackets — but only under a field the SCHEMA declares an array, the
83
+ * lost its brackets — but only under a field the schema declares an array, the
84
84
  * caller's guard to apply: this module holds the grammar and no schema.
85
85
  */
86
86
  export declare function elementAddrForFieldPath(path: DocPath): ElementAddr | undefined;
87
- /** An array element's address: the ARRAY's `Addr`, and which element of it. */
87
+ /** An array element's address: the array's `Addr`, and which element of it. */
88
88
  export interface ElementAddr {
89
89
  field: Addr;
90
90
  index: number;
@@ -8,7 +8,7 @@ import { core } from './lifecycle.js';
8
8
  /**
9
9
  * A field's canonical `DocPath`, or `undefined` when `addr.card` is outside the live
10
10
  * `kinds` array (a stale address: drop it rather than mis-target). A field-less card
11
- * addresses its BODY, which is the leaf `{card: i}` names.
11
+ * addresses its body, which is the leaf `{card: i}` names.
12
12
  *
13
13
  * - `{}` → `"main.body"`
14
14
  * - `{field}` → `"main.<field>"`
@@ -26,7 +26,7 @@ export function fieldPathForAddr(addr, kinds) {
26
26
  return core().formatDocPath([head, tail]);
27
27
  }
28
28
  /**
29
- * A CARD's own path (`cards.<kind>[i]`), not a leaf's: what a structure op names,
29
+ * A card's own path (`cards.<kind>[i]`), not a leaf's: what a structure op names,
30
30
  * where the change is the card rather than anything inside it. `undefined` for an
31
31
  * index outside `kinds`.
32
32
  */
@@ -49,7 +49,7 @@ export function addrForFieldPath(path) {
49
49
  return segs && addrForSegs(segs);
50
50
  }
51
51
  /**
52
- * An ARRAY ELEMENT's address, split. `main.references.0` and `main.references[0]`
52
+ * An array element's address, split. `main.references.0` and `main.references[0]`
53
53
  * both land here; anything {@link addrForFieldPath} can name, and anything whose
54
54
  * trailing segment is not an index, does not.
55
55
  *
@@ -57,7 +57,7 @@ export function addrForFieldPath(path) {
57
57
  * and `positionAt` emit `main.references.0`, which `parseDocPath` reads as a field
58
58
  * literally named `"0"`, while `formatDocPath` spells the index segment
59
59
  * `main.references[0]`. A trailing all-digits field name is therefore an index that
60
- * lost its brackets — but only under a field the SCHEMA declares an array, the
60
+ * lost its brackets — but only under a field the schema declares an array, the
61
61
  * caller's guard to apply: this module holds the grammar and no schema.
62
62
  */
63
63
  export function elementAddrForFieldPath(path) {
@@ -73,11 +73,11 @@ export function elementAddrForFieldPath(path) {
73
73
  if (index == null)
74
74
  return undefined;
75
75
  const field = addrForSegs(segs.slice(0, -1));
76
- // A body has no elements: the parent must name a FIELD.
76
+ // A body has no elements: the parent must name a field.
77
77
  return field?.field != null ? { field, index } : undefined;
78
78
  }
79
79
  /** `parseDocPath`, with a malformed path as `undefined` rather than a throw. The gate
80
- * is read OUTSIDE the try: an uninitialized core is not a malformed path, and would
80
+ * is read outside the try: an uninitialized core is not a malformed path, and would
81
81
  * otherwise leave here as one. */
82
82
  function segsOf(path) {
83
83
  const { parseDocPath } = core();
@@ -9,12 +9,12 @@ export interface BloomTiming {
9
9
  /**
10
10
  * Resolve the timing off `el`, so the dwell is single-sourced in CSS.
11
11
  *
12
- * ONE property, both motion preferences: `--_qm-bloom-dwell` already shortens under
12
+ * One property, both motion preferences: `--_qm-bloom-dwell` already shortens under
13
13
  * `prefers-reduced-motion` in the derivation, so what is left to `matchMedia` here is
14
- * the SHAPE: a hold and a cut carry no ramps, which is a frame list rather than a
14
+ * the shape: a hold and a cut carry no ramps, which is a frame list rather than a
15
15
  * number and the one half CSS cannot hand over.
16
16
  *
17
- * Read ONCE PER BATCH, not per element: `getComputedStyle` forces a style recalc and
17
+ * Read once per batch, not per element: `getComputedStyle` forces a style recalc and
18
18
  * `animate` re-dirties style, so a read inside a loop flushes once per element (~0.15ms
19
19
  * each). The rungs inherit, so every element in a batch resolves the same numbers.
20
20
  */
@@ -27,10 +27,10 @@ export declare function bloomTiming(el: HTMLElement): BloomTiming;
27
27
  */
28
28
  export declare function bloom(el: HTMLElement, elapsed?: number, timing?: BloomTiming): Animation | undefined;
29
29
  /**
30
- * Bloom over `host`'s CONTENT: an inset child, so the wash neither tints the host's
30
+ * Bloom over `host`'s content: an inset child, so the wash neither tints the host's
31
31
  * own background nor fades the text under it. `host` must be positioned.
32
32
  *
33
- * ONE wash node per host, reused while it lives: two quick landings on the same leaf
33
+ * One wash node per host, reused while it lives: two quick landings on the same leaf
34
34
  * must not stack two alphas over it. No `elapsed` seam: the surfaces that use this
35
35
  * (editor leaves) are not rebuilt under the animation the way the preview's boxes are.
36
36
  */
@@ -1,12 +1,12 @@
1
1
  // The correlation bloom: a soft accent wash that rises over a target and decays to
2
- // nothing. The editor↔preview address is an EVENT, not a state: the preview claims
2
+ // nothing. The editor↔preview address is an event, not a state: the preview claims
3
3
  // to be the rendered output, so it carries no resting ink, and a highlight that
4
4
  // outlives the hop that caused it is ink the document did not ask for. Both
5
5
  // directions land here: the preview's field boxes bloom themselves (they are already
6
6
  // empty rects over the page), an editor leaf blooms a transient child over its
7
7
  // surface.
8
8
  //
9
- // OPACITY is the animated property, never the colour. The wash's alpha is a peak
9
+ // Opacity is the animated property, never the colour. The wash's alpha is a peak
10
10
  // fixed by `--_qm-accent-wash`, so interpolating 0→1→0 keeps the scale in CSS where
11
11
  // `color-mix` resolves, instead of in keyframe values a script would have to
12
12
  // pre-resolve and re-resolve on a theme change.
@@ -23,7 +23,7 @@ const WASH = 'var(--_qm-accent-wash)';
23
23
  * absence of the scale, not a copy of it. */
24
24
  const FALLBACK_MS = 1100;
25
25
  // Rise fast, hold, then leave slowly: a highlighter's decay, not a linear fade. A
26
- // keyframe's easing governs the segment that STARTS at it. Under reduced motion the
26
+ // keyframe's easing governs the segment that starts at it. Under reduced motion the
27
27
  // ramps are dropped entirely: the wash holds at full for a beat and cuts, which
28
28
  // still answers "here" with nothing to track.
29
29
  const FRAMES = [
@@ -40,12 +40,12 @@ export function primeWash(el) {
40
40
  /**
41
41
  * Resolve the timing off `el`, so the dwell is single-sourced in CSS.
42
42
  *
43
- * ONE property, both motion preferences: `--_qm-bloom-dwell` already shortens under
43
+ * One property, both motion preferences: `--_qm-bloom-dwell` already shortens under
44
44
  * `prefers-reduced-motion` in the derivation, so what is left to `matchMedia` here is
45
- * the SHAPE: a hold and a cut carry no ramps, which is a frame list rather than a
45
+ * the shape: a hold and a cut carry no ramps, which is a frame list rather than a
46
46
  * number and the one half CSS cannot hand over.
47
47
  *
48
- * Read ONCE PER BATCH, not per element: `getComputedStyle` forces a style recalc and
48
+ * Read once per batch, not per element: `getComputedStyle` forces a style recalc and
49
49
  * `animate` re-dirties style, so a read inside a loop flushes once per element (~0.15ms
50
50
  * each). The rungs inherit, so every element in a batch resolves the same numbers.
51
51
  */
@@ -74,10 +74,10 @@ export function bloom(el, elapsed = 0, timing) {
74
74
  return anim;
75
75
  }
76
76
  /**
77
- * Bloom over `host`'s CONTENT: an inset child, so the wash neither tints the host's
77
+ * Bloom over `host`'s content: an inset child, so the wash neither tints the host's
78
78
  * own background nor fades the text under it. `host` must be positioned.
79
79
  *
80
- * ONE wash node per host, reused while it lives: two quick landings on the same leaf
80
+ * One wash node per host, reused while it lives: two quick landings on the same leaf
81
81
  * must not stack two alphas over it. No `elapsed` seam: the surfaces that use this
82
82
  * (editor leaves) are not rebuilt under the animation the way the preview's boxes are.
83
83
  */
@@ -8,7 +8,7 @@ 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
11
+ * The measure is total. `coordsAtPos` reads layout, and layout can be gone: a
12
12
  * position the document has since invalidated, a leaf inside a collapsed section, a
13
13
  * view torn down between a scroll firing and the measure running. `autoUpdate` calls
14
14
  * this at moments none of its callers choose, so an unmeasurable anchor returns the
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A virtual anchor over one PM range, measured when asked rather than when made.
3
3
  *
4
- * The measure is TOTAL. `coordsAtPos` reads layout, and layout can be gone: a
4
+ * The measure is total. `coordsAtPos` reads layout, and layout can be gone: a
5
5
  * position the document has since invalidated, a leaf inside a collapsed section, a
6
6
  * view torn down between a scroll firing and the measure running. `autoUpdate` calls
7
7
  * this at moments none of its callers choose, so an unmeasurable anchor returns the
@@ -0,0 +1,10 @@
1
+ import { type Command } from 'prosemirror-state';
2
+ import type { Schema } from 'prosemirror-model';
3
+ /**
4
+ * The block-atom bindings for a block-schema leaf: `{}` for the inline/plaintext
5
+ * schemas, which hold no block to be against.
6
+ *
7
+ * Both keys are the same command on opposite sides, because what a delete against an
8
+ * atom means does not depend on which side the caret approached from.
9
+ */
10
+ export declare function atomKeymap(schema: Schema): Record<string, Command>;
@@ -0,0 +1,78 @@
1
+ // The block-atom link of the body's Backspace/Delete chains: a delete against an
2
+ // island or a divider selects it, and the press after that deletes it.
3
+ //
4
+ // This is the neighbour half of the rule the leaf already holds for a key typed over a
5
+ // selection (CODEC §"The table island"): a selection is the subject of the next
6
+ // command, never a thing armed for replacement. Without it the base keymap's
7
+ // `joinBackward` / `joinForward` reach their "the neighbour is an atom, delete it" arm
8
+ // and a table goes in one keystroke, from the paragraph a table insert parks the caret
9
+ // in — with nothing drawn first to say what is about to go.
10
+ //
11
+ // It runs last of the links, after the list ones: a Backspace at the start of a list's
12
+ // first item lifts the item, and that stays the item's answer whatever sits above the
13
+ // list.
14
+ import { NodeSelection, TextSelection } from 'prosemirror-state';
15
+ /** The caret, when it sits at a textblock edge with an empty selection: the only place
16
+ * a delete is about a neighbour rather than about a character. `null` everywhere else,
17
+ * which is where the key keeps its ordinary meaning. */
18
+ function atEdge(state, side) {
19
+ const { selection } = state;
20
+ if (!(selection instanceof TextSelection) || !selection.empty)
21
+ return null;
22
+ const $head = selection.$head;
23
+ const at = side < 0 ? 0 : $head.parent.content.size;
24
+ return $head.parentOffset === at ? $head : null;
25
+ }
26
+ /**
27
+ * The cut the caret's block sits against on `side`: the shallowest boundary with a
28
+ * sibling beyond it. `prosemirror-commands` walks the same one to find the node a join
29
+ * would reach; this reads it to find the node a delete would take.
30
+ */
31
+ function cutAt($pos, side) {
32
+ if ($pos.parent.type.spec.isolating)
33
+ return null;
34
+ for (let d = $pos.depth - 1; d >= 0; d--) {
35
+ if (side < 0) {
36
+ if ($pos.index(d) > 0)
37
+ return $pos.doc.resolve($pos.before(d + 1));
38
+ }
39
+ else if ($pos.index(d) + 1 < $pos.node(d).childCount) {
40
+ return $pos.doc.resolve($pos.after(d + 1));
41
+ }
42
+ if ($pos.node(d).type.spec.isolating)
43
+ break;
44
+ }
45
+ return null;
46
+ }
47
+ /** Select the block atom the caret is against, rather than deleting it. Declines when
48
+ * the neighbour is anything a caret can enter, so a paragraph, a heading, a quote and
49
+ * a list all keep the join the base keymap gives them. */
50
+ function selectAtom(side) {
51
+ return (state, dispatch) => {
52
+ const $head = atEdge(state, side);
53
+ const $cut = $head && cutAt($head, side);
54
+ if (!$cut)
55
+ return false;
56
+ const node = side < 0 ? $cut.nodeBefore : $cut.nodeAfter;
57
+ if (!node || !node.isAtom || !node.isBlock || !NodeSelection.isSelectable(node))
58
+ return false;
59
+ const at = side < 0 ? $cut.pos - node.nodeSize : $cut.pos;
60
+ dispatch?.(state.tr.setSelection(NodeSelection.create(state.doc, at)).scrollIntoView());
61
+ return true;
62
+ };
63
+ }
64
+ /**
65
+ * The block-atom bindings for a block-schema leaf: `{}` for the inline/plaintext
66
+ * schemas, which hold no block to be against.
67
+ *
68
+ * Both keys are the same command on opposite sides, because what a delete against an
69
+ * atom means does not depend on which side the caret approached from.
70
+ */
71
+ export function atomKeymap(schema) {
72
+ if (!schema.nodes.island_block)
73
+ return {};
74
+ return {
75
+ Backspace: selectAtom(-1),
76
+ Delete: selectAtom(1)
77
+ };
78
+ }
@@ -0,0 +1,59 @@
1
+ import { DOMParser as PMDOMParser, Slice } from 'prosemirror-model';
2
+ import type { Node as PMNode, Schema } from 'prosemirror-model';
3
+ import { Plugin } from 'prosemirror-state';
4
+ import type { TableCell, TableProps } from '@quillmark/wasm';
5
+ import { type TableAlign } from './table.js';
6
+ /** What a clipboard event hands over, which is all either arm needs of it: jsdom has no
7
+ * `DataTransfer`, and the suite drives these paths with the two methods themselves. */
8
+ interface Clipboard {
9
+ getData: (type: string) => string;
10
+ setData: (type: string, data: string) => void;
11
+ }
12
+ /**
13
+ * A `<table>` element as props. `rowspan` is not carried — a merged cell is not a
14
+ * rectangle, so a table with one arrives with the rows below it short and
15
+ * {@link normalizeTable} pads them at the end. `colspan` is, as the empty cells it
16
+ * covers, so the columns after it keep their index.
17
+ *
18
+ * Only the table's own rows: a `<tr>` inside a nested table belongs to a cell this
19
+ * reader is about to flatten, not to a rank of this one.
20
+ */
21
+ export declare function tableFromDOM(dom: Element): TableProps;
22
+ /** The table a paste is carrying, or `undefined` for a paste that holds none. The first
23
+ * one: a clipboard holding a table among other blocks is a document paste, and only a
24
+ * surface that takes nothing but a table (a cell) reads it this way. */
25
+ export declare function tableFromClipboard(data: Clipboard | null | undefined): TableProps | undefined;
26
+ /**
27
+ * A rectangle as a `<table>`: the wire the HTML arm writes and reads. Its first row is
28
+ * a `<th>` row whatever row of the source it came from, that being the row index 0 a
29
+ * paste lands it at.
30
+ */
31
+ export declare function tableToDOM(block: TableCell[][], aligns: TableAlign[], owner: Document): Element;
32
+ /**
33
+ * A rectangle as pipe rows, delimiter row included: what a plain-text target reads as a
34
+ * table and what markdown carries. The delimiter follows the first row for the reason
35
+ * that row serializes as `<th>` — a pasted rectangle's row 0 is a header.
36
+ */
37
+ export declare function tableToPipe(block: TableCell[][], aligns: TableAlign[]): string;
38
+ /** Put a rectangle on the clipboard, both arms. The caller is a `copy` / `cut` handler
39
+ * over a selection its own surface holds, so the rectangle arrives already cut out and
40
+ * claiming the event is the caller's. */
41
+ export declare function writeRectangle(data: Clipboard, block: TableCell[][], aligns: TableAlign[], owner: Document): void;
42
+ /** The clipboard's parser: the schema's own rules with the table rule ahead of them.
43
+ * Ahead, because a `<table>` otherwise matches nothing and its cells' text falls
44
+ * through to the enclosing block. */
45
+ export declare function tableClipboardParser(schema: Schema): PMDOMParser;
46
+ /** Mint an id for every island a paste is carrying. An id is part of the document's
47
+ * canonical bytes and the channel that addresses an island (CODEC §Islands), so a
48
+ * pasted island with none reaches the store as an insert nothing can name afterwards.
49
+ * One minter for the whole slice, or a paste placing two tables hands them one id. */
50
+ export declare function stampIslandIds(slice: Slice, doc: PMNode): Slice;
51
+ /**
52
+ * The block leaf's clipboard plugin: the table rule on the way in, and the id mint over
53
+ * what it produced. A `clipboardParser` rather than the schema's own `parseDOM`,
54
+ * because what a `<table>` in the DOM means is a question only the clipboard asks — the
55
+ * schema's parse rules are also what a re-parse of the editor's own DOM runs through,
56
+ * where an island is already a node and a `<table>` is the chrome drawing one.
57
+ */
58
+ export declare function tableClipboardPlugin(schema: Schema): Plugin;
59
+ export {};
@@ -0,0 +1,202 @@
1
+ // The table island's clipboard door, both directions: what a copy writes, a paste
2
+ // reads. The design and the two wire formats are CODEC §"The table island" and
3
+ // §"Markdown at the edges"; what is here is the grammar.
4
+ //
5
+ // What crosses is A rectangle, never a cell. A `<table>` read in is one; the held cell
6
+ // selection written out is one; a rectangle overlaid onto a table at a cell is one. The
7
+ // wire therefore carries `TableCell[][]` and no coordinates: each end names its own
8
+ // corner.
9
+ //
10
+ // A row 0 is A header, on both sides, which is the model's own rule (`table.ts`
11
+ // §`allRows`): whichever row holds index 0 is the header. So a `<table>` with no `<th>`
12
+ // promotes its first row, and a copied rectangle's first row is the header of what it
13
+ // pastes.
14
+ import { DOMParser as PMDOMParser, DOMSerializer, Fragment, Slice } from 'prosemirror-model';
15
+ import { Plugin } from 'prosemirror-state';
16
+ import { islandMinter } from './islands.js';
17
+ import { inlineSchema } from './schema.js';
18
+ import { ALIGNS, cellContent, cellFromDoc, emptyCell, normalizeTable } from './table.js';
19
+ import { decode } from './decode.js';
20
+ /** The two keys, spelled once: a reader and a writer that disagreed about which holds
21
+ * the table would each work alone and never round-trip. */
22
+ const HTML = 'text/html';
23
+ const PLAIN = 'text/plain';
24
+ // ── Reading a table in ──────────────────────────────────────────────────────
25
+ /**
26
+ * One cell from its `<td>` / `<th>`. The inline schema is the whole normalization: it
27
+ * declares one paragraph, no containers and no islands, so a cell holding blocks
28
+ * flattens to its text and an image in one drops out rather than leaving a slot char
29
+ * behind. Whitespace collapses and the ends trim, which is PM's own parse.
30
+ */
31
+ function cellFromDOM(dom) {
32
+ const para = PMDOMParser.fromSchema(inlineSchema).parse(dom, {
33
+ topNode: inlineSchema.nodes.paragraph.create()
34
+ });
35
+ return cellFromDoc(inlineSchema.nodes.doc.create(null, para), emptyCell());
36
+ }
37
+ /** A column's alignment off its header cell, which is where a pipe table's delimiter
38
+ * row puts it. Anything else (`start`, `justify`, absent) is `none`. */
39
+ function alignFromDOM(dom) {
40
+ const raw = (dom.getAttribute('align') ??
41
+ dom.style?.textAlign ??
42
+ '').toLowerCase();
43
+ return ALIGNS.includes(raw) ? raw : 'none';
44
+ }
45
+ /**
46
+ * A `<table>` element as props. `rowspan` is not carried — a merged cell is not a
47
+ * rectangle, so a table with one arrives with the rows below it short and
48
+ * {@link normalizeTable} pads them at the end. `colspan` is, as the empty cells it
49
+ * covers, so the columns after it keep their index.
50
+ *
51
+ * Only the table's own rows: a `<tr>` inside a nested table belongs to a cell this
52
+ * reader is about to flatten, not to a rank of this one.
53
+ */
54
+ export function tableFromDOM(dom) {
55
+ const rows = [];
56
+ const aligns = [];
57
+ for (const tr of dom.querySelectorAll('tr')) {
58
+ if (tr.closest('table') !== dom)
59
+ continue;
60
+ const cells = [];
61
+ for (const box of tr.children) {
62
+ if (box.tagName !== 'TD' && box.tagName !== 'TH')
63
+ continue;
64
+ if (!rows.length)
65
+ aligns.push(alignFromDOM(box));
66
+ cells.push(cellFromDOM(box));
67
+ const span = Number(box.getAttribute('colspan')) || 1;
68
+ for (let i = 1; i < span; i++) {
69
+ if (!rows.length)
70
+ aligns.push('none');
71
+ cells.push(emptyCell());
72
+ }
73
+ }
74
+ rows.push(cells);
75
+ }
76
+ const [header = [], ...body] = rows;
77
+ return normalizeTable({ header, rows: body, aligns });
78
+ }
79
+ /** The table a paste is carrying, or `undefined` for a paste that holds none. The first
80
+ * one: a clipboard holding a table among other blocks is a document paste, and only a
81
+ * surface that takes nothing but a table (a cell) reads it this way. */
82
+ export function tableFromClipboard(data) {
83
+ const html = data?.getData(HTML);
84
+ if (!html || !/<table[\s>]/i.test(html))
85
+ return undefined;
86
+ const dom = new DOMParser().parseFromString(html, HTML).querySelector('table');
87
+ return dom ? tableFromDOM(dom) : undefined;
88
+ }
89
+ // ── Writing a rectangle out ─────────────────────────────────────────────────
90
+ /** A cell's inline DOM, serialized by the schema that declares its marks, which is what
91
+ * makes {@link cellFromDOM} its exact inverse. */
92
+ function cellToDOM(cell, owner) {
93
+ const doc = decode(cellContent(cell), inlineSchema);
94
+ const line = doc.firstChild;
95
+ return DOMSerializer.fromSchema(inlineSchema).serializeFragment(line ? line.content : Fragment.empty, { document: owner });
96
+ }
97
+ /**
98
+ * A rectangle as a `<table>`: the wire the HTML arm writes and reads. Its first row is
99
+ * a `<th>` row whatever row of the source it came from, that being the row index 0 a
100
+ * paste lands it at.
101
+ */
102
+ export function tableToDOM(block, aligns, owner) {
103
+ const table = owner.createElement('table');
104
+ block.forEach((cells, r) => {
105
+ const tr = owner.createElement('tr');
106
+ cells.forEach((cell, c) => {
107
+ const box = owner.createElement(r === 0 ? 'th' : 'td');
108
+ const align = aligns[c] ?? 'none';
109
+ if (align !== 'none')
110
+ box.style.textAlign = align;
111
+ box.appendChild(cellToDOM(cell, owner));
112
+ tr.appendChild(box);
113
+ });
114
+ table.appendChild(tr);
115
+ });
116
+ return table;
117
+ }
118
+ /** A cell's text as one pipe-table field: the delimiter escaped, since a `|` inside a
119
+ * cell would otherwise open a column that is not there. Marks are dropped, markdown
120
+ * being the lossy arm. */
121
+ const pipeCell = (cell) => cell.text.replaceAll('|', '\\|');
122
+ /**
123
+ * A rectangle as pipe rows, delimiter row included: what a plain-text target reads as a
124
+ * table and what markdown carries. The delimiter follows the first row for the reason
125
+ * that row serializes as `<th>` — a pasted rectangle's row 0 is a header.
126
+ */
127
+ export function tableToPipe(block, aligns) {
128
+ const width = block[0]?.length ?? 0;
129
+ const rule = Array.from({ length: width }, (_, c) => {
130
+ const align = aligns[c] ?? 'none';
131
+ if (align === 'center')
132
+ return ':---:';
133
+ return align === 'left' ? ':---' : align === 'right' ? '---:' : '---';
134
+ });
135
+ const line = (cells) => `| ${cells.join(' | ')} |`;
136
+ const [head = [], ...body] = block;
137
+ return [line(head.map(pipeCell)), line(rule), ...body.map((row) => line(row.map(pipeCell)))].join('\n');
138
+ }
139
+ /** Put a rectangle on the clipboard, both arms. The caller is a `copy` / `cut` handler
140
+ * over a selection its own surface holds, so the rectangle arrives already cut out and
141
+ * claiming the event is the caller's. */
142
+ export function writeRectangle(data, block, aligns, owner) {
143
+ data.setData(HTML, tableToDOM(block, aligns, owner).outerHTML);
144
+ data.setData(PLAIN, tableToPipe(block, aligns));
145
+ }
146
+ // ── The paste door ──────────────────────────────────────────────────────────
147
+ /** The `<table>` rule: a clipboard table becomes the island the model already holds
148
+ * rather than the paragraph its text flattens to. `id` is left empty for
149
+ * {@link stampIslandIds}, a parse rule having no document to mint against. `loss` is
150
+ * authored: what this reader produces is a pipe table, so markdown carries it whole. */
151
+ function tableParseRule() {
152
+ return {
153
+ tag: 'table',
154
+ node: 'island_block',
155
+ getAttrs: (dom) => ({
156
+ id: '',
157
+ islandType: 'table',
158
+ props: tableFromDOM(dom),
159
+ loss: 'lossless'
160
+ })
161
+ };
162
+ }
163
+ /** The clipboard's parser: the schema's own rules with the table rule ahead of them.
164
+ * Ahead, because a `<table>` otherwise matches nothing and its cells' text falls
165
+ * through to the enclosing block. */
166
+ export function tableClipboardParser(schema) {
167
+ return new PMDOMParser(schema, [tableParseRule(), ...PMDOMParser.fromSchema(schema).rules]);
168
+ }
169
+ /** An island node a parse rule produced, which is the only kind carrying no id. */
170
+ const unstamped = (node) => (node.type.name === 'island_block' || node.type.name === 'island_inline') && !node.attrs.id;
171
+ /** Mint an id for every island a paste is carrying. An id is part of the document's
172
+ * canonical bytes and the channel that addresses an island (CODEC §Islands), so a
173
+ * pasted island with none reaches the store as an insert nothing can name afterwards.
174
+ * One minter for the whole slice, or a paste placing two tables hands them one id. */
175
+ export function stampIslandIds(slice, doc) {
176
+ const mint = islandMinter(doc);
177
+ const walk = (fragment) => {
178
+ const out = [];
179
+ fragment.forEach((node) => {
180
+ const next = node.content.size ? node.copy(walk(node.content)) : node;
181
+ out.push(unstamped(next) ? next.type.create({ ...next.attrs, id: mint() }, next.content) : next);
182
+ });
183
+ return Fragment.fromArray(out);
184
+ };
185
+ return new Slice(walk(slice.content), slice.openStart, slice.openEnd);
186
+ }
187
+ /**
188
+ * The block leaf's clipboard plugin: the table rule on the way in, and the id mint over
189
+ * what it produced. A `clipboardParser` rather than the schema's own `parseDOM`,
190
+ * because what a `<table>` in the DOM means is a question only the clipboard asks — the
191
+ * schema's parse rules are also what a re-parse of the editor's own DOM runs through,
192
+ * where an island is already a node and a `<table>` is the chrome drawing one.
193
+ */
194
+ export function tableClipboardPlugin(schema) {
195
+ const parser = tableClipboardParser(schema);
196
+ return new Plugin({
197
+ props: {
198
+ clipboardParser: parser,
199
+ transformPasted: (slice, view) => stampIslandIds(slice, view.state.doc)
200
+ }
201
+ });
202
+ }