From e50722948d426f2cadb6fa113c43006d73018844 Mon Sep 17 00:00:00 2001 From: EwdErna Date: Thu, 22 Oct 2026 09:39:19 +0900 Subject: [PATCH] =?UTF-8?q?mock:=20=E3=82=AD=E3=83=BC=E3=82=92=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E3=81=AE=E7=AB=AF=E3=81=B8=E5=AF=84=E3=81=9B=E3=82=8B?= =?UTF-8?q?=E3=81=A8=E3=80=81=E3=82=AD=E3=83=A3=E3=83=B3=E3=83=90=E3=82=B9?= =?UTF-8?q?=E3=82=92=E8=87=AA=E5=8B=95=E3=81=A7=E3=82=B9=E3=82=AF=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=81=99=E3=82=8B(T-30=20=E5=8D=98=E4=BD=8D4?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ノードのドラッグ中にポインタがSVGの端から32px以内か外にあると、 requestAnimationFrame で pan を動かし続け、DOM更新後の最後のポインタ位置で ドラッグ位置を計算し直す。速度は端に近いほど速く、上限480px/秒(autoPanVelocity)。 範囲選択とパンのドラッグは対象外。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL --- mock/e2e/phy.spec.ts | 20 ++++++ mock/src/ui/phy/usePhyCanvasPointer.ts | 99 ++++++++++++++++++++++---- mock/src/ui/phy/viewport.ts | 20 ++++++ mock/tests/phyLogic.test.ts | 10 ++- 4 files changed, 133 insertions(+), 16 deletions(-) diff --git a/mock/e2e/phy.spec.ts b/mock/e2e/phy.spec.ts index be6aed3..a543506 100644 --- a/mock/e2e/phy.spec.ts +++ b/mock/e2e/phy.spec.ts @@ -95,6 +95,26 @@ test("ドラッグの刻みをUndo/Redoでき、コンソールにエラーが expect(errors).toEqual([]); }); +test("ドラッグ中に右端で自動スクロールし、Undo 1回で戻る", async ({ page }) => { + const key = page.locator('[data-node-type="key"]').first(); + const before = await key.getAttribute("transform"); + const keyBox = (await key.boundingBox())!; + const canvasBox = (await page.locator("svg.canvas").boundingBox())!; + const y = keyBox.y + keyBox.height / 2; + await page.mouse.move(keyBox.x + keyBox.width / 2, y); + await page.mouse.down(); + await page.mouse.move(canvasBox.x + canvasBox.width - 8, y, { steps: 8 }); + const atEdge = await key.getAttribute("transform"); + await page.waitForTimeout(700); + expect(await key.getAttribute("transform")).not.toBe(atEdge); + await page.mouse.up(); + const released = await key.getAttribute("transform"); + await page.waitForTimeout(250); + expect(await key.getAttribute("transform")).toBe(released); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(key).toHaveAttribute("transform", before!); +}); + test("クラスター化で画面位置を保つ", async ({ page }) => { const keys = page.locator('[data-node-type="key"]'); const centers = async () => diff --git a/mock/src/ui/phy/usePhyCanvasPointer.ts b/mock/src/ui/phy/usePhyCanvasPointer.ts index ea12163..2becf5b 100644 --- a/mock/src/ui/phy/usePhyCanvasPointer.ts +++ b/mock/src/ui/phy/usePhyCanvasPointer.ts @@ -1,9 +1,10 @@ -import { ref, type Ref } from "vue"; +import { nextTick, onUnmounted, ref, type Ref } from "vue"; import type { ProjectFile } from "../../domain/project"; import type { KeyNode, Node } from "../../domain/phy"; import { apply, worldTransform } from "../../domain/geometry"; import { moveNode } from "../../domain/phyCommands"; import { normalizeSelection, snap } from "./logic"; +import { autoPanVelocity } from "./viewport"; type Point = { x: number; y: number }; type Box = Point & { width: number; height: number }; @@ -44,6 +45,9 @@ export function usePhyCanvasPointer(options: Options) { let holdCycle: number | undefined; let holdShift = false; let holdActivated = false; + let lastPointer: Point | null = null; + let autoPanFrame: number | undefined; + let lastAutoPanAt = 0; let panDrag: { start: Point; origin: Point } | null = null; let rangeDrag: { start: Point; shift: boolean; moved: boolean } | null = null; let dragState: { @@ -57,6 +61,78 @@ export function usePhyCanvasPointer(options: Options) { moved: boolean; } | null = null; + function stopAutoPan() { + if (autoPanFrame !== undefined) window.cancelAnimationFrame(autoPanFrame); + autoPanFrame = undefined; + lastAutoPanAt = 0; + } + + function updateDraggedPosition(clientX: number, clientY: number) { + const state = dragState; + if (!state?.moved) return; + let phy = state.base.phy; + for (const id of state.ids) { + const parent = state.parents.get(id); + const start = state.starts.get(id); + if (!parent || !start) continue; + const current = options.localPoint(parent, clientX, clientY); + phy = moveNode( + phy, + id, + snap(current.x - start.x, options.grid.value), + snap(current.y - start.y, options.grid.value), + ); + } + options.dispatch("ドラッグ", (project) => ({ ...project, phy })); + } + + function autoPanTick(time: number) { + autoPanFrame = undefined; + const svg = options.svg.value; + const pointer = lastPointer; + if (!svg || !pointer || !dragState?.moved || longLabel.value) return; + const rect = svg.getBoundingClientRect(); + const velocity = autoPanVelocity( + { x: pointer.x, y: pointer.y }, + { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, + ); + if (!velocity.x && !velocity.y) { + lastAutoPanAt = 0; + return; + } + const elapsed = lastAutoPanAt ? Math.min((time - lastAutoPanAt) / 1000, 0.05) : 0; + lastAutoPanAt = time; + const transform = svg.getScreenCTM(); + if (elapsed && transform) { + options.pan.value = { + x: options.pan.value.x - (velocity.x * elapsed) / transform.a, + y: options.pan.value.y - (velocity.y * elapsed) / transform.d, + }; + void nextTick(() => { + if (dragState?.moved && lastPointer) updateDraggedPosition(lastPointer.x, lastPointer.y); + }); + } + autoPanFrame = window.requestAnimationFrame(autoPanTick); + } + + function scheduleAutoPan() { + const svg = options.svg.value; + if (!svg || !lastPointer || !dragState?.moved || longLabel.value) { + stopAutoPan(); + return; + } + const rect = svg.getBoundingClientRect(); + const velocity = autoPanVelocity( + { x: lastPointer.x, y: lastPointer.y }, + { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, + ); + if (!velocity.x && !velocity.y) { + stopAutoPan(); + } else if (autoPanFrame === undefined) { + autoPanFrame = window.requestAnimationFrame(autoPanTick); + } + } + function priorSelection() { return preClickSelection; } @@ -184,6 +260,7 @@ export function usePhyCanvasPointer(options: Options) { return; } if (!dragState) return; + lastPointer = { x: event.clientX, y: event.clientY }; const state = dragState; if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return; if (holdTimer && !longLabel.value) { @@ -223,23 +300,12 @@ export function usePhyCanvasPointer(options: Options) { options.beginHistory(); } state.moved = true; - let phy = state.base.phy; - for (const id of state.ids) { - const parent = state.parents.get(id); - const start = state.starts.get(id); - if (!parent || !start) continue; - const current = options.localPoint(parent, event.clientX, event.clientY); - phy = moveNode( - phy, - id, - snap(current.x - start.x, options.grid.value), - snap(current.y - start.y, options.grid.value), - ); - } - options.dispatch("ドラッグ", (project) => ({ ...project, phy })); + updateDraggedPosition(event.clientX, event.clientY); + scheduleAutoPan(); } function up() { + stopAutoPan(); window.removeEventListener("pointermove", move, true); window.removeEventListener("pointerup", up, true); if (panDrag) { @@ -323,12 +389,15 @@ export function usePhyCanvasPointer(options: Options) { } if (holdActivated || moved) preClickTarget = null; dragState = null; + lastPointer = null; } function spaceUp(event: KeyboardEvent) { if (event.code === "Space") spaceHeld.value = false; } + onUnmounted(stopAutoPan); + return { canvasDown, down, diff --git a/mock/src/ui/phy/viewport.ts b/mock/src/ui/phy/viewport.ts index 51bb6e3..eda58ae 100644 --- a/mock/src/ui/phy/viewport.ts +++ b/mock/src/ui/phy/viewport.ts @@ -1,6 +1,26 @@ export type Viewport = { zoom: number; pan: { x: number; y: number } }; export type ViewBounds = { x: number; y: number; width: number; height: number }; +/** SVG画面端に近いほど強く、端の外では上限に達する自動パン速度(px/秒)。 */ +export function autoPanVelocity( + point: { x: number; y: number }, + bounds: ViewBounds, + edge = 32, + maxSpeed = 480, +): { x: number; y: number } { + const axis = (position: number, start: number, length: number) => { + const fromStart = position - start; + const fromEnd = start + length - position; + if (fromStart < edge) return -maxSpeed * Math.min(1, (edge - fromStart) / edge); + if (fromEnd < edge) return maxSpeed * Math.min(1, (edge - fromEnd) / edge); + return 0; + }; + return { + x: axis(point.x, bounds.x, bounds.width), + y: axis(point.y, bounds.y, bounds.height), + }; +} + /** 境界を余白付きで中央へ収める。座標・倍率だけを扱う純粋関数。 */ export function fitViewport( bounds: ViewBounds, diff --git a/mock/tests/phyLogic.test.ts b/mock/tests/phyLogic.test.ts index 7ea4c93..a59f1eb 100644 --- a/mock/tests/phyLogic.test.ts +++ b/mock/tests/phyLogic.test.ts @@ -17,7 +17,7 @@ import { treeIndex, visibleNodeIds, } from "../src/ui/phy/logic"; -import { fitViewport, zoomAround } from "../src/ui/phy/viewport"; +import { autoPanVelocity, fitViewport, zoomAround } from "../src/ui/phy/viewport"; import type { PhyFile, Node as PhyNode } from "../src/domain/phy"; const phy: PhyFile = { version: "0.1.0", @@ -189,4 +189,12 @@ describe("PHY pure interaction rules", () => { expect((cursor.x - zoomed.pan.x) / zoomed.zoom).toBeCloseTo(world.x); expect((cursor.y - zoomed.pan.y) / zoomed.zoom).toBeCloseTo(world.y); }); + it("scales auto-pan speed near edges, outside the canvas, and at corners", () => { + const bounds = { x: 10, y: 20, width: 200, height: 100 }; + expect(autoPanVelocity({ x: 110, y: 70 }, bounds)).toEqual({ x: 0, y: 0 }); + expect(autoPanVelocity({ x: 194, y: 70 }, bounds)).toEqual({ x: 240, y: 0 }); + expect(autoPanVelocity({ x: 210, y: 70 }, bounds)).toEqual({ x: 480, y: 0 }); + expect(autoPanVelocity({ x: 220, y: 70 }, bounds)).toEqual({ x: 480, y: 0 }); + expect(autoPanVelocity({ x: 210, y: 10 }, bounds)).toEqual({ x: 480, y: -480 }); + }); });