@37signals/lexxy 0.9.27 → 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 +168 -1
  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, 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';
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';
@@ -5938,6 +5938,149 @@ BaseNodeInserter.for = (selection) => {
5938
5938
  return inserterClass ? new inserterClass(selection) : selection
5939
5939
  };
5940
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
+
5941
6084
  class PastedContentFormatter {
5942
6085
  constructor(doc) {
5943
6086
  this.doc = doc;
@@ -5947,6 +6090,7 @@ class PastedContentFormatter {
5947
6090
  this.#stripStyleElements();
5948
6091
  this.#unwrapPlaceholderAnchors();
5949
6092
  this.#stripTableCellColorStyles();
6093
+ new OfficeFormatter(this.doc).format();
5950
6094
  this.#unwrapWrappedListChildren();
5951
6095
  this.#nestStrayListChildren();
5952
6096
  this.#stripStrayListChildren();
@@ -6105,6 +6249,20 @@ class Contents {
6105
6249
  }, { tag });
6106
6250
  }
6107
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
+
6108
6266
  insertAtCursor(...nodes) {
6109
6267
  const selection = this.#insertableSelection();
6110
6268
  const inserter = BaseNodeInserter.for(selection);
@@ -9096,6 +9254,7 @@ class LexicalEditorElement extends HTMLElement {
9096
9254
  #initialize() {
9097
9255
  this.#registerComponents();
9098
9256
  this.#handleEnter();
9257
+ this.#handleReplacementText();
9099
9258
  this.#registerFocusEvents();
9100
9259
  this.#registerHistoryEvents();
9101
9260
  this.#registerFileAcceptFilter();
@@ -9359,6 +9518,14 @@ class LexicalEditorElement extends HTMLElement {
9359
9518
  ));
9360
9519
  }
9361
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
+
9362
9529
  #registerFocusEvents() {
9363
9530
  this.#listeners.track(
9364
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.27",
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",