From cf456c888f264ccc418cf45587b6b41d66b024d7 Mon Sep 17 00:00:00 2001 From: doouding Date: Wed, 28 May 2025 02:47:01 +0000 Subject: [PATCH] feat: support snap when resizing element (#12563) Fixes [BS-2753](https://linear.app/affine-design/issue/BS-2753/) ## 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. --- .../gfx/pointer/src/snap/snap-manager.ts | 61 ++++++++++++- .../src/gfx/interactivity/extension/base.ts | 28 +++++- .../std/src/gfx/interactivity/manager.ts | 66 +++++++++++++- .../src/gfx/interactivity/resize/manager.ts | 85 +++++++++++++------ .../std/src/gfx/interactivity/types/resize.ts | 30 +++++++ .../blocksuite/e2e/edgeless/resizing.spec.ts | 17 +++- 6 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 blocksuite/framework/std/src/gfx/interactivity/types/resize.ts diff --git a/blocksuite/affine/gfx/pointer/src/snap/snap-manager.ts b/blocksuite/affine/gfx/pointer/src/snap/snap-manager.ts index 0fef9bccb0..dd49fd1567 100644 --- a/blocksuite/affine/gfx/pointer/src/snap/snap-manager.ts +++ b/blocksuite/affine/gfx/pointer/src/snap/snap-manager.ts @@ -1,6 +1,6 @@ import { OverlayIdentifier } from '@blocksuite/affine-block-surface'; import { MindmapElementModel } from '@blocksuite/affine-model'; -import type { Bound } from '@blocksuite/global/gfx'; +import { Bound } from '@blocksuite/global/gfx'; import { type DragExtensionInitializeContext, type ExtensionDragMoveContext, @@ -28,7 +28,7 @@ export class SnapExtension extends InteractivityExtension { return {}; } - let alignBound: Bound; + let alignBound: Bound | null = null; return { onDragStart() { @@ -46,6 +46,7 @@ export class SnapExtension extends InteractivityExtension { onDragMove(context: ExtensionDragMoveContext) { if ( context.elements.length === 0 || + !alignBound || alignBound.w === 0 || alignBound.h === 0 ) { @@ -58,11 +59,65 @@ export class SnapExtension extends InteractivityExtension { context.dx = alignRst.dx + context.dx; context.dy = alignRst.dy + context.dy; }, - onDragEnd() { + clear() { + alignBound = null; snapOverlay.clear(); }, }; } ); + + this.action.onElementResize(() => { + const snapOverlay = this.snapOverlay; + + if (!snapOverlay) { + return {}; + } + + let alignBound: Bound | null = null; + + return { + onResizeStart(context) { + alignBound = snapOverlay.setMovingElements(context.elements); + }, + onResizeMove(context) { + if (!alignBound || alignBound.w === 0 || alignBound.h === 0) { + return; + } + + const { handle, handleSign, lockRatio } = context; + let { dx, dy } = context; + + if (lockRatio) { + const min = Math.min( + Math.abs(dx / alignBound.w), + Math.abs(dy / alignBound.h) + ); + + dx = min * Math.sign(dx) * alignBound.w; + dy = min * Math.sign(dy) * alignBound.h; + } + + const currentBound = new Bound( + alignBound.x + + (handle.includes('left') ? -dx * handleSign.xSign : 0), + alignBound.y + + (handle.includes('top') ? -dy * handleSign.ySign : 0), + Math.abs(alignBound.w + dx * handleSign.xSign), + Math.abs(alignBound.h + dy * handleSign.ySign) + ); + const alignRst = snapOverlay.align(currentBound); + + context.suggest({ + dx: alignRst.dx + context.dx, + dy: alignRst.dy + context.dy, + }); + }, + onResizeEnd() { + alignBound = null; + snapOverlay.clear(); + }, + }; + }); } } diff --git a/blocksuite/framework/std/src/gfx/interactivity/extension/base.ts b/blocksuite/framework/std/src/gfx/interactivity/extension/base.ts index 1b624b69c2..8892dd97cf 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/extension/base.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/extension/base.ts @@ -13,6 +13,12 @@ import type { ExtensionDragMoveContext, ExtensionDragStartContext, } from '../types/drag.js'; +import type { + ExtensionElementResizeContext, + ExtensionElementResizeEndContext, + ExtensionElementResizeMoveContext, + ExtensionElementResizeStartContext, +} from '../types/resize.js'; import type { ExtensionElementSelectContext } from '../types/select.js'; export const InteractivityExtensionIdentifier = @@ -100,7 +106,7 @@ export class InteractivityEventAPI { } } -type ActionContextMap = { +export type ActionContextMap = { dragInitialize: { context: DragExtensionInitializeContext; returnType: { @@ -119,6 +125,14 @@ type ActionContextMap = { | undefined >; }; + elementResize: { + context: ExtensionElementResizeContext; + returnType: { + onResizeStart?: (context: ExtensionElementResizeStartContext) => void; + onResizeMove?: (context: ExtensionElementResizeMoveContext) => void; + onResizeEnd?: (context: ExtensionElementResizeEndContext) => void; + }; + }; elementSelect: { context: ExtensionElementSelectContext; returnType: void; @@ -144,6 +158,18 @@ export class InteractivityActionAPI { }; } + onElementResize( + handler: ( + ctx: ActionContextMap['elementResize']['context'] + ) => ActionContextMap['elementResize']['returnType'] + ) { + this._handlers['elementResize'] = handler; + + return () => { + return delete this._handlers['elementResize']; + }; + } + onRequestElementsClone( handler: ( ctx: ActionContextMap['elementsClone']['context'] diff --git a/blocksuite/framework/std/src/gfx/interactivity/manager.ts b/blocksuite/framework/std/src/gfx/interactivity/manager.ts index 703739efb7..7837dd550b 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/manager.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/manager.ts @@ -14,6 +14,7 @@ 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, @@ -882,7 +883,11 @@ export class InteractivityManager extends GfxExtension { handleElementResize( options: Omit< OptionResize, - 'lockRatio' | 'onResizeStart' | 'onResizeEnd' | 'onResizeUpdate' + | 'lockRatio' + | 'onResizeStart' + | 'onResizeEnd' + | 'onResizeUpdate' + | 'onResizeMove' > & { onResizeStart?: () => void; onResizeEnd?: () => void; @@ -910,6 +915,23 @@ export class InteractivityManager extends GfxExtension { 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 => { @@ -925,11 +947,46 @@ export class InteractivityManager extends GfxExtension { ...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)) { @@ -990,6 +1047,13 @@ export class InteractivityManager extends GfxExtension { }, onResizeEnd: ({ data }) => { this.activeInteraction$.value = null; + + extensionHandlers.forEach(ext => { + ext.onResizeEnd?.({ + elements, + handle, + }); + }); options.onResizeEnd?.(); this.std.store.transact(() => { data.forEach(({ model }) => { diff --git a/blocksuite/framework/std/src/gfx/interactivity/resize/manager.ts b/blocksuite/framework/std/src/gfx/interactivity/resize/manager.ts index 68a70593fe..44a43f39e4 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/resize/manager.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/resize/manager.ts @@ -1,6 +1,7 @@ import { Bound, getCommonBoundWithRotation, + type IBound, type IVec, } from '@blocksuite/global/gfx'; @@ -28,19 +29,24 @@ export const DEFAULT_HANDLES: ResizeHandle[] = [ 'bottom', ]; -interface ElementInitialSnapshot { - x: number; - y: number; - w: number; - h: number; - rotate: number; -} +type ElementInitialSnapshot = Readonly>; export interface OptionResize { elements: GfxModel[]; handle: ResizeHandle; lockRatio: boolean; event: PointerEvent; + onResizeMove: (payload: { + dx: number; + dy: number; + + handleSign: { + xSign: number; + ySign: number; + }; + + lockRatio: boolean; + }) => { dx: number; dy: number }; onResizeUpdate: (payload: { lockRatio: boolean; scaleX: number; @@ -95,6 +101,7 @@ export class ResizeController { handle, lockRatio, onResizeStart, + onResizeMove, onResizeUpdate, onResizeEnd, event, @@ -107,35 +114,49 @@ export class ResizeController { h: el.h, rotate: el.rotate, })); + const originalBound = getCommonBoundWithRotation(originals); const startPt = this.gfx.viewport.toModelCoordFromClientCoord([ event.clientX, event.clientY, ]); + const handleSign = this.getHandleSign(handle); const onPointerMove = (e: PointerEvent) => { const currPt = this.gfx.viewport.toModelCoordFromClientCoord([ e.clientX, e.clientY, ]); + let delta = { + dx: currPt[0] - startPt[0], + dy: currPt[1] - startPt[1], + }; const shouldLockRatio = lockRatio || e.shiftKey; + delta = onResizeMove({ + dx: delta.dx, + dy: delta.dy, + handleSign, + lockRatio: shouldLockRatio, + }); + if (elements.length === 1) { this.resizeSingle( originals[0], elements[0], shouldLockRatio, startPt, - currPt, - handle, + delta, + handleSign, onResizeUpdate ); } else { this.resizeMulti( + originalBound, originals, elements, - handle, - currPt, startPt, + delta, + handleSign, onResizeUpdate ); } @@ -159,11 +180,14 @@ export class ResizeController { model: GfxModel, lockRatio: boolean, startPt: IVec, - currPt: IVec, - handle: ResizeHandle, + delta: { + dx: number; + dy: number; + }, + handleSign: { xSign: number; ySign: number }, updateCallback: OptionResize['onResizeUpdate'] ) { - const { xSign, ySign } = this.getHandleSign(handle); + const { xSign, ySign } = handleSign; const pivot = new DOMPoint( orig.x + (-xSign === 1 ? orig.w : 0), @@ -181,8 +205,11 @@ export class ResizeController { 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); + const currPtLocal = toLocal( + new DOMPoint(startPt[0] + delta.dx, startPt[1] + delta.dy), + true + ); let scaleX = xSign ? (xSign * (currPtLocal.x - handleLocal.x) + orig.w) / orig.w @@ -255,34 +282,38 @@ export class ResizeController { } private resizeMulti( + originalBound: Bound, originals: ElementInitialSnapshot[], elements: GfxModel[], - handle: ResizeHandle, - currPt: IVec, startPt: IVec, + delta: { + dx: number; + dy: number; + }, + handleSign: { xSign: number; ySign: number }, updateCallback: OptionResize['onResizeUpdate'] ) { - const commonBound = getCommonBoundWithRotation(originals); - const { xSign, ySign } = this.getHandleSign(handle); - + const { xSign, ySign } = handleSign; const pivot = new DOMPoint( - commonBound.x + ((-xSign + 1) / 2) * commonBound.w, - commonBound.y + ((-ySign + 1) / 2) * commonBound.h + originalBound.x + ((-xSign + 1) / 2) * originalBound.w, + originalBound.y + ((-ySign + 1) / 2) * originalBound.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])); + const currPtLocal = toLocal( + new DOMPoint(startPt[0] + delta.dx, startPt[1] + delta.dy) + ); let scaleX = xSign - ? (xSign * (currPtLocal.x - handleLocal.x) + commonBound.w) / - commonBound.w + ? (xSign * (currPtLocal.x - handleLocal.x) + originalBound.w) / + originalBound.w : 1; let scaleY = ySign - ? (ySign * (currPtLocal.y - handleLocal.y) + commonBound.h) / - commonBound.h + ? (ySign * (currPtLocal.y - handleLocal.y) + originalBound.h) / + originalBound.h : 1; const min = Math.max(Math.abs(scaleX), Math.abs(scaleY)); diff --git a/blocksuite/framework/std/src/gfx/interactivity/types/resize.ts b/blocksuite/framework/std/src/gfx/interactivity/types/resize.ts new file mode 100644 index 0000000000..465ba6ec40 --- /dev/null +++ b/blocksuite/framework/std/src/gfx/interactivity/types/resize.ts @@ -0,0 +1,30 @@ +import type { GfxModel } from '../../model/model'; +import type { ResizeHandle } from '../resize/manager'; + +export type ExtensionElementResizeContext = { + elements: GfxModel[]; +}; + +export type ExtensionElementResizeStartContext = { + elements: GfxModel[]; + + handle: ResizeHandle; +}; + +export type ExtensionElementResizeEndContext = + ExtensionElementResizeStartContext; + +export type ExtensionElementResizeMoveContext = + ExtensionElementResizeStartContext & { + dx: number; + dy: number; + + lockRatio: boolean; + + handleSign: { + xSign: number; + ySign: number; + }; + + suggest: (distance: { dx: number; dy: number }) => void; + }; diff --git a/tests/blocksuite/e2e/edgeless/resizing.spec.ts b/tests/blocksuite/e2e/edgeless/resizing.spec.ts index 46fd70e5d3..1f06682120 100644 --- a/tests/blocksuite/e2e/edgeless/resizing.spec.ts +++ b/tests/blocksuite/e2e/edgeless/resizing.spec.ts @@ -1,13 +1,16 @@ import { + selectElementInEdgeless, switchEditorMode, zoomResetByKeyboard, } from '../utils/actions/edgeless.js'; import { addBasicBrushElement, addBasicRectShapeElement, + clickView, dragBetweenCoords, enterPlaygroundRoom, initEmptyEdgelessState, + pressBackspace, resizeElementByHandle, } from '../utils/actions/index.js'; import { @@ -19,10 +22,15 @@ import { test } from '../utils/playwright.js'; test.describe('resizing shapes and aspect ratio will be maintained', () => { test('positive adjustment', async ({ page }) => { await enterPlaygroundRoom(page); - await initEmptyEdgelessState(page); + const { noteId } = await initEmptyEdgelessState(page); await switchEditorMode(page); await zoomResetByKeyboard(page); + // delete note to avoid snapping to it + await clickView(page, [0, 0]); + await selectElementInEdgeless(page, [noteId]); + await pressBackspace(page); + await addBasicBrushElement(page, { x: 100, y: 100 }, { x: 200, y: 200 }); await page.mouse.click(110, 110); await assertEdgelessSelectedRect(page, [98, 98, 104, 104]); @@ -50,10 +58,15 @@ test.describe('resizing shapes and aspect ratio will be maintained', () => { test('negative adjustment', async ({ page }) => { await enterPlaygroundRoom(page); - await initEmptyEdgelessState(page); + const { noteId } = await initEmptyEdgelessState(page); await switchEditorMode(page); await zoomResetByKeyboard(page); + // delete note to avoid snapping to it + await clickView(page, [0, 0]); + await selectElementInEdgeless(page, [noteId]); + await pressBackspace(page); + await addBasicBrushElement(page, { x: 100, y: 100 }, { x: 200, y: 200 }); await page.mouse.click(110, 110); await assertEdgelessSelectedRect(page, [98, 98, 104, 104]);