@37signals/lexxy 0.9.28 → 0.9.29

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 +151 -33
  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, CONTROLLED_TEXT_INSERTION_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, 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';
@@ -1527,7 +1527,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1527
1527
  return Lexxy.global.get("attachmentTagName")
1528
1528
  }
1529
1529
 
1530
- constructor({ tagName, sgid, contentType, innerHtml, plainText }, key) {
1530
+ constructor({ tagName, sgid, contentType, innerHtml, plainText } = {}, key) {
1531
1531
  super(key);
1532
1532
 
1533
1533
  const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
@@ -2328,7 +2328,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
2328
2328
  return Lexxy.global.get("attachmentTagName")
2329
2329
  }
2330
2330
 
2331
- constructor({ tagName, sgid, src, previewSrc, previewable, previewStatusUrl, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
2331
+ constructor({ tagName, sgid, src, previewSrc, previewable, previewStatusUrl, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError } = {}, key) {
2332
2332
  super(key);
2333
2333
 
2334
2334
  this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
@@ -5472,7 +5472,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
5472
5472
  return null
5473
5473
  }
5474
5474
 
5475
- constructor(node, key) {
5475
+ constructor(node = {}, key) {
5476
5476
  const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node;
5477
5477
  super({ ...node, contentType: file?.type ?? contentType }, key);
5478
5478
  this.file = file ?? null;
@@ -5493,9 +5493,10 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
5493
5493
  // node is reloaded from saved state such as from history.
5494
5494
  this.#startUploadIfNeeded();
5495
5495
 
5496
- // Bridge-managed uploads (uploadUrl is null) don't have file data to show
5497
- // an image preview, so always show the file icon during upload.
5498
- const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null;
5496
+ // Bridge-managed uploads (uploadUrl is null) and restored or remote
5497
+ // instances (no local File) don't have file data to show an image
5498
+ // preview, so always show the file icon during upload.
5499
+ const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null && this.file != null;
5499
5500
  const figure = this.createAttachmentFigure(canPreviewFile);
5500
5501
 
5501
5502
  if (canPreviewFile) {
@@ -5566,9 +5567,12 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
5566
5567
  const figcaption = createElement("figcaption", { className: "attachment__caption" });
5567
5568
 
5568
5569
  const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.fileName || "" });
5569
- const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file?.size) });
5570
5570
  figcaption.appendChild(nameSpan);
5571
- figcaption.appendChild(sizeSpan);
5571
+
5572
+ if (this.file) {
5573
+ const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file.size) });
5574
+ figcaption.appendChild(sizeSpan);
5575
+ }
5572
5576
 
5573
5577
  return figcaption
5574
5578
  }
@@ -5590,6 +5594,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
5590
5594
  async #startUploadIfNeeded() {
5591
5595
  if (this.#uploadStarted) return
5592
5596
  if (!this.uploadUrl) return // Bridge-managed upload — skip DirectUpload
5597
+ if (!this.file) return // Restored or remote instance — no local File to upload
5593
5598
 
5594
5599
  this.#setUploadStarted();
5595
5600
 
@@ -6081,12 +6086,24 @@ class OfficeFormatter {
6081
6086
  }
6082
6087
  }
6083
6088
 
6089
+ // Properties worth rescuing from a foreign style sheet before #stripStyleElements
6090
+ // drops it, keyed by the elements that carry them. Word and Outlook declare
6091
+ // paragraph spacing in the sheet (p.MsoNormal { margin: 0cm }) and never inline,
6092
+ // so once the sheet goes there is nothing left to say how tightly the author's
6093
+ // paragraphs sat, and hosts that turn pasted paragraph margins into explicit
6094
+ // blank lines read tight Office paragraphs as spaced ones and multiply every
6095
+ // line break.
6096
+ const PRESERVABLE_STYLES = {
6097
+ p: [ "margin-top", "margin-bottom" ]
6098
+ };
6099
+
6084
6100
  class PastedContentFormatter {
6085
6101
  constructor(doc) {
6086
6102
  this.doc = doc;
6087
6103
  }
6088
6104
 
6089
6105
  format() {
6106
+ this.#inlinePreservableStyles();
6090
6107
  this.#stripStyleElements();
6091
6108
  this.#unwrapPlaceholderAnchors();
6092
6109
  this.#stripTableCellColorStyles();
@@ -6094,9 +6111,23 @@ class PastedContentFormatter {
6094
6111
  this.#unwrapWrappedListChildren();
6095
6112
  this.#nestStrayListChildren();
6096
6113
  this.#stripStrayListChildren();
6114
+ this.#replaceGmailEmojiImgTags();
6097
6115
  return this.doc
6098
6116
  }
6099
6117
 
6118
+ // Runs before #stripStyleElements, to inline anything we still need
6119
+ // from an external style before we remove it
6120
+ #inlinePreservableStyles() {
6121
+ const rules = this.#styleRules();
6122
+ if (rules.length === 0) return
6123
+
6124
+ for (const [ selector, properties ] of Object.entries(PRESERVABLE_STYLES)) {
6125
+ for (const element of this.doc.querySelectorAll(selector)) {
6126
+ this.#inlineDeclaredStyles(element, properties, rules);
6127
+ }
6128
+ }
6129
+ }
6130
+
6100
6131
  // Spreadsheets (e.g. Excel) copy a <style> block whose rules (td { color:
6101
6132
  // black }, .xlNN { ... }) cascade onto the imported text. That color rides
6102
6133
  // in through the cascade rather than an inline style, so it slips past both
@@ -6191,6 +6222,72 @@ class PastedContentFormatter {
6191
6222
  }
6192
6223
  }
