-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add a BlockFlyoutInflater class. (#8591)
* feat: Add a BlockFlyoutInflater class. * fix: Fix the capacity filter callback argument name. * fix: Fix addBlockListeners comment. * chore: Add license. * chore: Add TSDoc. * refactor: Make capacity filtering a normal method. * fix: Bind flyout filter to `this`.
- Loading branch information
Showing
1 changed file
with
262 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,262 @@ | ||
/** | ||
* @license | ||
* Copyright 2024 Google LLC | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
import type {IFlyout} from './interfaces/i_flyout.js'; | ||
import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js'; | ||
import type {IBoundedElement} from './interfaces/i_bounded_element.js'; | ||
import {BlockSvg} from './block_svg.js'; | ||
import type {WorkspaceSvg} from './workspace_svg.js'; | ||
import * as utilsXml from './utils/xml.js'; | ||
import * as eventUtils from './events/utils.js'; | ||
import * as Xml from './xml.js'; | ||
import * as blocks from './serialization/blocks.js'; | ||
import * as common from './common.js'; | ||
import * as registry from './registry.js'; | ||
import {MANUALLY_DISABLED} from './constants.js'; | ||
import type {Abstract as AbstractEvent} from './events/events_abstract.js'; | ||
import type {BlockInfo} from './utils/toolbox.js'; | ||
import * as browserEvents from './browser_events.js'; | ||
|
||
/** | ||
* The language-neutral ID for when the reason why a block is disabled is | ||
* because the workspace is at block capacity. | ||
*/ | ||
const WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON = | ||
'WORKSPACE_AT_BLOCK_CAPACITY'; | ||
|
||
/** | ||
* Class responsible for creating blocks for flyouts. | ||
*/ | ||
export class BlockFlyoutInflater implements IFlyoutInflater { | ||
protected permanentlyDisabledBlocks = new Set<BlockSvg>(); | ||
protected listeners = new Map<string, browserEvents.Data[]>(); | ||
protected flyoutWorkspace?: WorkspaceSvg; | ||
protected flyout?: IFlyout; | ||
private capacityWrapper: (event: AbstractEvent) => void; | ||
|
||
/** | ||
* Creates a new BlockFlyoutInflater instance. | ||
*/ | ||
constructor() { | ||
this.capacityWrapper = this.filterFlyoutBasedOnCapacity.bind(this); | ||
} | ||
|
||
/** | ||
* Inflates a flyout block from the given state and adds it to the flyout. | ||
* | ||
* @param state A JSON representation of a flyout block. | ||
* @param flyoutWorkspace The workspace to create the block on. | ||
* @returns A newly created block. | ||
*/ | ||
load(state: Object, flyoutWorkspace: WorkspaceSvg): IBoundedElement { | ||
this.setFlyoutWorkspace(flyoutWorkspace); | ||
this.flyout = flyoutWorkspace.targetWorkspace?.getFlyout() ?? undefined; | ||
const block = this.createBlock(state as BlockInfo, flyoutWorkspace); | ||
|
||
if (!block.isEnabled()) { | ||
// Record blocks that were initially disabled. | ||
// Do not enable these blocks as a result of capacity filtering. | ||
this.permanentlyDisabledBlocks.add(block); | ||
} else { | ||
this.updateStateBasedOnCapacity(block); | ||
} | ||
|
||
// Mark blocks as being inside a flyout. This is used to detect and | ||
// prevent the closure of the flyout if the user right-clicks on such | ||
// a block. | ||
block.getDescendants(false).forEach((b) => (b.isInFlyout = true)); | ||
this.addBlockListeners(block); | ||
|
||
return block; | ||
} | ||
|
||
/** | ||
* Creates a block on the given workspace. | ||
* | ||
* @param blockDefinition A JSON representation of the block to create. | ||
* @param workspace The workspace to create the block on. | ||
* @returns The newly created block. | ||
*/ | ||
createBlock(blockDefinition: BlockInfo, workspace: WorkspaceSvg): BlockSvg { | ||
let block; | ||
if (blockDefinition['blockxml']) { | ||
const xml = ( | ||
typeof blockDefinition['blockxml'] === 'string' | ||
? utilsXml.textToDom(blockDefinition['blockxml']) | ||
: blockDefinition['blockxml'] | ||
) as Element; | ||
block = Xml.domToBlockInternal(xml, workspace); | ||
} else { | ||
if (blockDefinition['enabled'] === undefined) { | ||
blockDefinition['enabled'] = | ||
blockDefinition['disabled'] !== 'true' && | ||
blockDefinition['disabled'] !== true; | ||
} | ||
if ( | ||
blockDefinition['disabledReasons'] === undefined && | ||
blockDefinition['enabled'] === false | ||
) { | ||
blockDefinition['disabledReasons'] = [MANUALLY_DISABLED]; | ||
} | ||
block = blocks.appendInternal(blockDefinition as blocks.State, workspace); | ||
} | ||
|
||
return block as BlockSvg; | ||
} | ||
|
||
/** | ||
* Returns the amount of space that should follow this block. | ||
* | ||
* @param state A JSON representation of a flyout block. | ||
* @param defaultGap The default spacing for flyout items. | ||
* @returns The amount of space that should follow this block. | ||
*/ | ||
gapForElement(state: Object, defaultGap: number): number { | ||
const blockState = state as BlockInfo; | ||
let gap; | ||
if (blockState['gap']) { | ||
gap = parseInt(String(blockState['gap'])); | ||
} else if (blockState['blockxml']) { | ||
const xml = ( | ||
typeof blockState['blockxml'] === 'string' | ||
? utilsXml.textToDom(blockState['blockxml']) | ||
: blockState['blockxml'] | ||
) as Element; | ||
gap = parseInt(xml.getAttribute('gap')!); | ||
} | ||
|
||
return !gap || isNaN(gap) ? defaultGap : gap; | ||
} | ||
|
||
/** | ||
* Disposes of the given block. | ||
* | ||
* @param element The flyout block to dispose of. | ||
*/ | ||
disposeElement(element: IBoundedElement): void { | ||
if (!(element instanceof BlockSvg)) return; | ||
this.removeListeners(element.id); | ||
element.dispose(false, false); | ||
} | ||
|
||
/** | ||
* Removes event listeners for the block with the given ID. | ||
* | ||
* @param blockId The ID of the block to remove event listeners from. | ||
*/ | ||
protected removeListeners(blockId: string) { | ||
const blockListeners = this.listeners.get(blockId) ?? []; | ||
blockListeners.forEach((l) => browserEvents.unbind(l)); | ||
this.listeners.delete(blockId); | ||
} | ||
|
||
/** | ||
* Updates this inflater's flyout workspace. | ||
* | ||
* @param workspace The workspace of the flyout that owns this inflater. | ||
*/ | ||
protected setFlyoutWorkspace(workspace: WorkspaceSvg) { | ||
if (this.flyoutWorkspace === workspace) return; | ||
|
||
if (this.flyoutWorkspace) { | ||
this.flyoutWorkspace.targetWorkspace?.removeChangeListener( | ||
this.capacityWrapper, | ||
); | ||
} | ||
this.flyoutWorkspace = workspace; | ||
this.flyoutWorkspace.targetWorkspace?.addChangeListener( | ||
this.capacityWrapper, | ||
); | ||
} | ||
|
||
/** | ||
* Updates the enabled state of the given block based on the capacity of the | ||
* workspace. | ||
* | ||
* @param block The block to update the enabled/disabled state of. | ||
*/ | ||
private updateStateBasedOnCapacity(block: BlockSvg) { | ||
const enable = this.flyoutWorkspace?.targetWorkspace?.isCapacityAvailable( | ||
common.getBlockTypeCounts(block), | ||
); | ||
let currentBlock: BlockSvg | null = block; | ||
while (currentBlock) { | ||
currentBlock.setDisabledReason( | ||
!enable, | ||
WORKSPACE_AT_BLOCK_CAPACITY_DISABLED_REASON, | ||
); | ||
currentBlock = currentBlock.getNextBlock(); | ||
} | ||
} | ||
|
||
/** | ||
* Add listeners to a block that has been added to the flyout. | ||
* | ||
* @param block The block to add listeners for. | ||
*/ | ||
protected addBlockListeners(block: BlockSvg) { | ||
const blockListeners = []; | ||
|
||
blockListeners.push( | ||
browserEvents.conditionalBind( | ||
block.getSvgRoot(), | ||
'pointerdown', | ||
block, | ||
(e: PointerEvent) => { | ||
const gesture = this.flyoutWorkspace?.targetWorkspace?.getGesture(e); | ||
const flyout = this.flyoutWorkspace?.targetWorkspace?.getFlyout(); | ||
if (gesture && flyout) { | ||
gesture.setStartBlock(block); | ||
gesture.handleFlyoutStart(e, flyout); | ||
} | ||
}, | ||
), | ||
); | ||
|
||
blockListeners.push( | ||
browserEvents.bind(block.getSvgRoot(), 'pointerenter', null, () => { | ||
if (!this.flyoutWorkspace?.targetWorkspace?.isDragging()) { | ||
block.addSelect(); | ||
} | ||
}), | ||
); | ||
blockListeners.push( | ||
browserEvents.bind(block.getSvgRoot(), 'pointerleave', null, () => { | ||
if (!this.flyoutWorkspace?.targetWorkspace?.isDragging()) { | ||
block.removeSelect(); | ||
} | ||
}), | ||
); | ||
|
||
this.listeners.set(block.id, blockListeners); | ||
} | ||
|
||
/** | ||
* Updates the state of blocks in our owning flyout to be disabled/enabled | ||
* based on the capacity of the workspace for more blocks of that type. | ||
* | ||
* @param event The event that triggered this update. | ||
*/ | ||
private filterFlyoutBasedOnCapacity(event: AbstractEvent) { | ||
if ( | ||
!this.flyoutWorkspace || | ||
(event && | ||
!( | ||
event.type === eventUtils.BLOCK_CREATE || | ||
event.type === eventUtils.BLOCK_DELETE | ||
)) | ||
) | ||
return; | ||
|
||
this.flyoutWorkspace.getTopBlocks(false).forEach((block) => { | ||
if (!this.permanentlyDisabledBlocks.has(block)) { | ||
this.updateStateBasedOnCapacity(block); | ||
} | ||
}); | ||
} | ||
} | ||
|
||
registry.register(registry.Type.FLYOUT_INFLATER, 'block', BlockFlyoutInflater); |