Files
blockly/core/events/utils.ts
Christopher Allen ce22f42868 chore: Organise imports (#8527)
* chore(deps): Add pretter-plugin-organize-imports

* chore: Remove insignificant blank lines in import sections

  Since prettier-plugin-organize-imports sorts imports within
  sections separated by blank lines, but preserves the section
  divisions, remove any blank lines that are not dividing imports
  into meaningful sections.

  Do not remove blank lines separating side-effect-only imports
  from main imports.

* chore: Remove unneded eslint-disable directives

* chore: Organise imports
2024-08-15 03:16:14 +01:00

590 lines
16 KiB
TypeScript

/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.Events.utils
import type {Block} from '../block.js';
import * as common from '../common.js';
import * as registry from '../registry.js';
import * as idGenerator from '../utils/idgenerator.js';
import type {Workspace} from '../workspace.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import type {Abstract} from './events_abstract.js';
import type {BlockChange} from './events_block_change.js';
import type {BlockCreate} from './events_block_create.js';
import type {BlockMove} from './events_block_move.js';
import type {CommentCreate} from './events_comment_create.js';
import type {CommentMove} from './events_comment_move.js';
import type {CommentResize} from './events_comment_resize.js';
import type {ViewportChange} from './events_viewport.js';
/** Group ID for new events. Grouped events are indivisible. */
let group = '';
/** Sets whether the next event should be added to the undo stack. */
let recordUndo = true;
/**
* Sets whether events should be added to the undo stack.
*
* @param newValue True if events should be added to the undo stack.
*/
export function setRecordUndo(newValue: boolean) {
recordUndo = newValue;
}
/**
* Returns whether or not events will be added to the undo stack.
*
* @returns True if events will be added to the undo stack.
*/
export function getRecordUndo(): boolean {
return recordUndo;
}
/** Allow change events to be created and fired. */
let disabled = 0;
/**
* Name of event that creates a block. Will be deprecated for BLOCK_CREATE.
*/
export const CREATE = 'create';
/**
* Name of event that creates a block.
*/
export const BLOCK_CREATE = CREATE;
/**
* Name of event that deletes a block. Will be deprecated for BLOCK_DELETE.
*/
export const DELETE = 'delete';
/**
* Name of event that deletes a block.
*/
export const BLOCK_DELETE = DELETE;
/**
* Name of event that changes a block. Will be deprecated for BLOCK_CHANGE.
*/
export const CHANGE = 'change';
/**
* Name of event that changes a block.
*/
export const BLOCK_CHANGE = CHANGE;
/**
* Name of event representing an in-progress change to a field of a block, which
* is expected to be followed by a block change event.
*/
export const BLOCK_FIELD_INTERMEDIATE_CHANGE =
'block_field_intermediate_change';
/**
* Name of event that moves a block. Will be deprecated for BLOCK_MOVE.
*/
export const MOVE = 'move';
/**
* Name of event that moves a block.
*/
export const BLOCK_MOVE = MOVE;
/**
* Name of event that creates a variable.
*/
export const VAR_CREATE = 'var_create';
/**
* Name of event that deletes a variable.
*/
export const VAR_DELETE = 'var_delete';
/**
* Name of event that renames a variable.
*/
export const VAR_RENAME = 'var_rename';
/**
* Name of generic event that records a UI change.
*/
export const UI = 'ui';
/**
* Name of event that drags a block.
*/
export const BLOCK_DRAG = 'drag';
/**
* Name of event that records a change in selected element.
*/
export const SELECTED = 'selected';
/**
* Name of event that records a click.
*/
export const CLICK = 'click';
/**
* Name of event that records a marker move.
*/
export const MARKER_MOVE = 'marker_move';
/**
* Name of event that records a bubble open.
*/
export const BUBBLE_OPEN = 'bubble_open';
/**
* Name of event that records a trashcan open.
*/
export const TRASHCAN_OPEN = 'trashcan_open';
/**
* Name of event that records a toolbox item select.
*/
export const TOOLBOX_ITEM_SELECT = 'toolbox_item_select';
/**
* Name of event that records a theme change.
*/
export const THEME_CHANGE = 'theme_change';
/**
* Name of event that records a viewport change.
*/
export const VIEWPORT_CHANGE = 'viewport_change';
/**
* Name of event that creates a comment.
*/
export const COMMENT_CREATE = 'comment_create';
/**
* Name of event that deletes a comment.
*/
export const COMMENT_DELETE = 'comment_delete';
/**
* Name of event that changes a comment.
*/
export const COMMENT_CHANGE = 'comment_change';
/**
* Name of event that moves a comment.
*/
export const COMMENT_MOVE = 'comment_move';
/** Name of event that resizes a comment. */
export const COMMENT_RESIZE = 'comment_resize';
/** Name of event that drags a comment. */
export const COMMENT_DRAG = 'comment_drag';
/** Type of event that collapses a comment. */
export const COMMENT_COLLAPSE = 'comment_collapse';
/**
* Name of event that records a workspace load.
*/
export const FINISHED_LOADING = 'finished_loading';
/**
* The language-neutral ID for when the reason why a block is disabled is
* because the block is not descended from a root block.
*/
const ORPHANED_BLOCK_DISABLED_REASON = 'ORPHANED_BLOCK';
/**
* Type of events that cause objects to be bumped back into the visible
* portion of the workspace.
*
* Not to be confused with bumping so that disconnected connections do not
* appear connected.
*/
export type BumpEvent =
| BlockCreate
| BlockMove
| CommentCreate
| CommentMove
| CommentResize;
/**
* List of events that cause objects to be bumped back into the visible
* portion of the workspace.
*
* Not to be confused with bumping so that disconnected connections do not
* appear connected.
*/
export const BUMP_EVENTS: string[] = [
BLOCK_CREATE,
BLOCK_MOVE,
COMMENT_CREATE,
COMMENT_MOVE,
];
/** List of events queued for firing. */
const FIRE_QUEUE: Abstract[] = [];
/**
* Create a custom event and fire it.
*
* @param event Custom data for event.
*/
export function fire(event: Abstract) {
TEST_ONLY.fireInternal(event);
}
/**
* Private version of fireInternal for stubbing in tests.
*/
function fireInternal(event: Abstract) {
if (!isEnabled()) {
return;
}
if (!FIRE_QUEUE.length) {
// First event added; schedule a firing of the event queue.
try {
// If we are in a browser context, we want to make sure that the event
// fires after blocks have been rerendered this frame.
requestAnimationFrame(() => {
setTimeout(fireNow, 0);
});
} catch {
// Otherwise we just want to delay so events can be coallesced.
// requestAnimationFrame will error triggering this.
setTimeout(fireNow, 0);
}
}
FIRE_QUEUE.push(event);
}
/** Fire all queued events. */
function fireNow() {
const queue = filter(FIRE_QUEUE, true);
FIRE_QUEUE.length = 0;
for (let i = 0, event; (event = queue[i]); i++) {
if (!event.workspaceId) {
continue;
}
const eventWorkspace = common.getWorkspaceById(event.workspaceId);
if (eventWorkspace) {
eventWorkspace.fireChangeListener(event);
}
}
// Post-filter the undo stack to squash and remove any events that result in
// a null event
// 1. Determine which workspaces will need to have their undo stacks validated
const workspaceIds = new Set(queue.map((e) => e.workspaceId));
for (const workspaceId of workspaceIds) {
// Only process valid workspaces
if (!workspaceId) {
continue;
}
const eventWorkspace = common.getWorkspaceById(workspaceId);
if (!eventWorkspace) {
continue;
}
// Find the last contiguous group of events on the stack
const undoStack = eventWorkspace.getUndoStack();
let i;
let group: string | undefined = undefined;
for (i = undoStack.length; i > 0; i--) {
const event = undoStack[i - 1];
if (event.group === '') {
break;
} else if (group === undefined) {
group = event.group;
} else if (event.group !== group) {
break;
}
}
if (!group || i == undoStack.length - 1) {
// Need a group of two or more events on the stack. Nothing to do here.
continue;
}
// Extract the event group, filter, and add back to the undo stack
let events = undoStack.splice(i, undoStack.length - i);
events = filter(events, true);
undoStack.push(...events);
}
}
/**
* Filter the queued events and merge duplicates.
*
* @param queueIn Array of events.
* @param forward True if forward (redo), false if backward (undo).
* @returns Array of filtered events.
*/
export function filter(queueIn: Abstract[], forward: boolean): Abstract[] {
let queue = queueIn.slice();
// Shallow copy of queue.
if (!forward) {
// Undo is merged in reverse order.
queue.reverse();
}
const mergedQueue = [];
const hash = Object.create(null);
// Merge duplicates.
for (let i = 0, event; (event = queue[i]); i++) {
if (!event.isNull()) {
// Treat all UI events as the same type in hash table.
const eventType = event.isUiEvent ? UI : event.type;
// TODO(#5927): Check whether `blockId` exists before accessing it.
const blockId = (event as AnyDuringMigration).blockId;
const key = [eventType, blockId, event.workspaceId].join(' ');
const lastEntry = hash[key];
const lastEvent = lastEntry ? lastEntry.event : null;
if (!lastEntry) {
// Each item in the hash table has the event and the index of that event
// in the input array. This lets us make sure we only merge adjacent
// move events.
hash[key] = {event, index: i};
mergedQueue.push(event);
} else if (event.type === MOVE && lastEntry.index === i - 1) {
const moveEvent = event as BlockMove;
// Merge move events.
lastEvent.newParentId = moveEvent.newParentId;
lastEvent.newInputName = moveEvent.newInputName;
lastEvent.newCoordinate = moveEvent.newCoordinate;
if (moveEvent.reason) {
if (lastEvent.reason) {
// Concatenate reasons without duplicates.
const reasonSet = new Set(
moveEvent.reason.concat(lastEvent.reason),
);
lastEvent.reason = Array.from(reasonSet);
} else {
lastEvent.reason = moveEvent.reason;
}
}
lastEntry.index = i;
} else if (
event.type === CHANGE &&
(event as BlockChange).element === lastEvent.element &&
(event as BlockChange).name === lastEvent.name
) {
const changeEvent = event as BlockChange;
// Merge change events.
lastEvent.newValue = changeEvent.newValue;
} else if (event.type === VIEWPORT_CHANGE) {
const viewportEvent = event as ViewportChange;
// Merge viewport change events.
lastEvent.viewTop = viewportEvent.viewTop;
lastEvent.viewLeft = viewportEvent.viewLeft;
lastEvent.scale = viewportEvent.scale;
lastEvent.oldScale = viewportEvent.oldScale;
} else if (event.type === CLICK && lastEvent.type === BUBBLE_OPEN) {
// Drop click events caused by opening/closing bubbles.
} else {
// Collision: newer events should merge into this event to maintain
// order.
hash[key] = {event, index: i};
mergedQueue.push(event);
}
}
}
// Filter out any events that have become null due to merging.
queue = mergedQueue.filter(function (e) {
return !e.isNull();
});
if (!forward) {
// Restore undo order.
queue.reverse();
}
// Move mutation events to the top of the queue.
// Intentionally skip first event.
for (let i = 1, event; (event = queue[i]); i++) {
// AnyDuringMigration because: Property 'element' does not exist on type
// 'Abstract'.
if (
event.type === CHANGE &&
(event as AnyDuringMigration).element === 'mutation'
) {
queue.unshift(queue.splice(i, 1)[0]);
}
}
return queue;
}
/**
* Modify pending undo events so that when they are fired they don't land
* in the undo stack. Called by Workspace.clearUndo.
*/
export function clearPendingUndo() {
for (let i = 0, event; (event = FIRE_QUEUE[i]); i++) {
event.recordUndo = false;
}
}
/**
* Stop sending events. Every call to this function MUST also call enable.
*/
export function disable() {
disabled++;
}
/**
* Start sending events. Unless events were already disabled when the
* corresponding call to disable was made.
*/
export function enable() {
disabled--;
}
/**
* Returns whether events may be fired or not.
*
* @returns True if enabled.
*/
export function isEnabled(): boolean {
return disabled === 0;
}
/**
* Current group.
*
* @returns ID string.
*/
export function getGroup(): string {
return group;
}
/**
* Start or stop a group.
*
* @param state True to start new group, false to end group.
* String to set group explicitly.
*/
export function setGroup(state: boolean | string) {
TEST_ONLY.setGroupInternal(state);
}
/**
* Private version of setGroup for stubbing in tests.
*/
function setGroupInternal(state: boolean | string) {
if (typeof state === 'boolean') {
group = state ? idGenerator.genUid() : '';
} else {
group = state;
}
}
/**
* Compute a list of the IDs of the specified block and all its descendants.
*
* @param block The root block.
* @returns List of block IDs.
* @internal
*/
export function getDescendantIds(block: Block): string[] {
const ids = [];
const descendants = block.getDescendants(false);
for (let i = 0, descendant; (descendant = descendants[i]); i++) {
ids[i] = descendant.id;
}
return ids;
}
/**
* Decode the JSON into an event.
*
* @param json JSON representation.
* @param workspace Target workspace for event.
* @returns The event represented by the JSON.
* @throws {Error} if an event type is not found in the registry.
*/
export function fromJson(
json: AnyDuringMigration,
workspace: Workspace,
): Abstract {
const eventClass = get(json['type']);
if (!eventClass) throw Error('Unknown event type.');
return (eventClass as any).fromJson(json, workspace);
}
/**
* Gets the class for a specific event type from the registry.
*
* @param eventType The type of the event to get.
* @returns The event class with the given type.
*/
export function get(
eventType: string,
): new (...p1: AnyDuringMigration[]) => Abstract {
const event = registry.getClass(registry.Type.EVENT, eventType);
if (!event) {
throw new Error(`Event type ${eventType} not found in registry.`);
}
return event;
}
/**
* Set if a block is disabled depending on whether it is properly connected.
* Use this on applications where all blocks should be connected to a top block.
*
* @param event Custom data for event.
*/
export function disableOrphans(event: Abstract) {
if (event.type === MOVE || event.type === CREATE) {
const blockEvent = event as BlockMove | BlockCreate;
if (!blockEvent.workspaceId) {
return;
}
const eventWorkspace = common.getWorkspaceById(
blockEvent.workspaceId,
) as WorkspaceSvg;
if (!blockEvent.blockId) {
throw new Error('Encountered a blockEvent without a proper blockId');
}
let block = eventWorkspace.getBlockById(blockEvent.blockId);
if (block) {
// Changing blocks as part of this event shouldn't be undoable.
const initialUndoFlag = recordUndo;
try {
recordUndo = false;
const parent = block.getParent();
if (
parent &&
!parent.hasDisabledReason(ORPHANED_BLOCK_DISABLED_REASON)
) {
const children = block.getDescendants(false);
for (let i = 0, child; (child = children[i]); i++) {
child.setDisabledReason(false, ORPHANED_BLOCK_DISABLED_REASON);
}
} else if (
(block.outputConnection || block.previousConnection) &&
!eventWorkspace.isDragging()
) {
do {
block.setDisabledReason(true, ORPHANED_BLOCK_DISABLED_REASON);
block = block.getNextBlock();
} while (block);
}
} finally {
recordUndo = initialUndoFlag;
}
}
}
}
export const TEST_ONLY = {
FIRE_QUEUE,
fireNow,
fireInternal,
setGroupInternal,
};