From 1524e214d7fe7d6fcb671bce289bada03f4347d2 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:17:07 +0100 Subject: [PATCH 1/8] Move to separate file --- main/blocklyinit.js | 563 +------------------------------------------ ui/contextmenu.js | 566 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 568 insertions(+), 561 deletions(-) create mode 100644 ui/contextmenu.js diff --git a/main/blocklyinit.js b/main/blocklyinit.js index 1f39b89a..cb645f5e 100644 --- a/main/blocklyinit.js +++ b/main/blocklyinit.js @@ -36,6 +36,7 @@ import { defineTextBlocks } from '../blocks/text.js'; import { defineGenerators } from '../generators/generators.js'; import { registerCustomCommentIcon } from './customCommentIcon.js'; import { getMeshFromBlock } from '../ui/blockmesh.js'; +import { initContextMenus } from '../ui/contextmenu.js'; import { toolbox as toolboxDef } from '../toolbox.js'; // Blockly v13 moved variable methods off the workspace onto VariableMap/Variables. @@ -2208,567 +2209,7 @@ export function createBlocklyWorkspace() { }; })(); - // ------- Pointer tracking for "paste at pointer" ------- - const mainWs = Blockly.getMainWorkspace(); - let lastCM = { x: 0, y: 0 }; - (mainWs.getInjectionDiv() || document).addEventListener( - 'contextmenu', - (e) => { - lastCM = { x: e.clientX, y: e.clientY }; - }, - { capture: true } - ); - - // Screen -> workspace coords - function screenToWs(ws, xy) { - const c = new Blockly.utils.Coordinate(xy.x, xy.y); - return Blockly.utils.svgMath.screenToWsCoordinates(ws, c); - } - - // Add a context menu item that mirrors the keyboard-navigation "detach" (X) shortcut. - (function registerDetachContextMenuItem() { - const registry = Blockly.ContextMenuRegistry.registry; - const id = 'detachBlockWithShortcut'; - if (registry.getItem && registry.getItem(id)) return; - - function renderShortcut(label, shortcut) { - const wrapper = document.createElement('span'); - wrapper.style.display = 'flex'; - wrapper.style.alignItems = 'center'; - wrapper.style.justifyContent = 'space-between'; - wrapper.style.gap = '1.5em'; - wrapper.style.width = '100%'; - - const labelEl = document.createElement('span'); - labelEl.textContent = label; - - const shortcutEl = document.createElement('span'); - shortcutEl.textContent = shortcut; - shortcutEl.style.color = 'var(--blockly-text-disabled, #aaa)'; - - wrapper.append(labelEl, shortcutEl); - return wrapper; - } - - registry.register({ - id, - weight: 80, - displayText: () => { - const text = translate('detach_block_option'); - const label = text === 'detach_block_option' ? 'Detach' : text; - return renderShortcut(label, 'X'); - }, - preconditionFn: (scope) => { - const block = scope.block; - if (!block || block.isInFlyout) return 'hidden'; - - const hasParent = - !!block.getParent() || - !!block.previousConnection?.targetConnection || - !!block.outputConnection?.targetConnection; - return hasParent ? 'enabled' : 'disabled'; - }, - callback: (scope) => { - const block = scope.block; - if (!block) return; - - const healStack = !block.outputConnection?.isConnected(); - const prevGroup = Blockly.Events.getGroup(); - Blockly.Events.setGroup('contextmenu_detach'); - block.unplug(healStack); - const cursor = block.workspace?.getCursor?.(); - if (cursor?.setCurNode) cursor.setCurNode(block); - Blockly.Events.setGroup(prevGroup || null); - }, - scopeType: Blockly.ContextMenuRegistry.ScopeType.BLOCK, - }); - })(); - - // Add a context menu item to focus the canvas camera on a block's mesh. - (function registerViewInCanvasContextMenuItem() { - const registry = Blockly.ContextMenuRegistry.registry; - const id = 'viewBlockInCanvas'; - if (registry.getItem && registry.getItem(id)) return; - - function renderShortcut(label, shortcut) { - const wrapper = document.createElement('span'); - wrapper.style.cssText = - 'display:flex;align-items:center;justify-content:space-between;gap:1.5em;width:100%'; - const labelEl = document.createElement('span'); - labelEl.textContent = label; - const shortcutEl = document.createElement('span'); - shortcutEl.textContent = shortcut; - shortcutEl.style.color = 'var(--blockly-text-disabled, #aaa)'; - wrapper.append(labelEl, shortcutEl); - return wrapper; - } - - registry.register({ - id, - weight: 8, - displayText: () => { - const text = translate('view_in_canvas_option'); - const label = text === 'view_in_canvas_option' ? 'View in canvas' : text; - return renderShortcut(label, 'V'); - }, - preconditionFn: (scope) => { - const block = scope.block; - if (!block || block.isInFlyout) return 'hidden'; - try { - const mesh = getMeshFromBlock(block); - return mesh && mesh.name !== 'ground' ? 'enabled' : 'hidden'; - } catch { - return 'hidden'; - } - }, - callback: (scope) => { - const block = scope.block; - if (!block) return; - Promise.all([import('./view.js'), import('../ui/gizmos.js')]).then( - ([{ showCanvasView }, { viewMeshWithCamera }]) => { - showCanvasView(); - window.currentBlock = block; - viewMeshWithCamera(block); - } - ); - }, - scopeType: Blockly.ContextMenuRegistry.ScopeType.BLOCK, - }); - })(); - - // Reorder block context menu items for better grouping. - // Cut/copy/paste are registered at weights 1/2/3; push everything else above that. - (function adjustBlockContextMenuWeights() { - const registry = Blockly.ContextMenuRegistry.registry; - - const weights = { - blockDuplicate: 9, - detachBlockWithShortcut: 10, - viewBlockInCanvas: 10.5, - blockComment: 12, - blockInline: 13, - blockCollapseExpand: 14, - blockDisable: 15, - blockDelete: 20, - blockHelp: 999, - }; - for (const [id, weight] of Object.entries(weights)) { - const item = registry.getItem?.(id); - if (item) item.weight = weight; - } - })(); - - // Remove undo/redo (toolbar buttons cover this) and clean up (flock does this automatically). - // Also remove the separate collapse/expand workspace items — replaced by a single toggle below. - (function removeRedundantContextMenuItems() { - const registry = Blockly.ContextMenuRegistry.registry; - [ - 'undoWorkspace', - 'redoWorkspace', - 'cleanWorkspace', - 'collapseWorkspace', - 'expandWorkspace', - ].forEach((id) => { - try { - registry.unregister(id); - } catch (_) {} - }); - })(); - - // Replace separate "Collapse all" / "Expand all" workspace items with a single toggle. - (function registerCollapseExpandWorkspaceToggle() { - const registry = Blockly.ContextMenuRegistry.registry; - const WORKSPACE = Blockly.ContextMenuRegistry.ScopeType.WORKSPACE; - if (registry.getItem?.('flockCollapseExpandWorkspace')) return; - - const hasAnyExpanded = (ws) => { - for (const block of ws.getTopBlocks(false)) { - let b = block; - while (b) { - if (!b.isCollapsed()) return true; - b = b.getNextBlock(); - } - } - return false; - }; - - registry.register({ - id: 'flockCollapseExpandWorkspace', - weight: 4, - scopeType: WORKSPACE, - displayText: (scope) => - hasAnyExpanded(scope.workspace) - ? translate('context_collapse_all_option') - : translate('context_expand_all_option'), - preconditionFn: (scope) => { - if (!scope.workspace?.options?.collapse) return 'hidden'; - return scope.workspace.getTopBlocks(false).length ? 'enabled' : 'hidden'; - }, - callback: (scope) => { - const ws = scope.workspace; - const shouldCollapse = hasAnyExpanded(ws); - Blockly.Events.setGroup(true); - for (const block of ws.getTopBlocks(true)) { - let b = block; - while (b) { - b.setCollapsed(shouldCollapse); - b = b.getNextBlock(); - } - } - Blockly.Events.setGroup(false); - }, - }); - })(); - - // Rename built-in workspace "Delete" item to the localized "Delete all blocks" label. - (function renameWorkspaceDeleteMenuItem() { - const item = Blockly.ContextMenuRegistry.registry.getItem?.('workspaceDelete'); - if (item) item.displayText = () => translate('context_delete_all_blocks_option'); - })(); - - // Add "Find in workspace" to the workspace context menu. - (function registerWorkspaceSearchContextMenuItem() { - const registry = Blockly.ContextMenuRegistry.registry; - const id = 'workspaceFindInWorkspace'; - if (registry.getItem?.(id)) return; - registry.register({ - id, - weight: 50, - displayText: () => translate('workspace_search_placeholder'), - preconditionFn: () => 'enabled', - callback: () => window.flockWorkspaceSearch?.open(), - scopeType: Blockly.ContextMenuRegistry.ScopeType.WORKSPACE, - }); - })(); - - // Register cut/copy/paste at the top of the block context menu (weights 1/2/3). - (function registerClipboardContextMenuItems() { - const registry = Blockly.ContextMenuRegistry.registry; - const BLOCK = Blockly.ContextMenuRegistry.ScopeType.BLOCK; - const WORKSPACE = Blockly.ContextMenuRegistry.ScopeType.WORKSPACE; - - const notInFlyout = (scope) => (scope.block?.isInFlyout ? 'hidden' : 'enabled'); - const hasCopiedData = () => !!Blockly.clipboard?.getLastCopiedData?.(); - - registry.register({ - id: 'blockCut', - weight: 1, - displayText: () => Blockly.Msg['CUT_SHORTCUT'] || 'Cut', - preconditionFn: notInFlyout, - callback: (scope) => { - const block = scope.block; - if (!block) return; - copyWithoutToast(block); - Blockly.Events.setGroup('contextmenu_cut'); - block.dispose(true); - Blockly.Events.setGroup(false); - }, - scopeType: BLOCK, - }); - - registry.register({ - id: 'blockCopy', - weight: 2, - displayText: () => Blockly.Msg['COPY_SHORTCUT'] || 'Copy', - preconditionFn: notInFlyout, - callback: (scope) => { - const block = scope.block; - if (block) copyWithoutToast(block); - }, - scopeType: BLOCK, - }); - - registry.register({ - id: 'blockPaste', - weight: 3, - displayText: () => Blockly.Msg['PASTE_SHORTCUT'] || 'Paste', - preconditionFn: (scope) => { - if (scope.block?.isInFlyout) return 'hidden'; - return hasCopiedData() ? 'enabled' : 'disabled'; - }, - callback: (scope) => { - const data = Blockly.clipboard?.getLastCopiedData?.(); - if (!data) return; - const ws = scope?.block?.workspace ?? mainWs; - if (!ws) return; - const selected = Blockly.common?.getSelected?.() || null; - if (selected && selected.isInFlyout) return; - pasteAsChildOrHere(selected || null, ws, data); - }, - scopeType: BLOCK, - }); - - registry.register({ - id: 'workspacePaste', - weight: 3, - displayText: () => Blockly.Msg['PASTE_SHORTCUT'] || 'Paste', - preconditionFn: () => (hasCopiedData() ? 'enabled' : 'disabled'), - callback: (scope) => { - const data = Blockly.clipboard?.getLastCopiedData?.(); - if (!data) return; - const ws = scope?.workspace ?? mainWs; - if (!ws) return; - pasteAsChildOrHere(null, ws, data); - }, - scopeType: WORKSPACE, - }); - - if (!registry.getItem?.('flock_ws_sep_after_paste')) { - registry.register({ - id: 'flock_ws_sep_after_paste', - weight: 3.5, - separator: true, - scopeType: WORKSPACE, - }); - } - })(); - - // Add separators to the block context menu to group related items. - // Weights: clipboard(1-3) | 5 | block-ops(9-10) | 10.5 | comment(11-14) | 18 | delete(20) | 50 | export(100-200) | 500 | help(999) - (function registerBlockContextMenuSeparators() { - const registry = Blockly.ContextMenuRegistry.registry; - const BLOCK = Blockly.ContextMenuRegistry.ScopeType.BLOCK; - const separators = [ - { id: 'flock_sep_after_clipboard', weight: 5 }, - { id: 'flock_sep_before_comment', weight: 10.5 }, - { id: 'flock_sep_before_delete', weight: 18 }, - { id: 'flock_sep_before_export', weight: 50 }, - { id: 'flock_sep_before_help', weight: 500 }, - ]; - for (const { id, weight } of separators) { - if (!registry.getItem?.(id)) { - registry.register({ id, weight, separator: true, scopeType: BLOCK }); - } - } - })(); - - // ===== OVERRIDE CLIPBOARD METHODS ===== - const origCopy = Blockly.clipboard.copy; - const origToastShow = Blockly.Toast?.show; - - Blockly.clipboard.copy = function (block) { - origCopy.call(Blockly.clipboard, block); - - if (block?.isInFlyout) { - const tb = Blockly.getMainWorkspace()?.getToolbox?.(); - tb?.getFlyout?.()?.hide?.(); - tb?.getSelectedItem?.()?.setSelected?.(false); - } - }; - - // Assuming Blockly 13 has removed toasts, this is not needed - function copyWithoutToast(block) { - if (!block) return; - if (Blockly.Toast?.show) Blockly.Toast.show = () => {}; - try { - Blockly.clipboard.copy.call(Blockly.clipboard, block); - } finally { - if (Blockly.Toast?.show) Blockly.Toast.show = origToastShow; - } - } - - function overrideContextMenuCopyItem() { - const ids = [ - 'blockCopyToStorage', // Blockly core (common) - 'blockCopyFromContextMenu', // possible variant - ]; - - let item = null; - for (const id of ids) { - item = Blockly.ContextMenuRegistry.registry.getItem(id); - if (item) break; - } - if (!item) return false; - - const original = item.callback; - - item.callback = function (scope, menuOpenEvent, location) { - const block = scope?.block; - if (block) { - copyWithoutToast(block); - return; - } - return original?.call(this, scope, menuOpenEvent, location); - }; - - return true; - } - - (function installCopyOverrideWithRetry(maxAttempts = 20, delayMs = 50) { - let attempts = 0; - const t = setInterval(() => { - attempts++; - if (overrideContextMenuCopyItem() || attempts >= maxAttempts) { - clearInterval(t); - } - }, delayMs); - })(); - - function isTypingInInput() { - const el = document.activeElement; - if (!el) return false; - const tag = el.tagName?.toLowerCase(); - return tag === 'input' || tag === 'textarea' || !!el.isContentEditable; - } - - const host = mainWs.getInjectionDiv() || document; - let __fcLastPointer = { x: 0, y: 0 }; - let __fcLastPointerType = 'mouse'; // 'mouse' | 'touch' | 'pen' - let __fcMenuPoint = null; - let __fcMenuPointerType = 'mouse'; - - host.addEventListener( - 'pointerdown', - (e) => { - if (!e.isPrimary) return; - __fcLastPointer = { x: e.clientX, y: e.clientY }; - __fcLastPointerType = e.pointerType || 'mouse'; - }, - { capture: true } - ); - - host.addEventListener( - 'pointermove', - (e) => { - if (!e.isPrimary) return; - __fcLastPointer = { x: e.clientX, y: e.clientY }; - __fcLastPointerType = e.pointerType || __fcLastPointerType; - }, - { capture: true } - ); - - // Capture the *actual* coordinates that opened the context menu (works for long-press) - const __origShow = Blockly.ContextMenu.show; - Blockly.ContextMenu.show = function (e, options, rtl) { - __fcMenuPoint = { x: e.clientX, y: e.clientY }; - __fcMenuPointerType = e.pointerType || __fcLastPointerType || 'mouse'; - return __origShow.call(Blockly.ContextMenu, e, options, rtl); - }; - host.addEventListener( - 'contextmenu', - (e) => { - lastCM = { x: e.clientX, y: e.clientY }; - }, - { capture: true } - ); - host.addEventListener( - 'mousemove', - (e) => { - lastCM = { x: e.clientX, y: e.clientY }; - }, - { capture: true } - ); - - function pasteAsChildOrHere(targetBlock /* may be null */, ws, data) { - if (!data) return; - const at = screenToWs(ws, lastCM); - const pasted = Blockly.clipboard.paste(data, ws, at); - const pb = /** @type {Blockly.BlockSvg} */ (pasted); - if (!targetBlock) return; - - const checker = ws.getConnectionChecker - ? ws.getConnectionChecker() - : new Blockly.ConnectionChecker(); - const can = (a, b) => checker.canConnect(a, b, /*isDragging=*/ false); - - // 1) stack after: target.next ⟷ pb.previous - if ( - targetBlock.nextConnection && - pb.previousConnection && - can(targetBlock.nextConnection, pb.previousConnection) - ) { - targetBlock.nextConnection.connect(pb.previousConnection); - return; - } - // 2) empty statement input ⟷ pb.previous - for (const input of targetBlock.inputList) { - if ( - input.type === Blockly.NEXT_STATEMENT && - input.connection && - !input.connection.targetBlock() && - pb.previousConnection && - can(input.connection, pb.previousConnection) - ) { - input.connection.connect(pb.previousConnection); - return; - } - } - // 2b) top-level block: insert pb as first child in statement input, - // pushing existing children after pb - const isTopLevel = !targetBlock.previousConnection && !targetBlock.nextConnection; - if (isTopLevel && pb.previousConnection) { - for (const input of targetBlock.inputList) { - if ( - input.type === Blockly.NEXT_STATEMENT && - input.connection && - input.connection.targetBlock() && - can(input.connection, pb.previousConnection) - ) { - const firstChild = input.connection.targetBlock(); - input.connection.disconnect(); - input.connection.connect(pb.previousConnection); - // Append previous first child after pb chain - let lastPb = pb; - while (lastPb.nextConnection && lastPb.nextConnection.targetBlock()) { - lastPb = lastPb.nextConnection.targetBlock(); - } - if ( - lastPb.nextConnection && - firstChild.previousConnection && - can(lastPb.nextConnection, firstChild.previousConnection) - ) { - lastPb.nextConnection.connect(firstChild.previousConnection); - } - return; - } - } - } - // 3) empty value input ⟷ pb.output - for (const input of targetBlock.inputList) { - if ( - input.type === Blockly.INPUT_VALUE && - input.connection && - !input.connection.targetBlock() && - pb.outputConnection && - can(input.connection, pb.outputConnection) - ) { - input.connection.connect(pb.outputConnection); - return; - } - } - // 4) insert above: target.previous ⟷ pb.next - if ( - targetBlock.previousConnection && - pb.nextConnection && - can(targetBlock.previousConnection, pb.nextConnection) - ) { - targetBlock.previousConnection.connect(pb.nextConnection); - return; - } - // else: stays at pointer - } - - // ---- Bind Ctrl/Cmd+V ---- - host.addEventListener( - 'keydown', - (e) => { - if (!(e.ctrlKey || e.metaKey)) return; - if ((e.key || '').toLowerCase() !== 'v') return; - if (isTypingInInput()) return; - - const data = Blockly.clipboard?.getLastCopiedData?.(); - if (!data) return; - - // Selected block (if any, and not from flyout) - const selected = Blockly.common?.getSelected?.() || null; - if (selected && selected.isInFlyout) return; // never paste in the flyout - - e.preventDefault(); - e.stopPropagation(); - pasteAsChildOrHere(selected || null, mainWs, data); - }, - { capture: true } - ); + initContextMenus(workspace); // ---- Touch-friendly confirm dialog ---- if (navigator.maxTouchPoints > 0) { diff --git a/ui/contextmenu.js b/ui/contextmenu.js new file mode 100644 index 00000000..d8ff1999 --- /dev/null +++ b/ui/contextmenu.js @@ -0,0 +1,566 @@ +import * as Blockly from 'blockly'; +import { translate } from '../main/translation.js'; +import { getMeshFromBlock } from './blockmesh.js'; + +export function initContextMenus(workspace) { + // ------- Pointer tracking for "paste at pointer" ------- + let lastCM = { x: 0, y: 0 }; + (workspace.getInjectionDiv() || document).addEventListener( + 'contextmenu', + (e) => { + lastCM = { x: e.clientX, y: e.clientY }; + }, + { capture: true } + ); + + // Screen -> workspace coords + function screenToWs(ws, xy) { + const c = new Blockly.utils.Coordinate(xy.x, xy.y); + return Blockly.utils.svgMath.screenToWsCoordinates(ws, c); + } + + // Add a context menu item that mirrors the keyboard-navigation "detach" (X) shortcut. + (function registerDetachContextMenuItem() { + const registry = Blockly.ContextMenuRegistry.registry; + const id = 'detachBlockWithShortcut'; + if (registry.getItem && registry.getItem(id)) return; + + function renderShortcut(label, shortcut) { + const wrapper = document.createElement('span'); + wrapper.style.display = 'flex'; + wrapper.style.alignItems = 'center'; + wrapper.style.justifyContent = 'space-between'; + wrapper.style.gap = '1.5em'; + wrapper.style.width = '100%'; + + const labelEl = document.createElement('span'); + labelEl.textContent = label; + + const shortcutEl = document.createElement('span'); + shortcutEl.textContent = shortcut; + shortcutEl.style.color = 'var(--blockly-text-disabled, #aaa)'; + + wrapper.append(labelEl, shortcutEl); + return wrapper; + } + + registry.register({ + id, + weight: 80, + displayText: () => { + const text = translate('detach_block_option'); + const label = text === 'detach_block_option' ? 'Detach' : text; + return renderShortcut(label, 'X'); + }, + preconditionFn: (scope) => { + const block = scope.block; + if (!block || block.isInFlyout) return 'hidden'; + + const hasParent = + !!block.getParent() || + !!block.previousConnection?.targetConnection || + !!block.outputConnection?.targetConnection; + return hasParent ? 'enabled' : 'disabled'; + }, + callback: (scope) => { + const block = scope.block; + if (!block) return; + + const healStack = !block.outputConnection?.isConnected(); + const prevGroup = Blockly.Events.getGroup(); + Blockly.Events.setGroup('contextmenu_detach'); + block.unplug(healStack); + const cursor = block.workspace?.getCursor?.(); + if (cursor?.setCurNode) cursor.setCurNode(block); + Blockly.Events.setGroup(prevGroup || null); + }, + scopeType: Blockly.ContextMenuRegistry.ScopeType.BLOCK, + }); + })(); + + // Add a context menu item to focus the canvas camera on a block's mesh. + (function registerViewInCanvasContextMenuItem() { + const registry = Blockly.ContextMenuRegistry.registry; + const id = 'viewBlockInCanvas'; + if (registry.getItem && registry.getItem(id)) return; + + function renderShortcut(label, shortcut) { + const wrapper = document.createElement('span'); + wrapper.style.cssText = + 'display:flex;align-items:center;justify-content:space-between;gap:1.5em;width:100%'; + const labelEl = document.createElement('span'); + labelEl.textContent = label; + const shortcutEl = document.createElement('span'); + shortcutEl.textContent = shortcut; + shortcutEl.style.color = 'var(--blockly-text-disabled, #aaa)'; + wrapper.append(labelEl, shortcutEl); + return wrapper; + } + + registry.register({ + id, + weight: 8, + displayText: () => { + const text = translate('view_in_canvas_option'); + const label = text === 'view_in_canvas_option' ? 'View in canvas' : text; + return renderShortcut(label, 'V'); + }, + preconditionFn: (scope) => { + const block = scope.block; + if (!block || block.isInFlyout) return 'hidden'; + try { + const mesh = getMeshFromBlock(block); + return mesh && mesh.name !== 'ground' ? 'enabled' : 'hidden'; + } catch { + return 'hidden'; + } + }, + callback: (scope) => { + const block = scope.block; + if (!block) return; + Promise.all([import('../main/view.js'), import('./gizmos.js')]).then( + ([{ showCanvasView }, { viewMeshWithCamera }]) => { + showCanvasView(); + window.currentBlock = block; + viewMeshWithCamera(block); + } + ); + }, + scopeType: Blockly.ContextMenuRegistry.ScopeType.BLOCK, + }); + })(); + + // Reorder block context menu items for better grouping. + // Cut/copy/paste are registered at weights 1/2/3; push everything else above that. + (function adjustBlockContextMenuWeights() { + const registry = Blockly.ContextMenuRegistry.registry; + + const weights = { + blockDuplicate: 9, + detachBlockWithShortcut: 10, + viewBlockInCanvas: 10.5, + blockComment: 12, + blockInline: 13, + blockCollapseExpand: 14, + blockDisable: 15, + blockDelete: 20, + blockHelp: 999, + }; + for (const [id, weight] of Object.entries(weights)) { + const item = registry.getItem?.(id); + if (item) item.weight = weight; + } + })(); + + // Remove undo/redo (toolbar buttons cover this) and clean up (flock does this automatically). + // Also remove the separate collapse/expand workspace items — replaced by a single toggle below. + (function removeRedundantContextMenuItems() { + const registry = Blockly.ContextMenuRegistry.registry; + [ + 'undoWorkspace', + 'redoWorkspace', + 'cleanWorkspace', + 'collapseWorkspace', + 'expandWorkspace', + ].forEach((id) => { + try { + registry.unregister(id); + } catch (_) {} + }); + })(); + + // Replace separate "Collapse all" / "Expand all" workspace items with a single toggle. + (function registerCollapseExpandWorkspaceToggle() { + const registry = Blockly.ContextMenuRegistry.registry; + const WORKSPACE = Blockly.ContextMenuRegistry.ScopeType.WORKSPACE; + if (registry.getItem?.('flockCollapseExpandWorkspace')) return; + + const hasAnyExpanded = (ws) => { + for (const block of ws.getTopBlocks(false)) { + let b = block; + while (b) { + if (!b.isCollapsed()) return true; + b = b.getNextBlock(); + } + } + return false; + }; + + registry.register({ + id: 'flockCollapseExpandWorkspace', + weight: 4, + scopeType: WORKSPACE, + displayText: (scope) => + hasAnyExpanded(scope.workspace) + ? translate('context_collapse_all_option') + : translate('context_expand_all_option'), + preconditionFn: (scope) => { + if (!scope.workspace?.options?.collapse) return 'hidden'; + return scope.workspace.getTopBlocks(false).length ? 'enabled' : 'hidden'; + }, + callback: (scope) => { + const ws = scope.workspace; + const shouldCollapse = hasAnyExpanded(ws); + Blockly.Events.setGroup(true); + for (const block of ws.getTopBlocks(true)) { + let b = block; + while (b) { + b.setCollapsed(shouldCollapse); + b = b.getNextBlock(); + } + } + Blockly.Events.setGroup(false); + }, + }); + })(); + + // Rename built-in workspace "Delete" item to the localized "Delete all blocks" label. + (function renameWorkspaceDeleteMenuItem() { + const item = Blockly.ContextMenuRegistry.registry.getItem?.('workspaceDelete'); + if (item) item.displayText = () => translate('context_delete_all_blocks_option'); + })(); + + // Add "Find in workspace" to the workspace context menu. + (function registerWorkspaceSearchContextMenuItem() { + const registry = Blockly.ContextMenuRegistry.registry; + const id = 'workspaceFindInWorkspace'; + if (registry.getItem?.(id)) return; + registry.register({ + id, + weight: 50, + displayText: () => translate('workspace_search_placeholder'), + preconditionFn: () => 'enabled', + callback: () => window.flockWorkspaceSearch?.open(), + scopeType: Blockly.ContextMenuRegistry.ScopeType.WORKSPACE, + }); + })(); + + // Register cut/copy/paste at the top of the block context menu (weights 1/2/3). + (function registerClipboardContextMenuItems() { + const registry = Blockly.ContextMenuRegistry.registry; + const BLOCK = Blockly.ContextMenuRegistry.ScopeType.BLOCK; + const WORKSPACE = Blockly.ContextMenuRegistry.ScopeType.WORKSPACE; + + const notInFlyout = (scope) => (scope.block?.isInFlyout ? 'hidden' : 'enabled'); + const hasCopiedData = () => !!Blockly.clipboard?.getLastCopiedData?.(); + + registry.register({ + id: 'blockCut', + weight: 1, + displayText: () => Blockly.Msg['CUT_SHORTCUT'] || 'Cut', + preconditionFn: notInFlyout, + callback: (scope) => { + const block = scope.block; + if (!block) return; + copyWithoutToast(block); + Blockly.Events.setGroup('contextmenu_cut'); + block.dispose(true); + Blockly.Events.setGroup(false); + }, + scopeType: BLOCK, + }); + + registry.register({ + id: 'blockCopy', + weight: 2, + displayText: () => Blockly.Msg['COPY_SHORTCUT'] || 'Copy', + preconditionFn: notInFlyout, + callback: (scope) => { + const block = scope.block; + if (block) copyWithoutToast(block); + }, + scopeType: BLOCK, + }); + + registry.register({ + id: 'blockPaste', + weight: 3, + displayText: () => Blockly.Msg['PASTE_SHORTCUT'] || 'Paste', + preconditionFn: (scope) => { + if (scope.block?.isInFlyout) return 'hidden'; + return hasCopiedData() ? 'enabled' : 'disabled'; + }, + callback: (scope) => { + const data = Blockly.clipboard?.getLastCopiedData?.(); + if (!data) return; + const ws = scope?.block?.workspace ?? workspace; + if (!ws) return; + const selected = Blockly.common?.getSelected?.() || null; + if (selected && selected.isInFlyout) return; + pasteAsChildOrHere(selected || null, ws, data); + }, + scopeType: BLOCK, + }); + + registry.register({ + id: 'workspacePaste', + weight: 3, + displayText: () => Blockly.Msg['PASTE_SHORTCUT'] || 'Paste', + preconditionFn: () => (hasCopiedData() ? 'enabled' : 'disabled'), + callback: (scope) => { + const data = Blockly.clipboard?.getLastCopiedData?.(); + if (!data) return; + const ws = scope?.workspace ?? workspace; + if (!ws) return; + pasteAsChildOrHere(null, ws, data); + }, + scopeType: WORKSPACE, + }); + + if (!registry.getItem?.('flock_ws_sep_after_paste')) { + registry.register({ + id: 'flock_ws_sep_after_paste', + weight: 3.5, + separator: true, + scopeType: WORKSPACE, + }); + } + })(); + + // Add separators to the block context menu to group related items. + // Weights: clipboard(1-3) | 5 | block-ops(9-10) | 10.5 | comment(11-14) | 18 | delete(20) | 50 | export(100-200) | 500 | help(999) + (function registerBlockContextMenuSeparators() { + const registry = Blockly.ContextMenuRegistry.registry; + const BLOCK = Blockly.ContextMenuRegistry.ScopeType.BLOCK; + const separators = [ + { id: 'flock_sep_after_clipboard', weight: 5 }, + { id: 'flock_sep_before_comment', weight: 10.5 }, + { id: 'flock_sep_before_delete', weight: 18 }, + { id: 'flock_sep_before_export', weight: 50 }, + { id: 'flock_sep_before_help', weight: 500 }, + ]; + for (const { id, weight } of separators) { + if (!registry.getItem?.(id)) { + registry.register({ id, weight, separator: true, scopeType: BLOCK }); + } + } + })(); + + // ===== OVERRIDE CLIPBOARD METHODS ===== + const origCopy = Blockly.clipboard.copy; + const origToastShow = Blockly.Toast?.show; + + Blockly.clipboard.copy = function (block) { + origCopy.call(Blockly.clipboard, block); + + if (block?.isInFlyout) { + const tb = Blockly.getMainWorkspace()?.getToolbox?.(); + tb?.getFlyout?.()?.hide?.(); + tb?.getSelectedItem?.()?.setSelected?.(false); + } + }; + + // Assuming Blockly 13 has removed toasts, this is not needed + function copyWithoutToast(block) { + if (!block) return; + if (Blockly.Toast?.show) Blockly.Toast.show = () => {}; + try { + Blockly.clipboard.copy.call(Blockly.clipboard, block); + } finally { + if (Blockly.Toast?.show) Blockly.Toast.show = origToastShow; + } + } + + function overrideContextMenuCopyItem() { + const ids = [ + 'blockCopyToStorage', // Blockly core (common) + 'blockCopyFromContextMenu', // possible variant + ]; + + let item = null; + for (const id of ids) { + item = Blockly.ContextMenuRegistry.registry.getItem(id); + if (item) break; + } + if (!item) return false; + + const original = item.callback; + + item.callback = function (scope, menuOpenEvent, location) { + const block = scope?.block; + if (block) { + copyWithoutToast(block); + return; + } + return original?.call(this, scope, menuOpenEvent, location); + }; + + return true; + } + + (function installCopyOverrideWithRetry(maxAttempts = 20, delayMs = 50) { + let attempts = 0; + const t = setInterval(() => { + attempts++; + if (overrideContextMenuCopyItem() || attempts >= maxAttempts) { + clearInterval(t); + } + }, delayMs); + })(); + + function isTypingInInput() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName?.toLowerCase(); + return tag === 'input' || tag === 'textarea' || !!el.isContentEditable; + } + + const host = workspace.getInjectionDiv() || document; + let __fcLastPointer = { x: 0, y: 0 }; + let __fcLastPointerType = 'mouse'; // 'mouse' | 'touch' | 'pen' + let __fcMenuPoint = null; + let __fcMenuPointerType = 'mouse'; + + host.addEventListener( + 'pointerdown', + (e) => { + if (!e.isPrimary) return; + __fcLastPointer = { x: e.clientX, y: e.clientY }; + __fcLastPointerType = e.pointerType || 'mouse'; + }, + { capture: true } + ); + + host.addEventListener( + 'pointermove', + (e) => { + if (!e.isPrimary) return; + __fcLastPointer = { x: e.clientX, y: e.clientY }; + __fcLastPointerType = e.pointerType || __fcLastPointerType; + }, + { capture: true } + ); + + // Capture the *actual* coordinates that opened the context menu (works for long-press) + const __origShow = Blockly.ContextMenu.show; + Blockly.ContextMenu.show = function (e, options, rtl) { + __fcMenuPoint = { x: e.clientX, y: e.clientY }; + __fcMenuPointerType = e.pointerType || __fcLastPointerType || 'mouse'; + return __origShow.call(Blockly.ContextMenu, e, options, rtl); + }; + host.addEventListener( + 'contextmenu', + (e) => { + lastCM = { x: e.clientX, y: e.clientY }; + }, + { capture: true } + ); + host.addEventListener( + 'mousemove', + (e) => { + lastCM = { x: e.clientX, y: e.clientY }; + }, + { capture: true } + ); + + function pasteAsChildOrHere(targetBlock /* may be null */, ws, data) { + if (!data) return; + const at = screenToWs(ws, lastCM); + const pasted = Blockly.clipboard.paste(data, ws, at); + const pb = /** @type {Blockly.BlockSvg} */ (pasted); + if (!targetBlock) return; + + const checker = ws.getConnectionChecker + ? ws.getConnectionChecker() + : new Blockly.ConnectionChecker(); + const can = (a, b) => checker.canConnect(a, b, /*isDragging=*/ false); + + // 1) stack after: target.next ⟷ pb.previous + if ( + targetBlock.nextConnection && + pb.previousConnection && + can(targetBlock.nextConnection, pb.previousConnection) + ) { + targetBlock.nextConnection.connect(pb.previousConnection); + return; + } + // 2) empty statement input ⟷ pb.previous + for (const input of targetBlock.inputList) { + if ( + input.type === Blockly.NEXT_STATEMENT && + input.connection && + !input.connection.targetBlock() && + pb.previousConnection && + can(input.connection, pb.previousConnection) + ) { + input.connection.connect(pb.previousConnection); + return; + } + } + // 2b) top-level block: insert pb as first child in statement input, + // pushing existing children after pb + const isTopLevel = !targetBlock.previousConnection && !targetBlock.nextConnection; + if (isTopLevel && pb.previousConnection) { + for (const input of targetBlock.inputList) { + if ( + input.type === Blockly.NEXT_STATEMENT && + input.connection && + input.connection.targetBlock() && + can(input.connection, pb.previousConnection) + ) { + const firstChild = input.connection.targetBlock(); + input.connection.disconnect(); + input.connection.connect(pb.previousConnection); + // Append previous first child after pb chain + let lastPb = pb; + while (lastPb.nextConnection && lastPb.nextConnection.targetBlock()) { + lastPb = lastPb.nextConnection.targetBlock(); + } + if ( + lastPb.nextConnection && + firstChild.previousConnection && + can(lastPb.nextConnection, firstChild.previousConnection) + ) { + lastPb.nextConnection.connect(firstChild.previousConnection); + } + return; + } + } + } + // 3) empty value input ⟷ pb.output + for (const input of targetBlock.inputList) { + if ( + input.type === Blockly.INPUT_VALUE && + input.connection && + !input.connection.targetBlock() && + pb.outputConnection && + can(input.connection, pb.outputConnection) + ) { + input.connection.connect(pb.outputConnection); + return; + } + } + // 4) insert above: target.previous ⟷ pb.next + if ( + targetBlock.previousConnection && + pb.nextConnection && + can(targetBlock.previousConnection, pb.nextConnection) + ) { + targetBlock.previousConnection.connect(pb.nextConnection); + return; + } + // else: stays at pointer + } + + // ---- Bind Ctrl/Cmd+V ---- + host.addEventListener( + 'keydown', + (e) => { + if (!(e.ctrlKey || e.metaKey)) return; + if ((e.key || '').toLowerCase() !== 'v') return; + if (isTypingInInput()) return; + + const data = Blockly.clipboard?.getLastCopiedData?.(); + if (!data) return; + + // Selected block (if any, and not from flyout) + const selected = Blockly.common?.getSelected?.() || null; + if (selected && selected.isInFlyout) return; // never paste in the flyout + + e.preventDefault(); + e.stopPropagation(); + pasteAsChildOrHere(selected || null, workspace, data); + }, + { capture: true } + ); +} From 6afd2e21d3f6bfe6b131538aab12c60e20a7b830 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:28:02 +0100 Subject: [PATCH 2/8] Move mobile context menu --- main/blocklyinit.js | 236 ------------------------------------------- ui/contextmenu.js | 238 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 236 deletions(-) diff --git a/main/blocklyinit.js b/main/blocklyinit.js index cb645f5e..ae3e9246 100644 --- a/main/blocklyinit.js +++ b/main/blocklyinit.js @@ -2263,242 +2263,6 @@ export function createBlocklyWorkspace() { }); } - // ---- Tablet floating block toolbar ---- - if (navigator.maxTouchPoints > 0) { - const blockToolbar = document.createElement('div'); - blockToolbar.className = 'fc-block-toolbar'; - blockToolbar.setAttribute('role', 'toolbar'); - document.body.appendChild(blockToolbar); - - const mkFaSvg = (path, vw = '0 0 448 512') => - `${path}`; - - const duplicateBtn = document.createElement('button'); - duplicateBtn.type = 'button'; - duplicateBtn.className = 'fc-block-toolbar-btn'; - duplicateBtn.setAttribute('aria-label', translate('duplicate_button') || 'Duplicate'); - duplicateBtn.innerHTML = mkFaSvg( - '' - ); - - const deleteBtn = document.createElement('button'); - deleteBtn.type = 'button'; - deleteBtn.className = 'fc-block-toolbar-btn fc-block-toolbar-btn--delete'; - deleteBtn.setAttribute('aria-label', 'Delete'); - deleteBtn.innerHTML = mkFaSvg( - '' - ); - - const detachBtn = document.createElement('button'); - detachBtn.type = 'button'; - detachBtn.className = 'fc-block-toolbar-btn'; - detachBtn.setAttribute('aria-label', translate('detach_block_option') || 'Detach'); - detachBtn.innerHTML = mkFaSvg( - '', - '0 0 640 512' - ); - - const commentBtn = document.createElement('button'); - commentBtn.type = 'button'; - commentBtn.className = 'fc-block-toolbar-btn'; - commentBtn.setAttribute('aria-label', 'Add comment'); - const commentAddSvg = mkFaSvg( - '', - '0 0 512 512' - ); - const commentDeleteSvg = mkFaSvg( - '', - '0 0 640 512' - ); - commentBtn.innerHTML = commentAddSvg; - - const viewBtn = document.createElement('button'); - viewBtn.type = 'button'; - viewBtn.className = 'fc-block-toolbar-btn'; - viewBtn.setAttribute('aria-label', 'View in canvas'); - viewBtn.innerHTML = mkFaSvg( - '', - '0 0 576 512' - ); - - blockToolbar.append(duplicateBtn, detachBtn, commentBtn, viewBtn, deleteBtn); - - let toolbarBlock = null; - let toolbarShowTimer = null; - - const isDetachable = (block) => - !!block?.getParent() || - !!block?.previousConnection?.targetConnection || - !!block?.outputConnection?.targetConnection; - - function positionBlockToolbar() { - if (!toolbarBlock) return; - const svgRoot = toolbarBlock.getSvgRoot?.(); - if (!svgRoot) return; - const rect = svgRoot.getBoundingClientRect(); - const blockCenterX = Math.round(rect.left + rect.width / 2); - blockToolbar.style.left = `${blockCenterX}px`; - blockToolbar.style.top = `${Math.round(rect.top)}px`; - blockToolbar.style.removeProperty('--caret-shift'); - - // Clamp to viewport; shift caret opposite so it still points at the block - const margin = 8; - const tbRect = blockToolbar.getBoundingClientRect(); - let adj = 0; - if (tbRect.left < margin) adj = margin - tbRect.left; - else if (tbRect.right > window.innerWidth - margin) - adj = window.innerWidth - margin - tbRect.right; - if (adj !== 0) { - blockToolbar.style.left = `${blockCenterX + adj}px`; - blockToolbar.style.setProperty('--caret-shift', `${-adj}px`); - } - } - - function showBlockToolbar(block) { - toolbarBlock = block; - detachBtn.style.display = isDetachable(block) ? '' : 'none'; - const hasComment = block.getCommentText() !== null; - commentBtn.setAttribute('aria-label', hasComment ? 'Delete comment' : 'Add comment'); - commentBtn.innerHTML = hasComment ? commentDeleteSvg : commentAddSvg; - let mesh = null; - try { - mesh = getMeshFromBlock(block); - } catch { - /* scene not ready */ - } - viewBtn.style.display = !mesh || mesh.name === 'ground' ? 'none' : ''; - positionBlockToolbar(); - blockToolbar.classList.add('visible'); - } - - function hideBlockToolbar() { - clearTimeout(toolbarShowTimer); - toolbarShowTimer = null; - toolbarBlock = null; - blockToolbar.classList.remove('visible'); - } - - const isToolbarBlock = (block) => block && !block.isInFlyout && !block.isShadow(); - - workspace.addChangeListener((e) => { - if (e.type === Blockly.Events.SELECTED) { - clearTimeout(toolbarShowTimer); - toolbarShowTimer = null; - if (e.newElementId) { - const block = workspace.getBlockById(e.newElementId); - if (isToolbarBlock(block)) { - toolbarShowTimer = setTimeout(() => showBlockToolbar(block), 400); - } else { - hideBlockToolbar(); - } - } else { - hideBlockToolbar(); - } - } else if ( - (e.type === Blockly.Events.BLOCK_MOVE || e.type === Blockly.Events.VIEWPORT_CHANGE) && - toolbarBlock - ) { - positionBlockToolbar(); - } else if (e.type === Blockly.Events.BLOCK_DRAG && e.isStart) { - hideBlockToolbar(); - } - }); - - duplicateBtn.addEventListener('pointerdown', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (!toolbarBlock) return; - const block = toolbarBlock; - Blockly.Events.setGroup('toolbar_duplicate'); - const json = Blockly.serialization.blocks.save(block, { includeShadows: true }); - delete json.next; - const copy = Blockly.serialization.blocks.append(json, workspace); - const orig = block.getRelativeToSurfaceXY(); - copy.moveTo(new Blockly.utils.Coordinate(orig.x + 30, orig.y + 30)); - Blockly.Events.setGroup(false); - }); - - detachBtn.addEventListener('pointerdown', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (!toolbarBlock || !isDetachable(toolbarBlock)) return; - const block = toolbarBlock; - const healStack = !block.outputConnection?.isConnected(); - Blockly.Events.setGroup('toolbar_detach'); - block.unplug(healStack); - Blockly.Events.setGroup(false); - }); - - commentBtn.addEventListener('pointerdown', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (!toolbarBlock) return; - const block = toolbarBlock; - if (block.getCommentText() !== null) { - block.setCommentText(null); - } else { - block.setCommentText(''); - const icon = block.getIcons?.().find((i) => typeof i.setBubbleVisible === 'function'); - icon?.setBubbleVisible(true); - } - hideBlockToolbar(); - }); - - viewBtn.addEventListener('pointerdown', async (e) => { - e.preventDefault(); - e.stopPropagation(); - if (!toolbarBlock || viewBtn.style.display === 'none') return; - const block = toolbarBlock; - hideBlockToolbar(); - const [{ showCanvasView }, { viewMeshWithCamera }] = await Promise.all([ - import('./view.js'), - import('../ui/gizmos.js'), - ]); - showCanvasView(); - window.currentBlock = block; - viewMeshWithCamera(block); - }); - - deleteBtn.addEventListener('pointerdown', (e) => { - e.preventDefault(); - e.stopPropagation(); - if (!toolbarBlock) return; - const block = toolbarBlock; - // Count only blocks that will actually be deleted: the block + its input - // descendants, but NOT the top-level next chain (which gets healed, not deleted). - const countDeleted = (b, followNext) => { - if (!b || b.isShadow()) return 0; - let n = 1; - for (const input of b.inputList) { - n += countDeleted(input.connection?.targetBlock(), true); - } - if (followNext) n += countDeleted(b.nextConnection?.targetBlock(), true); - return n; - }; - const count = countDeleted(block, false); - if (count > 1) { - const msg = (Blockly.Msg['DELETE_ALL_BLOCKS'] || 'Delete all %1 blocks?').replace( - '%1', - count - ); - Blockly.dialog.confirm(msg, (ok) => { - if (!ok) return; - hideBlockToolbar(); - block.checkAndDelete(); - Blockly.Toast.show(workspace, { - message: translate('DELETE_UNDO_HINT'), - id: 'delete-undo-tip', - oncePerSession: true, - duration: 8, - }); - }); - } else { - hideBlockToolbar(); - block.checkAndDelete(); - } - }); - } - initializeTheme(); // Register comment options for workspace comments diff --git a/ui/contextmenu.js b/ui/contextmenu.js index d8ff1999..a7090067 100644 --- a/ui/contextmenu.js +++ b/ui/contextmenu.js @@ -1,3 +1,5 @@ +// Context menu for blocks. One appears on right click, another on selection. + import * as Blockly from 'blockly'; import { translate } from '../main/translation.js'; import { getMeshFromBlock } from './blockmesh.js'; @@ -563,4 +565,240 @@ export function initContextMenus(workspace) { }, { capture: true } ); + + // ---- Tablet floating block toolbar ---- + if (navigator.maxTouchPoints > 0) { + const blockToolbar = document.createElement('div'); + blockToolbar.className = 'fc-block-toolbar'; + blockToolbar.setAttribute('role', 'toolbar'); + document.body.appendChild(blockToolbar); + + const mkFaSvg = (path, vw = '0 0 448 512') => + `${path}`; + + const duplicateBtn = document.createElement('button'); + duplicateBtn.type = 'button'; + duplicateBtn.className = 'fc-block-toolbar-btn'; + duplicateBtn.setAttribute('aria-label', translate('duplicate_button') || 'Duplicate'); + duplicateBtn.innerHTML = mkFaSvg( + '' + ); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'fc-block-toolbar-btn fc-block-toolbar-btn--delete'; + deleteBtn.setAttribute('aria-label', 'Delete'); + deleteBtn.innerHTML = mkFaSvg( + '' + ); + + const detachBtn = document.createElement('button'); + detachBtn.type = 'button'; + detachBtn.className = 'fc-block-toolbar-btn'; + detachBtn.setAttribute('aria-label', translate('detach_block_option') || 'Detach'); + detachBtn.innerHTML = mkFaSvg( + '', + '0 0 640 512' + ); + + const commentBtn = document.createElement('button'); + commentBtn.type = 'button'; + commentBtn.className = 'fc-block-toolbar-btn'; + commentBtn.setAttribute('aria-label', 'Add comment'); + const commentAddSvg = mkFaSvg( + '', + '0 0 512 512' + ); + const commentDeleteSvg = mkFaSvg( + '', + '0 0 640 512' + ); + commentBtn.innerHTML = commentAddSvg; + + const viewBtn = document.createElement('button'); + viewBtn.type = 'button'; + viewBtn.className = 'fc-block-toolbar-btn'; + viewBtn.setAttribute('aria-label', 'View in canvas'); + viewBtn.innerHTML = mkFaSvg( + '', + '0 0 576 512' + ); + + blockToolbar.append(duplicateBtn, detachBtn, commentBtn, viewBtn, deleteBtn); + + let toolbarBlock = null; + let toolbarShowTimer = null; + + const isDetachable = (block) => + !!block?.getParent() || + !!block?.previousConnection?.targetConnection || + !!block?.outputConnection?.targetConnection; + + function positionBlockToolbar() { + if (!toolbarBlock) return; + const svgRoot = toolbarBlock.getSvgRoot?.(); + if (!svgRoot) return; + const rect = svgRoot.getBoundingClientRect(); + const blockCenterX = Math.round(rect.left + rect.width / 2); + blockToolbar.style.left = `${blockCenterX}px`; + blockToolbar.style.top = `${Math.round(rect.top)}px`; + blockToolbar.style.removeProperty('--caret-shift'); + + // Clamp to viewport; shift caret opposite so it still points at the block + const margin = 8; + const tbRect = blockToolbar.getBoundingClientRect(); + let adj = 0; + if (tbRect.left < margin) adj = margin - tbRect.left; + else if (tbRect.right > window.innerWidth - margin) + adj = window.innerWidth - margin - tbRect.right; + if (adj !== 0) { + blockToolbar.style.left = `${blockCenterX + adj}px`; + blockToolbar.style.setProperty('--caret-shift', `${-adj}px`); + } + } + + function showBlockToolbar(block) { + toolbarBlock = block; + detachBtn.style.display = isDetachable(block) ? '' : 'none'; + const hasComment = block.getCommentText() !== null; + commentBtn.setAttribute('aria-label', hasComment ? 'Delete comment' : 'Add comment'); + commentBtn.innerHTML = hasComment ? commentDeleteSvg : commentAddSvg; + let mesh = null; + try { + mesh = getMeshFromBlock(block); + } catch { + /* scene not ready */ + } + viewBtn.style.display = !mesh || mesh.name === 'ground' ? 'none' : ''; + positionBlockToolbar(); + blockToolbar.classList.add('visible'); + } + + function hideBlockToolbar() { + clearTimeout(toolbarShowTimer); + toolbarShowTimer = null; + toolbarBlock = null; + blockToolbar.classList.remove('visible'); + } + + const isToolbarBlock = (block) => block && !block.isInFlyout && !block.isShadow(); + + workspace.addChangeListener((e) => { + if (e.type === Blockly.Events.SELECTED) { + clearTimeout(toolbarShowTimer); + toolbarShowTimer = null; + if (e.newElementId) { + const block = workspace.getBlockById(e.newElementId); + if (isToolbarBlock(block)) { + toolbarShowTimer = setTimeout(() => showBlockToolbar(block), 400); + } else { + hideBlockToolbar(); + } + } else { + hideBlockToolbar(); + } + } else if ( + (e.type === Blockly.Events.BLOCK_MOVE || e.type === Blockly.Events.VIEWPORT_CHANGE) && + toolbarBlock + ) { + positionBlockToolbar(); + } else if (e.type === Blockly.Events.BLOCK_DRAG && e.isStart) { + hideBlockToolbar(); + } + }); + + duplicateBtn.addEventListener('pointerdown', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!toolbarBlock) return; + const block = toolbarBlock; + Blockly.Events.setGroup('toolbar_duplicate'); + const json = Blockly.serialization.blocks.save(block, { includeShadows: true }); + delete json.next; + const copy = Blockly.serialization.blocks.append(json, workspace); + const orig = block.getRelativeToSurfaceXY(); + copy.moveTo(new Blockly.utils.Coordinate(orig.x + 30, orig.y + 30)); + Blockly.Events.setGroup(false); + }); + + detachBtn.addEventListener('pointerdown', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!toolbarBlock || !isDetachable(toolbarBlock)) return; + const block = toolbarBlock; + const healStack = !block.outputConnection?.isConnected(); + Blockly.Events.setGroup('toolbar_detach'); + block.unplug(healStack); + Blockly.Events.setGroup(false); + }); + + commentBtn.addEventListener('pointerdown', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!toolbarBlock) return; + const block = toolbarBlock; + if (block.getCommentText() !== null) { + block.setCommentText(null); + } else { + block.setCommentText(''); + const icon = block.getIcons?.().find((i) => typeof i.setBubbleVisible === 'function'); + icon?.setBubbleVisible(true); + } + hideBlockToolbar(); + }); + + viewBtn.addEventListener('pointerdown', async (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!toolbarBlock || viewBtn.style.display === 'none') return; + const block = toolbarBlock; + hideBlockToolbar(); + const [{ showCanvasView }, { viewMeshWithCamera }] = await Promise.all([ + import('../main/view.js'), + import('./gizmos.js'), + ]); + showCanvasView(); + window.currentBlock = block; + viewMeshWithCamera(block); + }); + + deleteBtn.addEventListener('pointerdown', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!toolbarBlock) return; + const block = toolbarBlock; + // Count only blocks that will actually be deleted: the block + its input + // descendants, but NOT the top-level next chain (which gets healed, not deleted). + const countDeleted = (b, followNext) => { + if (!b || b.isShadow()) return 0; + let n = 1; + for (const input of b.inputList) { + n += countDeleted(input.connection?.targetBlock(), true); + } + if (followNext) n += countDeleted(b.nextConnection?.targetBlock(), true); + return n; + }; + const count = countDeleted(block, false); + if (count > 1) { + const msg = (Blockly.Msg['DELETE_ALL_BLOCKS'] || 'Delete all %1 blocks?').replace( + '%1', + count + ); + Blockly.dialog.confirm(msg, (ok) => { + if (!ok) return; + hideBlockToolbar(); + block.checkAndDelete(); + Blockly.Toast.show(workspace, { + message: translate('DELETE_UNDO_HINT'), + id: 'delete-undo-tip', + oncePerSession: true, + duration: 8, + }); + }); + } else { + hideBlockToolbar(); + block.checkAndDelete(); + } + }); + } } From 4e4fd33d8e31438378f93e7521c8a5abef2a9624 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:17:49 +0100 Subject: [PATCH 3/8] Toggle menu --- ui/contextmenu.js | 48 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/ui/contextmenu.js b/ui/contextmenu.js index a7090067..61f0e5fb 100644 --- a/ui/contextmenu.js +++ b/ui/contextmenu.js @@ -566,8 +566,8 @@ export function initContextMenus(workspace) { { capture: true } ); - // ---- Tablet floating block toolbar ---- - if (navigator.maxTouchPoints > 0) { + // ---- Floating block toolbar (all devices, pointer-driven only) ---- + { const blockToolbar = document.createElement('div'); blockToolbar.className = 'fc-block-toolbar'; blockToolbar.setAttribute('role', 'toolbar'); @@ -626,8 +626,17 @@ export function initContextMenus(workspace) { blockToolbar.append(duplicateBtn, detachBtn, commentBtn, viewBtn, deleteBtn); - let toolbarBlock = null; + let toolbarBlock = null; // block the toolbar is currently visible for + let selectedBlock = null; // block currently selected (regardless of toolbar visibility) + let toolbarDismissed = false; // user closed toolbar for the current selection let toolbarShowTimer = null; + let lastSelectionWasPointer = false; + + const injectionDiv = workspace.getInjectionDiv(); + + injectionDiv.addEventListener('pointerdown', () => { + lastSelectionWasPointer = true; + }, { capture: true }); const isDetachable = (block) => !!block?.getParent() || @@ -659,6 +668,7 @@ export function initContextMenus(workspace) { function showBlockToolbar(block) { toolbarBlock = block; + toolbarDismissed = false; detachBtn.style.display = isDetachable(block) ? '' : 'none'; const hasComment = block.getCommentText() !== null; commentBtn.setAttribute('aria-label', hasComment ? 'Delete comment' : 'Add comment'); @@ -687,14 +697,28 @@ export function initContextMenus(workspace) { if (e.type === Blockly.Events.SELECTED) { clearTimeout(toolbarShowTimer); toolbarShowTimer = null; + if (e.newElementId) { const block = workspace.getBlockById(e.newElementId); + // Consume the pointer flag only here, on actual selection, not on deselect. + // Blockly may fire SELECTED(null) before SELECTED(blockId) on a click, so + // consuming it on deselect would clear it before we can use it. + const wasPointer = lastSelectionWasPointer; + lastSelectionWasPointer = false; if (isToolbarBlock(block)) { - toolbarShowTimer = setTimeout(() => showBlockToolbar(block), 400); + selectedBlock = block; + toolbarDismissed = false; + if (wasPointer) { + toolbarShowTimer = setTimeout(() => showBlockToolbar(block), 400); + } else { + hideBlockToolbar(); + } } else { + selectedBlock = null; hideBlockToolbar(); } } else { + selectedBlock = null; hideBlockToolbar(); } } else if ( @@ -707,6 +731,22 @@ export function initContextMenus(workspace) { } }); + // Toggle toolbar on click of the selected block + injectionDiv.addEventListener('pointerdown', (e) => { + if (!selectedBlock) return; + const svgRoot = selectedBlock.getSvgRoot?.(); + if (!svgRoot || !svgRoot.contains(e.target)) return; + if (toolbarBlock) { + // Toolbar visible → hide it; prevent SELECTED from re-showing + toolbarDismissed = true; + hideBlockToolbar(); + lastSelectionWasPointer = false; + } else if (toolbarDismissed) { + // Toolbar dismissed → re-show it + showBlockToolbar(selectedBlock); + } + }, { capture: true }); + duplicateBtn.addEventListener('pointerdown', (e) => { e.preventDefault(); e.stopPropagation(); From b8b508f7cba6e8539db6b452436fe109b2a4466c Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:00:15 +0100 Subject: [PATCH 4/8] Toggle orbit on or off --- ui/contextmenu.js | 20 ++++++++++++++++---- ui/gizmos.js | 14 +++++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/ui/contextmenu.js b/ui/contextmenu.js index 61f0e5fb..30c2e5d7 100644 --- a/ui/contextmenu.js +++ b/ui/contextmenu.js @@ -573,6 +573,8 @@ export function initContextMenus(workspace) { blockToolbar.setAttribute('role', 'toolbar'); document.body.appendChild(blockToolbar); + // Icon paths: Font Awesome Free 6.7.2 by @fontawesome — https://fontawesome.com + // License: https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc. const mkFaSvg = (path, vw = '0 0 448 512') => `${path}`; @@ -615,14 +617,20 @@ export function initContextMenus(workspace) { ); commentBtn.innerHTML = commentAddSvg; + const viewEnterSvg = mkFaSvg( + '', + '0 0 576 512' + ); + const viewExitSvg = mkFaSvg( + '', + '0 0 640 512' + ); + const viewBtn = document.createElement('button'); viewBtn.type = 'button'; viewBtn.className = 'fc-block-toolbar-btn'; viewBtn.setAttribute('aria-label', 'View in canvas'); - viewBtn.innerHTML = mkFaSvg( - '', - '0 0 576 512' - ); + viewBtn.innerHTML = viewEnterSvg; blockToolbar.append(duplicateBtn, detachBtn, commentBtn, viewBtn, deleteBtn); @@ -680,6 +688,9 @@ export function initContextMenus(workspace) { /* scene not ready */ } viewBtn.style.display = !mesh || mesh.name === 'ground' ? 'none' : ''; + const exitMode = !!window.orbitViewActive && window.orbitBlock === block; + viewBtn.innerHTML = exitMode ? viewExitSvg : viewEnterSvg; + viewBtn.setAttribute('aria-label', exitMode ? 'Exit canvas view' : 'View in canvas'); positionBlockToolbar(); blockToolbar.classList.add('visible'); } @@ -800,6 +811,7 @@ export function initContextMenus(workspace) { showCanvasView(); window.currentBlock = block; viewMeshWithCamera(block); + window.orbitBlock = window.orbitViewActive ? block : null; }); deleteBtn.addEventListener('pointerdown', (e) => { diff --git a/ui/gizmos.js b/ui/gizmos.js index f7b93101..92cd80c3 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -461,10 +461,14 @@ export function viewMeshWithCamera(block) { const camera = flock.scene.activeCamera; if (!camera?.metadata?.following) { - // Already orbiting? V toggles back to the free camera. if (camera?.metadata?.orbitView) { - disconnectOrbitView(); - return; + // Toggle off if: V key (no block), or eye button on the already-orbited mesh. + // Switch if: eye button on a different mesh. + if (!block || mesh === gizmoManager.attachedMesh) { + disconnectOrbitView(); + return; + } + disconnectOrbitView(); // switch target — disconnect first, then fall through } if (mesh) attachOrbitView(mesh); return; @@ -651,6 +655,8 @@ function attachOrbitView(mesh) { orbitViewObserver = gizmoManager.onAttachedToMeshObservable.add((attached) => { if (attached !== selectedMesh) disconnectOrbitView(); }); + window.orbitViewActive = true; + window.orbitBlock = window.currentBlock ?? null; } // Restore the stashed free camera, disposing the orbit camera. Does not @@ -684,6 +690,8 @@ function restoreFreeCameraFromOrbit() { function disconnectOrbitView() { if (!flock.scene.activeCamera?.metadata?.orbitView) return; restoreFreeCameraFromOrbit(); + window.orbitViewActive = false; + window.orbitBlock = null; const canvas = flock.scene.getEngine().getRenderingCanvas(); if (canvas) { flock.scene.activeCamera?.attachControl(canvas, false); From 9b007f2866b83505b86ba77af30ba2a2972e0851 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:21:23 +0100 Subject: [PATCH 5/8] Bug fix --- ui/contextmenu.js | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/contextmenu.js b/ui/contextmenu.js index 30c2e5d7..f20826d1 100644 --- a/ui/contextmenu.js +++ b/ui/contextmenu.js @@ -839,6 +839,7 @@ export function initContextMenus(workspace) { Blockly.dialog.confirm(msg, (ok) => { if (!ok) return; hideBlockToolbar(); + if (block.isDisposed?.()) return; block.checkAndDelete(); Blockly.Toast.show(workspace, { message: translate('DELETE_UNDO_HINT'), From 79f9f63e218ab59b844a4f40b3790bb3c5dfa8d7 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:51:21 +0100 Subject: [PATCH 6/8] Fix aria fallbacks --- ui/contextmenu.js | 85 +++++++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 29 deletions(-) diff --git a/ui/contextmenu.js b/ui/contextmenu.js index f20826d1..8dc73449 100644 --- a/ui/contextmenu.js +++ b/ui/contextmenu.js @@ -167,7 +167,9 @@ export function initContextMenus(workspace) { ].forEach((id) => { try { registry.unregister(id); - } catch (_) {} + } catch (e) { + void e; + } }); })(); @@ -287,9 +289,9 @@ export function initContextMenus(workspace) { if (!data) return; const ws = scope?.block?.workspace ?? workspace; if (!ws) return; - const selected = Blockly.common?.getSelected?.() || null; - if (selected && selected.isInFlyout) return; - pasteAsChildOrHere(selected || null, ws, data); + const block = scope.block; + if (!block || !(block instanceof Blockly.Block)) return; + pasteAsChildOrHere(block, ws, data); }, scopeType: BLOCK, }); @@ -558,6 +560,7 @@ export function initContextMenus(workspace) { // Selected block (if any, and not from flyout) const selected = Blockly.common?.getSelected?.() || null; if (selected && selected.isInFlyout) return; // never paste in the flyout + if (selected && !(selected instanceof Blockly.Block)) return; // only paste to blocks e.preventDefault(); e.stopPropagation(); @@ -578,10 +581,16 @@ export function initContextMenus(workspace) { const mkFaSvg = (path, vw = '0 0 448 512') => `${path}`; + // Helper: detect untranslated keys and apply English fallback + const getToolbarLabel = (key, fallback) => { + const result = translate(key); + return result === key ? fallback : result; + }; + const duplicateBtn = document.createElement('button'); duplicateBtn.type = 'button'; duplicateBtn.className = 'fc-block-toolbar-btn'; - duplicateBtn.setAttribute('aria-label', translate('duplicate_button') || 'Duplicate'); + duplicateBtn.setAttribute('aria-label', getToolbarLabel('duplicate_button_ui', 'Duplicate')); duplicateBtn.innerHTML = mkFaSvg( '' ); @@ -589,7 +598,7 @@ export function initContextMenus(workspace) { const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'fc-block-toolbar-btn fc-block-toolbar-btn--delete'; - deleteBtn.setAttribute('aria-label', 'Delete'); + deleteBtn.setAttribute('aria-label', getToolbarLabel('delete_button_ui', 'Delete')); deleteBtn.innerHTML = mkFaSvg( '' ); @@ -597,7 +606,7 @@ export function initContextMenus(workspace) { const detachBtn = document.createElement('button'); detachBtn.type = 'button'; detachBtn.className = 'fc-block-toolbar-btn'; - detachBtn.setAttribute('aria-label', translate('detach_block_option') || 'Detach'); + detachBtn.setAttribute('aria-label', getToolbarLabel('shortcut_detach_block', 'Detach')); detachBtn.innerHTML = mkFaSvg( '', '0 0 640 512' @@ -606,7 +615,7 @@ export function initContextMenus(workspace) { const commentBtn = document.createElement('button'); commentBtn.type = 'button'; commentBtn.className = 'fc-block-toolbar-btn'; - commentBtn.setAttribute('aria-label', 'Add comment'); + commentBtn.setAttribute('aria-label', getToolbarLabel('add_comment', 'Add comment')); const commentAddSvg = mkFaSvg( '', '0 0 512 512' @@ -629,12 +638,12 @@ export function initContextMenus(workspace) { const viewBtn = document.createElement('button'); viewBtn.type = 'button'; viewBtn.className = 'fc-block-toolbar-btn'; - viewBtn.setAttribute('aria-label', 'View in canvas'); + viewBtn.setAttribute('aria-label', getToolbarLabel('view_in_canvas', 'View in canvas')); viewBtn.innerHTML = viewEnterSvg; blockToolbar.append(duplicateBtn, detachBtn, commentBtn, viewBtn, deleteBtn); - let toolbarBlock = null; // block the toolbar is currently visible for + let toolbarBlock = null; // block the toolbar is currently visible for let selectedBlock = null; // block currently selected (regardless of toolbar visibility) let toolbarDismissed = false; // user closed toolbar for the current selection let toolbarShowTimer = null; @@ -642,9 +651,13 @@ export function initContextMenus(workspace) { const injectionDiv = workspace.getInjectionDiv(); - injectionDiv.addEventListener('pointerdown', () => { - lastSelectionWasPointer = true; - }, { capture: true }); + injectionDiv.addEventListener( + 'pointerdown', + () => { + lastSelectionWasPointer = true; + }, + { capture: true } + ); const isDetachable = (block) => !!block?.getParent() || @@ -679,7 +692,12 @@ export function initContextMenus(workspace) { toolbarDismissed = false; detachBtn.style.display = isDetachable(block) ? '' : 'none'; const hasComment = block.getCommentText() !== null; - commentBtn.setAttribute('aria-label', hasComment ? 'Delete comment' : 'Add comment'); + commentBtn.setAttribute( + 'aria-label', + hasComment + ? getToolbarLabel('delete_comment', 'Delete comment') + : getToolbarLabel('add_comment', 'Add comment') + ); commentBtn.innerHTML = hasComment ? commentDeleteSvg : commentAddSvg; let mesh = null; try { @@ -690,7 +708,12 @@ export function initContextMenus(workspace) { viewBtn.style.display = !mesh || mesh.name === 'ground' ? 'none' : ''; const exitMode = !!window.orbitViewActive && window.orbitBlock === block; viewBtn.innerHTML = exitMode ? viewExitSvg : viewEnterSvg; - viewBtn.setAttribute('aria-label', exitMode ? 'Exit canvas view' : 'View in canvas'); + viewBtn.setAttribute( + 'aria-label', + exitMode + ? getToolbarLabel('exit_canvas_view', 'Exit canvas view') + : getToolbarLabel('view_in_canvas', 'View in canvas') + ); positionBlockToolbar(); blockToolbar.classList.add('visible'); } @@ -743,20 +766,24 @@ export function initContextMenus(workspace) { }); // Toggle toolbar on click of the selected block - injectionDiv.addEventListener('pointerdown', (e) => { - if (!selectedBlock) return; - const svgRoot = selectedBlock.getSvgRoot?.(); - if (!svgRoot || !svgRoot.contains(e.target)) return; - if (toolbarBlock) { - // Toolbar visible → hide it; prevent SELECTED from re-showing - toolbarDismissed = true; - hideBlockToolbar(); - lastSelectionWasPointer = false; - } else if (toolbarDismissed) { - // Toolbar dismissed → re-show it - showBlockToolbar(selectedBlock); - } - }, { capture: true }); + injectionDiv.addEventListener( + 'pointerdown', + (e) => { + if (!selectedBlock) return; + const svgRoot = selectedBlock.getSvgRoot?.(); + if (!svgRoot || !svgRoot.contains(e.target)) return; + if (toolbarBlock) { + // Toolbar visible → hide it; prevent SELECTED from re-showing + toolbarDismissed = true; + hideBlockToolbar(); + lastSelectionWasPointer = false; + } else if (toolbarDismissed) { + // Toolbar dismissed → re-show it + showBlockToolbar(selectedBlock); + } + }, + { capture: true } + ); duplicateBtn.addEventListener('pointerdown', (e) => { e.preventDefault(); From 353df3e36b3545b2f60466ee45e954479103974c Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:58:21 +0100 Subject: [PATCH 7/8] Rabbit complaint --- ui/contextmenu.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ui/contextmenu.js b/ui/contextmenu.js index 8dc73449..4b8cc168 100644 --- a/ui/contextmenu.js +++ b/ui/contextmenu.js @@ -649,9 +649,7 @@ export function initContextMenus(workspace) { let toolbarShowTimer = null; let lastSelectionWasPointer = false; - const injectionDiv = workspace.getInjectionDiv(); - - injectionDiv.addEventListener( + document.addEventListener( 'pointerdown', () => { lastSelectionWasPointer = true; @@ -766,7 +764,7 @@ export function initContextMenus(workspace) { }); // Toggle toolbar on click of the selected block - injectionDiv.addEventListener( + document.addEventListener( 'pointerdown', (e) => { if (!selectedBlock) return; @@ -868,7 +866,7 @@ export function initContextMenus(workspace) { hideBlockToolbar(); if (block.isDisposed?.()) return; block.checkAndDelete(); - Blockly.Toast.show(workspace, { + Blockly.Toast?.show?.(workspace, { message: translate('DELETE_UNDO_HINT'), id: 'delete-undo-tip', oncePerSession: true, From 53cf1c01493ff7d9b58d00f146e9526893e7c4fb Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:01:00 +0100 Subject: [PATCH 8/8] Bug fix --- ui/gizmos.js | 97 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/ui/gizmos.js b/ui/gizmos.js index 92cd80c3..b6205c33 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -77,7 +77,21 @@ const cleanupFns = []; // Track DO sections and their associated blocks for cleanup const gizmoCreatedBlocks = new Map(); // blockId -> { parentId, createdDoSection, timestamp } -function createAdaptiveInput({ onMove, onConfirm, onCancel, stepNormal, stepFast, mode, showUniform, stepLabels, onHudHide, onAxisChange, stepLabelsByAxis, initialKeyboardAxis = null, initialHudAxis = null }) { +function createAdaptiveInput({ + onMove, + onConfirm, + onCancel, + stepNormal, + stepFast, + mode, + showUniform, + stepLabels, + onHudHide, + onAxisChange, + stepLabelsByAxis, + initialKeyboardAxis = null, + initialHudAxis = null, +}) { let hud = null; let keyboard = null; @@ -99,8 +113,26 @@ function createAdaptiveInput({ onMove, onConfirm, onCancel, stepNormal, stepFast onAxisChange?.(axis); } - hud = createGizmoMobileHud({ onMove, stepNormal, stepFast, mode, showUniform, stepLabels, onAxisChange: onHudAxisChange, stepLabelsByAxis, initialAxis: initialHudAxis ?? initialKeyboardAxis }); - keyboard = createAxisKeyboardHandler({ onMove, onConfirm, onCancel, stepNormal, stepFast, onAxisChange: onKbAxisChange, initialAxis: initialKeyboardAxis }); + hud = createGizmoMobileHud({ + onMove, + stepNormal, + stepFast, + mode, + showUniform, + stepLabels, + onAxisChange: onHudAxisChange, + stepLabelsByAxis, + initialAxis: initialHudAxis ?? initialKeyboardAxis, + }); + keyboard = createAxisKeyboardHandler({ + onMove, + onConfirm, + onCancel, + stepNormal, + stepFast, + onAxisChange: onKbAxisChange, + initialAxis: initialKeyboardAxis, + }); const startAxis = initialKeyboardAxis ?? initialHudAxis; if (startAxis) onAxisChange?.(startAxis); flock.canvas?.focus(); @@ -126,7 +158,11 @@ function registerBindings() { }; // Focus on mesh with V or F key KeyboardDispatcher.on('GIZMO', 'KeyF', noMod(focusCameraOnMesh)); - KeyboardDispatcher.on('GIZMO', 'KeyV', noMod(() => viewMeshWithCamera())); + KeyboardDispatcher.on( + 'GIZMO', + 'KeyV', + noMod(() => viewMeshWithCamera()) + ); // Delete selected mesh with Del key KeyboardDispatcher.on('GIZMO', 'Delete', (e) => { if (!gizmoManager?.attachedMesh) return; @@ -469,6 +505,10 @@ export function viewMeshWithCamera(block) { return; } disconnectOrbitView(); // switch target — disconnect first, then fall through + // If disconnect failed (orbit camera still active), don't attach a new one on top + if (flock.scene.activeCamera?.metadata?.orbitView) { + return; + } } if (mesh) attachOrbitView(mesh); return; @@ -782,7 +822,10 @@ function startMoveKeyboardHandler(mesh, savedHudAxis = null, onHudAxisSaved = nu stepFast: FAST_CURSOR, mode: 'arrows', stepLabelsByAxis: { x: ['◁', '▷'], y: ['▽', '△'], z: ['▽', '△'], all: ['◁', '▷'] }, - onAxisChange: (axis) => { onHudAxisSaved?.(axis); highlightGizmoAxis(gizmoManager.gizmos?.positionGizmo, axis); }, + onAxisChange: (axis) => { + onHudAxisSaved?.(axis); + highlightGizmoAxis(gizmoManager.gizmos?.positionGizmo, axis); + }, onHudHide: () => highlightGizmoAxis(gizmoManager.gizmos?.positionGizmo, null), initialKeyboardAxis, initialHudAxis: savedHudAxis, @@ -843,7 +886,10 @@ function startRotateKeyboardHandler(mesh, savedHudAxis = null, onHudAxisSaved = stepFast: FAST_ROTATION, mode: 'slider', onHudHide: () => highlightGizmoAxis(gizmoManager.gizmos?.rotationGizmo, null), - onAxisChange: (axis) => { onHudAxisSaved?.(axis); highlightGizmoAxis(gizmoManager.gizmos?.rotationGizmo, axis); }, + onAxisChange: (axis) => { + onHudAxisSaved?.(axis); + highlightGizmoAxis(gizmoManager.gizmos?.rotationGizmo, axis); + }, initialKeyboardAxis, initialHudAxis: savedHudAxis, }); @@ -905,7 +951,10 @@ function startScaleKeyboardHandler(mesh, savedHudAxis = null, onHudAxisSaved = n mode: 'arrows', showUniform: true, stepLabels: ['-', '+'], - onAxisChange: (axis) => { onHudAxisSaved?.(axis); highlightGizmoAxis(gizmoManager.gizmos?.scaleGizmo, axis); }, + onAxisChange: (axis) => { + onHudAxisSaved?.(axis); + highlightGizmoAxis(gizmoManager.gizmos?.scaleGizmo, axis); + }, onHudHide: () => highlightGizmoAxis(gizmoManager.gizmos?.scaleGizmo, null), initialKeyboardAxis, initialHudAxis: savedHudAxis, @@ -1522,8 +1571,12 @@ function handleScaleGizmo() { { const usg = gizmoManager.gizmos.scaleGizmo.uniformScaleGizmo; if (usg?.dragBehavior) { - const startObs = usg.dragBehavior.onDragStartObservable.add(() => stopAxisKeyboard?.setAxis('all')); - const endObs = usg.dragBehavior.onDragEndObservable.add(() => stopAxisKeyboard?.setAxis(null)); + const startObs = usg.dragBehavior.onDragStartObservable.add(() => + stopAxisKeyboard?.setAxis('all') + ); + const endObs = usg.dragBehavior.onDragEndObservable.add(() => + stopAxisKeyboard?.setAxis(null) + ); onExit(() => { usg.dragBehavior.onDragStartObservable.remove(startObs); usg.dragBehavior.onDragEndObservable.remove(endObs); @@ -1550,7 +1603,9 @@ function handleScaleGizmo() { let savedHudAxis = null; const mesh = gizmoManager.attachedMesh; if (mesh) { - startScaleKeyboardHandler(mesh, savedHudAxis, (axis) => { if (axis) savedHudAxis = axis; }); + startScaleKeyboardHandler(mesh, savedHudAxis, (axis) => { + if (axis) savedHudAxis = axis; + }); } else { pickMeshFromScene((pickedMesh) => { if (!pickedMesh || pickedMesh.name === 'ground') { @@ -1571,7 +1626,9 @@ function handleScaleGizmo() { } lastScaledMesh = mesh; - startScaleKeyboardHandler(mesh, savedHudAxis, (axis) => { if (axis) savedHudAxis = axis; }); + startScaleKeyboardHandler(mesh, savedHudAxis, (axis) => { + if (axis) savedHudAxis = axis; + }); }); onExit(() => gizmoManager.onAttachedToMeshObservable.remove(scaleObs)); @@ -1673,7 +1730,7 @@ function highlightGizmoAxis(gizmo, axis) { const map = { x: gizmo?.xGizmo, y: gizmo?.yGizmo, z: gizmo?.zGizmo }; Object.entries(map).forEach(([key, g]) => { if (g?._coloredMaterial) { - g._coloredMaterial.alpha = (!axis || axis === key || axis === 'all') ? 1 : 0.2; + g._coloredMaterial.alpha = !axis || axis === key || axis === 'all' ? 1 : 0.2; } }); } @@ -1683,7 +1740,9 @@ function observeDragAxis(gizmo) { for (const axisKey of ['x', 'y', 'z']) { const g = gizmo?.[`${axisKey}Gizmo`]; if (!g?.dragBehavior) continue; - const startObs = g.dragBehavior.onDragStartObservable.add(() => stopAxisKeyboard?.setAxis(axisKey)); + const startObs = g.dragBehavior.onDragStartObservable.add(() => + stopAxisKeyboard?.setAxis(axisKey) + ); const endObs = g.dragBehavior.onDragEndObservable.add(() => stopAxisKeyboard?.setAxis(null)); onExit(() => { g.dragBehavior.onDragStartObservable.remove(startObs); @@ -1704,7 +1763,9 @@ function handleRotationGizmo() { let savedHudAxis = null; const mesh = gizmoManager.attachedMesh; if (mesh) { - startRotateKeyboardHandler(mesh, savedHudAxis, (axis) => { if (axis) savedHudAxis = axis; }); + startRotateKeyboardHandler(mesh, savedHudAxis, (axis) => { + if (axis) savedHudAxis = axis; + }); } else { pickMeshFromScene((pickedMesh) => { if (!pickedMesh || pickedMesh.name === 'ground') { @@ -1727,7 +1788,9 @@ function handleRotationGizmo() { lastRotatedMesh = mesh; - startRotateKeyboardHandler(mesh, savedHudAxis, (axis) => { if (axis) savedHudAxis = axis; }); + startRotateKeyboardHandler(mesh, savedHudAxis, (axis) => { + if (axis) savedHudAxis = axis; + }); }); onExit(() => gizmoManager.onAttachedToMeshObservable.remove(rotateObs)); @@ -1791,7 +1854,9 @@ function handlePositionGizmo() { if (keyboardAttachedMesh === mesh) return; keyboardAttachedMesh = mesh; - startMoveKeyboardHandler(mesh, savedHudAxis, (axis) => { if (axis) savedHudAxis = axis; }); + startMoveKeyboardHandler(mesh, savedHudAxis, (axis) => { + if (axis) savedHudAxis = axis; + }); const blockKey = mesh?.metadata?.blockKey; const blockId = blockKey ? meshMap[blockKey] : null;