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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ef504d8ff6c3ee45519815bb36ea8c2d21316f8032c87967602138c88f4457b
4
- data.tar.gz: 95e79bbbccdffe031d6938acce662d7d466fc248c63b609d0b6a058e9f586da1
3
+ metadata.gz: 3c6392cdd1a3fd5cc9535778cb84e544b5864e6d75378af4e19b6d7c6c0a5628
4
+ data.tar.gz: 88bccc783d7e3de2f7650e2d80ad7899369053182de3fbe4b51c50f7566421fc
5
5
  SHA512:
6
- metadata.gz: d600ab9f59fa036b56143d081ecadecde64c24f674639d64067add0bebb276ab4da142c0022802e6cfe5c656902f866ae4fecc80a37ba63e8385d567e3517caa
7
- data.tar.gz: a0eb4b38a69a6f436a611f5023e078bfff26b37152603f2f3d923256c1e57672845c0bed337cec6c38989582e400be3a24ff7d9e803782d0c56dc94bb5f7995f
6
+ metadata.gz: 0aa7bf890d287539720cb79cd58cee9e32833c26ed4bd0d6418ab7f94c0bed4fee739a14299093a3333e8337d23a0d4cd00fe1524defefa8f14842ceb0b278a9
7
+ data.tar.gz: c0dfc34fee283b34b76281b77b9dd24a57fcd7ef8d488e70628107fb1aa7a537c8811986e1a5f77d5df2573e6d040214a62f037c5f3845de136f44b813f2e71c
@@ -8029,7 +8029,7 @@ class CustomActionTextAttachmentNode extends Ji {
8029
8029
  return Lexxy.global.get("attachmentTagName")
8030
8030
  }
8031
8031
 
8032
- constructor({ tagName, sgid, contentType, innerHtml, plainText }, key) {
8032
+ constructor({ tagName, sgid, contentType, innerHtml, plainText } = {}, key) {
8033
8033
  super(key);
8034
8034
 
8035
8035
  const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
@@ -8840,7 +8840,7 @@ class ActionTextAttachmentNode extends Ji {
8840
8840
  return Lexxy.global.get("attachmentTagName")
8841
8841
  }
8842
8842
 
8843
- constructor({ tagName, sgid, src, previewSrc, previewable, previewStatusUrl, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
8843
+ constructor({ tagName, sgid, src, previewSrc, previewable, previewStatusUrl, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError } = {}, key) {
8844
8844
  super(key);
8845
8845
 
8846
8846
  this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
@@ -12034,7 +12034,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
12034
12034
  return null
12035
12035
  }
12036
12036
 
12037
- constructor(node, key) {
12037
+ constructor(node = {}, key) {
12038
12038
  const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node;
12039
12039
  super({ ...node, contentType: file?.type ?? contentType }, key);
12040
12040
  this.file = file ?? null;
@@ -12055,9 +12055,10 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
12055
12055
  // node is reloaded from saved state such as from history.
12056
12056
  this.#startUploadIfNeeded();
12057
12057
 
12058
- // Bridge-managed uploads (uploadUrl is null) don't have file data to show
12059
- // an image preview, so always show the file icon during upload.
12060
- const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null;
12058
+ // Bridge-managed uploads (uploadUrl is null) and restored or remote
12059
+ // instances (no local File) don't have file data to show an image
12060
+ // preview, so always show the file icon during upload.
12061
+ const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null && this.file != null;
12061
12062
  const figure = this.createAttachmentFigure(canPreviewFile);
12062
12063
 
12063
12064
  if (canPreviewFile) {
@@ -12128,9 +12129,12 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
12128
12129
  const figcaption = createElement("figcaption", { className: "attachment__caption" });
12129
12130
 
12130
12131
  const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.fileName || "" });
12131
- const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file?.size) });
12132
12132
  figcaption.appendChild(nameSpan);
12133
- figcaption.appendChild(sizeSpan);
12133
+
12134
+ if (this.file) {
12135
+ const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file.size) });
12136
+ figcaption.appendChild(sizeSpan);
12137
+ }
12134
12138
 
