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

Configure Feed

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

at main 217 lines 7.8 kB view raw
1/** 2 * Block Handle UI — DOM creation, positioning, context menu rendering, 3 * turn-into menu, mouse tracking, and event wiring. 4 * 5 * Extracted from main.ts for decomposition. 6 */ 7 8import { BlockHandleState, BLOCK_HANDLE_ACTIONS, BLOCK_HANDLE_ICON, BLOCK_HANDLE_ADD_ICON, TURN_INTO_ITEMS } from './block-handle.js'; 9import { getCommandExecutor } from './extensions/slash-commands.js'; 10 11// ── Types ─────────────────────────────────────────────────── 12 13export interface BlockHandleUIDeps { 14 editor: any; 15 $: (id: string) => HTMLElement; 16} 17 18// ── Block Handle UI ───────────────────────────────────────── 19 20export function wireBlockHandleUI(deps: BlockHandleUIDeps): { blockHandleState: BlockHandleState } { 21 const { editor } = deps; 22 const editorEl = document.getElementById('editor'); 23 const blockHandleState = new BlockHandleState(); 24 25 // Create block handle element 26 const blockHandleEl = document.createElement('div'); 27 blockHandleEl.className = 'block-handle'; 28 blockHandleEl.id = 'block-handle'; 29 blockHandleEl.style.display = 'none'; 30 blockHandleEl.innerHTML = `<button class="block-handle-add" title="Add block below">${BLOCK_HANDLE_ADD_ICON}</button><button class="block-handle-grip" title="Drag to reorder / Click for options">${BLOCK_HANDLE_ICON}</button>`; 31 document.body.appendChild(blockHandleEl); 32 33 // Create block context menu element 34 const blockContextMenuEl = document.createElement('div'); 35 blockContextMenuEl.className = 'block-context-menu'; 36 blockContextMenuEl.id = 'block-context-menu'; 37 blockContextMenuEl.style.display = 'none'; 38 document.body.appendChild(blockContextMenuEl); 39 40 let blockHandleTimeout: ReturnType<typeof setTimeout> | null = null; 41 42 function showBlockHandle(blockElement: Element, pos: number): void { 43 if (!blockElement || !editorEl) return; 44 const editorRect = editorEl.getBoundingClientRect(); 45 const blockRect = blockElement.getBoundingClientRect(); 46 const top = blockRect.top; 47 const left = editorRect.left - 36; 48 blockHandleState.show({ top, left }, pos); 49 blockHandleEl.style.display = 'flex'; 50 blockHandleEl.style.top = `${top}px`; 51 blockHandleEl.style.left = `${Math.max(4, left)}px`; 52 } 53 54 function hideBlockHandle() { 55 blockHandleState.hide(); 56 blockHandleEl.style.display = 'none'; 57 blockContextMenuEl.style.display = 'none'; 58 } 59 60 function renderBlockContextMenu() { 61 let html = ''; 62 for (const action of BLOCK_HANDLE_ACTIONS) { 63 html += `<button class="block-context-item" data-action="${action.id}">`; 64 html += `<span class="block-context-icon">${action.icon}</span>`; 65 html += `<span class="block-context-label">${action.label}</span>`; 66 html += `</button>`; 67 } 68 blockContextMenuEl.innerHTML = html; 69 blockContextMenuEl.style.display = 'block'; 70 71 const pos = blockHandleState.position; 72 if (pos) { 73 blockContextMenuEl.style.top = `${pos.top + 24}px`; 74 blockContextMenuEl.style.left = `${pos.left}px`; 75 } 76 77 // Wire context menu actions 78 blockContextMenuEl.querySelectorAll('.block-context-item').forEach((btn: any) => { 79 btn.addEventListener('mousedown', (e: Event) => { 80 e.preventDefault(); 81 const actionId = btn.dataset.action; 82 executeBlockAction(actionId); 83 }); 84 }); 85 } 86 87 function executeBlockAction(actionId: string): void { 88 const pos = blockHandleState.blockPos; 89 if (pos == null) return; 90 91 switch (actionId) { 92 case 'delete': 93 editor.chain().focus().deleteNode(editor.state.doc.resolve(pos).parent.type.name).run(); 94 blockContextMenuEl.style.display = 'none'; 95 hideBlockHandle(); 96 break; 97 case 'duplicate': { 98 const node = editor.state.doc.resolve(pos).parent; 99 const endPos = pos + node.nodeSize; 100 editor.chain().focus().insertContentAt(endPos, node.toJSON()).run(); 101 blockContextMenuEl.style.display = 'none'; 102 break; 103 } 104 case 'moveUp': 105 case 'moveDown': 106 blockContextMenuEl.style.display = 'none'; 107 break; 108 case 'turnInto': 109 renderTurnIntoMenu(); 110 break; 111 default: 112 blockContextMenuEl.style.display = 'none'; 113 } 114 } 115 116 function renderTurnIntoMenu() { 117 blockHandleState.openTurnIntoMenu(); 118 let html = '<div class="block-context-sub-header">Turn into</div>'; 119 for (const item of TURN_INTO_ITEMS) { 120 html += `<button class="block-context-item" data-turn-into="${item.id}">`; 121 html += `<span class="block-context-icon">${item.icon}</span>`; 122 html += `<span class="block-context-label">${item.name}</span>`; 123 html += `</button>`; 124 } 125 blockContextMenuEl.innerHTML = html; 126 127 blockContextMenuEl.querySelectorAll('[data-turn-into]').forEach((btn: any) => { 128 btn.addEventListener('mousedown', (e: Event) => { 129 e.preventDefault(); 130 const typeId = btn.dataset.turnInto; 131 executeTurnInto(typeId); 132 }); 133 }); 134 } 135 136 function executeTurnInto(typeId: string): void { 137 const executor = getCommandExecutor({ id: typeId }); 138 if (executor) { 139 executor(editor); 140 } 141 blockContextMenuEl.style.display = 'none'; 142 blockHandleState.closeContextMenu(); 143 } 144 145 // Grip click -> context menu 146 blockHandleEl.querySelector('.block-handle-grip')!.addEventListener('click', (e: Event) => { 147 e.stopPropagation(); 148 if (blockHandleState.contextMenuOpen) { 149 blockHandleState.closeContextMenu(); 150 blockContextMenuEl.style.display = 'none'; 151 } else { 152 blockHandleState.openContextMenu(); 153 renderBlockContextMenu(); 154 } 155 }); 156 157 // Add button -> insert paragraph below 158 blockHandleEl.querySelector('.block-handle-add')!.addEventListener('click', (e: Event) => { 159 e.stopPropagation(); 160 const pos = blockHandleState.blockPos; 161 if (pos != null) { 162 const resolved = editor.state.doc.resolve(pos); 163 const endOfBlock = pos + resolved.parent.nodeSize; 164 editor.chain().focus().insertContentAt(endOfBlock, { type: 'paragraph' }).run(); 165 } 166 }); 167 168 // Close block context menu when clicking outside 169 document.addEventListener('click', (e: any) => { 170 if (!e.target.closest('#block-context-menu') && !e.target.closest('#block-handle')) { 171 blockHandleState.closeContextMenu(); 172 blockContextMenuEl.style.display = 'none'; 173 } 174 }); 175 176 // Track mouse position over editor to show block handles 177 if (editorEl) { 178 editorEl.addEventListener('mousemove', (e: any) => { 179 if (blockHandleState.isHiddenInMode( 180 document.querySelector('.app-shell.zen-mode') ? 'zen' : 'normal' 181 )) { 182 hideBlockHandle(); 183 return; 184 } 185 186 if (blockHandleTimeout) clearTimeout(blockHandleTimeout); 187 blockHandleTimeout = setTimeout(() => { 188 const target = e.target; 189 const blockEl = target.closest('.ProseMirror > *'); 190 if (!blockEl) { 191 hideBlockHandle(); 192 return; 193 } 194 195 const view = editor.view; 196 const pos = view.posAtDOM(blockEl, 0); 197 if (pos != null) { 198 showBlockHandle(blockEl, pos); 199 } 200 }, 50); 201 }); 202 203 editorEl.addEventListener('mouseleave', () => { 204 if (blockHandleTimeout) clearTimeout(blockHandleTimeout); 205 if (!blockHandleState.contextMenuOpen) { 206 blockHandleTimeout = setTimeout(() => hideBlockHandle(), 300); 207 } 208 }); 209 } 210 211 // Prevent hiding when hovering the handle itself 212 blockHandleEl.addEventListener('mouseenter', () => { 213 if (blockHandleTimeout) clearTimeout(blockHandleTimeout); 214 }); 215 216 return { blockHandleState }; 217}