@openleaf-editor/core 0.1.0-beta.0 → 0.1.0-beta.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/autolink.d.ts +12 -0
- package/dist/autolink.d.ts.map +1 -0
- package/dist/autolink.js +98 -0
- package/dist/autolink.js.map +1 -0
- package/dist/commands.d.ts +109 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +639 -10
- package/dist/commands.js.map +1 -1
- package/dist/css.d.ts +176 -0
- package/dist/css.d.ts.map +1 -0
- package/dist/css.js +517 -0
- package/dist/css.js.map +1 -0
- package/dist/embed.d.ts +52 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +116 -0
- package/dist/embed.js.map +1 -0
- package/dist/extensions.d.ts.map +1 -1
- package/dist/extensions.js +104 -4
- 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 +111 -0
- package/dist/formats.js.map +1 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -5
- package/dist/index.js.map +1 -1
- package/dist/keymap.d.ts.map +1 -1
- package/dist/keymap.js +15 -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 +93 -0
- package/dist/noneditable.js.map +1 -0
- package/dist/preserve.d.ts +53 -0
- package/dist/preserve.d.ts.map +1 -1
- package/dist/preserve.js +133 -4
- package/dist/preserve.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +331 -61
- package/dist/schema.js.map +1 -1
- package/dist/structure.d.ts +44 -0
- package/dist/structure.d.ts.map +1 -0
- package/dist/structure.js +379 -0
- package/dist/structure.js.map +1 -0
- package/dist/tables.d.ts +13 -0
- package/dist/tables.d.ts.map +1 -1
- package/dist/tables.js +224 -19
- package/dist/tables.js.map +1 -1
- package/dist/tokens.d.ts +28 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +68 -0
- package/dist/tokens.js.map +1 -0
- package/dist/visual-aids.d.ts +10 -0
- package/dist/visual-aids.d.ts.map +1 -0
- package/dist/visual-aids.js +61 -0
- package/dist/visual-aids.js.map +1 -0
- package/package.json +3 -2
package/dist/commands.js
CHANGED
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { redo, undo } from 'prosemirror-history';
|
|
16
16
|
import { setBlockType, toggleMark, wrapIn } from 'prosemirror-commands';
|
|
17
|
+
import { Slice } from 'prosemirror-model';
|
|
17
18
|
import { liftListItem, sinkListItem, splitListItem, wrapInList, } from 'prosemirror-schema-list';
|
|
19
|
+
import { MAX_INDENT, safeColor, safeDir, safeFontFamily, safeFontSize, safeLang, safeLineHeight, safeListStyle, } from './css.js';
|
|
20
|
+
import { safeAllowList, safeEmbedSrc } from './embed.js';
|
|
21
|
+
import { parseHtml } from './html.js';
|
|
22
|
+
import { IMAGE_ALIGN_CLASSES, safeClassList, safeId } from './tokens.js';
|
|
23
|
+
import { isSafeUrl } from './url.js';
|
|
18
24
|
/**
|
|
19
25
|
* Types are resolved from the state's schema, never from a captured singleton.
|
|
20
26
|
*
|
|
@@ -152,12 +158,26 @@ export const toggleItalic = markCommand('em', toggleMark);
|
|
|
152
158
|
export const toggleUnderline = markCommand('underline', toggleMark);
|
|
153
159
|
export const toggleStrike = markCommand('strike', toggleMark);
|
|
154
160
|
export const toggleInlineCode = markCommand('code', toggleMark);
|
|
161
|
+
export const toggleSubscript = markCommand('subscript', toggleMark);
|
|
162
|
+
export const toggleSuperscript = markCommand('superscript', toggleMark);
|
|
155
163
|
/* ------------------------------------------------------------------ *
|
|
156
164
|
* Block commands
|
|
157
165
|
* ------------------------------------------------------------------ */
|
|
158
166
|
export const setParagraph = nodeCommand('paragraph', (type) => setBlockType(type));
|
|
159
167
|
export function setHeading(level) {
|
|
160
|
-
return
|
|
168
|
+
return (state, dispatch, view) => {
|
|
169
|
+
const type = nodeIn(state, 'heading');
|
|
170
|
+
if (!type)
|
|
171
|
+
return false;
|
|
172
|
+
const parent = state.selection.$from.parent;
|
|
173
|
+
const attrs = {
|
|
174
|
+
level,
|
|
175
|
+
dir: parent.attrs['dir'] ?? null,
|
|
176
|
+
align: parent.attrs['align'] ?? null,
|
|
177
|
+
id: parent.type === type ? (parent.attrs['id'] ?? null) : null,
|
|
178
|
+
};
|
|
179
|
+
return setBlockType(type, attrs)(state, dispatch, view);
|
|
180
|
+
};
|
|
161
181
|
}
|
|
162
182
|
/**
|
|
163
183
|
* Toggle a heading level: applying the level you are already in returns the
|
|
@@ -261,6 +281,437 @@ function toggleList(target) {
|
|
|
261
281
|
export const splitListItemCommand = nodeCommand('list_item', (t) => splitListItem(t));
|
|
262
282
|
export const indentListItem = nodeCommand('list_item', (t) => sinkListItem(t));
|
|
263
283
|
export const outdentListItem = nodeCommand('list_item', (t) => liftListItem(t));
|
|
284
|
+
/**
|
|
285
|
+
* Indent: nest a list item when the selection is in a list, otherwise add a
|
|
286
|
+
* `padding-inline-start` step on the text blocks. Outdent is the reverse, and
|
|
287
|
+
* lifting a top-level list item out of its list is still how you leave a list.
|
|
288
|
+
*/
|
|
289
|
+
export const indent = (state, dispatch, view) => {
|
|
290
|
+
if (enclosingList(state))
|
|
291
|
+
return indentListItem(state, dispatch, view);
|
|
292
|
+
return adjustIndent(1)(state, dispatch, view);
|
|
293
|
+
};
|
|
294
|
+
export const outdent = (state, dispatch, view) => {
|
|
295
|
+
if (enclosingList(state))
|
|
296
|
+
return outdentListItem(state, dispatch, view);
|
|
297
|
+
return adjustIndent(-1)(state, dispatch, view);
|
|
298
|
+
};
|
|
299
|
+
function adjustIndent(delta) {
|
|
300
|
+
return (state, dispatch) => {
|
|
301
|
+
const blocks = blocksWithAttr(state, 'indent');
|
|
302
|
+
if (blocks.length === 0)
|
|
303
|
+
return false;
|
|
304
|
+
if (dispatch) {
|
|
305
|
+
const tr = state.tr;
|
|
306
|
+
for (const { pos, node } of blocks) {
|
|
307
|
+
const current = node.attrs['indent'] ?? 0;
|
|
308
|
+
const next = Math.max(0, Math.min(MAX_INDENT, current + delta));
|
|
309
|
+
tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: next === 0 ? null : next });
|
|
310
|
+
}
|
|
311
|
+
dispatch(tr.scrollIntoView());
|
|
312
|
+
}
|
|
313
|
+
return true;
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
export function activeIndent(state) {
|
|
317
|
+
return uniformBlockAttr(state, 'indent');
|
|
318
|
+
}
|
|
319
|
+
export function setListStyle(style) {
|
|
320
|
+
return (state, dispatch) => {
|
|
321
|
+
const enclosing = enclosingList(state);
|
|
322
|
+
if (!enclosing)
|
|
323
|
+
return false;
|
|
324
|
+
const value = style === null ? null : safeListStyle(style);
|
|
325
|
+
if (style !== null && value === null)
|
|
326
|
+
return false;
|
|
327
|
+
if (dispatch) {
|
|
328
|
+
const node = state.doc.nodeAt(enclosing.pos);
|
|
329
|
+
if (!node)
|
|
330
|
+
return false;
|
|
331
|
+
dispatch(state.tr.setNodeMarkup(enclosing.pos, undefined, { ...node.attrs, listStyle: value }).scrollIntoView());
|
|
332
|
+
}
|
|
333
|
+
return true;
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
export function activeListStyle(state) {
|
|
337
|
+
const enclosing = enclosingList(state);
|
|
338
|
+
if (!enclosing)
|
|
339
|
+
return null;
|
|
340
|
+
const node = state.doc.nodeAt(enclosing.pos);
|
|
341
|
+
return node?.attrs['listStyle'] ?? null;
|
|
342
|
+
}
|
|
343
|
+
/* ------------------------------------------------------------------ *
|
|
344
|
+
* Alignment
|
|
345
|
+
* ------------------------------------------------------------------ */
|
|
346
|
+
/**
|
|
347
|
+
* The text blocks in the selection that can carry an alignment.
|
|
348
|
+
*
|
|
349
|
+
* Membership is decided by asking whether the node carries an `align`
|
|
350
|
+
* attribute, not by naming paragraph and heading. A plugin that adds an
|
|
351
|
+
* alignable block -- a caption, a callout -- gets the toolbar control and the
|
|
352
|
+
* keyboard shortcut for free, and this function does not have to learn its name.
|
|
353
|
+
*/
|
|
354
|
+
function blocksWithAttr(state, attr) {
|
|
355
|
+
const found = [];
|
|
356
|
+
const { from, to } = state.selection;
|
|
357
|
+
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
358
|
+
if (!node.isTextblock)
|
|
359
|
+
return true;
|
|
360
|
+
if (Object.hasOwn(node.attrs, attr))
|
|
361
|
+
found.push({ pos, node });
|
|
362
|
+
return false;
|
|
363
|
+
});
|
|
364
|
+
return found;
|
|
365
|
+
}
|
|
366
|
+
function uniformBlockAttr(state, attr) {
|
|
367
|
+
const blocks = blocksWithAttr(state, attr);
|
|
368
|
+
const first = blocks[0];
|
|
369
|
+
if (!first)
|
|
370
|
+
return null;
|
|
371
|
+
const value = first.node.attrs[attr] ?? null;
|
|
372
|
+
return blocks.every((b) => (b.node.attrs[attr] ?? null) === value) ? value : null;
|
|
373
|
+
}
|
|
374
|
+
function alignableBlocks(state) {
|
|
375
|
+
return blocksWithAttr(state, 'align');
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The alignment of the selection, or null when it has none or is mixed.
|
|
379
|
+
*
|
|
380
|
+
* Null means "no explicit alignment", which is a genuinely different state from
|
|
381
|
+
* "aligned left" and is reported as such: an unaligned paragraph follows the
|
|
382
|
+
* document's reading direction, so it renders right-aligned in Arabic or Hebrew.
|
|
383
|
+
* Reporting it as left would make the left button light up on text that is not
|
|
384
|
+
* left-aligned, and would make `aria-pressed="true"` a lie about a paragraph
|
|
385
|
+
* nobody has aligned.
|
|
386
|
+
*
|
|
387
|
+
* A selection spanning blocks with different alignments also reports null.
|
|
388
|
+
* Showing the first block's value as the state of all of them is a lie the
|
|
389
|
+
* author acts on -- they see "centred", press it to turn it off, and one
|
|
390
|
+
* paragraph they never looked at moves.
|
|
391
|
+
*/
|
|
392
|
+
export function activeTextAlign(state) {
|
|
393
|
+
const blocks = alignableBlocks(state);
|
|
394
|
+
const first = blocks[0];
|
|
395
|
+
if (!first)
|
|
396
|
+
return null;
|
|
397
|
+
const value = first.node.attrs['align'] ?? null;
|
|
398
|
+
return blocks.every((b) => (b.node.attrs['align'] ?? null) === value) ? value : null;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Set the alignment of every alignable block in the selection. `null` clears it.
|
|
402
|
+
*
|
|
403
|
+
* Reports true whenever there is something to align, even if it already carries
|
|
404
|
+
* this value. The alternative -- returning false because the work is already
|
|
405
|
+
* done -- would make the toolbar render the button for the CURRENT alignment as
|
|
406
|
+
* disabled, since a command's no-dispatch call is what drives enabled state.
|
|
407
|
+
*/
|
|
408
|
+
export function setTextAlign(align) {
|
|
409
|
+
return (state, dispatch) => {
|
|
410
|
+
const blocks = alignableBlocks(state);
|
|
411
|
+
if (blocks.length === 0)
|
|
412
|
+
return false;
|
|
413
|
+
if (dispatch) {
|
|
414
|
+
const tr = state.tr;
|
|
415
|
+
for (const { pos, node } of blocks) {
|
|
416
|
+
// No position mapping needed: an attribute-only change replaces a node
|
|
417
|
+
// with one of identical size, so earlier edits cannot shift later
|
|
418
|
+
// positions in this loop.
|
|
419
|
+
tr.setNodeMarkup(pos, undefined, { ...node.attrs, align });
|
|
420
|
+
}
|
|
421
|
+
dispatch(tr.scrollIntoView());
|
|
422
|
+
}
|
|
423
|
+
return true;
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Toggle an alignment: applying the one already in force clears it.
|
|
428
|
+
*
|
|
429
|
+
* The same reasoning as `toggleHeading`. An author who centred a paragraph by
|
|
430
|
+
* accident reaches for the control they just pressed, and having it do nothing
|
|
431
|
+
* because "centre" is already the value leaves them hunting for an
|
|
432
|
+
* un-centre button that does not exist.
|
|
433
|
+
*/
|
|
434
|
+
export function toggleTextAlign(align) {
|
|
435
|
+
return (state, dispatch, view) => {
|
|
436
|
+
const next = activeTextAlign(state) === align ? null : align;
|
|
437
|
+
return setTextAlign(next)(state, dispatch, view);
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
export function setLineHeight(value) {
|
|
441
|
+
return setBlockStringAttr('lineHeight', value, safeLineHeight);
|
|
442
|
+
}
|
|
443
|
+
export function activeLineHeight(state) {
|
|
444
|
+
return uniformBlockAttr(state, 'lineHeight');
|
|
445
|
+
}
|
|
446
|
+
export function setDir(dir) {
|
|
447
|
+
return (state, dispatch) => {
|
|
448
|
+
const blocks = blocksWithAttr(state, 'dir');
|
|
449
|
+
if (blocks.length === 0)
|
|
450
|
+
return false;
|
|
451
|
+
const value = dir === null ? null : safeDir(dir);
|
|
452
|
+
if (dir !== null && value === null)
|
|
453
|
+
return false;
|
|
454
|
+
if (dispatch) {
|
|
455
|
+
const tr = state.tr;
|
|
456
|
+
for (const { pos, node } of blocks) {
|
|
457
|
+
tr.setNodeMarkup(pos, undefined, { ...node.attrs, dir: value });
|
|
458
|
+
}
|
|
459
|
+
dispatch(tr.scrollIntoView());
|
|
460
|
+
}
|
|
461
|
+
return true;
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
export function toggleDir(dir) {
|
|
465
|
+
return (state, dispatch, view) => {
|
|
466
|
+
const next = activeDir(state) === dir ? null : dir;
|
|
467
|
+
return setDir(next)(state, dispatch, view);
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
export function activeDir(state) {
|
|
471
|
+
return uniformBlockAttr(state, 'dir');
|
|
472
|
+
}
|
|
473
|
+
function setBlockStringAttr(attr, value, validate) {
|
|
474
|
+
return (state, dispatch) => {
|
|
475
|
+
const blocks = blocksWithAttr(state, attr);
|
|
476
|
+
if (blocks.length === 0)
|
|
477
|
+
return false;
|
|
478
|
+
const next = value === null ? null : validate(value);
|
|
479
|
+
if (value !== null && next === null)
|
|
480
|
+
return false;
|
|
481
|
+
if (dispatch) {
|
|
482
|
+
const tr = state.tr;
|
|
483
|
+
for (const { pos, node } of blocks) {
|
|
484
|
+
tr.setNodeMarkup(pos, undefined, { ...node.attrs, [attr]: next });
|
|
485
|
+
}
|
|
486
|
+
dispatch(tr.scrollIntoView());
|
|
487
|
+
}
|
|
488
|
+
return true;
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
/* ------------------------------------------------------------------ *
|
|
492
|
+
* Colour
|
|
493
|
+
* ------------------------------------------------------------------ */
|
|
494
|
+
/**
|
|
495
|
+
* Apply a colour mark, replacing any existing one of the same kind.
|
|
496
|
+
*
|
|
497
|
+
* Not `toggleMark`: with attributes, toggling means "the same colour twice
|
|
498
|
+
* removes it", so choosing red on text that is already red would clear it --
|
|
499
|
+
* and choosing red on text that is blue would leave two overlapping marks whose
|
|
500
|
+
* winner is decided by mark order. A colour picker is a set operation, so the
|
|
501
|
+
* existing mark is removed first and the new one added over the whole range.
|
|
502
|
+
*
|
|
503
|
+
* With an empty selection the colour goes onto the stored marks, so it applies
|
|
504
|
+
* to what the author types next. That is the same behaviour as pressing Bold on
|
|
505
|
+
* an empty cursor, and leaving it out is the most common complaint about colour
|
|
506
|
+
* pickers -- you choose a colour, type, and get black text.
|
|
507
|
+
*/
|
|
508
|
+
function colorCommand(name, color) {
|
|
509
|
+
return (state, dispatch) => {
|
|
510
|
+
const type = markIn(state, name);
|
|
511
|
+
if (!type)
|
|
512
|
+
return false;
|
|
513
|
+
// A colour the schema would refuse is a bug in the caller, not a formatting
|
|
514
|
+
// request. Declining is better than writing an attribute that will be
|
|
515
|
+
// dropped on the next parse, which would look like the editor losing edits.
|
|
516
|
+
const value = color === null ? null : safeColor(color);
|
|
517
|
+
if (color !== null && value === null)
|
|
518
|
+
return false;
|
|
519
|
+
const { empty, from, to } = state.selection;
|
|
520
|
+
if (empty) {
|
|
521
|
+
const current = state.storedMarks ?? state.selection.$from.marks();
|
|
522
|
+
const stripped = current.filter((mark) => mark.type !== type);
|
|
523
|
+
if (value === null && stripped.length === current.length)
|
|
524
|
+
return false;
|
|
525
|
+
if (dispatch) {
|
|
526
|
+
dispatch(state.tr.setStoredMarks(value === null ? stripped : [...stripped, type.create({ color: value })]));
|
|
527
|
+
}
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
if (value === null && !state.doc.rangeHasMark(from, to, type))
|
|
531
|
+
return false;
|
|
532
|
+
if (dispatch) {
|
|
533
|
+
const tr = state.tr.removeMark(from, to, type);
|
|
534
|
+
if (value !== null)
|
|
535
|
+
tr.addMark(from, to, type.create({ color: value }));
|
|
536
|
+
dispatch(tr.scrollIntoView());
|
|
537
|
+
}
|
|
538
|
+
return true;
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
/** Attribute of a colour mark across the selection, or null when absent or mixed. */
|
|
542
|
+
function activeColor(state, name) {
|
|
543
|
+
const type = markIn(state, name);
|
|
544
|
+
if (!type)
|
|
545
|
+
return null;
|
|
546
|
+
const { empty, $from, from, to } = state.selection;
|
|
547
|
+
if (empty) {
|
|
548
|
+
const found = type.isInSet(state.storedMarks ?? $from.marks());
|
|
549
|
+
return found ? found.attrs['color'] : null;
|
|
550
|
+
}
|
|
551
|
+
let value = null;
|
|
552
|
+
let uniform = true;
|
|
553
|
+
state.doc.nodesBetween(from, to, (child) => {
|
|
554
|
+
if (!child.isText)
|
|
555
|
+
return true;
|
|
556
|
+
const found = type.isInSet(child.marks);
|
|
557
|
+
const colour = found ? found.attrs['color'] : null;
|
|
558
|
+
if (value === null && uniform)
|
|
559
|
+
value = colour;
|
|
560
|
+
else if (colour !== value)
|
|
561
|
+
uniform = false;
|
|
562
|
+
return true;
|
|
563
|
+
});
|
|
564
|
+
// Mixed colours report null for the same reason mixed alignment does: a
|
|
565
|
+
// swatch showing one of them invites the author to act on all of them.
|
|
566
|
+
return uniform ? value : null;
|
|
567
|
+
}
|
|
568
|
+
export function setTextColor(color) {
|
|
569
|
+
return colorCommand('text_color', color);
|
|
570
|
+
}
|
|
571
|
+
export function setBackgroundColor(color) {
|
|
572
|
+
return colorCommand('background_color', color);
|
|
573
|
+
}
|
|
574
|
+
export const clearTextColor = colorCommand('text_color', null);
|
|
575
|
+
export const clearBackgroundColor = colorCommand('background_color', null);
|
|
576
|
+
export function activeTextColor(state) {
|
|
577
|
+
return activeColor(state, 'text_color');
|
|
578
|
+
}
|
|
579
|
+
export function activeBackgroundColor(state) {
|
|
580
|
+
return activeColor(state, 'background_color');
|
|
581
|
+
}
|
|
582
|
+
function attrMarkCommand(name, attr, value, validate) {
|
|
583
|
+
return (state, dispatch) => {
|
|
584
|
+
const type = markIn(state, name);
|
|
585
|
+
if (!type)
|
|
586
|
+
return false;
|
|
587
|
+
const next = value === null ? null : validate(value);
|
|
588
|
+
if (value !== null && next === null)
|
|
589
|
+
return false;
|
|
590
|
+
const { empty, from, to } = state.selection;
|
|
591
|
+
if (empty) {
|
|
592
|
+
const current = state.storedMarks ?? state.selection.$from.marks();
|
|
593
|
+
const stripped = current.filter((mark) => mark.type !== type);
|
|
594
|
+
if (next === null && stripped.length === current.length)
|
|
595
|
+
return false;
|
|
596
|
+
if (dispatch) {
|
|
597
|
+
dispatch(state.tr.setStoredMarks(next === null ? stripped : [...stripped, type.create({ [attr]: next })]));
|
|
598
|
+
}
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
if (next === null && !state.doc.rangeHasMark(from, to, type))
|
|
602
|
+
return false;
|
|
603
|
+
if (dispatch) {
|
|
604
|
+
const tr = state.tr.removeMark(from, to, type);
|
|
605
|
+
if (next !== null)
|
|
606
|
+
tr.addMark(from, to, type.create({ [attr]: next }));
|
|
607
|
+
dispatch(tr.scrollIntoView());
|
|
608
|
+
}
|
|
609
|
+
return true;
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
function activeMarkAttr(state, name, attr) {
|
|
613
|
+
const type = markIn(state, name);
|
|
614
|
+
if (!type)
|
|
615
|
+
return null;
|
|
616
|
+
const { empty, $from, from, to } = state.selection;
|
|
617
|
+
if (empty) {
|
|
618
|
+
const found = type.isInSet(state.storedMarks ?? $from.marks());
|
|
619
|
+
return found ? found.attrs[attr] : null;
|
|
620
|
+
}
|
|
621
|
+
let value = null;
|
|
622
|
+
let uniform = true;
|
|
623
|
+
state.doc.nodesBetween(from, to, (child) => {
|
|
624
|
+
if (!child.isText)
|
|
625
|
+
return true;
|
|
626
|
+
const found = type.isInSet(child.marks);
|
|
627
|
+
const current = found ? found.attrs[attr] : null;
|
|
628
|
+
if (value === null && uniform)
|
|
629
|
+
value = current;
|
|
630
|
+
else if (current !== value)
|
|
631
|
+
uniform = false;
|
|
632
|
+
return true;
|
|
633
|
+
});
|
|
634
|
+
return uniform ? value : null;
|
|
635
|
+
}
|
|
636
|
+
export function setFontFamily(family) {
|
|
637
|
+
return attrMarkCommand('font_family', 'family', family, safeFontFamily);
|
|
638
|
+
}
|
|
639
|
+
export function setFontSize(size) {
|
|
640
|
+
return attrMarkCommand('font_size', 'size', size, safeFontSize);
|
|
641
|
+
}
|
|
642
|
+
export function setLanguage(lang) {
|
|
643
|
+
return attrMarkCommand('language', 'lang', lang, safeLang);
|
|
644
|
+
}
|
|
645
|
+
export function activeFontFamily(state) {
|
|
646
|
+
return activeMarkAttr(state, 'font_family', 'family');
|
|
647
|
+
}
|
|
648
|
+
export function activeFontSize(state) {
|
|
649
|
+
return activeMarkAttr(state, 'font_size', 'size');
|
|
650
|
+
}
|
|
651
|
+
export function activeLanguage(state) {
|
|
652
|
+
return activeMarkAttr(state, 'language', 'lang');
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Strip character formatting and block decoration from the selection.
|
|
656
|
+
*
|
|
657
|
+
* Links stay: they are a destination, not a look. Direction stays: it is
|
|
658
|
+
* content. Headings and lists stay: they are structure. Alignment, indent,
|
|
659
|
+
* line height, fonts, colours, and the common character marks go.
|
|
660
|
+
*/
|
|
661
|
+
export const clearFormatting = (state, dispatch) => {
|
|
662
|
+
const keep = new Set(['link']);
|
|
663
|
+
const { empty, from, to, $from } = state.selection;
|
|
664
|
+
const marks = Object.values(state.schema.marks).filter((type) => !keep.has(type.name));
|
|
665
|
+
if (empty) {
|
|
666
|
+
const current = state.storedMarks ?? $from.marks();
|
|
667
|
+
const stripped = current.filter((mark) => keep.has(mark.type.name));
|
|
668
|
+
const blocks = blocksWithAttr(state, 'align');
|
|
669
|
+
const canClearBlocks = blocks.some((b) => b.node.attrs['align'] != null ||
|
|
670
|
+
b.node.attrs['lineHeight'] != null ||
|
|
671
|
+
b.node.attrs['indent'] != null);
|
|
672
|
+
if (stripped.length === current.length && !canClearBlocks)
|
|
673
|
+
return false;
|
|
674
|
+
if (dispatch) {
|
|
675
|
+
let tr = state.tr.setStoredMarks(stripped);
|
|
676
|
+
for (const { pos, node } of blocks) {
|
|
677
|
+
tr = tr.setNodeMarkup(pos, undefined, {
|
|
678
|
+
...node.attrs,
|
|
679
|
+
align: null,
|
|
680
|
+
lineHeight: null,
|
|
681
|
+
indent: null,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
dispatch(tr.scrollIntoView());
|
|
685
|
+
}
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
let hasMarks = false;
|
|
689
|
+
for (const type of marks) {
|
|
690
|
+
if (state.doc.rangeHasMark(from, to, type))
|
|
691
|
+
hasMarks = true;
|
|
692
|
+
}
|
|
693
|
+
const blocks = blocksWithAttr(state, 'align');
|
|
694
|
+
const canClearBlocks = blocks.some((b) => b.node.attrs['align'] != null ||
|
|
695
|
+
b.node.attrs['lineHeight'] != null ||
|
|
696
|
+
b.node.attrs['indent'] != null);
|
|
697
|
+
if (!hasMarks && !canClearBlocks)
|
|
698
|
+
return false;
|
|
699
|
+
if (dispatch) {
|
|
700
|
+
let tr = state.tr;
|
|
701
|
+
for (const type of marks)
|
|
702
|
+
tr = tr.removeMark(from, to, type);
|
|
703
|
+
for (const { pos, node } of blocks) {
|
|
704
|
+
tr = tr.setNodeMarkup(pos, undefined, {
|
|
705
|
+
...node.attrs,
|
|
706
|
+
align: null,
|
|
707
|
+
lineHeight: null,
|
|
708
|
+
indent: null,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
dispatch(tr.scrollIntoView());
|
|
712
|
+
}
|
|
713
|
+
return true;
|
|
714
|
+
};
|
|
264
715
|
/**
|
|
265
716
|
* Apply or update a link across the selection.
|
|
266
717
|
*
|
|
@@ -279,7 +730,13 @@ export function setLink(attrs) {
|
|
|
279
730
|
if (dispatch) {
|
|
280
731
|
const tr = state.tr;
|
|
281
732
|
tr.removeMark(from, to, type);
|
|
282
|
-
tr.addMark(from, to, type.create({
|
|
733
|
+
tr.addMark(from, to, type.create({
|
|
734
|
+
href: attrs.href,
|
|
735
|
+
title: attrs.title ?? null,
|
|
736
|
+
target: attrs.target ?? null,
|
|
737
|
+
rel: attrs.rel ?? null,
|
|
738
|
+
id: safeId(attrs.id),
|
|
739
|
+
}));
|
|
283
740
|
dispatch(tr.scrollIntoView());
|
|
284
741
|
}
|
|
285
742
|
return true;
|
|
@@ -298,22 +755,194 @@ export const unsetLink = (state, dispatch) => {
|
|
|
298
755
|
};
|
|
299
756
|
export function insertImage(attrs) {
|
|
300
757
|
return (state, dispatch) => {
|
|
758
|
+
const imageType = nodeIn(state, 'image');
|
|
759
|
+
if (!imageType)
|
|
760
|
+
return false;
|
|
761
|
+
const image = imageType.create({
|
|
762
|
+
src: attrs.src,
|
|
763
|
+
// An absent alt and alt="" mean different things to a screen reader:
|
|
764
|
+
// "undescribed" versus "decorative". Never collapse them.
|
|
765
|
+
alt: attrs.alt ?? null,
|
|
766
|
+
title: attrs.title ?? null,
|
|
767
|
+
width: attrs.width ?? null,
|
|
768
|
+
height: attrs.height ?? null,
|
|
769
|
+
align: attrs.align ?? null,
|
|
770
|
+
className: safeClassList(attrs.className ?? null, IMAGE_ALIGN_CLASSES),
|
|
771
|
+
});
|
|
772
|
+
if (attrs.caption !== undefined && attrs.caption !== null) {
|
|
773
|
+
if (!canInsert(state, 'figure'))
|
|
774
|
+
return false;
|
|
775
|
+
const figureType = nodeIn(state, 'figure');
|
|
776
|
+
const captionType = nodeIn(state, 'figcaption');
|
|
777
|
+
if (!figureType || !captionType)
|
|
778
|
+
return false;
|
|
779
|
+
if (dispatch) {
|
|
780
|
+
const caption = attrs.caption === '' ? captionType.create() : captionType.create(null, state.schema.text(attrs.caption));
|
|
781
|
+
dispatch(state.tr.replaceSelectionWith(figureType.create(null, [image, caption])).scrollIntoView());
|
|
782
|
+
}
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
301
785
|
if (!canInsert(state, 'image'))
|
|
302
786
|
return false;
|
|
787
|
+
if (dispatch)
|
|
788
|
+
dispatch(state.tr.replaceSelectionWith(image).scrollIntoView());
|
|
789
|
+
return true;
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
export function insertText(text) {
|
|
793
|
+
return (state, dispatch) => {
|
|
794
|
+
if (text === '')
|
|
795
|
+
return false;
|
|
796
|
+
if (dispatch)
|
|
797
|
+
dispatch(state.tr.insertText(text).scrollIntoView());
|
|
798
|
+
return true;
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
export const insertNonBreakingSpace = insertText('\u00a0');
|
|
802
|
+
export const insertPageBreak = (state, dispatch) => {
|
|
803
|
+
if (!canInsert(state, 'page_break'))
|
|
804
|
+
return false;
|
|
805
|
+
if (dispatch) {
|
|
806
|
+
const type = nodeIn(state, 'page_break');
|
|
807
|
+
if (!type)
|
|
808
|
+
return false;
|
|
809
|
+
dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView());
|
|
810
|
+
}
|
|
811
|
+
return true;
|
|
812
|
+
};
|
|
813
|
+
export function insertNamedAnchor(id) {
|
|
814
|
+
return (state, dispatch) => {
|
|
815
|
+
const value = safeId(id);
|
|
816
|
+
if (!value || !canInsert(state, 'named_anchor'))
|
|
817
|
+
return false;
|
|
303
818
|
if (dispatch) {
|
|
304
|
-
const
|
|
305
|
-
if (!
|
|
819
|
+
const type = nodeIn(state, 'named_anchor');
|
|
820
|
+
if (!type)
|
|
306
821
|
return false;
|
|
307
|
-
|
|
822
|
+
dispatch(state.tr.replaceSelectionWith(type.create({ id: value })).scrollIntoView());
|
|
823
|
+
}
|
|
824
|
+
return true;
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
export function setHeadingId(id) {
|
|
828
|
+
return (state, dispatch) => {
|
|
829
|
+
const type = nodeIn(state, 'heading');
|
|
830
|
+
if (!type)
|
|
831
|
+
return false;
|
|
832
|
+
const { $from } = state.selection;
|
|
833
|
+
for (let depth = $from.depth; depth >= 0; depth -= 1) {
|
|
834
|
+
const node = $from.node(depth);
|
|
835
|
+
if (node.type !== type)
|
|
836
|
+
continue;
|
|
837
|
+
if (dispatch) {
|
|
838
|
+
dispatch(state.tr
|
|
839
|
+
.setNodeMarkup($from.before(depth), undefined, { ...node.attrs, id: safeId(id) })
|
|
840
|
+
.scrollIntoView());
|
|
841
|
+
}
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
return false;
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
export function insertDetails(summaryText = 'Details') {
|
|
848
|
+
return (state, dispatch) => {
|
|
849
|
+
if (!canInsert(state, 'details'))
|
|
850
|
+
return false;
|
|
851
|
+
const detailsType = nodeIn(state, 'details');
|
|
852
|
+
const summaryType = nodeIn(state, 'summary');
|
|
853
|
+
const paragraphType = nodeIn(state, 'paragraph');
|
|
854
|
+
if (!detailsType || !summaryType || !paragraphType)
|
|
855
|
+
return false;
|
|
856
|
+
if (dispatch) {
|
|
857
|
+
const body = paragraphType.createAndFill();
|
|
858
|
+
if (!body)
|
|
859
|
+
return false;
|
|
860
|
+
const node = detailsType.create(null, [
|
|
861
|
+
summaryType.create(null, summaryText === '' ? undefined : state.schema.text(summaryText)),
|
|
862
|
+
body,
|
|
863
|
+
]);
|
|
864
|
+
dispatch(state.tr.replaceSelectionWith(node).scrollIntoView());
|
|
865
|
+
}
|
|
866
|
+
return true;
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
export function insertVideo(attrs) {
|
|
870
|
+
return (state, dispatch) => {
|
|
871
|
+
if (!isSafeUrl(attrs.src) || !canInsert(state, 'video'))
|
|
872
|
+
return false;
|
|
873
|
+
if (dispatch) {
|
|
874
|
+
const type = nodeIn(state, 'video');
|
|
875
|
+
if (!type)
|
|
876
|
+
return false;
|
|
877
|
+
dispatch(state.tr
|
|
878
|
+
.replaceSelectionWith(type.create({
|
|
308
879
|
src: attrs.src,
|
|
309
|
-
// An absent alt and alt="" mean different things to a screen reader:
|
|
310
|
-
// "undescribed" versus "decorative". Never collapse them.
|
|
311
|
-
alt: attrs.alt ?? null,
|
|
312
880
|
title: attrs.title ?? null,
|
|
313
881
|
width: attrs.width ?? null,
|
|
314
882
|
height: attrs.height ?? null,
|
|
315
|
-
|
|
316
|
-
|
|
883
|
+
controls: attrs.controls !== false,
|
|
884
|
+
poster: attrs.poster && isSafeUrl(attrs.poster) ? attrs.poster : null,
|
|
885
|
+
}))
|
|
886
|
+
.scrollIntoView());
|
|
887
|
+
}
|
|
888
|
+
return true;
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
export function insertAudio(attrs) {
|
|
892
|
+
return (state, dispatch) => {
|
|
893
|
+
if (!isSafeUrl(attrs.src) || !canInsert(state, 'audio'))
|
|
894
|
+
return false;
|
|
895
|
+
if (dispatch) {
|
|
896
|
+
const type = nodeIn(state, 'audio');
|
|
897
|
+
if (!type)
|
|
898
|
+
return false;
|
|
899
|
+
dispatch(state.tr
|
|
900
|
+
.replaceSelectionWith(type.create({
|
|
901
|
+
src: attrs.src,
|
|
902
|
+
title: attrs.title ?? null,
|
|
903
|
+
controls: attrs.controls !== false,
|
|
904
|
+
}))
|
|
905
|
+
.scrollIntoView());
|
|
906
|
+
}
|
|
907
|
+
return true;
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
export function insertIframe(attrs) {
|
|
911
|
+
return (state, dispatch) => {
|
|
912
|
+
const src = safeEmbedSrc(attrs.src);
|
|
913
|
+
if (!src || !canInsert(state, 'iframe'))
|
|
914
|
+
return false;
|
|
915
|
+
if (dispatch) {
|
|
916
|
+
const type = nodeIn(state, 'iframe');
|
|
917
|
+
if (!type)
|
|
918
|
+
return false;
|
|
919
|
+
dispatch(state.tr
|
|
920
|
+
.replaceSelectionWith(type.create({
|
|
921
|
+
src,
|
|
922
|
+
title: attrs.title ?? null,
|
|
923
|
+
width: attrs.width ?? null,
|
|
924
|
+
height: attrs.height ?? null,
|
|
925
|
+
allow: safeAllowList(attrs.allow),
|
|
926
|
+
allowfullscreen: attrs.allowfullscreen !== false,
|
|
927
|
+
}))
|
|
928
|
+
.scrollIntoView());
|
|
929
|
+
}
|
|
930
|
+
return true;
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Parse HTML against the live schema and insert it.
|
|
935
|
+
*
|
|
936
|
+
* Used by the snippet picker. The HTML still goes through the same parse
|
|
937
|
+
* pipeline as stored content, so a snippet cannot smuggle in a `<script>`.
|
|
938
|
+
*/
|
|
939
|
+
export function insertHtml(html) {
|
|
940
|
+
return (state, dispatch) => {
|
|
941
|
+
const parsed = parseHtml(html, { schema: state.schema });
|
|
942
|
+
if (parsed.childCount === 0)
|
|
943
|
+
return false;
|
|
944
|
+
if (dispatch) {
|
|
945
|
+
dispatch(state.tr.replaceSelection(new Slice(parsed.content, 0, 0)).scrollIntoView());
|
|
317
946
|
}
|
|
318
947
|
return true;
|
|
319
948
|
};
|