experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(editor): pane management, preview sync, persistent highlights, and UI improvements

+1317 -199
+177
extensions/editor/annotations-pane.js
··· 1 + /** 2 + * Annotations Pane - displays persistent highlights and their annotations. 3 + * Anchored to the far right of the editor layout. 4 + * Clicking an annotation scrolls to and selects the highlighted range. 5 + */ 6 + 7 + export class AnnotationsPane { 8 + constructor(options) { 9 + this.container = options.container; 10 + this.onAnnotationClick = options.onAnnotationClick; // (highlight) => void 11 + this.onAnnotationDelete = options.onAnnotationDelete; // (highlightId) => void 12 + this.onAnnotationEdit = options.onAnnotationEdit; // (highlightId, note) => void 13 + this.collapsed = true; // Start collapsed 14 + this.highlights = []; 15 + 16 + this.element = document.createElement('div'); 17 + this.element.className = 'annotations-pane collapsed'; 18 + 19 + // Header 20 + this.header = document.createElement('div'); 21 + this.header.className = 'sidebar-header'; 22 + 23 + const title = document.createElement('span'); 24 + title.className = 'sidebar-title'; 25 + title.textContent = 'Annotations'; 26 + this.header.appendChild(title); 27 + 28 + const countBadge = document.createElement('span'); 29 + countBadge.className = 'annotations-count'; 30 + countBadge.textContent = '0'; 31 + this.countBadge = countBadge; 32 + this.header.appendChild(countBadge); 33 + 34 + this.toggleBtn = document.createElement('button'); 35 + this.toggleBtn.className = 'sidebar-toggle'; 36 + this.toggleBtn.innerHTML = '\u25B6'; // > 37 + this.toggleBtn.tabIndex = -1; 38 + this.toggleBtn.addEventListener('mousedown', (e) => e.preventDefault()); 39 + this.toggleBtn.addEventListener('click', () => this.toggle()); 40 + this.header.appendChild(this.toggleBtn); 41 + 42 + this.element.appendChild(this.header); 43 + 44 + // Content area 45 + this.content = document.createElement('div'); 46 + this.content.className = 'annotations-content'; 47 + this.element.appendChild(this.content); 48 + 49 + this.container.appendChild(this.element); 50 + } 51 + 52 + /** 53 + * Update the displayed highlights. 54 + * @param {Array} highlights - Array of {id, from, to, text, note, color} 55 + */ 56 + update(highlights) { 57 + this.highlights = highlights || []; 58 + this.countBadge.textContent = String(this.highlights.length); 59 + this.render(); 60 + } 61 + 62 + render() { 63 + this.content.innerHTML = ''; 64 + 65 + if (this.highlights.length === 0) { 66 + const empty = document.createElement('div'); 67 + empty.className = 'annotations-empty'; 68 + empty.textContent = 'No highlights yet. Select text and use Cmd+H to highlight.'; 69 + this.content.appendChild(empty); 70 + return; 71 + } 72 + 73 + // Sort highlights by position 74 + const sorted = [...this.highlights].sort((a, b) => a.from - b.from); 75 + 76 + for (const hl of sorted) { 77 + const item = document.createElement('div'); 78 + item.className = 'annotation-item'; 79 + 80 + // Color indicator 81 + const colorDot = document.createElement('span'); 82 + colorDot.className = 'annotation-color'; 83 + colorDot.style.background = hl.color || 'var(--base0A)'; 84 + item.appendChild(colorDot); 85 + 86 + // Text content 87 + const textWrap = document.createElement('div'); 88 + textWrap.className = 'annotation-text-wrap'; 89 + 90 + const text = document.createElement('div'); 91 + text.className = 'annotation-text'; 92 + text.textContent = hl.text.length > 80 ? hl.text.substring(0, 80) + '...' : hl.text; 93 + textWrap.appendChild(text); 94 + 95 + if (hl.note) { 96 + const note = document.createElement('div'); 97 + note.className = 'annotation-note'; 98 + note.textContent = hl.note; 99 + textWrap.appendChild(note); 100 + } 101 + 102 + item.appendChild(textWrap); 103 + 104 + // Actions 105 + const actions = document.createElement('div'); 106 + actions.className = 'annotation-actions'; 107 + 108 + const editBtn = document.createElement('button'); 109 + editBtn.className = 'annotation-btn'; 110 + editBtn.textContent = 'Note'; 111 + editBtn.title = 'Edit note'; 112 + editBtn.addEventListener('mousedown', (e) => e.preventDefault()); 113 + editBtn.addEventListener('click', (e) => { 114 + e.stopPropagation(); 115 + this._editNote(hl); 116 + }); 117 + actions.appendChild(editBtn); 118 + 119 + const deleteBtn = document.createElement('button'); 120 + deleteBtn.className = 'annotation-btn annotation-btn-delete'; 121 + deleteBtn.textContent = '\u00D7'; // x 122 + deleteBtn.title = 'Remove highlight'; 123 + deleteBtn.addEventListener('mousedown', (e) => e.preventDefault()); 124 + deleteBtn.addEventListener('click', (e) => { 125 + e.stopPropagation(); 126 + if (this.onAnnotationDelete) { 127 + this.onAnnotationDelete(hl.id); 128 + } 129 + }); 130 + actions.appendChild(deleteBtn); 131 + 132 + item.appendChild(actions); 133 + 134 + // Click to jump to highlight 135 + item.addEventListener('mousedown', (e) => e.preventDefault()); 136 + item.addEventListener('click', () => { 137 + if (this.onAnnotationClick) { 138 + this.onAnnotationClick(hl); 139 + } 140 + }); 141 + 142 + this.content.appendChild(item); 143 + } 144 + } 145 + 146 + _editNote(highlight) { 147 + const note = prompt('Annotation note:', highlight.note || ''); 148 + if (note !== null && this.onAnnotationEdit) { 149 + this.onAnnotationEdit(highlight.id, note); 150 + } 151 + } 152 + 153 + toggle() { 154 + this.collapsed = !this.collapsed; 155 + this.element.classList.toggle('collapsed', this.collapsed); 156 + 157 + if (this.collapsed) { 158 + this.toggleBtn.innerHTML = '\u25B6'; 159 + this.toggleBtn.title = 'Show Annotations'; 160 + } else { 161 + this.toggleBtn.innerHTML = '\u25C0'; 162 + this.toggleBtn.title = 'Hide Annotations'; 163 + } 164 + } 165 + 166 + isCollapsed() { 167 + return this.collapsed; 168 + } 169 + 170 + getElement() { 171 + return this.element; 172 + } 173 + 174 + destroy() { 175 + this.element.remove(); 176 + } 177 + }
+14 -2
extensions/editor/codemirror.js
··· 14 14 import { vim, Vim, getCM } from '@replit/codemirror-vim'; 15 15 import { searchKeymap } from '@codemirror/search'; 16 16 import { clickableLinks } from './links.js'; 17 + import { highlightField } from './highlights.js'; 17 18 18 19 // Compartments for runtime-reconfigurable extensions 19 20 const vimCompartment = new Compartment(); ··· 559 560 borderLeftColor: 'var(--base05)', 560 561 }, 561 562 '.cm-selectionBackground, ::selection': { 562 - backgroundColor: 'var(--base02)', 563 + backgroundColor: 'color-mix(in srgb, var(--base0D) 35%, var(--base02))', 564 + }, 565 + '&.cm-focused .cm-selectionBackground': { 566 + backgroundColor: 'color-mix(in srgb, var(--base0D) 40%, var(--base01))', 563 567 }, 564 568 '.cm-activeLine': { 565 569 backgroundColor: 'var(--base02)', ··· 688 692 * @param {Function} options.onFoldChange - Callback when fold state changes 689 693 * @returns {EditorView} - CodeMirror EditorView instance 690 694 */ 691 - export function createEditor({ parent, content = '', vimMode = false, showLineNumbers = true, onChange, onSelectionChange, onVimModeChange, onFoldChange }) { 695 + export function createEditor({ parent, content = '', vimMode = false, showLineNumbers = true, onChange, onSelectionChange, onVimModeChange, onFoldChange, onScroll }) { 692 696 // Track last known vim mode to detect changes 693 697 let lastVimMode = 'normal'; 694 698 ··· 720 724 721 725 // Clickable links (Cmd+click to open URLs) 722 726 clickableLinks(), 727 + 728 + // Persistent highlights 729 + highlightField, 723 730 724 731 // Theming 725 732 themeCompartment.of(peekTheme), ··· 758 765 } 759 766 } 760 767 } 768 + } 769 + 770 + // Scroll change 771 + if (onScroll && update.geometryChanged) { 772 + onScroll(update.view); 761 773 } 762 774 763 775 // Vim mode change detection
+577 -172
extensions/editor/editor-layout.js
··· 1 1 /** 2 - * Editor Layout - Three-panel markdown editor. 3 - * Combines outline sidebar, CodeMirror editor, and preview sidebar. 4 - * Resizable panels with vim mode support. 2 + * Editor Layout - Multi-panel markdown editor with pane management. 3 + * 4 + * Features: 5 + * - Three main panes: outline, editor, preview (independently togglable) 6 + * - Fourth annotations pane anchored to far right 7 + * - Pane ordering via toolbar controls (persisted) 8 + * - Pane resizing with drag handles, double-click to maximize/minimize 9 + * - Preview scroll sync with editor 10 + * - Persistent highlights with annotations 5 11 */ 6 12 7 13 import { OutlineSidebar } from './outline-sidebar.js'; 8 14 import { PreviewSidebar } from './preview-sidebar.js'; 15 + import { AnnotationsPane } from './annotations-pane.js'; 9 16 import { StatusLine } from './status-line.js'; 10 17 import * as CodeMirror from './codemirror.js'; 11 18 import { foldEffect, unfoldEffect, foldable, foldedRanges } from '@codemirror/language'; 12 19 import { observeCommandBar } from './cmd-suggestions.js'; 20 + import { 21 + highlightField, getHighlights, addHighlight, removeHighlight, 22 + setHighlights, updateHighlightNote, serializeHighlights, 23 + deserializeHighlights, nextHighlightColor, generateHighlightId, 24 + } from './highlights.js'; 25 + 26 + const LAYOUT_PREFS_KEY = 'editor:layout-prefs'; 27 + 28 + /** 29 + * Load layout preferences from localStorage. 30 + */ 31 + function loadLayoutPrefs() { 32 + try { 33 + const raw = localStorage.getItem(LAYOUT_PREFS_KEY); 34 + if (raw) return JSON.parse(raw); 35 + } catch (e) { /* ignore */ } 36 + return {}; 37 + } 38 + 39 + /** 40 + * Save layout preferences to localStorage. 41 + */ 42 + function saveLayoutPrefs(prefs) { 43 + try { 44 + localStorage.setItem(LAYOUT_PREFS_KEY, JSON.stringify(prefs)); 45 + } catch (e) { /* ignore */ } 46 + } 13 47 14 48 export class EditorLayout { 15 49 constructor(options) { ··· 17 51 this.onContentChange = options.onContentChange; 18 52 this.initialContent = options.initialContent || ''; 19 53 this.vimMode = options.vimMode || false; 20 - this.initialVimSettings = options.vimSettings || null; // { wrap, lineNumbers } 54 + this.initialVimSettings = options.vimSettings || null; 21 55 22 56 this.outlineSidebar = null; 23 57 this.previewSidebar = null; 58 + this.annotationsPane = null; 24 59 this.statusLine = null; 25 60 this.cmEditor = null; 26 61 this.lastContent = ''; ··· 30 65 this.originalContainerStyles = ''; 31 66 this.cmdSuggestionsCleanup = null; 32 67 68 + // Pane state 69 + this.paneVisibility = { outline: false, editor: true, preview: false, annotations: false }; 70 + this.paneOrder = ['outline', 'editor', 'preview']; // annotations always far right 71 + this.maximizedPane = null; // null or pane name 72 + 73 + // Scroll sync state 74 + this._scrollSyncEnabled = true; 75 + this._scrollSyncRAF = null; 76 + this._isEditorScrolling = false; 77 + 78 + this._loadPrefs(); 33 79 this.init(); 80 + } 81 + 82 + _loadPrefs() { 83 + const prefs = loadLayoutPrefs(); 84 + if (prefs.paneVisibility) { 85 + this.paneVisibility = { ...this.paneVisibility, ...prefs.paneVisibility }; 86 + } 87 + if (Array.isArray(prefs.paneOrder) && prefs.paneOrder.length === 3) { 88 + this.paneOrder = prefs.paneOrder; 89 + } 90 + } 91 + 92 + _savePrefs() { 93 + saveLayoutPrefs({ 94 + paneVisibility: this.paneVisibility, 95 + paneOrder: this.paneOrder, 96 + }); 34 97 } 35 98 36 99 init() { ··· 38 101 this.wrapper = document.createElement('div'); 39 102 this.wrapper.className = 'editor-layout'; 40 103 41 - // Create outline sidebar (left) 104 + // Create pane container (holds the three reorderable panes + resizers) 105 + this.paneContainer = document.createElement('div'); 106 + this.paneContainer.className = 'pane-container'; 107 + 108 + // Create outline sidebar 42 109 this.outlineSidebar = new OutlineSidebar({ 43 - container: this.wrapper, 110 + container: document.createElement('div'), // temporary, will reparent 44 111 onHeaderClick: (header) => this.jumpToHeader(header), 45 112 onFoldToggle: (header, shouldFold) => this.handleOutlineFoldToggle(header, shouldFold), 46 113 }); 47 - 48 - // Create left resizer 49 - this.leftResizer = this.createResizer('left'); 50 - this.wrapper.appendChild(this.leftResizer); 51 114 52 115 // Create editor container (center) 53 116 this.editorContainer = document.createElement('div'); ··· 58 121 this.cmContainer.className = 'cm-container'; 59 122 this.editorContainer.appendChild(this.cmContainer); 60 123 61 - // Status line container (below editor, above toolbar) 124 + // Status line container 62 125 this.statusLineContainer = document.createElement('div'); 63 126 this.statusLineContainer.className = 'status-line-container'; 64 127 this.editorContainer.appendChild(this.statusLineContainer); ··· 67 130 this.toolbar = document.createElement('div'); 68 131 this.toolbar.className = 'editor-toolbar'; 69 132 70 - // Sidebar toggles 133 + // Left group: pane toggles 71 134 const sidebarToggles = document.createElement('div'); 72 135 sidebarToggles.className = 'sidebar-toggles'; 73 136 74 - this.outlineToggleBtn = document.createElement('button'); 75 - this.outlineToggleBtn.className = 'toolbar-btn'; 76 - this.outlineToggleBtn.textContent = 'Outline'; 77 - this.outlineToggleBtn.title = 'Toggle outline sidebar (Cmd+Shift+O)'; 78 - this.outlineToggleBtn.addEventListener('click', () => this.toggleOutline()); 137 + this.outlineToggleBtn = this._createToggleBtn('Outline', 'Toggle outline (Cmd+Shift+O)', () => this.togglePane('outline')); 79 138 sidebarToggles.appendChild(this.outlineToggleBtn); 80 139 81 - this.previewToggleBtn = document.createElement('button'); 82 - this.previewToggleBtn.className = 'toolbar-btn'; 83 - this.previewToggleBtn.textContent = 'Preview'; 84 - this.previewToggleBtn.title = 'Toggle preview sidebar (Cmd+Shift+P)'; 85 - this.previewToggleBtn.addEventListener('click', () => this.togglePreview()); 140 + this.editorToggleBtn = this._createToggleBtn('Editor', 'Toggle editor pane', () => this.togglePane('editor')); 141 + sidebarToggles.appendChild(this.editorToggleBtn); 142 + 143 + this.previewToggleBtn = this._createToggleBtn('Preview', 'Toggle preview (Cmd+Shift+P)', () => this.togglePane('preview')); 86 144 sidebarToggles.appendChild(this.previewToggleBtn); 87 145 88 - this.focusBtn = document.createElement('button'); 89 - this.focusBtn.className = 'toolbar-btn'; 90 - this.focusBtn.textContent = 'Focus'; 91 - this.focusBtn.title = 'Toggle focus mode (Escape to exit)'; 92 - this.focusBtn.addEventListener('click', () => this.toggleFocusMode()); 146 + this.annotationsToggleBtn = this._createToggleBtn('Notes', 'Toggle annotations (Cmd+Shift+H)', () => this.togglePane('annotations')); 147 + sidebarToggles.appendChild(this.annotationsToggleBtn); 148 + 149 + // Pane order cycle button 150 + this.orderBtn = document.createElement('button'); 151 + this.orderBtn.className = 'toolbar-btn toolbar-btn-icon'; 152 + this.orderBtn.textContent = '\u21C4'; // arrows 153 + this.orderBtn.title = 'Cycle pane order'; 154 + this.orderBtn.addEventListener('click', () => this.cyclePaneOrder()); 155 + sidebarToggles.appendChild(this.orderBtn); 156 + 157 + this.focusBtn = this._createToggleBtn('Focus', 'Toggle focus mode (Escape to exit)', () => this.toggleFocusMode()); 93 158 sidebarToggles.appendChild(this.focusBtn); 94 159 95 160 this.toolbar.appendChild(sidebarToggles); 96 161 97 - // Filename display (center of toolbar) 162 + // Filename display (center) 98 163 this.filenameDisplay = document.createElement('span'); 99 164 this.filenameDisplay.className = 'filename-display'; 100 165 this.filenameDisplay.style.display = 'none'; 101 166 this.toolbar.appendChild(this.filenameDisplay); 102 167 103 - // Save status indicator (shown when editing datastore items or files) 168 + // Save status indicator 104 169 this.saveStatus = document.createElement('span'); 105 170 this.saveStatus.id = 'save-status'; 106 171 this.saveStatus.className = 'save-status save-status-saved'; 107 172 this.saveStatus.textContent = 'Saved'; 108 - this.saveStatus.style.display = 'none'; // Hidden until editing an item or file 173 + this.saveStatus.style.display = 'none'; 109 174 this.toolbar.appendChild(this.saveStatus); 110 175 111 176 this.editorContainer.appendChild(this.toolbar); 112 177 113 - this.wrapper.appendChild(this.editorContainer); 114 - 115 - // Create right resizer 116 - this.rightResizer = this.createResizer('right'); 117 - this.wrapper.appendChild(this.rightResizer); 118 - 119 - // Create preview sidebar (right) 178 + // Create preview sidebar 120 179 this.previewSidebar = new PreviewSidebar({ 121 - container: this.wrapper, 180 + container: document.createElement('div'), // temporary 122 181 }); 123 182 183 + // Create annotations pane (always far right) 184 + this.annotationsPane = new AnnotationsPane({ 185 + container: document.createElement('div'), // temporary 186 + onAnnotationClick: (hl) => this._jumpToHighlight(hl), 187 + onAnnotationDelete: (id) => this._removeHighlight(id), 188 + onAnnotationEdit: (id, note) => this._updateHighlightNote(id, note), 189 + }); 190 + 191 + // Build pane layout 192 + this._buildPaneLayout(); 193 + 194 + // Annotations pane always far right, outside main pane container 195 + this.wrapper.appendChild(this.paneContainer); 196 + 197 + // Annotations resizer and pane 198 + this.annotationsResizer = this._createResizer('annotations'); 199 + this.wrapper.appendChild(this.annotationsResizer); 200 + this.wrapper.appendChild(this.annotationsPane.getElement()); 201 + 124 202 this.container.appendChild(this.wrapper); 125 203 126 - // Initialize status line (only shown when vim mode is enabled) 204 + // Initialize status line 127 205 this.statusLine = new StatusLine({ 128 206 container: this.statusLineContainer, 129 207 }); 130 - 131 - // Hide status line initially if vim mode is off 132 208 if (!this.vimMode) { 133 209 this.statusLine.hide(); 134 210 } ··· 143 219 onSelectionChange: (line, col) => this.handleSelectionChange(line, col), 144 220 onVimModeChange: (mode) => this.handleVimModeUpdate(mode), 145 221 onFoldChange: () => this.syncOutlineFromFolds(), 222 + onScroll: (view) => this._handleEditorScroll(view), 146 223 }); 147 224 148 - // Apply persisted vim settings (wrap, lineNumbers) if provided 225 + // Apply persisted vim settings 149 226 if (this.initialVimSettings && this.cmEditor) { 150 227 CodeMirror.applyVimSettings(this.cmEditor, this.initialVimSettings); 151 228 } 152 229 153 - // Set up command bar suggestions (ghost-text completions for : commands) 230 + // Set up command bar suggestions 154 231 if (this.cmContainer) { 155 232 this.cmdSuggestionsCleanup = observeCommandBar(this.cmContainer); 156 233 } 157 234 158 - // Default sidebars to collapsed 159 - this.outlineSidebar.toggle(); 160 - this.previewSidebar.toggle(); 235 + // Apply visibility state 236 + this._applyVisibility(); 161 237 162 238 // Initial update 163 239 this.lastContent = this.initialContent; 164 240 this.updateSidebars(); 165 241 166 - // Start watching for changes (throttled updates) 242 + // Start watching for changes 167 243 this.startWatching(); 168 244 169 245 // Set up keyboard shortcuts 170 246 this.setupKeyboardShortcuts(); 171 247 248 + // Set up editor scroll sync via scroll event on scroller 249 + this._setupEditorScrollListener(); 250 + 172 251 // Focus editor 173 252 setTimeout(() => { 174 253 if (this.cmEditor) CodeMirror.focus(this.cmEditor); 175 254 }, 100); 176 255 } 177 256 178 - createResizer(side) { 257 + _createToggleBtn(label, title, onClick) { 258 + const btn = document.createElement('button'); 259 + btn.className = 'toolbar-btn'; 260 + btn.textContent = label; 261 + btn.title = title; 262 + btn.addEventListener('click', onClick); 263 + return btn; 264 + } 265 + 266 + /** 267 + * Build the pane layout based on current paneOrder. 268 + * Clears and repopulates paneContainer. 269 + */ 270 + _buildPaneLayout() { 271 + this.paneContainer.innerHTML = ''; 272 + 273 + const paneElements = { 274 + outline: this.outlineSidebar.getElement(), 275 + editor: this.editorContainer, 276 + preview: this.previewSidebar.getElement(), 277 + }; 278 + 279 + for (let i = 0; i < this.paneOrder.length; i++) { 280 + const paneName = this.paneOrder[i]; 281 + const el = paneElements[paneName]; 282 + if (el) { 283 + el.style.order = String(i); 284 + this.paneContainer.appendChild(el); 285 + } 286 + 287 + // Add resizer between panes (not after last) 288 + if (i < this.paneOrder.length - 1) { 289 + const resizerKey = `${this.paneOrder[i]}-${this.paneOrder[i + 1]}`; 290 + const resizer = this._createResizer(resizerKey); 291 + resizer.style.order = String(i) + '.5'; 292 + resizer.dataset.leftPane = this.paneOrder[i]; 293 + resizer.dataset.rightPane = this.paneOrder[i + 1]; 294 + this.paneContainer.appendChild(resizer); 295 + } 296 + } 297 + } 298 + 299 + _createResizer(key) { 179 300 const resizer = document.createElement('div'); 180 - resizer.className = `resizer resizer-${side}`; 301 + resizer.className = `resizer resizer-${key}`; 302 + resizer.dataset.resizerKey = key; 181 303 182 304 const indicator = document.createElement('div'); 183 305 indicator.className = 'resizer-indicator'; ··· 196 318 resizer.addEventListener('mousedown', (e) => { 197 319 e.preventDefault(); 198 320 indicator.classList.add('visible'); 199 - this.startResize(side, e, indicator); 321 + this._startResize(resizer, e, indicator); 322 + }); 323 + 324 + // Double-click to maximize/minimize 325 + resizer.addEventListener('dblclick', (e) => { 326 + e.preventDefault(); 327 + this._handleResizerDoubleClick(resizer); 200 328 }); 201 329 202 330 return resizer; 203 331 } 204 332 205 - startResize(side, startEvent, indicator) { 206 - const resizer = side === 'left' ? this.leftResizer : this.rightResizer; 207 - const target = side === 'left' 208 - ? this.outlineSidebar.getElement() 209 - : this.previewSidebar.getElement(); 333 + _startResize(resizer, startEvent, indicator) { 334 + const leftPaneName = resizer.dataset.leftPane; 335 + const rightPaneName = resizer.dataset.rightPane; 210 336 211 - if (!resizer || !target) return; 337 + // For annotations resizer, left is paneContainer, right is annotations 338 + const isAnnotationsResizer = resizer.dataset.resizerKey === 'annotations'; 212 339 213 - resizer.classList.add('dragging'); 340 + let leftEl, rightEl; 341 + if (isAnnotationsResizer) { 342 + rightEl = this.annotationsPane.getElement(); 343 + leftEl = this.paneContainer; 344 + } else { 345 + leftEl = this._getPaneElement(leftPaneName); 346 + rightEl = this._getPaneElement(rightPaneName); 347 + } 348 + 349 + if (!leftEl || !rightEl) return; 214 350 351 + resizer.classList.add('dragging'); 215 352 const startX = startEvent.clientX; 216 - const startWidth = target.offsetWidth; 353 + const startLeftWidth = leftEl.offsetWidth; 354 + const startRightWidth = rightEl.offsetWidth; 217 355 218 356 document.body.style.userSelect = 'none'; 219 357 document.body.style.cursor = 'col-resize'; 220 358 221 359 const onMouseMove = (e) => { 222 - const delta = side === 'left' 223 - ? e.clientX - startX 224 - : startX - e.clientX; 360 + const delta = e.clientX - startX; 225 361 226 - const newWidth = Math.max(100, Math.min(600, startWidth + delta)); 227 - target.style.width = `${newWidth}px`; 228 - target.style.minWidth = `${newWidth}px`; 362 + if (isAnnotationsResizer) { 363 + // Resize annotations pane (drag right = shrink annotations) 364 + const newWidth = Math.max(100, Math.min(600, startRightWidth - delta)); 365 + rightEl.style.width = `${newWidth}px`; 366 + rightEl.style.minWidth = `${newWidth}px`; 367 + } else { 368 + const newLeftWidth = Math.max(100, startLeftWidth + delta); 369 + const newRightWidth = Math.max(100, startRightWidth - delta); 370 + leftEl.style.width = `${newLeftWidth}px`; 371 + leftEl.style.minWidth = `${newLeftWidth}px`; 372 + rightEl.style.width = `${newRightWidth}px`; 373 + rightEl.style.minWidth = `${newRightWidth}px`; 374 + } 229 375 }; 230 376 231 377 const onMouseUp = () => { ··· 241 387 document.addEventListener('mouseup', onMouseUp); 242 388 } 243 389 390 + _handleResizerDoubleClick(resizer) { 391 + const leftPaneName = resizer.dataset.leftPane; 392 + const rightPaneName = resizer.dataset.rightPane; 393 + 394 + if (resizer.dataset.resizerKey === 'annotations') { 395 + // Toggle annotations between collapsed and expanded 396 + this.togglePane('annotations'); 397 + return; 398 + } 399 + 400 + // If a pane is maximized, restore it 401 + if (this.maximizedPane) { 402 + this._restoreAllPanes(); 403 + this.maximizedPane = null; 404 + return; 405 + } 406 + 407 + // Maximize the left pane (convention: double-click maximizes the pane to the left of the resizer) 408 + this._maximizePane(leftPaneName); 409 + } 410 + 411 + _maximizePane(paneName) { 412 + this.maximizedPane = paneName; 413 + for (const name of this.paneOrder) { 414 + const el = this._getPaneElement(name); 415 + if (!el) continue; 416 + if (name === paneName) { 417 + el.style.flex = '1'; 418 + el.style.display = ''; 419 + el.style.width = ''; 420 + el.style.minWidth = ''; 421 + } else { 422 + el.style.display = 'none'; 423 + } 424 + } 425 + // Hide resizers inside pane container 426 + this.paneContainer.querySelectorAll('.resizer').forEach(r => r.style.display = 'none'); 427 + } 428 + 429 + _restoreAllPanes() { 430 + for (const name of this.paneOrder) { 431 + const el = this._getPaneElement(name); 432 + if (!el) continue; 433 + el.style.flex = ''; 434 + el.style.display = this.paneVisibility[name] ? '' : 'none'; 435 + el.style.width = ''; 436 + el.style.minWidth = ''; 437 + } 438 + this.paneContainer.querySelectorAll('.resizer').forEach(r => r.style.display = ''); 439 + this._applyVisibility(); 440 + } 441 + 442 + _getPaneElement(name) { 443 + switch (name) { 444 + case 'outline': return this.outlineSidebar?.getElement(); 445 + case 'editor': return this.editorContainer; 446 + case 'preview': return this.previewSidebar?.getElement(); 447 + case 'annotations': return this.annotationsPane?.getElement(); 448 + default: return null; 449 + } 450 + } 451 + 452 + /** 453 + * Toggle a pane's visibility. 454 + */ 455 + togglePane(paneName) { 456 + if (paneName === 'annotations') { 457 + this.paneVisibility.annotations = !this.paneVisibility.annotations; 458 + if (this.annotationsPane) { 459 + if (this.paneVisibility.annotations) { 460 + this.annotationsPane.getElement().style.display = ''; 461 + this.annotationsPane.collapsed = false; 462 + this.annotationsPane.getElement().classList.remove('collapsed'); 463 + this.annotationsResizer.style.display = ''; 464 + } else { 465 + this.annotationsPane.getElement().style.display = 'none'; 466 + this.annotationsResizer.style.display = 'none'; 467 + } 468 + } 469 + this.annotationsToggleBtn.classList.toggle('active', this.paneVisibility.annotations); 470 + this._savePrefs(); 471 + return; 472 + } 473 + 474 + this.paneVisibility[paneName] = !this.paneVisibility[paneName]; 475 + this._applyVisibility(); 476 + this._savePrefs(); 477 + } 478 + 479 + /** 480 + * Apply current visibility state to DOM. 481 + */ 482 + _applyVisibility() { 483 + if (this.maximizedPane) return; // Don't interfere with maximized state 484 + 485 + for (const name of this.paneOrder) { 486 + const el = this._getPaneElement(name); 487 + if (!el) continue; 488 + 489 + if (name === 'outline') { 490 + if (this.paneVisibility.outline) { 491 + el.style.display = ''; 492 + this.outlineSidebar.collapsed = false; 493 + el.classList.remove('collapsed'); 494 + } else { 495 + el.style.display = 'none'; 496 + } 497 + } else if (name === 'preview') { 498 + if (this.paneVisibility.preview) { 499 + el.style.display = ''; 500 + this.previewSidebar.collapsed = false; 501 + el.classList.remove('collapsed'); 502 + } else { 503 + el.style.display = 'none'; 504 + } 505 + } else if (name === 'editor') { 506 + el.style.display = this.paneVisibility.editor ? '' : 'none'; 507 + } 508 + } 509 + 510 + // Annotations pane 511 + if (this.annotationsPane) { 512 + this.annotationsPane.getElement().style.display = this.paneVisibility.annotations ? '' : 'none'; 513 + this.annotationsResizer.style.display = this.paneVisibility.annotations ? '' : 'none'; 514 + } 515 + 516 + // Update toggle button states 517 + this.outlineToggleBtn.classList.toggle('active', this.paneVisibility.outline); 518 + this.editorToggleBtn.classList.toggle('active', this.paneVisibility.editor); 519 + this.previewToggleBtn.classList.toggle('active', this.paneVisibility.preview); 520 + this.annotationsToggleBtn.classList.toggle('active', this.paneVisibility.annotations); 521 + } 522 + 523 + /** 524 + * Cycle the pane order through common arrangements. 525 + */ 526 + cyclePaneOrder() { 527 + const arrangements = [ 528 + ['outline', 'editor', 'preview'], 529 + ['editor', 'outline', 'preview'], 530 + ['outline', 'preview', 'editor'], 531 + ['preview', 'editor', 'outline'], 532 + ['editor', 'preview', 'outline'], 533 + ['preview', 'outline', 'editor'], 534 + ]; 535 + 536 + const currentStr = this.paneOrder.join(','); 537 + let idx = arrangements.findIndex(a => a.join(',') === currentStr); 538 + idx = (idx + 1) % arrangements.length; 539 + this.paneOrder = arrangements[idx]; 540 + 541 + this._buildPaneLayout(); 542 + this._applyVisibility(); 543 + this._savePrefs(); 544 + } 545 + 546 + /** 547 + * Set up editor scroll listener for preview sync. 548 + */ 549 + _setupEditorScrollListener() { 550 + // Watch for scroll on the cm-scroller element 551 + const scroller = this.cmContainer?.querySelector('.cm-scroller'); 552 + if (scroller) { 553 + scroller.addEventListener('scroll', () => { 554 + if (this._scrollSyncEnabled) { 555 + this._syncPreviewScroll(); 556 + } 557 + }, { passive: true }); 558 + } 559 + } 560 + 561 + /** 562 + * Handle editor scroll from CodeMirror update listener. 563 + */ 564 + _handleEditorScroll(view) { 565 + if (this._scrollSyncEnabled && !this._scrollSyncRAF) { 566 + this._scrollSyncRAF = requestAnimationFrame(() => { 567 + this._scrollSyncRAF = null; 568 + this._syncPreviewScroll(); 569 + }); 570 + } 571 + } 572 + 573 + /** 574 + * Sync preview scroll position based on editor scroll. 575 + */ 576 + _syncPreviewScroll() { 577 + if (!this.cmEditor || !this.previewSidebar || this.previewSidebar.isCollapsed()) return; 578 + if (!this.paneVisibility.preview) return; 579 + 580 + const scroller = this.cmContainer?.querySelector('.cm-scroller'); 581 + if (!scroller) return; 582 + 583 + const scrollRatio = scroller.scrollTop / Math.max(1, scroller.scrollHeight - scroller.clientHeight); 584 + 585 + const previewContent = this.previewSidebar.getContentElement(); 586 + if (!previewContent) return; 587 + 588 + const maxScroll = previewContent.scrollHeight - previewContent.clientHeight; 589 + previewContent.scrollTo({ 590 + top: scrollRatio * maxScroll, 591 + behavior: 'smooth', 592 + }); 593 + } 594 + 595 + /** 596 + * Sync preview to a specific heading (from outline click). 597 + */ 598 + _syncPreviewToHeading(headerText) { 599 + if (!this.previewSidebar || this.previewSidebar.isCollapsed()) return; 600 + if (!this.paneVisibility.preview) return; 601 + 602 + const previewContent = this.previewSidebar.getContentElement(); 603 + if (!previewContent) return; 604 + 605 + // Find matching heading in preview 606 + const headings = previewContent.querySelectorAll('h1, h2, h3, h4, h5, h6'); 607 + for (const heading of headings) { 608 + if (heading.textContent.trim() === headerText.trim()) { 609 + heading.scrollIntoView({ behavior: 'smooth', block: 'start' }); 610 + return; 611 + } 612 + } 613 + } 614 + 244 615 setupKeyboardShortcuts() { 245 616 document.addEventListener('keydown', (e) => { 246 617 // Cmd+Shift+O: Toggle outline 247 618 if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'o') { 248 619 e.preventDefault(); 249 - this.toggleOutline(); 620 + this.togglePane('outline'); 250 621 } 251 622 // Cmd+Shift+P: Toggle preview 252 623 if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'p') { 253 624 e.preventDefault(); 254 - this.togglePreview(); 625 + this.togglePane('preview'); 626 + } 627 + // Cmd+Shift+H: Toggle annotations 628 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'h') { 629 + e.preventDefault(); 630 + this.togglePane('annotations'); 631 + } 632 + // Cmd+H: Add highlight on selected text 633 + if ((e.metaKey || e.ctrlKey) && e.key === 'h' && !e.shiftKey) { 634 + e.preventDefault(); 635 + this._addHighlightFromSelection(); 255 636 } 256 637 // Escape: Exit focus mode 257 638 if (e.key === 'Escape' && this.isFocusMode) { ··· 260 641 }); 261 642 } 262 643 644 + // ── Highlight management ── 645 + 646 + _addHighlightFromSelection() { 647 + if (!this.cmEditor) return; 648 + 649 + const state = this.cmEditor.state; 650 + const sel = state.selection.main; 651 + if (sel.from === sel.to) return; // No selection 652 + 653 + const text = state.doc.sliceString(sel.from, sel.to); 654 + const highlight = { 655 + id: generateHighlightId(), 656 + from: sel.from, 657 + to: sel.to, 658 + text, 659 + note: '', 660 + color: nextHighlightColor(), 661 + }; 662 + 663 + addHighlight(this.cmEditor, highlight); 664 + this._syncAnnotationsPane(); 665 + this._persistHighlights(); 666 + 667 + // Auto-show annotations pane 668 + if (!this.paneVisibility.annotations) { 669 + this.togglePane('annotations'); 670 + } 671 + } 672 + 673 + _removeHighlight(id) { 674 + if (!this.cmEditor) return; 675 + removeHighlight(this.cmEditor, id); 676 + this._syncAnnotationsPane(); 677 + this._persistHighlights(); 678 + } 679 + 680 + _updateHighlightNote(id, note) { 681 + if (!this.cmEditor) return; 682 + updateHighlightNote(this.cmEditor, id, note); 683 + this._syncAnnotationsPane(); 684 + this._persistHighlights(); 685 + } 686 + 687 + _jumpToHighlight(hl) { 688 + if (!this.cmEditor) return; 689 + this.cmEditor.dispatch({ 690 + selection: { anchor: hl.from, head: hl.to }, 691 + scrollIntoView: true, 692 + }); 693 + CodeMirror.focus(this.cmEditor); 694 + } 695 + 696 + _syncAnnotationsPane() { 697 + if (!this.cmEditor || !this.annotationsPane) return; 698 + const highlights = getHighlights(this.cmEditor.state); 699 + this.annotationsPane.update(highlights); 700 + } 701 + 702 + _persistHighlights() { 703 + if (!this.cmEditor) return; 704 + const highlights = getHighlights(this.cmEditor.state); 705 + const data = serializeHighlights(highlights); 706 + try { 707 + // Use itemId-based key if available, otherwise generic 708 + const key = this._getHighlightStorageKey(); 709 + localStorage.setItem(key, JSON.stringify(data)); 710 + } catch (e) { /* ignore */ } 711 + } 712 + 713 + _restoreHighlights() { 714 + if (!this.cmEditor) return; 715 + try { 716 + const key = this._getHighlightStorageKey(); 717 + const raw = localStorage.getItem(key); 718 + if (raw) { 719 + const data = JSON.parse(raw); 720 + const highlights = deserializeHighlights(data, this.cmEditor.state.doc.length); 721 + if (highlights.length > 0) { 722 + setHighlights(this.cmEditor, highlights); 723 + this._syncAnnotationsPane(); 724 + } 725 + } 726 + } catch (e) { /* ignore */ } 727 + } 728 + 729 + _getHighlightStorageKey() { 730 + // Uses URL params to create a unique key per document 731 + const params = new URLSearchParams(window.location.search); 732 + const itemId = params.get('itemId'); 733 + const file = params.get('file'); 734 + if (itemId) return `editor:highlights:${itemId}`; 735 + if (file) return `editor:highlights:file:${file}`; 736 + return 'editor:highlights:scratch'; 737 + } 738 + 739 + // ── Content & sidebar updates ── 740 + 263 741 startWatching() { 264 742 const check = () => { 265 743 if (this.isDestroyed) return; ··· 274 752 }; 275 753 276 754 this.rafId = requestAnimationFrame(check); 755 + 756 + // Restore highlights after editor is ready 757 + setTimeout(() => this._restoreHighlights(), 200); 277 758 } 278 759 279 760 stopWatching() { ··· 299 780 this.lastContent = content; 300 781 this.updateSidebars(); 301 782 783 + // Persist highlights (positions may have changed) 784 + this._persistHighlights(); 785 + this._syncAnnotationsPane(); 786 + 302 787 if (this.onContentChange) { 303 788 this.onContentChange(content); 304 789 } 305 790 } 306 791 307 - /** 308 - * Update vim mode state (called from setVimMode). 309 - */ 310 792 updateVimModeState(enabled) { 311 793 this.vimMode = enabled; 312 794 if (this.cmEditor) { 313 795 CodeMirror.setVimMode(this.cmEditor, this.vimMode); 314 796 } 315 - 316 - // Show/hide status line based on vim mode 317 797 if (this.statusLine) { 318 798 if (this.vimMode) { 319 799 this.statusLine.show(); ··· 324 804 } 325 805 } 326 806 327 - /** 328 - * Handle cursor position changes. 329 - */ 330 807 handleSelectionChange(line, col) { 331 808 if (this.statusLine) { 332 809 this.statusLine.updatePosition(line, col); 333 810 } 334 811 } 335 812 336 - /** 337 - * Handle vim mode state changes (normal, insert, visual, etc.) 338 - */ 339 813 handleVimModeUpdate(mode) { 340 814 if (this.statusLine && this.vimMode) { 341 815 this.statusLine.updateMode(mode); 342 816 } 343 817 } 344 818 345 - /** 346 - * Handle outline sidebar fold toggle — fold/unfold the corresponding 347 - * section in the CodeMirror document. 348 - * @param {Object} header - Header object with offset, line, level, text 349 - * @param {boolean} shouldFold - true to fold, false to unfold 350 - */ 351 819 handleOutlineFoldToggle(header, shouldFold) { 352 820 if (!this.cmEditor) return; 353 821 354 822 const view = this.cmEditor; 355 823 const state = view.state; 356 824 357 - // Find the line at the header's offset 358 825 if (header.offset >= state.doc.length) return; 359 826 const line = state.doc.lineAt(header.offset); 360 - 361 - // Get the foldable range for this line 362 827 const foldRange = foldable(state, line.from, line.to); 363 828 if (!foldRange) return; 364 829 365 830 if (shouldFold) { 366 - // Check it's not already folded 367 831 const folded = foldedRanges(state); 368 832 let alreadyFolded = false; 369 833 folded.between(foldRange.from, foldRange.to, (from) => { ··· 375 839 }); 376 840 } 377 841 } else { 378 - // Unfold 379 842 view.dispatch({ 380 843 effects: unfoldEffect.of({ from: foldRange.from, to: foldRange.to }) 381 844 }); 382 845 } 383 846 } 384 847 385 - /** 386 - * Sync outline collapse state from the editor's current fold state. 387 - * Called after fold changes in the editor. 388 - */ 389 848 syncOutlineFromFolds() { 390 849 if (this.outlineSidebar && this.cmEditor) { 391 850 this.outlineSidebar.syncFromEditor(this.cmEditor); ··· 395 854 jumpToHeader(header) { 396 855 if (!this.cmEditor) return; 397 856 398 - // Set cursor position to the header's offset 399 857 const view = this.cmEditor; 400 858 view.dispatch({ 401 859 selection: { anchor: header.offset }, 402 860 scrollIntoView: true, 403 861 }); 404 862 CodeMirror.focus(view); 863 + 864 + // Sync preview to this heading 865 + this._syncPreviewToHeading(header.text); 405 866 } 406 867 407 - /** 408 - * Get the current content. 409 - */ 410 868 getContent() { 411 869 if (this.cmEditor) { 412 870 return CodeMirror.getContent(this.cmEditor); ··· 414 872 return this.lastContent; 415 873 } 416 874 417 - /** 418 - * Set the editor content. 419 - */ 420 875 setContent(content) { 421 876 if (this.cmEditor) { 422 877 CodeMirror.setContent(this.cmEditor, content); ··· 425 880 this.updateSidebars(); 426 881 } 427 882 428 - /** 429 - * Toggle outline sidebar. 430 - */ 431 883 toggleOutline() { 432 - if (this.outlineSidebar) { 433 - this.outlineSidebar.toggle(); 434 - this.outlineToggleBtn.classList.toggle('active', !this.outlineSidebar.isCollapsed()); 435 - } 884 + this.togglePane('outline'); 436 885 } 437 886 438 - /** 439 - * Toggle preview sidebar. 440 - */ 441 887 togglePreview() { 442 - if (this.previewSidebar) { 443 - this.previewSidebar.toggle(); 444 - this.previewToggleBtn.classList.toggle('active', !this.previewSidebar.isCollapsed()); 445 - } 888 + this.togglePane('preview'); 446 889 } 447 890 448 - /** 449 - * Enter focus mode - expand editor to fill viewport. 450 - */ 451 891 enterFocusMode() { 452 892 if (this.isFocusMode) return; 453 893 ··· 456 896 this.wrapper.classList.add('focus-mode'); 457 897 this.focusBtn.classList.add('active'); 458 898 459 - // Hide sidebars and resizers 460 - if (this.outlineSidebar) { 461 - this.outlineSidebar.getElement().style.display = 'none'; 899 + // Hide everything except editor 900 + if (this.outlineSidebar) this.outlineSidebar.getElement().style.display = 'none'; 901 + if (this.previewSidebar) this.previewSidebar.getElement().style.display = 'none'; 902 + if (this.annotationsPane) { 903 + this.annotationsPane.getElement().style.display = 'none'; 904 + this.annotationsResizer.style.display = 'none'; 462 905 } 463 - if (this.previewSidebar) { 464 - this.previewSidebar.getElement().style.display = 'none'; 465 - } 466 - if (this.leftResizer) { 467 - this.leftResizer.style.display = 'none'; 468 - } 469 - if (this.rightResizer) { 470 - this.rightResizer.style.display = 'none'; 471 - } 906 + this.paneContainer.querySelectorAll('.resizer').forEach(r => r.style.display = 'none'); 472 907 473 908 if (this.cmEditor) CodeMirror.focus(this.cmEditor); 474 909 } 475 910 476 - /** 477 - * Exit focus mode. 478 - */ 479 911 exitFocusMode() { 480 912 if (!this.isFocusMode) return; 481 913 ··· 484 916 this.wrapper.classList.remove('focus-mode'); 485 917 this.focusBtn.classList.remove('active'); 486 918 487 - // Restore sidebars and resizers 488 - if (this.outlineSidebar) { 489 - this.outlineSidebar.getElement().style.display = ''; 919 + // Restore visibility 920 + if (this.outlineSidebar) this.outlineSidebar.getElement().style.display = ''; 921 + if (this.previewSidebar) this.previewSidebar.getElement().style.display = ''; 922 + if (this.annotationsPane) { 923 + this.annotationsPane.getElement().style.display = ''; 924 + this.annotationsResizer.style.display = ''; 490 925 } 491 - if (this.previewSidebar) { 492 - this.previewSidebar.getElement().style.display = ''; 493 - } 494 - if (this.leftResizer) { 495 - this.leftResizer.style.display = ''; 496 - } 497 - if (this.rightResizer) { 498 - this.rightResizer.style.display = ''; 499 - } 926 + this.paneContainer.querySelectorAll('.resizer').forEach(r => r.style.display = ''); 927 + 928 + this._applyVisibility(); 500 929 501 930 if (this.cmEditor) CodeMirror.focus(this.cmEditor); 502 931 } 503 932 504 - /** 505 - * Toggle focus mode. 506 - */ 507 933 toggleFocusMode() { 508 934 if (this.isFocusMode) { 509 935 this.exitFocusMode(); ··· 512 938 } 513 939 } 514 940 515 - /** 516 - * Check if in focus mode. 517 - */ 518 941 isInFocusMode() { 519 942 return this.isFocusMode; 520 943 } 521 944 522 - /** 523 - * Set vim mode. 524 - */ 525 945 setVimMode(enabled) { 526 946 this.updateVimModeState(enabled); 527 947 } 528 948 529 - /** 530 - * Apply vim settings (wrap, lineNumbers) to the editor. 531 - */ 532 949 applyVimSettings(settings) { 533 950 if (this.cmEditor && settings) { 534 951 CodeMirror.applyVimSettings(this.cmEditor, settings); 535 952 } 536 953 } 537 954 538 - /** 539 - * Get vim mode state. 540 - */ 541 955 getVimMode() { 542 956 return this.vimMode; 543 957 } 544 958 545 - /** 546 - * Focus the editor. 547 - */ 548 959 focus() { 549 960 if (this.cmEditor) { 550 961 CodeMirror.focus(this.cmEditor); 551 962 } 552 963 } 553 964 554 - /** 555 - * Show/hide save status indicator. 556 - * @param {boolean} visible - Whether to show the indicator 557 - */ 558 965 setSaveStatusVisible(visible) { 559 966 if (this.saveStatus) { 560 967 this.saveStatus.style.display = visible ? 'inline-block' : 'none'; 561 968 } 562 969 } 563 970 564 - /** 565 - * Set the displayed filename in the toolbar. 566 - * @param {string|null} filename - Filename to show, or null to hide 567 - */ 568 971 setFilename(filename) { 569 972 if (this.filenameDisplay) { 570 973 if (filename) { ··· 577 980 } 578 981 } 579 982 580 - /** 581 - * Destroy the layout and clean up. 582 - */ 583 983 destroy() { 584 984 this.isDestroyed = true; 585 985 this.stopWatching(); 586 986 987 + if (this._scrollSyncRAF) { 988 + cancelAnimationFrame(this._scrollSyncRAF); 989 + } 990 + 587 991 if (this.isFocusMode) { 588 992 this.exitFocusMode(); 589 993 } ··· 601 1005 this.statusLine?.destroy(); 602 1006 this.outlineSidebar?.destroy(); 603 1007 this.previewSidebar?.destroy(); 1008 + this.annotationsPane?.destroy(); 604 1009 this.wrapper.remove(); 605 1010 } 606 1011 }
+200
extensions/editor/highlights.js
··· 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 + 8 + import { StateField, StateEffect } from '@codemirror/state'; 9 + import { Decoration, EditorView } from '@codemirror/view'; 10 + 11 + // Effects for adding/removing highlights 12 + export const addHighlightEffect = StateEffect.define(); 13 + export const removeHighlightEffect = StateEffect.define(); 14 + export const setHighlightsEffect = StateEffect.define(); 15 + export const updateHighlightNoteEffect = StateEffect.define(); 16 + 17 + // Highlight colors cycling palette 18 + const 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 + 27 + let colorIndex = 0; 28 + 29 + /** 30 + * Get the next highlight color in the palette. 31 + */ 32 + export 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 + */ 41 + export 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 + */ 48 + function 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 + */ 63 + export 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 + */ 130 + export function getHighlights(state) { 131 + return state.field(highlightField).highlights; 132 + } 133 + 134 + /** 135 + * Add a highlight to the editor. 136 + */ 137 + export function addHighlight(view, highlight) { 138 + view.dispatch({ 139 + effects: addHighlightEffect.of(highlight), 140 + }); 141 + } 142 + 143 + /** 144 + * Remove a highlight by ID. 145 + */ 146 + export 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 + */ 155 + export function setHighlights(view, highlights) { 156 + view.dispatch({ 157 + effects: setHighlightsEffect.of(highlights), 158 + }); 159 + } 160 + 161 + /** 162 + * Update a highlight's note. 163 + */ 164 + export 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 + */ 173 + export 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 + */ 187 + export 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 + }
+241 -22
extensions/editor/home.css
··· 29 29 height: 100%; 30 30 } 31 31 32 - /* ═══════════════════════════════════════════════════════════════════ 33 - Editor Layout - Three-panel structure 34 - ═══════════════════════════════════════════════════════════════════ */ 32 + /* ===================================================================== 33 + Editor Layout - Multi-panel structure 34 + ===================================================================== */ 35 35 36 36 .editor-layout { 37 37 display: flex; ··· 49 49 z-index: 99999; 50 50 } 51 51 52 - /* ═══════════════════════════════════════════════════════════════════ 52 + /* Pane container holds the three reorderable panes */ 53 + .pane-container { 54 + display: flex; 55 + flex: 1; 56 + min-width: 0; 57 + overflow: hidden; 58 + } 59 + 60 + /* ===================================================================== 53 61 Sidebar shared styles 54 - ═══════════════════════════════════════════════════════════════════ */ 62 + ===================================================================== */ 55 63 56 64 .outline-sidebar, 57 65 .preview-sidebar { ··· 97 105 display: none; 98 106 } 99 107 108 + .outline-sidebar.collapsed .outline-zoom-controls { 109 + display: none; 110 + } 111 + 100 112 .outline-sidebar.collapsed .sidebar-header, 101 113 .preview-sidebar.collapsed .sidebar-header { 102 114 justify-content: center; ··· 115 127 text-transform: uppercase; 116 128 letter-spacing: 0.5px; 117 129 font-size: 11px; 130 + gap: 6px; 118 131 } 119 132 120 133 .sidebar-title { ··· 129 142 padding: 2px 6px; 130 143 font-size: 10px; 131 144 transition: color 0.15s; 145 + flex-shrink: 0; 132 146 } 133 147 134 148 .sidebar-toggle:hover { ··· 142 156 padding: 8px 0; 143 157 } 144 158 145 - /* ═══════════════════════════════════════════════════════════════════ 159 + /* ===================================================================== 146 160 Outline Sidebar 147 - ═══════════════════════════════════════════════════════════════════ */ 161 + ===================================================================== */ 148 162 149 163 .outline-empty { 150 164 padding: 12px; ··· 210 224 white-space: nowrap; 211 225 } 212 226 213 - /* ═══════════════════════════════════════════════════════════════════ 227 + /* ── Outline Zoom Controls ── */ 228 + 229 + .outline-zoom-controls { 230 + display: flex; 231 + align-items: center; 232 + gap: 2px; 233 + margin-left: auto; 234 + } 235 + 236 + .outline-zoom-btn { 237 + background: none; 238 + border: 1px solid var(--base02); 239 + color: var(--base04); 240 + cursor: pointer; 241 + padding: 1px 5px; 242 + font-size: 11px; 243 + line-height: 1; 244 + border-radius: 3px; 245 + transition: all 0.1s; 246 + font-weight: 600; 247 + } 248 + 249 + .outline-zoom-btn:hover:not(:disabled) { 250 + background: var(--base02); 251 + color: var(--base05); 252 + } 253 + 254 + .outline-zoom-btn:disabled { 255 + opacity: 0.3; 256 + cursor: default; 257 + } 258 + 259 + .outline-zoom-label { 260 + font-size: 10px; 261 + color: var(--base03); 262 + min-width: 28px; 263 + text-align: center; 264 + user-select: none; 265 + } 266 + 267 + /* ===================================================================== 214 268 Preview Sidebar 215 - ═══════════════════════════════════════════════════════════════════ */ 269 + ===================================================================== */ 216 270 217 271 .preview-content { 218 272 flex: 1; ··· 220 274 padding: 16px; 221 275 color: var(--base05); 222 276 line-height: 1.6; 277 + scroll-behavior: smooth; 223 278 } 224 279 225 280 .preview-content h1, ··· 315 370 border-radius: 4px; 316 371 } 317 372 318 - /* ═══════════════════════════════════════════════════════════════════ 373 + /* ===================================================================== 374 + Annotations Pane (far right) 375 + ===================================================================== */ 376 + 377 + .annotations-pane { 378 + display: flex; 379 + flex-direction: column; 380 + background: var(--base00); 381 + font-family: var(--theme-font-mono, 'SF Mono', 'Fira Code', 'Consolas', monospace); 382 + font-size: 12px; 383 + overflow: hidden; 384 + width: 260px; 385 + min-width: 260px; 386 + border-left: 1px solid var(--base02); 387 + transition: width 0.15s ease, min-width 0.15s ease; 388 + } 389 + 390 + .annotations-pane.collapsed { 391 + width: 32px; 392 + min-width: 32px; 393 + } 394 + 395 + .annotations-pane.collapsed .annotations-content { 396 + display: none; 397 + } 398 + 399 + .annotations-pane.collapsed .sidebar-title { 400 + display: none; 401 + } 402 + 403 + .annotations-pane.collapsed .annotations-count { 404 + display: none; 405 + } 406 + 407 + .annotations-pane.collapsed .sidebar-header { 408 + justify-content: center; 409 + padding: 8px 4px; 410 + } 411 + 412 + .annotations-count { 413 + background: var(--base02); 414 + color: var(--base04); 415 + font-size: 10px; 416 + padding: 1px 6px; 417 + border-radius: 8px; 418 + min-width: 18px; 419 + text-align: center; 420 + } 421 + 422 + .annotations-content { 423 + flex: 1; 424 + overflow-y: auto; 425 + padding: 4px 0; 426 + } 427 + 428 + .annotations-empty { 429 + padding: 12px; 430 + color: var(--base03); 431 + font-style: italic; 432 + font-size: 11px; 433 + line-height: 1.5; 434 + } 435 + 436 + .annotation-item { 437 + display: flex; 438 + align-items: flex-start; 439 + gap: 8px; 440 + padding: 8px 12px; 441 + cursor: pointer; 442 + transition: background 0.1s; 443 + border-bottom: 1px solid var(--base01); 444 + } 445 + 446 + .annotation-item:hover { 447 + background: var(--base01); 448 + } 449 + 450 + .annotation-color { 451 + display: inline-block; 452 + width: 8px; 453 + height: 8px; 454 + border-radius: 50%; 455 + flex-shrink: 0; 456 + margin-top: 4px; 457 + } 458 + 459 + .annotation-text-wrap { 460 + flex: 1; 461 + min-width: 0; 462 + overflow: hidden; 463 + } 464 + 465 + .annotation-text { 466 + color: var(--base05); 467 + font-size: 11px; 468 + line-height: 1.4; 469 + overflow: hidden; 470 + text-overflow: ellipsis; 471 + white-space: nowrap; 472 + } 473 + 474 + .annotation-note { 475 + color: var(--base0D); 476 + font-size: 10px; 477 + margin-top: 2px; 478 + font-style: italic; 479 + overflow: hidden; 480 + text-overflow: ellipsis; 481 + white-space: nowrap; 482 + } 483 + 484 + .annotation-actions { 485 + display: flex; 486 + gap: 4px; 487 + flex-shrink: 0; 488 + opacity: 0; 489 + transition: opacity 0.1s; 490 + } 491 + 492 + .annotation-item:hover .annotation-actions { 493 + opacity: 1; 494 + } 495 + 496 + .annotation-btn { 497 + background: var(--base02); 498 + border: none; 499 + color: var(--base04); 500 + cursor: pointer; 501 + padding: 2px 6px; 502 + font-size: 10px; 503 + border-radius: 3px; 504 + transition: all 0.1s; 505 + } 506 + 507 + .annotation-btn:hover { 508 + background: var(--base03); 509 + color: var(--base05); 510 + } 511 + 512 + .annotation-btn-delete:hover { 513 + background: var(--base08); 514 + color: var(--base00); 515 + } 516 + 517 + /* ===================================================================== 319 518 Resizers 320 - ═══════════════════════════════════════════════════════════════════ */ 519 + ===================================================================== */ 321 520 322 521 .resizer { 323 522 width: 8px; ··· 347 546 background: var(--base0A); 348 547 } 349 548 350 - /* ═══════════════════════════════════════════════════════════════════ 549 + /* ===================================================================== 351 550 Editor Container (center panel) 352 - ═══════════════════════════════════════════════════════════════════ */ 551 + ===================================================================== */ 353 552 354 553 .editor-container { 355 554 flex: 1; ··· 372 571 overflow: auto; 373 572 } 374 573 375 - /* ═══════════════════════════════════════════════════════════════════ 574 + /* ===================================================================== 376 575 Editor Toolbar 377 - ═══════════════════════════════════════════════════════════════════ */ 576 + ===================================================================== */ 378 577 379 578 .editor-toolbar { 380 579 display: flex; ··· 383 582 padding: 6px 12px; 384 583 background: var(--base01); 385 584 border-top: 1px solid var(--base02); 585 + gap: 8px; 386 586 } 387 587 388 588 .sidebar-toggles { 389 589 display: flex; 390 - gap: 6px; 590 + gap: 4px; 591 + flex-wrap: wrap; 391 592 } 392 593 393 594 .toolbar-btn { ··· 412 613 color: var(--base00); 413 614 } 414 615 415 - /* ═══════════════════════════════════════════════════════════════════ 616 + .toolbar-btn-icon { 617 + font-size: 13px; 618 + padding: 4px 8px; 619 + } 620 + 621 + /* ===================================================================== 416 622 Filename Display 417 - ═══════════════════════════════════════════════════════════════════ */ 623 + ===================================================================== */ 418 624 419 625 .filename-display { 420 626 font-size: 12px; ··· 427 633 user-select: none; 428 634 } 429 635 430 - /* ═══════════════════════════════════════════════════════════════════ 636 + /* ===================================================================== 431 637 Save Status Indicator 432 - ═══════════════════════════════════════════════════════════════════ */ 638 + ===================================================================== */ 433 639 434 640 .save-status { 435 641 font-size: 11px; ··· 455 661 background: var(--base01); 456 662 } 457 663 458 - /* ═══════════════════════════════════════════════════════════════════ 664 + /* ===================================================================== 459 665 Vim Command Bar Ghost-Text Suggestions 460 - ═══════════════════════════════════════════════════════════════════ */ 666 + ===================================================================== */ 461 667 462 668 .vim-cmd-ghost { 463 669 color: var(--base03); ··· 467 673 font-family: inherit; 468 674 font-size: inherit; 469 675 } 676 + 677 + /* ===================================================================== 678 + Highlight Marks in Editor 679 + ===================================================================== */ 680 + 681 + .cm-highlight-mark { 682 + border-radius: 2px; 683 + transition: background-color 0.15s; 684 + } 685 + 686 + .cm-highlight-mark:hover { 687 + filter: brightness(1.2); 688 + }
+101 -3
extensions/editor/outline-sidebar.js
··· 79 79 this.parentSet = new Set(); 80 80 this.collapsedSections = new Set(); // Set of header indices that are collapsed in outline 81 81 this.itemElements = []; // DOM elements for each header, indexed by header index 82 + this.zoomLevel = 6; // Show all levels by default (1-6) 82 83 83 84 // Create sidebar element 84 85 this.element = document.createElement('div'); 85 86 this.element.className = 'outline-sidebar'; 86 87 87 - // Header with title and collapse button 88 + // Header with title, zoom controls, and collapse button 88 89 this.header = document.createElement('div'); 89 90 this.header.className = 'sidebar-header'; 90 91 ··· 93 94 title.textContent = 'Outline'; 94 95 this.header.appendChild(title); 95 96 97 + // Zoom controls 98 + const zoomControls = document.createElement('span'); 99 + zoomControls.className = 'outline-zoom-controls'; 100 + 101 + this.zoomOutBtn = document.createElement('button'); 102 + this.zoomOutBtn.className = 'outline-zoom-btn'; 103 + this.zoomOutBtn.textContent = '\u2212'; // minus 104 + this.zoomOutBtn.title = 'Show fewer levels'; 105 + this.zoomOutBtn.tabIndex = -1; 106 + this.zoomOutBtn.addEventListener('mousedown', (e) => e.preventDefault()); 107 + this.zoomOutBtn.addEventListener('click', () => this.zoomOut()); 108 + zoomControls.appendChild(this.zoomOutBtn); 109 + 110 + this.zoomLabel = document.createElement('span'); 111 + this.zoomLabel.className = 'outline-zoom-label'; 112 + this.zoomLabel.textContent = 'H1-6'; 113 + zoomControls.appendChild(this.zoomLabel); 114 + 115 + this.zoomInBtn = document.createElement('button'); 116 + this.zoomInBtn.className = 'outline-zoom-btn'; 117 + this.zoomInBtn.textContent = '+'; 118 + this.zoomInBtn.title = 'Show more levels'; 119 + this.zoomInBtn.tabIndex = -1; 120 + this.zoomInBtn.addEventListener('mousedown', (e) => e.preventDefault()); 121 + this.zoomInBtn.addEventListener('click', () => this.zoomIn()); 122 + zoomControls.appendChild(this.zoomInBtn); 123 + 124 + this.header.appendChild(zoomControls); 125 + 96 126 this.toggleBtn = document.createElement('button'); 97 127 this.toggleBtn.className = 'sidebar-toggle'; 98 - this.toggleBtn.innerHTML = '\u25C0'; // ◀ 128 + this.toggleBtn.innerHTML = '\u25C0'; // left arrow 99 129 this.toggleBtn.tabIndex = -1; 100 130 this.toggleBtn.addEventListener('mousedown', (e) => e.preventDefault()); 101 131 this.toggleBtn.addEventListener('click', () => this.toggle()); ··· 109 139 this.element.appendChild(this.content); 110 140 111 141 this.container.appendChild(this.element); 142 + 143 + // Restore persisted zoom level 144 + this._restoreZoomLevel(); 145 + } 146 + 147 + /** 148 + * Zoom out - show fewer heading levels. 149 + */ 150 + zoomOut() { 151 + if (this.zoomLevel > 1) { 152 + this.zoomLevel--; 153 + this._updateZoomLabel(); 154 + this._persistZoomLevel(); 155 + this.render(); 156 + } 157 + } 158 + 159 + /** 160 + * Zoom in - show more heading levels. 161 + */ 162 + zoomIn() { 163 + if (this.zoomLevel < 6) { 164 + this.zoomLevel++; 165 + this._updateZoomLabel(); 166 + this._persistZoomLevel(); 167 + this.render(); 168 + } 169 + } 170 + 171 + /** 172 + * Set the zoom level directly. 173 + */ 174 + setZoomLevel(level) { 175 + this.zoomLevel = Math.max(1, Math.min(6, level)); 176 + this._updateZoomLabel(); 177 + this.render(); 178 + } 179 + 180 + _updateZoomLabel() { 181 + if (this.zoomLabel) { 182 + this.zoomLabel.textContent = this.zoomLevel === 6 ? 'H1-6' : `H1-${this.zoomLevel}`; 183 + } 184 + if (this.zoomOutBtn) { 185 + this.zoomOutBtn.disabled = this.zoomLevel <= 1; 186 + } 187 + if (this.zoomInBtn) { 188 + this.zoomInBtn.disabled = this.zoomLevel >= 6; 189 + } 190 + } 191 + 192 + _persistZoomLevel() { 193 + try { 194 + localStorage.setItem('editor:outline-zoom', String(this.zoomLevel)); 195 + } catch (e) { /* ignore */ } 196 + } 197 + 198 + _restoreZoomLevel() { 199 + try { 200 + const saved = localStorage.getItem('editor:outline-zoom'); 201 + if (saved) { 202 + const level = parseInt(saved, 10); 203 + if (level >= 1 && level <= 6) { 204 + this.zoomLevel = level; 205 + this._updateZoomLabel(); 206 + } 207 + } 208 + } catch (e) { /* ignore */ } 112 209 } 113 210 114 211 /** ··· 160 257 const isParent = this.parentSet.has(i); 161 258 const isSectionCollapsed = this.collapsedSections.has(i); 162 259 const isHidden = hiddenSet.has(i); 260 + const isAboveZoomLevel = header.level > this.zoomLevel; 163 261 164 262 const item = document.createElement('div'); 165 263 item.className = 'outline-item'; 166 - if (isHidden) { 264 + if (isHidden || isAboveZoomLevel) { 167 265 item.style.display = 'none'; 168 266 } 169 267 item.style.paddingLeft = `${(header.level - 1) * 12}px`;
+7
extensions/editor/preview-sidebar.js
··· 178 178 } 179 179 180 180 /** 181 + * Get the content element (for scroll sync). 182 + */ 183 + getContentElement() { 184 + return this.content; 185 + } 186 + 187 + /** 181 188 * Destroy the sidebar. 182 189 */ 183 190 destroy() {