@37signals/lexxy 0.9.11-beta → 0.9.13-beta
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/lexxy.esm.js +1860 -1494
- package/dist/stylesheets/lexxy-content.css +22 -6
- package/dist/stylesheets/lexxy-editor.css +142 -117
- package/package.json +24 -17
package/dist/lexxy.esm.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export { highlightCode } from './lexxy_helpers.esm.js';
|
|
2
2
|
import DOMPurify from 'dompurify';
|
|
3
|
-
import { getStyleObjectFromCSS, getCSSFromStyleObject, $
|
|
4
|
-
import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $
|
|
3
|
+
import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $ensureForwardRangeSelection, $isAtNodeEnd, $patchStyleText, $setBlocksType, $forEachSelectedTextNode } from '@lexical/selection';
|
|
4
|
+
import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, defineExtension, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, $getRoot, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, ElementNode, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, PASTE_COMMAND, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, CLEAR_HISTORY_COMMAND, $onUpdate, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
|
|
5
|
+
import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
|
|
5
6
|
import { buildEditorFromExtensions } from '@lexical/extension';
|
|
6
7
|
import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
|
|
7
|
-
import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
|
|
8
8
|
import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, $descendantsMatching, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from '@lexical/utils';
|
|
9
9
|
import { registerPlainText } from '@lexical/plain-text';
|
|
10
10
|
import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
|
|
@@ -128,7 +128,9 @@ class ListenerBin {
|
|
|
128
128
|
function createElement(name, properties, content = "") {
|
|
129
129
|
const element = document.createElement(name);
|
|
130
130
|
for (const [ key, value ] of Object.entries(properties || {})) {
|
|
131
|
-
if (key
|
|
131
|
+
if (key === "dataset") {
|
|
132
|
+
Object.entries(value).forEach(([ key, value ]) => (element.dataset[key] = value));
|
|
133
|
+
} else if (key in element) {
|
|
132
134
|
element[key] = value;
|
|
133
135
|
} else if (value !== null && value !== undefined) {
|
|
134
136
|
element.setAttribute(key, value);
|
|
@@ -180,7 +182,19 @@ function extractPlainTextFromHtml(innerHtml = "") {
|
|
|
180
182
|
}
|
|
181
183
|
|
|
182
184
|
function isActiveAndVisible(element) {
|
|
183
|
-
return element && !element.disabled &&
|
|
185
|
+
return element && !element.disabled && checkVisibility(element)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// no `checkVisibility` in Safari < 17.4
|
|
189
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/Element/checkVisibility#browser_compatibility
|
|
190
|
+
function checkVisibility(element, options) {
|
|
191
|
+
if (element.checkVisibility) {
|
|
192
|
+
return element.checkVisibility(options)
|
|
193
|
+
} else {
|
|
194
|
+
// Will not work for body or a fixed position element child of the body
|
|
195
|
+
// which is OK since that doesn't apply in the toolbar where this is used
|
|
196
|
+
return Boolean(element.offsetParent)
|
|
197
|
+
}
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
function handleRollingTabIndex(elements, event) {
|
|
@@ -393,6 +407,7 @@ var ToolbarIcons = {
|
|
|
393
407
|
class LexicalToolbarElement extends HTMLElement {
|
|
394
408
|
static observedAttributes = [ "connected" ]
|
|
395
409
|
#listeners = new ListenerBin()
|
|
410
|
+
#refreshToolbarAF = null
|
|
396
411
|
|
|
397
412
|
constructor() {
|
|
398
413
|
super();
|
|
@@ -403,7 +418,7 @@ class LexicalToolbarElement extends HTMLElement {
|
|
|
403
418
|
}
|
|
404
419
|
|
|
405
420
|
connectedCallback() {
|
|
406
|
-
|
|
421
|
+
this.requestOverflowRefresh();
|
|
407
422
|
this.setAttribute("role", "toolbar");
|
|
408
423
|
this.#installResizeObserver();
|
|
409
424
|
}
|
|
@@ -415,9 +430,12 @@ class LexicalToolbarElement extends HTMLElement {
|
|
|
415
430
|
dispose() {
|
|
416
431
|
this.#listeners.dispose();
|
|
417
432
|
|
|
433
|
+
cancelAnimationFrame(this.#refreshToolbarAF);
|
|
434
|
+
|
|
418
435
|
this.editorElement = null;
|
|
419
436
|
this.editor = null;
|
|
420
437
|
this.selection = null;
|
|
438
|
+
this.#refreshToolbarAF = null;
|
|
421
439
|
|
|
422
440
|
this.#createEditorPromise();
|
|
423
441
|
}
|
|
@@ -443,10 +461,9 @@ class LexicalToolbarElement extends HTMLElement {
|
|
|
443
461
|
this.#bindButtons();
|
|
444
462
|
this.#bindHotkeys();
|
|
445
463
|
this.#resetTabIndexValues();
|
|
446
|
-
this.#setItemPositionValues();
|
|
447
464
|
this.#monitorSelectionChanges();
|
|
448
465
|
this.#monitorHistoryChanges();
|
|
449
|
-
this
|
|
466
|
+
this.requestOverflowRefresh();
|
|
450
467
|
this.#bindFocusListeners();
|
|
451
468
|
|
|
452
469
|
this.resolveEditorPromise(editorElement);
|
|
@@ -458,6 +475,23 @@ class LexicalToolbarElement extends HTMLElement {
|
|
|
458
475
|
return this.editorElement || await this.editorPromise
|
|
459
476
|
}
|
|
460
477
|
|
|
478
|
+
requestOverflowRefresh() {
|
|
479
|
+
if (this.#refreshToolbarAF != null) return
|
|
480
|
+
|
|
481
|
+
this.#refreshToolbarAF = requestAnimationFrame(() => {
|
|
482
|
+
this.#refreshOverflow();
|
|
483
|
+
this.#refreshToolbarAF = null;
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
closeDropdowns({ except } = {}) {
|
|
488
|
+
this.#dropdowns.forEach((dropdown) => {
|
|
489
|
+
if (dropdown !== except) {
|
|
490
|
+
dropdown.close({ focusEditor: false });
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
461
495
|
#reconnect() {
|
|
462
496
|
this.disconnectedCallback();
|
|
463
497
|
this.connectedCallback();
|
|
@@ -472,7 +506,7 @@ class LexicalToolbarElement extends HTMLElement {
|
|
|
472
506
|
}
|
|
473
507
|
|
|
474
508
|
#installResizeObserver() {
|
|
475
|
-
const resizeObserver = new ResizeObserver(() => this
|
|
509
|
+
const resizeObserver = new ResizeObserver(() => this.requestOverflowRefresh());
|
|
476
510
|
resizeObserver.observe(this);
|
|
477
511
|
this.#listeners.track(() => resizeObserver.disconnect());
|
|
478
512
|
}
|
|
@@ -539,30 +573,29 @@ class LexicalToolbarElement extends HTMLElement {
|
|
|
539
573
|
}
|
|
540
574
|
|
|
541
575
|
#handleEditorFocus = () => {
|
|
542
|
-
const firstVisible = this.#
|
|
576
|
+
const firstVisible = this.#buttons.find(isActiveAndVisible);
|
|
543
577
|
if (firstVisible) firstVisible.tabIndex = 0;
|
|
544
578
|
}
|
|
545
579
|
|
|
546
580
|
#handleEditorBlur = () => {
|
|
547
581
|
this.#resetTabIndexValues();
|
|
548
|
-
this.#closeDropdowns();
|
|
549
582
|
}
|
|
550
583
|
|
|
551
584
|
#handleKeydown = (event) => {
|
|
552
|
-
handleRollingTabIndex(this.#
|
|
585
|
+
handleRollingTabIndex(this.#buttons, event);
|
|
553
586
|
}
|
|
554
587
|
|
|
555
588
|
#resetTabIndexValues() {
|
|
556
|
-
this.#
|
|
589
|
+
this.#buttons.forEach((button) => {
|
|
557
590
|
button.tabIndex = -1;
|
|
558
591
|
});
|
|
559
592
|
}
|
|
560
593
|
|
|
561
594
|
#monitorSelectionChanges() {
|
|
562
|
-
this.#listeners.track(this.editor.registerUpdateListener(() => {
|
|
563
|
-
|
|
595
|
+
this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => {
|
|
596
|
+
editorState.read(() => {
|
|
564
597
|
this.#updateButtonStates();
|
|
565
|
-
this
|
|
598
|
+
this.closeDropdowns();
|
|
566
599
|
});
|
|
567
600
|
}));
|
|
568
601
|
}
|
|
@@ -630,96 +663,106 @@ class LexicalToolbarElement extends HTMLElement {
|
|
|
630
663
|
}
|
|
631
664
|
}
|
|
632
665
|
|
|
633
|
-
#
|
|
666
|
+
#refreshOverflow() {
|
|
667
|
+
this.#hideOverflowMenuButton();
|
|
634
668
|
this.#resetToolbarOverflow();
|
|
669
|
+
this.#reindexToolbarItems();
|
|
635
670
|
this.#compactMenu();
|
|
636
671
|
|
|
637
|
-
|
|
638
|
-
this.#overflow.setAttribute("nonce", getNonce());
|
|
672
|
+
const isOverflowing = this.#overflowMenuDropdown.children.length > 0;
|
|
639
673
|
|
|
640
|
-
const isOverflowing = this.#overflowMenu.children.length > 0;
|
|
641
674
|
this.toggleAttribute("overflowing", isOverflowing);
|
|
642
|
-
this.#
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
// Separates layout reads from DOM writes to avoid forced reflows during init.
|
|
646
|
-
// Measures every button's right edge in a single read pass, figures out which
|
|
647
|
-
// buttons overflow using math, and then moves them in a single write pass.
|
|
648
|
-
// The previous implementation interleaved `scrollWidth`/`clientWidth` reads with
|
|
649
|
-
// `prepend()` writes inside a loop, forcing one full browser reflow per button.
|
|
650
|
-
#compactMenu() {
|
|
651
|
-
const buttons = this.#buttons;
|
|
652
|
-
if (buttons.length === 0) return
|
|
653
|
-
|
|
654
|
-
const availableWidth = this.clientWidth + 1; // +1 for Safari zoom rounding
|
|
655
|
-
const buttonRightEdges = buttons.map(button => button.offsetLeft + button.offsetWidth);
|
|
656
|
-
|
|
657
|
-
let firstOverflowing = -1;
|
|
658
|
-
for (let i = 0; i < buttons.length; i++) {
|
|
659
|
-
if (buttonRightEdges[i] > availableWidth) {
|
|
660
|
-
firstOverflowing = i;
|
|
661
|
-
break
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
if (firstOverflowing === -1) return
|
|
666
|
-
|
|
667
|
-
// Move one extra button to reserve space for the overflow control, which is
|
|
668
|
-
// `display: none` until we show it — matching the previous implementation's
|
|
669
|
-
// "move one more after it stops overflowing" behaviour.
|
|
670
|
-
const overflowIndex = Math.max(0, firstOverflowing - 1);
|
|
671
|
-
const overflowButtons = buttons.slice(overflowIndex).reverse();
|
|
672
|
-
for (const button of overflowButtons) {
|
|
673
|
-
this.#overflowMenu.prepend(button);
|
|
674
|
-
}
|
|
675
|
+
this.#setOverflowMenuNonce();
|
|
676
|
+
this.#showOverflowMenuButton(isOverflowing);
|
|
675
677
|
}
|
|
676
678
|
|
|
677
679
|
#resetToolbarOverflow() {
|
|
678
|
-
const items = Array.from(this.#
|
|
680
|
+
const items = Array.from(this.#overflowMenuDropdown.children);
|
|
679
681
|
items.sort((a, b) => this.#itemPosition(b) - this.#itemPosition(a));
|
|
680
682
|
|
|
681
683
|
for (const item of items) {
|
|
682
|
-
const nextItem = this.querySelector(`[data-position="${this.#itemPosition(item) + 1}"]`) ?? this.#
|
|
684
|
+
const nextItem = this.querySelector(`[data-position="${this.#itemPosition(item) + 1}"]`) ?? this.#overflowMenuButton;
|
|
685
|
+
item.removeAttribute("role");
|
|
683
686
|
this.insertBefore(item, nextItem);
|
|
684
687
|
}
|
|
685
688
|
}
|
|
686
689
|
|
|
690
|
+
#showOverflowMenuButton(show = true) {
|
|
691
|
+
this.#overflowMenuDropdown.toggleAttribute("disabled", !show);
|
|
692
|
+
this.#overflowMenuButton.style.display = show ? "block" : "none";
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
#hideOverflowMenuButton() {
|
|
696
|
+
this.#showOverflowMenuButton(false);
|
|
697
|
+
}
|
|
698
|
+
|
|
687
699
|
#itemPosition(item) {
|
|
688
700
|
return parseInt(item.dataset.position ?? "999")
|
|
689
701
|
}
|
|
690
702
|
|
|
691
|
-
#
|
|
703
|
+
#reindexToolbarItems() {
|
|
692
704
|
this.#toolbarItems.forEach((item, index) => {
|
|
693
|
-
|
|
694
|
-
item.dataset.position = index;
|
|
695
|
-
}
|
|
705
|
+
item.dataset.position = index;
|
|
696
706
|
});
|
|
697
707
|
}
|
|
698
708
|
|
|
699
|
-
#
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
709
|
+
#compactMenu() {
|
|
710
|
+
const overflowWidth = this.#getOverflowWidth();
|
|
711
|
+
|
|
712
|
+
if (overflowWidth > 0) {
|
|
713
|
+
this.#showOverflowMenuButton();
|
|
714
|
+
const gap = this.#getToolbarGap();
|
|
715
|
+
const spaceForOverflow = gap + this.#overflowMenuButton.offsetWidth;
|
|
716
|
+
this.#reclaimWidth(overflowWidth + spaceForOverflow, { gap });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
#getOverflowWidth() {
|
|
721
|
+
return this.scrollWidth - this.clientWidth
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
#reclaimWidth(overflowWidth, { gap }) {
|
|
725
|
+
const buttons = this.#overflowableButtons;
|
|
726
|
+
const overflowButtons = [];
|
|
727
|
+
let recoveredWidth = 0;
|
|
728
|
+
|
|
729
|
+
while (recoveredWidth < overflowWidth && buttons.length) {
|
|
730
|
+
const button = buttons.pop();
|
|
731
|
+
|
|
732
|
+
overflowButtons.push(button);
|
|
733
|
+
button.role = "menuitem";
|
|
734
|
+
recoveredWidth += button.offsetWidth + gap;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
this.#overflowMenuDropdown.append(...overflowButtons.reverse());
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
#setOverflowMenuNonce() {
|
|
741
|
+
this.#overflowMenuButton.setAttribute("nonce", getNonce());
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
#getToolbarGap() {
|
|
745
|
+
return parseFloat(window.getComputedStyle(this).columnGap) || 0
|
|
746
|
+
}
|
|
704
747
|
|
|
705
748
|
get #dropdowns() {
|
|
706
|
-
return this.querySelectorAll("
|
|
749
|
+
return this.querySelectorAll(":scope .lexxy-editor__toolbar-dropdown")
|
|
707
750
|
}
|
|
708
751
|
|
|
709
|
-
get #
|
|
752
|
+
get #overflowMenuButton() {
|
|
710
753
|
return this.querySelector(".lexxy-editor__toolbar-overflow")
|
|
711
754
|
}
|
|
712
755
|
|
|
713
|
-
get #
|
|
714
|
-
return this
|
|
756
|
+
get #overflowMenuDropdown() {
|
|
757
|
+
return this.#overflowMenuButton?.querySelector(":scope > [data-dropdown-panel]")
|
|
715
758
|
}
|
|
716
759
|
|
|
717
|
-
get #
|
|
718
|
-
return Array.from(this.querySelectorAll(":scope > button:not([data-prevent-overflow
|
|
760
|
+
get #overflowableButtons() {
|
|
761
|
+
return Array.from(this.querySelectorAll(":scope > button:not([data-prevent-overflow])"))
|
|
719
762
|
}
|
|
720
763
|
|
|
721
|
-
get #
|
|
722
|
-
return Array.from(this.querySelectorAll(":scope button
|
|
764
|
+
get #buttons() {
|
|
765
|
+
return Array.from(this.querySelectorAll(":scope button"))
|
|
723
766
|
}
|
|
724
767
|
|
|
725
768
|
get #toolbarItems() {
|
|
@@ -727,6 +770,8 @@ class LexicalToolbarElement extends HTMLElement {
|
|
|
727
770
|
}
|
|
728
771
|
|
|
729
772
|
static get defaultTemplate() {
|
|
773
|
+
const linkInputId = generateDomId("lexxy-link-url");
|
|
774
|
+
|
|
730
775
|
return `
|
|
731
776
|
<button class="lexxy-editor__toolbar-button" type="button" name="image" data-command="uploadImage" data-prevent-overflow="true" title="Add images and video">
|
|
732
777
|
${ToolbarIcons.image}
|
|
@@ -744,59 +789,59 @@ class LexicalToolbarElement extends HTMLElement {
|
|
|
744
789
|
${ToolbarIcons.italic}
|
|
745
790
|
</button>
|
|
746
791
|
|
|
747
|
-
<
|
|
748
|
-
<
|
|
792
|
+
<lexxy-toolbar-dropdown class="lexxy-editor__toolbar-dropdown">
|
|
793
|
+
<button data-dropdown-trigger class="lexxy-editor__toolbar-button lexxy-editor__toolbar-button--chevron" type="button" name="format" title="Text formatting" aria-haspopup="menu" aria-expanded="false">
|
|
749
794
|
${ToolbarIcons.heading}
|
|
750
|
-
</
|
|
751
|
-
<div class="lexxy-editor__toolbar-dropdown-list">
|
|
752
|
-
<button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph">
|
|
795
|
+
</button>
|
|
796
|
+
<div data-dropdown-panel role="menu" class="lexxy-editor__toolbar-dropdown-list" hidden>
|
|
797
|
+
<button type="button" name="paragraph" data-command="setFormatParagraph" title="Paragraph" role="menuitem">
|
|
753
798
|
${ToolbarIcons.paragraph} <span>Normal</span>
|
|
754
799
|
</button>
|
|
755
|
-
<button type="button" name="heading-large" data-command="setFormatHeadingLarge" title="Large heading">
|
|
800
|
+
<button type="button" name="heading-large" data-command="setFormatHeadingLarge" title="Large heading" role="menuitem">
|
|
756
801
|
${ToolbarIcons.h2} <span>Large Heading</span>
|
|
757
802
|
</button>
|
|
758
|
-
<button type="button" name="heading-medium" data-command="setFormatHeadingMedium" title="Medium heading">
|
|
803
|
+
<button type="button" name="heading-medium" data-command="setFormatHeadingMedium" title="Medium heading" role="menuitem">
|
|
759
804
|
${ToolbarIcons.h3} <span>Medium Heading</span>
|
|
760
805
|
</button>
|
|
761
|
-
<button class="lexxy-editor__toolbar-group-end" type="button" name="heading-small" data-command="setFormatHeadingSmall" title="Small heading">
|
|
806
|
+
<button class="lexxy-editor__toolbar-group-end" type="button" name="heading-small" data-command="setFormatHeadingSmall" title="Small heading" role="menuitem">
|
|
762
807
|
${ToolbarIcons.h4} <span>Small Heading</span>
|
|
763
808
|
</button>
|
|
764
809
|
<div class="lexxy-editor__toolbar-separator" role="separator"></div>
|
|
765
|
-
<button type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough">
|
|
810
|
+
<button type="button" name="strikethrough" data-command="strikethrough" title="Strikethrough" role="menuitem">
|
|
766
811
|
${ToolbarIcons.strikethrough} <span>Strikethrough</span>
|
|
767
812
|
</button>
|
|
768
|
-
<button type="button" name="underline" data-command="underline" title="Underline">
|
|
813
|
+
<button type="button" name="underline" data-command="underline" title="Underline" role="menuitem">
|
|
769
814
|
${ToolbarIcons.underline} <span>Underline</span>
|
|
770
815
|
</button>
|
|
771
816
|
<div class="lexxy-editor__toolbar-separator" role="separator"></div>
|
|
772
|
-
<button type="button" name="clear-formatting" data-command="clearFormatting" title="Clear formatting">
|
|
817
|
+
<button type="button" name="clear-formatting" data-command="clearFormatting" title="Clear formatting" role="menuitem">
|
|
773
818
|
${ToolbarIcons.clearFormatting} <span>Clear formatting</span>
|
|
774
819
|
</button>
|
|
775
820
|
</div>
|
|
776
|
-
</
|
|
821
|
+
</lexxy-toolbar-dropdown>
|
|
777
822
|
|
|
778
|
-
<
|
|
779
|
-
<
|
|
823
|
+
<lexxy-highlight-dropdown class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--highlight">
|
|
824
|
+
<button data-dropdown-trigger class="lexxy-editor__toolbar-button lexxy-editor__toolbar-button--chevron" type="button" name="highlight" title="Color highlight" aria-haspopup="menu" aria-expanded="false">
|
|
780
825
|
${ToolbarIcons.highlight}
|
|
781
|
-
</
|
|
782
|
-
<
|
|
826
|
+
</button>
|
|
827
|
+
<div data-dropdown-panel role="menu" hidden>
|
|
783
828
|
<div class="lexxy-highlight-colors"></div>
|
|
784
|
-
<button data-command="removeHighlight" class="lexxy-editor__toolbar-button lexxy-editor__toolbar-dropdown-reset">Remove all coloring</button>
|
|
785
|
-
</
|
|
786
|
-
</
|
|
829
|
+
<button data-command="removeHighlight" type="button" class="lexxy-editor__toolbar-button lexxy-editor__toolbar-dropdown-reset" role="menuitem">Remove all coloring</button>
|
|
830
|
+
</div>
|
|
831
|
+
</lexxy-highlight-dropdown>
|
|
787
832
|
|
|
788
|
-
<
|
|
789
|
-
<
|
|
833
|
+
<lexxy-link-dropdown class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--link">
|
|
834
|
+
<button data-dropdown-trigger class="lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end" type="button" name="link" title="Link" data-hotkey="cmd+k ctrl+k" aria-haspopup="dialog" aria-expanded="false">
|
|
790
835
|
${ToolbarIcons.link}
|
|
791
|
-
</
|
|
792
|
-
<
|
|
793
|
-
<input type="url" placeholder="Enter a URL…" class="input">
|
|
836
|
+
</button>
|
|
837
|
+
<div data-dropdown-panel role="dialog" aria-label="Link" hidden>
|
|
838
|
+
<input type="url" placeholder="Enter a URL…" class="input" id="${linkInputId}">
|
|
794
839
|
<div class="lexxy-editor__toolbar-dropdown-actions">
|
|
795
840
|
<button type="button" class="lexxy-editor__toolbar-button" value="link">Link</button>
|
|
796
841
|
<button type="button" class="lexxy-editor__toolbar-button" value="unlink">Unlink</button>
|
|
797
842
|
</div>
|
|
798
|
-
</
|
|
799
|
-
</
|
|
843
|
+
</div>
|
|
844
|
+
</lexxy-link-dropdown>
|
|
800
845
|
|
|
801
846
|
<button class="lexxy-editor__toolbar-button" type="button" name="quote" data-command="insertQuoteBlock" title="Quote">
|
|
802
847
|
${ToolbarIcons.quote}
|
|
@@ -821,9 +866,7 @@ class LexicalToolbarElement extends HTMLElement {
|
|
|
821
866
|
${ToolbarIcons.hr}
|
|
822
867
|
</button>
|
|
823
868
|
|
|
824
|
-
<
|
|
825
|
-
|
|
826
|
-
<button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo" disabled aria-disabled="true">
|
|
869
|
+
<button class="lexxy-editor__toolbar-button lexxy-editor__toolbar-button--push-right" type="button" name="undo" data-command="undo" title="Undo" disabled aria-disabled="true">
|
|
827
870
|
${ToolbarIcons.undo}
|
|
828
871
|
</button>
|
|
829
872
|
|
|
@@ -831,601 +874,1043 @@ class LexicalToolbarElement extends HTMLElement {
|
|
|
831
874
|
${ToolbarIcons.redo}
|
|
832
875
|
</button>
|
|
833
876
|
|
|
834
|
-
<
|
|
835
|
-
<
|
|
836
|
-
|
|
837
|
-
|
|
877
|
+
<lexxy-toolbar-dropdown class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-button--push-right lexxy-editor__toolbar-overflow">
|
|
878
|
+
<button data-dropdown-trigger class="lexxy-editor__toolbar-button" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Show more toolbar buttons">
|
|
879
|
+
${ToolbarIcons.overflow}
|
|
880
|
+
</button>
|
|
881
|
+
<div data-dropdown-panel role="menu" class="lexxy-editor__toolbar-overflow-menu" aria-label="More toolbar buttons" hidden></div>
|
|
882
|
+
</lexxy-toolbar-dropdown>
|
|
838
883
|
`
|
|
839
884
|
}
|
|
840
885
|
}
|
|
841
886
|
|
|
842
|
-
function
|
|
843
|
-
|
|
844
|
-
for (const [ key, value ] of Object.entries(source)) {
|
|
845
|
-
if (arePlainHashes(target[key], value)) {
|
|
846
|
-
result[key] = deepMerge(target[key], value);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
return result
|
|
851
|
-
}
|
|
887
|
+
function debounce(fn, wait) {
|
|
888
|
+
let timeout;
|
|
852
889
|
|
|
853
|
-
|
|
854
|
-
|
|
890
|
+
return (...args) => {
|
|
891
|
+
clearTimeout(timeout);
|
|
892
|
+
timeout = setTimeout(() => fn(...args), wait);
|
|
893
|
+
}
|
|
855
894
|
}
|
|
856
895
|
|
|
857
|
-
|
|
858
|
-
|
|
896
|
+
function debounceAsync(fn, wait) {
|
|
897
|
+
let timeout;
|
|
859
898
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
}
|
|
899
|
+
return (...args) => {
|
|
900
|
+
clearTimeout(timeout);
|
|
863
901
|
|
|
864
|
-
|
|
865
|
-
|
|
902
|
+
return new Promise((resolve, reject) => {
|
|
903
|
+
timeout = setTimeout(async () => {
|
|
904
|
+
try {
|
|
905
|
+
const result = await fn(...args);
|
|
906
|
+
resolve(result);
|
|
907
|
+
} catch (err) {
|
|
908
|
+
reject(err);
|
|
909
|
+
}
|
|
910
|
+
}, wait);
|
|
911
|
+
})
|
|
866
912
|
}
|
|
913
|
+
}
|
|
867
914
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
return keys.reduce((node, key) => node[key], this.#tree)
|
|
871
|
-
}
|
|
915
|
+
function delay(ms) {
|
|
916
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
872
917
|
}
|
|
873
918
|
|
|
874
|
-
function
|
|
875
|
-
return
|
|
919
|
+
function nextFrame() {
|
|
920
|
+
return new Promise(requestAnimationFrame)
|
|
876
921
|
}
|
|
877
922
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
attachmentContentTypeNamespace: "actiontext",
|
|
881
|
-
authenticatedUploads: false,
|
|
882
|
-
extensions: []
|
|
883
|
-
});
|
|
923
|
+
class ToolbarDropdown extends HTMLElement {
|
|
924
|
+
#listeners = new ListenerBin()
|
|
884
925
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
permittedAttachmentTypes: null,
|
|
891
|
-
richText: true,
|
|
892
|
-
toolbar: {
|
|
893
|
-
upload: "both"
|
|
894
|
-
},
|
|
895
|
-
highlight: {
|
|
896
|
-
buttons: {
|
|
897
|
-
color: range(1, 9).map(n => `var(--highlight-${n})`),
|
|
898
|
-
"background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
|
|
899
|
-
},
|
|
900
|
-
permit: {
|
|
901
|
-
color: [],
|
|
902
|
-
"background-color": []
|
|
903
|
-
}
|
|
904
|
-
}
|
|
926
|
+
connectedCallback() {
|
|
927
|
+
this.#onToolbarEditor(() => {
|
|
928
|
+
this.#registerListeners();
|
|
929
|
+
this.editorReady();
|
|
930
|
+
});
|
|
905
931
|
}
|
|
906
|
-
});
|
|
907
932
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
presets,
|
|
911
|
-
configure({ global: newGlobal, ...newPresets }) {
|
|
912
|
-
if (newGlobal) {
|
|
913
|
-
global.merge(newGlobal);
|
|
914
|
-
}
|
|
915
|
-
presets.merge(newPresets);
|
|
933
|
+
disconnectedCallback() {
|
|
934
|
+
this.#listeners.dispose();
|
|
916
935
|
}
|
|
917
|
-
};
|
|
918
|
-
|
|
919
|
-
function setSanitizerConfig(allowedTags) {
|
|
920
|
-
DOMPurify.clearConfig();
|
|
921
|
-
DOMPurify.setConfig(buildConfig(allowedTags));
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
function sanitize(html) {
|
|
925
|
-
return DOMPurify.sanitize(html)
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
function bytesToHumanSize(bytes) {
|
|
929
|
-
if (bytes === 0) return "0 B"
|
|
930
|
-
const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
|
|
931
|
-
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
932
|
-
const value = bytes / Math.pow(1024, i);
|
|
933
|
-
return `${ value.toFixed(2) } ${ sizes[i] }`
|
|
934
|
-
}
|
|
935
936
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
}
|
|
937
|
+
editorReady() {}
|
|
938
|
+
onOpen() {}
|
|
939
|
+
onClose() {}
|
|
939
940
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
function parseAttachmentContent(content) {
|
|
943
|
-
try {
|
|
944
|
-
return JSON.parse(content)
|
|
945
|
-
} catch {
|
|
946
|
-
return content
|
|
941
|
+
get trigger() {
|
|
942
|
+
return this.querySelector(":scope > [data-dropdown-trigger]")
|
|
947
943
|
}
|
|
948
|
-
}
|
|
949
944
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
return "custom_action_text_attachment"
|
|
945
|
+
get panel() {
|
|
946
|
+
return this.querySelector(":scope > [data-dropdown-panel]")
|
|
953
947
|
}
|
|
954
948
|
|
|
955
|
-
|
|
956
|
-
return
|
|
949
|
+
get toolbar() {
|
|
950
|
+
return this.closest("lexxy-toolbar")
|
|
957
951
|
}
|
|
958
952
|
|
|
959
|
-
|
|
960
|
-
return
|
|
953
|
+
get editorElement() {
|
|
954
|
+
return this.toolbar?.editorElement
|
|
961
955
|
}
|
|
962
956
|
|
|
963
|
-
|
|
964
|
-
return
|
|
965
|
-
|
|
966
|
-
if (!element.getAttribute("content")) {
|
|
967
|
-
return null
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
return {
|
|
971
|
-
conversion: (attachment) => {
|
|
972
|
-
// Preserve initial space if present since Lexical removes it
|
|
973
|
-
const nodes = [];
|
|
974
|
-
const previousSibling = attachment.previousSibling;
|
|
975
|
-
if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
|
|
976
|
-
nodes.push($createTextNode(" "));
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
const innerHtml = parseAttachmentContent(attachment.getAttribute("content"));
|
|
957
|
+
get editor() {
|
|
958
|
+
return this.toolbar?.editor
|
|
959
|
+
}
|
|
980
960
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
plainText: attachment.textContent.trim() || extractPlainTextFromHtml(innerHtml),
|
|
985
|
-
contentType: attachment.getAttribute("content-type")
|
|
986
|
-
}));
|
|
961
|
+
get isOpen() {
|
|
962
|
+
return this.panel.hidden === false
|
|
963
|
+
}
|
|
987
964
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
}
|
|
965
|
+
get isClosed() {
|
|
966
|
+
return !this.isOpen
|
|
967
|
+
}
|
|
992
968
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
priority: 2
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
}
|
|
969
|
+
track(...listeners) {
|
|
970
|
+
this.#listeners.track(...listeners);
|
|
999
971
|
}
|
|
1000
972
|
|
|
1001
|
-
|
|
1002
|
-
|
|
973
|
+
open() {
|
|
974
|
+
if (this.isOpen) return
|
|
975
|
+
this.trigger.setAttribute("aria-expanded", "true");
|
|
976
|
+
this.panel.hidden = false;
|
|
977
|
+
this.onOpen();
|
|
978
|
+
this.#focusFirstInteractive();
|
|
1003
979
|
}
|
|
1004
980
|
|
|
1005
|
-
|
|
1006
|
-
|
|
981
|
+
close({ focusEditor = true } = {}) {
|
|
982
|
+
if (focusEditor) this.editor?.focus();
|
|
1007
983
|
|
|
1008
|
-
|
|
984
|
+
if (this.isClosed) return
|
|
985
|
+
this.trigger.setAttribute("aria-expanded", "false");
|
|
986
|
+
this.panel.hidden = true;
|
|
987
|
+
this.onClose();
|
|
988
|
+
}
|
|
1009
989
|
|
|
1010
|
-
|
|
1011
|
-
this.
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
990
|
+
#registerListeners() {
|
|
991
|
+
this.#listeners.track(
|
|
992
|
+
registerEventListener(this, "keydown", this.#handleKeyDown),
|
|
993
|
+
registerEventListener(this.trigger, "click", this.#handleTriggerClick)
|
|
994
|
+
);
|
|
1015
995
|
}
|
|
1016
996
|
|
|
1017
|
-
|
|
1018
|
-
|
|
997
|
+
#handleTriggerClick = () => {
|
|
998
|
+
if (this.isOpen) {
|
|
999
|
+
this.close({ focusEditor: false });
|
|
1000
|
+
} else {
|
|
1001
|
+
this.toolbar?.closeDropdowns({ except: this });
|
|
1002
|
+
this.open();
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1019
1005
|
|
|
1020
|
-
|
|
1006
|
+
async #onToolbarEditor(callback) {
|
|
1007
|
+
if (!this.toolbar) return
|
|
1021
1008
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1009
|
+
await this.toolbar.getEditorElement();
|
|
1010
|
+
if (this.isConnected && this.toolbar) callback();
|
|
1011
|
+
}
|
|
1024
1012
|
|
|
1025
|
-
|
|
1013
|
+
#handleKeyDown = (event) => {
|
|
1014
|
+
if (event.key === "Escape") {
|
|
1015
|
+
event.stopPropagation();
|
|
1016
|
+
this.close();
|
|
1017
|
+
}
|
|
1026
1018
|
}
|
|
1027
1019
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1020
|
+
async #focusFirstInteractive() {
|
|
1021
|
+
this.#interactiveElements[0]?.focus();
|
|
1022
|
+
await this.#resetTabIndexValues();
|
|
1030
1023
|
}
|
|
1031
1024
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1025
|
+
async #resetTabIndexValues() {
|
|
1026
|
+
await nextFrame();
|
|
1027
|
+
this.#buttons.forEach((element, index) => {
|
|
1028
|
+
element.setAttribute("tabindex", index === 0 ? 0 : "-1");
|
|
1029
|
+
});
|
|
1034
1030
|
}
|
|
1035
1031
|
|
|
1036
|
-
|
|
1037
|
-
return this.
|
|
1032
|
+
get #interactiveElements() {
|
|
1033
|
+
return Array.from(this.panel.querySelectorAll("button, input"))
|
|
1038
1034
|
}
|
|
1039
1035
|
|
|
1040
|
-
|
|
1041
|
-
return
|
|
1036
|
+
get #buttons() {
|
|
1037
|
+
return Array.from(this.panel.querySelectorAll("button"))
|
|
1042
1038
|
}
|
|
1039
|
+
}
|
|
1043
1040
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
sgid: this.sgid,
|
|
1047
|
-
content: this.innerHtml,
|
|
1048
|
-
"content-type": this.contentType
|
|
1049
|
-
});
|
|
1041
|
+
const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
|
|
1042
|
+
const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
|
|
1050
1043
|
|
|
1051
|
-
|
|
1044
|
+
// Use Symbol instead of null since $getSelectionStyleValueForProperty
|
|
1045
|
+
// responds differently for backward selections if null is the default
|
|
1046
|
+
// see https://github.com/facebook/lexical/issues/8013
|
|
1047
|
+
const NO_STYLE = Symbol("no_style");
|
|
1048
|
+
|
|
1049
|
+
class HighlightDropdown extends ToolbarDropdown {
|
|
1050
|
+
editorReady() {
|
|
1051
|
+
this.#setUpButtons();
|
|
1052
|
+
this.#registerButtonHandlers();
|
|
1052
1053
|
}
|
|
1053
1054
|
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
tagName: this.tagName,
|
|
1059
|
-
sgid: this.sgid,
|
|
1060
|
-
contentType: this.contentType,
|
|
1061
|
-
innerHtml: this.innerHtml,
|
|
1062
|
-
plainText: this.plainText
|
|
1063
|
-
}
|
|
1055
|
+
onOpen() {
|
|
1056
|
+
this.editor.getEditorState().read(() => {
|
|
1057
|
+
this.#updateColorButtonStates($getSelection());
|
|
1058
|
+
});
|
|
1064
1059
|
}
|
|
1065
1060
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1061
|
+
#registerButtonHandlers() {
|
|
1062
|
+
this.#colorButtons.forEach(button => {
|
|
1063
|
+
this.track(registerEventListener(button, "click", this.#handleColorButtonClick));
|
|
1064
|
+
});
|
|
1068
1065
|
}
|
|
1069
|
-
}
|
|
1070
1066
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
}
|
|
1067
|
+
#setUpButtons() {
|
|
1068
|
+
this.#buttonContainer.innerHTML = "";
|
|
1074
1069
|
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1070
|
+
const colorGroups = this.editorElement.config.get("highlight.buttons");
|
|
1071
|
+
|
|
1072
|
+
this.#populateButtonGroup("color", colorGroups.color);
|
|
1073
|
+
this.#populateButtonGroup("background-color", colorGroups["background-color"]);
|
|
1074
|
+
|
|
1075
|
+
const maxNumberOfColors = Math.max(colorGroups.color.length, colorGroups["background-color"].length);
|
|
1076
|
+
this.panel.style.setProperty("--max-colors", maxNumberOfColors);
|
|
1081
1077
|
}
|
|
1082
|
-
}
|
|
1083
1078
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
}
|
|
1079
|
+
#populateButtonGroup(attribute, values) {
|
|
1080
|
+
values.forEach((value, index) => {
|
|
1081
|
+
this.#buttonContainer.appendChild(this.#createButton(attribute, value, index));
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1089
1084
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1085
|
+
#createButton(attribute, value, index) {
|
|
1086
|
+
return createElement("button", {
|
|
1087
|
+
type: "button",
|
|
1088
|
+
dataset: { value, style: attribute },
|
|
1089
|
+
style: `${attribute}: ${value}`,
|
|
1090
|
+
class: "lexxy-editor__toolbar-button lexxy-highlight-button",
|
|
1091
|
+
name: `${attribute}-${index}`,
|
|
1092
|
+
role: "menuitem"
|
|
1093
|
+
})
|
|
1094
|
+
}
|
|
1093
1095
|
|
|
1094
|
-
|
|
1096
|
+
#handleColorButtonClick = (event) => {
|
|
1097
|
+
event.preventDefault();
|
|
1095
1098
|
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
}
|
|
1099
|
+
const button = event.target.closest(APPLY_HIGHLIGHT_SELECTOR);
|
|
1100
|
+
if (!button) return
|
|
1099
1101
|
|
|
1100
|
-
|
|
1101
|
-
return string.charAt(0).toUpperCase() + string.slice(1)
|
|
1102
|
-
}
|
|
1102
|
+
const { style, value } = button.dataset;
|
|
1103
1103
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
}
|
|
1104
|
+
this.editor.dispatchCommand("toggleHighlight", { [style]: value });
|
|
1105
|
+
this.close();
|
|
1106
|
+
}
|
|
1107
1107
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
function parseBoolean(value) {
|
|
1111
|
-
if (typeof value === "string") return value === "true"
|
|
1112
|
-
return Boolean(value)
|
|
1113
|
-
}
|
|
1108
|
+
#updateColorButtonStates(selection) {
|
|
1109
|
+
if (!$isRangeSelection(selection)) { return }
|
|
1114
1110
|
|
|
1115
|
-
|
|
1116
|
-
|
|
1111
|
+
// Use non-"" default, so "" indicates mixed highlighting
|
|
1112
|
+
const textColor = $getSelectionStyleValueForProperty(selection, "color", NO_STYLE);
|
|
1113
|
+
const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", NO_STYLE);
|
|
1117
1114
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1115
|
+
this.#colorButtons.forEach(button => {
|
|
1116
|
+
const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor;
|
|
1117
|
+
const next = matchesSelection.toString();
|
|
1118
|
+
if (button.getAttribute("aria-pressed") !== next) {
|
|
1119
|
+
button.setAttribute("aria-pressed", next);
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1121
1122
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1123
|
+
const hasHighlight = textColor !== NO_STYLE || backgroundColor !== NO_STYLE;
|
|
1124
|
+
this.panel.querySelector(REMOVE_HIGHLIGHT_SELECTOR).disabled = !hasHighlight;
|
|
1124
1125
|
}
|
|
1125
1126
|
|
|
1126
|
-
get
|
|
1127
|
-
return this
|
|
1127
|
+
get #buttonContainer() {
|
|
1128
|
+
return this.panel.querySelector(".lexxy-highlight-colors")
|
|
1128
1129
|
}
|
|
1129
1130
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
return true
|
|
1131
|
+
get #colorButtons() {
|
|
1132
|
+
return Array.from(this.panel.querySelectorAll(APPLY_HIGHLIGHT_SELECTOR))
|
|
1133
1133
|
}
|
|
1134
|
+
}
|
|
1134
1135
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1136
|
+
class LinkDropdown extends ToolbarDropdown {
|
|
1137
|
+
editorReady() {
|
|
1138
|
+
this.input = this.panel.querySelector("input");
|
|
1138
1139
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1140
|
+
this.track(
|
|
1141
|
+
registerEventListener(this.input, "keydown", this.#handleEnter),
|
|
1142
|
+
registerEventListener(this.linkButton, "click", this.#handleLink),
|
|
1143
|
+
registerEventListener(this.unlinkButton, "click", this.#handleUnlink)
|
|
1144
|
+
);
|
|
1141
1145
|
}
|
|
1142
1146
|
|
|
1143
|
-
|
|
1147
|
+
onOpen() {
|
|
1148
|
+
this.input.value = this.#selectedLinkUrl;
|
|
1149
|
+
this.input.required = true;
|
|
1150
|
+
}
|
|
1144
1151
|
|
|
1152
|
+
onClose() {
|
|
1153
|
+
this.input.required = false;
|
|
1145
1154
|
}
|
|
1146
1155
|
|
|
1147
|
-
|
|
1156
|
+
get linkButton() {
|
|
1157
|
+
return this.panel.querySelector("[value='link']")
|
|
1148
1158
|
}
|
|
1149
|
-
}
|
|
1150
1159
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const { commonAncestor } = $getCommonAncestor(selection.focus.getNode(), selection.anchor.getNode());
|
|
1154
|
-
return $findMatchingParent(commonAncestor, parent => parent.is(node))
|
|
1155
|
-
} else {
|
|
1156
|
-
return false
|
|
1160
|
+
get unlinkButton() {
|
|
1161
|
+
return this.panel.querySelector("[value='unlink']")
|
|
1157
1162
|
}
|
|
1158
|
-
}
|
|
1159
1163
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1164
|
+
#handleEnter = (event) => {
|
|
1165
|
+
if (event.key === "Enter") {
|
|
1166
|
+
event.preventDefault();
|
|
1167
|
+
event.stopPropagation();
|
|
1168
|
+
this.#handleLink(event);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1165
1171
|
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1172
|
+
#handleLink = () => {
|
|
1173
|
+
if (!this.input.checkValidity()) {
|
|
1174
|
+
this.input.reportValidity();
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1169
1177
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
return $wrapNodeInElement(node, $createParagraphNode)
|
|
1173
|
-
} else if (node.isParentRequired()) {
|
|
1174
|
-
const parent = node.createRequiredParent();
|
|
1175
|
-
return $wrapNodeInElement(node, parent)
|
|
1176
|
-
} else {
|
|
1177
|
-
return node
|
|
1178
|
+
this.editor.dispatchCommand("link", this.input.value);
|
|
1179
|
+
this.close();
|
|
1178
1180
|
}
|
|
1179
|
-
}
|
|
1180
1181
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
}
|
|
1182
|
+
#handleUnlink = () => {
|
|
1183
|
+
this.editor.dispatchCommand("unlink");
|
|
1184
|
+
this.close();
|
|
1185
|
+
}
|
|
1185
1186
|
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1187
|
+
get #selectedLinkUrl() {
|
|
1188
|
+
return this.editor.getEditorState().read(() => {
|
|
1189
|
+
const linkNode = this.editorElement.selection.nearestNodeOfType(LinkNode);
|
|
1190
|
+
return linkNode?.getURL() ?? ""
|
|
1191
|
+
})
|
|
1192
|
+
}
|
|
1189
1193
|
}
|
|
1190
1194
|
|
|
1191
|
-
function
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1195
|
+
function deepMerge(target, source) {
|
|
1196
|
+
const result = { ...target, ...source };
|
|
1197
|
+
for (const [ key, value ] of Object.entries(source)) {
|
|
1198
|
+
if (arePlainHashes(target[key], value)) {
|
|
1199
|
+
result[key] = deepMerge(target[key], value);
|
|
1200
|
+
}
|
|
1196
1201
|
}
|
|
1197
|
-
}
|
|
1198
1202
|
|
|
1199
|
-
|
|
1200
|
-
return point.offset === 0
|
|
1203
|
+
return result
|
|
1201
1204
|
}
|
|
1202
1205
|
|
|
1203
|
-
function
|
|
1204
|
-
return
|
|
1205
|
-
...conversionOutput,
|
|
1206
|
-
forChild: (lexicalNode, parentNode) => {
|
|
1207
|
-
const originalForChild = conversionOutput?.forChild ?? (x => x);
|
|
1208
|
-
let childNode = originalForChild(lexicalNode, parentNode);
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
if ($isTextNode(childNode)) {
|
|
1212
|
-
childNode = callbacks.reduce(
|
|
1213
|
-
(childNode, callback) => callback(childNode, element) ?? childNode,
|
|
1214
|
-
childNode
|
|
1215
|
-
);
|
|
1216
|
-
return childNode
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
}))
|
|
1206
|
+
function arePlainHashes(...values) {
|
|
1207
|
+
return values.every(value => value && value.constructor == Object)
|
|
1220
1208
|
}
|
|
1221
1209
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
|
|
1225
|
-
if (!converter) return null
|
|
1210
|
+
class Configuration {
|
|
1211
|
+
#tree = {}
|
|
1226
1212
|
|
|
1227
|
-
|
|
1228
|
-
|
|
1213
|
+
constructor(...configs) {
|
|
1214
|
+
this.merge(...configs);
|
|
1215
|
+
}
|
|
1229
1216
|
|
|
1230
|
-
|
|
1217
|
+
merge(...configs) {
|
|
1218
|
+
return this.#tree = configs.reduce(deepMerge, this.#tree)
|
|
1231
1219
|
}
|
|
1232
|
-
}
|
|
1233
1220
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
if (children.length === 0) return true
|
|
1239
|
-
|
|
1240
|
-
const lastChild = children[children.length - 1];
|
|
1241
|
-
|
|
1242
|
-
if (anchorNode === elementNode.getLatest() && selection.anchor.offset === children.length) return true
|
|
1243
|
-
if (anchorNode === lastChild) return true
|
|
1244
|
-
|
|
1245
|
-
const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
|
|
1246
|
-
if (lastLineBreakIndex === -1) return true
|
|
1247
|
-
|
|
1248
|
-
const anchorIndex = children.indexOf(anchorNode);
|
|
1249
|
-
return anchorIndex > lastLineBreakIndex
|
|
1221
|
+
get(path) {
|
|
1222
|
+
const keys = path.split(".");
|
|
1223
|
+
return keys.reduce((node, key) => node[key], this.#tree)
|
|
1224
|
+
}
|
|
1250
1225
|
}
|
|
1251
1226
|
|
|
1252
|
-
function
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
const children = node.getChildren?.();
|
|
1256
|
-
if (!children || children.length === 0) return true
|
|
1257
|
-
|
|
1258
|
-
return children.every(child => {
|
|
1259
|
-
if ($isLineBreakNode(child)) return true
|
|
1260
|
-
return $isBlankNode(child)
|
|
1261
|
-
})
|
|
1227
|
+
function range(from, to) {
|
|
1228
|
+
return [ ...Array(1 + to - from).keys() ].map(i => i + from)
|
|
1262
1229
|
}
|
|
1263
1230
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1231
|
+
const global = new Configuration({
|
|
1232
|
+
attachmentTagName: "action-text-attachment",
|
|
1233
|
+
attachmentContentTypeNamespace: "actiontext",
|
|
1234
|
+
authenticatedUploads: false,
|
|
1235
|
+
extensions: []
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
const presets = new Configuration({
|
|
1239
|
+
default: {
|
|
1240
|
+
attachments: true,
|
|
1241
|
+
markdown: true,
|
|
1242
|
+
multiLine: true,
|
|
1243
|
+
permittedAttachmentTypes: null,
|
|
1244
|
+
richText: true,
|
|
1245
|
+
toolbar: {
|
|
1246
|
+
upload: "both"
|
|
1247
|
+
},
|
|
1248
|
+
highlight: {
|
|
1249
|
+
buttons: {
|
|
1250
|
+
color: range(1, 9).map(n => `var(--highlight-${n})`),
|
|
1251
|
+
"background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
|
|
1252
|
+
},
|
|
1253
|
+
permit: {
|
|
1254
|
+
color: [],
|
|
1255
|
+
"background-color": []
|
|
1256
|
+
}
|
|
1270
1257
|
}
|
|
1271
1258
|
}
|
|
1272
|
-
}
|
|
1259
|
+
});
|
|
1273
1260
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
for (const child of children) {
|
|
1281
|
-
if ($isDecoratorNode(child)) return false
|
|
1282
|
-
if ($isLineBreakNode(child)) continue
|
|
1283
|
-
if ($isTextNode(child)) {
|
|
1284
|
-
if (child.getTextContent().trim() !== "") return false
|
|
1285
|
-
} else if ($isElementNode(child)) {
|
|
1286
|
-
if (child.getTextContent().trim() !== "") return false
|
|
1261
|
+
var Lexxy = {
|
|
1262
|
+
global,
|
|
1263
|
+
presets,
|
|
1264
|
+
configure({ global: newGlobal, ...newPresets }) {
|
|
1265
|
+
if (newGlobal) {
|
|
1266
|
+
global.merge(newGlobal);
|
|
1287
1267
|
}
|
|
1268
|
+
presets.merge(newPresets);
|
|
1288
1269
|
}
|
|
1289
|
-
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
function setSanitizerConfig(allowedTags) {
|
|
1273
|
+
DOMPurify.clearConfig();
|
|
1274
|
+
DOMPurify.setConfig(buildConfig(allowedTags));
|
|
1290
1275
|
}
|
|
1291
1276
|
|
|
1292
|
-
function
|
|
1293
|
-
return
|
|
1294
|
-
&& node.getTextContent() === " "
|
|
1295
|
-
&& index === childCount - 1
|
|
1296
|
-
&& previousNode instanceof CustomActionTextAttachmentNode
|
|
1277
|
+
function sanitize(html) {
|
|
1278
|
+
return DOMPurify.sanitize(html)
|
|
1297
1279
|
}
|
|
1298
1280
|
|
|
1299
|
-
function
|
|
1300
|
-
|
|
1281
|
+
function bytesToHumanSize(bytes) {
|
|
1282
|
+
if (bytes === 0) return "0 B"
|
|
1283
|
+
const sizes = [ "B", "KB", "MB", "GB", "TB", "PB" ];
|
|
1284
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
1285
|
+
const value = bytes / Math.pow(1024, i);
|
|
1286
|
+
return `${ value.toFixed(2) } ${ sizes[i] }`
|
|
1287
|
+
}
|
|
1301
1288
|
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
$splitAtNearestLineBreak(selection.anchor, "previous");
|
|
1289
|
+
function extractFileName(string) {
|
|
1290
|
+
return string.split("/").pop()
|
|
1305
1291
|
}
|
|
1306
1292
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1293
|
+
// The content attribute is raw HTML (matching Trix/ActionText). Older Lexxy
|
|
1294
|
+
// versions JSON-encoded it, so try JSON.parse first for backward compatibility.
|
|
1295
|
+
function parseAttachmentContent(content) {
|
|
1296
|
+
try {
|
|
1297
|
+
return JSON.parse(content)
|
|
1298
|
+
} catch {
|
|
1299
|
+
return content
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1310
1302
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
const lineBreakCaret = $caretAtNearestNodeOfType(selectionChild, LineBreakNode, direction);
|
|
1314
|
-
if (!lineBreakCaret) return
|
|
1303
|
+
function mimeTypeToExtension(mimeType) {
|
|
1304
|
+
if (!mimeType) return null
|
|
1315
1305
|
|
|
1316
|
-
const
|
|
1317
|
-
|
|
1306
|
+
const extension = mimeType.split("/")[1];
|
|
1307
|
+
return extension
|
|
1308
|
+
}
|
|
1318
1309
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1310
|
+
class CustomActionTextAttachmentNode extends DecoratorNode {
|
|
1311
|
+
static getType() {
|
|
1312
|
+
return "custom_action_text_attachment"
|
|
1321
1313
|
}
|
|
1322
1314
|
|
|
1323
|
-
|
|
1324
|
-
}
|
|
1315
|
+
static clone(node) {
|
|
1316
|
+
return new CustomActionTextAttachmentNode({ ...node }, node.__key)
|
|
1317
|
+
}
|
|
1325
1318
|
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
if (caret.origin instanceof klass) return caret
|
|
1319
|
+
static importJSON(serializedNode) {
|
|
1320
|
+
return new CustomActionTextAttachmentNode({ ...serializedNode })
|
|
1329
1321
|
}
|
|
1330
|
-
return null
|
|
1331
|
-
}
|
|
1332
1322
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1323
|
+
static importDOM() {
|
|
1324
|
+
return {
|
|
1325
|
+
[this.TAG_NAME]: (element) => {
|
|
1326
|
+
if (!element.getAttribute("content")) {
|
|
1327
|
+
return null
|
|
1328
|
+
}
|
|
1337
1329
|
|
|
1338
|
-
|
|
1339
|
-
|
|
1330
|
+
return {
|
|
1331
|
+
conversion: (attachment) => {
|
|
1332
|
+
// Preserve initial space if present since Lexical removes it
|
|
1333
|
+
const nodes = [];
|
|
1334
|
+
const previousSibling = attachment.previousSibling;
|
|
1335
|
+
if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
|
|
1336
|
+
nodes.push($createTextNode(" "));
|
|
1337
|
+
}
|
|
1340
1338
|
|
|
1341
|
-
|
|
1342
|
-
return defineExtension({
|
|
1343
|
-
name: "lexxy/rewritable-history",
|
|
1344
|
-
dependencies: [ HistoryExtension ],
|
|
1345
|
-
register: (editor, _config, state) => {
|
|
1346
|
-
const historyOutput = state.getDependency(HistoryExtension).output;
|
|
1347
|
-
this.#historyState = historyOutput.historyState.value;
|
|
1339
|
+
const innerHtml = parseAttachmentContent(attachment.getAttribute("content"));
|
|
1348
1340
|
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
})
|
|
1356
|
-
}
|
|
1341
|
+
nodes.push(new CustomActionTextAttachmentNode({
|
|
1342
|
+
sgid: attachment.getAttribute("sgid"),
|
|
1343
|
+
innerHtml,
|
|
1344
|
+
plainText: attachment.textContent.trim() || extractPlainTextFromHtml(innerHtml),
|
|
1345
|
+
contentType: attachment.getAttribute("content-type")
|
|
1346
|
+
}));
|
|
1357
1347
|
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1348
|
+
const nextSibling = attachment.nextSibling;
|
|
1349
|
+
if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && /^\s/.test(nextSibling.textContent)) {
|
|
1350
|
+
nodes.push($createTextNode(" "));
|
|
1351
|
+
}
|
|
1361
1352
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1353
|
+
return { node: nodes }
|
|
1354
|
+
},
|
|
1355
|
+
priority: 2
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1366
1359
|
}
|
|
1367
1360
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
this.#applyRewritesToHistory(rewrites);
|
|
1371
|
-
|
|
1372
|
-
return true
|
|
1361
|
+
static get TAG_NAME() {
|
|
1362
|
+
return Lexxy.global.get("attachmentTagName")
|
|
1373
1363
|
}
|
|
1374
1364
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
|
|
1378
|
-
const node = $getNodeByKey(nodeKey);
|
|
1379
|
-
if (!node) continue
|
|
1365
|
+
constructor({ tagName, sgid, contentType, innerHtml, plainText }, key) {
|
|
1366
|
+
super(key);
|
|
1380
1367
|
|
|
1381
|
-
|
|
1382
|
-
if (replace) node.replace(replace);
|
|
1383
|
-
}
|
|
1384
|
-
}, { discrete: true, tag: this.#getBackgroundUpdateTags() });
|
|
1385
|
-
}
|
|
1368
|
+
const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
|
|
1386
1369
|
|
|
1387
|
-
|
|
1388
|
-
|
|
1370
|
+
this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
|
|
1371
|
+
this.sgid = sgid;
|
|
1372
|
+
this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
|
|
1373
|
+
this.innerHtml = innerHtml;
|
|
1374
|
+
this.plainText = plainText ?? extractPlainTextFromHtml(innerHtml);
|
|
1375
|
+
}
|
|
1389
1376
|
|
|
1390
|
-
|
|
1391
|
-
|
|
1377
|
+
createDOM() {
|
|
1378
|
+
const figure = createElement(this.tagName, { "content-type": this.contentType, "data-lexxy-decorator": true });
|
|
1392
1379
|
|
|
1393
|
-
|
|
1380
|
+
figure.insertAdjacentHTML("beforeend", sanitize(this.innerHtml));
|
|
1394
1381
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
if (!node) continue
|
|
1382
|
+
const deleteButton = createElement("lexxy-node-delete-button");
|
|
1383
|
+
figure.appendChild(deleteButton);
|
|
1398
1384
|
|
|
1399
|
-
|
|
1400
|
-
this.#patchNodeInEditorState(editorState, node, patch);
|
|
1401
|
-
} else if (replace) {
|
|
1402
|
-
this.#replaceNodeInEditorState(editorState, node, replace);
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1385
|
+
return figure
|
|
1406
1386
|
}
|
|
1407
1387
|
|
|
1408
|
-
|
|
1409
|
-
return
|
|
1388
|
+
updateDOM() {
|
|
1389
|
+
return false
|
|
1410
1390
|
}
|
|
1411
1391
|
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
if (!isEditorFocused(this.editorElement.editor)) { tags.push(SKIP_DOM_SELECTION_TAG); }
|
|
1415
|
-
return tags
|
|
1392
|
+
getTextContent() {
|
|
1393
|
+
return "\ufeff"
|
|
1416
1394
|
}
|
|
1417
1395
|
|
|
1418
|
-
|
|
1419
|
-
|
|
1396
|
+
getReadableTextContent() {
|
|
1397
|
+
return this.plainText || `[${this.contentType}]`
|
|
1420
1398
|
}
|
|
1421
1399
|
|
|
1422
|
-
|
|
1423
|
-
|
|
1400
|
+
isInline() {
|
|
1401
|
+
return true
|
|
1424
1402
|
}
|
|
1425
|
-
}
|
|
1426
1403
|
|
|
1427
|
-
|
|
1428
|
-
|
|
1404
|
+
exportDOM() {
|
|
1405
|
+
const attachment = createElement(this.tagName, {
|
|
1406
|
+
sgid: this.sgid,
|
|
1407
|
+
content: this.innerHtml,
|
|
1408
|
+
"content-type": this.contentType
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
return { element: attachment }
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
exportJSON() {
|
|
1415
|
+
return {
|
|
1416
|
+
type: "custom_action_text_attachment",
|
|
1417
|
+
version: 1,
|
|
1418
|
+
tagName: this.tagName,
|
|
1419
|
+
sgid: this.sgid,
|
|
1420
|
+
contentType: this.contentType,
|
|
1421
|
+
innerHtml: this.innerHtml,
|
|
1422
|
+
plainText: this.plainText
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
decorate() {
|
|
1427
|
+
return null
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function dasherize(value) {
|
|
1432
|
+
return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function isAutolinkableURL(string) {
|
|
1436
|
+
return /^(?:[a-z0-9]+:\/\/|www\.)[^\s]+$/i.test(string)
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function normalizeFilteredText(string) {
|
|
1440
|
+
return string
|
|
1441
|
+
.toLowerCase()
|
|
1442
|
+
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function filterMatchPosition(text, potentialMatch) {
|
|
1446
|
+
const normalizedText = normalizeFilteredText(text);
|
|
1447
|
+
const normalizedMatch = normalizeFilteredText(potentialMatch);
|
|
1448
|
+
|
|
1449
|
+
if (!normalizedMatch) return 0
|
|
1450
|
+
|
|
1451
|
+
const match = normalizedText.match(new RegExp(`(?:^|\\b)${escapeForRegExp(normalizedMatch)}`));
|
|
1452
|
+
return match ? match.index : -1
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function upcaseFirst(string) {
|
|
1456
|
+
return string.charAt(0).toUpperCase() + string.slice(1)
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function escapeForRegExp(string) {
|
|
1460
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Parses a value that may arrive as a boolean or as a string (e.g. from DOM
|
|
1464
|
+
// getAttribute) into a proper boolean. Ensures "false" doesn't evaluate as truthy.
|
|
1465
|
+
function parseBoolean(value) {
|
|
1466
|
+
if (typeof value === "string") return value === "true"
|
|
1467
|
+
return Boolean(value)
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
class LexxyExtension {
|
|
1471
|
+
#editorElement
|
|
1472
|
+
|
|
1473
|
+
constructor(editorElement) {
|
|
1474
|
+
this.#editorElement = editorElement;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
get editorElement() {
|
|
1478
|
+
return this.#editorElement
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
get editorConfig() {
|
|
1482
|
+
return this.#editorElement.config
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// optional: defaults to true
|
|
1486
|
+
get enabled() {
|
|
1487
|
+
return true
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
get lexicalExtension() {
|
|
1491
|
+
return null
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
get allowedElements() {
|
|
1495
|
+
return []
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
initializeToolbar(_lexxyToolbar) {
|
|
1499
|
+
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
setEditorValidity(flags, message) {
|
|
1503
|
+
this.editorElement.setElementValidity(this, flags, message);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
dispose() {
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function $containsRangeSelection(node, selection = $getSelection()) {
|
|
1511
|
+
if ($isRangeSelection(selection)) {
|
|
1512
|
+
const { commonAncestor } = $getCommonAncestor(selection.focus.getNode(), selection.anchor.getNode());
|
|
1513
|
+
return $findMatchingParent(commonAncestor, parent => parent.is(node))
|
|
1514
|
+
} else {
|
|
1515
|
+
return false
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function $createNodeSelectionWith(...nodes) {
|
|
1520
|
+
const selection = $createNodeSelection();
|
|
1521
|
+
nodes.forEach(node => selection.add(node.getKey()));
|
|
1522
|
+
return selection
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function $isShadowRoot(node) {
|
|
1526
|
+
return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function $makeSafeForRoot(node) {
|
|
1530
|
+
if ($isTextNode(node)) {
|
|
1531
|
+
return $wrapNodeInElement(node, $createParagraphNode)
|
|
1532
|
+
} else if (node.isParentRequired()) {
|
|
1533
|
+
const parent = node.createRequiredParent();
|
|
1534
|
+
return $wrapNodeInElement(node, parent)
|
|
1535
|
+
} else {
|
|
1536
|
+
return node
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
function getListType(node) {
|
|
1541
|
+
const list = $getNearestNodeOfType(node, ListNode);
|
|
1542
|
+
return list?.getListType() ?? null
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function isEditorFocused(editor) {
|
|
1546
|
+
const rootElement = editor.getRootElement();
|
|
1547
|
+
return rootElement !== null && rootElement.contains(document.activeElement)
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function $isAtNodeEdge(point, atStart = null) {
|
|
1551
|
+
if (atStart === null) {
|
|
1552
|
+
return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false)
|
|
1553
|
+
} else {
|
|
1554
|
+
return atStart ? $isAtNodeStart(point) : $isAtNodeEnd(point)
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function $isAtNodeStart(point) {
|
|
1559
|
+
return point.offset === 0
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function extendTextNodeConversion(conversionName, ...callbacks) {
|
|
1563
|
+
return extendConversion(TextNode, conversionName, (conversionOutput, element) => ({
|
|
1564
|
+
...conversionOutput,
|
|
1565
|
+
forChild: (lexicalNode, parentNode) => {
|
|
1566
|
+
const originalForChild = conversionOutput?.forChild ?? (x => x);
|
|
1567
|
+
let childNode = originalForChild(lexicalNode, parentNode);
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
if ($isTextNode(childNode)) {
|
|
1571
|
+
childNode = callbacks.reduce(
|
|
1572
|
+
(childNode, callback) => callback(childNode, element) ?? childNode,
|
|
1573
|
+
childNode
|
|
1574
|
+
);
|
|
1575
|
+
return childNode
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}))
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function extendConversion(nodeKlass, conversionName, callback = (output => output)) {
|
|
1582
|
+
return (element) => {
|
|
1583
|
+
const converter = nodeKlass.importDOM()?.[conversionName]?.(element);
|
|
1584
|
+
if (!converter) return null
|
|
1585
|
+
|
|
1586
|
+
const conversionOutput = converter.conversion(element);
|
|
1587
|
+
if (!conversionOutput) return conversionOutput
|
|
1588
|
+
|
|
1589
|
+
return callback(conversionOutput, element) ?? conversionOutput
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function $isCursorOnLastLine(selection) {
|
|
1594
|
+
const anchorNode = selection.anchor.getNode();
|
|
1595
|
+
const elementNode = $isElementNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow();
|
|
1596
|
+
const children = elementNode.getChildren();
|
|
1597
|
+
if (children.length === 0) return true
|
|
1598
|
+
|
|
1599
|
+
const lastChild = children[children.length - 1];
|
|
1600
|
+
|
|
1601
|
+
if (anchorNode === elementNode.getLatest() && selection.anchor.offset === children.length) return true
|
|
1602
|
+
if (anchorNode === lastChild) return true
|
|
1603
|
+
|
|
1604
|
+
const lastLineBreakIndex = children.findLastIndex(child => $isLineBreakNode(child));
|
|
1605
|
+
if (lastLineBreakIndex === -1) return true
|
|
1606
|
+
|
|
1607
|
+
const anchorIndex = children.indexOf(anchorNode);
|
|
1608
|
+
return anchorIndex > lastLineBreakIndex
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function $isBlankNode(node) {
|
|
1612
|
+
if (node.getTextContent().trim() !== "") return false
|
|
1613
|
+
|
|
1614
|
+
const children = node.getChildren?.();
|
|
1615
|
+
if (!children || children.length === 0) return true
|
|
1616
|
+
|
|
1617
|
+
return children.every(child => {
|
|
1618
|
+
if ($isLineBreakNode(child)) return true
|
|
1619
|
+
return $isBlankNode(child)
|
|
1620
|
+
})
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function $trimTrailingBlankNodes(parent) {
|
|
1624
|
+
for (const child of $lastToFirstIterator(parent)) {
|
|
1625
|
+
if ($isBlankNode(child)) {
|
|
1626
|
+
child.remove();
|
|
1627
|
+
} else {
|
|
1628
|
+
break
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// A list item is structurally empty if it contains no meaningful content.
|
|
1634
|
+
// Unlike getTextContent().trim() === "", this walks descendants to ensure
|
|
1635
|
+
// decorator nodes (mentions, attachments whose getTextContent() may return
|
|
1636
|
+
// invisible characters like \ufeff) are treated as non-empty content.
|
|
1637
|
+
function $isListItemStructurallyEmpty(listItem) {
|
|
1638
|
+
const children = listItem.getChildren();
|
|
1639
|
+
for (const child of children) {
|
|
1640
|
+
if ($isDecoratorNode(child)) return false
|
|
1641
|
+
if ($isLineBreakNode(child)) continue
|
|
1642
|
+
if ($isTextNode(child)) {
|
|
1643
|
+
if (child.getTextContent().trim() !== "") return false
|
|
1644
|
+
} else if ($isElementNode(child)) {
|
|
1645
|
+
if (child.getTextContent().trim() !== "") return false
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
return true
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
|
|
1652
|
+
return $isTextNode(node)
|
|
1653
|
+
&& node.getTextContent() === " "
|
|
1654
|
+
&& index === childCount - 1
|
|
1655
|
+
&& previousNode instanceof CustomActionTextAttachmentNode
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
function $splitSelectedParagraphsAtInnerLineBreaks(selection) {
|
|
1659
|
+
const topLevelElements = new Set();
|
|
1660
|
+
for (const node of selection.getNodes()) {
|
|
1661
|
+
const topLevel = node.getTopLevelElement();
|
|
1662
|
+
if (topLevel) topLevelElements.add(topLevel);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
for (const element of topLevelElements) {
|
|
1666
|
+
if (!$isParagraphNode(element)) continue
|
|
1667
|
+
|
|
1668
|
+
const children = element.getChildren();
|
|
1669
|
+
if (!children.some($isLineBreakNode)) continue
|
|
1670
|
+
|
|
1671
|
+
const groups = [ [] ];
|
|
1672
|
+
for (const child of children) {
|
|
1673
|
+
if ($isLineBreakNode(child)) {
|
|
1674
|
+
groups.push([]);
|
|
1675
|
+
child.remove();
|
|
1676
|
+
} else {
|
|
1677
|
+
groups[groups.length - 1].push(child);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
for (const group of groups) {
|
|
1682
|
+
if (group.length === 0) continue
|
|
1683
|
+
const paragraph = $createParagraphNode();
|
|
1684
|
+
group.forEach(child => paragraph.append(child));
|
|
1685
|
+
element.insertBefore(paragraph);
|
|
1686
|
+
}
|
|
1687
|
+
if (groups.some(group => group.length > 0)) element.remove();
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function $expandSelectionToLineBreaksAndSplitAtEdges(selection) {
|
|
1692
|
+
$ensureForwardRangeSelection(selection);
|
|
1693
|
+
|
|
1694
|
+
const focusCaret = $caretFromPoint(selection.focus, "next");
|
|
1695
|
+
const anchorCaret = $caretFromPoint(selection.anchor, "previous");
|
|
1696
|
+
|
|
1697
|
+
// A collapsed cursor adjacent to a <br> would claim it from both sides via
|
|
1698
|
+
// inward-edge; force outward-only walks so each side finds its own boundary.
|
|
1699
|
+
const skipInwardEdge = selection.isCollapsed();
|
|
1700
|
+
const focusBrCaret = $getCaretAtLineBreakBoundary(focusCaret, skipInwardEdge);
|
|
1701
|
+
let anchorBrCaret = $getCaretAtLineBreakBoundary(anchorCaret, skipInwardEdge);
|
|
1702
|
+
|
|
1703
|
+
if (focusBrCaret?.origin.is(anchorBrCaret?.origin)) {
|
|
1704
|
+
anchorBrCaret = null;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Splitting focus first keeps the anchor <br>'s position stable.
|
|
1708
|
+
const focusOuter = focusBrCaret && $splitAroundLineBreak(focusBrCaret);
|
|
1709
|
+
const anchorOuter = anchorBrCaret && $splitAroundLineBreak(anchorBrCaret);
|
|
1710
|
+
|
|
1711
|
+
const innerStart = anchorOuter?.getNextSibling() ?? selection.anchor.getNode().getTopLevelElement();
|
|
1712
|
+
const innerEnd = focusOuter?.getPreviousSibling() ?? selection.focus.getNode().getTopLevelElement();
|
|
1713
|
+
if (!innerStart || !innerEnd) return
|
|
1714
|
+
|
|
1715
|
+
$setSelectionFromCaretRange($getCaretRange(
|
|
1716
|
+
$normalizeCaret($getChildCaret(innerStart, "next")),
|
|
1717
|
+
$getCaretInDirection(
|
|
1718
|
+
$normalizeCaret($getChildCaret(innerEnd, "previous")),
|
|
1719
|
+
"next",
|
|
1720
|
+
),
|
|
1721
|
+
));
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function $getCaretAtLineBreakBoundary(caret, skipInwardEdge = false) {
|
|
1725
|
+
const paragraph = caret.origin.getTopLevelElement();
|
|
1726
|
+
if (!paragraph || !$isParagraphNode(paragraph)) return null
|
|
1727
|
+
|
|
1728
|
+
const lineBreak = (skipInwardEdge ? null : $inwardEdgeLineBreak(caret, paragraph))
|
|
1729
|
+
?? $outwardLineBreak(caret, paragraph);
|
|
1730
|
+
|
|
1731
|
+
return lineBreak ? $getSiblingCaret(lineBreak, caret.direction) : null
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// Prefer a <br> the cursor is sitting flush against, except when a further <br>
|
|
1735
|
+
// also exists outward — that one is the real paragraph break for this side.
|
|
1736
|
+
function $inwardEdgeLineBreak(caret, paragraph) {
|
|
1737
|
+
let candidateCaret;
|
|
1738
|
+
|
|
1739
|
+
if (
|
|
1740
|
+
($isChildCaret(caret) && caret.origin.is(paragraph)) ||
|
|
1741
|
+
($isTextPointCaret(caret) && $isExtendableTextPointCaret(caret.getFlipped()))
|
|
1742
|
+
) {
|
|
1743
|
+
candidateCaret = null;
|
|
1744
|
+
} else if ($isSiblingCaret(caret) && caret.getParentAtCaret().is(paragraph)) {
|
|
1745
|
+
candidateCaret = caret;
|
|
1746
|
+
} else {
|
|
1747
|
+
const childCaret = $paragraphChildCaretAtInwardEdge(caret, paragraph);
|
|
1748
|
+
candidateCaret = childCaret ? $rewindSiblingCaret(childCaret) : null;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
if (candidateCaret && $isLineBreakNode(candidateCaret.origin)) {
|
|
1752
|
+
return $candidateUnlessShadowed(candidateCaret)
|
|
1753
|
+
} else {
|
|
1754
|
+
return null
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function $candidateUnlessShadowed(candidateCaret) {
|
|
1759
|
+
const outward = candidateCaret.getNodeAtCaret();
|
|
1760
|
+
return $isLineBreakNode(outward) ? null : candidateCaret.origin
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
function $outwardLineBreak(caret, paragraph) {
|
|
1764
|
+
const startCaret = $outwardWalkStartCaret(caret, paragraph);
|
|
1765
|
+
if (!startCaret) return null
|
|
1766
|
+
|
|
1767
|
+
for (const { origin } of startCaret) {
|
|
1768
|
+
if (!origin.getParent().is(paragraph)) break
|
|
1769
|
+
if ($isLineBreakNode(origin)) return origin
|
|
1770
|
+
}
|
|
1771
|
+
return null
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
function $outwardWalkStartCaret(caret, paragraph) {
|
|
1775
|
+
if (caret.getParentAtCaret().is(paragraph)) {
|
|
1776
|
+
return caret
|
|
1777
|
+
} else {
|
|
1778
|
+
return $paragraphChildCaretContaining(caret, paragraph)
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function $paragraphChildCaretContaining(caret, paragraph) {
|
|
1783
|
+
let cursor = caret.getSiblingCaret();
|
|
1784
|
+
while (cursor && !cursor.origin.getParent()?.is(paragraph)) {
|
|
1785
|
+
cursor = cursor.getParentCaret();
|
|
1786
|
+
}
|
|
1787
|
+
return cursor?.origin.getParent()?.is(paragraph) ? cursor : null
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// Only succeeds when the cursor is flush against the inward edge of every
|
|
1791
|
+
// ancestor between itself and the paragraph child.
|
|
1792
|
+
function $paragraphChildCaretAtInwardEdge(caret, paragraph) {
|
|
1793
|
+
let cursor = caret.getSiblingCaret();
|
|
1794
|
+
while (cursor && !cursor.origin.getParent()?.is(paragraph)) {
|
|
1795
|
+
if (cursor.getNodeAtCaret()) return null
|
|
1796
|
+
cursor = cursor.getParentCaret();
|
|
1797
|
+
}
|
|
1798
|
+
return cursor?.origin.getParent()?.is(paragraph) ? cursor : null
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function $splitAroundLineBreak(lineBreakCaret) {
|
|
1802
|
+
let outer = null;
|
|
1803
|
+
|
|
1804
|
+
if (lineBreakCaret.getNodeAtCaret() === null) {
|
|
1805
|
+
lineBreakCaret.origin.remove();
|
|
1806
|
+
} else {
|
|
1807
|
+
const lineBreak = lineBreakCaret.origin;
|
|
1808
|
+
const splitCaret = $getCaretInDirection($rewindSiblingCaret(lineBreakCaret), "next");
|
|
1809
|
+
|
|
1810
|
+
$splitAtPointCaretNext(splitCaret);
|
|
1811
|
+
outer = lineBreak.getTopLevelElement();
|
|
1812
|
+
lineBreak.remove();
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
return outer
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// Payload: Record<nodeKey, { patch?, replace? }>
|
|
1819
|
+
// - patch: plain object, shallow-merged into the existing node's properties
|
|
1820
|
+
// - replace: a LexicalNode instance that replaces the node
|
|
1821
|
+
const REWRITE_HISTORY_COMMAND = createCommand("REWRITE_HISTORY_COMMAND");
|
|
1822
|
+
|
|
1823
|
+
class RewritableHistoryExtension extends LexxyExtension {
|
|
1824
|
+
#historyState = null
|
|
1825
|
+
|
|
1826
|
+
get lexicalExtension() {
|
|
1827
|
+
return defineExtension({
|
|
1828
|
+
name: "lexxy/rewritable-history",
|
|
1829
|
+
dependencies: [ HistoryExtension ],
|
|
1830
|
+
register: (editor, _config, state) => {
|
|
1831
|
+
const historyOutput = state.getDependency(HistoryExtension).output;
|
|
1832
|
+
this.#historyState = historyOutput.historyState.value;
|
|
1833
|
+
|
|
1834
|
+
return editor.registerCommand(
|
|
1835
|
+
REWRITE_HISTORY_COMMAND,
|
|
1836
|
+
(rewrites) => this.#rewriteHistory(rewrites),
|
|
1837
|
+
COMMAND_PRIORITY_EDITOR
|
|
1838
|
+
)
|
|
1839
|
+
}
|
|
1840
|
+
})
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
get historyState() {
|
|
1844
|
+
return this.#historyState
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
get #allHistoryEntries() {
|
|
1848
|
+
const entries = Array.from(this.#historyState.undoStack);
|
|
1849
|
+
if (this.#historyState.current) entries.push(this.#historyState.current);
|
|
1850
|
+
return entries.concat(this.#historyState.redoStack)
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
#rewriteHistory(rewrites) {
|
|
1854
|
+
this.#applyRewritesImmediatelyToCurrentState(rewrites);
|
|
1855
|
+
this.#applyRewritesToHistory(rewrites);
|
|
1856
|
+
|
|
1857
|
+
return true
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
#applyRewritesImmediatelyToCurrentState(rewrites) {
|
|
1861
|
+
$getEditor().update(() => {
|
|
1862
|
+
for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
|
|
1863
|
+
const node = $getNodeByKey(nodeKey);
|
|
1864
|
+
if (!node) continue
|
|
1865
|
+
|
|
1866
|
+
if (patch) Object.assign(node.getWritable(), patch);
|
|
1867
|
+
if (replace) node.replace(replace);
|
|
1868
|
+
}
|
|
1869
|
+
}, { discrete: true, tag: this.#getBackgroundUpdateTags() });
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
#applyRewritesToHistory(rewrites) {
|
|
1873
|
+
const nodeKeys = Object.keys(rewrites);
|
|
1874
|
+
|
|
1875
|
+
for (const entry of this.#allHistoryEntries) {
|
|
1876
|
+
if (!this.#entryHasSomeKeys(entry, nodeKeys)) continue
|
|
1877
|
+
|
|
1878
|
+
const editorState = entry.editorState = safeCloneEditorState(entry.editorState);
|
|
1879
|
+
|
|
1880
|
+
for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
|
|
1881
|
+
const node = editorState._nodeMap.get(nodeKey);
|
|
1882
|
+
if (!node) continue
|
|
1883
|
+
|
|
1884
|
+
if (patch) {
|
|
1885
|
+
this.#patchNodeInEditorState(editorState, node, patch);
|
|
1886
|
+
} else if (replace) {
|
|
1887
|
+
this.#replaceNodeInEditorState(editorState, node, replace);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
#entryHasSomeKeys(entry, nodeKeys) {
|
|
1894
|
+
return nodeKeys.some(key => entry.editorState._nodeMap.has(key))
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
#getBackgroundUpdateTags() {
|
|
1898
|
+
const tags = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
|
|
1899
|
+
if (!isEditorFocused(this.editorElement.editor)) { tags.push(SKIP_DOM_SELECTION_TAG); }
|
|
1900
|
+
return tags
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
#patchNodeInEditorState(editorState, node, patch) {
|
|
1904
|
+
editorState._nodeMap.set(node.__key, $cloneNodeWithPatch(node, patch));
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
#replaceNodeInEditorState(editorState, node, replaceWith) {
|
|
1908
|
+
editorState._nodeMap.set(node.__key, $cloneNodeAdoptingKeys(replaceWith, node));
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function $cloneNodeWithPatch(node, patch) {
|
|
1913
|
+
const clone = $cloneWithProperties(node);
|
|
1429
1914
|
Object.assign(clone, patch);
|
|
1430
1915
|
return clone
|
|
1431
1916
|
}
|
|
@@ -1873,11 +2358,6 @@ function $isActionTextAttachmentNode(node) {
|
|
|
1873
2358
|
return node instanceof ActionTextAttachmentNode
|
|
1874
2359
|
}
|
|
1875
2360
|
|
|
1876
|
-
function $generateFilteredNodesFromDOM(editorElement, doc) {
|
|
1877
|
-
const nodes = $generateNodesFromDOM(editorElement.editor, doc);
|
|
1878
|
-
return filterDisallowedAttachmentNodes(nodes, editorElement)
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
2361
|
function filterDisallowedAttachmentNodes(nodes, editorElement) {
|
|
1882
2362
|
return nodes
|
|
1883
2363
|
.filter(node => !isDisallowedAttachment(node, editorElement))
|
|
@@ -1896,6 +2376,67 @@ function isDisallowedAttachment(node, editorElement) {
|
|
|
1896
2376
|
!editorElement.permitsAttachmentContentType(node.contentType)
|
|
1897
2377
|
}
|
|
1898
2378
|
|
|
2379
|
+
// Replaces inline `data:image/...` attachments with upload nodes that flow through the normal
|
|
2380
|
+
// file upload pipeline.
|
|
2381
|
+
//
|
|
2382
|
+
// Without this step, pasted-from-Google-Docs-style content lands in the editor with the entire
|
|
2383
|
+
// base64 image embedded in the attachment's `src`, which then persists into the saved HTML and
|
|
2384
|
+
// bloats the stored document.
|
|
2385
|
+
//
|
|
2386
|
+
// Each conversion dispatches the cancelable `lexxy:file-accept` event so the host's allowlist (and
|
|
2387
|
+
// any other listener) can refuse the synthesized File before it's accepted into the upload
|
|
2388
|
+
// pipeline; on refusal, the node is dropped silently — matching how `Contents#uploadFiles` handles
|
|
2389
|
+
// file-picker rejections.
|
|
2390
|
+
function $convertInlineImageDataURIs(nodes, editorElement) {
|
|
2391
|
+
const topLevel = nodes
|
|
2392
|
+
.map(node => isInlineImageDataURIAttachment(node) ? $tryCreateUploadFromDataURI(node, editorElement) : node)
|
|
2393
|
+
.filter(node => node !== null);
|
|
2394
|
+
|
|
2395
|
+
for (const node of topLevel) {
|
|
2396
|
+
for (const desc of $descendantsMatching([ node ], isInlineImageDataURIAttachment)) {
|
|
2397
|
+
const upload = $tryCreateUploadFromDataURI(desc, editorElement);
|
|
2398
|
+
if (upload) {
|
|
2399
|
+
desc.replace(upload);
|
|
2400
|
+
} else {
|
|
2401
|
+
desc.remove();
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
return topLevel
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function isInlineImageDataURIAttachment(node) {
|
|
2410
|
+
return node instanceof ActionTextAttachmentNode &&
|
|
2411
|
+
/^data:image\/[^,]*;base64,/i.test(node.src ?? "")
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function $tryCreateUploadFromDataURI(node, editorElement) {
|
|
2415
|
+
const file = dataURIToFile(node.src);
|
|
2416
|
+
if (file && editorElement.acceptsFile(file)) {
|
|
2417
|
+
return editorElement.contents.$createUploadNode(file)
|
|
2418
|
+
}
|
|
2419
|
+
return null
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
function dataURIToFile(dataURI) {
|
|
2423
|
+
try {
|
|
2424
|
+
const [ header, data ] = dataURI.split(",");
|
|
2425
|
+
|
|
2426
|
+
// https://datatracker.ietf.org/doc/html/rfc6838#section-4.2
|
|
2427
|
+
const mimeType = header.match(/^data:(image\/[A-Za-z0-9][A-Za-z0-9!#$&\-^_.+]*)/)?.[1];
|
|
2428
|
+
if (mimeType) {
|
|
2429
|
+
const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
|
|
2430
|
+
const extension = mimeTypeToExtension(mimeType) ?? "png";
|
|
2431
|
+
return new File([ bytes ], `pasted-image-${Date.now()}.${extension}`, { type: mimeType })
|
|
2432
|
+
} else {
|
|
2433
|
+
return null
|
|
2434
|
+
}
|
|
2435
|
+
} catch {
|
|
2436
|
+
return null
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
|
|
1899
2440
|
class HorizontalDividerNode extends DecoratorNode {
|
|
1900
2441
|
static getType() {
|
|
1901
2442
|
return "horizontal_divider"
|
|
@@ -3150,12 +3691,18 @@ class CommandDispatcher {
|
|
|
3150
3691
|
#registerDragAndDropHandlers() {
|
|
3151
3692
|
if (this.editorElement.supportsAttachments) {
|
|
3152
3693
|
this.dragCounter = 0;
|
|
3153
|
-
const root = this.editor.getRootElement();
|
|
3154
3694
|
this.#listeners.track(
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3695
|
+
this.editor.registerRootListener((rootElement) => {
|
|
3696
|
+
if (rootElement) {
|
|
3697
|
+
const teardowns = [
|
|
3698
|
+
registerEventListener(rootElement, "dragover", this.#handleDragOver.bind(this)),
|
|
3699
|
+
registerEventListener(rootElement, "drop", this.#handleDrop.bind(this)),
|
|
3700
|
+
registerEventListener(rootElement, "dragenter", this.#handleDragEnter.bind(this)),
|
|
3701
|
+
registerEventListener(rootElement, "dragleave", this.#handleDragLeave.bind(this))
|
|
3702
|
+
];
|
|
3703
|
+
return () => teardowns.forEach((teardown) => teardown())
|
|
3704
|
+
}
|
|
3705
|
+
})
|
|
3159
3706
|
);
|
|
3160
3707
|
}
|
|
3161
3708
|
}
|
|
@@ -3254,42 +3801,6 @@ function capitalize(str) {
|
|
|
3254
3801
|
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
3255
3802
|
}
|
|
3256
3803
|
|
|
3257
|
-
function debounce(fn, wait) {
|
|
3258
|
-
let timeout;
|
|
3259
|
-
|
|
3260
|
-
return (...args) => {
|
|
3261
|
-
clearTimeout(timeout);
|
|
3262
|
-
timeout = setTimeout(() => fn(...args), wait);
|
|
3263
|
-
}
|
|
3264
|
-
}
|
|
3265
|
-
|
|
3266
|
-
function debounceAsync(fn, wait) {
|
|
3267
|
-
let timeout;
|
|
3268
|
-
|
|
3269
|
-
return (...args) => {
|
|
3270
|
-
clearTimeout(timeout);
|
|
3271
|
-
|
|
3272
|
-
return new Promise((resolve, reject) => {
|
|
3273
|
-
timeout = setTimeout(async () => {
|
|
3274
|
-
try {
|
|
3275
|
-
const result = await fn(...args);
|
|
3276
|
-
resolve(result);
|
|
3277
|
-
} catch (err) {
|
|
3278
|
-
reject(err);
|
|
3279
|
-
}
|
|
3280
|
-
}, wait);
|
|
3281
|
-
})
|
|
3282
|
-
}
|
|
3283
|
-
}
|
|
3284
|
-
|
|
3285
|
-
function delay(ms) {
|
|
3286
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
3287
|
-
}
|
|
3288
|
-
|
|
3289
|
-
function nextFrame() {
|
|
3290
|
-
return new Promise(requestAnimationFrame)
|
|
3291
|
-
}
|
|
3292
|
-
|
|
3293
3804
|
class Selection {
|
|
3294
3805
|
#listeners = new ListenerBin()
|
|
3295
3806
|
|
|
@@ -3361,32 +3872,6 @@ class Selection {
|
|
|
3361
3872
|
return { node: null, offset: 0 }
|
|
3362
3873
|
}
|
|
3363
3874
|
|
|
3364
|
-
preservingSelection(fn) {
|
|
3365
|
-
let selectionState = null;
|
|
3366
|
-
|
|
3367
|
-
this.editor.getEditorState().read(() => {
|
|
3368
|
-
const selection = $getSelection();
|
|
3369
|
-
if (selection && $isRangeSelection(selection)) {
|
|
3370
|
-
selectionState = {
|
|
3371
|
-
anchor: { key: selection.anchor.key, offset: selection.anchor.offset },
|
|
3372
|
-
focus: { key: selection.focus.key, offset: selection.focus.offset }
|
|
3373
|
-
};
|
|
3374
|
-
}
|
|
3375
|
-
});
|
|
3376
|
-
|
|
3377
|
-
fn();
|
|
3378
|
-
|
|
3379
|
-
if (selectionState) {
|
|
3380
|
-
this.editor.update(() => {
|
|
3381
|
-
const selection = $getSelection();
|
|
3382
|
-
if (selection && $isRangeSelection(selection)) {
|
|
3383
|
-
selection.anchor.set(selectionState.anchor.key, selectionState.anchor.offset, "text");
|
|
3384
|
-
selection.focus.set(selectionState.focus.key, selectionState.focus.offset, "text");
|
|
3385
|
-
}
|
|
3386
|
-
});
|
|
3387
|
-
}
|
|
3388
|
-
}
|
|
3389
|
-
|
|
3390
3875
|
getFormat() {
|
|
3391
3876
|
const selection = $getSelection();
|
|
3392
3877
|
if (!$isRangeSelection(selection)) return {}
|
|
@@ -3651,46 +4136,59 @@ class Selection {
|
|
|
3651
4136
|
return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode)
|
|
3652
4137
|
}, COMMAND_PRIORITY_LOW));
|
|
3653
4138
|
|
|
3654
|
-
const rootElement = this.editor.getRootElement();
|
|
3655
4139
|
this.#listeners.track(
|
|
3656
|
-
|
|
4140
|
+
this.editor.registerRootListener((rootElement) => {
|
|
4141
|
+
if (rootElement) {
|
|
4142
|
+
return registerEventListener(rootElement, "lexxy:internal:move-to-next-line", () => this.#selectOrAppendNextLine())
|
|
4143
|
+
}
|
|
4144
|
+
})
|
|
3657
4145
|
);
|
|
3658
4146
|
}
|
|
3659
4147
|
|
|
3660
4148
|
#containEditorFocus() {
|
|
3661
4149
|
// Workaround for a bizarre Chrome bug where the cursor abandons the editor to focus on not-focusable elements
|
|
3662
4150
|
// above when navigating UP/DOWN when Lexical shows its fake cursor on custom decorator nodes.
|
|
3663
|
-
this.
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
4151
|
+
this.#listeners.track(
|
|
4152
|
+
this.editor.registerRootListener((rootElement) => {
|
|
4153
|
+
if (rootElement) {
|
|
4154
|
+
const handler = (event) => this.#handleArrowKeyOnLexicalCursor(event);
|
|
4155
|
+
rootElement.addEventListener("keydown", handler, true);
|
|
4156
|
+
return () => rootElement.removeEventListener("keydown", handler, true)
|
|
4157
|
+
}
|
|
4158
|
+
})
|
|
4159
|
+
);
|
|
4160
|
+
}
|
|
3672
4161
|
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
4162
|
+
#handleArrowKeyOnLexicalCursor(event) {
|
|
4163
|
+
if (event.key === "ArrowUp") {
|
|
4164
|
+
const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
|
|
4165
|
+
|
|
4166
|
+
if (lexicalCursor) {
|
|
4167
|
+
let currentElement = lexicalCursor.previousElementSibling;
|
|
4168
|
+
while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
|
|
4169
|
+
currentElement = currentElement.previousElementSibling;
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
if (!currentElement) {
|
|
4173
|
+
event.preventDefault();
|
|
3676
4174
|
}
|
|
3677
4175
|
}
|
|
4176
|
+
}
|
|
3678
4177
|
|
|
3679
|
-
|
|
3680
|
-
|
|
4178
|
+
if (event.key === "ArrowDown") {
|
|
4179
|
+
const lexicalCursor = this.editor.getRootElement().querySelector("[data-lexical-cursor]");
|
|
3681
4180
|
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
4181
|
+
if (lexicalCursor) {
|
|
4182
|
+
let currentElement = lexicalCursor.nextElementSibling;
|
|
4183
|
+
while (currentElement && currentElement.hasAttribute("data-lexical-cursor")) {
|
|
4184
|
+
currentElement = currentElement.nextElementSibling;
|
|
4185
|
+
}
|
|
3687
4186
|
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
}
|
|
4187
|
+
if (!currentElement) {
|
|
4188
|
+
event.preventDefault();
|
|
3691
4189
|
}
|
|
3692
4190
|
}
|
|
3693
|
-
}
|
|
4191
|
+
}
|
|
3694
4192
|
}
|
|
3695
4193
|
|
|
3696
4194
|
#syncSelectedClasses() {
|
|
@@ -4013,472 +4511,193 @@ class Selection {
|
|
|
4013
4511
|
const parentElement = this.#getElementFromNode(anchorNode);
|
|
4014
4512
|
|
|
4015
4513
|
if (parentElement instanceof HTMLElement) {
|
|
4016
|
-
const computed = window.getComputedStyle(parentElement);
|
|
4017
|
-
return parseFloat(computed.fontSize)
|
|
4018
|
-
}
|
|
4019
|
-
|
|
4020
|
-
return 0
|
|
4021
|
-
}
|
|
4022
|
-
|
|
4023
|
-
#getElementFromNode(node) {
|
|
4024
|
-
return node?.nodeType === Node.TEXT_NODE ? node.parentElement : node
|
|
4025
|
-
}
|
|
4026
|
-
|
|
4027
|
-
#getCollapsedSelectionData() {
|
|
4028
|
-
const selection = $getSelection();
|
|
4029
|
-
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
|
4030
|
-
return { anchorNode: null, offset: 0 }
|
|
4031
|
-
}
|
|
4032
|
-
|
|
4033
|
-
const { anchor } = selection;
|
|
4034
|
-
return { anchorNode: anchor.getNode(), offset: anchor.offset }
|
|
4035
|
-
}
|
|
4036
|
-
|
|
4037
|
-
#getNodeAfterTextNode(anchorNode, offset) {
|
|
4038
|
-
if (offset === anchorNode.getTextContentSize()) {
|
|
4039
|
-
return this.#getNextNodeFromTextEnd(anchorNode)
|
|
4040
|
-
}
|
|
4041
|
-
return null
|
|
4042
|
-
}
|
|
4043
|
-
|
|
4044
|
-
#getNextNodeFromTextEnd(anchorNode) {
|
|
4045
|
-
const nextSibling = anchorNode.getNextSibling();
|
|
4046
|
-
if ($isDecoratorNode(nextSibling)) {
|
|
4047
|
-
return nextSibling
|
|
4048
|
-
}
|
|
4049
|
-
if (nextSibling != null) {
|
|
4050
|
-
return null
|
|
4051
|
-
}
|
|
4052
|
-
const parent = anchorNode.getParent();
|
|
4053
|
-
return parent ? parent.getNextSibling() : null
|
|
4054
|
-
}
|
|
4055
|
-
|
|
4056
|
-
#getNodeAfterElementNode(anchorNode, offset) {
|
|
4057
|
-
if (offset < anchorNode.getChildrenSize()) {
|
|
4058
|
-
return anchorNode.getChildAtIndex(offset)
|
|
4059
|
-
}
|
|
4060
|
-
return this.#findNextSiblingUp(anchorNode)
|
|
4061
|
-
}
|
|
4062
|
-
|
|
4063
|
-
#getNodeBeforeTextNode(anchorNode, offset) {
|
|
4064
|
-
if (offset === 0) {
|
|
4065
|
-
return this.#getPreviousNodeFromTextStart(anchorNode)
|
|
4066
|
-
}
|
|
4067
|
-
return null
|
|
4068
|
-
}
|
|
4069
|
-
|
|
4070
|
-
#getPreviousNodeFromTextStart(anchorNode) {
|
|
4071
|
-
const previousSibling = anchorNode.getPreviousSibling();
|
|
4072
|
-
if ($isDecoratorNode(previousSibling)) {
|
|
4073
|
-
return previousSibling
|
|
4074
|
-
}
|
|
4075
|
-
if (previousSibling != null) {
|
|
4076
|
-
return null
|
|
4077
|
-
}
|
|
4078
|
-
const parent = anchorNode.getParent();
|
|
4079
|
-
return parent ? parent.getPreviousSibling() : null
|
|
4080
|
-
}
|
|
4081
|
-
|
|
4082
|
-
#getNodeBeforeElementNode(anchorNode, offset) {
|
|
4083
|
-
if (offset > 0) {
|
|
4084
|
-
return anchorNode.getChildAtIndex(offset - 1)
|
|
4085
|
-
}
|
|
4086
|
-
return this.#findPreviousSiblingUp(anchorNode)
|
|
4087
|
-
}
|
|
4088
|
-
|
|
4089
|
-
#findNextSiblingUp(node) {
|
|
4090
|
-
let current = node;
|
|
4091
|
-
while (current && current.getNextSibling() == null) {
|
|
4092
|
-
current = current.getParent();
|
|
4093
|
-
}
|
|
4094
|
-
return current ? current.getNextSibling() : null
|
|
4095
|
-
}
|
|
4096
|
-
|
|
4097
|
-
#findPreviousSiblingUp(node) {
|
|
4098
|
-
let current = node;
|
|
4099
|
-
while (current && current.getPreviousSibling() == null) {
|
|
4100
|
-
current = current.getParent();
|
|
4101
|
-
}
|
|
4102
|
-
return current ? current.getPreviousSibling() : null
|
|
4103
|
-
}
|
|
4104
|
-
|
|
4105
|
-
#isCursorOnFirstVisualLineOfBlock(anchorNode) {
|
|
4106
|
-
return this.#isCursorOnEdgeLineOfBlock(anchorNode, "first")
|
|
4107
|
-
}
|
|
4108
|
-
|
|
4109
|
-
#isCursorOnLastVisualLineOfBlock(anchorNode) {
|
|
4110
|
-
return this.#isCursorOnEdgeLineOfBlock(anchorNode, "last")
|
|
4111
|
-
}
|
|
4112
|
-
|
|
4113
|
-
// Check whether the cursor sits on the first or last visual line of its
|
|
4114
|
-
// top-level block by comparing the Y position of the cursor with the Y
|
|
4115
|
-
// position of the block's start (first line) or end (last line).
|
|
4116
|
-
#isCursorOnEdgeLineOfBlock(anchorNode, edge) {
|
|
4117
|
-
const topLevelElement = anchorNode.getTopLevelElement();
|
|
4118
|
-
if (!topLevelElement) return false
|
|
4119
|
-
|
|
4120
|
-
const domElement = this.editor.getElementByKey(topLevelElement.getKey());
|
|
4121
|
-
if (!domElement) return false
|
|
4122
|
-
|
|
4123
|
-
const nativeSelection = window.getSelection();
|
|
4124
|
-
if (!nativeSelection?.rangeCount) return false
|
|
4125
|
-
|
|
4126
|
-
const cursorRect = this.#getReliableRectFromRange(nativeSelection.getRangeAt(0));
|
|
4127
|
-
if (!cursorRect || this.#isRectUnreliable(cursorRect)) return false
|
|
4128
|
-
|
|
4129
|
-
const edgeRect = this.#getEdgeCharRect(domElement, edge);
|
|
4130
|
-
if (!edgeRect || this.#isRectUnreliable(edgeRect)) return false
|
|
4131
|
-
|
|
4132
|
-
const tolerance = edgeRect.height > 0 ? edgeRect.height * 0.5 : 5;
|
|
4133
|
-
return Math.abs(cursorRect.top - edgeRect.top) < tolerance
|
|
4134
|
-
}
|
|
4135
|
-
|
|
4136
|
-
// Get a reliable bounding rect for the first or last character in a DOM
|
|
4137
|
-
// element by creating a non-collapsed range around it.
|
|
4138
|
-
#getEdgeCharRect(element, edge) {
|
|
4139
|
-
const walker = document.createTreeWalker(element, 4 /* NodeFilter.SHOW_TEXT */);
|
|
4140
|
-
let textNode;
|
|
4141
|
-
|
|
4142
|
-
if (edge === "first") {
|
|
4143
|
-
textNode = walker.nextNode();
|
|
4144
|
-
} else {
|
|
4145
|
-
while (walker.nextNode()) textNode = walker.currentNode;
|
|
4146
|
-
}
|
|
4147
|
-
|
|
4148
|
-
if (!textNode || textNode.length === 0) return null
|
|
4149
|
-
|
|
4150
|
-
const range = document.createRange();
|
|
4151
|
-
if (edge === "first") {
|
|
4152
|
-
range.setStart(textNode, 0);
|
|
4153
|
-
range.setEnd(textNode, 1);
|
|
4154
|
-
} else {
|
|
4155
|
-
range.setStart(textNode, textNode.length - 1);
|
|
4156
|
-
range.setEnd(textNode, textNode.length);
|
|
4157
|
-
}
|
|
4158
|
-
|
|
4159
|
-
return range.getBoundingClientRect()
|
|
4160
|
-
}
|
|
4161
|
-
}
|
|
4162
|
-
|
|
4163
|
-
class EditorConfiguration {
|
|
4164
|
-
#editorElement
|
|
4165
|
-
#config
|
|
4166
|
-
|
|
4167
|
-
constructor(editorElement) {
|
|
4168
|
-
this.#editorElement = editorElement;
|
|
4169
|
-
this.#config = new Configuration(
|
|
4170
|
-
Lexxy.presets.get("default"),
|
|
4171
|
-
Lexxy.presets.get(editorElement.preset),
|
|
4172
|
-
this.#overrides
|
|
4173
|
-
);
|
|
4174
|
-
}
|
|
4175
|
-
|
|
4176
|
-
get(path) {
|
|
4177
|
-
return this.#config.get(path)
|
|
4178
|
-
}
|
|
4179
|
-
|
|
4180
|
-
get #overrides() {
|
|
4181
|
-
const overrides = {};
|
|
4182
|
-
for (const option of this.#defaultOptions) {
|
|
4183
|
-
const attribute = dasherize(option);
|
|
4184
|
-
if (this.#editorElement.hasAttribute(attribute)) {
|
|
4185
|
-
overrides[option] = this.#parseAttribute(attribute);
|
|
4186
|
-
}
|
|
4187
|
-
}
|
|
4188
|
-
return overrides
|
|
4189
|
-
}
|
|
4190
|
-
|
|
4191
|
-
get #defaultOptions() {
|
|
4192
|
-
return Object.keys(Lexxy.presets.get("default"))
|
|
4193
|
-
}
|
|
4194
|
-
|
|
4195
|
-
#parseAttribute(attribute) {
|
|
4196
|
-
const value = this.#editorElement.getAttribute(attribute);
|
|
4197
|
-
try {
|
|
4198
|
-
return JSON.parse(value)
|
|
4199
|
-
} catch {
|
|
4200
|
-
return value
|
|
4201
|
-
}
|
|
4202
|
-
}
|
|
4203
|
-
}
|
|
4204
|
-
|
|
4205
|
-
async function loadFileIntoImage(file, image) {
|
|
4206
|
-
return new Promise((resolve) => {
|
|
4207
|
-
const reader = new FileReader();
|
|
4208
|
-
|
|
4209
|
-
image.addEventListener("load", () => {
|
|
4210
|
-
resolve(image);
|
|
4211
|
-
});
|
|
4212
|
-
|
|
4213
|
-
reader.onload = (event) => {
|
|
4214
|
-
image.src = event.target.result || null;
|
|
4215
|
-
};
|
|
4216
|
-
|
|
4217
|
-
reader.readAsDataURL(file);
|
|
4218
|
-
})
|
|
4219
|
-
}
|
|
4220
|
-
|
|
4221
|
-
class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
|
|
4222
|
-
static getType() {
|
|
4223
|
-
return "action_text_attachment_upload"
|
|
4224
|
-
}
|
|
4225
|
-
|
|
4226
|
-
static clone(node) {
|
|
4227
|
-
return new ActionTextAttachmentUploadNode({ ...node }, node.__key)
|
|
4228
|
-
}
|
|
4229
|
-
|
|
4230
|
-
static importJSON(serializedNode) {
|
|
4231
|
-
return new ActionTextAttachmentUploadNode({ ...serializedNode })
|
|
4232
|
-
}
|
|
4233
|
-
|
|
4234
|
-
// Should never run since this is a transient node. Defined to remove console warning.
|
|
4235
|
-
static importDOM() {
|
|
4236
|
-
return null
|
|
4237
|
-
}
|
|
4238
|
-
|
|
4239
|
-
constructor(node, key) {
|
|
4240
|
-
const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node;
|
|
4241
|
-
super({ ...node, contentType: file?.type ?? contentType }, key);
|
|
4242
|
-
this.file = file ?? null;
|
|
4243
|
-
this.fileName = file?.name ?? fileName;
|
|
4244
|
-
this.uploadUrl = uploadUrl;
|
|
4245
|
-
this.blobUrlTemplate = blobUrlTemplate;
|
|
4246
|
-
this.progress = progress ?? null;
|
|
4247
|
-
this.width = width;
|
|
4248
|
-
this.height = height;
|
|
4249
|
-
this.uploadError = uploadError;
|
|
4250
|
-
}
|
|
4251
|
-
|
|
4252
|
-
createDOM() {
|
|
4253
|
-
if (this.uploadError) return this.createDOMForError()
|
|
4254
|
-
|
|
4255
|
-
// This side-effect is trigged on DOM load to fire only once and avoid multiple
|
|
4256
|
-
// uploads through cloning. The upload is guarded from restarting in case the
|
|
4257
|
-
// node is reloaded from saved state such as from history.
|
|
4258
|
-
this.#startUploadIfNeeded();
|
|
4259
|
-
|
|
4260
|
-
// Bridge-managed uploads (uploadUrl is null) don't have file data to show
|
|
4261
|
-
// an image preview, so always show the file icon during upload.
|
|
4262
|
-
const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null;
|
|
4263
|
-
const figure = this.createAttachmentFigure(canPreviewFile);
|
|
4264
|
-
|
|
4265
|
-
if (canPreviewFile) {
|
|
4266
|
-
const img = figure.appendChild(this.#createDOMForImage());
|
|
4267
|
-
|
|
4268
|
-
// load file locally to set dimensions and prevent vertical shifting
|
|
4269
|
-
loadFileIntoImage(this.file, img).then(img => this.#setDimensionsFromImage(img));
|
|
4270
|
-
} else {
|
|
4271
|
-
figure.appendChild(this.#createDOMForFile());
|
|
4514
|
+
const computed = window.getComputedStyle(parentElement);
|
|
4515
|
+
return parseFloat(computed.fontSize)
|
|
4272
4516
|
}
|
|
4273
4517
|
|
|
4274
|
-
|
|
4275
|
-
figure.appendChild(this.#createProgressBar());
|
|
4276
|
-
|
|
4277
|
-
return figure
|
|
4518
|
+
return 0
|
|
4278
4519
|
}
|
|
4279
4520
|
|
|
4280
|
-
|
|
4281
|
-
|
|
4521
|
+
#getElementFromNode(node) {
|
|
4522
|
+
return node?.nodeType === Node.TEXT_NODE ? node.parentElement : node
|
|
4523
|
+
}
|
|
4282
4524
|
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4525
|
+
#getCollapsedSelectionData() {
|
|
4526
|
+
const selection = $getSelection();
|
|
4527
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
|
4528
|
+
return { anchorNode: null, offset: 0 }
|
|
4286
4529
|
}
|
|
4287
4530
|
|
|
4288
|
-
|
|
4531
|
+
const { anchor } = selection;
|
|
4532
|
+
return { anchorNode: anchor.getNode(), offset: anchor.offset }
|
|
4289
4533
|
}
|
|
4290
4534
|
|
|
4291
|
-
|
|
4292
|
-
|
|
4535
|
+
#getNodeAfterTextNode(anchorNode, offset) {
|
|
4536
|
+
if (offset === anchorNode.getTextContentSize()) {
|
|
4537
|
+
return this.#getNextNodeFromTextEnd(anchorNode)
|
|
4538
|
+
}
|
|
4539
|
+
return null
|
|
4293
4540
|
}
|
|
4294
4541
|
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
uploadUrl: this.uploadUrl,
|
|
4303
|
-
blobUrlTemplate: this.blobUrlTemplate,
|
|
4304
|
-
progress: this.progress,
|
|
4305
|
-
width: this.width,
|
|
4306
|
-
height: this.height,
|
|
4307
|
-
uploadError: this.uploadError
|
|
4542
|
+
#getNextNodeFromTextEnd(anchorNode) {
|
|
4543
|
+
const nextSibling = anchorNode.getNextSibling();
|
|
4544
|
+
if ($isDecoratorNode(nextSibling)) {
|
|
4545
|
+
return nextSibling
|
|
4546
|
+
}
|
|
4547
|
+
if (nextSibling != null) {
|
|
4548
|
+
return null
|
|
4308
4549
|
}
|
|
4550
|
+
const parent = anchorNode.getParent();
|
|
4551
|
+
return parent ? parent.getNextSibling() : null
|
|
4309
4552
|
}
|
|
4310
4553
|
|
|
4311
|
-
|
|
4312
|
-
|
|
4554
|
+
#getNodeAfterElementNode(anchorNode, offset) {
|
|
4555
|
+
if (offset < anchorNode.getChildrenSize()) {
|
|
4556
|
+
return anchorNode.getChildAtIndex(offset)
|
|
4557
|
+
}
|
|
4558
|
+
return this.#findNextSiblingUp(anchorNode)
|
|
4313
4559
|
}
|
|
4314
4560
|
|
|
4315
|
-
#
|
|
4316
|
-
|
|
4561
|
+
#getNodeBeforeTextNode(anchorNode, offset) {
|
|
4562
|
+
if (offset === 0) {
|
|
4563
|
+
return this.#getPreviousNodeFromTextStart(anchorNode)
|
|
4564
|
+
}
|
|
4565
|
+
return null
|
|
4317
4566
|
}
|
|
4318
4567
|
|
|
4319
|
-
#
|
|
4320
|
-
const
|
|
4321
|
-
|
|
4322
|
-
|
|
4568
|
+
#getPreviousNodeFromTextStart(anchorNode) {
|
|
4569
|
+
const previousSibling = anchorNode.getPreviousSibling();
|
|
4570
|
+
if ($isDecoratorNode(previousSibling)) {
|
|
4571
|
+
return previousSibling
|
|
4572
|
+
}
|
|
4573
|
+
if (previousSibling != null) {
|
|
4574
|
+
return null
|
|
4575
|
+
}
|
|
4576
|
+
const parent = anchorNode.getParent();
|
|
4577
|
+
return parent ? parent.getPreviousSibling() : null
|
|
4323
4578
|
}
|
|
4324
4579
|
|
|
4325
|
-
#
|
|
4326
|
-
|
|
4580
|
+
#getNodeBeforeElementNode(anchorNode, offset) {
|
|
4581
|
+
if (offset > 0) {
|
|
4582
|
+
return anchorNode.getChildAtIndex(offset - 1)
|
|
4583
|
+
}
|
|
4584
|
+
return this.#findPreviousSiblingUp(anchorNode)
|
|
4327
4585
|
}
|
|
4328
4586
|
|
|
4329
|
-
#
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
figcaption.appendChild(sizeSpan);
|
|
4336
|
-
|
|
4337
|
-
return figcaption
|
|
4587
|
+
#findNextSiblingUp(node) {
|
|
4588
|
+
let current = node;
|
|
4589
|
+
while (current && current.getNextSibling() == null) {
|
|
4590
|
+
current = current.getParent();
|
|
4591
|
+
}
|
|
4592
|
+
return current ? current.getNextSibling() : null
|
|
4338
4593
|
}
|
|
4339
4594
|
|
|
4340
|
-
#
|
|
4341
|
-
|
|
4595
|
+
#findPreviousSiblingUp(node) {
|
|
4596
|
+
let current = node;
|
|
4597
|
+
while (current && current.getPreviousSibling() == null) {
|
|
4598
|
+
current = current.getParent();
|
|
4599
|
+
}
|
|
4600
|
+
return current ? current.getPreviousSibling() : null
|
|
4342
4601
|
}
|
|
4343
4602
|
|
|
4344
|
-
#
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
this.patchAndRewriteHistory({ width, height });
|
|
4603
|
+
#isCursorOnFirstVisualLineOfBlock(anchorNode) {
|
|
4604
|
+
return this.#isCursorOnEdgeLineOfBlock(anchorNode, "first")
|
|
4348
4605
|
}
|
|
4349
4606
|
|
|
4350
|
-
|
|
4351
|
-
return
|
|
4607
|
+
#isCursorOnLastVisualLineOfBlock(anchorNode) {
|
|
4608
|
+
return this.#isCursorOnEdgeLineOfBlock(anchorNode, "last")
|
|
4352
4609
|
}
|
|
4353
4610
|
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4611
|
+
// Check whether the cursor sits on the first or last visual line of its
|
|
4612
|
+
// top-level block by comparing the Y position of the cursor with the Y
|
|
4613
|
+
// position of the block's start (first line) or end (last line).
|
|
4614
|
+
#isCursorOnEdgeLineOfBlock(anchorNode, edge) {
|
|
4615
|
+
const topLevelElement = anchorNode.getTopLevelElement();
|
|
4616
|
+
if (!topLevelElement) return false
|
|
4357
4617
|
|
|
4358
|
-
this
|
|
4618
|
+
const domElement = this.editor.getElementByKey(topLevelElement.getKey());
|
|
4619
|
+
if (!domElement) return false
|
|
4359
4620
|
|
|
4360
|
-
const
|
|
4621
|
+
const nativeSelection = window.getSelection();
|
|
4622
|
+
if (!nativeSelection?.rangeCount) return false
|
|
4361
4623
|
|
|
4362
|
-
const
|
|
4363
|
-
|
|
4624
|
+
const cursorRect = this.#getReliableRectFromRange(nativeSelection.getRangeAt(0));
|
|
4625
|
+
if (!cursorRect || this.#isRectUnreliable(cursorRect)) return false
|
|
4364
4626
|
|
|
4365
|
-
this.#
|
|
4627
|
+
const edgeRect = this.#getEdgeCharRect(domElement, edge);
|
|
4628
|
+
if (!edgeRect || this.#isRectUnreliable(edgeRect)) return false
|
|
4366
4629
|
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
this.#dispatchEvent("lexxy:upload-end", { file: this.file, error });
|
|
4370
|
-
this.#handleUploadError(error);
|
|
4371
|
-
} else {
|
|
4372
|
-
this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
|
|
4373
|
-
this.editor.update(() => {
|
|
4374
|
-
this.$showUploadedAttachment(blob);
|
|
4375
|
-
});
|
|
4376
|
-
}
|
|
4377
|
-
});
|
|
4630
|
+
const tolerance = edgeRect.height > 0 ? edgeRect.height * 0.5 : 5;
|
|
4631
|
+
return Math.abs(cursorRect.top - edgeRect.top) < tolerance
|
|
4378
4632
|
}
|
|
4379
4633
|
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
if (shouldAuthenticateUploads) request.withCredentials = true;
|
|
4386
|
-
},
|
|
4387
|
-
directUploadWillStoreFileWithXHR: (request) => {
|
|
4388
|
-
if (shouldAuthenticateUploads) request.withCredentials = true;
|
|
4634
|
+
// Get a reliable bounding rect for the first or last character in a DOM
|
|
4635
|
+
// element by creating a non-collapsed range around it.
|
|
4636
|
+
#getEdgeCharRect(element, edge) {
|
|
4637
|
+
const walker = document.createTreeWalker(element, 4 /* NodeFilter.SHOW_TEXT */);
|
|
4638
|
+
let textNode;
|
|
4389
4639
|
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4640
|
+
if (edge === "first") {
|
|
4641
|
+
textNode = walker.nextNode();
|
|
4642
|
+
} else {
|
|
4643
|
+
while (walker.nextNode()) textNode = walker.currentNode;
|
|
4393
4644
|
}
|
|
4394
|
-
}
|
|
4395
4645
|
|
|
4396
|
-
|
|
4397
|
-
this.#setProgress(1);
|
|
4398
|
-
}
|
|
4646
|
+
if (!textNode || textNode.length === 0) return null
|
|
4399
4647
|
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4648
|
+
const range = document.createRange();
|
|
4649
|
+
if (edge === "first") {
|
|
4650
|
+
range.setStart(textNode, 0);
|
|
4651
|
+
range.setEnd(textNode, 1);
|
|
4652
|
+
} else {
|
|
4653
|
+
range.setStart(textNode, textNode.length - 1);
|
|
4654
|
+
range.setEnd(textNode, textNode.length);
|
|
4407
4655
|
}
|
|
4408
|
-
}
|
|
4409
|
-
|
|
4410
|
-
#setProgress(progress) {
|
|
4411
|
-
this.patchAndRewriteHistory({ progress });
|
|
4412
|
-
}
|
|
4413
|
-
|
|
4414
|
-
#handleUploadError(error) {
|
|
4415
|
-
console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
|
|
4416
|
-
|
|
4417
|
-
this.patchAndRewriteHistory({ uploadError: true });
|
|
4418
|
-
}
|
|
4419
|
-
|
|
4420
|
-
$showUploadedAttachment(blob) {
|
|
4421
|
-
const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null;
|
|
4422
4656
|
|
|
4423
|
-
|
|
4424
|
-
this.replaceAndRewriteHistory(replacementNode);
|
|
4425
|
-
|
|
4426
|
-
return replacementNode.getKey()
|
|
4427
|
-
}
|
|
4428
|
-
|
|
4429
|
-
#toActionTextAttachmentNodeWith(blob, previewSrc) {
|
|
4430
|
-
const conversion = new AttachmentNodeConversion(this, blob, previewSrc);
|
|
4431
|
-
return conversion.toAttachmentNode()
|
|
4432
|
-
}
|
|
4433
|
-
|
|
4434
|
-
#dispatchEvent(name, detail) {
|
|
4435
|
-
const figure = this.editor.getElementByKey(this.getKey());
|
|
4436
|
-
if (figure) dispatch(figure, name, detail);
|
|
4657
|
+
return range.getBoundingClientRect()
|
|
4437
4658
|
}
|
|
4438
4659
|
}
|
|
4439
4660
|
|
|
4440
|
-
class
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
this.blob = blob;
|
|
4444
|
-
this.previewSrc = previewSrc;
|
|
4445
|
-
}
|
|
4446
|
-
|
|
4447
|
-
toAttachmentNode() {
|
|
4448
|
-
return new ActionTextAttachmentNode({
|
|
4449
|
-
...this.uploadNode,
|
|
4450
|
-
...this.#propertiesFromBlob,
|
|
4451
|
-
src: this.#src,
|
|
4452
|
-
previewSrc: this.previewSrc,
|
|
4453
|
-
pendingPreview: this.blob.previewable && !this.uploadNode.isPreviewableImage
|
|
4454
|
-
})
|
|
4455
|
-
}
|
|
4661
|
+
class EditorConfiguration {
|
|
4662
|
+
#editorElement
|
|
4663
|
+
#config
|
|
4456
4664
|
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
fileSize: blob.byte_size,
|
|
4465
|
-
previewable: blob.previewable,
|
|
4466
|
-
}
|
|
4665
|
+
constructor(editorElement) {
|
|
4666
|
+
this.#editorElement = editorElement;
|
|
4667
|
+
this.#config = new Configuration(
|
|
4668
|
+
Lexxy.presets.get("default"),
|
|
4669
|
+
Lexxy.presets.get(editorElement.preset),
|
|
4670
|
+
this.#overrides
|
|
4671
|
+
);
|
|
4467
4672
|
}
|
|
4468
4673
|
|
|
4469
|
-
get
|
|
4470
|
-
return this.
|
|
4674
|
+
get(path) {
|
|
4675
|
+
return this.#config.get(path)
|
|
4471
4676
|
}
|
|
4472
4677
|
|
|
4473
|
-
get #
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4678
|
+
get #overrides() {
|
|
4679
|
+
const overrides = {};
|
|
4680
|
+
for (const option of this.#defaultOptions) {
|
|
4681
|
+
const attribute = dasherize(option);
|
|
4682
|
+
if (this.#editorElement.hasAttribute(attribute)) {
|
|
4683
|
+
overrides[option] = this.#parseAttribute(attribute);
|
|
4684
|
+
}
|
|
4685
|
+
}
|
|
4686
|
+
return overrides
|
|
4477
4687
|
}
|
|
4478
|
-
}
|
|
4479
4688
|
|
|
4480
|
-
|
|
4481
|
-
|
|
4689
|
+
get #defaultOptions() {
|
|
4690
|
+
return Object.keys(Lexxy.presets.get("default"))
|
|
4691
|
+
}
|
|
4692
|
+
|
|
4693
|
+
#parseAttribute(attribute) {
|
|
4694
|
+
const value = this.#editorElement.getAttribute(attribute);
|
|
4695
|
+
try {
|
|
4696
|
+
return JSON.parse(value)
|
|
4697
|
+
} catch {
|
|
4698
|
+
return value
|
|
4699
|
+
}
|
|
4700
|
+
}
|
|
4482
4701
|
}
|
|
4483
4702
|
|
|
4484
4703
|
class ImageGalleryNode extends ElementNode {
|
|
@@ -4670,119 +4889,385 @@ class Uploader {
|
|
|
4670
4889
|
this.selection = editorElement.selection;
|
|
4671
4890
|
}
|
|
4672
4891
|
|
|
4673
|
-
get files() {
|
|
4674
|
-
return Array.from(this.#files)
|
|
4892
|
+
get files() {
|
|
4893
|
+
return Array.from(this.#files)
|
|
4894
|
+
}
|
|
4895
|
+
|
|
4896
|
+
$uploadFiles() {
|
|
4897
|
+
this.$createUploadNodes();
|
|
4898
|
+
this.$insertUploadNodes();
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
$createUploadNodes() {
|
|
4902
|
+
this.nodes = this.files.map(file => this.contents.$createUploadNode(file));
|
|
4903
|
+
}
|
|
4904
|
+
|
|
4905
|
+
$insertUploadNodes() {
|
|
4906
|
+
this.contents.insertAtCursor(...this.nodes);
|
|
4907
|
+
}
|
|
4908
|
+
}
|
|
4909
|
+
|
|
4910
|
+
class GalleryUploader extends Uploader {
|
|
4911
|
+
#gallery
|
|
4912
|
+
|
|
4913
|
+
static handle(editorElement, files) {
|
|
4914
|
+
return this.isMultipleImageUpload(files) || this.gallerySelection(editorElement.selection)
|
|
4915
|
+
}
|
|
4916
|
+
|
|
4917
|
+
static isMultipleImageUpload(files) {
|
|
4918
|
+
let imageFileCount = 0;
|
|
4919
|
+
for (const file of files) {
|
|
4920
|
+
if (isPreviewableImage(file.type)) imageFileCount++;
|
|
4921
|
+
if (imageFileCount > 1) return true
|
|
4922
|
+
}
|
|
4923
|
+
return false
|
|
4924
|
+
}
|
|
4925
|
+
|
|
4926
|
+
static gallerySelection(selection) {
|
|
4927
|
+
return selection.isOnPreviewableImage || this.selectionIsAfterGalleryEdge(selection)
|
|
4928
|
+
}
|
|
4929
|
+
|
|
4930
|
+
static selectionIsAfterGalleryEdge(selection) {
|
|
4931
|
+
return selection.isAtNodeStart && ImageGalleryNode.canCollapseWith(selection.nodeBeforeCursor)
|
|
4932
|
+
}
|
|
4933
|
+
|
|
4934
|
+
$insertUploadNodes() {
|
|
4935
|
+
this.#findOrCreateGallery();
|
|
4936
|
+
this.#insertImagesInGallery();
|
|
4937
|
+
this.#insertNonImagesAfterGallery();
|
|
4938
|
+
}
|
|
4939
|
+
|
|
4940
|
+
#findOrCreateGallery() {
|
|
4941
|
+
if (this.selection.isOnPreviewableImage) {
|
|
4942
|
+
this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode);
|
|
4943
|
+
} else if (this.#selectionIsAfterGalleryEdge) {
|
|
4944
|
+
this.#gallery = $findOrCreateGalleryForImage(this.selection.nodeBeforeCursor);
|
|
4945
|
+
} else {
|
|
4946
|
+
this.#gallery = $createImageGalleryNode();
|
|
4947
|
+
this.contents.insertAtCursor(this.#gallery);
|
|
4948
|
+
}
|
|
4949
|
+
}
|
|
4950
|
+
|
|
4951
|
+
get #selectionIsAfterGalleryEdge() {
|
|
4952
|
+
return this.constructor.selectionIsAfterGalleryEdge(this.selection)
|
|
4953
|
+
}
|
|
4954
|
+
|
|
4955
|
+
get #selectedNode() {
|
|
4956
|
+
const { node } = this.selection.selectedNodeWithOffset();
|
|
4957
|
+
return node
|
|
4958
|
+
}
|
|
4959
|
+
|
|
4960
|
+
get #galleryInsertPosition() {
|
|
4961
|
+
if (this.#selectionIsAfterGalleryEdge) return this.#gallery.getChildrenSize()
|
|
4962
|
+
|
|
4963
|
+
const anchor = $getSelection()?.anchor;
|
|
4964
|
+
const galleryHasElementSelection = anchor?.getNode().is(this.#gallery);
|
|
4965
|
+
if (galleryHasElementSelection) return anchor.offset
|
|
4966
|
+
|
|
4967
|
+
const selectedNode = this.#selectedNode;
|
|
4968
|
+
const childIndex = this.#gallery.isParentOf(selectedNode) && selectedNode.getIndexWithinParent();
|
|
4969
|
+
return childIndex !== false ? (childIndex + 1) : 0
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4972
|
+
get #imageNodes() {
|
|
4973
|
+
return this.nodes.filter(node => ImageGalleryNode.isValidChild(node))
|
|
4974
|
+
}
|
|
4975
|
+
|
|
4976
|
+
get #nonImageNodes() {
|
|
4977
|
+
return this.nodes.filter(node => !ImageGalleryNode.isValidChild(node))
|
|
4978
|
+
}
|
|
4979
|
+
|
|
4980
|
+
#insertImagesInGallery() {
|
|
4981
|
+
this.#gallery.splice(this.#galleryInsertPosition, 0, this.#imageNodes);
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4984
|
+
#insertNonImagesAfterGallery() {
|
|
4985
|
+
let beforeNode = this.#gallery;
|
|
4986
|
+
|
|
4987
|
+
for (const node of this.#nonImageNodes) {
|
|
4988
|
+
beforeNode.insertAfter(node);
|
|
4989
|
+
beforeNode = node;
|
|
4990
|
+
}
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4993
|
+
|
|
4994
|
+
async function loadFileIntoImage(file, image) {
|
|
4995
|
+
return new Promise((resolve) => {
|
|
4996
|
+
const reader = new FileReader();
|
|
4997
|
+
|
|
4998
|
+
image.addEventListener("load", () => {
|
|
4999
|
+
resolve(image);
|
|
5000
|
+
});
|
|
5001
|
+
|
|
5002
|
+
reader.onload = (event) => {
|
|
5003
|
+
image.src = event.target.result || null;
|
|
5004
|
+
};
|
|
5005
|
+
|
|
5006
|
+
reader.readAsDataURL(file);
|
|
5007
|
+
})
|
|
5008
|
+
}
|
|
5009
|
+
|
|
5010
|
+
class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
|
|
5011
|
+
static getType() {
|
|
5012
|
+
return "action_text_attachment_upload"
|
|
5013
|
+
}
|
|
5014
|
+
|
|
5015
|
+
static clone(node) {
|
|
5016
|
+
return new ActionTextAttachmentUploadNode({ ...node }, node.__key)
|
|
5017
|
+
}
|
|
5018
|
+
|
|
5019
|
+
static importJSON(serializedNode) {
|
|
5020
|
+
return new ActionTextAttachmentUploadNode({ ...serializedNode })
|
|
5021
|
+
}
|
|
5022
|
+
|
|
5023
|
+
// Should never run since this is a transient node. Defined to remove console warning.
|
|
5024
|
+
static importDOM() {
|
|
5025
|
+
return null
|
|
5026
|
+
}
|
|
5027
|
+
|
|
5028
|
+
constructor(node, key) {
|
|
5029
|
+
const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node;
|
|
5030
|
+
super({ ...node, contentType: file?.type ?? contentType }, key);
|
|
5031
|
+
this.file = file ?? null;
|
|
5032
|
+
this.fileName = file?.name ?? fileName;
|
|
5033
|
+
this.uploadUrl = uploadUrl;
|
|
5034
|
+
this.blobUrlTemplate = blobUrlTemplate;
|
|
5035
|
+
this.progress = progress ?? null;
|
|
5036
|
+
this.width = width;
|
|
5037
|
+
this.height = height;
|
|
5038
|
+
this.uploadError = uploadError;
|
|
5039
|
+
}
|
|
5040
|
+
|
|
5041
|
+
createDOM() {
|
|
5042
|
+
if (this.uploadError) return this.createDOMForError()
|
|
5043
|
+
|
|
5044
|
+
// This side-effect is trigged on DOM load to fire only once and avoid multiple
|
|
5045
|
+
// uploads through cloning. The upload is guarded from restarting in case the
|
|
5046
|
+
// node is reloaded from saved state such as from history.
|
|
5047
|
+
this.#startUploadIfNeeded();
|
|
5048
|
+
|
|
5049
|
+
// Bridge-managed uploads (uploadUrl is null) don't have file data to show
|
|
5050
|
+
// an image preview, so always show the file icon during upload.
|
|
5051
|
+
const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null;
|
|
5052
|
+
const figure = this.createAttachmentFigure(canPreviewFile);
|
|
5053
|
+
|
|
5054
|
+
if (canPreviewFile) {
|
|
5055
|
+
const img = figure.appendChild(this.#createDOMForImage());
|
|
5056
|
+
|
|
5057
|
+
// load file locally to set dimensions and prevent vertical shifting
|
|
5058
|
+
loadFileIntoImage(this.file, img).then(img => this.#setDimensionsFromImage(img));
|
|
5059
|
+
} else {
|
|
5060
|
+
figure.appendChild(this.#createDOMForFile());
|
|
5061
|
+
}
|
|
5062
|
+
|
|
5063
|
+
figure.appendChild(this.#createCaption());
|
|
5064
|
+
figure.appendChild(this.#createProgressBar());
|
|
5065
|
+
|
|
5066
|
+
return figure
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
updateDOM(prevNode, dom) {
|
|
5070
|
+
if (this.uploadError !== prevNode.uploadError) return true
|
|
5071
|
+
|
|
5072
|
+
if (prevNode.progress !== this.progress) {
|
|
5073
|
+
const progress = dom.querySelector("progress");
|
|
5074
|
+
progress.value = this.progress ?? 0;
|
|
5075
|
+
}
|
|
5076
|
+
|
|
5077
|
+
return false
|
|
5078
|
+
}
|
|
5079
|
+
|
|
5080
|
+
exportDOM() {
|
|
5081
|
+
return { element: null }
|
|
5082
|
+
}
|
|
5083
|
+
|
|
5084
|
+
exportJSON() {
|
|
5085
|
+
return {
|
|
5086
|
+
...super.exportJSON(),
|
|
5087
|
+
type: "action_text_attachment_upload",
|
|
5088
|
+
version: 1,
|
|
5089
|
+
fileName: this.fileName,
|
|
5090
|
+
contentType: this.contentType,
|
|
5091
|
+
uploadUrl: this.uploadUrl,
|
|
5092
|
+
blobUrlTemplate: this.blobUrlTemplate,
|
|
5093
|
+
progress: this.progress,
|
|
5094
|
+
width: this.width,
|
|
5095
|
+
height: this.height,
|
|
5096
|
+
uploadError: this.uploadError
|
|
5097
|
+
}
|
|
5098
|
+
}
|
|
5099
|
+
|
|
5100
|
+
get #uploadStarted() {
|
|
5101
|
+
return this.progress !== null
|
|
5102
|
+
}
|
|
5103
|
+
|
|
5104
|
+
#createDOMForImage() {
|
|
5105
|
+
return createElement("img")
|
|
5106
|
+
}
|
|
5107
|
+
|
|
5108
|
+
#createDOMForFile() {
|
|
5109
|
+
const extension = this.#getFileExtension();
|
|
5110
|
+
const span = createElement("span", { className: "attachment__icon", textContent: extension });
|
|
5111
|
+
return span
|
|
5112
|
+
}
|
|
5113
|
+
|
|
5114
|
+
#getFileExtension() {
|
|
5115
|
+
return (this.fileName || "").split(".").pop().toLowerCase()
|
|
4675
5116
|
}
|
|
4676
5117
|
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
this.$insertUploadNodes();
|
|
4680
|
-
}
|
|
5118
|
+
#createCaption() {
|
|
5119
|
+
const figcaption = createElement("figcaption", { className: "attachment__caption" });
|
|
4681
5120
|
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
file: file,
|
|
4687
|
-
contentType: file.type
|
|
4688
|
-
})
|
|
4689
|
-
);
|
|
4690
|
-
}
|
|
5121
|
+
const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.fileName || "" });
|
|
5122
|
+
const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file?.size) });
|
|
5123
|
+
figcaption.appendChild(nameSpan);
|
|
5124
|
+
figcaption.appendChild(sizeSpan);
|
|
4691
5125
|
|
|
4692
|
-
|
|
4693
|
-
this.contents.insertAtCursor(...this.nodes);
|
|
5126
|
+
return figcaption
|
|
4694
5127
|
}
|
|
4695
5128
|
|
|
4696
|
-
|
|
4697
|
-
return {
|
|
4698
|
-
uploadUrl: this.editorElement.directUploadUrl,
|
|
4699
|
-
blobUrlTemplate: this.editorElement.blobUrlTemplate
|
|
4700
|
-
}
|
|
5129
|
+
#createProgressBar() {
|
|
5130
|
+
return createElement("progress", { value: this.progress ?? 0, max: 100 })
|
|
4701
5131
|
}
|
|
4702
|
-
}
|
|
4703
5132
|
|
|
4704
|
-
|
|
4705
|
-
|
|
5133
|
+
#setDimensionsFromImage({ width, height }) {
|
|
5134
|
+
if (this.#hasDimensions) return
|
|
4706
5135
|
|
|
4707
|
-
|
|
4708
|
-
return this.isMultipleImageUpload(files) || this.gallerySelection(editorElement.selection)
|
|
5136
|
+
this.patchAndRewriteHistory({ width, height });
|
|
4709
5137
|
}
|
|
4710
5138
|
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
for (const file of files) {
|
|
4714
|
-
if (isPreviewableImage(file.type)) imageFileCount++;
|
|
4715
|
-
if (imageFileCount > 1) return true
|
|
4716
|
-
}
|
|
4717
|
-
return false
|
|
5139
|
+
get #hasDimensions() {
|
|
5140
|
+
return Boolean(this.width && this.height)
|
|
4718
5141
|
}
|
|
4719
5142
|
|
|
4720
|
-
|
|
4721
|
-
|
|
5143
|
+
async #startUploadIfNeeded() {
|
|
5144
|
+
if (this.#uploadStarted) return
|
|
5145
|
+
if (!this.uploadUrl) return // Bridge-managed upload — skip DirectUpload
|
|
5146
|
+
|
|
5147
|
+
this.#setUploadStarted();
|
|
5148
|
+
|
|
5149
|
+
const { DirectUpload } = await import('@rails/activestorage');
|
|
5150
|
+
|
|
5151
|
+
const upload = new DirectUpload(this.file, this.uploadUrl, this);
|
|
5152
|
+
upload.delegate = this.#createUploadDelegate();
|
|
5153
|
+
|
|
5154
|
+
this.#dispatchEvent("lexxy:upload-start", { file: this.file });
|
|
5155
|
+
|
|
5156
|
+
upload.create((error, blob) => {
|
|
5157
|
+
if (error) {
|
|
5158
|
+
this.#dispatchEvent("lexxy:upload-end", { file: this.file, error });
|
|
5159
|
+
this.#handleUploadError(error);
|
|
5160
|
+
} else {
|
|
5161
|
+
this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
|
|
5162
|
+
this.editor.update(() => {
|
|
5163
|
+
this.$showUploadedAttachment(blob);
|
|
5164
|
+
});
|
|
5165
|
+
}
|
|
5166
|
+
});
|
|
4722
5167
|
}
|
|
4723
5168
|
|
|
4724
|
-
|
|
4725
|
-
|
|
5169
|
+
#createUploadDelegate() {
|
|
5170
|
+
const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads");
|
|
5171
|
+
|
|
5172
|
+
return {
|
|
5173
|
+
directUploadWillCreateBlobWithXHR: (request) => {
|
|
5174
|
+
if (shouldAuthenticateUploads) request.withCredentials = true;
|
|
5175
|
+
},
|
|
5176
|
+
directUploadWillStoreFileWithXHR: (request) => {
|
|
5177
|
+
if (shouldAuthenticateUploads) request.withCredentials = true;
|
|
5178
|
+
|
|
5179
|
+
const uploadProgressHandler = (event) => this.#handleUploadProgress(event, request);
|
|
5180
|
+
request.upload.addEventListener("progress", uploadProgressHandler);
|
|
5181
|
+
}
|
|
5182
|
+
}
|
|
4726
5183
|
}
|
|
4727
5184
|
|
|
4728
|
-
|
|
4729
|
-
this.#
|
|
4730
|
-
this.#insertImagesInGallery();
|
|
4731
|
-
this.#insertNonImagesAfterGallery();
|
|
5185
|
+
#setUploadStarted() {
|
|
5186
|
+
this.#setProgress(1);
|
|
4732
5187
|
}
|
|
4733
5188
|
|
|
4734
|
-
#
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
this.#
|
|
4739
|
-
}
|
|
4740
|
-
|
|
4741
|
-
this.contents.insertAtCursor(this.#gallery);
|
|
5189
|
+
#handleUploadProgress(event, request) {
|
|
5190
|
+
const progress = Math.round(event.loaded / event.total * 100);
|
|
5191
|
+
try {
|
|
5192
|
+
this.#setProgress(progress);
|
|
5193
|
+
this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress });
|
|
5194
|
+
} catch {
|
|
5195
|
+
request.abort();
|
|
4742
5196
|
}
|
|
4743
5197
|
}
|
|
4744
5198
|
|
|
4745
|
-
|
|
4746
|
-
|
|
5199
|
+
#setProgress(progress) {
|
|
5200
|
+
this.patchAndRewriteHistory({ progress });
|
|
4747
5201
|
}
|
|
4748
5202
|
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
5203
|
+
#handleUploadError(error) {
|
|
5204
|
+
console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
|
|
5205
|
+
|
|
5206
|
+
this.patchAndRewriteHistory({ uploadError: true });
|
|
4752
5207
|
}
|
|
4753
5208
|
|
|
4754
|
-
|
|
4755
|
-
|
|
5209
|
+
$showUploadedAttachment(blob) {
|
|
5210
|
+
const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null;
|
|
4756
5211
|
|
|
4757
|
-
const
|
|
4758
|
-
|
|
4759
|
-
if (galleryHasElementSelection) return anchor.offset
|
|
5212
|
+
const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc);
|
|
5213
|
+
this.replaceAndRewriteHistory(replacementNode);
|
|
4760
5214
|
|
|
4761
|
-
|
|
4762
|
-
const childIndex = this.#gallery.isParentOf(selectedNode) && selectedNode.getIndexWithinParent();
|
|
4763
|
-
return childIndex !== false ? (childIndex + 1) : 0
|
|
5215
|
+
return replacementNode.getKey()
|
|
4764
5216
|
}
|
|
4765
5217
|
|
|
4766
|
-
|
|
4767
|
-
|
|
5218
|
+
#toActionTextAttachmentNodeWith(blob, previewSrc) {
|
|
5219
|
+
const conversion = new AttachmentNodeConversion(this, blob, previewSrc);
|
|
5220
|
+
return conversion.toAttachmentNode()
|
|
4768
5221
|
}
|
|
4769
5222
|
|
|
4770
|
-
|
|
4771
|
-
|
|
5223
|
+
#dispatchEvent(name, detail) {
|
|
5224
|
+
const figure = this.editor.getElementByKey(this.getKey());
|
|
5225
|
+
if (figure) dispatch(figure, name, detail);
|
|
4772
5226
|
}
|
|
5227
|
+
}
|
|
4773
5228
|
|
|
4774
|
-
|
|
4775
|
-
|
|
5229
|
+
class AttachmentNodeConversion {
|
|
5230
|
+
constructor(uploadNode, blob, previewSrc) {
|
|
5231
|
+
this.uploadNode = uploadNode;
|
|
5232
|
+
this.blob = blob;
|
|
5233
|
+
this.previewSrc = previewSrc;
|
|
4776
5234
|
}
|
|
4777
5235
|
|
|
4778
|
-
|
|
4779
|
-
|
|
5236
|
+
toAttachmentNode() {
|
|
5237
|
+
return new ActionTextAttachmentNode({
|
|
5238
|
+
...this.uploadNode,
|
|
5239
|
+
...this.#propertiesFromBlob,
|
|
5240
|
+
src: this.#src,
|
|
5241
|
+
previewSrc: this.previewSrc,
|
|
5242
|
+
pendingPreview: this.blob.previewable && !this.uploadNode.isPreviewableImage
|
|
5243
|
+
})
|
|
5244
|
+
}
|
|
4780
5245
|
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
5246
|
+
get #propertiesFromBlob() {
|
|
5247
|
+
const { blob } = this;
|
|
5248
|
+
return {
|
|
5249
|
+
sgid: blob.attachable_sgid,
|
|
5250
|
+
altText: blob.filename,
|
|
5251
|
+
contentType: blob.content_type,
|
|
5252
|
+
fileName: blob.filename,
|
|
5253
|
+
fileSize: blob.byte_size,
|
|
5254
|
+
previewable: blob.previewable,
|
|
4784
5255
|
}
|
|
4785
5256
|
}
|
|
5257
|
+
|
|
5258
|
+
get #src() {
|
|
5259
|
+
return this.blob.previewable ? this.blob.url : this.#blobSrc
|
|
5260
|
+
}
|
|
5261
|
+
|
|
5262
|
+
get #blobSrc() {
|
|
5263
|
+
return this.uploadNode.blobUrlTemplate
|
|
5264
|
+
.replace(":signed_id", this.blob.signed_id)
|
|
5265
|
+
.replace(":filename", encodeURIComponent(this.blob.filename))
|
|
5266
|
+
}
|
|
5267
|
+
}
|
|
5268
|
+
|
|
5269
|
+
function $createActionTextAttachmentUploadNode(...args) {
|
|
5270
|
+
return new ActionTextAttachmentUploadNode(...args)
|
|
4786
5271
|
}
|
|
4787
5272
|
|
|
4788
5273
|
class NodeInserter {
|
|
@@ -4891,7 +5376,7 @@ class Contents {
|
|
|
4891
5376
|
this.editor.update(() => {
|
|
4892
5377
|
if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
|
|
4893
5378
|
|
|
4894
|
-
const nodes =
|
|
5379
|
+
const nodes = this.editorElement.$generateNodesFromDOM(doc);
|
|
4895
5380
|
if (!this.#insertUploadNodes(nodes)) {
|
|
4896
5381
|
this.insertAtCursor(...nodes);
|
|
4897
5382
|
}
|
|
@@ -4914,6 +5399,7 @@ class Contents {
|
|
|
4914
5399
|
const selection = $getSelection();
|
|
4915
5400
|
if (!$isRangeSelection(selection)) return
|
|
4916
5401
|
|
|
5402
|
+
$expandSelectionToLineBreaksAndSplitAtEdges(selection);
|
|
4917
5403
|
$setBlocksType(selection, () => $createParagraphNode());
|
|
4918
5404
|
}
|
|
4919
5405
|
|
|
@@ -4921,6 +5407,7 @@ class Contents {
|
|
|
4921
5407
|
const selection = $getSelection();
|
|
4922
5408
|
if (!$isRangeSelection(selection)) return
|
|
4923
5409
|
|
|
5410
|
+
$expandSelectionToLineBreaksAndSplitAtEdges(selection);
|
|
4924
5411
|
$setBlocksType(selection, () => $createHeadingNode(tag));
|
|
4925
5412
|
}
|
|
4926
5413
|
|
|
@@ -4962,10 +5449,14 @@ class Contents {
|
|
|
4962
5449
|
if (allCode) {
|
|
4963
5450
|
blockElements.forEach(node => this.#unwrapCodeBlock(node));
|
|
4964
5451
|
} else {
|
|
5452
|
+
$expandSelectionToLineBreaksAndSplitAtEdges(selection);
|
|
5453
|
+
const elements = this.#blockLevelElementsInSelection(selection);
|
|
5454
|
+
if (elements.length === 0) return
|
|
5455
|
+
|
|
4965
5456
|
const codeNode = $createCodeNode("plain");
|
|
4966
|
-
|
|
5457
|
+
elements.at(-1).insertAfter(codeNode);
|
|
4967
5458
|
codeNode.selectEnd();
|
|
4968
|
-
this.insertAtCursor(...
|
|
5459
|
+
this.insertAtCursor(...elements);
|
|
4969
5460
|
}
|
|
4970
5461
|
}
|
|
4971
5462
|
|
|
@@ -4984,8 +5475,7 @@ class Contents {
|
|
|
4984
5475
|
} else {
|
|
4985
5476
|
topLevelElements.filter($isQuoteNode).forEach(node => this.#unwrap(node));
|
|
4986
5477
|
|
|
4987
|
-
|
|
4988
|
-
|
|
5478
|
+
$expandSelectionToLineBreaksAndSplitAtEdges(selection);
|
|
4989
5479
|
const elements = this.#topLevelElementsInSelection(selection);
|
|
4990
5480
|
if (elements.length === 0) return
|
|
4991
5481
|
|
|
@@ -5100,7 +5590,7 @@ class Contents {
|
|
|
5100
5590
|
console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
|
|
5101
5591
|
return
|
|
5102
5592
|
}
|
|
5103
|
-
const validFiles = Array.from(files).filter(this
|
|
5593
|
+
const validFiles = Array.from(files).filter(file => this.editorElement.acceptsFile(file));
|
|
5104
5594
|
|
|
5105
5595
|
this.editor.update(() => {
|
|
5106
5596
|
const uploader = Uploader.for(this.editorElement, validFiles);
|
|
@@ -5114,6 +5604,15 @@ class Contents {
|
|
|
5114
5604
|
});
|
|
5115
5605
|
}
|
|
5116
5606
|
|
|
5607
|
+
$createUploadNode(file) {
|
|
5608
|
+
return $createActionTextAttachmentUploadNode({
|
|
5609
|
+
file,
|
|
5610
|
+
uploadUrl: this.editorElement.directUploadUrl,
|
|
5611
|
+
blobUrlTemplate: this.editorElement.blobUrlTemplate,
|
|
5612
|
+
contentType: file.type,
|
|
5613
|
+
})
|
|
5614
|
+
}
|
|
5615
|
+
|
|
5117
5616
|
insertPendingAttachment(file) {
|
|
5118
5617
|
if (!this.editorElement.supportsAttachments) return null
|
|
5119
5618
|
|
|
@@ -5244,45 +5743,8 @@ class Contents {
|
|
|
5244
5743
|
const selection = $getSelection();
|
|
5245
5744
|
if (!$isRangeSelection(selection)) return
|
|
5246
5745
|
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
#splitParagraphsAtLineBreaks(selection) {
|
|
5251
|
-
const anchorTopLevel = selection.anchor.getNode().getTopLevelElement();
|
|
5252
|
-
const focusTopLevel = selection.focus.getNode().getTopLevelElement();
|
|
5253
|
-
const topLevelElements = this.#topLevelElementsInSelection(selection);
|
|
5254
|
-
|
|
5255
|
-
for (const element of topLevelElements) {
|
|
5256
|
-
if (!$isParagraphNode(element)) continue
|
|
5257
|
-
|
|
5258
|
-
const children = element.getChildren();
|
|
5259
|
-
if (!children.some($isLineBreakNode)) continue
|
|
5260
|
-
|
|
5261
|
-
// Check whether this paragraph needs splitting: skip only if neither
|
|
5262
|
-
// selection endpoint is inside it (meaning it's a middle paragraph
|
|
5263
|
-
// fully between anchor and focus with no partial lines to split off).
|
|
5264
|
-
// Compare top-level elements so endpoints inside nested inline nodes
|
|
5265
|
-
// (e.g. text inside a LinkNode) are still recognized.
|
|
5266
|
-
if (element !== anchorTopLevel && element !== focusTopLevel) continue
|
|
5267
|
-
|
|
5268
|
-
const groups = [ [] ];
|
|
5269
|
-
for (const child of children) {
|
|
5270
|
-
if ($isLineBreakNode(child)) {
|
|
5271
|
-
groups.push([]);
|
|
5272
|
-
child.remove();
|
|
5273
|
-
} else {
|
|
5274
|
-
groups[groups.length - 1].push(child);
|
|
5275
|
-
}
|
|
5276
|
-
}
|
|
5277
|
-
|
|
5278
|
-
for (const group of groups) {
|
|
5279
|
-
if (group.length === 0) continue
|
|
5280
|
-
const paragraph = $createParagraphNode();
|
|
5281
|
-
group.forEach(child => paragraph.append(child));
|
|
5282
|
-
element.insertBefore(paragraph);
|
|
5283
|
-
}
|
|
5284
|
-
if (groups.some(group => group.length > 0)) element.remove();
|
|
5285
|
-
}
|
|
5746
|
+
$expandSelectionToLineBreaksAndSplitAtEdges(selection);
|
|
5747
|
+
$splitSelectedParagraphsAtInnerLineBreaks(selection);
|
|
5286
5748
|
}
|
|
5287
5749
|
|
|
5288
5750
|
#blockLevelElementsInSelection(selection) {
|
|
@@ -5452,14 +5914,10 @@ class Contents {
|
|
|
5452
5914
|
}
|
|
5453
5915
|
|
|
5454
5916
|
#createHtmlNodeWith(html) {
|
|
5455
|
-
const htmlNodes =
|
|
5917
|
+
const htmlNodes = this.editorElement.$generateNodesFromDOM(parseHtml(html));
|
|
5456
5918
|
return htmlNodes[0] || $createParagraphNode()
|
|
5457
5919
|
}
|
|
5458
5920
|
|
|
5459
|
-
#shouldUploadFile(file) {
|
|
5460
|
-
return dispatch(this.editorElement, "lexxy:file-accept", { file }, true)
|
|
5461
|
-
}
|
|
5462
|
-
|
|
5463
5921
|
// When the selection anchor is on a shadow root (e.g. a table cell), Lexical's
|
|
5464
5922
|
// insertNodes can't find a block parent and fails silently. Normalize the
|
|
5465
5923
|
// selection to point inside the shadow root's content instead.
|
|
@@ -5551,8 +6009,12 @@ class Clipboard {
|
|
|
5551
6009
|
|
|
5552
6010
|
#isOnlyURLPasted(clipboardData) {
|
|
5553
6011
|
// Safari URLs are copied as a text/plain + text/uri-list object
|
|
6012
|
+
// App ShareSheet URLs are copied as solo text/uri-list object
|
|
5554
6013
|
const types = Array.from(clipboardData.types);
|
|
5555
|
-
return types.length
|
|
6014
|
+
return types.length
|
|
6015
|
+
&& types.length <= 2
|
|
6016
|
+
&& types.includes("text/uri-list")
|
|
6017
|
+
&& (types.length < 2 || types.includes("text/plain"))
|
|
5556
6018
|
}
|
|
5557
6019
|
|
|
5558
6020
|
#isPastingIntoCodeBlock() {
|
|
@@ -5589,9 +6051,9 @@ class Clipboard {
|
|
|
5589
6051
|
#pastePlainText(clipboardData) {
|
|
5590
6052
|
const item = clipboardData.items[0];
|
|
5591
6053
|
item.getAsString((text) => {
|
|
5592
|
-
if (
|
|
6054
|
+
if (isAutolinkableURL(text) && this.contents.hasSelectedText()) {
|
|
5593
6055
|
this.contents.createLinkWithSelectedText(text);
|
|
5594
|
-
} else if (
|
|
6056
|
+
} else if (isAutolinkableURL(text)) {
|
|
5595
6057
|
const nodeKey = this.contents.createLink(text);
|
|
5596
6058
|
this.#dispatchLinkInsertEvent(nodeKey, { url: text });
|
|
5597
6059
|
} else if (this.editorElement.supportsMarkdown) {
|
|
@@ -5751,6 +6213,7 @@ class Extensions {
|
|
|
5751
6213
|
|
|
5752
6214
|
this.#clearPreviousExtensionToolbarButtons(toolbar);
|
|
5753
6215
|
this.#addExtensionToolbarButtons(toolbar);
|
|
6216
|
+
toolbar.requestOverflowRefresh();
|
|
5754
6217
|
}
|
|
5755
6218
|
|
|
5756
6219
|
dispose() {
|
|
@@ -6609,7 +7072,11 @@ class AttachmentDragAndDrop {
|
|
|
6609
7072
|
const ATTACHMENT_ATTRIBUTES = [ "alt", "caption", "content", "content-type", "data-direct-upload-id",
|
|
6610
7073
|
"data-sgid", "filename", "filesize", "height", "presentation", "previewable", "sgid", "url", "width" ];
|
|
6611
7074
|
|
|
7075
|
+
const UPLOADS_BUSY_MESSAGE = "Please wait for all files to upload";
|
|
7076
|
+
|
|
6612
7077
|
class AttachmentsExtension extends LexxyExtension {
|
|
7078
|
+
#uploadsCount = 0
|
|
7079
|
+
|
|
6613
7080
|
get enabled() {
|
|
6614
7081
|
return this.editorElement.supportsAttachments
|
|
6615
7082
|
}
|
|
@@ -6626,17 +7093,41 @@ class AttachmentsExtension extends LexxyExtension {
|
|
|
6626
7093
|
ActionTextAttachmentUploadNode,
|
|
6627
7094
|
ImageGalleryNode
|
|
6628
7095
|
],
|
|
6629
|
-
register(editor) {
|
|
7096
|
+
register: (editor) => {
|
|
6630
7097
|
const dragAndDrop = new AttachmentDragAndDrop(editor);
|
|
6631
7098
|
|
|
6632
7099
|
return mergeRegister(
|
|
6633
7100
|
editor.registerNodeTransform(ActionTextAttachmentNode, $extractAttachmentFromParagraph),
|
|
6634
7101
|
editor.registerCommand(DELETE_CHARACTER_COMMAND, $collapseIntoGallery, COMMAND_PRIORITY_NORMAL),
|
|
7102
|
+
editor.registerMutationListener(ActionTextAttachmentUploadNode, this.#handleUploadMutations.bind(this)),
|
|
6635
7103
|
() => dragAndDrop.destroy()
|
|
6636
7104
|
)
|
|
6637
7105
|
}
|
|
6638
7106
|
})
|
|
6639
7107
|
}
|
|
7108
|
+
|
|
7109
|
+
#handleUploadMutations(mutations) {
|
|
7110
|
+
const previousUploadsCount = this.#uploadsCount;
|
|
7111
|
+
for (const [ , mutation ] of mutations) {
|
|
7112
|
+
if (mutation === "created") {
|
|
7113
|
+
this.#uploadsCount++;
|
|
7114
|
+
} else if (mutation === "destroyed") {
|
|
7115
|
+
this.#uploadsCount--;
|
|
7116
|
+
}
|
|
7117
|
+
}
|
|
7118
|
+
|
|
7119
|
+
if (this.#uploadsCount !== previousUploadsCount) {
|
|
7120
|
+
this.#setUploadsValidity();
|
|
7121
|
+
}
|
|
7122
|
+
}
|
|
7123
|
+
|
|
7124
|
+
#setUploadsValidity() {
|
|
7125
|
+
if (this.#uploadsCount) {
|
|
7126
|
+
this.setEditorValidity({ customError: true }, UPLOADS_BUSY_MESSAGE);
|
|
7127
|
+
} else {
|
|
7128
|
+
this.setEditorValidity({});
|
|
7129
|
+
}
|
|
7130
|
+
}
|
|
6640
7131
|
}
|
|
6641
7132
|
|
|
6642
7133
|
// Decorator nodes can be wrapped in a Paragraph Node by Lexical when contained in a <div>
|
|
@@ -7004,6 +7495,38 @@ function $openLink(target) {
|
|
|
7004
7495
|
}
|
|
7005
7496
|
}
|
|
7006
7497
|
|
|
7498
|
+
class PreventLexicalTripleClickExtension extends LexxyExtension {
|
|
7499
|
+
get lexicalExtension() {
|
|
7500
|
+
return defineExtension({
|
|
7501
|
+
name: "lexxy/prevent-lexical-triple-click",
|
|
7502
|
+
register: (editor) => editor.registerRootListener((rootElement) => {
|
|
7503
|
+
if (rootElement) {
|
|
7504
|
+
return registerEventListener(
|
|
7505
|
+
rootElement,
|
|
7506
|
+
"click",
|
|
7507
|
+
this.#handleTripleClick.bind(this),
|
|
7508
|
+
{ capture: true }
|
|
7509
|
+
)
|
|
7510
|
+
}
|
|
7511
|
+
})
|
|
7512
|
+
})
|
|
7513
|
+
}
|
|
7514
|
+
|
|
7515
|
+
// Stop propagation of the triple-click to prevent Lexical's handler from running.
|
|
7516
|
+
//
|
|
7517
|
+
// Lexical's onClick handler implements a triple-click handler that is trivial/anemic/naïve. The
|
|
7518
|
+
// intention of the change, made in facebook/lexical#4512, seems to be to deal with browsers'
|
|
7519
|
+
// "overselection" behavior, where a triple-click selection might end at offset 0 of the following
|
|
7520
|
+
// block, which can cause issues when transforming the selection. But the implementation breaks
|
|
7521
|
+
// many common real-world use cases and Lexxy does not demonstrate the behavior it's intended to
|
|
7522
|
+
// work around (in headers or tables).
|
|
7523
|
+
#handleTripleClick(event) {
|
|
7524
|
+
if (event.detail === 3) {
|
|
7525
|
+
event.stopPropagation();
|
|
7526
|
+
}
|
|
7527
|
+
}
|
|
7528
|
+
}
|
|
7529
|
+
|
|
7007
7530
|
class LexicalEditorElement extends HTMLElement {
|
|
7008
7531
|
static formAssociated = true
|
|
7009
7532
|
static debug = false
|
|
@@ -7012,12 +7535,16 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7012
7535
|
static observedAttributes = [ "connected", "required" ]
|
|
7013
7536
|
|
|
7014
7537
|
#initialValue = ""
|
|
7015
|
-
#
|
|
7016
|
-
#
|
|
7538
|
+
#initializeEventDispatched = false
|
|
7539
|
+
#editorInitializedDispatched = false
|
|
7540
|
+
#valueLoaded = false
|
|
7017
7541
|
#listeners = new ListenerBin()
|
|
7018
7542
|
#disposables = []
|
|
7019
7543
|
#historyState = { undo: false, redo: false }
|
|
7020
7544
|
|
|
7545
|
+
#validity = new Map()
|
|
7546
|
+
#validationTextArea = document.createElement("textarea")
|
|
7547
|
+
|
|
7021
7548
|
constructor() {
|
|
7022
7549
|
super();
|
|
7023
7550
|
this.internals = this.attachInternals();
|
|
@@ -7050,29 +7577,40 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7050
7577
|
|
|
7051
7578
|
this.#initialize();
|
|
7052
7579
|
|
|
7053
|
-
this.#scheduleEditorInitializedDispatch();
|
|
7054
7580
|
this.toggleAttribute("connected", true);
|
|
7055
7581
|
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7582
|
+
requestAnimationFrame(() => {
|
|
7583
|
+
this.#mountRoot();
|
|
7584
|
+
this.#handleAutofocus();
|
|
7585
|
+
this.#dispatchInitialize();
|
|
7586
|
+
});
|
|
7059
7587
|
}
|
|
7060
7588
|
|
|
7061
7589
|
disconnectedCallback() {
|
|
7062
|
-
this.#
|
|
7063
|
-
this
|
|
7590
|
+
this.#initializeEventDispatched = false;
|
|
7591
|
+
this.#editorInitializedDispatched = false;
|
|
7592
|
+
if (this.#valueLoaded) {
|
|
7593
|
+
this.valueBeforeDisconnect = this.value;
|
|
7594
|
+
} else {
|
|
7595
|
+
this.valueBeforeDisconnect = null;
|
|
7596
|
+
}
|
|
7597
|
+
this.#valueLoaded = false;
|
|
7064
7598
|
this.#reset(); // Prevent hangs with Safari when morphing
|
|
7065
7599
|
}
|
|
7066
7600
|
|
|
7067
7601
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
7068
|
-
if (name === "connected"
|
|
7602
|
+
if (name === "connected") this.connectedChangedCallback(oldValue, newValue);
|
|
7603
|
+
if (name === "required") this.requiredChangedCallback(oldValue, newValue);
|
|
7604
|
+
}
|
|
7605
|
+
|
|
7606
|
+
connectedChangedCallback(oldValue, newValue) {
|
|
7607
|
+
if (this.isConnected && oldValue != null && oldValue !== newValue) {
|
|
7069
7608
|
requestAnimationFrame(() => this.#reconnect());
|
|
7070
7609
|
}
|
|
7610
|
+
}
|
|
7071
7611
|
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
this.#setValidity();
|
|
7075
|
-
}
|
|
7612
|
+
requiredChangedCallback() {
|
|
7613
|
+
if (this.isConnected) this.#requestValidityRefresh();
|
|
7076
7614
|
}
|
|
7077
7615
|
|
|
7078
7616
|
formResetCallback() {
|
|
@@ -7098,6 +7636,27 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7098
7636
|
return this.getAttribute("name")
|
|
7099
7637
|
}
|
|
7100
7638
|
|
|
7639
|
+
get required() {
|
|
7640
|
+
return this.hasAttribute("required")
|
|
7641
|
+
}
|
|
7642
|
+
|
|
7643
|
+
get validity() {
|
|
7644
|
+
return this.internals.validity
|
|
7645
|
+
}
|
|
7646
|
+
|
|
7647
|
+
checkValidity() {
|
|
7648
|
+
return this.internals.checkValidity()
|
|
7649
|
+
}
|
|
7650
|
+
|
|
7651
|
+
reportValidity() {
|
|
7652
|
+
return this.internals.reportValidity()
|
|
7653
|
+
}
|
|
7654
|
+
|
|
7655
|
+
setElementValidity(key, flags, message) {
|
|
7656
|
+
this.#validity.set(key, { flags, message });
|
|
7657
|
+
this.#requestValidityRefresh();
|
|
7658
|
+
}
|
|
7659
|
+
|
|
7101
7660
|
get toolbarElement() {
|
|
7102
7661
|
if (!this.#hasToolbar) return null
|
|
7103
7662
|
|
|
@@ -7114,7 +7673,8 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7114
7673
|
RewritableHistoryExtension,
|
|
7115
7674
|
AttachmentsExtension,
|
|
7116
7675
|
FormatEscapeExtension,
|
|
7117
|
-
LinkOpenerExtension
|
|
7676
|
+
LinkOpenerExtension,
|
|
7677
|
+
PreventLexicalTripleClickExtension
|
|
7118
7678
|
]
|
|
7119
7679
|
}
|
|
7120
7680
|
|
|
@@ -7145,6 +7705,16 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7145
7705
|
}
|
|
7146
7706
|
}
|
|
7147
7707
|
|
|
7708
|
+
acceptsFile(file) {
|
|
7709
|
+
return dispatch(this, "lexxy:file-accept", { file }, true)
|
|
7710
|
+
}
|
|
7711
|
+
|
|
7712
|
+
$generateNodesFromDOM(doc) {
|
|
7713
|
+
let nodes = $generateNodesFromDOM(this.editor, doc);
|
|
7714
|
+
if ($hasUpdateTag(PASTE_TAG)) nodes = $convertInlineImageDataURIs(nodes, this);
|
|
7715
|
+
return filterDisallowedAttachmentNodes(nodes, this)
|
|
7716
|
+
}
|
|
7717
|
+
|
|
7148
7718
|
get isEmpty() {
|
|
7149
7719
|
return [ "<p><br></p>", "<p></p>", "" ].includes(this.value.trim())
|
|
7150
7720
|
}
|
|
@@ -7182,7 +7752,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7182
7752
|
|
|
7183
7753
|
if (!this.editor) return
|
|
7184
7754
|
|
|
7185
|
-
this.#
|
|
7755
|
+
this.#editorInitializedDispatched = true;
|
|
7186
7756
|
this.#dispatchEditorInitialized();
|
|
7187
7757
|
this.#dispatchAttributesChange();
|
|
7188
7758
|
}
|
|
@@ -7237,6 +7807,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7237
7807
|
}
|
|
7238
7808
|
|
|
7239
7809
|
set value(html) {
|
|
7810
|
+
this.#valueLoaded = true;
|
|
7240
7811
|
const editorHasFocus = this.#isContentFocused;
|
|
7241
7812
|
|
|
7242
7813
|
this.editor.update(() => {
|
|
@@ -7267,7 +7838,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7267
7838
|
|
|
7268
7839
|
#parseHtmlIntoLexicalNodes(html) {
|
|
7269
7840
|
if (!html) html = "<p></p>";
|
|
7270
|
-
const nodes =
|
|
7841
|
+
const nodes = this.$generateNodesFromDOM(parseHtml(`${html}`));
|
|
7271
7842
|
|
|
7272
7843
|
return nodes
|
|
7273
7844
|
.filter(this.#isNotWhitespaceOnlyNode)
|
|
@@ -7333,11 +7904,17 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7333
7904
|
...this.extensions.lexicalExtensions
|
|
7334
7905
|
);
|
|
7335
7906
|
|
|
7336
|
-
editor.setRootElement(this.editorContentElement);
|
|
7337
|
-
|
|
7338
7907
|
return editor
|
|
7339
7908
|
}
|
|
7340
7909
|
|
|
7910
|
+
// Toggling editable around setRootElement skips Lexical's DOM-selection sync,
|
|
7911
|
+
// which would otherwise steal focus from elsewhere on the page.
|
|
7912
|
+
#mountRoot() {
|
|
7913
|
+
this.editor.setEditable(false);
|
|
7914
|
+
this.editor.setRootElement(this.editorContentElement);
|
|
7915
|
+
this.editor.setEditable(true);
|
|
7916
|
+
}
|
|
7917
|
+
|
|
7341
7918
|
get #lexicalNodes() {
|
|
7342
7919
|
const nodes = [ CustomActionTextAttachmentNode ];
|
|
7343
7920
|
|
|
@@ -7394,7 +7971,6 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7394
7971
|
|
|
7395
7972
|
this.internals.setFormValue(html);
|
|
7396
7973
|
this._internalFormValue = html;
|
|
7397
|
-
this.#validationTextArea.value = this.isEmpty ? "" : html;
|
|
7398
7974
|
|
|
7399
7975
|
if (changed) {
|
|
7400
7976
|
dispatch(this, "lexxy:change");
|
|
@@ -7406,10 +7982,12 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7406
7982
|
}
|
|
7407
7983
|
|
|
7408
7984
|
#loadInitialValue() {
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
this.
|
|
7412
|
-
|
|
7985
|
+
if (!this.#valueLoaded) {
|
|
7986
|
+
const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p><br></p>";
|
|
7987
|
+
this.editor.update(() => {
|
|
7988
|
+
this.value = this.#initialValue = initialHtml;
|
|
7989
|
+
}, { tag: HISTORY_MERGE_TAG });
|
|
7990
|
+
}
|
|
7413
7991
|
}
|
|
7414
7992
|
|
|
7415
7993
|
#resetBeforeTurboCaches() {
|
|
@@ -7429,11 +8007,50 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7429
8007
|
this.#clearCachedValues();
|
|
7430
8008
|
this.#internalFormValue = this.value;
|
|
7431
8009
|
this.#toggleEmptyStatus();
|
|
7432
|
-
this.#
|
|
8010
|
+
this.#requestValidityRefresh();
|
|
7433
8011
|
this.#dispatchAttributesChange();
|
|
7434
8012
|
}));
|
|
7435
8013
|
}
|
|
7436
8014
|
|
|
8015
|
+
async #requestValidityRefresh() {
|
|
8016
|
+
await nextFrame();
|
|
8017
|
+
|
|
8018
|
+
if (this.isConnected) this.#refreshValidity();
|
|
8019
|
+
}
|
|
8020
|
+
|
|
8021
|
+
#refreshValidity() {
|
|
8022
|
+
this.#refreshInternalValidity();
|
|
8023
|
+
const { validity, message } = this.#calculateValidity();
|
|
8024
|
+
this.internals.setValidity(validity, message, this.editorContentElement);
|
|
8025
|
+
}
|
|
8026
|
+
|
|
8027
|
+
#refreshInternalValidity() {
|
|
8028
|
+
this.#validationTextArea.required = this.required && this.isBlank;
|
|
8029
|
+
const flags = this.#validationTextArea.validity;
|
|
8030
|
+
const message = this.#validationTextArea.validationMessage;
|
|
8031
|
+
|
|
8032
|
+
this.#validity.set(this, { flags, message });
|
|
8033
|
+
}
|
|
8034
|
+
|
|
8035
|
+
#calculateValidity() {
|
|
8036
|
+
const validity = {};
|
|
8037
|
+
const messages = [];
|
|
8038
|
+
|
|
8039
|
+
for (const { flags, message } of this.#validity.values()) {
|
|
8040
|
+
// internal TextArea's ValidityState can contain `valid: true`
|
|
8041
|
+
if (flags.valid === true) continue
|
|
8042
|
+
|
|
8043
|
+
for (const flag in flags) {
|
|
8044
|
+
if (flags[flag]) {
|
|
8045
|
+
validity[flag] = true;
|
|
8046
|
+
messages.push(message);
|
|
8047
|
+
}
|
|
8048
|
+
}
|
|
8049
|
+
}
|
|
8050
|
+
|
|
8051
|
+
return { validity, message: messages.join("\n") }
|
|
8052
|
+
}
|
|
8053
|
+
|
|
7437
8054
|
#clearCachedValues() {
|
|
7438
8055
|
this.cachedValue = null;
|
|
7439
8056
|
this.cachedStringValue = null;
|
|
@@ -7589,14 +8206,6 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7589
8206
|
this.classList.toggle("lexxy-editor--empty", this.isEmpty);
|
|
7590
8207
|
}
|
|
7591
8208
|
|
|
7592
|
-
#setValidity() {
|
|
7593
|
-
if (this.#validationTextArea.validity.valid) {
|
|
7594
|
-
this.internals.setValidity({});
|
|
7595
|
-
} else {
|
|
7596
|
-
this.internals.setValidity(this.#validationTextArea.validity, this.#validationTextArea.validationMessage, this.editorContentElement);
|
|
7597
|
-
}
|
|
7598
|
-
}
|
|
7599
|
-
|
|
7600
8209
|
#configureSanitizer() {
|
|
7601
8210
|
setSanitizerConfig(this.#allowedElements);
|
|
7602
8211
|
}
|
|
@@ -7660,22 +8269,18 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7660
8269
|
});
|
|
7661
8270
|
}
|
|
7662
8271
|
|
|
7663
|
-
#
|
|
7664
|
-
this
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
dispatch(this, "lexxy:initialize");
|
|
7670
|
-
this.#dispatchEditorInitialized();
|
|
7671
|
-
});
|
|
7672
|
-
}
|
|
7673
|
-
|
|
7674
|
-
#cancelEditorInitializedDispatch() {
|
|
7675
|
-
if (this.#editorInitializedRafId == null) return
|
|
8272
|
+
#dispatchInitialize() {
|
|
8273
|
+
if (this.isConnected && this.adapter) {
|
|
8274
|
+
if (!this.#initializeEventDispatched) {
|
|
8275
|
+
this.#initializeEventDispatched = true;
|
|
8276
|
+
dispatch(this, "lexxy:initialize");
|
|
8277
|
+
}
|
|
7676
8278
|
|
|
7677
|
-
|
|
7678
|
-
|
|
8279
|
+
if (!this.#editorInitializedDispatched) {
|
|
8280
|
+
this.#editorInitializedDispatched = true;
|
|
8281
|
+
this.#dispatchEditorInitialized();
|
|
8282
|
+
}
|
|
8283
|
+
}
|
|
7679
8284
|
}
|
|
7680
8285
|
|
|
7681
8286
|
get #resolvedHighlightColors() {
|
|
@@ -7726,8 +8331,8 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7726
8331
|
}
|
|
7727
8332
|
|
|
7728
8333
|
#reset() {
|
|
7729
|
-
this.#cancelEditorInitializedDispatch();
|
|
7730
8334
|
this.#dispose();
|
|
8335
|
+
this.#resetValidity();
|
|
7731
8336
|
this.editorContentElement?.remove();
|
|
7732
8337
|
this.editorContentElement = null;
|
|
7733
8338
|
|
|
@@ -7747,6 +8352,10 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7747
8352
|
this.valueBeforeDisconnect = null;
|
|
7748
8353
|
this.connectedCallback();
|
|
7749
8354
|
}
|
|
8355
|
+
|
|
8356
|
+
#resetValidity() {
|
|
8357
|
+
this.#validity = new Map();
|
|
8358
|
+
}
|
|
7750
8359
|
}
|
|
7751
8360
|
|
|
7752
8361
|
// Like $getRoot().getTextContent() but uses readable text for custom attachment nodes
|
|
@@ -7776,256 +8385,6 @@ function $getReadableTextContent(node) {
|
|
|
7776
8385
|
return node.getTextContent()
|
|
7777
8386
|
}
|
|
7778
8387
|
|
|
7779
|
-
class ToolbarDropdown extends HTMLElement {
|
|
7780
|
-
#listeners = new ListenerBin()
|
|
7781
|
-
|
|
7782
|
-
connectedCallback() {
|
|
7783
|
-
this.container = this.closest("details");
|
|
7784
|
-
|
|
7785
|
-
this.#listeners.track(
|
|
7786
|
-
registerEventListener(this.container, "toggle", this.#handleToggle),
|
|
7787
|
-
registerEventListener(this.container, "keydown", this.#handleKeyDown)
|
|
7788
|
-
);
|
|
7789
|
-
|
|
7790
|
-
this.#onToolbarEditor(this.initialize.bind(this));
|
|
7791
|
-
}
|
|
7792
|
-
|
|
7793
|
-
disconnectedCallback() {
|
|
7794
|
-
this.#listeners.dispose();
|
|
7795
|
-
}
|
|
7796
|
-
|
|
7797
|
-
get toolbar() {
|
|
7798
|
-
return this.closest("lexxy-toolbar")
|
|
7799
|
-
}
|
|
7800
|
-
|
|
7801
|
-
get editorElement() {
|
|
7802
|
-
return this.toolbar.editorElement
|
|
7803
|
-
}
|
|
7804
|
-
|
|
7805
|
-
get editor() {
|
|
7806
|
-
return this.toolbar.editor
|
|
7807
|
-
}
|
|
7808
|
-
|
|
7809
|
-
track(...listeners) {
|
|
7810
|
-
this.#listeners.track(...listeners);
|
|
7811
|
-
}
|
|
7812
|
-
|
|
7813
|
-
initialize() {
|
|
7814
|
-
// Any post-editor initialization
|
|
7815
|
-
}
|
|
7816
|
-
|
|
7817
|
-
close() {
|
|
7818
|
-
this.editor.focus();
|
|
7819
|
-
this.container.open = false;
|
|
7820
|
-
}
|
|
7821
|
-
|
|
7822
|
-
async #onToolbarEditor(callback) {
|
|
7823
|
-
await this.toolbar.editorElement;
|
|
7824
|
-
callback();
|
|
7825
|
-
}
|
|
7826
|
-
|
|
7827
|
-
#handleToggle = () => {
|
|
7828
|
-
if (this.container.open) {
|
|
7829
|
-
this.#handleOpen();
|
|
7830
|
-
}
|
|
7831
|
-
}
|
|
7832
|
-
|
|
7833
|
-
async #handleOpen() {
|
|
7834
|
-
this.#interactiveElements[0].focus();
|
|
7835
|
-
this.#resetTabIndexValues();
|
|
7836
|
-
}
|
|
7837
|
-
|
|
7838
|
-
#handleKeyDown = (event) => {
|
|
7839
|
-
if (event.key === "Escape") {
|
|
7840
|
-
event.stopPropagation();
|
|
7841
|
-
this.close();
|
|
7842
|
-
}
|
|
7843
|
-
}
|
|
7844
|
-
|
|
7845
|
-
async #resetTabIndexValues() {
|
|
7846
|
-
await nextFrame();
|
|
7847
|
-
this.#buttons.forEach((element, index) => {
|
|
7848
|
-
element.setAttribute("tabindex", index === 0 ? 0 : "-1");
|
|
7849
|
-
});
|
|
7850
|
-
}
|
|
7851
|
-
|
|
7852
|
-
get #interactiveElements() {
|
|
7853
|
-
return Array.from(this.querySelectorAll("button, input"))
|
|
7854
|
-
}
|
|
7855
|
-
|
|
7856
|
-
get #buttons() {
|
|
7857
|
-
return Array.from(this.querySelectorAll("button"))
|
|
7858
|
-
}
|
|
7859
|
-
}
|
|
7860
|
-
|
|
7861
|
-
class LinkDropdown extends ToolbarDropdown {
|
|
7862
|
-
connectedCallback() {
|
|
7863
|
-
super.connectedCallback();
|
|
7864
|
-
|
|
7865
|
-
this.input = this.querySelector("input");
|
|
7866
|
-
|
|
7867
|
-
this.track(
|
|
7868
|
-
registerEventListener(this.container, "toggle", this.#handleToggle),
|
|
7869
|
-
registerEventListener(this.input, "keydown", this.#handleEnter),
|
|
7870
|
-
registerEventListener(this.linkButton, "click", this.#handleLink),
|
|
7871
|
-
registerEventListener(this.unlinkButton, "click", this.#handleUnlink)
|
|
7872
|
-
);
|
|
7873
|
-
}
|
|
7874
|
-
|
|
7875
|
-
get linkButton() {
|
|
7876
|
-
return this.querySelector("[value='link']")
|
|
7877
|
-
}
|
|
7878
|
-
|
|
7879
|
-
get unlinkButton() {
|
|
7880
|
-
return this.querySelector("[value='unlink']")
|
|
7881
|
-
}
|
|
7882
|
-
|
|
7883
|
-
#handleToggle = ({ newState }) => {
|
|
7884
|
-
this.input.value = this.#selectedLinkUrl;
|
|
7885
|
-
this.input.required = newState === "open";
|
|
7886
|
-
}
|
|
7887
|
-
|
|
7888
|
-
#handleEnter = (event) => {
|
|
7889
|
-
if (event.key === "Enter") {
|
|
7890
|
-
event.preventDefault();
|
|
7891
|
-
event.stopPropagation();
|
|
7892
|
-
this.#handleLink(event);
|
|
7893
|
-
}
|
|
7894
|
-
}
|
|
7895
|
-
|
|
7896
|
-
#handleLink = () => {
|
|
7897
|
-
if (!this.input.checkValidity()) {
|
|
7898
|
-
this.input.reportValidity();
|
|
7899
|
-
return
|
|
7900
|
-
}
|
|
7901
|
-
|
|
7902
|
-
this.editor.dispatchCommand("link", this.input.value);
|
|
7903
|
-
this.close();
|
|
7904
|
-
}
|
|
7905
|
-
|
|
7906
|
-
#handleUnlink = () => {
|
|
7907
|
-
this.editor.dispatchCommand("unlink");
|
|
7908
|
-
this.close();
|
|
7909
|
-
}
|
|
7910
|
-
|
|
7911
|
-
get #selectedLinkUrl() {
|
|
7912
|
-
return this.editor.getEditorState().read(() => {
|
|
7913
|
-
const linkNode = this.editorElement.selection.nearestNodeOfType(LinkNode);
|
|
7914
|
-
return linkNode?.getURL() ?? ""
|
|
7915
|
-
})
|
|
7916
|
-
}
|
|
7917
|
-
}
|
|
7918
|
-
|
|
7919
|
-
const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
|
|
7920
|
-
const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
|
|
7921
|
-
|
|
7922
|
-
// Use Symbol instead of null since $getSelectionStyleValueForProperty
|
|
7923
|
-
// responds differently for backward selections if null is the default
|
|
7924
|
-
// see https://github.com/facebook/lexical/issues/8013
|
|
7925
|
-
const NO_STYLE = Symbol("no_style");
|
|
7926
|
-
|
|
7927
|
-
class HighlightDropdown extends ToolbarDropdown {
|
|
7928
|
-
initialize() {
|
|
7929
|
-
this.#setUpButtons();
|
|
7930
|
-
this.#registerButtonHandlers();
|
|
7931
|
-
}
|
|
7932
|
-
|
|
7933
|
-
connectedCallback() {
|
|
7934
|
-
super.connectedCallback();
|
|
7935
|
-
this.track(registerEventListener(this.container, "toggle", this.#handleToggle));
|
|
7936
|
-
}
|
|
7937
|
-
|
|
7938
|
-
#registerButtonHandlers() {
|
|
7939
|
-
this.#colorButtons.forEach(button => {
|
|
7940
|
-
this.track(registerEventListener(button, "click", this.#handleColorButtonClick));
|
|
7941
|
-
});
|
|
7942
|
-
this.track(registerEventListener(this.querySelector(REMOVE_HIGHLIGHT_SELECTOR), "click", this.#handleRemoveHighlightClick));
|
|
7943
|
-
}
|
|
7944
|
-
|
|
7945
|
-
#setUpButtons() {
|
|
7946
|
-
this.#buttonContainer.innerHTML = "";
|
|
7947
|
-
|
|
7948
|
-
const colorGroups = this.editorElement.config.get("highlight.buttons");
|
|
7949
|
-
|
|
7950
|
-
this.#populateButtonGroup("color", colorGroups.color);
|
|
7951
|
-
this.#populateButtonGroup("background-color", colorGroups["background-color"]);
|
|
7952
|
-
|
|
7953
|
-
const maxNumberOfColors = Math.max(colorGroups.color.length, colorGroups["background-color"].length);
|
|
7954
|
-
this.style.setProperty("--max-colors", maxNumberOfColors);
|
|
7955
|
-
}
|
|
7956
|
-
|
|
7957
|
-
#populateButtonGroup(attribute, values) {
|
|
7958
|
-
values.forEach((value, index) => {
|
|
7959
|
-
this.#buttonContainer.appendChild(this.#createButton(attribute, value, index));
|
|
7960
|
-
});
|
|
7961
|
-
}
|
|
7962
|
-
|
|
7963
|
-
#createButton(attribute, value, index) {
|
|
7964
|
-
const button = document.createElement("button");
|
|
7965
|
-
button.dataset.style = attribute;
|
|
7966
|
-
button.style.setProperty(attribute, value);
|
|
7967
|
-
button.dataset.value = value;
|
|
7968
|
-
button.classList.add("lexxy-editor__toolbar-button", "lexxy-highlight-button");
|
|
7969
|
-
button.name = attribute + "-" + index;
|
|
7970
|
-
return button
|
|
7971
|
-
}
|
|
7972
|
-
|
|
7973
|
-
#handleToggle = ({ newState }) => {
|
|
7974
|
-
if (newState === "open") {
|
|
7975
|
-
this.editor.getEditorState().read(() => {
|
|
7976
|
-
this.#updateColorButtonStates($getSelection());
|
|
7977
|
-
});
|
|
7978
|
-
}
|
|
7979
|
-
}
|
|
7980
|
-
|
|
7981
|
-
#handleColorButtonClick = (event) => {
|
|
7982
|
-
event.preventDefault();
|
|
7983
|
-
|
|
7984
|
-
const button = event.target.closest(APPLY_HIGHLIGHT_SELECTOR);
|
|
7985
|
-
if (!button) return
|
|
7986
|
-
|
|
7987
|
-
const attribute = button.dataset.style;
|
|
7988
|
-
const value = button.dataset.value;
|
|
7989
|
-
|
|
7990
|
-
this.editor.dispatchCommand("toggleHighlight", { [attribute]: value });
|
|
7991
|
-
this.close();
|
|
7992
|
-
}
|
|
7993
|
-
|
|
7994
|
-
#handleRemoveHighlightClick = (event) => {
|
|
7995
|
-
event.preventDefault();
|
|
7996
|
-
|
|
7997
|
-
this.editor.dispatchCommand("removeHighlight");
|
|
7998
|
-
this.close();
|
|
7999
|
-
}
|
|
8000
|
-
|
|
8001
|
-
#updateColorButtonStates(selection) {
|
|
8002
|
-
if (!$isRangeSelection(selection)) { return }
|
|
8003
|
-
|
|
8004
|
-
// Use non-"" default, so "" indicates mixed highlighting
|
|
8005
|
-
const textColor = $getSelectionStyleValueForProperty(selection, "color", NO_STYLE);
|
|
8006
|
-
const backgroundColor = $getSelectionStyleValueForProperty(selection, "background-color", NO_STYLE);
|
|
8007
|
-
|
|
8008
|
-
this.#colorButtons.forEach(button => {
|
|
8009
|
-
const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor;
|
|
8010
|
-
const next = matchesSelection.toString();
|
|
8011
|
-
if (button.getAttribute("aria-pressed") !== next) {
|
|
8012
|
-
button.setAttribute("aria-pressed", next);
|
|
8013
|
-
}
|
|
8014
|
-
});
|
|
8015
|
-
|
|
8016
|
-
const hasHighlight = textColor !== NO_STYLE || backgroundColor !== NO_STYLE;
|
|
8017
|
-
this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).disabled = !hasHighlight;
|
|
8018
|
-
}
|
|
8019
|
-
|
|
8020
|
-
get #buttonContainer() {
|
|
8021
|
-
return this.querySelector(".lexxy-highlight-colors")
|
|
8022
|
-
}
|
|
8023
|
-
|
|
8024
|
-
get #colorButtons() {
|
|
8025
|
-
return Array.from(this.querySelectorAll(APPLY_HIGHLIGHT_SELECTOR))
|
|
8026
|
-
}
|
|
8027
|
-
}
|
|
8028
|
-
|
|
8029
8388
|
class BaseSource {
|
|
8030
8389
|
// Template method to override
|
|
8031
8390
|
async buildListItems(filter = "") {
|
|
@@ -8384,6 +8743,7 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
8384
8743
|
|
|
8385
8744
|
if (this.#doesSpaceSelect) {
|
|
8386
8745
|
this.#popoverListeners.track(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_CRITICAL));
|
|
8746
|
+
this.#popoverListeners.track(this.#editor.registerCommand(INPUT_COMMAND, this.#handleInputCommand.bind(this), COMMAND_PRIORITY_CRITICAL));
|
|
8387
8747
|
}
|
|
8388
8748
|
|
|
8389
8749
|
// Register arrow keys with CRITICAL priority to prevent Lexical's selection handlers from running
|
|
@@ -8417,16 +8777,12 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
8417
8777
|
return Array.from(this.popoverElement.querySelectorAll(".lexxy-prompt-menu__item"))
|
|
8418
8778
|
}
|
|
8419
8779
|
|
|
8420
|
-
#selectOption(listItem) {
|
|
8780
|
+
#selectOption(listItem, { scrollIntoView = false } = {}) {
|
|
8421
8781
|
this.#clearListItemSelection();
|
|
8422
8782
|
listItem.toggleAttribute("aria-selected", true);
|
|
8423
|
-
|
|
8424
|
-
|
|
8425
|
-
|
|
8426
|
-
// Preserve selection to prevent cursor jump
|
|
8427
|
-
this.#selection.preservingSelection(() => {
|
|
8428
|
-
this.#editorElement.focus();
|
|
8429
|
-
});
|
|
8783
|
+
if (scrollIntoView) {
|
|
8784
|
+
listItem.scrollIntoView({ block: "nearest", container: "nearest", behavior: "smooth" });
|
|
8785
|
+
}
|
|
8430
8786
|
|
|
8431
8787
|
this.#setEditorAssociationAttribute("aria-controls", this.popoverElement.id);
|
|
8432
8788
|
this.#setEditorAssociationAttribute("aria-activedescendant", listItem.id);
|
|
@@ -8545,7 +8901,7 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
8545
8901
|
|
|
8546
8902
|
#showEmptyResults() {
|
|
8547
8903
|
this.popoverElement.classList.add("lexxy-prompt-menu--empty");
|
|
8548
|
-
const el = createElement("li", {
|
|
8904
|
+
const el = createElement("li", { textContent: this.#emptyResultsMessage });
|
|
8549
8905
|
el.classList.add("lexxy-prompt-menu__item--empty");
|
|
8550
8906
|
this.popoverElement.append(el);
|
|
8551
8907
|
}
|
|
@@ -8570,17 +8926,22 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
8570
8926
|
}
|
|
8571
8927
|
});
|
|
8572
8928
|
}
|
|
8573
|
-
// Arrow keys are
|
|
8929
|
+
// Arrow keys are handled via Lexical commands
|
|
8930
|
+
}
|
|
8931
|
+
|
|
8932
|
+
// Android Mobile keyboard doesn't trigger KEY_SPACE_COMMAND
|
|
8933
|
+
#handleInputCommand(event) {
|
|
8934
|
+
if (event.inputType === "insertText" && event.data === " ") return this.#handleSelectedOption(event)
|
|
8574
8935
|
}
|
|
8575
8936
|
|
|
8576
8937
|
#moveSelectionDown() {
|
|
8577
8938
|
const nextIndex = this.#selectedIndex + 1;
|
|
8578
|
-
if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex]);
|
|
8939
|
+
if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex], { scrollIntoView: true });
|
|
8579
8940
|
}
|
|
8580
8941
|
|
|
8581
8942
|
#moveSelectionUp() {
|
|
8582
8943
|
const previousIndex = this.#selectedIndex - 1;
|
|
8583
|
-
if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex]);
|
|
8944
|
+
if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex], { scrollIntoView: true });
|
|
8584
8945
|
}
|
|
8585
8946
|
|
|
8586
8947
|
get #selectedIndex() {
|
|
@@ -8627,7 +8988,7 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
8627
8988
|
}
|
|
8628
8989
|
|
|
8629
8990
|
#buildEditableTextNodes(template) {
|
|
8630
|
-
return
|
|
8991
|
+
return this.#editorElement.$generateNodesFromDOM(parseHtml(`${template.innerHTML}`))
|
|
8631
8992
|
}
|
|
8632
8993
|
|
|
8633
8994
|
#insertTemplatesAsAttachments(templates, stringToReplace, fallbackSgid = null) {
|
|
@@ -9606,14 +9967,19 @@ class TableTools extends HTMLElement {
|
|
|
9606
9967
|
|
|
9607
9968
|
function defineElements() {
|
|
9608
9969
|
const elements = {
|
|
9970
|
+
// Toolbar must be registered BEFORE Editor
|
|
9609
9971
|
"lexxy-toolbar": LexicalToolbarElement,
|
|
9610
|
-
"lexxy-
|
|
9611
|
-
"lexxy-link-dropdown": LinkDropdown,
|
|
9972
|
+
"lexxy-toolbar-dropdown": ToolbarDropdown,
|
|
9612
9973
|
"lexxy-highlight-dropdown": HighlightDropdown,
|
|
9974
|
+
"lexxy-link-dropdown": LinkDropdown,
|
|
9975
|
+
|
|
9976
|
+
"lexxy-editor": LexicalEditorElement,
|
|
9977
|
+
|
|
9978
|
+
// Prompt must be registered AFTER Editor
|
|
9613
9979
|
"lexxy-prompt": LexicalPromptElement,
|
|
9614
9980
|
"lexxy-code-language-picker": CodeLanguagePicker,
|
|
9615
9981
|
"lexxy-node-delete-button": NodeDeleteButton,
|
|
9616
|
-
"lexxy-table-tools": TableTools
|
|
9982
|
+
"lexxy-table-tools": TableTools
|
|
9617
9983
|
};
|
|
9618
9984
|
|
|
9619
9985
|
Object.entries(elements).forEach(([ name, element ]) => {
|