diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ed84b..ce883c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -270,6 +270,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix E2E test flakiness: replace page reload with addInitScript, add waitForURL before waitForSelector (#305) ### Changed +- Decompose docs/main.ts into focused modules (#464) - Decompose diagrams/main.ts into focused modules (#463) - Phase 5: extract toolbar, keyboard, cell-editing, grid-rendering from sheets main.ts (#462) - Phase 4: extract formula-bar, keyboard-shortcuts, clipboard-selection from sheets main.ts (#461) diff --git a/src/docs/block-handle-ui.ts b/src/docs/block-handle-ui.ts new file mode 100644 index 0000000..140f2ee --- /dev/null +++ b/src/docs/block-handle-ui.ts @@ -0,0 +1,217 @@ +/** + * Block Handle UI — DOM creation, positioning, context menu rendering, + * turn-into menu, mouse tracking, and event wiring. + * + * Extracted from main.ts for decomposition. + */ + +import { BlockHandleState, BLOCK_HANDLE_ACTIONS, BLOCK_HANDLE_ICON, BLOCK_HANDLE_ADD_ICON, TURN_INTO_ITEMS } from './block-handle.js'; +import { getCommandExecutor } from './extensions/slash-commands.js'; + +// ── Types ─────────────────────────────────────────────────── + +export interface BlockHandleUIDeps { + editor: any; + $: (id: string) => HTMLElement; +} + +// ── Block Handle UI ───────────────────────────────────────── + +export function wireBlockHandleUI(deps: BlockHandleUIDeps): { blockHandleState: BlockHandleState } { + const { editor } = deps; + const editorEl = document.getElementById('editor'); + const blockHandleState = new BlockHandleState(); + + // Create block handle element + const blockHandleEl = document.createElement('div'); + blockHandleEl.className = 'block-handle'; + blockHandleEl.id = 'block-handle'; + blockHandleEl.style.display = 'none'; + blockHandleEl.innerHTML = ``; + document.body.appendChild(blockHandleEl); + + // Create block context menu element + const blockContextMenuEl = document.createElement('div'); + blockContextMenuEl.className = 'block-context-menu'; + blockContextMenuEl.id = 'block-context-menu'; + blockContextMenuEl.style.display = 'none'; + document.body.appendChild(blockContextMenuEl); + + let blockHandleTimeout: ReturnType | null = null; + + function showBlockHandle(blockElement: Element, pos: number): void { + if (!blockElement || !editorEl) return; + const editorRect = editorEl.getBoundingClientRect(); + const blockRect = blockElement.getBoundingClientRect(); + const top = blockRect.top; + const left = editorRect.left - 36; + blockHandleState.show({ top, left }, pos); + blockHandleEl.style.display = 'flex'; + blockHandleEl.style.top = `${top}px`; + blockHandleEl.style.left = `${Math.max(4, left)}px`; + } + + function hideBlockHandle() { + blockHandleState.hide(); + blockHandleEl.style.display = 'none'; + blockContextMenuEl.style.display = 'none'; + } + + function renderBlockContextMenu() { + let html = ''; + for (const action of BLOCK_HANDLE_ACTIONS) { + html += ``; + } + blockContextMenuEl.innerHTML = html; + blockContextMenuEl.style.display = 'block'; + + const pos = blockHandleState.position; + if (pos) { + blockContextMenuEl.style.top = `${pos.top + 24}px`; + blockContextMenuEl.style.left = `${pos.left}px`; + } + + // Wire context menu actions + blockContextMenuEl.querySelectorAll('.block-context-item').forEach((btn: any) => { + btn.addEventListener('mousedown', (e: Event) => { + e.preventDefault(); + const actionId = btn.dataset.action; + executeBlockAction(actionId); + }); + }); + } + + function executeBlockAction(actionId: string): void { + const pos = blockHandleState.blockPos; + if (pos == null) return; + + switch (actionId) { + case 'delete': + editor.chain().focus().deleteNode(editor.state.doc.resolve(pos).parent.type.name).run(); + blockContextMenuEl.style.display = 'none'; + hideBlockHandle(); + break; + case 'duplicate': { + const node = editor.state.doc.resolve(pos).parent; + const endPos = pos + node.nodeSize; + editor.chain().focus().insertContentAt(endPos, node.toJSON()).run(); + blockContextMenuEl.style.display = 'none'; + break; + } + case 'moveUp': + case 'moveDown': + blockContextMenuEl.style.display = 'none'; + break; + case 'turnInto': + renderTurnIntoMenu(); + break; + default: + blockContextMenuEl.style.display = 'none'; + } + } + + function renderTurnIntoMenu() { + blockHandleState.openTurnIntoMenu(); + let html = '
Turn into
'; + for (const item of TURN_INTO_ITEMS) { + html += ``; + } + blockContextMenuEl.innerHTML = html; + + blockContextMenuEl.querySelectorAll('[data-turn-into]').forEach((btn: any) => { + btn.addEventListener('mousedown', (e: Event) => { + e.preventDefault(); + const typeId = btn.dataset.turnInto; + executeTurnInto(typeId); + }); + }); + } + + function executeTurnInto(typeId: string): void { + const executor = getCommandExecutor({ id: typeId }); + if (executor) { + executor(editor); + } + blockContextMenuEl.style.display = 'none'; + blockHandleState.closeContextMenu(); + } + + // Grip click -> context menu + blockHandleEl.querySelector('.block-handle-grip')!.addEventListener('click', (e: Event) => { + e.stopPropagation(); + if (blockHandleState.contextMenuOpen) { + blockHandleState.closeContextMenu(); + blockContextMenuEl.style.display = 'none'; + } else { + blockHandleState.openContextMenu(); + renderBlockContextMenu(); + } + }); + + // Add button -> insert paragraph below + blockHandleEl.querySelector('.block-handle-add')!.addEventListener('click', (e: Event) => { + e.stopPropagation(); + const pos = blockHandleState.blockPos; + if (pos != null) { + const resolved = editor.state.doc.resolve(pos); + const endOfBlock = pos + resolved.parent.nodeSize; + editor.chain().focus().insertContentAt(endOfBlock, { type: 'paragraph' }).run(); + } + }); + + // Close block context menu when clicking outside + document.addEventListener('click', (e: any) => { + if (!e.target.closest('#block-context-menu') && !e.target.closest('#block-handle')) { + blockHandleState.closeContextMenu(); + blockContextMenuEl.style.display = 'none'; + } + }); + + // Track mouse position over editor to show block handles + if (editorEl) { + editorEl.addEventListener('mousemove', (e: any) => { + if (blockHandleState.isHiddenInMode( + document.querySelector('.app-shell.zen-mode') ? 'zen' : 'normal' + )) { + hideBlockHandle(); + return; + } + + if (blockHandleTimeout) clearTimeout(blockHandleTimeout); + blockHandleTimeout = setTimeout(() => { + const target = e.target; + const blockEl = target.closest('.ProseMirror > *'); + if (!blockEl) { + hideBlockHandle(); + return; + } + + const view = editor.view; + const pos = view.posAtDOM(blockEl, 0); + if (pos != null) { + showBlockHandle(blockEl, pos); + } + }, 50); + }); + + editorEl.addEventListener('mouseleave', () => { + if (blockHandleTimeout) clearTimeout(blockHandleTimeout); + if (!blockHandleState.contextMenuOpen) { + blockHandleTimeout = setTimeout(() => hideBlockHandle(), 300); + } + }); + } + + // Prevent hiding when hovering the handle itself + blockHandleEl.addEventListener('mouseenter', () => { + if (blockHandleTimeout) clearTimeout(blockHandleTimeout); + }); + + return { blockHandleState }; +} diff --git a/src/docs/export-import.ts b/src/docs/export-import.ts new file mode 100644 index 0000000..af5302d --- /dev/null +++ b/src/docs/export-import.ts @@ -0,0 +1,190 @@ +/** + * Export/Import — HTML, Markdown, Text, PDF, DOCX export; file import; + * drag-and-drop; download helper; toast notification. + * + * Extracted from main.ts for decomposition. + */ + +import { exportPdf } from './pdf-export.js'; +import { exportDocx } from './docx-export.js'; +import { importDocx } from './docx-import.js'; +import { markdownToHtml } from './markdown-parser.js'; +import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; + +// ── Types ─────────────────────────────────────────────────── + +export interface ExportImportDeps { + editor: any; + provider: any; + titleInput: HTMLInputElement; + $: (id: string) => HTMLElement; + closeAllDropdowns: () => void; +} + +// ── Download helper ───────────────────────────────────────── + +export function downloadFile(content: string | Uint8Array, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ── Toast notification ────────────────────────────────────── + +export function showToast(message: string, duration = 3000): void { + const existing = document.querySelector('.toast-notification'); + if (existing) existing.remove(); + const toast = document.createElement('div'); + toast.className = 'toast-notification'; + toast.textContent = message; + document.body.appendChild(toast); + toast.offsetHeight; // force reflow + toast.classList.add('toast-visible'); + setTimeout(() => { toast.classList.remove('toast-visible'); setTimeout(() => toast.remove(), 300); }, duration); +} + +// ── Filename helper ───────────────────────────────────────── + +function getDocFilename(titleInput: HTMLInputElement): string { + const title = titleInput.value.trim() || 'Untitled Document'; + return title.replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_'); +} + +// ── Export functions ──────────────────────────────────────── + +const htmlToMarkdown = turndownHtmlToMarkdown; + +export function exportHTML(editor: any, titleInput: HTMLInputElement): void { + const html = editor.getHTML(); + const title = titleInput.value.trim() || 'Untitled Document'; + const fullDoc = '\n\n\n\n\n' + title + '\n\n\n\n

' + title + '

\n' + html + '\n\n'; + downloadFile(fullDoc, getDocFilename(titleInput) + '.html', 'text/html'); +} + +export function exportMarkdown(editor: any, titleInput: HTMLInputElement): void { + const html = editor.getHTML(); + const md = htmlToMarkdown(html); + downloadFile(md, getDocFilename(titleInput) + '.md', 'text/markdown'); +} + +export function exportText(editor: any, titleInput: HTMLInputElement): void { + const text = editor.getText(); + downloadFile(text, getDocFilename(titleInput) + '.txt', 'text/plain'); +} + +export function doExportPdf(editor: any, titleInput: HTMLInputElement): void { + exportPdf({ + editorHtml: editor.getHTML(), + title: titleInput.value.trim() || 'Untitled Document', + }); +} + +export function doExportDocx(editor: any, titleInput: HTMLInputElement): void { + exportDocx({ + editorHtml: editor.getHTML(), + title: titleInput.value.trim() || 'Untitled Document', + }); +} + +// ── Import functions ──────────────────────────────────────── + +export async function handleImportedFile(file: File, editor: any, provider: any): Promise { + const ext = file.name.split('.').pop()!.toLowerCase(); + + if (ext === 'docx') { + await importDocx(file, editor, showToast); + await provider._saveSnapshot(); + return; + } + + const reader = new FileReader(); + reader.onload = (e: any) => { + const content = e.target.result; + if (ext === 'html' || ext === 'htm') { + editor.commands.setContent(content); + } else if (ext === 'md') { + const html = markdownToHtml(content); + editor.commands.setContent(html); + } else { + editor.commands.insertContent(content); + } + showToast(`Imported "${file.name}" successfully`, 3000); + provider._saveSnapshot(); + }; + reader.readAsText(file); +} + +export function importFile(editor: any, provider: any): void { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.txt,.html,.htm,.md,.docx'; + input.addEventListener('change', () => { + if (input.files!.length > 0) handleImportedFile(input.files![0], editor, provider); + }); + input.click(); +} + +export function printDocument(): void { + window.print(); +} + +// ── Drag-and-drop ─────────────────────────────────────────── + +export function wireDragDrop(editor: any, provider: any): void { + const editorContainer = document.querySelector('.editor-container'); + if (!editorContainer) return; + + editorContainer.addEventListener('dragover', (e: Event) => { + e.preventDefault(); + editorContainer.classList.add('drag-over'); + }); + editorContainer.addEventListener('dragleave', () => { + editorContainer.classList.remove('drag-over'); + }); + editorContainer.addEventListener('drop', (e: any) => { + e.preventDefault(); + editorContainer.classList.remove('drag-over'); + if (e.dataTransfer.files.length > 0) handleImportedFile(e.dataTransfer.files[0], editor, provider); + }); +} + +// ── Wire export/import toolbar buttons ────────────────────── + +export function wireExportImportButtons(deps: ExportImportDeps): { + exportHTML: () => void; + doExportPdf: () => void; + doExportDocx: () => void; + importFile: () => void; + printDocument: () => void; +} { + const { editor, provider, titleInput, $, closeAllDropdowns: closeAll } = deps; + + const doExport = () => exportHTML(editor, titleInput); + const doPdf = () => doExportPdf(editor, titleInput); + const doDocx = () => doExportDocx(editor, titleInput); + const doImport = () => importFile(editor, provider); + + $('tb-export-html').addEventListener('click', () => { closeAll(); doExport(); }); + $('tb-export-md').addEventListener('click', () => { closeAll(); exportMarkdown(editor, titleInput); }); + $('tb-export-txt').addEventListener('click', () => { closeAll(); exportText(editor, titleInput); }); + $('tb-export-pdf').addEventListener('click', () => { closeAll(); doPdf(); }); + $('tb-export-docx').addEventListener('click', () => { closeAll(); doDocx(); }); + $('tb-import').addEventListener('click', () => { closeAll(); doImport(); }); + $('tb-print').addEventListener('click', () => { closeAll(); printDocument(); }); + + wireDragDrop(editor, provider); + + return { + exportHTML: doExport, + doExportPdf: doPdf, + doExportDocx: doDocx, + importFile: doImport, + printDocument, + }; +} diff --git a/src/docs/keyboard-shortcuts.ts b/src/docs/keyboard-shortcuts.ts new file mode 100644 index 0000000..2f92720 --- /dev/null +++ b/src/docs/keyboard-shortcuts.ts @@ -0,0 +1,274 @@ +/** + * Keyboard Shortcuts — shortcut definitions, cheatsheet modal, find bar UI, + * and global keyboard event handlers. + * + * Extracted from main.ts for decomposition. + */ + +// ── Types ─────────────────────────────────────────────────── + +export interface ShortcutCategory { + category: string; + shortcuts: Array<{ keys: string[]; label: string }>; +} + +export interface KeyboardShortcutDeps { + editor: any; + provider: any; + $: (id: string) => HTMLElement; + exportHTML: () => void; + doExportPdf: () => void; + printDocument: () => void; + mdToggle: { toggle: () => void }; + showShortcutModal: () => void; +} + +export interface FindBarDeps { + editor: any; + $: (id: string) => HTMLElement; +} + +// ── Shortcut definitions ──────────────────────────────────── + +export const DOCS_SHORTCUTS: ShortcutCategory[] = [ + { category: 'Formatting', shortcuts: [ + { keys: ['\u2318', 'B'], label: 'Bold' }, + { keys: ['\u2318', 'I'], label: 'Italic' }, + { keys: ['\u2318', 'U'], label: 'Underline' }, + { keys: ['\u2318', 'E'], label: 'Inline code' }, + { keys: ['\u2318', '\u21e7', 'X'], label: 'Strikethrough' }, + ]}, + { category: 'Structure', shortcuts: [ + { keys: ['\u2318', '\u21e7', '7'], label: 'Ordered list' }, + { keys: ['\u2318', '\u21e7', '8'], label: 'Bullet list' }, + { keys: ['\u2318', '\u21e7', '9'], label: 'Task list' }, + { keys: ['\u2318', '\u21e7', 'B'], label: 'Blockquote' }, + { keys: ['\u2318', '\u2325', 'C'], label: 'Code block' }, + { keys: ['Tab'], label: 'Indent' }, + { keys: ['\u21e7', 'Tab'], label: 'Outdent' }, + { keys: ['\u2318', 'Enter'], label: 'Page break' }, + ]}, + { category: 'Markdown Autoformat', shortcuts: [ + { keys: ['#', 'Space'], label: 'Heading 1' }, + { keys: ['##', 'Space'], label: 'Heading 2' }, + { keys: ['###', 'Space'], label: 'Heading 3' }, + { keys: ['```', 'Space'], label: 'Code block' }, + { keys: ['>', 'Space'], label: 'Blockquote' }, + { keys: ['-', 'Space'], label: 'Bullet list' }, + { keys: ['1.', 'Space'], label: 'Numbered list' }, + { keys: ['[]', 'Space'], label: 'Task list' }, + { keys: ['---'], label: 'Horizontal rule' }, + { keys: ['**text**'], label: 'Bold' }, + { keys: ['*text*'], label: 'Italic' }, + { keys: ['~~text~~'], label: 'Strikethrough' }, + { keys: ['`text`'], label: 'Inline code' }, + { keys: ['[text](url)'], label: 'Link' }, + ]}, + { category: 'Navigation', shortcuts: [ + { keys: ['\u2318', 'A'], label: 'Select all' }, + { keys: ['\u2318', 'Z'], label: 'Undo' }, + { keys: ['\u2318', '\u21e7', 'Z'], label: 'Redo' }, + ]}, + { category: 'Document', shortcuts: [ + { keys: ['\u2318', 'S'], label: 'Save snapshot' }, + { keys: ['\u2318', '\u21e7', 'S'], label: 'Export HTML' }, + { keys: ['\u2318', '\u21e7', 'P'], label: 'Export PDF' }, + { keys: ['\u2318', '\u21e7', 'M'], label: 'Toggle markdown source' }, + { keys: ['\u2318', 'P'], label: 'Print' }, + { keys: ['\u2318', '/'], label: 'Keyboard shortcuts' }, + ]}, +]; + +// ── Shortcut Modal ────────────────────────────────────────── + +export function buildShortcutModal(shortcuts: ShortcutCategory[]): HTMLElement { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + const modal = document.createElement('div'); + modal.className = 'modal shortcuts-modal'; + let html = '

