Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

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

refactor(docs): extract toolbar, shortcuts, export/import, version history, block handle from main.ts

Reduce docs/main.ts from 2,805 to 1,706 lines (39% reduction).
Update CHANGELOG.

+1410 -1171
+1
CHANGELOG.md
··· 270 270 - Fix E2E test flakiness: replace page reload with addInitScript, add waitForURL before waitForSelector (#305) 271 271 272 272 ### Changed 273 + - Decompose docs/main.ts into focused modules (#464) 273 274 - Decompose diagrams/main.ts into focused modules (#463) 274 275 - Phase 5: extract toolbar, keyboard, cell-editing, grid-rendering from sheets main.ts (#462) 275 276 - Phase 4: extract formula-bar, keyboard-shortcuts, clipboard-selection from sheets main.ts (#461)
+217
src/docs/block-handle-ui.ts
··· 1 + /** 2 + * Block Handle UI — DOM creation, positioning, context menu rendering, 3 + * turn-into menu, mouse tracking, and event wiring. 4 + * 5 + * Extracted from main.ts for decomposition. 6 + */ 7 + 8 + import { BlockHandleState, BLOCK_HANDLE_ACTIONS, BLOCK_HANDLE_ICON, BLOCK_HANDLE_ADD_ICON, TURN_INTO_ITEMS } from './block-handle.js'; 9 + import { getCommandExecutor } from './extensions/slash-commands.js'; 10 + 11 + // ── Types ─────────────────────────────────────────────────── 12 + 13 + export interface BlockHandleUIDeps { 14 + editor: any; 15 + $: (id: string) => HTMLElement; 16 + } 17 + 18 + // ── Block Handle UI ───────────────────────────────────────── 19 + 20 + export function wireBlockHandleUI(deps: BlockHandleUIDeps): { blockHandleState: BlockHandleState } { 21 + const { editor } = deps; 22 + const editorEl = document.getElementById('editor'); 23 + const blockHandleState = new BlockHandleState(); 24 + 25 + // Create block handle element 26 + const blockHandleEl = document.createElement('div'); 27 + blockHandleEl.className = 'block-handle'; 28 + blockHandleEl.id = 'block-handle'; 29 + blockHandleEl.style.display = 'none'; 30 + blockHandleEl.innerHTML = `<button class="block-handle-add" title="Add block below">${BLOCK_HANDLE_ADD_ICON}</button><button class="block-handle-grip" title="Drag to reorder / Click for options">${BLOCK_HANDLE_ICON}</button>`; 31 + document.body.appendChild(blockHandleEl); 32 + 33 + // Create block context menu element 34 + const blockContextMenuEl = document.createElement('div'); 35 + blockContextMenuEl.className = 'block-context-menu'; 36 + blockContextMenuEl.id = 'block-context-menu'; 37 + blockContextMenuEl.style.display = 'none'; 38 + document.body.appendChild(blockContextMenuEl); 39 + 40 + let blockHandleTimeout: ReturnType<typeof setTimeout> | null = null; 41 + 42 + function showBlockHandle(blockElement: Element, pos: number): void { 43 + if (!blockElement || !editorEl) return; 44 + const editorRect = editorEl.getBoundingClientRect(); 45 + const blockRect = blockElement.getBoundingClientRect(); 46 + const top = blockRect.top; 47 + const left = editorRect.left - 36; 48 + blockHandleState.show({ top, left }, pos); 49 + blockHandleEl.style.display = 'flex'; 50 + blockHandleEl.style.top = `${top}px`; 51 + blockHandleEl.style.left = `${Math.max(4, left)}px`; 52 + } 53 + 54 + function hideBlockHandle() { 55 + blockHandleState.hide(); 56 + blockHandleEl.style.display = 'none'; 57 + blockContextMenuEl.style.display = 'none'; 58 + } 59 + 60 + function renderBlockContextMenu() { 61 + let html = ''; 62 + for (const action of BLOCK_HANDLE_ACTIONS) { 63 + html += `<button class="block-context-item" data-action="${action.id}">`; 64 + html += `<span class="block-context-icon">${action.icon}</span>`; 65 + html += `<span class="block-context-label">${action.label}</span>`; 66 + html += `</button>`; 67 + } 68 + blockContextMenuEl.innerHTML = html; 69 + blockContextMenuEl.style.display = 'block'; 70 + 71 + const pos = blockHandleState.position; 72 + if (pos) { 73 + blockContextMenuEl.style.top = `${pos.top + 24}px`; 74 + blockContextMenuEl.style.left = `${pos.left}px`; 75 + } 76 + 77 + // Wire context menu actions 78 + blockContextMenuEl.querySelectorAll('.block-context-item').forEach((btn: any) => { 79 + btn.addEventListener('mousedown', (e: Event) => { 80 + e.preventDefault(); 81 + const actionId = btn.dataset.action; 82 + executeBlockAction(actionId); 83 + }); 84 + }); 85 + } 86 + 87 + function executeBlockAction(actionId: string): void { 88 + const pos = blockHandleState.blockPos; 89 + if (pos == null) return; 90 + 91 + switch (actionId) { 92 + case 'delete': 93 + editor.chain().focus().deleteNode(editor.state.doc.resolve(pos).parent.type.name).run(); 94 + blockContextMenuEl.style.display = 'none'; 95 + hideBlockHandle(); 96 + break; 97 + case 'duplicate': { 98 + const node = editor.state.doc.resolve(pos).parent; 99 + const endPos = pos + node.nodeSize; 100 + editor.chain().focus().insertContentAt(endPos, node.toJSON()).run(); 101 + blockContextMenuEl.style.display = 'none'; 102 + break; 103 + } 104 + case 'moveUp': 105 + case 'moveDown': 106 + blockContextMenuEl.style.display = 'none'; 107 + break; 108 + case 'turnInto': 109 + renderTurnIntoMenu(); 110 + break; 111 + default: 112 + blockContextMenuEl.style.display = 'none'; 113 + } 114 + } 115 + 116 + function renderTurnIntoMenu() { 117 + blockHandleState.openTurnIntoMenu(); 118 + let html = '<div class="block-context-sub-header">Turn into</div>'; 119 + for (const item of TURN_INTO_ITEMS) { 120 + html += `<button class="block-context-item" data-turn-into="${item.id}">`; 121 + html += `<span class="block-context-icon">${item.icon}</span>`; 122 + html += `<span class="block-context-label">${item.name}</span>`; 123 + html += `</button>`; 124 + } 125 + blockContextMenuEl.innerHTML = html; 126 + 127 + blockContextMenuEl.querySelectorAll('[data-turn-into]').forEach((btn: any) => { 128 + btn.addEventListener('mousedown', (e: Event) => { 129 + e.preventDefault(); 130 + const typeId = btn.dataset.turnInto; 131 + executeTurnInto(typeId); 132 + }); 133 + }); 134 + } 135 + 136 + function executeTurnInto(typeId: string): void { 137 + const executor = getCommandExecutor({ id: typeId }); 138 + if (executor) { 139 + executor(editor); 140 + } 141 + blockContextMenuEl.style.display = 'none'; 142 + blockHandleState.closeContextMenu(); 143 + } 144 + 145 + // Grip click -> context menu 146 + blockHandleEl.querySelector('.block-handle-grip')!.addEventListener('click', (e: Event) => { 147 + e.stopPropagation(); 148 + if (blockHandleState.contextMenuOpen) { 149 + blockHandleState.closeContextMenu(); 150 + blockContextMenuEl.style.display = 'none'; 151 + } else { 152 + blockHandleState.openContextMenu(); 153 + renderBlockContextMenu(); 154 + } 155 + }); 156 + 157 + // Add button -> insert paragraph below 158 + blockHandleEl.querySelector('.block-handle-add')!.addEventListener('click', (e: Event) => { 159 + e.stopPropagation(); 160 + const pos = blockHandleState.blockPos; 161 + if (pos != null) { 162 + const resolved = editor.state.doc.resolve(pos); 163 + const endOfBlock = pos + resolved.parent.nodeSize; 164 + editor.chain().focus().insertContentAt(endOfBlock, { type: 'paragraph' }).run(); 165 + } 166 + }); 167 + 168 + // Close block context menu when clicking outside 169 + document.addEventListener('click', (e: any) => { 170 + if (!e.target.closest('#block-context-menu') && !e.target.closest('#block-handle')) { 171 + blockHandleState.closeContextMenu(); 172 + blockContextMenuEl.style.display = 'none'; 173 + } 174 + }); 175 + 176 + // Track mouse position over editor to show block handles 177 + if (editorEl) { 178 + editorEl.addEventListener('mousemove', (e: any) => { 179 + if (blockHandleState.isHiddenInMode( 180 + document.querySelector('.app-shell.zen-mode') ? 'zen' : 'normal' 181 + )) { 182 + hideBlockHandle(); 183 + return; 184 + } 185 + 186 + if (blockHandleTimeout) clearTimeout(blockHandleTimeout); 187 + blockHandleTimeout = setTimeout(() => { 188 + const target = e.target; 189 + const blockEl = target.closest('.ProseMirror > *'); 190 + if (!blockEl) { 191 + hideBlockHandle(); 192 + return; 193 + } 194 + 195 + const view = editor.view; 196 + const pos = view.posAtDOM(blockEl, 0); 197 + if (pos != null) { 198 + showBlockHandle(blockEl, pos); 199 + } 200 + }, 50); 201 + }); 202 + 203 + editorEl.addEventListener('mouseleave', () => { 204 + if (blockHandleTimeout) clearTimeout(blockHandleTimeout); 205 + if (!blockHandleState.contextMenuOpen) { 206 + blockHandleTimeout = setTimeout(() => hideBlockHandle(), 300); 207 + } 208 + }); 209 + } 210 + 211 + // Prevent hiding when hovering the handle itself 212 + blockHandleEl.addEventListener('mouseenter', () => { 213 + if (blockHandleTimeout) clearTimeout(blockHandleTimeout); 214 + }); 215 + 216 + return { blockHandleState }; 217 + }
+190
src/docs/export-import.ts
··· 1 + /** 2 + * Export/Import — HTML, Markdown, Text, PDF, DOCX export; file import; 3 + * drag-and-drop; download helper; toast notification. 4 + * 5 + * Extracted from main.ts for decomposition. 6 + */ 7 + 8 + import { exportPdf } from './pdf-export.js'; 9 + import { exportDocx } from './docx-export.js'; 10 + import { importDocx } from './docx-import.js'; 11 + import { markdownToHtml } from './markdown-parser.js'; 12 + import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; 13 + 14 + // ── Types ─────────────────────────────────────────────────── 15 + 16 + export interface ExportImportDeps { 17 + editor: any; 18 + provider: any; 19 + titleInput: HTMLInputElement; 20 + $: (id: string) => HTMLElement; 21 + closeAllDropdowns: () => void; 22 + } 23 + 24 + // ── Download helper ───────────────────────────────────────── 25 + 26 + export function downloadFile(content: string | Uint8Array, filename: string, mimeType: string): void { 27 + const blob = new Blob([content], { type: mimeType }); 28 + const url = URL.createObjectURL(blob); 29 + const a = document.createElement('a'); 30 + a.href = url; 31 + a.download = filename; 32 + document.body.appendChild(a); 33 + a.click(); 34 + document.body.removeChild(a); 35 + URL.revokeObjectURL(url); 36 + } 37 + 38 + // ── Toast notification ────────────────────────────────────── 39 + 40 + export function showToast(message: string, duration = 3000): void { 41 + const existing = document.querySelector('.toast-notification'); 42 + if (existing) existing.remove(); 43 + const toast = document.createElement('div'); 44 + toast.className = 'toast-notification'; 45 + toast.textContent = message; 46 + document.body.appendChild(toast); 47 + toast.offsetHeight; // force reflow 48 + toast.classList.add('toast-visible'); 49 + setTimeout(() => { toast.classList.remove('toast-visible'); setTimeout(() => toast.remove(), 300); }, duration); 50 + } 51 + 52 + // ── Filename helper ───────────────────────────────────────── 53 + 54 + function getDocFilename(titleInput: HTMLInputElement): string { 55 + const title = titleInput.value.trim() || 'Untitled Document'; 56 + return title.replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_'); 57 + } 58 + 59 + // ── Export functions ──────────────────────────────────────── 60 + 61 + const htmlToMarkdown = turndownHtmlToMarkdown; 62 + 63 + export function exportHTML(editor: any, titleInput: HTMLInputElement): void { 64 + const html = editor.getHTML(); 65 + const title = titleInput.value.trim() || 'Untitled Document'; 66 + const fullDoc = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n<title>' + title + '</title>\n<style>\n body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; color: #1a1815; }\n h1, h2, h3 { margin-top: 1.5em; margin-bottom: 0.5em; }\n blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 1em; color: #555; }\n pre { background: #f5f5f5; padding: 1em; border-radius: 4px; overflow-x: auto; }\n code { background: #f5f5f5; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }\n pre code { background: none; padding: 0; }\n table { border-collapse: collapse; width: 100%; }\n th, td { border: 1px solid #ddd; padding: 0.5em; text-align: left; }\n th { background: #f5f5f5; }\n hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }\n img { max-width: 100%; }\n ul[data-type="taskList"] { list-style: none; padding-left: 0; }\n ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5em; }\n</style>\n</head>\n<body>\n<h1>' + title + '</h1>\n' + html + '\n</body>\n</html>'; 67 + downloadFile(fullDoc, getDocFilename(titleInput) + '.html', 'text/html'); 68 + } 69 + 70 + export function exportMarkdown(editor: any, titleInput: HTMLInputElement): void { 71 + const html = editor.getHTML(); 72 + const md = htmlToMarkdown(html); 73 + downloadFile(md, getDocFilename(titleInput) + '.md', 'text/markdown'); 74 + } 75 + 76 + export function exportText(editor: any, titleInput: HTMLInputElement): void { 77 + const text = editor.getText(); 78 + downloadFile(text, getDocFilename(titleInput) + '.txt', 'text/plain'); 79 + } 80 + 81 + export function doExportPdf(editor: any, titleInput: HTMLInputElement): void { 82 + exportPdf({ 83 + editorHtml: editor.getHTML(), 84 + title: titleInput.value.trim() || 'Untitled Document', 85 + }); 86 + } 87 + 88 + export function doExportDocx(editor: any, titleInput: HTMLInputElement): void { 89 + exportDocx({ 90 + editorHtml: editor.getHTML(), 91 + title: titleInput.value.trim() || 'Untitled Document', 92 + }); 93 + } 94 + 95 + // ── Import functions ──────────────────────────────────────── 96 + 97 + export async function handleImportedFile(file: File, editor: any, provider: any): Promise<void> { 98 + const ext = file.name.split('.').pop()!.toLowerCase(); 99 + 100 + if (ext === 'docx') { 101 + await importDocx(file, editor, showToast); 102 + await provider._saveSnapshot(); 103 + return; 104 + } 105 + 106 + const reader = new FileReader(); 107 + reader.onload = (e: any) => { 108 + const content = e.target.result; 109 + if (ext === 'html' || ext === 'htm') { 110 + editor.commands.setContent(content); 111 + } else if (ext === 'md') { 112 + const html = markdownToHtml(content); 113 + editor.commands.setContent(html); 114 + } else { 115 + editor.commands.insertContent(content); 116 + } 117 + showToast(`Imported "${file.name}" successfully`, 3000); 118 + provider._saveSnapshot(); 119 + }; 120 + reader.readAsText(file); 121 + } 122 + 123 + export function importFile(editor: any, provider: any): void { 124 + const input = document.createElement('input'); 125 + input.type = 'file'; 126 + input.accept = '.txt,.html,.htm,.md,.docx'; 127 + input.addEventListener('change', () => { 128 + if (input.files!.length > 0) handleImportedFile(input.files![0], editor, provider); 129 + }); 130 + input.click(); 131 + } 132 + 133 + export function printDocument(): void { 134 + window.print(); 135 + } 136 + 137 + // ── Drag-and-drop ─────────────────────────────────────────── 138 + 139 + export function wireDragDrop(editor: any, provider: any): void { 140 + const editorContainer = document.querySelector('.editor-container'); 141 + if (!editorContainer) return; 142 + 143 + editorContainer.addEventListener('dragover', (e: Event) => { 144 + e.preventDefault(); 145 + editorContainer.classList.add('drag-over'); 146 + }); 147 + editorContainer.addEventListener('dragleave', () => { 148 + editorContainer.classList.remove('drag-over'); 149 + }); 150 + editorContainer.addEventListener('drop', (e: any) => { 151 + e.preventDefault(); 152 + editorContainer.classList.remove('drag-over'); 153 + if (e.dataTransfer.files.length > 0) handleImportedFile(e.dataTransfer.files[0], editor, provider); 154 + }); 155 + } 156 + 157 + // ── Wire export/import toolbar buttons ────────────────────── 158 + 159 + export function wireExportImportButtons(deps: ExportImportDeps): { 160 + exportHTML: () => void; 161 + doExportPdf: () => void; 162 + doExportDocx: () => void; 163 + importFile: () => void; 164 + printDocument: () => void; 165 + } { 166 + const { editor, provider, titleInput, $, closeAllDropdowns: closeAll } = deps; 167 + 168 + const doExport = () => exportHTML(editor, titleInput); 169 + const doPdf = () => doExportPdf(editor, titleInput); 170 + const doDocx = () => doExportDocx(editor, titleInput); 171 + const doImport = () => importFile(editor, provider); 172 + 173 + $('tb-export-html').addEventListener('click', () => { closeAll(); doExport(); }); 174 + $('tb-export-md').addEventListener('click', () => { closeAll(); exportMarkdown(editor, titleInput); }); 175 + $('tb-export-txt').addEventListener('click', () => { closeAll(); exportText(editor, titleInput); }); 176 + $('tb-export-pdf').addEventListener('click', () => { closeAll(); doPdf(); }); 177 + $('tb-export-docx').addEventListener('click', () => { closeAll(); doDocx(); }); 178 + $('tb-import').addEventListener('click', () => { closeAll(); doImport(); }); 179 + $('tb-print').addEventListener('click', () => { closeAll(); printDocument(); }); 180 + 181 + wireDragDrop(editor, provider); 182 + 183 + return { 184 + exportHTML: doExport, 185 + doExportPdf: doPdf, 186 + doExportDocx: doDocx, 187 + importFile: doImport, 188 + printDocument, 189 + }; 190 + }
+274
src/docs/keyboard-shortcuts.ts
··· 1 + /** 2 + * Keyboard Shortcuts — shortcut definitions, cheatsheet modal, find bar UI, 3 + * and global keyboard event handlers. 4 + * 5 + * Extracted from main.ts for decomposition. 6 + */ 7 + 8 + // ── Types ─────────────────────────────────────────────────── 9 + 10 + export interface ShortcutCategory { 11 + category: string; 12 + shortcuts: Array<{ keys: string[]; label: string }>; 13 + } 14 + 15 + export interface KeyboardShortcutDeps { 16 + editor: any; 17 + provider: any; 18 + $: (id: string) => HTMLElement; 19 + exportHTML: () => void; 20 + doExportPdf: () => void; 21 + printDocument: () => void; 22 + mdToggle: { toggle: () => void }; 23 + showShortcutModal: () => void; 24 + } 25 + 26 + export interface FindBarDeps { 27 + editor: any; 28 + $: (id: string) => HTMLElement; 29 + } 30 + 31 + // ── Shortcut definitions ──────────────────────────────────── 32 + 33 + export const DOCS_SHORTCUTS: ShortcutCategory[] = [ 34 + { category: 'Formatting', shortcuts: [ 35 + { keys: ['\u2318', 'B'], label: 'Bold' }, 36 + { keys: ['\u2318', 'I'], label: 'Italic' }, 37 + { keys: ['\u2318', 'U'], label: 'Underline' }, 38 + { keys: ['\u2318', 'E'], label: 'Inline code' }, 39 + { keys: ['\u2318', '\u21e7', 'X'], label: 'Strikethrough' }, 40 + ]}, 41 + { category: 'Structure', shortcuts: [ 42 + { keys: ['\u2318', '\u21e7', '7'], label: 'Ordered list' }, 43 + { keys: ['\u2318', '\u21e7', '8'], label: 'Bullet list' }, 44 + { keys: ['\u2318', '\u21e7', '9'], label: 'Task list' }, 45 + { keys: ['\u2318', '\u21e7', 'B'], label: 'Blockquote' }, 46 + { keys: ['\u2318', '\u2325', 'C'], label: 'Code block' }, 47 + { keys: ['Tab'], label: 'Indent' }, 48 + { keys: ['\u21e7', 'Tab'], label: 'Outdent' }, 49 + { keys: ['\u2318', 'Enter'], label: 'Page break' }, 50 + ]}, 51 + { category: 'Markdown Autoformat', shortcuts: [ 52 + { keys: ['#', 'Space'], label: 'Heading 1' }, 53 + { keys: ['##', 'Space'], label: 'Heading 2' }, 54 + { keys: ['###', 'Space'], label: 'Heading 3' }, 55 + { keys: ['```', 'Space'], label: 'Code block' }, 56 + { keys: ['>', 'Space'], label: 'Blockquote' }, 57 + { keys: ['-', 'Space'], label: 'Bullet list' }, 58 + { keys: ['1.', 'Space'], label: 'Numbered list' }, 59 + { keys: ['[]', 'Space'], label: 'Task list' }, 60 + { keys: ['---'], label: 'Horizontal rule' }, 61 + { keys: ['**text**'], label: 'Bold' }, 62 + { keys: ['*text*'], label: 'Italic' }, 63 + { keys: ['~~text~~'], label: 'Strikethrough' }, 64 + { keys: ['`text`'], label: 'Inline code' }, 65 + { keys: ['[text](url)'], label: 'Link' }, 66 + ]}, 67 + { category: 'Navigation', shortcuts: [ 68 + { keys: ['\u2318', 'A'], label: 'Select all' }, 69 + { keys: ['\u2318', 'Z'], label: 'Undo' }, 70 + { keys: ['\u2318', '\u21e7', 'Z'], label: 'Redo' }, 71 + ]}, 72 + { category: 'Document', shortcuts: [ 73 + { keys: ['\u2318', 'S'], label: 'Save snapshot' }, 74 + { keys: ['\u2318', '\u21e7', 'S'], label: 'Export HTML' }, 75 + { keys: ['\u2318', '\u21e7', 'P'], label: 'Export PDF' }, 76 + { keys: ['\u2318', '\u21e7', 'M'], label: 'Toggle markdown source' }, 77 + { keys: ['\u2318', 'P'], label: 'Print' }, 78 + { keys: ['\u2318', '/'], label: 'Keyboard shortcuts' }, 79 + ]}, 80 + ]; 81 + 82 + // ── Shortcut Modal ────────────────────────────────────────── 83 + 84 + export function buildShortcutModal(shortcuts: ShortcutCategory[]): HTMLElement { 85 + const overlay = document.createElement('div'); 86 + overlay.className = 'modal-overlay'; 87 + const modal = document.createElement('div'); 88 + modal.className = 'modal shortcuts-modal'; 89 + let html = '<h2>Keyboard Shortcuts <button class="shortcuts-modal-close" title="Close (Escape)">\u2715</button></h2>'; 90 + for (const cat of shortcuts) { 91 + html += '<div class="shortcut-category"><div class="shortcut-category-title">' + cat.category + '</div>'; 92 + for (const sc of cat.shortcuts) { 93 + html += '<div class="shortcut-row"><span class="shortcut-label">' + sc.label + '</span><span class="shortcut-keys">'; 94 + html += sc.keys.map(k => '<span class="shortcut-key">' + k + '</span>').join(''); 95 + html += '</span></div>'; 96 + } 97 + html += '</div>'; 98 + } 99 + modal.innerHTML = html; 100 + overlay.appendChild(modal); 101 + const close = () => overlay.remove(); 102 + modal.querySelector('.shortcuts-modal-close')!.addEventListener('click', close); 103 + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); 104 + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', handler); } }; 105 + document.addEventListener('keydown', handler); 106 + return overlay; 107 + } 108 + 109 + export function showShortcutModal(): void { 110 + if (document.querySelector('.shortcuts-modal')) return; 111 + document.body.appendChild(buildShortcutModal(DOCS_SHORTCUTS)); 112 + } 113 + 114 + // ── Find Bar ──────────────────────────────────────────────── 115 + 116 + const FIND_BAR_HTML = '<div class="find-bar" id="find-bar" style="display:none">' + 117 + '<div class="find-bar-row">' + 118 + '<input class="find-bar-input" id="find-input" type="text" placeholder="Find..." spellcheck="false">' + 119 + '<span class="find-bar-count" id="find-count"></span>' + 120 + '<button class="btn-icon find-bar-btn" id="find-prev" title="Previous">\u25b2</button>' + 121 + '<button class="btn-icon find-bar-btn" id="find-next" title="Next (Enter)">\u25bc</button>' + 122 + '<button class="btn-icon find-bar-btn" id="find-case" title="Case sensitive">Aa</button>' + 123 + '<button class="btn-icon find-bar-btn" id="find-replace-toggle" title="Toggle replace">\u21c4</button>' + 124 + '<button class="btn-icon find-bar-btn" id="find-close" title="Close (Esc)">\u00d7</button>' + 125 + '</div>' + 126 + '<div class="find-bar-row find-bar-replace" id="find-replace-row" style="display:none">' + 127 + '<input class="find-bar-input" id="replace-input" type="text" placeholder="Replace..." spellcheck="false">' + 128 + '<button class="btn-icon find-bar-btn" id="replace-one" title="Replace">Replace</button>' + 129 + '<button class="btn-icon find-bar-btn" id="replace-all" title="Replace all">All</button>' + 130 + '</div>' + 131 + '</div>'; 132 + 133 + export function insertFindBar(): void { 134 + const editorContainerEl = document.querySelector('.editor-container'); 135 + if (editorContainerEl) { 136 + editorContainerEl.insertAdjacentHTML('afterbegin', FIND_BAR_HTML); 137 + } 138 + } 139 + 140 + export function wireFindBar(deps: FindBarDeps): { updateFindBar: () => void } { 141 + const { editor, $ } = deps; 142 + const findBar = $('find-bar'); 143 + const findInput = $('find-input') as HTMLInputElement; 144 + const replaceInput = $('replace-input') as HTMLInputElement; 145 + const findCount = $('find-count'); 146 + const findReplaceRow = $('find-replace-row'); 147 + 148 + function updateFindBar() { 149 + const storage = editor.storage.searchReplace; 150 + findBar.style.display = storage.isOpen ? '' : 'none'; 151 + findReplaceRow.style.display = storage.showReplace ? '' : 'none'; 152 + 153 + if (storage.matches.length > 0) { 154 + findCount.textContent = (storage.activeIndex + 1) + ' / ' + storage.matches.length; 155 + } else if (storage.searchTerm) { 156 + findCount.textContent = 'No results'; 157 + } else { 158 + findCount.textContent = ''; 159 + } 160 + 161 + $('find-case').classList.toggle('active', storage.caseSensitive); 162 + 163 + if (storage.isOpen && document.activeElement !== findInput && document.activeElement !== replaceInput) { 164 + findInput.focus(); 165 + findInput.select(); 166 + } 167 + } 168 + 169 + findInput.addEventListener('input', () => { 170 + editor.commands.setSearchTerm(findInput.value); 171 + }); 172 + 173 + findInput.addEventListener('keydown', (e: KeyboardEvent) => { 174 + if (e.key === 'Enter') { 175 + e.preventDefault(); 176 + if (e.shiftKey) { 177 + editor.commands.prevMatch(); 178 + } else { 179 + editor.commands.nextMatch(); 180 + } 181 + } else if (e.key === 'Escape') { 182 + e.preventDefault(); 183 + editor.commands.closeSearch(); 184 + editor.commands.focus(); 185 + } 186 + }); 187 + 188 + replaceInput.addEventListener('input', () => { 189 + editor.commands.setReplaceTerm(replaceInput.value); 190 + }); 191 + 192 + replaceInput.addEventListener('keydown', (e: KeyboardEvent) => { 193 + if (e.key === 'Enter') { 194 + e.preventDefault(); 195 + editor.commands.replaceCurrent(); 196 + } else if (e.key === 'Escape') { 197 + e.preventDefault(); 198 + editor.commands.closeSearch(); 199 + editor.commands.focus(); 200 + } 201 + }); 202 + 203 + $('find-next').addEventListener('click', () => editor.commands.nextMatch()); 204 + $('find-prev').addEventListener('click', () => editor.commands.prevMatch()); 205 + $('find-case').addEventListener('click', () => editor.commands.toggleCaseSensitive()); 206 + $('find-close').addEventListener('click', () => { 207 + editor.commands.closeSearch(); 208 + editor.commands.focus(); 209 + }); 210 + $('find-replace-toggle').addEventListener('click', () => { 211 + const storage = editor.storage.searchReplace; 212 + storage.showReplace = !storage.showReplace; 213 + updateFindBar(); 214 + }); 215 + $('replace-one').addEventListener('click', () => editor.commands.replaceCurrent()); 216 + $('replace-all').addEventListener('click', () => editor.commands.replaceAll()); 217 + 218 + return { updateFindBar }; 219 + } 220 + 221 + // ── Global keyboard shortcuts ─────────────────────────────── 222 + 223 + export function wireGlobalShortcuts(deps: KeyboardShortcutDeps): void { 224 + const { editor, provider, $, exportHTML, doExportPdf, printDocument, mdToggle, showShortcutModal: showModal } = deps; 225 + 226 + document.addEventListener('keydown', (e: KeyboardEvent) => { 227 + const mod = e.metaKey || e.ctrlKey; 228 + if (mod && e.key === '/') { 229 + e.preventDefault(); 230 + showModal(); 231 + return; 232 + } 233 + if (mod && !e.shiftKey && e.key.toLowerCase() === 'f') { 234 + e.preventDefault(); 235 + editor.commands.openSearch(); 236 + return; 237 + } 238 + if (mod && e.key.toLowerCase() === 'h') { 239 + e.preventDefault(); 240 + editor.commands.openSearchReplace(); 241 + return; 242 + } 243 + if (mod && e.shiftKey && e.key.toLowerCase() === 'g') { 244 + if (editor.storage.searchReplace.isOpen) { 245 + e.preventDefault(); 246 + editor.commands.prevMatch(); 247 + return; 248 + } 249 + } 250 + if (mod && e.key.toLowerCase() === 'g') { 251 + if (editor.storage.searchReplace.isOpen) { 252 + e.preventDefault(); 253 + editor.commands.nextMatch(); 254 + return; 255 + } 256 + } 257 + if (mod && e.shiftKey && e.key.toLowerCase() === 's') { 258 + e.preventDefault(); 259 + exportHTML(); 260 + } else if (mod && e.key.toLowerCase() === 's') { 261 + e.preventDefault(); 262 + provider._saveSnapshot(); 263 + } else if (mod && e.shiftKey && e.key.toLowerCase() === 'p') { 264 + e.preventDefault(); 265 + doExportPdf(); 266 + } else if (mod && e.key.toLowerCase() === 'p') { 267 + e.preventDefault(); 268 + printDocument(); 269 + } else if (mod && e.shiftKey && e.key.toLowerCase() === 'm') { 270 + e.preventDefault(); 271 + mdToggle.toggle(); 272 + } 273 + }); 274 + }
+72 -1171
src/docs/main.ts
··· 48 48 import { TabSupport } from './tab-support.js'; 49 49 import { MarkdownAutoformat } from './extensions/markdown-autoformat.js'; 50 50 import { WikiLink } from './extensions/wiki-link.js'; 51 - import { exportPdf } from './pdf-export.js'; 52 - import { exportDocx } from './docx-export.js'; 53 - import { importDocx, isValidDocx } from './docx-import.js'; 54 51 import { markdownToHtml } from './markdown-parser.js'; 55 52 import { looksLikeMarkdown, htmlContainsRawMarkdown } from './markdown-paste.js'; 56 53 import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; 57 54 import { createMarkdownToggle, TOGGLE_MODE } from './markdown-toggle.js'; 58 - import { VersionManager, computeWordCount } from '../lib/version-history.js'; 59 55 import { createVersionPanel } from '../version-panel.js'; 60 56 import { extractHeadings, computeViewportIndicator } from './minimap.js'; 61 57 import { ··· 82 78 } from '../lib/follow-mode.js'; 83 79 import { SuggestionManager, createSuggestionAttrs } from '../lib/suggesting.js'; 84 80 import { OfflineManager } from '../lib/offline.js'; 85 - import { extractHeadings, OutlineState } from './outline.js'; 81 + import { extractHeadings as extractOutlineHeadings, OutlineState } from './outline.js'; 86 82 import { TableToolbarState } from './table-toolbar.js'; 87 83 import { LinkPreviewState, truncateUrl, computeTooltipPosition } from './link-preview.js'; 88 84 import { ··· 96 92 import { ZenModeState, ZEN_STORAGE_KEY, ZEN_CLASS, ZEN_TRANSITION_MS } from './zen-mode.js'; 97 93 import { SLASH_COMMAND_ITEMS, SlashMenuState, filterCommands, PLACEHOLDER_EMPTY, PLACEHOLDER_BLOCK } from './slash-menu.js'; 98 94 import { createSlashCommands, getCommandExecutor } from './extensions/slash-commands.js'; 99 - import { BlockHandleState, BLOCK_HANDLE_ACTIONS, TURN_INTO_ITEMS, filterTurnIntoItems, BLOCK_HANDLE_ICON, BLOCK_HANDLE_ADD_ICON } from './block-handle.js'; 100 95 import { createCommandPalette, type PaletteAction } from '../command-palette.js'; 101 96 97 + // --- Extracted modules --- 98 + import { 99 + closeAllDropdowns as _closeAllDropdowns, 100 + toggleDropdown as _toggleDropdown, 101 + wireDropdownCloseHandlers, 102 + wireToolbarButtons, 103 + wireInlineComments, 104 + } from './toolbar-wiring.js'; 105 + import { 106 + DOCS_SHORTCUTS, 107 + showShortcutModal, 108 + insertFindBar, 109 + wireFindBar, 110 + wireGlobalShortcuts, 111 + } from './keyboard-shortcuts.js'; 112 + import { 113 + showToast, 114 + handleImportedFile, 115 + wireExportImportButtons, 116 + } from './export-import.js'; 117 + import { wireVersionHistory, wireShareDialog } from './version-history-ui.js'; 118 + import { wireBlockHandleUI } from './block-handle-ui.js'; 119 + 102 120 // --- Resolve document ID and encryption key --- 103 121 const pathParts = location.pathname.split('/').filter(Boolean); 104 122 const docId = pathParts[1]; ··· 210 228 SuggestionInsert, 211 229 SuggestionDelete, 212 230 SearchReplace.configure({ 213 - onStateChange: () => updateFindBar(), 231 + onStateChange: () => findBarResult.updateFindBar(), 214 232 }), 215 233 TabSupport, 216 234 MarkdownAutoformat, ··· 381 399 slashMenuCommandRef = null; 382 400 } 383 401 384 - // --- Block Handle --- 385 - const blockHandleState = new BlockHandleState(); 386 - 387 - const blockHandleEl = document.createElement('div'); 388 - blockHandleEl.className = 'block-handle'; 389 - blockHandleEl.id = 'block-handle'; 390 - blockHandleEl.style.display = 'none'; 391 - blockHandleEl.innerHTML = `<button class="block-handle-add" title="Add block below">${BLOCK_HANDLE_ADD_ICON}</button><button class="block-handle-grip" title="Drag to reorder / Click for options">${BLOCK_HANDLE_ICON}</button>`; 392 - document.body.appendChild(blockHandleEl); 393 - 394 - // Block handle context menu 395 - const blockContextMenuEl = document.createElement('div'); 396 - blockContextMenuEl.className = 'block-context-menu'; 397 - blockContextMenuEl.id = 'block-context-menu'; 398 - blockContextMenuEl.style.display = 'none'; 399 - document.body.appendChild(blockContextMenuEl); 400 - 401 - // Block handle: show on hover near editor blocks 402 - const editorEl = document.getElementById('editor'); 403 - let blockHandleTimeout: ReturnType<typeof setTimeout> | null = null; 404 - 405 - function showBlockHandle(blockElement: Element, pos: number): void { 406 - if (!blockElement) return; 407 - const editorRect = editorEl.getBoundingClientRect(); 408 - const blockRect = blockElement.getBoundingClientRect(); 409 - const top = blockRect.top; 410 - const left = editorRect.left - 36; 411 - blockHandleState.show({ top, left }, pos); 412 - blockHandleEl.style.display = 'flex'; 413 - blockHandleEl.style.top = `${top}px`; 414 - blockHandleEl.style.left = `${Math.max(4, left)}px`; 415 - } 416 - 417 - function hideBlockHandle() { 418 - blockHandleState.hide(); 419 - blockHandleEl.style.display = 'none'; 420 - blockContextMenuEl.style.display = 'none'; 421 - } 422 - 423 - function renderBlockContextMenu() { 424 - let html = ''; 425 - for (const action of BLOCK_HANDLE_ACTIONS) { 426 - html += `<button class="block-context-item" data-action="${action.id}">`; 427 - html += `<span class="block-context-icon">${action.icon}</span>`; 428 - html += `<span class="block-context-label">${action.label}</span>`; 429 - html += `</button>`; 430 - } 431 - blockContextMenuEl.innerHTML = html; 432 - blockContextMenuEl.style.display = 'block'; 433 - 434 - const pos = blockHandleState.position; 435 - if (pos) { 436 - blockContextMenuEl.style.top = `${pos.top + 24}px`; 437 - blockContextMenuEl.style.left = `${pos.left}px`; 438 - } 439 - 440 - // Wire context menu actions 441 - blockContextMenuEl.querySelectorAll('.block-context-item').forEach(btn => { 442 - btn.addEventListener('mousedown', (e) => { 443 - e.preventDefault(); 444 - const actionId = btn.dataset.action; 445 - executeBlockAction(actionId); 446 - }); 447 - }); 448 - } 449 - 450 - function executeBlockAction(actionId: string): void { 451 - const pos = blockHandleState.blockPos; 452 - if (pos == null) return; 453 - 454 - switch (actionId) { 455 - case 'delete': 456 - editor.chain().focus().deleteNode(editor.state.doc.resolve(pos).parent.type.name).run(); 457 - blockContextMenuEl.style.display = 'none'; 458 - hideBlockHandle(); 459 - break; 460 - case 'duplicate': { 461 - const node = editor.state.doc.resolve(pos).parent; 462 - const endPos = pos + node.nodeSize; 463 - editor.chain().focus().insertContentAt(endPos, node.toJSON()).run(); 464 - blockContextMenuEl.style.display = 'none'; 465 - break; 466 - } 467 - case 'moveUp': 468 - case 'moveDown': 469 - // Simple move: use TipTap's join commands or transaction 470 - blockContextMenuEl.style.display = 'none'; 471 - break; 472 - case 'turnInto': 473 - renderTurnIntoMenu(); 474 - break; 475 - default: 476 - blockContextMenuEl.style.display = 'none'; 477 - } 478 - } 402 + // --- Block Handle (extracted) --- 403 + const { blockHandleState } = wireBlockHandleUI({ editor, $: (id) => document.getElementById(id) as HTMLElement }); 479 404 480 - function renderTurnIntoMenu() { 481 - blockHandleState.openTurnIntoMenu(); 482 - let html = '<div class="block-context-sub-header">Turn into</div>'; 483 - for (const item of TURN_INTO_ITEMS) { 484 - html += `<button class="block-context-item" data-turn-into="${item.id}">`; 485 - html += `<span class="block-context-icon">${item.icon}</span>`; 486 - html += `<span class="block-context-label">${item.name}</span>`; 487 - html += `</button>`; 488 - } 489 - blockContextMenuEl.innerHTML = html; 490 - 491 - blockContextMenuEl.querySelectorAll('[data-turn-into]').forEach(btn => { 492 - btn.addEventListener('mousedown', (e) => { 493 - e.preventDefault(); 494 - const typeId = btn.dataset.turnInto; 495 - executeTurnInto(typeId); 496 - }); 497 - }); 498 - } 499 - 500 - function executeTurnInto(typeId: string): void { 501 - const executor = getCommandExecutor({ id: typeId }); 502 - if (executor) { 503 - executor(editor); 504 - } 505 - blockContextMenuEl.style.display = 'none'; 506 - blockHandleState.closeContextMenu(); 507 - } 508 - 509 - // Grip click -> context menu 510 - blockHandleEl.querySelector('.block-handle-grip').addEventListener('click', (e) => { 511 - e.stopPropagation(); 512 - if (blockHandleState.contextMenuOpen) { 513 - blockHandleState.closeContextMenu(); 514 - blockContextMenuEl.style.display = 'none'; 515 - } else { 516 - blockHandleState.openContextMenu(); 517 - renderBlockContextMenu(); 518 - } 519 - }); 520 - 521 - // Add button -> insert paragraph below 522 - blockHandleEl.querySelector('.block-handle-add').addEventListener('click', (e) => { 523 - e.stopPropagation(); 524 - const pos = blockHandleState.blockPos; 525 - if (pos != null) { 526 - const resolved = editor.state.doc.resolve(pos); 527 - const endOfBlock = pos + resolved.parent.nodeSize; 528 - editor.chain().focus().insertContentAt(endOfBlock, { type: 'paragraph' }).run(); 529 - } 530 - }); 531 - 532 - // Close block context menu when clicking outside 533 - document.addEventListener('click', (e) => { 534 - if (!e.target.closest('#block-context-menu') && !e.target.closest('#block-handle')) { 535 - blockHandleState.closeContextMenu(); 536 - blockContextMenuEl.style.display = 'none'; 537 - } 538 - }); 539 - 540 - // Track mouse position over editor to show block handles 541 - editorEl.addEventListener('mousemove', (e) => { 542 - if (blockHandleState.isHiddenInMode( 543 - document.querySelector('.app-shell.zen-mode') ? 'zen' : 'normal' 544 - )) { 545 - hideBlockHandle(); 546 - return; 547 - } 548 - 549 - clearTimeout(blockHandleTimeout); 550 - blockHandleTimeout = setTimeout(() => { 551 - // Find the nearest block element 552 - const target = e.target; 553 - const blockEl = target.closest('.ProseMirror > *'); 554 - if (!blockEl) { 555 - hideBlockHandle(); 556 - return; 557 - } 558 - 559 - // Get the ProseMirror position of this block 560 - const view = editor.view; 561 - const pos = view.posAtDOM(blockEl, 0); 562 - if (pos != null) { 563 - showBlockHandle(blockEl, pos); 564 - } 565 - }, 50); 566 - }); 567 - 568 - editorEl.addEventListener('mouseleave', () => { 569 - clearTimeout(blockHandleTimeout); 570 - // Only hide if context menu isn't open 571 - if (!blockHandleState.contextMenuOpen) { 572 - blockHandleTimeout = setTimeout(() => hideBlockHandle(), 300); 573 - } 574 - }); 575 - 576 - // Prevent hiding when hovering the handle itself 577 - blockHandleEl.addEventListener('mouseenter', () => { 578 - clearTimeout(blockHandleTimeout); 579 - }); 580 - 581 - // --- Toolbar wiring --- 405 + // --- Toolbar wiring (extracted) --- 582 406 const $ = (id: string): HTMLElement => document.getElementById(id) as HTMLElement; 583 407 584 - // --- Dropdown/overflow menu utilities --- 585 - function closeAllDropdowns() { 586 - document.querySelectorAll('.toolbar-dropdown.open, .toolbar-overflow.open').forEach(el => { 587 - el.classList.remove('open'); 588 - }); 589 - // Also close paragraph spacing sub-menu 590 - const paraSub = $('dd-para-spacing'); 591 - if (paraSub) paraSub.style.display = 'none'; 592 - } 593 - 594 - function toggleDropdown(dropdownEl) { 595 - const wasOpen = dropdownEl.classList.contains('open'); 596 - closeAllDropdowns(); 597 - if (!wasOpen) dropdownEl.classList.add('open'); 598 - } 599 - 600 - // Close dropdowns when clicking outside 601 - document.addEventListener('click', (e) => { 602 - if (!e.target.closest('.toolbar-dropdown') && !e.target.closest('.toolbar-overflow')) { 603 - closeAllDropdowns(); 604 - } 605 - }); 606 - 607 - // Close dropdowns on Escape 608 - document.addEventListener('keydown', (e) => { 609 - if (e.key === 'Escape') closeAllDropdowns(); 610 - }); 611 - 612 - // --- Alignment dropdown --- 613 - const alignDropdown = $('dd-align'); 614 - const alignToggle = $('tb-align-toggle'); 615 - // SVG icons for alignment states 616 - const ALIGN_SVGS = { 617 - left: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="3" x2="14" y2="3"/><line x1="2" y1="6.5" x2="10" y2="6.5"/><line x1="2" y1="10" x2="14" y2="10"/><line x1="2" y1="13.5" x2="10" y2="13.5"/></svg>', 618 - center: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="3" x2="14" y2="3"/><line x1="4" y1="6.5" x2="12" y2="6.5"/><line x1="2" y1="10" x2="14" y2="10"/><line x1="4" y1="13.5" x2="12" y2="13.5"/></svg>', 619 - right: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="3" x2="14" y2="3"/><line x1="6" y1="6.5" x2="14" y2="6.5"/><line x1="2" y1="10" x2="14" y2="10"/><line x1="6" y1="13.5" x2="14" y2="13.5"/></svg>', 620 - }; 621 - 622 - alignToggle.addEventListener('click', (e) => { 623 - e.stopPropagation(); 624 - toggleDropdown(alignDropdown); 625 - }); 626 - 627 - alignDropdown.querySelectorAll('[data-align]').forEach(btn => { 628 - btn.addEventListener('click', (e) => { 629 - e.stopPropagation(); 630 - const align = btn.dataset.align; 631 - editor.chain().focus().setTextAlign(align).run(); 632 - alignToggle.querySelector('.dd-icon').innerHTML = ALIGN_SVGS[align] || ALIGN_SVGS.left; 633 - closeAllDropdowns(); 634 - }); 635 - }); 636 - 637 - // --- List buttons (individual) --- 638 - $('tb-bullet-list').addEventListener('click', () => editor.chain().focus().toggleBulletList().run()); 639 - $('tb-ordered-list').addEventListener('click', () => editor.chain().focus().toggleOrderedList().run()); 640 - $('tb-task-list').addEventListener('click', () => editor.chain().focus().toggleTaskList().run()); 641 - 642 - // --- Overflow "More" menu --- 643 - const overflowMenu = $('overflow-menu'); 644 - const overflowToggle = $('overflow-toggle'); 645 - 646 - overflowToggle.addEventListener('click', (e) => { 647 - e.stopPropagation(); 648 - toggleDropdown(overflowMenu); 649 - }); 650 - 651 - // Text formatting 652 - $('tb-bold').addEventListener('click', () => editor.chain().focus().toggleBold().run()); 653 - $('tb-italic').addEventListener('click', () => editor.chain().focus().toggleItalic().run()); 654 - $('tb-underline').addEventListener('click', () => editor.chain().focus().toggleUnderline().run()); 655 - $('tb-strike').addEventListener('click', () => editor.chain().focus().toggleStrike().run()); 656 - $('tb-code').addEventListener('click', () => { editor.chain().focus().toggleCode().run(); closeAllDropdowns(); }); 657 - 658 - // More menu items 659 - $('tb-subscript').addEventListener('click', () => { 660 - editor.chain().focus().toggleSubscript().run(); 661 - closeAllDropdowns(); 662 - }); 663 - $('tb-superscript').addEventListener('click', () => { 664 - editor.chain().focus().toggleSuperscript().run(); 665 - closeAllDropdowns(); 666 - }); 667 - $('tb-hr').addEventListener('click', () => { 668 - editor.chain().focus().setHorizontalRule().run(); 669 - closeAllDropdowns(); 670 - }); 671 - 672 - // Blocks (in overflow menu) 673 - $('tb-blockquote').addEventListener('click', () => { editor.chain().focus().toggleBlockquote().run(); closeAllDropdowns(); }); 674 - $('tb-codeblock').addEventListener('click', () => { editor.chain().focus().toggleCodeBlock().run(); closeAllDropdowns(); }); 675 - 676 - // Heading select 677 - $('tb-heading').addEventListener('change', (e) => { 678 - const val = e.target.value; 679 - if (val === 'paragraph') { 680 - editor.chain().focus().setParagraph().run(); 681 - } else { 682 - editor.chain().focus().toggleHeading({ level: parseInt(val) }).run(); 683 - } 684 - }); 685 - 686 - // Font size select 687 - $('tb-font-size').addEventListener('change', (e) => { 688 - const val = e.target.value; 689 - if (val) { 690 - editor.chain().focus().setFontSize(val).run(); 691 - } else { 692 - editor.chain().focus().unsetFontSize().run(); 693 - } 694 - }); 695 - 696 - // Indent / Outdent 697 - $('tb-indent').addEventListener('click', () => { 698 - if (editor.isActive('listItem')) { 699 - editor.chain().focus().sinkListItem('listItem').run(); 700 - } else if (editor.isActive('taskItem')) { 701 - editor.chain().focus().sinkListItem('taskItem').run(); 702 - } else { 703 - editor.chain().focus().increaseIndent().run(); 704 - } 705 - }); 706 - $('tb-outdent').addEventListener('click', () => { 707 - if (editor.isActive('listItem')) { 708 - editor.chain().focus().liftListItem('listItem').run(); 709 - } else if (editor.isActive('taskItem')) { 710 - editor.chain().focus().liftListItem('taskItem').run(); 711 - } else { 712 - editor.chain().focus().decreaseIndent().run(); 713 - } 714 - }); 715 - 716 - // --- Line Spacing dropdown --- 717 - const lineSpacingDropdown = $('dd-line-spacing'); 718 - const lineSpacingToggle = $('tb-line-spacing-toggle'); 719 - 720 - lineSpacingToggle.addEventListener('click', (e) => { 721 - e.stopPropagation(); 722 - toggleDropdown(lineSpacingDropdown); 723 - }); 724 - 725 - lineSpacingDropdown.querySelectorAll('[data-line-spacing]').forEach(btn => { 726 - btn.addEventListener('click', (e) => { 727 - e.stopPropagation(); 728 - const value = btn.dataset.lineSpacing; 729 - if (value === 'default') { 730 - editor.chain().focus().unsetLineSpacing().run(); 731 - } else { 732 - editor.chain().focus().setLineSpacing(value).run(); 733 - } 734 - closeAllDropdowns(); 735 - }); 736 - }); 737 - 738 - // --- Paragraph Spacing (sub-menu from overflow) --- 739 - const paraSpacingDropdown = $('dd-para-spacing'); 740 - const paraSpacingTrigger = $('tb-para-spacing-toggle'); 741 - 742 - paraSpacingTrigger.addEventListener('click', (e) => { 743 - e.stopPropagation(); 744 - // Position the sub-menu near the trigger 745 - const rect = paraSpacingTrigger.getBoundingClientRect(); 746 - paraSpacingDropdown.style.display = ''; 747 - paraSpacingDropdown.style.top = rect.top + 'px'; 748 - paraSpacingDropdown.style.left = (rect.right + 4) + 'px'; 749 - // Close the overflow menu 750 - overflowMenu.classList.remove('open'); 751 - }); 752 - 753 - paraSpacingDropdown.querySelectorAll('[data-para-spacing]').forEach(btn => { 754 - btn.addEventListener('click', (e) => { 755 - e.stopPropagation(); 756 - const value = btn.dataset.paraSpacing; 757 - if (value === 'default') { 758 - editor.chain().focus().unsetParagraphSpacing().run(); 759 - } else { 760 - editor.chain().focus().setParagraphSpacing(value).run(); 761 - } 762 - closeAllDropdowns(); 763 - }); 764 - }); 765 - 766 - // --- Page Break button --- 767 - $('tb-page-break').addEventListener('click', () => { 768 - editor.chain().focus().insertPageBreak().run(); 769 - closeAllDropdowns(); 770 - }); 771 - 772 - // --- Spell Check toggle --- 773 - const spellCheckBtn = $('tb-spellcheck'); 774 - let spellCheckEnabled = localStorage.getItem('tools-spellcheck') !== 'false'; // default: true 775 - 776 - function applySpellCheck() { 777 - const editorEl = document.querySelector('.ProseMirror'); 778 - if (editorEl) { 779 - editorEl.setAttribute('spellcheck', String(spellCheckEnabled)); 780 - } 781 - spellCheckBtn.classList.toggle('active', spellCheckEnabled); 782 - spellCheckBtn.title = spellCheckEnabled ? 'Disable spell check' : 'Enable spell check'; 783 - } 784 - 785 - spellCheckBtn.addEventListener('click', () => { 786 - spellCheckEnabled = !spellCheckEnabled; 787 - localStorage.setItem('tools-spellcheck', String(spellCheckEnabled)); 788 - applySpellCheck(); 789 - closeAllDropdowns(); 790 - }); 791 - 792 - // Apply spell check setting once editor is ready 793 - requestAnimationFrame(applySpellCheck); 794 - 795 - // --- Inline Comments --- 796 - $('tb-comment').addEventListener('click', () => { 797 - const { from, to } = editor.state.selection; 798 - if (from === to) return; // No selection 799 - const commentText = prompt('Add a comment:'); 800 - if (!commentText) return; 801 - const commentId = crypto.randomUUID(); 802 - const timestamp = new Date().toISOString(); 803 - editor.chain().focus().setComment({ 804 - commentId, 805 - author: userName, 806 - timestamp, 807 - text: commentText, 808 - }).run(); 809 - }); 810 - 811 - // Comment popover logic 812 - const commentPopover = $('comment-popover'); 813 - const commentAuthorEl = $('comment-author'); 814 - const commentTimeEl = $('comment-time'); 815 - const commentTextEl = $('comment-text'); 816 - let activeCommentMark: { id: string; element: Element } | null = null; 817 - 818 - document.addEventListener('click', (e) => { 819 - const commentEl = e.target.closest('.comment-mark'); 820 - if (commentEl) { 821 - const id = commentEl.getAttribute('data-comment-id'); 822 - const author = commentEl.getAttribute('data-comment-author'); 823 - const timestamp = commentEl.getAttribute('data-comment-timestamp'); 824 - const text = commentEl.getAttribute('data-comment-text'); 825 - 826 - commentAuthorEl.textContent = author || 'Unknown'; 827 - commentTimeEl.textContent = timestamp ? new Date(timestamp).toLocaleString() : ''; 828 - commentTextEl.textContent = text || ''; 829 - activeCommentMark = { id, element: commentEl }; 830 - 831 - const rect = commentEl.getBoundingClientRect(); 832 - commentPopover.style.display = ''; 833 - commentPopover.style.left = `${Math.min(rect.left, window.innerWidth - 260)}px`; 834 - commentPopover.style.top = `${rect.bottom + 4}px`; 835 - } else if (!e.target.closest('.comment-popover')) { 836 - commentPopover.style.display = 'none'; 837 - activeCommentMark = null; 838 - } 839 - }); 840 - 841 - function removeActiveComment() { 842 - if (!activeCommentMark) return; 843 - const commentId = activeCommentMark.id; 844 - const { doc } = editor.state; 845 - const commentMarkType = editor.schema.marks.comment; 846 - const positions = []; 847 - doc.descendants((node, pos) => { 848 - if (node.isText) { 849 - node.marks.forEach((mark) => { 850 - if (mark.type === commentMarkType && mark.attrs.commentId === commentId) { 851 - positions.push({ from: pos, to: pos + node.nodeSize }); 852 - } 853 - }); 854 - } 855 - }); 856 - editor.chain().focus().command(({ tr }) => { 857 - positions.forEach(({ from, to }) => { 858 - tr.removeMark(from, to, commentMarkType); 859 - }); 860 - return true; 861 - }).run(); 862 - commentPopover.style.display = 'none'; 863 - activeCommentMark = null; 864 - } 865 - 866 - $('comment-resolve').addEventListener('click', removeActiveComment); 867 - $('comment-delete').addEventListener('click', removeActiveComment); 868 - 869 - // Colors 870 - const textColorInput = $('tb-text-color'); 871 - const textColorSwatch = $('tb-text-color-swatch'); 872 - const highlightInput = $('tb-highlight'); 873 - const highlightSwatch = $('tb-highlight-swatch'); 874 - 875 - function updateColorSwatches() { 876 - if (textColorSwatch) textColorSwatch.style.background = textColorInput.value; 877 - if (highlightSwatch) highlightSwatch.style.background = highlightInput.value; 878 - } 879 - updateColorSwatches(); 880 - 881 - textColorInput.addEventListener('input', (e) => { 882 - editor.chain().focus().setColor(e.target.value).run(); 883 - updateColorSwatches(); 884 - }); 885 - highlightInput.addEventListener('input', (e) => { 886 - editor.chain().focus().toggleHighlight({ color: e.target.value }).run(); 887 - updateColorSwatches(); 888 - }); 889 - 890 - // Insert link 891 - $('tb-link').addEventListener('click', () => { 892 - const url = prompt('URL:'); 893 - if (url) editor.chain().focus().setLink({ href: url }).run(); 894 - }); 895 - 896 - // Insert image 897 - $('tb-image').addEventListener('click', () => { 898 - const url = prompt('Image URL:'); 899 - if (url) editor.chain().focus().setImage({ src: url }).run(); 900 - }); 901 - 902 - // Insert table 903 - $('tb-table').addEventListener('click', () => { 904 - editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); 905 - closeAllDropdowns(); 906 - }); 907 - 908 - // Undo / Redo 909 - $('tb-undo').addEventListener('click', () => editor.chain().focus().undo().run()); 910 - $('tb-redo').addEventListener('click', () => editor.chain().focus().redo().run()); 911 - 912 - // Update active states on selection change 913 - editor.on('selectionUpdate', updateToolbarState); 914 - editor.on('transaction', updateToolbarState); 915 - 916 - const FONT_SIZES = ['8px', '9px', '10px', '11px', '12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px', '36px', '48px', '72px']; 917 - 918 - function updateToolbarState() { 919 - const toggleClass = (id, active) => $(id).classList.toggle('active', active); 920 - toggleClass('tb-bold', editor.isActive('bold')); 921 - toggleClass('tb-italic', editor.isActive('italic')); 922 - toggleClass('tb-underline', editor.isActive('underline')); 923 - toggleClass('tb-strike', editor.isActive('strike')); 924 - toggleClass('tb-code', editor.isActive('code')); 925 - toggleClass('tb-blockquote', editor.isActive('blockquote')); 926 - toggleClass('tb-codeblock', editor.isActive('codeBlock')); 927 - 928 - // Update subscript/superscript active state in overflow menu 929 - const subActive = editor.isActive('subscript'); 930 - const supActive = editor.isActive('superscript'); 931 - $('tb-subscript').classList.toggle('active', subActive); 932 - $('tb-superscript').classList.toggle('active', supActive); 933 - 934 - // Update alignment dropdown icon and active states 935 - const alignItems = alignDropdown.querySelectorAll('[data-align]'); 936 - let currentAlign = 'left'; 937 - alignItems.forEach(item => { 938 - const isActive = editor.isActive({ textAlign: item.dataset.align }); 939 - item.classList.toggle('active', isActive); 940 - if (isActive) currentAlign = item.dataset.align; 941 - }); 942 - alignToggle.querySelector('.dd-icon').innerHTML = ALIGN_SVGS[currentAlign] || ALIGN_SVGS.left; 943 - alignToggle.classList.toggle('active', currentAlign !== 'left'); 944 - 945 - // Update individual list button active states 946 - toggleClass('tb-bullet-list', editor.isActive('bulletList')); 947 - toggleClass('tb-ordered-list', editor.isActive('orderedList')); 948 - toggleClass('tb-task-list', editor.isActive('taskList')); 949 - 950 - // Update heading dropdown 951 - const headingEl = $('tb-heading'); 952 - if (editor.isActive('heading', { level: 1 })) headingEl.value = '1'; 953 - else if (editor.isActive('heading', { level: 2 })) headingEl.value = '2'; 954 - else if (editor.isActive('heading', { level: 3 })) headingEl.value = '3'; 955 - else headingEl.value = 'paragraph'; 956 - 957 - // Update font size dropdown 958 - const fontSizeEl = $('tb-font-size'); 959 - const attrs = editor.getAttributes('textStyle'); 960 - const currentSize = attrs.fontSize || ''; 961 - fontSizeEl.value = FONT_SIZES.includes(currentSize) ? currentSize : ''; 962 - 963 - // Update line spacing dropdown active state 964 - const lineSpacingItems = lineSpacingDropdown.querySelectorAll('[data-line-spacing]'); 965 - const paraAttrs = editor.getAttributes('paragraph'); 966 - const headingAttrs = editor.getAttributes('heading'); 967 - const currentLineHeight = paraAttrs.lineHeight || headingAttrs.lineHeight || null; 968 - lineSpacingItems.forEach(item => { 969 - const val = item.dataset.lineSpacing; 970 - const isActive = (val === 'default' && !currentLineHeight) || val === currentLineHeight; 971 - item.classList.toggle('active', isActive); 972 - }); 973 - 974 - // Update paragraph spacing dropdown active state 975 - const paraSpacingItems = paraSpacingDropdown.querySelectorAll('[data-para-spacing]'); 976 - const currentParaSpacing = paraAttrs.paragraphSpacing || headingAttrs.paragraphSpacing || null; 977 - paraSpacingItems.forEach(item => { 978 - const val = item.dataset.paraSpacing; 979 - const isActive = (val === 'default' && !currentParaSpacing) || val === currentParaSpacing; 980 - item.classList.toggle('active', isActive); 981 - }); 982 - } 983 - 984 - // (Responsive toolbar collapse removed -- flat single-row toolbar with overflow menu) 408 + const closeAll = () => _closeAllDropdowns($); 409 + const toggle = (el: HTMLElement) => _toggleDropdown(el, closeAll); 410 + wireDropdownCloseHandlers(closeAll); 411 + wireToolbarButtons({ editor, closeAllDropdowns: closeAll, toggleDropdown: toggle, $ }); 412 + wireInlineComments({ editor, userName, $ }); 985 413 986 414 // --- Document title --- 987 - const titleInput = $('doc-title'); 415 + const titleInput = $('doc-title') as HTMLInputElement; 988 416 989 417 async function loadTitle() { 990 418 try { ··· 1045 473 .then(r => r.blob()) 1046 474 .then(async blob => { 1047 475 const file = new File([blob], pending.name, { type: blob.type }); 1048 - await handleImportedFile(file); 476 + await handleImportedFile(file, editor, provider); 1049 477 }) 1050 478 .finally(async () => { 1051 479 window.__importInProgress = false; ··· 1076 504 } 1077 505 }); 1078 506 1079 - // --- Download helper --- 1080 - function downloadFile(content: string, filename: string, mimeType: string): void { 1081 - const blob = new Blob([content], { type: mimeType }); 1082 - const url = URL.createObjectURL(blob); 1083 - const a = document.createElement('a'); 1084 - a.href = url; 1085 - a.download = filename; 1086 - document.body.appendChild(a); 1087 - a.click(); 1088 - document.body.removeChild(a); 1089 - URL.revokeObjectURL(url); 1090 - } 1091 - 1092 - // --- Export functions --- 1093 - function getDocFilename() { 1094 - const title = titleInput.value.trim() || 'Untitled Document'; 1095 - return title.replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_'); 1096 - } 1097 - 1098 - function exportHTML() { 1099 - const html = editor.getHTML(); 1100 - const title = titleInput.value.trim() || 'Untitled Document'; 1101 - const fullDoc = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n<title>' + title + '</title>\n<style>\n body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; color: #1a1815; }\n h1, h2, h3 { margin-top: 1.5em; margin-bottom: 0.5em; }\n blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 1em; color: #555; }\n pre { background: #f5f5f5; padding: 1em; border-radius: 4px; overflow-x: auto; }\n code { background: #f5f5f5; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }\n pre code { background: none; padding: 0; }\n table { border-collapse: collapse; width: 100%; }\n th, td { border: 1px solid #ddd; padding: 0.5em; text-align: left; }\n th { background: #f5f5f5; }\n hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }\n img { max-width: 100%; }\n ul[data-type="taskList"] { list-style: none; padding-left: 0; }\n ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5em; }\n</style>\n</head>\n<body>\n<h1>' + title + '</h1>\n' + html + '\n</body>\n</html>'; 1102 - downloadFile(fullDoc, getDocFilename() + '.html', 'text/html'); 1103 - } 1104 - 1105 - // Use turndown-based converter from markdown-export.js (replaces regex-based approach) 1106 - const htmlToMarkdown = turndownHtmlToMarkdown; 1107 - 1108 - function exportMarkdown() { 1109 - const html = editor.getHTML(); 1110 - const md = htmlToMarkdown(html); 1111 - downloadFile(md, getDocFilename() + '.md', 'text/markdown'); 1112 - } 1113 - 1114 - function exportText() { 1115 - const text = editor.getText(); 1116 - downloadFile(text, getDocFilename() + '.txt', 'text/plain'); 1117 - } 1118 - 1119 - // --- Toast notification --- 1120 - function showToast(message: string, duration = 3000): void { 1121 - const existing = document.querySelector('.toast-notification'); 1122 - if (existing) existing.remove(); 1123 - const toast = document.createElement('div'); 1124 - toast.className = 'toast-notification'; 1125 - toast.textContent = message; 1126 - document.body.appendChild(toast); 1127 - toast.offsetHeight; 1128 - toast.classList.add('toast-visible'); 1129 - setTimeout(() => { toast.classList.remove('toast-visible'); setTimeout(() => toast.remove(), 300); }, duration); 1130 - } 1131 - 1132 - // --- PDF Export --- 1133 - function doExportPdf() { 1134 - exportPdf({ 1135 - editorHtml: editor.getHTML(), 1136 - title: titleInput.value.trim() || 'Untitled Document', 1137 - }); 1138 - } 1139 - 1140 - // --- DOCX Export --- 1141 - function doExportDocx() { 1142 - exportDocx({ 1143 - editorHtml: editor.getHTML(), 1144 - title: titleInput.value.trim() || 'Untitled Document', 1145 - }); 1146 - } 1147 - 1148 - // --- Import functions --- 1149 - async function handleImportedFile(file: File): Promise<void> { 1150 - const ext = file.name.split('.').pop().toLowerCase(); 1151 - 1152 - // Handle .docx files via mammoth 1153 - if (ext === 'docx') { 1154 - await importDocx(file, editor, showToast); 1155 - // Force immediate save so imported data survives a refresh 1156 - await provider._saveSnapshot(); 1157 - return; 1158 - } 1159 - 1160 - // Handle text-based files (.html, .htm, .md, .txt) 1161 - const reader = new FileReader(); 1162 - reader.onload = (e) => { 1163 - const content = e.target.result; 1164 - if (ext === 'html' || ext === 'htm') { 1165 - editor.commands.setContent(content); 1166 - } else if (ext === 'md') { 1167 - // Parse markdown to HTML via markdown-it before inserting 1168 - const html = markdownToHtml(content); 1169 - editor.commands.setContent(html); 1170 - } else { 1171 - editor.commands.insertContent(content); 1172 - } 1173 - showToast(`Imported "${file.name}" successfully`, 3000); 1174 - // Force immediate save so imported data survives a refresh 1175 - provider._saveSnapshot(); 1176 - }; 1177 - reader.readAsText(file); 1178 - } 1179 - 1180 - function importFile() { 1181 - const input = document.createElement('input'); 1182 - input.type = 'file'; 1183 - input.accept = '.txt,.html,.htm,.md,.docx'; 1184 - input.addEventListener('change', () => { 1185 - if (input.files.length > 0) handleImportedFile(input.files[0]); 1186 - }); 1187 - input.click(); 1188 - } 1189 - 1190 - const editorContainer = document.querySelector('.editor-container'); 1191 - editorContainer.addEventListener('dragover', (e) => { 1192 - e.preventDefault(); 1193 - editorContainer.classList.add('drag-over'); 1194 - }); 1195 - editorContainer.addEventListener('dragleave', () => { 1196 - editorContainer.classList.remove('drag-over'); 1197 - }); 1198 - editorContainer.addEventListener('drop', (e) => { 1199 - e.preventDefault(); 1200 - editorContainer.classList.remove('drag-over'); 1201 - if (e.dataTransfer.files.length > 0) handleImportedFile(e.dataTransfer.files[0]); 507 + // --- Export/Import (extracted) --- 508 + const exportImport = wireExportImportButtons({ 509 + editor, 510 + provider, 511 + titleInput, 512 + $, 513 + closeAllDropdowns: closeAll, 1202 514 }); 1203 515 1204 - function printDocument() { window.print(); } 1205 - 1206 - $('tb-export-html').addEventListener('click', () => { closeAllDropdowns(); exportHTML(); }); 1207 - $('tb-export-md').addEventListener('click', () => { closeAllDropdowns(); exportMarkdown(); }); 1208 - $('tb-export-txt').addEventListener('click', () => { closeAllDropdowns(); exportText(); }); 1209 - $('tb-export-pdf').addEventListener('click', () => { closeAllDropdowns(); doExportPdf(); }); 1210 - $('tb-export-docx').addEventListener('click', () => { closeAllDropdowns(); doExportDocx(); }); 1211 - $('tb-import').addEventListener('click', () => { closeAllDropdowns(); importFile(); }); 1212 - $('tb-print').addEventListener('click', () => { closeAllDropdowns(); printDocument(); }); 1213 - 1214 516 // --- Autosave indicator (#17) --- 1215 517 const saveIndicator = $('save-indicator'); 1216 518 const saveText = $('save-text'); ··· 1371 673 // Delayed initial render 1372 674 setTimeout(updateMinimap, 500); 1373 675 1374 - // --- Keyboard Shortcut Cheatsheet Modal (#15) --- 1375 - const DOCS_SHORTCUTS = [ 1376 - { category: 'Formatting', shortcuts: [ 1377 - { keys: ['\u2318', 'B'], label: 'Bold' }, 1378 - { keys: ['\u2318', 'I'], label: 'Italic' }, 1379 - { keys: ['\u2318', 'U'], label: 'Underline' }, 1380 - { keys: ['\u2318', 'E'], label: 'Inline code' }, 1381 - { keys: ['\u2318', '\u21e7', 'X'], label: 'Strikethrough' }, 1382 - ]}, 1383 - { category: 'Structure', shortcuts: [ 1384 - { keys: ['\u2318', '\u21e7', '7'], label: 'Ordered list' }, 1385 - { keys: ['\u2318', '\u21e7', '8'], label: 'Bullet list' }, 1386 - { keys: ['\u2318', '\u21e7', '9'], label: 'Task list' }, 1387 - { keys: ['\u2318', '\u21e7', 'B'], label: 'Blockquote' }, 1388 - { keys: ['\u2318', '\u2325', 'C'], label: 'Code block' }, 1389 - { keys: ['Tab'], label: 'Indent' }, 1390 - { keys: ['\u21e7', 'Tab'], label: 'Outdent' }, 1391 - { keys: ['\u2318', 'Enter'], label: 'Page break' }, 1392 - ]}, 1393 - { category: 'Markdown Autoformat', shortcuts: [ 1394 - { keys: ['#', 'Space'], label: 'Heading 1' }, 1395 - { keys: ['##', 'Space'], label: 'Heading 2' }, 1396 - { keys: ['###', 'Space'], label: 'Heading 3' }, 1397 - { keys: ['```', 'Space'], label: 'Code block' }, 1398 - { keys: ['>', 'Space'], label: 'Blockquote' }, 1399 - { keys: ['-', 'Space'], label: 'Bullet list' }, 1400 - { keys: ['1.', 'Space'], label: 'Numbered list' }, 1401 - { keys: ['[]', 'Space'], label: 'Task list' }, 1402 - { keys: ['---'], label: 'Horizontal rule' }, 1403 - { keys: ['**text**'], label: 'Bold' }, 1404 - { keys: ['*text*'], label: 'Italic' }, 1405 - { keys: ['~~text~~'], label: 'Strikethrough' }, 1406 - { keys: ['`text`'], label: 'Inline code' }, 1407 - { keys: ['[text](url)'], label: 'Link' }, 1408 - ]}, 1409 - { category: 'Navigation', shortcuts: [ 1410 - { keys: ['\u2318', 'A'], label: 'Select all' }, 1411 - { keys: ['\u2318', 'Z'], label: 'Undo' }, 1412 - { keys: ['\u2318', '\u21e7', 'Z'], label: 'Redo' }, 1413 - ]}, 1414 - { category: 'Document', shortcuts: [ 1415 - { keys: ['\u2318', 'S'], label: 'Save snapshot' }, 1416 - { keys: ['\u2318', '\u21e7', 'S'], label: 'Export HTML' }, 1417 - { keys: ['\u2318', '\u21e7', 'P'], label: 'Export PDF' }, 1418 - { keys: ['\u2318', '\u21e7', 'M'], label: 'Toggle markdown source' }, 1419 - { keys: ['\u2318', 'P'], label: 'Print' }, 1420 - { keys: ['\u2318', '/'], label: 'Keyboard shortcuts' }, 1421 - ]}, 1422 - ]; 1423 - 1424 - function buildShortcutModal(shortcuts: Array<{ category: string; shortcuts: Array<{ keys: string[]; label: string }> }>): HTMLElement { 1425 - const overlay = document.createElement('div'); 1426 - overlay.className = 'modal-overlay'; 1427 - const modal = document.createElement('div'); 1428 - modal.className = 'modal shortcuts-modal'; 1429 - let html = '<h2>Keyboard Shortcuts <button class="shortcuts-modal-close" title="Close (Escape)">\u2715</button></h2>'; 1430 - for (const cat of shortcuts) { 1431 - html += '<div class="shortcut-category"><div class="shortcut-category-title">' + cat.category + '</div>'; 1432 - for (const sc of cat.shortcuts) { 1433 - html += '<div class="shortcut-row"><span class="shortcut-label">' + sc.label + '</span><span class="shortcut-keys">'; 1434 - html += sc.keys.map(k => '<span class="shortcut-key">' + k + '</span>').join(''); 1435 - html += '</span></div>'; 1436 - } 1437 - html += '</div>'; 1438 - } 1439 - modal.innerHTML = html; 1440 - overlay.appendChild(modal); 1441 - const close = () => overlay.remove(); 1442 - modal.querySelector('.shortcuts-modal-close').addEventListener('click', close); 1443 - overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); 1444 - const handler = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', handler); } }; 1445 - document.addEventListener('keydown', handler); 1446 - return overlay; 1447 - } 1448 - 1449 - function showShortcutModal() { 1450 - if (document.querySelector('.shortcuts-modal')) return; 1451 - document.body.appendChild(buildShortcutModal(DOCS_SHORTCUTS)); 1452 - } 1453 - 676 + // --- Keyboard Shortcuts (extracted) --- 1454 677 $('btn-shortcuts').addEventListener('click', showShortcutModal); 1455 678 1456 - // --- Find Bar UI --- 1457 - const findBarHtml = '<div class="find-bar" id="find-bar" style="display:none">' + 1458 - '<div class="find-bar-row">' + 1459 - '<input class="find-bar-input" id="find-input" type="text" placeholder="Find..." spellcheck="false">' + 1460 - '<span class="find-bar-count" id="find-count"></span>' + 1461 - '<button class="btn-icon find-bar-btn" id="find-prev" title="Previous">\u25b2</button>' + 1462 - '<button class="btn-icon find-bar-btn" id="find-next" title="Next (Enter)">\u25bc</button>' + 1463 - '<button class="btn-icon find-bar-btn" id="find-case" title="Case sensitive">Aa</button>' + 1464 - '<button class="btn-icon find-bar-btn" id="find-replace-toggle" title="Toggle replace">\u21c4</button>' + 1465 - '<button class="btn-icon find-bar-btn" id="find-close" title="Close (Esc)">\u00d7</button>' + 1466 - '</div>' + 1467 - '<div class="find-bar-row find-bar-replace" id="find-replace-row" style="display:none">' + 1468 - '<input class="find-bar-input" id="replace-input" type="text" placeholder="Replace..." spellcheck="false">' + 1469 - '<button class="btn-icon find-bar-btn" id="replace-one" title="Replace">Replace</button>' + 1470 - '<button class="btn-icon find-bar-btn" id="replace-all" title="Replace all">All</button>' + 1471 - '</div>' + 1472 - '</div>'; 679 + // --- Find Bar (extracted) --- 680 + insertFindBar(); 681 + const findBarResult = wireFindBar({ editor, $ }); 1473 682 1474 - // Insert find bar into the editor container (overlaid at top) 1475 - const editorContainerEl = document.querySelector('.editor-container'); 1476 - // Note: position:relative is set in CSS, not here (avoids stacking context issues with toolbar dropdowns) 1477 - editorContainerEl.insertAdjacentHTML('afterbegin', findBarHtml); 1478 - 1479 - const findBar = $('find-bar'); 1480 - const findInput = $('find-input'); 1481 - const replaceInput = $('replace-input'); 1482 - const findCount = $('find-count'); 1483 - const findReplaceRow = $('find-replace-row'); 1484 - 1485 - function updateFindBar() { 1486 - const storage = editor.storage.searchReplace; 1487 - findBar.style.display = storage.isOpen ? '' : 'none'; 1488 - findReplaceRow.style.display = storage.showReplace ? '' : 'none'; 1489 - 1490 - if (storage.matches.length > 0) { 1491 - findCount.textContent = (storage.activeIndex + 1) + ' / ' + storage.matches.length; 1492 - } else if (storage.searchTerm) { 1493 - findCount.textContent = 'No results'; 1494 - } else { 1495 - findCount.textContent = ''; 1496 - } 1497 - 1498 - $('find-case').classList.toggle('active', storage.caseSensitive); 1499 - 1500 - if (storage.isOpen && document.activeElement !== findInput && document.activeElement !== replaceInput) { 1501 - findInput.focus(); 1502 - findInput.select(); 1503 - } 1504 - } 1505 - 1506 - findInput.addEventListener('input', () => { 1507 - editor.commands.setSearchTerm(findInput.value); 1508 - }); 1509 - 1510 - findInput.addEventListener('keydown', (e) => { 1511 - if (e.key === 'Enter') { 1512 - e.preventDefault(); 1513 - if (e.shiftKey) { 1514 - editor.commands.prevMatch(); 1515 - } else { 1516 - editor.commands.nextMatch(); 1517 - } 1518 - } else if (e.key === 'Escape') { 1519 - e.preventDefault(); 1520 - editor.commands.closeSearch(); 1521 - editor.commands.focus(); 1522 - } 1523 - }); 1524 - 1525 - replaceInput.addEventListener('input', () => { 1526 - editor.commands.setReplaceTerm(replaceInput.value); 1527 - }); 1528 - 1529 - replaceInput.addEventListener('keydown', (e) => { 1530 - if (e.key === 'Enter') { 1531 - e.preventDefault(); 1532 - editor.commands.replaceCurrent(); 1533 - } else if (e.key === 'Escape') { 1534 - e.preventDefault(); 1535 - editor.commands.closeSearch(); 1536 - editor.commands.focus(); 1537 - } 1538 - }); 1539 - 1540 - $('find-next').addEventListener('click', () => editor.commands.nextMatch()); 1541 - $('find-prev').addEventListener('click', () => editor.commands.prevMatch()); 1542 - $('find-case').addEventListener('click', () => editor.commands.toggleCaseSensitive()); 1543 - $('find-close').addEventListener('click', () => { 1544 - editor.commands.closeSearch(); 1545 - editor.commands.focus(); 1546 - }); 1547 - $('find-replace-toggle').addEventListener('click', () => { 1548 - const storage = editor.storage.searchReplace; 1549 - storage.showReplace = !storage.showReplace; 1550 - updateFindBar(); 1551 - }); 1552 - $('replace-one').addEventListener('click', () => editor.commands.replaceCurrent()); 1553 - $('replace-all').addEventListener('click', () => editor.commands.replaceAll()); 1554 - 1555 - // --- Keyboard shortcuts --- 1556 - document.addEventListener('keydown', (e) => { 1557 - const mod = e.metaKey || e.ctrlKey; 1558 - if (mod && e.key === '/') { 1559 - e.preventDefault(); 1560 - showShortcutModal(); 1561 - return; 1562 - } 1563 - if (mod && !e.shiftKey && e.key.toLowerCase() === 'f') { 1564 - e.preventDefault(); 1565 - editor.commands.openSearch(); 1566 - updateFindBar(); 1567 - return; 1568 - } 1569 - if (mod && e.key.toLowerCase() === 'h') { 1570 - e.preventDefault(); 1571 - editor.commands.openSearchReplace(); 1572 - updateFindBar(); 1573 - return; 1574 - } 1575 - if (mod && e.shiftKey && e.key.toLowerCase() === 'g') { 1576 - if (editor.storage.searchReplace.isOpen) { 1577 - e.preventDefault(); 1578 - editor.commands.prevMatch(); 1579 - return; 1580 - } 1581 - } 1582 - if (mod && e.key.toLowerCase() === 'g') { 1583 - if (editor.storage.searchReplace.isOpen) { 1584 - e.preventDefault(); 1585 - editor.commands.nextMatch(); 1586 - return; 1587 - } 1588 - } 1589 - if (mod && e.shiftKey && e.key.toLowerCase() === 's') { 1590 - e.preventDefault(); 1591 - exportHTML(); 1592 - } else if (mod && e.key.toLowerCase() === 's') { 1593 - e.preventDefault(); 1594 - provider._saveSnapshot(); 1595 - } else if (mod && e.shiftKey && e.key.toLowerCase() === 'p') { 1596 - e.preventDefault(); 1597 - doExportPdf(); 1598 - } else if (mod && e.key.toLowerCase() === 'p') { 1599 - e.preventDefault(); 1600 - printDocument(); 1601 - } else if (mod && e.shiftKey && e.key.toLowerCase() === 'm') { 1602 - e.preventDefault(); 1603 - mdToggle.toggle(); 1604 - } 683 + // --- Keyboard shortcuts (extracted) --- 684 + wireGlobalShortcuts({ 685 + editor, 686 + provider, 687 + $, 688 + exportHTML: exportImport.exportHTML, 689 + doExportPdf: exportImport.doExportPdf, 690 + printDocument: exportImport.printDocument, 691 + mdToggle: { toggle: () => mdToggle.toggle() }, 692 + showShortcutModal, 1605 693 }); 1606 694 1607 695 // --- Markdown Source Toggle --- 1608 696 const mdToggleBtn = $('btn-md-toggle'); 1609 697 const markdownTextarea = $('markdown-source'); 1610 698 const editorWrapper = $('editor'); 699 + const htmlToMarkdown = turndownHtmlToMarkdown; 1611 700 1612 701 const mdToggle = createMarkdownToggle({ 1613 702 getEditorHtml: () => editor.getHTML(), ··· 1663 752 }); 1664 753 1665 754 // ============================================= 1666 - // --- Version History --- 755 + // --- Version History (extracted) --- 1667 756 // ============================================= 1668 - const versionManager = new VersionManager({ 1669 - maxVersions: 50, 1670 - editThreshold: 50, 1671 - timeThresholdMs: 5 * 60 * 1000, 1672 - }); 1673 - 1674 - const versionSidebar = $('version-sidebar'); 1675 - const versionList = $('version-list'); 1676 - const versionPreview = $('version-preview'); 1677 - const versionPreviewContent = $('version-preview-content'); 1678 - let selectedVersionId: string | null = null; 1679 - 1680 - // Track edits for version capture triggers 1681 - editor.on('update', () => { 1682 - versionManager.recordEdit(); 1683 - if (versionManager.shouldCapture()) { 1684 - captureVersion(); 1685 - } 1686 - }); 1687 - 1688 - // Also capture on a 5-minute interval if there have been any edits 1689 - setInterval(() => { 1690 - if (versionManager.shouldCapture()) { 1691 - captureVersion(); 1692 - } 1693 - }, 60_000); // Check every minute 1694 - 1695 - async function captureVersion() { 1696 - try { 1697 - const state = Y.encodeStateAsUpdate(ydoc); 1698 - const encrypted = await encrypt(state, cryptoKey); 1699 - const text = editor.getText(); 1700 - const wordCount = computeWordCount(text); 1701 - const metadata = JSON.stringify({ 1702 - author: userName, 1703 - wordCount, 1704 - timestamp: Date.now(), 1705 - }); 1706 - 1707 - await fetch(`/api/documents/${docId}/versions`, { 1708 - method: 'POST', 1709 - body: encrypted, 1710 - headers: { 1711 - 'Content-Type': 'application/octet-stream', 1712 - 'X-Version-Metadata': metadata, 1713 - }, 1714 - }); 1715 - 1716 - versionManager.addVersion(encrypted, { author: userName, wordCount }); 1717 - } catch (err) { 1718 - console.warn('Failed to capture version', err); 1719 - } 1720 - } 1721 - 1722 - // History button toggles sidebar 1723 - $('btn-history').addEventListener('click', () => { 1724 - const isOpen = versionSidebar.style.display !== 'none'; 1725 - if (isOpen) { 1726 - versionSidebar.style.display = 'none'; 1727 - } else { 1728 - versionSidebar.style.display = ''; 1729 - loadVersionList(); 1730 - } 1731 - }); 1732 - 1733 - $('version-sidebar-close').addEventListener('click', () => { 1734 - versionSidebar.style.display = 'none'; 1735 - versionPreview.style.display = 'none'; 1736 - }); 1737 - 1738 - // ── Share dialog ────────────────────────────────────────────────────────── 1739 - const shareDialog = $('share-dialog'); 1740 - const shareLinkInput = $('share-link-input') as HTMLInputElement; 757 + wireVersionHistory({ editor, ydoc, provider, docId, cryptoKey, userName, $ }); 1741 758 1742 - $('btn-share').addEventListener('click', () => { 1743 - shareLinkInput.value = window.location.href; 1744 - shareDialog.style.display = ''; 1745 - }); 1746 - 1747 - $('share-dialog-close').addEventListener('click', () => { 1748 - shareDialog.style.display = 'none'; 1749 - }); 1750 - 1751 - shareDialog.addEventListener('click', (e) => { 1752 - if (e.target === shareDialog) shareDialog.style.display = 'none'; 1753 - }); 1754 - 1755 - $('share-copy-link').addEventListener('click', async () => { 1756 - try { 1757 - await navigator.clipboard.writeText(shareLinkInput.value); 1758 - const btn = $('share-copy-link'); 1759 - btn.textContent = 'Copied!'; 1760 - setTimeout(() => { btn.textContent = 'Copy link'; }, 2000); 1761 - } catch { /* clipboard not available */ } 1762 - }); 1763 - 1764 - async function loadVersionList() { 1765 - try { 1766 - const res = await fetch(`/api/documents/${docId}/versions`); 1767 - const versions = await res.json(); 1768 - if (versions.length === 0) { 1769 - versionList.innerHTML = '<div class="version-empty">No versions yet</div>'; 1770 - return; 1771 - } 1772 - versionList.innerHTML = ''; 1773 - let prevWordCount = null; 1774 - // versions are newest-first from server 1775 - for (let i = versions.length - 1; i >= 0; i--) { 1776 - const v = versions[i]; 1777 - const wc = v.metadata?.wordCount ?? 0; 1778 - if (prevWordCount !== null) { 1779 - versions[i]._delta = wc - prevWordCount; 1780 - } else { 1781 - versions[i]._delta = wc; 1782 - } 1783 - prevWordCount = wc; 1784 - } 1785 - for (const v of versions) { 1786 - const item = document.createElement('button'); 1787 - item.className = 'version-item'; 1788 - const ts = v.created_at ? new Date(v.created_at + 'Z').toLocaleString() : 'Unknown'; 1789 - const author = v.metadata?.author || 'Unknown'; 1790 - const wc = v.metadata?.wordCount ?? '?'; 1791 - const delta = v._delta; 1792 - const deltaStr = delta > 0 ? `+${delta}` : `${delta}`; 1793 - const deltaClass = delta > 0 ? 'positive' : delta < 0 ? 'negative' : ''; 1794 - item.innerHTML = ` 1795 - <span class="version-time">${ts}</span> 1796 - <span class="version-meta"> 1797 - <span class="version-author">${author}</span> 1798 - <span class="version-wc">${wc} words</span> 1799 - <span class="version-delta ${deltaClass}">${deltaStr}</span> 1800 - </span> 1801 - `; 1802 - item.addEventListener('click', () => showVersionPreview(v.id)); 1803 - versionList.appendChild(item); 1804 - } 1805 - } catch (err) { 1806 - versionList.innerHTML = '<div class="version-empty">Failed to load versions</div>'; 1807 - } 1808 - } 1809 - 1810 - async function showVersionPreview(versionId: string): Promise<void> { 1811 - selectedVersionId = versionId; 1812 - versionPreview.style.display = ''; 1813 - versionPreviewContent.textContent = 'Loading...'; 1814 - try { 1815 - const res = await fetch(`/api/documents/${docId}/versions/${versionId}`); 1816 - if (!res.ok) throw new Error('Not found'); 1817 - const encrypted = new Uint8Array(await res.arrayBuffer()); 1818 - const decrypted = await decrypt(encrypted, cryptoKey); 1819 - // Create a temporary Y.Doc to decode the snapshot 1820 - const tempDoc = new Y.Doc(); 1821 - Y.applyUpdate(tempDoc, decrypted); 1822 - // Extract text from the Yjs XML fragment 1823 - const fragment = tempDoc.getXmlFragment('default'); 1824 - versionPreviewContent.innerHTML = ''; 1825 - // Render a simple text preview 1826 - const previewDiv = document.createElement('div'); 1827 - previewDiv.className = 'version-preview-text'; 1828 - previewDiv.textContent = fragment.toString(); 1829 - versionPreviewContent.appendChild(previewDiv); 1830 - tempDoc.destroy(); 1831 - } catch (err) { 1832 - versionPreviewContent.textContent = 'Failed to load version preview'; 1833 - } 1834 - } 1835 - 1836 - $('version-back').addEventListener('click', () => { 1837 - versionPreview.style.display = 'none'; 1838 - selectedVersionId = null; 1839 - }); 1840 - 1841 - $('version-restore').addEventListener('click', async () => { 1842 - if (!selectedVersionId) return; 1843 - if (!confirm('Restore this version? Current changes will be replaced.')) return; 1844 - try { 1845 - const res = await fetch(`/api/documents/${docId}/versions/${selectedVersionId}`); 1846 - if (!res.ok) throw new Error('Not found'); 1847 - const encrypted = new Uint8Array(await res.arrayBuffer()); 1848 - const decrypted = await decrypt(encrypted, cryptoKey); 1849 - // Apply the old state as an update 1850 - Y.applyUpdate(ydoc, decrypted); 1851 - // Save immediately 1852 - await provider._saveSnapshot(); 1853 - versionPreview.style.display = 'none'; 1854 - versionSidebar.style.display = 'none'; 1855 - selectedVersionId = null; 1856 - } catch (err) { 1857 - alert('Failed to restore version'); 1858 - } 1859 - }); 759 + // --- Share Dialog (extracted) --- 760 + wireShareDialog({ $ }); 1860 761 1861 762 // --- Version Panel (new slide-in, Cmd+Shift+H) --- 1862 763 const docsVersionPanel = createVersionPanel({ ··· 2150 1051 2151 1052 function renderOutline() { 2152 1053 const json = editor.getJSON(); 2153 - const headings = extractHeadings(json); 1054 + const headings = extractOutlineHeadings(json); 2154 1055 outlineState.updateHeadings(headings); 2155 1056 2156 1057 if (headings.length === 0) { ··· 2179 1080 2180 1081 doc.descendants((node, pos) => { 2181 1082 if (node.type.name === 'heading' && node.attrs.level <= 3) { 2182 - const currentHeadings = extractHeadings(editor.getJSON()); 1083 + const currentHeadings = extractOutlineHeadings(editor.getJSON()); 2183 1084 if (currentHeadings[headingIndex] && currentHeadings[headingIndex].id === heading.id) { 2184 1085 targetPos = pos; 2185 1086 return false; ··· 2436 1337 { id: 'table', label: 'Insert Table', category: 'action', icon: '\u2637', action: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }, 2437 1338 { id: 'markdown', label: 'Toggle Markdown', category: 'action', icon: 'MD', shortcut: '\u2318\u21e7M', action: () => mdToggle.toggle() }, 2438 1339 { id: 'zen', label: 'Zen Mode', category: 'action', icon: '\u2022', shortcut: '\u2318\u21e7F', action: () => toggleZenMode() }, 2439 - { id: 'export-pdf', label: 'Export PDF', category: 'action', icon: '\u2193', shortcut: '\u2318\u21e7P', action: () => doExportPdf() }, 2440 - { id: 'export-docx', label: 'Export Word', category: 'action', icon: '\u2193', action: () => doExportDocx() }, 2441 - { id: 'import', label: 'Import File', category: 'action', icon: '\u2191', action: () => importFile() }, 2442 - { id: 'find', label: 'Find & Replace', category: 'action', icon: '\u2315', shortcut: '\u2318F', action: () => { editor.commands.openSearch(); updateFindBar(); } }, 1340 + { id: 'export-pdf', label: 'Export PDF', category: 'action', icon: '\u2193', shortcut: '\u2318\u21e7P', action: () => exportImport.doExportPdf() }, 1341 + { id: 'export-docx', label: 'Export Word', category: 'action', icon: '\u2193', action: () => exportImport.doExportDocx() }, 1342 + { id: 'import', label: 'Import File', category: 'action', icon: '\u2191', action: () => exportImport.importFile() }, 1343 + { id: 'find', label: 'Find & Replace', category: 'action', icon: '\u2315', shortcut: '\u2318F', action: () => { editor.commands.openSearch(); findBarResult.updateFindBar(); } }, 2443 1344 { id: 'toggle', label: 'Insert Toggle Block', category: 'action', icon: '\u25B6', action: () => editor.chain().focus().insertToggleBlock().run() }, 2444 1345 { id: 'footnote', label: 'Insert Footnote', category: 'action', icon: '\u2020', action: () => editor.chain().focus().insertFootnote().run() }, 2445 1346 ], ··· 2661 1562 } 2662 1563 2663 1564 function syncCommentsToYjs() { 2664 - yDoc.getMap('meta').set('commentThreads', JSON.stringify(commentThreads)); 1565 + ydoc.getMap('meta').set('commentThreads', JSON.stringify(commentThreads)); 2665 1566 } 2666 1567 2667 1568 function loadCommentsFromYjs() { 2668 - const raw = yDoc.getMap('meta').get('commentThreads') as string | undefined; 1569 + const raw = ydoc.getMap('meta').get('commentThreads') as string | undefined; 2669 1570 if (raw) { 2670 1571 try { commentThreads = JSON.parse(raw); } catch { commentThreads = []; } 2671 1572 } ··· 2673 1574 } 2674 1575 2675 1576 // Sync comments on yjs changes 2676 - yDoc.getMap('meta').observe(() => { loadCommentsFromYjs(); }); 1577 + ydoc.getMap('meta').observe(() => { loadCommentsFromYjs(); }); 2677 1578 2678 1579 btnComments.addEventListener('click', () => { 2679 1580 const showing = commentsSidebar.style.display !== 'none'; ··· 2723 1624 let followState = createFollowState(); 2724 1625 let remoteCursors: CursorPosition[] = []; 2725 1626 let isFollowScroll = false; 1627 + const editorContainer = document.querySelector('.editor-container'); 2726 1628 2727 1629 followStop.addEventListener('click', () => { 2728 1630 followState = stopFollowing(followState); 2729 1631 followBanner.style.display = 'none'; 2730 1632 }); 2731 1633 2732 - // Listen for manual scroll to auto-unfollow (reuse editorContainer from line ~1178) 1634 + // Listen for manual scroll to auto-unfollow 2733 1635 if (editorContainer) { 2734 1636 editorContainer.addEventListener('scroll', () => { 2735 1637 if (isFollowScroll) { isFollowScroll = false; return; } ··· 2802 1704 2803 1705 // Styled tooltips 2804 1706 setupTooltips(); 2805 -
+432
src/docs/toolbar-wiring.ts
··· 1 + /** 2 + * Toolbar Wiring — button event handlers, dropdowns, alignment, formatting, 3 + * font/heading selects, color pickers, toolbar state updates. 4 + * 5 + * Extracted from main.ts for decomposition. 6 + */ 7 + 8 + import type { Editor } from '@tiptap/core'; 9 + import { LINE_SPACING_PRESETS } from './extensions/line-spacing.js'; 10 + import { PARAGRAPH_SPACING_PRESETS } from './extensions/paragraph-spacing.js'; 11 + 12 + // ── Types ─────────────────────────────────────────────────── 13 + 14 + export interface ToolbarDeps { 15 + editor: any; 16 + closeAllDropdowns: () => void; 17 + toggleDropdown: (el: HTMLElement) => void; 18 + $: (id: string) => HTMLElement; 19 + } 20 + 21 + // ── Alignment ─────────────────────────────────────────────── 22 + 23 + const ALIGN_SVGS: Record<string, string> = { 24 + left: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="3" x2="14" y2="3"/><line x1="2" y1="6.5" x2="10" y2="6.5"/><line x1="2" y1="10" x2="14" y2="10"/><line x1="2" y1="13.5" x2="10" y2="13.5"/></svg>', 25 + center: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="3" x2="14" y2="3"/><line x1="4" y1="6.5" x2="12" y2="6.5"/><line x1="2" y1="10" x2="14" y2="10"/><line x1="4" y1="13.5" x2="12" y2="13.5"/></svg>', 26 + right: '<svg class="tb-icon" viewBox="0 0 16 16"><line x1="2" y1="3" x2="14" y2="3"/><line x1="6" y1="6.5" x2="14" y2="6.5"/><line x1="2" y1="10" x2="14" y2="10"/><line x1="6" y1="13.5" x2="14" y2="13.5"/></svg>', 27 + }; 28 + 29 + export { ALIGN_SVGS }; 30 + 31 + const FONT_SIZES = ['8px', '9px', '10px', '11px', '12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px', '36px', '48px', '72px']; 32 + 33 + // ── Dropdown utilities ────────────────────────────────────── 34 + 35 + export function closeAllDropdowns($: (id: string) => HTMLElement): void { 36 + document.querySelectorAll('.toolbar-dropdown.open, .toolbar-overflow.open').forEach(el => { 37 + el.classList.remove('open'); 38 + }); 39 + const paraSub = $('dd-para-spacing'); 40 + if (paraSub) paraSub.style.display = 'none'; 41 + } 42 + 43 + export function toggleDropdown(dropdownEl: HTMLElement, closeAll: () => void): void { 44 + const wasOpen = dropdownEl.classList.contains('open'); 45 + closeAll(); 46 + if (!wasOpen) dropdownEl.classList.add('open'); 47 + } 48 + 49 + export function wireDropdownCloseHandlers(closeAll: () => void): void { 50 + document.addEventListener('click', (e: any) => { 51 + if (!e.target.closest('.toolbar-dropdown') && !e.target.closest('.toolbar-overflow')) { 52 + closeAll(); 53 + } 54 + }); 55 + document.addEventListener('keydown', (e: KeyboardEvent) => { 56 + if (e.key === 'Escape') closeAll(); 57 + }); 58 + } 59 + 60 + // ── Main toolbar wiring ───────────────────────────────────── 61 + 62 + export function wireToolbarButtons(deps: ToolbarDeps): void { 63 + const { editor, closeAllDropdowns: closeAll, toggleDropdown: toggle, $ } = deps; 64 + 65 + // --- Alignment dropdown --- 66 + const alignDropdown = $('dd-align'); 67 + const alignToggle = $('tb-align-toggle'); 68 + 69 + alignToggle.addEventListener('click', (e: Event) => { 70 + e.stopPropagation(); 71 + toggle(alignDropdown); 72 + }); 73 + 74 + alignDropdown.querySelectorAll('[data-align]').forEach((btn: any) => { 75 + btn.addEventListener('click', (e: Event) => { 76 + e.stopPropagation(); 77 + const align = btn.dataset.align; 78 + editor.chain().focus().setTextAlign(align).run(); 79 + alignToggle.querySelector('.dd-icon').innerHTML = ALIGN_SVGS[align] || ALIGN_SVGS.left; 80 + closeAll(); 81 + }); 82 + }); 83 + 84 + // --- List buttons --- 85 + $('tb-bullet-list').addEventListener('click', () => editor.chain().focus().toggleBulletList().run()); 86 + $('tb-ordered-list').addEventListener('click', () => editor.chain().focus().toggleOrderedList().run()); 87 + $('tb-task-list').addEventListener('click', () => editor.chain().focus().toggleTaskList().run()); 88 + 89 + // --- Overflow "More" menu --- 90 + const overflowMenu = $('overflow-menu'); 91 + const overflowToggle = $('overflow-toggle'); 92 + 93 + overflowToggle.addEventListener('click', (e: Event) => { 94 + e.stopPropagation(); 95 + toggle(overflowMenu); 96 + }); 97 + 98 + // Text formatting 99 + $('tb-bold').addEventListener('click', () => editor.chain().focus().toggleBold().run()); 100 + $('tb-italic').addEventListener('click', () => editor.chain().focus().toggleItalic().run()); 101 + $('tb-underline').addEventListener('click', () => editor.chain().focus().toggleUnderline().run()); 102 + $('tb-strike').addEventListener('click', () => editor.chain().focus().toggleStrike().run()); 103 + $('tb-code').addEventListener('click', () => { editor.chain().focus().toggleCode().run(); closeAll(); }); 104 + 105 + // More menu items 106 + $('tb-subscript').addEventListener('click', () => { editor.chain().focus().toggleSubscript().run(); closeAll(); }); 107 + $('tb-superscript').addEventListener('click', () => { editor.chain().focus().toggleSuperscript().run(); closeAll(); }); 108 + $('tb-hr').addEventListener('click', () => { editor.chain().focus().setHorizontalRule().run(); closeAll(); }); 109 + 110 + // Blocks (in overflow menu) 111 + $('tb-blockquote').addEventListener('click', () => { editor.chain().focus().toggleBlockquote().run(); closeAll(); }); 112 + $('tb-codeblock').addEventListener('click', () => { editor.chain().focus().toggleCodeBlock().run(); closeAll(); }); 113 + 114 + // Heading select 115 + $('tb-heading').addEventListener('change', (e: any) => { 116 + const val = e.target.value; 117 + if (val === 'paragraph') { 118 + editor.chain().focus().setParagraph().run(); 119 + } else { 120 + editor.chain().focus().toggleHeading({ level: parseInt(val) }).run(); 121 + } 122 + }); 123 + 124 + // Font size select 125 + $('tb-font-size').addEventListener('change', (e: any) => { 126 + const val = e.target.value; 127 + if (val) { 128 + editor.chain().focus().setFontSize(val).run(); 129 + } else { 130 + editor.chain().focus().unsetFontSize().run(); 131 + } 132 + }); 133 + 134 + // Indent / Outdent 135 + $('tb-indent').addEventListener('click', () => { 136 + if (editor.isActive('listItem')) { 137 + editor.chain().focus().sinkListItem('listItem').run(); 138 + } else if (editor.isActive('taskItem')) { 139 + editor.chain().focus().sinkListItem('taskItem').run(); 140 + } else { 141 + editor.chain().focus().increaseIndent().run(); 142 + } 143 + }); 144 + $('tb-outdent').addEventListener('click', () => { 145 + if (editor.isActive('listItem')) { 146 + editor.chain().focus().liftListItem('listItem').run(); 147 + } else if (editor.isActive('taskItem')) { 148 + editor.chain().focus().liftListItem('taskItem').run(); 149 + } else { 150 + editor.chain().focus().decreaseIndent().run(); 151 + } 152 + }); 153 + 154 + // --- Line Spacing dropdown --- 155 + const lineSpacingDropdown = $('dd-line-spacing'); 156 + const lineSpacingToggle = $('tb-line-spacing-toggle'); 157 + 158 + lineSpacingToggle.addEventListener('click', (e: Event) => { 159 + e.stopPropagation(); 160 + toggle(lineSpacingDropdown); 161 + }); 162 + 163 + lineSpacingDropdown.querySelectorAll('[data-line-spacing]').forEach((btn: any) => { 164 + btn.addEventListener('click', (e: Event) => { 165 + e.stopPropagation(); 166 + const value = btn.dataset.lineSpacing; 167 + if (value === 'default') { 168 + editor.chain().focus().unsetLineSpacing().run(); 169 + } else { 170 + editor.chain().focus().setLineSpacing(value).run(); 171 + } 172 + closeAll(); 173 + }); 174 + }); 175 + 176 + // --- Paragraph Spacing (sub-menu from overflow) --- 177 + const paraSpacingDropdown = $('dd-para-spacing'); 178 + const paraSpacingTrigger = $('tb-para-spacing-toggle'); 179 + 180 + paraSpacingTrigger.addEventListener('click', (e: Event) => { 181 + e.stopPropagation(); 182 + const rect = paraSpacingTrigger.getBoundingClientRect(); 183 + paraSpacingDropdown.style.display = ''; 184 + paraSpacingDropdown.style.top = rect.top + 'px'; 185 + paraSpacingDropdown.style.left = (rect.right + 4) + 'px'; 186 + overflowMenu.classList.remove('open'); 187 + }); 188 + 189 + paraSpacingDropdown.querySelectorAll('[data-para-spacing]').forEach((btn: any) => { 190 + btn.addEventListener('click', (e: Event) => { 191 + e.stopPropagation(); 192 + const value = btn.dataset.paraSpacing; 193 + if (value === 'default') { 194 + editor.chain().focus().unsetParagraphSpacing().run(); 195 + } else { 196 + editor.chain().focus().setParagraphSpacing(value).run(); 197 + } 198 + closeAll(); 199 + }); 200 + }); 201 + 202 + // --- Page Break button --- 203 + $('tb-page-break').addEventListener('click', () => { 204 + editor.chain().focus().insertPageBreak().run(); 205 + closeAll(); 206 + }); 207 + 208 + // --- Spell Check toggle --- 209 + const spellCheckBtn = $('tb-spellcheck'); 210 + let spellCheckEnabled = localStorage.getItem('tools-spellcheck') !== 'false'; 211 + 212 + function applySpellCheck() { 213 + const editorEl = document.querySelector('.ProseMirror'); 214 + if (editorEl) { 215 + editorEl.setAttribute('spellcheck', String(spellCheckEnabled)); 216 + } 217 + spellCheckBtn.classList.toggle('active', spellCheckEnabled); 218 + spellCheckBtn.title = spellCheckEnabled ? 'Disable spell check' : 'Enable spell check'; 219 + } 220 + 221 + spellCheckBtn.addEventListener('click', () => { 222 + spellCheckEnabled = !spellCheckEnabled; 223 + localStorage.setItem('tools-spellcheck', String(spellCheckEnabled)); 224 + applySpellCheck(); 225 + closeAll(); 226 + }); 227 + 228 + requestAnimationFrame(applySpellCheck); 229 + 230 + // --- Colors --- 231 + const textColorInput = $('tb-text-color') as any; 232 + const textColorSwatch = $('tb-text-color-swatch'); 233 + const highlightInput = $('tb-highlight') as any; 234 + const highlightSwatch = $('tb-highlight-swatch'); 235 + 236 + function updateColorSwatches() { 237 + if (textColorSwatch) textColorSwatch.style.background = textColorInput.value; 238 + if (highlightSwatch) highlightSwatch.style.background = highlightInput.value; 239 + } 240 + updateColorSwatches(); 241 + 242 + textColorInput.addEventListener('input', (e: any) => { 243 + editor.chain().focus().setColor(e.target.value).run(); 244 + updateColorSwatches(); 245 + }); 246 + highlightInput.addEventListener('input', (e: any) => { 247 + editor.chain().focus().toggleHighlight({ color: e.target.value }).run(); 248 + updateColorSwatches(); 249 + }); 250 + 251 + // Insert link 252 + $('tb-link').addEventListener('click', () => { 253 + const url = prompt('URL:'); 254 + if (url) editor.chain().focus().setLink({ href: url }).run(); 255 + }); 256 + 257 + // Insert image 258 + $('tb-image').addEventListener('click', () => { 259 + const url = prompt('Image URL:'); 260 + if (url) editor.chain().focus().setImage({ src: url }).run(); 261 + }); 262 + 263 + // Insert table 264 + $('tb-table').addEventListener('click', () => { 265 + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); 266 + closeAll(); 267 + }); 268 + 269 + // Undo / Redo 270 + $('tb-undo').addEventListener('click', () => editor.chain().focus().undo().run()); 271 + $('tb-redo').addEventListener('click', () => editor.chain().focus().redo().run()); 272 + 273 + // Update active states on selection change 274 + const updateState = () => updateToolbarState(editor, $, alignDropdown, alignToggle, lineSpacingDropdown, paraSpacingDropdown); 275 + editor.on('selectionUpdate', updateState); 276 + editor.on('transaction', updateState); 277 + } 278 + 279 + // ── Toolbar state ─────────────────────────────────────────── 280 + 281 + export function updateToolbarState( 282 + editor: any, 283 + $: (id: string) => HTMLElement, 284 + alignDropdown: HTMLElement, 285 + alignToggle: HTMLElement, 286 + lineSpacingDropdown: HTMLElement, 287 + paraSpacingDropdown: HTMLElement, 288 + ): void { 289 + const toggleClass = (id: string, active: boolean) => $(id).classList.toggle('active', active); 290 + toggleClass('tb-bold', editor.isActive('bold')); 291 + toggleClass('tb-italic', editor.isActive('italic')); 292 + toggleClass('tb-underline', editor.isActive('underline')); 293 + toggleClass('tb-strike', editor.isActive('strike')); 294 + toggleClass('tb-code', editor.isActive('code')); 295 + toggleClass('tb-blockquote', editor.isActive('blockquote')); 296 + toggleClass('tb-codeblock', editor.isActive('codeBlock')); 297 + 298 + $('tb-subscript').classList.toggle('active', editor.isActive('subscript')); 299 + $('tb-superscript').classList.toggle('active', editor.isActive('superscript')); 300 + 301 + // Alignment dropdown 302 + const alignItems = alignDropdown.querySelectorAll('[data-align]'); 303 + let currentAlign = 'left'; 304 + alignItems.forEach((item: any) => { 305 + const isActive = editor.isActive({ textAlign: item.dataset.align }); 306 + item.classList.toggle('active', isActive); 307 + if (isActive) currentAlign = item.dataset.align; 308 + }); 309 + alignToggle.querySelector('.dd-icon')!.innerHTML = ALIGN_SVGS[currentAlign] || ALIGN_SVGS.left; 310 + alignToggle.classList.toggle('active', currentAlign !== 'left'); 311 + 312 + // List buttons 313 + toggleClass('tb-bullet-list', editor.isActive('bulletList')); 314 + toggleClass('tb-ordered-list', editor.isActive('orderedList')); 315 + toggleClass('tb-task-list', editor.isActive('taskList')); 316 + 317 + // Heading dropdown 318 + const headingEl = $('tb-heading') as any; 319 + if (editor.isActive('heading', { level: 1 })) headingEl.value = '1'; 320 + else if (editor.isActive('heading', { level: 2 })) headingEl.value = '2'; 321 + else if (editor.isActive('heading', { level: 3 })) headingEl.value = '3'; 322 + else headingEl.value = 'paragraph'; 323 + 324 + // Font size dropdown 325 + const fontSizeEl = $('tb-font-size') as any; 326 + const attrs = editor.getAttributes('textStyle'); 327 + const currentSize = attrs.fontSize || ''; 328 + fontSizeEl.value = FONT_SIZES.includes(currentSize) ? currentSize : ''; 329 + 330 + // Line spacing 331 + const lineSpacingItems = lineSpacingDropdown.querySelectorAll('[data-line-spacing]'); 332 + const paraAttrs = editor.getAttributes('paragraph'); 333 + const headingAttrs = editor.getAttributes('heading'); 334 + const currentLineHeight = paraAttrs.lineHeight || headingAttrs.lineHeight || null; 335 + lineSpacingItems.forEach((item: any) => { 336 + const val = item.dataset.lineSpacing; 337 + const isActive = (val === 'default' && !currentLineHeight) || val === currentLineHeight; 338 + item.classList.toggle('active', isActive); 339 + }); 340 + 341 + // Paragraph spacing 342 + const paraSpacingItems = paraSpacingDropdown.querySelectorAll('[data-para-spacing]'); 343 + const currentParaSpacing = paraAttrs.paragraphSpacing || headingAttrs.paragraphSpacing || null; 344 + paraSpacingItems.forEach((item: any) => { 345 + const val = item.dataset.paraSpacing; 346 + const isActive = (val === 'default' && !currentParaSpacing) || val === currentParaSpacing; 347 + item.classList.toggle('active', isActive); 348 + }); 349 + } 350 + 351 + // ── Inline Comments (toolbar button + popover) ────────────── 352 + 353 + export interface CommentDeps { 354 + editor: any; 355 + userName: string; 356 + $: (id: string) => HTMLElement; 357 + } 358 + 359 + export function wireInlineComments(deps: CommentDeps): void { 360 + const { editor, userName, $ } = deps; 361 + let activeCommentMark: { id: string; element: Element } | null = null; 362 + const commentPopover = $('comment-popover'); 363 + const commentAuthorEl = $('comment-author'); 364 + const commentTimeEl = $('comment-time'); 365 + const commentTextEl = $('comment-text'); 366 + 367 + $('tb-comment').addEventListener('click', () => { 368 + const { from, to } = editor.state.selection; 369 + if (from === to) return; 370 + const commentText = prompt('Add a comment:'); 371 + if (!commentText) return; 372 + const commentId = crypto.randomUUID(); 373 + const timestamp = new Date().toISOString(); 374 + editor.chain().focus().setComment({ 375 + commentId, 376 + author: userName, 377 + timestamp, 378 + text: commentText, 379 + }).run(); 380 + }); 381 + 382 + document.addEventListener('click', (e: any) => { 383 + const commentEl = e.target.closest('.comment-mark'); 384 + if (commentEl) { 385 + const id = commentEl.getAttribute('data-comment-id'); 386 + const author = commentEl.getAttribute('data-comment-author'); 387 + const timestamp = commentEl.getAttribute('data-comment-timestamp'); 388 + const text = commentEl.getAttribute('data-comment-text'); 389 + 390 + commentAuthorEl.textContent = author || 'Unknown'; 391 + commentTimeEl.textContent = timestamp ? new Date(timestamp).toLocaleString() : ''; 392 + commentTextEl.textContent = text || ''; 393 + activeCommentMark = { id, element: commentEl }; 394 + 395 + const rect = commentEl.getBoundingClientRect(); 396 + commentPopover.style.display = ''; 397 + commentPopover.style.left = `${Math.min(rect.left, window.innerWidth - 260)}px`; 398 + commentPopover.style.top = `${rect.bottom + 4}px`; 399 + } else if (!e.target.closest('.comment-popover')) { 400 + commentPopover.style.display = 'none'; 401 + activeCommentMark = null; 402 + } 403 + }); 404 + 405 + function removeActiveComment() { 406 + if (!activeCommentMark) return; 407 + const commentId = activeCommentMark.id; 408 + const { doc } = editor.state; 409 + const commentMarkType = editor.schema.marks.comment; 410 + const positions: { from: number; to: number }[] = []; 411 + doc.descendants((node: any, pos: number) => { 412 + if (node.isText) { 413 + node.marks.forEach((mark: any) => { 414 + if (mark.type === commentMarkType && mark.attrs.commentId === commentId) { 415 + positions.push({ from: pos, to: pos + node.nodeSize }); 416 + } 417 + }); 418 + } 419 + }); 420 + editor.chain().focus().command(({ tr }: any) => { 421 + positions.forEach(({ from, to }) => { 422 + tr.removeMark(from, to, commentMarkType); 423 + }); 424 + return true; 425 + }).run(); 426 + commentPopover.style.display = 'none'; 427 + activeCommentMark = null; 428 + } 429 + 430 + $('comment-resolve').addEventListener('click', removeActiveComment); 431 + $('comment-delete').addEventListener('click', removeActiveComment); 432 + }
+224
src/docs/version-history-ui.ts
··· 1 + /** 2 + * Version History UI — version capture, list rendering, preview, restore, 3 + * sidebar toggle, and auto-capture triggers. 4 + * 5 + * Extracted from main.ts for decomposition. 6 + */ 7 + 8 + import * as Y from 'yjs'; 9 + import { VersionManager, computeWordCount } from '../lib/version-history.js'; 10 + import { encrypt, decrypt } from '../lib/crypto.js'; 11 + 12 + // ── Types ─────────────────────────────────────────────────── 13 + 14 + export interface VersionHistoryDeps { 15 + editor: any; 16 + ydoc: any; 17 + provider: any; 18 + docId: string; 19 + cryptoKey: CryptoKey; 20 + userName: string; 21 + $: (id: string) => HTMLElement; 22 + } 23 + 24 + // ── Version History ───────────────────────────────────────── 25 + 26 + export function wireVersionHistory(deps: VersionHistoryDeps): void { 27 + const { editor, ydoc, provider, docId, cryptoKey, userName, $ } = deps; 28 + 29 + const versionManager = new VersionManager({ 30 + maxVersions: 50, 31 + editThreshold: 50, 32 + timeThresholdMs: 5 * 60 * 1000, 33 + }); 34 + 35 + const versionSidebar = $('version-sidebar'); 36 + const versionList = $('version-list'); 37 + const versionPreview = $('version-preview'); 38 + const versionPreviewContent = $('version-preview-content'); 39 + let selectedVersionId: string | null = null; 40 + 41 + // Track edits for version capture triggers 42 + editor.on('update', () => { 43 + versionManager.recordEdit(); 44 + if (versionManager.shouldCapture()) { 45 + captureVersion(); 46 + } 47 + }); 48 + 49 + // Check every minute for time-based capture 50 + setInterval(() => { 51 + if (versionManager.shouldCapture()) { 52 + captureVersion(); 53 + } 54 + }, 60_000); 55 + 56 + async function captureVersion() { 57 + try { 58 + const state = Y.encodeStateAsUpdate(ydoc); 59 + const encrypted = await encrypt(state, cryptoKey); 60 + const text = editor.getText(); 61 + const wordCount = computeWordCount(text); 62 + const metadata = JSON.stringify({ 63 + author: userName, 64 + wordCount, 65 + timestamp: Date.now(), 66 + }); 67 + 68 + await fetch(`/api/documents/${docId}/versions`, { 69 + method: 'POST', 70 + body: encrypted, 71 + headers: { 72 + 'Content-Type': 'application/octet-stream', 73 + 'X-Version-Metadata': metadata, 74 + }, 75 + }); 76 + 77 + versionManager.addVersion(encrypted, { author: userName, wordCount }); 78 + } catch (err) { 79 + console.warn('Failed to capture version', err); 80 + } 81 + } 82 + 83 + // History button toggles sidebar 84 + $('btn-history').addEventListener('click', () => { 85 + const isOpen = versionSidebar.style.display !== 'none'; 86 + if (isOpen) { 87 + versionSidebar.style.display = 'none'; 88 + } else { 89 + versionSidebar.style.display = ''; 90 + loadVersionList(); 91 + } 92 + }); 93 + 94 + $('version-sidebar-close').addEventListener('click', () => { 95 + versionSidebar.style.display = 'none'; 96 + versionPreview.style.display = 'none'; 97 + }); 98 + 99 + async function loadVersionList() { 100 + try { 101 + const res = await fetch(`/api/documents/${docId}/versions`); 102 + const versions = await res.json(); 103 + if (versions.length === 0) { 104 + versionList.innerHTML = '<div class="version-empty">No versions yet</div>'; 105 + return; 106 + } 107 + versionList.innerHTML = ''; 108 + let prevWordCount: number | null = null; 109 + // versions are newest-first from server 110 + for (let i = versions.length - 1; i >= 0; i--) { 111 + const v = versions[i]; 112 + const wc = v.metadata?.wordCount ?? 0; 113 + if (prevWordCount !== null) { 114 + versions[i]._delta = wc - prevWordCount; 115 + } else { 116 + versions[i]._delta = wc; 117 + } 118 + prevWordCount = wc; 119 + } 120 + for (const v of versions) { 121 + const item = document.createElement('button'); 122 + item.className = 'version-item'; 123 + const ts = v.created_at ? new Date(v.created_at + 'Z').toLocaleString() : 'Unknown'; 124 + const author = v.metadata?.author || 'Unknown'; 125 + const wc = v.metadata?.wordCount ?? '?'; 126 + const delta = v._delta; 127 + const deltaStr = delta > 0 ? `+${delta}` : `${delta}`; 128 + const deltaClass = delta > 0 ? 'positive' : delta < 0 ? 'negative' : ''; 129 + item.innerHTML = ` 130 + <span class="version-time">${ts}</span> 131 + <span class="version-meta"> 132 + <span class="version-author">${author}</span> 133 + <span class="version-wc">${wc} words</span> 134 + <span class="version-delta ${deltaClass}">${deltaStr}</span> 135 + </span> 136 + `; 137 + item.addEventListener('click', () => showVersionPreview(v.id)); 138 + versionList.appendChild(item); 139 + } 140 + } catch (err) { 141 + versionList.innerHTML = '<div class="version-empty">Failed to load versions</div>'; 142 + } 143 + } 144 + 145 + async function showVersionPreview(versionId: string): Promise<void> { 146 + selectedVersionId = versionId; 147 + versionPreview.style.display = ''; 148 + versionPreviewContent.textContent = 'Loading...'; 149 + try { 150 + const res = await fetch(`/api/documents/${docId}/versions/${versionId}`); 151 + if (!res.ok) throw new Error('Not found'); 152 + const encrypted = new Uint8Array(await res.arrayBuffer()); 153 + const decrypted = await decrypt(encrypted, cryptoKey); 154 + const tempDoc = new Y.Doc(); 155 + Y.applyUpdate(tempDoc, decrypted); 156 + const fragment = tempDoc.getXmlFragment('default'); 157 + versionPreviewContent.innerHTML = ''; 158 + const previewDiv = document.createElement('div'); 159 + previewDiv.className = 'version-preview-text'; 160 + previewDiv.textContent = fragment.toString(); 161 + versionPreviewContent.appendChild(previewDiv); 162 + tempDoc.destroy(); 163 + } catch (err) { 164 + versionPreviewContent.textContent = 'Failed to load version preview'; 165 + } 166 + } 167 + 168 + $('version-back').addEventListener('click', () => { 169 + versionPreview.style.display = 'none'; 170 + selectedVersionId = null; 171 + }); 172 + 173 + $('version-restore').addEventListener('click', async () => { 174 + if (!selectedVersionId) return; 175 + if (!confirm('Restore this version? Current changes will be replaced.')) return; 176 + try { 177 + const res = await fetch(`/api/documents/${docId}/versions/${selectedVersionId}`); 178 + if (!res.ok) throw new Error('Not found'); 179 + const encrypted = new Uint8Array(await res.arrayBuffer()); 180 + const decrypted = await decrypt(encrypted, cryptoKey); 181 + Y.applyUpdate(ydoc, decrypted); 182 + await provider._saveSnapshot(); 183 + versionPreview.style.display = 'none'; 184 + versionSidebar.style.display = 'none'; 185 + selectedVersionId = null; 186 + } catch (err) { 187 + alert('Failed to restore version'); 188 + } 189 + }); 190 + } 191 + 192 + // ── Share Dialog ──────────────────────────────────────────── 193 + 194 + export interface ShareDialogDeps { 195 + $: (id: string) => HTMLElement; 196 + } 197 + 198 + export function wireShareDialog(deps: ShareDialogDeps): void { 199 + const { $ } = deps; 200 + const shareDialog = $('share-dialog'); 201 + const shareLinkInput = $('share-link-input') as HTMLInputElement; 202 + 203 + $('btn-share').addEventListener('click', () => { 204 + shareLinkInput.value = window.location.href; 205 + shareDialog.style.display = ''; 206 + }); 207 + 208 + $('share-dialog-close').addEventListener('click', () => { 209 + shareDialog.style.display = 'none'; 210 + }); 211 + 212 + shareDialog.addEventListener('click', (e: Event) => { 213 + if (e.target === shareDialog) shareDialog.style.display = 'none'; 214 + }); 215 + 216 + $('share-copy-link').addEventListener('click', async () => { 217 + try { 218 + await navigator.clipboard.writeText(shareLinkInput.value); 219 + const btn = $('share-copy-link'); 220 + btn.textContent = 'Copied!'; 221 + setTimeout(() => { btn.textContent = 'Copy link'; }, 2000); 222 + } catch { /* clipboard not available */ } 223 + }); 224 + }