@quillmark/svelte 0.12.0 → 0.14.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 (46) hide show
  1. package/NOTICE +3 -2
  2. package/dist/core/codec/decode.d.ts +17 -0
  3. package/dist/core/codec/decode.js +57 -4
  4. package/dist/core/codec/field.d.ts +63 -15
  5. package/dist/core/codec/field.js +201 -65
  6. package/dist/core/codec/index.d.ts +3 -3
  7. package/dist/core/codec/index.js +3 -3
  8. package/dist/core/codec/keymap.d.ts +5 -4
  9. package/dist/core/codec/keymap.js +5 -4
  10. package/dist/core/codec/prose.css +42 -0
  11. package/dist/core/codec/schema.d.ts +24 -5
  12. package/dist/core/codec/schema.js +40 -9
  13. package/dist/core/errors.d.ts +3 -2
  14. package/dist/core/theme.css +9 -6
  15. package/dist/preview/paint.js +21 -17
  16. package/dist/visual/ArrayField.svelte +66 -22
  17. package/dist/visual/ArrayField.svelte.d.ts +2 -0
  18. package/dist/visual/BooleanField.svelte +28 -6
  19. package/dist/visual/BooleanField.svelte.d.ts +2 -0
  20. package/dist/visual/Card.svelte +4 -3
  21. package/dist/visual/DateField.svelte +158 -40
  22. package/dist/visual/DateField.svelte.d.ts +5 -2
  23. package/dist/visual/EnumField.svelte +27 -9
  24. package/dist/visual/EnumField.svelte.d.ts +5 -0
  25. package/dist/visual/Field.svelte +48 -11
  26. package/dist/visual/FormatPopover.svelte +4 -3
  27. package/dist/visual/MatrixField.svelte +3 -2
  28. package/dist/visual/NumberField.svelte +73 -29
  29. package/dist/visual/NumberField.svelte.d.ts +5 -1
  30. package/dist/visual/ObjectField.svelte +63 -15
  31. package/dist/visual/ProseField.svelte +53 -23
  32. package/dist/visual/ProseField.svelte.d.ts +15 -3
  33. package/dist/visual/ProseValue.svelte +102 -46
  34. package/dist/visual/ProseValue.svelte.d.ts +17 -5
  35. package/dist/visual/TextField.svelte +35 -18
  36. package/dist/visual/TextField.svelte.d.ts +7 -0
  37. package/dist/visual/VariantField.svelte +1 -0
  38. package/dist/visual/VisualEditorInner.svelte +24 -24
  39. package/dist/visual/controls.css +16 -5
  40. package/dist/visual/icons/nodes.d.ts +28 -0
  41. package/dist/visual/icons/nodes.js +14 -1
  42. package/dist/visual/strings.d.ts +11 -1
  43. package/dist/visual/strings.js +3 -0
  44. package/dist/visual/structure.d.ts +77 -43
  45. package/dist/visual/structure.js +138 -64
  46. package/package.json +2 -2
@@ -5,30 +5,32 @@
5
5
  // (a) apply optimistically to the view,
6
6
  // (b) lower the tr to a `ChangeBundle` (or `overwrite` for a field not yet at
7
7
  // content rest, which has nothing to splice),
8
- // (c) commit via `doc.applyChange(addr, bundle)`,
8
+ // (c) commit via `doc.applyChange(addr, bundle)`, or a `plaintext` leaf's whole
9
+ // text through the typed writer,
9
10
  // (d) fire `onChange` when the transaction committed, then `onCaretMove` with
10
11
  // the new USV caret, which a bare selection move fires on its own.
11
12
  // On an `applyChange` throw the optimistic PM state stays and the failure reports
12
13
  // through `onError`: never a crash. Caret continuity across own-edits is the PM
13
14
  // `StepMap`; an external content change re-hydrates through `applyExternal`, gated
14
- // by `reconcile`.
15
+ // by `reconcile` except where the hold it re-evaluates flips.
15
16
  import { baseKeymap, toggleMark } from 'prosemirror-commands';
16
17
  import { gapCursor } from 'prosemirror-gapcursor';
17
18
  import { history, redo, undo } from 'prosemirror-history';
18
19
  import { keymap } from 'prosemirror-keymap';
20
+ import { Fragment, Slice } from 'prosemirror-model';
19
21
  import { EditorState, NodeSelection, Plugin, PluginKey, Selection, TextSelection } from 'prosemirror-state';
20
22
  import { Decoration, DecorationSet, EditorView } from 'prosemirror-view';
21
23
  import { reportError, errorMessage } from '../errors.js';
22
- import { decode } from './decode.js';
24
+ import { decode, fitsLeaf } from './decode.js';
23
25
  import { usvToPM, pmToUsv, buildLineIndex, lineIndexOf } from './positions.js';
24
26
  import { lower, pmToContent, scanContent, scanDoc, contentEdit } from './encode.js';
25
27
  import { islandPastePlugin } from './islands.js';
26
28
  import { anchorsFromContent } from './marks.js';
