diff --git a/plugins/workspace-minimap/src/focus_mode.ts b/plugins/workspace-minimap/src/focus_mode.ts new file mode 100644 index 0000000000..956ebcc0a2 --- /dev/null +++ b/plugins/workspace-minimap/src/focus_mode.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A class that highlights the user's + * viewport on the minimap. + * @author cesarades@google.com (Cesar Ades) + */ + +import * as Blockly from 'blockly/core'; + +const blockEvents = new Set([ + Blockly.Events.VIEWPORT_CHANGE, + Blockly.Events.BLOCK_CHANGE, + Blockly.Events.BLOCK_CREATE, + Blockly.Events.BLOCK_DELETE, + Blockly.Events.BLOCK_DRAG, + Blockly.Events.BLOCK_MOVE]); + +const borderRadius = 6; + +/** + * A class that highlights the user's viewport on the minimap. + */ +export class FocusRegion { + private onChangeWrapper: (e: Blockly.Events.Abstract) => void; + private svgGroup: SVGElement; + private rect: SVGElement; + private background: SVGElement; + private id: string; + private initialized = false; + + + /** + * Constructor for the focus region. + * @param primaryWorkspace The primary workspaceSvg. + * @param minimapWorkspace The minimap workspaceSvg. + */ + constructor(private primaryWorkspace: Blockly.WorkspaceSvg, + private minimapWorkspace: Blockly.WorkspaceSvg) { + this.id = String(Math.random()).substring(2); + } + + + /** + * Initializes focus region. + */ + init() { + // Make the svg group element. + this.svgGroup = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.G, {'class': 'focusRegion'}, null); + + // Make the mask under the svg group. + const mask = Blockly.utils.dom.createSvgElement( + new Blockly.utils.Svg('mask'), + {'id': 'focusRegionMask' + this.id}, + this.svgGroup); + + // Make the background under the svg group. + this.background = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.RECT, { + 'x': 0, + 'y': 0, + 'width': '100%', + 'height': '100%', + 'mask': 'url(#focusRegionMask' + this.id + ')', + }, this.svgGroup); + + // Make the white layer under the svg mask. + Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.RECT, { + 'x': 0, + 'y': 0, + 'width': '100%', + 'height': '100%', + 'fill': 'white', + }, mask); + + // Make the black layer under the mask. + this.rect = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.RECT, { + 'x': 0, + 'y': 0, + 'rx': borderRadius, + 'ry': borderRadius, + 'fill': 'black', + }, mask); + + // Theme. + this.background.setAttribute('fill', '#e6e6e6'); + + // Add the svg group to the minimap. + const parentSvg = this.minimapWorkspace.getParentSvg(); + if (parentSvg.firstChild) { + parentSvg.insertBefore(this.svgGroup, parentSvg.firstChild); + } else { + parentSvg.appendChild(this.svgGroup); + } + + window.addEventListener('resize', () => void this.update()); + this.onChangeWrapper = this.onChange.bind(this); + this.primaryWorkspace.addChangeListener(this.onChangeWrapper); + + this.update(); + this.initialized = true; + } + + + /** + * Disposes of the focus region. + * Unlinks from all DOM elements and remove all event listeners + * to prevent memory leaks. + */ + dispose() { + if (this.onChangeWrapper) { + this.primaryWorkspace.removeChangeListener(this.onChangeWrapper); + } + if (this.svgGroup) { + Blockly.utils.dom.removeNode(this.svgGroup); + } + this.svgGroup = null; + this.rect = null; + this.background = null; + this.initialized = false; + } + + + /** + * Handles events triggered on the primary workspace. + * @param event The event. + */ + private onChange(event: Blockly.Events.Abstract): void { + if (blockEvents.has(event.type)) { + this.update(); + } + } + + + /** + * Positions and sizes the highlight on the minimap + * based on the primary workspace. + */ + private update(): void { + // Get the metrics. + const primaryMetrics = this.primaryWorkspace.getMetricsManager(); + const minimapMetrics = this.minimapWorkspace.getMetricsManager(); + + const primaryView = primaryMetrics.getViewMetrics(); + const primaryContent = primaryMetrics.getContentMetrics(); + const minimapContent = minimapMetrics.getContentMetrics(); + const minimapSvg = minimapMetrics.getSvgMetrics(); + + // Get the workscape to pixel scale on the minimap. + const scale = minimapContent.width / + minimapMetrics.getContentMetrics(true).width; + + // Get the viewport size on a minimap scale. + const width = primaryView.width * scale; + const height = primaryView.height * scale; + + // Get the viewport position in relation to the content. + let left = (primaryView.left - primaryContent.left) * scale; + let top = (primaryView.top - primaryContent.top) * scale; + + // Account for the padding outside the content on the minimap. + left += (minimapSvg.width - minimapContent.width) / 2; + top += (minimapSvg.height - minimapContent.height) / 2; + + // Set the svg attributes. + this.rect.setAttribute('transform', `translate(${left},${top})`); + this.rect.setAttribute('width', width.toString()); + this.rect.setAttribute('height', height.toString()); + } + + /** + * Returns whether focus region is initialized or not. + * @returns True if focus region is initialized else false. + */ + isEnabled(): boolean { + return this.initialized; + } +} diff --git a/plugins/workspace-minimap/src/minimap.ts b/plugins/workspace-minimap/src/minimap.ts index 1eed61d350..b6c042ca46 100644 --- a/plugins/workspace-minimap/src/minimap.ts +++ b/plugins/workspace-minimap/src/minimap.ts @@ -12,6 +12,7 @@ */ import * as Blockly from 'blockly/core'; +import {FocusRegion} from './focus_mode'; // Events that should be send over to the minimap from the primary workspace const BlockEvents = new Set([ @@ -29,6 +30,7 @@ const BlockEvents = new Set([ export class Minimap { protected primaryWorkspace: Blockly.WorkspaceSvg; protected minimapWorkspace: Blockly.WorkspaceSvg; + protected focusRegion: FocusRegion; private onMouseMoveWrapper: Blockly.browserEvents.Data; /** * Constructor for a minimap. @@ -83,6 +85,11 @@ export class Minimap { this.minimapWorkspace.svgGroup_, 'mousedown', this, this.onClickDown); Blockly.browserEvents.bind( primaryInjectParentDiv, 'mouseup', this, this.onClickUp); + + // Initializes the focus region. + this.focusRegion = new FocusRegion( + this.primaryWorkspace, this.minimapWorkspace); + this.enableFocusRegion(); } @@ -183,4 +190,18 @@ export class Minimap { private onMouseMove(event: PointerEvent): void { this.primaryScroll(event); } + + /** + * Enables the focus region; A highlight of the viewport in the minimap. + */ + enableFocusRegion(): void { + this.focusRegion.init(); + } + + /** + * Disables the focus region. + */ + disableFocusRegion(): void { + this.focusRegion.dispose(); + } } diff --git a/plugins/workspace-minimap/src/positioned_minimap.ts b/plugins/workspace-minimap/src/positioned_minimap.ts index e8dcdb18cd..31bb5eb487 100644 --- a/plugins/workspace-minimap/src/positioned_minimap.ts +++ b/plugins/workspace-minimap/src/positioned_minimap.ts @@ -22,6 +22,7 @@ export class PositionedMinimap extends Minimap implements Blockly.IPositionable protected width: number; protected height: number; id: string; + /** * Constructor for a positionable minimap. * @param workspace The workspace to mirror. diff --git a/plugins/workspace-minimap/test/index.html b/plugins/workspace-minimap/test/index.html index adab38d841..4fdf93c7c7 100644 --- a/plugins/workspace-minimap/test/index.html +++ b/plugins/workspace-minimap/test/index.html @@ -12,10 +12,7 @@ -
-
-
-
+
diff --git a/plugins/workspace-minimap/test/index.ts b/plugins/workspace-minimap/test/index.ts index 56d49b5eb4..ea64c990f7 100644 --- a/plugins/workspace-minimap/test/index.ts +++ b/plugins/workspace-minimap/test/index.ts @@ -13,18 +13,11 @@ import {toolboxCategories} from '@blockly/dev-tools'; import {Minimap, PositionedMinimap} from '../src/index'; // Creates the primary workspace and adds the minimap. -const positionedWorkspace = Blockly.inject('positionedRoot', +const positionedWorkspace = Blockly.inject('root', {toolbox: toolboxCategories}); const positionedMinimap = new PositionedMinimap(positionedWorkspace); positionedMinimap.init(); -// Creates the primary workspace and adds the minimap. -const unpositionedWorkspace = Blockly.inject('unpositionedRoot', - {toolbox: toolboxCategories}); -const unpositionedMinimap = new Minimap(unpositionedWorkspace); -unpositionedMinimap.init(); - - const seedTest = (workspace: Blockly.WorkspaceSvg): void => { // Creates 100 if blocks for (let i = 0; i < 100; i++) {