Files
blockly/core/events/utils.ts

470 lines
14 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 deprecation from '../utils/deprecation.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 {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 {
isBlockChange,
isBlockCreate,
isBlockMove,
isBubbleOpen,
isClick,
isViewportChange,
} from './predicates.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;
/**
* 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 queued for firing. */
const FIRE_QUEUE: Abstract[] = [];
/**
* Enqueue an event to be dispatched to change listeners.
*
* Notes:
*
* - Events are enqueued until a timeout, generally after rendering is
* complete or at the end of the current microtask, if not running
* in a browser.
* - Queued events are subject to destructive modification by being
* combined with later-enqueued events, but only until they are
* fired.
* - Events are dispatched via the fireChangeListener method on the
* affected workspace.
*
* @param event Any Blockly 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);
}
}
enqueueEvent(event);
}
/** Dispatch all queued events. */
function fireNow() {
const queue = filter(FIRE_QUEUE, true);
FIRE_QUEUE.length = 0;
for (const event of queue) {
if (!event.workspaceId) continue;
common.getWorkspaceById(event.workspaceId)?.fireChangeListener(event);
}
}
/**
* Enqueue an event on FIRE_QUEUE.
*
* Normally this is equivalent to FIRE_QUEUE.push(event), but if the
* enqueued event is a BlockChange event and the most recent event(s)
* on the queue are BlockMove events that (re)connect other blocks to
* the changed block (and belong to the same event group) then the
* enqueued event will be enqueued before those events rather than
* after.
*
* This is a workaround for a problem caused by the fact that
* MutatorIcon.prototype.recomposeSourceBlock can only fire a
* BlockChange event after the mutating block's compose method
* returns, meaning that if the compose method reconnects child blocks
* the corresponding BlockMove events are emitted _before_ the
* BlockChange event, causing issues with undo, mirroring, etc.; see
* https://github.com/google/blockly/issues/8225#issuecomment-2195751783
* (and following) for details.
*/
function enqueueEvent(event: Abstract) {
if (isBlockChange(event) && event.element === 'mutation') {
let i;
for (i = FIRE_QUEUE.length; i > 0; i--) {
const otherEvent = FIRE_QUEUE[i - 1];
if (
otherEvent.group !== event.group ||
otherEvent.workspaceId !== event.workspaceId ||
!isBlockMove(otherEvent) ||
otherEvent.newParentId !== event.blockId
) {
break;
}
}
FIRE_QUEUE.splice(i, 0, event);
return;
}
FIRE_QUEUE.push(event);
}
/**
* Filter the queued events by merging duplicates, removing null
* events and reording BlockChange events.
*
* History of this function:
*
* This function was originally added in commit cf257ea5 with the
* intention of dramatically reduing the total number of dispatched
* events. Initialy it affected only BlockMove events but others were
* added over time.
*
* Code was added to reorder BlockChange events added in commit
* 5578458, for uncertain reasons but most probably as part of an
* only-partially-successful attemp to fix problems with event
* ordering during block mutations. This code should probably have
* been added to the top of the function, before merging and
* null-removal, but was added at the bottom for now-forgotten
* reasons. See these bug investigations for a fuller discussion of
* the underlying issue and some of the failures that arose because of
* this incomplete/incorrect fix:
*
* https://github.com/google/blockly/issues/8225#issuecomment-2195751783
* https://github.com/google/blockly/issues/2037#issuecomment-2209696351
*
* Later, in PR #1205 the original O(n^2) implementation was replaced
* by a linear-time implementation, though additonal fixes were made
* subsequently.
*
* In August 2024 a number of significant simplifications were made:
*
* This function was previously called from Workspace.prototype.undo,
* but the mutation of events by this function was the cause of issue
* #7026 (note that events would combine differently in reverse order
* vs. forward order). The originally-chosen fix for this was the
* addition (in PR #7069) of code to fireNow to post-filter the
* .undoStack_ and .redoStack_ of any workspace that had just been
* involved in dispatching events; this apparently resolved the issue
* but added considerable additional complexity and made it difficult
* to reason about how events are processed for undo/redo, so both the
* call from undo and the post-processing code was removed, and
* forward=true was made the default while calling the function with
* forward=false was deprecated.
*
* At the same time, the buggy code to reorder BlockChange events was
* replaced by a less-buggy version of the same functionality in a new
* function, enqueueEvent, called from fireInternal, thus assuring
* that events will be in the correct order at the time filter is
* called.
*
* Additionally, the event merging code was modified so that only
* immediately adjacent events would be merged. This simplified the
* implementation while ensuring that the merging of events cannot
* cause them to be reordered.
*
* @param queue Array of events.
* @param forward True if forward (redo), false if backward (undo).
* This parameter is deprecated: true is now the default and
* calling filter with it set to false will in future not be
* supported.
* @returns Array of filtered events.
*/
export function filter(queue: Abstract[], forward = true): Abstract[] {
if (!forward) {
deprecation.warn('filter(queue, /*forward=*/false)', 'v11.2', 'v12');
// Undo was merged in reverse order.
queue = queue.slice().reverse(); // Copy before reversing in place.
}
const mergedQueue: Abstract[] = [];
// Merge duplicates.
for (const event of queue) {
const lastEvent = mergedQueue[mergedQueue.length - 1];
if (event.isNull()) continue;
if (
!lastEvent ||
lastEvent.workspaceId !== event.workspaceId ||
lastEvent.group !== event.group
) {
mergedQueue.push(event);
continue;
}
if (
isBlockMove(event) &&
isBlockMove(lastEvent) &&
event.blockId === lastEvent.blockId
) {
// Merge move events.
lastEvent.newParentId = event.newParentId;
lastEvent.newInputName = event.newInputName;
lastEvent.newCoordinate = event.newCoordinate;
// Concatenate reasons without duplicates.
if (lastEvent.reason || event.reason) {
lastEvent.reason = Array.from(
new Set((lastEvent.reason ?? []).concat(event.reason ?? [])),
);
}
} else if (
isBlockChange(event) &&
isBlockChange(lastEvent) &&
event.blockId === lastEvent.blockId &&
event.element === lastEvent.element &&
event.name === lastEvent.name
) {
// Merge change events.
lastEvent.newValue = event.newValue;
} else if (isViewportChange(event) && isViewportChange(lastEvent)) {
// Merge viewport change events.
lastEvent.viewTop = event.viewTop;
lastEvent.viewLeft = event.viewLeft;
lastEvent.scale = event.scale;
lastEvent.oldScale = event.oldScale;
} else if (isClick(event) && isBubbleOpen(lastEvent)) {
// Drop click events caused by opening/closing bubbles.
} else {
mergedQueue.push(event);
}
}
// Filter out any events that have become null due to merging.
queue = mergedQueue.filter((e) => !e.isNull());
if (!forward) {
// Restore undo order.
queue.reverse();
}
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 (isBlockMove(event) || isBlockCreate(event)) {
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,
enqueueEvent,
fireNow,
fireInternal,
setGroupInternal,
};