/** * 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 }; }