@quillmark/svelte 0.24.0 → 0.26.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 (57) hide show
  1. package/THEMING.md +1 -1
  2. package/dist/core/codec/blocks.d.ts +23 -6
  3. package/dist/core/codec/blocks.js +163 -38
  4. package/dist/core/codec/code.js +3 -8
  5. package/dist/core/codec/field.d.ts +4 -2
  6. package/dist/core/codec/field.js +13 -4
  7. package/dist/core/codec/index.d.ts +2 -0
  8. package/dist/core/codec/index.js +4 -0
  9. package/dist/core/codec/inputrules.js +40 -10
  10. package/dist/core/codec/keymap.js +19 -1
  11. package/dist/core/codec/line-paste.d.ts +7 -0
  12. package/dist/core/codec/line-paste.js +36 -0
  13. package/dist/core/codec/list-move.d.ts +6 -0
  14. package/dist/core/codec/list-move.js +30 -0
  15. package/dist/core/codec/list-paste.d.ts +2 -0
  16. package/dist/core/codec/list-paste.js +142 -0
  17. package/dist/core/codec/list-toggle.d.ts +4 -0
  18. package/dist/core/codec/list-toggle.js +197 -0
  19. package/dist/core/codec/lists.d.ts +22 -15
  20. package/dist/core/codec/lists.js +404 -97
  21. package/dist/core/codec/markdown-paste.d.ts +9 -0
  22. package/dist/core/codec/markdown-paste.js +63 -2
  23. package/dist/core/codec/nesting.d.ts +5 -1
  24. package/dist/core/codec/nesting.js +1 -1
  25. package/dist/core/codec/prose.css +21 -20
  26. package/dist/core/codec/slash.d.ts +2 -1
  27. package/dist/core/codec/slash.js +0 -0
  28. package/dist/core/codec/touching.d.ts +12 -0
  29. package/dist/core/codec/touching.js +33 -0
  30. package/dist/core/codec/undo.d.ts +5 -0
  31. package/dist/core/codec/undo.js +21 -0
  32. package/dist/core/codec/word-paste.d.ts +5 -0
  33. package/dist/core/codec/word-paste.js +87 -0
  34. package/dist/preview/Preview.svelte +88 -13
  35. package/dist/preview/Preview.svelte.d.ts +2 -2
  36. package/dist/preview/bridge.js +8 -2
  37. package/dist/preview/controller.d.ts +8 -4
  38. package/dist/preview/controller.js +10 -2
  39. package/dist/preview/index.js +1 -1
  40. package/dist/preview/paint.d.ts +6 -2
  41. package/dist/preview/paint.js +67 -56
  42. package/dist/preview/strings.d.ts +2 -0
  43. package/dist/preview/strings.js +4 -2
  44. package/dist/preview/viewport.d.ts +10 -0
  45. package/dist/preview/viewport.js +271 -0
  46. package/dist/preview/zoom.d.ts +41 -0
  47. package/dist/preview/zoom.js +52 -0
  48. package/dist/visual/ArrayField.svelte +24 -4
  49. package/dist/visual/DateField.svelte +26 -13
  50. package/dist/visual/FormatPopover.svelte +41 -2
  51. package/dist/visual/ProseValue.svelte +35 -3
  52. package/dist/visual/ProseValue.svelte.d.ts +7 -0
  53. package/dist/visual/icons/nodes.d.ts +26 -0
  54. package/dist/visual/icons/nodes.js +16 -0
  55. package/dist/visual/strings.d.ts +4 -2
  56. package/dist/visual/strings.js +9 -3
  57. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  <!--
2
2
  A `date` (or `datetime`) field → a styled segmented date field on bits-ui. The
3
- stored value is a string, `YYYY-MM-DD` or `today`; a cleared control commits as the
4
- focus leaves it, the blank `""` over a `default:`, which stays blank, and `undefined`
3
+ stored value is a string, `YYYY-MM-DD` or `today`; a cleared control commits as its
4
+ last segment empties, the blank `""` over a `default:`, which stays blank, and `undefined`
5
5
  (the unset rung) where nothing is declared, the parent removing the field. The value-object a date field lowers to is a render-time
