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

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

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

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

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

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

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

### Others

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

354 lines
10 KiB
TypeScript

import { expect, type Locator, type Page } from '@playwright/test';
import { dragBetweenCoords } from '../utils/actions/drag.js';
import {
addBasicShapeElement,
addNote,
createNote,
createShapeElement,
dragBetweenViewCoords,
edgelessCommonSetup,
enterPresentationMode,
getZoomLevel,
setEdgelessTool,
Shape,
switchEditorMode,
toggleFramePanel,
} from '../utils/actions/edgeless.js';
import { waitNextFrame } from '../utils/actions/index.js';
import {
assertEdgelessNonSelectedRect,
assertEdgelessSelectedRect,
assertZoomLevel,
} from '../utils/asserts.js';
import { test } from '../utils/playwright.js';
async function dragFrameCard(
page: Page,
fromCard: Locator,
toCard: Locator,
direction: 'up' | 'down' = 'down'
) {
const fromRect = await fromCard.boundingBox();
const toRect = await toCard.boundingBox();
// drag to the center of the toCard
const center = { x: toRect!.width / 2, y: toRect!.height / 2 };
const offset = direction === 'up' ? { x: 0, y: -20 } : { x: 0, y: 20 };
await page.mouse.move(fromRect!.x + center.x, fromRect!.y + center.y);
await page.mouse.down();
await page.mouse.move(
toRect!.x + center.x + offset.x,
toRect!.y + center.y + offset.y,
{ steps: 10 }
);
await page.mouse.up();
}
test.describe('frame panel', () => {
test('should display empty placeholder when no frames', async ({ page }) => {
await edgelessCommonSetup(page);
await toggleFramePanel(page);
const frameCards = page.locator('affine-frame-card');
expect(await frameCards.count()).toBe(0);
const placeholder = page.locator('.no-frame-placeholder');
expect(await placeholder.isVisible()).toBeTruthy();
});
test('should display frame cards when there are frames', async ({ page }) => {
await edgelessCommonSetup(page);
await toggleFramePanel(page);
await addBasicShapeElement(
page,
{ x: 300, y: 300 },
{ x: 350, y: 350 },
Shape.Square
);
await addNote(page, 'hello', 150, 500);
await page.mouse.click(0, 0);
await setEdgelessTool(page, 'frame');
await dragBetweenCoords(page, { x: 250, y: 250 }, { x: 360, y: 360 });
await setEdgelessTool(page, 'frame');
await dragBetweenCoords(page, { x: 100, y: 440 }, { x: 600, y: 600 });
const frames = page.locator('affine-frame');
expect(await frames.count()).toBe(2);
const frameCards = page.locator('affine-frame-card');
expect(await frameCards.count()).toBe(2);
});
test('should render edgeless note correctly in frame preview', async ({
page,
}) => {
await edgelessCommonSetup(page);
await toggleFramePanel(page);
await addNote(page, 'hello', 150, 500);
await page.mouse.click(0, 0);
await setEdgelessTool(page, 'frame');
await dragBetweenCoords(page, { x: 100, y: 440 }, { x: 600, y: 600 });
await waitNextFrame(page, 100);
const frames = page.locator('affine-frame');
expect(await frames.count()).toBe(1);
const frameCards = page.locator('affine-frame-card');
expect(await frameCards.count()).toBe(1);
const edgelessNote = page.locator('affine-frame-card affine-edgeless-note');
expect(await edgelessNote.count()).toBe(1);
});
test('should update panel when frames change', async ({ page }) => {
await edgelessCommonSetup(page);
await toggleFramePanel(page);
const frameCards = page.locator('affine-frame-card');
expect(await frameCards.count()).toBe(0);
await addNote(page, 'hello', 150, 500);
await page.mouse.click(0, 0);
await setEdgelessTool(page, 'frame');
await dragBetweenCoords(page, { x: 100, y: 440 }, { x: 600, y: 600 });
await setEdgelessTool(page, 'frame');
await dragBetweenCoords(page, { x: 50, y: 300 }, { x: 120, y: 400 });
await waitNextFrame(page);
const frames = page.locator('affine-frame');
expect(await frames.count()).toBe(2);
expect(await frameCards.count()).toBe(2);
await page.mouse.click(50, 300);
await page.keyboard.press('Delete');
await waitNextFrame(page);
expect(await frames.count()).toBe(1);
expect(await frameCards.count()).toBe(1);
});
test.describe('frame panel behavior after mode switch', () => {
async function setupFrameTest(page: Page) {
await edgelessCommonSetup(page);
await toggleFramePanel(page);
await addNote(page, 'hello', 150, 500);
await page.mouse.click(0, 0);
await waitNextFrame(page, 100);
await setEdgelessTool(page, 'frame');
await dragBetweenCoords(
page,
{ x: 100, y: 440 },
{ x: 640, y: 600 },
{ steps: 10 }
);
await waitNextFrame(page, 100);
const edgelessNote = page.locator(
'affine-frame-card affine-edgeless-note'
);
expect(await edgelessNote.count()).toBe(1);
return edgelessNote;
}
test('should render edgeless note correctly after mode switch', async ({
page,
}) => {
const edgelessNote = await setupFrameTest(page);
const initialNoteRect = await edgelessNote.boundingBox();
expect(initialNoteRect).not.toBeNull();
const {
width: noteWidth,
height: noteHeight,
x: noteX,
y: noteY,
} = initialNoteRect!;
const checkNoteRect = async () => {
expect(await edgelessNote.count()).toBe(1);
const newNoteRect = await edgelessNote.boundingBox();
expect(newNoteRect).not.toBeNull();
expect(newNoteRect!.width).toBe(noteWidth);
expect(newNoteRect!.height).toBe(noteHeight);
expect(newNoteRect!.x).toBe(noteX);
expect(newNoteRect!.y).toBe(noteY);
};
await switchEditorMode(page);
await checkNoteRect();
await switchEditorMode(page);
await checkNoteRect();
});
test('should update frame preview when note is moved', async ({ page }) => {
const edgelessNote = await setupFrameTest(page);
const initialNoteRect = await edgelessNote.boundingBox();
expect(initialNoteRect).not.toBeNull();
await switchEditorMode(page);
await switchEditorMode(page);
async function moveNoteAndCheck(
start: { x: number; y: number },
end: { x: number; y: number },
comparison: 'greaterThan' | 'lessThan'
) {
await dragBetweenCoords(page, start, end);
await waitNextFrame(page);
const newNoteRect = await edgelessNote.boundingBox();
expect(newNoteRect).not.toBeNull();
if (comparison === 'greaterThan') {
expect(newNoteRect!.x).toBeGreaterThan(initialNoteRect!.x);
expect(newNoteRect!.y).toBeGreaterThan(initialNoteRect!.y);
} else {
expect(newNoteRect!.x).toBeLessThan(initialNoteRect!.x);
expect(newNoteRect!.y).toBeLessThan(initialNoteRect!.y);
}
}
// Move the note to the right
await moveNoteAndCheck(
{ x: 150, y: 500 },
{ x: 200, y: 550 },
'greaterThan'
);
// Move the note back to the left
await moveNoteAndCheck(
{ x: 200, y: 550 },
{ x: 100, y: 450 },
'lessThan'
);
// Move the note diagonally
await moveNoteAndCheck(
{ x: 100, y: 450 },
{ x: 250, y: 600 },
'greaterThan'
);
});
});
test.describe('select and de-select frame', () => {
async function setupFrameTest(page: Page) {
await edgelessCommonSetup(page);
await toggleFramePanel(page);
await addNote(page, 'hello', 150, 500);
await page.mouse.click(0, 0);
await setEdgelessTool(page, 'frame');
await dragBetweenCoords(page, { x: 100, y: 440 }, { x: 640, y: 600 });
await waitNextFrame(page);
const frames = page.locator('affine-frame');
const frameCards = page.locator('affine-frame-card');
expect(await frames.count()).toBe(1);
expect(await frameCards.count()).toBe(1);
return { frames, frameCards };
}
test('by click on frame card', async ({ page }) => {
const { frameCards } = await setupFrameTest(page);
// click on the first frame card
await frameCards.nth(0).click();
await assertEdgelessSelectedRect(page, [100, 440, 540, 160]);
await frameCards.nth(0).click();
await assertEdgelessNonSelectedRect(page);
});
test('by click on blank area', async ({ page }) => {
const { frameCards } = await setupFrameTest(page);
// click on the first frame card
await frameCards.nth(0).click();
await assertEdgelessSelectedRect(page, [100, 440, 540, 160]);
const framePanel = page.locator('.frame-panel-container');
const panelRect = await framePanel.boundingBox();
expect(panelRect).not.toBeNull();
const { x, y, width, height } = panelRect!;
await page.mouse.click(x + width / 2, y + height / 2);
await assertEdgelessNonSelectedRect(page);
});
});
test('should fit the viewport to the frame when double click frame card', async ({
page,
}) => {
await edgelessCommonSetup(page);
await toggleFramePanel(page);
await assertZoomLevel(page, 100);
await addNote(page, 'hello', 150, 500);
await page.mouse.click(0, 0);
await setEdgelessTool(page, 'frame');
await dragBetweenCoords(page, { x: 100, y: 440 }, { x: 600, y: 600 });
await waitNextFrame(page);
const frameCards = page.locator('affine-frame-card');
await frameCards.nth(0).dblclick();
const zoomLevel = await getZoomLevel(page);
expect(zoomLevel).toBeGreaterThan(100);
});
test('should reorder frames when drag and drop frame card', async ({
page,
}) => {
await edgelessCommonSetup(page);
await createShapeElement(page, [100, 100], [200, 200], Shape.Square);
await createNote(page, [300, 100], 'hello');
// Frame shape
await setEdgelessTool(page, 'frame');
await dragBetweenViewCoords(page, [80, 80], [220, 220]);
await waitNextFrame(page, 100);
// Frame note
await setEdgelessTool(page, 'frame');
await dragBetweenViewCoords(page, [240, 0], [800, 200]);
expect(await page.locator('affine-frame').count()).toBe(2);
await toggleFramePanel(page);
const frameCards = page.locator('affine-frame-card');
expect(await frameCards.count()).toBe(2);
// Drag the first frame card to the second
await dragFrameCard(page, frameCards.nth(0), frameCards.nth(1));
await enterPresentationMode(page);
await waitNextFrame(page, 100);
// Check if frame contains note now is the first
const edgelessNote = page.locator(
'affine-edgeless-root affine-edgeless-note'
);
await expect(edgelessNote).toBeVisible();
});
});