experiments in a post-browser web
1/**
2 * Highlights Module - Persistent text highlights with annotations.
3 *
4 * Manages highlight data (create, delete, update notes) and provides
5 * CodeMirror decorations to render highlights in the editor.
6 */
7
8import { StateField, StateEffect } from '@codemirror/state';
9import { Decoration, EditorView } from '@codemirror/view';
10
11// Effects for adding/removing highlights
12export const addHighlightEffect = StateEffect.define();
13export const removeHighlightEffect = StateEffect.define();
14export const setHighlightsEffect = StateEffect.define();
15export const updateHighlightNoteEffect = StateEffect.define();
16
17// Highlight colors cycling palette
18const HIGHLIGHT_COLORS = [
19 'var(--base0A)', // yellow
20 'var(--base0B)', // green
21 'var(--base0C)', // cyan
22 'var(--base0D)', // blue
23 'var(--base0E)', // purple
24 'var(--base09)', // orange
25];
26
27let colorIndex = 0;
28
29/**
30 * Get the next highlight color in the palette.
31 */
32export function nextHighlightColor() {
33 const color = HIGHLIGHT_COLORS[colorIndex % HIGHLIGHT_COLORS.length];
34 colorIndex++;
35 return color;
36}
37
38/**
39 * Generate a unique highlight ID.
40 */
41export function generateHighlightId() {
42 return 'hl-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 8);
43}
44
45/**
46 * Build a Decoration.mark for a highlight.
47 */
48function makeHighlightMark(hl) {
49 // Convert CSS var to a translucent background
50 // We use the color as a class suffix and handle in CSS
51 return Decoration.mark({
52 class: 'cm-highlight-mark',
53 attributes: {
54 'data-highlight-id': hl.id,
55 'style': `background-color: color-mix(in srgb, ${hl.color} 30%, transparent); border-bottom: 2px solid ${hl.color};`,
56 },
57 });
58}
59
60/**
61 * StateField that tracks highlights and produces decorations.
62 */
63export const highlightField = StateField.define({
64 create() {
65 return { highlights: [], decorations: Decoration.none };
66 },
67
68 update(value, tr) {
69 let highlights = value.highlights;
70 let changed = false;
71
72 for (const effect of tr.effects) {
73 if (effect.is(setHighlightsEffect)) {
74 highlights = effect.value;
75 changed = true;
76 } else if (effect.is(addHighlightEffect)) {
77 highlights = [...highlights, effect.value];
78 changed = true;
79 } else if (effect.is(removeHighlightEffect)) {
80 highlights = highlights.filter(h => h.id !== effect.value);
81 changed = true;
82 } else if (effect.is(updateHighlightNoteEffect)) {
83 const { id, note } = effect.value;
84 highlights = highlights.map(h => h.id === id ? { ...h, note } : h);
85 changed = true;
86 }
87 }
88
89 // If document changed, adjust highlight positions
90 if (tr.docChanged && !changed) {
91 highlights = highlights.map(hl => {
92 const newFrom = tr.changes.mapPos(hl.from, 1);
93 const newTo = tr.changes.mapPos(hl.to, -1);
94 if (newFrom >= newTo) return null; // Highlight collapsed
95 return {
96 ...hl,
97 from: newFrom,
98 to: newTo,
99 text: tr.state.doc.sliceString(newFrom, newTo),
100 };
101 }).filter(Boolean);
102 changed = true;
103 }
104
105 if (!changed) return value;
106
107 // Rebuild decorations
108 const decos = [];
109 for (const hl of highlights) {
110 if (hl.from >= 0 && hl.to <= tr.state.doc.length && hl.from < hl.to) {
111 decos.push(makeHighlightMark(hl).range(hl.from, hl.to));
112 }
113 }
114
115 // Sort by from position
116 decos.sort((a, b) => a.from - b.from || a.startSide - b.startSide);
117
118 return {
119 highlights,
120 decorations: Decoration.set(decos),
121 };
122 },
123
124 provide: f => EditorView.decorations.from(f, val => val.decorations),
125});
126
127/**
128 * Get current highlights from editor state.
129 */
130export function getHighlights(state) {
131 return state.field(highlightField).highlights;
132}
133
134/**
135 * Add a highlight to the editor.
136 */
137export function addHighlight(view, highlight) {
138 view.dispatch({
139 effects: addHighlightEffect.of(highlight),
140 });
141}
142
143/**
144 * Remove a highlight by ID.
145 */
146export function removeHighlight(view, highlightId) {
147 view.dispatch({
148 effects: removeHighlightEffect.of(highlightId),
149 });
150}
151
152/**
153 * Set all highlights (used for restoring from storage).
154 */
155export function setHighlights(view, highlights) {
156 view.dispatch({
157 effects: setHighlightsEffect.of(highlights),
158 });
159}
160
161/**
162 * Update a highlight's note.
163 */
164export function updateHighlightNote(view, highlightId, note) {
165 view.dispatch({
166 effects: updateHighlightNoteEffect.of({ id: highlightId, note }),
167 });
168}
169
170/**
171 * Serialize highlights for storage.
172 */
173export function serializeHighlights(highlights) {
174 return highlights.map(hl => ({
175 id: hl.id,
176 from: hl.from,
177 to: hl.to,
178 text: hl.text,
179 note: hl.note || '',
180 color: hl.color,
181 }));
182}
183
184/**
185 * Deserialize highlights from storage, validating against doc length.
186 */
187export function deserializeHighlights(data, docLength) {
188 if (!Array.isArray(data)) return [];
189 return data.filter(hl =>
190 hl && typeof hl.from === 'number' && typeof hl.to === 'number' &&
191 hl.from >= 0 && hl.to <= docLength && hl.from < hl.to
192 ).map(hl => ({
193 id: hl.id || generateHighlightId(),
194 from: hl.from,
195 to: hl.to,
196 text: hl.text || '',
197 note: hl.note || '',
198 color: hl.color || 'var(--base0A)',
199 }));
200}