6
6
  concern: the editor only sees the stored string.
7
7
 
@@ -48,12 +48,14 @@
48
48
  A segment edit that leaves a segment empty empties the primitive's value, which is
49
49
  held rather than committed while the focus stays inside: the first Backspace through
50
50
  a date writes nothing unset, and the empty segment shows its hint, not the default's
51
- digits beside the ones still standing. Leaving settles it: blank where every segment
52
- is empty, otherwise the stored date back. A field storing no date puts its segments
53
- back by remounting the primitive, which re-reads an unchanged empty value as nothing
54
- to sync.
51
+ digits beside the ones still standing. The keystroke that empties the last segment
52
+ writes the blank at once, so the render follows the clear without waiting on the
53
+ focus. Leaving with a segment filled puts the stored date back; a field storing no
54
+ date puts its segments back by remounting the primitive, which re-reads an unchanged
55
+ empty value as nothing to sync.
55
56
  -->
56
57
  <script lang="ts">
58
+ import { flushSync } from 'svelte';
57
59
  import { DateField as BitsDateField } from 'bits-ui';
58
60
  import { getLocalTimeZone, parseDate, today, type DateValue } from '@internationalized/date';
59
61
  import Icon from './icons/Icon.svelte';
@@ -164,18 +166,28 @@
164
166
  function enter(e: FocusEvent): void {
165
167
  if (!within(e) && value == null && fallbackDate) seat = true;
166
168
  }
169
+ const filled = (): boolean =>
170
+ [...(wrapEl?.querySelectorAll('[data-segment]') ?? [])].some(
171
+ (s) => s.getAttribute('data-segment') !== 'literal' && !s.hasAttribute('data-ghosted')
172
+ );
173
+ /** Every segment empty: the blank, written at once. */
174
+ function clear(): void {
175
+ const blank = fallbackDate ? '' : undefined;
176
+ if (value !== blank) onCommit(blank);
177
+ }
167
178
  function leave(e: FocusEvent): void {
168
179
  if (within(e)) return;
169
180
  seat = false;
170
181
  if (!held) return;
171
182
  held = false;
172
- const filled = [...(wrapEl?.querySelectorAll('[data-segment]') ?? [])].some(
173
- (s) => s.getAttribute('data-segment') !== 'literal' && !s.hasAttribute('data-ghosted')
174
- );
175
- const blank = fallbackDate ? '' : undefined;
176
- if (!filled) {
177
- if (value !== blank) onCommit(blank);
178
- } else if (!value) generation++;
183
+ if (!filled()) clear();
184
+ else if (!value) generation++;
185
+ }
186
+ /** The keystroke that empties the last segment, after the primitive has drawn it. */
187
+ function settle(): void {
188
+ if (!held) return;
189
+ flushSync();
190
+ if (!filled()) clear();
179
191
  }
180
192
 
181
193
  // What one segment prints, and which rung it takes.
@@ -249,6 +261,7 @@
249
261
  aria-describedby={describedBy}
250
262
  data-ghosted={ghost ? '' : undefined}
251
263
  data-default={seated || follows ? '' : undefined}
264
+ onkeydown={settle}
252
265
  >
