mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 20:38:52 +00:00
refactor(editor): rewrite resize and rotate (#12054)
### Changed
This pr split the old `edgeless-selected-rect` into four focused modules:
- `edgeless-selected-rect`: Provide an entry point for user operation on view layer only, no further logic here.
- `GfxViewInteractionExtension`: Allow you to plug in custom resize/rotate behaviors for block or canvas element. If you don’t register an extension, it falls back to the default behaviours.
- `InteractivityManager`: Provide the API that accepts resize/rotate requests, invokes any custom behaviors you’ve registered, tracks the lifecycle and intermediate state, then hands off to the math engine.
- `ResizeController`: A pure math engine that listens for pointer moves and pointer ups and calculates new sizes, positions, and angles. It doesn’t call any business APIs.
### Customizing an element’s resize/rotate behavior
Call `GfxViewInteractionExtension` with the element’s flavour or type plus a config object. In the config you can define:
- `resizeConstraint` (min/max width & height, lock ratio)
- `handleResize(context)` method that returns an object containing `beforeResize`、`onResizeStart`、`onResizeMove`、`onResizeEnd`
- `handleRotate(context)` method that returns an object containing `beforeRotate`、`onRotateStart`、`onRotateMove`、`onRotateEnd`
```typescript
import { GfxViewInteractionExtension } from '@blocksuite/std/gfx';
GfxViewInteractionExtension(
flavourOrElementType,
{
resizeConstraint: {
minWidth,
maxWidth,
lockRatio,
minHeight,
maxHeight
},
handleResize(context) {
return {
beforeResize(context) {},
onResizeStart(context) {},
onResizeMove(context) {},
onResizeEnd(context) {}
};
},
handleRotate(context) {
return {
beforeRotate(context) {},
onRotateStart(context) {},
onRotateMove(context) {},
onRotateEnd(context) {}
};
}
}
);
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
- **New Features**
- Added interaction extensions for edgeless variants of attachment, bookmark, edgeless text, embedded docs, images, notes, frames, AI chat blocks, and various embed blocks (Figma, GitHub, HTML, iframe, Loom, YouTube).
- Introduced interaction extensions for graphical elements including connectors, groups, mind maps, shapes, and text, supporting constrained resizing and rotation disabling where applicable.
- Implemented a unified interaction extension framework enabling configurable resize and rotate lifecycle handlers.
- Enhanced autocomplete overlay behavior based on selection context.
- **Refactor**
- Removed legacy resize manager and element-specific resize/rotate logic, replacing with a centralized, extensible interaction system.
- Simplified resize handle rendering to a data-driven approach with improved cursor management.
- Replaced complex cursor rotation calculations with fixed-angle mappings for resize handles.
- Streamlined selection rectangle component to use interactivity services for resize and rotate handling.
- **Bug Fixes**
- Fixed connector update triggers to reduce unnecessary updates.
- Improved resize constraints enforcement and interaction state tracking.
- **Tests**
- Refined end-to-end tests to use higher-level resize utilities and added finer-grained assertions on element dimensions.
- Enhanced mouse movement granularity in drag tests for better simulation fidelity.
- **Chores**
- Added new workspace dependencies and project references for the interaction framework modules.
- Extended public API exports to include new interaction types and extensions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -26,10 +26,21 @@ export type {
|
||||
ExtensionDragMoveContext,
|
||||
ExtensionDragStartContext,
|
||||
GfxInteractivityContext,
|
||||
GfxViewInteractionConfig,
|
||||
ResizeConstraint,
|
||||
ResizeEndContext,
|
||||
ResizeHandle,
|
||||
ResizeMoveContext,
|
||||
ResizeStartContext,
|
||||
RotateConstraint,
|
||||
RotateEndContext,
|
||||
RotateMoveContext,
|
||||
RotateStartContext,
|
||||
SelectedContext,
|
||||
} from './interactivity/index.js';
|
||||
export {
|
||||
GfxViewEventManager,
|
||||
GfxViewInteractionExtension,
|
||||
InteractivityExtension,
|
||||
InteractivityIdentifier,
|
||||
InteractivityManager,
|
||||
|
||||
118
blocksuite/framework/std/src/gfx/interactivity/extension/view.ts
Normal file
118
blocksuite/framework/std/src/gfx/interactivity/extension/view.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type { BlockStdScope } from '../../../scope';
|
||||
import type { GfxBlockComponent } from '../../../view';
|
||||
import type { GfxController, GfxModel } from '../..';
|
||||
import type { GfxElementModelView } from '../../view/view';
|
||||
import type {
|
||||
BeforeResizeContext,
|
||||
BeforeRotateContext,
|
||||
ResizeConstraint,
|
||||
ResizeEndContext,
|
||||
ResizeMoveContext,
|
||||
ResizeStartContext,
|
||||
RotateEndContext,
|
||||
RotateMoveContext,
|
||||
RotateStartContext,
|
||||
} from '../types/view';
|
||||
|
||||
type ExtendedViewContext<
|
||||
T extends GfxBlockComponent | GfxElementModelView,
|
||||
Context,
|
||||
> = {
|
||||
/**
|
||||
* The default function of the interaction.
|
||||
* If the interaction is handled by the extension, the default function will not be executed.
|
||||
* But extension can choose to call the default function by `context.default(context)` if needed.
|
||||
*/
|
||||
default: (context: Context) => void;
|
||||
|
||||
model: T['model'];
|
||||
|
||||
view: T;
|
||||
};
|
||||
|
||||
type ViewInteractionHandleContext<
|
||||
T extends GfxBlockComponent | GfxElementModelView,
|
||||
> = {
|
||||
std: BlockStdScope;
|
||||
gfx: GfxController;
|
||||
view: T;
|
||||
model: T['model'];
|
||||
|
||||
/**
|
||||
* Used to add an element to resize list.
|
||||
* @param model
|
||||
*/
|
||||
add(element: GfxModel): void;
|
||||
|
||||
/**
|
||||
* Used to remove an element from resize list.
|
||||
* @param element
|
||||
*/
|
||||
delete(element: GfxModel): void;
|
||||
};
|
||||
|
||||
export type GfxViewInteractionConfig<
|
||||
T extends GfxBlockComponent | GfxElementModelView =
|
||||
| GfxBlockComponent
|
||||
| GfxElementModelView,
|
||||
> = {
|
||||
readonly resizeConstraint?: ResizeConstraint;
|
||||
|
||||
/**
|
||||
* The function that will be called when the view is resized.
|
||||
* You can add or delete the resize element before resize starts in this function.,
|
||||
* And return handlers to customize the resize behavior.
|
||||
* @param context
|
||||
* @returns
|
||||
*/
|
||||
handleResize?: (context: ViewInteractionHandleContext<T>) => {
|
||||
/**
|
||||
* Called before resize starts. When this method is called, the resize elements are confirmed and will not be changed.
|
||||
* You can set the resize constraint in this method.
|
||||
* @param context
|
||||
* @returns
|
||||
*/
|
||||
beforeResize?: (context: BeforeResizeContext) => void;
|
||||
onResizeStart?(
|
||||
context: ResizeStartContext & ExtendedViewContext<T, ResizeStartContext>
|
||||
): void;
|
||||
onResizeMove?(
|
||||
context: ResizeMoveContext & ExtendedViewContext<T, ResizeMoveContext>
|
||||
): void;
|
||||
onResizeEnd?(
|
||||
context: ResizeEndContext & ExtendedViewContext<T, ResizeEndContext>
|
||||
): void;
|
||||
};
|
||||
|
||||
handleRotate?: (context: ViewInteractionHandleContext<T>) => {
|
||||
beforeRotate?: (context: BeforeRotateContext) => void;
|
||||
onRotateStart?(
|
||||
context: RotateStartContext & ExtendedViewContext<T, RotateStartContext>
|
||||
): void;
|
||||
onRotateMove?(
|
||||
context: RotateMoveContext & ExtendedViewContext<T, RotateMoveContext>
|
||||
): void;
|
||||
onRotateEnd?(
|
||||
context: RotateEndContext & ExtendedViewContext<T, RotateEndContext>
|
||||
): void;
|
||||
};
|
||||
};
|
||||
|
||||
export const GfxViewInteractionIdentifier =
|
||||
createIdentifier<GfxViewInteractionConfig>('GfxViewInteraction');
|
||||
|
||||
export function GfxViewInteractionExtension<
|
||||
T extends GfxBlockComponent | GfxElementModelView,
|
||||
>(viewType: string, config: GfxViewInteractionConfig<T>): ExtensionType {
|
||||
return {
|
||||
setup(di) {
|
||||
di.addImpl(
|
||||
GfxViewInteractionIdentifier(viewType),
|
||||
() => config as GfxViewInteractionConfig
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
export type { GfxInteractivityContext } from './event.js';
|
||||
export { InteractivityExtension } from './extension/base.js';
|
||||
export {
|
||||
type GfxViewInteractionConfig,
|
||||
GfxViewInteractionExtension,
|
||||
GfxViewInteractionIdentifier,
|
||||
} from './extension/view.js';
|
||||
export { GfxViewEventManager } from './gfx-view-event-handler.js';
|
||||
export { InteractivityIdentifier, InteractivityManager } from './manager.js';
|
||||
export { type ResizeHandle } from './resize/manager.js';
|
||||
export type {
|
||||
DragExtensionInitializeContext,
|
||||
DragInitializationOption,
|
||||
@@ -15,5 +21,13 @@ export type {
|
||||
DragMoveContext,
|
||||
DragStartContext,
|
||||
GfxViewTransformInterface,
|
||||
ResizeConstraint,
|
||||
ResizeEndContext,
|
||||
ResizeMoveContext,
|
||||
ResizeStartContext,
|
||||
RotateConstraint,
|
||||
RotateEndContext,
|
||||
RotateMoveContext,
|
||||
RotateStartContext,
|
||||
SelectedContext,
|
||||
} from './types/view.js';
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import { type ServiceIdentifier } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { Bound, Point } from '@blocksuite/global/gfx';
|
||||
import { Bound, clamp, Point } from '@blocksuite/global/gfx';
|
||||
import { signal } from '@preact/signals-core';
|
||||
|
||||
import type { PointerEventState } from '../../event/state/pointer.js';
|
||||
import { getTopElements } from '../../utils/tree.js';
|
||||
import type { GfxBlockComponent } from '../../view/index.js';
|
||||
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
|
||||
import { GfxBlockElementModel } from '../model/gfx-block-model.js';
|
||||
import type { GfxModel } from '../model/model.js';
|
||||
import type { GfxElementModelView } from '../view/view.js';
|
||||
import { createInteractionContext, type SupportedEvents } from './event.js';
|
||||
import {
|
||||
type InteractivityActionAPI,
|
||||
type InteractivityEventAPI,
|
||||
InteractivityExtensionIdentifier,
|
||||
} from './extension/base.js';
|
||||
import {
|
||||
type GfxViewInteractionConfig,
|
||||
GfxViewInteractionIdentifier,
|
||||
} from './extension/view.js';
|
||||
import { GfxViewEventManager } from './gfx-view-event-handler.js';
|
||||
import {
|
||||
DEFAULT_HANDLES,
|
||||
type OptionResize,
|
||||
ResizeController,
|
||||
type ResizeHandle,
|
||||
type RotateOption,
|
||||
} from './resize/manager.js';
|
||||
import type { RequestElementsCloneContext } from './types/clone.js';
|
||||
import type {
|
||||
DragExtensionInitializeContext,
|
||||
@@ -21,7 +36,11 @@ import type {
|
||||
ExtensionDragMoveContext,
|
||||
ExtensionDragStartContext,
|
||||
} from './types/drag.js';
|
||||
import type { BoxSelectionContext } from './types/view.js';
|
||||
import type {
|
||||
BoxSelectionContext,
|
||||
ResizeConstraint,
|
||||
RotateConstraint,
|
||||
} from './types/view.js';
|
||||
|
||||
type ExtensionPointerHandler = Exclude<
|
||||
SupportedEvents,
|
||||
@@ -46,6 +65,11 @@ export class InteractivityManager extends GfxExtension {
|
||||
});
|
||||
}
|
||||
|
||||
activeInteraction$ = signal<null | {
|
||||
type: 'move' | 'resize' | 'rotate';
|
||||
elements: GfxModel[];
|
||||
} | null>(null);
|
||||
|
||||
override unmounted(): void {
|
||||
this._disposable.dispose();
|
||||
this.interactExtensions.forEach(ext => {
|
||||
@@ -76,6 +100,10 @@ export class InteractivityManager extends GfxExtension {
|
||||
* @returns
|
||||
*/
|
||||
dispatchEvent(eventName: ExtensionPointerHandler, evt: PointerEventState) {
|
||||
if (this.activeInteraction$.peek()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { context, preventDefaultState } = createInteractionContext(evt);
|
||||
const extensions = this.interactExtensions;
|
||||
|
||||
@@ -247,6 +275,8 @@ export class InteractivityManager extends GfxExtension {
|
||||
});
|
||||
};
|
||||
const onDragEnd = (event: PointerEvent) => {
|
||||
this.activeInteraction$.value = null;
|
||||
|
||||
host.removeEventListener('pointermove', onDragMove, false);
|
||||
host.removeEventListener('pointerup', onDragEnd, false);
|
||||
viewportWatcher.unsubscribe();
|
||||
@@ -292,6 +322,11 @@ export class InteractivityManager extends GfxExtension {
|
||||
host.addEventListener('pointerup', onDragEnd, false);
|
||||
};
|
||||
const dragStart = () => {
|
||||
this.activeInteraction$.value = {
|
||||
type: 'move',
|
||||
elements: context.elements,
|
||||
};
|
||||
|
||||
internal.elements.forEach(({ view, originalBound }) => {
|
||||
view.onDragStart({
|
||||
currentBound: originalBound,
|
||||
@@ -316,6 +351,519 @@ export class InteractivityManager extends GfxExtension {
|
||||
dragStart();
|
||||
}
|
||||
|
||||
handleElementRotate(
|
||||
options: Omit<
|
||||
RotateOption,
|
||||
'onRotateStart' | 'onRotateEnd' | 'onRotateUpdate'
|
||||
> & {
|
||||
onRotateUpdate?: (payload: {
|
||||
currentAngle: number;
|
||||
delta: number;
|
||||
}) => void;
|
||||
onRotateStart?: () => void;
|
||||
onRotateEnd?: () => void;
|
||||
}
|
||||
) {
|
||||
const { rotatable, viewConfigMap, initialRotate } =
|
||||
this._getViewRotateConfig(options.elements);
|
||||
|
||||
if (!rotatable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = new ResizeController({ gfx: this.gfx });
|
||||
const elements = Array.from(viewConfigMap.values()).map(
|
||||
config => config.view.model
|
||||
) as GfxModel[];
|
||||
|
||||
handler.startRotate({
|
||||
...options,
|
||||
elements,
|
||||
onRotateStart: payload => {
|
||||
this.activeInteraction$.value = {
|
||||
type: 'rotate',
|
||||
elements,
|
||||
};
|
||||
options.onRotateStart?.();
|
||||
payload.data.forEach(({ model }) => {
|
||||
if (!viewConfigMap.has(model.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { handlers, defaultHandlers, view, constraint } =
|
||||
viewConfigMap.get(model.id)!;
|
||||
|
||||
handlers.onRotateStart({
|
||||
default: defaultHandlers.onRotateStart as () => void,
|
||||
constraint,
|
||||
model,
|
||||
view,
|
||||
});
|
||||
});
|
||||
},
|
||||
onRotateUpdate: payload => {
|
||||
options.onRotateUpdate?.({
|
||||
currentAngle: initialRotate + payload.delta,
|
||||
delta: payload.delta,
|
||||
});
|
||||
payload.data.forEach(
|
||||
({
|
||||
model,
|
||||
newBound,
|
||||
originalBound,
|
||||
newRotate,
|
||||
originalRotate,
|
||||
matrix,
|
||||
}) => {
|
||||
if (!viewConfigMap.has(model.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { handlers, defaultHandlers, view, constraint } =
|
||||
viewConfigMap.get(model.id)!;
|
||||
|
||||
handlers.onRotateMove({
|
||||
model,
|
||||
newBound,
|
||||
originalBound,
|
||||
newRotate,
|
||||
originalRotate,
|
||||
default: defaultHandlers.onRotateMove as () => void,
|
||||
constraint,
|
||||
view,
|
||||
matrix,
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
onRotateEnd: payload => {
|
||||
this.activeInteraction$.value = null;
|
||||
options.onRotateEnd?.();
|
||||
this.std.store.transact(() => {
|
||||
payload.data.forEach(({ model }) => {
|
||||
if (!viewConfigMap.has(model.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { handlers, defaultHandlers, view, constraint } =
|
||||
viewConfigMap.get(model.id)!;
|
||||
|
||||
handlers.onRotateEnd({
|
||||
default: defaultHandlers.onRotateEnd as () => void,
|
||||
view,
|
||||
model,
|
||||
constraint,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getViewRotateConfig(elements: GfxModel[]) {
|
||||
const deleted = new Set<GfxModel>();
|
||||
const added = new Set<GfxModel>();
|
||||
const del = (model: GfxModel) => {
|
||||
deleted.add(model);
|
||||
};
|
||||
const add = (model: GfxModel) => {
|
||||
added.add(model);
|
||||
};
|
||||
|
||||
type ViewRotateHandlers = Required<
|
||||
ReturnType<Required<GfxViewInteractionConfig>['handleRotate']>
|
||||
>;
|
||||
|
||||
const viewConfigMap = new Map<
|
||||
string,
|
||||
{
|
||||
model: GfxModel;
|
||||
view: GfxElementModelView | GfxBlockComponent;
|
||||
handlers: ViewRotateHandlers;
|
||||
defaultHandlers: ViewRotateHandlers;
|
||||
constraint: Required<RotateConstraint>;
|
||||
}
|
||||
>();
|
||||
|
||||
const addToConfigMap = (model: GfxModel) => {
|
||||
const flavourOrType = 'type' in model ? model.type : model.flavour;
|
||||
const interactionConfig = this.std.getOptional(
|
||||
GfxViewInteractionIdentifier(flavourOrType)
|
||||
);
|
||||
const view = this.gfx.view.get(model);
|
||||
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultHandlers: ViewRotateHandlers = {
|
||||
beforeRotate: () => {},
|
||||
onRotateStart: context => {
|
||||
if (!context.constraint.rotatable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (model instanceof GfxBlockElementModel) {
|
||||
if (Object.hasOwn(model.props, 'rotate')) {
|
||||
// @ts-expect-error prop existence has been checked
|
||||
model.stash('rotate');
|
||||
model.stash('xywh');
|
||||
}
|
||||
} else {
|
||||
model.stash('rotate');
|
||||
model.stash('xywh');
|
||||
}
|
||||
},
|
||||
onRotateEnd: context => {
|
||||
if (!context.constraint.rotatable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (model instanceof GfxBlockElementModel) {
|
||||
if (Object.hasOwn(model.props, 'rotate')) {
|
||||
// @ts-expect-error prop existence has been checked
|
||||
model.pop('rotate');
|
||||
model.pop('xywh');
|
||||
}
|
||||
} else {
|
||||
model.pop('rotate');
|
||||
model.pop('xywh');
|
||||
}
|
||||
},
|
||||
onRotateMove: context => {
|
||||
if (!context.constraint.rotatable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { newBound, newRotate } = context;
|
||||
model.rotate = newRotate;
|
||||
model.xywh = newBound.serialize();
|
||||
},
|
||||
};
|
||||
const handlers = interactionConfig?.handleRotate?.({
|
||||
std: this.std,
|
||||
gfx: this.gfx,
|
||||
view,
|
||||
model,
|
||||
delete: del,
|
||||
add,
|
||||
});
|
||||
|
||||
viewConfigMap.set(model.id, {
|
||||
model,
|
||||
view,
|
||||
defaultHandlers,
|
||||
handlers: Object.assign({}, defaultHandlers, handlers ?? {}),
|
||||
constraint: {
|
||||
rotatable: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
elements.forEach(addToConfigMap);
|
||||
|
||||
deleted.forEach(model => {
|
||||
if (viewConfigMap.has(model.id)) {
|
||||
viewConfigMap.delete(model.id);
|
||||
}
|
||||
});
|
||||
|
||||
added.forEach(model => {
|
||||
if (viewConfigMap.has(model.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
addToConfigMap(model);
|
||||
});
|
||||
|
||||
const views = Array.from(viewConfigMap.values().map(item => item.view));
|
||||
|
||||
let rotatable = true;
|
||||
viewConfigMap.forEach(config => {
|
||||
const handlers = config.handlers;
|
||||
|
||||
handlers.beforeRotate({
|
||||
set: (newConstraint: RotateConstraint) => {
|
||||
Object.assign(config.constraint, newConstraint);
|
||||
rotatable = rotatable && config.constraint.rotatable;
|
||||
},
|
||||
elements: views,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
initialRotate: views.length > 1 ? 0 : (views[0]?.model.rotate ?? 0),
|
||||
rotatable,
|
||||
viewConfigMap,
|
||||
};
|
||||
}
|
||||
|
||||
private _getViewResizeConfig(elements: GfxModel[]) {
|
||||
const deleted = new Set<GfxModel>();
|
||||
const added = new Set<GfxModel>();
|
||||
const del = (model: GfxModel) => {
|
||||
deleted.add(model);
|
||||
};
|
||||
const add = (model: GfxModel) => {
|
||||
added.add(model);
|
||||
};
|
||||
|
||||
type ViewResizeHandlers = Required<
|
||||
ReturnType<Required<GfxViewInteractionConfig>['handleResize']>
|
||||
>;
|
||||
|
||||
const viewConfigMap = new Map<
|
||||
string,
|
||||
{
|
||||
model: GfxModel;
|
||||
view: GfxElementModelView | GfxBlockComponent;
|
||||
constraint: Required<ResizeConstraint>;
|
||||
handlers: ViewResizeHandlers;
|
||||
defaultHandlers: ViewResizeHandlers;
|
||||
}
|
||||
>();
|
||||
const addToConfigMap = (model: GfxModel) => {
|
||||
const flavourOrType = 'type' in model ? model.type : model.flavour;
|
||||
const interactionConfig = this.std.getOptional(
|
||||
GfxViewInteractionIdentifier(flavourOrType)
|
||||
);
|
||||
const view = this.gfx.view.get(model);
|
||||
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultHandlers: ViewResizeHandlers = {
|
||||
beforeResize: () => {},
|
||||
onResizeStart: () => {
|
||||
model.stash('xywh');
|
||||
},
|
||||
onResizeEnd: () => {
|
||||
model.pop('xywh');
|
||||
},
|
||||
onResizeMove: context => {
|
||||
const { newBound, constraint } = context;
|
||||
const { minWidth, minHeight, maxWidth, maxHeight } = constraint;
|
||||
|
||||
newBound.w = clamp(newBound.w, minWidth, maxWidth);
|
||||
newBound.h = clamp(newBound.h, minHeight, maxHeight);
|
||||
|
||||
model.xywh = newBound.serialize();
|
||||
},
|
||||
};
|
||||
const handlers = interactionConfig?.handleResize?.({
|
||||
std: this.std,
|
||||
gfx: this.gfx,
|
||||
view,
|
||||
model,
|
||||
delete: del,
|
||||
add,
|
||||
});
|
||||
|
||||
viewConfigMap.set(model.id, {
|
||||
model,
|
||||
view,
|
||||
constraint: {
|
||||
lockRatio: false,
|
||||
allowedHandlers: DEFAULT_HANDLES,
|
||||
minHeight: 2,
|
||||
minWidth: 2,
|
||||
maxHeight: 5000000,
|
||||
maxWidth: 5000000,
|
||||
...interactionConfig?.resizeConstraint,
|
||||
},
|
||||
defaultHandlers,
|
||||
handlers: Object.assign({}, defaultHandlers, handlers ?? {}),
|
||||
});
|
||||
};
|
||||
|
||||
elements.forEach(addToConfigMap);
|
||||
|
||||
deleted.forEach(model => {
|
||||
if (viewConfigMap.has(model.id)) {
|
||||
viewConfigMap.delete(model.id);
|
||||
}
|
||||
});
|
||||
|
||||
added.forEach(model => {
|
||||
if (viewConfigMap.has(model.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
addToConfigMap(model);
|
||||
});
|
||||
|
||||
const views = Array.from(viewConfigMap.values().map(item => item.view));
|
||||
let allowedHandlers = new Set(DEFAULT_HANDLES);
|
||||
|
||||
viewConfigMap.forEach(config => {
|
||||
const currConstraint: Required<ResizeConstraint> = config.constraint;
|
||||
|
||||
config.handlers.beforeResize({
|
||||
set: (newConstraint: ResizeConstraint) => {
|
||||
Object.assign(currConstraint, newConstraint);
|
||||
},
|
||||
elements: views,
|
||||
});
|
||||
|
||||
config.constraint = currConstraint;
|
||||
|
||||
const currentAllowedHandlers = new Set(currConstraint.allowedHandlers);
|
||||
allowedHandlers.forEach(h => {
|
||||
if (!currentAllowedHandlers.has(h)) {
|
||||
allowedHandlers.delete(h);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
allowedHandlers: Array.from(allowedHandlers) as ResizeHandle[],
|
||||
viewConfigMap,
|
||||
};
|
||||
}
|
||||
|
||||
getRotateConfig(options: { elements: GfxModel[] }) {
|
||||
return this._getViewRotateConfig(options.elements);
|
||||
}
|
||||
|
||||
getResizeHandlers(options: { elements: GfxModel[] }) {
|
||||
return this._getViewResizeConfig(options.elements).allowedHandlers;
|
||||
}
|
||||
|
||||
handleElementResize(
|
||||
options: Omit<
|
||||
OptionResize,
|
||||
'lockRatio' | 'onResizeStart' | 'onResizeEnd' | 'onResizeUpdate'
|
||||
> & {
|
||||
onResizeStart?: () => void;
|
||||
onResizeEnd?: () => void;
|
||||
onResizeUpdate?: (payload: {
|
||||
lockRatio: boolean;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
exceed: {
|
||||
w: boolean;
|
||||
h: boolean;
|
||||
};
|
||||
}) => void;
|
||||
}
|
||||
) {
|
||||
const { viewConfigMap, allowedHandlers } = this._getViewResizeConfig(
|
||||
options.elements
|
||||
);
|
||||
|
||||
if (!allowedHandlers.includes(options.handle)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { handle } = options;
|
||||
const controller = new ResizeController({ gfx: this.gfx });
|
||||
const elements = Array.from(viewConfigMap.values()).map(
|
||||
config => config.view.model
|
||||
) as GfxModel[];
|
||||
let lockRatio = false;
|
||||
|
||||
viewConfigMap.forEach(config => {
|
||||
const { lockRatio: lockRatioConfig } = config.constraint;
|
||||
|
||||
lockRatio =
|
||||
lockRatio ||
|
||||
lockRatioConfig === true ||
|
||||
(Array.isArray(lockRatioConfig) && lockRatioConfig.includes(handle));
|
||||
});
|
||||
|
||||
controller.startResize({
|
||||
...options,
|
||||
lockRatio,
|
||||
elements,
|
||||
onResizeStart: ({ data }) => {
|
||||
this.activeInteraction$.value = {
|
||||
type: 'resize',
|
||||
elements,
|
||||
};
|
||||
options.onResizeStart?.();
|
||||
data.forEach(({ model }) => {
|
||||
if (!viewConfigMap.has(model.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { handlers, defaultHandlers, view, constraint } =
|
||||
viewConfigMap.get(model.id)!;
|
||||
|
||||
handlers.onResizeStart({
|
||||
handle,
|
||||
default: defaultHandlers.onResizeStart as () => void,
|
||||
constraint,
|
||||
model,
|
||||
view,
|
||||
});
|
||||
});
|
||||
},
|
||||
onResizeUpdate: ({ data, scaleX, scaleY, lockRatio }) => {
|
||||
const exceed = {
|
||||
w: false,
|
||||
h: false,
|
||||
};
|
||||
|
||||
data.forEach(
|
||||
({ model, newBound, originalBound, lockRatio, matrix }) => {
|
||||
if (!viewConfigMap.has(model.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { handlers, defaultHandlers, view, constraint } =
|
||||
viewConfigMap.get(model.id)!;
|
||||
|
||||
handlers.onResizeMove({
|
||||
model,
|
||||
newBound,
|
||||
originalBound,
|
||||
handle,
|
||||
default: defaultHandlers.onResizeMove as () => void,
|
||||
constraint,
|
||||
view,
|
||||
lockRatio,
|
||||
matrix,
|
||||
});
|
||||
|
||||
exceed.w =
|
||||
exceed.w ||
|
||||
model.w === constraint.minWidth ||
|
||||
model.w === constraint.maxWidth;
|
||||
exceed.h =
|
||||
exceed.h ||
|
||||
model.h === constraint.minHeight ||
|
||||
model.h === constraint.maxHeight;
|
||||
}
|
||||
);
|
||||
|
||||
options.onResizeUpdate?.({ scaleX, scaleY, lockRatio, exceed });
|
||||
},
|
||||
onResizeEnd: ({ data }) => {
|
||||
this.activeInteraction$.value = null;
|
||||
options.onResizeEnd?.();
|
||||
this.std.store.transact(() => {
|
||||
data.forEach(({ model }) => {
|
||||
if (!viewConfigMap.has(model.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { handlers, defaultHandlers, view, constraint } =
|
||||
viewConfigMap.get(model.id)!;
|
||||
|
||||
handlers.onResizeEnd({
|
||||
default: defaultHandlers.onResizeEnd as () => void,
|
||||
view,
|
||||
model,
|
||||
constraint,
|
||||
handle,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
requestElementClone(options: RequestElementsCloneContext) {
|
||||
const extensions = this.interactExtensions;
|
||||
|
||||
|
||||
558
blocksuite/framework/std/src/gfx/interactivity/resize/manager.ts
Normal file
558
blocksuite/framework/std/src/gfx/interactivity/resize/manager.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
import {
|
||||
Bound,
|
||||
getCommonBoundWithRotation,
|
||||
type IVec,
|
||||
} from '@blocksuite/global/gfx';
|
||||
|
||||
import type { GfxController } from '../..';
|
||||
import type { GfxModel } from '../../model/model';
|
||||
|
||||
export type ResizeHandle =
|
||||
| 'top-left'
|
||||
| 'top'
|
||||
| 'top-right'
|
||||
| 'right'
|
||||
| 'bottom-right'
|
||||
| 'bottom'
|
||||
| 'bottom-left'
|
||||
| 'left';
|
||||
|
||||
export const DEFAULT_HANDLES: ResizeHandle[] = [
|
||||
'top-left',
|
||||
'top-right',
|
||||
'bottom-left',
|
||||
'bottom-right',
|
||||
'left',
|
||||
'right',
|
||||
'top',
|
||||
'bottom',
|
||||
];
|
||||
|
||||
interface ElementInitialSnapshot {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
rotate: number;
|
||||
}
|
||||
|
||||
export interface OptionResize {
|
||||
elements: GfxModel[];
|
||||
handle: ResizeHandle;
|
||||
lockRatio: boolean;
|
||||
event: PointerEvent;
|
||||
onResizeUpdate: (payload: {
|
||||
lockRatio: boolean;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
data: {
|
||||
model: GfxModel;
|
||||
originalBound: Bound;
|
||||
newBound: Bound;
|
||||
lockRatio: boolean;
|
||||
matrix: DOMMatrix;
|
||||
}[];
|
||||
}) => void;
|
||||
onResizeStart?: (payload: { data: { model: GfxModel }[] }) => void;
|
||||
onResizeEnd?: (payload: { data: { model: GfxModel }[] }) => void;
|
||||
}
|
||||
|
||||
export type RotateOption = {
|
||||
elements: GfxModel[];
|
||||
event: PointerEvent;
|
||||
|
||||
onRotateUpdate: (payload: {
|
||||
delta: number;
|
||||
data: {
|
||||
model: GfxModel;
|
||||
newBound: Bound;
|
||||
originalBound: Bound;
|
||||
originalRotate: number;
|
||||
newRotate: number;
|
||||
matrix: DOMMatrix;
|
||||
}[];
|
||||
}) => void;
|
||||
|
||||
onRotateStart?: (payload: { data: { model: GfxModel }[] }) => void;
|
||||
|
||||
onRotateEnd?: (payload: { data: { model: GfxModel }[] }) => void;
|
||||
};
|
||||
|
||||
export class ResizeController {
|
||||
private readonly gfx: GfxController;
|
||||
|
||||
get host() {
|
||||
return this.gfx.std.host;
|
||||
}
|
||||
|
||||
constructor(option: { gfx: GfxController }) {
|
||||
this.gfx = option.gfx;
|
||||
}
|
||||
|
||||
startResize(options: OptionResize) {
|
||||
const {
|
||||
elements,
|
||||
handle,
|
||||
lockRatio,
|
||||
onResizeStart,
|
||||
onResizeUpdate,
|
||||
onResizeEnd,
|
||||
event,
|
||||
} = options;
|
||||
|
||||
const originals: ElementInitialSnapshot[] = elements.map(el => ({
|
||||
x: el.x,
|
||||
y: el.y,
|
||||
w: el.w,
|
||||
h: el.h,
|
||||
rotate: el.rotate,
|
||||
}));
|
||||
const startPt = this.gfx.viewport.toModelCoordFromClientCoord([
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
]);
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const currPt = this.gfx.viewport.toModelCoordFromClientCoord([
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
]);
|
||||
const shouldLockRatio = lockRatio || e.shiftKey;
|
||||
|
||||
if (elements.length === 1) {
|
||||
this.resizeSingle(
|
||||
originals[0],
|
||||
elements[0],
|
||||
shouldLockRatio,
|
||||
startPt,
|
||||
currPt,
|
||||
handle,
|
||||
onResizeUpdate
|
||||
);
|
||||
} else {
|
||||
this.resizeMulti(
|
||||
originals,
|
||||
elements,
|
||||
handle,
|
||||
currPt,
|
||||
startPt,
|
||||
onResizeUpdate
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onResizeStart?.({ data: elements.map(model => ({ model })) });
|
||||
|
||||
const onPointerUp = () => {
|
||||
this.host.removeEventListener('pointermove', onPointerMove);
|
||||
this.host.removeEventListener('pointerup', onPointerUp);
|
||||
|
||||
onResizeEnd?.({ data: elements.map(model => ({ model })) });
|
||||
};
|
||||
|
||||
this.host.addEventListener('pointermove', onPointerMove);
|
||||
this.host.addEventListener('pointerup', onPointerUp);
|
||||
}
|
||||
|
||||
private resizeSingle(
|
||||
orig: ElementInitialSnapshot,
|
||||
model: GfxModel,
|
||||
lockRatio: boolean,
|
||||
startPt: IVec,
|
||||
currPt: IVec,
|
||||
handle: ResizeHandle,
|
||||
updateCallback: OptionResize['onResizeUpdate']
|
||||
) {
|
||||
const { xSign, ySign } = this.getHandleSign(handle);
|
||||
|
||||
const pivot = new DOMPoint(
|
||||
orig.x + (-xSign === 1 ? orig.w : 0),
|
||||
orig.y + (-ySign === 1 ? orig.h : 0)
|
||||
);
|
||||
const toLocalRotatedM = new DOMMatrix()
|
||||
.translate(-pivot.x, -pivot.y)
|
||||
.translate(orig.w / 2 + orig.x, orig.h / 2 + orig.y)
|
||||
.rotate(-orig.rotate)
|
||||
.translate(-(orig.w / 2 + orig.x), -(orig.h / 2 + orig.y));
|
||||
const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y);
|
||||
|
||||
const toLocal = (p: DOMPoint, withRotation: boolean) =>
|
||||
p.matrixTransform(withRotation ? toLocalRotatedM : toLocalM);
|
||||
const toModel = (p: DOMPoint) =>
|
||||
p.matrixTransform(toLocalRotatedM.inverse());
|
||||
|
||||
const currPtLocal = toLocal(new DOMPoint(currPt[0], currPt[1]), true);
|
||||
const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1]), true);
|
||||
|
||||
let scaleX = xSign
|
||||
? (xSign * (currPtLocal.x - handleLocal.x) + orig.w) / orig.w
|
||||
: 1;
|
||||
let scaleY = ySign
|
||||
? (ySign * (currPtLocal.y - handleLocal.y) + orig.h) / orig.h
|
||||
: 1;
|
||||
|
||||
if (lockRatio) {
|
||||
const min = Math.min(Math.abs(scaleX), Math.abs(scaleY));
|
||||
scaleX = Math.sign(scaleX) * min;
|
||||
scaleY = Math.sign(scaleY) * min;
|
||||
}
|
||||
|
||||
const scaleM = new DOMMatrix().scale(scaleX, scaleY);
|
||||
|
||||
const [visualTopLeft, visualBottomRight] = [
|
||||
new DOMPoint(orig.x, orig.y),
|
||||
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
|
||||
].map(p => {
|
||||
const localP = toLocal(p, false);
|
||||
const scaledP = localP.matrixTransform(scaleM);
|
||||
|
||||
return toModel(scaledP);
|
||||
});
|
||||
|
||||
const center = {
|
||||
x:
|
||||
Math.min(visualTopLeft.x, visualBottomRight.x) +
|
||||
Math.abs(visualBottomRight.x - visualTopLeft.x) / 2,
|
||||
y:
|
||||
Math.min(visualTopLeft.y, visualBottomRight.y) +
|
||||
Math.abs(visualBottomRight.y - visualTopLeft.y) / 2,
|
||||
};
|
||||
|
||||
const restoreM = new DOMMatrix()
|
||||
.translate(center.x, center.y)
|
||||
.rotate(-orig.rotate)
|
||||
.translate(-center.x, -center.y);
|
||||
|
||||
// only used to provide the matrix information
|
||||
const finalM = restoreM
|
||||
.multiply(toLocalRotatedM.inverse())
|
||||
.multiply(scaleM)
|
||||
.multiply(toLocalM);
|
||||
|
||||
const [topLeft, bottomRight] = [visualTopLeft, visualBottomRight].map(p => {
|
||||
return p.matrixTransform(restoreM);
|
||||
});
|
||||
|
||||
updateCallback({
|
||||
lockRatio,
|
||||
scaleX,
|
||||
scaleY,
|
||||
data: [
|
||||
{
|
||||
model: model,
|
||||
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
|
||||
newBound: new Bound(
|
||||
Math.min(topLeft.x, bottomRight.x),
|
||||
Math.min(bottomRight.y, topLeft.y),
|
||||
Math.abs(bottomRight.x - topLeft.x),
|
||||
Math.abs(bottomRight.y - topLeft.y)
|
||||
),
|
||||
lockRatio: lockRatio,
|
||||
matrix: finalM,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private resizeMulti(
|
||||
originals: ElementInitialSnapshot[],
|
||||
elements: GfxModel[],
|
||||
handle: ResizeHandle,
|
||||
currPt: IVec,
|
||||
startPt: IVec,
|
||||
updateCallback: OptionResize['onResizeUpdate']
|
||||
) {
|
||||
const commonBound = getCommonBoundWithRotation(originals);
|
||||
const { xSign, ySign } = this.getHandleSign(handle);
|
||||
|
||||
const pivot = new DOMPoint(
|
||||
commonBound.x + ((-xSign + 1) / 2) * commonBound.w,
|
||||
commonBound.y + ((-ySign + 1) / 2) * commonBound.h
|
||||
);
|
||||
const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y);
|
||||
|
||||
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
|
||||
|
||||
const currPtLocal = toLocal(new DOMPoint(currPt[0], currPt[1]));
|
||||
const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1]));
|
||||
|
||||
let scaleX = xSign
|
||||
? (xSign * (currPtLocal.x - handleLocal.x) + commonBound.w) /
|
||||
commonBound.w
|
||||
: 1;
|
||||
let scaleY = ySign
|
||||
? (ySign * (currPtLocal.y - handleLocal.y) + commonBound.h) /
|
||||
commonBound.h
|
||||
: 1;
|
||||
|
||||
const min = Math.max(Math.abs(scaleX), Math.abs(scaleY));
|
||||
scaleX = Math.sign(scaleX) * min;
|
||||
scaleY = Math.sign(scaleY) * min;
|
||||
|
||||
const scaleM = new DOMMatrix().scale(scaleX, scaleY);
|
||||
|
||||
const data = elements.map((model, i) => {
|
||||
const orig = originals[i];
|
||||
const finalM = new DOMMatrix()
|
||||
.multiply(toLocalM.inverse())
|
||||
.multiply(scaleM)
|
||||
.multiply(toLocalM);
|
||||
const [topLeft, bottomRight] = [
|
||||
new DOMPoint(orig.x, orig.y),
|
||||
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
|
||||
].map(p => {
|
||||
return p.matrixTransform(finalM);
|
||||
});
|
||||
|
||||
const newBound = new Bound(
|
||||
Math.min(topLeft.x, bottomRight.x),
|
||||
Math.min(bottomRight.y, topLeft.y),
|
||||
Math.abs(bottomRight.x - topLeft.x),
|
||||
Math.abs(bottomRight.y - topLeft.y)
|
||||
);
|
||||
|
||||
return {
|
||||
model,
|
||||
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
|
||||
newBound,
|
||||
lockRatio: true,
|
||||
matrix: finalM,
|
||||
};
|
||||
});
|
||||
|
||||
updateCallback({ lockRatio: true, scaleX, scaleY, data });
|
||||
}
|
||||
|
||||
startRotate(option: RotateOption) {
|
||||
const { event, elements, onRotateUpdate } = option;
|
||||
|
||||
const originals: ElementInitialSnapshot[] = elements.map(el => ({
|
||||
x: el.x,
|
||||
y: el.y,
|
||||
w: el.w,
|
||||
h: el.h,
|
||||
rotate: el.rotate,
|
||||
}));
|
||||
|
||||
const startPt = this.gfx.viewport.toModelCoordFromClientCoord([
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
]);
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const currentPt = this.gfx.viewport.toModelCoordFromClientCoord([
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
]);
|
||||
const snap = e.shiftKey;
|
||||
|
||||
if (elements.length > 1) {
|
||||
this.rotateMulti({
|
||||
origs: originals,
|
||||
models: elements,
|
||||
startPt,
|
||||
currentPt,
|
||||
snap,
|
||||
onRotateUpdate,
|
||||
});
|
||||
} else {
|
||||
this.rotateSingle({
|
||||
orig: originals[0],
|
||||
model: elements[0],
|
||||
startPt,
|
||||
currentPt,
|
||||
snap,
|
||||
onRotateUpdate,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
this.host.removeEventListener('pointermove', onPointerMove);
|
||||
this.host.removeEventListener('pointerup', onPointerUp);
|
||||
this.host.removeEventListener('pointercancel', onPointerUp);
|
||||
|
||||
option.onRotateEnd?.({ data: elements.map(model => ({ model })) });
|
||||
};
|
||||
|
||||
option.onRotateStart?.({ data: elements.map(model => ({ model })) });
|
||||
|
||||
this.host.addEventListener('pointermove', onPointerMove, false);
|
||||
this.host.addEventListener('pointerup', onPointerUp, false);
|
||||
this.host.addEventListener('pointercancel', onPointerUp, false);
|
||||
}
|
||||
|
||||
private getNormalizedAngle(y: number, x: number) {
|
||||
let angle = Math.atan2(y, x);
|
||||
if (angle < 0) {
|
||||
angle += 2 * Math.PI;
|
||||
}
|
||||
|
||||
return (angle * 180) / Math.PI;
|
||||
}
|
||||
|
||||
private toNormalizedAngle(angle: number) {
|
||||
if (angle < 0) {
|
||||
angle += 360;
|
||||
}
|
||||
|
||||
return Math.round(angle) % 360;
|
||||
}
|
||||
|
||||
private rotateSingle(option: {
|
||||
orig: ElementInitialSnapshot;
|
||||
model: GfxModel;
|
||||
startPt: IVec;
|
||||
currentPt: IVec;
|
||||
snap: boolean;
|
||||
onRotateUpdate?: RotateOption['onRotateUpdate'];
|
||||
}) {
|
||||
const { orig, model, startPt, currentPt, snap, onRotateUpdate } = option;
|
||||
|
||||
const center = {
|
||||
x: orig.x + orig.w / 2,
|
||||
y: orig.y + orig.h / 2,
|
||||
};
|
||||
const toLocalM = new DOMMatrix().translate(-center.x, -center.y);
|
||||
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
|
||||
|
||||
const v0 = toLocal(new DOMPoint(startPt[0], startPt[1])),
|
||||
v1 = toLocal(new DOMPoint(currentPt[0], currentPt[1]));
|
||||
|
||||
const startAngle = this.getNormalizedAngle(v0.y, v0.x),
|
||||
endAngle = this.getNormalizedAngle(v1.y, v1.x);
|
||||
const deltaDeg = endAngle - startAngle;
|
||||
const rotatedAngle = orig.rotate + deltaDeg;
|
||||
const targetRotate = this.toNormalizedAngle(
|
||||
snap
|
||||
? Math.round((rotatedAngle % 15) / 15) * 15 +
|
||||
Math.floor(rotatedAngle / 15) * 15
|
||||
: rotatedAngle
|
||||
);
|
||||
|
||||
// only used to provide the matrix information
|
||||
const rotateM = new DOMMatrix()
|
||||
.translate(center.x, center.y)
|
||||
.rotate(targetRotate - orig.rotate)
|
||||
.translate(-center.x, -center.y);
|
||||
|
||||
onRotateUpdate?.({
|
||||
delta: deltaDeg,
|
||||
data: [
|
||||
{
|
||||
model,
|
||||
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
|
||||
newBound: new Bound(orig.x, orig.y, orig.w, orig.h),
|
||||
originalRotate: orig.rotate,
|
||||
newRotate: targetRotate,
|
||||
matrix: rotateM,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private rotateMulti(option: {
|
||||
origs: ElementInitialSnapshot[];
|
||||
models: GfxModel[];
|
||||
startPt: IVec;
|
||||
currentPt: IVec;
|
||||
snap: boolean;
|
||||
onRotateUpdate?: RotateOption['onRotateUpdate'];
|
||||
}) {
|
||||
const { models, startPt, currentPt, onRotateUpdate } = option;
|
||||
const commonBound = getCommonBoundWithRotation(option.origs);
|
||||
|
||||
const center = {
|
||||
x: commonBound.x + commonBound.w / 2,
|
||||
y: commonBound.y + commonBound.h / 2,
|
||||
};
|
||||
const toLocalM = new DOMMatrix().translate(-center.x, -center.y);
|
||||
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
|
||||
|
||||
const v0 = toLocal(new DOMPoint(startPt[0], startPt[1])),
|
||||
v1 = toLocal(new DOMPoint(currentPt[0], currentPt[1]));
|
||||
const a0 = this.getNormalizedAngle(v0.y, v0.x),
|
||||
a1 = this.getNormalizedAngle(v1.y, v1.x);
|
||||
const deltaDeg = a1 - a0;
|
||||
const rotateM = new DOMMatrix()
|
||||
.translate(center.x, center.y)
|
||||
.rotate(deltaDeg)
|
||||
.translate(-center.x, -center.y);
|
||||
const toRotatedPoint = (p: DOMPoint) => p.matrixTransform(rotateM);
|
||||
|
||||
onRotateUpdate?.({
|
||||
delta: deltaDeg,
|
||||
data: models.map((model, index) => {
|
||||
const orig = option.origs[index];
|
||||
const center = {
|
||||
x: orig.x + orig.w / 2,
|
||||
y: orig.y + orig.h / 2,
|
||||
};
|
||||
|
||||
const visualM = new DOMMatrix()
|
||||
.translate(center.x, center.y)
|
||||
.rotate(orig.rotate)
|
||||
.translate(-center.x, -center.y);
|
||||
const toVisual = (p: DOMPoint) => p.matrixTransform(visualM);
|
||||
|
||||
const [rotatedVisualLeftTop, rotatedVisualBottomRight] = [
|
||||
new DOMPoint(orig.x, orig.y),
|
||||
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
|
||||
].map(p => toRotatedPoint(toVisual(p)));
|
||||
|
||||
const newCenter = {
|
||||
x:
|
||||
Math.min(rotatedVisualLeftTop.x, rotatedVisualBottomRight.x) +
|
||||
Math.abs(rotatedVisualBottomRight.x - rotatedVisualLeftTop.x) / 2,
|
||||
y:
|
||||
Math.min(rotatedVisualLeftTop.y, rotatedVisualBottomRight.y) +
|
||||
Math.abs(rotatedVisualBottomRight.y - rotatedVisualLeftTop.y) / 2,
|
||||
};
|
||||
const newRotated = this.toNormalizedAngle(orig.rotate + deltaDeg);
|
||||
const finalM = new DOMMatrix()
|
||||
.translate(newCenter.x, newCenter.y)
|
||||
.rotate(-newRotated)
|
||||
.translate(-newCenter.x, -newCenter.y)
|
||||
.multiply(rotateM)
|
||||
.multiply(visualM);
|
||||
|
||||
const topLeft = rotatedVisualLeftTop.matrixTransform(
|
||||
new DOMMatrix()
|
||||
.translate(newCenter.x, newCenter.y)
|
||||
.rotate(-newRotated)
|
||||
.translate(-newCenter.x, -newCenter.y)
|
||||
);
|
||||
|
||||
return {
|
||||
model,
|
||||
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
|
||||
newBound: new Bound(topLeft.x, topLeft.y, orig.w, orig.h),
|
||||
originalRotate: orig.rotate,
|
||||
newRotate: newRotated,
|
||||
matrix: finalM,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private getHandleSign(handle: ResizeHandle) {
|
||||
switch (handle) {
|
||||
case 'top-left':
|
||||
return { xSign: -1, ySign: -1 };
|
||||
case 'top':
|
||||
return { xSign: 0, ySign: -1 };
|
||||
case 'top-right':
|
||||
return { xSign: 1, ySign: -1 };
|
||||
case 'right':
|
||||
return { xSign: 1, ySign: 0 };
|
||||
case 'bottom-right':
|
||||
return { xSign: 1, ySign: 1 };
|
||||
case 'bottom':
|
||||
return { xSign: 0, ySign: 1 };
|
||||
case 'bottom-left':
|
||||
return { xSign: -1, ySign: 1 };
|
||||
case 'left':
|
||||
return { xSign: -1, ySign: 0 };
|
||||
default:
|
||||
return { xSign: 0, ySign: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Bound, IBound, IPoint } from '@blocksuite/global/gfx';
|
||||
import type { GfxBlockComponent } from '../../../view/element/gfx-block-component.js';
|
||||
import type { GfxModel } from '../../model/model.js';
|
||||
import type { GfxElementModelView } from '../../view/view.js';
|
||||
import type { ResizeHandle } from '../resize/manager.js';
|
||||
|
||||
export type DragStartContext = {
|
||||
/**
|
||||
@@ -34,6 +35,97 @@ export type DragMoveContext = DragStartContext & {
|
||||
|
||||
export type DragEndContext = DragMoveContext;
|
||||
|
||||
export type ResizeConstraint = {
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
allowedHandlers?: ResizeHandle[];
|
||||
|
||||
/**
|
||||
* Whether to lock the aspect ratio of the element when resizing.
|
||||
* If the value is an array, it will only lock the aspect ratio when resizing the specified handles.
|
||||
*/
|
||||
lockRatio?: boolean | ResizeHandle[];
|
||||
};
|
||||
|
||||
export type BeforeResizeContext = {
|
||||
/**
|
||||
* The elements that will be resized
|
||||
*/
|
||||
elements: (GfxBlockComponent | GfxElementModelView)[];
|
||||
|
||||
/**
|
||||
* Set the constraint before resize starts.
|
||||
*/
|
||||
set: (constraint: ResizeConstraint) => void;
|
||||
};
|
||||
|
||||
export type ResizeStartContext = {
|
||||
/**
|
||||
* The handle that is used to resize the element
|
||||
*/
|
||||
handle: ResizeHandle;
|
||||
|
||||
/**
|
||||
* The resize constraint.
|
||||
*/
|
||||
constraint: Readonly<Required<ResizeConstraint>>;
|
||||
};
|
||||
|
||||
export type ResizeMoveContext = ResizeStartContext & {
|
||||
/**
|
||||
* The element bound when resize starts
|
||||
*/
|
||||
originalBound: Bound;
|
||||
|
||||
newBound: Bound;
|
||||
|
||||
/**
|
||||
* The matrix that used to transform the element.
|
||||
*/
|
||||
matrix: DOMMatrix;
|
||||
|
||||
lockRatio: boolean;
|
||||
};
|
||||
|
||||
export type ResizeEndContext = ResizeStartContext;
|
||||
|
||||
export type RotateConstraint = {
|
||||
rotatable?: boolean;
|
||||
};
|
||||
|
||||
export type BeforeRotateContext = {
|
||||
/**
|
||||
* The elements that will be rotated
|
||||
*/
|
||||
elements: (GfxBlockComponent | GfxElementModelView)[];
|
||||
|
||||
/**
|
||||
* Set the constraint before rotate starts.
|
||||
*/
|
||||
set: (constraint: RotateConstraint) => void;
|
||||
};
|
||||
|
||||
export type RotateStartContext = {
|
||||
constraint: Readonly<Required<RotateConstraint>>;
|
||||
};
|
||||
|
||||
export type RotateMoveContext = RotateStartContext & {
|
||||
newBound: Bound;
|
||||
|
||||
originalBound: Bound;
|
||||
|
||||
newRotate: number;
|
||||
|
||||
originalRotate: number;
|
||||
|
||||
matrix: DOMMatrix;
|
||||
};
|
||||
|
||||
export type RotateEndContext = RotateStartContext;
|
||||
|
||||
export type SelectedContext = {
|
||||
/**
|
||||
* The selected state of the element
|
||||
@@ -79,8 +171,6 @@ 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
|
||||
|
||||
@@ -116,7 +116,19 @@ export class GfxBlockElementModel<
|
||||
*/
|
||||
responseExtension: [number, number] = [0, 0];
|
||||
|
||||
rotate = 0;
|
||||
get rotate() {
|
||||
if ('rotate' in this.props) {
|
||||
return this.props.rotate as number;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
set rotate(rotate: number) {
|
||||
if ('rotate' in this.props) {
|
||||
this.props.rotate = rotate;
|
||||
}
|
||||
}
|
||||
|
||||
get deserializedXYWH() {
|
||||
if (this._cacheDeserKey !== this.xywh || !this._cacheDeserXYWH) {
|
||||
|
||||
@@ -224,10 +224,6 @@ export class GfxElementModelView<
|
||||
|
||||
onBoxSelected(_: BoxSelectionContext): boolean | void {}
|
||||
|
||||
onResize = () => {};
|
||||
|
||||
onRotate = () => {};
|
||||
|
||||
/**
|
||||
* Called when the view is destroyed.
|
||||
* Override this method requires calling `super.onDestroyed()`.
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
GfxViewTransformInterface,
|
||||
SelectedContext,
|
||||
} from '../../gfx/interactivity/index.js';
|
||||
import { type GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js';
|
||||
import type { GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js';
|
||||
import { SurfaceSelection } from '../../selection/index.js';
|
||||
import { BlockComponent } from './block-component.js';
|
||||
|
||||
@@ -116,10 +116,6 @@ export abstract class GfxBlockComponent<
|
||||
|
||||
onBoxSelected(_: BoxSelectionContext) {}
|
||||
|
||||
onRotate() {}
|
||||
|
||||
onResize() {}
|
||||
|
||||
getCSSTransform() {
|
||||
const viewport = this.gfx.viewport;
|
||||
const { translateX, translateY, zoom } = viewport;
|
||||
@@ -236,10 +232,6 @@ export function toGfxBlockComponent<
|
||||
|
||||
onBoxSelected(_: BoxSelectionContext) {}
|
||||
|
||||
onRotate() {}
|
||||
|
||||
onResize() {}
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user