Skip to content

Commit 6ae4ae8

Browse files
committed
optimized character highlighting
1 parent cf82f19 commit 6ae4ae8

1 file changed

Lines changed: 160 additions & 78 deletions

File tree

src/lib/screenplay/extensions/character-highlight-extension.ts

Lines changed: 160 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { Editor, Extension } from "@tiptap/core";
22
import { Plugin, PluginKey } from "@tiptap/pm/state";
33
import { Decoration, DecorationSet } from "@tiptap/pm/view";
44
import { ScreenplayElement } from "../../utils/enums";
5-
import { getNodeFlattenContent } from "../screenplay";
65

76
const 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-
*/
1713
function 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-
*/
3223
function 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
*/
4543
function 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

123184
export 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
*/
176259
export 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

Comments
 (0)