@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.
- package/dist/lexxy.esm.js +294 -9
- 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)
|
|
5497
|
-
//
|
|
5498
|
-
|
|
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
|
-
|
|
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
|
|