12135
12139
  return figcaption
12136
12140
  }
@@ -12152,6 +12156,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
12152
12156
  async #startUploadIfNeeded() {
12153
12157
  if (this.#uploadStarted) return
12154
12158
  if (!this.uploadUrl) return // Bridge-managed upload — skip DirectUpload
12159
+ if (!this.file) return // Restored or remote instance — no local File to upload
12155
12160
 
12156
12161
  this.#setUploadStarted();
12157
12162
 
@@ -12500,21 +12505,191 @@ BaseNodeInserter.for = (selection) => {
12500
12505
  return inserterClass ? new inserterClass(selection) : selection
12501
12506
  };
12502
12507
 
12508
+ // Word and Outlook never emit <ul>/<ol>/<li>. They simulate lists with flat
12509
+ // paragraphs that declare their depth in an inline `mso-list:l0 level2 lfo1`
12510
+ // and carry the bullet or number glyph in a <span style="mso-list:Ignore">.
12511
+ // Nothing downstream understands either, so the items land as plain paragraphs
12512
+ // with the marker baked into the text. Rebuild real lists from the level each
12513
+ // paragraph declares.
12514
+ //
12515
+ // Everything this reads lives in inline attributes, so it stays correct after
12516
+ // the pasted content formatter has dropped Office's style sheet.
12517
+ class OfficeFormatter {
12518
+ constructor(doc) {
12519
+ this.doc = doc;
12520
+ }
12521
+
12522
+ format() {
12523
+ for (const run of this.#listRuns()) {
12524
+ this.#replaceWithLists(run);
12525
+ }
12526
+ }
12527
+
12528
+ // A run is a stretch of Office list paragraphs that are siblings with nothing
12529
+ // but whitespace and Word's conditional comments between them. Anything else
12530
+ // in between (a heading, a plain paragraph) ends one list and starts another.
12531
+ #listRuns() {
12532
+ const runs = [];
12533
+ const claimed = new Set();
12534
+
12535
+ for (const paragraph of this.doc.querySelectorAll("p[style*='mso-list']")) {
12536
+ if (claimed.has(paragraph) || this.#listLevel(paragraph) === null) continue
12537
+
12538
+ const run = [];
12539
+ let node = paragraph;
12540
+ while (node) {
12541
+ run.push(node);
12542
+ claimed.add(node);
12543
+ node = this.#nextListParagraph(node);
12544
+ }
12545
+ runs.push(run);
12546
+ }
12547
+
12548
+ return runs
12549
+ }
12550
+
12551
+ #nextListParagraph(paragraph) {
12552
+ let sibling = paragraph.nextSibling;
12553
+ while (sibling && this.#isIgnorableBetweenListParagraphs(sibling)) {
12554
+ sibling = sibling.nextSibling;
12555
+ }
12556
+
12557
+ if (sibling && sibling.nodeType === Node.ELEMENT_NODE && this.#listLevel(sibling) !== null) {
12558
+ return sibling
12559
+ }
12560
+
12561
+ return null
12562
+ }
12563
+
12564
+ #isIgnorableBetweenListParagraphs(node) {
12565
+ if (node.nodeType === Node.COMMENT_NODE) {
12566
+ return true
12567
+ }
12568
+ return node.nodeType === Node.TEXT_NODE && node.textContent.trim() === ""
12569
+ }
12570
+
12571
+ // Word numbers levels from 1, but a copied selection often starts partway down
12572
+ // a list, so every paragraph declares level3 with no level1 above it. Anchoring
12573
+ // the run at its own shallowest level is what keeps the result from arriving
12574
+ // needlessly deep.
12575
+ #replaceWithLists(paragraphs) {
12576
+ const levels = paragraphs.map((paragraph) => this.#listLevel(paragraph));
12577
+ const baseLevel = Math.min(...levels);
12578
+ const roots = [];
12579
+ const stack = [];
12580
+
12581
+ paragraphs.forEach((paragraph, index) => {
12582
+ const depth = levels[index] - baseLevel + 1;
12583
+ const tagName = this.#listTagName(paragraph);
12584
+
12585
+ while (stack.length > depth) {
12586
+ stack.pop();
12587
+ }
12588
+ if (stack.length === depth && stack[depth - 1].tagName !== tagName.toUpperCase()) {
12589
+ stack.pop();
12590
+ }
12591
+
12592
+ while (stack.length < depth) {
12593
+ const list = this.doc.createElement(tagName);
12594
+ if (stack.length === 0) {
12595
+ roots.push(list);
12596
+ } else {
12597
+ this.#lastItemOf(stack[stack.length - 1]).appendChild(list);
12598
+ }
12599
+ stack.push(list);
12600
+ }
12601
+
12602
+ stack[stack.length - 1].appendChild(this.#createListItem(paragraph));
12603
+ });
12604
+
12605
+ paragraphs[0].replaceWith(...roots);
12606
+ for (const paragraph of paragraphs.slice(1)) {
12607
+ paragraph.remove();
12608
+ }
12609
+ }
12610
+
12611
+ #lastItemOf(list) {
12612
+ if (!list.lastElementChild) {
12613
+ list.appendChild(this.doc.createElement("li"));
12614
+ }
12615
+ return list.lastElementChild
12616
+ }
12617
+
12618
+ // Word writes bullets as the raw glyph of the marker font (Symbol's "·",
12619
+ // Wingdings' "§", Courier New's "o") and numbers as the rendered counter
12620
+ // ("1.", "a)", "iv)"). A bare letter with no delimiter after it is Courier
12621
+ // New's bullet, not a counter, so the delimiter is what tells them apart.
12622
+ #listTagName(paragraph) {
12623
+ const marker = paragraph.querySelector("[style*='mso-list:Ignore']");
12624
+ const text = marker?.textContent || "";
12625
+
12626
+ if (/\d/.test(text) || /^\(?[A-Za-z]+[.)]/.test(text)) {
12627
+ return "ol"
12628
+ }
12629
+ return "ul"
12630
+ }
12631
+
12632
+ #createListItem(paragraph) {
12633
+ for (const marker of paragraph.querySelectorAll("[style*='mso-list:Ignore']")) {
12634
+ marker.remove();
12635
+ }
12636
+
12637
+ const item = this.doc.createElement("li");
12638
+ item.append(...paragraph.childNodes);
12639
+ return item
12640
+ }
12641
+
12642
+ #listLevel(element) {
12643
+ const match = /mso-list:[^;"']*\blevel(\d+)/.exec(element.getAttribute("style") || "");
12644
+ if (match) {
12645
+ return parseInt(match[1], 10)
12646
+ }
12647
+ return null
12648
+ }
12649
+ }
12650
+
12651
+ // Properties worth rescuing from a foreign style sheet before #stripStyleElements
12652
+ // drops it, keyed by the elements that carry them. Word and Outlook declare
12653
+ // paragraph spacing in the sheet (p.MsoNormal { margin: 0cm }) and never inline,
12654
+ // so once the sheet goes there is nothing left to say how tightly the author's
12655
+ // paragraphs sat, and hosts that turn pasted paragraph margins into explicit
12656
+ // blank lines read tight Office paragraphs as spaced ones and multiply every
12657
+ // line break.
12658
+ const PRESERVABLE_STYLES = {
12659
+ p: [ "margin-top", "margin-bottom" ]
12660
+ };
12661
+
12503
12662
  class PastedContentFormatter {
12504
12663
  constructor(doc) {
12505
12664
  this.doc = doc;
12506
12665
  }
12507
12666
 
12508
12667
  format() {
12668
+ this.#inlinePreservableStyles();
12509
12669
  this.#stripStyleElements();
12510
12670
  this.#unwrapPlaceholderAnchors();
12511
12671
  this.#stripTableCellColorStyles();
12672
+ new OfficeFormatter(this.doc).format();
12512
12673
  this.#unwrapWrappedListChildren();
12513
12674
  this.#nestStrayListChildren();
12514
12675
  this.#stripStrayListChildren();
12676
+ this.#replaceGmailEmojiImgTags();
12515
12677
  return this.doc
12516
12678
  }
12517
12679
 
12680
+ // Runs before #stripStyleElements, to inline anything we still need
12681
+ // from an external style before we remove it
12682
+ #inlinePreservableStyles() {
12683
+ const rules = this.#styleRules();
12684
+ if (rules.length === 0) return
12685
+
12686
+ for (const [ selector, properties ] of Object.entries(PRESERVABLE_STYLES)) {
12687
+ for (const element of this.doc.querySelectorAll(selector)) {
12688
+ this.#inlineDeclaredStyles(element, properties, rules);
12689
+ }
12690
+ }
12691
+ }
12692
+
12518
12693
  // Spreadsheets (e.g. Excel) copy a <style> block whose rules (td { color:
