mirror of
https://github.com/google/blockly.git
synced 2026-04-26 23:20:22 +02:00
fix: Make trashcan and zoom controls accessible
This commit is contained in:
+129
-138
@@ -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<typeof setTimeout> | 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)"></image>
|
||||
</g>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <g> element. */
|
||||
private zoomInGroup: SVGGElement | null = null;
|
||||
/** The zoom out control. */
|
||||
private zoomOutControl: ZoomControl | null = null;
|
||||
|
||||
/** The zoom out svg <g> element. */
|
||||
private zoomOutGroup: SVGGElement | null = null;
|
||||
|
||||
/** The zoom reset svg <g> 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:
|
||||
<g class="blocklyZoom">
|
||||
<clipPath id="blocklyZoomoutClipPath837493">
|
||||
<rect width="32" height="32></rect>
|
||||
</clipPath>
|
||||
<image width="96" height="124" x="-64" y="-92"
|
||||
xlink:href="media/sprites.png"
|
||||
clip-path="url(#blocklyZoomoutClipPath837493)"></image>
|
||||
</g>
|
||||
*/
|
||||
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:
|
||||
<g class="blocklyZoom">
|
||||
<clipPath id="blocklyZoominClipPath837493">
|
||||
<rect width="32" height="32"></rect>
|
||||
</clipPath>
|
||||
<image width="96" height="124" x="-32" y="-92"
|
||||
xlink:href="media/sprites.png"
|
||||
clip-path="url(#blocklyZoominClipPath837493)"></image>
|
||||
</g>
|
||||
*/
|
||||
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:
|
||||
<g class="blocklyZoom">
|
||||
<clipPath id="blocklyZoomresetClipPath837493">
|
||||
<rect width="32" height="32"></rect>
|
||||
</clipPath>
|
||||
<image width="96" height="124" x="-32" y="-92"
|
||||
xlink:href="media/sprites.png"
|
||||
clip-path="url(#blocklyZoomresetClipPath837493)"></image>
|
||||
</g>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"@metadata": {
|
||||
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
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';
|
||||
Reference in New Issue
Block a user