@openleaf-editor/core 0.1.0-beta.1 → 0.1.0-beta.3
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 +115 -0
- package/dist/autolink.d.ts +12 -0
- package/dist/autolink.d.ts.map +1 -0
- package/dist/autolink.js +136 -0
- package/dist/autolink.js.map +1 -0
- package/dist/command-helpers.d.ts +8 -0
- package/dist/command-helpers.d.ts.map +1 -0
- package/dist/command-helpers.js +36 -0
- package/dist/command-helpers.js.map +1 -0
- package/dist/commands.d.ts +43 -22
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +513 -115
- package/dist/commands.js.map +1 -1
- package/dist/css.d.ts +1 -123
- package/dist/css.d.ts.map +1 -1
- package/dist/css.js +1 -214
- package/dist/css.js.map +1 -1
- package/dist/disclosure.d.ts +21 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +144 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/elements.d.ts +2 -0
- package/dist/elements.d.ts.map +1 -0
- package/dist/elements.js +2 -0
- package/dist/elements.js.map +1 -0
- package/dist/embed.d.ts +2 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +2 -0
- package/dist/embed.js.map +1 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +40 -0
- package/dist/errors.js.map +1 -0
- package/dist/extensions.d.ts +46 -5
- package/dist/extensions.d.ts.map +1 -1
- package/dist/extensions.js +292 -65
- package/dist/extensions.js.map +1 -1
- package/dist/formats.d.ts +44 -0
- package/dist/formats.d.ts.map +1 -0
- package/dist/formats.js +123 -0
- package/dist/formats.js.map +1 -0
- package/dist/html.d.ts +32 -2
- package/dist/html.d.ts.map +1 -1
- package/dist/html.js +185 -31
- package/dist/html.js.map +1 -1
- package/dist/index.d.ts +27 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -10
- package/dist/index.js.map +1 -1
- package/dist/insert-commands.d.ts +172 -0
- package/dist/insert-commands.d.ts.map +1 -0
- package/dist/insert-commands.js +560 -0
- package/dist/insert-commands.js.map +1 -0
- package/dist/isolating-selection.d.ts +41 -0
- package/dist/isolating-selection.d.ts.map +1 -0
- package/dist/isolating-selection.js +162 -0
- package/dist/isolating-selection.js.map +1 -0
- package/dist/keymap.d.ts.map +1 -1
- package/dist/keymap.js +6 -3
- package/dist/keymap.js.map +1 -1
- package/dist/noneditable.d.ts +14 -0
- package/dist/noneditable.d.ts.map +1 -0
- package/dist/noneditable.js +107 -0
- package/dist/noneditable.js.map +1 -0
- package/dist/plugins.d.ts +20 -2
- package/dist/plugins.d.ts.map +1 -1
- package/dist/plugins.js +27 -2
- package/dist/plugins.js.map +1 -1
- package/dist/preserve.d.ts +76 -0
- package/dist/preserve.d.ts.map +1 -1
- package/dist/preserve.js +308 -58
- package/dist/preserve.js.map +1 -1
- package/dist/schema.d.ts +15 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +356 -76
- package/dist/schema.js.map +1 -1
- package/dist/structure.d.ts +76 -0
- package/dist/structure.d.ts.map +1 -0
- package/dist/structure.js +422 -0
- package/dist/structure.js.map +1 -0
- package/dist/tables.d.ts +60 -1
- package/dist/tables.d.ts.map +1 -1
- package/dist/tables.js +617 -35
- package/dist/tables.js.map +1 -1
- package/dist/testing.d.ts +3 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +3 -0
- package/dist/testing.js.map +1 -0
- package/dist/tokens.d.ts +36 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +116 -0
- package/dist/tokens.js.map +1 -0
- package/dist/url.d.ts +1 -25
- package/dist/url.d.ts.map +1 -1
- package/dist/url.js +1 -77
- package/dist/url.js.map +1 -1
- package/dist/visual-aids.d.ts +20 -0
- package/dist/visual-aids.d.ts.map +1 -0
- package/dist/visual-aids.js +149 -0
- package/dist/visual-aids.js.map +1 -0
- package/package.json +16 -1
package/dist/commands.js
CHANGED
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
* button disabled-state free -- the same function answers "can I?" and "do it".
|
|
14
14
|
*/
|
|
15
15
|
import { redo, undo } from 'prosemirror-history';
|
|
16
|
-
import {
|
|
16
|
+
import { toggleMark, wrapIn } from 'prosemirror-commands';
|
|
17
|
+
import {} from 'prosemirror-model';
|
|
17
18
|
import { liftListItem, sinkListItem, splitListItem, wrapInList, } from 'prosemirror-schema-list';
|
|
18
|
-
import { safeColor } from './css.js';
|
|
19
|
+
import { MAX_INDENT, safeColor, safeDir, safeFontFamily, safeFontSize, safeLang, safeLineHeight, safeListStyle, } from './css.js';
|
|
20
|
+
import { canInsertNode, markCommand, markIn, nodeCommand, nodeIn } from './command-helpers.js';
|
|
21
|
+
import { CARRIED_ATTR } from './extensions.js';
|
|
19
22
|
/**
|
|
20
23
|
* Types are resolved from the state's schema, never from a captured singleton.
|
|
21
24
|
*
|
|
@@ -31,28 +34,183 @@ import { safeColor } from './css.js';
|
|
|
31
34
|
* renders that as a disabled button. Throwing would take the editor down
|
|
32
35
|
* because a plugin trimmed the schema.
|
|
33
36
|
*/
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
/* ------------------------------------------------------------------ *
|
|
38
|
+
* Two things every command in this file has to get right
|
|
39
|
+
* ------------------------------------------------------------------ */
|
|
40
|
+
/**
|
|
41
|
+
* The selected ranges. For an ordinary selection there is exactly one; for a
|
|
42
|
+
* table selection there is one per cell.
|
|
43
|
+
*
|
|
44
|
+
* `selection.from` and `selection.to` are the bounds of a SINGLE range, not the
|
|
45
|
+
* union of all of them. That is fine for a text selection and silently wrong for
|
|
46
|
+
* a `CellSelection` from prosemirror-tables: selecting a column of a 3x2 table
|
|
47
|
+
* gives `ranges.length === 2` while `from`/`to` describe the interior of one
|
|
48
|
+
* cell. Every command that read those two numbers therefore coloured, sized or
|
|
49
|
+
* cleared one cell of a selected column and left the others untouched.
|
|
50
|
+
*
|
|
51
|
+
* What made it a bug rather than an unimplemented feature is that `toggleBold`
|
|
52
|
+
* was never affected: it goes through prosemirror-commands' `toggleMark`, which
|
|
53
|
+
* iterates ranges. So on one identical selection, Bold worked and colour did
|
|
54
|
+
* not, which an author reads as the editor failing at random rather than as a
|
|
55
|
+
* limitation they could work around.
|
|
56
|
+
*
|
|
57
|
+
* A named accessor rather than inlining `state.selection.ranges` everywhere,
|
|
58
|
+
* because the point that needs to survive future edits is "never destructure
|
|
59
|
+
* from/to for an operation over a selection", and a function is where that
|
|
60
|
+
* reasoning can live.
|
|
61
|
+
*/
|
|
62
|
+
function selectedRanges(state) {
|
|
63
|
+
return state.selection.ranges;
|
|
36
64
|
}
|
|
37
|
-
|
|
38
|
-
|
|
65
|
+
/** True when any selected range carries this mark. */
|
|
66
|
+
function anyRangeHasMark(state, type) {
|
|
67
|
+
return selectedRanges(state).some((range) => state.doc.rangeHasMark(range.$from.pos, range.$to.pos, type));
|
|
39
68
|
}
|
|
40
|
-
/**
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
69
|
+
/** Remove a mark from every selected range, optionally re-adding it. */
|
|
70
|
+
function replaceMarkInRanges(state, tr, type, attrs) {
|
|
71
|
+
for (const range of selectedRanges(state)) {
|
|
72
|
+
const { pos: from } = range.$from;
|
|
73
|
+
const { pos: to } = range.$to;
|
|
74
|
+
tr.removeMark(from, to, type);
|
|
75
|
+
if (attrs !== null)
|
|
76
|
+
tr.addMark(from, to, type.create(attrs));
|
|
77
|
+
}
|
|
48
78
|
}
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The attributes to give a node being retyped, so that changing a block's TYPE
|
|
81
|
+
* does not also change everything else about it.
|
|
82
|
+
*
|
|
83
|
+
* Every attribute the destination type also declares comes across unchanged;
|
|
84
|
+
* anything it does not declare is dropped, because there is nowhere to put it.
|
|
85
|
+
* The one that matters most is `__openleafCarried`, the residue holding `class`,
|
|
86
|
+
* every `data-*` and all unmodelled CSS -- and `dir`, which schema.ts makes a
|
|
87
|
+
* first-class attribute precisely because dropping it silently breaks Arabic,
|
|
88
|
+
* Hebrew and Persian content.
|
|
89
|
+
*
|
|
90
|
+
* Three separate mechanisms used to lose all of it, which is why the fix is a
|
|
91
|
+
* shared helper rather than four local patches. `setHeading` hand-built its
|
|
92
|
+
* attribute object and listed four names, so anything added to the schema
|
|
93
|
+
* afterwards fell back to a default. `setParagraph` and `toggleCodeBlock` passed
|
|
94
|
+
* no attributes at all. `toggleList` called `setNodeMarkup(pos, type)` and
|
|
95
|
+
* prosemirror-transform does `type.create(undefined, ...)` there -- it does NOT
|
|
96
|
+
* fall back to the old node's attributes, which is the part that reads as though
|
|
97
|
+
* it should be safe and is not.
|
|
98
|
+
*
|
|
99
|
+
* It also silently broke `formats.ts`: `setBlockClass` writes the chosen format
|
|
100
|
+
* class into the carried residue, so applying a format and then changing the
|
|
101
|
+
* heading level removed the format.
|
|
102
|
+
*/
|
|
103
|
+
function carryOver(from, to, overrides = {}) {
|
|
104
|
+
const out = {};
|
|
105
|
+
const declared = new Set(Object.keys(to.spec.attrs ?? {}));
|
|
106
|
+
for (const name of declared) {
|
|
107
|
+
if (name in from.attrs)
|
|
108
|
+
out[name] = from.attrs[name];
|
|
109
|
+
}
|
|
110
|
+
/*
|
|
111
|
+
* An `id` outlives the block type it was written on.
|
|
112
|
+
*
|
|
113
|
+
* `heading` declares `id` and `paragraph` does not, so demoting a heading to
|
|
114
|
+
* a paragraph dropped it -- and an id on a heading is not decoration, it is
|
|
115
|
+
* the target of every in-page link and every table-of-contents entry pointing
|
|
116
|
+
* at that section. Changing a block's type is not a request to break the
|
|
117
|
+
* links to it.
|
|
118
|
+
*
|
|
119
|
+
* It moves into the carried residue rather than into an attribute, because
|
|
120
|
+
* the destination type has no such attribute to move it into. That is exactly
|
|
121
|
+
* what residue is for, and it is already where a `<p id="x">` parsed from
|
|
122
|
+
* stored content keeps its id, so both routes arrive at one representation.
|
|
123
|
+
*/
|
|
124
|
+
if (!declared.has('id') && declared.has(CARRIED_ATTR)) {
|
|
125
|
+
const id = from.attrs['id'];
|
|
126
|
+
if (typeof id === 'string' && id !== '') {
|
|
127
|
+
const carried = { ...(out[CARRIED_ATTR] ?? {}) };
|
|
128
|
+
if (carried['id'] === undefined)
|
|
129
|
+
carried['id'] = id;
|
|
130
|
+
out[CARRIED_ATTR] = carried;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { ...out, ...overrides };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* `setBlockType`, with per-node attributes and every selected range.
|
|
137
|
+
*
|
|
138
|
+
* prosemirror-commands' own `setBlockType` takes ONE attribute object and
|
|
139
|
+
* applies it to every block in the selection, which cannot express "keep each
|
|
140
|
+
* block's own residue". prosemirror-transform's `Transform.setBlockType` does
|
|
141
|
+
* accept a function, so the applicability check and the loop are reproduced here
|
|
142
|
+
* against that. The shape is deliberately the same as upstream's so the two stay
|
|
143
|
+
* comparable.
|
|
144
|
+
*/
|
|
145
|
+
function setBlockTypeCarrying(name, overrides = () => ({})) {
|
|
146
|
+
return (state, dispatch) => {
|
|
52
147
|
const type = nodeIn(state, name);
|
|
53
148
|
if (!type)
|
|
54
149
|
return false;
|
|
55
|
-
|
|
150
|
+
const attrsFor = (node) => carryOver(node, type, overrides(node));
|
|
151
|
+
let applicable = false;
|
|
152
|
+
for (const range of selectedRanges(state)) {
|
|
153
|
+
if (applicable)
|
|
154
|
+
break;
|
|
155
|
+
state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
|
|
156
|
+
if (applicable)
|
|
157
|
+
return false;
|
|
158
|
+
// `hasMarkup` is what makes "set paragraph on a plain paragraph" report
|
|
159
|
+
// false, and it has to be asked with the attributes this call would
|
|
160
|
+
// actually write -- otherwise a no-op conversion looks like a change.
|
|
161
|
+
if (!node.isTextblock || node.hasMarkup(type, attrsFor(node)))
|
|
162
|
+
return;
|
|
163
|
+
// A figure is a textblock (`content: 'inline+'`) but its children are an
|
|
164
|
+
// image and a caption -- retyping it produces an <h2> holding a
|
|
165
|
+
// <figcaption>. `heading` and `paragraph` also accept inline content, so
|
|
166
|
+
// `validContent` is not enough; isolating is what marks a textblock that
|
|
167
|
+
// is not a retypable paragraph.
|
|
168
|
+
if (node.type.spec.isolating)
|
|
169
|
+
return;
|
|
170
|
+
// `code_block` cannot hold an image or a figcaption, which is where the
|
|
171
|
+
// TransformError came from when this check was missing.
|
|
172
|
+
if (!type.validContent(node.content))
|
|
173
|
+
return;
|
|
174
|
+
if (node.type === type) {
|
|
175
|
+
applicable = true;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const $pos = state.doc.resolve(pos);
|
|
179
|
+
const index = $pos.index();
|
|
180
|
+
applicable = $pos.parent.canReplaceWith(index, index + 1, type);
|
|
181
|
+
return;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (!applicable)
|
|
185
|
+
return false;
|
|
186
|
+
if (dispatch) {
|
|
187
|
+
const tr = state.tr;
|
|
188
|
+
for (const range of selectedRanges(state)) {
|
|
189
|
+
// Per node, not the whole range: `setBlockType` on a span that also
|
|
190
|
+
// covers a figure would still try to retype that figure, which is the
|
|
191
|
+
// TransformError a select-all used to throw even after the
|
|
192
|
+
// applicability check started skipping it.
|
|
193
|
+
state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
|
|
194
|
+
if (!node.isTextblock || node.hasMarkup(type, attrsFor(node)))
|
|
195
|
+
return;
|
|
196
|
+
if (node.type.spec.isolating)
|
|
197
|
+
return;
|
|
198
|
+
if (!type.validContent(node.content))
|
|
199
|
+
return;
|
|
200
|
+
if (node.type !== type) {
|
|
201
|
+
const $pos = state.doc.resolve(pos);
|
|
202
|
+
const index = $pos.index();
|
|
203
|
+
if (!$pos.parent.canReplaceWith(index, index + 1, type))
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const mappedFrom = tr.mapping.map(pos);
|
|
207
|
+
const mappedTo = tr.mapping.map(pos + node.nodeSize);
|
|
208
|
+
tr.setBlockType(mappedFrom, mappedTo, type, attrsFor);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
dispatch(tr.scrollIntoView());
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
56
214
|
};
|
|
57
215
|
}
|
|
58
216
|
/* ------------------------------------------------------------------ *
|
|
@@ -70,11 +228,11 @@ export function isMarkActive(state, markName) {
|
|
|
70
228
|
const type = markIn(state, markName);
|
|
71
229
|
if (!type)
|
|
72
230
|
return false;
|
|
73
|
-
const {
|
|
231
|
+
const { $from, empty } = state.selection;
|
|
74
232
|
if (empty) {
|
|
75
233
|
return !!type.isInSet(state.storedMarks ?? $from.marks());
|
|
76
234
|
}
|
|
77
|
-
return state
|
|
235
|
+
return anyRangeHasMark(state, type);
|
|
78
236
|
}
|
|
79
237
|
/** Is the selection inside a node of this type (with these attributes)? */
|
|
80
238
|
export function isNodeActive(state, nodeName, attrs) {
|
|
@@ -100,16 +258,7 @@ export function isNodeActive(state, nodeName, attrs) {
|
|
|
100
258
|
}
|
|
101
259
|
/** Can a node of this type be inserted at the current selection? */
|
|
102
260
|
export function canInsert(state, nodeName) {
|
|
103
|
-
|
|
104
|
-
if (!type)
|
|
105
|
-
return false;
|
|
106
|
-
const { $from } = state.selection;
|
|
107
|
-
for (let depth = $from.depth; depth >= 0; depth -= 1) {
|
|
108
|
-
const index = $from.index(depth);
|
|
109
|
-
if ($from.node(depth).canReplaceWith(index, index, type))
|
|
110
|
-
return true;
|
|
111
|
-
}
|
|
112
|
-
return false;
|
|
261
|
+
return canInsertNode(state, nodeName);
|
|
113
262
|
}
|
|
114
263
|
/** The heading level at the cursor, or null when not in a heading. */
|
|
115
264
|
export function activeHeadingLevel(state) {
|
|
@@ -129,20 +278,22 @@ export function activeLink(state) {
|
|
|
129
278
|
const type = markIn(state, 'link');
|
|
130
279
|
if (!type)
|
|
131
280
|
return null;
|
|
132
|
-
const { $from, empty
|
|
281
|
+
const { $from, empty } = state.selection;
|
|
133
282
|
if (empty) {
|
|
134
283
|
const found = type.isInSet($from.marks());
|
|
135
284
|
return found ? found.attrs : null;
|
|
136
285
|
}
|
|
137
286
|
let attrs = null;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
287
|
+
for (const range of selectedRanges(state)) {
|
|
288
|
+
state.doc.nodesBetween(range.$from.pos, range.$to.pos, (child) => {
|
|
289
|
+
if (attrs)
|
|
290
|
+
return false;
|
|
291
|
+
const found = type.isInSet(child.marks);
|
|
292
|
+
if (found)
|
|
293
|
+
attrs = found.attrs;
|
|
294
|
+
return true;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
146
297
|
return attrs;
|
|
147
298
|
}
|
|
148
299
|
/* ------------------------------------------------------------------ *
|
|
@@ -153,12 +304,24 @@ export const toggleItalic = markCommand('em', toggleMark);
|
|
|
153
304
|
export const toggleUnderline = markCommand('underline', toggleMark);
|
|
154
305
|
export const toggleStrike = markCommand('strike', toggleMark);
|
|
155
306
|
export const toggleInlineCode = markCommand('code', toggleMark);
|
|
307
|
+
export const toggleSubscript = markCommand('subscript', toggleMark);
|
|
308
|
+
export const toggleSuperscript = markCommand('superscript', toggleMark);
|
|
156
309
|
/* ------------------------------------------------------------------ *
|
|
157
310
|
* Block commands
|
|
158
311
|
* ------------------------------------------------------------------ */
|
|
159
|
-
export const setParagraph =
|
|
312
|
+
export const setParagraph = setBlockTypeCarrying('paragraph');
|
|
313
|
+
/**
|
|
314
|
+
* `level` is the only attribute this command decides; everything else about the
|
|
315
|
+
* block is the author's and travels with it.
|
|
316
|
+
*
|
|
317
|
+
* `id` needs no special case any more, and that is a property of the schema
|
|
318
|
+
* rather than a shortcut: `paragraph` does not declare an `id`, so `carryOver`
|
|
319
|
+
* cannot promote one from a paragraph, while a heading's own `id` is declared
|
|
320
|
+
* and does come across. A paragraph stored as `<p id="x">` keeps its id anyway,
|
|
321
|
+
* through the carried residue, which is where an unmodelled attribute belongs.
|
|
322
|
+
*/
|
|
160
323
|
export function setHeading(level) {
|
|
161
|
-
return
|
|
324
|
+
return setBlockTypeCarrying('heading', () => ({ level }));
|
|
162
325
|
}
|
|
163
326
|
/**
|
|
164
327
|
* Toggle a heading level: applying the level you are already in returns the
|
|
@@ -175,7 +338,7 @@ export function toggleHeading(level) {
|
|
|
175
338
|
export const toggleCodeBlock = (state, dispatch, view) => {
|
|
176
339
|
if (isNodeActive(state, 'code_block'))
|
|
177
340
|
return setParagraph(state, dispatch, view);
|
|
178
|
-
return
|
|
341
|
+
return setBlockTypeCarrying('code_block')(state, dispatch, view);
|
|
179
342
|
};
|
|
180
343
|
export const wrapInBlockquote = nodeCommand('blockquote', (type) => wrapIn(type));
|
|
181
344
|
export const toggleBlockquote = (state, dispatch, view) => {
|
|
@@ -252,8 +415,18 @@ function toggleList(target) {
|
|
|
252
415
|
const type = nodeIn(state, target);
|
|
253
416
|
if (!type)
|
|
254
417
|
return false;
|
|
255
|
-
|
|
256
|
-
|
|
418
|
+
const node = state.doc.nodeAt(enclosing.pos);
|
|
419
|
+
if (!node)
|
|
420
|
+
return false;
|
|
421
|
+
// Attributes must be passed explicitly. `setNodeMarkup(pos, type)` reads
|
|
422
|
+
// as "change the type and leave the rest alone" and does not do that:
|
|
423
|
+
// prosemirror-transform calls `type.create(undefined, ...)`, so every
|
|
424
|
+
// attribute silently reverts to its default. `start` is genuinely dropped
|
|
425
|
+
// here because a `<ul>` has no such concept; `listStyle` and the residue
|
|
426
|
+
// holding `class` and every `data-*` are not.
|
|
427
|
+
if (dispatch) {
|
|
428
|
+
dispatch(state.tr.setNodeMarkup(enclosing.pos, type, carryOver(node, type)).scrollIntoView());
|
|
429
|
+
}
|
|
257
430
|
return true;
|
|
258
431
|
}
|
|
259
432
|
return nodeCommand(target, (type) => wrapInList(type))(state, dispatch, view);
|
|
@@ -262,6 +435,65 @@ function toggleList(target) {
|
|
|
262
435
|
export const splitListItemCommand = nodeCommand('list_item', (t) => splitListItem(t));
|
|
263
436
|
export const indentListItem = nodeCommand('list_item', (t) => sinkListItem(t));
|
|
264
437
|
export const outdentListItem = nodeCommand('list_item', (t) => liftListItem(t));
|
|
438
|
+
/**
|
|
439
|
+
* Indent: nest a list item when the selection is in a list, otherwise add a
|
|
440
|
+
* `padding-inline-start` step on the text blocks. Outdent is the reverse, and
|
|
441
|
+
* lifting a top-level list item out of its list is still how you leave a list.
|
|
442
|
+
*/
|
|
443
|
+
export const indent = (state, dispatch, view) => {
|
|
444
|
+
if (enclosingList(state))
|
|
445
|
+
return indentListItem(state, dispatch, view);
|
|
446
|
+
return adjustIndent(1)(state, dispatch, view);
|
|
447
|
+
};
|
|
448
|
+
export const outdent = (state, dispatch, view) => {
|
|
449
|
+
if (enclosingList(state))
|
|
450
|
+
return outdentListItem(state, dispatch, view);
|
|
451
|
+
return adjustIndent(-1)(state, dispatch, view);
|
|
452
|
+
};
|
|
453
|
+
function adjustIndent(delta) {
|
|
454
|
+
return (state, dispatch) => {
|
|
455
|
+
const blocks = blocksWithAttr(state, 'indent');
|
|
456
|
+
if (blocks.length === 0)
|
|
457
|
+
return false;
|
|
458
|
+
if (dispatch) {
|
|
459
|
+
const tr = state.tr;
|
|
460
|
+
for (const { pos, node } of blocks) {
|
|
461
|
+
const current = node.attrs['indent'] ?? 0;
|
|
462
|
+
const next = Math.max(0, Math.min(MAX_INDENT, current + delta));
|
|
463
|
+
tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: next === 0 ? null : next });
|
|
464
|
+
}
|
|
465
|
+
dispatch(tr.scrollIntoView());
|
|
466
|
+
}
|
|
467
|
+
return true;
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
export function activeIndent(state) {
|
|
471
|
+
return uniformBlockAttr(state, 'indent');
|
|
472
|
+
}
|
|
473
|
+
export function setListStyle(style) {
|
|
474
|
+
return (state, dispatch) => {
|
|
475
|
+
const enclosing = enclosingList(state);
|
|
476
|
+
if (!enclosing)
|
|
477
|
+
return false;
|
|
478
|
+
const value = style === null ? null : safeListStyle(style);
|
|
479
|
+
if (style !== null && value === null)
|
|
480
|
+
return false;
|
|
481
|
+
if (dispatch) {
|
|
482
|
+
const node = state.doc.nodeAt(enclosing.pos);
|
|
483
|
+
if (!node)
|
|
484
|
+
return false;
|
|
485
|
+
dispatch(state.tr.setNodeMarkup(enclosing.pos, undefined, { ...node.attrs, listStyle: value }).scrollIntoView());
|
|
486
|
+
}
|
|
487
|
+
return true;
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
export function activeListStyle(state) {
|
|
491
|
+
const enclosing = enclosingList(state);
|
|
492
|
+
if (!enclosing)
|
|
493
|
+
return null;
|
|
494
|
+
const node = state.doc.nodeAt(enclosing.pos);
|
|
495
|
+
return node?.attrs['listStyle'] ?? null;
|
|
496
|
+
}
|
|
265
497
|
/* ------------------------------------------------------------------ *
|
|
266
498
|
* Alignment
|
|
267
499
|
* ------------------------------------------------------------------ */
|
|
@@ -273,23 +505,36 @@ export const outdentListItem = nodeCommand('list_item', (t) => liftListItem(t));
|
|
|
273
505
|
* alignable block -- a caption, a callout -- gets the toolbar control and the
|
|
274
506
|
* keyboard shortcut for free, and this function does not have to learn its name.
|
|
275
507
|
*/
|
|
276
|
-
function
|
|
508
|
+
function blocksWithAttr(state, attr) {
|
|
277
509
|
const found = [];
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
510
|
+
// Deduplicated by position: callers write one `setNodeMarkup` per entry, and
|
|
511
|
+
// two writes to the same position in one transaction would have the second
|
|
512
|
+
// one built from a node the first already replaced.
|
|
513
|
+
const seen = new Set();
|
|
514
|
+
for (const range of selectedRanges(state)) {
|
|
515
|
+
state.doc.nodesBetween(range.$from.pos, range.$to.pos, (node, pos) => {
|
|
516
|
+
if (!node.isTextblock)
|
|
517
|
+
return true;
|
|
518
|
+
if (Object.hasOwn(node.attrs, attr) && !seen.has(pos)) {
|
|
519
|
+
seen.add(pos);
|
|
520
|
+
found.push({ pos, node });
|
|
521
|
+
}
|
|
522
|
+
return false;
|
|
523
|
+
});
|
|
524
|
+
}
|
|
291
525
|
return found;
|
|
292
526
|
}
|
|
527
|
+
function uniformBlockAttr(state, attr) {
|
|
528
|
+
const blocks = blocksWithAttr(state, attr);
|
|
529
|
+
const first = blocks[0];
|
|
530
|
+
if (!first)
|
|
531
|
+
return null;
|
|
532
|
+
const value = first.node.attrs[attr] ?? null;
|
|
533
|
+
return blocks.every((b) => (b.node.attrs[attr] ?? null) === value) ? value : null;
|
|
534
|
+
}
|
|
535
|
+
function alignableBlocks(state) {
|
|
536
|
+
return blocksWithAttr(state, 'align');
|
|
537
|
+
}
|
|
293
538
|
/**
|
|
294
539
|
* The alignment of the selection, or null when it has none or is mixed.
|
|
295
540
|
*
|
|
@@ -353,6 +598,57 @@ export function toggleTextAlign(align) {
|
|
|
353
598
|
return setTextAlign(next)(state, dispatch, view);
|
|
354
599
|
};
|
|
355
600
|
}
|
|
601
|
+
export function setLineHeight(value) {
|
|
602
|
+
return setBlockStringAttr('lineHeight', value, safeLineHeight);
|
|
603
|
+
}
|
|
604
|
+
export function activeLineHeight(state) {
|
|
605
|
+
return uniformBlockAttr(state, 'lineHeight');
|
|
606
|
+
}
|
|
607
|
+
export function setDir(dir) {
|
|
608
|
+
return (state, dispatch) => {
|
|
609
|
+
const blocks = blocksWithAttr(state, 'dir');
|
|
610
|
+
if (blocks.length === 0)
|
|
611
|
+
return false;
|
|
612
|
+
const value = dir === null ? null : safeDir(dir);
|
|
613
|
+
if (dir !== null && value === null)
|
|
614
|
+
return false;
|
|
615
|
+
if (dispatch) {
|
|
616
|
+
const tr = state.tr;
|
|
617
|
+
for (const { pos, node } of blocks) {
|
|
618
|
+
tr.setNodeMarkup(pos, undefined, { ...node.attrs, dir: value });
|
|
619
|
+
}
|
|
620
|
+
dispatch(tr.scrollIntoView());
|
|
621
|
+
}
|
|
622
|
+
return true;
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
export function toggleDir(dir) {
|
|
626
|
+
return (state, dispatch, view) => {
|
|
627
|
+
const next = activeDir(state) === dir ? null : dir;
|
|
628
|
+
return setDir(next)(state, dispatch, view);
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
export function activeDir(state) {
|
|
632
|
+
return uniformBlockAttr(state, 'dir');
|
|
633
|
+
}
|
|
634
|
+
function setBlockStringAttr(attr, value, validate) {
|
|
635
|
+
return (state, dispatch) => {
|
|
636
|
+
const blocks = blocksWithAttr(state, attr);
|
|
637
|
+
if (blocks.length === 0)
|
|
638
|
+
return false;
|
|
639
|
+
const next = value === null ? null : validate(value);
|
|
640
|
+
if (value !== null && next === null)
|
|
641
|
+
return false;
|
|
642
|
+
if (dispatch) {
|
|
643
|
+
const tr = state.tr;
|
|
644
|
+
for (const { pos, node } of blocks) {
|
|
645
|
+
tr.setNodeMarkup(pos, undefined, { ...node.attrs, [attr]: next });
|
|
646
|
+
}
|
|
647
|
+
dispatch(tr.scrollIntoView());
|
|
648
|
+
}
|
|
649
|
+
return true;
|
|
650
|
+
};
|
|
651
|
+
}
|
|
356
652
|
/* ------------------------------------------------------------------ *
|
|
357
653
|
* Colour
|
|
358
654
|
* ------------------------------------------------------------------ */
|
|
@@ -381,7 +677,7 @@ function colorCommand(name, color) {
|
|
|
381
677
|
const value = color === null ? null : safeColor(color);
|
|
382
678
|
if (color !== null && value === null)
|
|
383
679
|
return false;
|
|
384
|
-
const { empty
|
|
680
|
+
const { empty } = state.selection;
|
|
385
681
|
if (empty) {
|
|
386
682
|
const current = state.storedMarks ?? state.selection.$from.marks();
|
|
387
683
|
const stripped = current.filter((mark) => mark.type !== type);
|
|
@@ -392,12 +688,11 @@ function colorCommand(name, color) {
|
|
|
392
688
|
}
|
|
393
689
|
return true;
|
|
394
690
|
}
|
|
395
|
-
if (value === null && !state
|
|
691
|
+
if (value === null && !anyRangeHasMark(state, type))
|
|
396
692
|
return false;
|
|
397
693
|
if (dispatch) {
|
|
398
|
-
const tr = state.tr
|
|
399
|
-
|
|
400
|
-
tr.addMark(from, to, type.create({ color: value }));
|
|
694
|
+
const tr = state.tr;
|
|
695
|
+
replaceMarkInRanges(state, tr, type, value === null ? null : { color: value });
|
|
401
696
|
dispatch(tr.scrollIntoView());
|
|
402
697
|
}
|
|
403
698
|
return true;
|
|
@@ -408,24 +703,37 @@ function activeColor(state, name) {
|
|
|
408
703
|
const type = markIn(state, name);
|
|
409
704
|
if (!type)
|
|
410
705
|
return null;
|
|
411
|
-
const { empty, $from
|
|
706
|
+
const { empty, $from } = state.selection;
|
|
412
707
|
if (empty) {
|
|
413
708
|
const found = type.isInSet(state.storedMarks ?? $from.marks());
|
|
414
709
|
return found ? found.attrs['color'] : null;
|
|
415
710
|
}
|
|
711
|
+
// `seen` is load-bearing: `null` means both "not visited yet" and "unmarked
|
|
712
|
+
// text", so without a separate flag a selection that starts unmarked and then
|
|
713
|
+
// hits a coloured run would report the colour as uniform.
|
|
714
|
+
let seen = false;
|
|
416
715
|
let value = null;
|
|
417
716
|
let uniform = true;
|
|
418
|
-
|
|
419
|
-
|
|
717
|
+
// Over every range, for the same reason the command below writes over every
|
|
718
|
+
// range: reading one cell of a selected column and reporting it as the state
|
|
719
|
+
// of all of them puts a colour in the swatch that most of the selection does
|
|
720
|
+
// not have.
|
|
721
|
+
for (const range of selectedRanges(state)) {
|
|
722
|
+
state.doc.nodesBetween(range.$from.pos, range.$to.pos, (child) => {
|
|
723
|
+
if (!child.isText)
|
|
724
|
+
return true;
|
|
725
|
+
const found = type.isInSet(child.marks);
|
|
726
|
+
const colour = found ? found.attrs['color'] : null;
|
|
727
|
+
if (!seen) {
|
|
728
|
+
value = colour;
|
|
729
|
+
seen = true;
|
|
730
|
+
}
|
|
731
|
+
else if (colour !== value) {
|
|
732
|
+
uniform = false;
|
|
733
|
+
}
|
|
420
734
|
return true;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (value === null && uniform)
|
|
424
|
-
value = colour;
|
|
425
|
-
else if (colour !== value)
|
|
426
|
-
uniform = false;
|
|
427
|
-
return true;
|
|
428
|
-
});
|
|
735
|
+
});
|
|
736
|
+
}
|
|
429
737
|
// Mixed colours report null for the same reason mixed alignment does: a
|
|
430
738
|
// swatch showing one of them invites the author to act on all of them.
|
|
431
739
|
return uniform ? value : null;
|
|
@@ -444,63 +752,153 @@ export function activeTextColor(state) {
|
|
|
444
752
|
export function activeBackgroundColor(state) {
|
|
445
753
|
return activeColor(state, 'background_color');
|
|
446
754
|
}
|
|
447
|
-
|
|
448
|
-
* Apply or update a link across the selection.
|
|
449
|
-
*
|
|
450
|
-
* Removes any existing link first: without that, updating a link that only
|
|
451
|
-
* partially overlaps the selection leaves two adjacent links with different
|
|
452
|
-
* hrefs, which looks fine and is wrong.
|
|
453
|
-
*/
|
|
454
|
-
export function setLink(attrs) {
|
|
755
|
+
function attrMarkCommand(name, attr, value, validate) {
|
|
455
756
|
return (state, dispatch) => {
|
|
456
|
-
const type = markIn(state,
|
|
757
|
+
const type = markIn(state, name);
|
|
457
758
|
if (!type)
|
|
458
759
|
return false;
|
|
459
|
-
const
|
|
460
|
-
if (
|
|
760
|
+
const next = value === null ? null : validate(value);
|
|
761
|
+
if (value !== null && next === null)
|
|
762
|
+
return false;
|
|
763
|
+
const { empty } = state.selection;
|
|
764
|
+
if (empty) {
|
|
765
|
+
const current = state.storedMarks ?? state.selection.$from.marks();
|
|
766
|
+
const stripped = current.filter((mark) => mark.type !== type);
|
|
767
|
+
if (next === null && stripped.length === current.length)
|
|
768
|
+
return false;
|
|
769
|
+
if (dispatch) {
|
|
770
|
+
dispatch(state.tr.setStoredMarks(next === null ? stripped : [...stripped, type.create({ [attr]: next })]));
|
|
771
|
+
}
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
if (next === null && !anyRangeHasMark(state, type))
|
|
461
775
|
return false;
|
|
462
776
|
if (dispatch) {
|
|
463
777
|
const tr = state.tr;
|
|
464
|
-
|
|
465
|
-
tr.addMark(from, to, type.create({ ...attrs }));
|
|
778
|
+
replaceMarkInRanges(state, tr, type, next === null ? null : { [attr]: next });
|
|
466
779
|
dispatch(tr.scrollIntoView());
|
|
467
780
|
}
|
|
468
781
|
return true;
|
|
469
782
|
};
|
|
470
783
|
}
|
|
471
|
-
|
|
472
|
-
const type = markIn(state,
|
|
784
|
+
function activeMarkAttr(state, name, attr) {
|
|
785
|
+
const type = markIn(state, name);
|
|
473
786
|
if (!type)
|
|
474
|
-
return
|
|
475
|
-
const {
|
|
476
|
-
if (empty
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
787
|
+
return null;
|
|
788
|
+
const { empty, $from } = state.selection;
|
|
789
|
+
if (empty) {
|
|
790
|
+
const found = type.isInSet(state.storedMarks ?? $from.marks());
|
|
791
|
+
return found ? found.attrs[attr] : null;
|
|
792
|
+
}
|
|
793
|
+
// Same sentinel problem as activeColor: without `seen`, an unmarked run at
|
|
794
|
+
// the start of the selection is overwritten by the first marked value and
|
|
795
|
+
// the dropdown claims the whole range has that family or size.
|
|
796
|
+
let seen = false;
|
|
797
|
+
let value = null;
|
|
798
|
+
let uniform = true;
|
|
799
|
+
for (const range of selectedRanges(state)) {
|
|
800
|
+
state.doc.nodesBetween(range.$from.pos, range.$to.pos, (child) => {
|
|
801
|
+
if (!child.isText)
|
|
802
|
+
return true;
|
|
803
|
+
const found = type.isInSet(child.marks);
|
|
804
|
+
const current = found ? found.attrs[attr] : null;
|
|
805
|
+
if (!seen) {
|
|
806
|
+
value = current;
|
|
807
|
+
seen = true;
|
|
808
|
+
}
|
|
809
|
+
else if (current !== value) {
|
|
810
|
+
uniform = false;
|
|
811
|
+
}
|
|
812
|
+
return true;
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
return uniform ? value : null;
|
|
816
|
+
}
|
|
817
|
+
export function setFontFamily(family) {
|
|
818
|
+
return attrMarkCommand('font_family', 'family', family, safeFontFamily);
|
|
819
|
+
}
|
|
820
|
+
export function setFontSize(size) {
|
|
821
|
+
return attrMarkCommand('font_size', 'size', size, safeFontSize);
|
|
822
|
+
}
|
|
823
|
+
export function setLanguage(lang) {
|
|
824
|
+
return attrMarkCommand('language', 'lang', lang, safeLang);
|
|
825
|
+
}
|
|
826
|
+
export function activeFontFamily(state) {
|
|
827
|
+
return activeMarkAttr(state, 'font_family', 'family');
|
|
828
|
+
}
|
|
829
|
+
export function activeFontSize(state) {
|
|
830
|
+
return activeMarkAttr(state, 'font_size', 'size');
|
|
831
|
+
}
|
|
832
|
+
export function activeLanguage(state) {
|
|
833
|
+
return activeMarkAttr(state, 'language', 'lang');
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Strip character formatting and block decoration from the selection.
|
|
837
|
+
*
|
|
838
|
+
* Links stay: they are a destination, not a look. Direction stays: it is
|
|
839
|
+
* content. Language stays: it is the same kind of fact as direction, only
|
|
840
|
+
* modelled as a mark (`<span lang>`) rather than a node attribute. Headings
|
|
841
|
+
* and lists stay: they are structure. Alignment, indent, line height, fonts,
|
|
842
|
+
* colours, and the common character marks go.
|
|
843
|
+
*/
|
|
844
|
+
export const clearFormatting = (state, dispatch) => {
|
|
845
|
+
const keep = new Set(['link', 'language']);
|
|
846
|
+
const { empty, $from } = state.selection;
|
|
847
|
+
const marks = Object.values(state.schema.marks).filter((type) => !keep.has(type.name));
|
|
848
|
+
if (empty) {
|
|
849
|
+
const current = state.storedMarks ?? $from.marks();
|
|
850
|
+
const stripped = current.filter((mark) => keep.has(mark.type.name));
|
|
851
|
+
const blocks = blocksWithAttr(state, 'align');
|
|
852
|
+
const canClearBlocks = blocks.some((b) => b.node.attrs['align'] != null ||
|
|
853
|
+
b.node.attrs['lineHeight'] != null ||
|
|
854
|
+
b.node.attrs['indent'] != null);
|
|
855
|
+
if (stripped.length === current.length && !canClearBlocks)
|
|
485
856
|
return false;
|
|
486
857
|
if (dispatch) {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
height: attrs.height ?? null,
|
|
498
|
-
});
|
|
499
|
-
dispatch(state.tr.replaceSelectionWith(image).scrollIntoView());
|
|
858
|
+
let tr = state.tr.setStoredMarks(stripped);
|
|
859
|
+
for (const { pos, node } of blocks) {
|
|
860
|
+
tr = tr.setNodeMarkup(pos, undefined, {
|
|
861
|
+
...node.attrs,
|
|
862
|
+
align: null,
|
|
863
|
+
lineHeight: null,
|
|
864
|
+
indent: null,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
dispatch(tr.scrollIntoView());
|
|
500
868
|
}
|
|
501
869
|
return true;
|
|
502
|
-
}
|
|
503
|
-
|
|
870
|
+
}
|
|
871
|
+
let hasMarks = false;
|
|
872
|
+
for (const type of marks) {
|
|
873
|
+
if (anyRangeHasMark(state, type))
|
|
874
|
+
hasMarks = true;
|
|
875
|
+
}
|
|
876
|
+
const blocks = blocksWithAttr(state, 'align');
|
|
877
|
+
const canClearBlocks = blocks.some((b) => b.node.attrs['align'] != null ||
|
|
878
|
+
b.node.attrs['lineHeight'] != null ||
|
|
879
|
+
b.node.attrs['indent'] != null);
|
|
880
|
+
if (!hasMarks && !canClearBlocks)
|
|
881
|
+
return false;
|
|
882
|
+
if (dispatch) {
|
|
883
|
+
let tr = state.tr;
|
|
884
|
+
for (const type of marks) {
|
|
885
|
+
for (const range of selectedRanges(state)) {
|
|
886
|
+
tr = tr.removeMark(range.$from.pos, range.$to.pos, type);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
for (const { pos, node } of blocks) {
|
|
890
|
+
tr = tr.setNodeMarkup(pos, undefined, {
|
|
891
|
+
...node.attrs,
|
|
892
|
+
align: null,
|
|
893
|
+
lineHeight: null,
|
|
894
|
+
indent: null,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
dispatch(tr.scrollIntoView());
|
|
898
|
+
}
|
|
899
|
+
return true;
|
|
900
|
+
};
|
|
901
|
+
export { insertAudio, insertDetails, insertHtml, insertIframe, insertImage, insertNamedAnchor, insertNonBreakingSpace, insertPageBreak, insertText, insertVideo, selectedImage, selectedMedia, setHeadingId, setLink, unsetLink, updateImage, updateMedia, } from './insert-commands.js';
|
|
504
902
|
/* ------------------------------------------------------------------ *
|
|
505
903
|
* History
|
|
506
904
|
* ------------------------------------------------------------------ */
|