Files
AFFiNE-Mirror/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts
doouding 1c8d25bc29 feat: add ElementTransformManager for edgeless element basic manipulation (#10824)
### Overview:
We've been working with some legacy code in the default-tool and edgeless-selected-rect modules, which are responsible for fundamental operations like moving, resizing, and rotating elements. Currently, these operations are hardcoded, making it challenging to extend functionalities without diving deep into the code.

### What's Changing:
Introducing `ElementTransformManager` to streamline the handling of basic transformations (move, resize, rotate) while allowing the business logic to dictate when these actions occur.

Providing two ways to extend the transformations behaviour:
- Extends inside element view definition: Elements can decide how to handle move/resize events, such as enforcing size constraints.
- Extension mechanism provided by this manager: Adjust or completely override default drag behaviors, like snapping elements into alignment.

### Code Examples:
Delegate element movement to TransformManager:
```typescript
class DefaultTool {
  override dragStart(event) {
    if(this.dragType === DragType.ContentMoving) {
      const transformManager = this.std.get(TransformManagerIdentifier);
      transformManager.startDrag({ selectedElements, event });
    }
  }
}
```

 Enforce minimum width inside view definition:
```typescript
class EdgelessNoteBlock extends GfxBlockComponent {
  onResizeDelta({ dw, dh }) {
    const bound = this.model.elementBound;
    bound.w = Math.min(MAX_WIDTH, bound.w + dw);
    bound.h = Math.min(MAX_HEIGHT, bound.h + dh);
    this.model.xywh = bound.serialize();
  }
}
```

Use extension to implement element snapping:
```typescript
import { TransformerExtension } from '@blocksuite/std/gfx';

// Just extends the TransformerExtension
class SnapManager extends TransformerExtension {
  static override key = 'snap-manager';
  onDragInitialize() {
    return {
      onDragMove(context) {
        const { dx, dy } = this.getAlignmentMoveDistance(context.elements);
        context.dx = dx;
        context.dy = dy;
      }
    }
  }
}
```

### Others

The migration will be divided into several PRs. This PR mostly focus on refactoring elements movement part of `default-tool`.
- Delegate elements movement to `TransformManager`
- Rewrite the default tool extension into `TransformManager` extension
- Add drag handler interface to gfx view (both `GfxBlockComponent` and `GfxElementModelView`) to allow element to define how it gonna react on drag
2025-03-19 15:30:06 +00:00

284 lines
7.4 KiB
TypeScript

import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound } from '@blocksuite/global/gfx';
import { computed } from '@preact/signals-core';
import { nothing } from 'lit';
import type { BlockService } from '../../extension/index.js';
import type {
DragMoveContext,
GfxViewTransformInterface,
} from '../../gfx/element-transform/view-transform.js';
import { GfxControllerIdentifier } from '../../gfx/identifiers.js';
import type { GfxBlockElementModel } from '../../gfx/index.js';
import { SurfaceSelection } from '../../selection/index.js';
import { BlockComponent } from './block-component.js';
export function isGfxBlockComponent(
element: unknown
): element is GfxBlockComponent {
return (element as GfxBlockComponent)?.[GfxElementSymbol] === true;
}
export const GfxElementSymbol = Symbol('GfxElement');
function updateTransform(element: GfxBlockComponent) {
if (element.dataset.blockState === 'idle') return;
const { viewport } = element.gfx;
element.dataset.viewportState = viewport.serializeRecord();
element.style.transformOrigin = '0 0';
element.style.transform = element.getCSSTransform();
}
function handleGfxConnection(instance: GfxBlockComponent) {
instance.style.position = 'absolute';
instance.disposables.add(
instance.gfx.viewport.viewportUpdated.subscribe(() => {
updateTransform(instance);
})
);
instance.disposables.add(
instance.doc.slots.blockUpdated.subscribe(({ type, id }) => {
if (id === instance.model.id && type === 'update') {
updateTransform(instance);
}
})
);
updateTransform(instance);
}
export abstract class GfxBlockComponent<
Model extends GfxBlockElementModel = GfxBlockElementModel,
Service extends BlockService = BlockService,
WidgetName extends string = string,
>
extends BlockComponent<Model, Service, WidgetName>
implements GfxViewTransformInterface
{
[GfxElementSymbol] = true;
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
override connectedCallback(): void {
super.connectedCallback();
handleGfxConnection(this);
}
onDragMove = ({ dx, dy, currentBound }: DragMoveContext) => {
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
};
onDragStart() {
this.model.stash('xywh');
}
onDragEnd() {
this.model.pop('xywh');
}
onRotate() {}
onResize() {}
getCSSTransform() {
const viewport = this.gfx.viewport;
const { translateX, translateY, zoom } = viewport;
const bound = Bound.deserialize(this.model.xywh);
const scaledX = bound.x * zoom;
const scaledY = bound.y * zoom;
const deltaX = scaledX - bound.x;
const deltaY = scaledY - bound.y;
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`;
}
getRenderingRect() {
const { xywh$ } = this.model;
if (!xywh$) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
`Error on rendering '${this.model.flavour}': Gfx block's model should have 'xywh' property.`
);
}
const [x, y, w, h] = JSON.parse(xywh$.value);
return { x, y, w, h, zIndex: this.toZIndex() };
}
override renderBlock() {
const { x, y, w, h, zIndex } = this.getRenderingRect();
if (this.style.left !== `${x}px`) this.style.left = `${x}px`;
if (this.style.top !== `${y}px`) this.style.top = `${y}px`;
if (this.style.width !== `${w}px`) this.style.width = `${w}px`;
if (this.style.height !== `${h}px`) this.style.height = `${h}px`;
if (this.style.zIndex !== zIndex) this.style.zIndex = zIndex;
return this.renderGfxBlock();
}
renderGfxBlock(): unknown {
return nothing;
}
renderPageContent(): unknown {
return nothing;
}
override async scheduleUpdate() {
const parent = this.parentElement;
if (this.hasUpdated || !parent || !('scheduleUpdateChildren' in parent)) {
return super.scheduleUpdate();
} else {
await (parent.scheduleUpdateChildren as (id: string) => Promise<void>)(
this.model.id
);
return super.scheduleUpdate();
}
}
toZIndex(): string {
return this.gfx.layer.getZIndex(this.model).toString() ?? '0';
}
updateZIndex(): void {
this.style.zIndex = this.toZIndex();
}
}
export function toGfxBlockComponent<
Model extends GfxBlockElementModel,
Service extends BlockService,
WidgetName extends string,
B extends typeof BlockComponent<Model, Service, WidgetName>,
>(CustomBlock: B) {
// @ts-expect-error ignore
return class extends CustomBlock {
[GfxElementSymbol] = true;
override selected$ = computed(() => {
const selection = this.std.selection.value.find(
selection => selection.blockId === this.model?.id
);
if (!selection) return false;
return selection.is(SurfaceSelection);
});
onDragMove({ dx, dy, currentBound }: DragMoveContext) {
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
}
onDragStart() {
this.model.stash('xywh');
}
onDragEnd() {
this.model.pop('xywh');
}
onRotate() {}
onResize() {}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
override connectedCallback(): void {
super.connectedCallback();
handleGfxConnection(this);
}
// eslint-disable-next-line sonarjs/no-identical-functions
getCSSTransform() {
const viewport = this.gfx.viewport;
const { translateX, translateY, zoom } = viewport;
const bound = Bound.deserialize(this.model.xywh);
const scaledX = bound.x * zoom;
const scaledY = bound.y * zoom;
const deltaX = scaledX - bound.x;
const deltaY = scaledY - bound.y;
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`;
}
// eslint-disable-next-line sonarjs/no-identical-functions
getRenderingRect(): {
x: number;
y: number;
w: number | string;
h: number | string;
zIndex: string;
} {
const { xywh$ } = this.model;
if (!xywh$) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
`Error on rendering '${this.model.flavour}': Gfx block's model should have 'xywh' property.`
);
}
const [x, y, w, h] = JSON.parse(xywh$.value);
return { x, y, w, h, zIndex: this.toZIndex() };
}
override renderBlock() {
const { x, y, w, h, zIndex } = this.getRenderingRect();
this.style.left = `${x}px`;
this.style.top = `${y}px`;
this.style.width = typeof w === 'number' ? `${w}px` : w;
this.style.height = typeof h === 'number' ? `${h}px` : h;
this.style.zIndex = zIndex;
return this.renderGfxBlock();
}
renderGfxBlock(): unknown {
return this.renderPageContent();
}
renderPageContent() {
return super.renderBlock();
}
// eslint-disable-next-line sonarjs/no-identical-functions
override async scheduleUpdate() {
const parent = this.parentElement;
if (this.hasUpdated || !parent || !('scheduleUpdateChildren' in parent)) {
return super.scheduleUpdate();
} else {
await (parent.scheduleUpdateChildren as (id: string) => Promise<void>)(
this.model.id
);
return super.scheduleUpdate();
}
}
toZIndex(): string {
return this.gfx.layer.getZIndex(this.model).toString() ?? '0';
}
updateZIndex(): void {
this.style.zIndex = this.toZIndex();
}
} as B & {
new (...args: any[]): GfxBlockComponent;
};
}