@quillmark/svelte 0.11.0 → 0.13.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.
- package/dist/core/address.d.ts +17 -10
- package/dist/core/address.js +26 -12
- package/dist/core/bloom.js +4 -3
- package/dist/core/codec/decode.d.ts +17 -0
- package/dist/core/codec/decode.js +78 -19
- package/dist/core/codec/encode.js +1 -1
- package/dist/core/codec/field.d.ts +64 -15
- package/dist/core/codec/field.js +201 -65
- package/dist/core/codec/index.d.ts +3 -3
- package/dist/core/codec/index.js +3 -3
- package/dist/core/codec/islands.d.ts +5 -4
- package/dist/core/codec/islands.js +23 -38
- package/dist/core/codec/keymap.d.ts +5 -4
- package/dist/core/codec/keymap.js +5 -4
- package/dist/core/codec/prose.css +42 -0
- package/dist/core/codec/schema.d.ts +24 -5
- package/dist/core/codec/schema.js +40 -9
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/errors.d.ts +3 -2
- package/dist/core/rungs.d.ts +8 -0
- package/dist/core/rungs.js +24 -0
- package/dist/core/theme.css +19 -6
- package/dist/preview/paint.js +21 -17
- package/dist/visual/ArrayField.svelte +649 -176
- package/dist/visual/ArrayField.svelte.d.ts +16 -1
- package/dist/visual/BooleanField.svelte +36 -11
- package/dist/visual/BooleanField.svelte.d.ts +2 -0
- package/dist/visual/Card.svelte +6 -25
- package/dist/visual/DateField.svelte +66 -31
- package/dist/visual/EnumField.svelte +27 -9
- package/dist/visual/EnumField.svelte.d.ts +5 -0
- package/dist/visual/Field.svelte +113 -38
- package/dist/visual/FormatPopover.svelte +4 -3
- package/dist/visual/MatrixField.svelte +353 -0
- package/dist/visual/MatrixField.svelte.d.ts +36 -0
- package/dist/visual/NumberField.svelte +31 -14
- package/dist/visual/NumberField.svelte.d.ts +5 -1
- package/dist/visual/ObjectField.svelte +404 -139
- package/dist/visual/ObjectField.svelte.d.ts +17 -2
- package/dist/visual/ProseField.svelte +53 -23
- package/dist/visual/ProseField.svelte.d.ts +15 -3
- package/dist/visual/ProseValue.svelte +102 -46
- package/dist/visual/ProseValue.svelte.d.ts +17 -5
- package/dist/visual/TextField.svelte +35 -18
- package/dist/visual/TextField.svelte.d.ts +7 -0
- package/dist/visual/VariantField.svelte +56 -2
- package/dist/visual/VariantField.svelte.d.ts +10 -1
- package/dist/visual/VisualEditorInner.svelte +77 -73
- package/dist/visual/controls.css +47 -5
- package/dist/visual/diagnostics.d.ts +26 -2
- package/dist/visual/diagnostics.js +52 -5
- package/dist/visual/hold.js +2 -2
- package/dist/visual/icons/nodes.d.ts +3 -0
- package/dist/visual/icons/nodes.js +2 -1
- package/dist/visual/leaves.d.ts +25 -12
- package/dist/visual/motion.d.ts +14 -5
- package/dist/visual/motion.js +13 -11
- package/dist/visual/strings.d.ts +27 -10
- package/dist/visual/strings.js +8 -2
- package/dist/visual/structure.d.ts +159 -40
- package/dist/visual/structure.js +337 -45
- package/package.json +2 -2
package/dist/core/codec/field.js
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* leaf
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
146
|
+
const reconciler = createReconciler(shown());
|
|
128
147
|
let index; // rebuilt on every structural change
|
|
129
148
|
let view;
|
|
130
|
-
// The
|
|
131
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
161
|
-
nodeViews:
|
|
162
|
-
?
|
|
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
|
|
246
|
-
// an authored string
|
|
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
|
-
|
|
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 =
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
|
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
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
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
|
|
431
|
-
//
|
|
432
|
-
if (
|
|
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 (
|
|
435
|
-
list.push(
|
|
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
|
|
475
|
-
*
|
|
476
|
-
*
|
|
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
|
-
* `
|
|
481
|
-
*
|
|
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
|
+
* that says what the unset field prints (`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
|
|
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
|
|
488
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
'
|
|
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';
|
package/dist/core/codec/index.js
CHANGED
|
@@ -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.
|
|
@@ -3,19 +3,20 @@ import { Plugin } from 'prosemirror-state';
|
|
|
3
3
|
import type { ContentIsland, TableProps } from '@quillmark/wasm';
|
|
4
4
|
/** The `U+FFFC` object-replacement char that occupies one island slot in `text`. */
|
|
5
5
|
export declare const ISLAND_SLOT = "\uFFFC";
|
|
6
|
-
/**
|
|
6
|
+
/** Whether an island of `type` is block markup: a `table` stands alone on its line. */
|
|
7
|
+
export declare function isBlockIsland(type: ContentIsland['type']): boolean;
|
|
8
|
+
/** Block island node (a table): a `U+FFFC` slot alone on its `para` line. Atom, unselectable content. */
|
|
7
9
|
export declare const islandBlockSpec: NodeSpec;
|
|
8
10
|
/** Inline island node (an image): a `U+FFFC` slot inside a `para` line. */
|
|
9
11
|
export declare const islandInlineSpec: NodeSpec;
|
|
10
12
|
/** A PM island node's attributes: the content entry, spelled the way a node spec
|
|
11
13
|
* spells it (`islandType`, because `type` on a PM node is the node type). `islandType`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* reads its set off the entry rather than restating it (DOCUMENT_MODEL §Stability
|
|
15
|
+
* seams). */
|
|
14
16
|
export interface IslandNodeAttrs {
|
|
15
17
|
id: string;
|
|
16
18
|
islandType: ContentIsland['type'];
|
|
17
19
|
props: unknown;
|
|
18
|
-
loss: ContentIsland['loss'];
|
|
19
20
|
}
|
|
20
21
|
/** Build a content island entry from a PM island node's attrs (block or inline). The
|
|
21
22
|
* cast pairs a closed `type` with a `props` that crossed the DOM as opaque JSON. The
|