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