@37signals/lexxy 0.9.25 → 0.9.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,7 @@ A modern rich text editor for Rails.
21
21
 
22
22
  - Bug reports and pull requests are welcome on [GitHub Issues](https://github.com/basecamp/lexxy/issues). Help is especially welcome with [those tagged as "Help Wanted"](https://github.com/basecamp/lexxy/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22).
23
23
  - For questions and general Lexxy discussion, please use the [Discussions section](https://github.com/basecamp/lexxy/discussions)
24
+ - To build with a development version of Lexical, use `bin/link-to-local-lexical`.
24
25
 
25
26
  ## License
26
27
 
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, defineExtension, $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, 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, $createRangeSelectionFromDom, 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, defineExtension, $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, 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, COMMAND_PRIORITY_CRITICAL, $addUpdateTag, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, ElementNode, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, DRAGSTART_COMMAND, DROP_COMMAND, mergeRegister as mergeRegister$1, $createRangeSelectionFromDom, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
5
5
  import * as lexical from 'lexical';
6
6
  export { lexical as Lexical };
7
7
  import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
@@ -4523,10 +4523,34 @@ class Selection {
4523
4523
 
4524
4524
  this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
4525
4525
  this.#syncSelectedClasses();
4526
- }, COMMAND_PRIORITY_LOW)
4526
+ }, COMMAND_PRIORITY_LOW),
4527
+
4528
+ this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
4529
+ this.#preserveFocusOfOtherFields();
4530
+ return false
4531
+ }, COMMAND_PRIORITY_CRITICAL)
4527
4532
  );
4528
4533
  }
4529
4534
 
4535
+ // Firefox (152+) keeps window.getSelection() anchored inside the editor after focus
4536
+ // moves to another field, and fires document selectionchange for every keystroke
4537
+ // typed there. Lexical reads that stale selection, reconciles, and pulls focus back
4538
+ // into the editor — stealing it mid-typing. When another text-entry field owns focus,
4539
+ // tag the update so Lexical leaves the DOM selection (and with it, focus) alone.
4540
+ #preserveFocusOfOtherFields() {
4541
+ if (this.#anotherFieldIsFocused) {
4542
+ $addUpdateTag(SKIP_DOM_SELECTION_TAG);
4543
+ }
4544
+ }
4545
+
4546
+ get #anotherFieldIsFocused() {
4547
+ const rootElement = this.editor.getRootElement();
4548
+ const activeElement = rootElement?.ownerDocument.activeElement;
4549
+
4550
+ return activeElement && !rootElement.contains(activeElement) &&
4551
+ (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.isContentEditable)
4552
+ }
4553
+
4530
4554
  #listenForNodeSelections() {
4531
4555
  this.#listeners.track(this.editor.registerCommand(CLICK_COMMAND, ({ target }) => {
4532
4556
  if (!isDOMNode(target)) return false
@@ -5923,6 +5947,7 @@ class PastedContentFormatter {
5923
5947
  this.#stripStyleElements();
5924
5948
  this.#unwrapPlaceholderAnchors();
5925
5949
  this.#stripTableCellColorStyles();
5950
+ this.#unwrapWrappedListChildren();
5926
5951
  this.#nestStrayListChildren();
5927
5952
  this.#stripStrayListChildren();
5928
5953
  return this.doc
@@ -5964,6 +5989,29 @@ class PastedContentFormatter {
5964
5989
  }
5965
5990
  }
5966
5991
 
5992
+ // Some sources wrap runs of <li>s in a stray element (e.g. a <div> directly
5993
+ // inside the list). Dissolve such wrappers so their items become direct
5994
+ // list children and any nested list they hide becomes visible to
5995
+ // #nestStrayListChildren below.
5996
+ #unwrapWrappedListChildren() {
5997
+ for (const list of this.doc.querySelectorAll("ol, ul")) {
5998
+ let wrapper = this.#wrappedListChild(list);
5999
+ while (wrapper) {
6000
+ wrapper.replaceWith(...wrapper.childNodes);
6001
+ wrapper = this.#wrappedListChild(list);
6002
+ }
6003
+ }
6004
+ }
6005
+
6006
+ #wrappedListChild(list) {
6007
+ for (const child of list.children) {
6008
+ if (child.tagName !== "LI" && !this.#isNestedList(child) && this.#containsListItems(child)) {
6009
+ return child
6010
+ }
6011
+ }
6012
+ return null
6013
+ }
6014
+
5967
6015
  // Some sources (e.g. Gmail) nest a sublist as a direct child of the parent