27
- import { createReconciler } from './reconcile.js';
29
+ import { contentEqual, createReconciler } from './reconcile.js';
28
30
  import { inputRulesPlugin } from './inputrules.js';
29
31
  import { linebreakPlugin } from './breaks.js';
30
32
  import { bodyKeymap } from './keymap.js';
31
- import { blockSchema, inlineSchema, plaintextSchema } from './schema.js';
33
+ import { blockSchema, isBlockSchema, leafSchema, plainSchema } from './schema.js';
32
34
  import { DEFAULT_TABLE_STRINGS, tableNodeView } from './table-view.js';
33
35
  import { focusSlashItem, runSlashItem, slashPlugin } from './slash.js';
34
36
  const anchorKey = new PluginKey('quill-anchors');
@@ -84,13 +86,10 @@ export function emptyContent() {
84
86
  * `applyChange` splices exactly the content the leaf read and PM is showing.
85
87
  *
86
88
  * The two other rest forms both take `overwrite` instead. An unset field (`undefined`;
87
- * a default-only richtext field before its first edit) has nothing to splice.
88
- * An authored string is the trap: `applyChange` reads it as markdown whatever the
89
- * declared type is, so on a `plaintext` field its pre-image is not the content the
90
- * leaf read and a delta computed against that content lands at the wrong offsets.
91
- * Installing over it costs nothing either way, since content-only marks do not
92
- * survive markdown and a string therefore carries no anchors to pay. A body always
93
- * rests as `Content`. */
89
+ * a default-only richtext field before its first edit) has nothing to splice, and an
90
+ * authored string nothing to lose: content-only marks do not survive markdown, so a
91
+ * string carries no anchors to pay. A body always rests as `Content`. A `plaintext`
92
+ * leaf never asks: it commits through the writer ({@link createField}). */
94
93
  function opsCommittable(doc, addr) {
95
94
  const stored = doc.getStored(addr);
96
95
  return stored !== null && typeof stored === 'object';
@@ -111,25 +110,46 @@ export function proseAttributes(opts) {
111
110
  attrs['aria-describedby'] = opts.describedBy;
112
111
  return Object.keys(attrs).length ? attrs : undefined;
113
112
  }
113
+ /** A held leaf's attributes, described by its note as well. A non-editable view is no
114
+ * textbox to assistive tech or to Tab, so a held leaf states both itself. */
115
+ export function heldAttributes(opts, noteId) {
116
+ const describedBy = [opts.describedBy, noteId].filter(Boolean).join(' ') || undefined;
117
+ return {
118
+ ...proseAttributes({ ...opts, describedBy }),
119
+ role: 'textbox',
120
+ 'aria-readonly': 'true',
121
+ 'aria-multiline': 'true',
122
+ tabindex: '0'
123
+ };
124
+ }
114
125
  export function createField(opts) {
115
126
  const { doc, addr, container } = opts;
116
- const inline = !!opts.inline || !!opts.plaintext;
127
+ const inline = !!opts.inline;
117
128
  const plaintext = !!opts.plaintext;
118
129
  // The declared type picks the schema, and the schema is the whole of what
119
130
  // `plaintext` suppresses: no mark types to toggle, to paste in, or to mint a
120
131
  // shorthand with (`schema.ts`).
121
- const schema = plaintext ? plaintextSchema : inline ? inlineSchema : blockSchema;
132
+ const schema = leafSchema({ plaintext, inline });
133
+ const block = isBlockSchema(schema);
122
134
  // Bound once and held: the reader is a `{quill, doc}` pair that reads live, so
123
135
  // every read below sees the commit before it.
124
136
  const reader = opts.quill.reader(doc);
125
- // `known` is the codec's view of the stored content: kept in sync after every
137
+ const writer = opts.quill.writer(doc);
138
+ let fallback = opts.fallback;
139
+ /** What the leaf holds: the stored content, else the default an unset field shows. */
140
+ const shown = () => reader.getContent(addr) ?? fallback ?? emptyContent();
141
+ /** Whether the leaf holds a default nobody has written: the `data-default` mark. */
142
+ const unwritten = () => !!fallback && doc.getStored(addr) == null;
143
+ let defaulted = unwritten();
144
+ // `known` is the codec's view of what the leaf holds: kept in sync after every
126
145
  // own-edit so `reconcile` can tell an external change from the field's own.
127
- const reconciler = createReconciler(readLeaf(reader, addr));
146
+ const reconciler = createReconciler(shown());
128
147
  let index; // rebuilt on every structural change
129
148
  let view;
130
- // The ghost's live cell: the placeholder plugin reads it per pass, so moving it
131
- // is an assignment plus a re-render rather than a rebuilt plugin stack.
149
+ // The ghosts' live cells: the ghost plugin reads them per pass, so moving one is
150
+ // an assignment plus a re-render rather than a rebuilt plugin stack.
132
151
  let placeholderText = opts.placeholder;
152
+ let exampleText = opts.example;
133
153
  // The views nested inside this leaf: one per table cell (`table-view.ts`). The
134
154
  // leaf holds the set because chrome asks the leaf, not the island, which view
135
155
  // holds the caret: the format popover raises over a cell exactly as over the
@@ -137,7 +157,15 @@ export function createField(opts) {
137
157
  const nested = new Set();
138
158
  // The menu's report channel, and the whole of what a leaf holds of it: the
139
159
  // vocabulary is the codec's own constant (`slash.ts`), not a wording to derive.
140
- const slash = inline ? undefined : opts.onSlash;
160
+ const slash = block ? opts.onSlash : undefined;
161
+ // Judged of the value being mounted rather than read off a diagnostic: a routed set
162
+ // can predate the value it arrives with, and `validation::not_plain` stands in for
163
+ // `not_inline` on a `plaintext(inline)` field.
164
+ const holds = (rt) => !fitsLeaf(rt, { inline, plaintext });
165
+ let held = holds(reconciler.last);
166
+ const named = proseAttributes(opts) ?? {};
167
+ const defaultNamed = { ...named, 'data-default': '' };
168
+ const heldNamed = heldAttributes(opts, opts.heldNoteId);
141
169
  const seeded = buildState(reconciler.last);
142
170
  const state = seeded.state;
143
171
  index = seeded.index;
@@ -150,17 +178,19 @@ export function createField(opts) {
150
178
  // where `check:style` cannot read it.
151
179
  const lineBox = Number.parseFloat(getComputedStyle(container).lineHeight);
152
180
  const clearance = Number.isFinite(lineBox) ? lineBox : undefined;
153
- view = new EditorView(container, {
181
+ // Prepended, so what the caller draws inside `container` (a held leaf's note)
182
+ // follows the text it is about.
183
+ view = new EditorView((dom) => container.prepend(dom), {
154
184
  state,
155
- attributes: proseAttributes(opts),
185
+ editable: () => !held,
186
+ attributes: () => (held ? heldNamed : defaulted ? defaultNamed : named),
156
187
  // Both terms, one line each: when a flagged dispatch moves the scrollport
157
188
  // (`scrollThreshold`) and what it leaves at the edge (`scrollMargin`).
158
189
  scrollThreshold: clearance,
159
190
  scrollMargin: clearance,
160
- // Islands are block-schema only, so an inline leaf mounts no node view at all.
161
- nodeViews: inline
162
- ? undefined
163
- : {
191
+ // Islands are block-schema only, so any other leaf mounts no node view at all.
192
+ nodeViews: block
193
+ ? {
164
194
  island_block: tableNodeView({
165
195
  strings: opts.tableStrings ?? (() => DEFAULT_TABLE_STRINGS),
166
196
  register: (cellView) => {
@@ -173,10 +203,15 @@ export function createField(opts) {
173
203
  onCellFocus: () => opts.onFocus?.(addr),
174
204
  clearance
175
205
  })
176
- },
206
+ }
207
+ : undefined,
177
208
  dispatchTransaction: (tr) => {
178
209
  const oldRt = reconciler.last;
179
210
  const next = view.state.apply(tr);
211
+ // An edit takes a held default: the commit below writes the whole leaf, so the
212
+ // mark goes in the same update that draws the edit.
213
+ if (!held && (tr.docChanged || tr.getMeta(anchorKey)))
214
+ defaulted = false;
180
215
  view.updateState(next); // (a) optimistic
181
216
  // One walk per structural change: the index and the projection the commit
182
217
  // diffs both come off it. A selection-only transaction leaves the document
@@ -187,7 +222,7 @@ export function createField(opts) {
187
222
  // Commit a content edit or an anchor mutation. An anchor insert/remove is
188
223
  // zero-width, so `docChanged` is false: the `anchorKey` meta is what
189
224
  // routes it through the same commit path (the diff emits the anchor op).
190
- if (tr.docChanged || tr.getMeta(anchorKey)) {
225
+ if (!held && (tr.docChanged || tr.getMeta(anchorKey))) {
191
226
  // (d) the change signal, only for what actually landed.
192
227
  const newRt = scan ? scanContent(scan) : pmToContent(next.doc);
193
228
  if (commitEdit(oldRt, newRt))
@@ -209,6 +244,13 @@ export function createField(opts) {
209
244
  /** The state, and the index over the document it was built from: the seed reads one
210
245
  * and every caller wants the same one, and `buildLineIndex` is a whole walk. */
211
246
  function buildState(rt) {
247
+ if (held) {
248
+ const pmDoc = decode(rt, blockSchema);
249
+ return {
250
+ state: EditorState.create({ doc: pmDoc }),
251
+ index: buildLineIndex(pmDoc)
252
+ };
253
+ }
212
254
  const pmDoc = decode(rt, schema);
213
255
  const anchors = plaintext ? [] : anchorsFromContent(rt);
214
256
  // Seed anchor plugin positions in PM coords via a fresh index over pmDoc.
@@ -226,6 +268,8 @@ export function createField(opts) {
226
268
  // Always installed, so a leaf that mounts without a ghost can still be
227
269
  // given one later; the plugin draws nothing while the text is empty.
228
270
  placeholder: () => placeholderText,
271
+ placeholderUntilEdit: opts.placeholderUntilEdit,
272
+ example: () => exampleText,
229
273
  afterHistory: [anchorPlugin(seededAnchors)]
230
274
  })
231
275
  });
@@ -236,14 +280,16 @@ export function createField(opts) {
236
280
  // Returns whether anything landed: the caller's change signal, which must not
237
281
  // be a property of which branch the commit took.
238
282
  function commitEdit(oldRt, newRt) {
283
+ if (plaintext)
284
+ return commitPlain(newRt.text);
239
285
  // The text splice, computed once per keystroke: the gate below, both overwrite
240
286
  // fallbacks, and `lower` all read this one `edit`. The projection is the
241
287
  // caller's, off the same walk that rebuilt the index.
242
288
  const edit = contentEdit(oldRt, newRt);
243
289
  try {
244
290
  // A field not yet at content rest is the only edit that overwrites by choice:
245
- // `applyChange` throws on an absent declared field (verified) and mis-reads
246
- // an authored string (`opsCommittable`), and neither has anchors to lose.
291
+ // `applyChange` throws on an absent declared field (verified), and neither an
292
+ // absent field nor an authored string has anchors to lose (`opsCommittable`).
247
293
  // Every structural edit lowers, island creation included: the island channel
248
294
  // places a slot the `delta` may not carry.
249
295
  if (!opsCommittable(doc, addr)) {
@@ -252,8 +298,7 @@ export function createField(opts) {
252
298
  else {
253
299
  // Post-edit anchors are the plugin's positions (mapped through the tr) as
254
300
  // USV; the pre-edit set is `oldRt`'s own, which `lower` reads for itself.
255
- const newAnchors = plaintext ? [] : readAnchorsUsv();
256
- doc.applyChange(addr, lower(edit, { newAnchors }));
301
+ doc.applyChange(addr, lower(edit, { newAnchors: readAnchorsUsv() }));
257
302
  }
258
303
  reconciler.commit(readLeaf(reader, addr));
259
304
  return true;
@@ -287,6 +332,35 @@ export function createField(opts) {
287
332
  }
288
333
  }
289
334
  }
335
+ /**
336
+ * A `plaintext` leaf's commit: its whole text through the typed writer, which rests
337
+ * the field as that literal string (CODEC §Inline mode). A content object is the
338
+ * other rest form, and `toMarkdown` emits one as markdown: a break spelled as a
339
+ * backslash, a `*` and a trailing space escaped, a blank line doubled, so the value
340
+ * reads back changed. The leaf carries no anchors, so the op path has none to keep.
341
+ *
342
+ * A refusal lands nothing and has no fallback, the other rest form being the
343
+ * corruption; the next edit writes the whole text again.
344
+ */
345
+ function commitPlain(text) {
346
+ try {
347
+ const name = addr.field;
348
+ if (name === undefined)
349
+ throw new Error('a plaintext leaf names a field');
350
+ (addr.card === undefined ? writer : writer.card(addr.card)).set(name, text);
351
+ reconciler.commit(readLeaf(reader, addr));
352
+ return true;
353
+ }
354
+ catch (e) {
355
+ reportError(opts.onError, {
356
+ code: 'commit-lost',
357
+ severity: 'error',
358
+ message: `the typed writer refused the text; the stored value is stale while the editor keeps the edit: ${errorMessage(e)}`,
359
+ cause: e
360
+ });
361
+ return false;
362
+ }
363
+ }
290
364
  /** The anchors the plugin currently holds (PM coords); `[]` before the first apply. */
291
365
  const heldAnchors = () => anchorKey.getState(view.state) ?? [];
292
366
  /** The plugin's anchor positions as USV, against `index` — which `dispatchTransaction`
@@ -313,17 +387,28 @@ export function createField(opts) {
313
387
  view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
314
388
  },
315
389
  applyExternal() {
316
- const current = readLeaf(reader, addr);
317
- if (!reconciler.shouldRehydrate(current))
318
- return; // own edit / no change
390
+ const current = shown();
391
+ const flipped = holds(current) !== held;
392
+ const wasDefaulted = defaulted;
393
+ defaulted = unwritten();
394
+ if (!flipped && !reconciler.shouldRehydrate(current)) {
395
+ // Own edit / no change; a default written as itself changes the mark alone.
396
+ if (defaulted !== wasDefaulted)
397
+ view.setProps({});
398
+ return;
399
+ }
400
+ if (flipped)
401
+ held = !held;
319
402
  const caretUsv = pmToUsv(index, view.state.selection.head);
320
403
  const fresh = buildState(current);
321
- view.updateState(fresh.state);
322
404
  index = fresh.index;
323
- // Best-effort caret continuity across an external change: keep the USV.
324
- const pm = usvToPM(index, caretUsv);
325
- view.dispatch(view.state.tr.setSelection(Selection.near(fresh.state.doc.resolve(pm))));
405
+ // Best-effort caret continuity across an external change: keep the USV. Set
406
+ // rather than dispatched, so a leaf the caret is not in reports no caret move.
407
+ const sel = Selection.near(fresh.state.doc.resolve(usvToPM(index, caretUsv)));
408
+ view.updateState(fresh.state.apply(fresh.state.tr.setSelection(sel)));
326
409
  reconciler.commit(current);
410
+ if (flipped)
411
+ opts.onHold?.(held);
327
412
  },
328
413
  setPlaceholder(text) {
329
414
  if (text === placeholderText)
@@ -333,6 +418,18 @@ export function createField(opts) {
333
418
  // transaction, so nothing commits and no caret is reported.
334
419
  view.setProps({});
335
420
  },
421
+ setExample(text) {
422
+ if (text === exampleText)
423
+ return;
424
+ exampleText = text;
425
+ view.setProps({});
426
+ },
427
+ setFallback(content) {
428
+ if (content === fallback || (content && fallback && contentEqual(content, fallback)))
429
+ return;
430
+ fallback = content;
431
+ controller.applyExternal();
432
+ },
336
433
  focus() {
337
434
  view.focus();
338
435
  // The placement rung's reveal: no caret to flag a transaction with, so the
@@ -344,8 +441,9 @@ export function createField(opts) {
344
441
  return readLeaf(reader, addr);
345
442
  },
346
443
  insertAnchor(id, pos) {
347
- if (plaintext)
348
- return; // a plaintext field carries no marks (§Inline mode)
444
+ // A plaintext field carries no marks (§Inline mode), and a held one commits nothing.
445
+ if (plaintext || held)
446
+ return;
349
447
  if (heldAnchors().some((a) => a.id === id))
350
448
  return; // ids are unique + invariant (0.97 policy)
351
449
  // An anchor commits through `applyChange`, so the field is brought to content
@@ -401,11 +499,13 @@ export function createField(opts) {
401
499
  return cellView;
402
500
  return view;
403
501
  };
502
+ if (held)
503
+ opts.onHold?.(true);
404
504
  return controller;
405
505
  }
406
506
  /**
407
507
  * The prose-leaf plugin stack (VISUAL_EDITOR §Surface): shared by
408
- * {@link createField} and the by-value inline editor (`ProseValue`), so the two
508
+ * {@link createField} and the by-value editor (`ProseValue`), so the two
409
509
  * never fork the keymap/plugin ordering. History first, then any leaf-specific
410
510
  * plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
411
511
  * a by-value leaf passes none), then {@link linebreakPlugin}, which normalizes what
@@ -414,10 +514,10 @@ export function createField(opts) {
414
514
  * shapes: the gap cursor, {@link pastAtomPlugin}, and the island paste pass
415
515
  * (`islands.ts`).
416
516
  *
417
- * Every mark-shaped plugin reads the schema rather than a flag: over
418
- * `plaintextSchema` the shorthand rules build nothing (each is guarded on its mark
419
- * type) and the toggles bind no key, so a `**bold**` keeps the delimiters its author
420
- * typed without a second rule saying so.
517
+ * Every mark-shaped plugin reads the schema rather than a flag: over either plaintext
518
+ * schema the shorthand rules build nothing (each is guarded on its mark type) and the
519
+ * toggles bind no key, so a `**bold**` keeps the delimiters its author typed without
520
+ * a second rule saying so.
421
521
  */
422
522
  export function proseLeafPlugins(schema, opts) {
423
523
  const list = [history(), ...(opts.afterHistory ?? []), linebreakPlugin(schema)];
@@ -427,14 +527,33 @@ export function proseLeafPlugins(schema, opts) {
427
527
  list.push(inputRulesPlugin(schema));
428
528
  list.push(keymap(editorKeymap(schema, opts.inline, !!opts.slash)));
429
529
  list.push(keymap(baseKeymap));
430
- // All three answer to something only a block leaf holds: an inline leaf is one
431
- // textblock with no island and no gap to put a cursor in.
432
- if (!opts.inline)
530
+ // All three answer to something only the block schema holds: any other leaf is
531
+ // textblocks alone, with no island and no gap to put a cursor in.
532
+ if (isBlockSchema(schema))
433
533
  list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin());
434
- if (opts.placeholder)
435
- list.push(placeholderPlugin(opts.placeholder));
534
+ if (schema === plainSchema)
535
+ list.push(plainClipboardPlugin());
536
+ if (opts.placeholder || opts.example)
537
+ list.push(ghostPlugin(opts));
436
538
  return list;
437
539
  }
540
+ /**
541
+ * A plain leaf's clipboard text is literal, one line per `\n` both ways. Upstream's
542
+ * defaults read pasted text as blocks split on any run of newlines, which drops a
543
+ * blank line the value holds as content, and write a copy's blocks joined by a blank
544
+ * line and its breaks as nothing.
545
+ */
546
+ function plainClipboardPlugin() {
547
+ const { paragraph } = plainSchema.nodes;
548
+ return new Plugin({
549
+ props: {
550
+ clipboardTextParser: (text) => new Slice(Fragment.from(text
551
+ .split(/\r\n?|\n/)
552
+ .map((line) => paragraph.create(null, line ? plainSchema.text(line) : null))), 1, 1),
553
+ clipboardTextSerializer: (slice) => slice.content.textBetween(0, slice.content.size, '\n', '\n')
554
+ }
555
+ });
556
+ }
438
557
  /**
439
558
  * A printable key over a selected atom writes past it. A selection is the subject
440
559
  * of the next command, never a thing armed for
@@ -471,33 +590,50 @@ function pastAtomPlugin() {
471
590
  });
472
591
  }
473
592
  /**
474
- * The empty-leaf ghost placeholder. A node decoration stamps the sole empty
475
- * textblock with a class + the ghost text,
476
- * which CSS renders via `::before { content: attr(data-placeholder) }`: so the
477
- * text never enters the document, the caret path, or a `pmToContent` export. It
593
+ * The empty leaf's ghosts. A node decoration stamps the sole empty textblock with a
594
+ * class and the ghost text, which `prose.css` draws from the attributes: so the text
595
+ * never enters the document, the caret path, or a `pmToContent` export, and it
478
596
  * vanishes the instant the leaf holds any content (the emptiness test fails).
479
597
  *
480
- * `read` is called per decoration pass rather than closed over, so moving the
481
- * ghost is a re-render and never a document edit.
598
+ * `data-placeholder` is drawn at rest, and `data-example` where there is none, and in
599
+ * its stead while the view holds the focus. The example is stamped only until the
600
+ * state's first edit, after which the leaf has been answered, and so is a placeholder
601
+ * under `placeholderUntilEdit`. An inline leaf's
602
+ * ghost keeps to the one line the leaf is (`qm-prose-placeholder-line`).
603
+ *
604
+ * The texts are read per decoration pass rather than closed over, so moving a ghost is
605
+ * a re-render and never a document edit.
482
606
  */
483
- function placeholderPlugin(read) {
607
+ function ghostPlugin(opts) {
608
+ const key = new PluginKey('qm-ghost');
484
609
  return new Plugin({
610
+ key,
611
+ state: {
612
+ init: () => false,
613
+ apply: (tr, edited) => edited || tr.docChanged
614
+ },
485
615
  props: {
486
616
  decorations(state) {
487
- const text = read();
488
- if (!text)
617
+ const edited = key.getState(state);
618
+ const text = edited && opts.placeholderUntilEdit ? undefined : opts.placeholder?.();
619
+ const example = edited ? undefined : opts.example?.();
620
+ if (!text && !example)
489
621
  return null;
490
622
  const { doc } = state;
491
623
  const first = doc.firstChild;
492
624
  const empty = doc.childCount === 1 && !!first && first.isTextblock && first.content.size === 0;
493
625
  if (!empty)
494
626
  return null;
495
- return DecorationSet.create(doc, [
496
- Decoration.node(0, first.nodeSize, {
497
- class: 'qm-prose-placeholder',
498
- 'data-placeholder': text
499
- })
500
- ]);
627
+ const attrs = {
628
+ class: opts.inline
629
+ ? 'qm-prose-placeholder qm-prose-placeholder-line'
630
+ : 'qm-prose-placeholder'
631
+ };
632
+ if (text)
633
+ attrs['data-placeholder'] = text;
634
+ if (example)
635
+ attrs['data-example'] = example;
636
+ return DecorationSet.create(doc, [Decoration.node(0, first.nodeSize, attrs)]);
501
637
  }
502
638
  }
503
639
  });
@@ -3,15 +3,15 @@ import 'prosemirror-gapcursor/style/gapcursor.css';
3
3
  import './prose.css';
4
4
  export { rangeAnchor } from './anchor.js';
5
5
  export type { RangeAnchor } from './anchor.js';
6
- export { createField, emptyContent, proseAttributes, proseLeafPlugins } from './field.js';
6
+ export { createField, emptyContent, heldAttributes, proseAttributes, proseLeafPlugins } from './field.js';
7
7
  export type { CreateFieldOpts, FieldController, LeafViews } from './field.js';
8
8
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
9
9
  export type { TableChromeStrings } from './table-view.js';
10
10
  export { DEFAULT_SLASH_STRINGS } from './slash.js';
11
11
  export type { SlashState, SlashStrings } from './slash.js';
12
- export { blockSchema, inlineSchema, plaintextSchema, hasMarks } from './schema.js';
12
+ export { blockSchema, inlineSchema, plaintextSchema, plainSchema, leafSchema, hasMarks } from './schema.js';
13
13
  export { rendersHref, storableUrl } from './urls.js';
14
- export { decode, renderContent, usvLength } from './decode.js';
14
+ export { decode, fitsInline, fitsLeaf, fitsPlain, renderContent, usvLength } from './decode.js';
15
15
  export { pmToContent, contentEdit, lower } from './encode.js';
16
16
  export type { ContentEdit } from './encode.js';
17
17
  export { usvToPM, pmToUsv, buildLineIndex } from './positions.js';
@@ -13,7 +13,7 @@ import './prose.css';
13
13
  // selection (`visual/FormatPopover.svelte`), so it is reached off-barrel.
14
14
  export { rangeAnchor } from './anchor.js';
15
15
  // The prose leaf.
16
- export { createField, emptyContent, proseAttributes, proseLeafPlugins } from './field.js';
16
+ export { createField, emptyContent, heldAttributes, proseAttributes, proseLeafPlugins } from './field.js';
17
17
  // The table island's chrome vocabulary: the visual tier's `strings` set extends it,
18
18
  // so the island's wording is overridden beside every other key.
19
19
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
@@ -22,7 +22,7 @@ export { DEFAULT_TABLE_STRINGS } from './table-view.js';
22
22
  export { DEFAULT_SLASH_STRINGS } from './slash.js';
23
23
  // Schemas (the decode/encode target; the VisualEditor mounts them). `hasMarks` is
24
24
  // what a mark surface asks before offering itself over a leaf.
25
- export { blockSchema, inlineSchema, plaintextSchema, hasMarks } from './schema.js';
25
+ export { blockSchema, inlineSchema, plaintextSchema, plainSchema, leafSchema, hasMarks } from './schema.js';
26
26
  // The url edge: `rendersHref` is the link gate the prompt re-asks on submit, and
27
27
  // `storableUrl` the encode the store's own refusal requires of the value it takes
28
28
  // (`visual/links.ts`).
@@ -32,7 +32,7 @@ export { rendersHref, storableUrl } from './urls.js';
32
32
  // The barrel carries what has an off-barrel caller, and nothing else. A symbol
33
33
  // reached only by relative import within `codec/` stays off it: an export nothing
34
34
  // imports is surface that still has to stay honest.
35
- export { decode, renderContent, usvLength } from './decode.js';
35
+ export { decode, fitsInline, fitsLeaf, fitsPlain, renderContent, usvLength } from './decode.js';
36
36
  export { pmToContent, contentEdit, lower } from './encode.js';
37
37
  export { usvToPM, pmToUsv, buildLineIndex } from './positions.js';
38
38
  // Reconciliation gate.
@@ -1,9 +1,10 @@
1
1
  import type { Schema } from 'prosemirror-model';
2
2
  import type { Command } from 'prosemirror-state';
3
- /** The block-schema body's structural keys: `{}` for the inline/plaintext schemas,
4
- * whose leaves declare neither lists nor code blocks. The argument list is the
5
- * precedence order VISUAL_EDITOR §Chrome states, so a new link is one more argument
6
- * at its place in that order rather than a nesting to read inside out.
3
+ /** A multi-block leaf's structural keys, each link binding only what its schema
4
+ * declares: over `plainSchema`, which holds neither lists nor code blocks nor islands,
5
+ * the block link and the break link alone. The argument list is the precedence order
6
+ * VISUAL_EDITOR §Chrome states, so a new link is one more argument at its place in that
7
+ * order rather than a nesting to read inside out.
7
8
  *
8
9
  * The slash menu is innermost of the three, and it is conditional rather than
9
10
  * schema-gated: the keys exist only where a menu can be drawn (`slash`), because Enter
@@ -17,10 +17,11 @@ function chainKeymaps(...maps) {
17
17
  }
18
18
  return out;
19
19
  }
20
- /** The block-schema body's structural keys: `{}` for the inline/plaintext schemas,
21
- * whose leaves declare neither lists nor code blocks. The argument list is the
22
- * precedence order VISUAL_EDITOR §Chrome states, so a new link is one more argument
23
- * at its place in that order rather than a nesting to read inside out.
20
+ /** A multi-block leaf's structural keys, each link binding only what its schema
21
+ * declares: over `plainSchema`, which holds neither lists nor code blocks nor islands,
22
+ * the block link and the break link alone. The argument list is the precedence order
23
+ * VISUAL_EDITOR §Chrome states, so a new link is one more argument at its place in that
24
+ * order rather than a nesting to read inside out.
24
25
  *
25
26
  * The slash menu is innermost of the three, and it is conditional rather than
26
27
  * schema-gated: the keys exist only where a menu can be drawn (`slash`), because Enter
@@ -71,6 +71,14 @@ li.ProseMirror-selectednode::after {
71
71
  margin-top: var(--_qm-space-3);
72
72
  }
73
73
 
74
+ /* A plain leaf's paragraph is a line of literal text (`schema.ts` §`plainSchema`), and
75
+ a boundary between two stores the one `\n` a hard break does. So no rhythm: a gap
76
+ would draw a blank line the value does not hold, and the two spellings of one string
77
+ would read apart. */
78
+ .ProseMirror > [data-qm-line] + [data-qm-line] {
79
+ margin-top: 0;
80
+ }
81
+
74
82
  /* A blockquote's UA indent was its only mark, and the reset above takes it. The rule
75
83
  and the inset carry it instead: the same hairline every surface reads, so a quote
76
84
  reads as set apart without a fill or a second box. */
@@ -187,6 +195,40 @@ li.ProseMirror-selectednode::after {
187
195
  font-weight: var(--_qm-weight-strong);
188
196
  }
189
197
 
198
+ /* The empty leaf's ghost (`field.ts`, `ghostPlugin`): a node decoration's data
199
+ attributes, so the text stays out of the document. At the rung every control's
200
+ placeholder takes; the italic is what a leaf with no written neighbour has instead
201
+ of the step to `ink`.
202
+
203
+ After the empty line rather than before it, and pulled up one line box onto it: the
204
+ caret keeps the start of that line, and a ghost's lines past the first grow the
205
+ leaf rather than hang over what follows it. A block leaf's ghost wraps and keeps the
206
+ breaks it was written with, a `body.example` being a document's worth; an inline
207
+ leaf's keeps to the one line the leaf is. The example is drawn at rest where there
208
+ is no placeholder, and stands in for the placeholder while the leaf holds the focus.
209
+
210
+ Drawn only where there is text to draw: an empty box pulled up a line would take
211
+ the empty line with it. */
212
+ .ProseMirror .qm-prose-placeholder[data-placeholder]::after,
213
+ .ProseMirror .qm-prose-placeholder[data-example]::after {
214
+ content: attr(data-placeholder);
215
+ display: block;
216
+ margin-top: -1lh;
217
+ white-space: pre-line;
218
+ color: var(--_qm-ink-label);
219
+ font-style: italic;
220
+ pointer-events: none;
221
+ }
222
+ .ProseMirror .qm-prose-placeholder[data-example]:not([data-placeholder])::after,
223
+ .ProseMirror-focused .qm-prose-placeholder[data-example]::after {
224
+ content: attr(data-example);
225
+ }
226
+ .ProseMirror .qm-prose-placeholder.qm-prose-placeholder-line::after {
227
+ white-space: nowrap;
228
+ overflow: hidden;
229
+ text-overflow: ellipsis;
230
+ }
231
+
190
232
  /* ── The table island ────────────────────────────────────────────────────────
191
233
 
192
234
  The one construct in this file with chrome in it. The rest of the stylesheet
@@ -7,8 +7,8 @@ export declare const blockSchema: Schema<string, string>;
7
7
  * a table cell, which is the same content unit). */
8
8
  export declare const inlineSchema: Schema<string, string>;
9
9
  /**
10
- * The inline schema with no mark types at all: a `plaintext` field, whose value is
11
- * literal text the boundary refuses to coerce back once it carries a mark.
10
+ * The inline schema with no mark types at all: a `plaintext(inline)` field, whose
11
+ * value is literal text the boundary refuses to coerce back once it carries a mark.
12
12
  *
13
13
  * Declaring none is what makes that structural rather than a rule each path
14
14
  * restates: `toggleMark` has no type to apply, the mark input rules build nothing,
@@ -16,11 +16,30 @@ export declare const inlineSchema: Schema<string, string>;
16
16
  * carrying (CODEC §Inline mode).
17
17
  */
18
18
  export declare const plaintextSchema: Schema<string, never>;
19
- /** Whether `schema` can carry formatting at all: false for {@link plaintextSchema}
20
- * alone. What a mark command asks before it offers itself. */
19
+ /**
20
+ * A `plaintext` field without `inline`: paragraphs and hard breaks, no marks, no
21
+ * islands, no containers, which is the shape `Content::is_plain` admits. Mark-free
22
+ * for the reason {@link plaintextSchema} is.
23
+ *
24
+ * A paragraph and a break both store one `\n`, so a paragraph here is a line and
25
+ * says so (`data-qm-line`): `prose.css` draws a run of them at the leading, and a
26
+ * boundary reads as the one line down that a break does.
27
+ */
28
+ export declare const plainSchema: Schema<"text" | "doc" | "paragraph" | "hard_break", never>;
29
+ /** The schema a leaf of the declared type mounts: `inline` narrows either type to one
30
+ * textblock, and `plaintext` takes its marks away. */
31
+ export declare function leafSchema(opts: {
32
+ plaintext?: boolean;
33
+ inline?: boolean;
34
+ }): Schema;
35
+ /** Whether `schema` can carry formatting at all: false for the two plaintext schemas.
36
+ * What a mark command asks before it offers itself. */
21
37
  export declare function hasMarks(schema: Schema): boolean;
22
- /** True for the constrained inline schema (no block containers); decode branches on it. */
38
+ /** True for the one-line schemas, which declare no `hard_break`; decode branches on it. */
23
39
  export declare function isInlineSchema(schema: Schema): boolean;
40
+ /** True for {@link blockSchema}, the one holding blocks past the paragraph: what a
41
+ * leaf's island, gap-cursor and slash machinery mounts on. */
42
+ export declare function isBlockSchema(schema: Schema): boolean;
24
43
  /** Whether `type` can hold a within-block line break, which a heading cannot (§`heading`).
25
44
  * What a `\n` landing in a textblock is rewritten to turns on it (`breaks.ts`), and so
26
45
  * does what decode puts between a block's continued segments. */