12519
12694
  // black }, .xlNN { ... }) cascade onto the imported text. That color rides
12520
12695
  // in through the cascade rather than an inline style, so it slips past both
@@ -12609,6 +12784,72 @@ class PastedContentFormatter {
12609
12784
  }
12610
12785
  }
12611
12786
 
12787
+ // Read the rules from a sheet we build rather than from this.doc.styleSheets: the
12788
+ // document came from DOMParser, so it has no browsing context, and WebKit leaves
12789
+ // its styleSheets empty. Parsing the text ourselves still expands the shorthands
12790
+ // Word writes (margin: 0cm) into the longhands we ask for.
12791
+ #styleRules() {
12792
+ const css = Array.from(this.doc.querySelectorAll("style"), (style) => style.textContent).join("\n");
12793
+ if (css.trim() === "") return []
12794
+
12795
+ const sheet = new CSSStyleSheet();
12796
+ sheet.replaceSync(css);
12797
+
12798
+ const rules = [];
12799
+ for (const rule of sheet.cssRules) {
12800
+ if (rule instanceof CSSStyleRule) {
12801
+ rules.push(rule);
12802
+ }
12803
+ }
12804
+ return rules
12805
+ }
12806
+
12807
+ // A declaration the element already carries inline is the author's own and wins.
12808
+ #inlineDeclaredStyles(element, properties, rules) {
12809
+ for (const property of properties) {
12810
+ if (element.style.getPropertyValue(property) === "") {
12811
+ const value = this.#declaredValue(rules, element, property);
12812
+ if (value) {
12813
+ this.#appendStyleProperty(element, property, value);
12814
+ }
12815
+ }
12816
+ }
12817
+ }
12818
+
12819
+ // Source order alone doesn't decide the cascade, so when the matching rules
12820
+ // disagree we can't say which value wins without resolving specificity. Skip
12821
+ // the property rather than guess: guessing is permanent, since the sheet is
12822
+ // about to go, and skipping only leaves the host on its own default.
12823
+ #declaredValue(rules, element, property) {
12824
+ const declared = new Set();
12825
+ for (const rule of rules) {
12826
+ const value = rule.style.getPropertyValue(property);
12827
+ if (value !== "" && element.matches(rule.selectorText)) {
12828
+ declared.add(value);
12829
+ }
12830
+ }
12831
+
12832
+ if (declared.size === 1) {
12833
+ return declared.values().next().value
12834
+ } else {
12835
+ return null
12836
+ }
12837
+ }
12838
+
12839
+ // Assigning through element.style would reserialize the style attribute and
12840
+ // drop the mso-* declarations Word leaves inline (they aren't valid CSS),
12841
+ // and OfficeFormatter still needs to read mso-list from them. Append to the
12842
+ // attribute instead so the original declarations survive untouched.
12843
+ #appendStyleProperty(element, property, value) {
12844
+ const style = element.getAttribute("style");
12845
+ const declaration = `${property}:${value}`;
12846
+ if (style === null || style.trim() === "") {
12847
+ element.setAttribute("style", declaration);
12848
+ } else {
12849
+ element.setAttribute("style", `${style.replace(/;\s*$/, "")};${declaration}`);
12850
+ }
12851
+ }
12852
+
12612
12853
  #firstStrayListChild(list) {