5968
6016
  // <ol>/<ul> instead of inside a <li>. Move each nested list into its
5969
6017
  // preceding <li> so the import preserves the nesting instead of dropping it.
@@ -5980,16 +6028,41 @@ class PastedContentFormatter {
5980
6028
  }
5981
6029
  }
5982
6030
 
5983
- // Only <li> is a valid child of a list; drop stray <br>/whitespace so the
5984
- // import doesn't wrap them into an empty leading item.
6031
+ // Only <li> is a valid child of a list. Unwrap remaining stray children
6032
+ // that still hold list items (a nested list with no preceding <li> to nest
6033
+ // under) so the items survive, and drop stray <br>/whitespace so the import
6034
+ // doesn't wrap them into an empty leading item.
5985
6035
  #stripStrayListChildren() {
5986
6036
  for (const list of this.doc.querySelectorAll("ol, ul")) {
5987
- for (const child of Array.from(list.childNodes)) {
5988
- if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "LI") continue
5989
- list.removeChild(child);
6037
+ let stray = this.#firstStrayListChild(list);
6038
+ while (stray) {
6039
+ if (this.#containsListItems(stray)) {
6040
+ stray.replaceWith(...stray.childNodes);
6041
+ } else {
6042
+ stray.remove();
6043
+ }
6044
+
6045
+ stray = this.#firstStrayListChild(list);
5990
6046
  }
5991
6047
  }
5992
6048
  }
6049
+
6050
+ #firstStrayListChild(list) {
6051
+ for (const child of list.childNodes) {
6052
+ if (child.nodeType !== Node.ELEMENT_NODE || child.tagName !== "LI") {
6053
+ return child
6054
+ }
6055
+ }
6056
+ return null
6057
+ }
6058
+
6059
+ #isNestedList(node) {
6060
+ return node.nodeType === Node.ELEMENT_NODE && (node.tagName === "OL" || node.tagName === "UL")
6061
+ }
6062
+
6063
+ #containsListItems(node) {
6064
+ return node.nodeType === Node.ELEMENT_NODE && node.querySelector("li") !== null
6065
+ }
5993
6066
  }
5994
6067
 
5995
6068
  class Contents {
@@ -9,6 +9,9 @@
9
9
  }
10
10
  --lexxy-toolbar-gap: 2px;
11
11
  --lexxy-toolbar-spacing: 0.5ch;
12
+ --lexxy-toolbar-padding: 2px;
13
+ --lexxy-toolbar-border-size: 1px;
14
+ --lexxy-toolbar-height: calc(var(--lexxy-toolbar-button-size) + 2 * var(--lexxy-toolbar-padding) + var(--lexxy-toolbar-border-size));
12
15
 
13
16
  border: 1px solid var(--lexxy-color-ink-lighter);
14
17
  border-radius: calc(var(--lexxy-radius) + var(--lexxy-toolbar-gap));
@@ -408,6 +411,11 @@
408
411
  }
409
412
  }
410
413
 
414
+ /* Reserve the mounted height before upgrade so it doesn't shift layout on mount. */
415
+ :where(lexxy-editor:not(:defined)) {
416
+ min-block-size: calc(var(--lexxy-toolbar-height) + var(--lexxy-editor-rows) + 2 * var(--lexxy-editor-padding));
417
+ }
418
+
411
419
  /* Placeholder */
412
420
  :where(.lexxy-editor--empty) {
413
421
  .lexxy-editor__content:not(:has(h1, h2, h3, h4, h5, h6, table, ul, ol, figure, .attachment-gallery))::before {
@@ -461,13 +469,13 @@
461
469
  :where(lexxy-toolbar) {
462
470
  --lexxy-toolbar-icon-size: 1.125em;
463
471
 
464
- border-block-end: 1px solid var(--lexxy-color-ink-lighter);
472
+ border-block-end: var(--lexxy-toolbar-border-size) solid var(--lexxy-color-ink-lighter);
465
473
  color: currentColor;
466
474
  display: flex;
467
475
  font-size: inherit;
468
476
  gap: var(--lexxy-toolbar-gap);
469
477
  max-inline-size: 100%;
470
- padding: 2px;
478
+ padding: var(--lexxy-toolbar-padding);
471
479
  position: relative;
472
480
 
473
481
  @container (width < 300px) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.25",
3
+ "version": "0.9.27",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",