diff --git a/packages/blockly/core/trashcan.ts b/packages/blockly/core/trashcan.ts index 3f7509239..220a05d13 100644 --- a/packages/blockly/core/trashcan.ts +++ b/packages/blockly/core/trashcan.ts @@ -11,26 +11,32 @@ */ // Former goog.module ID: Blockly.Trashcan -// Unused import preserved for side-effects. Remove if unneeded. import * as browserEvents from './browser_events.js'; import {ComponentManager} from './component_manager.js'; +import * as Css from './css.js'; import {DeleteArea} from './delete_area.js'; import type {Abstract} from './events/events_abstract.js'; import './events/events_trashcan_open.js'; import {isBlockDelete} from './events/predicates.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; +import {getFocusManager} from './focus_manager.js'; import type {IAutoHideable} from './interfaces/i_autohideable.js'; import type {IDraggable} from './interfaces/i_draggable.js'; import type {IFlyout} from './interfaces/i_flyout.js'; +import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IPositionable} from './interfaces/i_positionable.js'; import {KeyboardMover} from './keyboard_nav/keyboard_mover.js'; +import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import type {UiMetrics} from './metrics_manager.js'; +import {Msg} from './msg.js'; import * as uiPosition from './positionable_helpers.js'; import * as registry from './registry.js'; import type * as blocks from './serialization/blocks.js'; import {SPRITE} from './sprites.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; +import {getNextUniqueId} from './utils/idgenerator.js'; import {Rect} from './utils/rect.js'; import {Size} from './utils/size.js'; import {Svg} from './utils/svg.js'; @@ -43,14 +49,22 @@ import type {WorkspaceSvg} from './workspace_svg.js'; */ export class Trashcan extends DeleteArea - implements IAutoHideable, IPositionable + implements IAutoHideable, IPositionable, IFocusableNode { /** - * The unique id for this component that is used to register with the + * The id for this component that is used to register with the * ComponentManager. */ override id = 'trashcan'; + /** + * A globally unique ID for this particular trashcan. Component Manager IDs + * (the ID above) are 1:1 with classes, but if there are multiple workspaces + * with trashcans on a page, each actual trashcan DOM element needs a unique + * ID to support focusable node resolution. This ID is for that purpose. + */ + private uniqueId = getNextUniqueId(); + /** * A list of JSON (stored as strings) representing blocks in the trashcan. */ @@ -66,24 +80,9 @@ export class Trashcan /** Current open/close state of the lid. */ isLidOpen = false; - /** - * The minimum openness of the lid. Used to indicate if the trashcan - * contains blocks. - */ - private minOpenness = 0; - /** The SVG group containing the trash can. */ private svgGroup: SVGElement | null = null; - /** The SVG image element of the trash can lid. */ - private svgLid: SVGElement | null = null; - - /** Task ID of opening/closing animation. */ - private lidTask: ReturnType | null = null; - - /** Current state of lid opening (0.0 = closed, 1.0 = open). */ - private lidOpen = 0; - /** Left coordinate of the trash can. */ private left = 0; @@ -150,7 +149,15 @@ export class Trashcan clip-path="url(#blocklyTrashLidClipPath837493)"> */ - this.svgGroup = dom.createSvgElement(Svg.G, {'class': 'blocklyTrash'}); + this.svgGroup = dom.createSvgElement(Svg.G, { + 'class': 'blocklyTrash', + 'tabindex': '0', + 'id': this.uniqueId, + }); + + aria.setRole(this.svgGroup, aria.Role.BUTTON); + aria.setState(this.svgGroup, aria.State.LABEL, Msg['OPEN_TRASH']); + let clip; const rnd = String(Math.random()).substring(2); clip = dom.createSvgElement( @@ -190,21 +197,31 @@ export class Trashcan {'width': WIDTH, 'height': LID_HEIGHT}, clip, ); - this.svgLid = dom.createSvgElement( + + const lid = dom.createSvgElement( + Svg.G, + {'class': 'blocklyTrashLid'}, + this.svgGroup, + ); + + const lidGroup = dom.createSvgElement( + Svg.SVG, + { + 'viewBox': `0 ${SPRITE_TOP} ${WIDTH} ${LID_HEIGHT}`, + 'width': WIDTH, + 'height': LID_HEIGHT, + }, + lid, + ); + + dom.createSvgElement( Svg.IMAGE, { 'width': SPRITE.width, - 'x': -SPRITE_LEFT, 'height': SPRITE.height, - 'y': -SPRITE_TOP, - 'clip-path': 'url(#blocklyTrashLidClipPath' + rnd + ')', + 'href': this.workspace.options.pathToMedia + SPRITE.url, }, - this.svgGroup, - ); - this.svgLid.setAttributeNS( - dom.XLINK_NS, - 'xlink:href', - this.workspace.options.pathToMedia + SPRITE.url, + lidGroup, ); // bindEventWithChecks_ quashes events too aggressively. See: @@ -218,10 +235,6 @@ export class Trashcan this.blockMouseDownWhenOpenable, ); browserEvents.bind(this.svgGroup, 'pointerup', this, this.click); - // Bind to body instead of this.svgGroup so that we don't get lid jitters - browserEvents.bind(body, 'pointerover', this, this.mouseOver); - browserEvents.bind(body, 'pointerout', this, this.mouseOut); - this.animateLid(); return this.svgGroup; } @@ -256,9 +269,6 @@ export class Trashcan if (this.svgGroup) { dom.removeNode(this.svgGroup); } - if (this.lidTask) { - clearTimeout(this.lidTask); - } } /** @@ -294,6 +304,13 @@ export class Trashcan this.flyout?.show(contents); blocklyStyle.cursor = ''; this.workspace.scrollbar?.setVisible(false); + if (keyboardNavigationController.getIsActive()) { + const flyoutWorkspace = this.flyout?.getWorkspace(); + const firstItem = flyoutWorkspace?.getNavigator().getFirstNode(); + if (firstItem) { + getFocusManager().focusNode(firstItem); + } + } }, 10); this.fireUiEvent(true); } @@ -332,7 +349,7 @@ export class Trashcan return; } this.contents.length = 0; - this.setMinOpenness(0); + this.svgGroup?.classList.remove(TRASH_FULL); this.closeFlyout(); } @@ -465,70 +482,8 @@ export class Trashcan if (this.isLidOpen === state) { return; } - if (this.lidTask) { - clearTimeout(this.lidTask); - } this.isLidOpen = state; - this.animateLid(); - } - - /** Rotate the lid open or closed by one step. Then wait and recurse. */ - private animateLid() { - const frames = ANIMATION_FRAMES; - - const delta = 1 / (frames + 1); - this.lidOpen += this.isLidOpen ? delta : -delta; - this.lidOpen = Math.min(Math.max(this.lidOpen, this.minOpenness), 1); - - this.setLidAngle(this.lidOpen * MAX_LID_ANGLE); - - // Linear interpolation between min and max. - const opacity = OPACITY_MIN + this.lidOpen * (OPACITY_MAX - OPACITY_MIN); - if (this.svgGroup) { - this.svgGroup.style.opacity = `${opacity}`; - } - - if (this.lidOpen > this.minOpenness && this.lidOpen < 1) { - this.lidTask = setTimeout( - this.animateLid.bind(this), - ANIMATION_LENGTH / frames, - ); - } - } - - /** - * Set the angle of the trashcan's lid. - * - * @param lidAngle The angle at which to set the lid. - */ - private setLidAngle(lidAngle: number) { - const openAtRight = - this.workspace.toolboxPosition === toolbox.Position.RIGHT || - (this.workspace.horizontalLayout && this.workspace.RTL); - this.svgLid?.setAttribute( - 'transform', - 'rotate(' + - (openAtRight ? -lidAngle : lidAngle) + - ',' + - (openAtRight ? 4 : WIDTH - 4) + - ',' + - (LID_HEIGHT - 2) + - ')', - ); - } - - /** - * Sets the minimum openness of the trashcan lid. If the lid is currently - * closed, this will update lid's position. - * - * @param newMin The new minimum openness of the lid. Should be between 0 - * and 1. - */ - private setMinOpenness(newMin: number) { - this.minOpenness = newMin; - if (!this.isLidOpen) { - this.setLidAngle(newMin * MAX_LID_ANGLE); - } + this.svgGroup?.classList.toggle(TRASH_OPEN, state); } /** @@ -572,25 +527,6 @@ export class Trashcan } } - /** - * Indicate that the trashcan can be clicked (by opening it) if it has blocks. - */ - private mouseOver() { - if (this.hasContents()) { - this.setLidOpen(true); - } - } - - /** - * Close the lid of the trashcan if it was open (Vis. it was indicating it had - * blocks). - */ - private mouseOut() { - // No need to do a .hasBlocks check here because if it doesn't the trashcan - // won't be open in the first place, and setOpen won't run. - this.setLidOpen(false); - } - /** * Handle a BLOCK_DELETE event. Adds deleted blocks oldXml to the content * array. @@ -615,7 +551,7 @@ export class Trashcan this.contents.pop(); } - this.setMinOpenness(HAS_BLOCKS_LID_ANGLE); + this.svgGroup?.classList.add(TRASH_FULL); } /** @@ -686,6 +622,38 @@ export class Trashcan }; return blockInfo; } + + getFocusableElement() { + if (!this.svgGroup) { + throw new Error('Tried to focus uninitialized trashcan'); + } + return this.svgGroup; + } + + getFocusableTree() { + return this.workspace; + } + + onNodeFocus() {} + onNodeBlur() {} + + canBeFocused() { + return !!this.svgGroup; + } + + performAction() { + this.click(); + } + + /** + * Retrieves the globally unique ID of this Trashcan instance. Used for focus + * management. + * + * @internal + */ + getGloballyUniqueId() { + return this.uniqueId; + } } /** Width of both the trash can and lid images. */ @@ -712,26 +680,49 @@ const SPRITE_LEFT = 0; /** Location of trashcan in sprite image. */ const SPRITE_TOP = 32; -/** - * The openness of the lid when the trashcan contains blocks. - * (0.0 = closed, 1.0 = open) - */ -const HAS_BLOCKS_LID_ANGLE = 0.1; +const TRASH_FULL = 'blocklyTrashFull'; +const TRASH_OPEN = 'blocklyTrashOpen'; -/** The length of the lid open/close animation in milliseconds. */ -const ANIMATION_LENGTH = 80; +Css.register(` + .blocklyTrash { + opacity: 0.4; + transition: opacity 0.08 ease-out; + } -/** The number of frames in the animation. */ -const ANIMATION_FRAMES = 4; + .blocklyTrashLid { + transition: rotate 0.08s ease-out; + transform-origin: 46px 12px; + rotate: 0deg; + pointer-events: none; + } -/** The minimum (resting) opacity of the trashcan and lid. */ -const OPACITY_MIN = 0.4; + .blocklyRTL .blocklyTrashLid { + transform-origin: 0px 12px; + } -/** The maximum (hovered) opacity of the trashcan and lid. */ -const OPACITY_MAX = 0.8; + .blocklyTrash.blocklyTrashFull .blocklyTrashLid { + rotate: 5deg; + } -/** - * The maximum angle the trashcan lid can opens to. At the end of the open - * animation the lid will be open to this angle. - */ -const MAX_LID_ANGLE = 45; + .blocklyRTL .blocklyTrash.blocklyTrashFull .blocklyTrashLid { + rotate: -5deg; + } + + .blocklyTrash.blocklyTrashOpen, + .blocklyTrash:hover, + .blocklyTrash:focus { + opacity: 0.8; + } + + .blocklyTrash.blocklyTrashFull.blocklyTrashOpen .blocklyTrashLid, + .blocklyTrash.blocklyTrashFull:hover .blocklyTrashLid, + .blocklyTrash.blocklyTrashFull:focus .blocklyTrashLid { + rotate: 45deg; + } + + .blocklyRTL .blocklyTrash.blocklyTrashFull.blocklyTrashOpen .blocklyTrashLid, + .blocklyRTL .blocklyTrash.blocklyTrashFull:hover .blocklyTrashLid, + .blocklyRTL .blocklyTrash.blocklyTrashFull:focus .blocklyTrashLid { + rotate: -45deg; + } +`); diff --git a/packages/blockly/core/workspace_svg.ts b/packages/blockly/core/workspace_svg.ts index 6b548647a..f670ddbce 100644 --- a/packages/blockly/core/workspace_svg.ts +++ b/packages/blockly/core/workspace_svg.ts @@ -2830,6 +2830,13 @@ export class WorkspaceSvg } } + if (this.trashcan?.getGloballyUniqueId() === id) { + return this.trashcan; + } + + const zoomControl = this.zoomControls_?.getControlWithId(id); + if (zoomControl) return zoomControl; + return null; } diff --git a/packages/blockly/core/zoom_controls.ts b/packages/blockly/core/zoom_controls.ts index 4f14b73be..6d113f08e 100644 --- a/packages/blockly/core/zoom_controls.ts +++ b/packages/blockly/core/zoom_controls.ts @@ -14,6 +14,7 @@ // Unused import preserved for side-effects. Remove if unneeded. import './events/events_click.js'; +import {IFocusableNode} from './blockly.js'; import * as browserEvents from './browser_events.js'; import {ComponentManager} from './component_manager.js'; import * as Css from './css.js'; @@ -21,15 +22,256 @@ import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; import type {IPositionable} from './interfaces/i_positionable.js'; import type {UiMetrics} from './metrics_manager.js'; +import {Msg} from './msg.js'; import * as uiPosition from './positionable_helpers.js'; import {SPRITE} from './sprites.js'; import * as Touch from './touch.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; +import {getNextUniqueId} from './utils/idgenerator.js'; import {Rect} from './utils/rect.js'; import {Size} from './utils/size.js'; import {Svg} from './utils/svg.js'; import type {WorkspaceSvg} from './workspace_svg.js'; +/** + * Base class for an individual zoom control (in, out, reset). + * + * @internal + */ +abstract class ZoomControl implements IFocusableNode { + private pointerDownHandler: browserEvents.Data; + private id: string; + + constructor( + protected workspace: WorkspaceSvg, + protected group: SVGGElement, + ) { + this.pointerDownHandler = browserEvents.conditionalBind( + group, + 'pointerdown', + null, + this.performAction.bind(this), + ); + + aria.setRole(group, aria.Role.BUTTON); + + this.id = getNextUniqueId(); + this.group.id = this.id; + } + + getId() { + return this.id; + } + + /** + * Handles a mouse down event on the zoom in or zoom out buttons on the + * workspace. + * + * @param amount Amount of zooming. Negative amount values zoom out, and + * positive amount values zoom in. + * @param e A mouse down or keydown event. + */ + protected zoom(amount: number, e: Event) { + this.workspace.markFocused(); + this.workspace.zoomCenter(amount); + this.fireZoomEvent(); + Touch.clearTouchIdentifier(); // Don't block future drags. + e.stopPropagation(); // Don't start a workspace scroll. + e.preventDefault(); // Stop double-clicking from selecting text. + } + + /** Fires a zoom control UI event. */ + protected fireZoomEvent() { + const uiEvent = new (eventUtils.get(EventType.CLICK))( + null, + this.workspace.id, + 'zoom_controls', + ); + eventUtils.fire(uiEvent); + } + + getFocusableElement() { + return this.group; + } + + getFocusableTree() { + return this.workspace; + } + + onNodeFocus() {} + + onNodeBlur() {} + + canBeFocused() { + return true; + } + + abstract performAction(_e: Event): void; + + dispose() { + browserEvents.unbind(this.pointerDownHandler); + } +} + +class ZoomInControl extends ZoomControl { + constructor(workspace: WorkspaceSvg, zoomControlContainer: SVGElement) { + const rnd = String(Math.random()).substring(2); + const group = dom.createSvgElement( + Svg.G, + {'class': 'blocklyZoom blocklyZoomIn', 'tabindex': '0'}, + zoomControlContainer, + ); + aria.setState(group, aria.State.LABEL, Msg['ZOOM_IN']); + const clip = dom.createSvgElement( + Svg.CLIPPATH, + {'id': 'blocklyZoominClipPath' + rnd}, + group, + ); + dom.createSvgElement( + Svg.RECT, + { + 'width': 32, + 'height': 32, + }, + clip, + ); + const zoominSvg = dom.createSvgElement( + Svg.IMAGE, + { + 'width': SPRITE.width, + 'height': SPRITE.height, + 'x': -32, + 'y': -92, + 'clip-path': 'url(#blocklyZoominClipPath' + rnd + ')', + }, + group, + ); + zoominSvg.setAttributeNS( + dom.XLINK_NS, + 'xlink:href', + workspace.options.pathToMedia + SPRITE.url, + ); + + super(workspace, group); + } + + override performAction(e: Event) { + this.zoom(1, e); + } +} + +class ZoomOutControl extends ZoomControl { + constructor(workspace: WorkspaceSvg, zoomControlContainer: SVGElement) { + const rnd = String(Math.random()).substring(2); + const group = dom.createSvgElement( + Svg.G, + {'class': 'blocklyZoom blocklyZoomOut', 'tabindex': '0'}, + zoomControlContainer, + ); + aria.setState(group, aria.State.LABEL, Msg['ZOOM_OUT']); + const clip = dom.createSvgElement( + Svg.CLIPPATH, + {'id': 'blocklyZoomoutClipPath' + rnd}, + group, + ); + dom.createSvgElement( + Svg.RECT, + { + 'width': 32, + 'height': 32, + }, + clip, + ); + const zoomoutSvg = dom.createSvgElement( + Svg.IMAGE, + { + 'width': SPRITE.width, + 'height': SPRITE.height, + 'x': -64, + 'y': -92, + 'clip-path': 'url(#blocklyZoomoutClipPath' + rnd + ')', + }, + group, + ); + zoomoutSvg.setAttributeNS( + dom.XLINK_NS, + 'xlink:href', + workspace.options.pathToMedia + SPRITE.url, + ); + + super(workspace, group); + } + + override performAction(e: Event) { + this.zoom(-1, e); + } +} + +class ZoomResetControl extends ZoomControl { + constructor(workspace: WorkspaceSvg, zoomControlContainer: SVGElement) { + const rnd = String(Math.random()).substring(2); + const group = dom.createSvgElement( + Svg.G, + {'class': 'blocklyZoom blocklyZoomReset', 'tabindex': '0'}, + zoomControlContainer, + ); + aria.setState(group, aria.State.LABEL, Msg['RESET_ZOOM']); + const clip = dom.createSvgElement( + Svg.CLIPPATH, + {'id': 'blocklyZoomresetClipPath' + rnd}, + group, + ); + dom.createSvgElement(Svg.RECT, {'width': 32, 'height': 32}, clip); + const zoomresetSvg = dom.createSvgElement( + Svg.IMAGE, + { + 'width': SPRITE.width, + 'height': SPRITE.height, + 'y': -92, + 'clip-path': 'url(#blocklyZoomresetClipPath' + rnd + ')', + }, + group, + ); + zoomresetSvg.setAttributeNS( + dom.XLINK_NS, + 'xlink:href', + workspace.options.pathToMedia + SPRITE.url, + ); + + super(workspace, group); + } + + /** + * Handles a mouse down event on the reset zoom button on the workspace. + * + * @param e A mouse down or keydown event. + */ + override performAction(e: Event) { + this.workspace.markFocused(); + + // zoom is passed amount and computes the new scale using the formula: + // targetScale = currentScale * Math.pow(speed, amount) + const targetScale = this.workspace.options.zoomOptions.startScale; + const currentScale = this.workspace.scale; + const speed = this.workspace.options.zoomOptions.scaleSpeed; + // To compute amount: + // amount = log(speed, (targetScale / currentScale)) + // Math.log computes natural logarithm (ln), to change the base, use + // formula: log(base, value) = ln(value) / ln(base) + const amount = Math.log(targetScale / currentScale) / Math.log(speed); + this.workspace.beginCanvasTransition(); + this.workspace.zoomCenter(amount); + this.workspace.scrollCenter(); + + setTimeout(this.workspace.endCanvasTransition.bind(this.workspace), 500); + this.fireZoomEvent(); + Touch.clearTouchIdentifier(); // Don't block future drags. + e.stopPropagation(); // Don't start a workspace scroll. + e.preventDefault(); // Stop double-clicking from selecting text. + } +} + /** * Class for a zoom controls. */ @@ -40,21 +282,14 @@ export class ZoomControls implements IPositionable { */ id = 'zoomControls'; - /** - * Array holding info needed to unbind events. - * Used for disposing. - * Ex: [[node, name, func], [node, name, func]]. - */ - private boundEvents: browserEvents.Data[] = []; + /** The zoom in control. */ + private zoomInControl: ZoomInControl | null = null; - /** The zoom in svg element. */ - private zoomInGroup: SVGGElement | null = null; + /** The zoom out control. */ + private zoomOutControl: ZoomControl | null = null; - /** The zoom out svg element. */ - private zoomOutGroup: SVGGElement | null = null; - - /** The zoom reset svg element. */ - private zoomResetGroup: SVGGElement | null = null; + /** The zoom reset control. */ + private zoomResetControl: ZoomControl | null = null; /** Width of the zoom controls. */ private readonly WIDTH = 32; @@ -98,17 +333,15 @@ export class ZoomControls implements IPositionable { */ createDom(): SVGElement { this.svgGroup = dom.createSvgElement(Svg.G, {}); - - // Each filter/pattern needs a unique ID for the case of multiple Blockly - // instances on a page. Browser behaviour becomes undefined otherwise. - // https://neil.fraser.name/news/2015/11/01/ - const rnd = String(Math.random()).substring(2); - this.createZoomOutSvg(rnd); - this.createZoomInSvg(rnd); + this.zoomOutControl = new ZoomOutControl(this.workspace, this.svgGroup); + this.zoomInControl = new ZoomInControl(this.workspace, this.svgGroup); if (this.workspace.isMovable()) { // If we zoom to the center and the workspace isn't movable we could // loose blocks at the edges of the workspace. - this.createZoomResetSvg(rnd); + this.zoomResetControl = new ZoomResetControl( + this.workspace, + this.svgGroup, + ); } return this.svgGroup; } @@ -132,10 +365,9 @@ export class ZoomControls implements IPositionable { if (this.svgGroup) { dom.removeNode(this.svgGroup); } - for (const event of this.boundEvents) { - browserEvents.unbind(event); - } - this.boundEvents.length = 0; + this.zoomInControl?.dispose(); + this.zoomOutControl?.dispose(); + this.zoomResetControl?.dispose(); } /** @@ -147,7 +379,7 @@ export class ZoomControls implements IPositionable { */ getBoundingRectangle(): Rect | null { let height = this.SMALL_SPACING + 2 * this.HEIGHT; - if (this.zoomResetGroup) { + if (this.zoomResetControl) { height += this.LARGE_SPACING + this.HEIGHT; } const bottom = this.top + height; @@ -174,7 +406,7 @@ export class ZoomControls implements IPositionable { metrics, ); let height = this.SMALL_SPACING + 2 * this.HEIGHT; - if (this.zoomResetGroup) { + if (this.zoomResetControl) { height += this.LARGE_SPACING + this.HEIGHT; } const startRect = uiPosition.getStartPositionRect( @@ -200,32 +432,31 @@ export class ZoomControls implements IPositionable { if (verticalPosition === uiPosition.verticalPosition.TOP) { const zoomInTranslateY = this.SMALL_SPACING + this.HEIGHT; - this.zoomInGroup?.setAttribute( - 'transform', - 'translate(0, ' + zoomInTranslateY + ')', - ); - if (this.zoomResetGroup) { + this.zoomInControl + ?.getFocusableElement() + .setAttribute('transform', 'translate(0, ' + zoomInTranslateY + ')'); + if (this.zoomResetControl) { const zoomResetTranslateY = zoomInTranslateY + this.LARGE_SPACING + this.HEIGHT; - this.zoomResetGroup.setAttribute( - 'transform', - 'translate(0, ' + zoomResetTranslateY + ')', - ); + this.zoomResetControl + .getFocusableElement() + .setAttribute( + 'transform', + 'translate(0, ' + zoomResetTranslateY + ')', + ); } } else { - const zoomInTranslateY = this.zoomResetGroup + const zoomInTranslateY = this.zoomResetControl ? this.LARGE_SPACING + this.HEIGHT : 0; - this.zoomInGroup?.setAttribute( - 'transform', - 'translate(0, ' + zoomInTranslateY + ')', - ); + this.zoomInControl + ?.getFocusableElement() + .setAttribute('transform', 'translate(0, ' + zoomInTranslateY + ')'); const zoomOutTranslateY = zoomInTranslateY + this.SMALL_SPACING + this.HEIGHT; - this.zoomOutGroup?.setAttribute( - 'transform', - 'translate(0, ' + zoomOutTranslateY + ')', - ); + this.zoomOutControl + ?.getFocusableElement() + .setAttribute('transform', 'translate(0, ' + zoomOutTranslateY + ')'); } this.top = positionRect.top; @@ -237,243 +468,21 @@ export class ZoomControls implements IPositionable { } /** - * Create the zoom in icon and its event handler. + * Returns the individual zoom control, if any, with the given ID. Used for + * focus management. * - * @param rnd The random string to use as a suffix in the clip path's ID. - * These IDs must be unique in case there are multiple Blockly instances - * on the same page. + * @internal */ - private createZoomOutSvg(rnd: string) { - /* This markup will be generated and added to the .svgGroup: - - - - - */ - this.zoomOutGroup = dom.createSvgElement( - Svg.G, - {'class': 'blocklyZoom blocklyZoomOut'}, - this.svgGroup, - ); - const clip = dom.createSvgElement( - Svg.CLIPPATH, - {'id': 'blocklyZoomoutClipPath' + rnd}, - this.zoomOutGroup, - ); - dom.createSvgElement( - Svg.RECT, - { - 'width': 32, - 'height': 32, - }, - clip, - ); - const zoomoutSvg = dom.createSvgElement( - Svg.IMAGE, - { - 'width': SPRITE.width, - 'height': SPRITE.height, - 'x': -64, - 'y': -92, - 'clip-path': 'url(#blocklyZoomoutClipPath' + rnd + ')', - }, - this.zoomOutGroup, - ); - zoomoutSvg.setAttributeNS( - dom.XLINK_NS, - 'xlink:href', - this.workspace.options.pathToMedia + SPRITE.url, - ); - - // Attach listener. - this.boundEvents.push( - browserEvents.conditionalBind( - this.zoomOutGroup, - 'pointerdown', - null, - this.zoom.bind(this, -1), - ), - ); - } - - /** - * Create the zoom out icon and its event handler. - * - * @param rnd The random string to use as a suffix in the clip path's ID. - * These IDs must be unique in case there are multiple Blockly instances - * on the same page. - */ - private createZoomInSvg(rnd: string) { - /* This markup will be generated and added to the .svgGroup: - - - - - - - */ - this.zoomInGroup = dom.createSvgElement( - Svg.G, - {'class': 'blocklyZoom blocklyZoomIn'}, - this.svgGroup, - ); - const clip = dom.createSvgElement( - Svg.CLIPPATH, - {'id': 'blocklyZoominClipPath' + rnd}, - this.zoomInGroup, - ); - dom.createSvgElement( - Svg.RECT, - { - 'width': 32, - 'height': 32, - }, - clip, - ); - const zoominSvg = dom.createSvgElement( - Svg.IMAGE, - { - 'width': SPRITE.width, - 'height': SPRITE.height, - 'x': -32, - 'y': -92, - 'clip-path': 'url(#blocklyZoominClipPath' + rnd + ')', - }, - this.zoomInGroup, - ); - zoominSvg.setAttributeNS( - dom.XLINK_NS, - 'xlink:href', - this.workspace.options.pathToMedia + SPRITE.url, - ); - - // Attach listener. - this.boundEvents.push( - browserEvents.conditionalBind( - this.zoomInGroup, - 'pointerdown', - null, - this.zoom.bind(this, 1), - ), - ); - } - - /** - * Handles a mouse down event on the zoom in or zoom out buttons on the - * workspace. - * - * @param amount Amount of zooming. Negative amount values zoom out, and - * positive amount values zoom in. - * @param e A mouse down event. - */ - private zoom(amount: number, e: PointerEvent) { - this.workspace.markFocused(); - this.workspace.zoomCenter(amount); - this.fireZoomEvent(); - Touch.clearTouchIdentifier(); // Don't block future drags. - e.stopPropagation(); // Don't start a workspace scroll. - e.preventDefault(); // Stop double-clicking from selecting text. - } - - /** - * Create the zoom reset icon and its event handler. - * - * @param rnd The random string to use as a suffix in the clip path's ID. - * These IDs must be unique in case there are multiple Blockly instances - * on the same page. - */ - private createZoomResetSvg(rnd: string) { - /* This markup will be generated and added to the .svgGroup: - - - - - - - */ - this.zoomResetGroup = dom.createSvgElement( - Svg.G, - {'class': 'blocklyZoom blocklyZoomReset'}, - this.svgGroup, - ); - const clip = dom.createSvgElement( - Svg.CLIPPATH, - {'id': 'blocklyZoomresetClipPath' + rnd}, - this.zoomResetGroup, - ); - dom.createSvgElement(Svg.RECT, {'width': 32, 'height': 32}, clip); - const zoomresetSvg = dom.createSvgElement( - Svg.IMAGE, - { - 'width': SPRITE.width, - 'height': SPRITE.height, - 'y': -92, - 'clip-path': 'url(#blocklyZoomresetClipPath' + rnd + ')', - }, - this.zoomResetGroup, - ); - zoomresetSvg.setAttributeNS( - dom.XLINK_NS, - 'xlink:href', - this.workspace.options.pathToMedia + SPRITE.url, - ); - - // Attach event listeners. - this.boundEvents.push( - browserEvents.conditionalBind( - this.zoomResetGroup, - 'pointerdown', - null, - this.resetZoom.bind(this), - ), - ); - } - - /** - * Handles a mouse down event on the reset zoom button on the workspace. - * - * @param e A mouse down event. - */ - private resetZoom(e: PointerEvent) { - this.workspace.markFocused(); - - // zoom is passed amount and computes the new scale using the formula: - // targetScale = currentScale * Math.pow(speed, amount) - const targetScale = this.workspace.options.zoomOptions.startScale; - const currentScale = this.workspace.scale; - const speed = this.workspace.options.zoomOptions.scaleSpeed; - // To compute amount: - // amount = log(speed, (targetScale / currentScale)) - // Math.log computes natural logarithm (ln), to change the base, use - // formula: log(base, value) = ln(value) / ln(base) - const amount = Math.log(targetScale / currentScale) / Math.log(speed); - this.workspace.beginCanvasTransition(); - this.workspace.zoomCenter(amount); - this.workspace.scrollCenter(); - - setTimeout(this.workspace.endCanvasTransition.bind(this.workspace), 500); - this.fireZoomEvent(); - Touch.clearTouchIdentifier(); // Don't block future drags. - e.stopPropagation(); // Don't start a workspace scroll. - e.preventDefault(); // Stop double-clicking from selecting text. - } - - /** Fires a zoom control UI event. */ - private fireZoomEvent() { - const uiEvent = new (eventUtils.get(EventType.CLICK))( - null, - this.workspace.id, - 'zoom_controls', - ); - eventUtils.fire(uiEvent); + getControlWithId(id: string) { + for (const control of [ + this.zoomInControl, + this.zoomOutControl, + this.zoomResetControl, + ]) { + if (control?.getId() === id) { + return control; + } + } } } @@ -483,11 +492,11 @@ Css.register(` opacity: .4; } -.blocklyZoom>image:hover, .blocklyZoom>svg>image:hover { - opacity: .6; +.blocklyZoom>image:hover, .blocklyZoom>svg>image:hover, .blocklyZoom:focus>image { + opacity: .8; } .blocklyZoom>image:active, .blocklyZoom>svg>image:active { - opacity: .8; + opacity: 1; } `); diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 12fbfe682..839419d51 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2026-04-21 17:30:08.288719", + "lastupdated": "2026-04-24 12:57:57.429458", "locale": "en", "messagedocumentation" : "qqq" }, @@ -484,5 +484,9 @@ "ANNOUNCE_MOVE_CANCELED": "Canceled movement", "FIELD_LABEL_EMPTY": "empty", "ARIA_TYPE_FIELD_INPUT": "input field", - "FIELD_LABEL_EDIT_PREFIX": "Edit %1" + "FIELD_LABEL_EDIT_PREFIX": "Edit %1", + "OPEN_TRASH": "Open trash", + "ZOOM_IN": "Zoom in", + "ZOOM_OUT": "Zoom out", + "RESET_ZOOM": "Reset zoom" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index d2fcf86cb..030869c7b 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -491,5 +491,9 @@ "ANNOUNCE_MOVE_CANCELED": "ARIA live region message announcing a block movement has been canceled.", "FIELD_LABEL_EMPTY": "Label for an empty field, used by screen readers to identify fields that have no content.", "ARIA_TYPE_FIELD_INPUT": "ARIA type name for an input field, used by screen readers to identify the type of field.", - "FIELD_LABEL_EDIT_PREFIX": "Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. \n\nParameters:\n* %1 - the label of the field's value \n\nExamples:\n* 'Edit 5'\n* 'Edit item'" + "FIELD_LABEL_EDIT_PREFIX": "Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. \n\nParameters:\n* %1 - the label of the field's value \n\nExamples:\n* 'Edit 5'\n* 'Edit item'", + "OPEN_TRASH": "ARIA label for the trashcan.", + "ZOOM_IN": "ARIA label for the zoom in button.", + "ZOOM_OUT": "ARIA label for the zoom out button.", + "RESET_ZOOM": "ARIA label for the reset zoom button." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index d5cd502d7..c778833fb 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -1931,4 +1931,16 @@ Blockly.Msg.ARIA_TYPE_FIELD_INPUT = 'input field'; /// Label for an editable field, used by screen readers to identify fields that can be edited by the user. Placeholder corresponds to the label of the field's value. /// \n\nParameters:\n* %1 - the label of the field's value /// \n\nExamples:\n* "Edit 5"\n* "Edit item" -Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1'; \ No newline at end of file +Blockly.Msg.FIELD_LABEL_EDIT_PREFIX = 'Edit %1'; +/** @type {string} */ +/// ARIA label for the trashcan. +Blockly.Msg.OPEN_TRASH = 'Open trash'; +/** @type {string} */ +/// ARIA label for the zoom in button. +Blockly.Msg.ZOOM_IN = 'Zoom in'; +/** @type {string} */ +/// ARIA label for the zoom out button. +Blockly.Msg.ZOOM_OUT = 'Zoom out'; +/** @type {string} */ +/// ARIA label for the reset zoom button. +Blockly.Msg.RESET_ZOOM = 'Reset zoom'; \ No newline at end of file