Keyboard Shortcuts

'; + for (const cat of shortcuts) { + html += '
' + cat.category + '
'; + for (const sc of cat.shortcuts) { + html += '
' + sc.label + ''; + html += sc.keys.map(k => '' + k + '').join(''); + html += '
'; + } + html += '
'; + } + modal.innerHTML = html; + overlay.appendChild(modal); + const close = () => overlay.remove(); + modal.querySelector('.shortcuts-modal-close')!.addEventListener('click', close); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', handler); } }; + document.addEventListener('keydown', handler); + return overlay; +} + +export function showShortcutModal(): void { + if (document.querySelector('.shortcuts-modal')) return; + document.body.appendChild(buildShortcutModal(DOCS_SHORTCUTS)); +} + +// ── Find Bar ──────────────────────────────────────────────── + +const FIND_BAR_HTML = ''; + +export function insertFindBar(): void { + const editorContainerEl = document.querySelector('.editor-container'); + if (editorContainerEl) { + editorContainerEl.insertAdjacentHTML('afterbegin', FIND_BAR_HTML); + } +} + +export function wireFindBar(deps: FindBarDeps): { updateFindBar: () => void } { + const { editor, $ } = deps; + const findBar = $('find-bar'); + const findInput = $('find-input') as HTMLInputElement; + const replaceInput = $('replace-input') as HTMLInputElement; + const findCount = $('find-count'); + const findReplaceRow = $('find-replace-row'); + + function updateFindBar() { + const storage = editor.storage.searchReplace; + findBar.style.display = storage.isOpen ? '' : 'none'; + findReplaceRow.style.display = storage.showReplace ? '' : 'none'; + + if (storage.matches.length > 0) { + findCount.textContent = (storage.activeIndex + 1) + ' / ' + storage.matches.length; + } else if (storage.searchTerm) { + findCount.textContent = 'No results'; + } else { + findCount.textContent = ''; + } + + $('find-case').classList.toggle('active', storage.caseSensitive); + + if (storage.isOpen && document.activeElement !== findInput && document.activeElement !== replaceInput) { + findInput.focus(); + findInput.select(); + } + } + + findInput.addEventListener('input', () => { + editor.commands.setSearchTerm(findInput.value); + }); + + findInput.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (e.shiftKey) { + editor.commands.prevMatch(); + } else { + editor.commands.nextMatch(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + editor.commands.closeSearch(); + editor.commands.focus(); + } + }); + + replaceInput.addEventListener('input', () => { + editor.commands.setReplaceTerm(replaceInput.value); + }); + + replaceInput.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + editor.commands.replaceCurrent(); + } else if (e.key === 'Escape') { + e.preventDefault(); + editor.commands.closeSearch(); + editor.commands.focus(); + } + }); + + $('find-next').addEventListener('click', () => editor.commands.nextMatch()); + $('find-prev').addEventListener('click', () => editor.commands.prevMatch()); + $('find-case').addEventListener('click', () => editor.commands.toggleCaseSensitive()); + $('find-close').addEventListener('click', () => { + editor.commands.closeSearch(); + editor.commands.focus(); + }); + $('find-replace-toggle').addEventListener('click', () => { + const storage = editor.storage.searchReplace; + storage.showReplace = !storage.showReplace; + updateFindBar(); + }); + $('replace-one').addEventListener('click', () => editor.commands.replaceCurrent()); + $('replace-all').addEventListener('click', () => editor.commands.replaceAll()); + + return { updateFindBar }; +} + +// ── Global keyboard shortcuts ─────────────────────────────── + +export function wireGlobalShortcuts(deps: KeyboardShortcutDeps): void { + const { editor, provider, $, exportHTML, doExportPdf, printDocument, mdToggle, showShortcutModal: showModal } = deps; + + document.addEventListener('keydown', (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey; + if (mod && e.key === '/') { + e.preventDefault(); + showModal(); + return; + } + if (mod && !e.shiftKey && e.key.toLowerCase() === 'f') { + e.preventDefault(); + editor.commands.openSearch(); + return; + } + if (mod && e.key.toLowerCase() === 'h') { + e.preventDefault(); + editor.commands.openSearchReplace(); + return; + } + if (mod && e.shiftKey && e.key.toLowerCase() === 'g') { + if (editor.storage.searchReplace.isOpen) { + e.preventDefault(); + editor.commands.prevMatch(); + return; + } + } + if (mod && e.key.toLowerCase() === 'g') { + if (editor.storage.searchReplace.isOpen) { + e.preventDefault(); + editor.commands.nextMatch(); + return; + } + } + if (mod && e.shiftKey && e.key.toLowerCase() === 's') { + e.preventDefault(); + exportHTML(); + } else if (mod && e.key.toLowerCase() === 's') { + e.preventDefault(); + provider._saveSnapshot(); + } else if (mod && e.shiftKey && e.key.toLowerCase() === 'p') { + e.preventDefault(); + doExportPdf(); + } else if (mod && e.key.toLowerCase() === 'p') { + e.preventDefault(); + printDocument(); + } else if (mod && e.shiftKey && e.key.toLowerCase() === 'm') { + e.preventDefault(); + mdToggle.toggle(); + } + }); +} diff --git a/src/docs/main.ts b/src/docs/main.ts index e3570ae..991abde 100644 --- a/src/docs/main.ts +++ b/src/docs/main.ts @@ -48,14 +48,10 @@ import { SearchReplace } from './search-replace.js'; import { TabSupport } from './tab-support.js'; import { MarkdownAutoformat } from './extensions/markdown-autoformat.js'; import { WikiLink } from './extensions/wiki-link.js'; -import { exportPdf } from './pdf-export.js'; -import { exportDocx } from './docx-export.js'; -import { importDocx, isValidDocx } from './docx-import.js'; import { markdownToHtml } from './markdown-parser.js'; import { looksLikeMarkdown, htmlContainsRawMarkdown } from './markdown-paste.js'; import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; import { createMarkdownToggle, TOGGLE_MODE } from './markdown-toggle.js'; -import { VersionManager, computeWordCount } from '../lib/version-history.js'; import { createVersionPanel } from '../version-panel.js'; import { extractHeadings, computeViewportIndicator } from './minimap.js'; import { @@ -82,7 +78,7 @@ import { } from '../lib/follow-mode.js'; import { SuggestionManager, createSuggestionAttrs } from '../lib/suggesting.js'; import { OfflineManager } from '../lib/offline.js'; -import { extractHeadings, OutlineState } from './outline.js'; +import { extractHeadings as extractOutlineHeadings, OutlineState } from './outline.js'; import { TableToolbarState } from './table-toolbar.js'; import { LinkPreviewState, truncateUrl, computeTooltipPosition } from './link-preview.js'; import { @@ -96,9 +92,31 @@ import { executeDocAction } from './ai-doc-actions.js'; import { ZenModeState, ZEN_STORAGE_KEY, ZEN_CLASS, ZEN_TRANSITION_MS } from './zen-mode.js'; import { SLASH_COMMAND_ITEMS, SlashMenuState, filterCommands, PLACEHOLDER_EMPTY, PLACEHOLDER_BLOCK } from './slash-menu.js'; import { createSlashCommands, getCommandExecutor } from './extensions/slash-commands.js'; -import { BlockHandleState, BLOCK_HANDLE_ACTIONS, TURN_INTO_ITEMS, filterTurnIntoItems, BLOCK_HANDLE_ICON, BLOCK_HANDLE_ADD_ICON } from './block-handle.js'; import { createCommandPalette, type PaletteAction } from '../command-palette.js'; +// --- Extracted modules --- +import { + closeAllDropdowns as _closeAllDropdowns, + toggleDropdown as _toggleDropdown, + wireDropdownCloseHandlers, + wireToolbarButtons, + wireInlineComments, +} from './toolbar-wiring.js'; +import { + DOCS_SHORTCUTS, + showShortcutModal, + insertFindBar, + wireFindBar, + wireGlobalShortcuts, +} from './keyboard-shortcuts.js'; +import { + showToast, + handleImportedFile, + wireExportImportButtons, +} from './export-import.js'; +import { wireVersionHistory, wireShareDialog } from './version-history-ui.js'; +import { wireBlockHandleUI } from './block-handle-ui.js'; + // --- Resolve document ID and encryption key --- const pathParts = location.pathname.split('/').filter(Boolean); const docId = pathParts[1]; @@ -210,7 +228,7 @@ const editor = new Editor({ SuggestionInsert, SuggestionDelete, SearchReplace.configure({ - onStateChange: () => updateFindBar(), + onStateChange: () => findBarResult.updateFindBar(), }), TabSupport, MarkdownAutoformat, @@ -381,610 +399,20 @@ function hideSlashMenu() { slashMenuCommandRef = null; } -// --- Block Handle --- -const blockHandleState = new BlockHandleState(); - -const blockHandleEl = document.createElement('div'); -blockHandleEl.className = 'block-handle'; -blockHandleEl.id = 'block-handle'; -blockHandleEl.style.display = 'none'; -blockHandleEl.innerHTML = ``; -document.body.appendChild(blockHandleEl); - -// Block handle context menu -const blockContextMenuEl = document.createElement('div'); -blockContextMenuEl.className = 'block-context-menu'; -blockContextMenuEl.id = 'block-context-menu'; -blockContextMenuEl.style.display = 'none'; -document.body.appendChild(blockContextMenuEl); - -// Block handle: show on hover near editor blocks -const editorEl = document.getElementById('editor'); -let blockHandleTimeout: ReturnType | null = null; - -function showBlockHandle(blockElement: Element, pos: number): void { - if (!blockElement) return; - const editorRect = editorEl.getBoundingClientRect(); - const blockRect = blockElement.getBoundingClientRect(); - const top = blockRect.top; - const left = editorRect.left - 36; - blockHandleState.show({ top, left }, pos); - blockHandleEl.style.display = 'flex'; - blockHandleEl.style.top = `${top}px`; - blockHandleEl.style.left = `${Math.max(4, left)}px`; -} - -function hideBlockHandle() { - blockHandleState.hide(); - blockHandleEl.style.display = 'none'; - blockContextMenuEl.style.display = 'none'; -} - -function renderBlockContextMenu() { - let html = ''; - for (const action of BLOCK_HANDLE_ACTIONS) { - html += ``; - } - blockContextMenuEl.innerHTML = html; - blockContextMenuEl.style.display = 'block'; - - const pos = blockHandleState.position; - if (pos) { - blockContextMenuEl.style.top = `${pos.top + 24}px`; - blockContextMenuEl.style.left = `${pos.left}px`; - } - - // Wire context menu actions - blockContextMenuEl.querySelectorAll('.block-context-item').forEach(btn => { - btn.addEventListener('mousedown', (e) => { - e.preventDefault(); - const actionId = btn.dataset.action; - executeBlockAction(actionId); - }); - }); -} - -function executeBlockAction(actionId: string): void { - const pos = blockHandleState.blockPos; - if (pos == null) return; - - switch (actionId) { - case 'delete': - editor.chain().focus().deleteNode(editor.state.doc.resolve(pos).parent.type.name).run(); - blockContextMenuEl.style.display = 'none'; - hideBlockHandle(); - break; - case 'duplicate': { - const node = editor.state.doc.resolve(pos).parent; - const endPos = pos + node.nodeSize; - editor.chain().focus().insertContentAt(endPos, node.toJSON()).run(); - blockContextMenuEl.style.display = 'none'; - break; - } - case 'moveUp': - case 'moveDown': - // Simple move: use TipTap's join commands or transaction - blockContextMenuEl.style.display = 'none'; - break; - case 'turnInto': - renderTurnIntoMenu(); - break; - default: - blockContextMenuEl.style.display = 'none'; - } -} - -function renderTurnIntoMenu() { - blockHandleState.openTurnIntoMenu(); - let html = '
Turn into
'; - for (const item of TURN_INTO_ITEMS) { - html += ``; - } - blockContextMenuEl.innerHTML = html; - - blockContextMenuEl.querySelectorAll('[data-turn-into]').forEach(btn => { - btn.addEventListener('mousedown', (e) => { - e.preventDefault(); - const typeId = btn.dataset.turnInto; - executeTurnInto(typeId); - }); - }); -} - -function executeTurnInto(typeId: string): void { - const executor = getCommandExecutor({ id: typeId }); - if (executor) { - executor(editor); - } - blockContextMenuEl.style.display = 'none'; - blockHandleState.closeContextMenu(); -} - -// Grip click -> context menu -blockHandleEl.querySelector('.block-handle-grip').addEventListener('click', (e) => { - e.stopPropagation(); - if (blockHandleState.contextMenuOpen) { - blockHandleState.closeContextMenu(); - blockContextMenuEl.style.display = 'none'; - } else { - blockHandleState.openContextMenu(); - renderBlockContextMenu(); - } -}); - -// Add button -> insert paragraph below -blockHandleEl.querySelector('.block-handle-add').addEventListener('click', (e) => { - e.stopPropagation(); - const pos = blockHandleState.blockPos; - if (pos != null) { - const resolved = editor.state.doc.resolve(pos); - const endOfBlock = pos + resolved.parent.nodeSize; - editor.chain().focus().insertContentAt(endOfBlock, { type: 'paragraph' }).run(); - } -}); - -// Close block context menu when clicking outside -document.addEventListener('click', (e) => { - if (!e.target.closest('#block-context-menu') && !e.target.closest('#block-handle')) { - blockHandleState.closeContextMenu(); - blockContextMenuEl.style.display = 'none'; - } -}); - -// Track mouse position over editor to show block handles -editorEl.addEventListener('mousemove', (e) => { - if (blockHandleState.isHiddenInMode( - document.querySelector('.app-shell.zen-mode') ? 'zen' : 'normal' - )) { - hideBlockHandle(); - return; - } - - clearTimeout(blockHandleTimeout); - blockHandleTimeout = setTimeout(() => { - // Find the nearest block element - const target = e.target; - const blockEl = target.closest('.ProseMirror > *'); - if (!blockEl) { - hideBlockHandle(); - return; - } - - // Get the ProseMirror position of this block - const view = editor.view; - const pos = view.posAtDOM(blockEl, 0); - if (pos != null) { - showBlockHandle(blockEl, pos); - } - }, 50); -}); - -editorEl.addEventListener('mouseleave', () => { - clearTimeout(blockHandleTimeout); - // Only hide if context menu isn't open - if (!blockHandleState.contextMenuOpen) { - blockHandleTimeout = setTimeout(() => hideBlockHandle(), 300); - } -}); - -// Prevent hiding when hovering the handle itself -blockHandleEl.addEventListener('mouseenter', () => { - clearTimeout(blockHandleTimeout); -}); +// --- Block Handle (extracted) --- +const { blockHandleState } = wireBlockHandleUI({ editor, $: (id) => document.getElementById(id) as HTMLElement }); -// --- Toolbar wiring --- +// --- Toolbar wiring (extracted) --- const $ = (id: string): HTMLElement => document.getElementById(id) as HTMLElement; -// --- Dropdown/overflow menu utilities --- -function closeAllDropdowns() { - document.querySelectorAll('.toolbar-dropdown.open, .toolbar-overflow.open').forEach(el => { - el.classList.remove('open'); - }); - // Also close paragraph spacing sub-menu - const paraSub = $('dd-para-spacing'); - if (paraSub) paraSub.style.display = 'none'; -} - -function toggleDropdown(dropdownEl) { - const wasOpen = dropdownEl.classList.contains('open'); - closeAllDropdowns(); - if (!wasOpen) dropdownEl.classList.add('open'); -} - -// Close dropdowns when clicking outside -document.addEventListener('click', (e) => { - if (!e.target.closest('.toolbar-dropdown') && !e.target.closest('.toolbar-overflow')) { - closeAllDropdowns(); - } -}); - -// Close dropdowns on Escape -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') closeAllDropdowns(); -}); - -// --- Alignment dropdown --- -const alignDropdown = $('dd-align'); -const alignToggle = $('tb-align-toggle'); -// SVG icons for alignment states -const ALIGN_SVGS = { - left: '', - center: '', - right: '', -}; - -alignToggle.addEventListener('click', (e) => { - e.stopPropagation(); - toggleDropdown(alignDropdown); -}); - -alignDropdown.querySelectorAll('[data-align]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const align = btn.dataset.align; - editor.chain().focus().setTextAlign(align).run(); - alignToggle.querySelector('.dd-icon').innerHTML = ALIGN_SVGS[align] || ALIGN_SVGS.left; - closeAllDropdowns(); - }); -}); - -// --- List buttons (individual) --- -$('tb-bullet-list').addEventListener('click', () => editor.chain().focus().toggleBulletList().run()); -$('tb-ordered-list').addEventListener('click', () => editor.chain().focus().toggleOrderedList().run()); -$('tb-task-list').addEventListener('click', () => editor.chain().focus().toggleTaskList().run()); - -// --- Overflow "More" menu --- -const overflowMenu = $('overflow-menu'); -const overflowToggle = $('overflow-toggle'); - -overflowToggle.addEventListener('click', (e) => { - e.stopPropagation(); - toggleDropdown(overflowMenu); -}); - -// Text formatting -$('tb-bold').addEventListener('click', () => editor.chain().focus().toggleBold().run()); -$('tb-italic').addEventListener('click', () => editor.chain().focus().toggleItalic().run()); -$('tb-underline').addEventListener('click', () => editor.chain().focus().toggleUnderline().run()); -$('tb-strike').addEventListener('click', () => editor.chain().focus().toggleStrike().run()); -$('tb-code').addEventListener('click', () => { editor.chain().focus().toggleCode().run(); closeAllDropdowns(); }); - -// More menu items -$('tb-subscript').addEventListener('click', () => { - editor.chain().focus().toggleSubscript().run(); - closeAllDropdowns(); -}); -$('tb-superscript').addEventListener('click', () => { - editor.chain().focus().toggleSuperscript().run(); - closeAllDropdowns(); -}); -$('tb-hr').addEventListener('click', () => { - editor.chain().focus().setHorizontalRule().run(); - closeAllDropdowns(); -}); - -// Blocks (in overflow menu) -$('tb-blockquote').addEventListener('click', () => { editor.chain().focus().toggleBlockquote().run(); closeAllDropdowns(); }); -$('tb-codeblock').addEventListener('click', () => { editor.chain().focus().toggleCodeBlock().run(); closeAllDropdowns(); }); - -// Heading select -$('tb-heading').addEventListener('change', (e) => { - const val = e.target.value; - if (val === 'paragraph') { - editor.chain().focus().setParagraph().run(); - } else { - editor.chain().focus().toggleHeading({ level: parseInt(val) }).run(); - } -}); - -// Font size select -$('tb-font-size').addEventListener('change', (e) => { - const val = e.target.value; - if (val) { - editor.chain().focus().setFontSize(val).run(); - } else { - editor.chain().focus().unsetFontSize().run(); - } -}); - -// Indent / Outdent -$('tb-indent').addEventListener('click', () => { - if (editor.isActive('listItem')) { - editor.chain().focus().sinkListItem('listItem').run(); - } else if (editor.isActive('taskItem')) { - editor.chain().focus().sinkListItem('taskItem').run(); - } else { - editor.chain().focus().increaseIndent().run(); - } -}); -$('tb-outdent').addEventListener('click', () => { - if (editor.isActive('listItem')) { - editor.chain().focus().liftListItem('listItem').run(); - } else if (editor.isActive('taskItem')) { - editor.chain().focus().liftListItem('taskItem').run(); - } else { - editor.chain().focus().decreaseIndent().run(); - } -}); - -// --- Line Spacing dropdown --- -const lineSpacingDropdown = $('dd-line-spacing'); -const lineSpacingToggle = $('tb-line-spacing-toggle'); - -lineSpacingToggle.addEventListener('click', (e) => { - e.stopPropagation(); - toggleDropdown(lineSpacingDropdown); -}); - -lineSpacingDropdown.querySelectorAll('[data-line-spacing]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const value = btn.dataset.lineSpacing; - if (value === 'default') { - editor.chain().focus().unsetLineSpacing().run(); - } else { - editor.chain().focus().setLineSpacing(value).run(); - } - closeAllDropdowns(); - }); -}); - -// --- Paragraph Spacing (sub-menu from overflow) --- -const paraSpacingDropdown = $('dd-para-spacing'); -const paraSpacingTrigger = $('tb-para-spacing-toggle'); - -paraSpacingTrigger.addEventListener('click', (e) => { - e.stopPropagation(); - // Position the sub-menu near the trigger - const rect = paraSpacingTrigger.getBoundingClientRect(); - paraSpacingDropdown.style.display = ''; - paraSpacingDropdown.style.top = rect.top + 'px'; - paraSpacingDropdown.style.left = (rect.right + 4) + 'px'; - // Close the overflow menu - overflowMenu.classList.remove('open'); -}); - -paraSpacingDropdown.querySelectorAll('[data-para-spacing]').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const value = btn.dataset.paraSpacing; - if (value === 'default') { - editor.chain().focus().unsetParagraphSpacing().run(); - } else { - editor.chain().focus().setParagraphSpacing(value).run(); - } - closeAllDropdowns(); - }); -}); - -// --- Page Break button --- -$('tb-page-break').addEventListener('click', () => { - editor.chain().focus().insertPageBreak().run(); - closeAllDropdowns(); -}); - -// --- Spell Check toggle --- -const spellCheckBtn = $('tb-spellcheck'); -let spellCheckEnabled = localStorage.getItem('tools-spellcheck') !== 'false'; // default: true - -function applySpellCheck() { - const editorEl = document.querySelector('.ProseMirror'); - if (editorEl) { - editorEl.setAttribute('spellcheck', String(spellCheckEnabled)); - } - spellCheckBtn.classList.toggle('active', spellCheckEnabled); - spellCheckBtn.title = spellCheckEnabled ? 'Disable spell check' : 'Enable spell check'; -} - -spellCheckBtn.addEventListener('click', () => { - spellCheckEnabled = !spellCheckEnabled; - localStorage.setItem('tools-spellcheck', String(spellCheckEnabled)); - applySpellCheck(); - closeAllDropdowns(); -}); - -// Apply spell check setting once editor is ready -requestAnimationFrame(applySpellCheck); - -// --- Inline Comments --- -$('tb-comment').addEventListener('click', () => { - const { from, to } = editor.state.selection; - if (from === to) return; // No selection - const commentText = prompt('Add a comment:'); - if (!commentText) return; - const commentId = crypto.randomUUID(); - const timestamp = new Date().toISOString(); - editor.chain().focus().setComment({ - commentId, - author: userName, - timestamp, - text: commentText, - }).run(); -}); - -// Comment popover logic -const commentPopover = $('comment-popover'); -const commentAuthorEl = $('comment-author'); -const commentTimeEl = $('comment-time'); -const commentTextEl = $('comment-text'); -let activeCommentMark: { id: string; element: Element } | null = null; - -document.addEventListener('click', (e) => { - const commentEl = e.target.closest('.comment-mark'); - if (commentEl) { - const id = commentEl.getAttribute('data-comment-id'); - const author = commentEl.getAttribute('data-comment-author'); - const timestamp = commentEl.getAttribute('data-comment-timestamp'); - const text = commentEl.getAttribute('data-comment-text'); - - commentAuthorEl.textContent = author || 'Unknown'; - commentTimeEl.textContent = timestamp ? new Date(timestamp).toLocaleString() : ''; - commentTextEl.textContent = text || ''; - activeCommentMark = { id, element: commentEl }; - - const rect = commentEl.getBoundingClientRect(); - commentPopover.style.display = ''; - commentPopover.style.left = `${Math.min(rect.left, window.innerWidth - 260)}px`; - commentPopover.style.top = `${rect.bottom + 4}px`; - } else if (!e.target.closest('.comment-popover')) { - commentPopover.style.display = 'none'; - activeCommentMark = null; - } -}); - -function removeActiveComment() { - if (!activeCommentMark) return; - const commentId = activeCommentMark.id; - const { doc } = editor.state; - const commentMarkType = editor.schema.marks.comment; - const positions = []; - doc.descendants((node, pos) => { - if (node.isText) { - node.marks.forEach((mark) => { - if (mark.type === commentMarkType && mark.attrs.commentId === commentId) { - positions.push({ from: pos, to: pos + node.nodeSize }); - } - }); - } - }); - editor.chain().focus().command(({ tr }) => { - positions.forEach(({ from, to }) => { - tr.removeMark(from, to, commentMarkType); - }); - return true; - }).run(); - commentPopover.style.display = 'none'; - activeCommentMark = null; -} - -$('comment-resolve').addEventListener('click', removeActiveComment); -$('comment-delete').addEventListener('click', removeActiveComment); - -// Colors -const textColorInput = $('tb-text-color'); -const textColorSwatch = $('tb-text-color-swatch'); -const highlightInput = $('tb-highlight'); -const highlightSwatch = $('tb-highlight-swatch'); - -function updateColorSwatches() { - if (textColorSwatch) textColorSwatch.style.background = textColorInput.value; - if (highlightSwatch) highlightSwatch.style.background = highlightInput.value; -} -updateColorSwatches(); - -textColorInput.addEventListener('input', (e) => { - editor.chain().focus().setColor(e.target.value).run(); - updateColorSwatches(); -}); -highlightInput.addEventListener('input', (e) => { - editor.chain().focus().toggleHighlight({ color: e.target.value }).run(); - updateColorSwatches(); -}); - -// Insert link -$('tb-link').addEventListener('click', () => { - const url = prompt('URL:'); - if (url) editor.chain().focus().setLink({ href: url }).run(); -}); - -// Insert image -$('tb-image').addEventListener('click', () => { - const url = prompt('Image URL:'); - if (url) editor.chain().focus().setImage({ src: url }).run(); -}); - -// Insert table -$('tb-table').addEventListener('click', () => { - editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); - closeAllDropdowns(); -}); - -// Undo / Redo -$('tb-undo').addEventListener('click', () => editor.chain().focus().undo().run()); -$('tb-redo').addEventListener('click', () => editor.chain().focus().redo().run()); - -// Update active states on selection change -editor.on('selectionUpdate', updateToolbarState); -editor.on('transaction', updateToolbarState); - -const FONT_SIZES = ['8px', '9px', '10px', '11px', '12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px', '36px', '48px', '72px']; - -function updateToolbarState() { - const toggleClass = (id, active) => $(id).classList.toggle('active', active); - toggleClass('tb-bold', editor.isActive('bold')); - toggleClass('tb-italic', editor.isActive('italic')); - toggleClass('tb-underline', editor.isActive('underline')); - toggleClass('tb-strike', editor.isActive('strike')); - toggleClass('tb-code', editor.isActive('code')); - toggleClass('tb-blockquote', editor.isActive('blockquote')); - toggleClass('tb-codeblock', editor.isActive('codeBlock')); - - // Update subscript/superscript active state in overflow menu - const subActive = editor.isActive('subscript'); - const supActive = editor.isActive('superscript'); - $('tb-subscript').classList.toggle('active', subActive); - $('tb-superscript').classList.toggle('active', supActive); - - // Update alignment dropdown icon and active states - const alignItems = alignDropdown.querySelectorAll('[data-align]'); - let currentAlign = 'left'; - alignItems.forEach(item => { - const isActive = editor.isActive({ textAlign: item.dataset.align }); - item.classList.toggle('active', isActive); - if (isActive) currentAlign = item.dataset.align; - }); - alignToggle.querySelector('.dd-icon').innerHTML = ALIGN_SVGS[currentAlign] || ALIGN_SVGS.left; - alignToggle.classList.toggle('active', currentAlign !== 'left'); - - // Update individual list button active states - toggleClass('tb-bullet-list', editor.isActive('bulletList')); - toggleClass('tb-ordered-list', editor.isActive('orderedList')); - toggleClass('tb-task-list', editor.isActive('taskList')); - - // Update heading dropdown - const headingEl = $('tb-heading'); - if (editor.isActive('heading', { level: 1 })) headingEl.value = '1'; - else if (editor.isActive('heading', { level: 2 })) headingEl.value = '2'; - else if (editor.isActive('heading', { level: 3 })) headingEl.value = '3'; - else headingEl.value = 'paragraph'; - - // Update font size dropdown - const fontSizeEl = $('tb-font-size'); - const attrs = editor.getAttributes('textStyle'); - const currentSize = attrs.fontSize || ''; - fontSizeEl.value = FONT_SIZES.includes(currentSize) ? currentSize : ''; - - // Update line spacing dropdown active state - const lineSpacingItems = lineSpacingDropdown.querySelectorAll('[data-line-spacing]'); - const paraAttrs = editor.getAttributes('paragraph'); - const headingAttrs = editor.getAttributes('heading'); - const currentLineHeight = paraAttrs.lineHeight || headingAttrs.lineHeight || null; - lineSpacingItems.forEach(item => { - const val = item.dataset.lineSpacing; - const isActive = (val === 'default' && !currentLineHeight) || val === currentLineHeight; - item.classList.toggle('active', isActive); - }); - - // Update paragraph spacing dropdown active state - const paraSpacingItems = paraSpacingDropdown.querySelectorAll('[data-para-spacing]'); - const currentParaSpacing = paraAttrs.paragraphSpacing || headingAttrs.paragraphSpacing || null; - paraSpacingItems.forEach(item => { - const val = item.dataset.paraSpacing; - const isActive = (val === 'default' && !currentParaSpacing) || val === currentParaSpacing; - item.classList.toggle('active', isActive); - }); -} - -// (Responsive toolbar collapse removed -- flat single-row toolbar with overflow menu) +const closeAll = () => _closeAllDropdowns($); +const toggle = (el: HTMLElement) => _toggleDropdown(el, closeAll); +wireDropdownCloseHandlers(closeAll); +wireToolbarButtons({ editor, closeAllDropdowns: closeAll, toggleDropdown: toggle, $ }); +wireInlineComments({ editor, userName, $ }); // --- Document title --- -const titleInput = $('doc-title'); +const titleInput = $('doc-title') as HTMLInputElement; async function loadTitle() { try { @@ -1045,7 +473,7 @@ provider.on('sync', () => { .then(r => r.blob()) .then(async blob => { const file = new File([blob], pending.name, { type: blob.type }); - await handleImportedFile(file); + await handleImportedFile(file, editor, provider); }) .finally(async () => { window.__importInProgress = false; @@ -1076,141 +504,15 @@ provider.on('sync', () => { } }); -// --- Download helper --- -function downloadFile(content: string, filename: string, mimeType: string): void { - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -// --- Export functions --- -function getDocFilename() { - const title = titleInput.value.trim() || 'Untitled Document'; - return title.replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_'); -} - -function exportHTML() { - const html = editor.getHTML(); - const title = titleInput.value.trim() || 'Untitled Document'; - const fullDoc = '\n\n\n\n\n' + title + '\n\n\n\n

