Skip to content

Commit 5293b29

Browse files
committed
feat: cssHighlights helper — paint Range-based decorations without mutating DOM
Wraps the CSS Custom Highlights API for plugins that want to decorate text (syntax highlighting, spell-check, find-and-replace, lint, search) without splitText / innerHTML mutation that fights Etherpad's Ace bookkeeping. API: const reg = createCssHighlights(); reg.setLineRanges(lineEl, [{start, end, cls}, ...]); // per line reg.clearAll(); // global reset reg.removeLineRanges(lineEl); // single line Each instance owns its own Highlight registry so multiple plugins can coexist (recommend cls namespacing like 'myplugin-keyword'). Also exports buildRange / buildSegments / isSupported as pure helpers for unit testing without a browser window. setLineRanges no-ops gracefully when CSS.highlights is unavailable (Chrome <105, Firefox <140, Safari <17.2). Adds jsdom devDep + 11 unit tests covering buildSegments, buildRange (single + multi-node + out-of-bounds), isSupported, and the registry factory's setLineRanges/removeLineRanges/clearAll behavior with a stub Highlight implementation. README: new section under Logger explaining the 'don't mutate the DOM Ace owns' lesson and the API. Bumps 0.4.1 -> 0.5.0 (new helper export).
1 parent 48e2a24 commit 5293b29

5 files changed

Lines changed: 3789 additions & 2 deletions

