Skip to content

Commit d65ed42

Browse files
Fix markdown import/export round-tripping
1 parent 970164a commit d65ed42

7 files changed

Lines changed: 162 additions & 21 deletions

File tree

src/components/editor/lib/markdown.ts

Lines changed: 117 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,38 +98,141 @@ function normalizeImportedQuoteSpacing(node: LexicalNode): void {
9898
}
9999
}
100100

101-
function normalizeLoadedCodeBlockTrailingNewline(node: LexicalNode): void {
101+
type FencedCodeBlock = {
102+
signature: string;
103+
text: string;
104+
};
105+
106+
function normalizeCodeBlockLanguage(
107+
language: string | null | undefined,
108+
): string {
109+
return !language || language === "plain" ? "" : language;
110+
}
111+
112+
function getCodeBlockSignature(
113+
language: string | null | undefined,
114+
text: string,
115+
): string {
116+
return `${normalizeCodeBlockLanguage(language)}\u0000${text.replace(/\n+$/, "")}`;
117+
}
118+
119+
function collectFencedCodeBlocks(markdown: string): FencedCodeBlock[] {
120+
const lines = markdown.split("\n");
121+
const blocks: FencedCodeBlock[] = [];
122+
123+
for (let i = 0; i < lines.length; i++) {
124+
const line = lines[i];
125+
const trimmed = line.trimStart();
126+
127+
if (CODE_SINGLE_LINE_RE.test(trimmed)) {
128+
blocks.push({
129+
signature: getCodeBlockSignature("", ""),
130+
text: "",
131+
});
132+
continue;
133+
}
134+
135+
const match = CODE_FENCE_RE.exec(trimmed);
136+
if (!match) {
137+
continue;
138+
}
139+
140+
const fenceChar = match[1][0];
141+
const fenceLen = match[1].length;
142+
const escapedFenceChar = fenceChar === "`" ? "\\`" : "~";
143+
const closeFenceRe = new RegExp(
144+
`^[ \\t]*${escapedFenceChar}{${fenceLen},}[ \\t]*$`,
145+
);
146+
147+
let end = i + 1;
148+
while (end < lines.length && !closeFenceRe.test(lines[end])) {
149+
end++;
150+
}
151+
152+
const contentLines = lines.slice(i + 1, end);
153+
let trailingBlankLines = 0;
154+
for (let j = contentLines.length - 1; j >= 0; j--) {
155+
if (contentLines[j].trim() !== "") {
156+
break;
157+
}
158+
trailingBlankLines++;
159+
}
160+
161+
const baseLines =
162+
trailingBlankLines > 0
163+
? contentLines.slice(0, contentLines.length - trailingBlankLines)
164+
: contentLines;
165+
const text = baseLines.join("\n") + "\n".repeat(trailingBlankLines);
166+
const info = trimmed.slice(match[1].length).trim();
167+
const language =
168+
info.length > 0
169+
? normalizeCodeBlockLanguage(info.split(/\s+/, 1)[0])
170+
: "";
171+
blocks.push({
172+
signature: getCodeBlockSignature(language, text),
173+
text,
174+
});
175+
i = end;
176+
}
177+
178+
return blocks;
179+
}
180+
181+
function normalizeImportedCodeBlockText(
182+
node: LexicalNode,
183+
codeBlocksBySignature: Map<string, string[]>,
184+
): void {
102185
if ($isElementNode(node)) {
103186
for (const child of node.getChildren()) {
104-
normalizeLoadedCodeBlockTrailingNewline(child);
187+
normalizeImportedCodeBlockText(child, codeBlocksBySignature);
105188
}
106189
}
107190

108191
if (!$isCodeNode(node)) {
109192
return;
110193
}
111194

112-
const text = node.getTextContent();
113-
if (!text.endsWith("\n")) {
195+
const expectedText = codeBlocksBySignature
196+
.get(getCodeBlockSignature(node.getLanguage(), node.getTextContent()))
197+
?.shift();
198+
if (expectedText == null) {
199+
return;
200+
}
201+
202+
if (node.getTextContent() === expectedText) {
114203
return;
115204
}
116205

117-
// Comrak renders fenced code blocks as <pre><code>content\n</code></pre>.
118-
// Lexical preserves that final newline as an extra empty line in the editor,
119-
// but our markdown import path does not. Trim only the synthetic final
120-
// newline so reload matches paste behavior while preserving intentional
121-
// trailing blank lines inside the block.
122-
const normalized = text.slice(0, -1);
123206
node.clear();
124-
if (normalized.length > 0) {
125-
node.append($createTextNode(normalized));
207+
if (expectedText.length > 0) {
208+
node.append($createTextNode(expectedText));
209+
}
210+
}
211+
212+
function normalizeImportedCodeBlocksFromMarkdown(
213+
nodes: LexicalNode[],
214+
markdown: string,
215+
): void {
216+
const codeBlocksBySignature = new Map<string, string[]>();
217+
for (const block of collectFencedCodeBlocks(markdown)) {
218+
const existing = codeBlocksBySignature.get(block.signature);
219+
if (existing) {
220+
existing.push(block.text);
221+
} else {
222+
codeBlocksBySignature.set(block.signature, [block.text]);
223+
}
224+
}
225+
226+
for (const node of nodes) {
227+
normalizeImportedCodeBlockText(node, codeBlocksBySignature);
126228
}
127229
}
128230

129231
export function normalizeImportedNodes(nodes: LexicalNode[]): LexicalNode[] {
130232
for (const node of nodes) {
131233
normalizeImportedQuoteSpacing(node);
132234
}
235+
133236
return nodes;
134237
}
135238

@@ -141,6 +244,7 @@ const domParser = new DOMParser();
141244

142245
export function $importMarkdownFromHTML(
143246
html: string,
247+
markdown: string,
144248
node?: ElementNode,
145249
): void {
146250
const t0 = performance.now();
@@ -151,9 +255,7 @@ export function $importMarkdownFromHTML(
151255
);
152256
const t1 = performance.now();
153257
const nodes = normalizeImportedNodes($generateNodesFromDOM(editor, dom));
154-
for (const node of nodes) {
155-
normalizeLoadedCodeBlockTrailingNewline(node);
156-
}
258+
normalizeImportedCodeBlocksFromMarkdown(nodes, markdown);
157259
const t2 = performance.now();
158260
const target = node ?? $getRoot();
159261
target.clear();

src/components/editor/note-editor.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
import { CodeExtension } from "@lexical/code";
2323
import { HashtagExtension } from "./extensions/hashtag-extension";
2424
import { TableExtension } from "@lexical/table";
25-
import { LinkNode } from "@lexical/link";
25+
import { AutoLinkNode, LinkNode } from "@lexical/link";
2626
import { ImageNode } from "./nodes/image-node";
2727
import { YouTubeNode } from "./nodes/youtube-node";
2828

@@ -40,6 +40,7 @@ import HeadingAnchorPlugin from "./plugins/heading-anchor-plugin";
4040
import HeadingBackspacePlugin from "./plugins/heading-backspace-plugin";
4141
import LinkClickPlugin from "./plugins/link-click-plugin";
4242
import LinkPastePlugin from "./plugins/link-paste-plugin";
43+
import AutoLinkPlugin from "./plugins/autolink-plugin";
4344
import MarkdownCopyPlugin from "./plugins/markdown-copy-plugin";
4445
import MarkdownPastePlugin from "./plugins/markdown-paste-plugin";
4546
import ImageDropPlugin from "./plugins/image-drop-plugin";
@@ -145,6 +146,7 @@ function EditorInner({
145146
<HeadingBackspacePlugin />
146147
<LinkClickPlugin />
147148
<LinkPastePlugin />
149+
<AutoLinkPlugin />
148150
<MarkdownCopyPlugin />
149151
<MarkdownPastePlugin />
150152
<YouTubeEmbedPlugin />
@@ -167,7 +169,7 @@ export const NoteEditor = forwardRef<NoteEditorHandle, NoteEditorProps>(
167169
name: "CometEditor",
168170
namespace: "CometEditor",
169171
theme,
170-
nodes: [LinkNode, ImageNode, YouTubeNode],
172+
nodes: [AutoLinkNode, LinkNode, ImageNode, YouTubeNode],
171173
onError: (error: Error) => console.error("Lexical error:", error),
172174
dependencies: [
173175
RichTextExtension,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useEffect } from "react";
2+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
3+
import { createLinkMatcherWithRegExp, registerAutoLink } from "@lexical/link";
4+
5+
const EMAIL_LINK_MATCHER = createLinkMatcherWithRegExp(
6+
/[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/,
7+
(text) => `mailto:${text}`,
8+
);
9+
10+
export default function AutoLinkPlugin() {
11+
const [editor] = useLexicalComposerContext();
12+
13+
useEffect(() => {
14+
return registerAutoLink(editor, {
15+
changeHandlers: [],
16+
excludeParents: [],
17+
matchers: [EMAIL_LINK_MATCHER],
18+
});
19+
}, [editor]);
20+
21+
return null;
22+
}

src/components/editor/plugins/initial-content-plugin.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export default function InitialContentPlugin({
4444
$setSelection(null);
4545
} else if (html) {
4646
// Use pre-rendered HTML from Rust backend (comrak)
47-
$importMarkdownFromHTML(html);
47+
$importMarkdownFromHTML(html, markdown);
4848
if (isNew) {
4949
// Place cursor at end of heading for new notes
5050
const root = $getRoot();

src/components/editor/plugins/markdown-paste-plugin.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,7 @@ function trimBoundaryEmptyParagraphs(
138138
if (nodes.length === 0) return nodes;
139139

140140
const lines = sourceMarkdown.split("\n");
141-
const hasLeadingBlankLine =
142-
lines.length > 0 && lines[0]?.trim().length === 0;
141+
const hasLeadingBlankLine = lines.length > 0 && lines[0]?.trim().length === 0;
143142
const hasTrailingBlankLine =
144143
lines.length > 0 && lines[lines.length - 1]?.trim().length === 0;
145144

src/components/editor/transformers/code-transformer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const CODE_BLOCK: MultilineElementTransformer = {
1818
}
1919
const language = node.getLanguage();
2020
const languageTag = language && language !== "plain" ? language : "";
21-
const textContent = node.getTextContent().replace(/\n+$/, "");
21+
const textContent = node.getTextContent();
2222

2323
// Use dynamic fence length: find the longest run of backticks in the
2424
// content and use one more than that (minimum 3).

src/components/editor/transformers/link-transformer.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ function getLinkAttributes(url: string) {
99
return { target: "_blank", rel: "noopener noreferrer" };
1010
}
1111

12+
function isPlainEmailAutolink(
13+
displayText: string,
14+
url: string,
15+
title: string | null,
16+
) {
17+
return (
18+
title == null &&
19+
displayText.length > 0 &&
20+
url.startsWith("mailto:") &&
21+
url.slice("mailto:".length) === displayText
22+
);
23+
}
24+
1225
export const LINK: TextMatchTransformer = {
1326
dependencies: [LinkNode],
1427
export: (node) => {
@@ -18,6 +31,9 @@ export const LINK: TextMatchTransformer = {
1831
const displayText = node.getTextContent();
1932
const url = node.getURL();
2033
const title = node.getTitle();
34+
if (isPlainEmailAutolink(displayText, url, title)) {
35+
return displayText;
36+
}
2137
const titlePart = title ? ` "${title}"` : "";
2238
return `[${displayText || url}](${url}${titlePart})`;
2339
},

0 commit comments

Comments
 (0)