' + title + '

\n' + html + '\n\n'; - downloadFile(fullDoc, getDocFilename() + '.html', 'text/html'); -} - -// Use turndown-based converter from markdown-export.js (replaces regex-based approach) -const htmlToMarkdown = turndownHtmlToMarkdown; - -function exportMarkdown() { - const html = editor.getHTML(); - const md = htmlToMarkdown(html); - downloadFile(md, getDocFilename() + '.md', 'text/markdown'); -} - -function exportText() { - const text = editor.getText(); - downloadFile(text, getDocFilename() + '.txt', 'text/plain'); -} - -// --- Toast notification --- -function showToast(message: string, duration = 3000): void { - const existing = document.querySelector('.toast-notification'); - if (existing) existing.remove(); - const toast = document.createElement('div'); - toast.className = 'toast-notification'; - toast.textContent = message; - document.body.appendChild(toast); - toast.offsetHeight; - toast.classList.add('toast-visible'); - setTimeout(() => { toast.classList.remove('toast-visible'); setTimeout(() => toast.remove(), 300); }, duration); -} - -// --- PDF Export --- -function doExportPdf() { - exportPdf({ - editorHtml: editor.getHTML(), - title: titleInput.value.trim() || 'Untitled Document', - }); -} - -// --- DOCX Export --- -function doExportDocx() { - exportDocx({ - editorHtml: editor.getHTML(), - title: titleInput.value.trim() || 'Untitled Document', - }); -} - -// --- Import functions --- -async function handleImportedFile(file: File): Promise { - const ext = file.name.split('.').pop().toLowerCase(); - - // Handle .docx files via mammoth - if (ext === 'docx') { - await importDocx(file, editor, showToast); - // Force immediate save so imported data survives a refresh - await provider._saveSnapshot(); - return; - } - - // Handle text-based files (.html, .htm, .md, .txt) - const reader = new FileReader(); - reader.onload = (e) => { - const content = e.target.result; - if (ext === 'html' || ext === 'htm') { - editor.commands.setContent(content); - } else if (ext === 'md') { - // Parse markdown to HTML via markdown-it before inserting - const html = markdownToHtml(content); - editor.commands.setContent(html); - } else { - editor.commands.insertContent(content); - } - showToast(`Imported "${file.name}" successfully`, 3000); - // Force immediate save so imported data survives a refresh - provider._saveSnapshot(); - }; - reader.readAsText(file); -} - -function importFile() { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.txt,.html,.htm,.md,.docx'; - input.addEventListener('change', () => { - if (input.files.length > 0) handleImportedFile(input.files[0]); - }); - input.click(); -} - -const editorContainer = document.querySelector('.editor-container'); -editorContainer.addEventListener('dragover', (e) => { - e.preventDefault(); - editorContainer.classList.add('drag-over'); -}); -editorContainer.addEventListener('dragleave', () => { - editorContainer.classList.remove('drag-over'); -}); -editorContainer.addEventListener('drop', (e) => { - e.preventDefault(); - editorContainer.classList.remove('drag-over'); - if (e.dataTransfer.files.length > 0) handleImportedFile(e.dataTransfer.files[0]); +// --- Export/Import (extracted) --- +const exportImport = wireExportImportButtons({ + editor, + provider, + titleInput, + $, + closeAllDropdowns: closeAll, }); -function printDocument() { window.print(); } - -$('tb-export-html').addEventListener('click', () => { closeAllDropdowns(); exportHTML(); }); -$('tb-export-md').addEventListener('click', () => { closeAllDropdowns(); exportMarkdown(); }); -$('tb-export-txt').addEventListener('click', () => { closeAllDropdowns(); exportText(); }); -$('tb-export-pdf').addEventListener('click', () => { closeAllDropdowns(); doExportPdf(); }); -$('tb-export-docx').addEventListener('click', () => { closeAllDropdowns(); doExportDocx(); }); -$('tb-import').addEventListener('click', () => { closeAllDropdowns(); importFile(); }); -$('tb-print').addEventListener('click', () => { closeAllDropdowns(); printDocument(); }); - // --- Autosave indicator (#17) --- const saveIndicator = $('save-indicator'); const saveText = $('save-text'); @@ -1371,243 +673,30 @@ document.addEventListener('scroll', updateMinimapViewport, { passive: true }); // Delayed initial render setTimeout(updateMinimap, 500); -// --- Keyboard Shortcut Cheatsheet Modal (#15) --- -const DOCS_SHORTCUTS = [ - { category: 'Formatting', shortcuts: [ - { keys: ['\u2318', 'B'], label: 'Bold' }, - { keys: ['\u2318', 'I'], label: 'Italic' }, - { keys: ['\u2318', 'U'], label: 'Underline' }, - { keys: ['\u2318', 'E'], label: 'Inline code' }, - { keys: ['\u2318', '\u21e7', 'X'], label: 'Strikethrough' }, - ]}, - { category: 'Structure', shortcuts: [ - { keys: ['\u2318', '\u21e7', '7'], label: 'Ordered list' }, - { keys: ['\u2318', '\u21e7', '8'], label: 'Bullet list' }, - { keys: ['\u2318', '\u21e7', '9'], label: 'Task list' }, - { keys: ['\u2318', '\u21e7', 'B'], label: 'Blockquote' }, - { keys: ['\u2318', '\u2325', 'C'], label: 'Code block' }, - { keys: ['Tab'], label: 'Indent' }, - { keys: ['\u21e7', 'Tab'], label: 'Outdent' }, - { keys: ['\u2318', 'Enter'], label: 'Page break' }, - ]}, - { category: 'Markdown Autoformat', shortcuts: [ - { keys: ['#', 'Space'], label: 'Heading 1' }, - { keys: ['##', 'Space'], label: 'Heading 2' }, - { keys: ['###', 'Space'], label: 'Heading 3' }, - { keys: ['```', 'Space'], label: 'Code block' }, - { keys: ['>', 'Space'], label: 'Blockquote' }, - { keys: ['-', 'Space'], label: 'Bullet list' }, - { keys: ['1.', 'Space'], label: 'Numbered list' }, - { keys: ['[]', 'Space'], label: 'Task list' }, - { keys: ['---'], label: 'Horizontal rule' }, - { keys: ['**text**'], label: 'Bold' }, - { keys: ['*text*'], label: 'Italic' }, - { keys: ['~~text~~'], label: 'Strikethrough' }, - { keys: ['`text`'], label: 'Inline code' }, - { keys: ['[text](url)'], label: 'Link' }, - ]}, - { category: 'Navigation', shortcuts: [ - { keys: ['\u2318', 'A'], label: 'Select all' }, - { keys: ['\u2318', 'Z'], label: 'Undo' }, - { keys: ['\u2318', '\u21e7', 'Z'], label: 'Redo' }, - ]}, - { category: 'Document', shortcuts: [ - { keys: ['\u2318', 'S'], label: 'Save snapshot' }, - { keys: ['\u2318', '\u21e7', 'S'], label: 'Export HTML' }, - { keys: ['\u2318', '\u21e7', 'P'], label: 'Export PDF' }, - { keys: ['\u2318', '\u21e7', 'M'], label: 'Toggle markdown source' }, - { keys: ['\u2318', 'P'], label: 'Print' }, - { keys: ['\u2318', '/'], label: 'Keyboard shortcuts' }, - ]}, -]; - -function buildShortcutModal(shortcuts: Array<{ category: string; shortcuts: Array<{ keys: string[]; label: string }> }>): HTMLElement { - const overlay = document.createElement('div'); - overlay.className = 'modal-overlay'; - const modal = document.createElement('div'); - modal.className = 'modal shortcuts-modal'; - let html = '

Keyboard Shortcuts

'; - for (const cat of shortcuts) { - html += '
' + cat.category + '
'; - for (const sc of cat.shortcuts) { - html += '
' + sc.label + ''; - html += sc.keys.map(k => '' + k + '').join(''); - html += '
'; - } - html += '
'; - } - modal.innerHTML = html; - overlay.appendChild(modal); - const close = () => overlay.remove(); - modal.querySelector('.shortcuts-modal-close').addEventListener('click', close); - overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - const handler = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', handler); } }; - document.addEventListener('keydown', handler); - return overlay; -} - -function showShortcutModal() { - if (document.querySelector('.shortcuts-modal')) return; - document.body.appendChild(buildShortcutModal(DOCS_SHORTCUTS)); -} - +// --- Keyboard Shortcuts (extracted) --- $('btn-shortcuts').addEventListener('click', showShortcutModal); -// --- Find Bar UI --- -const findBarHtml = ''; - -// Insert find bar into the editor container (overlaid at top) -const editorContainerEl = document.querySelector('.editor-container'); -// Note: position:relative is set in CSS, not here (avoids stacking context issues with toolbar dropdowns) -editorContainerEl.insertAdjacentHTML('afterbegin', findBarHtml); - -const findBar = $('find-bar'); -const findInput = $('find-input'); -const replaceInput = $('replace-input'); -const findCount = $('find-count'); -const findReplaceRow = $('find-replace-row'); - -function updateFindBar() { - const storage = editor.storage.searchReplace; - findBar.style.display = storage.isOpen ? '' : 'none'; - findReplaceRow.style.display = storage.showReplace ? '' : 'none'; - - if (storage.matches.length > 0) { - findCount.textContent = (storage.activeIndex + 1) + ' / ' + storage.matches.length; - } else if (storage.searchTerm) { - findCount.textContent = 'No results'; - } else { - findCount.textContent = ''; - } - - $('find-case').classList.toggle('active', storage.caseSensitive); - - if (storage.isOpen && document.activeElement !== findInput && document.activeElement !== replaceInput) { - findInput.focus(); - findInput.select(); - } -} - -findInput.addEventListener('input', () => { - editor.commands.setSearchTerm(findInput.value); -}); - -findInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - if (e.shiftKey) { - editor.commands.prevMatch(); - } else { - editor.commands.nextMatch(); - } - } else if (e.key === 'Escape') { - e.preventDefault(); - editor.commands.closeSearch(); - editor.commands.focus(); - } -}); - -replaceInput.addEventListener('input', () => { - editor.commands.setReplaceTerm(replaceInput.value); -}); - -replaceInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - editor.commands.replaceCurrent(); - } else if (e.key === 'Escape') { - e.preventDefault(); - editor.commands.closeSearch(); - editor.commands.focus(); - } -}); - -$('find-next').addEventListener('click', () => editor.commands.nextMatch()); -$('find-prev').addEventListener('click', () => editor.commands.prevMatch()); -$('find-case').addEventListener('click', () => editor.commands.toggleCaseSensitive()); -$('find-close').addEventListener('click', () => { - editor.commands.closeSearch(); - editor.commands.focus(); -}); -$('find-replace-toggle').addEventListener('click', () => { - const storage = editor.storage.searchReplace; - storage.showReplace = !storage.showReplace; - updateFindBar(); -}); -$('replace-one').addEventListener('click', () => editor.commands.replaceCurrent()); -$('replace-all').addEventListener('click', () => editor.commands.replaceAll()); +// --- Find Bar (extracted) --- +insertFindBar(); +const findBarResult = wireFindBar({ editor, $ }); -// --- Keyboard shortcuts --- -document.addEventListener('keydown', (e) => { - const mod = e.metaKey || e.ctrlKey; - if (mod && e.key === '/') { - e.preventDefault(); - showShortcutModal(); - return; - } - if (mod && !e.shiftKey && e.key.toLowerCase() === 'f') { - e.preventDefault(); - editor.commands.openSearch(); - updateFindBar(); - return; - } - if (mod && e.key.toLowerCase() === 'h') { - e.preventDefault(); - editor.commands.openSearchReplace(); - updateFindBar(); - return; - } - if (mod && e.shiftKey && e.key.toLowerCase() === 'g') { - if (editor.storage.searchReplace.isOpen) { - e.preventDefault(); - editor.commands.prevMatch(); - return; - } - } - if (mod && e.key.toLowerCase() === 'g') { - if (editor.storage.searchReplace.isOpen) { - e.preventDefault(); - editor.commands.nextMatch(); - return; - } - } - if (mod && e.shiftKey && e.key.toLowerCase() === 's') { - e.preventDefault(); - exportHTML(); - } else if (mod && e.key.toLowerCase() === 's') { - e.preventDefault(); - provider._saveSnapshot(); - } else if (mod && e.shiftKey && e.key.toLowerCase() === 'p') { - e.preventDefault(); - doExportPdf(); - } else if (mod && e.key.toLowerCase() === 'p') { - e.preventDefault(); - printDocument(); - } else if (mod && e.shiftKey && e.key.toLowerCase() === 'm') { - e.preventDefault(); - mdToggle.toggle(); - } +// --- Keyboard shortcuts (extracted) --- +wireGlobalShortcuts({ + editor, + provider, + $, + exportHTML: exportImport.exportHTML, + doExportPdf: exportImport.doExportPdf, + printDocument: exportImport.printDocument, + mdToggle: { toggle: () => mdToggle.toggle() }, + showShortcutModal, }); // --- Markdown Source Toggle --- const mdToggleBtn = $('btn-md-toggle'); const markdownTextarea = $('markdown-source'); const editorWrapper = $('editor'); +const htmlToMarkdown = turndownHtmlToMarkdown; const mdToggle = createMarkdownToggle({ getEditorHtml: () => editor.getHTML(), @@ -1663,200 +752,12 @@ provider.awareness.on('change', () => { }); // ============================================= -// --- Version History --- +// --- Version History (extracted) --- // ============================================= -const versionManager = new VersionManager({ - maxVersions: 50, - editThreshold: 50, - timeThresholdMs: 5 * 60 * 1000, -}); - -const versionSidebar = $('version-sidebar'); -const versionList = $('version-list'); -const versionPreview = $('version-preview'); -const versionPreviewContent = $('version-preview-content'); -let selectedVersionId: string | null = null; - -// Track edits for version capture triggers -editor.on('update', () => { - versionManager.recordEdit(); - if (versionManager.shouldCapture()) { - captureVersion(); - } -}); - -// Also capture on a 5-minute interval if there have been any edits -setInterval(() => { - if (versionManager.shouldCapture()) { - captureVersion(); - } -}, 60_000); // Check every minute - -async function captureVersion() { - try { - const state = Y.encodeStateAsUpdate(ydoc); - const encrypted = await encrypt(state, cryptoKey); - const text = editor.getText(); - const wordCount = computeWordCount(text); - const metadata = JSON.stringify({ - author: userName, - wordCount, - timestamp: Date.now(), - }); - - await fetch(`/api/documents/${docId}/versions`, { - method: 'POST', - body: encrypted, - headers: { - 'Content-Type': 'application/octet-stream', - 'X-Version-Metadata': metadata, - }, - }); - - versionManager.addVersion(encrypted, { author: userName, wordCount }); - } catch (err) { - console.warn('Failed to capture version', err); - } -} - -// History button toggles sidebar -$('btn-history').addEventListener('click', () => { - const isOpen = versionSidebar.style.display !== 'none'; - if (isOpen) { - versionSidebar.style.display = 'none'; - } else { - versionSidebar.style.display = ''; - loadVersionList(); - } -}); - -$('version-sidebar-close').addEventListener('click', () => { - versionSidebar.style.display = 'none'; - versionPreview.style.display = 'none'; -}); +wireVersionHistory({ editor, ydoc, provider, docId, cryptoKey, userName, $ }); -// ── Share dialog ────────────────────────────────────────────────────────── -const shareDialog = $('share-dialog'); -const shareLinkInput = $('share-link-input') as HTMLInputElement; - -$('btn-share').addEventListener('click', () => { - shareLinkInput.value = window.location.href; - shareDialog.style.display = ''; -}); - -$('share-dialog-close').addEventListener('click', () => { - shareDialog.style.display = 'none'; -}); - -shareDialog.addEventListener('click', (e) => { - if (e.target === shareDialog) shareDialog.style.display = 'none'; -}); - -$('share-copy-link').addEventListener('click', async () => { - try { - await navigator.clipboard.writeText(shareLinkInput.value); - const btn = $('share-copy-link'); - btn.textContent = 'Copied!'; - setTimeout(() => { btn.textContent = 'Copy link'; }, 2000); - } catch { /* clipboard not available */ } -}); - -async function loadVersionList() { - try { - const res = await fetch(`/api/documents/${docId}/versions`); - const versions = await res.json(); - if (versions.length === 0) { - versionList.innerHTML = '
No versions yet
'; - return; - } - versionList.innerHTML = ''; - let prevWordCount = null; - // versions are newest-first from server - for (let i = versions.length - 1; i >= 0; i--) { - const v = versions[i]; - const wc = v.metadata?.wordCount ?? 0; - if (prevWordCount !== null) { - versions[i]._delta = wc - prevWordCount; - } else { - versions[i]._delta = wc; - } - prevWordCount = wc; - } - for (const v of versions) { - const item = document.createElement('button'); - item.className = 'version-item'; - const ts = v.created_at ? new Date(v.created_at + 'Z').toLocaleString() : 'Unknown'; - const author = v.metadata?.author || 'Unknown'; - const wc = v.metadata?.wordCount ?? '?'; - const delta = v._delta; - const deltaStr = delta > 0 ? `+${delta}` : `${delta}`; - const deltaClass = delta > 0 ? 'positive' : delta < 0 ? 'negative' : ''; - item.innerHTML = ` - ${ts} - - ${author} - ${wc} words - ${deltaStr} - - `; - item.addEventListener('click', () => showVersionPreview(v.id)); - versionList.appendChild(item); - } - } catch (err) { - versionList.innerHTML = '
Failed to load versions
'; - } -} - -async function showVersionPreview(versionId: string): Promise { - selectedVersionId = versionId; - versionPreview.style.display = ''; - versionPreviewContent.textContent = 'Loading...'; - try { - const res = await fetch(`/api/documents/${docId}/versions/${versionId}`); - if (!res.ok) throw new Error('Not found'); - const encrypted = new Uint8Array(await res.arrayBuffer()); - const decrypted = await decrypt(encrypted, cryptoKey); - // Create a temporary Y.Doc to decode the snapshot - const tempDoc = new Y.Doc(); - Y.applyUpdate(tempDoc, decrypted); - // Extract text from the Yjs XML fragment - const fragment = tempDoc.getXmlFragment('default'); - versionPreviewContent.innerHTML = ''; - // Render a simple text preview - const previewDiv = document.createElement('div'); - previewDiv.className = 'version-preview-text'; - previewDiv.textContent = fragment.toString(); - versionPreviewContent.appendChild(previewDiv); - tempDoc.destroy(); - } catch (err) { - versionPreviewContent.textContent = 'Failed to load version preview'; - } -} - -$('version-back').addEventListener('click', () => { - versionPreview.style.display = 'none'; - selectedVersionId = null; -}); - -$('version-restore').addEventListener('click', async () => { - if (!selectedVersionId) return; - if (!confirm('Restore this version? Current changes will be replaced.')) return; - try { - const res = await fetch(`/api/documents/${docId}/versions/${selectedVersionId}`); - if (!res.ok) throw new Error('Not found'); - const encrypted = new Uint8Array(await res.arrayBuffer()); - const decrypted = await decrypt(encrypted, cryptoKey); - // Apply the old state as an update - Y.applyUpdate(ydoc, decrypted); - // Save immediately - await provider._saveSnapshot(); - versionPreview.style.display = 'none'; - versionSidebar.style.display = 'none'; - selectedVersionId = null; - } catch (err) { - alert('Failed to restore version'); - } -}); +// --- Share Dialog (extracted) --- +wireShareDialog({ $ }); // --- Version Panel (new slide-in, Cmd+Shift+H) --- const docsVersionPanel = createVersionPanel({ @@ -2150,7 +1051,7 @@ const outlineList = $('outline-list'); function renderOutline() { const json = editor.getJSON(); - const headings = extractHeadings(json); + const headings = extractOutlineHeadings(json); outlineState.updateHeadings(headings); if (headings.length === 0) { @@ -2179,7 +1080,7 @@ function scrollToHeading(heading: { id: string; level: number; text: string }): doc.descendants((node, pos) => { if (node.type.name === 'heading' && node.attrs.level <= 3) { - const currentHeadings = extractHeadings(editor.getJSON()); + const currentHeadings = extractOutlineHeadings(editor.getJSON()); if (currentHeadings[headingIndex] && currentHeadings[headingIndex].id === heading.id) { targetPos = pos; return false; @@ -2436,10 +1337,10 @@ createCommandPalette({ { id: 'table', label: 'Insert Table', category: 'action', icon: '\u2637', action: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }, { id: 'markdown', label: 'Toggle Markdown', category: 'action', icon: 'MD', shortcut: '\u2318\u21e7M', action: () => mdToggle.toggle() }, { id: 'zen', label: 'Zen Mode', category: 'action', icon: '\u2022', shortcut: '\u2318\u21e7F', action: () => toggleZenMode() }, - { id: 'export-pdf', label: 'Export PDF', category: 'action', icon: '\u2193', shortcut: '\u2318\u21e7P', action: () => doExportPdf() }, - { id: 'export-docx', label: 'Export Word', category: 'action', icon: '\u2193', action: () => doExportDocx() }, - { id: 'import', label: 'Import File', category: 'action', icon: '\u2191', action: () => importFile() }, - { id: 'find', label: 'Find & Replace', category: 'action', icon: '\u2315', shortcut: '\u2318F', action: () => { editor.commands.openSearch(); updateFindBar(); } }, + { id: 'export-pdf', label: 'Export PDF', category: 'action', icon: '\u2193', shortcut: '\u2318\u21e7P', action: () => exportImport.doExportPdf() }, + { id: 'export-docx', label: 'Export Word', category: 'action', icon: '\u2193', action: () => exportImport.doExportDocx() }, + { id: 'import', label: 'Import File', category: 'action', icon: '\u2191', action: () => exportImport.importFile() }, + { id: 'find', label: 'Find & Replace', category: 'action', icon: '\u2315', shortcut: '\u2318F', action: () => { editor.commands.openSearch(); findBarResult.updateFindBar(); } }, { id: 'toggle', label: 'Insert Toggle Block', category: 'action', icon: '\u25B6', action: () => editor.chain().focus().insertToggleBlock().run() }, { id: 'footnote', label: 'Insert Footnote', category: 'action', icon: '\u2020', action: () => editor.chain().focus().insertFootnote().run() }, ], @@ -2661,11 +1562,11 @@ function renderCommentThreads() { } function syncCommentsToYjs() { - yDoc.getMap('meta').set('commentThreads', JSON.stringify(commentThreads)); + ydoc.getMap('meta').set('commentThreads', JSON.stringify(commentThreads)); } function loadCommentsFromYjs() { - const raw = yDoc.getMap('meta').get('commentThreads') as string | undefined; + const raw = ydoc.getMap('meta').get('commentThreads') as string | undefined; if (raw) { try { commentThreads = JSON.parse(raw); } catch { commentThreads = []; } } @@ -2673,7 +1574,7 @@ function loadCommentsFromYjs() { } // Sync comments on yjs changes -yDoc.getMap('meta').observe(() => { loadCommentsFromYjs(); }); +ydoc.getMap('meta').observe(() => { loadCommentsFromYjs(); }); btnComments.addEventListener('click', () => { const showing = commentsSidebar.style.display !== 'none'; @@ -2723,13 +1624,14 @@ const followStop = document.getElementById('follow-stop') as HTMLElement; let followState = createFollowState(); let remoteCursors: CursorPosition[] = []; let isFollowScroll = false; +const editorContainer = document.querySelector('.editor-container'); followStop.addEventListener('click', () => { followState = stopFollowing(followState); followBanner.style.display = 'none'; }); -// Listen for manual scroll to auto-unfollow (reuse editorContainer from line ~1178) +// Listen for manual scroll to auto-unfollow if (editorContainer) { editorContainer.addEventListener('scroll', () => { if (isFollowScroll) { isFollowScroll = false; return; } @@ -2802,4 +1704,3 @@ setTimeout(loadCommentsFromYjs, 500); // Styled tooltips setupTooltips(); - diff --git a/src/docs/toolbar-wiring.ts b/src/docs/toolbar-wiring.ts new file mode 100644 index 0000000..f3a57a5 --- /dev/null +++ b/src/docs/toolbar-wiring.ts @@ -0,0 +1,432 @@ +/** + * Toolbar Wiring — button event handlers, dropdowns, alignment, formatting, + * font/heading selects, color pickers, toolbar state updates. + * + * Extracted from main.ts for decomposition. + */ + +import type { Editor } from '@tiptap/core'; +import { LINE_SPACING_PRESETS } from './extensions/line-spacing.js'; +import { PARAGRAPH_SPACING_PRESETS } from './extensions/paragraph-spacing.js'; + +// ── Types ─────────────────────────────────────────────────── + +export interface ToolbarDeps { + editor: any; + closeAllDropdowns: () => void; + toggleDropdown: (el: HTMLElement) => void; + $: (id: string) => HTMLElement; +} + +// ── Alignment ─────────────────────────────────────────────── + +const ALIGN_SVGS: Record = { + left: '', + center: '', + right: '', +}; + +export { ALIGN_SVGS }; + +const FONT_SIZES = ['8px', '9px', '10px', '11px', '12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px', '36px', '48px', '72px']; + +// ── Dropdown utilities ────────────────────────────────────── + +export function closeAllDropdowns($: (id: string) => HTMLElement): void { + document.querySelectorAll('.toolbar-dropdown.open, .toolbar-overflow.open').forEach(el => { + el.classList.remove('open'); + }); + const paraSub = $('dd-para-spacing'); + if (paraSub) paraSub.style.display = 'none'; +} + +export function toggleDropdown(dropdownEl: HTMLElement, closeAll: () => void): void { + const wasOpen = dropdownEl.classList.contains('open'); + closeAll(); + if (!wasOpen) dropdownEl.classList.add('open'); +} + +export function wireDropdownCloseHandlers(closeAll: () => void): void { + document.addEventListener('click', (e: any) => { + if (!e.target.closest('.toolbar-dropdown') && !e.target.closest('.toolbar-overflow')) { + closeAll(); + } + }); + document.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape') closeAll(); + }); +} + +// ── Main toolbar wiring ───────────────────────────────────── + +export function wireToolbarButtons(deps: ToolbarDeps): void { + const { editor, closeAllDropdowns: closeAll, toggleDropdown: toggle, $ } = deps; + + // --- Alignment dropdown --- + const alignDropdown = $('dd-align'); + const alignToggle = $('tb-align-toggle'); + + alignToggle.addEventListener('click', (e: Event) => { + e.stopPropagation(); + toggle(alignDropdown); + }); + + alignDropdown.querySelectorAll('[data-align]').forEach((btn: any) => { + btn.addEventListener('click', (e: Event) => { + e.stopPropagation(); + const align = btn.dataset.align; + editor.chain().focus().setTextAlign(align).run(); + alignToggle.querySelector('.dd-icon').innerHTML = ALIGN_SVGS[align] || ALIGN_SVGS.left; + closeAll(); + }); + }); + + // --- List buttons --- + $('tb-bullet-list').addEventListener('click', () => editor.chain().focus().toggleBulletList().run()); + $('tb-ordered-list').addEventListener('click', () => editor.chain().focus().toggleOrderedList().run()); + $('tb-task-list').addEventListener('click', () => editor.chain().focus().toggleTaskList().run()); + + // --- Overflow "More" menu --- + const overflowMenu = $('overflow-menu'); + const overflowToggle = $('overflow-toggle'); + + overflowToggle.addEventListener('click', (e: Event) => { + e.stopPropagation(); + toggle(overflowMenu); + }); + + // Text formatting + $('tb-bold').addEventListener('click', () => editor.chain().focus().toggleBold().run()); + $('tb-italic').addEventListener('click', () => editor.chain().focus().toggleItalic().run()); + $('tb-underline').addEventListener('click', () => editor.chain().focus().toggleUnderline().run()); + $('tb-strike').addEventListener('click', () => editor.chain().focus().toggleStrike().run()); + $('tb-code').addEventListener('click', () => { editor.chain().focus().toggleCode().run(); closeAll(); }); + + // More menu items + $('tb-subscript').addEventListener('click', () => { editor.chain().focus().toggleSubscript().run(); closeAll(); }); + $('tb-superscript').addEventListener('click', () => { editor.chain().focus().toggleSuperscript().run(); closeAll(); }); + $('tb-hr').addEventListener('click', () => { editor.chain().focus().setHorizontalRule().run(); closeAll(); }); + + // Blocks (in overflow menu) + $('tb-blockquote').addEventListener('click', () => { editor.chain().focus().toggleBlockquote().run(); closeAll(); }); + $('tb-codeblock').addEventListener('click', () => { editor.chain().focus().toggleCodeBlock().run(); closeAll(); }); + + // Heading select + $('tb-heading').addEventListener('change', (e: any) => { + const val = e.target.value; + if (val === 'paragraph') { + editor.chain().focus().setParagraph().run(); + } else { + editor.chain().focus().toggleHeading({ level: parseInt(val) }).run(); + } + }); + + // Font size select + $('tb-font-size').addEventListener('change', (e: any) => { + const val = e.target.value; + if (val) { + editor.chain().focus().setFontSize(val).run(); + } else { + editor.chain().focus().unsetFontSize().run(); + } + }); + + // Indent / Outdent + $('tb-indent').addEventListener('click', () => { + if (editor.isActive('listItem')) { + editor.chain().focus().sinkListItem('listItem').run(); + } else if (editor.isActive('taskItem')) { + editor.chain().focus().sinkListItem('taskItem').run(); + } else { + editor.chain().focus().increaseIndent().run(); + } + }); + $('tb-outdent').addEventListener('click', () => { + if (editor.isActive('listItem')) { + editor.chain().focus().liftListItem('listItem').run(); + } else if (editor.isActive('taskItem')) { + editor.chain().focus().liftListItem('taskItem').run(); + } else { + editor.chain().focus().decreaseIndent().run(); + } + }); + + // --- Line Spacing dropdown --- + const lineSpacingDropdown = $('dd-line-spacing'); + const lineSpacingToggle = $('tb-line-spacing-toggle'); + + lineSpacingToggle.addEventListener('click', (e: Event) => { + e.stopPropagation(); + toggle(lineSpacingDropdown); + }); + + lineSpacingDropdown.querySelectorAll('[data-line-spacing]').forEach((btn: any) => { + btn.addEventListener('click', (e: Event) => { + e.stopPropagation(); + const value = btn.dataset.lineSpacing; + if (value === 'default') { + editor.chain().focus().unsetLineSpacing().run(); + } else { + editor.chain().focus().setLineSpacing(value).run(); + } + closeAll(); + }); + }); + + // --- Paragraph Spacing (sub-menu from overflow) --- + const paraSpacingDropdown = $('dd-para-spacing'); + const paraSpacingTrigger = $('tb-para-spacing-toggle'); + + paraSpacingTrigger.addEventListener('click', (e: Event) => { + e.stopPropagation(); + const rect = paraSpacingTrigger.getBoundingClientRect(); + paraSpacingDropdown.style.display = ''; + paraSpacingDropdown.style.top = rect.top + 'px'; + paraSpacingDropdown.style.left = (rect.right + 4) + 'px'; + overflowMenu.classList.remove('open'); + }); + + paraSpacingDropdown.querySelectorAll('[data-para-spacing]').forEach((btn: any) => { + btn.addEventListener('click', (e: Event) => { + e.stopPropagation(); + const value = btn.dataset.paraSpacing; + if (value === 'default') { + editor.chain().focus().unsetParagraphSpacing().run(); + } else { + editor.chain().focus().setParagraphSpacing(value).run(); + } + closeAll(); + }); + }); + + // --- Page Break button --- + $('tb-page-break').addEventListener('click', () => { + editor.chain().focus().insertPageBreak().run(); + closeAll(); + }); + + // --- Spell Check toggle --- + const spellCheckBtn = $('tb-spellcheck'); + let spellCheckEnabled = localStorage.getItem('tools-spellcheck') !== 'false'; + + function applySpellCheck() { + const editorEl = document.querySelector('.ProseMirror'); + if (editorEl) { + editorEl.setAttribute('spellcheck', String(spellCheckEnabled)); + } + spellCheckBtn.classList.toggle('active', spellCheckEnabled); + spellCheckBtn.title = spellCheckEnabled ? 'Disable spell check' : 'Enable spell check'; + } + + spellCheckBtn.addEventListener('click', () => { + spellCheckEnabled = !spellCheckEnabled; + localStorage.setItem('tools-spellcheck', String(spellCheckEnabled)); + applySpellCheck(); + closeAll(); + }); + + requestAnimationFrame(applySpellCheck); + + // --- Colors --- + const textColorInput = $('tb-text-color') as any; + const textColorSwatch = $('tb-text-color-swatch'); + const highlightInput = $('tb-highlight') as any; + const highlightSwatch = $('tb-highlight-swatch'); + + function updateColorSwatches() { + if (textColorSwatch) textColorSwatch.style.background = textColorInput.value; + if (highlightSwatch) highlightSwatch.style.background = highlightInput.value; + } + updateColorSwatches(); + + textColorInput.addEventListener('input', (e: any) => { + editor.chain().focus().setColor(e.target.value).run(); + updateColorSwatches(); + }); + highlightInput.addEventListener('input', (e: any) => { + editor.chain().focus().toggleHighlight({ color: e.target.value }).run(); + updateColorSwatches(); + }); + + // Insert link + $('tb-link').addEventListener('click', () => { + const url = prompt('URL:'); + if (url) editor.chain().focus().setLink({ href: url }).run(); + }); + + // Insert image + $('tb-image').addEventListener('click', () => { + const url = prompt('Image URL:'); + if (url) editor.chain().focus().setImage({ src: url }).run(); + }); + + // Insert table + $('tb-table').addEventListener('click', () => { + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + closeAll(); + }); + + // Undo / Redo + $('tb-undo').addEventListener('click', () => editor.chain().focus().undo().run()); + $('tb-redo').addEventListener('click', () => editor.chain().focus().redo().run()); + + // Update active states on selection change + const updateState = () => updateToolbarState(editor, $, alignDropdown, alignToggle, lineSpacingDropdown, paraSpacingDropdown); + editor.on('selectionUpdate', updateState); + editor.on('transaction', updateState); +} + +// ── Toolbar state ─────────────────────────────────────────── + +export function updateToolbarState( + editor: any, + $: (id: string) => HTMLElement, + alignDropdown: HTMLElement, + alignToggle: HTMLElement, + lineSpacingDropdown: HTMLElement, + paraSpacingDropdown: HTMLElement, +): void { + const toggleClass = (id: string, active: boolean) => $(id).classList.toggle('active', active); + toggleClass('tb-bold', editor.isActive('bold')); + toggleClass('tb-italic', editor.isActive('italic')); + toggleClass('tb-underline', editor.isActive('underline')); + toggleClass('tb-strike', editor.isActive('strike')); + toggleClass('tb-code', editor.isActive('code')); + toggleClass('tb-blockquote', editor.isActive('blockquote')); + toggleClass('tb-codeblock', editor.isActive('codeBlock')); + + $('tb-subscript').classList.toggle('active', editor.isActive('subscript')); + $('tb-superscript').classList.toggle('active', editor.isActive('superscript')); + + // Alignment dropdown + const alignItems = alignDropdown.querySelectorAll('[data-align]'); + let currentAlign = 'left'; + alignItems.forEach((item: any) => { + const isActive = editor.isActive({ textAlign: item.dataset.align }); + item.classList.toggle('active', isActive); + if (isActive) currentAlign = item.dataset.align; + }); + alignToggle.querySelector('.dd-icon')!.innerHTML = ALIGN_SVGS[currentAlign] || ALIGN_SVGS.left; + alignToggle.classList.toggle('active', currentAlign !== 'left'); + + // List buttons + toggleClass('tb-bullet-list', editor.isActive('bulletList')); + toggleClass('tb-ordered-list', editor.isActive('orderedList')); + toggleClass('tb-task-list', editor.isActive('taskList')); + + // Heading dropdown + const headingEl = $('tb-heading') as any; + if (editor.isActive('heading', { level: 1 })) headingEl.value = '1'; + else if (editor.isActive('heading', { level: 2 })) headingEl.value = '2'; + else if (editor.isActive('heading', { level: 3 })) headingEl.value = '3'; + else headingEl.value = 'paragraph'; + + // Font size dropdown + const fontSizeEl = $('tb-font-size') as any; + const attrs = editor.getAttributes('textStyle'); + const currentSize = attrs.fontSize || ''; + fontSizeEl.value = FONT_SIZES.includes(currentSize) ? currentSize : ''; + + // Line spacing + const lineSpacingItems = lineSpacingDropdown.querySelectorAll('[data-line-spacing]'); + const paraAttrs = editor.getAttributes('paragraph'); + const headingAttrs = editor.getAttributes('heading'); + const currentLineHeight = paraAttrs.lineHeight || headingAttrs.lineHeight || null; + lineSpacingItems.forEach((item: any) => { + const val = item.dataset.lineSpacing; + const isActive = (val === 'default' && !currentLineHeight) || val === currentLineHeight; + item.classList.toggle('active', isActive); + }); + + // Paragraph spacing + const paraSpacingItems = paraSpacingDropdown.querySelectorAll('[data-para-spacing]'); + const currentParaSpacing = paraAttrs.paragraphSpacing || headingAttrs.paragraphSpacing || null; + paraSpacingItems.forEach((item: any) => { + const val = item.dataset.paraSpacing; + const isActive = (val === 'default' && !currentParaSpacing) || val === currentParaSpacing; + item.classList.toggle('active', isActive); + }); +} + +// ── Inline Comments (toolbar button + popover) ────────────── + +export interface CommentDeps { + editor: any; + userName: string; + $: (id: string) => HTMLElement; +} + +export function wireInlineComments(deps: CommentDeps): void { + const { editor, userName, $ } = deps; + let activeCommentMark: { id: string; element: Element } | null = null; + const commentPopover = $('comment-popover'); + const commentAuthorEl = $('comment-author'); + const commentTimeEl = $('comment-time'); + const commentTextEl = $('comment-text'); + + $('tb-comment').addEventListener('click', () => { + const { from, to } = editor.state.selection; + if (from === to) return; + const commentText = prompt('Add a comment:'); + if (!commentText) return; + const commentId = crypto.randomUUID(); + const timestamp = new Date().toISOString(); + editor.chain().focus().setComment({ + commentId, + author: userName, + timestamp, + text: commentText, + }).run(); + }); + + document.addEventListener('click', (e: any) => { + const commentEl = e.target.closest('.comment-mark'); + if (commentEl) { + const id = commentEl.getAttribute('data-comment-id'); + const author = commentEl.getAttribute('data-comment-author'); + const timestamp = commentEl.getAttribute('data-comment-timestamp'); + const text = commentEl.getAttribute('data-comment-text'); + + commentAuthorEl.textContent = author || 'Unknown'; + commentTimeEl.textContent = timestamp ? new Date(timestamp).toLocaleString() : ''; + commentTextEl.textContent = text || ''; + activeCommentMark = { id, element: commentEl }; + + const rect = commentEl.getBoundingClientRect(); + commentPopover.style.display = ''; + commentPopover.style.left = `${Math.min(rect.left, window.innerWidth - 260)}px`; + commentPopover.style.top = `${rect.bottom + 4}px`; + } else if (!e.target.closest('.comment-popover')) { + commentPopover.style.display = 'none'; + activeCommentMark = null; + } + }); + + function removeActiveComment() { + if (!activeCommentMark) return; + const commentId = activeCommentMark.id; + const { doc } = editor.state; + const commentMarkType = editor.schema.marks.comment; + const positions: { from: number; to: number }[] = []; + doc.descendants((node: any, pos: number) => { + if (node.isText) { + node.marks.forEach((mark: any) => { + if (mark.type === commentMarkType && mark.attrs.commentId === commentId) { + positions.push({ from: pos, to: pos + node.nodeSize }); + } + }); + } + }); + editor.chain().focus().command(({ tr }: any) => { + positions.forEach(({ from, to }) => { + tr.removeMark(from, to, commentMarkType); + }); + return true; + }).run(); + commentPopover.style.display = 'none'; + activeCommentMark = null; + } + + $('comment-resolve').addEventListener('click', removeActiveComment); + $('comment-delete').addEventListener('click', removeActiveComment); +} diff --git a/src/docs/version-history-ui.ts b/src/docs/version-history-ui.ts new file mode 100644 index 0000000..56f5193 --- /dev/null +++ b/src/docs/version-history-ui.ts @@ -0,0 +1,224 @@ +/** + * Version History UI — version capture, list rendering, preview, restore, + * sidebar toggle, and auto-capture triggers. + * + * Extracted from main.ts for decomposition. + */ + +import * as Y from 'yjs'; +import { VersionManager, computeWordCount } from '../lib/version-history.js'; +import { encrypt, decrypt } from '../lib/crypto.js'; + +// ── Types ─────────────────────────────────────────────────── + +export interface VersionHistoryDeps { + editor: any; + ydoc: any; + provider: any; + docId: string; + cryptoKey: CryptoKey; + userName: string; + $: (id: string) => HTMLElement; +} + +// ── Version History ───────────────────────────────────────── + +export function wireVersionHistory(deps: VersionHistoryDeps): void { + const { editor, ydoc, provider, docId, cryptoKey, userName, $ } = deps; + + const versionManager = new VersionManager({ + maxVersions: 50, + editThreshold: 50, + timeThresholdMs: 5 * 60 * 1000, + }); + + const versionSidebar = $('version-sidebar'); + const versionList = $('version-list'); + const versionPreview = $('version-preview'); + const versionPreviewContent = $('version-preview-content'); + let selectedVersionId: string | null = null; + + // Track edits for version capture triggers + editor.on('update', () => { + versionManager.recordEdit(); + if (versionManager.shouldCapture()) { + captureVersion(); + } + }); + + // Check every minute for time-based capture + setInterval(() => { + if (versionManager.shouldCapture()) { + captureVersion(); + } + }, 60_000); + + async function captureVersion() { + try { + const state = Y.encodeStateAsUpdate(ydoc); + const encrypted = await encrypt(state, cryptoKey); + const text = editor.getText(); + const wordCount = computeWordCount(text); + const metadata = JSON.stringify({ + author: userName, + wordCount, + timestamp: Date.now(), + }); + + await fetch(`/api/documents/${docId}/versions`, { + method: 'POST', + body: encrypted, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Version-Metadata': metadata, + }, + }); + + versionManager.addVersion(encrypted, { author: userName, wordCount }); + } catch (err) { + console.warn('Failed to capture version', err); + } + } + + // History button toggles sidebar + $('btn-history').addEventListener('click', () => { + const isOpen = versionSidebar.style.display !== 'none'; + if (isOpen) { + versionSidebar.style.display = 'none'; + } else { + versionSidebar.style.display = ''; + loadVersionList(); + } + }); + + $('version-sidebar-close').addEventListener('click', () => { + versionSidebar.style.display = 'none'; + versionPreview.style.display = 'none'; + }); + + async function loadVersionList() { + try { + const res = await fetch(`/api/documents/${docId}/versions`); + const versions = await res.json(); + if (versions.length === 0) { + versionList.innerHTML = '
No versions yet
'; + return; + } + versionList.innerHTML = ''; + let prevWordCount: number | null = null; + // versions are newest-first from server + for (let i = versions.length - 1; i >= 0; i--) { + const v = versions[i]; + const wc = v.metadata?.wordCount ?? 0; + if (prevWordCount !== null) { + versions[i]._delta = wc - prevWordCount; + } else { + versions[i]._delta = wc; + } + prevWordCount = wc; + } + for (const v of versions) { + const item = document.createElement('button'); + item.className = 'version-item'; + const ts = v.created_at ? new Date(v.created_at + 'Z').toLocaleString() : 'Unknown'; + const author = v.metadata?.author || 'Unknown'; + const wc = v.metadata?.wordCount ?? '?'; + const delta = v._delta; + const deltaStr = delta > 0 ? `+${delta}` : `${delta}`; + const deltaClass = delta > 0 ? 'positive' : delta < 0 ? 'negative' : ''; + item.innerHTML = ` + ${ts} + + ${author} + ${wc} words + ${deltaStr} + + `; + item.addEventListener('click', () => showVersionPreview(v.id)); + versionList.appendChild(item); + } + } catch (err) { + versionList.innerHTML = '
Failed to load versions
'; + } + } + + async function showVersionPreview(versionId: string): Promise { + selectedVersionId = versionId; + versionPreview.style.display = ''; + versionPreviewContent.textContent = 'Loading...'; + try { + const res = await fetch(`/api/documents/${docId}/versions/${versionId}`); + if (!res.ok) throw new Error('Not found'); + const encrypted = new Uint8Array(await res.arrayBuffer()); + const decrypted = await decrypt(encrypted, cryptoKey); + const tempDoc = new Y.Doc(); + Y.applyUpdate(tempDoc, decrypted); + const fragment = tempDoc.getXmlFragment('default'); + versionPreviewContent.innerHTML = ''; + const previewDiv = document.createElement('div'); + previewDiv.className = 'version-preview-text'; + previewDiv.textContent = fragment.toString(); + versionPreviewContent.appendChild(previewDiv); + tempDoc.destroy(); + } catch (err) { + versionPreviewContent.textContent = 'Failed to load version preview'; + } + } + + $('version-back').addEventListener('click', () => { + versionPreview.style.display = 'none'; + selectedVersionId = null; + }); + + $('version-restore').addEventListener('click', async () => { + if (!selectedVersionId) return; + if (!confirm('Restore this version? Current changes will be replaced.')) return; + try { + const res = await fetch(`/api/documents/${docId}/versions/${selectedVersionId}`); + if (!res.ok) throw new Error('Not found'); + const encrypted = new Uint8Array(await res.arrayBuffer()); + const decrypted = await decrypt(encrypted, cryptoKey); + Y.applyUpdate(ydoc, decrypted); + await provider._saveSnapshot(); + versionPreview.style.display = 'none'; + versionSidebar.style.display = 'none'; + selectedVersionId = null; + } catch (err) { + alert('Failed to restore version'); + } + }); +} + +// ── Share Dialog ──────────────────────────────────────────── + +export interface ShareDialogDeps { + $: (id: string) => HTMLElement; +} + +export function wireShareDialog(deps: ShareDialogDeps): void { + const { $ } = deps; + const shareDialog = $('share-dialog'); + const shareLinkInput = $('share-link-input') as HTMLInputElement; + + $('btn-share').addEventListener('click', () => { + shareLinkInput.value = window.location.href; + shareDialog.style.display = ''; + }); + + $('share-dialog-close').addEventListener('click', () => { + shareDialog.style.display = 'none'; + }); + + shareDialog.addEventListener('click', (e: Event) => { + if (e.target === shareDialog) shareDialog.style.display = 'none'; + }); + + $('share-copy-link').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(shareLinkInput.value); + const btn = $('share-copy-link'); + btn.textContent = 'Copied!'; + setTimeout(() => { btn.textContent = 'Copy link'; }, 2000); + } catch { /* clipboard not available */ } + }); +}