@37signals/lexxy 0.9.31 → 0.9.32
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 +350 -85
- package/dist/lexxy_helpers.esm.js +61 -23
- package/package.json +1 -1
package/dist/lexxy.esm.js
CHANGED
|
@@ -12,7 +12,7 @@ import { registerPlainText } from '@lexical/plain-text';
|
|
|
12
12
|
import { RichTextExtension, $isQuoteNode, QuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
|
|
13
13
|
import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
|
|
14
14
|
import { HistoryExtension } from '@lexical/history';
|
|
15
|
-
import { $isCodeNode, CodeHighlightNode,
|
|
15
|
+
import { $isCodeNode, CodeHighlightNode, $isCodeHighlightNode, $createCodeNode, $createCodeHighlightNode, CodeNode, registerCodeHighlighting, PrismTokenizer, 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
18
|
import { Marked } from 'marked';
|
|
@@ -1908,12 +1908,11 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
|
|
|
1908
1908
|
return Lexxy.global.get("attachmentTagName")
|
|
1909
1909
|
}
|
|
1910
1910
|
|
|
1911
|
-
constructor({
|
|
1911
|
+
constructor({ sgid, contentType, innerHtml, plainText } = {}, key) {
|
|
1912
1912
|
super(key);
|
|
1913
1913
|
|
|
1914
1914
|
const contentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
|
|
1915
1915
|
|
|
1916
|
-
this.tagName = tagName || CustomActionTextAttachmentNode.TAG_NAME;
|
|
1917
1916
|
this.sgid = sgid;
|
|
1918
1917
|
this.contentType = contentType || `application/vnd.${contentTypeNamespace}.unknown`;
|
|
1919
1918
|
this.innerHtml = innerHtml;
|
|
@@ -1921,7 +1920,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
|
|
|
1921
1920
|
}
|
|
1922
1921
|
|
|
1923
1922
|
createDOM(_config, editor) {
|
|
1924
|
-
const figure = createElement(
|
|
1923
|
+
const figure = createElement(CustomActionTextAttachmentNode.TAG_NAME, { "content-type": this.contentType, "data-lexxy-decorator": true, draggable: true });
|
|
1925
1924
|
figure.dataset.lexicalNodeKey = this.__key;
|
|
1926
1925
|
|
|
1927
1926
|
// Resolved from the editor so this content is sanitized with its own
|
|
@@ -1964,7 +1963,7 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
|
|
|
1964
1963
|
}
|
|
1965
1964
|
|
|
1966
1965
|
exportDOM() {
|
|
1967
|
-
const attachment = createElement(
|
|
1966
|
+
const attachment = createElement(CustomActionTextAttachmentNode.TAG_NAME, {
|
|
1968
1967
|
sgid: this.sgid,
|
|
1969
1968
|
content: this.innerHtml,
|
|
1970
1969
|
"content-type": this.contentType
|
|
@@ -1977,7 +1976,6 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
|
|
|
1977
1976
|
return {
|
|
1978
1977
|
type: "custom_action_text_attachment",
|
|
1979
1978
|
version: 1,
|
|
1980
|
-
tagName: this.tagName,
|
|
1981
1979
|
sgid: this.sgid,
|
|
1982
1980
|
contentType: this.contentType,
|
|
1983
1981
|
innerHtml: this.innerHtml,
|
|
@@ -2724,10 +2722,9 @@ class ActionTextAttachmentNode extends DecoratorNode {
|
|
|
2724
2722
|
return Lexxy.global.get("attachmentTagName")
|
|
2725
2723
|
}
|
|
2726
2724
|
|
|
2727
|
-
constructor({
|
|
2725
|
+
constructor({ sgid, src, previewSrc, previewable, previewStatusUrl, pendingPreview, altText, caption, contentType, fileName, fileSize, width, height, uploadError } = {}, key) {
|
|
2728
2726
|
super(key);
|
|
2729
2727
|
|
|
2730
|
-
this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME;
|
|
2731
2728
|
this.sgid = sgid;
|
|
2732
2729
|
this.src = src;
|
|
2733
2730
|
this.previewSrc = previewSrc;
|
|
@@ -2786,7 +2783,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
|
|
|
2786
2783
|
}
|
|
2787
2784
|
|
|
2788
2785
|
exportDOM() {
|
|
2789
|
-
const attachment = createElement(
|
|
2786
|
+
const attachment = createElement(ActionTextAttachmentNode.TAG_NAME, {
|
|
2790
2787
|
sgid: this.sgid,
|
|
2791
2788
|
previewable: this.previewable || null,
|
|
2792
2789
|
url: this.src,
|
|
@@ -2807,7 +2804,6 @@ class ActionTextAttachmentNode extends DecoratorNode {
|
|
|
2807
2804
|
return {
|
|
2808
2805
|
type: "action_text_attachment",
|
|
2809
2806
|
version: 1,
|
|
2810
|
-
tagName: this.tagName,
|
|
2811
2807
|
sgid: this.sgid,
|
|
2812
2808
|
src: this.src,
|
|
2813
2809
|
previewable: this.previewable,
|
|
@@ -3527,6 +3523,34 @@ class UploadRequests {
|
|
|
3527
3523
|
}
|
|
3528
3524
|
}
|
|
3529
3525
|
|
|
3526
|
+
// Partition a stretch of text into consecutive { start, end, range } segments,
|
|
3527
|
+
// where offsets are relative to the text and range is the covering source
|
|
3528
|
+
// range — expressed in outer coordinates as { start, end, ... } — or null for
|
|
3529
|
+
// the stretches no range covers. Ranges must be sorted and non-overlapping.
|
|
3530
|
+
function segmentTextByRanges(text, textStart, ranges) {
|
|
3531
|
+
const segments = [];
|
|
3532
|
+
let cursor = 0;
|
|
3533
|
+
|
|
3534
|
+
for (const range of ranges) {
|
|
3535
|
+
const from = Math.max(range.start - textStart, cursor);
|
|
3536
|
+
const to = Math.min(range.end - textStart, text.length);
|
|
3537
|
+
|
|
3538
|
+
if (from < to) {
|
|
3539
|
+
if (from > cursor) {
|
|
3540
|
+
segments.push({ start: cursor, end: from, range: null });
|
|
3541
|
+
}
|
|
3542
|
+
segments.push({ start: from, end: to, range });
|
|
3543
|
+
cursor = to;
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
if (cursor < text.length) {
|
|
3548
|
+
segments.push({ start: cursor, end: text.length, range: null });
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
return segments
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3530
3554
|
// Shared, strictly-contained element used to attach ephemeral nodes when we
|
|
3531
3555
|
// need to read computed styles (e.g. canonicalizing style values, resolving
|
|
3532
3556
|
// CSS custom properties). The container is created once and attached to
|
|
@@ -3830,26 +3854,9 @@ function extractHighlightStyleFromElement(element) {
|
|
|
3830
3854
|
return css.length > 0 ? css : null
|
|
3831
3855
|
}
|
|
3832
3856
|
|
|
3833
|
-
//
|
|
3834
|
-
//
|
|
3835
|
-
//
|
|
3836
|
-
// recovers the block's highlight ranges — staged during HTML import, or read
|
|
3837
|
-
// from the children the fresh tokens are about to replace — and reapplies
|
|
3838
|
-
// them to the fresh tokens before the retokenizer splices them in.
|
|
3839
|
-
function buildHighlightPreservingTokenizer(editor) {
|
|
3840
|
-
return {
|
|
3841
|
-
defaultLanguage: PrismTokenizer.defaultLanguage,
|
|
3842
|
-
tokenize(code, language) {
|
|
3843
|
-
return PrismTokenizer.tokenize(code, language)
|
|
3844
|
-
},
|
|
3845
|
-
$tokenize(codeNode, language) {
|
|
3846
|
-
const tokens = PrismTokenizer.$tokenize(codeNode, language);
|
|
3847
|
-
const highlights = $takeHighlightRanges(editor, codeNode);
|
|
3848
|
-
return $applyHighlightRangesToTokens(tokens, highlights)
|
|
3849
|
-
}
|
|
3850
|
-
}
|
|
3851
|
-
}
|
|
3852
|
-
|
|
3857
|
+
// Recover the highlight ranges to reapply after a retokenization: the ranges
|
|
3858
|
+
// staged during HTML import, or the ones read from the children the fresh
|
|
3859
|
+
// tokens are about to replace.
|
|
3853
3860
|
function $takeHighlightRanges(editor, codeNode) {
|
|
3854
3861
|
const pending = $getPendingHighlights(editor);
|
|
3855
3862
|
const key = codeNode.getKey();
|
|
@@ -3891,52 +3898,25 @@ function $applyHighlightRangesToTokens(tokens, highlights) {
|
|
|
3891
3898
|
// attached to the tree yet, so we create CodeHighlightNode replacements.
|
|
3892
3899
|
function $splitTokenAtHighlightBoundaries(token, tokenStart, highlights) {
|
|
3893
3900
|
const text = token.getTextContent();
|
|
3894
|
-
const segments =
|
|
3901
|
+
const segments = segmentTextByRanges(text, tokenStart, highlights);
|
|
3895
3902
|
|
|
3896
3903
|
if (segments.length === 1) {
|
|
3897
3904
|
const [ segment ] = segments;
|
|
3898
|
-
if (segment.
|
|
3899
|
-
$applyHighlightStyleToToken(token, segment.style);
|
|
3905
|
+
if (segment.range) {
|
|
3906
|
+
$applyHighlightStyleToToken(token, segment.range.style);
|
|
3900
3907
|
}
|
|
3901
3908
|
return [ token ]
|
|
3902
3909
|
} else {
|
|
3903
3910
|
return segments.map((segment) => {
|
|
3904
3911
|
const segmentToken = $createCodeHighlightNode(text.slice(segment.start, segment.end), token.getHighlightType());
|
|
3905
|
-
if (segment.
|
|
3906
|
-
$applyHighlightStyleToToken(segmentToken, segment.style);
|
|
3912
|
+
if (segment.range) {
|
|
3913
|
+
$applyHighlightStyleToToken(segmentToken, segment.range.style);
|
|
3907
3914
|
}
|
|
3908
3915
|
return segmentToken
|
|
3909
3916
|
})
|
|
3910
3917
|
}
|
|
3911
3918
|
}
|
|
3912
3919
|
|
|
3913
|
-
// Partition a token's text into consecutive { start, end, style } segments,
|
|
3914
|
-
// where offsets are relative to the token and style is null for the stretches
|
|
3915
|
-
// no highlight covers.
|
|
3916
|
-
function segmentTextByHighlights(text, tokenStart, highlights) {
|
|
3917
|
-
const segments = [];
|
|
3918
|
-
let cursor = 0;
|
|
3919
|
-
|
|
3920
|
-
for (const { start, end, style } of highlights) {
|
|
3921
|
-
const from = Math.max(start - tokenStart, cursor);
|
|
3922
|
-
const to = Math.min(end - tokenStart, text.length);
|
|
3923
|
-
|
|
3924
|
-
if (from < to) {
|
|
3925
|
-
if (from > cursor) {
|
|
3926
|
-
segments.push({ start: cursor, end: from, style: null });
|
|
3927
|
-
}
|
|
3928
|
-
segments.push({ start: from, end: to, style });
|
|
3929
|
-
cursor = to;
|
|
3930
|
-
}
|
|
3931
|
-
}
|
|
3932
|
-
|
|
3933
|
-
if (cursor < text.length) {
|
|
3934
|
-
segments.push({ start: cursor, end: text.length, style: null });
|
|
3935
|
-
}
|
|
3936
|
-
|
|
3937
|
-
return segments
|
|
3938
|
-
}
|
|
3939
|
-
|
|
3940
3920
|
function $applyHighlightStyleToToken(token, style) {
|
|
3941
3921
|
token.setStyle(style);
|
|
3942
3922
|
$setCodeHighlightFormat(token, true);
|
|
@@ -3946,17 +3926,24 @@ function $buildChildRanges(codeNode) {
|
|
|
3946
3926
|
const childRanges = [];
|
|
3947
3927
|
let charOffset = 0;
|
|
3948
3928
|
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3929
|
+
function walk(node) {
|
|
3930
|
+
for (const child of node.getChildren()) {
|
|
3931
|
+
if ($isElementNode(child)) {
|
|
3932
|
+
// e.g. a LinkNode awaiting its first retokenization
|
|
3933
|
+
walk(child);
|
|
3934
|
+
} else if ($isCodeHighlightNode(child) || $isTextNode(child)) {
|
|
3935
|
+
const text = child.getTextContent();
|
|
3936
|
+
childRanges.push({ node: child, start: charOffset, end: charOffset + text.length });
|
|
3937
|
+
charOffset += text.length;
|
|
3938
|
+
} else {
|
|
3939
|
+
// LineBreakNode - counts as 1 character (\n)
|
|
3940
|
+
charOffset += 1;
|
|
3941
|
+
}
|
|
3957
3942
|
}
|
|
3958
3943
|
}
|
|
3959
3944
|
|
|
3945
|
+
walk(codeNode);
|
|
3946
|
+
|
|
3960
3947
|
return childRanges
|
|
3961
3948
|
}
|
|
3962
3949
|
|
|
@@ -5076,10 +5063,17 @@ class Selection {
|
|
|
5076
5063
|
|| this.#selectInLexical(this.topLevelNodeAfterCursor)
|
|
5077
5064
|
}
|
|
5078
5065
|
|
|
5066
|
+
// hasNodeSelection reads the committed state, while $getSelection() resolves
|
|
5067
|
+
// against the pending one — so the selection here can be missing, no longer a
|
|
5068
|
+
// NodeSelection, or reference a node that no longer exists (empty editor,
|
|
5069
|
+
// just-deleted node, decorator boundary). Only invoke the callback when a
|
|
5070
|
+
// node actually resolves; returning false lets the callers fall back —
|
|
5071
|
+
// #selectInLexical for plain arrows, native pass-through for shift-arrows.
|
|
5079
5072
|
#withCurrentNodeSelectionNode(fn) {
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5073
|
+
const selection = this.hasNodeSelection ? $getSelection() : null;
|
|
5074
|
+
const currentNode = $isNodeSelection(selection) ? selection.getNodes()[0] : undefined;
|
|
5075
|
+
|
|
5076
|
+
return currentNode ? fn(currentNode) : false
|
|
5083
5077
|
}
|
|
5084
5078
|
|
|
5085
5079
|
#rangeSelectDecorator(node, direction = "forward") {
|
|
@@ -6266,15 +6260,18 @@ class ListItemNodeInserter extends BaseNodeInserter {
|
|
|
6266
6260
|
// very corruption we are avoiding. The block must land at the list's level.
|
|
6267
6261
|
const anchorNode = this.selection.anchor.getNode();
|
|
6268
6262
|
const outerList = this.#outermostList(anchorNode);
|
|
6269
|
-
const topItem = this.#topLevelItemFor(anchorNode, outerList);
|
|
6270
6263
|
|
|
6271
|
-
//
|
|
6272
|
-
//
|
|
6273
|
-
//
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
6264
|
+
// removeText() can relocate the anchor before we read it back — clean out
|
|
6265
|
+
// of the list when the removed range spanned it. With no list left to
|
|
6266
|
+
// split, default insertion applies.
|
|
6267
|
+
if (!outerList) {
|
|
6268
|
+
this.selection.insertNodes(nodes);
|
|
6269
|
+
return
|
|
6270
|
+
}
|
|
6271
|
+
|
|
6272
|
+
const topItem = this.#topLevelItemFor(anchorNode, outerList);
|
|
6273
|
+
const [ listBefore, listAfter ] = $splitNode(outerList, this.#splitIndexFor(topItem));
|
|
6274
|
+
if (topItem && $isBlankNode(topItem)) { topItem.remove(); }
|
|
6278
6275
|
|
|
6279
6276
|
let anchor = listBefore ?? listAfter;
|
|
6280
6277
|
for (const node of nodes) {
|
|
@@ -6287,6 +6284,18 @@ class ListItemNodeInserter extends BaseNodeInserter {
|
|
|
6287
6284
|
nodes.at(-1).selectNext();
|
|
6288
6285
|
}
|
|
6289
6286
|
|
|
6287
|
+
// A blank top-level bullet is just the insertion point (e.g. the user pressed
|
|
6288
|
+
// Enter to leave the list); break out of it entirely. A bullet with content —
|
|
6289
|
+
// including one wrapping a nested list — splits so its content stays in the
|
|
6290
|
+
// list. With no top-level bullet at all — removeText() relocated the anchor
|
|
6291
|
+
// onto the list node itself — the anchor offset is the boundary between items.
|
|
6292
|
+
#splitIndexFor(topItem) {
|
|
6293
|
+
if (!topItem) return this.selection.anchor.offset
|
|
6294
|
+
|
|
6295
|
+
const splitAfterItem = $isBlankNode(topItem) ? topItem.getPreviousSibling() : topItem;
|
|
6296
|
+
return splitAfterItem ? splitAfterItem.getIndexWithinParent() + 1 : 0
|
|
6297
|
+
}
|
|
6298
|
+
|
|
6290
6299
|
#outermostList(node) {
|
|
6291
6300
|
return [ node, ...node.getParents() ].reverse().find($isListNode)
|
|
6292
6301
|
}
|
|
@@ -7386,11 +7395,93 @@ class Contents {
|
|
|
7386
7395
|
}
|
|
7387
7396
|
}
|
|
7388
7397
|
|
|
7398
|
+
function parsePastedMarkdown(text) {
|
|
7399
|
+
return pasteMarked.parse(text)
|
|
7400
|
+
}
|
|
7401
|
+
|
|
7402
|
+
// A marked instance scoped to the paste flow. A fresh Marked starts from
|
|
7403
|
+
// marked's stock defaults — gfm stays on and tables still parse — and is
|
|
7404
|
+
// isolated from the global `marked` singleton, so a host bundle sharing the
|
|
7405
|
+
// deduped module can't leak `marked.use(...)` customizations into pasting.
|
|
7406
|
+
// The additions are breaks: true, matching the render pass Clipboard always
|
|
7407
|
+
// used, the `html` renderer, and the dropped `code` tokenizer.
|
|
7408
|
+
//
|
|
7389
7409
|
// Markdown reads any line indented by a tab or four spaces as a code block. Pasted
|
|
7390
7410
|
// plain text is full of incidental indentation — outlines, notes, logs — and nobody
|
|
7391
7411
|
// indents a note meaning "this is code"; they fence it. Keep fences, drop the
|
|
7392
7412
|
// indentation rule.
|
|
7393
|
-
|
|
7413
|
+
//
|
|
7414
|
+
// Following CommonMark, marked tokenizes a bare "<tag>" in prose as raw inline
|
|
7415
|
+
// (or block) HTML. That is intentional for recognized tags — pasted plain text
|
|
7416
|
+
// carrying <span style>, <mark>, <b>… is styled on import — but for an
|
|
7417
|
+
// *unrecognized* element the Lexical importer has no converter and silently
|
|
7418
|
+
// unwraps it, dropping the tag. WEBVTT speaker cues such as
|
|
7419
|
+
// "<v Nabila Abdel Nabi>", where the name lives in what the HTML parser reads
|
|
7420
|
+
// as attributes, thus vanish entirely.
|
|
7421
|
+
//
|
|
7422
|
+
// Classifying at marked's html renderer rides its public token grammar instead
|
|
7423
|
+
// of shadowing it: marked routes code (`code`/`codespan`), autolinks and
|
|
7424
|
+
// [text](<dest>) links to *other* token types that never reach this renderer,
|
|
7425
|
+
// so their literal "<...>" content is preserved for free — no pre-escaping,
|
|
7426
|
+
// code-region collection, or lexer-option syncing required. This renderer fires
|
|
7427
|
+
// only for genuine raw-HTML tokens, and marked inserts its return value verbatim
|
|
7428
|
+
// (it does not re-encode), so we escape unsupported tags to literal text here.
|
|
7429
|
+
const pasteMarked = new Marked({
|
|
7430
|
+
breaks: true,
|
|
7431
|
+
renderer: { html: renderPastedHtmlToken }
|
|
7432
|
+
}).use({ tokenizer: { code: () => undefined } });
|
|
7433
|
+
|
|
7434
|
+
// Matches a single HTML tag lexeme: an optional "/", a tag name, then attribute
|
|
7435
|
+
// characters up to the closing ">". Quoted attribute values may contain ">" —
|
|
7436
|
+
// the HTML tokenizer consumes them as part of the value, and marked's tag
|
|
7437
|
+
// grammar matches them into the same raw token — so the attribute run matches
|
|
7438
|
+
// quoted strings whole. Otherwise a lexeme like <v title="a > b"> would end at
|
|
7439
|
+
// the inner ">", escaping only the prefix and leaving the tail for DOMParser
|
|
7440
|
+
// to decode.
|
|
7441
|
+
const HTML_TAG_LEXEME = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^'">])*)>/g;
|
|
7442
|
+
|
|
7443
|
+
// Escape unknown tags *within* the raw-HTML token, keeping known tags intact.
|
|
7444
|
+
// Lexeme-level (not whole-token) escaping matters because marked can emit a
|
|
7445
|
+
// single coarse block-HTML token whose first tag is known but which nests an
|
|
7446
|
+
// unknown one — e.g. "<div><v Name> Hello</div>" arrives as one token. We keep
|
|
7447
|
+
// the <div> so it still renders, and escape the nested <v Name> so it survives
|
|
7448
|
+
// as literal text rather than being unwrapped and dropped by the importer.
|
|
7449
|
+
function renderPastedHtmlToken(token) {
|
|
7450
|
+
return token.text.replace(HTML_TAG_LEXEME, (lexeme, _slash, name) => {
|
|
7451
|
+
return isSupportedHtmlElement(name) ? lexeme : escapeHtml(lexeme)
|
|
7452
|
+
})
|
|
7453
|
+
}
|
|
7454
|
+
|
|
7455
|
+
// The renderer's output is final, so escape the full lexeme — "&", "<" and ">" —
|
|
7456
|
+
// for an exact round-trip (e.g. "<v A B>" → "<v A&nbsp;B>").
|
|
7457
|
+
function escapeHtml(text) {
|
|
7458
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
7459
|
+
}
|
|
7460
|
+
|
|
7461
|
+
// "Supported" — safe to pass through for the importer to handle — is decided from
|
|
7462
|
+
// the element taxonomy, not the importer's converter registry. The importer both
|
|
7463
|
+
// converts tags it has a converter for (span→mark, b→bold, tr/td→table cells) and
|
|
7464
|
+
// transparently traverses standard structural wrappers it has *no* converter for
|
|
7465
|
+
// (thead, tbody, tfoot, colgroup, caption), keeping their children. A converter
|
|
7466
|
+
// registry (editor._htmlConversions) lists only the former, so classifying by it
|
|
7467
|
+
// escaped those wrappers to literal text and corrupted pasted tables — foster
|
|
7468
|
+
// parenting hoisted "<thead>…" out as a stray paragraph. The taxonomy covers
|
|
7469
|
+
// both: a standard HTML element is either converted or losslessly unwrapped.
|
|
7470
|
+
//
|
|
7471
|
+
// Two exclusions escape the tag to literal text instead:
|
|
7472
|
+
// - Invented tags (WEBVTT's "<v Nabila Abdel Nabi>", "<foo>"): document
|
|
7473
|
+
// .createElement returns HTMLUnknownElement. The importer would unwrap them,
|
|
7474
|
+
// dropping the tag — and for <v Name> the name lives in what parses as
|
|
7475
|
+
// attributes, so it vanishes entirely.
|
|
7476
|
+
// - Custom elements (turbo-frame, bc-attachment, action-text-attachment): a
|
|
7477
|
+
// hyphenated name is a custom element (HTML spec; no standard element has a
|
|
7478
|
+
// hyphen). They're excluded even when the importer registers a converter, so
|
|
7479
|
+
// a plain-text paste can never materialize an attachment/widget. Legitimate
|
|
7480
|
+
// attachments arrive through the rich-HTML paste path, not here.
|
|
7481
|
+
function isSupportedHtmlElement(name) {
|
|
7482
|
+
const tag = name.toLowerCase();
|
|
7483
|
+
return !(document.createElement(tag) instanceof HTMLUnknownElement) && !tag.includes("-")
|
|
7484
|
+
}
|
|
7394
7485
|
|
|
7395
7486
|
class Clipboard {
|
|
7396
7487
|
#listeners = new ListenerBin()
|
|
@@ -7578,7 +7669,7 @@ class Clipboard {
|
|
|
7578
7669
|
}
|
|
7579
7670
|
|
|
7580
7671
|
#pasteMarkdown(text) {
|
|
7581
|
-
const html =
|
|
7672
|
+
const html = parsePastedMarkdown(text);
|
|
7582
7673
|
const doc = parseHtml(html);
|
|
7583
7674
|
|
|
7584
7675
|
if (this.#isPlainTextWithoutMarkdown(doc)) {
|
|
@@ -7777,6 +7868,134 @@ class BrowserAdapter {
|
|
|
7777
7868
|
}
|
|
7778
7869
|
}
|
|
7779
7870
|
|
|
7871
|
+
// The code retokenizer only knows CodeHighlightNode, LineBreakNode and
|
|
7872
|
+
// TabNode, so a LinkNode inside a code block — Trix-authored documents allow
|
|
7873
|
+
// links in code — is destroyed on the first retokenization. Links survive as
|
|
7874
|
+
// state on the tokens instead: this state records the link attributes on each
|
|
7875
|
+
// CodeHighlightNode the link covers, the extraction below reads it (or a
|
|
7876
|
+
// still-untokenized LinkNode) back into ranges before every retokenization,
|
|
7877
|
+
// and the tokenizer reapplies it to the fresh tokens.
|
|
7878
|
+
const codeLinkState = createState("codeLink", {
|
|
7879
|
+
parse: (value) => (value && typeof value.url === "string" ? value : null)
|
|
7880
|
+
});
|
|
7881
|
+
|
|
7882
|
+
function $getCodeLink(node) {
|
|
7883
|
+
return $getState(node, codeLinkState)
|
|
7884
|
+
}
|
|
7885
|
+
|
|
7886
|
+
function $setCodeLink(node, link) {
|
|
7887
|
+
$setState(node, codeLinkState, link);
|
|
7888
|
+
}
|
|
7889
|
+
|
|
7890
|
+
// Build the list of { start, end, link } ranges covering every link in a code
|
|
7891
|
+
// block, with offsets into the block's text content. Links appear either as
|
|
7892
|
+
// LinkNode descendants (imported or just created, before their first
|
|
7893
|
+
// retokenization) or as link state on tokens (after it). The walk recurses
|
|
7894
|
+
// through nested elements because `<pre><code>` imports of multi-line content
|
|
7895
|
+
// briefly produce a CodeNode nested inside another before the outer one's
|
|
7896
|
+
// retokenization flattens them.
|
|
7897
|
+
function $extractLinkRangesFromCodeNode(codeNode) {
|
|
7898
|
+
const ranges = [];
|
|
7899
|
+
let offset = 0;
|
|
7900
|
+
|
|
7901
|
+
function walk(node) {
|
|
7902
|
+
for (const child of node.getChildren()) {
|
|
7903
|
+
if ($isLinkNode(child)) {
|
|
7904
|
+
const size = child.getTextContent().length;
|
|
7905
|
+
appendRange(ranges, { start: offset, end: offset + size, link: linkAttributesFrom(child) });
|
|
7906
|
+
offset += size;
|
|
7907
|
+
} else if ($isElementNode(child)) {
|
|
7908
|
+
walk(child);
|
|
7909
|
+
} else {
|
|
7910
|
+
const size = child.getTextContent().length;
|
|
7911
|
+
if ($isTextNode(child)) {
|
|
7912
|
+
const link = $getCodeLink(child);
|
|
7913
|
+
if (link) {
|
|
7914
|
+
appendRange(ranges, { start: offset, end: offset + size, link });
|
|
7915
|
+
}
|
|
7916
|
+
}
|
|
7917
|
+
offset += size;
|
|
7918
|
+
}
|
|
7919
|
+
}
|
|
7920
|
+
}
|
|
7921
|
+
|
|
7922
|
+
walk(codeNode);
|
|
7923
|
+
|
|
7924
|
+
return ranges
|
|
7925
|
+
}
|
|
7926
|
+
|
|
7927
|
+
function $applyLinkRangesToTokens(tokens, ranges) {
|
|
7928
|
+
if (ranges.length === 0) return tokens
|
|
7929
|
+
|
|
7930
|
+
const linkedTokens = [];
|
|
7931
|
+
let offset = 0;
|
|
7932
|
+
|
|
7933
|
+
for (const token of tokens) {
|
|
7934
|
+
if ($isCodeHighlightNode(token)) {
|
|
7935
|
+
linkedTokens.push(...$splitTokenAtLinkBoundaries(token, offset, ranges));
|
|
7936
|
+
} else {
|
|
7937
|
+
linkedTokens.push(token);
|
|
7938
|
+
}
|
|
7939
|
+
offset += token.getTextContentSize();
|
|
7940
|
+
}
|
|
7941
|
+
|
|
7942
|
+
return linkedTokens
|
|
7943
|
+
}
|
|
7944
|
+
|
|
7945
|
+
function $splitTokenAtLinkBoundaries(token, tokenStart, ranges) {
|
|
7946
|
+
const text = token.getTextContent();
|
|
7947
|
+
const segments = segmentTextByRanges(text, tokenStart, ranges);
|
|
7948
|
+
|
|
7949
|
+
if (segments.length === 1) {
|
|
7950
|
+
const [ segment ] = segments;
|
|
7951
|
+
if (segment.range) {
|
|
7952
|
+
$setCodeLink(token, segment.range.link);
|
|
7953
|
+
}
|
|
7954
|
+
return [ token ]
|
|
7955
|
+
} else {
|
|
7956
|
+
return segments.map((segment) => {
|
|
7957
|
+
const segmentToken = $cloneTokenSlice(token, text.slice(segment.start, segment.end));
|
|
7958
|
+
if (segment.range) {
|
|
7959
|
+
$setCodeLink(segmentToken, segment.range.link);
|
|
7960
|
+
}
|
|
7961
|
+
return segmentToken
|
|
7962
|
+
})
|
|
7963
|
+
}
|
|
7964
|
+
}
|
|
7965
|
+
|
|
7966
|
+
function $cloneTokenSlice(token, text) {
|
|
7967
|
+
const segmentToken = $createCodeHighlightNode(text, token.getHighlightType());
|
|
7968
|
+
segmentToken.setStyle(token.getStyle());
|
|
7969
|
+
// CodeHighlightNode.setFormat is a no-op, so carry the format over directly
|
|
7970
|
+
segmentToken.getWritable().__format = token.getFormat();
|
|
7971
|
+
return segmentToken
|
|
7972
|
+
}
|
|
7973
|
+
|
|
7974
|
+
// Consecutive children carrying the same link merge into a single range, so a
|
|
7975
|
+
// link split across tokens by an earlier retokenization keeps covering fresh
|
|
7976
|
+
// tokens as one contiguous stretch however they retokenize.
|
|
7977
|
+
function appendRange(ranges, range) {
|
|
7978
|
+
const previous = ranges[ranges.length - 1];
|
|
7979
|
+
|
|
7980
|
+
if (previous && previous.end === range.start && sameLink(previous.link, range.link)) {
|
|
7981
|
+
previous.end = range.end;
|
|
7982
|
+
} else {
|
|
7983
|
+
ranges.push(range);
|
|
7984
|
+
}
|
|
7985
|
+
}
|
|
7986
|
+
|
|
7987
|
+
function sameLink(a, b) {
|
|
7988
|
+
return a.url === b.url && a.target === b.target && a.rel === b.rel && a.title === b.title
|
|
7989
|
+
}
|
|
7990
|
+
|
|
7991
|
+
function linkAttributesFrom(linkNode) {
|
|
7992
|
+
const link = { url: linkNode.getURL() };
|
|
7993
|
+
if (linkNode.getTarget()) link.target = linkNode.getTarget();
|
|
7994
|
+
if (linkNode.getRel()) link.rel = linkNode.getRel();
|
|
7995
|
+
if (linkNode.getTitle()) link.title = linkNode.getTitle();
|
|
7996
|
+
return link
|
|
7997
|
+
}
|
|
7998
|
+
|
|
7780
7999
|
// Custom TextNode exportDOM that avoids redundant wrapping.
|
|
7781
8000
|
//
|
|
7782
8001
|
// Lexical's built-in TextNode.exportDOM() calls createDOM() which produces semantic tags
|
|
@@ -7791,6 +8010,30 @@ class BrowserAdapter {
|
|
|
7791
8010
|
// Any <span> elements produced by createDOM() are unwrapped, since they only carry
|
|
7792
8011
|
// editor classes that aren't meaningful in exported HTML.
|
|
7793
8012
|
|
|
8013
|
+
// A CodeHighlightNode exports like any other text node, plus an <a> wrapper
|
|
8014
|
+
// when it carries link state — that's how a link survives inside a code
|
|
8015
|
+
// block, whose retokenizer would destroy an actual LinkNode child.
|
|
8016
|
+
function exportCodeHighlightNodeDOM(editor, codeHighlightNode) {
|
|
8017
|
+
const output = exportTextNodeDOM(editor, codeHighlightNode);
|
|
8018
|
+
const link = $getCodeLink(codeHighlightNode);
|
|
8019
|
+
|
|
8020
|
+
if (link) {
|
|
8021
|
+
return { element: wrapWithAnchor(output.element, link) }
|
|
8022
|
+
} else {
|
|
8023
|
+
return output
|
|
8024
|
+
}
|
|
8025
|
+
}
|
|
8026
|
+
|
|
8027
|
+
function wrapWithAnchor(element, link) {
|
|
8028
|
+
const anchor = document.createElement("a");
|
|
8029
|
+
anchor.setAttribute("href", link.url);
|
|
8030
|
+
if (link.target) anchor.setAttribute("target", link.target);
|
|
8031
|
+
if (link.rel) anchor.setAttribute("rel", link.rel);
|
|
8032
|
+
if (link.title) anchor.setAttribute("title", link.title);
|
|
8033
|
+
anchor.appendChild(element);
|
|
8034
|
+
return anchor
|
|
8035
|
+
}
|
|
8036
|
+
|
|
7794
8037
|
function exportTextNodeDOM(editor, textNode) {
|
|
7795
8038
|
const element = textNode.createDOM(editor._config, editor);
|
|
7796
8039
|
element.style.whiteSpace = "pre-wrap";
|
|
@@ -8022,12 +8265,34 @@ class CodeHighlightingExtension extends LexxyExtension {
|
|
|
8022
8265
|
return defineExtension({
|
|
8023
8266
|
name: "lexxy/code-highlighting",
|
|
8024
8267
|
register(editor) {
|
|
8025
|
-
return registerCodeHighlighting(editor,
|
|
8268
|
+
return registerCodeHighlighting(editor, buildMarkupPreservingTokenizer(editor))
|
|
8026
8269
|
}
|
|
8027
8270
|
})
|
|
8028
8271
|
}
|
|
8029
8272
|
}
|
|
8030
8273
|
|
|
8274
|
+
// The code retokenizer replaces a code block's children with freshly created
|
|
8275
|
+
// tokens that carry no styles or links, which would drop color highlights and
|
|
8276
|
+
// hyperlinks on every edit. This tokenizer wraps the stock Prism tokenizer to
|
|
8277
|
+
// restore both: it recovers the block's highlight and link ranges — staged
|
|
8278
|
+
// during HTML import, or read from the children the fresh tokens are about to
|
|
8279
|
+
// replace — and reapplies them to the fresh tokens before the retokenizer
|
|
8280
|
+
// splices them in.
|
|
8281
|
+
function buildMarkupPreservingTokenizer(editor) {
|
|
8282
|
+
return {
|
|
8283
|
+
defaultLanguage: PrismTokenizer.defaultLanguage,
|
|
8284
|
+
tokenize(code, language) {
|
|
8285
|
+
return PrismTokenizer.tokenize(code, language)
|
|
8286
|
+
},
|
|
8287
|
+
$tokenize(codeNode, language) {
|
|
8288
|
+
const linkRanges = $extractLinkRangesFromCodeNode(codeNode);
|
|
8289
|
+
const highlightRanges = $takeHighlightRanges(editor, codeNode);
|
|
8290
|
+
const tokens = PrismTokenizer.$tokenize(codeNode, language);
|
|
8291
|
+
return $applyLinkRangesToTokens($applyHighlightRangesToTokens(tokens, highlightRanges), linkRanges)
|
|
8292
|
+
}
|
|
8293
|
+
}
|
|
8294
|
+
}
|
|
8295
|
+
|
|
8031
8296
|
const TRIX_LANGUAGE_ATTR = "language";
|
|
8032
8297
|
|
|
8033
8298
|
class TrixContentExtension extends LexxyExtension {
|
|
@@ -9814,7 +10079,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
9814
10079
|
theme: theme,
|
|
9815
10080
|
nodes: this.#lexicalNodes,
|
|
9816
10081
|
html: {
|
|
9817
|
-
export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode,
|
|
10082
|
+
export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode, exportCodeHighlightNodeDOM ] ])
|
|
9818
10083
|
},
|
|
9819
10084
|
$initialEditorState: (editor) => {
|
|
9820
10085
|
this.#configureSanitizer(editor);
|
|
@@ -44,12 +44,13 @@ function highlightElement(preElement) {
|
|
|
44
44
|
const grammar = Prism.languages?.[language];
|
|
45
45
|
if (!grammar) return
|
|
46
46
|
|
|
47
|
-
// Read the source text and <mark> ranges in a single walk,
|
|
48
|
-
// rewrites the element. Sharing one traversal keeps the
|
|
49
|
-
// aligned with the code string and preserves leading whitespace —
|
|
50
|
-
// either of them separately (e.g. textContent through DOMParser)
|
|
51
|
-
// leading whitespace and shifts every range, re-indenting the
|
|
52
|
-
|
|
47
|
+
// Read the source text and the <mark> and <a> ranges in a single walk,
|
|
48
|
+
// before Prism rewrites the element. Sharing one traversal keeps the range
|
|
49
|
+
// offsets aligned with the code string and preserves leading whitespace —
|
|
50
|
+
// deriving either of them separately (e.g. textContent through DOMParser)
|
|
51
|
+
// collapses leading whitespace and shifts every range, re-indenting the
|
|
52
|
+
// rendered block.
|
|
53
|
+
const { code, highlights, links } = extractCodeAndMarkup(preElement);
|
|
53
54
|
|
|
54
55
|
const highlightedHtml = Prism.highlight(code, grammar, language);
|
|
55
56
|
preElement.innerHTML = highlightedHtml;
|
|
@@ -58,18 +59,24 @@ function highlightElement(preElement) {
|
|
|
58
59
|
applyHighlightRanges(preElement, highlights);
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
if (links.length > 0) {
|
|
63
|
+
applyLinkRanges(preElement, links);
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
preElement.dataset.highlighted = "true";
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
// Walk the <pre> once, building Prism's source text and the <mark>
|
|
65
|
-
// together: a text node contributes its text verbatim, a <br>
|
|
66
|
-
// newline, and a <mark> records the slice of code it
|
|
67
|
-
// outputs come from the same walk, every range offset is
|
|
68
|
-
// `code` — so the
|
|
69
|
-
// block's leading whitespace survives (HTML parsing would
|
|
70
|
-
|
|
69
|
+
// Walk the <pre> once, building Prism's source text and the <mark> and <a>
|
|
70
|
+
// ranges together: a text node contributes its text verbatim, a <br>
|
|
71
|
+
// contributes a newline, and a <mark> or <a> records the slice of code it
|
|
72
|
+
// covers. Because all outputs come from the same walk, every range offset is
|
|
73
|
+
// just a position in `code` — so the markup can't drift out of sync with the
|
|
74
|
+
// source, and the block's leading whitespace survives (HTML parsing would
|
|
75
|
+
// collapse it).
|
|
76
|
+
function extractCodeAndMarkup(preElement) {
|
|
71
77
|
const root = preElement.querySelector("code") || preElement;
|
|
72
78
|
const highlights = [];
|
|
79
|
+
const links = [];
|
|
73
80
|
let code = "";
|
|
74
81
|
|
|
75
82
|
function walk(node) {
|
|
@@ -87,6 +94,12 @@ function extractCodeAndHighlights(preElement) {
|
|
|
87
94
|
if (style) {
|
|
88
95
|
highlights.push({ start, end: code.length, style });
|
|
89
96
|
}
|
|
97
|
+
} else if (node.tagName === "A" && node.getAttribute("href")) {
|
|
98
|
+
const start = code.length;
|
|
99
|
+
for (const child of node.childNodes) {
|
|
100
|
+
walk(child);
|
|
101
|
+
}
|
|
102
|
+
links.push({ start, end: code.length, attributes: linkAttributes(node) });
|
|
90
103
|
} else {
|
|
91
104
|
for (const child of node.childNodes) {
|
|
92
105
|
walk(child);
|
|
@@ -99,7 +112,17 @@ function extractCodeAndHighlights(preElement) {
|
|
|
99
112
|
walk(child);
|
|
100
113
|
}
|
|
101
114
|
|
|
102
|
-
return { code, highlights }
|
|
115
|
+
return { code, highlights, links }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function linkAttributes(element) {
|
|
119
|
+
const attributes = { href: element.getAttribute("href") };
|
|
120
|
+
for (const name of [ "target", "rel", "title" ]) {
|
|
121
|
+
if (element.getAttribute(name)) {
|
|
122
|
+
attributes[name] = element.getAttribute(name);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return attributes
|
|
103
126
|
}
|
|
104
127
|
|
|
105
128
|
function extractStyle(element) {
|
|
@@ -109,16 +132,32 @@ function extractStyle(element) {
|
|
|
109
132
|
return parts.length > 0 ? parts.join(" ") : null
|
|
110
133
|
}
|
|
111
134
|
|
|
112
|
-
// Wrap character ranges in <mark> elements within a Prism-highlighted
|
|
113
|
-
// Each range is applied independently, re-collecting text nodes
|
|
114
|
-
// account for splits from previous ranges.
|
|
135
|
+
// Wrap character ranges in <mark> or <a> elements within a Prism-highlighted
|
|
136
|
+
// DOM tree. Each range is applied independently, re-collecting text nodes
|
|
137
|
+
// each time to account for splits from previous ranges.
|
|
115
138
|
function applyHighlightRanges(element, highlights) {
|
|
116
139
|
for (const { start, end, style } of highlights) {
|
|
117
|
-
wrapRange(element, start, end,
|
|
140
|
+
wrapRange(element, start, end, () => {
|
|
141
|
+
const mark = document.createElement("mark");
|
|
142
|
+
mark.setAttribute("style", style);
|
|
143
|
+
return mark
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function applyLinkRanges(element, links) {
|
|
149
|
+
for (const { start, end, attributes } of links) {
|
|
150
|
+
wrapRange(element, start, end, () => {
|
|
151
|
+
const anchor = document.createElement("a");
|
|
152
|
+
for (const [ name, value ] of Object.entries(attributes)) {
|
|
153
|
+
anchor.setAttribute(name, value);
|
|
154
|
+
}
|
|
155
|
+
return anchor
|
|
156
|
+
});
|
|
118
157
|
}
|
|
119
158
|
}
|
|
120
159
|
|
|
121
|
-
function wrapRange(container, rangeStart, rangeEnd,
|
|
160
|
+
function wrapRange(container, rangeStart, rangeEnd, createWrapper) {
|
|
122
161
|
const textNodes = collectTextNodes(container);
|
|
123
162
|
|
|
124
163
|
// Process in reverse so DOM mutations don't shift earlier text node offsets
|
|
@@ -133,14 +172,13 @@ function wrapRange(container, rangeStart, rangeEnd, style) {
|
|
|
133
172
|
const text = node.textContent;
|
|
134
173
|
const parent = node.parentNode;
|
|
135
174
|
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
mark.textContent = text.slice(relStart, relEnd);
|
|
175
|
+
const wrapper = createWrapper();
|
|
176
|
+
wrapper.textContent = text.slice(relStart, relEnd);
|
|
139
177
|
|
|
140
178
|
if (relEnd < text.length) {
|
|
141
179
|
parent.insertBefore(document.createTextNode(text.slice(relEnd)), node.nextSibling);
|
|
142
180
|
}
|
|
143
|
-
parent.insertBefore(
|
|
181
|
+
parent.insertBefore(wrapper, node.nextSibling);
|
|
144
182
|
|
|
145
183
|
if (relStart > 0) {
|
|
146
184
|
node.textContent = text.slice(0, relStart);
|