@quillmark/svelte 0.10.0 → 0.11.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/README.md +1 -1
- package/THEMING.md +4 -2
- package/dist/core/codec/blocks.js +21 -1
- package/dist/core/codec/breaks.d.ts +10 -2
- package/dist/core/codec/breaks.js +65 -9
- package/dist/core/codec/code.d.ts +6 -4
- package/dist/core/codec/code.js +7 -4
- package/dist/core/codec/decode.js +91 -62
- package/dist/core/codec/encode.d.ts +5 -8
- package/dist/core/codec/encode.js +55 -85
- package/dist/core/codec/field.js +43 -24
- package/dist/core/codec/index.d.ts +2 -1
- package/dist/core/codec/index.js +6 -3
- package/dist/core/codec/islands.d.ts +13 -9
- package/dist/core/codec/islands.js +75 -49
- package/dist/core/codec/keymap.js +2 -1
- package/dist/core/codec/marks.d.ts +17 -13
- package/dist/core/codec/marks.js +39 -42
- package/dist/core/codec/positions.d.ts +4 -1
- package/dist/core/codec/positions.js +5 -1
- package/dist/core/codec/prose.css +10 -0
- package/dist/core/codec/reconcile.d.ts +6 -0
- package/dist/core/codec/reconcile.js +19 -0
- package/dist/core/codec/schema.d.ts +5 -8
- package/dist/core/codec/schema.js +30 -156
- package/dist/core/codec/table-view.js +77 -26
- package/dist/core/codec/table.d.ts +6 -5
- package/dist/core/codec/table.js +14 -31
- package/dist/core/codec/urls.d.ts +20 -0
- package/dist/core/codec/urls.js +45 -0
- package/dist/preset/recipes.css +1 -1
- package/dist/preset/scale.css +7 -4
- package/dist/preview/Preview.svelte +6 -0
- package/dist/preview/bridge.d.ts +8 -1
- package/dist/preview/bridge.js +31 -25
- package/dist/preview/controller.js +13 -13
- package/dist/preview/geometry.d.ts +12 -1
- package/dist/preview/geometry.js +14 -1
- package/dist/preview/paint.js +6 -4
- package/dist/preview/strings.d.ts +0 -2
- package/dist/preview/strings.js +5 -6
- package/dist/visual/ArrayField.svelte +11 -13
- package/dist/visual/Card.svelte +5 -8
- package/dist/visual/DateField.svelte +2 -2
- package/dist/visual/Field.svelte +7 -3
- package/dist/visual/FieldHint.svelte +1 -1
- package/dist/visual/ObjectField.svelte +6 -1
- package/dist/visual/VisualEditor.svelte +1 -1
- package/dist/visual/VisualEditorInner.svelte +73 -18
- package/dist/visual/controls.css +8 -9
- package/dist/visual/ext.d.ts +12 -13
- package/dist/visual/ext.js +22 -19
- package/dist/visual/index.js +2 -3
- package/dist/visual/links.d.ts +4 -0
- package/dist/visual/links.js +15 -4
- package/dist/visual/strings.d.ts +7 -4
- package/dist/visual/strings.js +1 -0
- package/dist/visual/structure.d.ts +9 -1
- package/dist/visual/structure.js +26 -4
- package/package.json +2 -2
|
@@ -1,92 +1,25 @@
|
|
|
1
1
|
// The PM schema: the codec owns it; decode/encode target it. Nodes mirror the
|
|
2
2
|
// content block kinds (para/heading/code/rule/island) and its container nesting
|
|
3
3
|
// (list_item/quote → lists/blockquote); marks mirror the content formatting set.
|
|
4
|
-
// Each
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// not marks here (decorations).
|
|
4
|
+
// Each set is closed upstream, so the schema names its whole vocabulary and carries
|
|
5
|
+
// nothing inert. `blockSchema` is the full field; `inlineSchema` is the constrained
|
|
6
|
+
// single-textblock form for `richtext(inline)` (one paragraph, no block split, no
|
|
7
|
+
// containers, no islands) and `plaintextSchema` is that one without marks: same
|
|
8
|
+
// decode/lower/position machinery, narrower shape. Anchors are not marks here
|
|
9
|
+
// (decorations).
|
|
11
10
|
//
|
|
12
11
|
// `toDOM` and `parseDOM` are one tier, not two halves of a rendering: a copy and a
|
|
13
12
|
// paste inside one body run the whole document through them (CODEC §"Markdown at the
|
|
14
|
-
// edges"), so every attribute written here is read back here.
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// web is unchanged; the one rule that widens that door on purpose is the fence's.
|
|
13
|
+
// edges"), so every attribute written here is read back here. Foreign HTML spells none
|
|
14
|
+
// of this package's `data-qm-*` names, so what a paste takes off the web is unchanged;
|
|
15
|
+
// the one rule that widens that door on purpose is the fence's.
|
|
18
16
|
import { Schema } from 'prosemirror-model';
|
|
19
17
|
import { islandBlockSpec, islandInlineSpec } from './islands.js';
|
|
20
|
-
|
|
21
|
-
// An `href` is an attribute value and not markup, so the markdown → typed node → DOM
|
|
22
|
-
// path that keeps a document's text from becoming tags never reaches it: what the
|
|
23
|
-
// mark carries is what `toDOM` emits, and `renderContent` paints marks outside a
|
|
24
|
-
// `contenteditable` (the tips card), where a click is a plain one.
|
|
25
|
-
//
|
|
26
|
-
// An allowlist, because the set a document can spell is open: naming the dangerous
|
|
27
|
-
// schemes instead loses to the first one this has not heard of. It holds what the
|
|
28
|
-
// surface has a caller for; a scheme reaching it later is a line.
|
|
29
|
-
const RENDERED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel', 'ftp']);
|
|
30
|
-
/** A scheme and its colon: a letter, then letters, digits, `+`, `-` or `.` (RFC 3986). */
|
|
31
|
-
const SCHEME = /^([a-z][a-z0-9+.-]*):/i;
|
|
32
|
-
/** Dropped before the scheme is read, so a tab spliced into `javascript:` is tested
|
|
33
|
-
* as what it navigates to. Wider than the URL parser's own tab/newline rule: no
|
|
34
|
-
* scheme carries a control character, and the strip decides without rewriting. */
|
|
35
|
-
const IGNORED = /[\u0000-\u0020]/g;
|
|
36
|
-
/**
|
|
37
|
-
* Whether a link carrying `href` renders as one. A value with no scheme is relative
|
|
38
|
-
* to the embedding page and has none to refuse.
|
|
39
|
-
*
|
|
40
|
-
* A refused href is unchanged: it stays on the mark and round-trips, so a document
|
|
41
|
-
* survives an editor that declines to make it clickable.
|
|
42
|
-
*/
|
|
43
|
-
export function rendersHref(href) {
|
|
44
|
-
const scheme = SCHEME.exec(href.replace(IGNORED, ''));
|
|
45
|
-
return scheme === null || RENDERED_SCHEMES.has(scheme[1].toLowerCase());
|
|
46
|
-
}
|
|
47
|
-
// ── The open sets' DOM payload ──────────────────────────────────────────────
|
|
48
|
-
// A carrier's tag is a `data-qm-unknown-*` name and its `attrs` are JSON beside it,
|
|
49
|
-
// under one name across all three: an element carries at most one carrier, a
|
|
50
|
-
// paragraph's tag and the mark's sitting on different elements.
|
|
51
|
-
/** The `attrs` half of a carrier, absent where there is none. */
|
|
52
|
-
function packAttrs(attrs) {
|
|
53
|
-
return attrs == null ? {} : { 'data-qm-unknown-attrs': JSON.stringify(attrs) };
|
|
54
|
-
}
|
|
55
|
-
/** The `attrs` half back off an element. `null` for anything that is not the JSON
|
|
56
|
-
* this schema wrote: a hand-built document reaches the same parse a paste does, and a
|
|
57
|
-
* carrier's payload is the keyed object upstream spells, never a bare scalar. */
|
|
58
|
-
function unpackAttrs(el) {
|
|
59
|
-
const raw = el.getAttribute('data-qm-unknown-attrs');
|
|
60
|
-
if (raw == null)
|
|
61
|
-
return null;
|
|
62
|
-
try {
|
|
63
|
-
const parsed = JSON.parse(raw);
|
|
64
|
-
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
// ── The names a carrier may not wear ────────────────────────────────────────
|
|
71
|
-
// A carrier holds a value this build does not know, so a name the model does know is
|
|
72
|
-
// the one it cannot carry: the projection re-emits it with `attrs` beside it
|
|
73
|
-
// (`encode.ts` §`textblockKind`) and the store refuses `attrs` beside a built-in
|
|
74
|
-
// discriminant, failing every write for the rest of the session. `decode` applies the
|
|
75
|
-
// same guard from the other side, where a `para` line mints no carrier.
|
|
76
|
-
/** The line kinds `ContentLineKind` names, which decode maps to a node of their own. */
|
|
77
|
-
const BUILTIN_LINE_KINDS = new Set(['para', 'heading', 'code', 'rule', 'island']);
|
|
78
|
-
/** The containers `ContentContainer` names. */
|
|
79
|
-
const BUILTIN_CONTAINERS = new Set(['list_item', 'quote']);
|
|
80
|
-
/** A carrier's name, or `null` where it is empty or one the model owns. */
|
|
81
|
-
function carrierName(el, attr, builtin) {
|
|
82
|
-
const name = el.getAttribute(attr);
|
|
83
|
-
return name && !builtin.has(name) ? name : null;
|
|
84
|
-
}
|
|
18
|
+
import { rendersHref, storableUrl } from './urls.js';
|
|
85
19
|
// ── Marks (the block and inline schemas share them; plaintext declares none) ─
|
|
86
20
|
const marks = {
|
|
87
21
|
// Order matters: it fixes mark-set sort order and parse precedence. `link`
|
|
88
|
-
// last so it wraps outermost
|
|
89
|
-
// distinct unknown marks coexist on one range.
|
|
22
|
+
// last so it wraps outermost.
|
|
90
23
|
strong: { parseDOM: [{ tag: 'strong' }, { tag: 'b' }], toDOM: () => ['strong', 0] },
|
|
91
24
|
em: { parseDOM: [{ tag: 'em' }, { tag: 'i' }], toDOM: () => ['em', 0] },
|
|
92
25
|
underline: { parseDOM: [{ tag: 'u' }], toDOM: () => ['u', 0] },
|
|
@@ -96,8 +29,11 @@ const marks = {
|
|
|
96
29
|
attrs: { href: { default: '' } },
|
|
97
30
|
inclusive: false,
|
|
98
31
|
parseDOM: [
|
|
99
|
-
{ tag: 'a[href]', getAttrs: (el) => ({ href: el.getAttribute('href') }) },
|
|
100
|
-
{
|
|
32
|
+
{ tag: 'a[href]', getAttrs: (el) => ({ href: storableUrl(el.getAttribute('href') ?? '') }) },
|
|
33
|
+
{
|
|
34
|
+
tag: 'span[data-qm-href]',
|
|
35
|
+
getAttrs: (el) => ({ href: storableUrl(el.getAttribute('data-qm-href') ?? '') })
|
|
36
|
+
}
|
|
101
37
|
],
|
|
102
38
|
// A refused href draws as a bare span: the text stands, unstyled and
|
|
103
39
|
// unclickable, and the mark keeps its value for encode. The value rides on the
|
|
@@ -108,35 +44,8 @@ const marks = {
|
|
|
108
44
|
const href = mark.attrs.href;
|
|
109
45
|
return rendersHref(href) ? ['a', { href }, 0] : ['span', { 'data-qm-href': href }, 0];
|
|
110
46
|
}
|
|
111
|
-
},
|
|
112
|
-
// The open-set escape hatch: an inert mark that renders as a bare span and
|
|
113
|
-
// re-emits its stored `type`/`attrs` on encode. `excludes: ''` lets marks of
|
|
114
|
-
// this type with different attrs share a range (they are distinct families).
|
|
115
|
-
unknown: {
|
|
116
|
-
attrs: { type: { default: '' }, attrs: { default: null } },
|
|
117
|
-
excludes: '',
|
|
118
|
-
parseDOM: [
|
|
119
|
-
{
|
|
120
|
-
tag: 'span[data-qm-unknown-mark]',
|
|
121
|
-
// `false` declines the rule, so the text arrives unmarked rather than
|
|
122
|
-
// carrying a second spelling of a mark this schema already has.
|
|
123
|
-
getAttrs: (el) => {
|
|
124
|
-
const type = carrierName(el, 'data-qm-unknown-mark', BUILTIN_MARKS);
|
|
125
|
-
return type ? { type, attrs: unpackAttrs(el) } : false;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
],
|
|
129
|
-
toDOM: (mark) => [
|
|
130
|
-
'span',
|
|
131
|
-
{ 'data-qm-unknown-mark': mark.attrs.type, ...packAttrs(mark.attrs.attrs) },
|
|
132
|
-
0
|
|
133
|
-
]
|
|
134
47
|
}
|
|
135
48
|
};
|
|
136
|
-
/** The mark types the content model names: this schema's own, and `anchor`, which is a
|
|
137
|
-
* decoration here rather than a mark (§Marks). Read off the record above, so a mark
|
|
138
|
-
* added there closes the carrier's door on its name in the same edit. */
|
|
139
|
-
const BUILTIN_MARKS = new Set([...Object.keys(marks), 'anchor']);
|
|
140
49
|
// ── The fence's language ────────────────────────────────────────────────────
|
|
141
50
|
// The one attribute no keystroke in the visual editor mints: the shorthand fires on
|
|
142
51
|
// the third backtick, before a language could be typed, and a slash command is a name
|
|
@@ -162,31 +71,18 @@ function fenceLang(pre) {
|
|
|
162
71
|
// ── Block nodes ─────────────────────────────────────────────────────────────
|
|
163
72
|
const blockNodes = {
|
|
164
73
|
doc: { content: 'block+' },
|
|
165
|
-
// `unknown` carries a line `kind` this build does not know (`{ kind, attrs }`,
|
|
166
|
-
// else null). A paragraph is how such a line renders, so the carrier is an
|
|
167
|
-
// attribute rather than a node type: every paragraph command already reaches it,
|
|
168
|
-
// and retyping it to a heading or a list drops the attribute: an explicit
|
|
169
|
-
// conversion, which is the one place the unknown kind should be lost.
|
|
170
74
|
paragraph: {
|
|
171
75
|
content: 'inline*',
|
|
172
76
|
group: 'block',
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
{
|
|
176
|
-
tag: 'p',
|
|
177
|
-
getAttrs: (el) => {
|
|
178
|
-
const kind = carrierName(el, 'data-qm-unknown-line', BUILTIN_LINE_KINDS);
|
|
179
|
-
return { unknown: kind ? { kind, attrs: unpackAttrs(el) } : null };
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
],
|
|
183
|
-
toDOM: (node) => {
|
|
184
|
-
const u = node.attrs.unknown;
|
|
185
|
-
return u ? ['p', { 'data-qm-unknown-line': u.kind, ...packAttrs(u.attrs) }, 0] : ['p', 0];
|
|
186
|
-
}
|
|
77
|
+
parseDOM: [{ tag: 'p' }],
|
|
78
|
+
toDOM: () => ['p', 0]
|
|
187
79
|
},
|
|
80
|
+
// A heading holds no `hard_break`: it is a block of one line, so the store clears a
|
|
81
|
+
// `continues` after one and markdown has no syntax for a break inside it. Declaring
|
|
82
|
+
// it out is what makes that structural — a paste parses the `<br>` away and a
|
|
83
|
+
// conversion onto a heading drops it, rather than each door holding the rule.
|
|
188
84
|
heading: {
|
|
189
|
-
content: '
|
|
85
|
+
content: '(text | island_inline)*',
|
|
190
86
|
group: 'block',
|
|
191
87
|
defining: true,
|
|
192
88
|
attrs: { level: { default: 1 } },
|
|
@@ -256,35 +152,6 @@ const blockNodes = {
|
|
|
256
152
|
parseDOM: [{ tag: 'li' }],
|
|
257
153
|
toDOM: () => ['li', 0]
|
|
258
154
|
},
|
|
259
|
-
// A container this build does not know: a transparent wrapper that renders as a
|
|
260
|
-
// bare block (its children flow at the enclosing level, which is how upstream
|
|
261
|
-
// renders it) and re-emits its `container`/`attrs` on encode. `defining` so a
|
|
262
|
-
// lift out of it is deliberate rather than a backspace away.
|
|
263
|
-
unknown_container: {
|
|
264
|
-
content: 'block+',
|
|
265
|
-
group: 'block',
|
|
266
|
-
defining: true,
|
|
267
|
-
attrs: { container: { default: '' }, attrs: { default: null } },
|
|
268
|
-
parseDOM: [
|
|
269
|
-
{
|
|
270
|
-
tag: 'div[data-qm-unknown-container]',
|
|
271
|
-
// `false` declines the rule, so the div's children flow at the enclosing
|
|
272
|
-
// level — which is how a transparent container renders anyway.
|
|
273
|
-
getAttrs: (el) => {
|
|
274
|
-
const container = carrierName(el, 'data-qm-unknown-container', BUILTIN_CONTAINERS);
|
|
275
|
-
return container ? { container, attrs: unpackAttrs(el) } : false;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
],
|
|
279
|
-
toDOM: (node) => [
|
|
280
|
-
'div',
|
|
281
|
-
{
|
|
282
|
-
'data-qm-unknown-container': node.attrs.container,
|
|
283
|
-
...packAttrs(node.attrs.attrs)
|
|
284
|
-
},
|
|
285
|
-
0
|
|
286
|
-
]
|
|
287
|
-
},
|
|
288
155
|
island_block: islandBlockSpec
|
|
289
156
|
};
|
|
290
157
|
// ── Inline nodes ────────────────────────────────────────────────────────────
|
|
@@ -339,3 +206,10 @@ export function hasMarks(schema) {
|
|
|
339
206
|
export function isInlineSchema(schema) {
|
|
340
207
|
return !schema.nodes.blockquote;
|
|
341
208
|
}
|
|
209
|
+
/** Whether `type` can hold a within-block line break, which a heading cannot (§`heading`).
|
|
210
|
+
* What a `\n` landing in a textblock is rewritten to turns on it (`breaks.ts`), and so
|
|
211
|
+
* does what decode puts between a block's continued segments. */
|
|
212
|
+
export function takesLineBreak(type) {
|
|
213
|
+
const br = type.schema.nodes.hard_break;
|
|
214
|
+
return !!br && type.contentMatch.matchType(br) !== null;
|
|
215
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// field's, so one undo stack covers the leaf), no anchor plugin (an anchor in a cell
|
|
15
15
|
// is preserved, never minted), and no placeholder.
|
|
16
16
|
//
|
|
17
|
-
// The chrome is
|
|
17
|
+
// The chrome is a band and a selection (CODEC §"The table island"), and it raises
|
|
18
18
|
// nothing. Every control is absolutely positioned out of the grid, so the band is in no
|
|
19
19
|
// row and no column of it; `codec/prose.css` draws the band and the selection wash both.
|
|
20
20
|
import { baseKeymap, chainCommands, toggleMark } from 'prosemirror-commands';
|
|
@@ -142,7 +142,8 @@ class TableIslandView {
|
|
|
142
142
|
drag;
|
|
143
143
|
sweep;
|
|
144
144
|
/** The grips, by the line each acts on: what selection paint and a drag reach for
|
|
145
|
-
* without a query. The typed twin of `cells
|
|
145
|
+
* without a query. The typed twin of `cells`, `Line` included — a key parsed back
|
|
146
|
+
* into one is the `data-` attribute `MountedCell.box` refuses. */
|
|
146
147
|
grips = new Map();
|
|
147
148
|
/** The grid's own box, and the containing block every out-of-flow control is placed
|
|
148
149
|
* against. Not the scroller: an absolute inside a scroll container is placed
|
|
@@ -175,13 +176,17 @@ class TableIslandView {
|
|
|
175
176
|
return true;
|
|
176
177
|
}
|
|
177
178
|
// Same rectangle: reseed only the cells whose value the nested view is not
|
|
178
|
-
// already showing. The cell that produced this update
|
|
179
|
-
//
|
|
179
|
+
// already showing. The cell that produced this update wrote exactly what it is
|
|
180
|
+
// showing, so it compares equal and keeps its caret; an undo or an external
|
|
180
181
|
// re-hydrate does not, and takes the fresh state.
|
|
182
|
+
//
|
|
183
|
+
// Against `shown` rather than a projection of the doc: this runs on every outer
|
|
184
|
+
// transaction, which is every keystroke in the table, and projecting each cell
|
|
185
|
+
// costs a `mapMarks` across the WASM boundary wherever one holds an anchor.
|
|
181
186
|
this.rendered = props;
|
|
182
187
|
for (const mounted of this.cells) {
|
|
183
188
|
const stored = cellAt(props, mounted.r, mounted.c);
|
|
184
|
-
if (cellEqual(stored,
|
|
189
|
+
if (cellEqual(stored, mounted.shown))
|
|
185
190
|
continue;
|
|
186
191
|
const head = mounted.view.state.selection.head;
|
|
187
192
|
const fresh = EditorState.create({
|
|
@@ -189,6 +194,7 @@ class TableIslandView {
|
|
|
189
194
|
plugins: cellPlugins(this.cellKeys(mounted.r, mounted.c))
|
|
190
195
|
});
|
|
191
196
|
mounted.view.updateState(fresh);
|
|
197
|
+
mounted.shown = stored;
|
|
192
198
|
// Best-effort caret continuity, the rule a field's own re-hydrate takes: keep
|
|
193
199
|
// the offset, clamped into the text that is there now. A fresh state resolves
|
|
194
200
|
// its selection to the start of the cell, so an undo would otherwise put the
|
|
@@ -272,19 +278,39 @@ class TableIslandView {
|
|
|
272
278
|
this.caretBeside(point);
|
|
273
279
|
};
|
|
274
280
|
/** The cell a press inside the frame belongs to: the nearest by rect, which for a
|
|
275
|
-
* press on a border or a cell's own padding is the cell it is against.
|
|
281
|
+
* press on a border or a cell's own padding is the cell it is against.
|
|
282
|
+
*
|
|
283
|
+
* A row's cells share a vertical extent and a column's a horizontal one, and the
|
|
284
|
+
* distance is a hypotenuse of the two, so the nearest cell is the nearest row
|
|
285
|
+
* crossed with the nearest column: the header row and the first column answer for
|
|
286
|
+
* the whole grid. R+C rects rather than R×C, for the reason a line drag measures
|
|
287
|
+
* once — at 20x8 that is 27 forced layouts a press instead of 160. */
|
|
276
288
|
focusNearestCell(point) {
|
|
277
|
-
let
|
|
278
|
-
let
|
|
289
|
+
let row;
|
|
290
|
+
let column;
|
|
291
|
+
let nearestRow = Infinity;
|
|
292
|
+
let nearestColumn = Infinity;
|
|
279
293
|
for (const mounted of this.cells) {
|
|
280
|
-
|
|
281
|
-
if (at >= nearest)
|
|
294
|
+
if (mounted.c !== 0 && mounted.r !== 0)
|
|
282
295
|
continue;
|
|
283
|
-
|
|
284
|
-
|
|
296
|
+
const box = mounted.host.getBoundingClientRect();
|
|
297
|
+
if (mounted.c === 0) {
|
|
298
|
+
const at = Math.max(box.top - point.y, 0, point.y - box.bottom);
|
|
299
|
+
if (at < nearestRow) {
|
|
300
|
+
nearestRow = at;
|
|
301
|
+
row = mounted.r;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (mounted.r === 0) {
|
|
305
|
+
const at = Math.max(box.left - point.x, 0, point.x - box.right);
|
|
306
|
+
if (at < nearestColumn) {
|
|
307
|
+
nearestColumn = at;
|
|
308
|
+
column = mounted.c;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
285
311
|
}
|
|
286
|
-
if (
|
|
287
|
-
this.focusCell(
|
|
312
|
+
if (row !== undefined && column !== undefined)
|
|
313
|
+
this.focusCell(row, column);
|
|
288
314
|
}
|
|
289
315
|
/** A caret beside the island, on the side the press landed: a gap cursor where the
|
|
290
316
|
* document holds no text position there, and the neighbouring block's own edge
|
|
@@ -388,9 +414,10 @@ class TableIslandView {
|
|
|
388
414
|
}
|
|
389
415
|
// ── The selection ─────────────────────────────────────────────────────────
|
|
390
416
|
/** The rectangle a line covers, which is what selecting one means: a grip draws no
|
|
391
|
-
* second kind of selection, it draws this one.
|
|
392
|
-
|
|
393
|
-
|
|
417
|
+
* second kind of selection, it draws this one. `props` is the caller's where it
|
|
418
|
+
* reads a line per grip ({@link TableIslandView.paintSelection}): each `props()`
|
|
419
|
+
* revalidates the whole table. */
|
|
420
|
+
lineCells(line, props = this.props()) {
|
|
394
421
|
return line.axis === 'row'
|
|
395
422
|
? { r0: line.index, c0: 0, r1: line.index, c1: columnCount(props) - 1 }
|
|
396
423
|
: { r0: 0, c0: line.index, r1: rowCount(props) - 1, c1: line.index };
|
|
@@ -442,6 +469,10 @@ class TableIslandView {
|
|
|
442
469
|
: undefined;
|
|
443
470
|
}
|
|
444
471
|
select(cells) {
|
|
472
|
+
// A sweep resolves a rectangle per pointer move and most of them are the one
|
|
473
|
+
// already held, which is a repaint of the state that is up.
|
|
474
|
+
if (this.selected && sameCells(this.selected, cells))
|
|
475
|
+
return;
|
|
445
476
|
// One subject at a time: a cell selection retires an island one, or the surface
|
|
446
477
|
// paints a washed row inside an outlined table and the next Backspace has two
|
|
447
478
|
// honest readings.
|
|
@@ -468,11 +499,9 @@ class TableIslandView {
|
|
|
468
499
|
* and a selection is exactly the state that must not cost the carets in them. */
|
|
469
500
|
paintSelection() {
|
|
470
501
|
const held = this.selected;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
grip.setAttribute('aria-pressed', String(!!held && sameCells(held, this.lineCells(line))));
|
|
475
|
-
}
|
|
502
|
+
const props = this.props();
|
|
503
|
+
for (const { line, grip } of this.grips.values())
|
|
504
|
+
grip.setAttribute('aria-pressed', String(!!held && sameCells(held, this.lineCells(line, props))));
|
|
476
505
|
for (const cell of this.cells)
|
|
477
506
|
cell.box.toggleAttribute('data-selected', !!held && cell.r >= held.r0 && cell.r <= held.r1 && cell.c >= held.c0 && cell.c <= held.c1);
|
|
478
507
|
}
|
|
@@ -556,6 +585,10 @@ class TableIslandView {
|
|
|
556
585
|
onGripDown = (line, grip, event) => {
|
|
557
586
|
if (event.button !== 0)
|
|
558
587
|
return;
|
|
588
|
+
// The previous gesture ends first, as `armSweep`'s does: `endDrag` reads
|
|
589
|
+
// `this.drag` to know which grip to unbind, so a second press over a live drag
|
|
590
|
+
// would orphan the first grip's listeners and its pointer capture.
|
|
591
|
+
this.endDrag();
|
|
559
592
|
this.drag = {
|
|
560
593
|
line,
|
|
561
594
|
origin: { x: event.clientX, y: event.clientY },
|
|
@@ -833,7 +866,7 @@ class TableIslandView {
|
|
|
833
866
|
bar.appendChild(svg(GRIP[line.axis]));
|
|
834
867
|
btn.appendChild(bar);
|
|
835
868
|
btn.addEventListener('pointerdown', (e) => this.onGripDown(line, btn, e));
|
|
836
|
-
this.grips.set(lineKey(line), btn);
|
|
869
|
+
this.grips.set(lineKey(line), { line, grip: btn });
|
|
837
870
|
return btn;
|
|
838
871
|
}
|
|
839
872
|
/** A trailing bar: the whole edge past the last line of its axis, and the one way a
|
|
@@ -863,9 +896,10 @@ class TableIslandView {
|
|
|
863
896
|
mountCell(box, host, r, c, s) {
|
|
864
897
|
const props = this.props();
|
|
865
898
|
const name = s.tableCell(r === 0 ? s.tableHeaderRow : s.tableRow(r), s.tableColumn(c + 1));
|
|
899
|
+
const seed = cellAt(props, r, c);
|
|
866
900
|
const view = new EditorView(host, {
|
|
867
901
|
state: EditorState.create({
|
|
868
|
-
doc: decode(cellContent(
|
|
902
|
+
doc: decode(cellContent(seed), inlineSchema),
|
|
869
903
|
plugins: cellPlugins(this.cellKeys(r, c))
|
|
870
904
|
}),
|
|
871
905
|
attributes: { 'aria-label': name, class: 'qm-table-cell-editor' },
|
|
@@ -885,7 +919,9 @@ class TableIslandView {
|
|
|
885
919
|
// reseed it comes back as changes no cell's own doc.
|
|
886
920
|
this.clearSelection();
|
|
887
921
|
const now = this.props();
|
|
888
|
-
|
|
922
|
+
const cell = cellFromDoc(next.doc, cellAt(now, r, c));
|
|
923
|
+
mounted.shown = cell;
|
|
924
|
+
this.write(withCell(now, r, c, cell));
|
|
889
925
|
},
|
|
890
926
|
handleDOMEvents: {
|
|
891
927
|
focus: () => {
|
|
@@ -895,7 +931,18 @@ class TableIslandView {
|
|
|
895
931
|
}
|
|
896
932
|
}
|
|
897
933
|
});
|
|
898
|
-
|
|
934
|
+
// `seed` is what the doc above was decoded from, and decode∘project is identity
|
|
935
|
+
// (`table.ts`, the cell codec), so it is what this view is showing.
|
|
936
|
+
const mounted = {
|
|
937
|
+
view,
|
|
938
|
+
host,
|
|
939
|
+
box,
|
|
940
|
+
shown: seed,
|
|
941
|
+
unregister: this.deps.register(view),
|
|
942
|
+
r,
|
|
943
|
+
c
|
|
944
|
+
};
|
|
945
|
+
this.cells.push(mounted);
|
|
899
946
|
}
|
|
900
947
|
// ── Ops ───────────────────────────────────────────────────────────────────
|
|
901
948
|
/** The table this view is currently showing; the zero table if the node stopped
|
|
@@ -1029,6 +1076,10 @@ class TableIslandView {
|
|
|
1029
1076
|
'Alt-ArrowRight': line('column', 1, true),
|
|
1030
1077
|
Tab: () => this.step(r, c, 1),
|
|
1031
1078
|
'Shift-Tab': () => this.step(r, c, -1),
|
|
1079
|
+
// A cell is one line (`normalize` rewrites a cell newline to a space), so the
|
|
1080
|
+
// key the body answers with a break is claimed here and does nothing: Enter
|
|
1081
|
+
// already means the next row, and an unclaimed key is the browser's `<br>`.
|
|
1082
|
+
'Shift-Enter': () => true,
|
|
1032
1083
|
Enter: () => {
|
|
1033
1084
|
// Over a rectangle it hands the caret back, which is the line verbs' own
|
|
1034
1085
|
// exit: the caret never left the cell, so there is nowhere else to put it.
|
|
@@ -88,14 +88,15 @@ export declare function cellContent(cell: TableCell): Content;
|
|
|
88
88
|
* the field plugin's coordinate space (the field's position map holds one `atom`
|
|
89
89
|
* run for the whole table), so nothing here can mint one and the popover withholds
|
|
90
90
|
* its `anchor` button while the caret is in a cell. Preserving them still means
|
|
91
|
-
* rebasing: a cell edit is a splice like any other, so each held
|
|
92
|
-
* the cell's own
|
|
93
|
-
*
|
|
94
|
-
*
|
|
91
|
+
* rebasing: a cell edit is a splice like any other, so `mapMarks` carries each held
|
|
92
|
+
* anchor through the cell's own delta by the rule the store applies to a field's,
|
|
93
|
+
* and a mark the splice deleted through lands where the splice left it rather than
|
|
94
|
+
* pointing past the text.
|
|
95
95
|
*/
|
|
96
96
|
export declare function cellFromDoc(doc: PMNode, prior: TableCell): TableCell;
|
|
97
97
|
/** Whether two cells are the same value: what tells an own edit (the projection the
|
|
98
|
-
* view just produced) from an external one (an undo, a re-hydrate) without a flag.
|
|
98
|
+
* view just produced) from an external one (an undo, a re-hydrate) without a flag.
|
|
99
|
+
* The marks structurally, for the reason `reconcile.ts` states. */
|
|
99
100
|
export declare function cellEqual(a: TableCell, b: TableCell): boolean;
|
|
100
101
|
/** Whether two tables have the same rectangle and alignment: the change that forces
|
|
101
102
|
* a rebuild rather than a per-cell reseed. */
|
package/dist/core/codec/table.js
CHANGED
|
@@ -1,23 +1,6 @@
|
|
|
1
|
-
// The table island's model: the cell codec (a `TableCell` ↔ one inline-schema PM
|
|
2
|
-
// doc) and the rectangularizing constructors every row and column op goes through
|
|
3
|
-
// (CODEC §Islands, §"The table island").
|
|
4
|
-
//
|
|
5
|
-
// `TableProps` normalizes to one column count shared by `header`, every row, and
|
|
6
|
-
// `aligns`, so a ragged table is not a state the content can hold. The editor keeps
|
|
7
|
-
// its optimistic PM and re-hydrates only on an external change (CODEC
|
|
8
|
-
// §Reconciliation), so an op that emitted a ragged table would leave the store
|
|
9
|
-
// rectangularized and PM ragged with no error channel and no repair. Every
|
|
10
|
-
// constructor here therefore ends in {@link normalizeTable}, and a caller reaches
|
|
11
|
-
// the shape only through them.
|
|
12
|
-
//
|
|
13
|
-
// A cell is its own content unit: `marks` are USV offsets into that cell's `text`,
|
|
14
|
-
// not into `Content.text`. That is the second coordinate space, and the inline mode
|
|
15
|
-
// the codec already runs (one paragraph, no containers, no islands) is exactly its
|
|
16
|
-
// shape, so a cell decodes and projects through the same machinery a
|
|
17
|
-
// `richtext(inline)` field does.
|
|
18
|
-
import { isAnchorMark } from '@quillmark/wasm';
|
|
19
1
|
import { core } from '../lifecycle.js';
|
|
20
2
|
import { contentEdit, pmToContent } from './encode.js';
|
|
3
|
+
import { valueEqual } from './reconcile.js';
|
|
21
4
|
/** The alignments a column can take: the four the content declares, `none` first
|
|
22
5
|
* because it is what a column arrives at and what a set is cleared back to. */
|
|
23
6
|
export const ALIGNS = ['none', 'left', 'center', 'right'];
|
|
@@ -219,32 +202,32 @@ export function cellContent(cell) {
|
|
|
219
202
|
* the field plugin's coordinate space (the field's position map holds one `atom`
|
|
220
203
|
* run for the whole table), so nothing here can mint one and the popover withholds
|
|
221
204
|
* its `anchor` button while the caret is in a cell. Preserving them still means
|
|
222
|
-
* rebasing: a cell edit is a splice like any other, so each held
|
|
223
|
-
* the cell's own
|
|
224
|
-
*
|
|
225
|
-
*
|
|
205
|
+
* rebasing: a cell edit is a splice like any other, so `mapMarks` carries each held
|
|
206
|
+
* anchor through the cell's own delta by the rule the store applies to a field's,
|
|
207
|
+
* and a mark the splice deleted through lands where the splice left it rather than
|
|
208
|
+
* pointing past the text.
|
|
226
209
|
*/
|
|
227
210
|
export function cellFromDoc(doc, prior) {
|
|
228
211
|
const projected = pmToContent(doc);
|
|
229
212
|
const text = projected.text;
|
|
230
|
-
const anchors = prior.marks.filter(
|
|
213
|
+
const anchors = prior.marks.filter((m) => m.type === 'anchor');
|
|
231
214
|
if (!anchors.length)
|
|
232
215
|
return { text, marks: projected.marks };
|
|
233
|
-
const
|
|
234
|
-
const {
|
|
235
|
-
const rebased =
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
});
|
|
216
|
+
const before = cellContent(prior);
|
|
217
|
+
const delta = contentEdit(before, cellContent({ text, marks: [] })).delta;
|
|
218
|
+
const rebased = core()
|
|
219
|
+
.mapMarks(before, delta ? { delta } : {})
|
|
220
|
+
.filter((m) => m.type === 'anchor');
|
|
239
221
|
return {
|
|
240
222
|
text,
|
|
241
223
|
marks: [...projected.marks, ...rebased].sort((a, b) => a.start - b.start || a.end - b.end)
|
|
242
224
|
};
|
|
243
225
|
}
|
|
244
226
|
/** Whether two cells are the same value: what tells an own edit (the projection the
|
|
245
|
-
* view just produced) from an external one (an undo, a re-hydrate) without a flag.
|
|
227
|
+
* view just produced) from an external one (an undo, a re-hydrate) without a flag.
|
|
228
|
+
* The marks structurally, for the reason `reconcile.ts` states. */
|
|
246
229
|
export function cellEqual(a, b) {
|
|
247
|
-
return a.text === b.text &&
|
|
230
|
+
return a.text === b.text && valueEqual(a.marks, b.marks);
|
|
248
231
|
}
|
|
249
232
|
/** Whether two tables have the same rectangle and alignment: the change that forces
|
|
250
233
|
* a rebuild rather than a per-cell reseed. */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a link carrying `href` renders as one. A value with no scheme is relative
|
|
3
|
+
* to the embedding page and has none to refuse.
|
|
4
|
+
*
|
|
5
|
+
* A refused href is unchanged: it stays on the mark and round-trips, so a document
|
|
6
|
+
* survives an editor that declines to make it clickable.
|
|
7
|
+
*/
|
|
8
|
+
export declare function rendersHref(href: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* A url the store will take: the one character class the authored lane refuses, a CR
|
|
11
|
+
* or an LF, percent-encoded. CommonMark admits no line ending in a link destination,
|
|
12
|
+
* so `MarkOp.add` of a `link`, an `image` island op and `overwrite` all throw on one —
|
|
13
|
+
* and both of a leaf's commit lanes throwing is a field that stops persisting for the
|
|
14
|
+
* session, since the PM doc still holds the mark on the next keystroke.
|
|
15
|
+
*
|
|
16
|
+
* Applied where a value enters the model from outside it (a paste's `href`, the link
|
|
17
|
+
* prompt), which is the only door that can produce one: `importMarkdown` mints none,
|
|
18
|
+
* and `toMarkdown` writes the same `%0A` / `%0D`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function storableUrl(url: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// URLs at the model's edge. A link's `href` and an image island's `url` are the two
|
|
2
|
+
// values a document carries that a renderer follows, and both enter the model from
|
|
3
|
+
// outside it — a paste's markup, the link prompt. One module because the two rules are
|
|
4
|
+
// one rule: what renders, and what the store will take.
|
|
5
|
+
// ── The href gate ───────────────────────────────────────────────────────────
|
|
6
|
+
// An `href` is an attribute value and not markup, so the markdown → typed node → DOM
|
|
7
|
+
// path that keeps a document's text from becoming tags never reaches it: what the
|
|
8
|
+
// mark carries is what `toDOM` emits, and `renderContent` paints marks outside a
|
|
9
|
+
// `contenteditable` (the tips card), where a click is a plain one.
|
|
10
|
+
//
|
|
11
|
+
// An allowlist, because the set a document can spell is open: naming the dangerous
|
|
12
|
+
// schemes instead loses to the first one this has not heard of. It holds what the
|
|
13
|
+
// surface has a caller for; a scheme reaching it later is a line.
|
|
14
|
+
const RENDERED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel', 'ftp']);
|
|
15
|
+
/** A scheme and its colon: a letter, then letters, digits, `+`, `-` or `.` (RFC 3986). */
|
|
16
|
+
const SCHEME = /^([a-z][a-z0-9+.-]*):/i;
|
|
17
|
+
/** Dropped before the scheme is read, so a tab spliced into `javascript:` is tested
|
|
18
|
+
* as what it navigates to. Wider than the URL parser's own tab/newline rule: no
|
|
19
|
+
* scheme carries a control character, and the strip decides without rewriting. */
|
|
20
|
+
const IGNORED = /[\u0000-\u0020]/g;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a link carrying `href` renders as one. A value with no scheme is relative
|
|
23
|
+
* to the embedding page and has none to refuse.
|
|
24
|
+
*
|
|
25
|
+
* A refused href is unchanged: it stays on the mark and round-trips, so a document
|
|
26
|
+
* survives an editor that declines to make it clickable.
|
|
27
|
+
*/
|
|
28
|
+
export function rendersHref(href) {
|
|
29
|
+
const scheme = SCHEME.exec(href.replace(IGNORED, ''));
|
|
30
|
+
return scheme === null || RENDERED_SCHEMES.has(scheme[1].toLowerCase());
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A url the store will take: the one character class the authored lane refuses, a CR
|
|
34
|
+
* or an LF, percent-encoded. CommonMark admits no line ending in a link destination,
|
|
35
|
+
* so `MarkOp.add` of a `link`, an `image` island op and `overwrite` all throw on one —
|
|
36
|
+
* and both of a leaf's commit lanes throwing is a field that stops persisting for the
|
|
37
|
+
* session, since the PM doc still holds the mark on the next keystroke.
|
|
38
|
+
*
|
|
39
|
+
* Applied where a value enters the model from outside it (a paste's `href`, the link
|
|
40
|
+
* prompt), which is the only door that can produce one: `importMarkdown` mints none,
|
|
41
|
+
* and `toMarkdown` writes the same `%0A` / `%0D`.
|
|
42
|
+
*/
|
|
43
|
+
export function storableUrl(url) {
|
|
44
|
+
return url.replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
45
|
+
}
|
package/dist/preset/recipes.css
CHANGED
|
@@ -112,7 +112,7 @@ html:has(.qm-workspace) {
|
|
|
112
112
|
display: none;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/* One mount at
|
|
115
|
+
/* One mount at a time under the threshold, rather than two of them abreast. Half a narrow
|
|
116
116
|
viewport is under the width either surface reads at, so the split stops being one and
|
|
117
117
|
the tracks become views of the same room: the host says which with `data-qm-show`, and
|
|
118
118
|
the attribute names the track rather than what is in it, so nothing here knows an editor
|
package/dist/preset/scale.css
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
/* The host'
|
|
1
|
+
/* The host's derivation: the same public dials in, a closed `--qmh-*` scale for the
|
|
2
2
|
page around the surfaces out. `core/theme.css` is the same shape one level down
|
|
3
3
|
(dials in, `--_qm-*` out), and the two are calibrated against each other: the
|
|
4
|
-
figures here are the figures there (the ground step's ramp, 8% hover,
|
|
5
|
-
|
|
4
|
+
figures here are the figures there (the ground step's ramp, 8% hover, the
|
|
5
|
+
ring's width, the status hue at both poles) and the
|
|
6
6
|
dial means the same plane in both, so a host plate and a card on it land on the same
|
|
7
|
-
tone at both poles and a host page meets an editor's own column exactly. The
|
|
7
|
+
tone at both poles and a host page meets an editor's own column exactly. The border
|
|
8
|
+
and ink rungs are each calibrated for their own plane rather than shared: a page's
|
|
9
|
+
chrome is read at one size against one ground, a card's interior at several against
|
|
10
|
+
the rungs under it, so the mixes are not the same figure. The well
|
|
8
11
|
under the ground has no counterpart here: a host's own controls are boxed and carry a
|
|
9
12
|
hairline, so nothing on a page needs the step an edgeless field does.
|
|
10
13
|
|