@quillmark/svelte 0.4.0 → 0.4.2
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/codec/decode.d.ts +2 -7
- package/dist/core/codec/decode.js +9 -5
- package/dist/core/codec/field.d.ts +6 -7
- package/dist/core/codec/field.js +21 -21
- package/dist/core/codec/index.d.ts +1 -1
- package/dist/core/codec/index.js +3 -2
- package/dist/core/codec/inputrules.js +17 -18
- package/dist/core/codec/schema.d.ts +15 -6
- package/dist/core/codec/schema.js +30 -17
- package/dist/visual/ArrayField.svelte +69 -40
- package/dist/visual/ArrayField.svelte.d.ts +1 -0
- package/dist/visual/Card.svelte +4 -0
- package/dist/visual/Field.svelte +17 -3
- package/dist/visual/FieldHint.svelte +14 -0
- package/dist/visual/FormatPopover.svelte +12 -1
- package/dist/visual/ProseArrayElement.svelte +1 -1
- package/package.json +1 -1
|
@@ -4,10 +4,6 @@ import type { Content } from '@quillmark/wasm';
|
|
|
4
4
|
export declare function codePoints(s: string): string[];
|
|
5
5
|
/** USV length of `s` (code points, not UTF-16 units). */
|
|
6
6
|
export declare function usvLength(s: string): number;
|
|
7
|
-
interface DecodeOpts {
|
|
8
|
-
/** Strip all marks (a `plaintext` field). */
|
|
9
|
-
plaintext?: boolean;
|
|
10
|
-
}
|
|
11
7
|
/**
|
|
12
8
|
* A `Content` as read-only DOM: decode under `schema`, then the nodes' own `toDOM`.
|
|
13
9
|
* The rendering half of the codec's job with no editing attached (no PM view, no
|
|
@@ -18,7 +14,6 @@ interface DecodeOpts {
|
|
|
18
14
|
* `DOMSerializer.fromSchema` memoizes on the schema, so repeat calls build no
|
|
19
15
|
* serializer.
|
|
20
16
|
*/
|
|
21
|
-
export declare function renderContent(rt: Content, schema: Schema
|
|
17
|
+
export declare function renderContent(rt: Content, schema: Schema): Node;
|
|
22
18
|
/** Decode a `Content` to a PM document under `schema`. */
|
|
23
|
-
export declare function decode(rt: Content, schema: Schema
|
|
24
|
-
export {};
|
|
19
|
+
export declare function decode(rt: Content, schema: Schema): PMNode;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { DOMSerializer } from 'prosemirror-model';
|
|
11
11
|
import { ISLAND_SLOT } from './islands.js';
|
|
12
12
|
import { descriptorOf, markKey, pmMarkFromContent } from './marks.js';
|
|
13
|
-
import { isInlineSchema } from './schema.js';
|
|
13
|
+
import { hasMarks, isInlineSchema } from './schema.js';
|
|
14
14
|
import { isAnchorMark, isCodeLine, isHeadingLine, isListItemContainer } from '@quillmark/wasm';
|
|
15
15
|
/** Code points of `s` (USV units): the iteration granularity the content speaks. */
|
|
16
16
|
export function codePoints(s) {
|
|
@@ -33,11 +33,11 @@ export function usvLength(s) {
|
|
|
33
33
|
* `DOMSerializer.fromSchema` memoizes on the schema, so repeat calls build no
|
|
34
34
|
* serializer.
|
|
35
35
|
*/
|
|
36
|
-
export function renderContent(rt, schema
|
|
37
|
-
return DOMSerializer.fromSchema(schema).serializeFragment(decode(rt, schema
|
|
36
|
+
export function renderContent(rt, schema) {
|
|
37
|
+
return DOMSerializer.fromSchema(schema).serializeFragment(decode(rt, schema).content);
|
|
38
38
|
}
|
|
39
39
|
/** Decode a `Content` to a PM document under `schema`. */
|
|
40
|
-
export function decode(rt, schema
|
|
40
|
+
export function decode(rt, schema) {
|
|
41
41
|
const lineTexts = rt.text.split('\n');
|
|
42
42
|
// Per-line USV start: line i begins after all earlier lines and their `\n`s.
|
|
43
43
|
const starts = [];
|
|
@@ -46,7 +46,11 @@ export function decode(rt, schema, opts = {}) {
|
|
|
46
46
|
starts.push(acc);
|
|
47
47
|
acc += usvLength(lineTexts[i]) + 1; // +1 for the `\n` boundary
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
// A mark-free schema (`plaintextSchema`) has nothing to apply them with, and a
|
|
50
|
+
// plaintext field that acquired one anyway — an older build's popover, a hand-
|
|
51
|
+
// written document — is exactly the content that must open and then heal on the
|
|
52
|
+
// next commit, not throw here.
|
|
53
|
+
const marks = hasMarks(schema) ? rt.marks.filter((m) => !isAnchorMark(m)) : [];
|
|
50
54
|
const cursor = { i: 0, rt };
|
|
51
55
|
if (isInlineSchema(schema)) {
|
|
52
56
|
return decodeInline(rt, schema, lineTexts, starts, marks, cursor);
|
|
@@ -16,7 +16,8 @@ export interface CreateFieldOpts {
|
|
|
16
16
|
container: HTMLElement;
|
|
17
17
|
/** Constrained single-textblock schema (a `richtext(inline)` field). */
|
|
18
18
|
inline?: boolean;
|
|
19
|
-
/**
|
|
19
|
+
/** The mark-free inline schema (a `plaintext` field): literal text, no formatting
|
|
20
|
+
* and no anchors. Implies `inline`. */
|
|
20
21
|
plaintext?: boolean;
|
|
21
22
|
/** Suppress the markdown-shorthand input rules. */
|
|
22
23
|
noInputRules?: boolean;
|
|
@@ -145,15 +146,13 @@ export declare function createField(opts: CreateFieldOpts): FieldController;
|
|
|
145
146
|
* last the two that answer to a caret or a selection beside a block: the gap cursor
|
|
146
147
|
* and {@link pastAtomPlugin}.
|
|
147
148
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* ones it would add: skip all.
|
|
149
|
+
* Every mark-shaped plugin reads the schema rather than a flag: over
|
|
150
|
+
* `plaintextSchema` the shorthand rules build nothing (each is guarded on its mark
|
|
151
|
+
* type) and the toggles bind no key, so a `**bold**` keeps the delimiters its author
|
|
152
|
+
* typed without a second rule saying so.
|
|
153
153
|
*/
|
|
154
154
|
export declare function proseLeafPlugins(schema: Schema, opts: {
|
|
155
155
|
inline: boolean;
|
|
156
|
-
plaintext: boolean;
|
|
157
156
|
/** The slash menu's report channel; absent mounts no menu and leaves
|
|
158
157
|
* Enter/Escape/the arrows to the links below (`keymap.ts`). */
|
|
159
158
|
slash?: (state: SlashState | undefined) => void;
|
package/dist/core/codec/field.js
CHANGED
|
@@ -27,7 +27,7 @@ import { anchorsFromContent } from './marks.js';
|
|
|
27
27
|
import { createReconciler } from './reconcile.js';
|
|
28
28
|
import { inputRulesPlugin } from './inputrules.js';
|
|
29
29
|
import { bodyKeymap } from './keymap.js';
|
|
30
|
-
import { blockSchema, inlineSchema } from './schema.js';
|
|
30
|
+
import { blockSchema, inlineSchema, plaintextSchema } from './schema.js';
|
|
31
31
|
import { DEFAULT_TABLE_STRINGS, tableNodeView } from './table-view.js';
|
|
32
32
|
import { focusSlashItem, runSlashItem, slashPlugin } from './slash.js';
|
|
33
33
|
const anchorKey = new PluginKey('quill-anchors');
|
|
@@ -108,7 +108,10 @@ export function createField(opts) {
|
|
|
108
108
|
const { doc, addr, container } = opts;
|
|
109
109
|
const inline = !!opts.inline || !!opts.plaintext;
|
|
110
110
|
const plaintext = !!opts.plaintext;
|
|
111
|
-
|
|
111
|
+
// The declared type picks the schema, and the schema is the whole of what
|
|
112
|
+
// `plaintext` suppresses: no mark types to toggle, to paste in, or to mint a
|
|
113
|
+
// shorthand with (`schema.ts`).
|
|
114
|
+
const schema = plaintext ? plaintextSchema : inline ? inlineSchema : blockSchema;
|
|
112
115
|
// Bound once and held: the reader is a `{quill, doc}` pair that reads live, so
|
|
113
116
|
// every read below sees the commit before it.
|
|
114
117
|
const reader = opts.quill.reader(doc);
|
|
@@ -176,7 +179,7 @@ export function createField(opts) {
|
|
|
176
179
|
}
|
|
177
180
|
});
|
|
178
181
|
function buildState(rt) {
|
|
179
|
-
const pmDoc = decode(rt, schema
|
|
182
|
+
const pmDoc = decode(rt, schema);
|
|
180
183
|
const anchors = plaintext ? [] : anchorsFromContent(rt);
|
|
181
184
|
// Seed anchor plugin positions in PM coords via a fresh index over pmDoc.
|
|
182
185
|
const seedIndex = buildLineIndex(pmDoc);
|
|
@@ -188,7 +191,6 @@ export function createField(opts) {
|
|
|
188
191
|
doc: pmDoc,
|
|
189
192
|
plugins: proseLeafPlugins(schema, {
|
|
190
193
|
inline,
|
|
191
|
-
plaintext,
|
|
192
194
|
slash,
|
|
193
195
|
noInputRules: opts.noInputRules,
|
|
194
196
|
// Always installed, so a leaf that mounts without a ghost can still be
|
|
@@ -368,19 +370,18 @@ export function createField(opts) {
|
|
|
368
370
|
* last the two that answer to a caret or a selection beside a block: the gap cursor
|
|
369
371
|
* and {@link pastAtomPlugin}.
|
|
370
372
|
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
* ones it would add: skip all.
|
|
373
|
+
* Every mark-shaped plugin reads the schema rather than a flag: over
|
|
374
|
+
* `plaintextSchema` the shorthand rules build nothing (each is guarded on its mark
|
|
375
|
+
* type) and the toggles bind no key, so a `**bold**` keeps the delimiters its author
|
|
376
|
+
* typed without a second rule saying so.
|
|
376
377
|
*/
|
|
377
378
|
export function proseLeafPlugins(schema, opts) {
|
|
378
379
|
const list = [history(), ...(opts.afterHistory ?? [])];
|
|
379
380
|
if (opts.slash)
|
|
380
381
|
list.push(slashPlugin(opts.slash));
|
|
381
|
-
if (!opts.noInputRules
|
|
382
|
+
if (!opts.noInputRules)
|
|
382
383
|
list.push(inputRulesPlugin(schema));
|
|
383
|
-
list.push(keymap(editorKeymap(schema, opts.inline,
|
|
384
|
+
list.push(keymap(editorKeymap(schema, opts.inline, !!opts.slash)));
|
|
384
385
|
list.push(keymap(baseKeymap));
|
|
385
386
|
// All three answer to something only a block leaf holds: an inline leaf is one
|
|
386
387
|
// textblock with no island, no gap to put a cursor in, and nowhere for a pasted
|
|
@@ -460,7 +461,8 @@ function placeholderPlugin(read) {
|
|
|
460
461
|
}
|
|
461
462
|
/**
|
|
462
463
|
* The field's keymap: history, mark toggles, and the body's structural keys; Enter
|
|
463
|
-
* suppressed inline.
|
|
464
|
+
* suppressed inline. The toggles are bound per mark type the schema declares, so a
|
|
465
|
+
* plaintext leaf leaves Mod-b/i/u to the browser.
|
|
464
466
|
*
|
|
465
467
|
* Tab forks on the leaf's role, not on the caret's position. An inline/plaintext
|
|
466
468
|
* leaf is a form field: Tab stays unbound, so field navigation is open for a shell
|
|
@@ -468,20 +470,18 @@ function placeholderPlugin(read) {
|
|
|
468
470
|
* is structural, a chain each nested surface prepends to (`keymap.ts`). One key
|
|
469
471
|
* never means two things within one surface.
|
|
470
472
|
*/
|
|
471
|
-
function editorKeymap(schema, inline,
|
|
473
|
+
function editorKeymap(schema, inline, slash) {
|
|
472
474
|
const map = {
|
|
473
475
|
'Mod-z': undo,
|
|
474
476
|
'Mod-y': redo,
|
|
475
477
|
'Shift-Mod-z': redo
|
|
476
478
|
};
|
|
477
|
-
if (
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
map['Mod-u'] = toggleMark(schema.marks.underline);
|
|
484
|
-
}
|
|
479
|
+
if (schema.marks.strong)
|
|
480
|
+
map['Mod-b'] = toggleMark(schema.marks.strong);
|
|
481
|
+
if (schema.marks.em)
|
|
482
|
+
map['Mod-i'] = toggleMark(schema.marks.em);
|
|
483
|
+
if (schema.marks.underline)
|
|
484
|
+
map['Mod-u'] = toggleMark(schema.marks.underline);
|
|
485
485
|
if (inline) {
|
|
486
486
|
// One textblock only: swallow Enter so no second block is attempted.
|
|
487
487
|
map['Enter'] = () => true;
|
|
@@ -9,7 +9,7 @@ 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 } from './schema.js';
|
|
12
|
+
export { blockSchema, inlineSchema, plaintextSchema, hasMarks } from './schema.js';
|
|
13
13
|
export { decode, renderContent, usvLength } from './decode.js';
|
|
14
14
|
export { pmToContent, contentEdit, lower } from './encode.js';
|
|
15
15
|
export type { ContentEdit } from './encode.js';
|
package/dist/core/codec/index.js
CHANGED
|
@@ -20,8 +20,9 @@ export { DEFAULT_TABLE_STRINGS } from './table-view.js';
|
|
|
20
20
|
// The slash menu: the codec owns the model and the picks, the chrome the words and
|
|
21
21
|
// the pixels (`visual/SlashMenu.svelte` is the only off-barrel caller).
|
|
22
22
|
export { DEFAULT_SLASH_STRINGS } from './slash.js';
|
|
23
|
-
// Schemas (the decode/encode target; the VisualEditor mounts them).
|
|
24
|
-
|
|
23
|
+
// Schemas (the decode/encode target; the VisualEditor mounts them). `hasMarks` is
|
|
24
|
+
// what a mark surface asks before offering itself over a leaf.
|
|
25
|
+
export { blockSchema, inlineSchema, plaintextSchema, hasMarks } from './schema.js';
|
|
25
26
|
// Decode / encode / positions (tests + VisualEditor).
|
|
26
27
|
//
|
|
27
28
|
// The barrel carries what has an off-barrel caller, and nothing else. A symbol
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
// block rule with no guard of its own reuses `wrappingInputRule`, and the rest mirror
|
|
6
6
|
// an upstream body around one. These produce transactions the codec already lowers:
|
|
7
7
|
// they add no new lowering surface.
|
|
8
|
+
//
|
|
9
|
+
// A rule declines only where what it would mint is unrepresentable, or is a shape
|
|
10
|
+
// another gesture owns — never on how a quill renders it. What a quill typesets is not
|
|
11
|
+
// knowable here (a quill this build has not seen is the open-sets case, CODEC §"Open
|
|
12
|
+
// sets"), and a construct that arrives through `importMarkdown` must stay authorable or
|
|
13
|
+
// a document opens carrying a shape the editor refuses to make. So one guard survives:
|
|
14
|
+
// `- ` / `1. ` at the head of an existing item, where Tab owns the nesting.
|
|
8
15
|
import { InputRule, inputRules, wrappingInputRule } from 'prosemirror-inputrules';
|
|
9
16
|
import { canJoin, findWrapping } from 'prosemirror-transform';
|
|
10
17
|
import { Selection } from 'prosemirror-state';
|
|
@@ -43,11 +50,10 @@ function markInputRule(regexp, markType, delimLen) {
|
|
|
43
50
|
* `wrappingInputRule` (whose body this mirrors), plus one normalization: a
|
|
44
51
|
* `heading` being wrapped becomes a `paragraph` first.
|
|
45
52
|
*
|
|
46
|
-
* `list_item` is `block+`, so `list_item > heading` is *representable
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* {@link markdownInputRules}.
|
|
53
|
+
* `list_item` is `block+`, so `list_item > heading` is *representable*, and `# `
|
|
54
|
+
* inside the item is the gesture that mints it ({@link markdownInputRules}). A wrap
|
|
55
|
+
* keeping the heading would be a second door onto that shape, so `- ` / `1. ` mint a
|
|
56
|
+
* paragraph item wherever they fire.
|
|
51
57
|
*
|
|
52
58
|
* `item` is the second guard, and it is about the gesture rather than the shape: the
|
|
53
59
|
* rule declines at the start of an item's own first block ({@link openingAnItem}).
|
|
@@ -114,16 +120,13 @@ export function markdownInputRules(schema) {
|
|
|
114
120
|
// Block shorthands (only where the schema has the node; the inline schema
|
|
115
121
|
// omits them, so this stays a no-op there).
|
|
116
122
|
if (schema.nodes.heading) {
|
|
117
|
-
// `textblockTypeInputRule`'s body
|
|
118
|
-
//
|
|
119
|
-
//
|
|
123
|
+
// `textblockTypeInputRule`'s body. It fires inside a list item like anywhere else:
|
|
124
|
+
// `list_item > heading` is a shape the content holds and `importMarkdown` produces
|
|
125
|
+
// from `- # title`, so a rule that declined there would refuse to author what a
|
|
126
|
+
// document can arrive carrying.
|
|
120
127
|
const heading = schema.nodes.heading;
|
|
121
128
|
rules.push(new InputRule(/^(#{1,6})\s$/, (state, match, start, end) => {
|
|
122
129
|
const $start = state.doc.resolve(start);
|
|
123
|
-
for (let d = $start.depth; d > 0; d--) {
|
|
124
|
-
if (item && $start.node(d).type === item)
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
130
|
if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), heading)) {
|
|
128
131
|
return null;
|
|
129
132
|
}
|
|
@@ -165,12 +168,6 @@ export function markdownInputRules(schema) {
|
|
|
165
168
|
if ($start.parentOffset !== 0 || $start.parent.content.size !== end - start) {
|
|
166
169
|
return null;
|
|
167
170
|
}
|
|
168
|
-
// The `# ` rule's guard, for its reason: `list_item` is `block+`, so a
|
|
169
|
-
// divider inside an item is representable and renders to nothing.
|
|
170
|
-
for (let d = $start.depth; d > 0; d--) {
|
|
171
|
-
if (item && $start.node(d).type === item)
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
171
|
if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), rule)) {
|
|
175
172
|
return null;
|
|
176
173
|
}
|
|
@@ -194,6 +191,8 @@ export function markdownInputRules(schema) {
|
|
|
194
191
|
if (schema.nodes.ordered_list && schema.nodes.paragraph) {
|
|
195
192
|
rules.push(listWrappingRule(/^(\d+)\.\s$/, schema.nodes.ordered_list, schema.nodes.paragraph, item, (match) => ({ start: +match[1] }), (match, node) => node.childCount + node.attrs.start === +match[1]));
|
|
196
193
|
}
|
|
194
|
+
// No item guard: a quote wrapping an item's own first block is a container the
|
|
195
|
+
// content holds, where a list wrapping one is an item whose only content is an item.
|
|
197
196
|
if (schema.nodes.blockquote) {
|
|
198
197
|
rules.push(wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote));
|
|
199
198
|
}
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { Schema } from 'prosemirror-model';
|
|
2
2
|
/** The full field schema: every block kind, container, mark, and island. */
|
|
3
3
|
export declare const blockSchema: Schema<string, string>;
|
|
4
|
+
/** The constrained inline schema: one paragraph, no block splitting, no
|
|
5
|
+
* containers, no islands, and the full mark set (a `richtext(inline)` field, and
|
|
6
|
+
* a table cell, which is the same content unit). */
|
|
7
|
+
export declare const inlineSchema: Schema<string, string>;
|
|
4
8
|
/**
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* The inline schema with no mark types at all: a `plaintext` field, whose value is
|
|
10
|
+
* literal text the boundary refuses to coerce back once it carries a mark.
|
|
11
|
+
*
|
|
12
|
+
* Declaring none is what makes that structural rather than a rule each path
|
|
13
|
+
* restates: `toggleMark` has no type to apply, the mark input rules build nothing,
|
|
14
|
+
* a paste parses its marks away, and `decode` drops any a stray content arrives
|
|
15
|
+
* carrying (CODEC §Inline mode).
|
|
10
16
|
*/
|
|
11
|
-
export declare const
|
|
17
|
+
export declare const plaintextSchema: Schema<string, never>;
|
|
18
|
+
/** Whether `schema` can carry formatting at all: false for {@link plaintextSchema}
|
|
19
|
+
* alone. What a mark command asks before it offers itself. */
|
|
20
|
+
export declare function hasMarks(schema: Schema): boolean;
|
|
12
21
|
/** True for the constrained inline schema (no block containers); decode branches on it. */
|
|
13
22
|
export declare function isInlineSchema(schema: Schema): boolean;
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
// Each of the three open sets gets an inert carrier so an unrecognized value
|
|
5
5
|
// survives a round-trip: the `unknown` mark, the paragraph's `unknown` attribute,
|
|
6
6
|
// and the `unknown_container` node. `blockSchema` is the full field; `inlineSchema`
|
|
7
|
-
// is the constrained single-textblock form for `richtext(inline)`
|
|
8
|
-
//
|
|
9
|
-
// position machinery, narrower shape. Anchors are
|
|
7
|
+
// is the constrained single-textblock form for `richtext(inline)` (one paragraph,
|
|
8
|
+
// no block split, no containers, no islands) and `plaintextSchema` is that one
|
|
9
|
+
// without marks: same decode/lower/position machinery, narrower shape. Anchors are
|
|
10
|
+
// not marks here (decorations).
|
|
10
11
|
import { Schema } from 'prosemirror-model';
|
|
11
12
|
import { islandBlockSpec, islandInlineSpec } from './islands.js';
|
|
12
|
-
// ── Marks (
|
|
13
|
+
// ── Marks (the block and inline schemas share them; plaintext declares none) ─
|
|
13
14
|
const marks = {
|
|
14
15
|
// Order matters: it fixes mark-set sort order and parse precedence. `link`
|
|
15
16
|
// last so it wraps outermost; `unknown` after it, non-exclusive so several
|
|
@@ -134,21 +135,33 @@ export const blockSchema = new Schema({
|
|
|
134
135
|
nodes: { ...blockNodes, ...inlineLeafNodes },
|
|
135
136
|
marks
|
|
136
137
|
});
|
|
138
|
+
// ── The constrained inline nodes (both inline schemas) ──────────────────────
|
|
139
|
+
// `doc: "paragraph"` (exactly one child) is what makes an Enter a no-op at the
|
|
140
|
+
// model level: there is no second block for a split to land.
|
|
141
|
+
const inlineNodes = {
|
|
142
|
+
doc: { content: 'paragraph' },
|
|
143
|
+
paragraph: { content: 'inline*', toDOM: () => ['p', 0] },
|
|
144
|
+
text: { group: 'inline' }
|
|
145
|
+
};
|
|
146
|
+
/** The constrained inline schema: one paragraph, no block splitting, no
|
|
147
|
+
* containers, no islands, and the full mark set (a `richtext(inline)` field, and
|
|
148
|
+
* a table cell, which is the same content unit). */
|
|
149
|
+
export const inlineSchema = new Schema({ nodes: inlineNodes, marks });
|
|
137
150
|
/**
|
|
138
|
-
* The
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
151
|
+
* The inline schema with no mark types at all: a `plaintext` field, whose value is
|
|
152
|
+
* literal text the boundary refuses to coerce back once it carries a mark.
|
|
153
|
+
*
|
|
154
|
+
* Declaring none is what makes that structural rather than a rule each path
|
|
155
|
+
* restates: `toggleMark` has no type to apply, the mark input rules build nothing,
|
|
156
|
+
* a paste parses its marks away, and `decode` drops any a stray content arrives
|
|
157
|
+
* carrying (CODEC §Inline mode).
|
|
143
158
|
*/
|
|
144
|
-
export const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
marks
|
|
151
|
-
});
|
|
159
|
+
export const plaintextSchema = new Schema({ nodes: inlineNodes, marks: {} });
|
|
160
|
+
/** Whether `schema` can carry formatting at all: false for {@link plaintextSchema}
|
|
161
|
+
* alone. What a mark command asks before it offers itself. */
|
|
162
|
+
export function hasMarks(schema) {
|
|
163
|
+
return Object.keys(schema.marks).length > 0;
|
|
164
|
+
}
|
|
152
165
|
/** True for the constrained inline schema (no block containers); decode branches on it. */
|
|
153
166
|
export function isInlineSchema(schema) {
|
|
154
167
|
return !schema.nodes.blockquote;
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
const els: Record<string, { focus: () => void } | undefined> = $state({});
|
|
108
108
|
let addEl: HTMLButtonElement | undefined = $state();
|
|
109
109
|
let rootEl: HTMLElement | undefined = $state();
|
|
110
|
+
let rowsEl: HTMLElement | undefined = $state();
|
|
110
111
|
|
|
111
112
|
// The awaited flush below is the only work that outlives a gesture here, so the
|
|
112
113
|
// span carries no cancellers: it is the liveness `focusAfterFlush` asks for.
|
|
@@ -165,6 +166,14 @@
|
|
|
165
166
|
// nothing about focusing it that the DOM does not already know.
|
|
166
167
|
rootEl?.querySelector<HTMLTextAreaElement>('.qm-array-row textarea')?.focus();
|
|
167
168
|
}
|
|
169
|
+
/** The box an arrival wash blooms in (`leaves.ts`, `core/bloom.ts`): the elements,
|
|
170
|
+
* not the header above them. This component owns the field's label, so the wrapper
|
|
171
|
+
* `Field` blooms every other control inside would wash the label here too. Empty,
|
|
172
|
+
* the box is `display: none` and the landing is answered by the focus the add
|
|
173
|
+
* affordance takes. */
|
|
174
|
+
export function washBox(): HTMLElement | undefined {
|
|
175
|
+
return rowsEl;
|
|
176
|
+
}
|
|
168
177
|
/** Take the caret to element `k`: what a landing on an element address resolves to
|
|
169
178
|
* (`leaves.ts`). The index resolves to the element's session id here, at the call,
|
|
170
179
|
* never carried as one — an index is stale the moment anything above it splices.
|
|
@@ -243,46 +252,48 @@
|
|
|
243
252
|
onclick={add}>{t.strings.arrayAdd}</button
|
|
244
253
|
>
|
|
245
254
|
</div>
|
|
246
|
-
{
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
255
|
+
<div class="qm-array-rows" class:empty={ids.length === 0} bind:this={rowsEl}>
|
|
256
|
+
{#each ids as id, k (id)}
|
|
257
|
+
<div class="qm-array-row">
|
|
258
|
+
{#if control === 'prose'}
|
|
259
|
+
<ProseArrayElement
|
|
260
|
+
bind:this={els[id]}
|
|
261
|
+
value={(arr[k] ?? emptyElement()) as Content}
|
|
262
|
+
label={label != null ? `${label} ${k + 1}` : undefined}
|
|
263
|
+
onChange={(rt) => commitElement(k, rt)}
|
|
264
|
+
onKey={(e) => onElementKey(e, k)}
|
|
265
|
+
/>
|
|
266
|
+
{:else if control === 'object'}
|
|
267
|
+
<textarea
|
|
268
|
+
class="qm-input qm-json qm-focus-ring"
|
|
269
|
+
aria-label={label != null ? `${label} ${k + 1}` : undefined}
|
|
270
|
+
value={JSON.stringify(arr[k] ?? {})}
|
|
271
|
+
onchange={(e) => {
|
|
272
|
+
try {
|
|
273
|
+
commitElement(k, JSON.parse((e.currentTarget as HTMLTextAreaElement).value));
|
|
274
|
+
} catch {
|
|
275
|
+
/* keep prior value on invalid JSON */
|
|
276
|
+
}
|
|
277
|
+
}}
|
|
278
|
+
></textarea>
|
|
279
|
+
{:else}
|
|
280
|
+
<TextField
|
|
281
|
+
bind:this={els[id]}
|
|
282
|
+
value={String(arr[k] ?? '')}
|
|
283
|
+
label={label != null ? `${label} ${k + 1}` : undefined}
|
|
284
|
+
onCommit={(v) => commitElement(k, v)}
|
|
285
|
+
onKey={(e) => onElementKey(e, k)}
|
|
286
|
+
/>
|
|
287
|
+
{/if}
|
|
288
|
+
<button
|
|
289
|
+
type="button"
|
|
290
|
+
class="qm-icon-btn qm-remove qm-focus-ring"
|
|
291
|
+
title={t.strings.arrayRemove}
|
|
292
|
+
onclick={() => remove(k)}><Icon name="minus" /></button
|
|
293
|
+
>
|
|
294
|
+
</div>
|
|
295
|
+
{/each}
|
|
296
|
+
</div>
|
|
286
297
|
</div>
|
|
287
298
|
|
|
288
299
|
<style>
|
|
@@ -302,6 +313,24 @@
|
|
|
302
313
|
justify-content: space-between;
|
|
303
314
|
gap: var(--_qm-space-2);
|
|
304
315
|
}
|
|
316
|
+
/* The elements, in a box of their own: the arrival wash blooms here rather than over
|
|
317
|
+
the wrapper `Field` hands this component (`washBox`), which is the label's box too.
|
|
318
|
+
Positioned for the wash's inset child, and rounded to the rung a row's own box
|
|
319
|
+
draws, the way `.qm-field-control` is for every other control.
|
|
320
|
+
|
|
321
|
+
`display: none` when there are no elements, so the header does not stand a gap above
|
|
322
|
+
an empty box; a wash over it then paints nothing, which is what an array with
|
|
323
|
+
nothing in it has to show. */
|
|
324
|
+
.qm-array-rows {
|
|
325
|
+
position: relative;
|
|
326
|
+
display: flex;
|
|
327
|
+
flex-direction: column;
|
|
328
|
+
gap: var(--_qm-space);
|
|
329
|
+
border-radius: var(--_qm-radius-inner);
|
|
330
|
+
}
|
|
331
|
+
.qm-array-rows.empty {
|
|
332
|
+
display: none;
|
|
333
|
+
}
|
|
305
334
|
/* A grid rather than a block: an `<input>` and a `<textarea>` are inline-level and
|
|
306
335
|
would sit on a baseline, standing the row a descender taller than the box the slab
|
|
307
336
|
measures itself against. `minmax(0, …)` because a long unbroken value grows an `auto`
|
package/dist/visual/Card.svelte
CHANGED
|
@@ -185,9 +185,13 @@
|
|
|
185
185
|
}
|
|
186
186
|
</script>
|
|
187
187
|
|
|
188
|
+
<!-- `data-qm-card` names the island for a floating surface that has to stay inside one
|
|
189
|
+
({@link FieldHint}). A surface portals to `[data-qm-root]`, so the card is not its
|
|
190
|
+
ancestor and nothing else marks the box its guidance belongs in. -->
|
|
188
191
|
<section
|
|
189
192
|
bind:this={el}
|
|
190
193
|
class="qm-card"
|
|
194
|
+
data-qm-card
|
|
191
195
|
class:qm-main={card.isMain}
|
|
192
196
|
class:qm-unschemable={card.unschemable}
|
|
193
197
|
>
|
package/dist/visual/Field.svelte
CHANGED
|
@@ -117,7 +117,14 @@
|
|
|
117
117
|
// object on its first property.
|
|
118
118
|
let proseEl = $state<{ focus: () => void } | undefined>();
|
|
119
119
|
let dateEl = $state<{ focus: () => void } | undefined>();
|
|
120
|
-
let arrayEl = $state<
|
|
120
|
+
let arrayEl = $state<
|
|
121
|
+
| {
|
|
122
|
+
focus: () => void;
|
|
123
|
+
focusElement: (k: number) => void;
|
|
124
|
+
washBox: () => HTMLElement | undefined;
|
|
125
|
+
}
|
|
126
|
+
| undefined
|
|
127
|
+
>();
|
|
121
128
|
let objectEl = $state<{ focus: () => void } | undefined>();
|
|
122
129
|
function focusControl(): void {
|
|
123
130
|
const owner = proseEl ?? dateEl ?? arrayEl ?? objectEl;
|
|
@@ -135,7 +142,11 @@
|
|
|
135
142
|
|
|
136
143
|
/**
|
|
137
144
|
* This field's landing handle. The wrapper is the bloom host rather than the
|
|
138
|
-
* control: `bloomInside` appends an inset child and an `<input>` holds none.
|
|
145
|
+
* control: `bloomInside` appends an inset child and an `<input>` holds none. The
|
|
146
|
+
* label is outside it, and stays out of the wash: an arrival marks where the caret
|
|
147
|
+
* landed, which is the control. The one control that owns its label is the array, so
|
|
148
|
+
* that one names its own box (`ArrayField.washBox`), read at the bloom the way
|
|
149
|
+
* `focusElement` is read at the call.
|
|
139
150
|
*
|
|
140
151
|
* A prose leaf is absent here — it registers its own controller from inside
|
|
141
152
|
* `ProseField`, carrying the codec seam this handle has no half of — and reactive
|
|
@@ -150,10 +161,13 @@
|
|
|
150
161
|
if (field.control === 'prose' || !controlEl || !leaves) return;
|
|
151
162
|
const key = leafKey;
|
|
152
163
|
const registry = leaves;
|
|
164
|
+
const wrapper = controlEl;
|
|
153
165
|
registry.registerControl(key, {
|
|
154
166
|
focus: focusControl,
|
|
155
167
|
focusElement: field.control === 'array' ? focusElement : undefined,
|
|
156
|
-
el
|
|
168
|
+
get el() {
|
|
169
|
+
return arrayEl?.washBox() ?? wrapper;
|
|
170
|
+
}
|
|
157
171
|
});
|
|
158
172
|
return () => registry.unregisterControl(key);
|
|
159
173
|
});
|
|
@@ -46,6 +46,13 @@
|
|
|
46
46
|
* along with the editor's subtree. `undefined` falls back to bits-ui's default. */
|
|
47
47
|
const portalTarget = $derived(triggerEl?.closest<HTMLElement>('[data-qm-root]') ?? undefined);
|
|
48
48
|
|
|
49
|
+
/** What the surface stays inside: the field's own card and the root it portals into.
|
|
50
|
+
* floating-ui intersects the two, so a flip answers to whichever is tighter. Empty
|
|
51
|
+
* falls back to the primitive's default, the surface's own clipping ancestors. */
|
|
52
|
+
const bounds = $derived(
|
|
53
|
+
[triggerEl?.closest<HTMLElement>('[data-qm-card]'), portalTarget].filter((el) => el != null)
|
|
54
|
+
);
|
|
55
|
+
|
|
49
56
|
/** Placement, re-measured on each opening: the surface rides the label rung beside
|
|
50
57
|
* the glyph, or stands over the field. */
|
|
51
58
|
let beside = $state(false);
|
|
@@ -132,11 +139,18 @@
|
|
|
132
139
|
neighbour's control instead, which reaching for it dismisses. -->
|
|
133
140
|
<!-- `align="center"` for the rung, `"start"` over the field: centred, a surface taller
|
|
134
141
|
than the glyph's line box would hang half its height onto the field's own control. -->
|
|
142
|
+
<!-- Both are preferences, and the card is what refuses them: its first field has no room
|
|
143
|
+
above its label inside one, so standing over that field puts the surface on the ground
|
|
144
|
+
above the card, reading as the neighbour's rather than as this field's. Refused, the
|
|
145
|
+
placement reverses and the surface goes under the field, over the control it describes:
|
|
146
|
+
the overlap the fork above spends a probe to avoid, and the only one left where there is
|
|
147
|
+
nothing above to stand over. -->
|
|
135
148
|
|
|
136
149
|
<Popover.Root bind:open>
|
|
137
150
|
<Popover.Portal to={portalTarget}>
|
|
138
151
|
<Popover.Content
|
|
139
152
|
customAnchor={triggerEl ?? null}
|
|
153
|
+
collisionBoundary={bounds}
|
|
140
154
|
side={beside ? 'right' : 'top'}
|
|
141
155
|
align={beside ? 'center' : 'start'}
|
|
142
156
|
sideOffset={6}
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
import Icon from './icons/Icon.svelte';
|
|
66
66
|
import type { IconName } from './icons/nodes.js';
|
|
67
67
|
import {
|
|
68
|
+
hasMarks,
|
|
68
69
|
rangeAnchor,
|
|
69
70
|
type FieldController,
|
|
70
71
|
type LeafViews,
|
|
@@ -140,7 +141,17 @@
|
|
|
140
141
|
// Escape out of a table cell lands.
|
|
141
142
|
const selection = view?.state.selection;
|
|
142
143
|
const formattable = selection instanceof TextSelection && !selection.empty;
|
|
143
|
-
|
|
144
|
+
// …in a leaf that has formatting at all. A `plaintext` leaf declares no mark
|
|
145
|
+
// types (`plaintextSchema`), so this surface withholds itself over one entirely,
|
|
146
|
+
// the way it never rises over the text input a `string` field draws: the value is
|
|
147
|
+
// literal text, and a mark on it is a value the boundary refuses to coerce back.
|
|
148
|
+
if (
|
|
149
|
+
!view ||
|
|
150
|
+
!view.hasFocus() ||
|
|
151
|
+
!formattable ||
|
|
152
|
+
!hasMarks(view.state.schema) ||
|
|
153
|
+
!hasText(view)
|
|
154
|
+
) {
|
|
144
155
|
open = false;
|
|
145
156
|
linkPromptOpen = false;
|
|
146
157
|
return;
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
const pmDoc = decode(value, inlineSchema);
|
|
48
48
|
const state = EditorState.create({
|
|
49
49
|
doc: pmDoc,
|
|
50
|
-
plugins: proseLeafPlugins(inlineSchema, { inline: true
|
|
50
|
+
plugins: proseLeafPlugins(inlineSchema, { inline: true })
|
|
51
51
|
});
|
|
52
52
|
const mounted = new EditorView(containerEl, {
|
|
53
53
|
state,
|