mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
Fixes [BS-2753](https://linear.app/affine-design/issue/BS-2753/) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added snapping support when resizing elements, improving alignment and precision during resize operations. - Introduced new resize event handlers allowing extensions to customize resize behavior with start, move, and end callbacks. - **Bug Fixes** - Improved handling of snapping state to prevent errors during drag and resize actions. - **Tests** - Updated resizing tests to ensure consistent snapping behavior by removing default elements that could interfere with test results. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1097 lines
29 KiB
TypeScript
1097 lines
29 KiB
TypeScript
import { type ServiceIdentifier } from '@blocksuite/global/di';
|
|
import { DisposableGroup } from '@blocksuite/global/disposable';
|
|
import { Bound, clamp, Point } from '@blocksuite/global/gfx';
|
|
import { signal } from '@preact/signals-core';
|
|
import last from 'lodash-es/last.js';
|
|
|
|
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 { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
|
import type { GfxElementModelView } from '../view/view.js';
|
|
import { createInteractionContext, type SupportedEvents } from './event.js';
|
|
import {
|
|
type ActionContextMap,
|
|
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,
|
|
DragInitializationOption,
|
|
ExtensionDragEndContext,
|
|
ExtensionDragMoveContext,
|
|
ExtensionDragStartContext,
|
|
} from './types/drag.js';
|
|
import type {
|
|
BoxSelectionContext,
|
|
ResizeConstraint,
|
|
RotateConstraint,
|
|
SelectContext,
|
|
} from './types/view.js';
|
|
|
|
type ExtensionPointerHandler = Exclude<
|
|
SupportedEvents,
|
|
'pointerleave' | 'pointerenter'
|
|
>;
|
|
|
|
export const InteractivityIdentifier = GfxExtensionIdentifier(
|
|
'interactivity-manager'
|
|
) as ServiceIdentifier<InteractivityManager>;
|
|
|
|
export class InteractivityManager extends GfxExtension {
|
|
static override key = 'interactivity-manager';
|
|
|
|
private readonly _disposable = new DisposableGroup();
|
|
|
|
private canvasEventHandler = new GfxViewEventManager(this.gfx);
|
|
|
|
override mounted(): void {
|
|
this.canvasEventHandler = new GfxViewEventManager(this.gfx);
|
|
this.interactExtensions.forEach(ext => {
|
|
ext.mounted();
|
|
});
|
|
}
|
|
|
|
activeInteraction$ = signal<null | {
|
|
type: 'move' | 'resize' | 'rotate';
|
|
elements: GfxModel[];
|
|
} | null>(null);
|
|
|
|
override unmounted(): void {
|
|
this._disposable.dispose();
|
|
this.interactExtensions.forEach(ext => {
|
|
ext.unmounted();
|
|
});
|
|
}
|
|
|
|
get interactExtensions() {
|
|
return this.std.provider.getAll(InteractivityExtensionIdentifier);
|
|
}
|
|
|
|
get keyboard() {
|
|
return this.gfx.keyboard;
|
|
}
|
|
|
|
private _safeExecute(fn: () => void, errorMessage: string) {
|
|
try {
|
|
fn();
|
|
} catch (e) {
|
|
console.error(errorMessage, e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dispatch event to extensions and gfx view.
|
|
* @param eventName
|
|
* @param evt
|
|
* @returns
|
|
*/
|
|
dispatchEvent(eventName: ExtensionPointerHandler, evt: PointerEventState) {
|
|
if (this.activeInteraction$.peek()) {
|
|
return;
|
|
}
|
|
|
|
const { context, preventDefaultState } = createInteractionContext(evt);
|
|
const extensions = this.interactExtensions;
|
|
|
|
extensions.forEach(ext => {
|
|
(ext.event as InteractivityEventAPI).emit(eventName, context);
|
|
});
|
|
|
|
const handledByView =
|
|
this.canvasEventHandler.dispatch(eventName, evt) ?? false;
|
|
|
|
return {
|
|
preventDefaultState,
|
|
handledByView,
|
|
};
|
|
}
|
|
|
|
private _getSelectionConfig(models: GfxModel[]) {
|
|
type SelectionHandlers = Required<
|
|
ReturnType<Required<GfxViewInteractionConfig>['handleSelection']>
|
|
>;
|
|
|
|
const selectionConfigMap = new Map<
|
|
string,
|
|
{
|
|
view: GfxBlockComponent | GfxElementModelView;
|
|
handlers: SelectionHandlers;
|
|
defaultHandlers: SelectionHandlers;
|
|
}
|
|
>();
|
|
|
|
models.forEach(model => {
|
|
const typeOrFlavour = 'flavour' in model ? model.flavour : model.type;
|
|
const view = this.gfx.view.get(model);
|
|
const config = this.std.getOptional(
|
|
GfxViewInteractionIdentifier(typeOrFlavour)
|
|
);
|
|
|
|
if (!view) {
|
|
return;
|
|
}
|
|
|
|
const selectionConfig =
|
|
config?.handleSelection?.({
|
|
gfx: this.gfx,
|
|
std: this.std,
|
|
view,
|
|
model,
|
|
}) ?? {};
|
|
const defaultHandlers = {
|
|
selectable: () => {
|
|
return !model.isLockedByAncestor();
|
|
},
|
|
onSelect: (context: SelectContext) => {
|
|
if (context.multiSelect) {
|
|
this.gfx.selection.toggle(model);
|
|
} else {
|
|
this.gfx.selection.set({ elements: [model.id] });
|
|
}
|
|
|
|
return true;
|
|
},
|
|
};
|
|
|
|
selectionConfigMap.set(model.id, {
|
|
view,
|
|
defaultHandlers,
|
|
handlers: {
|
|
...defaultHandlers,
|
|
...selectionConfig,
|
|
},
|
|
});
|
|
});
|
|
|
|
return selectionConfigMap;
|
|
}
|
|
|
|
private _getSuggestedTarget(context: {
|
|
candidates: GfxModel[];
|
|
target: GfxModel;
|
|
}) {
|
|
const { candidates, target } = context;
|
|
|
|
const suggestedElements: {
|
|
id: string;
|
|
priority?: number;
|
|
}[] = [];
|
|
const suggest = (element: { id: string; priority?: number }) => {
|
|
suggestedElements.push(element);
|
|
};
|
|
|
|
const extensions = this.interactExtensions;
|
|
extensions
|
|
.values()
|
|
.toArray()
|
|
.forEach(ext => {
|
|
return (ext.action as InteractivityActionAPI).emit('elementSelect', {
|
|
candidates,
|
|
target,
|
|
suggest,
|
|
});
|
|
});
|
|
|
|
if (suggestedElements.length) {
|
|
suggestedElements.sort((a, b) => {
|
|
return (a.priority ?? 0) - (b.priority ?? 0);
|
|
});
|
|
|
|
const suggested = last(suggestedElements) as {
|
|
id: string;
|
|
priority?: number;
|
|
};
|
|
const elm = this.gfx.getElementById(suggested.id);
|
|
|
|
return elm instanceof GfxPrimitiveElementModel ||
|
|
elm instanceof GfxBlockElementModel
|
|
? elm
|
|
: target;
|
|
}
|
|
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* Handle element selection.
|
|
* @param evt The pointer event that triggered the selection.
|
|
* @returns True if the element was selected, false otherwise.
|
|
*/
|
|
handleElementSelection(evt: PointerEventState) {
|
|
const { raw } = evt;
|
|
const { gfx } = this;
|
|
const [x, y] = gfx.viewport.toModelCoordFromClientCoord([raw.x, raw.y]);
|
|
let candidates = this.gfx.getElementByPoint(x, y, {
|
|
all: true,
|
|
});
|
|
|
|
const selectionConfigs = this._getSelectionConfig(candidates);
|
|
const context = {
|
|
multiSelect: raw.shiftKey,
|
|
event: raw,
|
|
position: Point.from([x, y]),
|
|
};
|
|
|
|
candidates = candidates.filter(model => {
|
|
if (!selectionConfigs.has(model.id)) {
|
|
return false;
|
|
}
|
|
const config = selectionConfigs.get(model.id)!;
|
|
|
|
return (
|
|
selectionConfigs.has(model.id) &&
|
|
selectionConfigs.get(model.id)?.handlers.selectable({
|
|
...context,
|
|
view: config.view,
|
|
model,
|
|
default: config.defaultHandlers.selectable as () => boolean,
|
|
})
|
|
);
|
|
});
|
|
|
|
{
|
|
let target = last(candidates);
|
|
|
|
if (!target) {
|
|
return false;
|
|
}
|
|
|
|
target = this._getSuggestedTarget({
|
|
candidates,
|
|
target,
|
|
});
|
|
|
|
const config = selectionConfigs.has(target.id)
|
|
? selectionConfigs.get(target.id)
|
|
: this._getSelectionConfig([target]).get(target.id);
|
|
|
|
if (!config) {
|
|
return false;
|
|
}
|
|
|
|
const multiSelect = raw.shiftKey;
|
|
const context = {
|
|
selected: multiSelect ? !gfx.selection.has(target.id) : true,
|
|
multiSelect,
|
|
event: raw,
|
|
position: Point.from([x, y]),
|
|
};
|
|
|
|
const result = config.handlers.onSelect({
|
|
...context,
|
|
selected: multiSelect ? !gfx.selection.has(target.id) : true,
|
|
view: config.view,
|
|
model: target,
|
|
default: config.defaultHandlers.onSelect as () => void,
|
|
});
|
|
|
|
return result ?? true;
|
|
}
|
|
}
|
|
|
|
handleBoxSelection(context: { box: BoxSelectionContext['box'] }) {
|
|
const elements = this.gfx.getElementsByBound(context.box).filter(model => {
|
|
const view = this.gfx.view.get(model);
|
|
|
|
if (
|
|
!view ||
|
|
view.onBoxSelected({
|
|
box: context.box,
|
|
}) === false
|
|
)
|
|
return false;
|
|
|
|
return true;
|
|
});
|
|
|
|
return getTopElements(elements).filter(elm => !elm.isLocked());
|
|
}
|
|
|
|
/**
|
|
* Initialize elements movements.
|
|
* It will handle drag start, move and end events automatically.
|
|
* Note: Call this when mouse is already down.
|
|
*/
|
|
handleElementMove(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.interactExtensions;
|
|
const activeExtensionHandlers = Array.from(
|
|
extension.values().map(ext => {
|
|
return (ext.action as InteractivityActionAPI).emit(
|
|
'dragInitialize',
|
|
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) => {
|
|
this.activeInteraction$.value = null;
|
|
|
|
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 = () => {
|
|
this.activeInteraction$.value = {
|
|
type: 'move',
|
|
elements: context.elements,
|
|
};
|
|
|
|
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();
|
|
}
|
|
|
|
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'
|
|
| 'onResizeMove'
|
|
> & {
|
|
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[];
|
|
const extensionHandlers = this.interactExtensions.values().reduce(
|
|
(handlers, ext) => {
|
|
const extHandlers = (ext.action as InteractivityActionAPI).emit(
|
|
'elementResize',
|
|
{
|
|
elements,
|
|
}
|
|
);
|
|
|
|
if (extHandlers) {
|
|
handlers.push(extHandlers);
|
|
}
|
|
|
|
return handlers;
|
|
},
|
|
[] as ActionContextMap['elementResize']['returnType'][]
|
|
);
|
|
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,
|
|
onResizeMove: ({ dx, dy, handleSign, lockRatio }) => {
|
|
const suggested: {
|
|
dx: number;
|
|
dy: number;
|
|
priority?: number;
|
|
}[] = [];
|
|
const suggest = (distance: { dx: number; dy: number }) => {
|
|
suggested.push(distance);
|
|
};
|
|
|
|
extensionHandlers.forEach(ext => {
|
|
ext.onResizeMove?.({
|
|
dx,
|
|
dy,
|
|
elements,
|
|
handleSign,
|
|
handle,
|
|
lockRatio,
|
|
suggest,
|
|
});
|
|
});
|
|
|
|
suggested.sort((a, b) => {
|
|
return (a.priority ?? 0) - (b.priority ?? 0);
|
|
});
|
|
|
|
return last(suggested) ?? { dx, dy };
|
|
},
|
|
onResizeStart: ({ data }) => {
|
|
this.activeInteraction$.value = {
|
|
type: 'resize',
|
|
elements,
|
|
};
|
|
extensionHandlers.forEach(ext => {
|
|
ext.onResizeStart?.({
|
|
elements,
|
|
handle,
|
|
});
|
|
});
|
|
|
|
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;
|
|
|
|
extensionHandlers.forEach(ext => {
|
|
ext.onResizeEnd?.({
|
|
elements,
|
|
handle,
|
|
});
|
|
});
|
|
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;
|
|
|
|
for (let ext of extensions.values()) {
|
|
const cloneData = (ext.action as InteractivityActionAPI).emit(
|
|
'elementsClone',
|
|
options
|
|
);
|
|
|
|
if (cloneData) {
|
|
return cloneData;
|
|
}
|
|
}
|
|
|
|
return Promise.resolve(undefined);
|
|
}
|
|
}
|