import { DisposableGroup } from '@blocksuite/global/disposable'; import { Bound, getCommonBound, getCommonBoundWithRotation, type IBound, } from '@blocksuite/global/gfx'; import { assertType } from '@blocksuite/global/utils'; import type { BlockModel } from '@blocksuite/store'; import { Signal } from '@preact/signals-core'; import last from 'lodash-es/last'; import { LifeCycleWatcher } from '../extension/lifecycle-watcher.js'; import type { BlockStdScope } from '../scope/block-std-scope.js'; import { onSurfaceAdded } from '../utils/gfx.js'; import type { BlockComponent } from '../view/index.js'; import type { CursorType } from './cursor.js'; import { GfxClassExtenderIdentifier, GfxExtensionIdentifier, } from './extension.js'; import { GridManager } from './grid.js'; import { gfxControllerKey } from './identifiers.js'; import { KeyboardController } from './keyboard.js'; import { LayerManager } from './layer.js'; import type { PointTestOptions } from './model/base.js'; import { GfxBlockElementModel } from './model/gfx-block-model.js'; import type { GfxModel } from './model/model.js'; import { GfxGroupLikeElementModel, GfxPrimitiveElementModel, } from './model/surface/element-model.js'; import type { SurfaceBlockModel } from './model/surface/surface-model.js'; import { FIT_TO_SCREEN_PADDING, Viewport, ZOOM_INITIAL } from './viewport.js'; export class GfxController extends LifeCycleWatcher { static override key = gfxControllerKey; private readonly _disposables: DisposableGroup = new DisposableGroup(); private readonly _surface$ = new Signal(null); readonly cursor$ = new Signal(); readonly keyboard: KeyboardController; readonly viewport: Viewport = new Viewport(); get grid() { return this.std.get(GridManager); } get layer() { return this.std.get(LayerManager); } get doc() { return this.std.store; } get elementsBound() { return getCommonBoundWithRotation(this.gfxElements); } get gfxElements(): GfxModel[] { return [...this.layer.blocks, ...this.layer.canvasElements]; } get surface$() { return this._surface$; } get surface() { return this._surface$.peek(); } get surfaceComponent(): BlockComponent | null { return this.surface ? (this.std.view.getBlock(this.surface.id) ?? null) : null; } constructor(std: BlockStdScope) { super(std); this.keyboard = new KeyboardController(std); this._disposables.add( onSurfaceAdded(this.doc, surface => { this._surface$.value = surface; }) ); this._disposables.add(this.viewport); this._disposables.add(this.keyboard); this.std.provider.getAll(GfxClassExtenderIdentifier).forEach(ext => { ext.extendFn(this); }); } deleteElement(element: GfxModel | BlockModel | string): void { element = typeof element === 'string' ? element : element.id; assertType(element); if (this.surface?.hasElementById(element)) { this.surface.deleteElement(element); } else { const block = this.doc.getBlock(element)?.model; block && this.doc.deleteBlock(block); } } /** * Get a block or element by its id. * Note that non-gfx block can also be queried in this method. * @param id * @returns */ getElementById< T extends GfxModel | BlockModel = GfxModel | BlockModel, >(id: string): T | null { // @ts-expect-error FIXME: ts error return ( this.surface?.getElementById(id) ?? this.doc.getBlock(id)?.model ?? null ); } /** * Get elements on a specific point. * @param x * @param y * @param options */ getElementByPoint( x: number, y: number, options: { all: true } & PointTestOptions ): GfxModel[]; getElementByPoint( x: number, y: number, options?: { all?: false } & PointTestOptions ): GfxModel | null; getElementByPoint( x: number, y: number, options: PointTestOptions & { all?: boolean; } = { all: false, hitThreshold: 10 } ): GfxModel | GfxModel[] | null { options.zoom = this.viewport.zoom; options.hitThreshold ??= 10; const hitThreshold = options.hitThreshold; const responsePadding = options.responsePadding ?? [ hitThreshold / 2, hitThreshold / 2, ]; const all = options.all ?? false; const hitTestBound = { x: x - responsePadding[0], y: y - responsePadding[1], w: responsePadding[0] * 2, h: responsePadding[1] * 2, }; const candidates = this.grid.search(hitTestBound); const picked = candidates.filter( elm => elm.includesPoint(x, y, options as PointTestOptions, this.std.host) || elm.externalBound?.isPointInBound([x, y]) ); picked.sort(this.layer.compare); if (all) { return picked; } return last(picked) ?? null; } getElementInGroup( x: number, y: number, options?: PointTestOptions ): GfxModel | null { const selectionManager = this.selection; const results = this.getElementByPoint(x, y, { ...options, all: true, }); let picked = last(results) ?? null; const { activeGroup } = selectionManager; const first = picked; if (activeGroup && picked && activeGroup.hasDescendant(picked)) { let index = results.length - 1; while ( picked === activeGroup || (picked instanceof GfxGroupLikeElementModel && picked.hasDescendant(activeGroup)) ) { picked = results[--index]; } } else if (picked) { let index = results.length - 1; while (picked.group instanceof GfxGroupLikeElementModel) { if (--index < 0) { picked = null; break; } picked = results[index]; } } return (picked ?? first) as GfxModel | null; } /** * Query all elements in an area. * @param bound * @param options */ getElementsByBound( bound: IBound | Bound, options?: { type: 'all' } ): GfxModel[]; getElementsByBound( bound: IBound | Bound, options: { type: 'canvas' } ): GfxPrimitiveElementModel[]; getElementsByBound( bound: IBound | Bound, options: { type: 'block' } ): GfxBlockElementModel[]; getElementsByBound( bound: IBound | Bound, options: { type: 'block' | 'canvas' | 'all' } = { type: 'all', } ): GfxModel[] { bound = bound instanceof Bound ? bound : Bound.from(bound); let candidates = this.grid.search(bound); if (options.type !== 'all') { const filter = options.type === 'block' ? (elm: GfxModel) => elm instanceof GfxBlockElementModel : (elm: GfxModel) => elm instanceof GfxPrimitiveElementModel; candidates = candidates.filter(filter); } candidates.sort(this.layer.compare); return candidates; } getElementsByType(type: string): (GfxModel | BlockModel)[] { return ( this.surface?.getElementsByType(type) ?? this.doc.getBlocksByFlavour(type).map(b => b.model) ); } override mounted() { this.viewport.setShellElement(this.std.host); this.std.provider.getAll(GfxExtensionIdentifier).forEach(ext => { ext.mounted(); }); } override unmounted() { this.std.provider.getAll(GfxExtensionIdentifier).forEach(ext => { ext.unmounted(); }); this.viewport.clearViewportElement(); this._disposables.dispose(); } updateElement( element: GfxModel | string, props: Record ): void { const elemId = typeof element === 'string' ? element : element.id; if (this.surface?.hasElementById(elemId)) { this.surface.updateElement(elemId, props); } else { const block = this.doc.getBlock(elemId); block && this.doc.updateBlock(block.model, props); } } fitToScreen( options: { bounds?: Bound[]; smooth?: boolean; padding?: [number, number, number, number]; } = { smooth: false, padding: [0, 0, 0, 0], } ) { const elemBounds = options.bounds ?? this.gfxElements.map(element => Bound.deserialize(element.xywh)); const commonBound = getCommonBound(elemBounds); const { zoom, centerX, centerY } = this.viewport.getFitToScreenData( commonBound, options.padding, ZOOM_INITIAL, FIT_TO_SCREEN_PADDING ); this.viewport.setViewport(zoom, [centerX, centerY], options.smooth); } }