@37signals/lexxy 0.9.18 → 0.9.19-alpha.1

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.
Files changed (2) hide show
  1. package/dist/lexxy.esm.js +99 -16
  2. package/package.json +1 -1
package/dist/lexxy.esm.js CHANGED
@@ -1,7 +1,7 @@
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, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_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';
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, $normalizeSelection__EXPERIMENTAL, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_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
7
  import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
@@ -4031,9 +4031,12 @@ class Selection {
4031
4031
  }
4032
4032
 
4033
4033
  get isOnPreviewableImage() {
4034
- const selection = $getSelection();
4035
- const firstNode = selection?.getNodes().at(0);
4036
- return $isActionTextAttachmentNode(firstNode) && firstNode.isPreviewableImage
4034
+ return this.previewableImageNode != null
4035
+ }
4036
+
4037
+ get previewableImageNode() {
4038
+ const firstNode = $getSelection()?.getNodes().at(0);
4039
+ return $isActionTextAttachmentNode(firstNode) && firstNode.isPreviewableImage ? firstNode : null
4037
4040
  }
4038
4041
 
4039
4042
  get isAtNodeStart() {
@@ -5008,7 +5011,10 @@ class GalleryUploader extends Uploader {
5008
5011
 
5009
5012
  #findOrCreateGallery() {
5010
5013
  if (this.selection.isOnPreviewableImage) {
5011
- this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode);
5014
+ // Resolve from the previewable image itself (the selection's first node), not from
5015
+ // #selectedNode (the anchor) — those differ when the selection runs from an image
5016
+ // into following text, and the anchor text node can't join a gallery (returns null).
5017
+ this.#gallery = $findOrCreateGalleryForImage(this.selection.previewableImageNode);
5012
5018
  } else if (this.#selectionIsAfterGalleryEdge) {
5013
5019
  this.#gallery = $findOrCreateGalleryForImage(this.selection.nodeBeforeCursor);
5014
5020
  } else {
@@ -5345,7 +5351,8 @@ class NodeInserter {
5345
5351
  const INSERTERS = [
5346
5352
  CodeNodeInserter,
5347
5353
  ShadowRootNodeInserter,
5348
- NodeSelectionNodeInserter
5354
+ NodeSelectionNodeInserter,
5355
+ BlockContainerNodeInserter
5349
5356
  ];
5350
5357
  const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
5351
5358
  return Inserter ? new Inserter(selection) : selection
@@ -5371,14 +5378,40 @@ class CodeNodeInserter extends NodeInserter {
5371
5378
 
5372
5379
  const caret = $getChildCaretAtIndex(codeNode, insertionIndex + 1, "previous");
5373
5380
 
5381
+ // Nodes that are already in the document come from the format-toggle path (existing
5382
+ // content converted into this code block). Brand-new nodes (dropped/pasted content)
5383
+ // were never attached.
5384
+ const existingNodes = new Set(nodes.filter(node => node.isAttached()));
5385
+ const trailingNodes = [];
5386
+
5374
5387
  for (const node of nodes) {
5375
- if (!node.isAttached()) continue
5376
- if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
5388
+ if (existingNodes.has(node)) {
5389
+ if (!node.isAttached()) continue // already pulled in when a converted ancestor was removed
5390
+ } else if (!this.#canJoinCodeBlock(node)) {
5391
+ trailingNodes.push(node); // e.g. a dropped attachment, which a code block can't hold
5392
+ continue
5393
+ }
5377
5394
 
5395
+ if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
5378
5396
  caret.insert(this.#convertNodeToCodeChild(node));
5379
5397
  }
5380
5398
 
5381
- caret.getNodeAtCaret().selectEnd();
5399
+ const lastTrailingNode = this.#insertAfterCodeBlock(codeNode, trailingNodes);
5400
+ const nodeToSelect = lastTrailingNode ?? caret.getNodeAtCaret();
5401
+ nodeToSelect?.selectEnd();
5402
+ }
5403
+
5404
+ #canJoinCodeBlock(node) {
5405
+ return $isTextNode(node) || $isLineBreakNode(node)
5406
+ }
5407
+
5408
+ #insertAfterCodeBlock(codeNode, nodes) {
5409
+ let previousNode = codeNode;
5410
+ for (const node of nodes) {
5411
+ previousNode.insertAfter(node);
5412
+ previousNode = node;
5413
+ }
5414
+ return nodes.at(-1)
5382
5415
  }
5383
5416
 
5384
5417
  #convertNodeToCodeChild(node) {
@@ -5394,7 +5427,7 @@ class CodeNodeInserter extends NodeInserter {
5394
5427
 
5395
5428
  class ShadowRootNodeInserter extends NodeInserter {
5396
5429
  static handles(selection) {
5397
- return $isShadowRoot(selection?.anchor.getNode())
5430
+ return $isShadowRoot(selection?.anchor?.getNode())
5398
5431
  }
5399
5432
 
5400
5433
  insertNodes(nodes) {
@@ -5423,6 +5456,31 @@ class NodeSelectionNodeInserter extends NodeInserter {
5423
5456
  }
5424
5457
  }
5425
5458
 
5459
+ // Lexical's RangeSelection.insertNodes requires every selection point to have a block
5460
+ // ancestor with inline children. An element point on a container of block nodes — e.g.
5461
+ // a quote holding paragraphs — has none, so Lexical throws invariant #211 or #212.
5462
+ // Descend such points to a leaf position before inserting.
5463
+ class BlockContainerNodeInserter extends NodeInserter {
5464
+ static handles(selection) {
5465
+ return $isRangeSelection(selection) &&
5466
+ [ selection.anchor, selection.focus ].some($isPointOnBlockContainer)
5467
+ }
5468
+
5469
+ insertNodes(nodes) {
5470
+ $normalizeSelection__EXPERIMENTAL(this.selection);
5471
+ this.selection.insertNodes(nodes);
5472
+ }
5473
+ }
5474
+
5475
+ function $isPointOnBlockContainer(point) {
5476
+ if (point.type === "element") {
5477
+ const firstChild = point.getNode().getFirstChild();
5478
+ return ($isElementNode(firstChild) || $isDecoratorNode(firstChild)) && !firstChild.isInline()
5479
+ } else {
5480
+ return false
5481
+ }
5482
+ }
5483
+
5426
5484
  class Contents {
5427
5485
  constructor(editorElement) {
5428
5486
  this.editorElement = editorElement;
@@ -5519,7 +5577,7 @@ class Contents {
5519
5577
  blockElements.forEach(node => this.#unwrapCodeBlock(node));
5520
5578
  } else {
5521
5579
  $expandSelectionToLineBreaksAndSplitAtEdges(selection);
5522
- const elements = this.#blockLevelElementsInSelection(selection);
5580
+ const elements = this.#outermostElements(this.#blockLevelElementsInSelection(selection));
5523
5581
  if (elements.length === 0) return
5524
5582
 
5525
5583
  const codeNode = $createCodeNode("plain");
@@ -5655,6 +5713,8 @@ class Contents {
5655
5713
  }
5656
5714
 
5657
5715
  uploadFiles(files, { selectLast } = {}) {
5716
+ if (!this.editorElement) return // Disposed (e.g. on turbo:before-cache); a late drop can still land here
5717
+
5658
5718
  if (!this.editorElement.supportsAttachments) {
5659
5719
  console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
5660
5720
  return
@@ -5845,6 +5905,18 @@ class Contents {
5845
5905
  return Array.from(elements)
5846
5906
  }
5847
5907
 
5908
+ // Selections spanning nested structures (a quote and its inner paragraphs,
5909
+ // nested list items) yield both an element and its ancestor. Converting the
5910
+ // ancestor detaches its whole subtree — including a node freshly inserted
5911
+ // inside it — which can leave the selection on removed nodes (Lexical
5912
+ // invariant #19). The outermost elements already cover their descendants'
5913
+ // text content, so keep only those.
5914
+ #outermostElements(elements) {
5915
+ return elements.filter((element) => {
5916
+ return elements.every((other) => other === element || !element.getParents().includes(other))
5917
+ })
5918
+ }
5919
+
5848
5920
  #insertUploadNodes(nodes) {
5849
5921
  if (nodes.every($isActionTextAttachmentNode)) {
5850
5922
  const uploader = Uploader.for(this.editorElement, []);
@@ -7372,12 +7444,23 @@ class EarlyEscapeListItemNode extends ListItemNode {
7372
7444
  return super.insertNewAfter(selection, restoreSelection)
7373
7445
  }
7374
7446
 
7447
+ get #isInBlockquote() {
7448
+ return Boolean($getNearestNodeOfType(this, QuoteNode))
7449
+ }
7450
+
7375
7451
  #shouldEscape(selection) {
7376
- if (!$getNearestNodeOfType(this, QuoteNode)) return false
7377
- if ($isBlankNode(this)) return true
7452
+ if (this.#isInPasteOperation() || !this.#isInBlockquote) {
7453
+ return false
7454
+ } else if ($isBlankNode(this)) {
7455
+ return true
7456
+ } else {
7457
+ const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode);
7458
+ return paragraph && $isBlankNode(paragraph) && $isListItemNode(paragraph.getParent())
7459
+ }
7460
+ }
7378
7461
 
7379
- const paragraph = $getNearestNodeOfType(selection.anchor.getNode(), ParagraphNode);
7380
- return paragraph && $isBlankNode(paragraph) && $isListItemNode(paragraph.getParent())
7462
+ #isInPasteOperation() {
7463
+ return $hasUpdateTag(PASTE_TAG)
7381
7464
  }
7382
7465
 
7383
7466
  #escapeFromList() {
@@ -7425,7 +7508,7 @@ class FormatEscapeExtension extends LexxyExtension {
7425
7508
  }
7426
7509
 
7427
7510
  get allowedElements() {
7428
- return [ { tag: "li", attributes: [ "value" ] } ]
7511
+ return [ { tag: "ol", attributes: [ "start" ] }, { tag: "li", attributes: [ "value" ] } ]
7429
7512
  }
7430
7513
 
7431
7514
  get lexicalExtension() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.18",
3
+ "version": "0.9.19-alpha.1",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",