From 8c161b0681a7f102f2499cf656e470d00355facb Mon Sep 17 00:00:00 2001 From: VelvetAbyss Date: Sat, 31 Oct 2026 12:48:27 +0800 Subject: [PATCH] fix(dashboard): prevent hanging note close animation jump --- apps/web/e2e/local-parity.smoke.spec.ts | 81 ++++++++++++++++++- .../dashboard/hanging-notes/controller.ts | 9 ++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/local-parity.smoke.spec.ts b/apps/web/e2e/local-parity.smoke.spec.ts index 12456ba..9d43da4 100644 --- a/apps/web/e2e/local-parity.smoke.spec.ts +++ b/apps/web/e2e/local-parity.smoke.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' test('a new visitor opens the same dashboard as local port 5174', async ({ page }) => { await page.route('**/api/**', (route) => route.fulfill({ @@ -27,3 +27,82 @@ test('a new visitor opens the same dashboard as local port 5174', async ({ page expect((await shell.boundingBox())?.width).toBeGreaterThan(1100) expect((await page.locator('main').first().boundingBox())?.height).toBeGreaterThan(300) }) + +async function expectNoteToCloseInPlace(page: Page) { + const detail = page.locator('#fg-notes-note-detail') + await expect(detail).toBeVisible() + await detail.evaluate(el => Promise.all(el.getAnimations().map(animation => animation.finished))) + const motion = await page.evaluate(() => { + const source = document.querySelector('.dashboard-notes .paper-stack[aria-expanded="true"]')! + const detail = document.querySelector('#fg-notes-note-detail')! + const point = (el: HTMLElement) => { + const rect = el.getBoundingClientRect() + return { x: rect.x + rect.width / 2, y: rect.y } + } + const from = point(detail), target = point(source), previous = new Set(source.getAnimations()) + document.querySelector('#fg-notes-close-note')!.click() + const animation = source.getAnimations().find(candidate => !previous.has(candidate)) + if (!animation) throw new Error('The note did not animate closed') + animation.pause() + const duration = Number(animation.effect!.getTiming().duration) + const samples = [0, .05, .25, .5, .75, .99, 1].map(progress => { + animation.currentTime = duration * progress + return point(source) + }) + animation.finish() + return { from, target, samples } + }) + // Inspect the rendered animation, not just its final resting position. + expect(Math.abs(motion.samples[0].x - motion.from.x)).toBeLessThan(3) + expect(Math.abs(motion.samples[0].y - motion.from.y)).toBeLessThan(3) + for (const sample of motion.samples) { + for (const axis of ['x', 'y'] as const) { + expect(sample[axis]).toBeGreaterThanOrEqual(Math.min(motion.from[axis], motion.target[axis]) - 3) + expect(sample[axis]).toBeLessThanOrEqual(Math.max(motion.from[axis], motion.target[axis]) + 3) + } + } + expect(Math.abs(motion.samples.at(-1)!.x - motion.target.x)).toBeLessThan(3) + expect(Math.abs(motion.samples.at(-1)!.y - motion.target.y)).toBeLessThan(3) + await expect(detail).toBeHidden() + return motion +} + +test.describe('hanging note close animation', () => { + test.use({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'no-preference' }) + + for (const zoom of ['default', '1.1']) { + test(`returns hanging and pinned notes without jumping at ${zoom} zoom`, async ({ page }) => { + await page.route('**/api/**', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: route.request().url().includes('get-session') ? 'null' : '{}', + })) + await page.goto('/') + const stack = page.locator('#fg-notes-paper-stack') + await expect(stack).toBeVisible() + if (zoom !== 'default') await page.locator('.focus-shell').evaluate((el, zoom) => { (el as HTMLElement).style.zoom = zoom }, zoom) + // Stop wind only; keep the closing animation enabled for exact geometry checks. + await page.locator('#fg-notes-pause').evaluate(el => (el as HTMLButtonElement).click()) + await stack.click({ force: true }) + await page.locator('#fg-notes-note-text').fill('关闭动画回归') + await expectNoteToCloseInPlace(page) + + await stack.click({ force: true }) + await page.locator('#fg-notes-placement-action').click() + const pinned = page.locator('.loose-note') + await expect(pinned).toBeVisible() + await pinned.evaluate(el => Promise.all(el.getAnimations().map(animation => animation.finished))) + const rect = (await pinned.boundingBox())! + await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2) + await page.mouse.down() + await page.mouse.move(1410, 540, { steps: 18 }) + await page.mouse.up() + // Let the drag's accidental-click suppression expire before opening. + await page.waitForTimeout(300) + // The side edge forces the full editor away from its thumbnail position. + await pinned.click({ force: true }) + const motion = await expectNoteToCloseInPlace(page) + expect(Math.abs(motion.from.x - motion.target.x)).toBeGreaterThan(20) + }) + } +}) diff --git a/apps/web/src/features/dashboard/hanging-notes/controller.ts b/apps/web/src/features/dashboard/hanging-notes/controller.ts index 3368723..73f7af6 100644 --- a/apps/web/src/features/dashboard/hanging-notes/controller.ts +++ b/apps/web/src/features/dashboard/hanging-notes/controller.ts @@ -488,8 +488,15 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on if (source?.isConnected) { const to = animationRect(source); if (!reduced) { + const scale = Math.min(2, from.width / to.width); + const [originX, originY] = getComputedStyle(source).transformOrigin.split(' ').map(Number.parseFloat); + const centerX = to.x + to.width / 2; + // Individual CSS scale also scales the positioning transform. Cancel that + // displacement so the first frame stays at the editor, including under zoom. + const dx = from.x + from.width / 2 - centerX - (scale - 1) * (centerX - source.offsetLeft - originX); + const dy = from.y - to.y - (scale - 1) * (to.y - source.offsetTop - originY); transitionSource = source; - transition = source.animate([{ translate: `${from.x + from.width / 2 - to.x - to.width / 2}px ${from.y - to.y}px`, scale: Math.min(2, from.width / to.width), opacity: .3 }, { translate: '0px 0px', scale: 1, opacity: 1 }], { duration: 280, easing: 'cubic-bezier(.16,1,.3,1)' }); + transition = source.animate([{ translate: `${dx}px ${dy}px`, scale, opacity: .3 }, { translate: '0px 0px', scale: 1, opacity: 1 }], { duration: 280, easing: 'cubic-bezier(.16,1,.3,1)' }); } if (focus) source.focus({ preventScroll: true });