File tree

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,43 @@ log.info('loaded');
290290
log.warn('something');
291291
```
292292

293+
### `cssHighlights()` — paint Range-based decorations without mutating Ace's DOM
294+
295+
Use cases: syntax highlighting, spell-check squiggles, find-and-replace highlights, lint indicators, "search-in-pad" UIs.
296+
297+
The trap: any plugin that calls `splitText` / `insertBefore` / `innerHTML =` to inject decorative `<span>`s into a line div is mutating DOM that Etherpad's Ace owns. Ace tracks each line's text nodes, attribute spans, and `_magicdom_dirtiness.knownHTML`. Mutating that DOM mid-edit fights its bookkeeping — broken caret on active-line typing, broken changeset application from collaborators, stuck stale decorations.
298+
299+
The fix: register character ranges with the browser's [CSS Custom Highlights API](https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API) and let the browser composite the paint via `::highlight()` CSS rules. **The DOM stays exactly as Ace wrote it** — Ace's bookkeeping never sees your decorations.
300+
301+
```js
302+
// Client-side (static/js/index.js)
303+
const {createCssHighlights} = require('ep_plugin_helpers/css-highlights');
304+
305+
const reg = createCssHighlights();
306+
307+
// Whenever a line should re-paint (acePostWriteDomLineHTML, MutationObserver,
308+
// aceEditEvent, language change, …):
309+
reg.setLineRanges(lineEl, [
310+
{start: 0, end: 5, cls: 'my-keyword'},
311+
{start: 12, end: 18, cls: 'my-string'},
312+
]);
313+
314+
// On a global state reset (e.g. user changed language):
315+
reg.clearAll();
316+
```
317+
318+
```css
319+
/* static/css/editor.css — applied inside the inner ace iframe */
320+
::highlight(my-keyword) { color: #d73a49; font-weight: bold; }
321+
::highlight(my-string) { color: #032f62; }
322+
```
323+
324+
**Returns:** `setLineRanges(lineEl, ranges)`, `removeLineRanges(lineEl)`, `clearAll()`, plus `buildRange` / `buildSegments` exposed as pure helpers for unit testing without a browser window.
325+
326+
Each instance owns its own Highlight registry — multiple plugins can use this helper side-by-side without colliding (as long as their `cls` names don't clash; namespacing like `myplugin-keyword` is recommended).
327+
328+
`setLineRanges` no-ops gracefully on browsers that lack `CSS.highlights` (Chrome < 105, Firefox < 140, Safari < 17.2). The host editor still works; just no decoration paint.
329+
293330
## Client vs Server imports
294331

295332
Etherpad bundles client-side JS with esbuild. To avoid pulling Node.js modules into the browser bundle:

css-highlights.js

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
'use strict';
2+
3+
// cssHighlights — wrapper around the CSS Custom Highlights API for plugins
4+
// that want to paint Range-based decorations *without mutating the editor's
5+
// DOM*. Use cases: syntax highlighting, spell-check squiggles, find-and-
6+
// replace highlights, lint indicators, "search-in-pad" UIs.
7+
//
8+
// Why not splitText / inject <span>: Etherpad's Ace tracks every line's
9+
// internal state (text nodes, attribute spans, _magicdom_dirtiness.knownHTML).
10+
// Mutating the DOM that Ace owns fights its bookkeeping — broken caret on
11+
// active-line typing, broken collab changeset application, stuck stale
12+
// decorations. Range-based highlights register with the browser; the DOM
13+
// itself stays exactly as Ace wrote it.
14+
//
15+
// CSS Custom Highlights ships in Chrome 105+, Safari 17.2+, Firefox 140+.
16+
// On older browsers `setLineRanges` no-ops gracefully — host editor still
17+
// works, just without the decoration paint.
18+
//
19+
// Usage (client-side; this module loads in the browser pad bundle):
20+
//
21+
// const {createCssHighlights} = require('ep_plugin_helpers/css-highlights');
22+
// const reg = createCssHighlights();
23+
//
24+
// // Whenever a line should re-paint:
25+
// reg.setLineRanges(lineEl, [
26+
// {start: 0, end: 5, cls: 'my-keyword'},
27+
// {start: 12, end: 18, cls: 'my-string'},
28+
// ]);
29+
//
30+
// // CSS:
31+
// // ::highlight(my-keyword) { color: #d73a49; }
32+
// // ::highlight(my-string) { color: #032f62; }
33+
//
34+
// On language change / state reset:
35+
// reg.clearAll();
36+
//
37+
// A line that the host removes from the DOM has its WeakMap entry GC'd
38+
// automatically; no explicit cleanup needed.
39+
40+
const isSupported = (win) => {
41+
if (!win || !win.Highlight) return false;
42+
if (!win.CSS || !win.CSS.highlights) return false;
43+
return typeof win.CSS.highlights.set === 'function';
44+
};
45+
46+
const buildSegments = (lineEl, win) => {
47+
const doc = lineEl.ownerDocument;
48+
const walker = doc.createTreeWalker(lineEl, win.NodeFilter.SHOW_TEXT);
49+
const segs = [];
50+
let pos = 0;
51+
let n;
52+
while ((n = walker.nextNode())) {
53+
const len = n.nodeValue.length;
54+
segs.push({node: n, start: pos, len});
55+
pos += len;
56+
}
57+
return segs;
58+
};
59+
60+
// Build a DOM Range covering the [start, end) character offsets within the
61+
// line's flattened text. Returns null if the bounds fall outside the line.
62+
const buildRange = (doc, segs, start, end) => {
63+
let startNode = null;
64+
let startOff = 0;
65+
let endNode = null;
66+
let endOff = 0;
67+
for (const seg of segs) {
68+
const segEnd = seg.start + seg.len;
69+
if (!startNode && start >= seg.start && start <= segEnd) {
70+
startNode = seg.node;
71+
startOff = start - seg.start;
72+
}
73+
// We break the moment endNode is set, so by the time we reach this
74+
// condition again endNode is guaranteed null.
75+
if (end > seg.start && end <= segEnd) {
76+
endNode = seg.node;
77+
endOff = end - seg.start;
78+
break;
79+
}
80+
}
81+
if (!startNode || !endNode) return null;
82+
const range = doc.createRange();
83+
try {
84+
range.setStart(startNode, startOff);
85+
range.setEnd(endNode, endOff);
86+
} catch (_e) {
87+
return null;
88+
}
89+
return range;
90+
};
91+
92+
const createCssHighlights = () => {
93+
// Per-instance state so plugins don't share Highlight registries.
94+
const lineRanges = new WeakMap();
95+
const classHighlights = new Map();
96+
97+
const getOrCreateHighlight = (win, cls) => {
98+
let h = classHighlights.get(cls);
99+
if (h) return h;
100+
h = new win.Highlight();
101+
win.CSS.highlights.set(cls, h);
102+
classHighlights.set(cls, h);
103+
return h;
104+
};
105+
106+
const removeLineRanges = (lineEl) => {
107+
const map = lineRanges.get(lineEl);
108+
if (!map) return;
109+
for (const [cls, arr] of map) {
110+
const h = classHighlights.get(cls);
111+
if (!h) continue;
112+
for (const r of arr) {
113+
try { h.delete(r); } catch (_e) { /* stale range */ }
114+
}
115+
}
116+
lineRanges.delete(lineEl);
117+
};
118+
119+
const setLineRanges = (lineEl, tokenRanges) => {
120+
if (!lineEl) return;
121+
const win = lineEl.ownerDocument && lineEl.ownerDocument.defaultView;
122+
if (!isSupported(win)) {
123+
removeLineRanges(lineEl);
124+
return;
125+
}
126+
removeLineRanges(lineEl);
127+
if (!tokenRanges || !tokenRanges.length) return;
128+
const segs = buildSegments(lineEl, win);
129+
if (!segs.length) return;
130+
const newMap = new Map();
131+
for (const tr of tokenRanges) {
132+
if (!tr || tr.start >= tr.end || !tr.cls) continue;
133+
const range = buildRange(lineEl.ownerDocument, segs, tr.start, tr.end);
134+
if (!range) continue;
135+
const h = getOrCreateHighlight(win, tr.cls);
136+
h.add(range);
137+
let arr = newMap.get(tr.cls);
138+
if (!arr) {
139+
arr = [];
140+
newMap.set(tr.cls, arr);
141+
}
142+
arr.push(range);
143+
}
144+
if (newMap.size > 0) lineRanges.set(lineEl, newMap);
145+
};
146+
147+
const clearAll = () => {
148+
for (const h of classHighlights.values()) {
149+
try { h.clear(); } catch (_e) { /* ignore */ }
150+
}
151+
};
152+
153+
return {setLineRanges, removeLineRanges, clearAll, buildRange, buildSegments};
154+
};
155+
156+
module.exports = {
157+
createCssHighlights,
158+
cssHighlights: createCssHighlights,
159+
// Pure helpers exposed for unit testing without a browser window.
160+
buildRange,
161+
buildSegments,
162+
isSupported,
163+
};

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ep_plugin_helpers",
3-
"version": "0.4.1",
3+
"version": "0.5.0",
44
"description": "Shared factory functions to eliminate boilerplate across Etherpad plugins",
55
"author": {
66
"name": "John McLear",
@@ -37,6 +37,7 @@
3737
"eslint": "^10.3.0",
3838
"eslint-config-etherpad": "^4.0.5",
3939
"mocha": "^11.0.0",
40-
"typescript": "^6.0.3"
40+
"typescript": "^6.0.3",
41+
"jsdom": "^25.0.1"
4142
}
4243
}

0 commit comments

Comments
 (0)