@@ -2,7 +2,6 @@ import { Editor, Extension } from "@tiptap/core";
22import { Plugin , PluginKey } from "@tiptap/pm/state" ;
33import { Decoration , DecorationSet } from "@tiptap/pm/view" ;
44import { ScreenplayElement } from "../../utils/enums" ;
5- import { getNodeFlattenContent } from "../screenplay" ;
65
76const characterHighlightPluginKey = new PluginKey ( "characterHighlight" ) ;
87
@@ -11,24 +10,16 @@ type CharacterHighlightConfig = {
1110 getCharacterColor : ( name : string ) => string | undefined ;
1211} ;
1312
14- /**
15- * Extracts the clean character name from a node (removes extensions like V.O., O.S., etc.)
16- */
1713function extractCharacterName ( node : any ) : string {
18- if ( ! node . content ) return "" ;
19- const text = getNodeFlattenContent ( node . content ) ;
14+ const text : string = node . textContent || "" ;
2015 return text
2116 . toUpperCase ( )
2217 . replace ( / \s * \( .* ?\) \s * $ / g, "" )
2318 . trim ( ) ;
2419}
2520
26- // Default highlight color when character has no assigned color
27- const DEFAULT_HIGHLIGHT_COLOR = "#6366f1" ; // Indigo
21+ const DEFAULT_HIGHLIGHT_COLOR = "#6366f1" ;
2822
29- /**
30- * Converts a hex color to rgba with alpha for background highlighting
31- */
3223function hexToRgba ( hex : string , alpha : number ) : string {
3324 const result = / ^ # ? ( [ a - f \d ] { 2 } ) ( [ a - f \d ] { 2 } ) ( [ a - f \d ] { 2 } ) $ / i. exec ( hex ) ;
3425 if ( ! result ) return hex ;
@@ -38,52 +29,43 @@ function hexToRgba(hex: string, alpha: number): string {
3829 return `rgba(${ r } , ${ g } , ${ b } , ${ alpha } )` ;
3930}
4031
32+ function makeDecoration ( pos : number , nodeSize : number , color : string ) : Decoration {
33+ return Decoration . node ( pos , pos + nodeSize , {
34+ class : "character-highlight" ,
35+ style : `--highlight-color: ${ color } ; --highlight-bg: ${ hexToRgba ( color , 0.15 ) } ;` ,
36+ } ) ;
37+ }
38+
4139/**
42- * Computes decorations for character dialogues and parentheticals .
43- * Highlights dialogue/parenthetical nodes that follow a highlighted character .
40+ * Walk the full document and build highlight decorations .
41+ * Used on init and explicit refresh (toggle / color change) .
4442 */
4543function computeHighlightDecorations (
4644 doc : any ,
47- highlightedCharacters : Set < string > ,
48- getCharacterColor : ( name : string ) => string | undefined ,
45+ highlighted : Set < string > ,
46+ getColor : ( name : string ) => string | undefined ,
4947) : DecorationSet {
50- if ( highlightedCharacters . size === 0 ) {
51- return DecorationSet . empty ;
52- }
48+ if ( highlighted . size === 0 ) return DecorationSet . empty ;
5349
5450 const decorations : Decoration [ ] = [ ] ;
5551 let currentColor : string | null = null ;
5652
5753 doc . forEach ( ( node : any , pos : number ) => {
58- const nodeClass : string = node . attrs ?. class ;
59-
60- if ( nodeClass === ScreenplayElement . Character ) {
61- const name = extractCharacterName ( node . content ) ;
62- if ( highlightedCharacters . has ( name ) ) {
63- currentColor = getCharacterColor ( name ) || DEFAULT_HIGHLIGHT_COLOR ;
64- // Also highlight the character name node
65- decorations . push (
66- Decoration . node ( pos , pos + node . nodeSize , {
67- class : "character-highlight" ,
68- style : `--highlight-color: ${ currentColor } ; --highlight-bg: ${ hexToRgba ( currentColor , 0.15 ) } ;` ,
69- } ) ,
70- ) ;
54+ const cls : string = node . attrs ?. class ;
55+ if ( cls === ScreenplayElement . Character ) {
56+ const name = extractCharacterName ( node ) ;
57+ if ( highlighted . has ( name ) ) {
58+ currentColor = getColor ( name ) || DEFAULT_HIGHLIGHT_COLOR ;
59+ decorations . push ( makeDecoration ( pos , node . nodeSize , currentColor ) ) ;
7160 } else {
7261 currentColor = null ;
7362 }
7463 } else if (
75- ( nodeClass === ScreenplayElement . Dialogue || nodeClass === ScreenplayElement . Parenthetical ) &&
64+ ( cls === ScreenplayElement . Dialogue || cls === ScreenplayElement . Parenthetical ) &&
7665 currentColor
7766 ) {
78- // Apply decoration to dialogue/parenthetical following a highlighted character
79- decorations . push (
80- Decoration . node ( pos , pos + node . nodeSize , {
81- class : "character-highlight" ,
82- style : `--highlight-color: ${ currentColor } ; --highlight-bg: ${ hexToRgba ( currentColor , 0.15 ) } ;` ,
83- } ) ,
84- ) ;
67+ decorations . push ( makeDecoration ( pos , node . nodeSize , currentColor ) ) ;
8568 } else {
86- // Reset when hitting non-dialogue elements (action, scene heading, transition, etc.)
8769 currentColor = null ;
8870 }
8971 } ) ;
@@ -92,41 +74,120 @@ function computeHighlightDecorations(
9274}
9375
9476/**
95- * Check if the transaction affects any Character nodes (which would require recomputation)
77+ * Walk a position range and build highlight decorations for it.
78+ * `from` must be the start position of a Character node (so context is unambiguous).
9679 */
97- function didCharacterNodesChange ( tr : any ) : boolean {
98- if ( ! tr . docChanged ) return false ;
99-
100- // Check if any step affects a Character node
101- for ( const step of tr . steps ) {
102- const stepMap = step . getMap ( ) ;
103- let affectsCharacter = false ;
104- stepMap . forEach ( ( oldStart : number , oldEnd : number ) => {
105- try {
106- const $pos = tr . docs [ 0 ] ?. resolve ( oldStart ) ;
107- if ( $pos ) {
108- const node = $pos . nodeAfter || $pos . parent ;
109- if ( node ?. attrs ?. class === ScreenplayElement . Character ) {
110- affectsCharacter = true ;
111- }
112- }
113- } catch {
114- // Position out of bounds, skip
80+ function computeDecorationsInRange (
81+ doc : any ,
82+ from : number ,
83+ to : number ,
84+ highlighted : Set < string > ,
85+ getColor : ( name : string ) => string | undefined ,
86+ ) : Decoration [ ] {
87+ const decorations : Decoration [ ] = [ ] ;
88+ let currentColor : string | null = null ;
89+ let pos = from ;
90+
91+ while ( pos < to ) {
92+ const node = doc . nodeAt ( pos ) ;
93+ if ( ! node ) break ;
94+
95+ const cls : string = node . attrs ?. class ;
96+ if ( cls === ScreenplayElement . Character ) {
97+ const name = extractCharacterName ( node ) ;
98+ if ( highlighted . has ( name ) ) {
99+ currentColor = getColor ( name ) || DEFAULT_HIGHLIGHT_COLOR ;
100+ decorations . push ( makeDecoration ( pos , node . nodeSize , currentColor ) ) ;
101+ } else {
102+ currentColor = null ;
115103 }
116- } ) ;
117- if ( affectsCharacter ) return true ;
104+ } else if (
105+ ( cls === ScreenplayElement . Dialogue || cls === ScreenplayElement . Parenthetical ) &&
106+ currentColor
107+ ) {
108+ decorations . push ( makeDecoration ( pos , node . nodeSize , currentColor ) ) ;
109+ } else {
110+ currentColor = null ;
111+ }
112+
113+ pos += node . nodeSize ;
118114 }
119115
120- return false ;
116+ return decorations ;
117+ }
118+
119+ /**
120+ * If the transaction affected a Character node, return the range [from, to] in the
121+ * new document that needs its decorations recomputed. `from` is the position of the
122+ * first affected Character; `to` extends to the end of its following dialogue block.
123+ * Returns null if no Character nodes were involved.
124+ */
125+ function computeChangedRange ( tr : any ) : [ number , number ] | null {
126+ if ( ! tr . docChanged ) return null ;
127+
128+ // Collect the overall changed range in the new document
129+ let changedFrom = Infinity ;
130+ let changedTo = - 1 ;
131+
132+ for ( let i = 0 ; i < tr . steps . length ; i ++ ) {
133+ tr . steps [ i ] . getMap ( ) . forEach (
134+ ( _os : number , _oe : number , newStart : number , newEnd : number ) => {
135+ const m = tr . mapping . slice ( i + 1 ) ;
136+ changedFrom = Math . min ( changedFrom , m . map ( newStart , - 1 ) ) ;
137+ changedTo = Math . max ( changedTo , m . map ( newEnd , 1 ) ) ;
138+ } ,
139+ ) ;
140+ }
141+
142+ if ( changedTo === - 1 ) return null ;
143+
144+ const doc = tr . doc ;
145+ const safeFrom = Math . max ( 0 , changedFrom ) ;
146+ const safeTo = Math . min ( changedTo , doc . content . size ) ;
147+
148+ // Look for a Character node in the changed range
149+ let characterFound = false ;
150+ let rangeStart = Infinity ;
151+
152+ doc . nodesBetween ( safeFrom , safeTo , ( node : any , pos : number ) => {
153+ if ( node . attrs ?. class === ScreenplayElement . Character ) {
154+ characterFound = true ;
155+ rangeStart = Math . min ( rangeStart , pos ) ;
156+ }
157+ } ) ;
158+
159+ if ( ! characterFound ) return null ;
160+
161+ // Extend `to` forward past the dialogue/parenthetical block following the change
162+ let walkPos : number ;
163+ try {
164+ const $to = doc . resolve ( safeTo ) ;
165+ walkPos = $to . depth > 0 ? $to . after ( 1 ) : safeTo ;
166+ } catch {
167+ walkPos = safeTo ;
168+ }
169+
170+ while ( walkPos < doc . content . size ) {
171+ const node = doc . nodeAt ( walkPos ) ;
172+ if ( ! node ) break ;
173+ const cls : string = node . attrs ?. class ;
174+ if ( cls === ScreenplayElement . Dialogue || cls === ScreenplayElement . Parenthetical ) {
175+ walkPos += node . nodeSize ;
176+ } else {
177+ break ;
178+ }
179+ }
180+
181+ return [ rangeStart , walkPos ] ;
121182}
122183
123184export const createCharacterHighlightExtension = ( config : CharacterHighlightConfig ) => {
185+ const { getHighlightedCharacters, getCharacterColor } = config ;
186+
124187 return Extension . create ( {
125188 name : "characterHighlight" ,
126189
127190 addProseMirrorPlugins ( ) {
128- const { getHighlightedCharacters, getCharacterColor } = config ;
129-
130191 return [
131192 new Plugin ( {
132193 key : characterHighlightPluginKey ,
@@ -135,27 +196,49 @@ export const createCharacterHighlightExtension = (config: CharacterHighlightConf
135196 return computeHighlightDecorations ( doc , getHighlightedCharacters ( ) , getCharacterColor ) ;
136197 } ,
137198 apply ( tr , oldDecorations , _oldState , newState ) {
138- // Always recompute when explicitly refreshed (highlight toggled from UI )
199+ // Explicit refresh (highlight toggled, color changed )
139200 if ( tr . getMeta ( "characterHighlightRefresh" ) ) {
140- return computeHighlightDecorations ( tr . doc , getHighlightedCharacters ( ) , getCharacterColor ) ;
201+ return computeHighlightDecorations (
202+ tr . doc ,
203+ getHighlightedCharacters ( ) ,
204+ getCharacterColor ,
205+ ) ;
141206 }
142207
143- // If no highlighted characters, return empty
144208 if ( getHighlightedCharacters ( ) . size === 0 ) {
145209 return DecorationSet . empty ;
146210 }
147211
148- // If document changed, check if Character nodes were affected
149- if ( tr . docChanged ) {
150- // For efficiency, check if the change might affect character highlighting
151- if ( didCharacterNodesChange ( tr ) ) {
152- return computeHighlightDecorations ( tr . doc , getHighlightedCharacters ( ) , getCharacterColor ) ;
153- }
154- // Simple text edit - just map existing decorations to new positions
155- return oldDecorations . map ( tr . mapping , newState . doc ) ;
212+ if ( ! tr . docChanged ) {
213+ return oldDecorations ;
214+ }
215+
216+ // Map existing decorations to new positions (cheap, O(decorations))
217+ const mapped = oldDecorations . map ( tr . mapping , newState . doc ) ;
218+
219+ // Compute the range affected by Character node changes
220+ const range = computeChangedRange ( tr ) ;
221+ if ( ! range ) {
222+ // No Character nodes changed — mapped positions are correct
223+ return mapped ;
156224 }
157225
158- return oldDecorations ;
226+ const [ from , to ] = range ;
227+
228+ // Replace decorations in the affected range only
229+ const outside = [
230+ ...mapped . find ( 0 , from ) ,
231+ ...mapped . find ( to , newState . doc . content . size ) ,
232+ ] ;
233+ const inside = computeDecorationsInRange (
234+ newState . doc ,
235+ from ,
236+ to ,
237+ getHighlightedCharacters ( ) ,
238+ getCharacterColor ,
239+ ) ;
240+
241+ return DecorationSet . create ( newState . doc , [ ...outside , ...inside ] ) ;
159242 } ,
160243 } ,
161244 props : {
@@ -174,7 +257,6 @@ export const createCharacterHighlightExtension = (config: CharacterHighlightConf
174257 * Call this when the highlighted characters set or character colors change.
175258 */
176259export const refreshCharacterHighlights = ( editor : Editor ) => {
177- if ( ! editor || ! editor . view ) return ;
178- // Dispatch an empty transaction to trigger decoration recomputation
260+ if ( ! editor ?. view ) return ;
179261 editor . view . dispatch ( editor . state . tr . setMeta ( "characterHighlightRefresh" , true ) ) ;
180262} ;
0 commit comments