From 9d2aa879e9fe6ad9f15f41598f1c05562b700141 Mon Sep 17 00:00:00 2001 From: suyx Date: Wed, 5 Aug 2026 17:59:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(POSTV1-07):=20=E5=9B=BA=E5=AE=9A=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E5=B8=83=E5=B1=80=E4=B8=8E=E6=96=87=E5=AD=97=E8=B4=B4?= =?UTF-8?q?=E7=BA=B8=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/editor-page.css | 10 ++-- apps/web/src/editor-stage.tsx | 41 +++++++++++----- tests/e2e/wp4-03-text-editor.spec.ts | 70 ++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 15 deletions(-) diff --git a/apps/web/src/editor-page.css b/apps/web/src/editor-page.css index d6cf84d..7a1e886 100644 --- a/apps/web/src/editor-page.css +++ b/apps/web/src/editor-page.css @@ -1,9 +1,12 @@ .editor-page-shell { - min-height: 100vh; + height: 100vh; + height: 100dvh; + min-height: 0; display: grid; grid-template-rows: 56px minmax(0, 1fr) 32px; background: #e8e8e5; color: #111111; + overflow: hidden; } .editor-page-shell :focus-visible { @@ -124,6 +127,7 @@ display: grid; grid-template-columns: 280px minmax(0, 1fr) 320px; min-height: 0; + overflow: hidden; } .editor-assets-panel, @@ -542,13 +546,13 @@ } @media (max-width: 760px) { - .editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; } + .editor-page-shell { height: auto; min-height: 100dvh; grid-template-rows: auto minmax(0, 1fr) auto; overflow: visible; } .editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; } .editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); } .editor-history-actions { order: 3; } .editor-save-status { order: 4; flex: 1 1 128px; } .editor-toolbar-controls > button { display: block; order: 5; } - .editor-layout { grid-template-columns: 1fr; } + .editor-layout { grid-template-columns: 1fr; overflow: visible; } .editor-assets-panel, .editor-inspector { border: 0; } .editor-assets-panel { order: 2; } .editor-inspector { order: 3; } diff --git a/apps/web/src/editor-stage.tsx b/apps/web/src/editor-stage.tsx index 6f6d0c0..4e61fad 100644 --- a/apps/web/src/editor-stage.tsx +++ b/apps/web/src/editor-stage.tsx @@ -12,12 +12,17 @@ import { drawColorCard } from "./palette-provider.js"; interface Gesture { append: boolean; + bounds: DOMRect; hit: boolean; longPressOpened: boolean; + moved: boolean; pointerId: number; start: CanvasPoint; + startClient: CanvasPoint; } +const DRAG_THRESHOLD_PX = 4; + interface EditorStageProps { assetId: string | null; canvasState: CanvasState; @@ -38,11 +43,10 @@ interface EditorStageProps { selectedIds: readonly string[]; } -function pointFromEvent(event: PointerEvent): CanvasPoint { - const bounds = event.currentTarget.getBoundingClientRect(); +function pointFromClient(clientX: number, clientY: number, bounds: DOMRect): CanvasPoint { return { - x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)), - y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)), + x: Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width)), + y: Math.max(0, Math.min(1, (clientY - bounds.top) / bounds.height)), }; } @@ -356,10 +360,14 @@ export function EditorStage(props: EditorStageProps) { function handlePointerDown(event: PointerEvent) { if (event.button !== 0) return; - const start = pointFromEvent(event); + const bounds = event.currentTarget.getBoundingClientRect(); + const start = pointFromClient(event.clientX, event.clientY, bounds); const append = event.shiftKey; const hit = props.onSelect(start, append); - gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start }; + gestureRef.current = { + append, bounds, hit, longPressOpened: false, moved: false, pointerId: event.pointerId, start, + startClient: { x: event.clientX, y: event.clientY }, + }; event.currentTarget.setPointerCapture(event.pointerId); longPressRef.current = setTimeout(() => { const gesture = gestureRef.current; @@ -375,9 +383,11 @@ export function EditorStage(props: EditorStageProps) { props.onPointerMoved(); return; } - const point = pointFromEvent(event); + const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y); + if (!gesture.moved && clientDistance < DRAG_THRESHOLD_PX) return; + gesture.moved = true; + const point = pointFromClient(event.clientX, event.clientY, gesture.bounds); const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y }; - if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return; if (longPressRef.current) clearTimeout(longPressRef.current); props.onPointerMoved(); if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta); @@ -388,11 +398,18 @@ export function EditorStage(props: EditorStageProps) { const gesture = gestureRef.current; if (!gesture || gesture.pointerId !== event.pointerId) return; if (longPressRef.current) clearTimeout(longPressRef.current); - const point = pointFromEvent(event); - const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y }; - const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003; + const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y); + const moved = gesture.moved || clientDistance >= DRAG_THRESHOLD_PX; + if (moved && !gesture.moved && !gesture.longPressOpened) { + const point = pointFromClient(event.clientX, event.clientY, gesture.bounds); + const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y }; + if (gesture.hit) props.onMovePreview(delta); + } if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit(); - else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append); + else if (!gesture.hit && moved) { + const point = pointFromClient(event.clientX, event.clientY, gesture.bounds); + props.onMarquee({ height: point.y - gesture.start.y, width: point.x - gesture.start.x, x: gesture.start.x, y: gesture.start.y }, gesture.append); + } setMarquee(undefined); gestureRef.current = undefined; event.currentTarget.releasePointerCapture(event.pointerId); diff --git a/tests/e2e/wp4-03-text-editor.spec.ts b/tests/e2e/wp4-03-text-editor.spec.ts index 786c5fb..308cbf3 100644 --- a/tests/e2e/wp4-03-text-editor.spec.ts +++ b/tests/e2e/wp4-03-text-editor.spec.ts @@ -212,3 +212,73 @@ test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 }); if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") }); }); + +test("POSTV1-07 keeps the canvas anchored when the text template panel opens", async ({ page }) => { + const projectId = uuid(760); + const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 5 }; + await routeEditor(page, projectId, backend); + await page.goto(`${webUrl}/app/projects/${projectId}/editor`); + + const stage = page.getByLabel("编辑画布"); + const before = await stage.boundingBox(); + if (!before) throw new Error("Canvas geometry is unavailable before opening text templates."); + + await page.getByRole("button", { name: "文字模板", exact: true }).click(); + await expect(page.locator(".editor-template-grid button")).toHaveCount(32); + const after = await stage.boundingBox(); + if (!after) throw new Error("Canvas geometry is unavailable after opening text templates."); + const assetsPanelScroll = await page.getByLabel("素材与底图来源").evaluate((panel) => ({ + clientHeight: panel.clientHeight, + overflowY: getComputedStyle(panel).overflowY, + scrollHeight: panel.scrollHeight, + })); + + expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1); + expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1); + expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1); + expect(Math.abs(after.height - before.height)).toBeLessThanOrEqual(1); + expect(assetsPanelScroll.overflowY).toBe("auto"); + expect(assetsPanelScroll.scrollHeight).toBeGreaterThan(assetsPanelScroll.clientHeight); +}); + +test("POSTV1-07 keeps text selection stable and dismisses move feedback", async ({ page }) => { + const projectId = uuid(770); + const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 6 }; + await routeEditor(page, projectId, backend); + await page.goto(`${webUrl}/app/projects/${projectId}/editor`); + await page.getByRole("button", { name: "文字模板", exact: true }).click(); + await page.getByRole("button", { name: /H003 生活分享家/ }).click(); + await expect.poll(() => backend.canvas.elements.length).toBe(1); + await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0); + + await page.reload(); + const stage = page.getByLabel("编辑画布"); + const bounds = await stage.boundingBox(); + const element = backend.canvas.elements[0]; + if (!bounds || !element) throw new Error("Text selection geometry is unavailable."); + const positionBefore = structuredClone(element.position); + const savesBefore = backend.saves; + const clientX = bounds.x + bounds.width * element.position.x; + const clientY = bounds.y + bounds.height * element.position.y; + + await page.mouse.move(clientX, clientY); + await page.mouse.down(); + await expect(page.getByLabel("字体覆盖")).toBeVisible(); + const selectedBounds = await stage.boundingBox(); + const inspectorScroll = await page.getByLabel("对象参数").evaluate((panel) => ({ + clientHeight: panel.clientHeight, + overflowY: getComputedStyle(panel).overflowY, + scrollHeight: panel.scrollHeight, + })); + if (!selectedBounds) throw new Error("Canvas geometry is unavailable after selecting text."); + expect(Math.abs(selectedBounds.y - bounds.y)).toBeLessThanOrEqual(1); + expect(inspectorScroll.overflowY).toBe("auto"); + expect(inspectorScroll.scrollHeight).toBeGreaterThan(inspectorScroll.clientHeight); + await page.mouse.move(clientX + 1, clientY); + await page.mouse.up(); + + await expect(page.getByLabel("字体覆盖")).toBeVisible(); + await page.waitForTimeout(800); + expect(backend.canvas.elements[0]?.position).toEqual(positionBefore); + expect(backend.saves).toBe(savesBefore); +});