@37signals/lexxy 0.9.19-alpha.1 → 0.9.19-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lexxy.esm.js +316 -89
- package/package.json +4 -3
package/dist/lexxy.esm.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
export { highlightCode, highlightElement } from './lexxy_helpers.esm.js';
|
|
2
2
|
import DOMPurify from 'dompurify';
|
|
3
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, $getRoot, $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, 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,
|
|
4
|
+
import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createTextNode, $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isTextNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $normalizeSelection__EXPERIMENTAL, $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, INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, INSERT_PARAGRAPH_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $isNodeSelection, 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, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, DRAGSTART_COMMAND, DROP_COMMAND, mergeRegister as mergeRegister$1, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
|
|
5
5
|
import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
|
|
6
6
|
import { buildEditorFromExtensions } from '@lexical/extension';
|
|
7
|
-
import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $
|
|
7
|
+
import { ListNode, ListItemNode, $getListDepth, $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $createListNode, $createListItemNode, registerList } from '@lexical/list';
|
|
8
8
|
import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, $descendantsMatching, mergeRegister, $insertNodeToNearestRoot, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, IS_APPLE } from '@lexical/utils';
|
|
9
9
|
import { registerPlainText } from '@lexical/plain-text';
|
|
10
|
-
import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode,
|
|
10
|
+
import { RichTextExtension, $isQuoteNode, QuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
|
|
11
11
|
import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
|
|
12
12
|
import { HistoryExtension } from '@lexical/history';
|
|
13
13
|
import { $isCodeNode, CodeHighlightNode, CodeNode, $createCodeNode, $isCodeHighlightNode, $createCodeHighlightNode, normalizeCodeLang, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
|
|
@@ -1534,8 +1534,12 @@ function $isSafeForRoot(node) {
|
|
|
1534
1534
|
function $makeSafeForRoot(node) {
|
|
1535
1535
|
if ($isSafeForRoot(node)) {
|
|
1536
1536
|
return node
|
|
1537
|
-
} else {
|
|
1537
|
+
} else if (node.getParent()) {
|
|
1538
1538
|
return $wrapNodeInElement(node, () => node.createParentElementNode())
|
|
1539
|
+
} else {
|
|
1540
|
+
// Detached nodes (e.g. clipboard nodes being inserted) can't be `replace`d in place,
|
|
1541
|
+
// so append them into a fresh required parent instead.
|
|
1542
|
+
return node.createParentElementNode().append(node)
|
|
1539
1543
|
}
|
|
1540
1544
|
}
|
|
1541
1545
|
|
|
@@ -1723,7 +1727,7 @@ function $splitSelectedParagraphsAtInnerLineBreaks(selection) {
|
|
|
1723
1727
|
}
|
|
1724
1728
|
}
|
|
1725
1729
|
|
|
1726
|
-
function $expandSelectionToLineBreaksAndSplitAtEdges(selection) {
|
|
1730
|
+
function $expandSelectionToLineBreaksAndSplitAtEdges(selection, fallbackAncestor = (node) => node.getTopLevelElement()) {
|
|
1727
1731
|
$ensureForwardRangeSelection(selection);
|
|
1728
1732
|
|
|
1729
1733
|
const focusCaret = $caretFromPoint(selection.focus, "next");
|
|
@@ -1743,8 +1747,8 @@ function $expandSelectionToLineBreaksAndSplitAtEdges(selection) {
|
|
|
1743
1747
|
const focusOuter = focusBrCaret && $splitAroundLineBreak(focusBrCaret);
|
|
1744
1748
|
const anchorOuter = anchorBrCaret && $splitAroundLineBreak(anchorBrCaret);
|
|
1745
1749
|
|
|
1746
|
-
const innerStart = anchorOuter?.getNextSibling() ?? selection.anchor.getNode()
|
|
1747
|
-
const innerEnd = focusOuter?.getPreviousSibling() ?? selection.focus.getNode()
|
|
1750
|
+
const innerStart = anchorOuter?.getNextSibling() ?? fallbackAncestor(selection.anchor.getNode());
|
|
1751
|
+
const innerEnd = focusOuter?.getPreviousSibling() ?? fallbackAncestor(selection.focus.getNode());
|
|
1748
1752
|
if (!innerStart || !innerEnd) return
|
|
1749
1753
|
|
|
1750
1754
|
$setSelectionFromCaretRange($getCaretRange(
|
|
@@ -1850,6 +1854,49 @@ function $splitAroundLineBreak(lineBreakCaret) {
|
|
|
1850
1854
|
return outer
|
|
1851
1855
|
}
|
|
1852
1856
|
|
|
1857
|
+
// Lexical's RangeSelection.insertNodes/insertLineBreak require every selection point to have a
|
|
1858
|
+
// block ancestor with inline children. An element point on a container of block nodes — e.g. a
|
|
1859
|
+
// quote holding paragraphs — has none, so Lexical throws invariant #211 or #212. This detects
|
|
1860
|
+
// such a point so callers can descend it to a leaf before inserting.
|
|
1861
|
+
function $isPointOnBlockContainer(point) {
|
|
1862
|
+
if (point.type !== "element") return false
|
|
1863
|
+
|
|
1864
|
+
const firstChild = point.getNode().getFirstChild();
|
|
1865
|
+
return ($isElementNode(firstChild) || $isDecoratorNode(firstChild)) && !firstChild.isInline()
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function $hasPointOnBlockContainer(selection) {
|
|
1869
|
+
return $isRangeSelection(selection) &&
|
|
1870
|
+
[ selection.anchor, selection.focus ].some($isPointOnBlockContainer)
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// Descend any block-container element point in the selection to a leaf position, so a subsequent
|
|
1874
|
+
// Lexical insert (insertNodes, insertLineBreak, INSERT_PARAGRAPH) doesn't throw invariant #211/#212.
|
|
1875
|
+
function $normalizeBlockContainerSelection(selection = $getSelection()) {
|
|
1876
|
+
if (!$hasPointOnBlockContainer(selection)) return false
|
|
1877
|
+
|
|
1878
|
+
$normalizeSelection__EXPERIMENTAL(selection);
|
|
1879
|
+
return true
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function $consecutiveSiblingGroups(blocks) {
|
|
1883
|
+
const ordered = [ ...blocks ].sort((a, b) => a.getIndexWithinParent() - b.getIndexWithinParent());
|
|
1884
|
+
const groups = [];
|
|
1885
|
+
|
|
1886
|
+
for (const block of ordered) {
|
|
1887
|
+
const lastGroup = groups.at(-1);
|
|
1888
|
+
const previous = lastGroup?.at(-1);
|
|
1889
|
+
|
|
1890
|
+
if (previous && previous.getParent().is(block.getParent()) && previous.getNextSibling()?.is(block)) {
|
|
1891
|
+
lastGroup.push(block);
|
|
1892
|
+
} else {
|
|
1893
|
+
groups.push([ block ]);
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
return groups
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1853
1900
|
// Payload: Record<nodeKey, { patch?, replace? }>
|
|
1854
1901
|
// - patch: plain object, shallow-merged into the existing node's properties
|
|
1855
1902
|
// - replace: a LexicalNode instance that replaces the node
|
|
@@ -3741,6 +3788,16 @@ class CommandDispatcher {
|
|
|
3741
3788
|
#registerKeyboardCommands() {
|
|
3742
3789
|
this.#registerCommandHandler(KEY_ARROW_RIGHT_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleArrowRightKey.bind(this));
|
|
3743
3790
|
this.#registerCommandHandler(KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleTabKey.bind(this));
|
|
3791
|
+
|
|
3792
|
+
// Run before Lexical's built-in insert handlers to descend an element point on a
|
|
3793
|
+
// block container to a leaf, avoiding error #211 on Enter / Shift+Enter in a quote.
|
|
3794
|
+
this.#registerCommandHandler(INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, this.#normalizeBlockContainerSelection.bind(this));
|
|
3795
|
+
this.#registerCommandHandler(INSERT_PARAGRAPH_COMMAND, COMMAND_PRIORITY_HIGH, this.#normalizeBlockContainerSelection.bind(this));
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
#normalizeBlockContainerSelection() {
|
|
3799
|
+
$normalizeBlockContainerSelection();
|
|
3800
|
+
return false
|
|
3744
3801
|
}
|
|
3745
3802
|
|
|
3746
3803
|
#handleArrowRightKey(event) {
|
|
@@ -4013,6 +4070,10 @@ class Selection {
|
|
|
4013
4070
|
return this.nearestNodeOfType(ListItemNode)
|
|
4014
4071
|
}
|
|
4015
4072
|
|
|
4073
|
+
get isInsideBlockQuote() {
|
|
4074
|
+
return this.nearestNodeOfType(QuoteNode)
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4016
4077
|
get isIndentedList() {
|
|
4017
4078
|
const closestListNode = this.nearestNodeOfType(ListNode);
|
|
4018
4079
|
return closestListNode && ($getListDepth(closestListNode) > 1)
|
|
@@ -4440,6 +4501,9 @@ class Selection {
|
|
|
4440
4501
|
// - First item (no previous sibling): convert to a paragraph above the
|
|
4441
4502
|
// list, matching the standard "unwrap list formatting" behavior that
|
|
4442
4503
|
// users expect from pressing backspace at the start of a list item.
|
|
4504
|
+
// Inside a blockquote we instead just remove the empty item and move
|
|
4505
|
+
// the cursor into the next one — stranding a paragraph there would
|
|
4506
|
+
// leave the blank line the user is trying to close.
|
|
4443
4507
|
//
|
|
4444
4508
|
// When the empty item is the last/only one in the list, we return false
|
|
4445
4509
|
// and let Lexical's default (convert to paragraph) provide the standard
|
|
@@ -4458,19 +4522,22 @@ class Selection {
|
|
|
4458
4522
|
if (!nextSibling) return false
|
|
4459
4523
|
|
|
4460
4524
|
const previousSibling = listItem.getPreviousSibling();
|
|
4525
|
+
const listNode = $getNearestNodeOfType(listItem, ListNode);
|
|
4526
|
+
if (!listNode) return false
|
|
4527
|
+
|
|
4461
4528
|
if (previousSibling) {
|
|
4462
4529
|
previousSibling.selectEnd();
|
|
4463
4530
|
listItem.remove();
|
|
4464
|
-
|
|
4531
|
+
} else if ($isQuoteNode(listNode.getParent())) {
|
|
4532
|
+
nextSibling.selectStart();
|
|
4533
|
+
listItem.remove();
|
|
4534
|
+
} else {
|
|
4535
|
+
const paragraph = $createParagraphNode();
|
|
4536
|
+
listNode.insertBefore(paragraph);
|
|
4537
|
+
listItem.remove();
|
|
4538
|
+
paragraph.selectStart();
|
|
4465
4539
|
}
|
|
4466
4540
|
|
|
4467
|
-
const listNode = $getNearestNodeOfType(listItem, ListNode);
|
|
4468
|
-
if (!listNode) return false
|
|
4469
|
-
|
|
4470
|
-
const paragraph = $createParagraphNode();
|
|
4471
|
-
listNode.insertBefore(paragraph);
|
|
4472
|
-
listItem.remove();
|
|
4473
|
-
paragraph.selectStart();
|
|
4474
4541
|
return true
|
|
4475
4542
|
}
|
|
4476
4543
|
|
|
@@ -5346,24 +5413,13 @@ function $createActionTextAttachmentUploadNode(...args) {
|
|
|
5346
5413
|
return new ActionTextAttachmentUploadNode(...args)
|
|
5347
5414
|
}
|
|
5348
5415
|
|
|
5349
|
-
class
|
|
5350
|
-
static for(selection) {
|
|
5351
|
-
const INSERTERS = [
|
|
5352
|
-
CodeNodeInserter,
|
|
5353
|
-
ShadowRootNodeInserter,
|
|
5354
|
-
NodeSelectionNodeInserter,
|
|
5355
|
-
BlockContainerNodeInserter
|
|
5356
|
-
];
|
|
5357
|
-
const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
|
|
5358
|
-
return Inserter ? new Inserter(selection) : selection
|
|
5359
|
-
}
|
|
5360
|
-
|
|
5416
|
+
class BaseNodeInserter {
|
|
5361
5417
|
constructor(selection) {
|
|
5362
5418
|
this.selection = selection;
|
|
5363
5419
|
}
|
|
5364
5420
|
}
|
|
5365
5421
|
|
|
5366
|
-
class CodeNodeInserter extends
|
|
5422
|
+
class CodeNodeInserter extends BaseNodeInserter {
|
|
5367
5423
|
static handles(selection) {
|
|
5368
5424
|
return $getNearestNodeOfType(selection.anchor?.getNode(), CodeNode)
|
|
5369
5425
|
}
|
|
@@ -5422,10 +5478,9 @@ class CodeNodeInserter extends NodeInserter {
|
|
|
5422
5478
|
return $createTextNode(node.getTextContent())
|
|
5423
5479
|
}
|
|
5424
5480
|
}
|
|
5425
|
-
|
|
5426
5481
|
}
|
|
5427
5482
|
|
|
5428
|
-
class ShadowRootNodeInserter extends
|
|
5483
|
+
class ShadowRootNodeInserter extends BaseNodeInserter {
|
|
5429
5484
|
static handles(selection) {
|
|
5430
5485
|
return $isShadowRoot(selection?.anchor?.getNode())
|
|
5431
5486
|
}
|
|
@@ -5439,31 +5494,106 @@ class ShadowRootNodeInserter extends NodeInserter {
|
|
|
5439
5494
|
}
|
|
5440
5495
|
}
|
|
5441
5496
|
|
|
5442
|
-
class NodeSelectionNodeInserter extends
|
|
5497
|
+
class NodeSelectionNodeInserter extends BaseNodeInserter {
|
|
5443
5498
|
static handles(selection) {
|
|
5444
5499
|
return $isNodeSelection(selection)
|
|
5445
5500
|
}
|
|
5446
5501
|
|
|
5447
5502
|
insertNodes(nodes) {
|
|
5448
|
-
const selectedNodes = this.selection.getNodes();
|
|
5449
|
-
|
|
5450
5503
|
// Overrides Lexical's default behavior of _removing_ the currently selected nodes
|
|
5451
5504
|
// https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
|
|
5452
|
-
let lastNode =
|
|
5505
|
+
let lastNode = this.selection.getNodes().at(-1);
|
|
5506
|
+
|
|
5453
5507
|
for (const node of nodes) {
|
|
5454
|
-
|
|
5508
|
+
// Inserting after a top-level node would make this one a root child. Inline nodes
|
|
5509
|
+
// can't live there (Lexical error #99), so wrap them in their required parent first.
|
|
5510
|
+
const nodeToInsert = this.#insertsIntoRoot(lastNode) ? $makeSafeForRoot(node) : node;
|
|
5511
|
+
lastNode = lastNode.insertAfter(nodeToInsert);
|
|
5455
5512
|
}
|
|
5456
5513
|
}
|
|
5514
|
+
|
|
5515
|
+
#insertsIntoRoot(node) {
|
|
5516
|
+
return node.is(node.getTopLevelElement())
|
|
5517
|
+
}
|
|
5518
|
+
}
|
|
5519
|
+
|
|
5520
|
+
// A list item can only hold inline content, so a block node (such as an image
|
|
5521
|
+
// attachment) dropped into one corrupts the list: Lexical lifts it into the
|
|
5522
|
+
// wrong list item and orphans an empty bullet. Block nodes belong at the top
|
|
5523
|
+
// level instead, splitting the list around the cursor. Inline content keeps
|
|
5524
|
+
// Lexical's default behavior and stays within the list item.
|
|
5525
|
+
class ListItemNodeInserter extends BaseNodeInserter {
|
|
5526
|
+
static handles(selection) {
|
|
5527
|
+
return $isRangeSelection(selection) &&
|
|
5528
|
+
$getNearestNodeOfType(selection.anchor.getNode(), ListItemNode)
|
|
5529
|
+
}
|
|
5530
|
+
|
|
5531
|
+
insertNodes(nodes) {
|
|
5532
|
+
if (nodes.some(node => this.#isBlockDecorator(node))) {
|
|
5533
|
+
this.#insertAroundList(nodes);
|
|
5534
|
+
} else {
|
|
5535
|
+
this.selection.insertNodes(nodes);
|
|
5536
|
+
}
|
|
5537
|
+
}
|
|
5538
|
+
|
|
5539
|
+
#insertAroundList(nodes) {
|
|
5540
|
+
if (!this.selection.isCollapsed()) { this.selection.removeText(); }
|
|
5541
|
+
|
|
5542
|
+
// Break out of any nesting to the outermost list. Splitting an inner list
|
|
5543
|
+
// would leave the block stranded inside an ancestor list item, which is the
|
|
5544
|
+
// very corruption we are avoiding. The block must land at the list's level.
|
|
5545
|
+
const anchorNode = this.selection.anchor.getNode();
|
|
5546
|
+
const outerList = this.#outermostList(anchorNode);
|
|
5547
|
+
const topItem = this.#topLevelItemFor(anchorNode, outerList);
|
|
5548
|
+
|
|
5549
|
+
// A blank top-level bullet is just the insertion point (e.g. the user pressed
|
|
5550
|
+
// Enter to leave the list); break out of it entirely. A bullet with content —
|
|
5551
|
+
// including one wrapping a nested list — splits so its content stays in the list.
|
|
5552
|
+
const splitAfterItem = $isBlankNode(topItem) ? topItem.getPreviousSibling() : topItem;
|
|
5553
|
+
const splitIndex = splitAfterItem ? splitAfterItem.getIndexWithinParent() + 1 : 0;
|
|
5554
|
+
const [ listBefore, listAfter ] = $splitNode(outerList, splitIndex);
|
|
5555
|
+
if ($isBlankNode(topItem)) { topItem.remove(); }
|
|
5556
|
+
|
|
5557
|
+
let anchor = listBefore ?? listAfter;
|
|
5558
|
+
for (const node of nodes) {
|
|
5559
|
+
anchor.insertAfter(node);
|
|
5560
|
+
anchor = node;
|
|
5561
|
+
}
|
|
5562
|
+
|
|
5563
|
+
this.#removeEmptyList(listBefore);
|
|
5564
|
+
this.#removeEmptyList(listAfter);
|
|
5565
|
+
nodes.at(-1).selectNext();
|
|
5566
|
+
}
|
|
5567
|
+
|
|
5568
|
+
#outermostList(node) {
|
|
5569
|
+
return [ node, ...node.getParents() ].reverse().find($isListNode)
|
|
5570
|
+
}
|
|
5571
|
+
|
|
5572
|
+
#topLevelItemFor(node, outerList) {
|
|
5573
|
+
return [ node, ...node.getParents() ].find(ancestor =>
|
|
5574
|
+
$isListItemNode(ancestor) && ancestor.getParent()?.is(outerList)
|
|
5575
|
+
)
|
|
5576
|
+
}
|
|
5577
|
+
|
|
5578
|
+
#removeEmptyList(list) {
|
|
5579
|
+
if ($isListNode(list) && list.isEmpty()) list.remove();
|
|
5580
|
+
}
|
|
5581
|
+
|
|
5582
|
+
// Only block decorator nodes (image/file attachments) are intercepted. A list
|
|
5583
|
+
// item cannot hold them, so they must break out. Pasted element blocks
|
|
5584
|
+
// (paragraphs, quotes) keep Lexical's own list-escape semantics.
|
|
5585
|
+
#isBlockDecorator(node) {
|
|
5586
|
+
return $isDecoratorNode(node) && !node.isInline()
|
|
5587
|
+
}
|
|
5457
5588
|
}
|
|
5458
5589
|
|
|
5459
5590
|
// Lexical's RangeSelection.insertNodes requires every selection point to have a block
|
|
5460
5591
|
// ancestor with inline children. An element point on a container of block nodes — e.g.
|
|
5461
5592
|
// a quote holding paragraphs — has none, so Lexical throws invariant #211 or #212.
|
|
5462
5593
|
// Descend such points to a leaf position before inserting.
|
|
5463
|
-
class BlockContainerNodeInserter extends
|
|
5594
|
+
class BlockContainerNodeInserter extends BaseNodeInserter {
|
|
5464
5595
|
static handles(selection) {
|
|
5465
|
-
return $
|
|
5466
|
-
[ selection.anchor, selection.focus ].some($isPointOnBlockContainer)
|
|
5596
|
+
return $hasPointOnBlockContainer(selection)
|
|
5467
5597
|
}
|
|
5468
5598
|
|
|
5469
5599
|
insertNodes(nodes) {
|
|
@@ -5472,12 +5602,66 @@ class BlockContainerNodeInserter extends NodeInserter {
|
|
|
5472
5602
|
}
|
|
5473
5603
|
}
|
|
5474
5604
|
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5605
|
+
const INSERTERS = [
|
|
5606
|
+
CodeNodeInserter,
|
|
5607
|
+
ShadowRootNodeInserter,
|
|
5608
|
+
NodeSelectionNodeInserter,
|
|
5609
|
+
ListItemNodeInserter,
|
|
5610
|
+
BlockContainerNodeInserter
|
|
5611
|
+
];
|
|
5612
|
+
|
|
5613
|
+
// Defined here rather than on the base class so the base can stay free of any
|
|
5614
|
+
// dependency on its subclasses (they import the base), avoiding an import cycle.
|
|
5615
|
+
BaseNodeInserter.for = (selection) => {
|
|
5616
|
+
const inserterClass = INSERTERS.find(inserter => inserter.handles(selection));
|
|
5617
|
+
return inserterClass ? new inserterClass(selection) : selection
|
|
5618
|
+
};
|
|
5619
|
+
|
|
5620
|
+
class PastedContentFormatter {
|
|
5621
|
+
constructor(doc) {
|
|
5622
|
+
this.doc = doc;
|
|
5623
|
+
}
|
|
5624
|
+
|
|
5625
|
+
format() {
|
|
5626
|
+
this.#unwrapPlaceholderAnchors();
|
|
5627
|
+
this.#stripTableCellColorStyles();
|
|
5628
|
+
this.#stripStrayListChildren();
|
|
5629
|
+
return this.doc
|
|
5630
|
+
}
|
|
5631
|
+
|
|
5632
|
+
// Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
|
|
5633
|
+
// from rendered views where mentions and interactive elements are wrapped in
|
|
5634
|
+
// <a href="#"> tags. Unwrap them so their text content pastes as plain text
|
|
5635
|
+
// and real links are preserved.
|
|
5636
|
+
#unwrapPlaceholderAnchors() {
|
|
5637
|
+
for (const anchor of this.doc.querySelectorAll("a")) {
|
|
5638
|
+
const href = anchor.getAttribute("href") || "";
|
|
5639
|
+
if (href === "" || href === "#") {
|
|
5640
|
+
anchor.replaceWith(...anchor.childNodes);
|
|
5641
|
+
}
|
|
5642
|
+
}
|
|
5643
|
+
}
|
|
5644
|
+
|
|
5645
|
+
// Table cells copied from a page inherit the source theme's inline color
|
|
5646
|
+
// styles (e.g. dark-mode backgrounds). Strip them so pasted tables adopt
|
|
5647
|
+
// the current theme instead of carrying stale colors.
|
|
5648
|
+
#stripTableCellColorStyles() {
|
|
5649
|
+
for (const cell of this.doc.querySelectorAll("td, th")) {
|
|
5650
|
+
cell.style.removeProperty("background-color");
|
|
5651
|
+
cell.style.removeProperty("background");
|
|
5652
|
+
cell.style.removeProperty("color");
|
|
5653
|
+
}
|
|
5654
|
+
}
|
|
5655
|
+
|
|
5656
|
+
// Only <li> is a valid child of a list; drop stray <br>/whitespace so the
|
|
5657
|
+
// import doesn't wrap them into an empty leading item.
|
|
5658
|
+
#stripStrayListChildren() {
|
|
5659
|
+
for (const list of this.doc.querySelectorAll("ol, ul")) {
|
|
5660
|
+
for (const child of Array.from(list.childNodes)) {
|
|
5661
|
+
if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "LI") continue
|
|
5662
|
+
list.removeChild(child);
|
|
5663
|
+
}
|
|
5664
|
+
}
|
|
5481
5665
|
}
|
|
5482
5666
|
}
|
|
5483
5667
|
|
|
@@ -5512,7 +5696,7 @@ class Contents {
|
|
|
5512
5696
|
|
|
5513
5697
|
insertAtCursor(...nodes) {
|
|
5514
5698
|
const selection = $getSelection() ?? $getRoot().selectEnd();
|
|
5515
|
-
const inserter =
|
|
5699
|
+
const inserter = BaseNodeInserter.for(selection);
|
|
5516
5700
|
|
|
5517
5701
|
inserter.insertNodes(nodes);
|
|
5518
5702
|
}
|
|
@@ -5526,7 +5710,7 @@ class Contents {
|
|
|
5526
5710
|
const selection = $getSelection();
|
|
5527
5711
|
if (!$isRangeSelection(selection)) return
|
|
5528
5712
|
|
|
5529
|
-
$expandSelectionToLineBreaksAndSplitAtEdges(selection);
|
|
5713
|
+
$expandSelectionToLineBreaksAndSplitAtEdges(selection, (node) => $getNearestBlockElementAncestorOrThrow(node));
|
|
5530
5714
|
$setBlocksType(selection, () => $createParagraphNode());
|
|
5531
5715
|
}
|
|
5532
5716
|
|
|
@@ -5539,13 +5723,11 @@ class Contents {
|
|
|
5539
5723
|
}
|
|
5540
5724
|
|
|
5541
5725
|
applyUnorderedListFormat() {
|
|
5542
|
-
this.#
|
|
5543
|
-
this.editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
|
5726
|
+
this.#applyListFormat("bullet", INSERT_UNORDERED_LIST_COMMAND);
|
|
5544
5727
|
}
|
|
5545
5728
|
|
|
5546
5729
|
applyOrderedListFormat() {
|
|
5547
|
-
this.#
|
|
5548
|
-
this.editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
|
|
5730
|
+
this.#applyListFormat("number", INSERT_ORDERED_LIST_COMMAND);
|
|
5549
5731
|
}
|
|
5550
5732
|
|
|
5551
5733
|
clearFormatting() {
|
|
@@ -5633,7 +5815,7 @@ class Contents {
|
|
|
5633
5815
|
|
|
5634
5816
|
const selection = $getSelection();
|
|
5635
5817
|
if ($isRangeSelection(selection)) {
|
|
5636
|
-
selection.insertNodes([ linkNode ]);
|
|
5818
|
+
BaseNodeInserter.for(selection).insertNodes([ linkNode ]);
|
|
5637
5819
|
linkNodeKey = linkNode.getKey();
|
|
5638
5820
|
}
|
|
5639
5821
|
});
|
|
@@ -5828,8 +6010,7 @@ class Contents {
|
|
|
5828
6010
|
}
|
|
5829
6011
|
|
|
5830
6012
|
#formatPastedDOM(doc) {
|
|
5831
|
-
|
|
5832
|
-
this.#stripTableCellColorStyles(doc);
|
|
6013
|
+
new PastedContentFormatter(doc).format();
|
|
5833
6014
|
}
|
|
5834
6015
|
|
|
5835
6016
|
#dispatchPastedNodesCommand(nodes) {
|
|
@@ -5877,6 +6058,45 @@ class Contents {
|
|
|
5877
6058
|
codeNode.remove();
|
|
5878
6059
|
}
|
|
5879
6060
|
|
|
6061
|
+
#applyListFormat(listType, command) {
|
|
6062
|
+
if (this.selection.isInsideBlockQuote) {
|
|
6063
|
+
this.#insertListInsideQuote(listType);
|
|
6064
|
+
} else {
|
|
6065
|
+
this.#splitParagraphsAtLineBreaksUnlessInsideList();
|
|
6066
|
+
this.editor.dispatchCommand(command);
|
|
6067
|
+
}
|
|
6068
|
+
}
|
|
6069
|
+
|
|
6070
|
+
#insertListInsideQuote(listType) {
|
|
6071
|
+
for (const group of $consecutiveSiblingGroups(this.#quotedBlocksInSelection())) {
|
|
6072
|
+
this.#wrapBlocksInList(group, listType);
|
|
6073
|
+
}
|
|
6074
|
+
}
|
|
6075
|
+
|
|
6076
|
+
#quotedBlocksInSelection() {
|
|
6077
|
+
const selection = $getSelection();
|
|
6078
|
+
if (!$isRangeSelection(selection)) return []
|
|
6079
|
+
|
|
6080
|
+
const blocks = this.#outermostElements(this.#blockLevelElementsInSelection(selection));
|
|
6081
|
+
return blocks.filter((block) => $isQuoteNode(block.getParent()))
|
|
6082
|
+
}
|
|
6083
|
+
|
|
6084
|
+
#wrapBlocksInList(blocks, listType) {
|
|
6085
|
+
const list = $createListNode(listType);
|
|
6086
|
+
blocks[0].insertBefore(list);
|
|
6087
|
+
|
|
6088
|
+
for (const block of blocks) {
|
|
6089
|
+
const listItem = $createListItemNode();
|
|
6090
|
+
if ($isListNode(block)) {
|
|
6091
|
+
listItem.append(...block.getChildren().flatMap((item) => item.getChildren()));
|
|
6092
|
+
} else {
|
|
6093
|
+
listItem.append(...block.getChildren());
|
|
6094
|
+
}
|
|
6095
|
+
list.append(listItem);
|
|
6096
|
+
block.remove();
|
|
6097
|
+
}
|
|
6098
|
+
}
|
|
6099
|
+
|
|
5880
6100
|
#splitParagraphsAtLineBreaksUnlessInsideList() {
|
|
5881
6101
|
if (this.selection.isInsideList) return
|
|
5882
6102
|
|
|
@@ -5957,30 +6177,6 @@ class Contents {
|
|
|
5957
6177
|
node.remove();
|
|
5958
6178
|
}
|
|
5959
6179
|
|
|
5960
|
-
// Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
|
|
5961
|
-
// from rendered views where mentions and interactive elements are wrapped in
|
|
5962
|
-
// <a href="#"> tags. Unwrap them so their text content pastes as plain text
|
|
5963
|
-
// and real links are preserved.
|
|
5964
|
-
#unwrapPlaceholderAnchors(doc) {
|
|
5965
|
-
for (const anchor of doc.querySelectorAll("a")) {
|
|
5966
|
-
const href = anchor.getAttribute("href") || "";
|
|
5967
|
-
if (href === "" || href === "#") {
|
|
5968
|
-
anchor.replaceWith(...anchor.childNodes);
|
|
5969
|
-
}
|
|
5970
|
-
}
|
|
5971
|
-
}
|
|
5972
|
-
|
|
5973
|
-
// Table cells copied from a page inherit the source theme's inline color
|
|
5974
|
-
// styles (e.g. dark-mode backgrounds). Strip them so pasted tables adopt
|
|
5975
|
-
// the current theme instead of carrying stale colors.
|
|
5976
|
-
#stripTableCellColorStyles(doc) {
|
|
5977
|
-
for (const cell of doc.querySelectorAll("td, th")) {
|
|
5978
|
-
cell.style.removeProperty("background-color");
|
|
5979
|
-
cell.style.removeProperty("background");
|
|
5980
|
-
cell.style.removeProperty("color");
|
|
5981
|
-
}
|
|
5982
|
-
}
|
|
5983
|
-
|
|
5984
6180
|
#getTextAnchorData() {
|
|
5985
6181
|
const selection = $getSelection();
|
|
5986
6182
|
if (!selection || !selection.isCollapsed()) return { anchorNode: null, offset: 0 }
|
|
@@ -6226,7 +6422,7 @@ class Clipboard {
|
|
|
6226
6422
|
}
|
|
6227
6423
|
|
|
6228
6424
|
const linkNode = $createLinkNode(url).append($createTextNode(url));
|
|
6229
|
-
selection.insertNodes([ linkNode ]);
|
|
6425
|
+
BaseNodeInserter.for(selection).insertNodes([ linkNode ]);
|
|
6230
6426
|
|
|
6231
6427
|
$onUpdate(() => this.#dispatchLinkInsertEvent(linkNode.getKey(), { url }));
|
|
6232
6428
|
}
|
|
@@ -7692,7 +7888,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7692
7888
|
static debug = false
|
|
7693
7889
|
static commands = [ "bold", "italic", "strikethrough" ]
|
|
7694
7890
|
|
|
7695
|
-
static observedAttributes = [ "connected", "required" ]
|
|
7891
|
+
static observedAttributes = [ "autocapitalize", "connected", "required" ]
|
|
7696
7892
|
|
|
7697
7893
|
#initialValue = ""
|
|
7698
7894
|
#previousInternalFormValue = null
|
|
@@ -7759,8 +7955,15 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
7759
7955
|
}
|
|
7760
7956
|
|
|
7761
7957
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
7762
|
-
if (name === "
|
|
7763
|
-
|
|
7958
|
+
if (typeof this[`${name}ChangedCallback`] === "function") {
|
|
7959
|
+
this[`${name}ChangedCallback`](oldValue, newValue);
|
|
7960
|
+
}
|
|
7961
|
+
}
|
|
7962
|
+
|
|
7963
|
+
autocapitalizeChangedCallback() {
|
|
7964
|
+
if (this.editorContentElement) {
|
|
7965
|
+
this.#transferAttributeToContentEditable(this.editorContentElement, "autocapitalize");
|
|
7966
|
+
}
|
|
7764
7967
|
}
|
|
7765
7968
|
|
|
7766
7969
|
connectedChangedCallback(oldValue, newValue) {
|
|
@@ -8092,25 +8295,33 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
8092
8295
|
|
|
8093
8296
|
#createEditorContentElement() {
|
|
8094
8297
|
const editorContentElement = createElement("div", {
|
|
8298
|
+
id: `${this.id}-content`,
|
|
8095
8299
|
classList: "lexxy-editor__content",
|
|
8096
8300
|
contenteditable: true,
|
|
8097
|
-
autocapitalize: "none",
|
|
8098
8301
|
role: "textbox",
|
|
8099
8302
|
"aria-multiline": true,
|
|
8100
8303
|
"aria-label": this.#labelText,
|
|
8101
8304
|
placeholder: this.getAttribute("placeholder")
|
|
8102
8305
|
});
|
|
8103
|
-
|
|
8306
|
+
|
|
8104
8307
|
this.#ariaAttributes.forEach(attribute => editorContentElement.setAttribute(attribute.name, attribute.value));
|
|
8105
8308
|
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
8309
|
+
this.#transferAttributeToContentEditable(editorContentElement, "autocapitalize");
|
|
8310
|
+
this.#transferAttributeToContentEditable(editorContentElement, "tabindex", { defaultValue: 0, removeSource: true });
|
|
8311
|
+
|
|
8312
|
+
return editorContentElement
|
|
8313
|
+
}
|
|
8314
|
+
|
|
8315
|
+
#transferAttributeToContentEditable(element, qualifiedName, { defaultValue = null, removeSource = false } = {}) {
|
|
8316
|
+
if (this.hasAttribute(qualifiedName)) {
|
|
8317
|
+
element.setAttribute(qualifiedName, this.getAttribute(qualifiedName));
|
|
8318
|
+
} else if (defaultValue !== null) {
|
|
8319
|
+
element.setAttribute(qualifiedName, defaultValue);
|
|
8109
8320
|
} else {
|
|
8110
|
-
|
|
8321
|
+
element.removeAttribute(qualifiedName);
|
|
8111
8322
|
}
|
|
8112
8323
|
|
|
8113
|
-
|
|
8324
|
+
if (removeSource) this.removeAttribute(qualifiedName);
|
|
8114
8325
|
}
|
|
8115
8326
|
|
|
8116
8327
|
get #labelText() {
|
|
@@ -8727,6 +8938,7 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
8727
8938
|
this.source = this.#createSource();
|
|
8728
8939
|
|
|
8729
8940
|
this.#addTriggerListener();
|
|
8941
|
+
this.#removePopoverBeforeTurboCaches();
|
|
8730
8942
|
this.toggleAttribute("connected", true);
|
|
8731
8943
|
}
|
|
8732
8944
|
|
|
@@ -8734,7 +8946,7 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
8734
8946
|
this.#popoverListeners.dispose();
|
|
8735
8947
|
this.#globalListeners.dispose();
|
|
8736
8948
|
this.source = null;
|
|
8737
|
-
this
|
|
8949
|
+
this.#removePopover();
|
|
8738
8950
|
}
|
|
8739
8951
|
|
|
8740
8952
|
|
|
@@ -9030,6 +9242,21 @@ class LexicalPromptElement extends HTMLElement {
|
|
|
9030
9242
|
this.#addTriggerListener();
|
|
9031
9243
|
}
|
|
9032
9244
|
|
|
9245
|
+
// The popover is appended to the <lexxy-editor> subtree, so Turbo serializes it
|
|
9246
|
+
// into the page cache. Removing it before caching prevents an orphaned, unmanaged
|
|
9247
|
+
// popover from being restored on history back/forward.
|
|
9248
|
+
#removePopoverBeforeTurboCaches() {
|
|
9249
|
+
this.#globalListeners.track(
|
|
9250
|
+
registerEventListener(document, "turbo:before-cache", () => this.#removePopover())
|
|
9251
|
+
);
|
|
9252
|
+
}
|
|
9253
|
+
|
|
9254
|
+
#removePopover() {
|
|
9255
|
+
this.#popoverListeners.dispose();
|
|
9256
|
+
this.popoverElement?.remove();
|
|
9257
|
+
this.popoverElement = null;
|
|
9258
|
+
}
|
|
9259
|
+
|
|
9033
9260
|
#filterOptions = async () => {
|
|
9034
9261
|
if (this.initialPrompt) {
|
|
9035
9262
|
this.initialPrompt = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@37signals/lexxy",
|
|
3
|
-
"version": "0.9.19-alpha.
|
|
3
|
+
"version": "0.9.19-alpha.2",
|
|
4
4
|
"description": "Lexxy - A modern rich text editor for Rails.",
|
|
5
5
|
"module": "dist/lexxy.esm.js",
|
|
6
6
|
"type": "module",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"@rollup/plugin-node-resolve": "^16.0.1",
|
|
29
29
|
"@rollup/plugin-terser": "^0.4.4",
|
|
30
30
|
"eslint": "^10.3.0",
|
|
31
|
-
"globals": "^17.6.0",
|
|
32
31
|
"eslint-plugin-compat": "^7.0.2",
|
|
32
|
+
"globals": "^17.6.0",
|
|
33
33
|
"jsdom": "^27.3.0",
|
|
34
34
|
"rollup": "^4.44.1",
|
|
35
35
|
"rollup-plugin-copy": "^3.5.0",
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
"test:browser:headed": "npx playwright test --config test/browser/playwright.config.js --headed",
|
|
53
53
|
"test:browser:debug": "npx playwright test --config test/browser/playwright.config.js --debug",
|
|
54
54
|
"prerelease": "yarn build:npm",
|
|
55
|
-
"release": "yarn build:npm && yarn publish"
|
|
55
|
+
"release": "yarn build:npm && yarn publish",
|
|
56
|
+
"release:alpha": "yarn build:npm && yarn publish --tag alpha"
|
|
56
57
|
},
|
|
57
58
|
"dependencies": {
|
|
58
59
|
"@lexical/clipboard": "^0.44.0",
|