@37signals/lexxy 0.9.27 → 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 +294 -9
  2. package/package.json +1 -1
package/dist/lexxy.esm.js CHANGED
@@ -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
 
@@ -5938,21 +5943,191 @@ BaseNodeInserter.for = (selection) => {
5938
5943
  return inserterClass ? new inserterClass(selection) : selection
5939
5944
  };
5940
5945
 
5946
+ // Word and Outlook never emit <ul>/<ol>/<li>. They simulate lists with flat
5947
+ // paragraphs that declare their depth in an inline `mso-list:l0 level2 lfo1`
5948
+ // and carry the bullet or number glyph in a <span style="mso-list:Ignore">.
5949
+ // Nothing downstream understands either, so the items land as plain paragraphs
5950
+ // with the marker baked into the text. Rebuild real lists from the level each
5951
+ // paragraph declares.
5952
+ //
5953
+ // Everything this reads lives in inline attributes, so it stays correct after
5954
+ // the pasted content formatter has dropped Office's style sheet.
5955
+ class OfficeFormatter {
5956
+ constructor(doc) {
5957
+ this.doc = doc;
5958
+ }
5959
+
5960
+ format() {
5961
+ for (const run of this.#listRuns()) {
5962
+ this.#replaceWithLists(run);
5963
+ }
5964
+ }
5965
+
5966
+ // A run is a stretch of Office list paragraphs that are siblings with nothing
5967
+ // but whitespace and Word's conditional comments between them. Anything else
5968
+ // in between (a heading, a plain paragraph) ends one list and starts another.
5969
+ #listRuns() {
5970
+ const runs = [];
5971
+ const claimed = new Set();
5972
+
5973
+ for (const paragraph of this.doc.querySelectorAll("p[style*='mso-list']")) {
5974
+ if (claimed.has(paragraph) || this.#listLevel(paragraph) === null) continue
5975
+
5976
+ const run = [];
5977
+ let node = paragraph;
5978
+ while (node) {
5979
+ run.push(node);
5980
+ claimed.add(node);
5981
+ node = this.#nextListParagraph(node);
5982
+ }
5983
+ runs.push(run);
5984
+ }
5985
+
5986
+ return runs
5987
+ }
5988
+
5989
+ #nextListParagraph(paragraph) {
5990
+ let sibling = paragraph.nextSibling;
5991
+ while (sibling && this.#isIgnorableBetweenListParagraphs(sibling)) {
5992
+ sibling = sibling.nextSibling;
5993
+ }
5994
+
5995
+ if (sibling && sibling.nodeType === Node.ELEMENT_NODE && this.#listLevel(sibling) !== null) {
5996
+ return sibling
5997
+ }
5998
+
5999
+ return null
6000
+ }
6001
+
6002
+ #isIgnorableBetweenListParagraphs(node) {
6003
+ if (node.nodeType === Node.COMMENT_NODE) {
6004
+ return true
6005
+ }
6006
+ return node.nodeType === Node.TEXT_NODE && node.textContent.trim() === ""
6007
+ }
6008
+
6009
+ // Word numbers levels from 1, but a copied selection often starts partway down
6010
+ // a list, so every paragraph declares level3 with no level1 above it. Anchoring
6011
+ // the run at its own shallowest level is what keeps the result from arriving
6012
+ // needlessly deep.
6013
+ #replaceWithLists(paragraphs) {
6014
+ const levels = paragraphs.map((paragraph) => this.#listLevel(paragraph));
6015
+ const baseLevel = Math.min(...levels);
6016
+ const roots = [];
6017
+ const stack = [];
6018
+
6019
+ paragraphs.forEach((paragraph, index) => {
6020
+ const depth = levels[index] - baseLevel + 1;
6021
+ const tagName = this.#listTagName(paragraph);
6022
+
6023
+ while (stack.length > depth) {
6024
+ stack.pop();
6025
+ }
6026
+ if (stack.length === depth && stack[depth - 1].tagName !== tagName.toUpperCase()) {
6027
+ stack.pop();
6028
+ }
6029
+
6030
+ while (stack.length < depth) {
6031
+ const list = this.doc.createElement(tagName);
6032
+ if (stack.length === 0) {
6033
+ roots.push(list);
6034
+ } else {
6035
+ this.#lastItemOf(stack[stack.length - 1]).appendChild(list);
6036
+ }
6037
+ stack.push(list);
6038
+ }
6039
+
6040
+ stack[stack.length - 1].appendChild(this.#createListItem(paragraph));
6041
+ });
6042
+
6043
+ paragraphs[0].replaceWith(...roots);
6044
+ for (const paragraph of paragraphs.slice(1)) {
6045
+ paragraph.remove();
6046
+ }
6047
+ }
6048
+
6049
+ #lastItemOf(list) {
6050
+ if (!list.lastElementChild) {
6051
+ list.appendChild(this.doc.createElement("li"));
6052
+ }
6053
+ return list.lastElementChild
6054
+ }
6055
+
6056
+ // Word writes bullets as the raw glyph of the marker font (Symbol's "·",
6057
+ // Wingdings' "§", Courier New's "o") and numbers as the rendered counter
6058
+ // ("1.", "a)", "iv)"). A bare letter with no delimiter after it is Courier
6059
+ // New's bullet, not a counter, so the delimiter is what tells them apart.
6060
+ #listTagName(paragraph) {
6061
+ const marker = paragraph.querySelector("[style*='mso-list:Ignore']");
6062
+ const text = marker?.textContent || "";
6063
+
6064
+ if (/\d/.test(text) || /^\(?[A-Za-z]+[.)]/.test(text)) {
6065
+ return "ol"
6066
+ }
6067
+ return "ul"
6068
+ }
6069
+
6070
+ #createListItem(paragraph) {
6071
+ for (const marker of paragraph.querySelectorAll("[style*='mso-list:Ignore']")) {
6072
+ marker.remove();
6073
+ }
6074
+
6075
+ const item = this.doc.createElement("li");
6076
+ item.append(...paragraph.childNodes);
6077
+ return item
6078
+ }
6079
+
6080
+ #listLevel(element) {
6081
+ const match = /mso-list:[^;"']*\blevel(\d+)/.exec(element.getAttribute("style") || "");
6082
+ if (match) {
6083
+ return parseInt(match[1], 10)
6084
+ }
6085
+ return null
6086
+ }
6087
+ }
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
+
5941
6100
  class PastedContentFormatter {
5942
6101
  constructor(doc) {
5943
6102
  this.doc = doc;
5944
6103
  }
5945
6104
 
5946
6105
  format() {
6106
+ this.#inlinePreservableStyles();
5947
6107
  this.#stripStyleElements();
5948
6108
  this.#unwrapPlaceholderAnchors();
5949
6109
  this.#stripTableCellColorStyles();
6110
+ new OfficeFormatter(this.doc).format();
5950
6111
  this.#unwrapWrappedListChildren();
5951
6112
  this.#nestStrayListChildren();
5952
6113
  this.#stripStrayListChildren();
6114
+ this.#replaceGmailEmojiImgTags();
5953
6115
  return this.doc
5954
6116
  }
5955
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
+
5956
6131
  // Spreadsheets (e.g. Excel) copy a <style> block whose rules (td { color:
5957
6132
  // black }, .xlNN { ... }) cascade onto the imported text. That color rides
5958
6133
  // in through the cascade rather than an inline style, so it slips past both
@@ -6047,6 +6222,72 @@ class PastedContentFormatter {
6047
6222
  }
6048
6223
  }