253
266
  {#snippet children({ segments })}
254
267
  <!-- Keyed by index: `part` repeats (the `literal` separators between
@@ -12,7 +12,10 @@
12
12
  (`ProseValue`). This component never touches the content. The keymap mirror
13
13
  (Mod-b/i/u) lives in the codec's keymap (field.ts); this is the pointer
14
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
+ being this surface's: a document `keydown` the leaf's keymap left unclaimed. Over a
16
+ leaf that holds lists, two more buttons number or bullet every selected line, or take
17
+ the marker off where every one carries it: the codec's `toggleList`, whose keys
18
+ (Mod-Shift-7/8) the leaf's keymap binds.
16
19
 
17
20
  Selection observation. The view's `dispatchTransaction` is the leaf's
18
21
  (read-only), so there is no hook to observe transactions from outside.
@@ -72,7 +75,7 @@
72
75
  import { Popover } from 'bits-ui';
73
76
  import Icon from './icons/Icon.svelte';
74
77
  import type { IconName } from './icons/nodes.js';
75
- import { hasMarks, rangeAnchor, type RangeAnchor } from '../core/codec/index.js';
78
+ import { hasMarks, rangeAnchor, toggleList, type RangeAnchor } from '../core/codec/index.js';
76
79
  import type { ActiveProse } from './leaves.js';
77
80
  import { clearLink, hrefInSelection, normalizeHref, setLink } from './links.js';
78
81
  import { spacePx } from '../core/rungs.js';
@@ -96,7 +99,15 @@
96
99
  { name: 'link', icon: 'link', title: t.strings.formatLink }
97
100
  ]);
98
101
 
102
+ const LISTS: { ordered: boolean; icon: IconName; title: string }[] = $derived([
103
+ { ordered: true, icon: 'list-ordered', title: t.strings.formatNumberedList },
104
+ { ordered: false, icon: 'list', title: t.strings.formatBulletedList }
105
+ ]);
106
+
99
107
  let open = $state(false);
108
+ /** Whether the leaf under the selection holds lists: a body does, a one-line field
109
+ * and a table cell do not. */
110
+ let listable = $state(false);
100
111
  /** A pointer is down outside this surface: the selection is still being made. */
101
112
  let pressed = false;
102
113
  let linkPromptOpen = $state(false);
@@ -161,6 +172,7 @@
161
172
  return;
162
173
  }
163
174
  portalTarget = view.dom.closest<HTMLElement>('[data-qm-root]') ?? undefined;
175
+ listable = !!view.state.schema.nodes.list_item;
164
176
  const { from, to } = view.state.selection;
165
177
  anchor = rangeAnchor(view, from, to);
166
178
  open = true;
@@ -268,6 +280,14 @@
268
280
  sync();
269
281
  }
270
282
 
283
+ function toggleLines(ordered: boolean): void {
284
+ const view = activeLeafView();
285
+ if (!view) return;
286
+ toggleList(ordered)(view.state, view.dispatch);
287
+ view.focus();
288
+ sync();
289
+ }
290
+
271
291
  /** Every exit from the prompt: lower it, run `cmd` if there is one, and hand the
272
292
  * leaf back its focus and the surface its buttons. */