6193
6224
 
6225
+ // Read the rules from a sheet we build rather than from this.doc.styleSheets: the
6226
+ // document came from DOMParser, so it has no browsing context, and WebKit leaves
6227
+ // its styleSheets empty. Parsing the text ourselves still expands the shorthands
6228
+ // Word writes (margin: 0cm) into the longhands we ask for.
6229
+ #styleRules() {
6230
+ const css = Array.from(this.doc.querySelectorAll("style"), (style) => style.textContent).join("\n");
6231
+ if (css.trim() === "") return []
6232
+
6233
+ const sheet = new CSSStyleSheet();
6234
+ sheet.replaceSync(css);
6235
+
6236
+ const rules = [];
6237
+ for (const rule of sheet.cssRules) {
6238
+ if (rule instanceof CSSStyleRule) {
6239
+ rules.push(rule);
6240
+ }
6241
+ }
6242
+ return rules
6243
+ }
6244
+
6245
+ // A declaration the element already carries inline is the author's own and wins.
6246
+ #inlineDeclaredStyles(element, properties, rules) {
6247
+ for (const property of properties) {
6248
+ if (element.style.getPropertyValue(property) === "") {
6249
+ const value = this.#declaredValue(rules, element, property);
6250
+ if (value) {
6251
+ this.#appendStyleProperty(element, property, value);
6252
+ }
6253
+ }
6254
+ }
6255
+ }
6256
+
6257
+ // Source order alone doesn't decide the cascade, so when the matching rules
6258
+ // disagree we can't say which value wins without resolving specificity. Skip
6259
+ // the property rather than guess: guessing is permanent, since the sheet is
6260
+ // about to go, and skipping only leaves the host on its own default.
6261
+ #declaredValue(rules, element, property) {
6262
+ const declared = new Set();
6263
+ for (const rule of rules) {
6264
+ const value = rule.style.getPropertyValue(property);
6265
+ if (value !== "" && element.matches(rule.selectorText)) {
6266
+ declared.add(value);
6267
+ }
6268
+ }
6269
+
6270
+ if (declared.size === 1) {
6271
+ return declared.values().next().value
6272
+ } else {
6273
+ return null
6274
+ }
6275
+ }
6276
+
6277
+ // Assigning through element.style would reserialize the style attribute and
6278
+ // drop the mso-* declarations Word leaves inline (they aren't valid CSS),
6279
+ // and OfficeFormatter still needs to read mso-list from them. Append to the
6280
+ // attribute instead so the original declarations survive untouched.
6281
+ #appendStyleProperty(element, property, value) {
6282
+ const style = element.getAttribute("style");
6283
+ const declaration = `${property}:${value}`;
6284
+ if (style === null || style.trim() === "") {
6285
+ element.setAttribute("style", declaration);
6286
+ } else {
6287
+ element.setAttribute("style", `${style.replace(/;\s*$/, "")};${declaration}`);
6288
+ }
6289
+ }
6290
+
6194
6291
  #firstStrayListChild(list) {
