refactor(editor): rename block-std to std (#11250)

Closes: BS-2946
This commit is contained in:
Saul-Mirone
2025-03-28 07:20:34 +00:00
parent 4498676a96
commit 205cd7a86d
1029 changed files with 1580 additions and 1698 deletions
@@ -0,0 +1,336 @@
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/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;
}
/**
* Get the top element in the given point.
* If the element is in a group, the group will be returned.
* If the group is currently selected, the child element will be returned.
* @param x
* @param y
* @param options
* @returns
*/
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);
}
}
@@ -0,0 +1,54 @@
export type StandardCursor =
| 'default'
| 'pointer'
| 'move'
| 'text'
| 'crosshair'
| 'not-allowed'
| 'grab'
| 'grabbing'
| 'nwse-resize'
| 'nesw-resize'
| 'ew-resize'
| 'ns-resize'
| 'n-resize'
| 's-resize'
| 'w-resize'
| 'e-resize'
| 'ne-resize'
| 'se-resize'
| 'sw-resize'
| 'nw-resize'
| 'zoom-in'
| 'zoom-out'
| 'help'
| 'wait'
| 'progress'
| 'copy'
| 'alias'
| 'context-menu'
| 'cell'
| 'vertical-text'
| 'no-drop'
| 'not-allowed'
| 'all-scroll'
| 'col-resize'
| 'row-resize'
| 'none'
| 'inherit'
| 'initial'
| 'unset';
export type URLCursor = `url(${string})`;
export type URLCursorWithCoords = `url(${string}) ${number} ${number}`;
export type URLCursorWithFallback =
| `${URLCursor}, ${StandardCursor}`
| `${URLCursorWithCoords}, ${StandardCursor}`;
export type CursorType =
| StandardCursor
| URLCursor
| URLCursorWithCoords
| URLCursorWithFallback;
@@ -0,0 +1,73 @@
import type { Bound } from '@blocksuite/global/gfx';
import type { GfxBlockComponent } from '../../view';
import type { GfxModel } from '../model/model';
import type { GfxElementModelView } from '../view/view';
export type DragInitializationOption = {
movingElements: GfxModel[];
event: PointerEvent | MouseEvent;
onDragEnd?: () => void;
};
export type DragExtensionInitializeContext = {
/**
* The elements that are being dragged.
* The extension can modify this array to add or remove dragging elements.
*/
elements: GfxModel[];
/**
* Prevent the default drag behavior. The following drag events will not be triggered.
*/
preventDefault: () => void;
/**
* The start position of the drag in model space.
*/
dragStartPos: Readonly<{
x: number;
y: number;
}>;
};
export type ExtensionBaseEvent = {
/**
* The elements that respond to the event.
*/
elements: {
view: GfxBlockComponent | GfxElementModelView;
originalBound: Bound;
model: GfxModel;
}[];
/**
* The mouse event
*/
event: PointerEvent;
/**
* The start position of the drag in model space.
*/
dragStartPos: Readonly<{
x: number;
y: number;
}>;
/**
* The last position of the drag in model space.
*/
dragLastPos: Readonly<{
x: number;
y: number;
}>;
};
export type ExtensionDragStartContext = ExtensionBaseEvent;
export type ExtensionDragMoveContext = ExtensionBaseEvent & {
dx: number;
dy: number;
};
export type ExtensionDragEndContext = ExtensionDragMoveContext;
@@ -0,0 +1,63 @@
import { Bound } from '@blocksuite/global/gfx';
import last from 'lodash-es/last';
import type { PointerEventState } from '../../../event';
import type { GfxController } from '../..';
import type { GfxElementModelView } from '../../view/view';
export class CanvasEventHandler {
private _currentStackedElm: GfxElementModelView[] = [];
private _callInReverseOrder(
callback: (view: GfxElementModelView) => void,
arr = this._currentStackedElm
) {
for (let i = arr.length - 1; i >= 0; i--) {
const view = arr[i];
callback(view);
}
}
constructor(private readonly gfx: GfxController) {}
click(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('click', _evt);
}
dblClick(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('dblclick', _evt);
}
pointerDown(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('pointerdown', _evt);
}
pointerMove(_evt: PointerEventState): void {
const [x, y] = this.gfx.viewport.toModelCoord(_evt.x, _evt.y);
const hoveredElmViews = this.gfx.grid
.search(new Bound(x - 5, y - 5, 10, 10), {
filter: ['canvas', 'local'],
})
.map(model => this.gfx.view.get(model)) as GfxElementModelView[];
const currentStackedViews = new Set(this._currentStackedElm);
const visited = new Set<GfxElementModelView>();
this._callInReverseOrder(view => {
if (currentStackedViews.has(view)) {
visited.add(view);
view.dispatch('pointermove', _evt);
} else {
view.dispatch('pointerenter', _evt);
}
}, hoveredElmViews);
this._callInReverseOrder(
view => !visited.has(view) && view.dispatch('pointerleave', _evt)
);
this._currentStackedElm = hoveredElmViews;
}
pointerUp(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('pointerup', _evt);
}
}
@@ -0,0 +1,340 @@
import {
type Container,
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound, Point } from '@blocksuite/global/gfx';
import { Extension } from '@blocksuite/store';
import type { PointerEventState } from '../../event/state/pointer.js';
import { type GfxController } from '../controller.js';
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
import { GfxControllerIdentifier } from '../identifiers.js';
import type { GfxModel } from '../model/model.js';
import { type SupportedEvent } from '../view/view.js';
import type {
DragExtensionInitializeContext,
DragInitializationOption,
ExtensionDragEndContext,
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from './drag.js';
import { CanvasEventHandler } from './extension/canvas-event-handler.js';
type ExtensionPointerHandler = Exclude<
SupportedEvent,
'pointerleave' | 'pointerenter'
>;
export const TransformManagerIdentifier = GfxExtensionIdentifier(
'element-transform-manager'
) as ServiceIdentifier<ElementTransformManager>;
const CAMEL_CASE_MAP: {
[key in ExtensionPointerHandler]: keyof CanvasEventHandler;
} = {
click: 'click',
dblclick: 'dblClick',
pointerdown: 'pointerDown',
pointermove: 'pointerMove',
pointerup: 'pointerUp',
};
export class ElementTransformManager extends GfxExtension {
static override key = 'element-transform-manager';
private readonly _disposable = new DisposableGroup();
private canvasEventHandler = new CanvasEventHandler(this.gfx);
override mounted(): void {
this.canvasEventHandler = new CanvasEventHandler(this.gfx);
}
override unmounted(): void {
this._disposable.dispose();
}
get transformExtensions() {
return this.std.provider.getAll(TransformExtensionIdentifier);
}
get keyboard() {
return this.gfx.keyboard;
}
private _safeExecute(fn: () => void, errorMessage: string) {
try {
fn();
} catch (e) {
console.error(errorMessage, e);
}
}
/**
* Dispatch the event to canvas elements
* @param eventName
* @param evt
*/
dispatch(eventName: ExtensionPointerHandler, evt: PointerEventState) {
const handlerName = CAMEL_CASE_MAP[eventName];
this.canvasEventHandler[handlerName](evt);
const extension = this.transformExtensions;
extension.forEach(ext => {
ext[handlerName]?.(evt);
});
}
dispatchOnSelected(evt: PointerEventState) {
const { raw } = evt;
const { gfx } = this;
const [x, y] = gfx.viewport.toModelCoordFromClientCoord([raw.x, raw.y]);
const picked = this.gfx.getElementInGroup(x, y);
const tryGetLockedAncestor = (e: GfxModel) => {
if (e?.isLockedByAncestor()) {
return e.groups.findLast(group => group.isLocked()) ?? e;
}
return e;
};
if (picked) {
const lockedElement = tryGetLockedAncestor(picked);
const multiSelect = raw.shiftKey;
const view = gfx.view.get(lockedElement);
const context = {
selected: multiSelect ? !gfx.selection.has(picked.id) : true,
multiSelect,
event: raw,
position: Point.from([x, y]),
fallback: lockedElement !== picked,
};
view?.onSelected(context);
return true;
}
return false;
}
initializeDrag(options: DragInitializationOption) {
let cancelledByExt = false;
const context: DragExtensionInitializeContext = {
/**
* The elements that are being dragged
*/
elements: options.movingElements,
preventDefault: () => {
cancelledByExt = true;
},
dragStartPos: Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([
options.event.x,
options.event.y,
])
),
};
const extension = this.transformExtensions;
const activeExtensionHandlers = Array.from(
extension.values().map(ext => {
return ext.onDragInitialize(context);
})
);
if (cancelledByExt) {
activeExtensionHandlers.forEach(handler => handler.clear?.());
return;
}
const host = this.std.host;
const { event } = options;
const internal = {
elements: context.elements.map(model => {
return {
view: this.gfx.view.get(model)!,
originalBound: Bound.deserialize(model.xywh),
model: model,
};
}),
dragStartPos: Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
),
};
let dragLastPos = internal.dragStartPos;
let lastEvent = event;
const viewportWatcher = this.gfx.viewport.viewportMoved.subscribe(() => {
onDragMove(lastEvent as PointerEvent);
});
const onDragMove = (event: PointerEvent) => {
dragLastPos = Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
);
const moveContext: ExtensionDragMoveContext = {
...internal,
event,
dragLastPos,
dx: dragLastPos.x - internal.dragStartPos.x,
dy: dragLastPos.y - internal.dragStartPos.y,
};
// If shift key is pressed, restrict the movement to one direction
if (this.keyboard.shiftKey$.peek()) {
const angle = Math.abs(Math.atan2(moveContext.dy, moveContext.dx));
const direction =
angle < Math.PI / 4 || angle > 3 * (Math.PI / 4) ? 'dy' : 'dx';
moveContext[direction] = 0;
}
this._safeExecute(() => {
activeExtensionHandlers.forEach(handler =>
handler.onDragMove?.(moveContext)
);
}, 'Error while executing extension `onDragMove`');
internal.elements.forEach(element => {
const { view, originalBound } = element;
view.onDragMove({
currentBound: originalBound,
dx: moveContext.dx,
dy: moveContext.dy,
elements: internal.elements,
});
});
};
const onDragEnd = (event: PointerEvent) => {
host.removeEventListener('pointermove', onDragMove, false);
host.removeEventListener('pointerup', onDragEnd, false);
viewportWatcher.unsubscribe();
dragLastPos = Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
);
const endContext: ExtensionDragEndContext = {
...internal,
event,
dragLastPos,
dx: dragLastPos.x - internal.dragStartPos.x,
dy: dragLastPos.y - internal.dragStartPos.y,
};
this._safeExecute(() => {
activeExtensionHandlers.forEach(handler =>
handler.onDragEnd?.(endContext)
);
}, 'Error while executing extension `onDragEnd` handler');
this.std.store.transact(() => {
internal.elements.forEach(element => {
const { view, originalBound } = element;
view.onDragEnd({
currentBound: originalBound.moveDelta(endContext.dx, endContext.dy),
dx: endContext.dx,
dy: endContext.dy,
elements: internal.elements,
});
});
});
this._safeExecute(() => {
activeExtensionHandlers.forEach(handler => handler.clear?.());
}, 'Error while executing extension `clear` handler');
options.onDragEnd?.();
};
const listenEvent = () => {
host.addEventListener('pointermove', onDragMove, false);
host.addEventListener('pointerup', onDragEnd, false);
};
const dragStart = () => {
internal.elements.forEach(({ view, originalBound }) => {
view.onDragStart({
currentBound: originalBound,
elements: internal.elements,
});
});
const dragStartContext: ExtensionDragStartContext = {
...internal,
event: event as PointerEvent,
dragLastPos,
};
this._safeExecute(() => {
activeExtensionHandlers.forEach(handler =>
handler.onDragStart?.(dragStartContext)
);
}, 'Error while executing extension `onDragStart` handler');
};
listenEvent();
dragStart();
}
}
export const TransformExtensionIdentifier =
createIdentifier<TransformExtension>('element-transform-extension');
export class TransformExtension extends Extension {
static key: string;
get std() {
return this.gfx.std;
}
constructor(protected readonly gfx: GfxController) {
super();
}
mounted() {}
unmounted() {}
click(_: PointerEventState) {}
dblClick(_: PointerEventState) {}
pointerDown(_: PointerEventState) {}
pointerMove(_: PointerEventState) {}
pointerUp(_: PointerEventState) {}
onDragInitialize(_: DragExtensionInitializeContext): {
onDragStart?: (context: ExtensionDragStartContext) => void;
onDragMove?: (context: ExtensionDragMoveContext) => void;
onDragEnd?: (context: ExtensionDragEndContext) => void;
clear?: () => void;
} {
return {};
}
static override setup(di: Container) {
if (!this.key) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'key is not defined in the TransformExtension'
);
}
di.add(
this as unknown as { new (gfx: GfxController): TransformExtension },
[GfxControllerIdentifier]
);
di.addImpl(TransformExtensionIdentifier(this.key), provider =>
provider.get(this)
);
}
}
@@ -0,0 +1,77 @@
import type { Bound, IPoint } from '@blocksuite/global/gfx';
import type { GfxBlockComponent } from '../../view';
import type { GfxModel } from '../model/model';
import type { GfxElementModelView } from '../view/view';
export type DragStartContext = {
/**
* The elements that are being dragged
*/
elements: {
view: GfxBlockComponent | GfxElementModelView;
originalBound: Bound;
model: GfxModel;
}[];
/**
* The bound of element when drag starts
*/
currentBound: Bound;
};
export type DragMoveContext = DragStartContext & {
/**
* The delta x of current drag position compared to the start position in model coordinate.
*/
dx: number;
/**
* The delta y of current drag position compared to the start position in model coordinate.
*/
dy: number;
};
export type DragEndContext = DragMoveContext;
export type SelectedContext = {
/**
* The selected state of the element
*/
selected: boolean;
/**
* Whether is multi-select, usually triggered by shift key
*/
multiSelect: boolean;
/**
* The pointer event that triggers the selection
*/
event: PointerEvent;
/**
* The model position of the event pointer
*/
position: IPoint;
/**
* If the current selection is a fallback selection, like selecting the element inside a group, the group will be selected instead
*/
fallback: boolean;
};
export type GfxViewTransformInterface = {
onDragStart: (context: DragStartContext) => void;
onDragMove: (context: DragMoveContext) => void;
onDragEnd: (context: DragEndContext) => void;
onRotate: (context: {}) => void;
onResize: (context: {}) => void;
/**
* When the element is selected by the pointer
* @param context
* @returns
*/
onSelected: (context: SelectedContext) => void;
};
@@ -0,0 +1,53 @@
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Extension } from '@blocksuite/store';
import type { GfxController } from './controller.js';
import { GfxControllerIdentifier } from './identifiers.js';
export const GfxExtensionIdentifier =
createIdentifier<GfxExtension>('GfxExtension');
export const GfxClassExtenderIdentifier = createIdentifier<{
extendFn: (gfx: GfxController) => void;
}>('GfxClassExtender');
export abstract class GfxExtension extends Extension {
static key: string;
get std() {
return this.gfx.std;
}
constructor(protected readonly gfx: GfxController) {
super();
}
// This method is used to extend the GfxController
static extendGfx(_: GfxController) {}
static override setup(di: Container) {
if (!this.key) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'key is not defined in the GfxExtension'
);
}
di.addImpl(GfxClassExtenderIdentifier(this.key), {
extendFn: this.extendGfx,
});
di.add(this as unknown as { new (gfx: GfxController): GfxExtension }, [
GfxControllerIdentifier,
]);
di.addImpl(GfxExtensionIdentifier(this.key), provider =>
provider.get(this)
);
}
mounted() {}
unmounted() {}
}
+507
View File
@@ -0,0 +1,507 @@
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { IBound } from '@blocksuite/global/gfx';
import {
Bound,
getBoundWithRotation,
intersects,
} from '@blocksuite/global/gfx';
import type { BlockModel } from '@blocksuite/store';
import { compare } from '../utils/layer.js';
import { GfxExtension } from './extension.js';
import { GfxBlockElementModel } from './model/gfx-block-model.js';
import type { GfxModel } from './model/model.js';
import { GfxPrimitiveElementModel } from './model/surface/element-model.js';
import { GfxLocalElementModel } from './model/surface/local-element-model.js';
import { SurfaceBlockModel } from './model/surface/surface-model.js';
function getGridIndex(val: number) {
return Math.ceil(val / DEFAULT_GRID_SIZE) - 1;
}
function rangeFromBound(a: IBound): number[] {
if (a.rotate) a = getBoundWithRotation(a);
const minRow = getGridIndex(a.x);
const maxRow = getGridIndex(a.x + a.w);
const minCol = getGridIndex(a.y);
const maxCol = getGridIndex(a.y + a.h);
return [minRow, maxRow, minCol, maxCol];
}
function rangeFromElement(ele: GfxModel | GfxLocalElementModel): number[] {
const bound = ele.elementBound;
bound.w += ele.responseExtension[0] * 2;
bound.h += ele.responseExtension[1] * 2;
bound.x -= ele.responseExtension[0];
bound.y -= ele.responseExtension[1];
const minRow = getGridIndex(bound.x);
const maxRow = getGridIndex(bound.maxX);
const minCol = getGridIndex(bound.y);
const maxCol = getGridIndex(bound.maxY);
return [minRow, maxRow, minCol, maxCol];
}
function rangeFromElementExternal(ele: GfxModel): number[] | null {
if (!ele.externalXYWH) return null;
const bound = Bound.deserialize(ele.externalXYWH);
const minRow = getGridIndex(bound.x);
const maxRow = getGridIndex(bound.maxX);
const minCol = getGridIndex(bound.y);
const maxCol = getGridIndex(bound.maxY);
return [minRow, maxRow, minCol, maxCol];
}
export const DEFAULT_GRID_SIZE = 3000;
const typeFilters = {
block: (model: GfxModel | GfxLocalElementModel) =>
model instanceof GfxBlockElementModel,
canvas: (model: GfxModel | GfxLocalElementModel) =>
model instanceof GfxPrimitiveElementModel,
local: (model: GfxModel | GfxLocalElementModel) =>
model instanceof GfxLocalElementModel,
};
type FilterFunc = (model: GfxModel | GfxLocalElementModel) => boolean;
export class GridManager extends GfxExtension {
static override key = 'grid';
private readonly _elementToGrids = new Map<
GfxModel | GfxLocalElementModel,
Set<Set<GfxModel | GfxLocalElementModel>>
>();
private readonly _externalElementToGrids = new Map<
GfxModel,
Set<Set<GfxModel>>
>();
private readonly _externalGrids = new Map<string, Set<GfxModel>>();
private readonly _grids = new Map<
string,
Set<GfxModel | GfxLocalElementModel>
>();
get isEmpty() {
return this._grids.size === 0;
}
private _addToExternalGrids(element: GfxModel) {
const range = rangeFromElementExternal(element);
if (!range) {
this._removeFromExternalGrids(element);
return;
}
const [minRow, maxRow, minCol, maxCol] = range;
const grids = new Set<Set<GfxModel>>();
this._externalElementToGrids.set(element, grids);
for (let i = minRow; i <= maxRow; i++) {
for (let j = minCol; j <= maxCol; j++) {
let grid = this._getExternalGrid(i, j);
if (!grid) {
grid = this._createExternalGrid(i, j);
}
grid.add(element);
grids.add(grid);
}
}
}
private _createExternalGrid(row: number, col: number) {
const id = row + '|' + col;
const elements = new Set<GfxModel>();
this._externalGrids.set(id, elements);
return elements;
}
private _createGrid(row: number, col: number) {
const id = row + '|' + col;
const elements = new Set<GfxModel>();
this._grids.set(id, elements);
return elements;
}
private _getExternalGrid(row: number, col: number) {
const id = row + '|' + col;
return this._externalGrids.get(id);
}
private _getGrid(row: number, col: number) {
const id = row + '|' + col;
return this._grids.get(id);
}
private _removeFromExternalGrids(element: GfxModel) {
const grids = this._externalElementToGrids.get(element);
if (grids) {
for (const grid of grids) {
grid.delete(element);
}
}
}
private _searchExternal(
bound: IBound,
options: { filterFunc: FilterFunc; strict: boolean }
): Set<GfxModel> {
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound);
const results = new Set<GfxModel>();
const b = Bound.from(bound);
for (let i = minRow; i <= maxRow; i++) {
for (let j = minCol; j <= maxCol; j++) {
const gridElements = this._getExternalGrid(i, j);
if (!gridElements) continue;
for (const element of gridElements) {
const externalBound = element.externalBound;
if (
options.filterFunc(element) &&
externalBound &&
(options.strict
? b.contains(externalBound)
: intersects(externalBound, bound))
) {
results.add(element);
}
}
}
}
return results;
}
private _toFilterFunc(filters: (keyof typeof typeFilters | FilterFunc)[]) {
const filterFuncs: FilterFunc[] = filters.map(filter => {
if (typeof filter === 'function') {
return filter;
}
return typeFilters[filter];
});
return (model: GfxModel | GfxLocalElementModel) =>
filterFuncs.some(filter => filter(model));
}
add(element: GfxModel | GfxLocalElementModel) {
if (!(element instanceof GfxLocalElementModel)) {
this._addToExternalGrids(element);
}
const [minRow, maxRow, minCol, maxCol] = rangeFromElement(element);
const grids = new Set<Set<GfxModel | GfxLocalElementModel>>();
this._elementToGrids.set(element, grids);
for (let i = minRow; i <= maxRow; i++) {
for (let j = minCol; j <= maxCol; j++) {
let grid = this._getGrid(i, j);
if (!grid) {
grid = this._createGrid(i, j);
}
grid.add(element);
grids.add(grid);
}
}
}
boundHasChanged(a: IBound, b: IBound) {
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(a);
const [minRow2, maxRow2, minCol2, maxCol2] = rangeFromBound(b);
return (
minRow !== minRow2 ||
maxRow !== maxRow2 ||
minCol !== minCol2 ||
maxCol !== maxCol2
);
}
/**
*
* @param bound
* @param strict
* @param reverseChecking If true, check if the bound is inside the elements instead of checking if the elements are inside the bound
* @returns
*/
has(
bound: IBound,
strict: boolean = false,
reverseChecking: boolean = false,
filter?: (model: GfxModel | GfxLocalElementModel) => boolean
) {
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound);
const b = Bound.from(bound);
const check = reverseChecking
? (target: Bound) => {
return strict ? target.contains(b) : intersects(b, target);
}
: (target: Bound) => {
return strict ? b.contains(target) : intersects(target, b);
};
for (let i = minRow; i <= maxRow; i++) {
for (let j = minCol; j <= maxCol; j++) {
const gridElements = this._getGrid(i, j);
if (!gridElements) continue;
for (const element of gridElements) {
if ((!filter || filter(element)) && check(element.elementBound)) {
return true;
}
}
}
}
return false;
}
remove(element: GfxModel | GfxLocalElementModel) {
const grids = this._elementToGrids.get(element);
if (grids) {
for (const grid of grids) {
grid.delete(element);
}
}
this._elementToGrids.delete(element);
if (!(element instanceof GfxLocalElementModel)) {
this._removeFromExternalGrids(element);
}
}
/**
* Search for elements in a bound.
* @param bound
* @param options
*/
search<T extends keyof typeof typeFilters>(
bound: IBound,
options?: {
/**
* If true, only return elements that are completely inside the bound.
* Default is false.
*/
strict?: boolean;
/**
* If true, return a set of elements instead of an array
*/
useSet?: false;
/**
* Use this to filter the elements, if not provided, it will return blocks and canvas elements by default
*/
filter?: (T | FilterFunc)[] | FilterFunc;
}
): T extends 'local'[] ? (GfxModel | GfxLocalElementModel)[] : GfxModel[];
search<T extends keyof typeof typeFilters>(
bound: IBound,
options: {
strict?: boolean | undefined;
useSet: true;
filter?: (T | FilterFunc)[] | FilterFunc;
}
): T extends 'local'[] ? Set<GfxModel | GfxLocalElementModel> : Set<GfxModel>;
search<T extends keyof typeof typeFilters>(
bound: IBound,
options: {
strict?: boolean;
useSet?: boolean;
filter?: (T | FilterFunc)[] | FilterFunc;
} = {
useSet: false,
}
):
| (GfxModel | GfxLocalElementModel)[]
| Set<GfxModel | GfxLocalElementModel> {
const strict = options.strict ?? false;
const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound);
const b = Bound.from(bound);
const returnSet = options.useSet ?? false;
const filterFunc =
(Array.isArray(options.filter)
? this._toFilterFunc(options.filter)
: options.filter) ?? this._toFilterFunc(['canvas', 'block']);
const results: Set<GfxModel | GfxLocalElementModel> = this._searchExternal(
bound,
{
filterFunc,
strict,
}
);
for (let i = minRow; i <= maxRow; i++) {
for (let j = minCol; j <= maxCol; j++) {
const gridElements = this._getGrid(i, j);
if (!gridElements) continue;
for (const element of gridElements) {
if (
!(element as GfxPrimitiveElementModel).hidden &&
filterFunc(element) &&
(strict
? b.contains(element.elementBound)
: intersects(element.responseBound, b))
) {
results.add(element);
}
}
}
}
if (returnSet) return results;
// sort elements in set based on index
const sorted = Array.from(results).sort(compare);
return sorted;
}
update(element: GfxModel | GfxLocalElementModel) {
this.remove(element);
this.add(element);
}
private readonly _disposables = new DisposableGroup();
override unmounted(): void {
this._disposables.dispose();
}
override mounted() {
const disposables = this._disposables;
const { store } = this.std;
const canBeRenderedAsGfxBlock = (
block: BlockModel
): block is GfxBlockElementModel => {
return (
block instanceof GfxBlockElementModel &&
(block.parent?.role === 'root' ||
block.parent instanceof SurfaceBlockModel)
);
};
disposables.add(
store.slots.blockUpdated.subscribe(payload => {
if (payload.type === 'add' && canBeRenderedAsGfxBlock(payload.model)) {
this.add(payload.model);
}
if (payload.type === 'update') {
const model = store.getBlock(payload.id)
?.model as GfxBlockElementModel;
if (!model) {
return;
}
if (payload.props.key === 'xywh' && canBeRenderedAsGfxBlock(model)) {
this.update(
store.getBlock(payload.id)?.model as GfxBlockElementModel
);
}
}
if (
payload.type === 'delete' &&
payload.model instanceof GfxBlockElementModel
) {
this.remove(payload.model);
}
})
);
Object.values(store.blocks.peek()).forEach(block => {
if (canBeRenderedAsGfxBlock(block.model)) {
this.add(block.model);
}
});
const watchSurface = (surface: SurfaceBlockModel) => {
let lastChildMap = new Map(surface.childMap.peek());
disposables.add(
surface.childMap.subscribe(val => {
val.forEach((_, id) => {
if (lastChildMap.has(id)) {
lastChildMap.delete(id);
return;
}
});
lastChildMap.forEach((_, id) => {
const block = store.getBlock(id);
if (block?.model) {
this.remove(block.model as GfxBlockElementModel);
}
});
lastChildMap = new Map(val);
})
);
disposables.add(
surface.elementAdded.subscribe(payload => {
this.add(surface.getElementById(payload.id)!);
})
);
disposables.add(
surface.elementRemoved.subscribe(payload => {
this.remove(payload.model);
})
);
disposables.add(
surface.elementUpdated.subscribe(payload => {
if (
payload.props['xywh'] ||
payload.props['externalXYWH'] ||
payload.props['responseExtension']
) {
this.update(surface.getElementById(payload.id)!);
}
})
);
disposables.add(
surface.localElementAdded.subscribe(elm => {
this.add(elm);
})
);
disposables.add(
surface.localElementUpdated.subscribe(payload => {
if (payload.props['xywh'] || payload.props['responseExtension']) {
this.update(payload.model);
}
})
);
disposables.add(
surface.localElementDeleted.subscribe(elm => {
this.remove(elm);
})
);
surface.elementModels.forEach(model => {
this.add(model);
});
surface.localElementModels.forEach(model => {
this.add(model);
});
};
if (this.gfx.surface) {
watchSurface(this.gfx.surface);
} else {
disposables.add(
this.gfx.surface$.subscribe(surface => {
if (surface) {
watchSurface(surface);
}
})
);
}
}
}
@@ -0,0 +1,10 @@
import type { ServiceIdentifier } from '@blocksuite/global/di';
import { LifeCycleWatcherIdentifier } from '../identifier.js';
import type { GfxController } from './controller.js';
export const gfxControllerKey = 'GfxController';
export const GfxControllerIdentifier = LifeCycleWatcherIdentifier(
gfxControllerKey
) as ServiceIdentifier<GfxController>;
+108
View File
@@ -0,0 +1,108 @@
export { generateKeyBetweenV2 } from '../utils/fractional-indexing.js';
export {
compare as compareLayer,
renderableInEdgeless,
SortOrder,
} from '../utils/layer.js';
export {
canSafeAddToContainer,
descendantElementsImpl,
getTopElements,
hasDescendantElementImpl,
} from '../utils/tree.js';
export { GfxController } from './controller.js';
export type { CursorType, StandardCursor } from './cursor.js';
export type {
DragExtensionInitializeContext,
DragInitializationOption,
ExtensionDragEndContext,
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from './element-transform/drag.js';
export { CanvasEventHandler } from './element-transform/extension/canvas-event-handler.js';
export {
ElementTransformManager,
TransformExtension,
TransformExtensionIdentifier,
TransformManagerIdentifier,
} from './element-transform/transform-manager.js';
export type {
DragEndContext,
DragMoveContext,
DragStartContext,
} from './element-transform/view-transform.js';
export { type SelectedContext } from './element-transform/view-transform.js';
export { GfxExtension, GfxExtensionIdentifier } from './extension.js';
export { GridManager } from './grid.js';
export { GfxControllerIdentifier } from './identifiers.js';
export { LayerManager, type ReorderingDirection } from './layer.js';
export type {
GfxCompatibleInterface,
GfxElementGeometry,
GfxGroupCompatibleInterface,
PointTestOptions,
} from './model/base.js';
export {
gfxGroupCompatibleSymbol,
isGfxGroupCompatibleModel,
} from './model/base.js';
export {
GfxBlockElementModel,
type GfxCommonBlockProps,
GfxCompatibleBlockModel as GfxCompatible,
type GfxCompatibleProps,
} from './model/gfx-block-model.js';
export { type GfxModel } from './model/model.js';
export {
convert,
convertProps,
derive,
field,
getDerivedProps,
getFieldPropsSet,
initializeObservers,
initializeWatchers,
local,
observe,
updateDerivedProps,
watch,
} from './model/surface/decorators/index.js';
export {
type BaseElementProps,
GfxGroupLikeElementModel,
GfxPrimitiveElementModel,
type SerializedElement,
} from './model/surface/element-model.js';
export {
GfxLocalElementModel,
prop,
} from './model/surface/local-element-model.js';
export {
SURFACE_TEXT_UNIQ_IDENTIFIER,
SURFACE_YMAP_UNIQ_IDENTIFIER,
SurfaceBlockModel,
type SurfaceBlockProps,
type SurfaceMiddleware,
} from './model/surface/surface-model.js';
export { GfxSelectionManager } from './selection.js';
export {
SurfaceMiddlewareBuilder,
SurfaceMiddlewareExtension,
} from './surface-middleware.js';
export {
BaseTool,
type GfxToolsFullOption,
type GfxToolsFullOptionValue,
type GfxToolsMap,
type GfxToolsOption,
} from './tool/tool.js';
export { MouseButton, ToolController } from './tool/tool-controller.js';
export {
type EventsHandlerMap,
GfxElementModelView,
type SupportedEvent,
} from './view/view.js';
export { ViewManager } from './view/view-manager.js';
export * from './viewport.js';
export { GfxViewportElement } from './viewport-element.js';
export { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
@@ -0,0 +1,51 @@
import { DisposableGroup } from '@blocksuite/global/disposable';
import { Signal } from '@preact/signals-core';
import type { BlockStdScope } from '../scope/std-scope.js';
export class KeyboardController {
private readonly _disposable = new DisposableGroup();
shiftKey$ = new Signal<boolean>(false);
spaceKey$ = new Signal<boolean>(false);
constructor(readonly std: BlockStdScope) {
this._init();
}
private _init() {
this._disposable.add(
this._listenKeyboard('keydown', evt => {
this.shiftKey$.value = evt.shiftKey && evt.key === 'Shift';
this.spaceKey$.value = evt.code === 'Space';
})
);
this._disposable.add(
this._listenKeyboard('keyup', evt => {
this.shiftKey$.value =
evt.shiftKey && evt.key === 'Shift' ? true : false;
if (evt.code === 'Space') {
this.spaceKey$.value = false;
}
})
);
}
private _listenKeyboard(
event: 'keydown' | 'keyup',
callback: (keyboardEvt: KeyboardEvent) => void
) {
document.addEventListener(event, callback, false);
return () => {
document.removeEventListener(event, callback, false);
};
}
dispose() {
this._disposable.dispose();
}
}
+903
View File
@@ -0,0 +1,903 @@
import { DisposableGroup } from '@blocksuite/global/disposable';
import { Bound } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import { generateKeyBetween } from 'fractional-indexing';
import last from 'lodash-es/last';
import { Subject } from 'rxjs';
import {
compare,
getElementIndex,
getLayerEndZIndex,
insertToOrderedArray,
isInRange,
removeFromOrderedArray,
SortOrder,
ungroupIndex,
updateLayersZIndex,
} from '../utils/layer.js';
import { GfxExtension } from './extension.js';
import type { GfxController } from './index.js';
import {
type GfxGroupCompatibleInterface,
isGfxGroupCompatibleModel,
} from './model/base.js';
import { GfxBlockElementModel } from './model/gfx-block-model.js';
import type { GfxModel } from './model/model.js';
import { GfxPrimitiveElementModel } from './model/surface/element-model.js';
import { GfxLocalElementModel } from './model/surface/local-element-model.js';
import { SurfaceBlockModel } from './model/surface/surface-model.js';
export type ReorderingDirection = 'front' | 'forward' | 'backward' | 'back';
type BaseLayer<T> = {
set: Set<T>;
elements: Array<T>;
/**
* fractional indexing range
*/
indexes: [string, string];
};
export type BlockLayer = BaseLayer<GfxBlockElementModel> & {
type: 'block';
/**
* The z-index of the first block in this layer.
*
* A block layer may contains multiple blocks,
* the block should be rendered with this `zIndex` + "its index in the layer" as the z-index property.
*/
zIndex: number;
};
export type CanvasLayer = BaseLayer<GfxPrimitiveElementModel> & {
type: 'canvas';
/**
* The z-index of canvas layer.
*
* A canvas layer renders all the elements in a single canvas,
* this property is used to render the canvas with correct z-index.
*/
zIndex: number;
};
export type Layer = BlockLayer | CanvasLayer;
export class LayerManager extends GfxExtension {
static override key = 'layerManager';
static INITIAL_INDEX = 'a0';
private readonly _disposable = new DisposableGroup();
private get _doc() {
return this.std.store;
}
private get _surface() {
return this.gfx.surface;
}
blocks: GfxBlockElementModel[] = [];
canvasElements: GfxPrimitiveElementModel[] = [];
canvasLayers: {
set: Set<GfxPrimitiveElementModel>;
/**
* fractional index
*/
indexes: [string, string];
/**
* z-index, used for actual rendering
*/
zIndex: number;
elements: Array<GfxPrimitiveElementModel>;
}[] = [];
layers: Layer[] = [];
slots = {
layerUpdated: new Subject<{
type: 'delete' | 'add' | 'update';
initiatingElement: GfxModel | GfxLocalElementModel;
}>(),
};
constructor(gfx: GfxController) {
super(gfx);
this._reset();
}
private _buildCanvasLayers() {
const canvasLayers = this.layers
.filter<CanvasLayer>(
(layer): layer is CanvasLayer => layer.type === 'canvas'
)
.map(layer => {
return {
set: layer.set,
elements: layer.elements,
zIndex: layer.zIndex,
indexes: layer.indexes,
};
}) as LayerManager['canvasLayers'];
if (!canvasLayers.length || last(this.layers)?.type !== 'canvas') {
canvasLayers.push({
set: new Set(),
elements: [],
zIndex: 0,
indexes: [LayerManager.INITIAL_INDEX, LayerManager.INITIAL_INDEX],
});
}
this.canvasLayers = canvasLayers;
}
private _getModelType(
element: GfxModel | GfxLocalElementModel
): 'block' | 'canvas' {
return element instanceof GfxLocalElementModel ||
element instanceof GfxPrimitiveElementModel
? 'canvas'
: 'block';
}
private _initLayers() {
let blockIdx = 0;
let canvasIdx = 0;
const layers: LayerManager['layers'] = [];
let curLayer: LayerManager['layers'][number] | undefined;
let currentCSSZindex = 1;
const pushCurLayer = () => {
if (curLayer) {
curLayer.indexes = [
getElementIndex(curLayer.elements[0]),
getElementIndex(
last(curLayer.elements as GfxPrimitiveElementModel[])!
),
];
curLayer.zIndex = currentCSSZindex;
layers.push(curLayer as LayerManager['layers'][number]);
currentCSSZindex +=
curLayer.type === 'block' ? curLayer.elements.length : 1;
}
};
const addLayer = (type: 'canvas' | 'block') => {
pushCurLayer();
curLayer =
type === 'canvas'
? ({
type,
indexes: [LayerManager.INITIAL_INDEX, LayerManager.INITIAL_INDEX],
zIndex: 0,
set: new Set(),
elements: [],
bound: new Bound(),
} as CanvasLayer)
: ({
type,
indexes: [LayerManager.INITIAL_INDEX, LayerManager.INITIAL_INDEX],
zIndex: 0,
set: new Set(),
elements: [],
} as BlockLayer);
};
while (
blockIdx < this.blocks.length ||
canvasIdx < this.canvasElements.length
) {
const curBlock = this.blocks[blockIdx];
const curCanvas = this.canvasElements[canvasIdx];
if (!curBlock && !curCanvas) {
break;
}
if (!curBlock) {
if (curLayer?.type !== 'canvas') {
addLayer('canvas');
}
assertType<CanvasLayer>(curLayer);
const remains = this.canvasElements.slice(canvasIdx);
curLayer!.elements = curLayer.elements.concat(remains);
remains.forEach(element => (curLayer as CanvasLayer).set.add(element));
break;
}
if (!curCanvas) {
if (curLayer?.type !== 'block') {
addLayer('block');
}
assertType<BlockLayer>(curLayer);
const remains = this.blocks.slice(blockIdx);
curLayer.elements = curLayer.elements.concat(remains);
remains.forEach(block => (curLayer as BlockLayer).set.add(block));
break;
}
const order = compare(curBlock, curCanvas);
switch (order) {
case -1:
if (curLayer?.type !== 'block') {
addLayer('block');
}
assertType<BlockLayer>(curLayer);
curLayer!.set.add(curBlock);
curLayer!.elements.push(curBlock);
++blockIdx;
break;
case 1:
if (curLayer?.type !== 'canvas') {
addLayer('canvas');
}
assertType<CanvasLayer>(curLayer);
curLayer!.set.add(curCanvas);
curLayer!.elements.push(curCanvas);
++canvasIdx;
break;
case 0:
if (!curLayer) {
addLayer('block');
}
if (curLayer!.type === 'block') {
curLayer!.set.add(curBlock);
curLayer!.elements.push(curBlock);
++blockIdx;
} else {
curLayer!.set.add(curCanvas);
curLayer!.elements.push(curCanvas);
++canvasIdx;
}
break;
}
}
if (curLayer && curLayer.elements.length) {
pushCurLayer();
}
this.layers = layers;
this._surface?.localElementModels.forEach(el => this.add(el));
}
private _insertIntoLayer(target: GfxModel, type: 'block' | 'canvas') {
const layers = this.layers;
let cur = layers.length - 1;
const addToLayer = (
layer: Layer,
element: GfxModel,
position: number | 'tail'
) => {
assertType<CanvasLayer>(layer);
assertType<GfxPrimitiveElementModel>(element);
if (position === 'tail') {
layer.elements.push(element);
} else {
layer.elements.splice(position, 0, element);
}
layer.set.add(element);
if (
position === 'tail' ||
position === 0 ||
position === layer.elements.length - 1
) {
layer.indexes = [
getElementIndex(layer.elements[0]),
getElementIndex(last(layer.elements)!),
];
}
};
const createLayer = (
type: 'block' | 'canvas',
targets: GfxModel[],
curZIndex: number
): Layer => {
const newLayer = {
type,
set: new Set(targets),
indexes: [
getElementIndex(targets[0]),
getElementIndex(last(targets)!),
] as [string, string],
zIndex: curZIndex + 1,
elements: targets,
} as BlockLayer;
return newLayer as Layer;
};
if (
!last(this.layers) ||
[SortOrder.AFTER, SortOrder.SAME].includes(
compare(
target,
last(last(this.layers)!.elements as GfxPrimitiveElementModel[])!
)
)
) {
const layer = last(this.layers);
if (layer?.type === type) {
addToLayer(layer, target, 'tail');
updateLayersZIndex(layers, cur);
} else {
this.layers.push(
createLayer(
type,
[target],
getLayerEndZIndex(layers, layers.length - 1)
)
);
}
} else {
while (cur > -1) {
const layer = layers[cur];
const layerElements = layer.elements;
if (
isInRange(
[
layerElements[0],
last(layerElements as GfxPrimitiveElementModel[])!,
],
target
)
) {
const insertIdx = layerElements.findIndex((_, idx) => {
const pre = layerElements[idx - 1];
return (
compare(target, layerElements[idx]) < 0 &&
(!pre || compare(target, pre) >= 0)
);
});
if (layer.type === type) {
addToLayer(layer, target, insertIdx);
updateLayersZIndex(layers, cur);
} else {
const splicedElements = layer.elements.splice(insertIdx);
layer.set = new Set(layer.elements as GfxPrimitiveElementModel[]);
layers.splice(
cur + 1,
0,
createLayer(layer.type, splicedElements, 1)
);
layers.splice(cur + 1, 0, createLayer(type, [target], 1));
updateLayersZIndex(layers, cur);
}
break;
} else {
const nextLayer = layers[cur - 1];
if (
!nextLayer ||
compare(
target,
last(nextLayer.elements as GfxPrimitiveElementModel[])!
) >= 0
) {
if (layer.type === type) {
addToLayer(layer, target, 0);
updateLayersZIndex(layers, cur);
} else {
if (nextLayer) {
addToLayer(nextLayer, target, 'tail');
updateLayersZIndex(layers, cur - 1);
} else {
layers.unshift(createLayer(type, [target], 1));
updateLayersZIndex(layers, 0);
}
}
break;
}
}
--cur;
}
}
}
private _removeFromLayer(
target: GfxModel | GfxLocalElementModel,
type: 'block' | 'canvas'
) {
const layers = this.layers;
const index = layers.findIndex(layer => {
if (layer.type !== type) return false;
assertType<CanvasLayer>(layer);
assertType<GfxPrimitiveElementModel>(target);
if (layer.set.has(target)) {
layer.set.delete(target);
const idx = layer.elements.indexOf(target);
if (idx !== -1) {
layer.elements.splice(layer.elements.indexOf(target), 1);
if (layer.elements.length) {
layer.indexes = [
getElementIndex(layer.elements[0]),
getElementIndex(last(layer.elements)!),
];
}
}
return true;
}
return false;
});
if (index === -1) return;
const isDeletedAtEdge = index === 0 || index === layers.length - 1;
if (layers[index].set.size === 0) {
if (isDeletedAtEdge) {
layers.splice(index, 1);
if (layers[index]) {
updateLayersZIndex(layers, index);
}
} else {
const lastLayer = layers[index - 1] as CanvasLayer;
const nextLayer = layers[index + 1] as CanvasLayer;
lastLayer.elements = lastLayer.elements.concat(nextLayer.elements);
lastLayer.set = new Set(lastLayer.elements);
layers.splice(index, 2);
updateLayersZIndex(layers, index - 1);
}
return;
}
updateLayersZIndex(layers, index);
}
private _reset() {
const elements = (
this._doc
.getAllModels()
.filter(
model =>
model instanceof GfxBlockElementModel &&
(model.parent instanceof SurfaceBlockModel ||
model.parent?.role === 'root')
) as GfxModel[]
).concat(this._surface?.elementModels ?? []);
this.canvasElements = [];
this.blocks = [];
elements.forEach(element => {
if (element instanceof GfxPrimitiveElementModel) {
this.canvasElements.push(element);
} else {
this.blocks.push(element);
}
});
this.canvasElements.sort(compare);
this.blocks.sort(compare);
this._initLayers();
this._buildCanvasLayers();
}
/**
* @returns a boolean value to indicate whether the layers have been updated
*/
private _updateLayer(
element: GfxModel | GfxLocalElementModel,
props?: Record<string, unknown>
) {
const modelType = this._getModelType(element);
const isLocalElem = element instanceof GfxLocalElementModel;
const indexChanged = !props || 'index' in props;
const childIdsChanged =
props && ('childIds' in props || 'childElementIds' in props);
const shouldUpdateGroupChildren =
isGfxGroupCompatibleModel(element) && (indexChanged || childIdsChanged);
const updateArray = (array: GfxModel[], element: GfxModel) => {
if (!indexChanged) return;
removeFromOrderedArray(array, element);
insertToOrderedArray(array, element);
};
if (shouldUpdateGroupChildren) {
this._reset();
return true;
}
if (!isLocalElem) {
if (modelType === 'canvas') {
updateArray(this.canvasElements, element);
} else {
updateArray(this.blocks, element);
}
}
if (indexChanged || childIdsChanged) {
this._removeFromLayer(element as GfxModel, modelType);
this._insertIntoLayer(element as GfxModel, modelType);
return true;
}
return false;
}
add(element: GfxModel | GfxLocalElementModel) {
const modelType = this._getModelType(element);
const isContainer = isGfxGroupCompatibleModel(element);
const isLocalElem = element instanceof GfxLocalElementModel;
if (isContainer) {
element.childElements.forEach(child => {
const childModelType = this._getModelType(child);
removeFromOrderedArray(
childModelType === 'canvas' ? this.canvasElements : this.blocks,
child
);
});
}
if (!isLocalElem) {
insertToOrderedArray(
modelType === 'canvas' ? this.canvasElements : this.blocks,
element
);
}
this._insertIntoLayer(element as GfxModel, modelType);
if (isContainer) {
element.childElements.forEach(child => child && this._updateLayer(child));
}
this._buildCanvasLayers();
this.slots.layerUpdated.next({
type: 'add',
initiatingElement: element,
});
}
/**
* Pass to the `Array.sort` to sort the elements by their index
*/
compare(a: GfxModel, b: GfxModel) {
return compare(a, b);
}
/**
* In some cases, we need to generate a bunch of indexes in advance before acutally adding the elements to the layer manager.
* Eg. when importing a template. The `generateIndex` is a function only depends on the current state of the manager.
* So we cannot use it because it will always return the same index if the element is not added to manager.
*
* This function return a index generator that can "remember" the index it generated without actually adding the element to the manager.
*
* @note The generator cannot work with `group` element.
*
* @returns
*/
createIndexGenerator() {
const manager = new LayerManager(this.gfx);
manager._reset();
return () => {
const idx = manager.generateIndex();
const bound = new Bound(0, 0, 10, 10);
const mockedFakeElement = {
index: idx,
type: 'shape',
x: 0,
y: 0,
w: 10,
h: 10,
elementBound: bound,
xywh: '[0, 0, 10, 10]',
get group() {
return null;
},
get groups() {
return [];
},
};
manager.add(mockedFakeElement as unknown as GfxModel);
return idx;
};
}
delete(element: GfxModel | GfxLocalElementModel) {
let deleteType: 'canvas' | 'block' | undefined = undefined;
const isGroup = isGfxGroupCompatibleModel(element);
const isLocalElem = element instanceof GfxLocalElementModel;
if (isGroup) {
this._reset();
this.slots.layerUpdated.next({
type: 'delete',
initiatingElement: element as GfxModel,
});
return;
}
if (
element instanceof GfxPrimitiveElementModel ||
element instanceof GfxLocalElementModel
) {
deleteType = 'canvas';
if (!isLocalElem) {
removeFromOrderedArray(this.canvasElements, element);
}
} else {
deleteType = 'block';
removeFromOrderedArray(this.blocks, element);
}
this._removeFromLayer(element, deleteType);
this._buildCanvasLayers();
this.slots.layerUpdated.next({
type: 'delete',
initiatingElement: element,
});
}
override unmounted() {
this.slots.layerUpdated.complete();
this._disposable.dispose();
}
/**
* @param reverse - if true, generate the index in reverse order
* @returns
*/
generateIndex(reverse = false): string {
if (reverse) {
const firstIndex = this.layers[0]?.indexes[0];
return firstIndex
? generateKeyBetween(null, ungroupIndex(firstIndex))
: LayerManager.INITIAL_INDEX;
} else {
const lastIndex = last(this.layers)?.indexes[1];
return lastIndex
? generateKeyBetween(ungroupIndex(lastIndex), null)
: LayerManager.INITIAL_INDEX;
}
}
getCanvasLayers() {
return this.canvasLayers;
}
getReorderedIndex(element: GfxModel, direction: ReorderingDirection): string {
const group = (element.group as GfxGroupCompatibleInterface) || null;
let elements: GfxModel[];
if (group !== null) {
elements = group.childElements;
elements.sort(compare);
} else {
elements = this.layers.reduce(
(pre: GfxModel[], current) =>
pre.concat(current.elements.filter(element => element.group == null)),
[]
);
}
const currentIdx = elements.indexOf(element);
switch (direction) {
case 'forward':
case 'front':
if (currentIdx === -1 || currentIdx === elements.length - 1)
return element.index;
{
const next =
direction === 'forward'
? elements[currentIdx + 1]
: elements[elements.length - 1];
const next2 =
direction === 'forward' ? elements[currentIdx + 2] : null;
return generateKeyBetween(
next.index,
next2?.index
? next.index < next2.index
? next2.index
: null
: null
);
}
case 'backward':
case 'back':
if (currentIdx === -1 || currentIdx === 0) return element.index;
{
const pre =
direction === 'backward' ? elements[currentIdx - 1] : elements[0];
const pre2 =
direction === 'backward' ? elements[currentIdx - 2] : null;
return generateKeyBetween(
!pre2 || pre2?.index >= pre.index ? null : pre2.index,
pre.index
);
}
}
}
getZIndex(element: GfxModel): number {
// @ts-expect-error FIXME: ts error
const layer = this.layers.find(layer => layer.set.has(element));
if (!layer) return -1;
// @ts-expect-error FIXME: ts error
return layer.zIndex + layer.elements.indexOf(element);
}
update(
element: GfxModel | GfxLocalElementModel,
props?: Record<string, unknown>
) {
if (this._updateLayer(element, props)) {
this._buildCanvasLayers();
this.slots.layerUpdated.next({
type: 'update',
initiatingElement: element,
});
}
}
override mounted() {
const store = this._doc;
this._disposable.add(
store.slots.blockUpdated.subscribe(payload => {
if (payload.type === 'add') {
const block = store.getModelById(payload.id)!;
if (
block instanceof GfxBlockElementModel &&
(block.parent instanceof SurfaceBlockModel ||
block.parent?.role === 'root') &&
this.blocks.indexOf(block) === -1
) {
this.add(block as GfxBlockElementModel);
}
}
if (payload.type === 'update') {
const block = store.getModelById(payload.id)!;
if (
(payload.props.key === 'index' ||
payload.props.key === 'childElementIds') &&
block instanceof GfxBlockElementModel &&
(block.parent instanceof SurfaceBlockModel ||
block.parent?.role === 'root')
) {
this.update(block as GfxBlockElementModel, {
[payload.props.key]: true,
});
}
}
if (payload.type === 'delete') {
const block = store.getModelById(payload.id);
if (block instanceof GfxBlockElementModel) {
this.delete(block as GfxBlockElementModel);
}
}
})
);
const watchSurface = (surface: SurfaceBlockModel) => {
let lastChildMap = new Map(surface.childMap.peek());
this._disposable.add(
surface.childMap.subscribe(val => {
val.forEach((_, id) => {
if (lastChildMap.has(id)) {
lastChildMap.delete(id);
return;
}
});
lastChildMap.forEach((_, id) => {
const block = this._doc.getBlock(id);
if (block?.model) {
this.delete(block.model as GfxBlockElementModel);
}
});
lastChildMap = new Map(val);
})
);
this._disposable.add(
surface.elementAdded.subscribe(payload =>
this.add(surface.getElementById(payload.id)!)
)
);
this._disposable.add(
surface.elementUpdated.subscribe(payload => {
if (payload.props['index'] || payload.props['childIds']) {
this.update(surface.getElementById(payload.id)!, payload.props);
}
})
);
this._disposable.add(
surface.elementRemoved.subscribe(payload => this.delete(payload.model!))
);
this._disposable.add(
surface.localElementAdded.subscribe(elm => {
this.add(elm);
})
);
this._disposable.add(
surface.localElementUpdated.subscribe(payload => {
if (payload.props['index'] || payload.props['groupId']) {
this.update(payload.model, payload.props);
}
})
);
this._disposable.add(
surface.localElementDeleted.subscribe(elm => {
this.delete(elm);
})
);
};
if (this.gfx.surface) {
watchSurface(this.gfx.surface);
} else {
this._disposable.add(
this.gfx.surface$.subscribe(surface => {
if (surface) {
watchSurface(surface);
}
})
);
}
}
}
@@ -0,0 +1,167 @@
import type {
Bound,
IBound,
IVec,
PointLocation,
SerializedXYWH,
XYWH,
} from '@blocksuite/global/gfx';
import type { EditorHost } from '../../view/element/lit-host.js';
import type { GfxGroupModel, GfxModel } from './model.js';
/**
* The methods that a graphic element should implement.
* It is already included in the `GfxCompatibleInterface` interface.
*/
export interface GfxElementGeometry {
containsBound(bound: Bound): boolean;
getNearestPoint(point: IVec): IVec;
getLineIntersections(start: IVec, end: IVec): PointLocation[] | null;
getRelativePointLocation(point: IVec): PointLocation;
includesPoint(
x: number,
y: number,
options: PointTestOptions,
host: EditorHost
): boolean;
intersectsBound(bound: Bound): boolean;
}
/**
* All the model that can be rendered in graphics mode should implement this interface.
*/
export interface GfxCompatibleInterface extends IBound, GfxElementGeometry {
xywh: SerializedXYWH;
index: string;
/**
* Defines the extension of the response area beyond the element's bounding box.
* This tuple specifies the horizontal and vertical margins to be added to the element's bound.
*
* The first value represents the horizontal extension (added to both left and right sides),
* and the second value represents the vertical extension (added to both top and bottom sides).
*
* The response area is computed as:
* `[x - horizontal, y - vertical, w + 2 * horizontal, h + 2 * vertical]`.
*
* Example:
* - xywh: `[0, 0, 100, 100]`, `responseExtension: [10, 20]`
* Resulting response area: `[-10, -20, 120, 140]`.
* - `responseExtension: [0, 0]` keeps the response area equal to the bounding box.
*/
responseExtension: [number, number];
readonly group: GfxGroupCompatibleInterface | null;
readonly groups: GfxGroupCompatibleInterface[];
readonly deserializedXYWH: XYWH;
/**
* The bound of the element without considering the response extension.
*/
readonly elementBound: Bound;
/**
* The bound of the element considering the response extension.
*/
readonly responseBound: Bound;
/**
* Indicates whether the current block is explicitly locked by self.
* For checking the lock status of the element, use `isLocked` instead.
* For (un)locking the element, use `(un)lock` instead.
*/
lockedBySelf?: boolean;
/**
* Check if the element is locked. It will check the lock status of the element and its ancestors.
*/
isLocked(): boolean;
isLockedBySelf(): boolean;
isLockedByAncestor(): boolean;
lock(): void;
unlock(): void;
}
/**
* The symbol to mark a model as a container.
*/
export const gfxGroupCompatibleSymbol = Symbol('GfxGroupCompatible');
/**
* Check if the element is a container element.
*/
export const isGfxGroupCompatibleModel = (
elm: unknown
): elm is GfxGroupModel => {
if (typeof elm !== 'object' || elm === null) return false;
return (
gfxGroupCompatibleSymbol in elm && elm[gfxGroupCompatibleSymbol] === true
);
};
/**
* GfxGroupCompatibleElement is a model that can contain other models.
* It just like a group that in common graphic software.
*/
export interface GfxGroupCompatibleInterface extends GfxCompatibleInterface {
[gfxGroupCompatibleSymbol]: true;
/**
* All child ids of this container.
*/
childIds: string[];
/**
* All child element models of this container.
* Note that the `childElements` may not contains all the children in `childIds`,
* because some children may not be loaded.
*/
childElements: GfxModel[];
descendantElements: GfxModel[];
addChild(element: GfxCompatibleInterface): void;
removeChild(element: GfxCompatibleInterface): void;
hasChild(element: GfxCompatibleInterface): boolean;
hasDescendant(element: GfxCompatibleInterface): boolean;
}
/**
* The options for the hit testing of a point.
*/
export interface PointTestOptions {
/**
* The threshold of the hit test. The unit is pixel.
*/
hitThreshold?: number;
/**
* If true, the element bound will be used for the hit testing.
* By default, the response bound will be used.
*/
useElementBound?: boolean;
/**
* The padding of the response area for each element when do the hit testing. The unit is pixel.
* The first value is the padding for the x-axis, and the second value is the padding for the y-axis.
*/
responsePadding?: [number, number];
/**
* If true, the transparent area of the element will be ignored during the point inclusion test.
* Otherwise, the transparent area will be considered as filled area.
*
* Default is true.
*/
ignoreTransparent?: boolean;
/**
* The zoom level of current view when do the hit testing.
*/
zoom?: number;
}
@@ -0,0 +1,303 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/gfx';
import {
Bound,
deserializeXYWH,
getBoundWithRotation,
getPointsFromBoundWithRotation,
linePolygonIntersects,
PointLocation,
polygonGetPointTangent,
polygonNearestPoint,
rotatePoints,
} from '@blocksuite/global/gfx';
import type { Constructor } from '@blocksuite/global/utils';
import { BlockModel } from '@blocksuite/store';
import {
isLockedByAncestorImpl,
isLockedBySelfImpl,
isLockedImpl,
lockElementImpl,
unlockElementImpl,
} from '../../utils/tree.js';
import type { EditorHost } from '../../view/index.js';
import type { GfxCompatibleInterface, PointTestOptions } from './base.js';
import type { GfxGroupModel } from './model.js';
import type { SurfaceBlockModel } from './surface/surface-model.js';
/**
* The props that a graphics block model should have.
*/
export type GfxCompatibleProps = {
xywh: SerializedXYWH;
index: string;
lockedBySelf?: boolean;
};
/**
* This type include the common props for the graphic block model.
* You can use this type with Omit to define the props of a graphic block model.
*/
export type GfxCommonBlockProps = GfxCompatibleProps & {
rotate: number;
scale: number;
};
/**
* The graphic block model that can be rendered in the graphics mode.
* All the graphic block model should extend this class.
* You can use `GfxCompatibleBlockModel` to convert a BlockModel to a subclass that extends it.
*/
export class GfxBlockElementModel<
Props extends GfxCompatibleProps = GfxCompatibleProps,
>
extends BlockModel<Props>
implements GfxCompatibleInterface
{
private _cacheDeserKey: string | null = null;
private _cacheDeserXYWH: XYWH | null = null;
private _externalXYWH: SerializedXYWH | undefined = undefined;
connectable = true;
get xywh() {
return this.props.xywh;
}
get xywh$() {
return this.props.xywh$;
}
set xywh(xywh: SerializedXYWH) {
this.props.xywh = xywh;
}
get index() {
return this.props.index;
}
get index$() {
return this.props.index$;
}
set index(index: string) {
this.props.index = index;
}
get lockedBySelf(): boolean | undefined {
return this.props.lockedBySelf;
}
get lockedBySelf$() {
return this.props.lockedBySelf$;
}
set lockedBySelf(lockedBySelf: boolean | undefined) {
this.props.lockedBySelf = lockedBySelf;
}
/**
* Defines the extension of the response area beyond the element's bounding box.
* This tuple specifies the horizontal and vertical margins to be added to the element's [x, y, width, height].
*
* The first value represents the horizontal extension (added to both left and right sides),
* and the second value represents the vertical extension (added to both top and bottom sides).
*
* The response area is computed as:
* `[x - horizontal, y - vertical, width + 2 * horizontal, height + 2 * vertical]`.
*
* Example:
* - Bounding box: `[0, 0, 100, 100]`, `responseExtension: [10, 20]`
* Resulting response area: `[-10, -20, 120, 140]`.
* - `responseExtension: [0, 0]` keeps the response area equal to the bounding box.
*/
responseExtension: [number, number] = [0, 0];
rotate = 0;
get deserializedXYWH() {
if (this._cacheDeserKey !== this.xywh || !this._cacheDeserXYWH) {
this._cacheDeserKey = this.xywh;
this._cacheDeserXYWH = deserializeXYWH(this.xywh);
}
return this._cacheDeserXYWH;
}
get elementBound() {
return Bound.from(getBoundWithRotation(this));
}
get externalBound(): Bound | null {
return this._externalXYWH ? Bound.deserialize(this._externalXYWH) : null;
}
get externalXYWH(): SerializedXYWH | undefined {
return this._externalXYWH;
}
set externalXYWH(xywh: SerializedXYWH | undefined) {
this._externalXYWH = xywh;
}
get group(): GfxGroupModel | null {
if (!this.surface) return null;
return this.surface.getGroup(this.id) ?? null;
}
get groups(): GfxGroupModel[] {
if (!this.surface) return [];
return this.surface.getGroups(this.id);
}
get h() {
return this.deserializedXYWH[3];
}
get responseBound() {
return this.elementBound.expand(this.responseExtension);
}
get surface(): SurfaceBlockModel | null {
const result = this.doc.getBlocksByFlavour('affine:surface');
if (result.length === 0) return null;
return result[0].model as SurfaceBlockModel;
}
get w() {
return this.deserializedXYWH[2];
}
get x() {
return this.deserializedXYWH[0];
}
get y() {
return this.deserializedXYWH[1];
}
containsBound(bounds: Bound): boolean {
const bound = Bound.deserialize(this.xywh);
const points = getPointsFromBoundWithRotation({
x: bound.x,
y: bound.y,
w: bound.w,
h: bound.h,
rotate: this.rotate,
});
return points.some(point => bounds.containsPoint(point));
}
getLineIntersections(start: IVec, end: IVec): PointLocation[] | null {
const bound = Bound.deserialize(this.xywh);
return linePolygonIntersects(
start,
end,
rotatePoints(bound.points, bound.center, this.rotate ?? 0)
);
}
getNearestPoint(point: IVec): IVec {
const bound = Bound.deserialize(this.xywh);
return polygonNearestPoint(
rotatePoints(bound.points, bound.center, this.rotate ?? 0),
point
);
}
getRelativePointLocation(relativePoint: IVec): PointLocation {
const bound = Bound.deserialize(this.xywh);
const point = bound.getRelativePoint(relativePoint);
const rotatePoint = rotatePoints(
[point],
bound.center,
this.rotate ?? 0
)[0];
const points = rotatePoints(bound.points, bound.center, this.rotate ?? 0);
const tangent = polygonGetPointTangent(points, rotatePoint);
return new PointLocation(rotatePoint, tangent);
}
includesPoint(
x: number,
y: number,
opt: PointTestOptions,
__: EditorHost
): boolean {
const bound = opt.useElementBound ? this.elementBound : this.responseBound;
return bound.isPointInBound([x, y], 0);
}
intersectsBound(bound: Bound): boolean {
return (
this.containsBound(bound) ||
bound.points.some((point, i, points) =>
this.getLineIntersections(point, points[(i + 1) % points.length])
)
);
}
isLocked(): boolean {
return isLockedImpl(this);
}
isLockedByAncestor(): boolean {
return isLockedByAncestorImpl(this);
}
isLockedBySelf(): boolean {
return isLockedBySelfImpl(this);
}
lock() {
lockElementImpl(this.doc, this);
}
unlock() {
unlockElementImpl(this.doc, this);
}
}
/**
* Convert a BlockModel to a GfxBlockElementModel.
* @param BlockModelSuperClass The BlockModel class to be converted.
* @returns The returned class is a subclass of the GfxBlockElementModel class and the given BlockModelSuperClass.
*/
export function GfxCompatibleBlockModel<
Props extends GfxCompatibleProps,
T extends Constructor<BlockModel<Props>> = Constructor<BlockModel<Props>>,
>(BlockModelSuperClass: T) {
if (BlockModelSuperClass === BlockModel) {
return GfxBlockElementModel as unknown as typeof GfxBlockElementModel<Props>;
} else {
let currentClass = BlockModelSuperClass;
while (
Object.getPrototypeOf(currentClass.prototype) !== BlockModel.prototype &&
Object.getPrototypeOf(currentClass.prototype) !== null
) {
currentClass = Object.getPrototypeOf(currentClass.prototype).constructor;
}
if (Object.getPrototypeOf(currentClass.prototype) === null) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
'The SuperClass is not a subclass of BlockModel'
);
}
Object.setPrototypeOf(
currentClass.prototype,
GfxBlockElementModel.prototype
);
}
return BlockModelSuperClass as unknown as typeof GfxBlockElementModel<Props>;
}
@@ -0,0 +1,12 @@
import type { GfxGroupCompatibleInterface } from './base.js';
import type { GfxBlockElementModel } from './gfx-block-model.js';
import type {
GfxGroupLikeElementModel,
GfxPrimitiveElementModel,
} from './surface/element-model.js';
export type GfxModel = GfxBlockElementModel | GfxPrimitiveElementModel;
export type GfxGroupModel =
| (GfxGroupCompatibleInterface & GfxBlockElementModel)
| GfxGroupLikeElementModel;
@@ -0,0 +1,53 @@
import type { SurfaceBlockModel } from '../surface-model.js';
/**
* Set metadata for a property
* @param symbol Unique symbol for the metadata
* @param target The target object to set metadata on, usually the prototype
* @param prop The property name
* @param val The value to set
*/
export function setObjectPropMeta(
symbol: symbol,
target: unknown,
prop: string | symbol,
val: unknown
) {
// @ts-expect-error ignore
target[symbol] = target[symbol] ?? {};
// @ts-expect-error ignore
target[symbol][prop] = val;
}
/**
* Get metadata for a property
* @param target The target object to retrieve metadata from, usually the prototype
* @param symbol Unique symbol for the metadata
* @param prop The property name, if not provided, returns all metadata for that symbol
* @returns
*/
export function getObjectPropMeta(
target: unknown,
symbol: symbol,
prop?: string | symbol
) {
if (prop) {
// @ts-expect-error ignore
return target[symbol]?.[prop] ?? null;
}
// @ts-expect-error ignore
return target[symbol] ?? {};
}
export function getDecoratorState(surface: SurfaceBlockModel) {
return surface['_decoratorState'];
}
export function createDecoratorState() {
return {
creating: false,
deriving: false,
skipField: false,
};
}
@@ -0,0 +1,49 @@
import type { GfxPrimitiveElementModel } from '../element-model.js';
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
const convertSymbol = Symbol('convert');
/**
* The convert decorator is used to convert the property value before it's
* set to the Y map.
*
* Note:
* 1. This decorator function will not execute in model initialization.
* @param fn
* @returns
*/
export function convert<V, T extends GfxPrimitiveElementModel>(
fn: (propValue: V, instance: T) => unknown
) {
return function convertDecorator(
_: unknown,
context: ClassAccessorDecoratorContext
) {
const prop = String(context.name);
return {
init(this: T, v: V) {
const proto = Object.getPrototypeOf(this);
setObjectPropMeta(convertSymbol, proto, prop, fn);
return v;
},
} as ClassAccessorDecoratorResult<T, V>;
};
}
function getConvertMeta(
proto: unknown,
prop: string | symbol
): null | ((propValue: unknown, instance: unknown) => unknown) {
return getObjectPropMeta(proto, convertSymbol, prop);
}
export function convertProps(
propName: string | symbol,
propValue: unknown,
receiver: unknown
) {
const proto = Object.getPrototypeOf(receiver);
const convertFn = getConvertMeta(proto, propName as string)!;
return convertFn ? convertFn(propValue, receiver) : propValue;
}
@@ -0,0 +1,103 @@
import type { GfxPrimitiveElementModel } from '../element-model.js';
import {
getDecoratorState,
getObjectPropMeta,
setObjectPropMeta,
} from './common.js';
const deriveSymbol = Symbol('derive');
const keys = Object.keys;
function getDerivedMeta(
proto: unknown,
prop: string | symbol
):
| null
| ((propValue: unknown, instance: unknown) => Record<string, unknown>)[] {
return getObjectPropMeta(proto, deriveSymbol, prop);
}
export function getDerivedProps(
prop: string | symbol,
propValue: unknown,
receiver: GfxPrimitiveElementModel
) {
const prototype = Object.getPrototypeOf(receiver);
const decoratorState = getDecoratorState(receiver.surface);
if (decoratorState.deriving || decoratorState.creating) {
return null;
}
const deriveFns = getDerivedMeta(prototype, prop as string)!;
return deriveFns
? deriveFns.reduce(
(derivedProps, fn) => {
const props = fn(propValue, receiver);
Object.entries(props).forEach(([key, value]) => {
derivedProps[key] = value;
});
return derivedProps;
},
{} as Record<string, unknown>
)
: null;
}
export function updateDerivedProps(
derivedProps: Record<string, unknown> | null,
receiver: GfxPrimitiveElementModel
) {
if (derivedProps) {
const decoratorState = getDecoratorState(receiver.surface);
decoratorState.deriving = true;
keys(derivedProps).forEach(key => {
// @ts-expect-error ignore
receiver[key] = derivedProps[key];
});
decoratorState.deriving = false;
}
}
/**
* The derive decorator is used to derive other properties' update when the
* decorated property is updated through assignment in the local.
*
* Note:
* 1. The first argument of the function is the new value of the decorated property
* before the `convert` decorator is called.
* 2. The decorator function will execute after the decorated property has been updated.
* 3. The decorator function will not execute during model creation.
* 4. The decorator function will not execute if the decorated property is updated through
* the Y map. That is to say, if other peers update the property will not trigger this decorator
* @param fn
* @returns
*/
export function derive<V, T extends GfxPrimitiveElementModel>(
fn: (propValue: any, instance: T) => Record<string, unknown>
) {
return function deriveDecorator(
_: unknown,
context: ClassAccessorDecoratorContext
) {
const prop = String(context.name);
return {
init(this: GfxPrimitiveElementModel, v: V) {
const proto = Object.getPrototypeOf(this);
const derived = getDerivedMeta(proto, prop);
if (Array.isArray(derived)) {
derived.push(fn as (typeof derived)[0]);
} else {
setObjectPropMeta(deriveSymbol, proto, prop as string, [fn]);
}
return v;
},
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
};
}
@@ -0,0 +1,88 @@
import type { GfxPrimitiveElementModel } from '../element-model.js';
import { getDecoratorState } from './common.js';
import { convertProps } from './convert.js';
import { getDerivedProps, updateDerivedProps } from './derive.js';
import { startObserve } from './observer.js';
const yPropsSetSymbol = Symbol('yProps');
export function getFieldPropsSet(target: unknown): Set<string | symbol> {
const proto = Object.getPrototypeOf(target);
if (!Object.hasOwn(proto, yPropsSetSymbol)) {
proto[yPropsSetSymbol] = new Set();
}
return proto[yPropsSetSymbol] as Set<string | symbol>;
}
export function field<V, T extends GfxPrimitiveElementModel>(fallback?: V) {
return function yDecorator(
_: ClassAccessorDecoratorTarget<T, V>,
context: ClassAccessorDecoratorContext
) {
const prop = context.name;
return {
init(this: GfxPrimitiveElementModel, v: V) {
const yProps = getFieldPropsSet(this);
yProps.add(prop);
if (
getDecoratorState(
this.surface ?? Object.getPrototypeOf(this).constructor
)?.skipField
) {
return;
}
if (this.yMap) {
if (this.yMap.doc) {
this.surface.doc.transact(() => {
this.yMap.set(prop as string, v);
});
} else {
this.yMap.set(prop as string, v);
this._preserved.set(prop as string, v);
}
}
return v;
},
get(this: GfxPrimitiveElementModel) {
return (
(this.yMap.doc ? this.yMap.get(prop as string) : null) ??
this._preserved.get(prop as string) ??
fallback
);
},
set(this: T, originalVal: V) {
const isCreating = getDecoratorState(this.surface)?.creating;
if (getDecoratorState(this.surface)?.skipField) {
return;
}
const derivedProps = getDerivedProps(prop, originalVal, this);
const val = isCreating
? originalVal
: convertProps(prop, originalVal, this);
if (this.yMap.doc) {
this.surface.doc.transact(() => {
this.yMap.set(prop as string, val);
});
} else {
this.yMap.set(prop as string, val);
this._preserved.set(prop as string, val);
}
startObserve(prop as string, this);
if (!isCreating) {
updateDerivedProps(derivedProps, this);
}
},
} as ClassAccessorDecoratorResult<T, V>;
};
}
@@ -0,0 +1,6 @@
export { convert, convertProps } from './convert.js';
export { derive, getDerivedProps, updateDerivedProps } from './derive.js';
export { field, getFieldPropsSet } from './field.js';
export { local } from './local.js';
export { initializeObservers, observe } from './observer.js';
export { initializeWatchers, watch } from './watch.js';
@@ -0,0 +1,58 @@
import type { GfxPrimitiveElementModel } from '../element-model.js';
import { getDecoratorState } from './common.js';
import { convertProps } from './convert.js';
import { getDerivedProps, updateDerivedProps } from './derive.js';
/**
* A decorator to mark the property as a local property.
*
* The local property act like it is a field property, but it's not synced to the Y map.
* Updating local property will also trigger the `elementUpdated` slot of the surface model
*/
export function local<V, T extends GfxPrimitiveElementModel>() {
return function localDecorator(
_target: ClassAccessorDecoratorTarget<T, V>,
context: ClassAccessorDecoratorContext
) {
const prop = context.name;
return {
init(this: T, v: V) {
this._local.set(prop, v);
return v;
},
get(this: T) {
return this._local.get(prop);
},
set(this: T, originalValue: unknown) {
const isCreating = getDecoratorState(this.surface)?.creating;
const oldValue = this._local.get(prop);
// When state is creating, the value is considered as default value
// hence there's no need to convert it
const newVal = isCreating
? originalValue
: convertProps(prop, originalValue, this);
const derivedProps = getDerivedProps(prop, originalValue, this);
this._local.set(prop, newVal);
// During creating, no need to invoke an update event and derive another update
if (!isCreating) {
updateDerivedProps(derivedProps, this);
this._onChange({
props: {
[prop]: newVal,
},
oldValues: {
[prop]: oldValue,
},
local: true,
});
}
},
} as ClassAccessorDecoratorResult<T, V>;
};
}
@@ -0,0 +1,122 @@
import type * as Y from 'yjs';
import type { GfxPrimitiveElementModel } from '../element-model.js';
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
const observeSymbol = Symbol('observe');
const observerDisposableSymbol = Symbol('observerDisposable');
type ObserveFn<
E extends Y.YEvent<any> = Y.YEvent<any>,
T extends GfxPrimitiveElementModel = GfxPrimitiveElementModel,
> = (
/**
* The event object of the Y.Map or Y.Array, the `null` value means the observer is initializing.
*/
event: E | null,
instance: T,
/**
* The transaction object of the Y.Map or Y.Array, the `null` value means the observer is initializing.
*/
transaction: Y.Transaction | null
) => void;
/**
* A decorator to observe the y type property.
* You can think of it is just a decorator version of 'observe' method of Y.Array and Y.Map.
*
* The observer function start to observe the property when the model is mounted. And it will
* re-observe the property automatically when the value is altered.
* @param fn
* @returns
*/
export function observe<
V,
E extends Y.YEvent<any>,
T extends GfxPrimitiveElementModel,
>(fn: ObserveFn<E, T>) {
return function observeDecorator(
_: unknown,
context: ClassAccessorDecoratorContext
) {
const prop = context.name;
return {
init(this: T, v: V) {
setObjectPropMeta(observeSymbol, Object.getPrototypeOf(this), prop, fn);
return v;
},
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
};
}
function getObserveMeta(
proto: unknown,
prop: string | symbol
): null | ObserveFn {
return getObjectPropMeta(proto, observeSymbol, prop);
}
export function startObserve(
prop: string | symbol,
receiver: GfxPrimitiveElementModel
) {
const proto = Object.getPrototypeOf(receiver);
const observeFn = getObserveMeta(proto, prop as string)!;
// @ts-expect-error ignore
const observerDisposable = receiver[observerDisposableSymbol] ?? {};
// @ts-expect-error ignore
receiver[observerDisposableSymbol] = observerDisposable;
if (observerDisposable[prop]) {
observerDisposable[prop]();
delete observerDisposable[prop];
}
if (!observeFn) {
return;
}
const value = receiver[prop as keyof GfxPrimitiveElementModel] as
| Y.Map<unknown>
| Y.Array<unknown>
| null;
observeFn(null, receiver, null);
const fn = (event: Y.YEvent<any>, transaction: Y.Transaction) => {
observeFn(event, receiver, transaction);
};
if (value && 'observe' in value) {
value.observe(fn);
observerDisposable[prop] = () => {
value.unobserve(fn);
};
} else {
console.warn(
`Failed to observe "${prop.toString()}" of ${
receiver.type
} element, make sure it's a Y type.`
);
}
}
export function initializeObservers(
proto: unknown,
receiver: GfxPrimitiveElementModel
) {
const observers = getObjectPropMeta(proto, observeSymbol);
Object.keys(observers).forEach(prop => {
startObserve(prop, receiver);
});
receiver['_disposable'].add(() => {
// @ts-expect-error ignore
Object.values(receiver[observerDisposableSymbol] ?? {}).forEach(dispose =>
(dispose as () => void)()
);
});
}
@@ -0,0 +1,59 @@
import type { GfxPrimitiveElementModel } from '../element-model.js';
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
type WatchFn<T extends GfxPrimitiveElementModel = GfxPrimitiveElementModel> = (
oldValue: unknown,
instance: T,
local: boolean
) => void;
const watchSymbol = Symbol('watch');
/**
* The watch decorator is used to watch the property change of the element.
* You can thinks of it as a decorator version of `elementUpdated` slot of the surface model.
*/
export function watch<V, T extends GfxPrimitiveElementModel>(fn: WatchFn<T>) {
return function watchDecorator(
_: unknown,
context: ClassAccessorDecoratorContext
) {
const prop = context.name;
return {
init(this: GfxPrimitiveElementModel, v: V) {
setObjectPropMeta(watchSymbol, Object.getPrototypeOf(this), prop, fn);
return v;
},
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
};
}
function getWatchMeta(proto: unknown, prop: string | symbol): null | WatchFn {
return getObjectPropMeta(proto, watchSymbol, prop);
}
function startWatch(prop: string | symbol, receiver: GfxPrimitiveElementModel) {
const proto = Object.getPrototypeOf(receiver);
const watchFn = getWatchMeta(proto, prop as string)!;
if (!watchFn) return;
receiver['_disposable'].add(
receiver.surface.elementUpdated.subscribe(payload => {
if (payload.id === receiver.id && prop in payload.props) {
watchFn(payload.oldValues[prop as string], receiver, payload.local);
}
})
);
}
export function initializeWatchers(
prototype: unknown,
receiver: GfxPrimitiveElementModel
) {
const watchers = getObjectPropMeta(prototype, watchSymbol);
Object.keys(watchers).forEach(prop => {
startWatch(prop, receiver);
});
}
@@ -0,0 +1,600 @@
import { DisposableGroup } from '@blocksuite/global/disposable';
import {
Bound,
deserializeXYWH,
getBoundWithRotation,
getPointsFromBoundWithRotation,
type IVec,
linePolygonIntersects,
PointLocation,
polygonGetPointTangent,
polygonNearestPoint,
randomSeed,
rotatePoints,
type SerializedXYWH,
type XYWH,
} from '@blocksuite/global/gfx';
import { createMutex } from 'lib0/mutex';
import isEqual from 'lodash-es/isEqual';
import { Subject } from 'rxjs';
import * as Y from 'yjs';
import {
descendantElementsImpl,
hasDescendantElementImpl,
isLockedByAncestorImpl,
isLockedBySelfImpl,
isLockedImpl,
lockElementImpl,
unlockElementImpl,
} from '../../../utils/tree.js';
import type { EditorHost } from '../../../view/index.js';
import type {
GfxCompatibleInterface,
GfxGroupCompatibleInterface,
PointTestOptions,
} from '../base.js';
import { gfxGroupCompatibleSymbol } from '../base.js';
import type { GfxBlockElementModel } from '../gfx-block-model.js';
import type { GfxGroupModel, GfxModel } from '../model.js';
import {
convertProps,
field,
getDerivedProps,
getFieldPropsSet,
local,
updateDerivedProps,
watch,
} from './decorators/index.js';
import type { SurfaceBlockModel } from './surface-model.js';
export type BaseElementProps = {
index: string;
seed: number;
lockedBySelf?: boolean;
};
export type SerializedElement = Record<string, unknown> & {
type: string;
xywh: SerializedXYWH;
id: string;
index: string;
lockedBySelf?: boolean;
props: Record<string, unknown>;
};
export abstract class GfxPrimitiveElementModel<
Props extends BaseElementProps = BaseElementProps,
> implements GfxCompatibleInterface
{
private _lastXYWH!: SerializedXYWH;
protected _disposable = new DisposableGroup();
protected _id: string;
protected _local = new Map<string | symbol, unknown>();
protected _onChange: (payload: {
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
local: boolean;
}) => void;
/**
* Used to store a copy of data in the yMap.
*/
protected _preserved = new Map<string, unknown>();
protected _stashed: Map<keyof Props | string, unknown>;
propsUpdated = new Subject<{ key: string }>();
abstract rotate: number;
surface!: SurfaceBlockModel;
abstract xywh: SerializedXYWH;
yMap: Y.Map<unknown>;
get connectable() {
return true;
}
get deserializedXYWH() {
if (!this._lastXYWH || this.xywh !== this._lastXYWH) {
const xywh = this.xywh;
this._local.set('deserializedXYWH', deserializeXYWH(xywh));
this._lastXYWH = xywh;
}
return (this._local.get('deserializedXYWH') as XYWH) ?? [0, 0, 0, 0];
}
/**
* The bound of the element after rotation.
* The bound without rotation should be created by `Bound.deserialize(this.xywh)`.
*/
get elementBound() {
if (this.rotate) {
return Bound.from(getBoundWithRotation(this));
}
return Bound.deserialize(this.xywh);
}
get externalBound(): Bound | null {
if (!this._local.has('externalBound')) {
const bound = this.externalXYWH
? Bound.deserialize(this.externalXYWH)
: null;
this._local.set('externalBound', bound);
}
return this._local.get('externalBound') as Bound | null;
}
get group(): GfxGroupModel | null {
return this.surface.getGroup(this.id);
}
/**
* Return the ancestor elements in order from the most recent to the earliest.
*/
get groups(): GfxGroupModel[] {
return this.surface.getGroups(this.id);
}
get h() {
return this.deserializedXYWH[3];
}
get id() {
return this._id;
}
get isConnected() {
return this.surface.hasElementById(this.id);
}
get responseBound() {
return this.elementBound.expand(this.responseExtension);
}
abstract get type(): string;
get w() {
return this.deserializedXYWH[2];
}
get x() {
return this.deserializedXYWH[0];
}
get y() {
return this.deserializedXYWH[1];
}
constructor(options: {
id: string;
yMap: Y.Map<unknown>;
model: SurfaceBlockModel;
stashedStore: Map<unknown, unknown>;
onChange: (payload: {
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
local: boolean;
}) => void;
}) {
const { id, yMap, model, stashedStore, onChange } = options;
this._id = id;
this.yMap = yMap;
this.surface = model;
this._stashed = stashedStore as Map<keyof Props, unknown>;
this._onChange = onChange;
this.index = 'a0';
this.seed = randomSeed();
}
containsBound(bounds: Bound): boolean {
return getPointsFromBoundWithRotation(this).some(point =>
bounds.containsPoint(point)
);
}
getLineIntersections(start: IVec, end: IVec) {
const points = getPointsFromBoundWithRotation(this);
return linePolygonIntersects(start, end, points);
}
getNearestPoint(point: IVec) {
const points = getPointsFromBoundWithRotation(this);
return polygonNearestPoint(points, point);
}
getRelativePointLocation(relativePoint: IVec) {
const bound = Bound.deserialize(this.xywh);
const point = bound.getRelativePoint(relativePoint);
const rotatePoint = rotatePoints([point], bound.center, this.rotate)[0];
const points = rotatePoints(bound.points, bound.center, this.rotate);
const tangent = polygonGetPointTangent(points, rotatePoint);
return new PointLocation(rotatePoint, tangent);
}
includesPoint(
x: number,
y: number,
opt: PointTestOptions,
__: EditorHost
): boolean {
const bound = opt.useElementBound ? this.elementBound : this.responseBound;
return bound.isPointInBound([x, y]);
}
intersectsBound(bound: Bound): boolean {
return (
this.containsBound(bound) ||
bound.points.some((point, i, points) =>
this.getLineIntersections(point, points[(i + 1) % points.length])
)
);
}
isLocked(): boolean {
return isLockedImpl(this);
}
isLockedByAncestor(): boolean {
return isLockedByAncestorImpl(this);
}
isLockedBySelf(): boolean {
return isLockedBySelfImpl(this);
}
lock() {
lockElementImpl(this.surface.doc, this);
}
onCreated() {}
onDestroyed() {
this._disposable.dispose();
this.propsUpdated.complete();
}
pop(prop: keyof Props | string) {
if (!this._stashed.has(prop)) {
return;
}
const value = this._stashed.get(prop);
this._stashed.delete(prop);
// @ts-expect-error ignore
delete this[prop];
if (getFieldPropsSet(this).has(prop as string)) {
if (!isEqual(value, this.yMap.get(prop as string))) {
this.surface.doc.transact(() => {
this.yMap.set(prop as string, value);
});
}
} else {
console.warn('pop a prop that is not field or local:', prop);
}
}
serialize() {
const result = this.yMap.toJSON();
result.xywh = this.xywh;
return result as SerializedElement;
}
stash(prop: keyof Props | string) {
if (this._stashed.has(prop)) {
return;
}
if (!getFieldPropsSet(this).has(prop as string)) {
return;
}
const curVal = this[prop as unknown as keyof GfxPrimitiveElementModel];
this._stashed.set(prop, curVal);
Object.defineProperty(this, prop, {
configurable: true,
enumerable: true,
get: () => this._stashed.get(prop),
set: (original: unknown) => {
const value = convertProps(prop as string, original, this);
const oldValue = this._stashed.get(prop);
const derivedProps = getDerivedProps(
prop as string,
original,
this as unknown as GfxPrimitiveElementModel
);
this._stashed.set(prop, value);
this._onChange({
props: {
[prop]: value,
},
oldValues: {
[prop]: oldValue,
},
local: true,
});
updateDerivedProps(
derivedProps,
this as unknown as GfxPrimitiveElementModel
);
},
});
}
unlock() {
unlockElementImpl(this.surface.doc, this);
}
@local()
accessor display: boolean = true;
/**
* In some cases, you need to draw something related to the element, but it does not belong to the element itself.
* And it is also interactive, you can select element by clicking on it. E.g. the title of the group element.
* In this case, we need to store this kind of external xywh in order to do hit test. This property should not be synced to the doc.
* This property should be updated every time it gets rendered.
*/
@watch((_, instance) => {
instance['_local'].delete('externalBound');
})
@local()
accessor externalXYWH: SerializedXYWH | undefined = undefined;
@field(false)
accessor hidden: boolean = false;
@field()
accessor index!: string;
@field()
accessor lockedBySelf: boolean | undefined = false;
@local()
accessor opacity: number = 1;
@local()
accessor responseExtension: [number, number] = [0, 0];
@field()
accessor seed!: number;
}
export abstract class GfxGroupLikeElementModel<
Props extends BaseElementProps = BaseElementProps,
>
extends GfxPrimitiveElementModel<Props>
implements GfxGroupCompatibleInterface
{
private _childIds: string[] = [];
private readonly _mutex = createMutex();
abstract children: Y.Map<any>;
[gfxGroupCompatibleSymbol] = true as const;
get childElements() {
const elements: GfxModel[] = [];
for (const key of this.childIds) {
const element =
this.surface.getElementById(key) ||
(this.surface.doc.getModelById(key) as GfxBlockElementModel);
element && elements.push(element);
}
return elements;
}
/**
* The ids of the children. Its role is to provide a unique way to access the children.
* You should update this field through `setChildIds` when the children are added or removed.
*/
get childIds() {
return this._childIds;
}
get descendantElements(): GfxModel[] {
return descendantElementsImpl(this);
}
get xywh() {
this._mutex(() => {
const curXYWH =
(this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
const newXYWH = this._getXYWH().serialize();
if (curXYWH !== newXYWH || !this._local.has('xywh')) {
this._local.set('xywh', newXYWH);
if (curXYWH !== newXYWH) {
this._onChange({
props: {
xywh: newXYWH,
},
oldValues: {
xywh: curXYWH,
},
local: true,
});
}
}
});
return (this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
}
set xywh(_) {}
protected _getXYWH(): Bound {
let bound: Bound | undefined;
this.childElements.forEach(child => {
if (child instanceof GfxPrimitiveElementModel && child.hidden) {
return;
}
bound = bound ? bound.unite(child.elementBound) : child.elementBound;
});
if (bound) {
this._local.set('xywh', bound.serialize());
} else {
this._local.delete('xywh');
}
return bound ?? new Bound(0, 0, 0, 0);
}
abstract addChild(element: GfxModel): void;
/**
* The actual field that stores the children of the group.
* It should be a ymap decorated with `@field`.
*/
hasChild(element: GfxCompatibleInterface) {
return this.childElements.includes(element as GfxModel);
}
/**
* Check if the group has the given descendant.
*/
hasDescendant(element: GfxCompatibleInterface): boolean {
return hasDescendantElementImpl(this, element);
}
/**
* Remove the child from the group
*/
abstract removeChild(element: GfxCompatibleInterface): void;
/**
* Set the new value of the childIds
* @param value the new value of the childIds
* @param fromLocal if true, the change is happened in the local
*/
setChildIds(value: string[], fromLocal: boolean) {
const oldChildIds = this.childIds;
this._childIds = value;
this._onChange({
props: {
childIds: value,
},
oldValues: {
childIds: oldChildIds,
},
local: fromLocal,
});
}
}
export function syncElementFromY(
model: GfxPrimitiveElementModel,
callback: (payload: {
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
local: boolean;
}) => void
) {
const disposables: Record<string, () => void> = {};
const observer = (
event: Y.YMapEvent<unknown>,
transaction: Y.Transaction
) => {
const props: Record<string, unknown> = {};
const oldValues: Record<string, unknown> = {};
event.keysChanged.forEach(key => {
const type = event.changes.keys.get(key);
const oldValue = event.changes.keys.get(key)?.oldValue;
if (!type) {
return;
}
if (type.action === 'update' || type.action === 'add') {
const value = model.yMap.get(key);
if (value instanceof Y.Text) {
disposables[key]?.();
disposables[key] = watchText(key, value, callback);
}
model['_preserved'].set(key, value);
props[key] = value;
oldValues[key] = oldValue;
} else {
model['_preserved'].delete(key);
oldValues[key] = oldValue;
}
});
callback({
props,
oldValues,
local: transaction.local,
});
};
Array.from(model.yMap.entries()).forEach(([key, value]) => {
if (value instanceof Y.Text) {
disposables[key] = watchText(key, value, callback);
}
model['_preserved'].set(key, value);
});
model.yMap.observe(observer);
disposables['ymap'] = () => {
model.yMap.unobserve(observer);
};
return () => {
Object.values(disposables).forEach(fn => fn());
};
}
function watchText(
key: string,
value: Y.Text,
callback: (payload: {
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
local: boolean;
}) => void
) {
const fn = (_: Y.YTextEvent, transaction: Y.Transaction) => {
callback({
props: {
[key]: value,
},
oldValues: {},
local: transaction.local,
});
};
value.observe(fn);
return () => {
value.unobserve(fn);
};
}
@@ -0,0 +1,252 @@
import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/gfx';
import {
Bound,
deserializeXYWH,
getPointsFromBoundWithRotation,
linePolygonIntersects,
PointLocation,
polygonGetPointTangent,
polygonNearestPoint,
rotatePoints,
} from '@blocksuite/global/gfx';
import { mutex } from 'lib0';
import type { EditorHost } from '../../../view/index.js';
import type { GfxCompatibleInterface, PointTestOptions } from '../base.js';
import type { GfxGroupModel } from '../model.js';
import type { SurfaceBlockModel } from './surface-model.js';
export function prop<V, T extends GfxLocalElementModel>() {
return function propDecorator(
_target: ClassAccessorDecoratorTarget<T, V>,
context: ClassAccessorDecoratorContext
) {
const prop = context.name;
return {
init(this: T, val: unknown) {
this._props.add(prop);
this._local.set(prop, val);
},
get(this: T) {
return this._local.get(prop);
},
set(this: T, val: V) {
this._local.set(prop, val);
},
} as ClassAccessorDecoratorResult<T, V>;
};
}
export abstract class GfxLocalElementModel implements GfxCompatibleInterface {
private readonly _mutex: mutex.mutex = mutex.createMutex();
protected _local = new Map<string | symbol, unknown>();
/**
* Used to store all the name of the properties that have been decorated
* with the `@prop`
*/
protected _props = new Set<string | symbol>();
protected _surface: SurfaceBlockModel;
/**
* used to store the properties' cache key
* when the properties required heavy computation
*/
cache = new Map<string | symbol, unknown>();
id: string = '';
abstract readonly type: string;
get deserializedXYWH() {
if (!this._local.has('deserializedXYWH')) {
const xywh = this.xywh;
const deserialized = deserializeXYWH(xywh);
this._local.set('deserializedXYWH', deserialized);
}
return this._local.get('deserializedXYWH') as XYWH;
}
get elementBound() {
return new Bound(this.x, this.y, this.w, this.h);
}
get group() {
return (
this.groupId ? this._surface.getElementById(this.groupId) : null
) as GfxGroupModel | null;
}
get groups() {
if (this.group) {
const groups = this._surface.getGroups(this.group.id);
groups.unshift(this.group);
return groups;
}
return [];
}
get h() {
return this.deserializedXYWH[3];
}
get responseBound() {
return this.elementBound.expand(this.responseExtension);
}
get surface() {
return this._surface;
}
get w() {
return this.deserializedXYWH[2];
}
get x() {
return this.deserializedXYWH[0];
}
get y() {
return this.deserializedXYWH[1];
}
constructor(surfaceModel: SurfaceBlockModel) {
this._surface = surfaceModel;
const p = new Proxy(this, {
set: (target, prop, value) => {
if (prop === 'xywh') {
this._local.delete('deserializedXYWH');
}
// @ts-expect-error ignore
const oldValue = target[prop as string];
if (oldValue === value) {
return true;
}
// @ts-expect-error ignore
target[prop as string] = value;
if (!this._props.has(prop)) {
return true;
}
if (surfaceModel.localElementModels.has(p)) {
this._mutex(() => {
surfaceModel.localElementUpdated.next({
model: p,
props: {
[prop as string]: value,
},
oldValues: {
[prop as string]: oldValue,
},
});
});
}
return true;
},
});
// oxlint-disable-next-line no-constructor-return
return p;
}
containsBound(bounds: Bound): boolean {
return getPointsFromBoundWithRotation(this).some(point =>
bounds.containsPoint(point)
);
}
getLineIntersections(start: IVec, end: IVec) {
const points = getPointsFromBoundWithRotation(this);
return linePolygonIntersects(start, end, points);
}
getNearestPoint(point: IVec) {
const points = getPointsFromBoundWithRotation(this);
return polygonNearestPoint(points, point);
}
getRelativePointLocation(relativePoint: IVec) {
const bound = Bound.deserialize(this.xywh);
const point = bound.getRelativePoint(relativePoint);
const rotatePoint = rotatePoints([point], bound.center, this.rotate)[0];
const points = rotatePoints(bound.points, bound.center, this.rotate);
const tangent = polygonGetPointTangent(points, rotatePoint);
return new PointLocation(rotatePoint, tangent);
}
includesPoint(
x: number,
y: number,
opt: PointTestOptions,
__: EditorHost
): boolean {
const bound = opt.useElementBound ? this.elementBound : this.responseBound;
return bound.isPointInBound([x, y]);
}
intersectsBound(bound: Bound): boolean {
return (
this.containsBound(bound) ||
bound.points.some((point, i, points) =>
this.getLineIntersections(point, points[(i + 1) % points.length])
)
);
}
isLocked() {
return false;
}
isLockedByAncestor() {
return false;
}
isLockedBySelf() {
return false;
}
lock() {
return;
}
unlock() {
return;
}
@prop()
accessor groupId: string = '';
@prop()
accessor hidden: boolean = false;
@prop()
accessor index: string = 'a0';
@prop()
accessor opacity: number = 1;
@prop()
accessor responseExtension: [number, number] = [0, 0];
@prop()
accessor rotate: number = 0;
@prop()
accessor seed: number = Math.random();
@prop()
accessor xywh: SerializedXYWH = '[0,0,0,0]';
}
@@ -0,0 +1,692 @@
import { DisposableGroup } from '@blocksuite/global/disposable';
import { assertType, type Constructor } from '@blocksuite/global/utils';
import type { Boxed } from '@blocksuite/store';
import { BlockModel, nanoid } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { Subject } from 'rxjs';
import * as Y from 'yjs';
import {
type GfxGroupCompatibleInterface,
isGfxGroupCompatibleModel,
} from '../base.js';
import type { GfxGroupModel, GfxModel } from '../model.js';
import { createDecoratorState } from './decorators/common.js';
import { initializeObservers, initializeWatchers } from './decorators/index.js';
import {
GfxGroupLikeElementModel,
type GfxPrimitiveElementModel,
syncElementFromY,
} from './element-model.js';
import type { GfxLocalElementModel } from './local-element-model.js';
/**
* Used for text field
*/
export const SURFACE_TEXT_UNIQ_IDENTIFIER = 'affine:surface:text';
/**
* Used for field that use Y.Map. E.g. group children field
*/
export const SURFACE_YMAP_UNIQ_IDENTIFIER = 'affine:surface:ymap';
export type SurfaceBlockProps = {
elements: Boxed<Y.Map<Y.Map<unknown>>>;
};
export interface ElementUpdatedData {
id: string;
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
local: boolean;
}
export type MiddlewareCtx = {
type: 'beforeAdd';
payload: {
type: string;
props: Record<string, unknown>;
};
};
export type SurfaceMiddleware = (ctx: MiddlewareCtx) => void;
export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
protected _decoratorState = createDecoratorState();
protected _elementCtorMap: Record<
string,
Constructor<
GfxPrimitiveElementModel,
ConstructorParameters<typeof GfxPrimitiveElementModel>
>
> = Object.create(null);
protected _elementModels = new Map<
string,
{
mount: () => void;
unmount: () => void;
model: GfxPrimitiveElementModel;
}
>();
protected _elementTypeMap = new Map<string, GfxPrimitiveElementModel[]>();
protected _groupLikeModels = new Map<string, GfxGroupModel>();
protected _middlewares: SurfaceMiddleware[] = [];
protected _surfaceBlockModel = true;
protected localElements = new Set<GfxLocalElementModel>();
elementAdded = new Subject<{ id: string; local: boolean }>();
elementRemoved = new Subject<{
id: string;
type: string;
model: GfxPrimitiveElementModel;
local: boolean;
}>();
elementUpdated = new Subject<ElementUpdatedData>();
localElementAdded = new Subject<GfxLocalElementModel>();
localElementDeleted = new Subject<GfxLocalElementModel>();
localElementUpdated = new Subject<{
model: GfxLocalElementModel;
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
}>();
private readonly _isEmpty$ = signal(false);
get elementModels() {
const models: GfxPrimitiveElementModel[] = [];
this._elementModels.forEach(model => models.push(model.model));
return models;
}
get elements() {
return this.props.elements;
}
get localElementModels() {
return this.localElements;
}
get registeredElementTypes() {
return Object.keys(this._elementCtorMap);
}
override isEmpty(): boolean {
return this._isEmpty$.value;
}
constructor() {
super();
const subscription = this.created.subscribe(() => {
this._init();
subscription.unsubscribe();
});
}
private _createElementFromProps(
props: Record<string, unknown>,
options: {
onChange: (payload: {
id: string;
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
local: boolean;
}) => void;
}
) {
const { type, id, ...rest } = props;
if (!id) {
throw new Error('Cannot find id in props');
}
const yMap = new Y.Map();
const elementModel = this._createElementFromYMap(
type as string,
id as string,
yMap,
{
...options,
newCreate: true,
}
);
props = this._propsToY(type as string, props);
yMap.set('type', type);
yMap.set('id', id);
Object.keys(rest).forEach(key => {
if (props[key] !== undefined) {
// @ts-expect-error ignore
elementModel.model[key] = props[key];
}
});
return elementModel;
}
private _createElementFromYMap(
type: string,
id: string,
yMap: Y.Map<unknown>,
options: {
onChange: (payload: {
id: string;
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
local: boolean;
}) => void;
skipFieldInit?: boolean;
newCreate?: boolean;
}
) {
const stashed = new Map<string | symbol, unknown>();
const Ctor = this._elementCtorMap[type];
if (!Ctor) {
throw new Error(`Invalid element type: ${yMap.get('type')}`);
}
const state = this._decoratorState;
state.creating = true;
state.skipField = options.skipFieldInit ?? false;
let mounted = false;
// @ts-expect-error ignore
Ctor['_decoratorState'] = state;
const elementModel = new Ctor({
id,
yMap,
model: this,
stashedStore: stashed,
onChange: payload => mounted && options.onChange({ id, ...payload }),
}) as GfxPrimitiveElementModel;
// @ts-expect-error ignore
delete Ctor['_decoratorState'];
state.creating = false;
state.skipField = false;
const unmount = () => {
mounted = false;
elementModel.onDestroyed();
};
const mount = () => {
initializeObservers(Ctor.prototype, elementModel);
initializeWatchers(Ctor.prototype, elementModel);
elementModel['_disposable'].add(
syncElementFromY(elementModel, payload => {
mounted &&
options.onChange({
id,
...payload,
});
})
);
mounted = true;
elementModel.onCreated();
};
return {
model: elementModel,
mount,
unmount,
};
}
private _initElementModels() {
const elementsYMap = this.elements.getValue()!;
const addToType = (type: string, model: GfxPrimitiveElementModel) => {
const sameTypeElements = this._elementTypeMap.get(type) || [];
if (sameTypeElements.indexOf(model) === -1) {
sameTypeElements.push(model);
}
this._elementTypeMap.set(type, sameTypeElements);
if (isGfxGroupCompatibleModel(model)) {
this._groupLikeModels.set(model.id, model);
}
};
const removeFromType = (type: string, model: GfxPrimitiveElementModel) => {
const sameTypeElements = this._elementTypeMap.get(type) || [];
const index = sameTypeElements.indexOf(model);
if (index !== -1) {
sameTypeElements.splice(index, 1);
}
if (this._groupLikeModels.has(model.id)) {
this._groupLikeModels.delete(model.id);
}
};
const onElementsMapChange = (
event: Y.YMapEvent<Y.Map<unknown>>,
transaction: Y.Transaction
) => {
const { changes, keysChanged } = event;
const addedElements: {
mount: () => void;
model: GfxPrimitiveElementModel;
}[] = [];
const deletedElements: {
unmount: () => void;
model: GfxPrimitiveElementModel;
}[] = [];
keysChanged.forEach(id => {
const change = changes.keys.get(id);
const element = this.elements.getValue()!.get(id);
switch (change?.action) {
case 'add':
if (element) {
const hasModel = this._elementModels.has(id);
const model = hasModel
? this._elementModels.get(id)!
: this._createElementFromYMap(
element.get('type') as string,
element.get('id') as string,
element,
{
onChange: payload => {
this.elementUpdated.next(payload);
Object.keys(payload.props).forEach(key => {
model.model.propsUpdated.next({ key });
});
},
skipFieldInit: true,
}
);
!hasModel && this._elementModels.set(id, model);
addToType(model.model.type, model.model);
addedElements.push(model);
}
break;
case 'delete':
if (this._elementModels.has(id)) {
const { model, unmount } = this._elementModels.get(id)!;
removeFromType(model.type, model);
this._elementModels.delete(id);
deletedElements.push({ model, unmount });
}
break;
}
});
addedElements.forEach(({ mount, model }) => {
mount();
this.elementAdded.next({ id: model.id, local: transaction.local });
});
deletedElements.forEach(({ unmount, model }) => {
unmount();
this.elementRemoved.next({
id: model.id,
type: model.type,
model,
local: transaction.local,
});
});
};
elementsYMap.forEach((val, key) => {
const model = this._createElementFromYMap(
val.get('type') as string,
val.get('id') as string,
val,
{
onChange: payload => {
this.elementUpdated.next(payload),
Object.keys(payload.props).forEach(key => {
model.model.propsUpdated.next({ key });
});
},
skipFieldInit: true,
}
);
this._elementModels.set(key, model);
});
this._elementModels.forEach(({ mount, model }) => {
addToType(model.type, model);
mount();
});
Object.values(this.doc.blocks.peek()).forEach(block => {
if (isGfxGroupCompatibleModel(block.model)) {
this._groupLikeModels.set(block.id, block.model);
}
});
elementsYMap.observe(onElementsMapChange);
const subscription = this.doc.slots.blockUpdated.subscribe(payload => {
switch (payload.type) {
case 'add':
if (isGfxGroupCompatibleModel(payload.model)) {
this._groupLikeModels.set(payload.id, payload.model);
}
break;
case 'delete':
if (isGfxGroupCompatibleModel(payload.model)) {
this._groupLikeModels.delete(payload.id);
}
{
const group = this.getGroup(payload.id);
if (group) {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group.removeChild(payload.model as GfxModel);
}
}
break;
}
});
this.deleted.subscribe(() => {
elementsYMap.unobserve(onElementsMapChange);
subscription.unsubscribe();
});
}
private _propsToY(type: string, props: Record<string, unknown>) {
const ctor = this._elementCtorMap[type];
if (!ctor) {
throw new Error(`Invalid element type: ${type}`);
}
Object.entries(props).forEach(([key, val]) => {
if (val instanceof Object) {
if (Reflect.has(val, SURFACE_TEXT_UNIQ_IDENTIFIER)) {
const yText = new Y.Text();
yText.applyDelta(Reflect.get(val, 'delta'));
Reflect.set(props, key, yText);
}
if (Reflect.has(val, SURFACE_YMAP_UNIQ_IDENTIFIER)) {
const childJson = Reflect.get(val, 'json') as Record<string, unknown>;
const childrenYMap = new Y.Map<unknown>();
Object.keys(childJson).forEach(childId => {
childrenYMap.set(childId, childJson[childId]);
});
Reflect.set(props, key, childrenYMap);
}
}
});
// @ts-expect-error ignore
return ctor.propsToY ? ctor.propsToY(props) : props;
}
private _watchGroupRelationChange() {
const isGroup = (
element: GfxPrimitiveElementModel
): element is GfxGroupLikeElementModel =>
element instanceof GfxGroupLikeElementModel;
const disposable = this.elementUpdated.subscribe(({ id, oldValues }) => {
const element = this.getElementById(id)!;
if (
isGroup(element) &&
oldValues['childIds'] &&
element.childIds.length === 0
) {
this.deleteElement(id);
}
});
this.deleted.subscribe(() => {
disposable.unsubscribe();
});
}
private _watchChildrenChange() {
const updateIsEmpty = () => {
this._isEmpty$.value =
this._elementModels.size === 0 && this.children.length === 0;
};
const disposables = new DisposableGroup();
disposables.add(this.elementAdded.subscribe(updateIsEmpty));
disposables.add(this.elementRemoved.subscribe(updateIsEmpty));
this.doc.slots.blockUpdated.subscribe(payload => {
if (['add', 'delete'].includes(payload.type)) {
updateIsEmpty();
}
});
this.deleted.subscribe(() => {
disposables.dispose();
});
}
protected _extendElement(
ctorMap: Record<
string,
Constructor<
GfxPrimitiveElementModel,
ConstructorParameters<typeof GfxPrimitiveElementModel>
>
>
) {
Object.assign(this._elementCtorMap, ctorMap);
}
protected _init() {
this._initElementModels();
this._watchGroupRelationChange();
this._watchChildrenChange();
}
getConstructor(type: string) {
return this._elementCtorMap[type];
}
addElement<T extends object = Record<string, unknown>>(
props: Partial<T> & { type: string }
) {
if (this.doc.readonly) {
throw new Error('Cannot add element in readonly mode');
}
const middlewareCtx: MiddlewareCtx = {
type: 'beforeAdd',
payload: {
type: props.type,
props,
},
};
this._middlewares.forEach(mid => mid(middlewareCtx));
props = middlewareCtx.payload.props as Partial<T> & { type: string };
const id = nanoid();
// @ts-expect-error ignore
props.id = id;
const elementModel = this._createElementFromProps(props, {
onChange: payload => {
this.elementUpdated.next(payload);
Object.keys(payload.props).forEach(key => {
elementModel.model.propsUpdated.next({ key });
});
},
});
this._elementModels.set(id, elementModel);
this.doc.transact(() => {
this.elements.getValue()!.set(id, elementModel.model.yMap);
});
return id;
}
addLocalElement(elem: GfxLocalElementModel) {
this.localElements.add(elem);
this.localElementAdded.next(elem);
}
applyMiddlewares(middlewares: SurfaceMiddleware[]) {
this._middlewares = middlewares;
}
deleteElement(id: string) {
if (this.doc.readonly) {
throw new Error('Cannot remove element in readonly mode');
}
if (!this.hasElementById(id)) {
return;
}
this.doc.transact(() => {
const element = this.getElementById(id)!;
const group = this.getGroup(id);
if (element instanceof GfxGroupLikeElementModel) {
element.childIds.forEach(childId => {
if (this.hasElementById(childId)) {
this.deleteElement(childId);
} else if (this.doc.hasBlock(childId)) {
this.doc.deleteBlock(this.doc.getBlock(childId)!.model);
}
});
}
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group?.removeChild(element as GfxModel);
this.elements.getValue()!.delete(id);
});
}
deleteLocalElement(elem: GfxLocalElementModel) {
if (this.localElements.delete(elem)) {
this.localElementDeleted.next(elem);
}
}
override dispose(): void {
super.dispose();
this.elementAdded.complete();
this.elementRemoved.complete();
this.elementUpdated.complete();
this._elementModels.forEach(({ unmount }) => unmount());
this._elementModels.clear();
}
getElementById(id: string): GfxPrimitiveElementModel | null {
return this._elementModels.get(id)?.model ?? null;
}
getElementsByType(type: string): GfxPrimitiveElementModel[] {
return this._elementTypeMap.get(type) || [];
}
getGroup(elem: string | GfxModel): GfxGroupModel | null {
elem =
typeof elem === 'string'
? ((this.getElementById(elem) ??
this.doc.getBlock(elem)?.model) as GfxModel)
: elem;
if (!elem) return null;
assertType<GfxModel>(elem);
for (const group of this._groupLikeModels.values()) {
if (group.hasChild(elem)) {
return group;
}
}
return null;
}
getGroups(id: string): GfxGroupModel[] {
const groups: GfxGroupModel[] = [];
const visited = new Set<GfxGroupModel>();
let group = this.getGroup(id);
while (group) {
if (visited.has(group)) {
console.warn('Exists a cycle in group relation');
break;
}
visited.add(group);
groups.push(group);
group = this.getGroup(group.id);
}
return groups;
}
hasElementById(id: string): boolean {
return this._elementModels.has(id);
}
isGroup(element: GfxModel): element is GfxModel & GfxGroupCompatibleInterface;
isGroup(id: string): boolean;
isGroup(element: string | GfxModel): boolean {
if (typeof element === 'string') {
const el = this.getElementById(element);
if (el) return isGfxGroupCompatibleModel(el);
const blockModel = this.doc.getBlock(element)?.model;
if (blockModel) return isGfxGroupCompatibleModel(blockModel);
return false;
} else {
return isGfxGroupCompatibleModel(element);
}
}
updateElement<T extends object = Record<string, unknown>>(
id: string,
props: Partial<T>
) {
if (this.doc.readonly) {
throw new Error('Cannot update element in readonly mode');
}
const elementModel = this.getElementById(id);
if (!elementModel) {
throw new Error(`Element ${id} is not found`);
}
this.doc.transact(() => {
props = this._propsToY(
elementModel.type,
props as Record<string, unknown>
) as T;
Object.entries(props).forEach(([key, value]) => {
// @ts-expect-error ignore
elementModel[key] = value;
});
});
}
}
@@ -0,0 +1,409 @@
import { DisposableGroup } from '@blocksuite/global/disposable';
import {
getCommonBoundWithRotation,
type IPoint,
} from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import groupBy from 'lodash-es/groupBy';
import { Subject } from 'rxjs';
import {
BlockSelection,
CursorSelection,
SurfaceSelection,
TextSelection,
} from '../selection/index.js';
import type { GfxController } from './controller.js';
import { GfxExtension, GfxExtensionIdentifier } from './extension.js';
import type { GfxModel } from './model/model.js';
import { GfxGroupLikeElementModel } from './model/surface/element-model.js';
export interface SurfaceSelectionState {
/**
* The selected elements. Could be blocks or canvas elements
*/
elements: string[];
/**
* Indicate whether the selected element is in editing mode
*/
editing?: boolean;
/**
* Cannot be operated, only box is displayed
*/
inoperable?: boolean;
}
/**
* GfxSelectionManager is just a wrapper of std selection providing
* convenient method and states in gfx
*/
export class GfxSelectionManager extends GfxExtension {
static override key = 'gfxSelection';
private _activeGroup: GfxGroupLikeElementModel | null = null;
private _cursorSelection: CursorSelection | null = null;
private _lastSurfaceSelections: SurfaceSelection[] = [];
private _remoteCursorSelectionMap = new Map<number, CursorSelection>();
private _remoteSelectedSet = new Set<string>();
private _remoteSurfaceSelectionsMap = new Map<number, SurfaceSelection[]>();
private _selectedSet = new Set<string>();
private _surfaceSelections: SurfaceSelection[] = [];
disposable: DisposableGroup = new DisposableGroup();
readonly slots = {
updated: new Subject<SurfaceSelection[]>(),
remoteUpdated: new Subject<void>(),
cursorUpdated: new Subject<CursorSelection>(),
remoteCursorUpdated: new Subject<void>(),
};
get activeGroup() {
return this._activeGroup;
}
get cursorSelection() {
return this._cursorSelection;
}
get editing() {
return this.surfaceSelections.some(sel => sel.editing);
}
get empty() {
return this.surfaceSelections.every(sel => sel.elements.length === 0);
}
get firstElement() {
return this.selectedElements[0];
}
get inoperable() {
return this.surfaceSelections.some(sel => sel.inoperable);
}
get lastSurfaceSelections() {
return this._lastSurfaceSelections;
}
get remoteCursorSelectionMap() {
return this._remoteCursorSelectionMap;
}
get remoteSelectedSet() {
return this._remoteSelectedSet;
}
get remoteSurfaceSelectionsMap() {
return this._remoteSurfaceSelectionsMap;
}
get selectedBound() {
return getCommonBoundWithRotation(this.selectedElements);
}
get selectedElements() {
const elements: GfxModel[] = [];
this.selectedIds.forEach(id => {
const el = this.gfx.getElementById(id) as GfxModel;
el && elements.push(el);
});
return elements;
}
get selectedIds() {
return [...this._selectedSet];
}
get selectedSet() {
return this._selectedSet;
}
get stdSelection() {
return this.std.selection;
}
get surfaceModel() {
return this.gfx.surface;
}
get surfaceSelections() {
return this._surfaceSelections;
}
static override extendGfx(gfx: GfxController): void {
Object.defineProperty(gfx, 'selection', {
get() {
return this.std.get(GfxExtensionIdentifier('gfxSelection'));
},
});
}
clear() {
this.stdSelection.clear();
this.set({
elements: [],
editing: false,
});
}
clearLast() {
this._lastSurfaceSelections = [];
}
equals(selection: SurfaceSelection[]) {
let count = 0;
let editing = false;
const exist = selection.every(sel => {
const exist = sel.elements.every(id => this._selectedSet.has(id));
if (exist) {
count += sel.elements.length;
}
if (sel.editing) editing = true;
return exist;
});
return (
exist && count === this._selectedSet.size && editing === this.editing
);
}
/**
* check if the element is selected in local
* @param element
*/
has(element: string) {
return this._selectedSet.has(element);
}
/**
* check if element is selected by remote peers
* @param element
*/
hasRemote(element: string) {
return this._remoteSelectedSet.has(element);
}
isEmpty(selections: SurfaceSelection[]) {
return selections.every(sel => sel.elements.length === 0);
}
isInSelectedRect(viewX: number, viewY: number) {
const selected = this.selectedElements;
if (!selected.length) return false;
const commonBound = getCommonBoundWithRotation(selected);
const [modelX, modelY] = this.gfx.viewport.toModelCoord(viewX, viewY);
if (commonBound && commonBound.isPointInBound([modelX, modelY])) {
return true;
}
return false;
}
override mounted() {
this.disposable.add(
this.stdSelection.slots.changed.subscribe(selections => {
const { cursor = [], surface = [] } = groupBy(selections, sel => {
if (sel.is(SurfaceSelection)) {
return 'surface';
} else if (sel.is(CursorSelection)) {
return 'cursor';
}
return 'none';
});
assertType<CursorSelection[]>(cursor);
assertType<SurfaceSelection[]>(surface);
if (cursor[0] && !this.cursorSelection?.equals(cursor[0])) {
this._cursorSelection = cursor[0];
this.slots.cursorUpdated.next(cursor[0]);
}
if ((surface.length === 0 && this.empty) || this.equals(surface)) {
return;
}
this._lastSurfaceSelections = this.surfaceSelections;
this._surfaceSelections = surface;
this._selectedSet = new Set<string>();
surface.forEach(sel =>
sel.elements.forEach(id => {
this._selectedSet.add(id);
})
);
this.slots.updated.next(this.surfaceSelections);
})
);
this.disposable.add(
this.stdSelection.slots.remoteChanged.subscribe(states => {
const surfaceMap = new Map<number, SurfaceSelection[]>();
const cursorMap = new Map<number, CursorSelection>();
const selectedSet = new Set<string>();
states.forEach((selections, id) => {
let hasTextSelection = false;
let hasBlockSelection = false;
selections.forEach(selection => {
if (selection.is(TextSelection)) {
hasTextSelection = true;
}
if (selection.is(BlockSelection)) {
hasBlockSelection = true;
}
if (selection.is(SurfaceSelection)) {
const surfaceSelections = surfaceMap.get(id) ?? [];
surfaceSelections.push(selection);
surfaceMap.set(id, surfaceSelections);
selection.elements.forEach(id => selectedSet.add(id));
}
if (selection.is(CursorSelection)) {
cursorMap.set(id, selection);
}
});
if (hasBlockSelection || hasTextSelection) {
surfaceMap.delete(id);
}
if (hasTextSelection) {
cursorMap.delete(id);
}
});
this._remoteCursorSelectionMap = cursorMap;
this._remoteSurfaceSelectionsMap = surfaceMap;
this._remoteSelectedSet = selectedSet;
this.slots.remoteUpdated.next();
this.slots.remoteCursorUpdated.next();
})
);
}
set(selection: SurfaceSelectionState | SurfaceSelection[]) {
if (Array.isArray(selection)) {
this.stdSelection.setGroup(
'gfx',
this.cursorSelection ? [...selection, this.cursorSelection] : selection
);
return;
}
const { blocks = [], elements = [] } = groupBy(selection.elements, id => {
return this.std.store.hasBlock(id) ? 'blocks' : 'elements';
});
let instances: (SurfaceSelection | CursorSelection)[] = [];
if (elements.length > 0 && this.surfaceModel) {
instances.push(
this.stdSelection.create(
SurfaceSelection,
this.surfaceModel.id,
elements,
selection.editing ?? false,
selection.inoperable
)
);
}
if (blocks.length > 0) {
instances = instances.concat(
blocks.map(blockId =>
this.stdSelection.create(
SurfaceSelection,
blockId,
[blockId],
selection.editing ?? false,
selection.inoperable
)
)
);
}
this.stdSelection.setGroup(
'gfx',
this.cursorSelection
? instances.concat([this.cursorSelection])
: instances
);
if (instances.length > 0) {
this.stdSelection.setGroup('note', []);
}
if (
selection.elements.length === 1 &&
this.firstElement instanceof GfxGroupLikeElementModel
) {
this._activeGroup = this.firstElement;
} else {
if (
this.selectedElements.some(ele => ele.group !== this._activeGroup) ||
this.selectedElements.length === 0
) {
this._activeGroup = null;
}
}
}
/**
* Toggle the selection state of single element
* @param element
* @returns
*/
toggle(element: GfxModel | string) {
element = typeof element === 'string' ? element : element.id;
this.set({
elements: this.has(element)
? this.selectedIds.filter(id => id !== element)
: [...this.selectedIds, element],
});
}
setCursor(cursor: CursorSelection | IPoint) {
const instance = this.stdSelection.create(
CursorSelection,
cursor.x,
cursor.y
);
this.stdSelection.setGroup('gfx', [...this.surfaceSelections, instance]);
}
override unmounted() {
this.disposable.dispose();
}
}
declare module './controller.js' {
interface GfxController {
readonly selection: GfxSelectionManager;
}
}
@@ -0,0 +1,61 @@
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Extension } from '@blocksuite/store';
import { LifeCycleWatcher } from '../extension/lifecycle-watcher.js';
import { StdIdentifier } from '../identifier.js';
import type { BlockStdScope } from '../scope/std-scope.js';
import { onSurfaceAdded } from '../utils/gfx.js';
import { GfxControllerIdentifier } from './identifiers.js';
import type { SurfaceMiddleware } from './model/surface/surface-model.js';
export abstract class SurfaceMiddlewareBuilder extends Extension {
static key: string = '';
abstract middleware: SurfaceMiddleware;
get gfx() {
return this.std.provider.get(GfxControllerIdentifier);
}
constructor(protected std: BlockStdScope) {
super();
}
static override setup(di: Container) {
if (!this.key) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'The surface middleware builder should have a static key property.'
);
}
di.addImpl(SurfaceMiddlewareBuilderIdentifier(this.key), this, [
StdIdentifier,
]);
}
mounted(): void {}
unmounted(): void {}
}
export const SurfaceMiddlewareBuilderIdentifier =
createIdentifier<SurfaceMiddlewareBuilder>('SurfaceMiddlewareBuilder');
export class SurfaceMiddlewareExtension extends LifeCycleWatcher {
static override key: string = 'surfaceMiddleware';
override mounted(): void {
const builders = Array.from(
this.std.provider.getAll(SurfaceMiddlewareBuilderIdentifier).values()
);
const dispose = onSurfaceAdded(this.std.store, surface => {
if (surface) {
surface.applyMiddlewares(builders.map(builder => builder.middleware));
queueMicrotask(() => dispose());
}
});
}
}
@@ -0,0 +1,554 @@
import type { ServiceIdentifier } from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { IBound, IPoint } from '@blocksuite/global/gfx';
import { Signal } from '@preact/signals-core';
import { Subject } from 'rxjs';
import type { PointerEventState } from '../../event/index.js';
import type { GfxController } from '../controller.js';
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
import {
type BaseTool,
type GfxToolsFullOptionValue,
type GfxToolsMap,
type GfxToolsOption,
ToolIdentifier,
} from './tool.js';
type BuiltInHookEvent<T> = {
data: T;
preventDefault(): void;
};
type BuiltInEventMap = {
beforeToolUpdate: BuiltInHookEvent<{
toolName: keyof GfxToolsMap;
}>;
toolUpdate: BuiltInHookEvent<{ toolName: keyof GfxToolsMap }>;
};
type BuiltInSlotContext = {
[K in keyof BuiltInEventMap]: { event: K } & BuiltInEventMap[K];
}[SupportedHooks];
export type SupportedHooks = keyof BuiltInEventMap;
const supportedEvents = [
'dragStart',
'dragEnd',
'dragMove',
'pointerMove',
'contextMenu',
'pointerDown',
'pointerUp',
'click',
'doubleClick',
'tripleClick',
'pointerOut',
] as const;
export type SupportedEvents = (typeof supportedEvents)[number];
export enum MouseButton {
FIFTH = 4,
FOURTH = 3,
MAIN = 0,
MIDDLE = 1,
SECONDARY = 2,
}
export interface ToolEventTarget {
/**
* Add a hook before the event is handled by the tool.
* Return false to prevent the tool from handling the event.
* @param evtName
* @param handler
*/
addHook<K extends SupportedHooks | SupportedEvents>(
evtName: K,
handler: (
evtState: K extends SupportedHooks
? BuiltInEventMap[K]
: PointerEventState
) => void | boolean
): void;
}
export const eventTarget = Symbol('eventTarget');
export class ToolController extends GfxExtension {
static override key = 'ToolController';
private readonly _builtInHookSlot = new Subject<BuiltInSlotContext>();
private readonly _disposableGroup = new DisposableGroup();
private readonly _toolOption$ = new Signal<GfxToolsFullOptionValue>(
{} as GfxToolsFullOptionValue
);
private readonly _tools = new Map<string, BaseTool>();
readonly currentToolName$ = new Signal<keyof GfxToolsMap>();
readonly dragging$ = new Signal<boolean>(false);
/**
* The area that is being dragged.
* The coordinates are in browser space.
*/
readonly draggingViewArea$ = new Signal<
IBound & {
startX: number;
startY: number;
endX: number;
endY: number;
}
>({
startX: 0,
startY: 0,
x: 0,
y: 0,
w: 0,
h: 0,
endX: 0,
endY: 0,
});
/**
* The last mouse move position
* The coordinates are in browser space
*/
readonly lastMousePos$ = new Signal<IPoint>({
x: 0,
y: 0,
});
get currentTool$() {
// oxlint-disable-next-line typescript/no-this-alias
const self = this;
return {
get value() {
return self._tools.get(self.currentToolName$.value);
},
peek() {
return self._tools.get(self.currentToolName$.peek());
},
};
}
get currentToolOption$() {
// oxlint-disable-next-line typescript/no-this-alias
const self = this;
return {
peek() {
const option = self._toolOption$.peek() as unknown as { type: string };
if (!option.type) {
option.type = '';
}
return option as GfxToolsFullOptionValue;
},
get value(): GfxToolsFullOptionValue {
const option = self._toolOption$.value as unknown as { type: string };
if (!option.type) {
option.type = '';
}
return option as GfxToolsFullOptionValue;
},
};
}
/**
* The area that is being dragged.
* The coordinates are in model space.
*/
get draggingArea$() {
const compute = (peek: boolean) => {
const area = peek
? this.draggingViewArea$.peek()
: this.draggingViewArea$.value;
const [startX, startY] = this.gfx.viewport.toModelCoord(
area.startX,
area.startY
);
const [endX, endY] = this.gfx.viewport.toModelCoord(area.endX, area.endY);
return {
x: Math.min(startX, endX),
y: Math.min(startY, endY),
w: Math.abs(endX - startX),
h: Math.abs(endY - startY),
startX,
startY,
endX,
endY,
};
};
return {
value() {
return compute(false);
},
peek() {
return compute(true);
},
};
}
static override extendGfx(gfx: GfxController) {
Object.defineProperty(gfx, 'tool', {
get() {
return this.std.provider.get(ToolControllerIdentifier);
},
});
}
private _createBuiltInHookCtx<K extends keyof BuiltInEventMap>(
eventName: K,
data: BuiltInEventMap[K]['data']
): {
prevented: boolean;
slotCtx: BuiltInSlotContext;
} {
const ctx = {
prevented: false,
slotCtx: {
event: eventName,
data,
preventDefault() {
ctx.prevented = true;
},
} as BuiltInSlotContext,
};
return ctx;
}
private _initializeEvents() {
const hooks: Record<
string,
((
evtState: PointerEventState | BuiltInSlotContext
) => undefined | boolean)[]
> = {};
/**
* Invoke the hook and the tool handler.
* @returns false if the handler is prevented by the hook
*/
const invokeToolHandler = (
evtName: SupportedEvents,
evt: PointerEventState,
tool?: BaseTool
) => {
const evtHooks = hooks[evtName];
const stopHandler = evtHooks?.reduce((pre, hook) => {
return pre || hook(evt) === false;
}, false);
tool = tool ?? this.currentTool$.peek();
if (stopHandler) {
return false;
}
try {
tool?.[evtName](evt);
return true;
} catch (e) {
throw new BlockSuiteError(
ErrorCode.ExecutionError,
`Error occurred while executing ${evtName} handler of tool "${tool?.toolName}"`,
{
cause: e as Error,
}
);
}
};
/**
* Hook into the event lifecycle.
* All hooks will be executed despite the current active tool.
* This is useful for tools that need to perform some action before an event is handled.
* @param evtName
* @param handler
*/
const addHook: ToolEventTarget['addHook'] = (evtName, handler) => {
hooks[evtName] = hooks[evtName] ?? [];
hooks[evtName].push(
handler as (
evtState: PointerEventState | BuiltInSlotContext
) => undefined | boolean
);
return () => {
const idx = hooks[evtName].indexOf(
handler as (
evtState: PointerEventState | BuiltInSlotContext
) => undefined | boolean
);
if (idx !== -1) {
hooks[evtName].splice(idx, 1);
}
};
};
let dragContext: {
tool: BaseTool;
} | null = null;
this._disposableGroup.add(
this.std.event.add('dragStart', ctx => {
const evt = ctx.get('pointerState');
if (
evt.button === MouseButton.SECONDARY &&
!this.currentTool$.peek()?.allowDragWithRightButton
) {
return;
}
if (evt.button === MouseButton.MIDDLE) {
evt.raw.preventDefault();
}
this.dragging$.value = true;
this.draggingViewArea$.value = {
startX: evt.x,
startY: evt.y,
endX: evt.x,
endY: evt.y,
x: evt.x,
y: evt.y,
w: 0,
h: 0,
};
// this means the dragEnd event is not even fired
// so we need to manually call the dragEnd method
if (dragContext?.tool) {
dragContext.tool.dragEnd(evt);
dragContext = null;
}
if (invokeToolHandler('dragStart', evt)) {
dragContext = this.currentTool$.peek()
? {
tool: this.currentTool$.peek()!,
}
: null;
}
})
);
this._disposableGroup.add(
this.std.event.add('dragMove', ctx => {
if (!this.dragging$.peek()) {
return;
}
const evt = ctx.get('pointerState');
const draggingStart = {
x: this.draggingArea$.peek().startX,
y: this.draggingArea$.peek().startY,
originX: this.draggingViewArea$.peek().startX,
originY: this.draggingViewArea$.peek().startY,
};
this.draggingViewArea$.value = {
...this.draggingViewArea$.peek(),
w: Math.abs(evt.x - draggingStart.originX),
h: Math.abs(evt.y - draggingStart.originY),
x: Math.min(evt.x, draggingStart.originX),
y: Math.min(evt.y, draggingStart.originY),
endX: evt.x,
endY: evt.y,
};
invokeToolHandler('dragMove', evt, dragContext?.tool);
})
);
this._disposableGroup.add(
this.std.event.add('dragEnd', ctx => {
if (!this.dragging$.peek()) {
return;
}
this.dragging$.value = false;
const evt = ctx.get('pointerState');
// if the tool dragEnd is prevented by the hook, call the dragEnd method manually
// this guarantee the dragStart and dragEnd events are always called together
if (
!invokeToolHandler('dragEnd', evt, dragContext?.tool) &&
dragContext?.tool
) {
dragContext.tool.dragEnd(evt);
}
dragContext = null;
this.draggingViewArea$.value = {
x: 0,
y: 0,
startX: 0,
startY: 0,
endX: 0,
endY: 0,
w: 0,
h: 0,
};
})
);
this._disposableGroup.add(
this.std.event.add('pointerMove', ctx => {
const evt = ctx.get('pointerState');
this.lastMousePos$.value = {
x: evt.x,
y: evt.y,
};
invokeToolHandler('pointerMove', evt);
})
);
this._disposableGroup.add(
this.std.event.add('contextMenu', ctx => {
const evt = ctx.get('defaultState');
// when in editing mode, allow context menu to pop up
if (this.gfx.selection.editing) return;
evt.event.preventDefault();
})
);
supportedEvents.slice(5).forEach(evtName => {
this._disposableGroup.add(
this.std.event.add(evtName, ctx => {
const evt = ctx.get('pointerState');
invokeToolHandler(evtName, evt);
})
);
});
this._builtInHookSlot.subscribe(evt => {
hooks[evt.event]?.forEach(hook => hook(evt));
});
return {
addHook,
};
}
private _register(tools: BaseTool) {
if (this._tools.has(tools.toolName)) {
this._tools.get(tools.toolName)?.unmounted();
}
this._tools.set(tools.toolName, tools);
tools.mounted();
}
get<K extends keyof GfxToolsMap>(key: K): GfxToolsMap[K] {
return this._tools.get(key) as GfxToolsMap[K];
}
override mounted(): void {
const { addHook } = this._initializeEvents();
const eventTarget: ToolEventTarget = {
addHook,
};
this.std.provider.getAll(ToolIdentifier).forEach(tool => {
// @ts-expect-error ignore
tool['eventTarget'] = eventTarget;
this._register(tool);
});
}
setTool(toolName: GfxToolsFullOptionValue, ...args: [void]): void;
setTool<K extends keyof GfxToolsMap>(
toolName: K,
...args: K extends keyof GfxToolsOption
? [option: GfxToolsOption[K]]
: [void]
): void;
setTool<K extends keyof GfxToolsMap>(
toolName: K | GfxToolsFullOptionValue,
...args: K extends keyof GfxToolsOption
? [option: GfxToolsOption[K]]
: [void]
): void {
const option = typeof toolName === 'string' ? args[0] : toolName;
const toolNameStr =
typeof toolName === 'string'
? toolName
: ((toolName as { type: string }).type as K);
const beforeUpdateCtx = this._createBuiltInHookCtx('beforeToolUpdate', {
toolName: toolNameStr,
});
this._builtInHookSlot.next(beforeUpdateCtx.slotCtx);
if (beforeUpdateCtx.prevented) {
return;
}
// explicitly clear the selection when switching tools
this.gfx.selection.set({ elements: [] });
this.currentTool$.peek()?.deactivate();
this.currentToolName$.value = toolNameStr;
const currentTool = this.currentTool$.peek();
if (!currentTool) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
`Tool "${this.currentToolName$.value}" is not defined`
);
}
currentTool.activatedOption = option ?? {};
this._toolOption$.value = {
...currentTool.activatedOption,
type: toolNameStr,
} as GfxToolsFullOptionValue;
currentTool.activate(currentTool.activatedOption);
const afterUpdateCtx = this._createBuiltInHookCtx('toolUpdate', {
toolName: toolNameStr,
});
this._builtInHookSlot.next(afterUpdateCtx.slotCtx);
}
override unmounted(): void {
this.currentTool$.peek()?.deactivate();
this._tools.forEach(tool => {
tool.unmounted();
tool['disposable'].dispose();
});
this._builtInHookSlot.complete();
}
}
export const ToolControllerIdentifier = GfxExtensionIdentifier(
'ToolController'
) as ServiceIdentifier<ToolController>;
declare module '../controller.js' {
interface GfxController {
readonly tool: ToolController;
}
}
@@ -0,0 +1,125 @@
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Extension } from '@blocksuite/store';
import type { PointerEventState } from '../../event/index.js';
import type { GfxController } from '../controller.js';
import { GfxControllerIdentifier } from '../identifiers.js';
import type { ToolEventTarget } from './tool-controller.js';
export abstract class BaseTool<
Option = Record<string, unknown>,
> extends Extension {
static toolName: string = '';
private readonly eventTarget!: ToolEventTarget;
activatedOption: Option = {} as Option;
addHook: ToolEventTarget['addHook'] = (evtName, handler) => {
this.eventTarget.addHook(evtName, handler);
};
/**
* The `disposable` will be disposed when the tool is unloaded.
*/
protected readonly disposable = new DisposableGroup();
get active() {
return this.gfx.tool.currentTool$.peek() === this;
}
get allowDragWithRightButton() {
return false;
}
get controller() {
return this.gfx.tool;
}
get doc() {
return this.gfx.doc;
}
get std() {
return this.gfx.std;
}
get toolName() {
return (this.constructor as typeof BaseTool).toolName;
}
constructor(readonly gfx: GfxController) {
super();
}
static override setup(di: Container): void {
if (!this.toolName) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
`The tool constructor '${this.name}' should have a static 'toolName' property.`
);
}
di.addImpl(ToolIdentifier(this.toolName), this, [GfxControllerIdentifier]);
}
/**
* Called when the tool is activated.
* @param _ - The data passed as second argument when calling `ToolController.use`.
*/
activate(_: Option): void {}
click(_: PointerEventState): void {}
contextMenu(_: PointerEventState): void {}
/**
* Called when the tool is deactivated.
*/
deactivate(): void {}
doubleClick(_: PointerEventState): void {}
dragEnd(_: PointerEventState): void {}
dragMove(_: PointerEventState): void {}
dragStart(_: PointerEventState): void {}
/**
* Called when the tool is registered.
*/
mounted(): void {}
pointerDown(_: PointerEventState): void {}
pointerMove(_: PointerEventState): void {}
pointerOut(_: PointerEventState): void {}
pointerUp(_: PointerEventState): void {}
tripleClick(_: PointerEventState): void {}
/**
* Called when the tool is unloaded, usually when the whole `ToolController` is destroyed.
*/
unmounted(): void {}
}
export const ToolIdentifier = createIdentifier<BaseTool>('GfxTool');
export interface GfxToolsMap {}
export interface GfxToolsOption {}
export type GfxToolsFullOption = {
[Key in keyof GfxToolsMap]: Key extends keyof GfxToolsOption
? { type: Key } & GfxToolsOption[Key]
: { type: Key };
};
export type GfxToolsFullOptionValue =
GfxToolsFullOption[keyof GfxToolsFullOption];
@@ -0,0 +1,139 @@
import { DisposableGroup } from '@blocksuite/global/disposable';
import { onSurfaceAdded } from '../../utils/gfx.js';
import {
type GfxBlockComponent,
isGfxBlockComponent,
} from '../../view/index.js';
import type { GfxController } from '../controller.js';
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
import type { GfxModel } from '../model/model.js';
import type { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
import type { GfxLocalElementModel } from '../model/surface/local-element-model.js';
import type { SurfaceBlockModel } from '../model/surface/surface-model.js';
import {
GfxElementModelView,
GfxElementModelViewExtIdentifier,
} from './view.js';
export class ViewManager extends GfxExtension {
static override key = 'viewManager';
private readonly _disposable = new DisposableGroup();
private readonly _viewCtorMap = new Map<string, typeof GfxElementModelView>();
private readonly _viewMap = new Map<string, GfxElementModelView>();
constructor(gfx: GfxController) {
super(gfx);
}
static override extendGfx(gfx: GfxController): void {
Object.defineProperty(gfx, 'view', {
get() {
return this.std.get(GfxExtensionIdentifier('viewManager'));
},
});
}
get(
model: GfxModel | GfxLocalElementModel | string
): GfxElementModelView | GfxBlockComponent | null {
model = typeof model === 'string' ? model : model.id;
if (this._viewMap.has(model)) {
return this._viewMap.get(model)!;
}
const blockView = this.std.view.getBlock(model);
if (blockView && isGfxBlockComponent(blockView)) {
return blockView;
}
return null;
}
override mounted(): void {
this.std.provider
.getAll(GfxElementModelViewExtIdentifier)
.forEach(viewCtor => {
this._viewCtorMap.set(viewCtor.type, viewCtor);
});
const updateViewOnElementChange = (surface: SurfaceBlockModel) => {
const createView = (
model: GfxPrimitiveElementModel | GfxLocalElementModel
) => {
const ViewCtor =
this._viewCtorMap.get(model.type) ?? GfxElementModelView;
const view = new ViewCtor(model, this.gfx);
this._viewMap.set(model.id, view);
view.onCreated();
};
this._disposable.add(
surface.elementAdded.subscribe(payload => {
const model = surface.getElementById(payload.id)!;
createView(model);
})
);
this._disposable.add(
surface.elementRemoved.subscribe(elem => {
const view = this._viewMap.get(elem.id);
this._viewMap.delete(elem.id);
view?.onDestroyed();
})
);
this._disposable.add(
surface.localElementAdded.subscribe(model => {
createView(model);
})
);
this._disposable.add(
surface.localElementDeleted.subscribe(model => {
const view = this._viewMap.get(model.id);
this._viewMap.delete(model.id);
view?.onDestroyed();
})
);
surface.localElementModels.forEach(model => {
createView(model);
});
surface.elementModels.forEach(model => {
createView(model);
});
};
if (this.gfx.surface) {
updateViewOnElementChange(this.gfx.surface);
} else {
this._disposable.add(
onSurfaceAdded(this.std.store, surface => {
if (surface) {
updateViewOnElementChange(surface);
}
})
);
}
}
override unmounted(): void {
this._disposable.dispose();
this._viewMap.forEach(view => view.onDestroyed());
this._viewMap.clear();
}
}
declare module '../controller.js' {
interface GfxController {
readonly view: ViewManager;
}
}
@@ -0,0 +1,221 @@
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { Bound, IVec } from '@blocksuite/global/gfx';
import type { Extension } from '@blocksuite/store';
import type { PointerEventState } from '../../event/index.js';
import type { EditorHost } from '../../view/index.js';
import type {
DragEndContext,
DragMoveContext,
DragStartContext,
GfxViewTransformInterface,
SelectedContext,
} from '../element-transform/view-transform.js';
import type { GfxController } from '../index.js';
import type { GfxElementGeometry, PointTestOptions } from '../model/base.js';
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
import type { GfxLocalElementModel } from '../model/surface/local-element-model.js';
export type EventsHandlerMap = {
click: PointerEventState;
dblclick: PointerEventState;
pointerdown: PointerEventState;
pointerenter: PointerEventState;
pointerleave: PointerEventState;
pointermove: PointerEventState;
pointerup: PointerEventState;
};
export type SupportedEvent = keyof EventsHandlerMap;
export const GfxElementModelViewExtIdentifier = createIdentifier<
typeof GfxElementModelView
>('GfxElementModelView');
export class GfxElementModelView<
T extends GfxLocalElementModel | GfxPrimitiveElementModel =
| GfxPrimitiveElementModel
| GfxLocalElementModel,
RendererContext = object,
>
implements GfxElementGeometry, Extension, GfxViewTransformInterface
{
static type: string;
private readonly _handlers = new Map<
keyof EventsHandlerMap,
((evt: any) => void)[]
>();
private _isConnected = true;
protected disposable = new DisposableGroup();
readonly model: T;
get isConnected() {
return this._isConnected;
}
get rotate() {
return this.model.rotate;
}
get surface() {
return this.model.surface;
}
get type() {
return this.model.type;
}
get std() {
return this.gfx.std;
}
constructor(
model: T,
readonly gfx: GfxController
) {
this.model = model;
}
static setup(di: Container): void {
if (!this.type) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'The GfxElementModelView should have a static `type` property.'
);
}
di.addImpl(GfxElementModelViewExtIdentifier(this.type), () => this);
}
containsBound(bounds: Bound): boolean {
return this.model.containsBound(bounds);
}
dispatch<K extends keyof EventsHandlerMap>(
event: K,
evt: EventsHandlerMap[K]
) {
this._handlers.get(event)?.forEach(callback => callback(evt));
}
getLineIntersections(start: IVec, end: IVec) {
return this.model.getLineIntersections(start, end);
}
getNearestPoint(point: IVec) {
return this.model.getNearestPoint(point);
}
getRelativePointLocation(relativePoint: IVec) {
return this.model.getRelativePointLocation(relativePoint);
}
includesPoint(
x: number,
y: number,
_: PointTestOptions,
__: EditorHost
): boolean {
return this.model.includesPoint(x, y, _, __);
}
intersectsBound(bound: Bound): boolean {
return (
this.containsBound(bound) ||
bound.points.some((point, i, points) =>
this.getLineIntersections(point, points[(i + 1) % points.length])
)
);
}
off<K extends keyof EventsHandlerMap>(
event: K,
callback: (evt: EventsHandlerMap[K]) => void
) {
if (!this._handlers.has(event)) {
return;
}
const callbacks = this._handlers.get(event)!;
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
}
}
on<K extends keyof EventsHandlerMap>(
event: K,
callback: (evt: EventsHandlerMap[K]) => void
) {
if (!this._handlers.has(event)) {
this._handlers.set(event, []);
}
this._handlers.get(event)!.push(callback);
return () => this.off(event, callback);
}
once<K extends keyof EventsHandlerMap>(
event: K,
callback: (evt: EventsHandlerMap[K]) => void
) {
const off = this.on(event, evt => {
off();
callback(evt);
});
return off;
}
onCreated() {}
onDragStart(_: DragStartContext) {
if (this.model instanceof GfxPrimitiveElementModel) {
this.model.stash('xywh');
}
}
onDragEnd(_: DragEndContext) {
if (this.model instanceof GfxPrimitiveElementModel) {
this.model.pop('xywh');
}
}
onDragMove({ dx, dy, currentBound }: DragMoveContext) {
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
}
onSelected(context: SelectedContext) {
if (this.model instanceof GfxPrimitiveElementModel) {
if (context.multiSelect) {
this.gfx.selection.toggle(this.model);
} else {
this.gfx.selection.set({ elements: [this.model.id] });
}
}
}
onResize = () => {};
onRotate = () => {};
/**
* Called when the view is destroyed.
* Override this method requires calling `super.onDestroyed()`.
*/
onDestroyed() {
this._isConnected = false;
this.disposable.dispose();
this._handlers.clear();
}
render(_: RendererContext) {}
}
@@ -0,0 +1,216 @@
import { WithDisposable } from '@blocksuite/global/lit';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { PropTypes, requiredProperties } from '../view/decorators/required.js';
import {
type BlockComponent,
type EditorHost,
ShadowlessElement,
} from '../view/index.js';
import type { GfxBlockElementModel } from './model/gfx-block-model.js';
import { Viewport } from './viewport.js';
/**
* A wrapper around `requestConnectedFrame` that only calls at most once in one frame
*/
export function requestThrottledConnectedFrame<
T extends (...args: unknown[]) => void,
>(func: T, element?: HTMLElement): T {
let raqId: number | undefined = undefined;
let latestArgs: unknown[] = [];
return ((...args: unknown[]) => {
latestArgs = args;
if (raqId === undefined) {
raqId = requestAnimationFrame(() => {
raqId = undefined;
if (!element || element.isConnected) {
func(...latestArgs);
}
});
}
}) as T;
}
function setBlockState(view: BlockComponent | null, state: 'active' | 'idle') {
if (!view) return;
if (state === 'active') {
view.style.visibility = 'visible';
view.style.pointerEvents = 'auto';
view.classList.remove('block-idle');
view.classList.add('block-active');
view.dataset.blockState = 'active';
} else {
view.style.visibility = 'hidden';
view.style.pointerEvents = 'none';
view.classList.remove('block-active');
view.classList.add('block-idle');
view.dataset.blockState = 'idle';
}
}
@requiredProperties({
viewport: PropTypes.instanceOf(Viewport),
})
export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
static override styles = css`
gfx-viewport {
position: absolute;
left: 0;
top: 0;
contain: size layout style;
display: block;
transform: none;
}
/* CSS for idle blocks that are hidden but maintain layout */
.block-idle {
visibility: hidden;
pointer-events: none;
will-change: transform;
contain: size layout style;
}
/* CSS for active blocks participating in viewport transformations */
.block-active {
visibility: visible;
pointer-events: auto;
}
`;
private readonly _hideOutsideBlock = () => {
if (!this.host) return;
const { host } = this;
const modelsInViewport = this.getModelsInViewport();
modelsInViewport.forEach(model => {
const view = host.std.view.getBlock(model.id);
setBlockState(view, 'active');
if (this._lastVisibleModels?.has(model)) {
this._lastVisibleModels!.delete(model);
}
});
this._lastVisibleModels?.forEach(model => {
const view = host.std.view.getBlock(model.id);
setBlockState(view, 'idle');
});
this._lastVisibleModels = modelsInViewport;
};
private _lastVisibleModels?: Set<GfxBlockElementModel>;
private readonly _pendingChildrenUpdates: {
id: string;
resolve: () => void;
}[] = [];
private readonly _refreshViewport = requestThrottledConnectedFrame(() => {
this._hideOutsideBlock();
}, this);
private _updatingChildrenFlag = false;
override connectedCallback(): void {
super.connectedCallback();
const viewportUpdateCallback = () => {
this._refreshViewport();
};
if (!this.enableChildrenSchedule) {
delete this.scheduleUpdateChildren;
}
this._hideOutsideBlock();
this.disposables.add(
this.viewport.viewportUpdated.subscribe(() => viewportUpdateCallback())
);
this.disposables.add(
this.viewport.sizeUpdated.subscribe(() => viewportUpdateCallback())
);
}
override render() {
return html``;
}
scheduleUpdateChildren? = (id: string) => {
const { promise, resolve } = Promise.withResolvers<void>();
this._pendingChildrenUpdates.push({ id, resolve });
if (!this._updatingChildrenFlag) {
this._updatingChildrenFlag = true;
const schedule = () => {
if (this._pendingChildrenUpdates.length) {
const childToUpdates = this._pendingChildrenUpdates.splice(
0,
this.maxConcurrentRenders
);
childToUpdates.forEach(({ resolve }) => resolve());
if (this._pendingChildrenUpdates.length) {
requestAnimationFrame(() => {
this.isConnected && schedule();
});
} else {
this._updatingChildrenFlag = false;
}
}
};
requestAnimationFrame(() => {
this.isConnected && schedule();
});
}
return promise;
};
@property({ attribute: false })
accessor getModelsInViewport: () => Set<GfxBlockElementModel> = () =>
new Set();
@property({ attribute: false })
accessor host: undefined | EditorHost;
@property({ type: Number })
accessor maxConcurrentRenders: number = 2;
@property({ attribute: false })
accessor enableChildrenSchedule: boolean = true;
@property({ attribute: false })
accessor viewport!: Viewport;
setBlocksActive(blockIds: string[]): void {
if (!this.host) return;
blockIds.forEach(id => {
const view = this.host?.std.view.getBlock(id);
if (view) {
setBlockState(view, 'active');
}
});
}
setBlocksIdle(blockIds: string[]): void {
if (!this.host) return;
blockIds.forEach(id => {
const view = this.host?.std.view.getBlock(id);
if (view) {
setBlockState(view, 'idle');
}
});
}
}
@@ -0,0 +1,642 @@
import {
Bound,
clamp,
type IPoint,
type IVec,
Vec,
} from '@blocksuite/global/gfx';
import debounce from 'lodash-es/debounce';
import { BehaviorSubject, debounceTime, Subject } from 'rxjs';
import type { GfxViewportElement } from '.';
function cutoff(value: number, ref: number, sign: number) {
if (sign > 0 && value > ref) return ref;
if (sign < 0 && value < ref) return ref;
return value;
}
export const ZOOM_MAX = 6.0;
export const ZOOM_MIN = 0.1;
export const ZOOM_STEP = 0.25;
export const ZOOM_INITIAL = 1.0;
export const FIT_TO_SCREEN_PADDING = 100;
export interface ViewportRecord {
left: number;
top: number;
viewportX: number;
viewportY: number;
zoom: number;
viewScale: number;
}
export function clientToModelCoord(
viewport: ViewportRecord,
clientCoord: [number, number]
): IVec {
const { left, top, viewportX, viewportY, zoom, viewScale } = viewport;
const [clientX, clientY] = clientCoord;
const viewportInternalX = clientX - left;
const viewportInternalY = clientY - top;
const modelX = viewportX + viewportInternalX / zoom / viewScale;
const modelY = viewportY + viewportInternalY / zoom / viewScale;
return [modelX, modelY];
}
export class Viewport {
private _cachedBoundingClientRect: DOMRect | null = null;
private _cachedOffsetWidth: number | null = null;
private _resizeObserver: ResizeObserver | null = null;
private readonly _resizeSubject = new Subject<{
width: number;
height: number;
left: number;
top: number;
}>();
private _isResizing = false;
private _initialTopLeft: IVec | null = null;
protected _center: IPoint = { x: 0, y: 0 };
protected _shell: HTMLElement | null = null;
protected _element: GfxViewportElement | null = null;
protected _height = 0;
protected _left = 0;
protected _locked = false;
protected _rafId: number | null = null;
protected _top = 0;
protected _width = 0;
protected _zoom: number = 1.0;
elementReady = new Subject<GfxViewportElement>();
sizeUpdated = new Subject<{
width: number;
height: number;
left: number;
top: number;
}>();
viewportMoved = new Subject<IVec>();
viewportUpdated = new Subject<{
zoom: number;
center: IVec;
}>();
zooming$ = new BehaviorSubject<boolean>(false);
panning$ = new BehaviorSubject<boolean>(false);
ZOOM_MAX = ZOOM_MAX;
ZOOM_MIN = ZOOM_MIN;
private readonly _resetZooming = debounce(() => {
this.zooming$.next(false);
}, 200);
private readonly _resetPanning = debounce(() => {
this.panning$.next(false);
}, 200);
constructor() {
const subscription = this.elementReady.subscribe(el => {
this._element = el;
subscription.unsubscribe();
});
this._setupResizeObserver();
}
private _setupResizeObserver() {
this._resizeSubject
.pipe(debounceTime(200))
.subscribe(({ width, height, left, top }) => {
if (!this._shell || !this._initialTopLeft) return;
this._completeResize(width, height, left, top);
});
}
private _completeResize(
width: number,
height: number,
left: number,
top: number
) {
if (!this._initialTopLeft) return;
const [initialTopLeftX, initialTopLeftY] = this._initialTopLeft;
const newCenterX = initialTopLeftX + width / (2 * this.zoom);
const newCenterY = initialTopLeftY + height / (2 * this.zoom);
this.setCenter(newCenterX, newCenterY);
this._width = width;
this._height = height;
this._left = left;
this._top = top;
this._isResizing = false;
this._initialTopLeft = null;
this.sizeUpdated.next({
left,
top,
width,
height,
});
}
private _forceCompleteResize() {
if (this._isResizing && this._shell) {
const { width, height, left, top } = this.boundingClientRect;
this._completeResize(width, height, left, top);
}
}
get boundingClientRect() {
if (!this._shell) return new DOMRect(0, 0, 0, 0);
if (!this._cachedBoundingClientRect) {
this._cachedBoundingClientRect = this._shell.getBoundingClientRect();
}
return this._cachedBoundingClientRect;
}
get element() {
return this._element;
}
get center() {
return this._center;
}
get centerX() {
return this._center.x;
}
get centerY() {
return this._center.y;
}
get height() {
return this.boundingClientRect.height;
}
get left() {
return this._left;
}
// Does not allow the user to move and zoom the canvas in copilot tool
get locked() {
return this._locked;
}
set locked(locked: boolean) {
this._locked = locked;
}
/**
* Note this is different from the zoom property.
* The editor itself may be scaled by outer container which is common in nested editor scenarios.
* This property is used to calculate the scale of the editor.
*/
get viewScale() {
if (!this._shell || this._cachedOffsetWidth === null) return 1;
return this.boundingClientRect.width / this._cachedOffsetWidth;
}
get top() {
return this._top;
}
get translateX() {
return -this.viewportX * this.zoom;
}
get translateY() {
return -this.viewportY * this.zoom;
}
get viewportBounds() {
const { viewportMinXY, viewportMaxXY } = this;
return Bound.from({
...viewportMinXY,
w: viewportMaxXY.x - viewportMinXY.x,
h: viewportMaxXY.y - viewportMinXY.y,
});
}
get viewportMaxXY() {
const { centerX, centerY, width, height, zoom } = this;
return {
x: centerX + width / 2 / zoom,
y: centerY + height / 2 / zoom,
};
}
get viewportMinXY() {
const { centerX, centerY, width, height, zoom } = this;
return {
x: centerX - width / 2 / zoom,
y: centerY - height / 2 / zoom,
};
}
get viewportX() {
const { centerX, width, zoom } = this;
return centerX - width / 2 / zoom;
}
get viewportY() {
const { centerY, height, zoom } = this;
return centerY - height / 2 / zoom;
}
get width() {
return this.boundingClientRect.width;
}
get zoom() {
return this._zoom;
}
applyDeltaCenter(deltaX: number, deltaY: number) {
this.setCenter(this.centerX + deltaX, this.centerY + deltaY);
}
clearViewportElement() {
if (this._resizeObserver && this._shell) {
this._resizeObserver.unobserve(this._shell);
this._resizeObserver.disconnect();
}
this._resizeObserver = null;
this._shell = null;
this._cachedBoundingClientRect = null;
this._cachedOffsetWidth = null;
}
dispose() {
this.clearViewportElement();
this.sizeUpdated.complete();
this.viewportMoved.complete();
this.viewportUpdated.complete();
this._resizeSubject.complete();
this.zooming$.complete();
this.panning$.complete();
}
getFitToScreenData(
bounds?: Bound | null,
padding: [number, number, number, number] = [0, 0, 0, 0],
maxZoom = ZOOM_MAX,
fitToScreenPadding = 100
) {
let { centerX, centerY, zoom } = this;
if (!bounds) {
return { zoom, centerX, centerY };
}
const { x, y, w, h } = bounds;
const [pt, pr, pb, pl] = padding;
const { width, height } = this;
zoom = Math.min(
(width - fitToScreenPadding - (pr + pl)) / w,
(height - fitToScreenPadding - (pt + pb)) / h
);
zoom = clamp(zoom, ZOOM_MIN, clamp(maxZoom, ZOOM_MIN, ZOOM_MAX));
centerX = x + (w + pr / zoom) / 2 - pl / zoom / 2;
centerY = y + (h + pb / zoom) / 2 - pt / zoom / 2;
return { zoom, centerX, centerY };
}
isInViewport(bound: Bound) {
const viewportBounds = Bound.from(this.viewportBounds);
return (
viewportBounds.contains(bound) ||
viewportBounds.isIntersectWithBound(bound)
);
}
onResize() {
if (!this._shell) return;
if (!this._isResizing) {
this._isResizing = true;
this._initialTopLeft = this.toModelCoord(0, 0);
}
const { left, top, width, height } = this.boundingClientRect;
this._cachedOffsetWidth = this._shell.offsetWidth;
this._left = left;
this._top = top;
this._resizeSubject.next({
left,
top,
width,
height,
});
}
/**
* Set the center of the viewport.
* @param centerX The new x coordinate of the center of the viewport.
* @param centerY The new y coordinate of the center of the viewport.
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
*/
setCenter(centerX: number, centerY: number, forceUpdate = false) {
if (forceUpdate && this._isResizing) {
this._forceCompleteResize();
}
this._center.x = centerX;
this._center.y = centerY;
this.panning$.next(true);
this.viewportUpdated.next({
zoom: this.zoom,
center: Vec.toVec(this.center) as IVec,
});
this._resetPanning();
}
setRect(left: number, top: number, width: number, height: number) {
if (this._isResizing) {
this._left = left;
this._top = top;
return;
}
this._left = left;
this._top = top;
this.sizeUpdated.next({
left,
top,
width,
height,
});
}
/**
* Set the viewport to the new zoom and center.
* @param newZoom The new zoom value.
* @param newCenter The new center of the viewport.
* @param smooth Whether to animate the zooming and panning.
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
*/
setViewport(
newZoom: number,
newCenter = Vec.toVec(this.center),
smooth = false,
forceUpdate = smooth
) {
// Force complete any pending resize operations if forceUpdate is true
if (forceUpdate && this._isResizing) {
this._forceCompleteResize();
}
const preZoom = this._zoom;
if (smooth) {
const cofficient = preZoom / newZoom;
if (cofficient === 1) {
this.smoothTranslate(newCenter[0], newCenter[1]);
} else {
const center = [this.centerX, this.centerY] as IVec;
const focusPoint = Vec.mul(
Vec.sub(newCenter, Vec.mul(center, cofficient)),
1 / (1 - cofficient)
);
this.smoothZoom(newZoom, Vec.toPoint(focusPoint));
}
} else {
this._center.x = newCenter[0];
this._center.y = newCenter[1];
this.setZoom(newZoom, undefined, false, forceUpdate);
}
}
/**
* Set the viewport to fit the bound with padding.
* @param bound The bound will be zoomed to fit the viewport.
* @param padding The padding will be applied to the bound after zooming, default is [0, 0, 0, 0],
* the value may be reduced if there is not enough space for the padding.
* Use decimal less than 1 to represent percentage padding. e.g. [0.1, 0.1, 0.1, 0.1] means 10% padding.
* @param smooth whether to animate the zooming
* @param forceUpdate whether to force complete any pending resize operations before setting the viewport
*/
setViewportByBound(
bound: Bound,
padding: [number, number, number, number] = [0, 0, 0, 0],
smooth = false,
forceUpdate = smooth
) {
let [pt, pr, pb, pl] = padding;
// Convert percentage padding to absolute values if they are between 0 and 1
if (pt > 0 && pt < 1) pt *= this.height;
if (pr > 0 && pr < 1) pr *= this.width;
if (pb > 0 && pb < 1) pb *= this.height;
if (pl > 0 && pl < 1) pl *= this.width;
// Calculate zoom
let zoom = Math.min(
(this.width - (pr + pl)) / bound.w,
(this.height - (pt + pb)) / bound.h
);
// Adjust padding if space is not enough
if (zoom < this.ZOOM_MIN) {
zoom = this.ZOOM_MIN;
const totalPaddingWidth = this.width - bound.w * zoom;
const totalPaddingHeight = this.height - bound.h * zoom;
pr = pl = Math.max(totalPaddingWidth / 2, 1);
pt = pb = Math.max(totalPaddingHeight / 2, 1);
}
// Ensure zoom does not exceed ZOOM_MAX
if (zoom > this.ZOOM_MAX) {
zoom = this.ZOOM_MAX;
}
const center = [
bound.x + (bound.w + pr / zoom) / 2 - pl / zoom / 2,
bound.y + (bound.h + pb / zoom) / 2 - pt / zoom / 2,
] as IVec;
this.setViewport(zoom, center, smooth, forceUpdate);
}
/** This is the outer container of the viewport, which is the host of the viewport element */
setShellElement(el: HTMLElement) {
this._shell = el;
this._cachedBoundingClientRect = el.getBoundingClientRect();
this._cachedOffsetWidth = el.offsetWidth;
const { left, top, width, height } = this._cachedBoundingClientRect;
this.setRect(left, top, width, height);
this._resizeObserver = new ResizeObserver(() => {
this._cachedBoundingClientRect = null;
this._cachedOffsetWidth = null;
this.onResize();
});
this._resizeObserver.observe(el);
}
/**
* Set the viewport to the new zoom.
* @param zoom The new zoom value.
* @param focusPoint The point to focus on after zooming, default is the center of the viewport.
* @param wheel Whether the zoom is caused by wheel event.
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
*/
setZoom(
zoom: number,
focusPoint?: IPoint,
wheel = false,
forceUpdate = false
) {
if (forceUpdate && this._isResizing) {
this._forceCompleteResize();
}
const prevZoom = this.zoom;
focusPoint = (focusPoint ?? this._center) as IPoint;
this._zoom = clamp(zoom, this.ZOOM_MIN, this.ZOOM_MAX);
const newZoom = this.zoom;
const offset = Vec.sub(Vec.toVec(this.center), Vec.toVec(focusPoint));
const newCenter = Vec.add(
Vec.toVec(focusPoint),
Vec.mul(offset, prevZoom / newZoom)
);
if (wheel) {
this.zooming$.next(true);
}
this.setCenter(newCenter[0], newCenter[1], forceUpdate);
this.viewportUpdated.next({
zoom: this.zoom,
center: Vec.toVec(this.center) as IVec,
});
this._resetZooming();
}
smoothTranslate(x: number, y: number, numSteps = 10) {
const { center } = this;
const delta = { x: x - center.x, y: y - center.y };
const innerSmoothTranslate = () => {
if (this._rafId) cancelAnimationFrame(this._rafId);
this._rafId = requestAnimationFrame(() => {
const step = { x: delta.x / numSteps, y: delta.y / numSteps };
const nextCenter = {
x: this.centerX + step.x,
y: this.centerY + step.y,
};
const signX = delta.x > 0 ? 1 : -1;
const signY = delta.y > 0 ? 1 : -1;
nextCenter.x = cutoff(nextCenter.x, x, signX);
nextCenter.y = cutoff(nextCenter.y, y, signY);
this.setCenter(nextCenter.x, nextCenter.y, true);
if (nextCenter.x != x || nextCenter.y != y) innerSmoothTranslate();
});
};
innerSmoothTranslate();
}
smoothZoom(zoom: number, focusPoint?: IPoint, numSteps = 10) {
const delta = zoom - this.zoom;
if (this._rafId) cancelAnimationFrame(this._rafId);
const innerSmoothZoom = () => {
this._rafId = requestAnimationFrame(() => {
const sign = delta > 0 ? 1 : -1;
const step = delta / numSteps;
const nextZoom = cutoff(this.zoom + step, zoom, sign);
this.setZoom(nextZoom, focusPoint, undefined, true);
if (nextZoom != zoom) innerSmoothZoom();
});
};
innerSmoothZoom();
}
toModelBound(bound: Bound) {
const { w, h } = bound;
const [x, y] = this.toModelCoord(bound.x, bound.y);
return new Bound(x, y, w / this.zoom, h / this.zoom);
}
toModelCoord(viewX: number, viewY: number): IVec {
const { viewportX, viewportY, zoom, viewScale } = this;
return [
viewportX + viewX / zoom / viewScale,
viewportY + viewY / zoom / viewScale,
];
}
toModelCoordFromClientCoord([x, y]: IVec): IVec {
return clientToModelCoord(this, [x, y]);
}
toViewBound(bound: Bound) {
const { w, h } = bound;
const [x, y] = this.toViewCoord(bound.x, bound.y);
return new Bound(x, y, w * this.zoom, h * this.zoom);
}
toViewCoord(modelX: number, modelY: number): IVec {
const { viewportX, viewportY, zoom, viewScale } = this;
return [
(modelX - viewportX) * zoom * viewScale,
(modelY - viewportY) * zoom * viewScale,
];
}
toViewCoordFromClientCoord([x, y]: IVec): IVec {
const { left, top } = this;
return [x - left, y - top];
}
serializeRecord() {
return JSON.stringify({
left: this.left,
top: this.top,
viewportX: this.viewportX,
viewportY: this.viewportY,
zoom: this.zoom,
viewScale: this.viewScale,
});
}
deserializeRecord(record?: string) {
try {
const result = JSON.parse(record || '{}') as ViewportRecord;
if (!('zoom' in result)) return null;
return result;
} catch (error) {
console.error('Failed to deserialize viewport record:', error);
return null;
}
}
}