273
293
  function closePrompt(cmd?: Command): void {
@@ -421,6 +441,19 @@
421
441
  onclick={() => toggle(m.name)}><Icon name={m.icon} /></button
422
442
  >
423
443
  {/each}
444
+ {#if listable}
445
+ <span class="qm-format-divider" aria-hidden="true"></span>
446
+ {#each LISTS as l (l.icon)}
447
+ <button
448
+ type="button"
449
+ class="qm-icon-btn qm-mark-btn qm-focus-ring"
450
+ title={l.title}
451
+ aria-label={l.title}
452
+ onmousedown={keepFocus}
453
+ onclick={() => toggleLines(l.ordered)}><Icon name={l.icon} /></button
454
+ >
455
+ {/each}
456
+ {/if}
424
457
  </div>
425
458
  {/if}
426
459
  </div>
@@ -452,6 +485,12 @@
452
485
 
453
486
  No state for a mark the selection already carries: a run may carry one over part of
454
487
  itself, which a two-state button has no third face for. */
488
+ .qm-format-divider {
489
+ align-self: stretch;
490
+ width: 1px;
491
+ margin: var(--_qm-space-half) 0;
492
+ background: var(--_qm-rule);
493
+ }
455
494
  .qm-mark-btn {
456
495
  padding: var(--_qm-space) var(--_qm-space-2);
457
496
  color: var(--_qm-ink);
@@ -31,7 +31,9 @@
31
31
  declaring `multiline: true` takes the full row ({@link ObjectField}), a `richtext` one
32
32
  on the block schema and a `text` one on the plain schema, paragraphs and hard breaks.
33
33
  An array element is one textblock whatever it declares, since Enter there is the
34
- repeater's. There is no slash menu at either width, and no island view.
34
+ repeater's, and a paste of several lines into one is a row per line, as Enter would
35
+ open them, up to the array's `max:`, the last row taking what is left joined
36
+ (`lines`). There is no slash menu at either width, and no island view.
35
37
 
36
38
  A leaf over content it cannot edit is held (`fitsLeaf`): a narrowed one over anything
37
39
  but one plain paragraph, a plain one over anything but plain lines, where the decode
@@ -43,6 +45,7 @@
43
45
  -->
44
46
  <script lang="ts">
45
47
  import { untrack } from 'svelte';
48
+ import { Slice, type Node as PMNode } from 'prosemirror-model';
46
49
  import { EditorState, Selection } from 'prosemirror-state';
47
50
  import { EditorView } from 'prosemirror-view';
48
51
  import {
@@ -52,7 +55,9 @@
52
55
  emptyContent,
53
56
  fitsLeaf,
54
57
  heldAttributes,
58
+ joinLines,
55
59
  leafSchema,
60
+ pastedLines,
56
61
  pmToContent,
57
62
  proseAttributes,
58
63
  proseLeafPlugins,
@@ -103,6 +108,10 @@
103
108
  /** The declared `max:`, a character count no edit takes the leaf past. */
104
109
  cap?: TextCap;
105
110
  onChange: (rt: Content) => void;
111
+ /** Where a paste's lines past the first go: rows of their own below this one, at
112
+ * most `room()` of them, the last taking the rest joined. The row's own edit comes
113
+ * up beside them, the two one commit. Absent, the leaf joins them into its line. */
114
+ lines?: { room: () => number; onLines: (rt: Content, below: Content[]) => void };
106
115
  /** Raw keydown, for a container whose own keys run through this leaf: the
107
116
  * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
108
117
  * keymap, so the state it reads is the one this keystroke has yet to change. */
@@ -120,6 +129,7 @@
120
129
  placeholder,
121
130
  cap,
122
131
  onChange,
132
+ lines,
123
133
  onKey
124
134
  }: Props = $props();
125
135
 
@@ -177,6 +187,14 @@
177
187
  let seen = value;
178
188
  let defaulted = stored === undefined && !!fallback;
179
189
  const reconciler = createReconciler(shown(stored));
190
+ // The rows a paste's later lines become, held from the parse to the edit that
191
+ // lands the first line.
192
+ let spill: Content[] | undefined;
193
+ const row = (group: PMNode[]): Content => {
194
+ const { schema } = mounted.state;
195
+ const line = schema.nodes.paragraph.create(null, joinLines(group, schema));
196
+ return pmToContent(schema.node('doc', null, [line]));
197
+ };
180
198
  held = !fitsLeaf(reconciler.last, leaf);
181
199
  // Which of `aria-label` / `aria-labelledby` wins is the codec's one answer
182
200
  // (`proseAttributes`), so a cell carrying a label element and a row carrying
@@ -208,14 +226,28 @@
208
226
  const named = held ? heldNamed() : plainNamed();
209
227
  return defaulted ? { ...named, 'data-default': '' } : named;
210
228
  },
229
+ transformPasted: (slice) => {
230
+ spill = undefined;
231
+ if (!lines || held) return slice;
232
+ const pasted = pastedLines(slice);
233
+ const room = Math.min(pasted.length - 1, lines.room());
234
+ if (room < 1) return slice;
235
+ const below = pasted.slice(1);
236
+ spill = [...below.slice(0, room - 1).map((l) => row([l])), row(below.slice(room - 1))];
237
+ return new Slice(pasted[0]!.content, 0, 0);
238
+ },
211
239
  dispatchTransaction(tr) {
240
+ const event = tr.getMeta('uiEvent');
241
+ const below = event === 'paste' || event === 'drop' ? spill : undefined;
242
+ spill = undefined;
212
243
  const next = mounted.state.apply(tr);
213
244
  if (next === mounted.state) return;
214
245
  const edit = tr.docChanged && !held;
215
246
  if (edit) defaulted = false;
216
247
  mounted.updateState(next);
217
- if (!edit) return;
218
- onChange(pmToContent(next.doc));
248
+ if (!edit && !below) return;
249
+ if (below && lines) lines.onLines(pmToContent(next.doc), below);
250
+ else onChange(pmToContent(next.doc));
219
251
  // The commit as the store took it, so its echo through the re-derive is no move.
220
252
  seen = value;
221
253
  reconciler.commit(shown(content()));
@@ -35,6 +35,13 @@ interface Props {
35
35
  /** The declared `max:`, a character count no edit takes the leaf past. */
36
36
  cap?: TextCap;
37
37
  onChange: (rt: Content) => void;
38
+ /** Where a paste's lines past the first go: rows of their own below this one, at
39
+ * most `room()` of them, the last taking the rest joined. The row's own edit comes
40
+ * up beside them, the two one commit. Absent, the leaf joins them into its line. */
41
+ lines?: {
42
+ room: () => number;
43
+ onLines: (rt: Content, below: Content[]) => void;
44
+ };
38
45
  /** Raw keydown, for a container whose own keys run through this leaf: the
39
46
  * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
40
47
  * keymap, so the state it reads is the one this keystroke has yet to change. */
@@ -82,6 +82,32 @@ export declare const ICONS: {
82
82
  }], readonly ["path", {
83
83
  readonly d: "m8 6-6 6 6 6";
84
84
  }]];
85
+ readonly list: readonly [readonly ["path", {
86
+ readonly d: "M3 5h.01";
87
+ }], readonly ["path", {
88
+ readonly d: "M3 12h.01";
89
+ }], readonly ["path", {
90
+ readonly d: "M3 19h.01";
91
+ }], readonly ["path", {
92
+ readonly d: "M8 5h13";
93
+ }], readonly ["path", {
94
+ readonly d: "M8 12h13";
95
+ }], readonly ["path", {
96
+ readonly d: "M8 19h13";
97
+ }]];
98
+ readonly 'list-ordered': readonly [readonly ["path", {
99
+ readonly d: "M11 5h10";
100
+ }], readonly ["path", {
101
+ readonly d: "M11 12h10";
102
+ }], readonly ["path", {
103
+ readonly d: "M11 19h10";
104
+ }], readonly ["path", {
105
+ readonly d: "M4 4h1v5";
106
+ }], readonly ["path", {
107
+ readonly d: "M4 9h2";
108
+ }], readonly ["path", {
109
+ readonly d: "M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02";
110
+ }]];
85
111
  readonly link: readonly [readonly ["path", {
86
112
  readonly d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71";
87
113
  }], readonly ["path", {
@@ -41,6 +41,22 @@ export const ICONS = {
41
41
  ['path', { d: 'm16 18 6-6-6-6' }],
42
42
  ['path', { d: 'm8 6-6 6 6 6' }]
43
43
  ],
44
+ list: [
45
+ ['path', { d: 'M3 5h.01' }],
46
+ ['path', { d: 'M3 12h.01' }],
47
+ ['path', { d: 'M3 19h.01' }],
48
+ ['path', { d: 'M8 5h13' }],
49
+ ['path', { d: 'M8 12h13' }],
50
+ ['path', { d: 'M8 19h13' }]
51
+ ],
52
+ 'list-ordered': [
53
+ ['path', { d: 'M11 5h10' }],
54
+ ['path', { d: 'M11 12h10' }],
55
+ ['path', { d: 'M11 19h10' }],
56
+ ['path', { d: 'M4 4h1v5' }],
57
+ ['path', { d: 'M4 9h2' }],
58
+ ['path', { d: 'M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02' }]
59
+ ],
44
60
  link: [
45
61
  ['path', { d: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71' }],
46
62
  ['path', { d: 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' }]
@@ -79,6 +79,8 @@ export interface VisualStrings extends TableChromeStrings, SlashStrings {
79
79
  formatStrikethrough: string;
80
80
  formatCode: string;
81
81
  formatLink: string;
82
+ formatNumberedList: string;
83
+ formatBulletedList: string;
82
84
  /** The anchor button's accessible name. The popover withholds the button, so
83
85
  * nothing draws this or the title below. */
84
86
  formatAnchor: string;
@@ -145,8 +147,8 @@ export interface VisualStrings extends TableChromeStrings, SlashStrings {
145
147
  export type FormatDiagnostic = (d: Diagnostic) => string | undefined;
146
148
  /**
147
149
  * A `Mod` chord as the writer's platform prints it: `⌘B` on Apple's, `Ctrl+B`
148
- * elsewhere. The platform test is the one `prosemirror-keymap` resolves `Mod` by, so a
149
- * tooltip names the chord the keymap binds.
150
+ * elsewhere, and `Shift-7` as `⇧⌘7` or `Ctrl+Shift+7`. The platform test is the one
151
+ * `prosemirror-keymap` resolves `Mod` by, so a tooltip names the chord the keymap binds.
150
152
  */
151
153
  export declare function chord(key: string, platform?: string): string;
152
154
  /** The package's English. Every key, so the merge below is total. */
@@ -21,11 +21,15 @@ import { getContext, setContext } from 'svelte';
21
21
  import { DEFAULT_SLASH_STRINGS, DEFAULT_TABLE_STRINGS } from '../core/codec/index.js';
22
22
  /**
23
23
  * A `Mod` chord as the writer's platform prints it: `⌘B` on Apple's, `Ctrl+B`
24
- * elsewhere. The platform test is the one `prosemirror-keymap` resolves `Mod` by, so a
25
- * tooltip names the chord the keymap binds.
24
+ * elsewhere, and `Shift-7` as `⇧⌘7` or `Ctrl+Shift+7`. The platform test is the one
25
+ * `prosemirror-keymap` resolves `Mod` by, so a tooltip names the chord the keymap binds.
26
26
  */
27
27
  export function chord(key, platform = typeof navigator === 'undefined' ? '' : navigator.platform) {
28
- return /Mac|iP(hone|[oa]d)/.test(platform) ? `⌘${key}` : `Ctrl+${key}`;
28
+ const shift = key.startsWith('Shift-');
29
+ const bare = shift ? key.slice('Shift-'.length) : key;
30
+ if (/Mac|iP(hone|[oa]d)/.test(platform))
31
+ return `${shift ? '⇧' : ''}⌘${bare}`;
32
+ return `Ctrl+${shift ? 'Shift+' : ''}${bare}`;
29
33
  }
30
34
  /** The package's English. Every key, so the merge below is total. */
31
35
  export const DEFAULT_VISUAL_STRINGS = {
@@ -59,6 +63,8 @@ export const DEFAULT_VISUAL_STRINGS = {
59
63
  formatStrikethrough: 'Strikethrough',
60
64
  formatCode: 'Code',
61
65
  formatLink: `Link (${chord('K')})`,
66
+ formatNumberedList: `Numbered list (${chord('Shift-7')})`,
67
+ formatBulletedList: `Bulleted list (${chord('Shift-8')})`,
62
68
  formatAnchor: 'Anchor',
63
69
  formatAnchorTitle: 'Anchor — an identity handle over the selection',
64
70
  linkPlaceholder: 'https://…',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/svelte",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Editor, live-preview and source surfaces over a Quillmark WASM session.",
5
5
  "repository": {
6
6
  "type": "git",