From 5b6463e68116d9c5349b3e6fea82e810fe677983 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:29:29 +0100 Subject: [PATCH 1/5] Add keyboard overlay for context menu --- accessibility/keyboardui.js | 5 ++ locale/de.js | 1 + locale/en.js | 1 + locale/es.js | 1 + locale/fr.js | 1 + locale/it.js | 1 + locale/pl.js | 1 + locale/pt.js | 1 + locale/sv.js | 1 + main/blocklyinit.js | 43 ++++++++++- style.css | 32 ++++++++ ui/blocklyutil.js | 16 ++++ ui/contextmenu.js | 150 ++++++++++++++++++++++++------------ 13 files changed, 205 insertions(+), 49 deletions(-) diff --git a/accessibility/keyboardui.js b/accessibility/keyboardui.js index 5377a089..a7d57f0e 100644 --- a/accessibility/keyboardui.js +++ b/accessibility/keyboardui.js @@ -485,6 +485,11 @@ function getShortcuts() { keys: `X`, category: translate('shortcut_category_editor'), }, + { + label: translate('shortcut_comment_block'), + keys: `K`, + category: translate('shortcut_category_editor'), + }, { label: translate('shortcut_start_move_block'), keys: `M`, diff --git a/locale/de.js b/locale/de.js index 00b44906..b57da9b9 100644 --- a/locale/de.js +++ b/locale/de.js @@ -1240,6 +1240,7 @@ export default { shortcut_context_menu: 'Kontextmenü öffnen', shortcut_duplicate_block: 'Block duplizieren', shortcut_detach_block: 'Block trennen', + shortcut_comment_block: 'Kommentar hinzufügen/entfernen', shortcut_start_move_block: 'Block verschieben', shortcut_move_arrows: 'Verschieben: zur Verbindung', shortcut_move_anywhere: 'Verschieben: überall', diff --git a/locale/en.js b/locale/en.js index 1f276b56..07d90b8f 100644 --- a/locale/en.js +++ b/locale/en.js @@ -1317,6 +1317,7 @@ export default { shortcut_context_menu: 'Open context menu', shortcut_duplicate_block: 'Duplicate block', shortcut_detach_block: 'Detach block', + shortcut_comment_block: 'Add/remove comment', shortcut_start_move_block: 'Move block', shortcut_move_arrows: 'Move: to connection', shortcut_move_anywhere: 'Move: anywhere', diff --git a/locale/es.js b/locale/es.js index e73ae629..0fb5b619 100644 --- a/locale/es.js +++ b/locale/es.js @@ -1271,6 +1271,7 @@ export default { shortcut_context_menu: 'Abrir menú contextual', shortcut_duplicate_block: 'Duplicar bloque', shortcut_detach_block: 'Desconectar bloque', + shortcut_comment_block: 'Añadir/quitar comentario', shortcut_start_move_block: 'Mover bloque', shortcut_move_arrows: 'Mover: a conexión', shortcut_move_anywhere: 'Mover: a cualquier lugar', diff --git a/locale/fr.js b/locale/fr.js index dda0b1a5..0e628993 100644 --- a/locale/fr.js +++ b/locale/fr.js @@ -1247,6 +1247,7 @@ export default { shortcut_context_menu: 'Ouvrir le menu contextuel', shortcut_duplicate_block: 'Dupliquer le bloc', shortcut_detach_block: 'Détacher le bloc', + shortcut_comment_block: 'Ajouter/supprimer un commentaire', shortcut_start_move_block: 'Déplacer le bloc', shortcut_move_arrows: 'Déplacer : vers une connexion', shortcut_move_anywhere: "Déplacer : n'importe où", diff --git a/locale/it.js b/locale/it.js index d918c1b0..a0083161 100644 --- a/locale/it.js +++ b/locale/it.js @@ -1248,6 +1248,7 @@ export default { shortcut_context_menu: 'Apri menu contestuale', shortcut_duplicate_block: 'Duplica blocco', shortcut_detach_block: 'Stacca blocco', + shortcut_comment_block: 'Aggiungi/rimuovi commento', shortcut_start_move_block: 'Sposta blocco', shortcut_move_arrows: 'Sposta: alla connessione', shortcut_move_anywhere: 'Sposta: ovunque', diff --git a/locale/pl.js b/locale/pl.js index cc7a0e61..df4055d3 100644 --- a/locale/pl.js +++ b/locale/pl.js @@ -1240,6 +1240,7 @@ export default { shortcut_context_menu: 'Otwórz menu kontekstowe', shortcut_duplicate_block: 'Duplikuj blok', shortcut_detach_block: 'Odłącz blok', + shortcut_comment_block: 'Dodaj/usuń komentarz', shortcut_start_move_block: 'Przesuń blok', shortcut_move_arrows: 'Przesuń: do połączenia', shortcut_move_anywhere: 'Przesuń: gdziekolwiek', diff --git a/locale/pt.js b/locale/pt.js index b7ea8888..7636e7af 100644 --- a/locale/pt.js +++ b/locale/pt.js @@ -1250,6 +1250,7 @@ export default { shortcut_context_menu: 'Abrir menu de contexto', shortcut_duplicate_block: 'Duplicar bloco', shortcut_detach_block: 'Desconectar bloco', + shortcut_comment_block: 'Adicionar/remover comentário', shortcut_start_move_block: 'Mover bloco', shortcut_move_arrows: 'Mover: para ligação', shortcut_move_anywhere: 'Mover: para qualquer lugar', diff --git a/locale/sv.js b/locale/sv.js index b275cb48..336e81f3 100644 --- a/locale/sv.js +++ b/locale/sv.js @@ -1228,6 +1228,7 @@ export default { shortcut_context_menu: 'Öppna snabbmeny', shortcut_duplicate_block: 'Duplicera block', shortcut_detach_block: 'Koppla loss block', + shortcut_comment_block: 'Lägg till/ta bort kommentar', shortcut_start_move_block: 'Flytta block', shortcut_move_arrows: 'Flytta: till anslutning', shortcut_move_anywhere: 'Flytta: var som helst', diff --git a/main/blocklyinit.js b/main/blocklyinit.js index 7ae66042..6c60b083 100644 --- a/main/blocklyinit.js +++ b/main/blocklyinit.js @@ -37,7 +37,12 @@ import { defineGenerators } from '../generators/generators.js'; import { registerCustomCommentIcon } from './customCommentIcon.js'; import { getMeshFromBlock } from '../ui/blockmesh.js'; import { initContextMenus } from '../ui/contextmenu.js'; -import { applyBlockLockState, stripLockState } from '../ui/blocklyutil.js'; +import { + applyBlockLockState, + stripLockState, + isBlockLocked, + toggleBlockComment, +} from '../ui/blocklyutil.js'; import { toolbox as toolboxDef } from '../toolbox.js'; // Persist locked blocks as part of the workspace serialization. Lower priority @@ -1593,6 +1598,42 @@ function installShadowNavigationPatch(ws) { return true; } ); + + // 'K' toggles a comment on the focused block. (N — the natural mnemonic for + // "note" — is already Blockly's next_stack navigation key, so K is used.) + // Comment has no built-in Blockly shortcut, so unlike X/D/Delete this single + // registration must resolve the target itself — from the focused block + // (scope.focusedNode) or, when focus is on a skippable field, that field's + // source block. + { + const commentTargetBlock = (scope) => { + const node = scope?.focusedNode; + if (node && typeof node.getCommentText === 'function') return node; + return skippableFieldBlock(); + }; + shortcutRegistry.register({ + name: 'comment_block', + keyCodes: [shortcutRegistry.createSerializedKey(Blockly.utils.KeyCodes.K)], + preconditionFn: (ws, scope) => { + const block = commentTargetBlock(scope); + return ( + !!block && + !ws.isDragging?.() && + !ws.isReadOnly?.() && + !block.isShadow?.() && + !isBlockLocked(block) + ); + }, + callback: (_ws, _event, _shortcut, scope) => { + const block = commentTargetBlock(scope); + if (!block || block.isShadow?.() || isBlockLocked(block)) return false; + Blockly.Events.setGroup('comment_shortcut'); + toggleBlockComment(block); + Blockly.Events.setGroup(false); + return true; + }, + }); + } } export function createBlocklyWorkspace() { diff --git a/style.css b/style.css index fa5c7a54..96991c9a 100644 --- a/style.css +++ b/style.css @@ -2255,6 +2255,38 @@ svg.blocklyTrashcanFlyout { pointer-events: auto; } +/* Keyboard-only shortcut-letter overlay for the floating block toolbar. */ +.fc-toolbar-badges { + position: fixed; + inset: 0; + z-index: 201; /* above .fc-block-toolbar (200) */ + pointer-events: none; + opacity: 0; +} + +.fc-toolbar-badges.visible { + opacity: 1; +} + +.fc-toolbar-key-badge { + position: absolute; + transform: translate(-50%, -100%); /* sit above the anchor point */ + background: #fff; + color: #000; + border: 1px solid #aaa; + border-radius: 4px; + box-shadow: 0 2px 0 #888; + padding: 2px 6px; + font-size: 12px; + font-weight: bold; + font-family: monospace; + pointer-events: none; + min-width: 10px; + text-align: center; + line-height: 1.4; + white-space: nowrap; +} + .fc-block-toolbar-btn { display: flex; align-items: center; diff --git a/ui/blocklyutil.js b/ui/blocklyutil.js index 2ecd8e26..b79330b2 100644 --- a/ui/blocklyutil.js +++ b/ui/blocklyutil.js @@ -117,6 +117,22 @@ export function setBlockLocked(block, locked) { } } +// Toggle a comment on a block: remove it if present, otherwise add an empty one +// and open its bubble for editing. Shared by the 'N' keyboard shortcut and the +// floating block toolbar's comment button. +export function toggleBlockComment(block) { + if (!block) return; + if (block.getCommentText() !== null) { + block.setCommentText(null); + } else { + block.setCommentText(""); + const icon = block + .getIcons?.() + .find((i) => typeof i.setBubbleVisible === "function"); + icon?.setBubbleVisible(true); + } +} + function trackBlockHighlight(workspace, blockId) { lastAddMenuHighlighted = { workspace, blockId }; const block = workspace.getBlockById(blockId); diff --git a/ui/contextmenu.js b/ui/contextmenu.js index acdeea5a..dda18c50 100644 --- a/ui/contextmenu.js +++ b/ui/contextmenu.js @@ -3,7 +3,28 @@ import * as Blockly from 'blockly'; import { translate } from '../main/translation.js'; import { getMeshFromBlock } from './blockmesh.js'; -import { setBlockLocked, isBlockLocked, stripLockState } from './blocklyutil.js'; +import { + setBlockLocked, + isBlockLocked, + stripLockState, + toggleBlockComment, +} from './blocklyutil.js'; + +// Render a context-menu row as "Label Shortcut", with the +// shortcut hint dimmed on the right. Shared by the detach (X), view (V) and +// comment (K) items. +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; +} export function initContextMenus(workspace) { // ------- Pointer tracking for "paste at pointer" ------- @@ -28,25 +49,6 @@ export function initContextMenus(workspace) { 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, @@ -87,19 +89,6 @@ export function initContextMenus(workspace) { 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, @@ -190,6 +179,22 @@ export function initContextMenus(workspace) { } })(); + // Show the "K" shortcut hint next to the built-in comment item, the same way + // detach shows "X" and view shows "V". The item's label is dynamic (Add / + // Remove Comment), so wrap the original displayText rather than replacing it. + (function addCommentShortcutHint() { + const registry = Blockly.ContextMenuRegistry.registry; + const item = registry.getItem?.('blockComment'); + if (!item || item.__shortcutWrapped) return; + const origDisplayText = item.displayText; + item.displayText = (scope) => { + const text = + typeof origDisplayText === 'function' ? origDisplayText(scope) : origDisplayText; + return renderShortcut(text, 'K'); + }; + item.__shortcutWrapped = true; + })(); + // Disable context-menu items that would edit a locked block (comment, inline // inputs, disable, detach). Delete is already disabled via setDeletable(false), // and "Lock/Unlock" stays enabled so the block can be unlocked. @@ -627,13 +632,20 @@ export function initContextMenus(workspace) { { capture: true } ); - // ---- Floating block toolbar (all devices, pointer-driven only) ---- + // ---- Floating block toolbar ---- + // Pointer selection shows it after a short hover; keyboard navigation shows it + // immediately with a shortcut-letter overlay (D/X/K/V/Del) above each button. { const blockToolbar = document.createElement('div'); blockToolbar.className = 'fc-block-toolbar'; blockToolbar.setAttribute('role', 'toolbar'); document.body.appendChild(blockToolbar); + // Keyboard-only overlay of shortcut-letter badges, one per visible button. + const badgeOverlay = document.createElement('div'); + badgeOverlay.className = 'fc-toolbar-badges'; + document.body.appendChild(badgeOverlay); + // 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') => @@ -701,11 +713,45 @@ export function initContextMenus(workspace) { blockToolbar.append(duplicateBtn, detachBtn, commentBtn, viewBtn, deleteBtn); + // The keyboard shortcut that each toolbar button mirrors. The overlay shows + // these as a passive legend — the keys themselves are bound elsewhere + // (blocklyinit.js for D/X/K/Del, gizmos.js for V) and already fire on the + // keyboard-selected block, so the badges only need to display them. + const buttonShortcuts = [ + [duplicateBtn, 'D'], + [detachBtn, 'X'], + [commentBtn, 'K'], + [viewBtn, 'V'], + [deleteBtn, 'Del'], + ]; + let toolbarBlock = null; // block the toolbar is currently visible for let selectedBlock = null; // block currently selected (regardless of toolbar visibility) let toolbarShowTimer = null; let lastSelectionWasPointer = false; let dismissedBlock = null; // block whose toolbar was just dismissed via toggle; suppress re-show for it only + let toolbarKeyboardMode = false; // toolbar was opened via keyboard → show badge overlay + + function clearBadges() { + badgeOverlay.replaceChildren(); + badgeOverlay.classList.remove('visible'); + } + + // Place a badge centred just above each currently-visible button. + function renderBadges() { + badgeOverlay.replaceChildren(); + for (const [btn, label] of buttonShortcuts) { + if (btn.style.display === 'none' || btn.offsetParent === null) continue; + const rect = btn.getBoundingClientRect(); + const badge = document.createElement('div'); + badge.className = 'fc-toolbar-key-badge'; + badge.textContent = label; + badge.style.left = `${Math.round(rect.left + rect.width / 2)}px`; + badge.style.top = `${Math.round(rect.top - 6)}px`; + badgeOverlay.appendChild(badge); + } + badgeOverlay.classList.add('visible'); + } document.addEventListener( 'pointerdown', @@ -741,10 +787,13 @@ export function initContextMenus(workspace) { blockToolbar.style.left = `${blockCenterX + adj}px`; blockToolbar.style.setProperty('--caret-shift', `${-adj}px`); } + // Badges are positioned off the buttons, so they must follow the toolbar. + if (toolbarKeyboardMode) renderBadges(); } - function showBlockToolbar(block) { + function showBlockToolbar(block, { keyboard = false } = {}) { toolbarBlock = block; + toolbarKeyboardMode = keyboard; // Locked blocks can't be edited: hide the mutating buttons (detach, // comment, delete), leaving duplicate and view-in-canvas available. @@ -777,15 +826,21 @@ export function initContextMenus(workspace) { ? getToolbarLabel('exit_canvas_view', 'Exit canvas view') : getToolbarLabel('view_in_canvas', 'View in canvas') ); - positionBlockToolbar(); blockToolbar.classList.add('visible'); + // Clear any stale badges from a previous keyboard selection; in keyboard + // mode positionBlockToolbar() draws fresh ones (it also re-runs on block + // move / viewport change to keep them aligned with the buttons). + if (!keyboard) clearBadges(); + positionBlockToolbar(); } function hideBlockToolbar() { clearTimeout(toolbarShowTimer); toolbarShowTimer = null; toolbarBlock = null; + toolbarKeyboardMode = false; blockToolbar.classList.remove('visible'); + clearBadges(); } const isToolbarBlock = (block) => block && !block.isInFlyout && !block.isShadow(); @@ -805,11 +860,17 @@ export function initContextMenus(workspace) { dismissedBlock = null; if (isToolbarBlock(block)) { selectedBlock = block; - - if (wasPointer && !wasDismissed) { - toolbarShowTimer = setTimeout(() => showBlockToolbar(block), 400); + + if (wasPointer) { + // Pointer selection: reveal after a short hover, no badges. + if (!wasDismissed) { + toolbarShowTimer = setTimeout(() => showBlockToolbar(block), 400); + } else { + hideBlockToolbar(); + } } else { - hideBlockToolbar(); + // Keyboard navigation: show immediately with the shortcut overlay. + showBlockToolbar(block, { keyboard: true }); } } else { selectedBlock = null; @@ -894,14 +955,7 @@ export function initContextMenus(workspace) { 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); - } + toggleBlockComment(toolbarBlock); hideBlockToolbar(); }); From 92f39750aeb6738bdd258e0df8d66479e01995fd Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:54:50 +0100 Subject: [PATCH 2/5] Add overlay for context menu --- accessibility/keyboardui.js | 5 +++ locale/de.js | 3 +- locale/en.js | 3 +- locale/es.js | 3 +- locale/fr.js | 3 +- locale/it.js | 3 +- locale/pl.js | 3 +- locale/pt.js | 3 +- locale/sv.js | 3 +- main/blocklyinit.js | 60 ++++++++++++++++++++--------- ui/blocklyutil.js | 42 +++++++++++++++++--- ui/contextmenu.js | 76 +++++++++++++++++++++++++++---------- 12 files changed, 157 insertions(+), 50 deletions(-) diff --git a/accessibility/keyboardui.js b/accessibility/keyboardui.js index a7d57f0e..f4a58585 100644 --- a/accessibility/keyboardui.js +++ b/accessibility/keyboardui.js @@ -490,6 +490,11 @@ function getShortcuts() { keys: `K`, category: translate('shortcut_category_editor'), }, + { + label: translate('shortcut_delete_comment'), + keys: `Shift + K`, + category: translate('shortcut_category_editor'), + }, { label: translate('shortcut_start_move_block'), keys: `M`, diff --git a/locale/de.js b/locale/de.js index b57da9b9..87998ad8 100644 --- a/locale/de.js +++ b/locale/de.js @@ -1240,7 +1240,8 @@ export default { shortcut_context_menu: 'Kontextmenü öffnen', shortcut_duplicate_block: 'Block duplizieren', shortcut_detach_block: 'Block trennen', - shortcut_comment_block: 'Kommentar hinzufügen/entfernen', + shortcut_comment_block: 'Kommentar ein-/ausblenden', + shortcut_delete_comment: 'Kommentar löschen', shortcut_start_move_block: 'Block verschieben', shortcut_move_arrows: 'Verschieben: zur Verbindung', shortcut_move_anywhere: 'Verschieben: überall', diff --git a/locale/en.js b/locale/en.js index 07d90b8f..1421ca1f 100644 --- a/locale/en.js +++ b/locale/en.js @@ -1317,7 +1317,8 @@ export default { shortcut_context_menu: 'Open context menu', shortcut_duplicate_block: 'Duplicate block', shortcut_detach_block: 'Detach block', - shortcut_comment_block: 'Add/remove comment', + shortcut_comment_block: 'Show/hide comment', + shortcut_delete_comment: 'Delete comment', shortcut_start_move_block: 'Move block', shortcut_move_arrows: 'Move: to connection', shortcut_move_anywhere: 'Move: anywhere', diff --git a/locale/es.js b/locale/es.js index 0fb5b619..ee337d5b 100644 --- a/locale/es.js +++ b/locale/es.js @@ -1271,7 +1271,8 @@ export default { shortcut_context_menu: 'Abrir menú contextual', shortcut_duplicate_block: 'Duplicar bloque', shortcut_detach_block: 'Desconectar bloque', - shortcut_comment_block: 'Añadir/quitar comentario', + shortcut_comment_block: 'Mostrar/ocultar comentario', + shortcut_delete_comment: 'Eliminar comentario', shortcut_start_move_block: 'Mover bloque', shortcut_move_arrows: 'Mover: a conexión', shortcut_move_anywhere: 'Mover: a cualquier lugar', diff --git a/locale/fr.js b/locale/fr.js index 0e628993..ab41c1fb 100644 --- a/locale/fr.js +++ b/locale/fr.js @@ -1247,7 +1247,8 @@ export default { shortcut_context_menu: 'Ouvrir le menu contextuel', shortcut_duplicate_block: 'Dupliquer le bloc', shortcut_detach_block: 'Détacher le bloc', - shortcut_comment_block: 'Ajouter/supprimer un commentaire', + shortcut_comment_block: 'Afficher/masquer le commentaire', + shortcut_delete_comment: 'Supprimer le commentaire', shortcut_start_move_block: 'Déplacer le bloc', shortcut_move_arrows: 'Déplacer : vers une connexion', shortcut_move_anywhere: "Déplacer : n'importe où", diff --git a/locale/it.js b/locale/it.js index a0083161..c72dd4ce 100644 --- a/locale/it.js +++ b/locale/it.js @@ -1248,7 +1248,8 @@ export default { shortcut_context_menu: 'Apri menu contestuale', shortcut_duplicate_block: 'Duplica blocco', shortcut_detach_block: 'Stacca blocco', - shortcut_comment_block: 'Aggiungi/rimuovi commento', + shortcut_comment_block: 'Mostra/nascondi commento', + shortcut_delete_comment: 'Elimina commento', shortcut_start_move_block: 'Sposta blocco', shortcut_move_arrows: 'Sposta: alla connessione', shortcut_move_anywhere: 'Sposta: ovunque', diff --git a/locale/pl.js b/locale/pl.js index df4055d3..2f1919c9 100644 --- a/locale/pl.js +++ b/locale/pl.js @@ -1240,7 +1240,8 @@ export default { shortcut_context_menu: 'Otwórz menu kontekstowe', shortcut_duplicate_block: 'Duplikuj blok', shortcut_detach_block: 'Odłącz blok', - shortcut_comment_block: 'Dodaj/usuń komentarz', + shortcut_comment_block: 'Pokaż/ukryj komentarz', + shortcut_delete_comment: 'Usuń komentarz', shortcut_start_move_block: 'Przesuń blok', shortcut_move_arrows: 'Przesuń: do połączenia', shortcut_move_anywhere: 'Przesuń: gdziekolwiek', diff --git a/locale/pt.js b/locale/pt.js index 7636e7af..78a8dcf8 100644 --- a/locale/pt.js +++ b/locale/pt.js @@ -1250,7 +1250,8 @@ export default { shortcut_context_menu: 'Abrir menu de contexto', shortcut_duplicate_block: 'Duplicar bloco', shortcut_detach_block: 'Desconectar bloco', - shortcut_comment_block: 'Adicionar/remover comentário', + shortcut_comment_block: 'Mostrar/ocultar comentário', + shortcut_delete_comment: 'Excluir comentário', shortcut_start_move_block: 'Mover bloco', shortcut_move_arrows: 'Mover: para ligação', shortcut_move_anywhere: 'Mover: para qualquer lugar', diff --git a/locale/sv.js b/locale/sv.js index 336e81f3..33dd027e 100644 --- a/locale/sv.js +++ b/locale/sv.js @@ -1228,7 +1228,8 @@ export default { shortcut_context_menu: 'Öppna snabbmeny', shortcut_duplicate_block: 'Duplicera block', shortcut_detach_block: 'Koppla loss block', - shortcut_comment_block: 'Lägg till/ta bort kommentar', + shortcut_comment_block: 'Visa/dölj kommentar', + shortcut_delete_comment: 'Ta bort kommentar', shortcut_start_move_block: 'Flytta block', shortcut_move_arrows: 'Flytta: till anslutning', shortcut_move_anywhere: 'Flytta: var som helst', diff --git a/main/blocklyinit.js b/main/blocklyinit.js index 6c60b083..e38e16b9 100644 --- a/main/blocklyinit.js +++ b/main/blocklyinit.js @@ -41,7 +41,8 @@ import { applyBlockLockState, stripLockState, isBlockLocked, - toggleBlockComment, + toggleCommentBubble, + deleteBlockComment, } from '../ui/blocklyutil.js'; import { toolbox as toolboxDef } from '../toolbox.js'; @@ -1599,36 +1600,61 @@ function installShadowNavigationPatch(ws) { } ); - // 'K' toggles a comment on the focused block. (N — the natural mnemonic for - // "note" — is already Blockly's next_stack navigation key, so K is used.) - // Comment has no built-in Blockly shortcut, so unlike X/D/Delete this single - // registration must resolve the target itself — from the focused block - // (scope.focusedNode) or, when focus is on a skippable field, that field's - // source block. + // Comment shortcuts. 'K' toggles the comment bubble open/closed (creating one + // and focusing it when none exists); 'Shift+K' deletes the comment. (N — the + // natural mnemonic for "note" — is already Blockly's next_stack nav key.) + // Comment has no built-in Blockly shortcut, so unlike X/D/Delete these must + // resolve the target themselves — from the focused block (scope.focusedNode) + // or, when focus is on a skippable field, that field's source block. { const commentTargetBlock = (scope) => { const node = scope?.focusedNode; if (node && typeof node.getCommentText === 'function') return node; return skippableFieldBlock(); }; + const commentEditable = (ws, block) => + !!block && + !ws.isDragging?.() && + !ws.isReadOnly?.() && + !block.isShadow?.() && + !isBlockLocked(block); + shortcutRegistry.register({ name: 'comment_block', keyCodes: [shortcutRegistry.createSerializedKey(Blockly.utils.KeyCodes.K)], + preconditionFn: (ws, scope) => commentEditable(ws, commentTargetBlock(scope)), + callback: (_ws, event, _shortcut, scope) => { + const block = commentTargetBlock(scope); + if (!block || block.isShadow?.() || isBlockLocked(block)) return false; + // Cancel the keystroke's default text input; otherwise the 'k' that + // triggered this lands in the comment editor we're about to focus. + event?.preventDefault?.(); + Blockly.Events.setGroup('comment_shortcut'); + // The undoable create runs synchronously inside toggleCommentBubble + // before it awaits, so it lands in this group; the bubble open/focus + // that follows is UI state and needn't be grouped. + toggleCommentBubble(block); + Blockly.Events.setGroup(false); + return true; + }, + }); + + shortcutRegistry.register({ + name: 'delete_comment_block', + keyCodes: [ + shortcutRegistry.createSerializedKey(Blockly.utils.KeyCodes.K, [ + Blockly.utils.KeyCodes.SHIFT, + ]), + ], preconditionFn: (ws, scope) => { const block = commentTargetBlock(scope); - return ( - !!block && - !ws.isDragging?.() && - !ws.isReadOnly?.() && - !block.isShadow?.() && - !isBlockLocked(block) - ); + return commentEditable(ws, block) && block.getCommentText?.() !== null; }, callback: (_ws, _event, _shortcut, scope) => { const block = commentTargetBlock(scope); - if (!block || block.isShadow?.() || isBlockLocked(block)) return false; - Blockly.Events.setGroup('comment_shortcut'); - toggleBlockComment(block); + if (!block || block.getCommentText?.() === null || isBlockLocked(block)) return false; + Blockly.Events.setGroup('delete_comment_shortcut'); + deleteBlockComment(block); Blockly.Events.setGroup(false); return true; }, diff --git a/ui/blocklyutil.js b/ui/blocklyutil.js index b79330b2..aaa565f3 100644 --- a/ui/blocklyutil.js +++ b/ui/blocklyutil.js @@ -118,21 +118,51 @@ export function setBlockLocked(block, locked) { } // Toggle a comment on a block: remove it if present, otherwise add an empty one -// and open its bubble for editing. Shared by the 'N' keyboard shortcut and the -// floating block toolbar's comment button. +// and open its bubble for editing. Used by the floating block toolbar's comment +// button (which is an add/remove affordance). export function toggleBlockComment(block) { if (!block) return; if (block.getCommentText() !== null) { block.setCommentText(null); } else { block.setCommentText(""); - const icon = block - .getIcons?.() - .find((i) => typeof i.setBubbleVisible === "function"); - icon?.setBubbleVisible(true); + getCommentIcon(block)?.setBubbleVisible(true); } } +function getCommentIcon(block) { + return ( + block?.getIcons?.().find((i) => typeof i.setBubbleVisible === "function") ?? + null + ); +} + +// Toggle the comment bubble open/closed, creating the comment if the block +// doesn't have one yet. When opening, move keyboard focus into the comment +// editor so the user can type straight away. Used by the 'K' shortcut; never +// deletes (use deleteBlockComment for that). Async because Blockly's +// setBubbleVisible resolves once the bubble has been (re)rendered. +export async function toggleCommentBubble(block) { + if (!block) return; + if (block.getCommentText() === null) block.setCommentText(""); + const icon = getCommentIcon(block); + if (!icon) return; + if (icon.bubbleIsVisible?.()) { + await icon.setBubbleVisible(false); + return; + } + await icon.setBubbleVisible(true); + // performAction() is the comment bubble's documented keyboard-navigation + // entry point: it focuses the editor's text area. + icon.getBubble?.()?.performAction?.(); +} + +// Remove a block's comment entirely (icon and text), if it has one. +export function deleteBlockComment(block) { + if (!block) return; + if (block.getCommentText() !== null) block.setCommentText(null); +} + function trackBlockHighlight(workspace, blockId) { lastAddMenuHighlighted = { workspace, blockId }; const block = workspace.getBlockById(blockId); diff --git a/ui/contextmenu.js b/ui/contextmenu.js index dda18c50..7e3351c8 100644 --- a/ui/contextmenu.js +++ b/ui/contextmenu.js @@ -8,6 +8,7 @@ import { isBlockLocked, stripLockState, toggleBlockComment, + toggleCommentBubble, } from './blocklyutil.js'; // Render a context-menu row as "Label Shortcut", with the @@ -179,20 +180,38 @@ export function initContextMenus(workspace) { } })(); - // Show the "K" shortcut hint next to the built-in comment item, the same way - // detach shows "X" and view shows "V". The item's label is dynamic (Add / - // Remove Comment), so wrap the original displayText rather than replacing it. - (function addCommentShortcutHint() { + // Customize the built-in comment item. (1) Show the shortcut hint, the same + // way detach shows "X" and view shows "V"; the item is dynamic — "Add Comment" + // adds (K), "Remove Comment" deletes (Shift+K) — so match the hint to whichever + // it will do. (2) Make "Add Comment" open and focus the bubble (Blockly's + // default leaves it closed), matching the K shortcut. + (function customizeCommentContextMenuItem() { const registry = Blockly.ContextMenuRegistry.registry; const item = registry.getItem?.('blockComment'); - if (!item || item.__shortcutWrapped) return; + if (!item || item.__commentItemWrapped) return; + const origDisplayText = item.displayText; item.displayText = (scope) => { const text = typeof origDisplayText === 'function' ? origDisplayText(scope) : origDisplayText; - return renderShortcut(text, 'K'); + const hasComment = scope?.block?.getCommentText?.() != null; + return renderShortcut(text, hasComment ? 'Shift+K' : 'K'); + }; + + const origCallback = item.callback; + item.callback = function (scope, ...rest) { + const block = scope?.block; + if (block && block.getCommentText?.() == null) { + // Adding: create the comment, open its bubble and focus the editor. + Blockly.Events.setGroup('contextmenu_comment'); + toggleCommentBubble(block); + Blockly.Events.setGroup(false); + return; + } + return origCallback?.call(this, scope, ...rest); }; - item.__shortcutWrapped = true; + + item.__commentItemWrapped = true; })(); // Disable context-menu items that would edit a locked block (comment, inline @@ -716,11 +735,14 @@ export function initContextMenus(workspace) { // The keyboard shortcut that each toolbar button mirrors. The overlay shows // these as a passive legend — the keys themselves are bound elsewhere // (blocklyinit.js for D/X/K/Del, gizmos.js for V) and already fire on the - // keyboard-selected block, so the badges only need to display them. + // keyboard-selected block, so the badges only need to display them. A label + // may be a function for state-dependent buttons. const buttonShortcuts = [ [duplicateBtn, 'D'], [detachBtn, 'X'], - [commentBtn, 'K'], + // Match the comment button's icon: '⇧K' (Shift+K, delete) when the block + // already has a comment, 'K' (show/hide) when it doesn't. + [commentBtn, () => (toolbarBlock?.getCommentText?.() != null ? '⇧K' : 'K')], [viewBtn, 'V'], [deleteBtn, 'Del'], ]; @@ -740,12 +762,12 @@ export function initContextMenus(workspace) { // Place a badge centred just above each currently-visible button. function renderBadges() { badgeOverlay.replaceChildren(); - for (const [btn, label] of buttonShortcuts) { + for (const [btn, labelSpec] of buttonShortcuts) { if (btn.style.display === 'none' || btn.offsetParent === null) continue; const rect = btn.getBoundingClientRect(); const badge = document.createElement('div'); badge.className = 'fc-toolbar-key-badge'; - badge.textContent = label; + badge.textContent = typeof labelSpec === 'function' ? labelSpec() : labelSpec; badge.style.left = `${Math.round(rect.left + rect.width / 2)}px`; badge.style.top = `${Math.round(rect.top - 6)}px`; badgeOverlay.appendChild(badge); @@ -791,6 +813,19 @@ export function initContextMenus(workspace) { if (toolbarKeyboardMode) renderBadges(); } + // Sync the comment button's icon + label to whether the block has a comment: + // crossed-out "delete" icon when it does, plain "add" icon when it doesn't. + function updateCommentButton(block) { + 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; + } + function showBlockToolbar(block, { keyboard = false } = {}) { toolbarBlock = block; toolbarKeyboardMode = keyboard; @@ -801,14 +836,7 @@ export function initContextMenus(workspace) { detachBtn.style.display = !locked && isDetachable(block) ? '' : 'none'; commentBtn.style.display = locked ? 'none' : ''; deleteBtn.style.display = locked ? '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; + updateCommentButton(block); let mesh = null; try { mesh = getMeshFromBlock(block); @@ -895,6 +923,16 @@ export function initContextMenus(workspace) { toolbarBlock ) { positionBlockToolbar(); + } else if ( + e.type === Blockly.Events.BLOCK_CHANGE && + e.element === 'comment' && + toolbarBlock && + e.blockId === toolbarBlock.id + ) { + // Comment added/removed (e.g. via Shift+K) while the toolbar is up: + // refresh the button icon and, in keyboard mode, its badge (K ⇄ ⇧K). + updateCommentButton(toolbarBlock); + if (toolbarKeyboardMode) renderBadges(); } else if (e.type === Blockly.Events.BLOCK_DRAG && e.isStart) { hideBlockToolbar(); } From f48035f3a7e9025eca7a8dc7c1a70ee65163fd4d Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:57:53 +0100 Subject: [PATCH 3/5] Move badges --- style.css | 2 +- ui/contextmenu.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/style.css b/style.css index 96991c9a..60b21a88 100644 --- a/style.css +++ b/style.css @@ -2270,7 +2270,7 @@ svg.blocklyTrashcanFlyout { .fc-toolbar-key-badge { position: absolute; - transform: translate(-50%, -100%); /* sit above the anchor point */ + transform: translate(-50%, -50%); /* centre on the anchor point, below the icon */ background: #fff; color: #000; border: 1px solid #aaa; diff --git a/ui/contextmenu.js b/ui/contextmenu.js index 7e3351c8..98c9f8fc 100644 --- a/ui/contextmenu.js +++ b/ui/contextmenu.js @@ -759,7 +759,8 @@ export function initContextMenus(workspace) { badgeOverlay.classList.remove('visible'); } - // Place a badge centred just above each currently-visible button. + // Place a badge below each currently-visible button, slightly overlapping + // it — same offset as the gizmo-menu badges. function renderBadges() { badgeOverlay.replaceChildren(); for (const [btn, labelSpec] of buttonShortcuts) { @@ -769,7 +770,7 @@ export function initContextMenus(workspace) { badge.className = 'fc-toolbar-key-badge'; badge.textContent = typeof labelSpec === 'function' ? labelSpec() : labelSpec; badge.style.left = `${Math.round(rect.left + rect.width / 2)}px`; - badge.style.top = `${Math.round(rect.top - 6)}px`; + badge.style.top = `${Math.round(rect.top + rect.height + 8)}px`; badgeOverlay.appendChild(badge); } badgeOverlay.classList.add('visible'); From d6566bad97b254031d4fa8331555311334c4004e Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:55:31 +0100 Subject: [PATCH 4/5] Bugfix badges on drag --- main/blocklyinit.js | 15 ++++++++++++--- ui/contextmenu.js | 24 ++++++++++++++++++++---- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/main/blocklyinit.js b/main/blocklyinit.js index e38e16b9..b72bc11f 100644 --- a/main/blocklyinit.js +++ b/main/blocklyinit.js @@ -1604,12 +1604,21 @@ function installShadowNavigationPatch(ws) { // and focusing it when none exists); 'Shift+K' deletes the comment. (N — the // natural mnemonic for "note" — is already Blockly's next_stack nav key.) // Comment has no built-in Blockly shortcut, so unlike X/D/Delete these must - // resolve the target themselves — from the focused block (scope.focusedNode) - // or, when focus is on a skippable field, that field's source block. + // resolve the target themselves — from the focused block (scope.focusedNode), + // or a focused field's source block, falling back to the skippable-field + // resolver. { const commentTargetBlock = (scope) => { const node = scope?.focusedNode; - if (node && typeof node.getCommentText === 'function') return node; + // A focused block exposes getCommentText; a focused field exposes + // getSourceBlock and unwraps to the block that owns it. + if (node) { + if (typeof node.getCommentText === 'function') return node; + if (typeof node.getSourceBlock === 'function') { + const block = node.getSourceBlock(); + if (block) return block; + } + } return skippableFieldBlock(); }; const commentEditable = (ws, block) => diff --git a/ui/contextmenu.js b/ui/contextmenu.js index 98c9f8fc..c9af9d9e 100644 --- a/ui/contextmenu.js +++ b/ui/contextmenu.js @@ -776,6 +776,10 @@ export function initContextMenus(workspace) { badgeOverlay.classList.add('visible'); } + // Track the input modality that drives selection. A pointer gesture can fire + // several SELECTED events (notably a drag fires one at start and one at end), + // so this is a persistent mode — set by the input device, not consumed on + // selection — rather than a one-shot flag. document.addEventListener( 'pointerdown', () => { @@ -783,6 +787,19 @@ export function initContextMenus(workspace) { }, { capture: true } ); + document.addEventListener( + 'keydown', + (e) => { + // Only genuine block navigation flips to keyboard mode. Ignore typing, + // app-level combos (Ctrl/Cmd/Alt+…), and bare modifiers — the last so a + // Shift held during a mouse drag doesn't switch the toolbar to keyboard. + if (isTypingInInput()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key === 'Shift') return; + lastSelectionWasPointer = false; + }, + { capture: true } + ); const isDetachable = (block) => !!block?.getParent() || @@ -880,11 +897,10 @@ export function initContextMenus(workspace) { clearTimeout(toolbarShowTimer); toolbarShowTimer = null; 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. + // Read (don't consume) the current input modality; it persists until + // the next pointer/keyboard input, so a drag's start/end SELECTED pair + // are both treated as pointer-driven. const wasPointer = lastSelectionWasPointer; - lastSelectionWasPointer = false; const wasDismissed = block === dismissedBlock; dismissedBlock = null; if (isToolbarBlock(block)) { From 8d3dfefdf8f74c8fcd409f7260ea6f6882219eec Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:56:56 +0100 Subject: [PATCH 5/5] Bugfix --- ui/contextmenu.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ui/contextmenu.js b/ui/contextmenu.js index c9af9d9e..df48a861 100644 --- a/ui/contextmenu.js +++ b/ui/contextmenu.js @@ -202,10 +202,14 @@ export function initContextMenus(workspace) { item.callback = function (scope, ...rest) { const block = scope?.block; if (block && block.getCommentText?.() == null) { - // Adding: create the comment, open its bubble and focus the editor. + // Adding: create the comment, open its bubble and focus the editor. The + // undoable create runs synchronously inside toggleCommentBubble (before + // it awaits) so it lands in this group; the async bubble open/focus is + // UI state. Preserve/restore the outer group like the other items here. + const prevGroup = Blockly.Events.getGroup(); Blockly.Events.setGroup('contextmenu_comment'); toggleCommentBubble(block); - Blockly.Events.setGroup(false); + Blockly.Events.setGroup(prevGroup || null); return; } return origCallback?.call(this, scope, ...rest);