@37signals/lexxy 0.9.28 → 0.9.30
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 +182 -37
- 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,
|
|
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';
|
|
@@ -15,7 +15,7 @@ import { HistoryExtension } from '@lexical/history';
|
|
|
15
15
|
import { $isCodeNode, CodeHighlightNode, PrismTokenizer, $isCodeHighlightNode, $createCodeNode, $createCodeHighlightNode, CodeNode, registerCodeHighlighting, normalizeCodeLang, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
|
|
16
16
|
import { TRANSFORMERS, registerMarkdownShortcuts } from '@lexical/markdown';
|
|
17
17
|
import { INSERT_TABLE_COMMAND, $getTableCellNodeFromLexicalNode, TableCellNode, TableNode, TableRowNode, setScrollableTablesActive, registerTablePlugin, registerTableSelectionObserver, TableCellHeaderStates, $insertTableRowAtSelection, $insertTableColumnAtSelection, $deleteTableRowAtSelection, $deleteTableColumnAtSelection, $findTableNode, $getTableRowIndexFromTableCellNode, $getTableColumnIndexFromTableCellNode, $findCellNode, $getElementForTableNode } from '@lexical/table';
|
|
18
|
-
import {
|
|
18
|
+
import { Marked } from 'marked';
|
|
19
19
|
import { $insertDataTransferForRichText } from '@lexical/clipboard';
|
|
20
20
|
import 'prismjs';
|
|
21
21
|
import 'prismjs/components/prism-clike';
|
|
@@ -67,6 +67,22 @@ function styleFilterHook(_currentNode, hookEvent) {
|
|
|
67
67
|
|
|
68
68
|
DOMPurify.addHook("uponSanitizeAttribute", styleFilterHook);
|
|
69
69
|
|
|
70
|
+
const FORBIDDEN_STIMULUS_ATTRIBUTES = [ "data-controller", "data-action" ];
|
|
71
|
+
|
|
72
|
+
// Stimulus behavior attributes must never survive sanitization, whatever an
|
|
73
|
+
// extension's allowedElements declares. FORBID_ATTR alone isn't enough: in
|
|
74
|
+
// DOMPurify 3.x the functional ADD_ATTR — which Lexxy builds from the public
|
|
75
|
+
// allowedElements API — is evaluated ahead of FORBID_ATTR, so an extension that
|
|
76
|
+
// listed one of these on a tag would otherwise reinstate it. This hook drops
|
|
77
|
+
// them unconditionally, keeping the class-level prohibition config-independent.
|
|
78
|
+
function stimulusAttributeFilterHook(_currentNode, hookEvent) {
|
|
79
|
+
if (FORBIDDEN_STIMULUS_ATTRIBUTES.includes(hookEvent.attrName)) {
|
|
80
|
+
hookEvent.keepAttr = false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
DOMPurify.addHook("uponSanitizeAttribute", stimulusAttributeFilterHook);
|
|
85
|
+
|
|
70
86
|
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
|
|
71
87
|
if (data.tagName === "strong" || data.tagName === "em") {
|
|
72
88
|
node.removeAttribute("class");
|
|
@@ -90,7 +106,12 @@ function buildConfig(allowedElements ) {
|
|
|
90
106
|
ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
|
|
91
107
|
ADD_ATTR: (attribute, tag) => tagAttributes[tag]?.includes(attribute),
|
|
92
108
|
ADD_URI_SAFE_ATTR: [ "caption", "filename" ],
|
|
93
|
-
SAFE_FOR_XML: false // So that it does not strip attributes that contains serialized HTML (like content)
|
|
109
|
+
SAFE_FOR_XML: false, // So that it does not strip attributes that contains serialized HTML (like content)
|
|
110
|
+
// Stimulus behavior attributes must never survive sanitization: they let stored content
|
|
111
|
+
// wire up arbitrary controllers/actions in the viewer's session. FORBID_ATTR wins over
|
|
112
|
+
// ALLOWED_ATTR/ADD_ATTR/ALLOW_DATA_ATTR in DOMPurify, so this holds even though other
|
|
113
|
+
// data-* attributes (data-language, data-trix-*, etc.) are otherwise allowed through.
|
|
114
|
+
FORBID_ATTR: [ "data-controller", "data-action" ]
|
|
94
115
|
}
|
|
95
116
|
}
|
|
96
117
|
|
|
@@ -1527,7 +1548,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
|
|
|
1527
1548
|
return Lexxy.global.get("attachmentTagName")
|
|
1528
1549
|
}
|
|
1529
1550
|
|
|
1530
|
-
constructor({ tagName, sgid, contentType, innerHtml, plainText }, key) {
|
|
1551
|
+
constructor({ tagName, sgid, contentType, innerHtml, plainText } = {}, key) {
|
|
1531
1552
|
super(key);
|
|
1532
1553
|
|
|
1533
1554
|
const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
|
|
@@ -2328,7 +2349,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
|
|
|
2328
2349
|
return Lexxy.global.get("attachmentTagName")
|
|
2329
2350
|
}
|
|
2330
2351
|
|
|
2331
|
-
constructor({ tagName, sgid, src, previewSrc, previewable, previewStatusUrl, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
|
|
2352
|
+
constructor({ tagName, sgid, src, previewSrc, previewable, previewStatusUrl, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError } = {}, key) {
|
|
2332
2353
|
super(key);
|
|
2333
2354
|
|
|
2334
2355
|
this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
|
|
@@ -5472,7 +5493,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
|
|
|
5472
5493
|
return null
|
|
5473
5494
|
}
|
|
5474
5495
|
|
|
5475
|
-
constructor(node, key) {
|
|
5496
|
+
constructor(node = {}, key) {
|
|
5476
5497
|
const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node;
|
|
5477
5498
|
super({ ...node, contentType: file?.type ?? contentType }, key);
|
|
5478
5499
|
this.file = file ?? null;
|
|
@@ -5493,9 +5514,10 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
|
|
|
5493
5514
|
// node is reloaded from saved state such as from history.
|
|
5494
5515
|
this.#startUploadIfNeeded();
|
|
5495
5516
|
|
|
5496
|
-
// Bridge-managed uploads (uploadUrl is null)
|
|
5497
|
-
//
|
|
5498
|
-
|
|
5517
|
+
// Bridge-managed uploads (uploadUrl is null) and restored or remote
|
|
5518
|
+
// instances (no local File) don't have file data to show an image
|
|
5519
|
+
// preview, so always show the file icon during upload.
|
|
5520
|
+
const canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null && this.file != null;
|
|
5499
5521
|
const figure = this.createAttachmentFigure(canPreviewFile);
|
|
5500
5522
|
|
|
5501
5523
|
if (canPreviewFile) {
|
|
@@ -5566,9 +5588,12 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
|
|
|
5566
5588
|
const figcaption = createElement("figcaption", { className: "attachment__caption" });
|
|
5567
5589
|
|
|
5568
5590
|
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
5591
|
figcaption.appendChild(nameSpan);
|
|
5571
|
-
|
|
5592
|
+
|
|
5593
|
+
if (this.file) {
|
|
5594
|
+
const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file.size) });
|
|
5595
|
+
figcaption.appendChild(sizeSpan);
|
|
5596
|
+
}
|
|
5572
5597
|
|
|
5573
5598
|
return figcaption
|
|
5574
5599
|
}
|
|
@@ -5590,6 +5615,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
|
|
|
5590
5615
|
async #startUploadIfNeeded() {
|
|
5591
5616
|
if (this.#uploadStarted) return
|
|
5592
5617
|
if (!this.uploadUrl) return // Bridge-managed upload — skip DirectUpload
|
|
5618
|
+
if (!this.file) return // Restored or remote instance — no local File to upload
|
|
5593
5619
|
|
|
5594
5620
|
this.#setUploadStarted();
|
|
5595
5621
|
|
|
@@ -6081,12 +6107,24 @@ class OfficeFormatter {
|
|
|
6081
6107
|
}
|
|
6082
6108
|
}
|
|
6083
6109
|
|
|
6110
|
+
// Properties worth rescuing from a foreign style sheet before #stripStyleElements
|
|
6111
|
+
// drops it, keyed by the elements that carry them. Word and Outlook declare
|
|
6112
|
+
// paragraph spacing in the sheet (p.MsoNormal { margin: 0cm }) and never inline,
|
|
6113
|
+
// so once the sheet goes there is nothing left to say how tightly the author's
|
|
6114
|
+
// paragraphs sat, and hosts that turn pasted paragraph margins into explicit
|
|
6115
|
+
// blank lines read tight Office paragraphs as spaced ones and multiply every
|
|
6116
|
+
// line break.
|
|
6117
|
+
const PRESERVABLE_STYLES = {
|
|
6118
|
+
p: [ "margin-top", "margin-bottom" ]
|
|
6119
|
+
};
|
|
6120
|
+
|
|
6084
6121
|
class PastedContentFormatter {
|
|
6085
6122
|
constructor(doc) {
|
|
6086
6123
|
this.doc = doc;
|
|
6087
6124
|
}
|
|
6088
6125
|
|
|
6089
6126
|
format() {
|
|
6127
|
+
this.#inlinePreservableStyles();
|
|
6090
6128
|
this.#stripStyleElements();
|
|
6091
6129
|
this.#unwrapPlaceholderAnchors();
|
|
6092
6130
|
this.#stripTableCellColorStyles();
|
|
@@ -6094,9 +6132,23 @@ class PastedContentFormatter {
|
|
|
6094
6132
|
this.#unwrapWrappedListChildren();
|
|
6095
6133
|
this.#nestStrayListChildren();
|
|
6096
6134
|
this.#stripStrayListChildren();
|
|
6135
|
+
this.#replaceGmailEmojiImgTags();
|
|
6097
6136
|
return this.doc
|
|
6098
6137
|
}
|
|
6099
6138
|
|
|
6139
|
+
// Runs before #stripStyleElements, to inline anything we still need
|
|
6140
|
+
// from an external style before we remove it
|
|
6141
|
+
#inlinePreservableStyles() {
|
|
6142
|
+
const rules = this.#styleRules();
|
|
6143
|
+
if (rules.length === 0) return
|
|
6144
|
+
|
|
6145
|
+
for (const [ selector, properties ] of Object.entries(PRESERVABLE_STYLES)) {
|
|
6146
|
+
for (const element of this.doc.querySelectorAll(selector)) {
|
|
6147
|
+
this.#inlineDeclaredStyles(element, properties, rules);
|
|
6148
|
+
}
|
|
6149
|
+
}
|
|
6150
|
+
}
|
|
6151
|
+
|
|
6100
6152
|
// Spreadsheets (e.g. Excel) copy a <style> block whose rules (td { color:
|
|
6101
6153
|
// black }, .xlNN { ... }) cascade onto the imported text. That color rides
|
|
6102
6154
|
// in through the cascade rather than an inline style, so it slips past both
|
|
@@ -6191,6 +6243,72 @@ class PastedContentFormatter {
|
|
|
6191
6243
|
}
|
|
6192
6244
|
}
|
|
6193
6245
|
|
|
6246
|
+
// Read the rules from a sheet we build rather than from this.doc.styleSheets: the
|
|
6247
|
+
// document came from DOMParser, so it has no browsing context, and WebKit leaves
|
|
6248
|
+
// its styleSheets empty. Parsing the text ourselves still expands the shorthands
|
|
6249
|
+
// Word writes (margin: 0cm) into the longhands we ask for.
|
|
6250
|
+
#styleRules() {
|
|
6251
|
+
const css = Array.from(this.doc.querySelectorAll("style"), (style) => style.textContent).join("\n");
|
|
6252
|
+
if (css.trim() === "") return []
|
|
6253
|
+
|
|
6254
|
+
const sheet = new CSSStyleSheet();
|
|
6255
|
+
sheet.replaceSync(css);
|
|
6256
|
+
|
|
6257
|
+
const rules = [];
|
|
6258
|
+
for (const rule of sheet.cssRules) {
|
|
6259
|
+
if (rule instanceof CSSStyleRule) {
|
|
6260
|
+
rules.push(rule);
|
|
6261
|
+
}
|
|
6262
|
+
}
|
|
6263
|
+
return rules
|
|
6264
|
+
}
|
|
6265
|
+
|
|
6266
|
+
// A declaration the element already carries inline is the author's own and wins.
|
|
6267
|
+
#inlineDeclaredStyles(element, properties, rules) {
|
|
6268
|
+
for (const property of properties) {
|
|
6269
|
+
if (element.style.getPropertyValue(property) === "") {
|
|
6270
|
+
const value = this.#declaredValue(rules, element, property);
|
|
6271
|
+
if (value) {
|
|
6272
|
+
this.#appendStyleProperty(element, property, value);
|
|
6273
|
+
}
|
|
6274
|
+
}
|
|
6275
|
+
}
|
|
6276
|
+
}
|
|
6277
|
+
|
|
6278
|
+
// Source order alone doesn't decide the cascade, so when the matching rules
|
|
6279
|
+
// disagree we can't say which value wins without resolving specificity. Skip
|
|
6280
|
+
// the property rather than guess: guessing is permanent, since the sheet is
|
|
6281
|
+
// about to go, and skipping only leaves the host on its own default.
|
|
6282
|
+
#declaredValue(rules, element, property) {
|
|
6283
|
+
const declared = new Set();
|
|
6284
|
+
for (const rule of rules) {
|
|
6285
|
+
const value = rule.style.getPropertyValue(property);
|
|
6286
|
+
if (value !== "" && element.matches(rule.selectorText)) {
|
|
6287
|
+
declared.add(value);
|
|
6288
|
+
}
|
|
6289
|
+
}
|
|
6290
|
+
|
|
6291
|
+
if (declared.size === 1) {
|
|
6292
|
+
return declared.values().next().value
|
|
6293
|
+
} else {
|
|
6294
|
+
return null
|
|
6295
|
+
}
|
|
6296
|
+
}
|
|
6297
|
+
|
|
6298
|
+
// Assigning through element.style would reserialize the style attribute and
|
|
6299
|
+
// drop the mso-* declarations Word leaves inline (they aren't valid CSS),
|
|
6300
|
+
// and OfficeFormatter still needs to read mso-list from them. Append to the
|
|
6301
|
+
// attribute instead so the original declarations survive untouched.
|
|
6302
|
+
#appendStyleProperty(element, property, value) {
|
|
6303
|
+
const style = element.getAttribute("style");
|
|
6304
|
+
const declaration = `${property}:${value}`;
|
|
6305
|
+
if (style === null || style.trim() === "") {
|
|
6306
|
+
element.setAttribute("style", declaration);
|
|
6307
|
+
} else {
|
|
6308
|
+
element.setAttribute("style", `${style.replace(/;\s*$/, "")};${declaration}`);
|
|
6309
|
+
}
|
|
6310
|
+
}
|
|
6311
|
+
|
|
6194
6312
|
#firstStrayListChild(list) {
|
|
6195
6313
|
for (const child of list.childNodes) {
|
|
6196
6314
|
if (child.nodeType !== Node.ELEMENT_NODE || child.tagName !== "LI") {
|
|
@@ -6207,6 +6325,16 @@ class PastedContentFormatter {
|
|
|
6207
6325
|
#containsListItems(node) {
|
|
6208
6326
|
return node.nodeType === Node.ELEMENT_NODE && node.querySelector("li") !== null
|
|
6209
6327
|
}
|
|
6328
|
+
|
|
6329
|
+
// 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;">
|
|
6330
|
+
#replaceGmailEmojiImgTags() {
|
|
6331
|
+
for (const emojiImg of this.doc.querySelectorAll("img[data-emoji]")) {
|
|
6332
|
+
const emoji = emojiImg.dataset.emoji;
|
|
6333
|
+
if (emoji === emojiImg.alt) {
|
|
6334
|
+
emojiImg.replaceWith(emoji);
|
|
6335
|
+
}
|
|
6336
|
+
}
|
|
6337
|
+
}
|
|
6210
6338
|
}
|
|
6211
6339
|
|
|
6212
6340
|
class Contents {
|
|
@@ -6241,7 +6369,7 @@ class Contents {
|
|
|
6241
6369
|
insertText(text, { tag } = {}) {
|
|
6242
6370
|
this.editor.update(() => {
|
|
6243
6371
|
const paragraph = $createParagraphNode();
|
|
6244
|
-
text.split(
|
|
6372
|
+
text.split(/\r\n|\r|\n/).forEach((line, index) => {
|
|
6245
6373
|
if (index > 0) paragraph.append($createLineBreakNode());
|
|
6246
6374
|
paragraph.append($createTextNode(line));
|
|
6247
6375
|
});
|
|
@@ -6249,20 +6377,6 @@ class Contents {
|
|
|
6249
6377
|
}, { tag });
|
|
6250
6378
|
}
|
|
6251
6379
|
|
|
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
6380
|
insertAtCursor(...nodes) {
|
|
6267
6381
|
const selection = this.#insertableSelection();
|
|
6268
6382
|
const inserter = BaseNodeInserter.for(selection);
|
|
@@ -6897,6 +7011,12 @@ class Contents {
|
|
|
6897
7011
|
}
|
|
6898
7012
|
}
|
|
6899
7013
|
|
|
7014
|
+
// Markdown reads any line indented by a tab or four spaces as a code block. Pasted
|
|
7015
|
+
// plain text is full of incidental indentation — outlines, notes, logs — and nobody
|
|
7016
|
+
// indents a note meaning "this is code"; they fence it. Keep fences, drop the
|
|
7017
|
+
// indentation rule.
|
|
7018
|
+
const pastedMarkdown = new Marked({ breaks: true }).use({ tokenizer: { code: () => undefined } });
|
|
7019
|
+
|
|
6900
7020
|
class Clipboard {
|
|
6901
7021
|
#listeners = new ListenerBin()
|
|
6902
7022
|
|
|
@@ -7083,7 +7203,7 @@ class Clipboard {
|
|
|
7083
7203
|
}
|
|
7084
7204
|
|
|
7085
7205
|
#pasteMarkdown(text) {
|
|
7086
|
-
const html =
|
|
7206
|
+
const html = pastedMarkdown.parse(text);
|
|
7087
7207
|
const doc = parseHtml(html);
|
|
7088
7208
|
|
|
7089
7209
|
if (this.#isPlainTextWithoutMarkdown(doc)) {
|
|
@@ -8908,6 +9028,39 @@ class CustomAttachmentDragAndDropExtension extends LexxyExtension {
|
|
|
8908
9028
|
}
|
|
8909
9029
|
}
|
|
8910
9030
|
|
|
9031
|
+
const LINE_SEPARATORS = /\r\n|[\n\r\u2028\u2029]/;
|
|
9032
|
+
|
|
9033
|
+
// A literal line separator inside a text node renders as a break in the editor, or not at all,
|
|
9034
|
+
// but never survives serialization to HTML. macOS text replacements deliver U+2028 LINE SEPARATOR,
|
|
9035
|
+
// and other insertions can carry "\n", so however the text arrives, a transform converts whatever
|
|
9036
|
+
// lands in a text node into line break nodes.
|
|
9037
|
+
class LineSeparatorsExtension extends LexxyExtension {
|
|
9038
|
+
get lexicalExtension() {
|
|
9039
|
+
return this.defineExtension({
|
|
9040
|
+
name: "lexxy/line-separators",
|
|
9041
|
+
register(editor) {
|
|
9042
|
+
return editor.registerNodeTransform(TextNode, $replaceLineSeparatorWithLineBreak)
|
|
9043
|
+
}
|
|
9044
|
+
})
|
|
9045
|
+
}
|
|
9046
|
+
}
|
|
9047
|
+
|
|
9048
|
+
// Replaces the first separator only: the split leaves dirty text nodes behind, so Lexical re-runs
|
|
9049
|
+
// the transform until none remain.
|
|
9050
|
+
function $replaceLineSeparatorWithLineBreak(textNode) {
|
|
9051
|
+
const match = textNode.getTextContent().match(LINE_SEPARATORS);
|
|
9052
|
+
|
|
9053
|
+
if (match) {
|
|
9054
|
+
let separatorNode;
|
|
9055
|
+
if (match.index === 0) {
|
|
9056
|
+
[ separatorNode ] = textNode.splitText(match[0].length);
|
|
9057
|
+
} else {
|
|
9058
|
+
[ , separatorNode ] = textNode.splitText(match.index, match.index + match[0].length);
|
|
9059
|
+
}
|
|
9060
|
+
separatorNode.replace($createLineBreakNode());
|
|
9061
|
+
}
|
|
9062
|
+
}
|
|
9063
|
+
|
|
8911
9064
|
class LexicalEditorElement extends HTMLElement {
|
|
8912
9065
|
static formAssociated = true
|
|
8913
9066
|
static debug = false
|
|
@@ -9066,7 +9219,8 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
9066
9219
|
FormatEscapeExtension,
|
|
9067
9220
|
LinkOpenerExtension,
|
|
9068
9221
|
PreventLexicalTripleClickExtension,
|
|
9069
|
-
CustomAttachmentDragAndDropExtension
|
|
9222
|
+
CustomAttachmentDragAndDropExtension,
|
|
9223
|
+
LineSeparatorsExtension
|
|
9070
9224
|
]
|
|
9071
9225
|
}
|
|
9072
9226
|
|
|
@@ -9254,7 +9408,6 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
9254
9408
|
#initialize() {
|
|
9255
9409
|
this.#registerComponents();
|
|
9256
9410
|
this.#handleEnter();
|
|
9257
|
-
this.#handleReplacementText();
|
|
9258
9411
|
this.#registerFocusEvents();
|
|
9259
9412
|
this.#registerHistoryEvents();
|
|
9260
9413
|
this.#registerFileAcceptFilter();
|
|
@@ -9518,14 +9671,6 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
9518
9671
|
));
|
|
9519
9672
|
}
|
|
9520
9673
|
|
|
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
9674
|
#registerFocusEvents() {
|
|
9530
9675
|
this.#listeners.track(
|
|
9531
9676
|
registerEventListener(this, "focusin", this.#handleFocusIn),
|