diff --git a/main/blocklyinit.js b/main/blocklyinit.js
index 1f39b89a..ae3e9246 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) {
@@ -2822,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') =>
- ``;
-
- 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
new file mode 100644
index 00000000..4b8cc168
--- /dev/null
+++ b/ui/contextmenu.js
@@ -0,0 +1,882 @@
+// 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';
+
+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 (e) {
+ void e;
+ }
+ });
+ })();
+
+ // 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 block = scope.block;
+ if (!block || !(block instanceof Blockly.Block)) return;
+ pasteAsChildOrHere(block, 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
+ if (selected && !(selected instanceof Blockly.Block)) return; // only paste to blocks
+
+ e.preventDefault();
+ e.stopPropagation();
+ pasteAsChildOrHere(selected || null, workspace, data);
+ },
+ { capture: true }
+ );
+
+ // ---- Floating block toolbar (all devices, pointer-driven only) ----
+ {
+ const blockToolbar = document.createElement('div');
+ blockToolbar.className = 'fc-block-toolbar';
+ 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') =>
+ ``;
+
+ // 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', getToolbarLabel('duplicate_button_ui', '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', getToolbarLabel('delete_button_ui', 'Delete'));
+ deleteBtn.innerHTML = mkFaSvg(
+ ''
+ );
+
+ const detachBtn = document.createElement('button');
+ detachBtn.type = 'button';
+ detachBtn.className = 'fc-block-toolbar-btn';
+ detachBtn.setAttribute('aria-label', getToolbarLabel('shortcut_detach_block', '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', getToolbarLabel('add_comment', 'Add comment'));
+ const commentAddSvg = mkFaSvg(
+ '',
+ '0 0 512 512'
+ );
+ const commentDeleteSvg = mkFaSvg(
+ '',
+ '0 0 640 512'
+ );
+ 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', 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 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;
+
+ document.addEventListener(
+ 'pointerdown',
+ () => {
+ lastSelectionWasPointer = true;
+ },
+ { capture: true }
+ );
+
+ 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;
+ toolbarDismissed = false;
+ detachBtn.style.display = isDetachable(block) ? '' : 'none';
+ const hasComment = block.getCommentText() !== null;
+ commentBtn.setAttribute(
+ 'aria-label',
+ hasComment
+ ? getToolbarLabel('delete_comment', 'Delete comment')
+ : getToolbarLabel('add_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' : '';
+ const exitMode = !!window.orbitViewActive && window.orbitBlock === block;
+ viewBtn.innerHTML = exitMode ? viewExitSvg : viewEnterSvg;
+ viewBtn.setAttribute(
+ 'aria-label',
+ exitMode
+ ? getToolbarLabel('exit_canvas_view', 'Exit canvas view')
+ : getToolbarLabel('view_in_canvas', 'View in canvas')
+ );
+ 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);
+ // 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)) {
+ selectedBlock = block;
+ toolbarDismissed = false;
+ if (wasPointer) {
+ toolbarShowTimer = setTimeout(() => showBlockToolbar(block), 400);
+ } else {
+ hideBlockToolbar();
+ }
+ } else {
+ selectedBlock = null;
+ hideBlockToolbar();
+ }
+ } else {
+ selectedBlock = null;
+ 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();
+ }
+ });
+
+ // Toggle toolbar on click of the selected block
+ document.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();
+ 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);
+ window.orbitBlock = window.orbitViewActive ? block : null;
+ });
+
+ 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();
+ if (block.isDisposed?.()) return;
+ block.checkAndDelete();
+ Blockly.Toast?.show?.(workspace, {
+ message: translate('DELETE_UNDO_HINT'),
+ id: 'delete-undo-tip',
+ oncePerSession: true,
+ duration: 8,
+ });
+ });
+ } else {
+ hideBlockToolbar();
+ block.checkAndDelete();
+ }
+ });
+ }
+}
diff --git a/ui/gizmos.js b/ui/gizmos.js
index 3ed6a83f..ea080c5e 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;
@@ -126,7 +140,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;
@@ -461,10 +479,18 @@ 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 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;
@@ -651,6 +677,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 +712,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);
@@ -790,7 +820,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,
@@ -870,7 +903,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,
});
@@ -932,7 +968,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,
@@ -1549,8 +1588,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);
@@ -1577,7 +1620,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') {
@@ -1598,7 +1643,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));
@@ -1715,7 +1762,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;
}
});
}
@@ -1725,7 +1772,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);
@@ -1746,7 +1795,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') {
@@ -1769,7 +1820,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));
@@ -1833,7 +1886,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;