11// Live Preview renderer using CodeMirror decorations
22import { Decoration , DecorationSet , EditorView , ViewPlugin , ViewUpdate , WidgetType } from "@codemirror/view" ;
3- import { RangeSetBuilder } from "@codemirror/state" ;
3+ import { RangeSetBuilder , Text } from "@codemirror/state" ;
44import { editorInfoField , editorLivePreviewField , TFile } from "obsidian" ;
5- import { getSyntaxRegex , resolveFrontmatterString } from "./metadata-utils" ;
5+ import { createFrontmatterResolver , getSyntaxOpen , getSyntaxRegex } from "./metadata-utils" ;
66import { renderInlineMarkdown } from "./markdown-render" ;
7- import { applyValueStyles } from "./metadata-style" ;
7+ import { applyValueStyles , getStyleKey } from "./metadata-style" ;
88import { EmbedMetadataPlugin } from "./settings" ;
99
10+ type LineMarker = {
11+ from : number ;
12+ to : number ;
13+ key : string ;
14+ } ;
15+
16+ type LineMarkers = {
17+ text : string ;
18+ markers : LineMarker [ ] ;
19+ } ;
20+
1021// Build the Live Preview view plugin that renders syntax markers in the editor.
1122export function createEditorExtension ( plugin : EmbedMetadataPlugin ) {
1223 return ViewPlugin . fromClass (
1324 class {
1425 decorations : DecorationSet ;
26+ private cursorMarkerKey : string ;
27+ private lineCache : Map < number , LineMarkers > ;
28+ private syntaxStyle : string ;
1529
1630 constructor ( view : EditorView ) {
17- this . decorations = buildDecorations ( view , plugin ) ;
31+ this . lineCache = new Map ( ) ;
32+ this . syntaxStyle = plugin . settings . syntaxStyle ;
33+ this . decorations = buildDecorations ( view , plugin , this . lineCache ) ;
34+ this . cursorMarkerKey = getCursorMarkerKey ( view , plugin ) ;
1835 }
1936
2037 update ( update : ViewUpdate ) {
21- if ( update . docChanged || update . viewportChanged || update . selectionSet ) {
22- this . decorations = buildDecorations ( update . view , plugin ) ;
38+ let needsRebuild = false ;
39+
40+ if ( this . syntaxStyle !== plugin . settings . syntaxStyle ) {
41+ this . syntaxStyle = plugin . settings . syntaxStyle ;
42+ this . lineCache . clear ( ) ;
43+ needsRebuild = true ;
44+ }
45+
46+ if ( update . docChanged ) {
47+ this . decorations = this . decorations . map ( update . changes ) ;
48+ pruneLineCache ( this . lineCache , update . state . doc . lines ) ;
49+ if ( shouldRebuildForChanges ( update , plugin ) ) {
50+ needsRebuild = true ;
51+ }
52+ }
53+
54+ if ( update . viewportChanged ) {
55+ needsRebuild = true ;
56+ }
57+
58+ if ( update . docChanged || update . selectionSet ) {
59+ const nextCursorMarkerKey = getCursorMarkerKey ( update . view , plugin ) ;
60+ if ( nextCursorMarkerKey !== this . cursorMarkerKey ) {
61+ this . cursorMarkerKey = nextCursorMarkerKey ;
62+ needsRebuild = true ;
63+ }
64+ }
65+
66+ if ( needsRebuild ) {
67+ this . decorations = buildDecorations ( update . view , plugin , this . lineCache ) ;
2368 }
2469 }
2570 } ,
@@ -30,7 +75,11 @@ export function createEditorExtension(plugin: EmbedMetadataPlugin) {
3075}
3176
3277// Scan visible ranges and replace syntax markers with widgets (skipping active edits).
33- function buildDecorations ( view : EditorView , plugin : EmbedMetadataPlugin ) : DecorationSet {
78+ function buildDecorations (
79+ view : EditorView ,
80+ plugin : EmbedMetadataPlugin ,
81+ lineCache : Map < number , LineMarkers >
82+ ) : DecorationSet {
3483 if ( ! view . state . field ( editorLivePreviewField ) ) {
3584 return Decoration . none ;
3685 }
@@ -48,73 +97,261 @@ function buildDecorations(view: EditorView, plugin: EmbedMetadataPlugin): Decora
4897
4998 const builder = new RangeSetBuilder < Decoration > ( ) ;
5099 const selectionRanges = view . state . selection . ranges ;
100+ const styleKey = getStyleKey ( plugin . settings ) ;
101+ const syntaxOpen = getSyntaxOpen ( plugin . settings . syntaxStyle ) ;
51102 const syntaxRegex = getSyntaxRegex ( plugin . settings . syntaxStyle ) ;
103+ const seenLines = new Set < number > ( ) ;
104+ const resolveValue = createFrontmatterResolver ( frontmatter , plugin . settings . caseInsensitiveKeys ) ;
52105
53106 for ( const range of view . visibleRanges ) {
54- const text = view . state . doc . sliceString ( range . from , range . to ) ;
55- syntaxRegex . lastIndex = 0 ;
56- let match : RegExpExecArray | null ;
57-
58- while ( ( match = syntaxRegex . exec ( text ) ) !== null ) {
59- const start = range . from + match . index ;
60- const end = start + match [ 0 ] . length ;
107+ const startLine = view . state . doc . lineAt ( range . from ) . number ;
108+ const endLine = view . state . doc . lineAt ( Math . max ( range . to - 1 , range . from ) ) . number ;
61109
62- if ( selectionRanges . some ( ( sel ) => sel . from <= end && sel . to >= start ) ) {
110+ for ( let lineNumber = startLine ; lineNumber <= endLine ; lineNumber += 1 ) {
111+ if ( seenLines . has ( lineNumber ) ) {
63112 continue ;
64113 }
114+ seenLines . add ( lineNumber ) ;
65115
66- if ( selectionRanges . some ( ( sel ) => {
67- const head = sel . head ?? sel . from ;
68- return head >= start && head <= end ;
69- } ) ) {
116+ const line = view . state . doc . line ( lineNumber ) ;
117+ const markers = getLineMarkers ( lineNumber , line , lineCache , syntaxRegex , syntaxOpen ) ;
118+ if ( markers . length === 0 ) {
70119 continue ;
71120 }
72121
122+ for ( const marker of markers ) {
123+ const start = line . from + marker . from ;
124+ const end = line . from + marker . to ;
125+
126+ if ( selectionRanges . some ( ( sel ) => sel . from === sel . to && sel . from >= start && sel . to <= end ) ) {
127+ continue ;
128+ }
129+
130+ const value = resolveValue ( marker . key ) ;
131+ if ( value === null ) {
132+ continue ;
133+ }
134+
135+ builder . add (
136+ start ,
137+ end ,
138+ Decoration . replace ( {
139+ widget : new MetadataWidget ( value , file . path , plugin , styleKey ) ,
140+ inclusive : false ,
141+ } )
142+ ) ;
143+ }
144+ }
145+ }
146+
147+ return builder . finish ( ) ;
148+ }
149+
150+ function getLineMarkers (
151+ lineNumber : number ,
152+ line : { from : number ; text : string } ,
153+ lineCache : Map < number , LineMarkers > ,
154+ syntaxRegex : RegExp ,
155+ syntaxOpen : string
156+ ) : LineMarker [ ] {
157+ const cached = lineCache . get ( lineNumber ) ;
158+ if ( cached && cached . text === line . text ) {
159+ return cached . markers ;
160+ }
161+
162+ const markers : LineMarker [ ] = [ ] ;
163+ if ( line . text . includes ( syntaxOpen ) ) {
164+ syntaxRegex . lastIndex = 0 ;
165+ let match : RegExpExecArray | null ;
166+ while ( ( match = syntaxRegex . exec ( line . text ) ) !== null ) {
73167 const key = ( match [ 1 ] ?? "" ) . trim ( ) ;
74168 if ( ! key ) {
75169 continue ;
76170 }
77171
78- const value = resolveFrontmatterString (
79- frontmatter ,
80- key ,
81- plugin . settings . caseInsensitiveKeys
82- ) ;
83- if ( value === null ) {
84- continue ;
172+ const start = match . index ;
173+ const end = start + match [ 0 ] . length ;
174+ markers . push ( { from : start , to : end , key} ) ;
175+ }
176+ }
177+
178+ lineCache . set ( lineNumber , { text : line . text , markers} ) ;
179+ return markers ;
180+ }
181+
182+ function pruneLineCache ( lineCache : Map < number , LineMarkers > , maxLine : number ) : void {
183+ for ( const lineNumber of lineCache . keys ( ) ) {
184+ if ( lineNumber > maxLine ) {
185+ lineCache . delete ( lineNumber ) ;
186+ }
187+ }
188+ }
189+
190+ function shouldRebuildForChanges ( update : ViewUpdate , plugin : EmbedMetadataPlugin ) : boolean {
191+ const syntaxOpen = getSyntaxOpen ( plugin . settings . syntaxStyle ) ;
192+ const syntaxRegex = getSyntaxRegex ( plugin . settings . syntaxStyle ) ;
193+ const nextDoc = update . state . doc ;
194+ const prevDoc = update . startState . doc ;
195+ const prevFrontmatter = getFrontmatterRange ( prevDoc ) ;
196+ const nextFrontmatter = getFrontmatterRange ( nextDoc ) ;
197+ let needsRebuild = false ;
198+
199+ update . changes . iterChanges ( ( fromA , toA , fromB , toB , inserted ) => {
200+ if ( needsRebuild ) {
201+ return ;
202+ }
203+
204+ if ( prevFrontmatter && rangesOverlap ( fromA , toA , prevFrontmatter . from , prevFrontmatter . to ) ) {
205+ needsRebuild = true ;
206+ return ;
207+ }
208+
209+ if ( nextFrontmatter && rangesOverlap ( fromB , toB , nextFrontmatter . from , nextFrontmatter . to ) ) {
210+ needsRebuild = true ;
211+ return ;
212+ }
213+
214+ if ( changeTouchesMarker ( prevDoc , fromA , toA , syntaxRegex , syntaxOpen ) ) {
215+ needsRebuild = true ;
216+ return ;
217+ }
218+
219+ if ( changeTouchesMarker ( nextDoc , fromB , toB , syntaxRegex , syntaxOpen ) ) {
220+ needsRebuild = true ;
221+ }
222+ } ) ;
223+
224+ return needsRebuild ;
225+ }
226+
227+ function changeTouchesMarker (
228+ doc : Text ,
229+ from : number ,
230+ to : number ,
231+ syntaxRegex : RegExp ,
232+ syntaxOpen : string
233+ ) : boolean {
234+ const safeTo = Math . max ( to - 1 , from ) ;
235+ const startLine = doc . lineAt ( from ) . number ;
236+ const endLine = doc . lineAt ( safeTo ) . number ;
237+
238+ for ( let lineNumber = startLine ; lineNumber <= endLine ; lineNumber += 1 ) {
239+ const line = doc . line ( lineNumber ) ;
240+ if ( ! line . text . includes ( syntaxOpen ) ) {
241+ continue ;
242+ }
243+
244+ syntaxRegex . lastIndex = 0 ;
245+ let match : RegExpExecArray | null ;
246+ while ( ( match = syntaxRegex . exec ( line . text ) ) !== null ) {
247+ const start = line . from + match . index ;
248+ const end = start + match [ 0 ] . length ;
249+ if ( rangesOverlap ( from , to , start , end ) ) {
250+ return true ;
85251 }
252+ }
253+ }
254+
255+ return false ;
256+ }
257+
258+ function rangesOverlap ( from : number , to : number , start : number , end : number ) : boolean {
259+ if ( from === to ) {
260+ return from >= start && from <= end ;
261+ }
262+ return start < to && end > from ;
263+ }
264+
265+ function getFrontmatterRange ( doc : Text ) : { from : number ; to : number } | null {
266+ if ( doc . lines === 0 ) {
267+ return null ;
268+ }
269+
270+ const firstLine = doc . line ( 1 ) ;
271+ if ( ! / ^ - - - \s * $ / . test ( firstLine . text ) ) {
272+ return null ;
273+ }
86274
87- builder . add (
88- start ,
89- end ,
90- Decoration . replace ( {
91- widget : new MetadataWidget ( value , file . path , plugin ) ,
92- inclusive : false ,
93- } )
94- ) ;
275+ for ( let lineNumber = 2 ; lineNumber <= doc . lines ; lineNumber += 1 ) {
276+ const line = doc . line ( lineNumber ) ;
277+ if ( / ^ ( - - - | \. \. \. ) \s * $ / . test ( line . text ) ) {
278+ return { from : firstLine . from , to : line . to } ;
95279 }
96280 }
97281
98- return builder . finish ( ) ;
282+ return null ;
283+ }
284+
285+ function getCursorMarkerKey ( view : EditorView , plugin : EmbedMetadataPlugin ) : string {
286+ const syntaxOpen = getSyntaxOpen ( plugin . settings . syntaxStyle ) ;
287+ const syntaxRegex = getSyntaxRegex ( plugin . settings . syntaxStyle ) ;
288+ const markerKeys : string [ ] = [ ] ;
289+
290+ for ( const range of view . state . selection . ranges ) {
291+ if ( range . from !== range . to ) {
292+ continue ;
293+ }
294+
295+ const pos = range . from ;
296+ const line = view . state . doc . lineAt ( pos ) ;
297+ if ( ! line . text . includes ( syntaxOpen ) ) {
298+ continue ;
299+ }
300+
301+ syntaxRegex . lastIndex = 0 ;
302+ let match : RegExpExecArray | null ;
303+ while ( ( match = syntaxRegex . exec ( line . text ) ) !== null ) {
304+ const start = line . from + match . index ;
305+ const end = start + match [ 0 ] . length ;
306+ if ( pos >= start && pos <= end ) {
307+ markerKeys . push ( `${ start } :${ end } ` ) ;
308+ break ;
309+ }
310+ }
311+ }
312+
313+ if ( markerKeys . length === 0 ) {
314+ return "" ;
315+ }
316+
317+ markerKeys . sort ( ) ;
318+ return markerKeys . join ( "|" ) ;
99319}
100320
101321class MetadataWidget extends WidgetType {
102322 private readonly value : string ;
103323 private readonly sourcePath : string ;
104324 private readonly plugin : EmbedMetadataPlugin ;
325+ private readonly styleKey : string ;
326+ private readonly isEmpty : boolean ;
105327
106- constructor ( value : string , sourcePath : string , plugin : EmbedMetadataPlugin ) {
328+ constructor ( value : string , sourcePath : string , plugin : EmbedMetadataPlugin , styleKey : string ) {
107329 super ( ) ;
108330 this . value = value ;
109331 this . sourcePath = sourcePath ;
110332 this . plugin = plugin ;
333+ this . styleKey = styleKey ;
334+ this . isEmpty = value . length === 0 ;
335+ }
336+
337+ eq ( other : MetadataWidget ) : boolean {
338+ return this . value === other . value
339+ && this . sourcePath === other . sourcePath
340+ && this . styleKey === other . styleKey ;
341+ }
342+
343+ ignoreEvent ( ) : boolean {
344+ return ! this . isEmpty ;
111345 }
112346
113347 // Render the replacement widget node for a single syntax marker.
114348 toDOM ( ) : HTMLElement {
115349 const span = document . createElement ( "span" ) ;
116350 renderInlineMarkdown ( this . plugin . app , this . sourcePath , span , this . value , this . plugin ) ;
117351 applyValueStyles ( span , this . plugin . settings ) ;
352+ if ( this . isEmpty ) {
353+ span . classList . add ( "embed-metadata-empty" ) ;
354+ }
118355 return span ;
119356 }
120357}
0 commit comments