diff --git a/.eslintrc.js b/.eslintrc.js index 363310c83..f2c969492 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,6 @@ const rules = { 'no-unused-vars': [ - 'warn', + 'error', { 'args': 'after-used', // Ignore vars starting with an underscore. diff --git a/core/blockly.ts b/core/blockly.ts index 5d0d6bbb0..0d817a058 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -23,7 +23,7 @@ import {BlockSvg} from './block_svg.js'; import {BlocklyOptions} from './blockly_options.js'; import {Blocks} from './blocks.js'; import * as browserEvents from './browser_events.js'; -import {Bubble} from './bubble.js'; +import {Bubble} from './bubble_old.js'; import {BubbleDragger} from './bubble_dragger.js'; import * as bumpObjects from './bump_objects.js'; import * as clipboard from './clipboard.js'; diff --git a/core/bubble_dragger.ts b/core/bubble_dragger.ts index 75a4c8415..31abf6c7b 100644 --- a/core/bubble_dragger.ts +++ b/core/bubble_dragger.ts @@ -58,7 +58,9 @@ export class BubbleDragger { } this.workspace.setResizesEnabled(false); - this.bubble.setAutoLayout(false); + if ((this.bubble as AnyDuringMigration).setAutoLayout) { + (this.bubble as AnyDuringMigration).setAutoLayout(false); + } this.bubble.setDragging && this.bubble.setDragging(true); } diff --git a/core/bubble.ts b/core/bubble_old.ts similarity index 100% rename from core/bubble.ts rename to core/bubble_old.ts diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts new file mode 100644 index 000000000..b2e57e9f6 --- /dev/null +++ b/core/bubbles/bubble.ts @@ -0,0 +1,588 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import {IBubble} from '../interfaces/i_bubble.js'; +import {ContainerRegion} from '../metrics_manager.js'; +import {Scrollbar} from '../scrollbar.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as dom from '../utils/dom.js'; +import * as math from '../utils/math.js'; +import {Rect} from '../utils/rect.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +export abstract class Bubble implements IBubble { + /** The width of the border around the bubble. */ + static BORDER_WIDTH = 6; + + /** The minimum size the bubble can have. */ + static MIN_SIZE = this.BORDER_WIDTH * 2; + + /** + * The thickness of the base of the tail in relation to the size of the + * bubble. Higher numbers result in thinner tails. + */ + static TAIL_THICKNESS = 1; + + /** The number of degrees that the tail bends counter-clockwise. */ + static TAIL_ANGLE = 20; + + /** + * The sharpness of the tail's bend. Higher numbers result in smoother + * tails. + */ + static TAIL_BEND = 4; + + /** Distance between arrow point and anchor point. */ + static ANCHOR_RADIUS = 8; + + /** The SVG group containing all parts of the bubble. */ + private svgRoot: SVGGElement; + + /** The SVG path for the arrow from the anchor to the bubble. */ + private tail: SVGPathElement; + + /** The SVG background rect for the main body of the bubble. */ + private background: SVGRectElement; + + /** The SVG group containing the contents of the bubble. */ + protected contentContainer: SVGGElement; + + /** + * The size of the bubble (including background and contents but not tail). + */ + private size = new Size(0, 0); + + /** The colour of the background of the bubble. */ + private colour = '#ffffff'; + + /** True if the bubble has been disposed, false otherwise. */ + public disposed = false; + + /** The position of the top of the bubble relative to its anchor. */ + private relativeTop = 0; + + /** The position of the left of the bubble realtive to its anchor. */ + private relativeLeft = 0; + + /** + * @param workspace The workspace this bubble belongs to. + * @param anchor The anchor location of the thing this bubble is attached to. + * The tail of the bubble will point to this location. + * @param ownerRect An optional rect we don't want the bubble to overlap with + * when automatically positioning. + */ + constructor( + protected readonly workspace: WorkspaceSvg, + protected anchor: Coordinate, + protected ownerRect?: Rect + ) { + this.svgRoot = dom.createSvgElement(Svg.G, {}, workspace.getBubbleCanvas()); + const embossGroup = dom.createSvgElement( + Svg.G, + { + 'filter': `url(#${ + this.workspace.getRenderer().getConstants().embossFilterId + })`, + }, + this.svgRoot + ); + this.tail = dom.createSvgElement(Svg.PATH, {}, embossGroup); + this.background = dom.createSvgElement( + Svg.RECT, + { + 'class': 'blocklyDraggable', + 'x': 0, + 'y': 0, + 'rx': Bubble.BORDER_WIDTH, + 'ry': Bubble.BORDER_WIDTH, + }, + embossGroup + ); + this.contentContainer = dom.createSvgElement(Svg.G, {}, this.svgRoot); + + browserEvents.conditionalBind( + this.background, + 'pointerDown', + this, + this.onMouseDown + ); + } + + /** Dispose of this bubble. */ + dispose() { + dom.removeNode(this.svgRoot); + this.disposed = true; + } + + /** + * Set the location the tail of this bubble points to. + * + * @param anchor The location the tail of this bubble points to. + * @param relayout If true, reposition the bubble from scratch so that it is + * optimally visible. If false, reposition it so it maintains the same + * position relative to the anchor. + */ + setAnchorLocation(anchor: Coordinate, relayout = false) { + this.anchor = anchor; + if (relayout) { + this.positionByRect(this.ownerRect); + } else { + this.positionRelativeToAnchor(); + } + this.renderTail(); + } + + /** Sets the position of this bubble relative to its anchor. */ + setPositionRelativeToAnchor(left: number, top: number) { + this.relativeLeft = left; + this.relativeTop = top; + this.positionRelativeToAnchor(); + this.renderTail(); + } + + /** @return the size of this bubble. */ + protected getSize() { + return this.size; + } + + /** + * Sets the size of this bubble, including the border. + * + * @param size Sets the size of this bubble, including the border. + * @param relayout If true, reposition the bubble from scratch so that it is + * optimally visible. If false, reposition it so it maintains the same + * position relative to the anchor. + */ + protected setSize(size: Size, relayout = false) { + size.width = Math.max(size.width, Bubble.MIN_SIZE); + size.height = Math.max(size.height, Bubble.MIN_SIZE); + this.size = size; + + this.background.setAttribute('width', `${size.width}`); + this.background.setAttribute('height', `${size.height}`); + + if (relayout) { + this.positionByRect(this.ownerRect); + } else { + this.positionRelativeToAnchor(); + } + this.renderTail(); + } + + /** Returns the colour of the background and tail of this bubble. */ + protected getColour(): string { + return this.colour; + } + + /** Sets the colour of the background and tail of this bubble. */ + protected setColour(colour: string) { + this.colour = colour; + this.tail.setAttribute('fill', colour); + this.background.setAttribute('fill', colour); + } + + /** Passes the pointer event off to the gesture system. */ + private onMouseDown(e: PointerEvent) { + this.workspace.getGesture(e)?.handleBubbleStart(e, this); + } + + /** Positions the bubble relative to its anchor. Does not render its tail. */ + protected positionRelativeToAnchor() { + let left = this.anchor.x; + if (this.workspace.RTL) { + left -= this.relativeLeft + this.size.width; + } else { + left += this.relativeLeft; + } + const top = this.relativeTop + this.anchor.y; + this.moveTo(left, top); + } + + /** + * Moves the bubble to the given coordinates. + * + * @internal + */ + moveTo(x: number, y: number) { + this.svgRoot.setAttribute('transform', `translate(${x}, ${y})`); + } + + /** + * Positions the bubble "optimally" so that the most of it is visible and + * it does not overlap the rect (if provided). + */ + protected positionByRect(rect = new Rect(0, 0, 0, 0)) { + const viewMetrics = this.workspace.getMetricsManager().getViewMetrics(true); + + const optimalLeft = this.getOptimalRelativeLeft(viewMetrics); + const optimalTop = this.getOptimalRelativeTop(viewMetrics); + + const topPosition = { + x: optimalLeft, + y: (-this.size.height - + this.workspace.getRenderer().getConstants().MIN_BLOCK_HEIGHT) as number, + }; + const startPosition = {x: -this.size.width - 30, y: optimalTop}; + const endPosition = {x: rect.getWidth(), y: optimalTop}; + const bottomPosition = {x: optimalLeft, y: rect.getHeight()}; + + const closerPosition = + rect.getWidth() < rect.getHeight() ? endPosition : bottomPosition; + const fartherPosition = + rect.getWidth() < rect.getHeight() ? bottomPosition : endPosition; + + const topPositionOverlap = this.getOverlap(topPosition, viewMetrics); + const startPositionOverlap = this.getOverlap(startPosition, viewMetrics); + const closerPositionOverlap = this.getOverlap(closerPosition, viewMetrics); + const fartherPositionOverlap = this.getOverlap( + fartherPosition, + viewMetrics + ); + + // Set the position to whichever position shows the most of the bubble, + // with tiebreaks going in the order: top > start > close > far. + const mostOverlap = Math.max( + topPositionOverlap, + startPositionOverlap, + closerPositionOverlap, + fartherPositionOverlap + ); + if (topPositionOverlap === mostOverlap) { + this.relativeLeft = topPosition.x; + this.relativeTop = topPosition.y; + this.positionRelativeToAnchor(); + return; + } + if (startPositionOverlap === mostOverlap) { + this.relativeLeft = startPosition.x; + this.relativeTop = startPosition.y; + this.positionRelativeToAnchor(); + return; + } + if (closerPositionOverlap === mostOverlap) { + this.relativeLeft = closerPosition.x; + this.relativeTop = closerPosition.y; + this.positionRelativeToAnchor(); + return; + } + // TODO: I believe relativeLeft_ should actually be called relativeStart_ + // and then the math should be fixed to reflect this. (hopefully it'll + // make it look simpler) + this.relativeLeft = fartherPosition.x; + this.relativeTop = fartherPosition.y; + this.positionRelativeToAnchor(); + } + + /** + * Calculate the what percentage of the bubble overlaps with the visible + * workspace (what percentage of the bubble is visible). + * + * @param relativeMin The position of the top-left corner of the bubble + * relative to the anchor point. + * @param viewMetrics The view metrics of the workspace the bubble will appear + * in. + * @returns The percentage of the bubble that is visible. + */ + private getOverlap( + relativeMin: {x: number; y: number}, + viewMetrics: ContainerRegion + ): number { + // The position of the top-left corner of the bubble in workspace units. + const bubbleMin = { + x: this.workspace.RTL + ? this.anchor.x - relativeMin.x - this.size.width + : relativeMin.x + this.anchor.x, + y: relativeMin.y + this.anchor.y, + }; + // The position of the bottom-right corner of the bubble in workspace units. + const bubbleMax = { + x: bubbleMin.x + this.size.width, + y: bubbleMin.y + this.size.height, + }; + + // We could adjust these values to account for the scrollbars, but the + // bubbles should have been adjusted to not collide with them anyway, so + // giving the workspace a slightly larger "bounding box" shouldn't affect + // the calculation. + + // The position of the top-left corner of the workspace. + const workspaceMin = {x: viewMetrics.left, y: viewMetrics.top}; + // The position of the bottom-right corner of the workspace. + const workspaceMax = { + x: viewMetrics.left + viewMetrics.width, + y: viewMetrics.top + viewMetrics.height, + }; + + const overlapWidth = + Math.min(bubbleMax.x, workspaceMax.x) - + Math.max(bubbleMin.x, workspaceMin.x); + const overlapHeight = + Math.min(bubbleMax.y, workspaceMax.y) - + Math.max(bubbleMin.y, workspaceMin.y); + return Math.max( + 0, + Math.min( + 1, + (overlapWidth * overlapHeight) / (this.size.width * this.size.height) + ) + ); + } + + /** + * Calculate what the optimal horizontal position of the top-left corner of + * the bubble is (relative to the anchor point) so that the most area of the + * bubble is shown. + * + * @param viewMetrics The view metrics of the workspace the bubble will appear + * in. + * @returns The optimal horizontal position of the top-left corner of the + * bubble. + */ + private getOptimalRelativeLeft(viewMetrics: ContainerRegion): number { + // By default, show the bubble just a bit to the left of the anchor. + let relativeLeft = -this.size.width / 4; + + // No amount of sliding left or right will give us better overlap. + if (this.size.width > viewMetrics.width) return relativeLeft; + + const workspaceRect = this.getWorkspaceViewRect(viewMetrics); + + if (this.workspace.RTL) { + // Bubble coordinates are flipped in RTL. + const bubbleRight = this.anchor.x - relativeLeft; + const bubbleLeft = bubbleRight - this.size.width; + + if (bubbleLeft < workspaceRect.left) { + // Slide the bubble right until it is onscreen. + relativeLeft = -(workspaceRect.left - this.anchor.x + this.size.width); + } else if (bubbleRight > workspaceRect.right) { + // Slide the bubble left until it is onscreen. + relativeLeft = -(workspaceRect.right - this.anchor.x); + } + } else { + const bubbleLeft = relativeLeft + this.anchor.x; + const bubbleRight = bubbleLeft + this.size.width; + + if (bubbleLeft < workspaceRect.left) { + // Slide the bubble right until it is onscreen. + relativeLeft = workspaceRect.left - this.anchor.x; + } else if (bubbleRight > workspaceRect.right) { + // Slide the bubble left until it is onscreen. + relativeLeft = workspaceRect.right - this.anchor.x - this.size.width; + } + } + + return relativeLeft; + } + + /** + * Calculate what the optimal vertical position of the top-left corner of + * the bubble is (relative to the anchor point) so that the most area of the + * bubble is shown. + * + * @param viewMetrics The view metrics of the workspace the bubble will appear + * in. + * @returns The optimal vertical position of the top-left corner of the + * bubble. + */ + private getOptimalRelativeTop(viewMetrics: ContainerRegion): number { + // By default, show the bubble just a bit higher than the anchor. + let relativeTop = -this.size.height / 4; + + // No amount of sliding up or down will give us better overlap. + if (this.size.height > viewMetrics.height) return relativeTop; + + const top = this.anchor.y + relativeTop; + const bottom = top + this.size.height; + const workspaceRect = this.getWorkspaceViewRect(viewMetrics); + + if (top < workspaceRect.top) { + // Slide the bubble down until it is onscreen. + relativeTop = workspaceRect.top - this.anchor.y; + } else if (bottom > workspaceRect.bottom) { + // Slide the bubble up until it is onscreen. + relativeTop = workspaceRect.bottom - this.anchor.y - this.size.height; + } + + return relativeTop; + } + + /** + * @return a rect defining the bounds of the workspace's view in workspace + * coordinates. + */ + private getWorkspaceViewRect(viewMetrics: ContainerRegion): Rect { + const top = viewMetrics.top; + let bottom = viewMetrics.top + viewMetrics.height; + let left = viewMetrics.left; + let right = viewMetrics.left + viewMetrics.width; + + bottom -= this.getScrollbarThickness(); + if (this.workspace.RTL) { + left -= this.getScrollbarThickness(); + } else { + right -= this.getScrollbarThickness(); + } + + return new Rect(top, bottom, left, right); + } + + /** @return the scrollbar thickness in workspace units. */ + private getScrollbarThickness() { + return Scrollbar.scrollbarThickness / this.workspace.scale; + } + + /** Draws the tail of the bubble. */ + private renderTail() { + const steps = []; + // Find the relative coordinates of the center of the bubble. + const relBubbleX = this.size.width / 2; + const relBubbleY = this.size.height / 2; + // Find the relative coordinates of the center of the anchor. + let relAnchorX = -this.relativeLeft; + let relAnchorY = -this.relativeTop; + if (relBubbleX === relAnchorX && relBubbleY === relAnchorY) { + // Null case. Bubble is directly on top of the anchor. + // Short circuit this rather than wade through divide by zeros. + steps.push('M ' + relBubbleX + ',' + relBubbleY); + } else { + // Compute the angle of the tail's line. + const rise = relAnchorY - relBubbleY; + let run = relAnchorX - relBubbleX; + if (this.workspace.RTL) { + run *= -1; + } + const hypotenuse = Math.sqrt(rise * rise + run * run); + let angle = Math.acos(run / hypotenuse); + if (rise < 0) { + angle = 2 * Math.PI - angle; + } + // Compute a line perpendicular to the tail. + let rightAngle = angle + Math.PI / 2; + if (rightAngle > Math.PI * 2) { + rightAngle -= Math.PI * 2; + } + const rightRise = Math.sin(rightAngle); + const rightRun = Math.cos(rightAngle); + + // Calculate the thickness of the base of the tail. + let thickness = + (this.size.width + this.size.height) / Bubble.TAIL_THICKNESS; + thickness = Math.min(thickness, this.size.width, this.size.height) / 4; + + // Back the tip of the tail off of the anchor. + const backoffRatio = 1 - Bubble.ANCHOR_RADIUS / hypotenuse; + relAnchorX = relBubbleX + backoffRatio * run; + relAnchorY = relBubbleY + backoffRatio * rise; + + // Coordinates for the base of the tail. + const baseX1 = relBubbleX + thickness * rightRun; + const baseY1 = relBubbleY + thickness * rightRise; + const baseX2 = relBubbleX - thickness * rightRun; + const baseY2 = relBubbleY - thickness * rightRise; + + // Distortion to curve the tail. + const radians = math.toRadians( + this.workspace.RTL ? -Bubble.TAIL_ANGLE : Bubble.TAIL_ANGLE + ); + let swirlAngle = angle + radians; + if (swirlAngle > Math.PI * 2) { + swirlAngle -= Math.PI * 2; + } + const swirlRise = (Math.sin(swirlAngle) * hypotenuse) / Bubble.TAIL_BEND; + const swirlRun = (Math.cos(swirlAngle) * hypotenuse) / Bubble.TAIL_BEND; + + steps.push('M' + baseX1 + ',' + baseY1); + steps.push( + 'C' + + (baseX1 + swirlRun) + + ',' + + (baseY1 + swirlRise) + + ' ' + + relAnchorX + + ',' + + relAnchorY + + ' ' + + relAnchorX + + ',' + + relAnchorY + ); + steps.push( + 'C' + + relAnchorX + + ',' + + relAnchorY + + ' ' + + (baseX2 + swirlRun) + + ',' + + (baseY2 + swirlRise) + + ' ' + + baseX2 + + ',' + + baseY2 + ); + } + steps.push('z'); + this.tail?.setAttribute('d', steps.join(' ')); + } + + /** @internal */ + getRelativeToSurfaceXY(): Coordinate { + return new Coordinate( + this.workspace.RTL + ? -this.relativeLeft + this.anchor.x - this.size.width + : this.anchor.x + this.relativeLeft, + this.anchor.y + this.relativeTop + ); + } + + /** @internal */ + getSvgRoot(): SVGElement { + return this.svgRoot; + } + + /** + * Move this bubble during a drag, taking into account whether or not there is + * a drag surface. + * + * @param dragSurface The surface that carries rendered items during a drag, + * or null if no drag surface is in use. + * @param newLoc The location to translate to, in workspace coordinates. + * @internal + */ + moveDuringDrag(newLoc: Coordinate) { + this.moveTo(newLoc.x, newLoc.y); + if (this.workspace.RTL) { + this.relativeLeft = this.anchor.x - newLoc.x - this.size.width; + } else { + this.relativeLeft = newLoc.x - this.anchor.x; + } + this.relativeTop = newLoc.y - this.anchor.y; + this.renderTail(); + } + + setDragging(_start: boolean) { + // NOOP in base class. + } + + /** @internal */ + setDeleteStyle(_enable: boolean) { + // NOOP in base class. + } + + /** @internal */ + isDeletable(): boolean { + return false; + } + + /** @internal */ + showContextMenu(_e: Event) { + // NOOP in base class. + } +} diff --git a/core/comment.ts b/core/comment.ts index 3d2cb26e3..4cb22690f 100644 --- a/core/comment.ts +++ b/core/comment.ts @@ -20,7 +20,7 @@ import './events/events_bubble_open.js'; import type {CommentModel} from './block.js'; import type {BlockSvg} from './block_svg.js'; import * as browserEvents from './browser_events.js'; -import {Bubble} from './bubble.js'; +import {Bubble} from './bubble_old.js'; import * as Css from './css.js'; import * as eventUtils from './events/utils.js'; import {Icon} from './icon_old.js'; diff --git a/core/icon_old.ts b/core/icon_old.ts index cc07bc575..f0f6ac97d 100644 --- a/core/icon_old.ts +++ b/core/icon_old.ts @@ -14,7 +14,7 @@ goog.declareModuleId('Blockly.Icon'); import type {BlockSvg} from './block_svg.js'; import * as browserEvents from './browser_events.js'; -import type {Bubble} from './bubble.js'; +import type {Bubble} from './bubble_old.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import {Size} from './utils/size.js'; diff --git a/core/interfaces/i_bubble.ts b/core/interfaces/i_bubble.ts index ba3d1ddf1..db0d4d86c 100644 --- a/core/interfaces/i_bubble.ts +++ b/core/interfaces/i_bubble.ts @@ -30,15 +30,6 @@ export interface IBubble extends IDraggable, IContextMenu { */ getSvgRoot(): SVGElement; - /** - * Set whether auto-layout of this bubble is enabled. The first time a bubble - * is shown it positions itself to not cover any blocks. Once a user has - * dragged it to reposition, it renders where the user put it. - * - * @param enable True if auto-layout should be enabled, false otherwise. - */ - setAutoLayout(enable: boolean): void; - /** * Sets whether or not this bubble is being dragged. * diff --git a/core/mutator.ts b/core/mutator.ts index 8e58e8692..3896a30fa 100644 --- a/core/mutator.ts +++ b/core/mutator.ts @@ -19,7 +19,7 @@ import './events/events_bubble_open.js'; import type {Block} from './block.js'; import type {BlockSvg} from './block_svg.js'; import type {BlocklyOptions} from './blockly_options.js'; -import {Bubble} from './bubble.js'; +import {Bubble} from './bubble_old.js'; import {config} from './config.js'; import type {Connection} from './connection.js'; import type {Abstract} from './events/events_abstract.js'; diff --git a/core/utils/rect.ts b/core/utils/rect.ts index 7baabd166..32d8bc692 100644 --- a/core/utils/rect.ts +++ b/core/utils/rect.ts @@ -31,6 +31,14 @@ export class Rect { public right: number ) {} + getHeight(): number { + return this.bottom - this.top; + } + + getWidth(): number { + return this.right - this.left; + } + /** * Tests whether this rectangle contains a x/y coordinate. * diff --git a/core/warning.ts b/core/warning.ts index 9e3e96e9d..439e3f81e 100644 --- a/core/warning.ts +++ b/core/warning.ts @@ -16,7 +16,7 @@ goog.declareModuleId('Blockly.Warning'); import './events/events_bubble_open.js'; import type {BlockSvg} from './block_svg.js'; -import {Bubble} from './bubble.js'; +import {Bubble} from './bubble_old.js'; import * as eventUtils from './events/utils.js'; import {Icon} from './icon_old.js'; import type {Coordinate} from './utils/coordinate.js';