Full document, spreadsheet, slideshow, and diagram tooling
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}