experiments in a post-browser web
10
fork

Configure Feed

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

feat(editor): integrate CodeMirror markdown editor with three-panel layout

Editor features:
- Three-panel layout: outline sidebar, editor, preview sidebar
- CodeMirror with markdown syntax highlighting
- Vim mode toggle (persisted in settings)
- Live markdown preview
- Outline navigation from headers
- Resizable panels, focus mode

Also:
- Update CHANGELOG.md with 2026-W05 completed items
- Update TODO.md: mark editor/web-extensions done, reorganize sections

+1916 -1585
+43
CHANGELOG.md
··· 5 5 6 6 Newly done items go here, grouped under third-level headings by week of year. 7 7 8 + ### 2026-W05 9 + 10 + Schema & Data Layer 11 + - [x] feat(schema): add schema codegen system with single source of truth 12 + - [x] feat(schema): integrate codegen into build system with Rust backend tests 13 + - [x] feat(schema): comprehensive test coverage for generated types 14 + - [x] chore(server): align Node engine to repo-wide v22 policy 15 + 16 + Testing Infrastructure 17 + - [x] test(components): add component test infrastructure 18 + - [x] test(components): expand coverage to 56 tests with deterministic waits 19 + - [x] docs(components): add Testing section to README 20 + 21 + Editor 22 + - [x] feat(editor): integrate CodeMirror markdown editor with three-panel layout 23 + - Outline sidebar with header navigation 24 + - Live markdown preview sidebar 25 + - Vim mode toggle (persisted in settings) 26 + - Resizable panels, focus mode 27 + - Full syntax highlighting 28 + 29 + Web Extensions 30 + - [x] feat(web-ext): add bundled web extensions infrastructure 31 + - [x] feat(extensions): bundle Consent-O-Matic for automatic cookie consent handling 32 + - [x] docs: add research on bundled web extensions (uBlock, Proton Pass, Consent-O-Matic) 33 + - [x] Integrated @cliqz/adblocker-electron for native ad blocking 34 + 35 + Mobile / iOS 36 + - [x] feat(mobile): add Release CLI builds via xcodebuild 37 + - Fix Share Extension configuration inheritance (CONFIGURATION=Release override) 38 + - Add yarn mobile:ios:xcodebuild:release command 39 + - Add yarn mobile:ios:xcodebuild:install:release command 40 + - [x] feat(tests): iOS e2e testing improvements and window utilities 41 + - Add PEEK_AUTO_SYNC env var support 42 + - Add --headless and --build flags to e2e-full-sync-test.sh 43 + - [x] docs: add research on xcodebuild CLI vs Xcode GUI environment issues 44 + 45 + Developer Tooling 46 + - [x] chore: add multi-agent workflow with jj workspaces 47 + - [x] chore: update agent-setup to handle both install and update 48 + - [x] docs: add CLAUDE.coordinator.md for coordinator agents 49 + - [x] docs: update jj workflow - always commit before operations 50 + 8 51 ### 2026-W04 9 52 10 53 - [x][desktop] history & addressability: track peek:// loads, all window/webview loads, in-page navigation, JS window.open child windows (mkylrnxy)
+37 -24
TODO.md
··· 50 50 - [ ] pop up a board of built-in shortcuts/actions 51 51 - [ ] pop up a board of common shortcuts/actions you use 52 52 53 - ## Addessibility / Core history / feeds 53 + ## Use cases 54 + 55 + key capabilities from application history chain + addressibility 56 + 57 + - record/replay 58 + - state feedback loops 59 + - observability 60 + 61 + to build 62 + - [ ] daily ribbon along bottom of screen, populated with actions and favicons of pages loaded (filter on web only?) 63 + - [ ] step counter: app level interaction tracing/counting. when is reset? when does action end and new one start? 54 64 55 - For record/replay, daily ribbon, state feedback loops and observability, etc we need a complete chained history. 56 - All of those require addressibility of all primary actions, and connections to prev/next actions. 57 - Includes any peek:// invocation and parameters passed. 58 - May require the connector/parameter context for each invocation, tbd. 59 - Requires explicit chaining. 65 + maybe todo 66 + - app level interaction tracing/counting - when is reset? what makes a discrete action start/stop/change? 67 + - may require connector/parameter context for each peek address load and connector invocation in a chained command 68 + - peeks/slides as addresses + metadata? or urls? eg open({json of peek url + context}) => localhost in left slide, or peek://slide?where=top&url=http://localhost? both? 60 69 61 70 Review against impl 62 - - [ ] step counter: app level interaction tracing/counting. when is reset? when does action end and new one start? 63 - - [ ] peeks/slides as tagged addresses with metadata properties? or urls? 64 71 65 72 ## UI Componentry 66 73 ··· 225 232 226 233 ## Izui 227 234 228 - - [ ] formalize model 229 - - [ ] make izui stack manager (part of window mgr?) 230 - - [ ] esc stack: from feature settings back to core settings 231 - - [ ] add to izui stack (and ix w/ history?) 232 - - [ ] interactions/sec-policy between peek:// and other 235 + formalizing and stabilizing Peek’s window management system 236 + 237 + immediate 238 + - [ ] hotfix: disable escape-to-close window when peek app is focused application in the OS 239 + 240 + model formalization 241 + - [ ] review the windowing approach used in ./app to manage windows by analyzing the source code of it and the peek extensions 242 + - [ ] formalize that review into a state machine or other declarative set of rules which let’s us easily reason about, revise, and generate code and tests for it 243 + - [ ] implement izui window manager based on those rules 244 + 245 + key pieces 246 + - [ ] esc works when global hotkeys are executed and peek is not focused 247 + - [ ] in-app navigations with escape, eg moving from sub items in settings back to settings default pane 248 + - [ ] centralized place we add to history chain 233 249 234 250 ## Polish 235 251 ··· 263 279 - editing in command chaining interstitials (edit cmd can apply to anything text-ish) 264 280 - OS level handler for editing files on filesystem 265 281 266 - Implementation 267 - - [ ] import from ~/misc/peek-editor, put in ./extensions/editor for now 268 - - [ ] evaluate using raw codemirror which is like “toolkit for an editor" 269 - - [ ] evaluate using https://github.com/MarkEdit-app/MarkEdit or its approach 282 + Implementation 283 + - [x] CodeMirror integrated with three-panel layout (outline, editor, preview) 284 + - [x] Vim mode toggle with settings persistence 285 + - [x] Live markdown preview sidebar 286 + - [x] Outline navigation from headers 270 287 271 288 Features 272 289 - [ ] add support for paste operations ··· 421 438 - [ ] in url saves/views, show oembed, or at least page title 422 439 - [ ] for url saves, save title and any other metadata 423 440 - [ ] investigate detecting which app a share came from 424 - - [x] fix xcodebuild CLI builds (DONE - uses /tmp/peek-xcodebuild for isolated DerivedData) 425 - - yarn mobile:ios:xcodebuild for CLI builds 426 - - yarn interactive-test:e2e:full-sync:auto for fully automated e2e tests 427 441 428 442 ## Session & State Management 429 443 ··· 446 460 - [ ] OpenSearch 447 461 448 462 Web extensions 449 - - [ ] WebExtension integration for bundled extensions only (not user-installable) 450 - - [ ] Electron first, using electron-chrome-extensions (Polypane fork for MV3) 451 - - [ ] @cliqz/adblocker-electron for ad blocking (native, not extension) 463 + - [x] WebExtension integration for bundled extensions (Electron, using electron-chrome-extensions) 464 + - [x] @cliqz/adblocker-electron for native ad blocking 465 + - [x] Consent-O-Matic for automatic cookie consent handling (MIT, Aarhus University) 452 466 - [ ] Proton Pass for password management 453 - - [ ] Consent-O-Matic for cookie consent auto-handling (MIT, Aarhus University) 454 467 - [ ] Enable/disable toggles in Settings UI 455 468 456 469 ## Feeds, time-series, scripts
+1 -1
app/index.js
··· 29 29 30 30 // Built-in extensions (now loaded by main process ExtensionManager) 31 31 // cmd is first so it's ready to receive command registrations from other extensions 32 - const builtinExtensions = ['cmd', 'groups', 'peeks', 'slides']; 32 + const builtinExtensions = ['cmd', 'editor', 'groups', 'peeks', 'slides']; 33 33 34 34 let _settingsWin = null; 35 35
+1 -1
app/settings/settings.js
··· 1211 1211 const allExtensions = []; 1212 1212 1213 1213 // Get all builtin extension IDs from the loader 1214 - const builtinExtIds = ['cmd', 'groups', 'peeks', 'slides', 'windows']; 1214 + const builtinExtIds = ['cmd', 'editor', 'groups', 'peeks', 'slides', 'windows']; 1215 1215 1216 1216 // Add builtin extensions (whether running or not) 1217 1217 builtinExtIds.forEach(extId => {
+1 -1
backend/electron/main.ts
··· 48 48 49 49 // Built-in extensions that load in consolidated mode (iframes) 50 50 // External extensions (including 'example') load in separate windows 51 - const CONSOLIDATED_EXTENSION_IDS = ['cmd', 'groups', 'peeks', 'slides', 'windows', 'overlay']; 51 + const CONSOLIDATED_EXTENSION_IDS = ['cmd', 'editor', 'groups', 'peeks', 'slides', 'windows', 'overlay']; 52 52 53 53 // Dev extensions loaded via --load-extension CLI flag 54 54 // These are transient (not persisted) and always have devtools open
+56
extensions/editor/README.md
··· 1 + # Editor Extension 2 + 3 + A markdown editor with a three-panel layout inspired by peek-edit. 4 + 5 + ## Features 6 + 7 + - **Outline sidebar** (left) - Table of contents from markdown headers, click to jump 8 + - **CodeMirror editor** (center) - Full-featured markdown editing with syntax highlighting 9 + - **Preview sidebar** (right) - Live rendered markdown preview 10 + - **Vim mode** - Toggle vim keybindings via toolbar checkbox 11 + - **Resizable panels** - Drag borders to resize sidebars 12 + - **Focus mode** - Hide sidebars for distraction-free editing 13 + 14 + ## Keyboard Shortcuts 15 + 16 + | Shortcut | Action | 17 + |----------|--------| 18 + | `Option+E` | Open editor (global) | 19 + | `Cmd+Shift+O` | Toggle outline sidebar | 20 + | `Cmd+Shift+P` | Toggle preview sidebar | 21 + | `Escape` | Exit focus mode | 22 + 23 + ## Architecture 24 + 25 + ``` 26 + extensions/editor/ 27 + ├── manifest.json # Extension metadata 28 + ├── background.html/js # Extension lifecycle, commands, shortcuts 29 + ├── home.html # Editor page with import map for CodeMirror 30 + ├── home.js # Editor initialization 31 + ├── home.css # Three-panel layout styles 32 + ├── editor-layout.js # Main layout component 33 + ├── outline-sidebar.js # TOC sidebar (parses headers) 34 + ├── preview-sidebar.js # Markdown preview renderer 35 + ├── codemirror.js # CodeMirror wrapper with Peek theming 36 + └── settings-schema.json # Vim mode preference 37 + ``` 38 + 39 + ## CodeMirror Integration 40 + 41 + Uses ES modules via import map (no bundler required). The import map in `home.html` maps bare specifiers to `peek://node_modules/` paths. 42 + 43 + Key packages: 44 + - `@codemirror/lang-markdown` - Markdown language support 45 + - `@codemirror/language` - Syntax highlighting, folding 46 + - `@replit/codemirror-vim` - Vim keybindings 47 + - `@codemirror/search` - Search functionality 48 + 49 + ## Pubsub Events 50 + 51 + - `editor:open` - Open editor (optional: `{ content, file }`) 52 + - `editor:contentChanged` - Emitted when content changes 53 + 54 + ## Settings 55 + 56 + - `vimMode` (boolean) - Remember vim mode preference across sessions
+17 -25
extensions/editor/background.js
··· 1 1 /** 2 - * Editor Extension - View, add, and edit saved items 2 + * Editor Extension - Markdown editor with sidebars 3 3 * 4 4 * Provides: 5 - * - Full item CRUD (URLs, text notes, tagsets, images) 6 - * - Tag editing on items 7 - * - Type filtering and search 8 - * - Pubsub integration (editor:open, editor:add, editor:changed) 5 + * - Three-panel layout: outline | editor | preview 6 + * - CodeMirror-based markdown editing 7 + * - Optional vim mode 8 + * - Resizable panels 9 + * - Pubsub integration 9 10 */ 10 11 11 12 // Feature detection ··· 13 14 const api = hasPeekAPI ? window.app : null; 14 15 15 16 /** 16 - * Open the editor home window 17 + * Open the editor window 17 18 */ 18 19 function openEditor(params) { 19 20 if (hasPeekAPI) { ··· 24 25 } 25 26 api.window.open(url, { 26 27 key: 'editor-home', 27 - width: 900, 28 - height: 700, 28 + width: 1200, 29 + height: 800, 29 30 title: 'Editor' 30 31 }); 31 32 } else { ··· 45 46 registerCommands() { 46 47 api.commands.register({ 47 48 name: 'open editor', 48 - description: 'Open the item editor', 49 + description: 'Open the markdown editor', 49 50 execute: () => openEditor() 50 51 }); 51 52 ··· 71 72 // Register global shortcut Option+e 72 73 api.shortcuts.register('Option+e', () => openEditor()); 73 74 74 - // Subscribe to editor:open — other extensions request item editing 75 + // Subscribe to editor:open — open editor with optional content 75 76 api.subscribe('editor:open', (msg) => { 76 - if (msg && msg.itemId) { 77 - openEditor({ itemId: msg.itemId }); 78 - } else { 79 - openEditor(); 77 + const params = {}; 78 + if (msg?.content) { 79 + params.content = msg.content; 80 80 } 81 - }, api.scopes.GLOBAL); 82 - 83 - // Subscribe to editor:add — other extensions request add mode 84 - api.subscribe('editor:add', (msg) => { 85 - const params = {}; 86 - if (msg) { 87 - if (msg.type) params.addType = msg.type; 88 - if (msg.content) params.addContent = msg.content; 89 - if (msg.url) params.addUrl = msg.url; 81 + if (msg?.file) { 82 + params.file = msg.file; 90 83 } 91 - params.mode = 'add'; 92 - openEditor(params); 84 + openEditor(Object.keys(params).length > 0 ? params : undefined); 93 85 }, api.scopes.GLOBAL); 94 86 95 87 console.log('[editor] Extension loaded');
+245
extensions/editor/codemirror.js
··· 1 + /** 2 + * CodeMirror Editor Module 3 + * 4 + * Provides a configured CodeMirror instance for markdown editing 5 + * with optional vim mode support. 6 + */ 7 + 8 + import { EditorState, Compartment } from '@codemirror/state'; 9 + import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection } from '@codemirror/view'; 10 + import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; 11 + import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; 12 + import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, indentOnInput, foldGutter } from '@codemirror/language'; 13 + import { oneDark } from '@codemirror/theme-one-dark'; 14 + import { vim } from '@replit/codemirror-vim'; 15 + import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; 16 + 17 + // Compartments for runtime-reconfigurable extensions 18 + const vimCompartment = new Compartment(); 19 + const themeCompartment = new Compartment(); 20 + 21 + /** 22 + * Create a peek-themed CodeMirror theme using CSS variables 23 + */ 24 + const peekTheme = EditorView.theme({ 25 + '&': { 26 + fontSize: '14px', 27 + fontFamily: 'var(--theme-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)', 28 + backgroundColor: 'var(--base01)', 29 + color: 'var(--base05)', 30 + borderRadius: '8px', 31 + border: '1px solid var(--base02)', 32 + }, 33 + '&.cm-focused': { 34 + outline: 'none', 35 + borderColor: 'var(--base0D)', 36 + }, 37 + '.cm-content': { 38 + padding: '10px 14px', 39 + caretColor: 'var(--base05)', 40 + }, 41 + '.cm-cursor, .cm-dropCursor': { 42 + borderLeftColor: 'var(--base05)', 43 + }, 44 + '.cm-selectionBackground, ::selection': { 45 + backgroundColor: 'var(--base02)', 46 + }, 47 + '.cm-activeLine': { 48 + backgroundColor: 'var(--base02)', 49 + }, 50 + '.cm-activeLineGutter': { 51 + backgroundColor: 'var(--base02)', 52 + }, 53 + '.cm-gutters': { 54 + backgroundColor: 'var(--base01)', 55 + color: 'var(--base03)', 56 + border: 'none', 57 + borderRight: '1px solid var(--base02)', 58 + }, 59 + '.cm-lineNumbers .cm-gutterElement': { 60 + padding: '0 8px 0 4px', 61 + }, 62 + '.cm-foldGutter': { 63 + width: '12px', 64 + }, 65 + // Markdown-specific highlighting 66 + '.cm-header': { 67 + color: 'var(--base0D)', 68 + fontWeight: '600', 69 + }, 70 + '.cm-strong': { 71 + color: 'var(--base0A)', 72 + fontWeight: '600', 73 + }, 74 + '.cm-emphasis': { 75 + color: 'var(--base0E)', 76 + fontStyle: 'italic', 77 + }, 78 + '.cm-link': { 79 + color: 'var(--base0C)', 80 + textDecoration: 'underline', 81 + }, 82 + '.cm-url': { 83 + color: 'var(--base0C)', 84 + }, 85 + '.cm-strikethrough': { 86 + textDecoration: 'line-through', 87 + color: 'var(--base03)', 88 + }, 89 + '.cm-monospace, .cm-inlineCode': { 90 + fontFamily: 'var(--theme-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)', 91 + backgroundColor: 'var(--base02)', 92 + padding: '1px 4px', 93 + borderRadius: '3px', 94 + }, 95 + '.cm-quote': { 96 + color: 'var(--base03)', 97 + fontStyle: 'italic', 98 + }, 99 + '.cm-list': { 100 + color: 'var(--base09)', 101 + }, 102 + // Vim-specific styles 103 + '.cm-fat-cursor': { 104 + backgroundColor: 'var(--base05) !important', 105 + color: 'var(--base00) !important', 106 + }, 107 + '&:not(.cm-focused) .cm-fat-cursor': { 108 + backgroundColor: 'transparent !important', 109 + outline: '1px solid var(--base05)', 110 + }, 111 + '.cm-vim-panel': { 112 + padding: '4px 10px', 113 + backgroundColor: 'var(--base00)', 114 + borderTop: '1px solid var(--base02)', 115 + fontFamily: 'var(--theme-font-mono, monospace)', 116 + fontSize: '13px', 117 + color: 'var(--base04)', 118 + }, 119 + '.cm-vim-panel input': { 120 + backgroundColor: 'transparent', 121 + border: 'none', 122 + outline: 'none', 123 + color: 'var(--base05)', 124 + fontFamily: 'inherit', 125 + fontSize: 'inherit', 126 + }, 127 + }, { dark: true }); 128 + 129 + /** 130 + * Create a CodeMirror editor instance 131 + * @param {Object} options - Configuration options 132 + * @param {HTMLElement} options.parent - Parent element to mount editor in 133 + * @param {string} options.content - Initial content 134 + * @param {boolean} options.vimMode - Enable vim mode 135 + * @param {boolean} options.showLineNumbers - Show line numbers 136 + * @param {Function} options.onChange - Callback when content changes 137 + * @returns {EditorView} - CodeMirror EditorView instance 138 + */ 139 + export function createEditor({ parent, content = '', vimMode = false, showLineNumbers = true, onChange }) { 140 + const extensions = [ 141 + // Core extensions 142 + history(), 143 + drawSelection(), 144 + indentOnInput(), 145 + bracketMatching(), 146 + highlightActiveLine(), 147 + highlightActiveLineGutter(), 148 + highlightSelectionMatches(), 149 + 150 + // Keymaps 151 + keymap.of([ 152 + ...defaultKeymap, 153 + ...historyKeymap, 154 + ...searchKeymap, 155 + indentWithTab, 156 + ]), 157 + 158 + // Language support 159 + markdown({ base: markdownLanguage }), 160 + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), 161 + 162 + // Theming 163 + themeCompartment.of(peekTheme), 164 + 165 + // Vim mode (initially based on setting) 166 + vimCompartment.of(vimMode ? vim() : []), 167 + 168 + // Change listener 169 + EditorView.updateListener.of(update => { 170 + if (update.docChanged && onChange) { 171 + onChange(update.state.doc.toString()); 172 + } 173 + }), 174 + ]; 175 + 176 + // Optional line numbers 177 + if (showLineNumbers) { 178 + extensions.push(lineNumbers(), foldGutter()); 179 + } 180 + 181 + const state = EditorState.create({ 182 + doc: content, 183 + extensions, 184 + }); 185 + 186 + const view = new EditorView({ 187 + state, 188 + parent, 189 + }); 190 + 191 + return view; 192 + } 193 + 194 + /** 195 + * Toggle vim mode on an existing editor 196 + * @param {EditorView} view - CodeMirror EditorView instance 197 + * @param {boolean} enabled - Whether to enable vim mode 198 + */ 199 + export function setVimMode(view, enabled) { 200 + view.dispatch({ 201 + effects: vimCompartment.reconfigure(enabled ? vim() : []), 202 + }); 203 + } 204 + 205 + /** 206 + * Get the current content from the editor 207 + * @param {EditorView} view - CodeMirror EditorView instance 208 + * @returns {string} - Current document content 209 + */ 210 + export function getContent(view) { 211 + return view.state.doc.toString(); 212 + } 213 + 214 + /** 215 + * Set the content of the editor 216 + * @param {EditorView} view - CodeMirror EditorView instance 217 + * @param {string} content - New content 218 + */ 219 + export function setContent(view, content) { 220 + view.dispatch({ 221 + changes: { 222 + from: 0, 223 + to: view.state.doc.length, 224 + insert: content, 225 + }, 226 + }); 227 + } 228 + 229 + /** 230 + * Focus the editor 231 + * @param {EditorView} view - CodeMirror EditorView instance 232 + */ 233 + export function focus(view) { 234 + view.focus(); 235 + } 236 + 237 + /** 238 + * Destroy the editor instance 239 + * @param {EditorView} view - CodeMirror EditorView instance 240 + */ 241 + export function destroy(view) { 242 + view.destroy(); 243 + } 244 + 245 + export { EditorView, EditorState };
+454
extensions/editor/editor-layout.js
··· 1 + /** 2 + * Editor Layout - Three-panel markdown editor. 3 + * Combines outline sidebar, CodeMirror editor, and preview sidebar. 4 + * Resizable panels with vim mode support. 5 + */ 6 + 7 + import { OutlineSidebar } from './outline-sidebar.js'; 8 + import { PreviewSidebar } from './preview-sidebar.js'; 9 + import * as CodeMirror from './codemirror.js'; 10 + 11 + export class EditorLayout { 12 + constructor(options) { 13 + this.container = options.container; 14 + this.onContentChange = options.onContentChange; 15 + this.initialContent = options.initialContent || ''; 16 + this.vimMode = options.vimMode || false; 17 + 18 + this.outlineSidebar = null; 19 + this.previewSidebar = null; 20 + this.cmEditor = null; 21 + this.lastContent = ''; 22 + this.rafId = null; 23 + this.isDestroyed = false; 24 + this.isFocusMode = false; 25 + this.originalContainerStyles = ''; 26 + 27 + this.init(); 28 + } 29 + 30 + init() { 31 + // Create main wrapper 32 + this.wrapper = document.createElement('div'); 33 + this.wrapper.className = 'editor-layout'; 34 + 35 + // Create outline sidebar (left) 36 + this.outlineSidebar = new OutlineSidebar({ 37 + container: this.wrapper, 38 + onHeaderClick: (header) => this.jumpToHeader(header), 39 + }); 40 + 41 + // Create left resizer 42 + this.leftResizer = this.createResizer('left'); 43 + this.wrapper.appendChild(this.leftResizer); 44 + 45 + // Create editor container (center) 46 + this.editorContainer = document.createElement('div'); 47 + this.editorContainer.className = 'editor-container'; 48 + 49 + // CodeMirror container 50 + this.cmContainer = document.createElement('div'); 51 + this.cmContainer.className = 'cm-container'; 52 + this.editorContainer.appendChild(this.cmContainer); 53 + 54 + // Toolbar below editor 55 + this.toolbar = document.createElement('div'); 56 + this.toolbar.className = 'editor-toolbar'; 57 + 58 + // Vim mode toggle 59 + this.vimToggle = document.createElement('label'); 60 + this.vimToggle.className = 'vim-toggle'; 61 + 62 + this.vimCheckbox = document.createElement('input'); 63 + this.vimCheckbox.type = 'checkbox'; 64 + this.vimCheckbox.checked = this.vimMode; 65 + this.vimCheckbox.addEventListener('change', () => this.handleVimToggle()); 66 + 67 + const vimLabel = document.createElement('span'); 68 + vimLabel.textContent = 'Vim'; 69 + 70 + this.vimToggle.appendChild(this.vimCheckbox); 71 + this.vimToggle.appendChild(vimLabel); 72 + this.toolbar.appendChild(this.vimToggle); 73 + 74 + // Sidebar toggles 75 + const sidebarToggles = document.createElement('div'); 76 + sidebarToggles.className = 'sidebar-toggles'; 77 + 78 + this.outlineToggleBtn = document.createElement('button'); 79 + this.outlineToggleBtn.className = 'toolbar-btn'; 80 + this.outlineToggleBtn.textContent = 'Outline'; 81 + this.outlineToggleBtn.title = 'Toggle outline sidebar (Cmd+Shift+O)'; 82 + this.outlineToggleBtn.addEventListener('click', () => this.toggleOutline()); 83 + sidebarToggles.appendChild(this.outlineToggleBtn); 84 + 85 + this.previewToggleBtn = document.createElement('button'); 86 + this.previewToggleBtn.className = 'toolbar-btn'; 87 + this.previewToggleBtn.textContent = 'Preview'; 88 + this.previewToggleBtn.title = 'Toggle preview sidebar (Cmd+Shift+P)'; 89 + this.previewToggleBtn.addEventListener('click', () => this.togglePreview()); 90 + sidebarToggles.appendChild(this.previewToggleBtn); 91 + 92 + this.focusBtn = document.createElement('button'); 93 + this.focusBtn.className = 'toolbar-btn'; 94 + this.focusBtn.textContent = 'Focus'; 95 + this.focusBtn.title = 'Toggle focus mode (Escape to exit)'; 96 + this.focusBtn.addEventListener('click', () => this.toggleFocusMode()); 97 + sidebarToggles.appendChild(this.focusBtn); 98 + 99 + this.toolbar.appendChild(sidebarToggles); 100 + this.editorContainer.appendChild(this.toolbar); 101 + 102 + this.wrapper.appendChild(this.editorContainer); 103 + 104 + // Create right resizer 105 + this.rightResizer = this.createResizer('right'); 106 + this.wrapper.appendChild(this.rightResizer); 107 + 108 + // Create preview sidebar (right) 109 + this.previewSidebar = new PreviewSidebar({ 110 + container: this.wrapper, 111 + }); 112 + 113 + this.container.appendChild(this.wrapper); 114 + 115 + // Initialize CodeMirror 116 + this.cmEditor = CodeMirror.createEditor({ 117 + parent: this.cmContainer, 118 + content: this.initialContent, 119 + vimMode: this.vimMode, 120 + showLineNumbers: true, 121 + onChange: (content) => this.handleContentChange(content), 122 + }); 123 + 124 + // Default sidebars to collapsed 125 + this.outlineSidebar.toggle(); 126 + this.previewSidebar.toggle(); 127 + 128 + // Initial update 129 + this.lastContent = this.initialContent; 130 + this.updateSidebars(); 131 + 132 + // Start watching for changes (throttled updates) 133 + this.startWatching(); 134 + 135 + // Set up keyboard shortcuts 136 + this.setupKeyboardShortcuts(); 137 + 138 + // Focus editor 139 + setTimeout(() => { 140 + if (this.cmEditor) CodeMirror.focus(this.cmEditor); 141 + }, 100); 142 + } 143 + 144 + createResizer(side) { 145 + const resizer = document.createElement('div'); 146 + resizer.className = `resizer resizer-${side}`; 147 + 148 + const indicator = document.createElement('div'); 149 + indicator.className = 'resizer-indicator'; 150 + resizer.appendChild(indicator); 151 + 152 + resizer.addEventListener('mouseenter', () => { 153 + indicator.classList.add('visible'); 154 + }); 155 + 156 + resizer.addEventListener('mouseleave', () => { 157 + if (!resizer.classList.contains('dragging')) { 158 + indicator.classList.remove('visible'); 159 + } 160 + }); 161 + 162 + resizer.addEventListener('mousedown', (e) => { 163 + e.preventDefault(); 164 + indicator.classList.add('visible'); 165 + this.startResize(side, e, indicator); 166 + }); 167 + 168 + return resizer; 169 + } 170 + 171 + startResize(side, startEvent, indicator) { 172 + const resizer = side === 'left' ? this.leftResizer : this.rightResizer; 173 + const target = side === 'left' 174 + ? this.outlineSidebar.getElement() 175 + : this.previewSidebar.getElement(); 176 + 177 + if (!resizer || !target) return; 178 + 179 + resizer.classList.add('dragging'); 180 + 181 + const startX = startEvent.clientX; 182 + const startWidth = target.offsetWidth; 183 + 184 + document.body.style.userSelect = 'none'; 185 + document.body.style.cursor = 'col-resize'; 186 + 187 + const onMouseMove = (e) => { 188 + const delta = side === 'left' 189 + ? e.clientX - startX 190 + : startX - e.clientX; 191 + 192 + const newWidth = Math.max(100, Math.min(600, startWidth + delta)); 193 + target.style.width = `${newWidth}px`; 194 + target.style.minWidth = `${newWidth}px`; 195 + }; 196 + 197 + const onMouseUp = () => { 198 + resizer.classList.remove('dragging'); 199 + indicator.classList.remove('visible'); 200 + document.body.style.userSelect = ''; 201 + document.body.style.cursor = ''; 202 + document.removeEventListener('mousemove', onMouseMove); 203 + document.removeEventListener('mouseup', onMouseUp); 204 + }; 205 + 206 + document.addEventListener('mousemove', onMouseMove); 207 + document.addEventListener('mouseup', onMouseUp); 208 + } 209 + 210 + setupKeyboardShortcuts() { 211 + document.addEventListener('keydown', (e) => { 212 + // Cmd+Shift+O: Toggle outline 213 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'o') { 214 + e.preventDefault(); 215 + this.toggleOutline(); 216 + } 217 + // Cmd+Shift+P: Toggle preview 218 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'p') { 219 + e.preventDefault(); 220 + this.togglePreview(); 221 + } 222 + // Escape: Exit focus mode 223 + if (e.key === 'Escape' && this.isFocusMode) { 224 + this.exitFocusMode(); 225 + } 226 + }); 227 + } 228 + 229 + startWatching() { 230 + const check = () => { 231 + if (this.isDestroyed) return; 232 + 233 + const currentContent = this.getContent(); 234 + if (currentContent !== this.lastContent) { 235 + this.lastContent = currentContent; 236 + this.updateSidebars(); 237 + } 238 + 239 + this.rafId = requestAnimationFrame(check); 240 + }; 241 + 242 + this.rafId = requestAnimationFrame(check); 243 + } 244 + 245 + stopWatching() { 246 + if (this.rafId !== null) { 247 + cancelAnimationFrame(this.rafId); 248 + this.rafId = null; 249 + } 250 + } 251 + 252 + updateSidebars() { 253 + const content = this.lastContent; 254 + 255 + if (this.outlineSidebar) { 256 + this.outlineSidebar.update(content); 257 + } 258 + 259 + if (this.previewSidebar) { 260 + this.previewSidebar.update(content); 261 + } 262 + } 263 + 264 + handleContentChange(content) { 265 + this.lastContent = content; 266 + this.updateSidebars(); 267 + 268 + if (this.onContentChange) { 269 + this.onContentChange(content); 270 + } 271 + } 272 + 273 + handleVimToggle() { 274 + this.vimMode = this.vimCheckbox.checked; 275 + if (this.cmEditor) { 276 + CodeMirror.setVimMode(this.cmEditor, this.vimMode); 277 + } 278 + } 279 + 280 + jumpToHeader(header) { 281 + if (!this.cmEditor) return; 282 + 283 + // Set cursor position to the header's offset 284 + const view = this.cmEditor; 285 + view.dispatch({ 286 + selection: { anchor: header.offset }, 287 + scrollIntoView: true, 288 + }); 289 + CodeMirror.focus(view); 290 + } 291 + 292 + /** 293 + * Get the current content. 294 + */ 295 + getContent() { 296 + if (this.cmEditor) { 297 + return CodeMirror.getContent(this.cmEditor); 298 + } 299 + return this.lastContent; 300 + } 301 + 302 + /** 303 + * Set the editor content. 304 + */ 305 + setContent(content) { 306 + if (this.cmEditor) { 307 + CodeMirror.setContent(this.cmEditor, content); 308 + } 309 + this.lastContent = content; 310 + this.updateSidebars(); 311 + } 312 + 313 + /** 314 + * Toggle outline sidebar. 315 + */ 316 + toggleOutline() { 317 + if (this.outlineSidebar) { 318 + this.outlineSidebar.toggle(); 319 + this.outlineToggleBtn.classList.toggle('active', !this.outlineSidebar.isCollapsed()); 320 + } 321 + } 322 + 323 + /** 324 + * Toggle preview sidebar. 325 + */ 326 + togglePreview() { 327 + if (this.previewSidebar) { 328 + this.previewSidebar.toggle(); 329 + this.previewToggleBtn.classList.toggle('active', !this.previewSidebar.isCollapsed()); 330 + } 331 + } 332 + 333 + /** 334 + * Enter focus mode - expand editor to fill viewport. 335 + */ 336 + enterFocusMode() { 337 + if (this.isFocusMode) return; 338 + 339 + this.isFocusMode = true; 340 + this.originalContainerStyles = this.container.style.cssText; 341 + this.wrapper.classList.add('focus-mode'); 342 + this.focusBtn.classList.add('active'); 343 + 344 + // Hide sidebars and resizers 345 + if (this.outlineSidebar) { 346 + this.outlineSidebar.getElement().style.display = 'none'; 347 + } 348 + if (this.previewSidebar) { 349 + this.previewSidebar.getElement().style.display = 'none'; 350 + } 351 + if (this.leftResizer) { 352 + this.leftResizer.style.display = 'none'; 353 + } 354 + if (this.rightResizer) { 355 + this.rightResizer.style.display = 'none'; 356 + } 357 + 358 + if (this.cmEditor) CodeMirror.focus(this.cmEditor); 359 + } 360 + 361 + /** 362 + * Exit focus mode. 363 + */ 364 + exitFocusMode() { 365 + if (!this.isFocusMode) return; 366 + 367 + this.isFocusMode = false; 368 + this.container.style.cssText = this.originalContainerStyles; 369 + this.wrapper.classList.remove('focus-mode'); 370 + this.focusBtn.classList.remove('active'); 371 + 372 + // Restore sidebars and resizers 373 + if (this.outlineSidebar) { 374 + this.outlineSidebar.getElement().style.display = ''; 375 + } 376 + if (this.previewSidebar) { 377 + this.previewSidebar.getElement().style.display = ''; 378 + } 379 + if (this.leftResizer) { 380 + this.leftResizer.style.display = ''; 381 + } 382 + if (this.rightResizer) { 383 + this.rightResizer.style.display = ''; 384 + } 385 + 386 + if (this.cmEditor) CodeMirror.focus(this.cmEditor); 387 + } 388 + 389 + /** 390 + * Toggle focus mode. 391 + */ 392 + toggleFocusMode() { 393 + if (this.isFocusMode) { 394 + this.exitFocusMode(); 395 + } else { 396 + this.enterFocusMode(); 397 + } 398 + } 399 + 400 + /** 401 + * Check if in focus mode. 402 + */ 403 + isInFocusMode() { 404 + return this.isFocusMode; 405 + } 406 + 407 + /** 408 + * Set vim mode. 409 + */ 410 + setVimMode(enabled) { 411 + this.vimMode = enabled; 412 + this.vimCheckbox.checked = enabled; 413 + if (this.cmEditor) { 414 + CodeMirror.setVimMode(this.cmEditor, enabled); 415 + } 416 + } 417 + 418 + /** 419 + * Get vim mode state. 420 + */ 421 + getVimMode() { 422 + return this.vimMode; 423 + } 424 + 425 + /** 426 + * Focus the editor. 427 + */ 428 + focus() { 429 + if (this.cmEditor) { 430 + CodeMirror.focus(this.cmEditor); 431 + } 432 + } 433 + 434 + /** 435 + * Destroy the layout and clean up. 436 + */ 437 + destroy() { 438 + this.isDestroyed = true; 439 + this.stopWatching(); 440 + 441 + if (this.isFocusMode) { 442 + this.exitFocusMode(); 443 + } 444 + 445 + if (this.cmEditor) { 446 + CodeMirror.destroy(this.cmEditor); 447 + this.cmEditor = null; 448 + } 449 + 450 + this.outlineSidebar?.destroy(); 451 + this.previewSidebar?.destroy(); 452 + this.wrapper.remove(); 453 + } 454 + }
+245 -581
extensions/editor/home.css
··· 7 7 padding: 0; 8 8 } 9 9 10 + html, body { 11 + height: 100%; 12 + overflow: hidden; 13 + } 14 + 10 15 html { 11 16 font-family: var(--theme-font-sans); 12 17 -webkit-font-smoothing: antialiased; ··· 17 22 body { 18 23 background: var(--base00); 19 24 color: var(--base05); 20 - min-height: 100vh; 21 - display: flex; 22 - flex-direction: column; 23 25 } 24 26 25 - /* Header */ 26 - .header { 27 - display: flex; 28 - align-items: center; 29 - justify-content: space-between; 30 - padding: 16px 24px; 31 - border-bottom: 1px solid var(--base02); 32 - background: var(--base00); 33 - } 34 - 35 - .header-left { 36 - display: flex; 37 - align-items: center; 38 - gap: 12px; 39 - } 40 - 41 - .header-title { 42 - font-size: 18px; 43 - font-weight: 600; 44 - color: var(--base05); 27 + #editor-root { 28 + width: 100%; 29 + height: 100%; 45 30 } 46 31 47 - /* Filter icons */ 48 - .filter-icons { 49 - display: flex; 50 - gap: 4px; 51 - } 32 + /* ═══════════════════════════════════════════════════════════════════ 33 + Editor Layout - Three-panel structure 34 + ═══════════════════════════════════════════════════════════════════ */ 52 35 53 - .filter-btn { 36 + .editor-layout { 54 37 display: flex; 55 - align-items: center; 56 - gap: 4px; 57 - padding: 6px 10px; 58 - background: transparent; 59 - border: none; 60 - border-radius: 6px; 61 - cursor: pointer; 62 - color: var(--base04); 63 - transition: all 0.15s; 64 - } 65 - 66 - .filter-btn svg { 67 - flex-shrink: 0; 68 - opacity: 0.7; 69 - } 70 - 71 - .filter-count { 72 - font-size: 11px; 73 - font-weight: 400; 74 - min-width: 14px; 75 - text-align: center; 76 - opacity: 0.8; 77 - } 78 - 79 - .filter-btn.active { 80 - color: var(--base0D); 81 - background: var(--base01); 82 - } 83 - 84 - .filter-btn.active svg { 85 - opacity: 1; 86 - } 87 - 88 - .filter-btn.active .filter-count { 89 - opacity: 1; 90 - } 91 - 92 - .filter-btn:not(.active):hover { 93 - color: var(--base05); 94 - background: var(--base01); 95 - } 96 - 97 - /* Search */ 98 - .search-container { 99 - padding: 12px 24px 0; 100 - } 101 - 102 - .search-input { 103 38 width: 100%; 104 - padding: 10px 14px; 105 - font-size: 14px; 106 - font-family: var(--theme-font-sans); 107 - background: var(--base01); 108 - border: 1px solid var(--base02); 109 - border-radius: 8px; 110 - color: var(--base05); 111 - outline: none; 112 - transition: all 0.15s ease; 113 - } 114 - 115 - .search-input:focus { 116 - border-color: var(--base0D); 39 + height: 100%; 117 40 background: var(--base00); 118 41 } 119 42 120 - .search-input::placeholder { 121 - color: var(--base03); 43 + .editor-layout.focus-mode { 44 + position: fixed; 45 + top: 0; 46 + left: 0; 47 + right: 0; 48 + bottom: 0; 49 + z-index: 99999; 122 50 } 123 51 124 - /* Add section */ 125 - .add-section { 126 - padding: 12px 24px; 127 - border-bottom: 1px solid var(--base02); 128 - } 52 + /* ═══════════════════════════════════════════════════════════════════ 53 + Sidebar shared styles 54 + ═══════════════════════════════════════════════════════════════════ */ 129 55 130 - .add-input-row { 56 + .outline-sidebar, 57 + .preview-sidebar { 131 58 display: flex; 132 - gap: 8px; 133 - align-items: center; 134 - } 135 - 136 - .add-input { 137 - flex: 1; 138 - padding: 10px 14px; 139 - font-size: 14px; 140 - font-family: var(--theme-font-sans); 141 - background: var(--base01); 142 - border: 1px solid var(--base02); 143 - border-radius: 8px; 144 - color: var(--base05); 145 - outline: none; 146 - transition: all 0.15s ease; 147 - } 148 - 149 - .add-input:focus { 150 - border-color: var(--base0D); 59 + flex-direction: column; 151 60 background: var(--base00); 61 + font-family: var(--theme-font-mono, 'SF Mono', 'Fira Code', 'Consolas', monospace); 62 + font-size: 12px; 63 + overflow: hidden; 64 + transition: width 0.15s ease, min-width 0.15s ease; 152 65 } 153 66 154 - .add-input::placeholder { 155 - color: var(--base03); 67 + .outline-sidebar { 68 + width: 220px; 69 + min-width: 220px; 70 + border-right: 1px solid var(--base02); 156 71 } 157 72 158 - .add-type-badge { 159 - font-size: 11px; 160 - font-weight: 500; 161 - padding: 4px 8px; 162 - background: var(--base02); 163 - color: var(--base04); 164 - border-radius: 4px; 165 - text-transform: uppercase; 166 - white-space: nowrap; 73 + .preview-sidebar { 74 + width: 400px; 75 + min-width: 400px; 76 + border-left: 1px solid var(--base02); 77 + font-family: var(--theme-font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); 78 + font-size: 14px; 167 79 } 168 80 169 - .add-save-btn { 170 - padding: 10px 20px; 171 - background: var(--base0D); 172 - border: none; 173 - border-radius: 8px; 174 - font-size: 14px; 175 - font-weight: 500; 176 - color: var(--base00); 177 - cursor: pointer; 178 - transition: all 0.15s; 179 - white-space: nowrap; 81 + /* Collapsed state */ 82 + .outline-sidebar.collapsed, 83 + .preview-sidebar.collapsed { 84 + width: 32px; 85 + min-width: 32px; 180 86 } 181 87 182 - .add-save-btn:hover { 183 - filter: brightness(1.1); 88 + .outline-sidebar.collapsed .sidebar-content, 89 + .preview-sidebar.collapsed .sidebar-content, 90 + .outline-sidebar.collapsed .preview-content, 91 + .preview-sidebar.collapsed .preview-content { 92 + display: none; 184 93 } 185 94 186 - .add-save-btn:disabled { 187 - opacity: 0.5; 188 - cursor: default; 95 + .outline-sidebar.collapsed .sidebar-title, 96 + .preview-sidebar.collapsed .sidebar-title { 97 + display: none; 189 98 } 190 99 191 - /* Add tags section */ 192 - .add-tags-section { 193 - margin-top: 10px; 194 - padding-top: 10px; 195 - border-top: 1px solid var(--base02); 100 + .outline-sidebar.collapsed .sidebar-header, 101 + .preview-sidebar.collapsed .sidebar-header { 102 + justify-content: center; 103 + padding: 8px 4px; 196 104 } 197 105 198 - .add-selected-tags { 106 + /* Sidebar header */ 107 + .sidebar-header { 199 108 display: flex; 200 - flex-wrap: wrap; 201 - gap: 6px; 202 - min-height: 24px; 203 - margin-bottom: 8px; 204 - } 205 - 206 - .add-tag-input-row { 207 - margin-bottom: 8px; 208 - } 209 - 210 - .add-tag-input { 211 - width: 100%; 109 + justify-content: space-between; 110 + align-items: center; 212 111 padding: 8px 12px; 213 - font-size: 13px; 214 - font-family: var(--theme-font-sans); 215 112 background: var(--base01); 216 - border: 1px solid var(--base02); 217 - border-radius: 6px; 218 - color: var(--base05); 219 - outline: none; 220 - } 221 - 222 - .add-tag-input:focus { 223 - border-color: var(--base0D); 113 + border-bottom: 1px solid var(--base02); 114 + color: var(--base04); 115 + text-transform: uppercase; 116 + letter-spacing: 0.5px; 117 + font-size: 11px; 224 118 } 225 119 226 - .add-tag-input::placeholder { 227 - color: var(--base03); 228 - } 229 - 230 - .add-available-tags { 231 - display: flex; 232 - flex-wrap: wrap; 233 - gap: 6px; 120 + .sidebar-title { 121 + user-select: none; 234 122 } 235 123 236 - /* Tag pill (shared for add and edit) */ 237 - .tag-pill { 238 - display: flex; 239 - align-items: center; 240 - gap: 4px; 241 - padding: 4px 8px; 242 - background: var(--base0D); 243 - border-radius: 12px; 244 - font-size: 12px; 245 - color: var(--base00); 246 - } 247 - 248 - .tag-pill .remove-pill { 124 + .sidebar-toggle { 249 125 background: none; 250 126 border: none; 251 - color: var(--base00); 127 + color: var(--base03); 252 128 cursor: pointer; 253 - font-size: 14px; 254 - line-height: 1; 255 - padding: 0; 256 - opacity: 0.8; 257 - } 258 - 259 - .tag-pill .remove-pill:hover { 260 - opacity: 1; 129 + padding: 2px 6px; 130 + font-size: 10px; 131 + transition: color 0.15s; 261 132 } 262 133 263 - .available-tag-btn { 264 - padding: 4px 10px; 265 - background: var(--base01); 266 - border: 1px solid var(--base02); 267 - border-radius: 12px; 268 - font-size: 12px; 134 + .sidebar-toggle:hover { 269 135 color: var(--base05); 270 - cursor: pointer; 271 - transition: all 0.15s; 272 136 } 273 137 274 - .available-tag-btn:hover { 275 - background: var(--base02); 276 - border-color: var(--base03); 277 - } 278 - 279 - .available-tag-btn.already-added { 280 - opacity: 0.5; 281 - cursor: default; 282 - } 283 - 284 - /* Items container */ 285 - .items-container { 138 + /* Sidebar content */ 139 + .sidebar-content { 286 140 flex: 1; 287 141 overflow-y: auto; 288 - padding: 16px 24px; 289 - } 290 - 291 - /* Cards grid */ 292 - .cards { 293 - display: grid; 294 - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 295 - gap: 12px; 296 - } 297 - 298 - /* Card base */ 299 - .card { 300 - background: var(--base01); 301 - border-radius: 8px; 302 - padding: 14px; 303 - cursor: pointer; 304 - transition: all 0.15s ease; 305 - display: flex; 306 - flex-direction: column; 307 - gap: 10px; 308 - } 309 - 310 - .card:hover { 311 - background: var(--base02); 312 - transform: translateY(-1px); 313 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 142 + padding: 8px 0; 314 143 } 315 144 316 - .card.selected { 317 - background: var(--base02); 318 - outline: 2px solid var(--base0D); 319 - outline-offset: -2px; 320 - } 321 - 322 - .card.selected:hover { 323 - background: var(--base03); 324 - } 325 - 326 - /* Card header (icon + content) */ 327 - .card-header { 328 - display: flex; 329 - align-items: flex-start; 330 - gap: 10px; 331 - } 145 + /* ═══════════════════════════════════════════════════════════════════ 146 + Outline Sidebar 147 + ═══════════════════════════════════════════════════════════════════ */ 332 148 333 - .card-icon { 334 - width: 24px; 335 - height: 24px; 336 - border-radius: 4px; 337 - flex-shrink: 0; 338 - display: flex; 339 - align-items: center; 340 - justify-content: center; 341 - font-size: 16px; 342 - background: var(--base02); 149 + .outline-empty { 150 + padding: 12px; 151 + color: var(--base03); 152 + font-style: italic; 343 153 } 344 154 345 - .card-content { 346 - flex: 1; 347 - min-width: 0; 348 - } 349 - 350 - .card-title { 351 - font-size: 14px; 352 - font-weight: 500; 353 - color: var(--base05); 354 - white-space: nowrap; 355 - overflow: hidden; 356 - text-overflow: ellipsis; 357 - } 358 - 359 - .card-subtitle { 360 - font-size: 12px; 155 + .outline-item { 156 + padding: 4px 12px; 361 157 color: var(--base04); 158 + cursor: pointer; 362 159 white-space: nowrap; 363 160 overflow: hidden; 364 161 text-overflow: ellipsis; 365 - margin-top: 2px; 366 - } 367 - 368 - /* Card tags */ 369 - .card-tags { 162 + transition: background 0.1s, color 0.1s; 370 163 display: flex; 371 - flex-wrap: wrap; 372 - gap: 4px; 164 + align-items: center; 165 + gap: 8px; 373 166 } 374 167 375 - .card-tag { 376 - padding: 2px 8px; 377 - background: var(--base02); 378 - border-radius: 10px; 379 - font-size: 11px; 380 - color: var(--base04); 381 - cursor: pointer; 382 - transition: all 0.15s; 168 + .outline-item:hover { 169 + background: var(--base01); 170 + color: var(--base07); 383 171 } 384 172 385 - .card-tag:hover { 386 - background: var(--base03); 387 - color: var(--base05); 173 + .outline-indicator { 174 + display: inline-block; 175 + width: 6px; 176 + height: 6px; 177 + border-radius: 50%; 178 + flex-shrink: 0; 388 179 } 389 180 390 - .card-date { 391 - font-size: 11px; 392 - color: var(--base03); 393 - } 181 + /* ═══════════════════════════════════════════════════════════════════ 182 + Preview Sidebar 183 + ═══════════════════════════════════════════════════════════════════ */ 394 184 395 - /* Empty state */ 396 - .empty-state { 397 - grid-column: 1 / -1; 398 - text-align: center; 399 - padding: 48px 24px; 400 - color: var(--base03); 401 - font-size: 14px; 185 + .preview-content { 186 + flex: 1; 187 + overflow-y: auto; 188 + padding: 16px; 189 + color: var(--base05); 190 + line-height: 1.6; 402 191 } 403 192 404 - /* Modal overlay */ 405 - .modal-overlay { 406 - display: none; 407 - position: fixed; 408 - top: 0; 409 - left: 0; 410 - right: 0; 411 - bottom: 0; 412 - background: rgba(0, 0, 0, 0.5); 413 - align-items: center; 414 - justify-content: center; 415 - z-index: 1000; 193 + .preview-content h1, 194 + .preview-content h2, 195 + .preview-content h3, 196 + .preview-content h4, 197 + .preview-content h5, 198 + .preview-content h6 { 199 + color: var(--base07); 200 + margin: 1em 0 0.5em 0; 201 + line-height: 1.3; 416 202 } 417 203 418 - .modal-overlay.visible { 419 - display: flex; 204 + .preview-content h1 { 205 + font-size: 1.8em; 206 + border-bottom: 1px solid var(--base02); 207 + padding-bottom: 0.3em; 420 208 } 421 209 422 - .modal { 423 - background: var(--base00); 424 - border-radius: 12px; 425 - width: 90%; 426 - max-width: 560px; 427 - max-height: 85vh; 428 - display: flex; 429 - flex-direction: column; 430 - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); 210 + .preview-content h2 { 211 + font-size: 1.5em; 212 + border-bottom: 1px solid var(--base02); 213 + padding-bottom: 0.3em; 431 214 } 432 215 433 - .modal-small { 434 - max-width: 400px; 216 + .preview-content h3 { font-size: 1.3em; } 217 + .preview-content h4 { font-size: 1.1em; } 218 + .preview-content h5 { font-size: 1em; } 219 + .preview-content h6 { font-size: 0.9em; color: var(--base04); } 220 + 221 + .preview-content p { 222 + margin: 0.8em 0; 435 223 } 436 224 437 - .modal-header { 438 - display: flex; 439 - align-items: center; 440 - justify-content: space-between; 441 - padding: 16px 20px; 442 - border-bottom: 1px solid var(--base02); 225 + .preview-content code { 226 + background: var(--base01); 227 + padding: 2px 6px; 228 + border-radius: 3px; 229 + font-family: var(--theme-font-mono, 'SF Mono', 'Fira Code', monospace); 230 + font-size: 0.9em; 231 + color: var(--base0A); 443 232 } 444 233 445 - .modal-title { 446 - font-size: 16px; 447 - font-weight: 600; 448 - color: var(--base05); 234 + .preview-content pre { 235 + background: var(--base01); 236 + padding: 12px; 237 + border-radius: 6px; 238 + overflow-x: auto; 239 + margin: 1em 0; 449 240 } 450 241 451 - .modal-close { 242 + .preview-content pre code { 452 243 background: none; 453 - border: none; 454 - font-size: 24px; 455 - color: var(--base04); 456 - cursor: pointer; 457 244 padding: 0; 458 - line-height: 1; 245 + color: var(--base05); 459 246 } 460 247 461 - .modal-close:hover { 462 - color: var(--base05); 248 + .preview-content a { 249 + color: var(--base0D); 250 + text-decoration: none; 463 251 } 464 252 465 - .modal-body { 466 - padding: 20px; 467 - overflow-y: auto; 468 - flex: 1; 253 + .preview-content a:hover { 254 + text-decoration: underline; 469 255 } 470 256 471 - .modal-body p { 472 - color: var(--base04); 473 - font-size: 14px; 474 - line-height: 1.6; 257 + .preview-content ul, 258 + .preview-content ol { 259 + padding-left: 1.5em; 260 + margin: 0.5em 0; 475 261 } 476 262 477 - .modal-footer { 478 - display: flex; 479 - align-items: center; 480 - justify-content: space-between; 481 - padding: 12px 20px; 482 - border-top: 1px solid var(--base02); 263 + .preview-content li { 264 + margin: 0.3em 0; 483 265 } 484 266 485 - .modal-footer-right { 486 - display: flex; 487 - gap: 8px; 488 - margin-left: auto; 267 + .preview-content blockquote { 268 + border-left: 3px solid var(--base02); 269 + padding-left: 12px; 270 + margin: 1em 0; 271 + color: var(--base04); 272 + font-style: italic; 489 273 } 490 274 491 - .modal-save-btn { 492 - padding: 8px 20px; 493 - background: var(--base0D); 275 + .preview-content hr { 494 276 border: none; 495 - border-radius: 6px; 496 - font-size: 13px; 497 - font-weight: 500; 498 - color: var(--base00); 499 - cursor: pointer; 500 - transition: all 0.15s; 277 + border-top: 1px solid var(--base02); 278 + margin: 1.5em 0; 501 279 } 502 280 503 - .modal-save-btn:hover { 504 - filter: brightness(1.1); 281 + .preview-content img { 282 + max-width: 100%; 283 + border-radius: 4px; 505 284 } 506 285 507 - .modal-cancel-btn, 508 - .delete-cancel-btn { 509 - padding: 8px 16px; 510 - background: var(--base02); 511 - border: none; 512 - border-radius: 6px; 513 - font-size: 13px; 514 - color: var(--base05); 515 - cursor: pointer; 516 - transition: all 0.15s; 517 - } 286 + /* ═══════════════════════════════════════════════════════════════════ 287 + Resizers 288 + ═══════════════════════════════════════════════════════════════════ */ 518 289 519 - .modal-cancel-btn:hover, 520 - .delete-cancel-btn:hover { 521 - background: var(--base03); 290 + .resizer { 291 + width: 8px; 292 + margin: 0 -4px; 293 + cursor: col-resize; 294 + flex-shrink: 0; 295 + position: relative; 296 + z-index: 20; 522 297 } 523 298 524 - .modal-delete-btn { 525 - padding: 8px 16px; 299 + .resizer-indicator { 300 + position: absolute; 301 + top: 0; 302 + bottom: 0; 303 + left: 50%; 304 + width: 2px; 305 + margin-left: -1px; 526 306 background: transparent; 527 - border: 1px solid var(--base08); 528 - border-radius: 6px; 529 - font-size: 13px; 530 - color: var(--base08); 531 - cursor: pointer; 532 - transition: all 0.15s; 307 + transition: background 0.15s; 533 308 } 534 309 535 - .modal-delete-btn:hover { 536 - background: var(--base08); 537 - color: var(--base00); 310 + .resizer-indicator.visible { 311 + background: var(--base0A); 538 312 } 539 313 540 - .delete-confirm-btn { 541 - padding: 8px 20px; 542 - background: var(--base08); 543 - border: none; 544 - border-radius: 6px; 545 - font-size: 13px; 546 - font-weight: 500; 547 - color: var(--base00); 548 - cursor: pointer; 549 - transition: all 0.15s; 314 + .resizer.dragging .resizer-indicator { 315 + background: var(--base0A); 550 316 } 551 317 552 - .delete-confirm-btn:hover { 553 - filter: brightness(1.1); 554 - } 318 + /* ═══════════════════════════════════════════════════════════════════ 319 + Editor Container (center panel) 320 + ═══════════════════════════════════════════════════════════════════ */ 555 321 556 - /* Edit content section */ 557 - .edit-content-section { 558 - margin-bottom: 16px; 322 + .editor-container { 323 + flex: 1; 324 + display: flex; 325 + flex-direction: column; 326 + min-width: 300px; 327 + overflow: hidden; 559 328 } 560 329 561 - .edit-url-input { 562 - width: 100%; 563 - padding: 10px 14px; 564 - font-size: 14px; 565 - font-family: var(--theme-font-sans); 566 - background: var(--base01); 567 - border: 1px solid var(--base02); 568 - border-radius: 8px; 569 - color: var(--base05); 570 - outline: none; 571 - margin-top: 8px; 330 + .cm-container { 331 + flex: 1; 332 + overflow: hidden; 572 333 } 573 334 574 - .edit-url-input:focus { 575 - border-color: var(--base0D); 335 + .cm-container .cm-editor { 336 + height: 100%; 576 337 } 577 338 578 - .edit-text-input { 579 - width: 100%; 580 - min-height: 120px; 581 - padding: 10px 14px; 582 - font-size: 14px; 583 - font-family: var(--theme-font-sans); 584 - background: var(--base01); 585 - border: 1px solid var(--base02); 586 - border-radius: 8px; 587 - color: var(--base05); 588 - outline: none; 589 - resize: vertical; 590 - margin-top: 8px; 339 + .cm-container .cm-scroller { 340 + overflow: auto; 591 341 } 592 342 593 - .edit-text-input:focus { 594 - border-color: var(--base0D); 595 - } 343 + /* ═══════════════════════════════════════════════════════════════════ 344 + Editor Toolbar 345 + ═══════════════════════════════════════════════════════════════════ */ 596 346 597 - .edit-image-preview { 598 - margin-top: 8px; 599 - padding: 12px; 347 + .editor-toolbar { 348 + display: flex; 349 + justify-content: space-between; 350 + align-items: center; 351 + padding: 6px 12px; 600 352 background: var(--base01); 601 - border-radius: 8px; 602 - text-align: center; 603 - } 604 - 605 - .edit-image-preview img { 606 - max-width: 100%; 607 - max-height: 200px; 608 - border-radius: 4px; 353 + border-top: 1px solid var(--base02); 609 354 } 610 355 611 - .edit-tagset-notice { 612 - margin-top: 8px; 613 - padding: 12px; 614 - background: var(--base01); 615 - border-radius: 8px; 616 - color: var(--base04); 617 - font-size: 13px; 618 - } 619 - 620 - /* Edit sections */ 621 - .edit-section { 622 - margin-bottom: 16px; 623 - } 624 - 625 - .edit-label { 626 - display: block; 627 - font-size: 12px; 628 - font-weight: 600; 629 - color: var(--base04); 630 - text-transform: uppercase; 631 - letter-spacing: 0.5px; 632 - margin-bottom: 8px; 633 - } 634 - 635 - /* Current tags */ 636 - .current-tags { 637 - display: flex; 638 - flex-wrap: wrap; 639 - gap: 6px; 640 - min-height: 32px; 641 - } 642 - 643 - .current-tag { 356 + .vim-toggle { 644 357 display: flex; 645 358 align-items: center; 646 - gap: 4px; 647 - padding: 4px 8px; 648 - background: var(--base0D); 649 - border-radius: 12px; 359 + gap: 6px; 360 + cursor: pointer; 650 361 font-size: 12px; 651 - color: var(--base00); 362 + color: var(--base04); 363 + user-select: none; 652 364 } 653 365 654 - .current-tag .remove-tag { 655 - background: none; 656 - border: none; 657 - color: var(--base00); 658 - cursor: pointer; 659 - font-size: 14px; 660 - line-height: 1; 661 - padding: 0; 662 - opacity: 0.8; 366 + .vim-toggle:hover { 367 + color: var(--base05); 663 368 } 664 369 665 - .current-tag .remove-tag:hover { 666 - opacity: 1; 370 + .vim-toggle input { 371 + width: 14px; 372 + height: 14px; 373 + margin: 0; 374 + accent-color: var(--base0D); 375 + cursor: pointer; 667 376 } 668 377 669 - .no-tags { 670 - color: var(--base03); 671 - font-size: 13px; 672 - font-style: italic; 378 + .vim-toggle span { 379 + font-family: var(--theme-font-mono, monospace); 673 380 } 674 381 675 - /* New tag row */ 676 - .new-tag-row { 382 + .sidebar-toggles { 677 383 display: flex; 678 - gap: 8px; 384 + gap: 6px; 679 385 } 680 386 681 - .new-tag-input { 682 - flex: 1; 683 - padding: 8px 12px; 684 - font-size: 13px; 685 - font-family: var(--theme-font-sans); 686 - background: var(--base01); 687 - border: 1px solid var(--base02); 688 - border-radius: 6px; 689 - color: var(--base05); 690 - outline: none; 691 - } 692 - 693 - .new-tag-input:focus { 694 - border-color: var(--base0D); 695 - } 696 - 697 - .new-tag-input::placeholder { 698 - color: var(--base03); 699 - } 700 - 701 - .add-tag-btn { 702 - padding: 8px 16px; 703 - background: var(--base0D); 387 + .toolbar-btn { 388 + padding: 4px 10px; 389 + background: var(--base02); 704 390 border: none; 705 - border-radius: 6px; 706 - font-size: 13px; 391 + border-radius: 4px; 392 + font-size: 11px; 707 393 font-weight: 500; 708 - color: var(--base00); 394 + color: var(--base04); 709 395 cursor: pointer; 710 396 transition: all 0.15s; 711 397 } 712 398 713 - .add-tag-btn:hover { 714 - filter: brightness(1.1); 715 - } 716 - 717 - /* Available tags */ 718 - .available-tags { 719 - display: flex; 720 - flex-wrap: wrap; 721 - gap: 6px; 722 - } 723 - 724 - .available-tag { 725 - padding: 6px 10px; 726 - background: var(--base01); 727 - border: 1px solid var(--base02); 728 - border-radius: 12px; 729 - font-size: 12px; 399 + .toolbar-btn:hover { 400 + background: var(--base03); 730 401 color: var(--base05); 731 - cursor: pointer; 732 - transition: all 0.15s; 733 - } 734 - 735 - .available-tag:hover { 736 - background: var(--base02); 737 - border-color: var(--base03); 738 402 } 739 403 740 - .available-tag.already-added { 741 - opacity: 0.5; 742 - cursor: default; 404 + .toolbar-btn.active { 405 + background: var(--base0D); 406 + color: var(--base00); 743 407 }
+30 -138
extensions/editor/home.html
··· 5 5 <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 6 <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 7 <title>Editor</title> 8 + <script type="importmap"> 9 + { 10 + "imports": { 11 + "@codemirror/state": "peek://node_modules/@codemirror/state/dist/index.js", 12 + "@codemirror/view": "peek://node_modules/@codemirror/view/dist/index.js", 13 + "@codemirror/commands": "peek://node_modules/@codemirror/commands/dist/index.js", 14 + "@codemirror/language": "peek://node_modules/@codemirror/language/dist/index.js", 15 + "@codemirror/autocomplete": "peek://node_modules/@codemirror/autocomplete/dist/index.js", 16 + "@codemirror/lang-markdown": "peek://node_modules/@codemirror/lang-markdown/dist/index.js", 17 + "@codemirror/lang-html": "peek://node_modules/@codemirror/lang-html/dist/index.js", 18 + "@codemirror/lang-css": "peek://node_modules/@codemirror/lang-css/dist/index.js", 19 + "@codemirror/lang-javascript": "peek://node_modules/@codemirror/lang-javascript/dist/index.js", 20 + "@codemirror/theme-one-dark": "peek://node_modules/@codemirror/theme-one-dark/dist/index.js", 21 + "@codemirror/search": "peek://node_modules/@codemirror/search/dist/index.js", 22 + "@replit/codemirror-vim": "peek://node_modules/@replit/codemirror-vim/dist/index.js", 23 + "@lezer/common": "peek://node_modules/@lezer/common/dist/index.js", 24 + "@lezer/highlight": "peek://node_modules/@lezer/highlight/dist/index.js", 25 + "@lezer/lr": "peek://node_modules/@lezer/lr/dist/index.js", 26 + "@lezer/markdown": "peek://node_modules/@lezer/markdown/dist/index.js", 27 + "@lezer/html": "peek://node_modules/@lezer/html/dist/index.js", 28 + "@lezer/css": "peek://node_modules/@lezer/css/dist/index.js", 29 + "@lezer/javascript": "peek://node_modules/@lezer/javascript/dist/index.js", 30 + "crelt": "peek://node_modules/crelt/index.js", 31 + "style-mod": "peek://node_modules/style-mod/src/style-mod.js", 32 + "w3c-keyname": "peek://node_modules/w3c-keyname/index.js", 33 + "@marijn/find-cluster-break": "peek://node_modules/@marijn/find-cluster-break/src/index.js" 34 + } 35 + } 36 + </script> 8 37 <link rel="stylesheet" type="text/css" href="home.css"> 9 38 </head> 10 39 <body> 11 - <header class="header"> 12 - <div class="header-left"> 13 - <h1 class="header-title">Editor</h1> 14 - </div> 15 - <div class="filter-icons"> 16 - <button class="filter-btn active" data-filter="all" title="All"> 17 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 18 - <rect x="3" y="3" width="7" height="7"></rect> 19 - <rect x="14" y="3" width="7" height="7"></rect> 20 - <rect x="3" y="14" width="7" height="7"></rect> 21 - <rect x="14" y="14" width="7" height="7"></rect> 22 - </svg> 23 - <span class="filter-count" data-count="all">0</span> 24 - </button> 25 - <button class="filter-btn" data-filter="page" title="Pages"> 26 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 27 - <circle cx="12" cy="12" r="10"></circle> 28 - <line x1="2" y1="12" x2="22" y2="12"></line> 29 - <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path> 30 - </svg> 31 - <span class="filter-count" data-count="page">0</span> 32 - </button> 33 - <button class="filter-btn" data-filter="text" title="Notes"> 34 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 35 - <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path> 36 - <polyline points="14 2 14 8 20 8"></polyline> 37 - <line x1="16" y1="13" x2="8" y2="13"></line> 38 - <line x1="16" y1="17" x2="8" y2="17"></line> 39 - </svg> 40 - <span class="filter-count" data-count="text">0</span> 41 - </button> 42 - <button class="filter-btn" data-filter="tagset" title="Tag Sets"> 43 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 44 - <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path> 45 - <line x1="7" y1="7" x2="7.01" y2="7"></line> 46 - </svg> 47 - <span class="filter-count" data-count="tagset">0</span> 48 - </button> 49 - <button class="filter-btn" data-filter="image" title="Images"> 50 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 51 - <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> 52 - <circle cx="8.5" cy="8.5" r="1.5"></circle> 53 - <polyline points="21 15 16 10 5 21"></polyline> 54 - </svg> 55 - <span class="filter-count" data-count="image">0</span> 56 - </button> 57 - </div> 58 - </header> 59 - 60 - <div class="search-container"> 61 - <input type="text" class="search-input" placeholder="Search items and tags..."> 62 - </div> 63 - 64 - <!-- Add item section --> 65 - <div class="add-section"> 66 - <div class="add-input-row"> 67 - <input type="text" class="add-input" placeholder="Add URL, note, or tagset..."> 68 - <span class="add-type-badge">text</span> 69 - <button class="add-save-btn">Save</button> 70 - </div> 71 - <div class="add-tags-section" style="display:none;"> 72 - <div class="add-selected-tags"></div> 73 - <div class="add-tag-input-row"> 74 - <input type="text" class="add-tag-input" placeholder="Add tags (comma-separated)..."> 75 - </div> 76 - <div class="add-available-tags"></div> 77 - </div> 78 - </div> 79 - 80 - <main class="items-container"> 81 - <div class="cards"></div> 82 - </main> 83 - 84 - <!-- Edit Modal Overlay --> 85 - <div class="modal-overlay" id="editModal"> 86 - <div class="modal"> 87 - <div class="modal-header"> 88 - <h2 class="modal-title">Edit Item</h2> 89 - <button class="modal-close">&times;</button> 90 - </div> 91 - <div class="modal-body"> 92 - <!-- Content editor (type-specific) --> 93 - <div class="edit-content-section"> 94 - <label class="edit-label">Content</label> 95 - <input type="text" class="edit-url-input" placeholder="URL" style="display:none;"> 96 - <textarea class="edit-text-input" placeholder="Note text..." style="display:none;"></textarea> 97 - <div class="edit-image-preview" style="display:none;"></div> 98 - <div class="edit-tagset-notice" style="display:none;">Tag set &mdash; edit tags below.</div> 99 - </div> 100 - 101 - <!-- Tag editing --> 102 - <div class="edit-section"> 103 - <label class="edit-label">Current Tags</label> 104 - <div class="current-tags"></div> 105 - </div> 106 - 107 - <div class="edit-section"> 108 - <label class="edit-label">Add Tag</label> 109 - <div class="new-tag-row"> 110 - <input type="text" class="new-tag-input" placeholder="Enter new tag..."> 111 - <button class="add-tag-btn">Add</button> 112 - </div> 113 - </div> 114 - 115 - <div class="edit-section"> 116 - <label class="edit-label">Available Tags</label> 117 - <div class="available-tags"></div> 118 - </div> 119 - </div> 120 - <div class="modal-footer"> 121 - <button class="modal-delete-btn">Delete</button> 122 - <div class="modal-footer-right"> 123 - <button class="modal-cancel-btn">Cancel</button> 124 - <button class="modal-save-btn">Save</button> 125 - </div> 126 - </div> 127 - </div> 128 - </div> 129 - 130 - <!-- Delete confirmation --> 131 - <div class="modal-overlay" id="deleteConfirm"> 132 - <div class="modal modal-small"> 133 - <div class="modal-header"> 134 - <h2 class="modal-title">Delete Item</h2> 135 - <button class="modal-close delete-cancel-btn">&times;</button> 136 - </div> 137 - <div class="modal-body"> 138 - <p>Are you sure you want to delete this item? This cannot be undone.</p> 139 - </div> 140 - <div class="modal-footer"> 141 - <div class="modal-footer-right"> 142 - <button class="delete-cancel-btn">Cancel</button> 143 - <button class="delete-confirm-btn">Delete</button> 144 - </div> 145 - </div> 146 - </div> 147 - </div> 148 - 40 + <div id="editor-root"></div> 149 41 <script type="module" src="home.js"></script> 150 42 </body> 151 43 </html>
+125 -807
extensions/editor/home.js
··· 1 1 /** 2 - * Editor Home - Full item CRUD with tag editing 2 + * Editor Home - Pure markdown editor with three-panel layout. 3 3 * 4 4 * Features: 5 - * - View all saved items with type filtering and search 6 - * - Add new items (URLs, text notes, tagsets) with smart type detection 7 - * - Edit items with type-specific UI 8 - * - Tag editing (add/remove) with frecency-sorted available tags 9 - * - Delete items with confirmation 10 - * - Publishes editor:changed after mutations 5 + * - Outline sidebar (TOC from headers) 6 + * - CodeMirror editor with vim mode support 7 + * - Preview sidebar (live markdown rendering) 8 + * - Resizable panels 9 + * - Focus mode 11 10 */ 11 + 12 + import { EditorLayout } from './editor-layout.js'; 12 13 13 14 const api = window.app; 14 15 const debug = api?.debug; 15 16 16 - // State 17 - let state = { 18 - activeFilter: 'all', // 'all' | 'page' | 'text' | 'tagset' | 'image' 19 - items: [], // All items 20 - tags: [], // All tags sorted by frecency 21 - itemTags: new Map(), // Map of itemId -> [tags] 22 - selectedIndex: 0, 23 - searchQuery: '', 24 - editingItem: null, // Item being edited in modal 25 - editOriginal: null, // Original content for dirty detection 26 - // Add mode 27 - addTags: [], // Selected tags for new item 28 - }; 17 + // Editor layout instance 18 + let editorLayout = null; 29 19 30 - // DOM elements 31 - let searchInput, cardsContainer, modalOverlay, deleteConfirmOverlay; 32 - let addInput, addTypeBadge, addSaveBtn, addTagsSection; 33 - let addSelectedTagsEl, addTagInput, addAvailableTagsEl; 20 + // Settings store for vim mode preference 21 + let settingsStore = null; 22 + const SETTINGS_KEY = 'vimMode'; 34 23 35 24 /** 36 - * Detect item type from input text (same logic as mobile) 25 + * Sample markdown content for new documents 37 26 */ 38 - const getAddInputType = (text) => { 39 - const trimmed = (text || '').trim(); 40 - if (!trimmed) return null; 41 - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return 'url'; 42 - return 'text'; 43 - }; 27 + const SAMPLE_CONTENT = `# Welcome to the Editor 44 28 45 - /** 46 - * Initialize the UI 47 - */ 48 - const init = async () => { 49 - debug && console.log('[editor] Home init'); 29 + This is a **markdown editor** with live preview and outline navigation. 50 30 51 - // Cache DOM elements 52 - searchInput = document.querySelector('.search-input'); 53 - cardsContainer = document.querySelector('.cards'); 54 - modalOverlay = document.getElementById('editModal'); 55 - deleteConfirmOverlay = document.getElementById('deleteConfirm'); 56 - addInput = document.querySelector('.add-input'); 57 - addTypeBadge = document.querySelector('.add-type-badge'); 58 - addSaveBtn = document.querySelector('.add-save-btn'); 59 - addTagsSection = document.querySelector('.add-tags-section'); 60 - addSelectedTagsEl = document.querySelector('.add-selected-tags'); 61 - addTagInput = document.querySelector('.add-tag-input'); 62 - addAvailableTagsEl = document.querySelector('.add-available-tags'); 31 + ## Features 63 32 64 - // Check URL params for deep-link behavior 65 - const params = new URLSearchParams(window.location.search); 66 - const openItemId = params.get('itemId'); 67 - const mode = params.get('mode'); 33 + - **Outline sidebar** - Click headers to jump to them 34 + - **Live preview** - See rendered markdown as you type 35 + - **Vim mode** - Toggle vim keybindings in the toolbar 36 + - **Focus mode** - Distraction-free editing 68 37 69 - // Load data 70 - await loadData(); 38 + ## Getting Started 71 39 72 - // Set up event listeners 73 - setupEventListeners(); 40 + Start typing to edit this document. Use the toolbar buttons to toggle sidebars. 74 41 75 - // Initial render 76 - render(); 42 + ### Keyboard Shortcuts 77 43 78 - // Deep-link: open specific item for editing 79 - if (openItemId) { 80 - const item = state.items.find(i => i.id === openItemId || String(i.id) === openItemId); 81 - if (item) { 82 - openEditModal(item); 83 - } 84 - } 44 + - \`Cmd+Shift+O\` - Toggle outline sidebar 45 + - \`Cmd+Shift+P\` - Toggle preview sidebar 46 + - \`Escape\` - Exit focus mode 85 47 86 - // Deep-link: open in add mode 87 - if (mode === 'add') { 88 - const addContent = params.get('addContent') || params.get('addUrl') || ''; 89 - if (addContent) { 90 - addInput.value = addContent; 91 - updateAddTypeBadge(); 92 - } 93 - showAddTags(); 94 - addInput.focus(); 95 - } 96 - }; 48 + ## Code Example 97 49 98 - /** 99 - * Load all data from datastore 100 - */ 101 - const loadData = async () => { 102 - // Load all items 103 - const itemsResult = await api.datastore.queryItems({}); 104 - if (itemsResult.success) { 105 - state.items = itemsResult.data; 106 - } else { 107 - console.error('[editor] Failed to load items:', itemsResult.error); 108 - state.items = []; 109 - } 50 + \`\`\`javascript 51 + function greet(name) { 52 + return \`Hello, \${name}!\`; 53 + } 54 + \`\`\` 110 55 111 - // Load tags for each item 112 - for (const item of state.items) { 113 - const tagsResult = await api.datastore.getItemTags(item.id); 114 - if (tagsResult.success) { 115 - state.itemTags.set(item.id, tagsResult.data); 116 - } 117 - } 118 - 119 - // Load all tags by frecency 120 - const tagsResult = await api.datastore.getTagsByFrecency(); 121 - if (tagsResult.success) { 122 - state.tags = tagsResult.data; 123 - } else { 124 - state.tags = []; 125 - } 126 - 127 - updateFilterCounts(); 128 - }; 129 - 130 - /** 131 - * Set up all event listeners 132 - */ 133 - const setupEventListeners = () => { 134 - // Search 135 - searchInput.addEventListener('input', (e) => { 136 - state.searchQuery = e.target.value; 137 - render(); 138 - }); 139 - 140 - // Filter buttons 141 - document.querySelectorAll('.filter-btn').forEach(btn => { 142 - btn.addEventListener('click', () => { 143 - const filter = btn.dataset.filter; 144 - if (state.activeFilter === filter && filter !== 'all') { 145 - state.activeFilter = 'all'; 146 - } else { 147 - state.activeFilter = filter; 148 - } 149 - state.selectedIndex = 0; 150 - render(); 151 - }); 152 - }); 153 - 154 - // Add input — type detection 155 - addInput.addEventListener('input', updateAddTypeBadge); 156 - addInput.addEventListener('focus', showAddTags); 157 - addInput.addEventListener('keydown', (e) => { 158 - if (e.key === 'Enter') { 159 - e.preventDefault(); 160 - handleAddSave(); 161 - } 162 - }); 163 - 164 - // Add save button 165 - addSaveBtn.addEventListener('click', handleAddSave); 56 + ## Lists 166 57 167 - // Add tag input 168 - addTagInput.addEventListener('keydown', (e) => { 169 - if (e.key === 'Enter') { 170 - e.preventDefault(); 171 - handleAddTagInput(); 172 - } 173 - }); 58 + - Item one 59 + - Item two 60 + - Item three 174 61 175 - // Edit modal close 176 - document.querySelector('#editModal .modal-close').addEventListener('click', handleEditCancel); 177 - modalOverlay.addEventListener('click', (e) => { 178 - if (e.target === modalOverlay) handleEditCancel(); 179 - }); 62 + 1. First 63 + 2. Second 64 + 3. Third 180 65 181 - // Edit modal buttons 182 - document.querySelector('.modal-save-btn').addEventListener('click', handleEditSave); 183 - document.querySelector('.modal-cancel-btn').addEventListener('click', handleEditCancel); 184 - document.querySelector('.modal-delete-btn').addEventListener('click', handleDeleteRequest); 66 + ## Links and Images 185 67 186 - // Edit modal new tag 187 - const newTagInput = document.querySelector('.new-tag-input'); 188 - const addTagBtn = document.querySelector('.add-tag-btn'); 189 - addTagBtn.addEventListener('click', () => addNewTagToItem(newTagInput.value)); 190 - newTagInput.addEventListener('keydown', (e) => { 191 - if (e.key === 'Enter') { 192 - addNewTagToItem(newTagInput.value); 193 - } 194 - }); 68 + [Visit GitHub](https://github.com) 195 69 196 - // Delete confirm modal 197 - deleteConfirmOverlay.querySelectorAll('.delete-cancel-btn').forEach(btn => { 198 - btn.addEventListener('click', closeDeleteConfirm); 199 - }); 200 - deleteConfirmOverlay.addEventListener('click', (e) => { 201 - if (e.target === deleteConfirmOverlay) closeDeleteConfirm(); 202 - }); 203 - document.querySelector('.delete-confirm-btn').addEventListener('click', handleDeleteConfirm); 70 + > This is a blockquote. 71 + > It can span multiple lines. 204 72 205 - // Keyboard navigation 206 - document.addEventListener('keydown', handleKeydown); 73 + --- 207 74 208 - // Escape handling 209 - if (api.escape) { 210 - api.escape.onEscape(() => { 211 - if (deleteConfirmOverlay.classList.contains('visible')) { 212 - closeDeleteConfirm(); 213 - return { handled: true }; 214 - } 215 - if (modalOverlay.classList.contains('visible')) { 216 - handleEditCancel(); 217 - return { handled: true }; 218 - } 219 - if (state.searchQuery) { 220 - state.searchQuery = ''; 221 - searchInput.value = ''; 222 - render(); 223 - return { handled: true }; 224 - } 225 - if (state.activeFilter !== 'all') { 226 - state.activeFilter = 'all'; 227 - state.selectedIndex = 0; 228 - render(); 229 - return { handled: true }; 230 - } 231 - return { handled: false }; 232 - }); 233 - } 234 - }; 75 + *Happy writing!* 76 + `; 235 77 236 78 /** 237 - * Handle keyboard navigation 79 + * Initialize the editor 238 80 */ 239 - const handleKeydown = (e) => { 240 - if (modalOverlay.classList.contains('visible') || deleteConfirmOverlay.classList.contains('visible')) { 241 - if (e.key === 'Escape') { 242 - if (deleteConfirmOverlay.classList.contains('visible')) { 243 - closeDeleteConfirm(); 244 - } else { 245 - handleEditCancel(); 246 - } 247 - } 248 - return; 249 - } 81 + const init = async () => { 82 + debug && console.log('[editor] Home init'); 250 83 251 - const isInputFocused = ['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName); 252 - 253 - if ((e.key === '/' || (e.key === 'f' && (e.metaKey || e.ctrlKey))) && !isInputFocused) { 254 - e.preventDefault(); 255 - searchInput.focus(); 84 + const rootEl = document.getElementById('editor-root'); 85 + if (!rootEl) { 86 + console.error('[editor] Root element not found'); 256 87 return; 257 88 } 258 89 259 - if (isInputFocused && !['ArrowUp', 'ArrowDown', 'Enter', 'Escape'].includes(e.key)) { 260 - return; 261 - } 262 - 263 - const cards = getCards(); 264 - if (cards.length === 0) return; 265 - const cols = getGridColumns(cards); 266 - 267 - switch (e.key) { 268 - case 'j': 269 - case 'ArrowDown': 270 - e.preventDefault(); 271 - if (state.selectedIndex + cols < cards.length) { 272 - state.selectedIndex += cols; 273 - updateSelection(); 274 - } 275 - break; 276 - case 'k': 277 - case 'ArrowUp': 278 - e.preventDefault(); 279 - if (state.selectedIndex - cols >= 0) { 280 - state.selectedIndex -= cols; 281 - updateSelection(); 282 - } 283 - break; 284 - case 'h': 285 - case 'ArrowLeft': 286 - if (isInputFocused) return; 287 - e.preventDefault(); 288 - if (state.selectedIndex > 0) { 289 - state.selectedIndex--; 290 - updateSelection(); 291 - } 292 - break; 293 - case 'l': 294 - case 'ArrowRight': 295 - if (isInputFocused) return; 296 - e.preventDefault(); 297 - if (state.selectedIndex < cards.length - 1) { 298 - state.selectedIndex++; 299 - updateSelection(); 300 - } 301 - break; 302 - case 'Enter': 303 - if (!isInputFocused) { 304 - e.preventDefault(); 305 - activateSelected(); 306 - } 307 - break; 308 - case 'Escape': 309 - if (isInputFocused) { 310 - document.activeElement.blur(); 311 - } 312 - break; 313 - } 314 - }; 315 - 316 - const getCards = () => Array.from(document.querySelectorAll('.cards .card')); 317 - 318 - const getGridColumns = (cards) => { 319 - if (cards.length < 2) return 1; 320 - const firstTop = cards[0].getBoundingClientRect().top; 321 - for (let i = 1; i < cards.length; i++) { 322 - if (cards[i].getBoundingClientRect().top !== firstTop) return i; 323 - } 324 - return cards.length; 325 - }; 326 - 327 - const updateSelection = () => { 328 - const cards = getCards(); 329 - cards.forEach((card, i) => { 330 - card.classList.toggle('selected', i === state.selectedIndex); 331 - }); 332 - const selected = cards[state.selectedIndex]; 333 - if (selected) { 334 - selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 335 - } 336 - }; 337 - 338 - const activateSelected = () => { 339 - const cards = getCards(); 340 - const selected = cards[state.selectedIndex]; 341 - if (selected) selected.click(); 342 - }; 343 - 344 - // ───────────────────────── Add item ───────────────────────── 345 - 346 - const updateAddTypeBadge = () => { 347 - const type = getAddInputType(addInput.value); 348 - if (!type) { 349 - addTypeBadge.textContent = ''; 350 - addSaveBtn.disabled = true; 351 - } else { 352 - addTypeBadge.textContent = type === 'url' ? 'URL' : 'TEXT'; 353 - addSaveBtn.disabled = false; 354 - } 355 - }; 356 - 357 - const showAddTags = () => { 358 - addTagsSection.style.display = ''; 359 - renderAddAvailableTags(); 360 - }; 361 - 362 - const renderAddSelectedTags = () => { 363 - addSelectedTagsEl.innerHTML = ''; 364 - state.addTags.forEach(tag => { 365 - const pill = document.createElement('span'); 366 - pill.className = 'tag-pill'; 367 - pill.innerHTML = `${escapeHtml(tag.name)} <button class="remove-pill">&times;</button>`; 368 - pill.querySelector('.remove-pill').addEventListener('click', () => { 369 - state.addTags = state.addTags.filter(t => t.id !== tag.id); 370 - renderAddSelectedTags(); 371 - renderAddAvailableTags(); 372 - }); 373 - addSelectedTagsEl.appendChild(pill); 374 - }); 375 - }; 376 - 377 - const renderAddAvailableTags = () => { 378 - const filterText = addTagInput.value.toLowerCase().trim(); 379 - const selectedIds = new Set(state.addTags.map(t => t.id)); 380 - let tags = state.tags; 381 - if (filterText) { 382 - tags = tags.filter(t => t.name.toLowerCase().includes(filterText)); 383 - } 384 - 385 - addAvailableTagsEl.innerHTML = ''; 386 - tags.forEach(tag => { 387 - const btn = document.createElement('span'); 388 - btn.className = 'available-tag-btn'; 389 - btn.textContent = tag.name; 390 - if (selectedIds.has(tag.id)) { 391 - btn.classList.add('already-added'); 392 - } else { 393 - btn.addEventListener('click', () => { 394 - state.addTags.push(tag); 395 - renderAddSelectedTags(); 396 - renderAddAvailableTags(); 397 - }); 398 - } 399 - addAvailableTagsEl.appendChild(btn); 400 - }); 401 - }; 402 - 403 - const handleAddTagInput = async () => { 404 - const raw = addTagInput.value.trim(); 405 - if (!raw) return; 406 - 407 - // Support comma-separated tags 408 - const names = raw.split(',').map(s => s.trim()).filter(s => s.length > 0); 409 - for (const name of names) { 410 - const existing = state.addTags.find(t => t.name.toLowerCase() === name.toLowerCase()); 411 - if (existing) continue; 412 - 413 - const result = await api.datastore.getOrCreateTag(name); 414 - if (result.success) { 415 - const tag = result.data.tag; 416 - if (result.data.created) { 417 - state.tags.unshift(tag); 418 - } 419 - if (!state.addTags.some(t => t.id === tag.id)) { 420 - state.addTags.push(tag); 421 - } 90 + // Load vim mode preference 91 + let vimMode = false; 92 + if (api?.utils?.createDatastoreStore) { 93 + try { 94 + settingsStore = await api.utils.createDatastoreStore('editor', { vimMode: false }); 95 + vimMode = settingsStore.get(SETTINGS_KEY) || false; 96 + debug && console.log('[editor] Loaded vimMode setting:', vimMode); 97 + } catch (err) { 98 + debug && console.log('[editor] Failed to load settings:', err); 422 99 } 423 100 } 424 101 425 - addTagInput.value = ''; 426 - renderAddSelectedTags(); 427 - renderAddAvailableTags(); 428 - }; 429 - 430 - const handleAddSave = async () => { 431 - const text = addInput.value.trim(); 432 - const type = getAddInputType(text); 433 - if (!type) return; 434 - 435 - // Determine if this should be a tagset (only tags, no content) 436 - // If input is empty but tags are selected, treat as tagset 437 - let itemType = type; 438 - let opts = {}; 439 - 440 - if (itemType === 'url') { 441 - opts.url = text; 442 - opts.content = text; 443 - } else { 444 - opts.content = text; 445 - } 102 + // Check URL params for content or file path 103 + const params = new URLSearchParams(window.location.search); 104 + const contentParam = params.get('content'); 105 + const fileParam = params.get('file'); 446 106 447 - const result = await api.datastore.addItem(itemType, opts); 448 - if (!result.success) { 449 - console.error('[editor] Failed to add item:', result.error); 450 - return; 451 - } 107 + let initialContent = SAMPLE_CONTENT; 452 108 453 - const itemId = result.data.id; 454 - 455 - // Tag the new item 456 - for (const tag of state.addTags) { 457 - await api.datastore.tagItem(itemId, tag.id); 109 + // If content provided via URL param, use it 110 + if (contentParam) { 111 + initialContent = contentParam; 458 112 } 459 113 460 - // Reset add state 461 - addInput.value = ''; 462 - state.addTags = []; 463 - renderAddSelectedTags(); 464 - updateAddTypeBadge(); 465 - 466 - // Reload and re-render 467 - await loadData(); 468 - render(); 469 - 470 - // Publish change 471 - api.publish('editor:changed', { action: 'add', itemId }, api.scopes.GLOBAL); 472 - }; 473 - 474 - // ───────────────────────── Filter & render ───────────────────────── 475 - 476 - const updateFilterCounts = () => { 477 - const allCount = state.items.length; 478 - const pageCount = state.items.filter(i => !i.type || i.type === 'url' || i.uri).length; 479 - const textCount = state.items.filter(i => i.type === 'text').length; 480 - const tagsetCount = state.items.filter(i => i.type === 'tagset').length; 481 - const imageCount = state.items.filter(i => i.type === 'image').length; 482 - 483 - const setCount = (key, val) => { 484 - const el = document.querySelector(`[data-count="${key}"]`); 485 - if (el) el.textContent = val; 486 - }; 487 - setCount('all', allCount); 488 - setCount('page', pageCount); 489 - setCount('text', textCount); 490 - setCount('tagset', tagsetCount); 491 - setCount('image', imageCount); 492 - }; 493 - 494 - const getFilteredItems = () => { 495 - let items = [...state.items]; 496 - 497 - // Filter by type 498 - if (state.activeFilter !== 'all') { 499 - items = items.filter(item => { 500 - const itemType = item.type || (item.uri ? 'url' : null); 501 - if (state.activeFilter === 'page') { 502 - return itemType === 'url' || !itemType; 114 + // If file path provided, try to load it 115 + if (fileParam && api?.fs?.readFile) { 116 + try { 117 + const result = await api.fs.readFile(fileParam); 118 + if (result.success) { 119 + initialContent = result.data; 503 120 } 504 - return itemType === state.activeFilter; 505 - }); 506 - } 507 - 508 - // Filter by search query — match content + tag names 509 - if (state.searchQuery) { 510 - const terms = state.searchQuery.toLowerCase().split(/[,\s]+/).map(t => t.trim()).filter(t => t); 511 - if (terms.length > 0) { 512 - items = items.filter(item => { 513 - const content = (item.content || '').toLowerCase(); 514 - const tags = state.itemTags.get(item.id) || []; 515 - const tagNames = tags.map(t => t.name.toLowerCase()); 516 - return terms.every(term => 517 - content.includes(term) || tagNames.some(n => n.includes(term)) 518 - ); 519 - }); 121 + } catch (err) { 122 + debug && console.log('[editor] Failed to load file:', err); 520 123 } 521 124 } 522 125 523 - return items; 524 - }; 525 - 526 - const render = () => { 527 - renderFilterButtons(); 528 - renderCards(); 529 - }; 530 - 531 - const renderFilterButtons = () => { 532 - document.querySelectorAll('.filter-btn').forEach(btn => { 533 - btn.classList.toggle('active', state.activeFilter === btn.dataset.filter); 126 + // Create editor layout 127 + editorLayout = new EditorLayout({ 128 + container: rootEl, 129 + initialContent, 130 + vimMode, 131 + onContentChange: handleContentChange, 534 132 }); 535 - }; 536 133 537 - const renderCards = () => { 538 - const items = getFilteredItems(); 539 - 540 - if (items.length === 0) { 541 - const msg = state.searchQuery 542 - ? 'No items match your search.' 543 - : 'No saved items yet.'; 544 - cardsContainer.innerHTML = `<div class="empty-state">${msg}</div>`; 545 - return; 546 - } 547 - 548 - cardsContainer.innerHTML = ''; 549 - items.forEach(item => { 550 - cardsContainer.appendChild(createItemCard(item)); 551 - }); 552 - 553 - state.selectedIndex = Math.min(state.selectedIndex, items.length - 1); 554 - updateSelection(); 555 - }; 556 - 557 - const TYPE_ICONS = { 558 - url: '\uD83C\uDF10', // globe 559 - text: '\uD83D\uDCDD', // memo 560 - tagset: '\uD83C\uDFF7\uFE0F', // label 561 - image: '\uD83D\uDDBC\uFE0F' // framed picture 562 - }; 563 - 564 - const createItemCard = (item) => { 565 - const card = document.createElement('div'); 566 - card.className = 'card'; 567 - card.dataset.itemId = item.id; 568 - 569 - const tags = state.itemTags.get(item.id) || []; 570 - const itemType = item.type || 'url'; 571 - 572 - let title, subtitle; 573 - if (itemType === 'url') { 574 - title = item.content; 575 - subtitle = item.content; 576 - } else if (itemType === 'text') { 577 - title = item.content.substring(0, 100) + (item.content.length > 100 ? '...' : ''); 578 - subtitle = 'Text'; 579 - } else if (itemType === 'tagset') { 580 - title = 'Tag Set'; 581 - subtitle = tags.length > 0 ? tags.map(t => t.name).join(', ') : 'Empty tagset'; 582 - } else if (itemType === 'image') { 583 - title = item.content || 'Image'; 584 - subtitle = 'Image'; 585 - } else { 586 - title = item.content || '(unknown)'; 587 - subtitle = itemType; 588 - } 589 - 590 - const icon = TYPE_ICONS[itemType] || '\uD83D\uDCC4'; 591 - const dateStr = item.createdAt ? formatDate(item.createdAt) : ''; 592 - 593 - card.innerHTML = ` 594 - <div class="card-header"> 595 - <div class="card-icon">${icon}</div> 596 - <div class="card-content"> 597 - <div class="card-title">${escapeHtml(title)}</div> 598 - <div class="card-subtitle">${escapeHtml(subtitle)}</div> 599 - </div> 600 - </div> 601 - <div class="card-tags"> 602 - ${tags.map(tag => `<span class="card-tag" data-tag-id="${tag.id}">${escapeHtml(tag.name)}</span>`).join('')} 603 - </div> 604 - ${dateStr ? `<div class="card-date">${dateStr}</div>` : ''} 605 - `; 606 - 607 - card.addEventListener('click', (e) => { 608 - if (e.target.classList.contains('card-tag')) return; 609 - openEditModal(item); 610 - }); 611 - 612 - return card; 613 - }; 614 - 615 - const formatDate = (ts) => { 616 - const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts); 617 - const now = new Date(); 618 - const diff = now - d; 619 - if (diff < 60000) return 'just now'; 620 - if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; 621 - if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; 622 - if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago'; 623 - return d.toLocaleDateString(); 624 - }; 625 - 626 - // ───────────────────────── Edit modal ───────────────────────── 627 - 628 - const openEditModal = (item) => { 629 - state.editingItem = item; 630 - const itemType = item.type || 'url'; 631 - const tags = state.itemTags.get(item.id) || []; 632 - 633 - // Title 634 - document.querySelector('#editModal .modal-title').textContent = 'Edit ' + typeLabel(itemType); 635 - 636 - // Show/hide type-specific editors 637 - const urlInput = document.querySelector('.edit-url-input'); 638 - const textInput = document.querySelector('.edit-text-input'); 639 - const imagePreview = document.querySelector('.edit-image-preview'); 640 - const tagsetNotice = document.querySelector('.edit-tagset-notice'); 641 - 642 - urlInput.style.display = 'none'; 643 - textInput.style.display = 'none'; 644 - imagePreview.style.display = 'none'; 645 - tagsetNotice.style.display = 'none'; 646 - 647 - if (itemType === 'url') { 648 - urlInput.style.display = ''; 649 - urlInput.value = item.content || ''; 650 - state.editOriginal = item.content || ''; 651 - } else if (itemType === 'text') { 652 - textInput.style.display = ''; 653 - textInput.value = item.content || ''; 654 - state.editOriginal = item.content || ''; 655 - } else if (itemType === 'image') { 656 - imagePreview.style.display = ''; 657 - imagePreview.innerHTML = item.content 658 - ? `<img src="${escapeHtml(item.content)}" alt="Image preview">` 659 - : '<p>No image preview available</p>'; 660 - state.editOriginal = item.content || ''; 661 - } else if (itemType === 'tagset') { 662 - tagsetNotice.style.display = ''; 663 - state.editOriginal = null; // tagsets have no editable content 664 - } 665 - 666 - // Render tags 667 - renderCurrentTags(tags); 668 - renderAvailableTags(tags); 669 - document.querySelector('.new-tag-input').value = ''; 670 - 671 - // Show 672 - modalOverlay.classList.add('visible'); 673 - }; 674 - 675 - const typeLabel = (type) => { 676 - switch (type) { 677 - case 'url': return 'URL'; 678 - case 'text': return 'Note'; 679 - case 'tagset': return 'Tag Set'; 680 - case 'image': return 'Image'; 681 - default: return 'Item'; 134 + // Set up escape handler 135 + if (api?.escape) { 136 + api.escape.onEscape(() => { 137 + if (editorLayout?.isInFocusMode()) { 138 + editorLayout.exitFocusMode(); 139 + return { handled: true }; 140 + } 141 + return { handled: false }; 142 + }); 682 143 } 683 - }; 684 144 685 - const handleEditSave = async () => { 686 - if (!state.editingItem) return; 687 - 688 - const item = state.editingItem; 689 - const itemType = item.type || 'url'; 690 - 691 - let newContent = null; 692 - if (itemType === 'url') { 693 - newContent = document.querySelector('.edit-url-input').value.trim(); 694 - } else if (itemType === 'text') { 695 - newContent = document.querySelector('.edit-text-input').value; 145 + // Listen for vim mode changes to persist 146 + const vimCheckbox = document.querySelector('.vim-toggle input'); 147 + if (vimCheckbox) { 148 + vimCheckbox.addEventListener('change', async () => { 149 + const enabled = vimCheckbox.checked; 150 + if (settingsStore) { 151 + try { 152 + await settingsStore.set(SETTINGS_KEY, enabled); 153 + debug && console.log('[editor] Saved vimMode setting:', enabled); 154 + } catch (err) { 155 + debug && console.log('[editor] Failed to save vimMode setting:', err); 156 + } 157 + } 158 + }); 696 159 } 697 160 698 - // Save content changes if applicable 699 - if (newContent !== null && newContent !== state.editOriginal) { 700 - const updateOpts = { content: newContent }; 701 - if (itemType === 'url') updateOpts.url = newContent; 702 - const result = await api.datastore.updateItem(item.id, updateOpts); 703 - if (!result.success) { 704 - console.error('[editor] Failed to update item:', result.error); 705 - } else { 706 - // Update local state 707 - item.content = newContent; 708 - } 709 - } 710 - 711 - closeEditModal(); 712 - await loadData(); 713 - render(); 714 - api.publish('editor:changed', { action: 'update', itemId: item.id }, api.scopes.GLOBAL); 161 + debug && console.log('[editor] Editor initialized'); 715 162 }; 716 163 717 - const handleEditCancel = () => { 718 - if (!state.editingItem) { 719 - closeEditModal(); 720 - return; 721 - } 722 - 723 - const item = state.editingItem; 724 - const itemType = item.type || 'url'; 725 - 726 - // Dirty check 727 - let currentContent = null; 728 - if (itemType === 'url') { 729 - currentContent = document.querySelector('.edit-url-input').value.trim(); 730 - } else if (itemType === 'text') { 731 - currentContent = document.querySelector('.edit-text-input').value; 732 - } 733 - 734 - if (currentContent !== null && state.editOriginal !== null && currentContent !== state.editOriginal) { 735 - if (!confirm('Discard unsaved changes?')) return; 164 + /** 165 + * Handle content changes 166 + */ 167 + const handleContentChange = (content) => { 168 + // Publish change event for other extensions 169 + if (api?.publish) { 170 + api.publish('editor:contentChanged', { content }, api.scopes.GLOBAL); 736 171 } 737 - 738 - closeEditModal(); 739 172 }; 740 173 741 - const closeEditModal = () => { 742 - modalOverlay.classList.remove('visible'); 743 - state.editingItem = null; 744 - state.editOriginal = null; 745 - }; 746 - 747 - // ───────────────────────── Delete ───────────────────────── 748 - 749 - const handleDeleteRequest = () => { 750 - deleteConfirmOverlay.classList.add('visible'); 751 - }; 752 - 753 - const closeDeleteConfirm = () => { 754 - deleteConfirmOverlay.classList.remove('visible'); 755 - }; 756 - 757 - const handleDeleteConfirm = async () => { 758 - if (!state.editingItem) return; 759 - 760 - const itemId = state.editingItem.id; 761 - const result = await api.datastore.deleteItem(itemId); 762 - if (!result.success) { 763 - console.error('[editor] Failed to delete item:', result.error); 764 - closeDeleteConfirm(); 765 - return; 174 + /** 175 + * Clean up on unload 176 + */ 177 + const cleanup = () => { 178 + if (editorLayout) { 179 + editorLayout.destroy(); 180 + editorLayout = null; 766 181 } 767 - 768 - closeDeleteConfirm(); 769 - closeEditModal(); 770 - await loadData(); 771 - render(); 772 - api.publish('editor:changed', { action: 'delete', itemId }, api.scopes.GLOBAL); 773 - }; 774 - 775 - // ───────────────────────── Tag editing (edit modal) ───────────────────────── 776 - 777 - const renderCurrentTags = (tags) => { 778 - const container = document.querySelector('.current-tags'); 779 - if (tags.length === 0) { 780 - container.innerHTML = '<span class="no-tags">No tags</span>'; 781 - return; 782 - } 783 - container.innerHTML = ''; 784 - tags.forEach(tag => { 785 - const el = document.createElement('span'); 786 - el.className = 'current-tag'; 787 - el.innerHTML = `${escapeHtml(tag.name)} <button class="remove-tag" data-tag-id="${tag.id}">&times;</button>`; 788 - el.querySelector('.remove-tag').addEventListener('click', () => removeTagFromItem(tag)); 789 - container.appendChild(el); 790 - }); 791 - }; 792 - 793 - const renderAvailableTags = (currentTags) => { 794 - const container = document.querySelector('.available-tags'); 795 - const currentIds = new Set(currentTags.map(t => t.id)); 796 - container.innerHTML = ''; 797 - 798 - state.tags.forEach(tag => { 799 - const el = document.createElement('span'); 800 - el.className = 'available-tag'; 801 - if (currentIds.has(tag.id)) { 802 - el.classList.add('already-added'); 803 - } 804 - el.textContent = tag.name; 805 - if (!currentIds.has(tag.id)) { 806 - el.addEventListener('click', () => addTagToItem(tag)); 807 - } 808 - container.appendChild(el); 809 - }); 810 - 811 - if (state.tags.length === 0) { 812 - container.innerHTML = '<span class="no-tags">No tags available</span>'; 813 - } 814 - }; 815 - 816 - const addTagToItem = async (tag) => { 817 - if (!state.editingItem) return; 818 - const result = await api.datastore.tagItem(state.editingItem.id, tag.id); 819 - if (result.success) { 820 - const tags = state.itemTags.get(state.editingItem.id) || []; 821 - if (!tags.some(t => t.id === tag.id)) { 822 - tags.push(tag); 823 - state.itemTags.set(state.editingItem.id, tags); 824 - } 825 - renderCurrentTags(tags); 826 - renderAvailableTags(tags); 827 - renderCards(); 828 - } 829 - }; 830 - 831 - const removeTagFromItem = async (tag) => { 832 - if (!state.editingItem) return; 833 - const result = await api.datastore.untagItem(state.editingItem.id, tag.id); 834 - if (result.success) { 835 - let tags = state.itemTags.get(state.editingItem.id) || []; 836 - tags = tags.filter(t => t.id !== tag.id); 837 - state.itemTags.set(state.editingItem.id, tags); 838 - renderCurrentTags(tags); 839 - renderAvailableTags(tags); 840 - renderCards(); 841 - } 842 - }; 843 - 844 - const addNewTagToItem = async (input) => { 845 - const name = (input || '').trim(); 846 - if (!name || !state.editingItem) return; 847 - 848 - const tagResult = await api.datastore.getOrCreateTag(name); 849 - if (!tagResult.success) return; 850 - 851 - const tag = tagResult.data.tag; 852 - if (tagResult.data.created) { 853 - state.tags.unshift(tag); 854 - } 855 - 856 - await addTagToItem(tag); 857 - document.querySelector('.new-tag-input').value = ''; 858 - }; 859 - 860 - // ───────────────────────── Helpers ───────────────────────── 861 - 862 - const escapeHtml = (str) => { 863 - if (!str) return ''; 864 - const div = document.createElement('div'); 865 - div.textContent = str; 866 - return div.innerHTML; 867 182 }; 868 183 869 184 // Initialize when DOM is ready 870 185 document.addEventListener('DOMContentLoaded', init); 186 + 187 + // Clean up on unload 188 + window.addEventListener('beforeunload', cleanup);
+3 -2
extensions/editor/manifest.json
··· 2 2 "id": "editor", 3 3 "shortname": "editor", 4 4 "name": "Editor", 5 - "description": "View, add, and edit saved items", 5 + "description": "Markdown editor with outline sidebar, live preview, and vim mode", 6 6 "version": "1.0.0", 7 7 "background": "background.html", 8 - "builtin": true 8 + "builtin": true, 9 + "settingsSchema": "./settings-schema.json" 9 10 }
+156
extensions/editor/outline-sidebar.js
··· 1 + /** 2 + * Outline Sidebar - displays markdown header hierarchy. 3 + * Clicking a header jumps to that location in the editor. 4 + */ 5 + 6 + /** 7 + * Parse markdown text and extract headers with their positions. 8 + * @param {string} text - Markdown text 9 + * @returns {Array<{level: number, text: string, line: number, offset: number}>} 10 + */ 11 + export function parseHeaders(text) { 12 + const headers = []; 13 + const lines = text.split('\n'); 14 + let offset = 0; 15 + 16 + for (let i = 0; i < lines.length; i++) { 17 + const line = lines[i]; 18 + const match = line.match(/^(#{1,6})\s+(.+)$/); 19 + 20 + if (match) { 21 + headers.push({ 22 + level: match[1].length, 23 + text: match[2].trim(), 24 + line: i + 1, 25 + offset: offset, 26 + }); 27 + } 28 + 29 + offset += line.length + 1; // +1 for newline 30 + } 31 + 32 + return headers; 33 + } 34 + 35 + export class OutlineSidebar { 36 + constructor(options) { 37 + this.container = options.container; 38 + this.onHeaderClick = options.onHeaderClick; 39 + this.collapsed = false; 40 + 41 + // Create sidebar element 42 + this.element = document.createElement('div'); 43 + this.element.className = 'outline-sidebar'; 44 + 45 + // Header with title and collapse button 46 + this.header = document.createElement('div'); 47 + this.header.className = 'sidebar-header'; 48 + 49 + const title = document.createElement('span'); 50 + title.className = 'sidebar-title'; 51 + title.textContent = 'Outline'; 52 + this.header.appendChild(title); 53 + 54 + this.toggleBtn = document.createElement('button'); 55 + this.toggleBtn.className = 'sidebar-toggle'; 56 + this.toggleBtn.innerHTML = '\u25C0'; // ◀ 57 + this.toggleBtn.tabIndex = -1; 58 + this.toggleBtn.addEventListener('mousedown', (e) => e.preventDefault()); 59 + this.toggleBtn.addEventListener('click', () => this.toggle()); 60 + this.header.appendChild(this.toggleBtn); 61 + 62 + this.element.appendChild(this.header); 63 + 64 + // Content area for header list 65 + this.content = document.createElement('div'); 66 + this.content.className = 'sidebar-content'; 67 + this.element.appendChild(this.content); 68 + 69 + this.container.appendChild(this.element); 70 + } 71 + 72 + /** 73 + * Update the outline with new headers. 74 + * @param {string} text - Markdown text 75 + */ 76 + update(text) { 77 + const headers = parseHeaders(text); 78 + this.content.innerHTML = ''; 79 + 80 + if (headers.length === 0) { 81 + const empty = document.createElement('div'); 82 + empty.className = 'outline-empty'; 83 + empty.textContent = 'No headers found'; 84 + this.content.appendChild(empty); 85 + return; 86 + } 87 + 88 + // Level indicator colors using theme variables 89 + const levelColors = [ 90 + 'var(--base0A)', 'var(--base0B)', 'var(--base09)', 91 + 'var(--base0D)', 'var(--base0E)', 'var(--base0C)' 92 + ]; 93 + 94 + for (const header of headers) { 95 + const item = document.createElement('div'); 96 + item.className = 'outline-item'; 97 + item.style.paddingLeft = `${12 + (header.level - 1) * 12}px`; 98 + 99 + // Level indicator dot 100 + const indicator = document.createElement('span'); 101 + indicator.className = 'outline-indicator'; 102 + indicator.style.background = levelColors[(header.level - 1) % levelColors.length]; 103 + item.appendChild(indicator); 104 + 105 + const text = document.createElement('span'); 106 + text.textContent = header.text; 107 + item.appendChild(text); 108 + 109 + item.addEventListener('mousedown', (e) => e.preventDefault()); 110 + item.addEventListener('click', () => { 111 + if (this.onHeaderClick) { 112 + this.onHeaderClick(header); 113 + } 114 + }); 115 + 116 + this.content.appendChild(item); 117 + } 118 + } 119 + 120 + /** 121 + * Toggle sidebar collapsed state. 122 + */ 123 + toggle() { 124 + this.collapsed = !this.collapsed; 125 + this.element.classList.toggle('collapsed', this.collapsed); 126 + 127 + if (this.collapsed) { 128 + this.toggleBtn.innerHTML = '\u2261'; // ≡ 129 + this.toggleBtn.title = 'Show Outline'; 130 + } else { 131 + this.toggleBtn.innerHTML = '\u25C0'; // ◀ 132 + this.toggleBtn.title = 'Hide Outline'; 133 + } 134 + } 135 + 136 + /** 137 + * Check if sidebar is collapsed. 138 + */ 139 + isCollapsed() { 140 + return this.collapsed; 141 + } 142 + 143 + /** 144 + * Get the sidebar element. 145 + */ 146 + getElement() { 147 + return this.element; 148 + } 149 + 150 + /** 151 + * Destroy the sidebar. 152 + */ 153 + destroy() { 154 + this.element.remove(); 155 + } 156 + }
+186
extensions/editor/preview-sidebar.js
··· 1 + /** 2 + * Preview Sidebar - renders markdown content as HTML. 3 + */ 4 + 5 + /** 6 + * Escape HTML special characters. 7 + * @param {string} text 8 + * @returns {string} 9 + */ 10 + function escapeHtml(text) { 11 + return text 12 + .replace(/&/g, '&amp;') 13 + .replace(/</g, '&lt;') 14 + .replace(/>/g, '&gt;'); 15 + } 16 + 17 + /** 18 + * Simple markdown renderer. 19 + * Supports: headers, bold, italic, code, links, lists, blockquotes, hr. 20 + * @param {string} text - Markdown text 21 + * @returns {string} - HTML string 22 + */ 23 + export function renderMarkdown(text) { 24 + let html = escapeHtml(text); 25 + 26 + // Code blocks (``` ... ```) 27 + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { 28 + return `<pre><code class="language-${lang}">${code.trim()}</code></pre>`; 29 + }); 30 + 31 + // Inline code 32 + html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); 33 + 34 + // Headers 35 + html = html.replace(/^###### (.+)$/gm, '<h6>$1</h6>'); 36 + html = html.replace(/^##### (.+)$/gm, '<h5>$1</h5>'); 37 + html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>'); 38 + html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>'); 39 + html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>'); 40 + html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>'); 41 + 42 + // Bold and italic 43 + html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>'); 44 + html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 45 + html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); 46 + html = html.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>'); 47 + html = html.replace(/__(.+?)__/g, '<strong>$1</strong>'); 48 + html = html.replace(/_(.+?)_/g, '<em>$1</em>'); 49 + 50 + // Links 51 + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>'); 52 + 53 + // Images 54 + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">'); 55 + 56 + // Horizontal rule 57 + html = html.replace(/^(---|\*\*\*|___)$/gm, '<hr>'); 58 + 59 + // Blockquotes 60 + html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>'); 61 + 62 + // Unordered lists 63 + html = html.replace(/^[-*+] (.+)$/gm, '<li>$1</li>'); 64 + html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>'); 65 + 66 + // Ordered lists 67 + html = html.replace(/^\d+[.)] (.+)$/gm, '<li>$1</li>'); 68 + 69 + // Paragraphs (lines that aren't already wrapped) 70 + const lines = html.split('\n'); 71 + const result = []; 72 + let inParagraph = false; 73 + 74 + for (let i = 0; i < lines.length; i++) { 75 + const line = lines[i]; 76 + const isBlock = /^<(h[1-6]|pre|ul|ol|li|blockquote|hr)/.test(line); 77 + const isEmpty = line.trim() === ''; 78 + 79 + if (isBlock) { 80 + if (inParagraph) { 81 + result.push('</p>'); 82 + inParagraph = false; 83 + } 84 + result.push(line); 85 + } else if (isEmpty) { 86 + if (inParagraph) { 87 + result.push('</p>'); 88 + inParagraph = false; 89 + } 90 + } else { 91 + if (!inParagraph) { 92 + result.push('<p>'); 93 + inParagraph = true; 94 + } 95 + result.push(line); 96 + } 97 + } 98 + 99 + if (inParagraph) { 100 + result.push('</p>'); 101 + } 102 + 103 + return result.join('\n'); 104 + } 105 + 106 + export class PreviewSidebar { 107 + constructor(options) { 108 + this.container = options.container; 109 + this.collapsed = false; 110 + 111 + // Create sidebar element 112 + this.element = document.createElement('div'); 113 + this.element.className = 'preview-sidebar'; 114 + 115 + // Header with title and collapse button 116 + this.header = document.createElement('div'); 117 + this.header.className = 'sidebar-header'; 118 + 119 + const title = document.createElement('span'); 120 + title.className = 'sidebar-title'; 121 + title.textContent = 'Preview'; 122 + this.header.appendChild(title); 123 + 124 + this.toggleBtn = document.createElement('button'); 125 + this.toggleBtn.className = 'sidebar-toggle'; 126 + this.toggleBtn.innerHTML = '\u25B6'; // ▶ 127 + this.toggleBtn.tabIndex = -1; 128 + this.toggleBtn.addEventListener('mousedown', (e) => e.preventDefault()); 129 + this.toggleBtn.addEventListener('click', () => this.toggle()); 130 + this.header.appendChild(this.toggleBtn); 131 + 132 + this.element.appendChild(this.header); 133 + 134 + // Content area for rendered preview 135 + this.content = document.createElement('div'); 136 + this.content.className = 'preview-content'; 137 + this.element.appendChild(this.content); 138 + 139 + this.container.appendChild(this.element); 140 + } 141 + 142 + /** 143 + * Update the preview with new markdown content. 144 + * @param {string} markdown 145 + */ 146 + update(markdown) { 147 + this.content.innerHTML = renderMarkdown(markdown); 148 + } 149 + 150 + /** 151 + * Toggle sidebar collapsed state. 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 = '\u25CE'; // ◎ 159 + this.toggleBtn.title = 'Show Preview'; 160 + } else { 161 + this.toggleBtn.innerHTML = '\u25B6'; // ▶ 162 + this.toggleBtn.title = 'Hide Preview'; 163 + } 164 + } 165 + 166 + /** 167 + * Check if sidebar is collapsed. 168 + */ 169 + isCollapsed() { 170 + return this.collapsed; 171 + } 172 + 173 + /** 174 + * Get the sidebar element. 175 + */ 176 + getElement() { 177 + return this.element; 178 + } 179 + 180 + /** 181 + * Destroy the sidebar. 182 + */ 183 + destroy() { 184 + this.element.remove(); 185 + } 186 + }
+24
extensions/editor/settings-schema.json
··· 1 + { 2 + "prefs": { 3 + "$schema": "https://json-schema.org/draft/2020-12/schema", 4 + "$id": "peek.editor.prefs.schema.json", 5 + "title": "Editor preferences", 6 + "description": "Peek app Editor user preferences", 7 + "type": "object", 8 + "properties": { 9 + "vimMode": { 10 + "description": "Enable vim keybindings in the text editor", 11 + "type": "boolean", 12 + "default": false 13 + } 14 + } 15 + }, 16 + "storageKeys": { 17 + "PREFS": "prefs" 18 + }, 19 + "defaults": { 20 + "prefs": { 21 + "vimMode": false 22 + } 23 + } 24 + }
+8
package.json
··· 155 155 }, 156 156 "dependencies": { 157 157 "@cliqz/adblocker-electron": "^1.27.6", 158 + "@codemirror/commands": "^6.10.1", 159 + "@codemirror/lang-markdown": "^6.5.0", 160 + "@codemirror/language": "^6.12.1", 161 + "@codemirror/search": "^6.6.0", 162 + "@codemirror/state": "^6.5.4", 163 + "@codemirror/theme-one-dark": "^6.1.3", 164 + "@codemirror/view": "^6.39.11", 165 + "@replit/codemirror-vim": "^6.3.0", 158 166 "archiver": "^7.0.0", 159 167 "better-sqlite3": "^12.5.0", 160 168 "cross-fetch": "^4.0.0",
+14 -5
peek-todo.md
··· 225 225 226 226 ## Izui 227 227 228 - - [ ] formalize model 229 - - [ ] make izui stack manager (part of window mgr?) 230 - - [ ] esc stack: from feature settings back to core settings 231 - - [ ] add to izui stack (and ix w/ history?) 232 - - [ ] interactions/sec-policy between peek:// and other 228 + formalizing and stabilizing Peek’s window management system 229 + 230 + immediate 231 + - [ ] hotfix: disable escape-to-close window when peek app is focused application in the OS 232 + 233 + model formalization 234 + - [ ] review the windowing approach used in ./app to manage windows by analyzing the source code of it and the peek extensions 235 + - [ ] formalize that review into a state machine or other declarative set of rules which let’s us easily reason about, revise, and generate code and tests for it 236 + - [ ] implement izui window manager based on those rules 237 + 238 + key pieces 239 + - [ ] esc works when global hotkeys are executed and peek is not focused 240 + - [ ] in-app navigations with escape, eg moving from sub items in settings back to settings default pane 241 + - [ ] centralized place we add to history chain 233 242 234 243 ## Polish 235 244
+270
yarn.lock
··· 93 93 languageName: node 94 94 linkType: hard 95 95 96 + "@codemirror/autocomplete@npm:^6.0.0, @codemirror/autocomplete@npm:^6.7.1": 97 + version: 6.20.0 98 + resolution: "@codemirror/autocomplete@npm:6.20.0" 99 + dependencies: 100 + "@codemirror/language": "npm:^6.0.0" 101 + "@codemirror/state": "npm:^6.0.0" 102 + "@codemirror/view": "npm:^6.17.0" 103 + "@lezer/common": "npm:^1.0.0" 104 + checksum: 10c0/d0d1cf3eca6269811eb66edcf742ffa0a5423d7d115ab82b0d62a24d6cfcfb2a4c3779333b2cb68e3004af46556ac6203049f581d35785c46ffd1b852f6e8076 105 + languageName: node 106 + linkType: hard 107 + 108 + "@codemirror/commands@npm:^6.10.1": 109 + version: 6.10.1 110 + resolution: "@codemirror/commands@npm:6.10.1" 111 + dependencies: 112 + "@codemirror/language": "npm:^6.0.0" 113 + "@codemirror/state": "npm:^6.4.0" 114 + "@codemirror/view": "npm:^6.27.0" 115 + "@lezer/common": "npm:^1.1.0" 116 + checksum: 10c0/1841d8ad6751f0d10d10200e81333c5c9b0d6afb55692e41df85992a3737abc8c2ee97e14816ce624276381fbb0261e7aaf8474e170b74f796c3ba62500be3da 117 + languageName: node 118 + linkType: hard 119 + 120 + "@codemirror/lang-css@npm:^6.0.0": 121 + version: 6.3.1 122 + resolution: "@codemirror/lang-css@npm:6.3.1" 123 + dependencies: 124 + "@codemirror/autocomplete": "npm:^6.0.0" 125 + "@codemirror/language": "npm:^6.0.0" 126 + "@codemirror/state": "npm:^6.0.0" 127 + "@lezer/common": "npm:^1.0.2" 128 + "@lezer/css": "npm:^1.1.7" 129 + checksum: 10c0/339387c5a1b90076ae41017e66d7da70dd2aca4e5e4d012c95df33d0f6e740410cf1fb53c4845e3814636d587ce6eff05ebca3173dcfc564a1f646d24f299180 130 + languageName: node 131 + linkType: hard 132 + 133 + "@codemirror/lang-html@npm:^6.0.0": 134 + version: 6.4.11 135 + resolution: "@codemirror/lang-html@npm:6.4.11" 136 + dependencies: 137 + "@codemirror/autocomplete": "npm:^6.0.0" 138 + "@codemirror/lang-css": "npm:^6.0.0" 139 + "@codemirror/lang-javascript": "npm:^6.0.0" 140 + "@codemirror/language": "npm:^6.4.0" 141 + "@codemirror/state": "npm:^6.0.0" 142 + "@codemirror/view": "npm:^6.17.0" 143 + "@lezer/common": "npm:^1.0.0" 144 + "@lezer/css": "npm:^1.1.0" 145 + "@lezer/html": "npm:^1.3.12" 146 + checksum: 10c0/d117b5c91377dd81e6551cbfaf881138787826c0d0c90fc2a7a69550decfc49af193ec1cd5c12b90d91de1acf09c42d4c5fec5bea848d354246b4ec778a4a88f 147 + languageName: node 148 + linkType: hard 149 + 150 + "@codemirror/lang-javascript@npm:^6.0.0": 151 + version: 6.2.4 152 + resolution: "@codemirror/lang-javascript@npm:6.2.4" 153 + dependencies: 154 + "@codemirror/autocomplete": "npm:^6.0.0" 155 + "@codemirror/language": "npm:^6.6.0" 156 + "@codemirror/lint": "npm:^6.0.0" 157 + "@codemirror/state": "npm:^6.0.0" 158 + "@codemirror/view": "npm:^6.17.0" 159 + "@lezer/common": "npm:^1.0.0" 160 + "@lezer/javascript": "npm:^1.0.0" 161 + checksum: 10c0/af6faaa9566c57e233459d48e8afbdbf99b6d666695fa49b2d352cda15b6022edbe319e1b296f76526f48c313627bbcd2a340872e6627a17358edabc08e8e129 162 + languageName: node 163 + linkType: hard 164 + 165 + "@codemirror/lang-markdown@npm:^6.5.0": 166 + version: 6.5.0 167 + resolution: "@codemirror/lang-markdown@npm:6.5.0" 168 + dependencies: 169 + "@codemirror/autocomplete": "npm:^6.7.1" 170 + "@codemirror/lang-html": "npm:^6.0.0" 171 + "@codemirror/language": "npm:^6.3.0" 172 + "@codemirror/state": "npm:^6.0.0" 173 + "@codemirror/view": "npm:^6.0.0" 174 + "@lezer/common": "npm:^1.2.1" 175 + "@lezer/markdown": "npm:^1.0.0" 176 + checksum: 10c0/b026997bca821d5faf62808bf125674c182a82090706f4b7250ea80d4e8e00a88c2783230b391950110e4f92eba773556b5361f6b65c705cf775f2458c6ff7b7 177 + languageName: node 178 + linkType: hard 179 + 180 + "@codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.12.1, @codemirror/language@npm:^6.3.0, @codemirror/language@npm:^6.4.0, @codemirror/language@npm:^6.6.0": 181 + version: 6.12.1 182 + resolution: "@codemirror/language@npm:6.12.1" 183 + dependencies: 184 + "@codemirror/state": "npm:^6.0.0" 185 + "@codemirror/view": "npm:^6.23.0" 186 + "@lezer/common": "npm:^1.5.0" 187 + "@lezer/highlight": "npm:^1.0.0" 188 + "@lezer/lr": "npm:^1.0.0" 189 + style-mod: "npm:^4.0.0" 190 + checksum: 10c0/d37e526a839f571f767372c49e28649c4e79a539c73845a74117ee408ad31c29d60a32b5e1bad439637b1456d18154d672eb225e9b4482d3e00eca150461bc6a 191 + languageName: node 192 + linkType: hard 193 + 194 + "@codemirror/lint@npm:^6.0.0": 195 + version: 6.9.3 196 + resolution: "@codemirror/lint@npm:6.9.3" 197 + dependencies: 198 + "@codemirror/state": "npm:^6.0.0" 199 + "@codemirror/view": "npm:^6.35.0" 200 + crelt: "npm:^1.0.5" 201 + checksum: 10c0/729af1fc39ced59edb5ad73ef95a71df8e4a7ed7bccac53bac3e6232a4f018f5d8b2b1c320eb014f5ba07a1a0e53fbc094907679e017dc5f3b5707765b2c6541 202 + languageName: node 203 + linkType: hard 204 + 205 + "@codemirror/search@npm:^6.6.0": 206 + version: 6.6.0 207 + resolution: "@codemirror/search@npm:6.6.0" 208 + dependencies: 209 + "@codemirror/state": "npm:^6.0.0" 210 + "@codemirror/view": "npm:^6.37.0" 211 + crelt: "npm:^1.0.5" 212 + checksum: 10c0/dacb6dbf94dbc4513b681ea2ea215b5771b478bc940c88e52976b7981dc135b3f17cfcb1e3e929579078f334b42e91bdfee89b9ec874638ddaf82f87cefa0de2 213 + languageName: node 214 + linkType: hard 215 + 216 + "@codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.4.0, @codemirror/state@npm:^6.5.0, @codemirror/state@npm:^6.5.4": 217 + version: 6.5.4 218 + resolution: "@codemirror/state@npm:6.5.4" 219 + dependencies: 220 + "@marijn/find-cluster-break": "npm:^1.0.0" 221 + checksum: 10c0/8f40e1a22b84752fc44637e586cb3d804f775c0cf9c8083a79eed5cb18fbdfb30b83c112d8b6d819046526d1f9e49bf1198bdca4c4c3427bdf2c657a96df7adf 222 + languageName: node 223 + linkType: hard 224 + 225 + "@codemirror/theme-one-dark@npm:^6.1.3": 226 + version: 6.1.3 227 + resolution: "@codemirror/theme-one-dark@npm:6.1.3" 228 + dependencies: 229 + "@codemirror/language": "npm:^6.0.0" 230 + "@codemirror/state": "npm:^6.0.0" 231 + "@codemirror/view": "npm:^6.0.0" 232 + "@lezer/highlight": "npm:^1.0.0" 233 + checksum: 10c0/de8483c69911bcd61a19679384de663ced9c8bed3c776f08581a8b724e9f456a17053b1cf6e9d1f2a475fa6bc42e905ec8ba1ee0a8b55213d18087d9d9150317 234 + languageName: node 235 + linkType: hard 236 + 237 + "@codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.35.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.39.11": 238 + version: 6.39.11 239 + resolution: "@codemirror/view@npm:6.39.11" 240 + dependencies: 241 + "@codemirror/state": "npm:^6.5.0" 242 + crelt: "npm:^1.0.6" 243 + style-mod: "npm:^4.1.0" 244 + w3c-keyname: "npm:^2.2.4" 245 + checksum: 10c0/832c240d9efcdc1df7e60686e471517059bc3e1f8d6083216d98f2ea15550d29d861ca5b82e4b9d61cbd3d71b0abb9e39f58608a373e3c5bc10e7a2e325aeff2 246 + languageName: node 247 + linkType: hard 248 + 96 249 "@develar/schema-utils@npm:~2.6.5": 97 250 version: 2.6.5 98 251 resolution: "@develar/schema-utils@npm:2.6.5" ··· 485 638 languageName: node 486 639 linkType: hard 487 640 641 + "@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1, @lezer/common@npm:^1.3.0, @lezer/common@npm:^1.5.0": 642 + version: 1.5.0 643 + resolution: "@lezer/common@npm:1.5.0" 644 + checksum: 10c0/12c4b0ea9d621eedb6aa13e41a0628e25f80efe6a1d44cf68a9ea1808db4c8fc124d0691458de69c833860f3235526fbec574005d812c710e693c2dc6e65e3a8 645 + languageName: node 646 + linkType: hard 647 + 648 + "@lezer/css@npm:^1.1.0, @lezer/css@npm:^1.1.7": 649 + version: 1.3.0 650 + resolution: "@lezer/css@npm:1.3.0" 651 + dependencies: 652 + "@lezer/common": "npm:^1.2.0" 653 + "@lezer/highlight": "npm:^1.0.0" 654 + "@lezer/lr": "npm:^1.3.0" 655 + checksum: 10c0/ca63614cbc1b3d884b80de86a76acfd35b19a0134ebeb0fb93f677f6dc034b79fb493168c3ef17143642264b59011dd28a26c6909861f02c8fd715e298477577 656 + languageName: node 657 + linkType: hard 658 + 659 + "@lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.1.3": 660 + version: 1.2.3 661 + resolution: "@lezer/highlight@npm:1.2.3" 662 + dependencies: 663 + "@lezer/common": "npm:^1.3.0" 664 + checksum: 10c0/3bcb4fce7a1a45b5973895d7cb2be47970a0098700f2a0970aef9878ffd37f540285a2d7388ec1f524726ec90cc5196b5701bbb9764b7e7300786d772b7d2ce2 665 + languageName: node 666 + linkType: hard 667 + 668 + "@lezer/html@npm:^1.3.12": 669 + version: 1.3.13 670 + resolution: "@lezer/html@npm:1.3.13" 671 + dependencies: 672 + "@lezer/common": "npm:^1.2.0" 673 + "@lezer/highlight": "npm:^1.0.0" 674 + "@lezer/lr": "npm:^1.0.0" 675 + checksum: 10c0/d8784fc2ef2bd5d583db7e144bfea09287f02e58cf3c2ae4dbed031debe00424797016d8b5234586d2099d8b69d35ee19a2d671f87a6316ca67bc425ada26c7b 676 + languageName: node 677 + linkType: hard 678 + 679 + "@lezer/javascript@npm:^1.0.0": 680 + version: 1.5.4 681 + resolution: "@lezer/javascript@npm:1.5.4" 682 + dependencies: 683 + "@lezer/common": "npm:^1.2.0" 684 + "@lezer/highlight": "npm:^1.1.3" 685 + "@lezer/lr": "npm:^1.3.0" 686 + checksum: 10c0/77b97c546d3661223ce77af15192efad42585d01e46022222273cd0c1cb20df8063d266037ae67774f051a6cb72db49804b6a46b06b926ee541807ef01741f6a 687 + languageName: node 688 + linkType: hard 689 + 690 + "@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.3.0": 691 + version: 1.4.8 692 + resolution: "@lezer/lr@npm:1.4.8" 693 + dependencies: 694 + "@lezer/common": "npm:^1.0.0" 695 + checksum: 10c0/8bd2228a316a5ef8da01908e3e22aca95fa9695211ffe56f3e8be756b37d0810d5aa91fbbdd274b198a343051d8637e130e26f51161161f089244af242b653c9 696 + languageName: node 697 + linkType: hard 698 + 699 + "@lezer/markdown@npm:^1.0.0": 700 + version: 1.6.3 701 + resolution: "@lezer/markdown@npm:1.6.3" 702 + dependencies: 703 + "@lezer/common": "npm:^1.5.0" 704 + "@lezer/highlight": "npm:^1.0.0" 705 + checksum: 10c0/00a1beb4119d76a66c08eee1e771529640b93144da5c63145e775fa5f08f244d35a11978d8af1d1d20f24685f738a51b1a69c01c4741cec54f85f6559a4dad82 706 + languageName: node 707 + linkType: hard 708 + 488 709 "@lit-labs/ssr-dom-shim@npm:^1.5.0": 489 710 version: 1.5.1 490 711 resolution: "@lit-labs/ssr-dom-shim@npm:1.5.1" ··· 519 740 lodash: "npm:^4.17.15" 520 741 tmp-promise: "npm:^3.0.2" 521 742 checksum: 10c0/b3c87f6482b1956411af1118c771afb39cd9a0568fbb5e86015547ff6d68d2e73a7f0d74b75a57f0a156391c347c8d0adc1037e75172b92da72b96e0a05a2f4f 743 + languageName: node 744 + linkType: hard 745 + 746 + "@marijn/find-cluster-break@npm:^1.0.0": 747 + version: 1.0.2 748 + resolution: "@marijn/find-cluster-break@npm:1.0.2" 749 + checksum: 10c0/1a17a60b16083cc5f7ce89d7b7d8aa87ce4099723e3e9e34e229ef2cd8a980e69d481ca8ee90ffedfec5119af1aed581642fb60ed0365e7e90634c81ea6b630f 522 750 languageName: node 523 751 linkType: hard 524 752 ··· 689 917 version: 1.5.0 690 918 resolution: "@remusao/trie@npm:1.5.0" 691 919 checksum: 10c0/31c584790b292c22db9a8573dbeffae65a51c3d1b669a6633c58c469a455f0d3450ea071c388ecbfbec2f96339fc2ce8f2702391df23fc72ca9860724ea32bc0 920 + languageName: node 921 + linkType: hard 922 + 923 + "@replit/codemirror-vim@npm:^6.3.0": 924 + version: 6.3.0 925 + resolution: "@replit/codemirror-vim@npm:6.3.0" 926 + peerDependencies: 927 + "@codemirror/commands": 6.x.x 928 + "@codemirror/language": 6.x.x 929 + "@codemirror/search": 6.x.x 930 + "@codemirror/state": 6.x.x 931 + "@codemirror/view": 6.x.x 932 + checksum: 10c0/ba222b8fb85f9e4f4e1ef08271c00a48ba5c35c16d8731daa3fa8f2b004a3a5ea51599d485f58be871b769ffd0e8c84b086dcd0071e4813b57497cc998f1b9ae 692 933 languageName: node 693 934 linkType: hard 694 935 ··· 928 1169 resolution: "Peek@workspace:." 929 1170 dependencies: 930 1171 "@cliqz/adblocker-electron": "npm:^1.27.6" 1172 + "@codemirror/commands": "npm:^6.10.1" 1173 + "@codemirror/lang-markdown": "npm:^6.5.0" 1174 + "@codemirror/language": "npm:^6.12.1" 1175 + "@codemirror/search": "npm:^6.6.0" 1176 + "@codemirror/state": "npm:^6.5.4" 1177 + "@codemirror/theme-one-dark": "npm:^6.1.3" 1178 + "@codemirror/view": "npm:^6.39.11" 931 1179 "@electron/rebuild": "npm:^4.0.2" 932 1180 "@playwright/test": "npm:^1.57.0" 1181 + "@replit/codemirror-vim": "npm:^6.3.0" 933 1182 "@types/archiver": "npm:^6.0.0" 934 1183 "@types/better-sqlite3": "npm:^7.6.13" 935 1184 "@types/node": "npm:^25.0.3" ··· 2025 2274 dependencies: 2026 2275 buffer: "npm:^5.1.0" 2027 2276 checksum: 10c0/1a0da36e5f95b19cd2a7b2eab5306a08f1c47bdd22da6f761ab764e2222e8e90a877398907cea94108bd5e41a6d311ea84d7914eaca67da2baa4050bd6384b3d 2277 + languageName: node 2278 + linkType: hard 2279 + 2280 + "crelt@npm:^1.0.5, crelt@npm:^1.0.6": 2281 + version: 1.0.6 2282 + resolution: "crelt@npm:1.0.6" 2283 + checksum: 10c0/e0fb76dff50c5eb47f2ea9b786c17f9425c66276025adee80876bdbf4a84ab72e899e56d3928431ab0cb057a105ef704df80fe5726ef0f7b1658f815521bdf09 2028 2284 languageName: node 2029 2285 linkType: hard 2030 2286 ··· 5808 6064 languageName: node 5809 6065 linkType: hard 5810 6066 6067 + "style-mod@npm:^4.0.0, style-mod@npm:^4.1.0": 6068 + version: 4.1.3 6069 + resolution: "style-mod@npm:4.1.3" 6070 + checksum: 10c0/36059006ea73cd96242ca8be06b625522d488bf8caca9c18436edf77092183381f08109577a4b3d35482f3395231099f195dbc854a46ce507fbf75c484f2cfcc 6071 + languageName: node 6072 + linkType: hard 6073 + 5811 6074 "sumchecker@npm:^3.0.1": 5812 6075 version: 3.0.1 5813 6076 resolution: "sumchecker@npm:3.0.1" ··· 6204 6467 core-util-is: "npm:1.0.2" 6205 6468 extsprintf: "npm:^1.2.0" 6206 6469 checksum: 10c0/293fb060a4c9b07965569a0c3e45efa954127818707995a8a4311f691b5d6687be99f972c759838ba6eecae717f9af28e3c49d2afc7bbdf5f0b675238f1426e8 6470 + languageName: node 6471 + linkType: hard 6472 + 6473 + "w3c-keyname@npm:^2.2.4": 6474 + version: 2.2.8 6475 + resolution: "w3c-keyname@npm:2.2.8" 6476 + checksum: 10c0/37cf335c90efff31672ebb345577d681e2177f7ff9006a9ad47c68c5a9d265ba4a7b39d6c2599ceea639ca9315584ce4bd9c9fbf7a7217bfb7a599e71943c4c4 6207 6477 languageName: node 6208 6478 linkType: hard 6209 6479