mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-11 23:26:30 +08:00
328 lines
8.3 KiB
TypeScript
328 lines
8.3 KiB
TypeScript
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<SurfaceBlockModel | null>(null);
|
|
|
|
readonly cursor$ = new Signal<CursorType>();
|
|
|
|
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<object> | string): void {
|
|
element = typeof element === 'string' ? element : element.id;
|
|
|
|
assertType<string>(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<object> = GfxModel | BlockModel<object>,
|
|
>(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<object>)[] {
|
|
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<string, unknown>
|
|
): 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);
|
|
}
|
|
}
|