12613
12854
  for (const child of list.childNodes) {
12614
12855
  if (child.nodeType !== Node.ELEMENT_NODE || child.tagName !== "LI") {
@@ -12625,6 +12866,16 @@ class PastedContentFormatter {
12625
12866
  #containsListItems(node) {
12626
12867
  return node.nodeType === Node.ELEMENT_NODE && node.querySelector("li") !== null
12627
12868
  }
12869
+
12870
+ // 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;">
12871
+ #replaceGmailEmojiImgTags() {
12872
+ for (const emojiImg of this.doc.querySelectorAll("img[data-emoji]")) {
12873
+ const emoji = emojiImg.dataset.emoji;
12874
+ if (emoji === emojiImg.alt) {
12875
+ emojiImg.replaceWith(emoji);
12876
+ }
12877
+ }
12878
+ }
12628
12879
  }
12629
12880
 
12630
12881
  class Contents {
@@ -15384,6 +15635,39 @@ class CustomAttachmentDragAndDropExtension extends LexxyExtension {
15384
15635
  }
15385
15636
  }
15386
15637
 
15638
+ const LINE_SEPARATORS = /\r\n|[\n\r\u2028\u2029]/;
15639
+
15640
+ // A literal line separator inside a text node renders as a break in the editor, or not at all,
15641
+ // but never survives serialization to HTML. macOS text replacements deliver U+2028 LINE SEPARATOR,
15642
+ // and other insertions can carry "\n", so however the text arrives, a transform converts whatever
15643
+ // lands in a text node into line break nodes.
15644
+ class LineSeparatorsExtension extends LexxyExtension {
15645
+ get lexicalExtension() {
15646
+ return this.defineExtension({
15647
+ name: "lexxy/line-separators",
15648
+ register(editor) {
15649
+ return editor.registerNodeTransform(gr, $replaceLineSeparatorWithLineBreak)
15650
+ }
15651
+ })
15652
+ }
15653
+ }
15654
+
15655
+ // Replaces the first separator only: the split leaves dirty text nodes behind, so Lexical re-runs
15656
+ // the transform until none remain.
15657
+ function $replaceLineSeparatorWithLineBreak(textNode) {
15658
+ const match = textNode.getTextContent().match(LINE_SEPARATORS);
15659
+
15660
+ if (match) {
15661
+ let separatorNode;
15662
+ if (match.index === 0) {
15663
+ [ separatorNode ] = textNode.splitText(match[0].length);
15664
+ } else {
15665
+ [ , separatorNode ] = textNode.splitText(match.index, match.index + match[0].length);
15666
+ }
15667
+ separatorNode.replace(or());
15668
+ }
15669
+ }
15670
+
15387
15671
  class LexicalEditorElement extends HTMLElement {
15388
15672
  static formAssociated = true
15389
15673
  static debug = false
@@ -15542,7 +15826,8 @@ class LexicalEditorElement extends HTMLElement {
15542
15826
  FormatEscapeExtension,
15543
15827
  LinkOpenerExtension,
15544
15828
  PreventLexicalTripleClickExtension,
15545
- CustomAttachmentDragAndDropExtension
15829
+ CustomAttachmentDragAndDropExtension,
15830
+ LineSeparatorsExtension
15546
15831
  ]
15547
15832
  }
15548
15833
 
Binary file
Binary file