6049
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
+
6050
6291
  #firstStrayListChild(list) {
6051
6292
  for (const child of list.childNodes) {
6052
6293
  if (child.nodeType !== Node.ELEMENT_NODE || child.tagName !== "LI") {
@@ -6063,6 +6304,16 @@ class PastedContentFormatter {
6063
6304
  #containsListItems(node) {
6064
6305
  return node.nodeType === Node.ELEMENT_NODE && node.querySelector("li") !== null
6065
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
+ }
6066
6317
  }
6067
6318
 
6068
6319
  class Contents {
@@ -8750,6 +9001,39 @@ class CustomAttachmentDragAndDropExtension extends LexxyExtension {
8750
9001
  }
8751
9002
  }
8752
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
+
8753
9037
  class LexicalEditorElement extends HTMLElement {
8754
9038
  static formAssociated = true
8755
9039
  static debug = false
@@ -8908,7 +9192,8 @@ class LexicalEditorElement extends HTMLElement {
8908
9192
  FormatEscapeExtension,
8909
9193
  LinkOpenerExtension,
8910
9194
  PreventLexicalTripleClickExtension,
8911
- CustomAttachmentDragAndDropExtension
9195
+ CustomAttachmentDragAndDropExtension,
9196
+ LineSeparatorsExtension
8912
9197
  ]
8913
9198
  }
8914
9199
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.27",
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",