@37signals/lexxy 0.9.26 → 0.9.28

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 +193 -2
  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, 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, CONTROLLED_TEXT_INSERTION_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
@@ -5914,6 +5938,149 @@ BaseNodeInserter.for = (selection) => {
5914
5938
  return inserterClass ? new inserterClass(selection) : selection
5915
5939
  };
5916
5940
 
5941
+ // Word and Outlook never emit <ul>/<ol>/<li>. They simulate lists with flat
5942
+ // paragraphs that declare their depth in an inline `mso-list:l0 level2 lfo1`
5943
+ // and carry the bullet or number glyph in a <span style="mso-list:Ignore">.
5944
+ // Nothing downstream understands either, so the items land as plain paragraphs
5945
+ // with the marker baked into the text. Rebuild real lists from the level each
5946
+ // paragraph declares.
5947
+ //
5948
+ // Everything this reads lives in inline attributes, so it stays correct after
5949
+ // the pasted content formatter has dropped Office's style sheet.
5950
+ class OfficeFormatter {
5951
+ constructor(doc) {
5952
+ this.doc = doc;
5953
+ }
5954
+
5955
+ format() {
5956
+ for (const run of this.#listRuns()) {
5957
+ this.#replaceWithLists(run);
5958
+ }
5959
+ }
5960
+
5961
+ // A run is a stretch of Office list paragraphs that are siblings with nothing
5962
+ // but whitespace and Word's conditional comments between them. Anything else
5963
+ // in between (a heading, a plain paragraph) ends one list and starts another.
5964
+ #listRuns() {
5965
+ const runs = [];
5966
+ const claimed = new Set();
5967
+
5968
+ for (const paragraph of this.doc.querySelectorAll("p[style*='mso-list']")) {
5969
+ if (claimed.has(paragraph) || this.#listLevel(paragraph) === null) continue
5970
+
5971
+ const run = [];
5972
+ let node = paragraph;
5973
+ while (node) {
5974
+ run.push(node);
5975
+ claimed.add(node);
5976
+ node = this.#nextListParagraph(node);
5977
+ }
5978
+ runs.push(run);
5979
+ }
5980
+
5981
+ return runs
5982
+ }
5983
+
5984
+ #nextListParagraph(paragraph) {
5985
+ let sibling = paragraph.nextSibling;
5986
+ while (sibling && this.#isIgnorableBetweenListParagraphs(sibling)) {
5987
+ sibling = sibling.nextSibling;
5988
+ }
5989
+
5990
+ if (sibling && sibling.nodeType === Node.ELEMENT_NODE && this.#listLevel(sibling) !== null) {
5991
+ return sibling
5992
+ }
5993
+
5994
+ return null
5995
+ }
5996
+
5997
+ #isIgnorableBetweenListParagraphs(node) {
5998
+ if (node.nodeType === Node.COMMENT_NODE) {
5999
+ return true
6000
+ }
6001
+ return node.nodeType === Node.TEXT_NODE && node.textContent.trim() === ""
6002
+ }
6003
+
6004
+ // Word numbers levels from 1, but a copied selection often starts partway down
6005
+ // a list, so every paragraph declares level3 with no level1 above it. Anchoring
6006
+ // the run at its own shallowest level is what keeps the result from arriving
6007
+ // needlessly deep.
6008
+ #replaceWithLists(paragraphs) {
6009
+ const levels = paragraphs.map((paragraph) => this.#listLevel(paragraph));
6010
+ const baseLevel = Math.min(...levels);
6011
+ const roots = [];
6012
+ const stack = [];
6013
+
6014
+ paragraphs.forEach((paragraph, index) => {
6015
+ const depth = levels[index] - baseLevel + 1;
6016
+ const tagName = this.#listTagName(paragraph);
6017
+
6018
+ while (stack.length > depth) {
6019
+ stack.pop();
6020
+ }
6021
+ if (stack.length === depth && stack[depth - 1].tagName !== tagName.toUpperCase()) {
6022
+ stack.pop();
6023
+ }
6024
+
6025
+ while (stack.length < depth) {
6026
+ const list = this.doc.createElement(tagName);
6027
+ if (stack.length === 0) {
6028
+ roots.push(list);
6029
+ } else {
6030
+ this.#lastItemOf(stack[stack.length - 1]).appendChild(list);
6031
+ }
6032
+ stack.push(list);
6033
+ }
6034
+
6035
+ stack[stack.length - 1].appendChild(this.#createListItem(paragraph));
6036
+ });
6037
+
6038
+ paragraphs[0].replaceWith(...roots);
6039
+ for (const paragraph of paragraphs.slice(1)) {
6040
+ paragraph.remove();
6041
+ }
6042
+ }
6043
+
6044
+ #lastItemOf(list) {
6045
+ if (!list.lastElementChild) {
6046
+ list.appendChild(this.doc.createElement("li"));
6047
+ }
6048
+ return list.lastElementChild
6049
+ }
6050
+
6051
+ // Word writes bullets as the raw glyph of the marker font (Symbol's "·",
6052
+ // Wingdings' "§", Courier New's "o") and numbers as the rendered counter
6053
+ // ("1.", "a)", "iv)"). A bare letter with no delimiter after it is Courier
6054
+ // New's bullet, not a counter, so the delimiter is what tells them apart.
6055
+ #listTagName(paragraph) {
6056
+ const marker = paragraph.querySelector("[style*='mso-list:Ignore']");
6057
+ const text = marker?.textContent || "";
6058
+
6059
+ if (/\d/.test(text) || /^\(?[A-Za-z]+[.)]/.test(text)) {
6060
+ return "ol"
6061
+ }
6062
+ return "ul"
6063
+ }
6064
+
6065
+ #createListItem(paragraph) {
6066
+ for (const marker of paragraph.querySelectorAll("[style*='mso-list:Ignore']")) {
6067
+ marker.remove();
6068
+ }
6069
+
6070
+ const item = this.doc.createElement("li");
6071
+ item.append(...paragraph.childNodes);
6072
+ return item
6073
+ }
6074
+
6075
+ #listLevel(element) {
6076
+ const match = /mso-list:[^;"']*\blevel(\d+)/.exec(element.getAttribute("style") || "");
6077
+ if (match) {
6078
+ return parseInt(match[1], 10)
6079
+ }
6080
+ return null
6081
+ }
6082
+ }
6083
+
5917
6084
  class PastedContentFormatter {
5918
6085
  constructor(doc) {
5919
6086
  this.doc = doc;
@@ -5923,6 +6090,7 @@ class PastedContentFormatter {
5923
6090
  this.#stripStyleElements();
5924
6091
  this.#unwrapPlaceholderAnchors();
5925
6092
  this.#stripTableCellColorStyles();
6093
+ new OfficeFormatter(this.doc).format();
5926
6094
  this.#unwrapWrappedListChildren();
5927
6095
  this.#nestStrayListChildren();
5928
6096
  this.#stripStrayListChildren();
@@ -6081,6 +6249,20 @@ class Contents {
6081
6249
  }, { tag });
6082
6250
  }
6083
6251
 
6252
+ // A literal "\n" stays visible in the editor, which Lexical renders with white-space: pre-wrap,
6253
+ // but collapses to a space once serialized to HTML, so it has to become line break nodes.
6254
+ insertTextWithLineBreaks(text) {
6255
+ const normalizedText = text?.replace(/\r\n?/g, "\n");
6256
+ const selection = $getSelection();
6257
+
6258
+ if (normalizedText?.includes("\n") && $isRangeSelection(selection)) {
6259
+ selection.insertRawText(normalizedText);
6260
+ return true
6261
+ } else {
6262
+ return false
6263
+ }
6264
+ }
6265
+
6084
6266
  insertAtCursor(...nodes) {
6085
6267
  const selection = this.#insertableSelection();
6086
6268
  const inserter = BaseNodeInserter.for(selection);
@@ -9072,6 +9254,7 @@ class LexicalEditorElement extends HTMLElement {
9072
9254
  #initialize() {
9073
9255
  this.#registerComponents();
9074
9256
  this.#handleEnter();
9257
+ this.#handleReplacementText();
9075
9258
  this.#registerFocusEvents();
9076
9259
  this.#registerHistoryEvents();
9077
9260
  this.#registerFileAcceptFilter();
@@ -9335,6 +9518,14 @@ class LexicalEditorElement extends HTMLElement {
9335
9518
  ));
9336
9519
  }
9337
9520
 
9521
+ #handleReplacementText() {
9522
+ this.#listeners.track(this.editor.registerCommand(
9523
+ CONTROLLED_TEXT_INSERTION_COMMAND,
9524
+ (event) => event instanceof InputEvent && this.contents.insertTextWithLineBreaks(event.data),
9525
+ COMMAND_PRIORITY_NORMAL
9526
+ ));
9527
+ }
9528
+
9338
9529
  #registerFocusEvents() {
9339
9530
  this.#listeners.track(
9340
9531
  registerEventListener(this, "focusin", this.#handleFocusIn),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.26",
3
+ "version": "0.9.28",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",