6195
6292
  for (const child of list.childNodes) {
6196
6293
  if (child.nodeType !== Node.ELEMENT_NODE || child.tagName !== "LI") {
@@ -6207,6 +6304,16 @@ class PastedContentFormatter {
6207
6304
  #containsListItems(node) {
6208
6305
  return node.nodeType === Node.ELEMENT_NODE && node.querySelector("li") !== null
6209
6306
  }
6307
+
6308
+ // Emoji in gmail: <img data-emoji="😂" class="an1" alt="😂" aria-label="😂" draggable="false" src="https://fonts.gstatic.com/s/e/notoemoji/17.0/1f602/32.png" loading="lazy" style="height: 1.2em; width: 1.2em; vertical-align: middle;">
6309
+ #replaceGmailEmojiImgTags() {
6310
+ for (const emojiImg of this.doc.querySelectorAll("img[data-emoji]")) {
6311
+ const emoji = emojiImg.dataset.emoji;
6312
+ if (emoji === emojiImg.alt) {
6313
+ emojiImg.replaceWith(emoji);
6314
+ }
6315
+ }
6316
+ }
6210
6317
  }
6211
6318
 
6212
6319
  class Contents {
@@ -6249,20 +6356,6 @@ class Contents {
6249
6356
  }, { tag });
6250
6357
  }
6251
6358
 
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
-
6266
6359
  insertAtCursor(...nodes) {
6267
6360
  const selection = this.#insertableSelection();
6268
6361
  const inserter = BaseNodeInserter.for(selection);
@@ -8908,6 +9001,39 @@ class CustomAttachmentDragAndDropExtension extends LexxyExtension {
8908
9001
  }
8909
9002
  }
8910
9003
 
9004
+ const LINE_SEPARATORS = /\r\n|[\n\r\u2028\u2029]/;
9005
+
9006
+ // A literal line separator inside a text node renders as a break in the editor, or not at all,
9007
+ // but never survives serialization to HTML. macOS text replacements deliver U+2028 LINE SEPARATOR,
9008
+ // and other insertions can carry "\n", so however the text arrives, a transform converts whatever
9009
+ // lands in a text node into line break nodes.
9010
+ class LineSeparatorsExtension extends LexxyExtension {
9011
+ get lexicalExtension() {
9012
+ return this.defineExtension({
9013
+ name: "lexxy/line-separators",
9014
+ register(editor) {
9015
+ return editor.registerNodeTransform(TextNode, $replaceLineSeparatorWithLineBreak)
9016
+ }
9017
+ })
9018
+ }
9019
+ }
9020
+
9021
+ // Replaces the first separator only: the split leaves dirty text nodes behind, so Lexical re-runs
9022
+ // the transform until none remain.
9023
+ function $replaceLineSeparatorWithLineBreak(textNode) {
9024
+ const match = textNode.getTextContent().match(LINE_SEPARATORS);
9025
+
9026
+ if (match) {
9027
+ let separatorNode;
9028
+ if (match.index === 0) {
9029
+ [ separatorNode ] = textNode.splitText(match[0].length);
9030
+ } else {
9031
+ [ , separatorNode ] = textNode.splitText(match.index, match.index + match[0].length);
9032
+ }
9033
+ separatorNode.replace($createLineBreakNode());
9034
+ }
9035
+ }
9036
+
8911
9037
  class LexicalEditorElement extends HTMLElement {
8912
9038
  static formAssociated = true
8913
9039
  static debug = false
@@ -9066,7 +9192,8 @@ class LexicalEditorElement extends HTMLElement {
9066
9192
  FormatEscapeExtension,
9067
9193
  LinkOpenerExtension,
9068
9194
  PreventLexicalTripleClickExtension,
9069
- CustomAttachmentDragAndDropExtension
9195
+ CustomAttachmentDragAndDropExtension,
9196
+ LineSeparatorsExtension
9070
9197
  ]
9071
9198
  }
9072
9199
 
@@ -9254,7 +9381,6 @@ class LexicalEditorElement extends HTMLElement {
9254
9381
  #initialize() {
9255
9382
  this.#registerComponents();
9256
9383
  this.#handleEnter();
9257
- this.#handleReplacementText();
9258
9384
  this.#registerFocusEvents();
9259
9385
  this.#registerHistoryEvents();
9260
9386
  this.#registerFileAcceptFilter();
@@ -9518,14 +9644,6 @@ class LexicalEditorElement extends HTMLElement {
9518
9644
  ));
9519
9645
  }
9520
9646
 
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
-
9529
9647
  #registerFocusEvents() {
9530
9648
  this.#listeners.track(
9531
9649
  registerEventListener(this, "focusin", this.#handleFocusIn),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.28",
3
+ "version": "0.9.29",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",