refactor(editor): extract draggable helper of edgeless toolbar (#11068)

This commit is contained in:
Saul-Mirone
2025-03-21 11:45:33 +00:00
parent 35e986cb94
commit ee3494e01d
10 changed files with 18 additions and 9 deletions

View File

@@ -0,0 +1,454 @@
import type { ShapeName } from '@blocksuite/affine-model';
import {
EditPropsStore,
ThemeProvider,
ViewportElementProvider,
} from '@blocksuite/affine-shared/services';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { Bound } from '@blocksuite/global/gfx';
import {
type ReactiveController,
type ReactiveControllerHost,
render,
} from 'lit';
import {
type ElementDragEvent,
mouseResolver,
touchResolver,
} from './event-resolver.js';
import {
createShapeDraggingOverlay,
defaultInfo,
type DraggingInfo,
} from './overlay-factory.js';
import {
defaultIsValidMove,
type EdgelessDraggableElementHost,
type EdgelessDraggableElementOptions,
type ElementInfo,
type OverlayLayer,
} from './types.js';
interface ReactiveState<T> {
cancelled: boolean;
draggingElement: ElementInfo<T> | null;
dragOut: boolean | null;
}
interface EventCache {
onMouseUp?: (e: MouseEvent) => void;
onMouseMove?: (e: MouseEvent) => void;
onTouchMove?: (e: TouchEvent) => void;
onTouchEnd?: (e: TouchEvent) => void;
}
export class EdgelessDraggableElementController<T>
implements ReactiveController
{
clearTimeout: ReturnType<typeof setTimeout> | null = null;
events: EventCache = {};
info = defaultInfo as DraggingInfo<T>;
overlay: OverlayLayer | null = null;
states: ReactiveState<T> = {
cancelled: false,
draggingElement: null,
dragOut: null,
};
constructor(
public host: EdgelessDraggableElementHost & ReactiveControllerHost,
public options: EdgelessDraggableElementOptions<T>
) {
this.host = host;
host.addController(this);
}
get gfx() {
return this.options.edgeless.std.get(GfxControllerIdentifier);
}
/**
* let overlay shape animate back to the original position
*/
private _animateCancelDrop(onFinished?: () => void, duration = 230) {
const { overlay, info } = this;
if (!overlay) return;
this.options?.onCanceled?.(overlay, info.elementInfo);
// unlock pointer events
overlay.mask.style.pointerEvents = 'none';
// clip bottom
if (info.scopeRect) {
overlay.mask.style.height =
info.scopeRect.bottom - info.edgelessRect.top + 'px';
}
const { element, elementRectOriginal } = info;
const newShapeRect = element.getBoundingClientRect();
const x = newShapeRect.left - elementRectOriginal.left;
const y = newShapeRect.top - elementRectOriginal.top;
// apply a transition
overlay.element.style.transition = `transform ${duration}ms ease`;
overlay.element.style.setProperty('--translate-x', `${x}px`);
overlay.element.style.setProperty('--translate-y', `${y}px`);
overlay.transitionWrapper.style.setProperty('--scale', '1');
this.clearTimeout = setTimeout(() => {
if (onFinished) return onFinished();
this.reset();
this.removeAllEvents();
this.clearTimeout = null;
}, duration);
}
private _createOverlay({ x, y }: Pick<ElementDragEvent, 'x' | 'y'>) {
const { edgeless } = this.options;
const { elementInfo, elementRectOriginal, offsetPos, edgelessRect } =
this.info;
this.reset();
this._updateState('draggingElement', elementInfo);
this.overlay = createShapeDraggingOverlay(this.info);
const { overlay } = this;
// init shape position with 'left' and 'top';
const { width, height, left, top } = elementRectOriginal;
const relativeX = left - edgelessRect.left;
const relativeY = top - edgelessRect.top;
// make sure the transform origin is the same as the mouse position
const ox = `${(((x - left) / width) * 100).toFixed(0)}%`;
const oy = `${(((y - top) / height) * 100).toFixed(0)}%`;
Object.assign(overlay.element.style, {
left: `${relativeX}px`,
top: `${relativeY}px`,
});
overlay.element.style.setProperty('--translate-x', `${offsetPos.x}px`);
overlay.element.style.setProperty('--translate-y', `${offsetPos.y}px`);
overlay.transitionWrapper.style.transformOrigin = `${ox} ${oy}`;
const shapeName = (elementInfo as ElementInfo<{ name: ShapeName }>).data
.name;
const { fillColor, strokeColor } =
edgeless.host.std.get(EditPropsStore).lastProps$.value[
`shape:${shapeName}`
] || {};
const color = edgeless.host.std
.get(ThemeProvider)
.generateColorProperty(fillColor);
const stroke = edgeless.host.std
.get(ThemeProvider)
.generateColorProperty(strokeColor);
overlay.element.style.setProperty('color', color);
overlay.element.style.setProperty('stroke', stroke);
// lifecycle hook
this.options.onOverlayCreated?.(overlay, elementInfo);
}
private _onDragEnd() {
const { overlay, info, options } = this;
const { startTime, elementInfo, edgelessRect, validMoved } = info;
const { clickThreshold = 1500 } = options;
const zoom = this.gfx.viewport.zoom;
if (!validMoved) {
const duration = Date.now() - startTime;
if (duration < clickThreshold) {
options.onElementClick?.(info.elementInfo);
if (options.clickToDrag) {
this._createOverlay(info.startPos);
this.info.moved = true;
setTimeout(() => {
this._updateOverlayScale(zoom);
}, 50);
return false;
}
}
this.reset();
return true;
}
if (this.states.dragOut && !this.states.cancelled && overlay) {
const rect = overlay.transitionWrapper.getBoundingClientRect();
const [modelX, modelY] = this.gfx.viewport.toModelCoord(
rect.left - edgelessRect.left,
rect.top - edgelessRect.top
);
const bound = new Bound(
modelX,
modelY,
rect.width / zoom,
rect.height / zoom
);
options?.onDrop?.(elementInfo, bound);
this.reset();
return true;
}
if (!this.states.dragOut) this._animateCancelDrop();
return true;
}
private _onDragMove(e: ElementDragEvent) {
if (this.states.cancelled) return;
const { info, options } = this;
// first move
if (!info.moved) {
info.moved = true;
this._createOverlay(e);
}
const { overlay } = this;
if (!overlay) return;
const { x, y } = e;
const { startPos, scopeRect } = info;
const offsetX = x - startPos.x;
const offsetY = y - startPos.y;
info.offsetPos = { x: offsetX, y: offsetY };
if (!info.validMoved) {
const isValidMove = options.isValidMove ?? defaultIsValidMove;
info.validMoved = isValidMove(info.offsetPos);
}
// check if inside scopeElement
const newDragOut =
!scopeRect ||
y < scopeRect.top ||
y > scopeRect.bottom ||
x < scopeRect.left ||
x > scopeRect.right;
if (newDragOut !== this.states.dragOut)
options.onEnterOrLeaveScope?.(overlay, newDragOut);
this._updateState('dragOut', newDragOut);
// apply transform
// - move shape with translate
overlay.element.style.setProperty('--translate-x', `${offsetX}px`);
overlay.element.style.setProperty('--translate-y', `${offsetY}px`);
// - scale shape with scale
const zoom = this.gfx.viewport.zoom;
this._updateOverlayScale(zoom);
}
private _onDragStart(e: ElementDragEvent, elementInfo: ElementInfo<T>) {
const { scopeElement, edgeless } = this.options;
e.originalEvent.stopPropagation();
e.originalEvent.preventDefault();
// Safari compatibility
// Cannot get edgeless.host.getBoundingClientRect().width in Safari (Always 0)
const edgelessRect = edgeless.host.getBoundingClientRect();
if (edgelessRect.width === 0) {
const { viewport } = edgeless.std.get(ViewportElementProvider);
edgelessRect.width = viewport.clientWidth;
}
this.info = {
startTime: Date.now(),
startPos: { x: e.x, y: e.y },
offsetPos: { x: 0, y: 0 },
scopeRect: scopeElement?.getBoundingClientRect() ?? null,
edgelessRect,
elementRectOriginal: e.el.getBoundingClientRect(),
element: e.el,
elementInfo,
moved: false,
validMoved: false,
parentToMount: edgeless.host,
};
this.removeAllEvents();
if (e.inputType === 'mouse') {
const onMouseMove = (e: MouseEvent) => {
this._onDragMove(mouseResolver(e));
};
const onMouseUp = (_: MouseEvent) => {
const finished = this._onDragEnd();
if (finished) {
edgeless.host.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
};
edgeless.host.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
this.events = { onMouseMove, onMouseUp };
} else {
const onTouchMove = (e: TouchEvent) => {
this._onDragMove(touchResolver(e));
};
const onTouchEnd = (_: TouchEvent) => {
const finished = this._onDragEnd();
if (finished) {
edgeless.host.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('touchend', onTouchEnd);
}
};
edgeless.host.addEventListener('touchmove', onTouchMove);
window.addEventListener('touchend', onTouchEnd);
this.events = { onTouchMove, onTouchEnd };
}
}
/**
* Update overlay shape scale according to the current zoom level
*/
private _updateOverlayScale(zoom: number) {
const transitionWrapper = this.overlay?.transitionWrapper;
if (!transitionWrapper) return;
const standardWidth =
this.info.elementInfo.standardWidth ?? this.options.standardWidth ?? 100;
const { elementRectOriginal } = this.info;
const scale = (standardWidth * zoom) / elementRectOriginal.width;
const clickToDragScale = this.options.clickToDragScale ?? 1.2;
const finalScale = this.states.dragOut
? scale
: this.options.clickToDrag
? clickToDragScale
: 1;
transitionWrapper.style.setProperty('--scale', finalScale.toFixed(2));
}
/**
* @internal
*/
private _updateState<Key extends keyof ReactiveState<T>>(
key: Key,
value: ReactiveState<T>[Key]
) {
this.states[key] = value;
this.host.requestUpdate();
}
private _updateStates(states: Partial<ReactiveState<T>>) {
Object.assign(this.states, states);
this.host.requestUpdate();
}
/**
* Cancel the current dragging & animate even if dragOut
*/
cancel() {
if (this.states.cancelled) return;
this._updateState('cancelled', true);
this._animateCancelDrop();
}
/**
* Same as {@link cancel} but without animation
*/
cancelWithoutAnimation() {
if (this.states.cancelled) return;
this._updateState('cancelled', true);
this.reset();
this.removeAllEvents();
}
/**
* A workaround to apply click event manually
*/
clickToDrag(target: HTMLElement, startPos: { x: number; y: number }) {
if (!this.options.clickToDrag) {
this.options.clickToDrag = true;
console.warn(
'clickToDrag is not enabled, it will be enabled automatically'
);
}
const targetRect = target.getBoundingClientRect();
const targetCenter = {
x: targetRect.left + targetRect.width / 2,
y: targetRect.top + targetRect.height / 2,
};
const mouseDownEvent = new MouseEvent('mousedown', {
clientX: targetCenter.x,
clientY: targetCenter.y,
});
const mouseUpEvent = new MouseEvent('mouseup', {
clientX: targetCenter.x,
clientY: targetCenter.y,
});
target.dispatchEvent(mouseDownEvent);
window.dispatchEvent(mouseUpEvent);
const mouseMoveEvent = new MouseEvent('mousemove', {
clientX: startPos.x,
clientY: startPos.y,
});
this.options.edgeless.host.dispatchEvent(mouseMoveEvent);
}
hostConnected() {
this.host.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(({ zoom }) => {
this._updateOverlayScale(zoom);
})
);
this.host.disposables.addFromEvent(
window,
'keydown',
(e: KeyboardEvent) => {
if (e.key === 'Escape' && this.states.draggingElement) this.cancel();
}
);
}
hostDisconnected() {
this.removeAllEvents();
this.reset();
}
onMouseDown(e: MouseEvent, elementInfo: ElementInfo<T>) {
this._onDragStart(mouseResolver(e), elementInfo);
}
onTouchStart(e: TouchEvent, elementInfo: ElementInfo<T>) {
this._onDragStart(touchResolver(e), elementInfo);
}
removeAllEvents() {
const { events, options } = this;
const host = options.edgeless.host;
const { onMouseUp, onMouseMove, onTouchMove, onTouchEnd } = events;
onMouseUp && window.removeEventListener('mouseup', onMouseUp);
onMouseMove && host && host.removeEventListener('mousemove', onMouseMove);
onTouchMove && host && host.removeEventListener('touchmove', onTouchMove);
onTouchEnd && window.removeEventListener('touchend', onTouchEnd);
this.events = {};
}
reset() {
if (this.clearTimeout) clearTimeout(this.clearTimeout);
this.overlay?.mask.remove();
this.overlay = null;
this._updateStates({
cancelled: false,
draggingElement: null,
dragOut: null,
});
}
updateElementInfo(elementInfo: Partial<ElementInfo<T>>) {
this.info.elementInfo = {
...this.info.elementInfo,
...elementInfo,
};
if (elementInfo.preview && this.overlay) {
render(elementInfo.preview, this.overlay.transitionWrapper);
}
}
}

View File

@@ -0,0 +1,25 @@
export type ElementDragEvent = {
inputType: 'mouse' | 'touch';
x: number;
y: number;
el: HTMLElement;
originalEvent: MouseEvent | TouchEvent;
};
export const touchResolver = (event: TouchEvent) =>
({
inputType: 'touch',
x: event.touches[0].clientX,
y: event.touches[0].clientY,
el: event.currentTarget as HTMLElement,
originalEvent: event,
}) satisfies ElementDragEvent;
export const mouseResolver = (event: MouseEvent) =>
({
inputType: 'mouse',
x: event.clientX,
y: event.clientY,
el: event.currentTarget as HTMLElement,
originalEvent: event,
}) satisfies ElementDragEvent;

View File

@@ -0,0 +1 @@
export * from './draggable-element.controller.js';

View File

@@ -0,0 +1,96 @@
import { render } from 'lit';
import type { ElementInfo, OverlayLayer } from './types.js';
export type DraggingInfo<T> = {
startPos: { x: number; y: number };
offsetPos: { x: number; y: number };
startTime: number;
scopeRect: DOMRect | null;
edgelessRect: DOMRect;
elementRectOriginal: DOMRect;
element: HTMLElement;
elementInfo: ElementInfo<T>;
parentToMount: HTMLElement;
moved: boolean;
validMoved: boolean;
};
export const defaultInfo = {
startPos: { x: 0, y: 0 },
offsetPos: { x: 0, y: 0 },
startTime: 0,
scopeRect: {} as DOMRect,
edgelessRect: {} as DOMRect,
elementRectOriginal: {} as DOMRect,
element: null as unknown as HTMLElement,
elementInfo: null as unknown as ElementInfo<unknown>,
parentToMount: null as unknown as HTMLElement,
moved: false,
validMoved: false,
} satisfies DraggingInfo<unknown>;
const className = (name: string) =>
`edgeless-draggable-control-overlay-${name}`;
const addClass = (node: HTMLElement, name: string) =>
node.classList.add(className(name));
export const createShapeDraggingOverlay = <T>(
info: DraggingInfo<T>
): OverlayLayer => {
const { edgelessRect, parentToMount, element: originalElement } = info;
const elementStyle = getComputedStyle(originalElement);
const mask = document.createElement('div');
addClass(mask, 'mask');
Object.assign(mask.style, {
position: 'absolute',
top: '0',
left: '0',
width: edgelessRect.width + 'px',
height: edgelessRect.height + 'px',
overflow: 'hidden',
zIndex: '9999',
// for debug purpose
// background: 'rgba(255, 0, 0, 0.1)',
});
const element = document.createElement('div');
addClass(element, 'element');
const transitionWrapper = document.createElement('div');
addClass(transitionWrapper, 'transition-wrapper');
Object.assign(transitionWrapper.style, {
transition: 'all 0.18s ease',
transform: 'scale(var(--scale, 1)) rotate(var(--rotate, 0deg))',
width: elementStyle.width,
height: elementStyle.height,
});
transitionWrapper.style.setProperty('--rotate', '0deg');
transitionWrapper.style.setProperty('--scale', '1');
render(info.elementInfo.preview, transitionWrapper);
Object.assign(element.style, {
transform:
'translate(var(--translate-x, 0), var(--translate-y, 0)) rotate(var(--rotate, 0deg)) scale(var(--scale, 1))',
position: 'absolute',
cursor: 'grabbing',
transition: 'inherit',
});
const styleTag = document.createElement('style');
styleTag.textContent = `
.${className('transition-wrapper')} > * {
display: block;
width: 100%;
height: 100%;
}
`;
mask.append(styleTag);
element.append(transitionWrapper);
mask.append(element);
parentToMount.append(mask);
return { mask, element, transitionWrapper };
};

View File

@@ -0,0 +1,97 @@
import type { BlockComponent } from '@blocksuite/block-std';
import type { Bound } from '@blocksuite/global/gfx';
import type { DisposableClass } from '@blocksuite/global/lit';
import type { TemplateResult } from 'lit';
export interface EdgelessDraggableElementHost extends DisposableClass {}
export interface OverlayLayer {
/**
* The root element of the overlay,
* used to handle clip & prevent pointer events
*/
mask: HTMLElement;
/**
* The real preview element
*/
element: HTMLElement;
/**
* The wrapper that contains the preview element,
* different from the element, this element has transition effect
*/
transitionWrapper: HTMLElement;
}
export interface EdgelessDraggableElementOptions<T> {
edgeless: BlockComponent;
/**
* In which element that the target should be dragged out
* If not provided, recognized as the drag-out whenever dragging
*/
scopeElement?: HTMLElement;
/**
* The width of the element when placed to canvas
* @default 100
*/
standardWidth?: number;
/**
* the threshold of mousedown and mouseup duration in ms
* if the duration is less than this value, it will be treated as a click
* @default 1500
*/
clickThreshold?: number;
/**
* if enabled, when clicked, will trigger drag, press ESC or reclick to cancel
*/
clickToDrag?: boolean;
/**
* the scale of the element inside {@link EdgelessDraggableElementController.scopeElement}
* when {@link EdgelessDraggableElementOptions.clickToDrag} is enabled
* @default 1.2
*/
clickToDragScale?: number;
/**
* To verify if the move is valid
*/
isValidMove?: (offset: { x: number; y: number }) => boolean;
/**
* when element is clicked - mouse down and up without moving
*/
onElementClick?: (element: ElementInfo<T>) => void;
/**
* when mouse down and moved, create overlay, customize overlay here
*/
onOverlayCreated?: (overlay: OverlayLayer, element: ElementInfo<T>) => void;
/**
* trigger when enter/leave the scope element
*/
onEnterOrLeaveScope?: (overlay: OverlayLayer, isOutside?: boolean) => void;
/**
* Drop the element on edgeless canvas
*/
onDrop?: (element: ElementInfo<T>, bound: Bound) => void;
/**
* - ESC pressed
* - or not dragged out and released
*/
onCanceled?: (overlay: OverlayLayer, element: ElementInfo<T>) => void;
}
export type ElementInfo<T> = {
// TODO: maybe make it optional, if not provided, clone event target
preview: TemplateResult;
data: T;
/**
* Override the value in {@link EdgelessDraggableElementOptions.standardWidth}
*/
standardWidth?: number;
};
export const defaultIsValidMove = (offset: { x: number; y: number }) => {
return Math.abs(offset.x) > 50 || Math.abs(offset.y) > 50;
};

View File

@@ -1,5 +1,6 @@
export * from './context';
export * from './create-popper';
export * from './draggable';
export * from './edgeless-toolbar';
export * from './extension';
export * from './mixins';