experiments in a post-browser web
at main 200 lines 5.3 kB view raw
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}