mirror of
https://github.com/google/blockly.git
synced 2026-05-13 07:30:10 +02:00
b0475b0c68
* fix: Remove spurious blank lines Remove extraneous blank lines introduced by deletion of 'use strict'; pragmas. Also fix the location of the goog.declareModuleId call in core/utils/array.ts. * fix: Add missing double-blank-line before body of modules Our convention is to have two blank lines between the imports (or module ID, if there are no imports) and the beginning of the body of the module. Enforce this. * fix: one addition format error for PR #6243
906 lines
30 KiB
TypeScript
906 lines
30 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2012 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* @fileoverview Object representing a UI bubble.
|
|
*/
|
|
|
|
/**
|
|
* Object representing a UI bubble.
|
|
* @class
|
|
*/
|
|
import * as goog from '../closure/goog/goog.js';
|
|
goog.declareModuleId('Blockly.Bubble');
|
|
|
|
/* eslint-disable-next-line no-unused-vars */
|
|
// Unused import preserved for side-effects. Remove if unneeded.
|
|
import './metrics_manager';
|
|
// Unused import preserved for side-effects. Remove if unneeded.
|
|
import './workspace';
|
|
|
|
/* eslint-disable-next-line no-unused-vars */
|
|
import {BlockDragSurfaceSvg} from './block_drag_surface.js';
|
|
/* eslint-disable-next-line no-unused-vars */
|
|
import {BlockSvg} from './block_svg.js';
|
|
import * as browserEvents from './browser_events.js';
|
|
/* eslint-disable-next-line no-unused-vars */
|
|
import {IBubble} from './interfaces/i_bubble.js';
|
|
import {ContainerRegion} from './metrics_manager.js';
|
|
import {Scrollbar} from './scrollbar.js';
|
|
import * as Touch from './touch.js';
|
|
import {Coordinate} from './utils/coordinate.js';
|
|
import * as dom from './utils/dom.js';
|
|
import * as math from './utils/math.js';
|
|
import {Size} from './utils/size.js';
|
|
import {Svg} from './utils/svg.js';
|
|
import * as userAgent from './utils/useragent.js';
|
|
/* eslint-disable-next-line no-unused-vars */
|
|
import {WorkspaceSvg} from './workspace_svg.js';
|
|
|
|
|
|
/**
|
|
* Class for UI bubble.
|
|
* @alias Blockly.Bubble
|
|
*/
|
|
export class Bubble implements IBubble {
|
|
/** Width of the border around the bubble. */
|
|
static BORDER_WIDTH = 6;
|
|
|
|
/**
|
|
* Determines the thickness of the base of the arrow in relation to the size
|
|
* of the bubble. Higher numbers result in thinner arrows.
|
|
*/
|
|
static ARROW_THICKNESS = 5;
|
|
|
|
/** The number of degrees that the arrow bends counter-clockwise. */
|
|
static ARROW_ANGLE = 20;
|
|
|
|
/**
|
|
* The sharpness of the arrow's bend. Higher numbers result in smoother
|
|
* arrows.
|
|
*/
|
|
static ARROW_BEND = 4;
|
|
|
|
/** Distance between arrow point and anchor point. */
|
|
static ANCHOR_RADIUS = 8;
|
|
|
|
/** Mouse up event data. */
|
|
private static onMouseUpWrapper_: browserEvents.Data|null = null;
|
|
|
|
/** Mouse move event data. */
|
|
private static onMouseMoveWrapper_: browserEvents.Data|null = null;
|
|
workspace_: AnyDuringMigration;
|
|
content_: AnyDuringMigration;
|
|
shape_: AnyDuringMigration;
|
|
|
|
/** Flag to stop incremental rendering during construction. */
|
|
private readonly rendered_: boolean;
|
|
|
|
/** The SVG group containing all parts of the bubble. */
|
|
// AnyDuringMigration because: Type 'null' is not assignable to type
|
|
// 'SVGGElement'.
|
|
private bubbleGroup_: SVGGElement = null as AnyDuringMigration;
|
|
|
|
/**
|
|
* The SVG path for the arrow from the bubble to the icon on the block.
|
|
*/
|
|
// AnyDuringMigration because: Type 'null' is not assignable to type
|
|
// 'SVGPathElement'.
|
|
private bubbleArrow_: SVGPathElement = null as AnyDuringMigration;
|
|
|
|
/** The SVG rect for the main body of the bubble. */
|
|
// AnyDuringMigration because: Type 'null' is not assignable to type
|
|
// 'SVGRectElement'.
|
|
private bubbleBack_: SVGRectElement = null as AnyDuringMigration;
|
|
|
|
/** The SVG group for the resize hash marks on some bubbles. */
|
|
// AnyDuringMigration because: Type 'null' is not assignable to type
|
|
// 'SVGGElement'.
|
|
private resizeGroup_: SVGGElement = null as AnyDuringMigration;
|
|
|
|
/** Absolute coordinate of anchor point, in workspace coordinates. */
|
|
// AnyDuringMigration because: Type 'null' is not assignable to type
|
|
// 'Coordinate'.
|
|
private anchorXY_: Coordinate = null as AnyDuringMigration;
|
|
|
|
/**
|
|
* Relative X coordinate of bubble with respect to the anchor's centre,
|
|
* in workspace units.
|
|
* In RTL mode the initial value is negated.
|
|
*/
|
|
private relativeLeft_ = 0;
|
|
|
|
/**
|
|
* Relative Y coordinate of bubble with respect to the anchor's centre, in
|
|
* workspace units.
|
|
*/
|
|
private relativeTop_ = 0;
|
|
|
|
/** Width of bubble, in workspace units. */
|
|
private width_ = 0;
|
|
|
|
/** Height of bubble, in workspace units. */
|
|
private height_ = 0;
|
|
|
|
/** Automatically position and reposition the bubble. */
|
|
private autoLayout_ = true;
|
|
|
|
/** Method to call on resize of bubble. */
|
|
private resizeCallback_: (() => AnyDuringMigration)|null = null;
|
|
|
|
/** Method to call on move of bubble. */
|
|
private moveCallback_: (() => AnyDuringMigration)|null = null;
|
|
|
|
/** Mouse down on bubbleBack_ event data. */
|
|
private onMouseDownBubbleWrapper_: browserEvents.Data|null = null;
|
|
|
|
/** Mouse down on resizeGroup_ event data. */
|
|
private onMouseDownResizeWrapper_: browserEvents.Data|null = null;
|
|
|
|
/**
|
|
* Describes whether this bubble has been disposed of (nodes and event
|
|
* listeners removed from the page) or not.
|
|
*/
|
|
disposed = false;
|
|
arrow_radians_: AnyDuringMigration;
|
|
|
|
/**
|
|
* @param workspace The workspace on which to draw the bubble.
|
|
* @param content SVG content for the bubble.
|
|
* @param shape SVG element to avoid eclipsing.
|
|
* @param anchorXY Absolute position of bubble's anchor point.
|
|
* @param bubbleWidth Width of bubble, or null if not resizable.
|
|
* @param bubbleHeight Height of bubble, or null if not resizable.
|
|
* @struct
|
|
*/
|
|
constructor(
|
|
workspace: WorkspaceSvg, content: SVGElement, shape: SVGElement,
|
|
anchorXY: Coordinate, bubbleWidth: number|null,
|
|
bubbleHeight: number|null) {
|
|
this.rendered_ = false;
|
|
this.workspace_ = workspace;
|
|
this.content_ = content;
|
|
this.shape_ = shape;
|
|
|
|
let angle = Bubble.ARROW_ANGLE;
|
|
if (this.workspace_.RTL) {
|
|
angle = -angle;
|
|
}
|
|
this.arrow_radians_ = math.toRadians(angle);
|
|
|
|
const canvas = workspace.getBubbleCanvas();
|
|
canvas.appendChild(
|
|
this.createDom_(content, !!(bubbleWidth && bubbleHeight)));
|
|
|
|
this.setAnchorLocation(anchorXY);
|
|
if (!bubbleWidth || !bubbleHeight) {
|
|
const bBox = (this.content_ as SVGGraphicsElement).getBBox();
|
|
bubbleWidth = bBox.width + 2 * Bubble.BORDER_WIDTH;
|
|
bubbleHeight = bBox.height + 2 * Bubble.BORDER_WIDTH;
|
|
}
|
|
this.setBubbleSize(bubbleWidth, bubbleHeight);
|
|
|
|
// Render the bubble.
|
|
this.positionBubble_();
|
|
this.renderArrow_();
|
|
this.rendered_ = true;
|
|
}
|
|
|
|
/**
|
|
* Create the bubble's DOM.
|
|
* @param content SVG content for the bubble.
|
|
* @param hasResize Add diagonal resize gripper if true.
|
|
* @return The bubble's SVG group.
|
|
*/
|
|
private createDom_(content: Element, hasResize: boolean): SVGElement {
|
|
/* Create the bubble. Here's the markup that will be generated:
|
|
<g>
|
|
<g filter="url(#blocklyEmbossFilter837493)">
|
|
<path d="... Z" />
|
|
<rect class="blocklyDraggable" rx="8" ry="8" width="180"
|
|
height="180"/>
|
|
</g>
|
|
<g transform="translate(165, 165)" class="blocklyResizeSE">
|
|
<polygon points="0,15 15,15 15,0"/>
|
|
<line class="blocklyResizeLine" x1="5" y1="14" x2="14" y2="5"/>
|
|
<line class="blocklyResizeLine" x1="10" y1="14" x2="14" y2="10"/>
|
|
</g>
|
|
[...content goes here...]
|
|
</g>
|
|
*/
|
|
this.bubbleGroup_ = dom.createSvgElement(Svg.G, {});
|
|
let filter: {filter?: string} = {
|
|
'filter': 'url(#' +
|
|
this.workspace_.getRenderer().getConstants().embossFilterId + ')',
|
|
};
|
|
if (userAgent.JavaFx) {
|
|
// Multiple reports that JavaFX can't handle filters.
|
|
// https://github.com/google/blockly/issues/99
|
|
filter = {};
|
|
}
|
|
const bubbleEmboss = dom.createSvgElement(Svg.G, filter, this.bubbleGroup_);
|
|
this.bubbleArrow_ = dom.createSvgElement(Svg.PATH, {}, bubbleEmboss);
|
|
this.bubbleBack_ = dom.createSvgElement(
|
|
Svg.RECT, {
|
|
'class': 'blocklyDraggable',
|
|
'x': 0,
|
|
'y': 0,
|
|
'rx': Bubble.BORDER_WIDTH,
|
|
'ry': Bubble.BORDER_WIDTH,
|
|
},
|
|
bubbleEmboss);
|
|
if (hasResize) {
|
|
this.resizeGroup_ = dom.createSvgElement(
|
|
Svg.G, {
|
|
'class': this.workspace_.RTL ? 'blocklyResizeSW' :
|
|
'blocklyResizeSE',
|
|
},
|
|
this.bubbleGroup_);
|
|
const resizeSize = 2 * Bubble.BORDER_WIDTH;
|
|
dom.createSvgElement(
|
|
Svg.POLYGON,
|
|
{'points': '0,x x,x x,0'.replace(/x/g, resizeSize.toString())},
|
|
this.resizeGroup_);
|
|
dom.createSvgElement(
|
|
Svg.LINE, {
|
|
'class': 'blocklyResizeLine',
|
|
'x1': resizeSize / 3,
|
|
'y1': resizeSize - 1,
|
|
'x2': resizeSize - 1,
|
|
'y2': resizeSize / 3,
|
|
},
|
|
this.resizeGroup_);
|
|
dom.createSvgElement(
|
|
Svg.LINE, {
|
|
'class': 'blocklyResizeLine',
|
|
'x1': resizeSize * 2 / 3,
|
|
'y1': resizeSize - 1,
|
|
'x2': resizeSize - 1,
|
|
'y2': resizeSize * 2 / 3,
|
|
},
|
|
this.resizeGroup_);
|
|
} else {
|
|
// AnyDuringMigration because: Type 'null' is not assignable to type
|
|
// 'SVGGElement'.
|
|
this.resizeGroup_ = null as AnyDuringMigration;
|
|
}
|
|
|
|
if (!this.workspace_.options.readOnly) {
|
|
this.onMouseDownBubbleWrapper_ = browserEvents.conditionalBind(
|
|
this.bubbleBack_, 'mousedown', this, this.bubbleMouseDown_);
|
|
if (this.resizeGroup_) {
|
|
this.onMouseDownResizeWrapper_ = browserEvents.conditionalBind(
|
|
this.resizeGroup_, 'mousedown', this, this.resizeMouseDown_);
|
|
}
|
|
}
|
|
this.bubbleGroup_.appendChild(content);
|
|
return this.bubbleGroup_;
|
|
}
|
|
|
|
/**
|
|
* Return the root node of the bubble's SVG group.
|
|
* @return The root SVG node of the bubble's group.
|
|
*/
|
|
getSvgRoot(): SVGElement {
|
|
return this.bubbleGroup_ as SVGElement;
|
|
}
|
|
|
|
/**
|
|
* Expose the block's ID on the bubble's top-level SVG group.
|
|
* @param id ID of block.
|
|
*/
|
|
setSvgId(id: string) {
|
|
this.bubbleGroup_.setAttribute('data-block-id', id);
|
|
}
|
|
|
|
/**
|
|
* Handle a mouse-down on bubble's border.
|
|
* @param e Mouse down event.
|
|
*/
|
|
private bubbleMouseDown_(e: Event) {
|
|
const gesture = this.workspace_.getGesture(e);
|
|
if (gesture) {
|
|
gesture.handleBubbleStart(e, this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show the context menu for this bubble.
|
|
* @param _e Mouse event.
|
|
*/
|
|
showContextMenu(_e: Event) {}
|
|
// NOP on bubbles, but used by the bubble dragger to pass events to
|
|
// workspace comments.
|
|
|
|
/**
|
|
* Get whether this bubble is deletable or not.
|
|
* @return True if deletable.
|
|
*/
|
|
isDeletable(): boolean {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Update the style of this bubble when it is dragged over a delete area.
|
|
* @param _enable True if the bubble is about to be deleted, false otherwise.
|
|
*/
|
|
setDeleteStyle(_enable: boolean) {}
|
|
// NOP if bubble is not deletable.
|
|
|
|
/**
|
|
* Handle a mouse-down on bubble's resize corner.
|
|
* @param e Mouse down event.
|
|
*/
|
|
private resizeMouseDown_(e: Event) {
|
|
this.promote();
|
|
Bubble.unbindDragEvents_();
|
|
if (browserEvents.isRightButton(e)) {
|
|
// No right-click.
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
// Left-click (or middle click)
|
|
this.workspace_.startDrag(
|
|
e,
|
|
new Coordinate(
|
|
this.workspace_.RTL ? -this.width_ : this.width_, this.height_));
|
|
|
|
Bubble.onMouseUpWrapper_ = browserEvents.conditionalBind(
|
|
document, 'mouseup', this, Bubble.bubbleMouseUp_);
|
|
Bubble.onMouseMoveWrapper_ = browserEvents.conditionalBind(
|
|
document, 'mousemove', this, this.resizeMouseMove_);
|
|
this.workspace_.hideChaff();
|
|
// This event has been handled. No need to bubble up to the document.
|
|
e.stopPropagation();
|
|
}
|
|
|
|
/**
|
|
* Resize this bubble to follow the mouse.
|
|
* @param e Mouse move event.
|
|
*/
|
|
private resizeMouseMove_(e: Event) {
|
|
this.autoLayout_ = false;
|
|
const newXY = this.workspace_.moveDrag(e);
|
|
this.setBubbleSize(this.workspace_.RTL ? -newXY.x : newXY.x, newXY.y);
|
|
if (this.workspace_.RTL) {
|
|
// RTL requires the bubble to move its left edge.
|
|
this.positionBubble_();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a function as a callback event for when the bubble is resized.
|
|
* @param callback The function to call on resize.
|
|
*/
|
|
registerResizeEvent(callback: Function) {
|
|
// AnyDuringMigration because: Type 'Function' is not assignable to type
|
|
// '() => any'.
|
|
this.resizeCallback_ = callback as AnyDuringMigration;
|
|
}
|
|
|
|
/**
|
|
* Register a function as a callback event for when the bubble is moved.
|
|
* @param callback The function to call on move.
|
|
*/
|
|
registerMoveEvent(callback: Function) {
|
|
// AnyDuringMigration because: Type 'Function' is not assignable to type
|
|
// '() => any'.
|
|
this.moveCallback_ = callback as AnyDuringMigration;
|
|
}
|
|
|
|
/**
|
|
* Move this bubble to the top of the stack.
|
|
* @return Whether or not the bubble has been moved.
|
|
*/
|
|
promote(): boolean {
|
|
const svgGroup = this.bubbleGroup_.parentNode;
|
|
if (svgGroup!.lastChild !== this.bubbleGroup_) {
|
|
svgGroup!.appendChild(this.bubbleGroup_);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Notification that the anchor has moved.
|
|
* Update the arrow and bubble accordingly.
|
|
* @param xy Absolute location.
|
|
*/
|
|
setAnchorLocation(xy: Coordinate) {
|
|
this.anchorXY_ = xy;
|
|
if (this.rendered_) {
|
|
this.positionBubble_();
|
|
}
|
|
}
|
|
|
|
/** Position the bubble so that it does not fall off-screen. */
|
|
private layoutBubble_() {
|
|
// Get the metrics in workspace units.
|
|
const viewMetrics =
|
|
this.workspace_.getMetricsManager().getViewMetrics(true);
|
|
|
|
const optimalLeft = this.getOptimalRelativeLeft_(viewMetrics);
|
|
const optimalTop = this.getOptimalRelativeTop_(viewMetrics);
|
|
const bbox = (this.shape_ as SVGGraphicsElement).getBBox();
|
|
|
|
const topPosition = {
|
|
x: optimalLeft,
|
|
y: -this.height_ -
|
|
this.workspace_.getRenderer().getConstants().MIN_BLOCK_HEIGHT as
|
|
number,
|
|
};
|
|
const startPosition = {x: -this.width_ - 30, y: optimalTop};
|
|
const endPosition = {x: bbox.width, y: optimalTop};
|
|
const bottomPosition = {x: optimalLeft, y: bbox.height};
|
|
|
|
const closerPosition =
|
|
bbox.width < bbox.height ? endPosition : bottomPosition;
|
|
const fartherPosition =
|
|
bbox.width < bbox.height ? 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;
|
|
return;
|
|
}
|
|
if (startPositionOverlap === mostOverlap) {
|
|
this.relativeLeft_ = startPosition.x;
|
|
this.relativeTop_ = startPosition.y;
|
|
return;
|
|
}
|
|
if (closerPositionOverlap === mostOverlap) {
|
|
this.relativeLeft_ = closerPosition.x;
|
|
this.relativeTop_ = closerPosition.y;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @return 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.anchorXY_.x - relativeMin.x - this.width_ :
|
|
relativeMin.x + this.anchorXY_.x,
|
|
y: relativeMin.y + this.anchorXY_.y,
|
|
};
|
|
// The position of the bottom-right corner of the bubble in workspace units.
|
|
const bubbleMax = {
|
|
x: bubbleMin.x + this.width_,
|
|
y: bubbleMin.y + this.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.width_ * this.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.
|
|
* @return The optimal horizontal position of the top-left corner of the
|
|
* bubble.
|
|
*/
|
|
private getOptimalRelativeLeft_(viewMetrics: ContainerRegion): number {
|
|
let relativeLeft = -this.width_ / 4;
|
|
|
|
// No amount of sliding left or right will give us a better overlap.
|
|
if (this.width_ > viewMetrics.width) {
|
|
return relativeLeft;
|
|
}
|
|
|
|
if (this.workspace_.RTL) {
|
|
// Bubble coordinates are flipped in RTL.
|
|
const bubbleRight = this.anchorXY_.x - relativeLeft;
|
|
const bubbleLeft = bubbleRight - this.width_;
|
|
|
|
const workspaceRight = viewMetrics.left + viewMetrics.width;
|
|
const workspaceLeft = viewMetrics.left + // Thickness in workspace units.
|
|
Scrollbar.scrollbarThickness / this.workspace_.scale;
|
|
|
|
if (bubbleLeft < workspaceLeft) {
|
|
// Slide the bubble right until it is onscreen.
|
|
relativeLeft = -(workspaceLeft - this.anchorXY_.x + this.width_);
|
|
} else if (bubbleRight > workspaceRight) {
|
|
// Slide the bubble left until it is onscreen.
|
|
relativeLeft = -(workspaceRight - this.anchorXY_.x);
|
|
}
|
|
} else {
|
|
const bubbleLeft = relativeLeft + this.anchorXY_.x;
|
|
const bubbleRight = bubbleLeft + this.width_;
|
|
|
|
const workspaceLeft = viewMetrics.left;
|
|
const workspaceRight = viewMetrics.left +
|
|
viewMetrics.width - // Thickness in workspace units.
|
|
Scrollbar.scrollbarThickness / this.workspace_.scale;
|
|
|
|
if (bubbleLeft < workspaceLeft) {
|
|
// Slide the bubble right until it is onscreen.
|
|
relativeLeft = workspaceLeft - this.anchorXY_.x;
|
|
} else if (bubbleRight > workspaceRight) {
|
|
// Slide the bubble left until it is onscreen.
|
|
relativeLeft = workspaceRight - this.anchorXY_.x - this.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.
|
|
* @return The optimal vertical position of the top-left corner of the bubble.
|
|
*/
|
|
private getOptimalRelativeTop_(viewMetrics: ContainerRegion): number {
|
|
let relativeTop = -this.height_ / 4;
|
|
|
|
// No amount of sliding up or down will give us a better overlap.
|
|
if (this.height_ > viewMetrics.height) {
|
|
return relativeTop;
|
|
}
|
|
|
|
const bubbleTop = this.anchorXY_.y + relativeTop;
|
|
const bubbleBottom = bubbleTop + this.height_;
|
|
const workspaceTop = viewMetrics.top;
|
|
const workspaceBottom = viewMetrics.top +
|
|
viewMetrics.height - // Thickness in workspace units.
|
|
Scrollbar.scrollbarThickness / this.workspace_.scale;
|
|
|
|
const anchorY = this.anchorXY_.y;
|
|
if (bubbleTop < workspaceTop) {
|
|
// Slide the bubble down until it is onscreen.
|
|
relativeTop = workspaceTop - anchorY;
|
|
} else if (bubbleBottom > workspaceBottom) {
|
|
// Slide the bubble up until it is onscreen.
|
|
relativeTop = workspaceBottom - anchorY - this.height_;
|
|
}
|
|
|
|
return relativeTop;
|
|
}
|
|
|
|
/** Move the bubble to a location relative to the anchor's centre. */
|
|
private positionBubble_() {
|
|
let left = this.anchorXY_.x;
|
|
if (this.workspace_.RTL) {
|
|
left -= this.relativeLeft_ + this.width_;
|
|
} else {
|
|
left += this.relativeLeft_;
|
|
}
|
|
const top = this.relativeTop_ + this.anchorXY_.y;
|
|
this.moveTo(left, top);
|
|
}
|
|
|
|
/**
|
|
* Move the bubble group to the specified location in workspace coordinates.
|
|
* @param x The x position to move to.
|
|
* @param y The y position to move to.
|
|
*/
|
|
moveTo(x: number, y: number) {
|
|
this.bubbleGroup_.setAttribute(
|
|
'transform', 'translate(' + x + ',' + y + ')');
|
|
}
|
|
|
|
/**
|
|
* Triggers a move callback if one exists at the end of a drag.
|
|
* @param adding True if adding, false if removing.
|
|
*/
|
|
setDragging(adding: boolean) {
|
|
if (!adding && this.moveCallback_) {
|
|
this.moveCallback_();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the dimensions of this bubble.
|
|
* @return The height and width of the bubble.
|
|
*/
|
|
getBubbleSize(): Size {
|
|
return new Size(this.width_, this.height_);
|
|
}
|
|
|
|
/**
|
|
* Size this bubble.
|
|
* @param width Width of the bubble.
|
|
* @param height Height of the bubble.
|
|
*/
|
|
setBubbleSize(width: number, height: number) {
|
|
const doubleBorderWidth = 2 * Bubble.BORDER_WIDTH;
|
|
// Minimum size of a bubble.
|
|
width = Math.max(width, doubleBorderWidth + 45);
|
|
height = Math.max(height, doubleBorderWidth + 20);
|
|
this.width_ = width;
|
|
this.height_ = height;
|
|
// AnyDuringMigration because: Argument of type 'number' is not assignable
|
|
// to parameter of type 'string'.
|
|
this.bubbleBack_.setAttribute('width', width as AnyDuringMigration);
|
|
// AnyDuringMigration because: Argument of type 'number' is not assignable
|
|
// to parameter of type 'string'.
|
|
this.bubbleBack_.setAttribute('height', height as AnyDuringMigration);
|
|
if (this.resizeGroup_) {
|
|
if (this.workspace_.RTL) {
|
|
// Mirror the resize group.
|
|
const resizeSize = 2 * Bubble.BORDER_WIDTH;
|
|
this.resizeGroup_.setAttribute(
|
|
'transform',
|
|
'translate(' + resizeSize + ',' + (height - doubleBorderWidth) +
|
|
') scale(-1 1)');
|
|
} else {
|
|
this.resizeGroup_.setAttribute(
|
|
'transform',
|
|
'translate(' + (width - doubleBorderWidth) + ',' +
|
|
(height - doubleBorderWidth) + ')');
|
|
}
|
|
}
|
|
if (this.autoLayout_) {
|
|
this.layoutBubble_();
|
|
}
|
|
this.positionBubble_();
|
|
this.renderArrow_();
|
|
|
|
// Allow the contents to resize.
|
|
if (this.resizeCallback_) {
|
|
this.resizeCallback_();
|
|
}
|
|
}
|
|
|
|
/** Draw the arrow between the bubble and the origin. */
|
|
private renderArrow_() {
|
|
const steps = [];
|
|
// Find the relative coordinates of the center of the bubble.
|
|
const relBubbleX = this.width_ / 2;
|
|
const relBubbleY = this.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 arrow'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 arrow.
|
|
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 arrow.
|
|
const bubbleSize = this.getBubbleSize();
|
|
let thickness =
|
|
(bubbleSize.width + bubbleSize.height) / Bubble.ARROW_THICKNESS;
|
|
thickness = Math.min(thickness, bubbleSize.width, bubbleSize.height) / 4;
|
|
|
|
// Back the tip of the arrow 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 arrow.
|
|
const baseX1 = relBubbleX + thickness * rightRun;
|
|
const baseY1 = relBubbleY + thickness * rightRise;
|
|
const baseX2 = relBubbleX - thickness * rightRun;
|
|
const baseY2 = relBubbleY - thickness * rightRise;
|
|
|
|
// Distortion to curve the arrow.
|
|
let swirlAngle = angle + this.arrow_radians_;
|
|
if (swirlAngle > Math.PI * 2) {
|
|
swirlAngle -= Math.PI * 2;
|
|
}
|
|
const swirlRise = Math.sin(swirlAngle) * hypotenuse / Bubble.ARROW_BEND;
|
|
const swirlRun = Math.cos(swirlAngle) * hypotenuse / Bubble.ARROW_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.bubbleArrow_.setAttribute('d', steps.join(' '));
|
|
}
|
|
|
|
/**
|
|
* Change the colour of a bubble.
|
|
* @param hexColour Hex code of colour.
|
|
*/
|
|
setColour(hexColour: string) {
|
|
this.bubbleBack_.setAttribute('fill', hexColour);
|
|
this.bubbleArrow_.setAttribute('fill', hexColour);
|
|
}
|
|
|
|
/** Dispose of this bubble. */
|
|
dispose() {
|
|
if (this.onMouseDownBubbleWrapper_) {
|
|
browserEvents.unbind(this.onMouseDownBubbleWrapper_);
|
|
}
|
|
if (this.onMouseDownResizeWrapper_) {
|
|
browserEvents.unbind(this.onMouseDownResizeWrapper_);
|
|
}
|
|
Bubble.unbindDragEvents_();
|
|
dom.removeNode(this.bubbleGroup_);
|
|
this.disposed = true;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
moveDuringDrag(dragSurface: BlockDragSurfaceSvg, newLoc: Coordinate) {
|
|
if (dragSurface) {
|
|
dragSurface.translateSurface(newLoc.x, newLoc.y);
|
|
} else {
|
|
this.moveTo(newLoc.x, newLoc.y);
|
|
}
|
|
if (this.workspace_.RTL) {
|
|
this.relativeLeft_ = this.anchorXY_.x - newLoc.x - this.width_;
|
|
} else {
|
|
this.relativeLeft_ = newLoc.x - this.anchorXY_.x;
|
|
}
|
|
this.relativeTop_ = newLoc.y - this.anchorXY_.y;
|
|
this.renderArrow_();
|
|
}
|
|
|
|
/**
|
|
* Return the coordinates of the top-left corner of this bubble's body
|
|
* relative to the drawing surface's origin (0,0), in workspace units.
|
|
* @return Object with .x and .y properties.
|
|
*/
|
|
getRelativeToSurfaceXY(): Coordinate {
|
|
return new Coordinate(
|
|
this.workspace_.RTL ?
|
|
-this.relativeLeft_ + this.anchorXY_.x - this.width_ :
|
|
this.anchorXY_.x + this.relativeLeft_,
|
|
this.anchorXY_.y + this.relativeTop_);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
this.autoLayout_ = enable;
|
|
}
|
|
|
|
/** Stop binding to the global mouseup and mousemove events. */
|
|
private static unbindDragEvents_() {
|
|
if (Bubble.onMouseUpWrapper_) {
|
|
browserEvents.unbind(Bubble.onMouseUpWrapper_);
|
|
Bubble.onMouseUpWrapper_ = null;
|
|
}
|
|
if (Bubble.onMouseMoveWrapper_) {
|
|
browserEvents.unbind(Bubble.onMouseMoveWrapper_);
|
|
Bubble.onMouseMoveWrapper_ = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a mouse-up event while dragging a bubble's border or resize handle.
|
|
* @param _e Mouse up event.
|
|
*/
|
|
private static bubbleMouseUp_(_e: Event) {
|
|
Touch.clearTouchIdentifier();
|
|
Bubble.unbindDragEvents_();
|
|
}
|
|
|
|
/**
|
|
* Create the text for a non editable bubble.
|
|
* @param text The text to display.
|
|
* @return The top-level node of the text.
|
|
*/
|
|
static textToDom(text: string): SVGTextElement {
|
|
const paragraph = dom.createSvgElement(Svg.TEXT, {
|
|
'class': 'blocklyText blocklyBubbleText blocklyNoPointerEvents',
|
|
'y': Bubble.BORDER_WIDTH,
|
|
});
|
|
const lines = text.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const tspanElement = dom.createSvgElement(
|
|
Svg.TSPAN, {'dy': '1em', 'x': Bubble.BORDER_WIDTH}, paragraph);
|
|
const textNode = document.createTextNode(lines[i]);
|
|
tspanElement.appendChild(textNode);
|
|
}
|
|
return paragraph;
|
|
}
|
|
|
|
/**
|
|
* Creates a bubble that can not be edited.
|
|
* @param paragraphElement The text element for the non editable bubble.
|
|
* @param block The block that the bubble is attached to.
|
|
* @param iconXY The coordinate of the icon.
|
|
* @return The non editable bubble.
|
|
*/
|
|
static createNonEditableBubble(
|
|
paragraphElement: SVGTextElement, block: BlockSvg,
|
|
iconXY: Coordinate): Bubble {
|
|
const bubble = new Bubble(
|
|
(block.workspace), paragraphElement, block.pathObject.svgPath, (iconXY),
|
|
null, null);
|
|
// Expose this bubble's block's ID on its top-level SVG group.
|
|
bubble.setSvgId(block.id);
|
|
if (block.RTL) {
|
|
// Right-align the paragraph.
|
|
// This cannot be done until the bubble is rendered on screen.
|
|
const maxWidth = paragraphElement.getBBox().width;
|
|
for (let i = 0, textElement;
|
|
textElement = paragraphElement.childNodes[i] as SVGTSpanElement;
|
|
i++) {
|
|
textElement.setAttribute('text-anchor', 'end');
|
|
textElement.setAttribute(
|
|
'x', (maxWidth + Bubble.BORDER_WIDTH).toString());
|
|
}
|
|
}
|
|
return bubble;
|
|
}
|
|
}
|