@@ -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
129231export 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
142245export 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 ( ) ;
0 commit comments