diff --git a/src/index.ts b/src/index.ts index 53d61283..8a856942 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import * as Blockly from 'blockly/core'; import {NavigationController} from './navigation_controller'; import {enableBlocksOnDrag} from './disabled_blocks'; +import {InputModeTracker} from './input_mode_tracker'; /** Plugin for keyboard navigation. */ export class KeyboardNavigation { @@ -25,6 +26,28 @@ export class KeyboardNavigation { */ private originalTheme: Blockly.Theme; + /** + * Input mode tracking. + */ + private inputModeTracker: InputModeTracker; + + /** + * Focus ring in the workspace. + */ + private workspaceFocusRing: Element | null = null; + + /** + * Selection ring inside the workspace. + */ + private workspaceSelectionRing: Element | null = null; + + /** + * Used to restore monkey patch. + */ + private oldWorkspaceResize: + | InstanceType['resize'] + | null = null; + /** * Constructs the keyboard navigation. * @@ -37,6 +60,7 @@ export class KeyboardNavigation { this.navigationController.init(); this.navigationController.addWorkspace(workspace); this.navigationController.enable(workspace); + this.inputModeTracker = new InputModeTracker(workspace); this.originalTheme = workspace.getTheme(); this.setGlowTheme(); @@ -57,18 +81,62 @@ export class KeyboardNavigation { workspace.getParentSvg(), ); } + + this.oldWorkspaceResize = workspace.resize; + workspace.resize = () => { + this.oldWorkspaceResize?.call(this.workspace); + this.resizeWorkspaceRings(); + }; + this.workspaceSelectionRing = Blockly.utils.dom.createSvgElement('rect', { + fill: 'none', + class: 'blocklyWorkspaceSelectionRing', + }); + workspace.getSvgGroup().appendChild(this.workspaceSelectionRing); + this.workspaceFocusRing = Blockly.utils.dom.createSvgElement('rect', { + fill: 'none', + class: 'blocklyWorkspaceFocusRing', + }); + workspace.getSvgGroup().appendChild(this.workspaceFocusRing); + this.resizeWorkspaceRings(); + } + + private resizeWorkspaceRings() { + if (!this.workspaceFocusRing || !this.workspaceSelectionRing) return; + this.resizeFocusRingInternal(this.workspaceSelectionRing, 5); + this.resizeFocusRingInternal(this.workspaceFocusRing, 0); + } + + private resizeFocusRingInternal(ring: Element, inset: number) { + const metrics = this.workspace.getMetrics(); + ring.setAttribute('x', (metrics.absoluteLeft + inset).toString()); + ring.setAttribute('y', (metrics.absoluteTop + inset).toString()); + ring.setAttribute( + 'width', + Math.max(0, metrics.viewWidth - inset * 2).toString(), + ); + ring.setAttribute( + 'height', + Math.max(0, metrics.svgHeight - inset * 2).toString(), + ); } /** * Disables keyboard navigation for this navigator's workspace. */ dispose() { + this.workspaceFocusRing?.remove(); + this.workspaceSelectionRing?.remove(); + if (this.oldWorkspaceResize) { + this.workspace.resize = this.oldWorkspaceResize; + } + // Remove the event listener that enables blocks on drag this.workspace.removeChangeListener(enableBlocksOnDrag); this.workspace.setTheme(this.originalTheme); this.navigationController.dispose(); + this.inputModeTracker.dispose(); } /** diff --git a/src/input_mode_tracker.ts b/src/input_mode_tracker.ts new file mode 100644 index 00000000..c2a4603b --- /dev/null +++ b/src/input_mode_tracker.ts @@ -0,0 +1,48 @@ +import {WorkspaceSvg} from 'blockly'; + +/** + * Types of user input. + */ +const enum InputMode { + Keyboard, + Pointer, +} + +/** + * Tracks the most recent input mode and sets a class indicating we're in + * keyboard nav mode. + */ +export class InputModeTracker { + private lastEventMode: InputMode | null = null; + + private pointerEventHandler = () => { + this.lastEventMode = InputMode.Pointer; + }; + private keyboardEventHandler = () => { + this.lastEventMode = InputMode.Keyboard; + }; + private focusChangeHandler = () => { + const isKeyboard = this.lastEventMode === InputMode.Keyboard; + const classList = this.workspace.getInjectionDiv().classList; + const className = 'blocklyKeyboardNavigation'; + if (isKeyboard) { + classList.add(className); + } else { + classList.remove(className); + } + }; + + constructor(private workspace: WorkspaceSvg) { + document.addEventListener('pointerdown', this.pointerEventHandler, true); + document.addEventListener('keydown', this.keyboardEventHandler, true); + document.addEventListener('focusout', this.focusChangeHandler, true); + document.addEventListener('focusin', this.focusChangeHandler, true); + } + + dispose() { + document.removeEventListener('pointerdown', this.pointerEventHandler, true); + document.removeEventListener('keydown', this.keyboardEventHandler, true); + document.removeEventListener('focusout', this.focusChangeHandler, true); + document.removeEventListener('focusin', this.focusChangeHandler, true); + } +} diff --git a/test/index.html b/test/index.html index 26036f2f..0afd3ed8 100644 --- a/test/index.html +++ b/test/index.html @@ -31,13 +31,6 @@ width: 100%; max-height: 100%; position: relative; - --outline-width: 5px; - } - - .blocklyFlyout { - top: var(--outline-width); - left: var(--outline-width); - height: calc(100% - calc(var(--outline-width) * 2)); } .blocklyToolboxDiv ~ .blocklyFlyout:focus { @@ -97,52 +90,70 @@ font-weight: bold; } - .blocklyActiveFocus:is( - .blocklyField, - .blocklyPath, - .blocklyHighlightedConnectionPath - ) { - stroke: #ffa200; - stroke-width: 3px; - } - .blocklyActiveFocus > .blocklyFlyoutBackground, - .blocklyActiveFocus > .blocklyMainBackground { - stroke: #ffa200; - stroke-width: 3px; - } - .blocklyActiveFocus:is( - .blocklyToolbox, - .blocklyToolboxCategoryContainer - ) { - outline: 3px solid #ffa200; - } - .blocklyPassiveFocus:is( - .blocklyField, - .blocklyPath, - .blocklyHighlightedConnectionPath - ) { - stroke: #ffa200; - stroke-dasharray: 5px 3px; - stroke-width: 3px; + html { + --blockly-active-node-color: #ffa200; + --blockly-active-tree-color: #60a5fa; + --blockly-selection-width: 3px; } - .blocklyPassiveFocus > .blocklyFlyoutBackground, - .blocklyPassiveFocus > .blocklyMainBackground { - stroke: #ffa200; - stroke-dasharray: 5px 3px; - stroke-width: 3px; + * { + box-sizing: border-box; } - .blocklyPassiveFocus:is( - .blocklyToolbox, - .blocklyToolboxCategoryContainer - ) { - border: 3px dashed #ffa200; + + /* Blocks, connections and fields. */ + .blocklyKeyboardNavigation + .blocklyActiveFocus:is(.blocklyPath, .blocklyHighlightedConnectionPath), + .blocklyKeyboardNavigation + .blocklyActiveFocus.blocklyField + > .blocklyFieldRect { + stroke: var(--blockly-active-node-color); + stroke-width: var(--blockly-selection-width); + } + .blocklyKeyboardNavigation + .blocklyPassiveFocus:is( + .blocklyPath:not(.blocklyFlyout .blocklyPath), + .blocklyHighlightedConnectionPath + ), + .blocklyKeyboardNavigation + .blocklyPassiveFocus.blocklyField + > .blocklyFieldRect { + stroke: var(--blockly-active-node-color); + stroke-dasharray: 5px 3px; + stroke-width: var(--blockly-selection-width); } - .blocklySelected:is(.blocklyPath) { - stroke: #ffa200; - stroke-width: 5; + .blocklyKeyboardNavigation + .blocklyPassiveFocus.blocklyHighlightedConnectionPath { + /* The connection path is being unexpectedly hidden in core */ + display: unset !important; } - .blocklySelected > .blocklyPathLight { - display: none; + + /* Toolbox and flyout. */ + .blocklyKeyboardNavigation .blocklyFlyout:has(.blocklyActiveFocus), + .blocklyKeyboardNavigation .blocklyToolbox:has(.blocklyActiveFocus), + .blocklyKeyboardNavigation + .blocklyActiveFocus:is(.blocklyFlyout, .blocklyToolbox) { + outline-offset: calc(var(--blockly-selection-width) * -1); + outline: var(--blockly-selection-width) solid + var(--blockly-active-tree-color); + } + /* Workspace */ + .blocklyKeyboardNavigation + .blocklyWorkspace:has(.blocklyActiveFocus) + .blocklyWorkspaceFocusRing, + .blocklyKeyboardNavigation + .blocklyWorkspace.blocklyActiveFocus + .blocklyWorkspaceFocusRing { + stroke: var(--blockly-active-tree-color); + stroke-width: calc(var(--blockly-selection-width) * 2); + } + .blocklyKeyboardNavigation + .blocklyWorkspace.blocklyActiveFocus + .blocklyWorkspaceSelectionRing { + stroke: var(--blockly-active-node-color); + stroke-width: var(--blockly-selection-width); + } + .blocklyKeyboardNavigation + .blocklyToolboxCategoryContainer:focus-visible { + outline: none; }