Files
blockly/core/comments/comment_view.ts

891 lines
25 KiB
TypeScript

/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
import * as layers from '../layers.js';
import * as css from '../css.js';
import {Coordinate} from '../utils/coordinate.js';
import {Size} from '../utils/size.js';
import * as browserEvents from '../browser_events.js';
import * as touch from '../touch.js';
export class CommentView implements IRenderedElement {
/** The root group element of the comment view. */
private svgRoot: SVGGElement;
/**
* The svg rect element that we use to create a hightlight around the comment.
*/
private highlightRect: SVGRectElement;
/** The group containing all of the top bar elements. */
private topBarGroup: SVGGElement;
/** The rect background for the top bar. */
private topBarBackground: SVGRectElement;
/** The delete icon that goes in the top bar. */
private deleteIcon: SVGImageElement;
/** The foldout icon that goes in the top bar. */
private foldoutIcon: SVGImageElement;
/** The text element that goes in the top bar. */
private textPreview: SVGTextElement;
/** The actual text node in the text preview. */
private textPreviewNode: Text;
/** The resize handle element. */
private resizeHandle: SVGImageElement;
/** The foreignObject containing the HTML text area. */
private foreignObject: SVGForeignObjectElement;
/** The text area where the user can type. */
private textArea: HTMLTextAreaElement;
/** The current size of the comment in workspace units. */
private size: Size = new Size(120, 100);
/** Whether the comment is collapsed or not. */
private collapsed: boolean = false;
/** Whether the comment is editable or not. */
private editable: boolean = true;
/** The current location of the comment in workspace coordinates. */
private location: Coordinate = new Coordinate(0, 0);
/** The current text of the comment. Updates on text area change. */
private text: string = '';
/** Listeners for changes to text. */
private textChangeListeners: Array<
(oldText: string, newText: string) => void
> = [];
/** Listeners for changes to size. */
private sizeChangeListeners: Array<(oldSize: Size, newSize: Size) => void> =
[];
/** Listeners for disposal. */
private disposeListeners: Array<() => void> = [];
/** Listeners for collapsing. */
private collapseChangeListeners: Array<(newCollapse: boolean) => void> = [];
/**
* Event data for the pointer up event on the resize handle. Used to
* unregister the listener.
*/
private resizePointerUpListener: browserEvents.Data | null = null;
/**
* Event data for the pointer move event on the resize handle. Used to
* unregister the listener.
*/
private resizePointerMoveListener: browserEvents.Data | null = null;
/** Whether this comment view is currently being disposed or not. */
private disposing = false;
/** Whether this comment view has been disposed or not. */
private disposed = false;
/** Size of this comment when the resize drag was initiated. */
private preResizeSize?: Size;
constructor(private readonly workspace: WorkspaceSvg) {
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyComment blocklyEditable blocklyDraggable',
});
this.highlightRect = this.createHighlightRect(this.svgRoot);
({
topBarGroup: this.topBarGroup,
topBarBackground: this.topBarBackground,
deleteIcon: this.deleteIcon,
foldoutIcon: this.foldoutIcon,
textPreview: this.textPreview,
textPreviewNode: this.textPreviewNode,
} = this.createTopBar(this.svgRoot, workspace));
({foreignObject: this.foreignObject, textArea: this.textArea} =
this.createTextArea(this.svgRoot));
this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace);
// TODO: Remove this comment before merging.
// I think we want comments to exist on the same layer as blocks.
workspace.getLayerManager()?.append(this, layers.BLOCK);
// Set size to the default size.
this.setSizeWithoutFiringEvents(this.size);
// Set default transform (including inverted scale for RTL).
this.moveTo(new Coordinate(0, 0));
}
/**
* Creates the rect we use for highlighting the comment when it's selected.
*/
private createHighlightRect(svgRoot: SVGGElement): SVGRectElement {
return dom.createSvgElement(
Svg.RECT,
{'class': 'blocklyCommentHighlight'},
svgRoot,
);
}
/**
* Creates the top bar and the elements visually within it.
* Registers event listeners.
*/
private createTopBar(
svgRoot: SVGGElement,
workspace: WorkspaceSvg,
): {
topBarGroup: SVGGElement;
topBarBackground: SVGRectElement;
deleteIcon: SVGImageElement;
foldoutIcon: SVGImageElement;
textPreview: SVGTextElement;
textPreviewNode: Text;
} {
const topBarGroup = dom.createSvgElement(
Svg.G,
{
'class': 'blocklyCommentTopbar',
},
svgRoot,
);
const topBarBackground = dom.createSvgElement(
Svg.RECT,
{
'class': 'blocklyCommentTopbarBackground',
},
topBarGroup,
);
// TODO: Before merging, does this mean to override an individual image,
// folks need to replace the whole media folder?
const deleteIcon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyDeleteIcon',
'href': `${workspace.options.pathToMedia}delete-icon.svg`,
},
topBarGroup,
);
const foldoutIcon = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyFoldoutIcon',
'href': `${workspace.options.pathToMedia}foldout-icon.svg`,
},
topBarGroup,
);
const textPreview = dom.createSvgElement(
Svg.TEXT,
{
'class': 'blocklyCommentPreview blocklyCommentText blocklyText',
},
topBarGroup,
);
const textPreviewNode = document.createTextNode('');
textPreview.appendChild(textPreviewNode);
// TODO(toychest): Triggering this on pointerdown means that we can't start
// drags on the foldout icon. We need to open up the gesture system
// to fix this.
browserEvents.conditionalBind(
foldoutIcon,
'pointerdown',
this,
this.onFoldoutDown,
);
browserEvents.conditionalBind(
deleteIcon,
'pointerdown',
this,
this.onDeleteDown,
);
return {
topBarGroup,
topBarBackground,
deleteIcon,
foldoutIcon,
textPreview,
textPreviewNode,
};
}
/**
* Creates the text area where users can type. Registers event listeners.
*/
private createTextArea(svgRoot: SVGGElement): {
foreignObject: SVGForeignObjectElement;
textArea: HTMLTextAreaElement;
} {
const foreignObject = dom.createSvgElement(
Svg.FOREIGNOBJECT,
{
'class': 'blocklyCommentForeignObject',
},
svgRoot,
);
const body = document.createElementNS(dom.HTML_NS, 'body');
body.setAttribute('xmlns', dom.HTML_NS);
body.className = 'blocklyMinimalBody';
const textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
dom.addClass(textArea, 'blocklyCommentText');
dom.addClass(textArea, 'blocklyTextarea');
dom.addClass(textArea, 'blocklyText');
body.appendChild(textArea);
foreignObject.appendChild(body);
browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);
return {foreignObject, textArea};
}
/** Creates the DOM elements for the comment resize handle. */
private createResizeHandle(
svgRoot: SVGGElement,
workspace: WorkspaceSvg,
): SVGImageElement {
const resizeHandle = dom.createSvgElement(
Svg.IMAGE,
{
'class': 'blocklyResizeHandle',
'href': `${workspace.options.pathToMedia}resize-handle.svg`,
},
svgRoot,
);
browserEvents.conditionalBind(
resizeHandle,
'pointerdown',
this,
this.onResizePointerDown,
);
return resizeHandle;
}
/** Returns the root SVG group element of the comment view. */
getSvgRoot(): SVGGElement {
return this.svgRoot;
}
/**
* Returns the current size of the comment in workspace units.
* Respects collapsing.
*/
getSize(): Size {
return this.collapsed ? this.topBarBackground.getBBox() : this.size;
}
/**
* Sets the size of the comment in workspace units, and updates the view
* elements to reflect the new size.
*/
setSizeWithoutFiringEvents(size: Size) {
const topBarSize = this.topBarBackground.getBBox();
const deleteSize = this.deleteIcon.getBBox();
const foldoutSize = this.foldoutIcon.getBBox();
const textPreviewSize = this.textPreview.getBBox();
const resizeSize = this.resizeHandle.getBBox();
size = Size.max(
size,
this.calcMinSize(topBarSize, foldoutSize, deleteSize),
);
this.size = size;
this.svgRoot.setAttribute('height', `${size.height}`);
this.svgRoot.setAttribute('width', `${size.width}`);
this.updateHighlightRect(size);
this.updateTopBarSize(size);
this.updateTextAreaSize(size, topBarSize);
this.updateDeleteIconPosition(size, topBarSize, deleteSize);
this.updateFoldoutIconPosition(topBarSize, foldoutSize);
this.updateTextPreviewSize(
size,
topBarSize,
textPreviewSize,
deleteSize,
resizeSize,
);
this.updateResizeHandlePosition(size, resizeSize);
}
/**
* Sets the size of the comment in workspace units, updates the view
* elements to reflect the new size, and triggers size change listeners.
*/
setSize(size: Size) {
const oldSize = this.preResizeSize || this.size;
this.setSizeWithoutFiringEvents(size);
this.onSizeChange(oldSize, this.size);
}
/**
* Calculates the minimum size for the uncollapsed comment based on text
* size and visible icons.
*
* The minimum width is based on the width of the truncated preview text.
*
* The minimum height is based on the height of the top bar.
*/
private calcMinSize(
topBarSize: Size,
foldoutSize: Size,
deleteSize: Size,
): Size {
this.updateTextPreview(this.textArea.value ?? '');
const textPreviewWidth = dom.getTextWidth(this.textPreview);
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
let width = textPreviewWidth;
if (this.foldoutIcon.checkVisibility()) {
width += foldoutSize.width + foldoutMargin * 2;
} else if (textPreviewWidth) {
width += 4; // Arbitrary margin before text.
}
if (this.deleteIcon.checkVisibility()) {
width += deleteSize.width + deleteMargin * 2;
} else if (textPreviewWidth) {
width += 4; // Arbitrary margin after text.
}
// Arbitrary additional height.
const height = topBarSize.height + 20;
return new Size(width, height);
}
/** Calculates the margin that should exist around the delete icon. */
private calcDeleteMargin(topBarSize: Size, deleteSize: Size) {
return (topBarSize.height - deleteSize.height) / 2;
}
/** Calculates the margin that should exist around the foldout icon. */
private calcFoldoutMargin(topBarSize: Size, foldoutSize: Size) {
return (topBarSize.height - foldoutSize.height) / 2;
}
/** Updates the size of the highlight rect to reflect the new size. */
private updateHighlightRect(size: Size) {
this.highlightRect.setAttribute('height', `${size.height}`);
this.highlightRect.setAttribute('width', `${size.width}`);
if (this.workspace.RTL) {
this.highlightRect.setAttribute('x', `${-size.width}`);
}
}
/** Updates the size of the top bar to reflect the new size. */
private updateTopBarSize(size: Size) {
this.topBarBackground.setAttribute('width', `${size.width}`);
}
/** Updates the size of the text area elements to reflect the new size. */
private updateTextAreaSize(size: Size, topBarSize: Size) {
this.foreignObject.setAttribute(
'height',
`${size.height - topBarSize.height}`,
);
this.foreignObject.setAttribute('width', `${size.width}`);
this.foreignObject.setAttribute('y', `${topBarSize.height}`);
if (this.workspace.RTL) {
this.foreignObject.setAttribute('x', `${-size.width}`);
}
}
/**
* Updates the position of the delete icon elements to reflect the new size.
*/
private updateDeleteIconPosition(
size: Size,
topBarSize: Size,
deleteSize: Size,
) {
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
this.deleteIcon.setAttribute('y', `${deleteMargin}`);
this.deleteIcon.setAttribute(
'x',
`${size.width - deleteSize.width - deleteMargin}`,
);
}
/**
* Updates the position of the foldout icon elements to reflect the new size.
*/
private updateFoldoutIconPosition(topBarSize: Size, foldoutSize: Size) {
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
this.foldoutIcon.setAttribute('y', `${foldoutMargin}`);
this.foldoutIcon.setAttribute('x', `${foldoutMargin}`);
}
/**
* Updates the size and position of the text preview elements to reflect the new size.
*/
private updateTextPreviewSize(
size: Size,
topBarSize: Size,
textPreviewSize: Size,
deleteSize: Size,
foldoutSize: Size,
) {
const textPreviewMargin = (topBarSize.height - textPreviewSize.height) / 2;
const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize);
const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize);
const textPreviewWidth =
size.width -
foldoutSize.width -
foldoutMargin * 2 -
deleteSize.width -
deleteMargin * 2;
this.textPreview.setAttribute(
'x',
`${
foldoutSize.width + foldoutMargin * 2 * (this.workspace.RTL ? -1 : 1)
}`,
);
this.textPreview.setAttribute(
'y',
`${textPreviewMargin + textPreviewSize.height / 2}`,
);
this.textPreview.setAttribute('width', `${textPreviewWidth}`);
}
/** Updates the position of the resize handle to reflect the new size. */
private updateResizeHandlePosition(size: Size, resizeSize: Size) {
this.resizeHandle.setAttribute('y', `${size.height - resizeSize.height}`);
this.resizeHandle.setAttribute('x', `${size.width - resizeSize.width}`);
}
/**
* Triggers listeners when the size of the comment changes, either
* programmatically or manually by the user.
*/
private onSizeChange(oldSize: Size, newSize: Size) {
// Loop through listeners backwards in case they remove themselves.
for (let i = this.sizeChangeListeners.length - 1; i >= 0; i--) {
this.sizeChangeListeners[i](oldSize, newSize);
}
}
/**
* Registers a callback that listens for size changes.
*
* @param listener Receives callbacks when the size of the comment changes.
* The new and old size are in workspace units.
*/
addSizeChangeListener(listener: (oldSize: Size, newSize: Size) => void) {
this.sizeChangeListeners.push(listener);
}
/** Removes the given listener from the list of size change listeners. */
removeSizeChangeListener(listener: () => void) {
this.sizeChangeListeners.splice(
this.sizeChangeListeners.indexOf(listener),
1,
);
}
/**
* Handles starting an interaction with the resize handle to resize the
* comment.
*/
private onResizePointerDown(e: PointerEvent) {
if (!this.isEditable()) return;
this.bringToFront();
if (browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
this.preResizeSize = this.getSize();
// TODO(#7926): Move this into a utils file.
this.workspace.startDrag(
e,
new Coordinate(
this.workspace.RTL ? -this.getSize().width : this.getSize().width,
this.getSize().height,
),
);
this.resizePointerUpListener = browserEvents.conditionalBind(
document,
'pointerup',
this,
this.onResizePointerUp,
);
this.resizePointerMoveListener = browserEvents.conditionalBind(
document,
'pointermove',
this,
this.onResizePointerMove,
);
this.workspace.hideChaff();
e.stopPropagation();
}
/** Ends an interaction with the resize handle. */
private onResizePointerUp(_e: PointerEvent) {
touch.clearTouchIdentifier();
if (this.resizePointerUpListener) {
browserEvents.unbind(this.resizePointerUpListener);
this.resizePointerUpListener = null;
}
if (this.resizePointerMoveListener) {
browserEvents.unbind(this.resizePointerMoveListener);
this.resizePointerMoveListener = null;
}
// When ending a resize drag, notify size change listeners to fire an event.
this.setSize(this.size);
this.preResizeSize = undefined;
}
/** Resizes the comment in response to a drag on the resize handle. */
private onResizePointerMove(e: PointerEvent) {
// TODO(#7926): Move this into a utils file.
const size = this.workspace.moveDrag(e);
this.setSizeWithoutFiringEvents(
new Size(this.workspace.RTL ? -size.x : size.x, size.y),
);
}
/** Returns true if the comment is currently collapsed. */
isCollapsed(): boolean {
return this.collapsed;
}
/** Sets whether the comment is currently collapsed or not. */
setCollapsed(collapsed: boolean) {
this.collapsed = collapsed;
if (collapsed) {
dom.addClass(this.svgRoot, 'blocklyCollapsed');
} else {
dom.removeClass(this.svgRoot, 'blocklyCollapsed');
}
// Repositions resize handle and such.
this.setSizeWithoutFiringEvents(this.size);
this.onCollapse();
}
/**
* Triggers listeners when the collapsed-ness of the comment changes, either
* progrmatically or manually by the user.
*/
private onCollapse() {
// Loop through listeners backwards in case they remove themselves.
for (let i = this.collapseChangeListeners.length - 1; i >= 0; i--) {
this.collapseChangeListeners[i](this.collapsed);
}
}
/** Registers a callback that listens for collapsed-ness changes. */
addOnCollapseListener(listener: (newCollapse: boolean) => void) {
this.collapseChangeListeners.push(listener);
}
/** Removes the given listener from the list of on collapse listeners. */
removeOnCollapseListener(listener: () => void) {
this.collapseChangeListeners.splice(
this.collapseChangeListeners.indexOf(listener),
1,
);
}
/**
* Toggles the collapsedness of the block when we receive a pointer down
* event on the foldout icon.
*/
private onFoldoutDown(e: PointerEvent) {
this.bringToFront();
if (browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
this.setCollapsed(!this.collapsed);
this.workspace.hideChaff();
e.stopPropagation();
}
/** Returns true if the comment is currently editable. */
isEditable(): boolean {
return this.editable;
}
/** Sets the editability of the comment. */
setEditable(editable: boolean) {
this.editable = editable;
if (this.editable) {
dom.addClass(this.svgRoot, 'blocklyEditable');
dom.removeClass(this.svgRoot, 'blocklyReadonly');
this.textArea.removeAttribute('readonly');
} else {
dom.removeClass(this.svgRoot, 'blocklyEditable');
dom.addClass(this.svgRoot, 'blocklyReadonly');
this.textArea.setAttribute('readonly', 'true');
}
}
/** Returns the current location of the comment in workspace coordinates. */
getRelativeToSurfaceXY(): Coordinate {
return this.location;
}
/**
* Moves the comment view to the given location.
*
* @param location The location to move to in workspace coordinates.
*/
moveTo(location: Coordinate) {
this.location = location;
this.svgRoot.setAttribute(
'transform',
`translate(${location.x}, ${location.y})`,
);
}
/** Retursn the current text of the comment. */
getText() {
return this.text;
}
/** Sets the current text of the comment. */
setText(text: string) {
this.textArea.value = text;
this.onTextChange();
}
/** Registers a callback that listens for text changes. */
addTextChangeListener(listener: (oldText: string, newText: string) => void) {
this.textChangeListeners.push(listener);
}
/** Removes the given listener from the list of text change listeners. */
removeTextChangeListener(listener: () => void) {
this.textChangeListeners.splice(
this.textChangeListeners.indexOf(listener),
1,
);
}
/**
* Triggers listeners when the text of the comment changes, either
* programmatically or manually by the user.
*/
private onTextChange() {
const oldText = this.text;
this.text = this.textArea.value;
this.updateTextPreview(this.text);
// Update size in case our minimum size increased.
this.setSize(this.size);
// Loop through listeners backwards in case they remove themselves.
for (let i = this.textChangeListeners.length - 1; i >= 0; i--) {
this.textChangeListeners[i](oldText, this.text);
}
}
/** Updates the preview text element to reflect the given text. */
private updateTextPreview(text: string) {
this.textPreviewNode.textContent = this.truncateText(text);
}
/** Truncates the text to fit within the top view. */
private truncateText(text: string): string {
return text.length >= 12 ? `${text.substring(0, 9)}...` : text;
}
/** Brings the workspace comment to the front of its layer. */
private bringToFront() {
const parent = this.svgRoot.parentNode;
const childNodes = parent!.childNodes;
// Avoid moving the comment if it's already at the bottom.
if (childNodes[childNodes.length - 1] !== this.svgRoot) {
parent!.appendChild(this.svgRoot);
}
}
/**
* Handles disposing of the comment when we get a pointer down event on the
* delete icon.
*/
private onDeleteDown(e: PointerEvent) {
if (browserEvents.isRightButton(e)) {
e.stopPropagation();
return;
}
this.dispose();
e.stopPropagation();
}
/** Disposes of this comment view. */
dispose() {
this.disposing = true;
dom.removeNode(this.svgRoot);
// Loop through listeners backwards in case they remove themselves.
for (let i = this.disposeListeners.length - 1; i >= 0; i--) {
this.disposeListeners[i]();
}
this.disposed = true;
}
/** Returns whether this comment view has been disposed or not. */
isDisposed(): boolean {
return this.disposed;
}
/**
* Returns true if this comment view is currently being disposed or has
* already been disposed.
*/
isDeadOrDying(): boolean {
return this.disposing || this.disposed;
}
/** Registers a callback that listens for disposal of this view. */
addDisposeListener(listener: () => void) {
this.disposeListeners.push(listener);
}
/** Removes the given listener from the list of disposal listeners. */
removeDisposeListener(listener: () => void) {
this.disposeListeners.splice(this.disposeListeners.indexOf(listener), 1);
}
}
css.register(`
.injectionDiv {
--commentFillColour: #FFFCC7;
--commentBorderColour: #F2E49B;
}
.blocklyComment .blocklyTextarea {
background-color: var(--commentFillColour);
border: 1px solid var(--commentBorderColour);
box-sizing: border-box;
display: block;
outline: 0;
padding: 5px;
resize: none;
width: 100%;
height: 100%;
}
.blocklyReadonly.blocklyComment .blocklyTextarea {
cursor: inherit;
}
.blocklyDeleteIcon {
width: 20px;
height: 20px;
display: none;
cursor: pointer;
}
.blocklyFoldoutIcon {
width: 20px;
height: 20px;
transform-origin: 12px 12px;
cursor: pointer;
}
.blocklyResizeHandle {
width: 12px;
height: 12px;
cursor: se-resize;
}
.blocklyReadonly.blocklyComment .blocklyResizeHandle {
cursor: inherit;
}
.blocklyCommentTopbarBackground {
cursor: grab;
fill: var(--commentBorderColour);
height: 24px;
}
.blocklyComment .blocklyCommentPreview.blocklyText {
fill: #000;
dominant-baseline: middle;
visibility: hidden;
}
.blocklyCollapsed.blocklyComment .blocklyCommentPreview {
visibility: visible;
}
.blocklyCollapsed.blocklyComment .blocklyCommentForeignObject,
.blocklyCollapsed.blocklyComment .blocklyResizeHandle {
display: none;
}
.blocklyCollapsed.blocklyComment .blocklyFoldoutIcon {
transform: rotate(-90deg);
}
.blocklyRTL .blocklyCommentTopbar {
transform: scale(-1, 1);
}
.blocklyRTL .blocklyCommentForeignObject {
direction: rtl;
}
.blocklyRTL .blocklyCommentPreview {
/* Revert the scale and control RTL using direction instead. */
transform: scale(-1, 1);
direction: rtl;
}
.blocklyRTL .blocklyResizeHandle {
transform: scale(-1, 1);
cursor: sw-resize;
}
.blocklyCommentHighlight {
fill: none;
}
.blocklySelected .blocklyCommentHighlight {
stroke: #fc3;
stroke-width: 3px;
}
.blocklyCollapsed.blocklySelected .blocklyCommentHighlight {
stroke: none;
}
.blocklyCollapsed.blocklySelected .blocklyCommentTopbarBackground {
stroke: #fc3;
stroke-width: 3px;
}
`);