diff --git a/core/connection.ts b/core/connection.ts index 039d8822c01..aed90e7c78c 100644 --- a/core/connection.ts +++ b/core/connection.ts @@ -20,6 +20,7 @@ import type {Input} from './inputs/input.js'; import type {IASTNodeLocationWithBlock} from './interfaces/i_ast_node_location_with_block.js'; import type {IConnectionChecker} from './interfaces/i_connection_checker.js'; import * as blocks from './serialization/blocks.js'; +import {idGenerator} from './utils.js'; import * as Xml from './xml.js'; /** @@ -55,6 +56,9 @@ export class Connection implements IASTNodeLocationWithBlock { /** DOM representation of a shadow block, or null if none. */ private shadowDom: Element | null = null; + /** The unique ID of this connection. */ + id: string; + /** * Horizontal location of this connection. * @@ -80,6 +84,7 @@ export class Connection implements IASTNodeLocationWithBlock { public type: number, ) { this.sourceBlock_ = source; + this.id = `${source.id}_connection_${idGenerator.getNextUniqueId()}`; } /** diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index 168e59744d2..7ada1b9f6b7 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -22,10 +22,13 @@ import * as ContextMenu from './contextmenu.js'; import {ContextMenuRegistry} from './contextmenu_registry.js'; import * as eventUtils from './events/utils.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; +import type {IFocusableTree} from './interfaces/i_focusable_tree.js'; import {hasBubble} from './interfaces/i_has_bubble.js'; import * as internalConstants from './internal_constants.js'; import {Coordinate} from './utils/coordinate.js'; import * as svgMath from './utils/svg_math.js'; +import {WorkspaceSvg} from './workspace_svg.js'; /** Maximum randomness in workspace units for bumping a block. */ const BUMP_RANDOMNESS = 10; @@ -33,7 +36,10 @@ const BUMP_RANDOMNESS = 10; /** * Class for a connection between blocks that may be rendered on screen. */ -export class RenderedConnection extends Connection implements IContextMenu { +export class RenderedConnection + extends Connection + implements IContextMenu, IFocusableNode +{ // TODO(b/109816955): remove '!', see go/strict-prop-init-fix. sourceBlock_!: BlockSvg; private readonly db: ConnectionDB; @@ -320,13 +326,28 @@ export class RenderedConnection extends Connection implements IContextMenu { /** Add highlighting around this connection. */ highlight() { this.highlighted = true; - this.getSourceBlock().queueRender(); + + // Note that this needs to be done synchronously (vs. queuing a render pass) + // since only a displayed element can be focused, and this focusable node is + // implemented to make itself visible immediately prior to receiving DOM + // focus. It's expected that the connection's position should already be + // correct by this point (otherwise it will be corrected in a subsequent + // draw pass). + const highlightSvg = this.findHighlightSvg(); + if (highlightSvg) { + highlightSvg.style.display = ''; + } } /** Remove the highlighting around this connection. */ unhighlight() { this.highlighted = false; - this.getSourceBlock().queueRender(); + + // Note that this is done synchronously for parity with highlight(). + const highlightSvg = this.findHighlightSvg(); + if (highlightSvg) { + highlightSvg.style.display = 'none'; + } } /** Returns true if this connection is highlighted, false otherwise. */ @@ -626,6 +647,36 @@ export class RenderedConnection extends Connection implements IContextMenu { ContextMenu.show(e, menuOptions, block.RTL, workspace, location); } + + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + const highlightSvg = this.findHighlightSvg(); + if (highlightSvg) return highlightSvg; + throw new Error('No highlight SVG found corresponding to this connection.'); + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + return this.getSourceBlock().workspace as WorkspaceSvg; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void { + this.highlight(); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void { + this.unhighlight(); + } + + private findHighlightSvg(): SVGElement | null { + // This cast is valid as TypeScript's definition is wrong. See: + // https://github.com/microsoft/TypeScript/issues/60996. + return document.getElementById(this.id) as + | unknown + | null as SVGElement | null; + } } export namespace RenderedConnection { diff --git a/core/renderers/common/drawer.ts b/core/renderers/common/drawer.ts index 09320710c51..7046406adc7 100644 --- a/core/renderers/common/drawer.ts +++ b/core/renderers/common/drawer.ts @@ -435,19 +435,16 @@ export class Drawer { for (const elem of row.elements) { if (!(elem instanceof Connection)) continue; - if (elem.highlighted) { - this.drawConnectionHighlightPath(elem); - } else { - this.block_.pathObject.removeConnectionHighlight?.( - elem.connectionModel, - ); + const highlightSvg = this.drawConnectionHighlightPath(elem); + if (highlightSvg) { + highlightSvg.style.display = elem.highlighted ? '' : 'none'; } } } } /** Returns a path to highlight the given connection. */ - drawConnectionHighlightPath(measurable: Connection) { + drawConnectionHighlightPath(measurable: Connection): SVGElement | undefined { const conn = measurable.connectionModel; let path = ''; if ( @@ -459,7 +456,7 @@ export class Drawer { path = this.getStatementConnectionHighlightPath(measurable); } const block = conn.getSourceBlock(); - block.pathObject.addConnectionHighlight?.( + return block.pathObject.addConnectionHighlight?.( conn, path, conn.getOffsetInBlock(), diff --git a/core/renderers/common/i_path_object.ts b/core/renderers/common/i_path_object.ts index 699f1d92edb..776ba0067ea 100644 --- a/core/renderers/common/i_path_object.ts +++ b/core/renderers/common/i_path_object.ts @@ -113,7 +113,7 @@ export interface IPathObject { connectionPath: string, offset: Coordinate, rtl: boolean, - ): void; + ): SVGElement; /** * Apply the stored colours to the block's path, taking into account whether diff --git a/core/renderers/common/path_object.ts b/core/renderers/common/path_object.ts index 72cf2a594ce..ed2bb7dda75 100644 --- a/core/renderers/common/path_object.ts +++ b/core/renderers/common/path_object.ts @@ -268,37 +268,33 @@ export class PathObject implements IPathObject { connectionPath: string, offset: Coordinate, rtl: boolean, - ) { - if (this.connectionHighlights.has(connection)) { - if (this.currentHighlightMatchesNew(connection, connectionPath, offset)) { - return; - } - this.removeConnectionHighlight(connection); + ): SVGElement { + const transformation = + `translate(${offset.x}, ${offset.y})` + (rtl ? ' scale(-1 1)' : ''); + + const previousHighlight = this.connectionHighlights.get(connection); + if (previousHighlight) { + // Since a connection already exists, make sure that its path and + // transform are correct. + previousHighlight.setAttribute('d', connectionPath); + previousHighlight.setAttribute('transform', transformation); + return previousHighlight; } const highlight = dom.createSvgElement( Svg.PATH, { + 'id': connection.id, 'class': 'blocklyHighlightedConnectionPath', + 'style': 'display: none;', + 'tabindex': '-1', 'd': connectionPath, - 'transform': - `translate(${offset.x}, ${offset.y})` + (rtl ? ' scale(-1 1)' : ''), + 'transform': transformation, }, this.svgRoot, ); this.connectionHighlights.set(connection, highlight); - } - - private currentHighlightMatchesNew( - connection: RenderedConnection, - newPath: string, - newOffset: Coordinate, - ): boolean { - const currPath = this.connectionHighlights - .get(connection) - ?.getAttribute('d'); - const currOffset = this.highlightOffsets.get(connection); - return currPath === newPath && Coordinate.equals(currOffset, newOffset); + return highlight; } /** diff --git a/core/renderers/zelos/drawer.ts b/core/renderers/zelos/drawer.ts index 5cc52c0cbb2..b38711eb6c3 100644 --- a/core/renderers/zelos/drawer.ts +++ b/core/renderers/zelos/drawer.ts @@ -234,15 +234,16 @@ export class Drawer extends BaseDrawer { } /** Returns a path to highlight the given connection. */ - drawConnectionHighlightPath(measurable: Connection) { + override drawConnectionHighlightPath( + measurable: Connection, + ): SVGElement | undefined { const conn = measurable.connectionModel; if ( conn.type === ConnectionType.NEXT_STATEMENT || conn.type === ConnectionType.PREVIOUS_STATEMENT || (conn.type === ConnectionType.OUTPUT_VALUE && !measurable.isDynamicShape) ) { - super.drawConnectionHighlightPath(measurable); - return; + return super.drawConnectionHighlightPath(measurable); } let path = ''; @@ -261,7 +262,7 @@ export class Drawer extends BaseDrawer { (output.shape as DynamicShape).pathDown(output.height); } const block = conn.getSourceBlock(); - block.pathObject.addConnectionHighlight?.( + return block.pathObject.addConnectionHighlight?.( conn, path, conn.getOffsetInBlock(), diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index ee526bf8b6b..51992fcf0af 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -2710,6 +2710,7 @@ export class WorkspaceSvg } const fieldIndicatorIndex = id.indexOf('_field_'); + const connectionIndicatorIndex = id.indexOf('_connection_'); if (fieldIndicatorIndex !== -1) { const blockId = id.substring(0, fieldIndicatorIndex); const block = this.getBlockById(blockId); @@ -2719,6 +2720,15 @@ export class WorkspaceSvg } } return null; + } else if (connectionIndicatorIndex !== -1) { + const blockId = id.substring(0, connectionIndicatorIndex); + const block = this.getBlockById(blockId); + if (block) { + for (const connection of block.getConnections_(true)) { + if (connection.id === id) return connection; + } + } + return null; } return this.getBlockById(id) as IFocusableNode;