diff --git a/main/blocklyinit.js b/main/blocklyinit.js index 5b1dd5d1..8aeee5e7 100644 --- a/main/blocklyinit.js +++ b/main/blocklyinit.js @@ -35,6 +35,7 @@ import { defineSensingBlocks } from '../blocks/sensing.js'; import { defineTextBlocks } from '../blocks/text.js'; import { defineGenerators } from '../generators/generators.js'; import { registerCustomCommentIcon } from './customCommentIcon.js'; +import { getMeshFromBlock } from '../ui/blockmesh.js'; import { toolbox as toolboxDef } from '../toolbox.js'; // Blockly v13 moved variable methods off the workspace onto VariableMap/Variables. @@ -2260,6 +2261,58 @@ export function createBlocklyWorkspace() { }); })(); + // 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() { @@ -2268,10 +2321,11 @@ export function createBlocklyWorkspace() { const weights = { blockDuplicate: 9, detachBlockWithShortcut: 10, - blockComment: 11, - blockInline: 12, - blockCollapseExpand: 13, - blockDisable: 14, + viewBlockInCanvas: 10.5, + blockComment: 12, + blockInline: 13, + blockCollapseExpand: 14, + blockDisable: 15, blockDelete: 20, blockHelp: 999, }; @@ -2769,7 +2823,16 @@ export function createBlocklyWorkspace() { ); commentBtn.innerHTML = commentAddSvg; - blockToolbar.append(duplicateBtn, detachBtn, commentBtn, deleteBtn); + 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; @@ -2784,16 +2847,36 @@ export function createBlocklyWorkspace() { const svgRoot = toolbarBlock.getSvgRoot?.(); if (!svgRoot) return; const rect = svgRoot.getBoundingClientRect(); - blockToolbar.style.left = `${Math.round(rect.left + rect.width / 2)}px`; + 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.disabled = !isDetachable(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'); } @@ -2806,7 +2889,7 @@ export function createBlocklyWorkspace() { } const isToolbarBlock = (block) => - block && !block.isInFlyout && !block.isShadow() && !block.outputConnection; + block && !block.isInFlyout && !block.isShadow(); workspace.addChangeListener((e) => { if (e.type === Blockly.Events.SELECTED) { @@ -2872,6 +2955,21 @@ export function createBlocklyWorkspace() { 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(); diff --git a/style.css b/style.css index 27f2830c..c12c42d7 100644 --- a/style.css +++ b/style.css @@ -2174,7 +2174,7 @@ svg.blocklyTrashcanFlyout { content: ''; position: absolute; bottom: -7px; - left: 50%; + left: calc(50% + var(--caret-shift, 0px)); transform: translateX(-50%); border: 7px solid transparent; border-top-color: var(--color-border, #ddd); @@ -2185,7 +2185,7 @@ svg.blocklyTrashcanFlyout { content: ''; position: absolute; bottom: -5px; - left: 50%; + left: calc(50% + var(--caret-shift, 0px)); transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--color-menu, #f9f9f9); diff --git a/ui/blocklyutil.js b/ui/blocklyutil.js index 154a7dcc..c998544a 100644 --- a/ui/blocklyutil.js +++ b/ui/blocklyutil.js @@ -74,9 +74,11 @@ export function restoreBlockFocus(workspace, blockId) { const block = workspace.getBlockById(blockId); if (!block) return; - ensureAddMenuSelectionCleanup(workspace); - clearAddMenuHighlight(workspace, blockId); - trackBlockHighlight(workspace, blockId); + // On a view switch (canvas -> code) just bring the block back into view. + // Deliberately do NOT select or focus it: re-selecting armed the persistent + // getRestoredFocusableNode override, which hijacked focus on the next tap + // (first tap showed no toolbar) and left a stale selection ring on the old + // block. Leaving nothing selected means the next tap selects cleanly. scrollToBlockTopParentLeft(workspace, blockId); } diff --git a/ui/gizmos.js b/ui/gizmos.js index 4d92b8c7..30d3512c 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -134,7 +134,7 @@ 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; @@ -395,8 +395,8 @@ function deleteBlockWithUndo(blockId) { } } -function focusCameraOnMesh() { - let mesh = gizmoManager.attachedMesh; +function focusCameraOnMesh(overrideMesh) { + let mesh = overrideMesh ?? gizmoManager.attachedMesh; if (mesh && mesh.name === 'ground') mesh = null; if (!mesh && window.currentBlock) { mesh = getMeshFromBlock(window.currentBlock); @@ -452,19 +452,24 @@ function applyMeshSelection(pickedMesh, pickedPoint) { } } -function viewMeshWithCamera() { - let mesh = gizmoManager.attachedMesh; - if (mesh && mesh.name === 'ground') mesh = null; - - if (!mesh && window.currentBlock) { - mesh = getMeshFromBlock(window.currentBlock); - if (mesh && mesh.name === 'ground') mesh = null; +export function viewMeshWithCamera(block) { + let mesh; + if (block) { + mesh = getMeshFromBlock(block); + if (mesh?.name === 'ground') mesh = null; + } else { + mesh = gizmoManager.attachedMesh; + if (mesh?.name === 'ground') mesh = null; + if (!mesh && window.currentBlock) { + mesh = getMeshFromBlock(window.currentBlock); + if (mesh?.name === 'ground') mesh = null; + } } const camera = flock.scene.activeCamera; if (!camera?.metadata?.following) { - if (mesh) focusCameraOnMesh(); + if (mesh) focusCameraOnMesh(mesh); return; }