mock: キーを画面の端へ寄せると、キャンバスを自動でスクロールする(T-30 単位4)
ノードのドラッグ中にポインタがSVGの端から32px以内か外にあると、 requestAnimationFrame で pan を動かし続け、DOM更新後の最後のポインタ位置で ドラッグ位置を計算し直す。速度は端に近いほど速く、上限480px/秒(autoPanVelocity)。 範囲選択とパンのドラッグは対象外。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL
This commit is contained in:
@@ -95,6 +95,26 @@ test("ドラッグの刻みをUndo/Redoでき、コンソールにエラーが
|
|||||||
expect(errors).toEqual([]);
|
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 }) => {
|
test("クラスター化で画面位置を保つ", async ({ page }) => {
|
||||||
const keys = page.locator('[data-node-type="key"]');
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
const centers = async () =>
|
const centers = async () =>
|
||||||
|
|||||||
@@ -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 { ProjectFile } from "../../domain/project";
|
||||||
import type { KeyNode, Node } from "../../domain/phy";
|
import type { KeyNode, Node } from "../../domain/phy";
|
||||||
import { apply, worldTransform } from "../../domain/geometry";
|
import { apply, worldTransform } from "../../domain/geometry";
|
||||||
import { moveNode } from "../../domain/phyCommands";
|
import { moveNode } from "../../domain/phyCommands";
|
||||||
import { normalizeSelection, snap } from "./logic";
|
import { normalizeSelection, snap } from "./logic";
|
||||||
|
import { autoPanVelocity } from "./viewport";
|
||||||
|
|
||||||
type Point = { x: number; y: number };
|
type Point = { x: number; y: number };
|
||||||
type Box = Point & { width: number; height: number };
|
type Box = Point & { width: number; height: number };
|
||||||
@@ -44,6 +45,9 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
let holdCycle: number | undefined;
|
let holdCycle: number | undefined;
|
||||||
let holdShift = false;
|
let holdShift = false;
|
||||||
let holdActivated = 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 panDrag: { start: Point; origin: Point } | null = null;
|
||||||
let rangeDrag: { start: Point; shift: boolean; moved: boolean } | null = null;
|
let rangeDrag: { start: Point; shift: boolean; moved: boolean } | null = null;
|
||||||
let dragState: {
|
let dragState: {
|
||||||
@@ -57,6 +61,78 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
moved: boolean;
|
moved: boolean;
|
||||||
} | null = null;
|
} | 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() {
|
function priorSelection() {
|
||||||
return preClickSelection;
|
return preClickSelection;
|
||||||
}
|
}
|
||||||
@@ -184,6 +260,7 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!dragState) return;
|
if (!dragState) return;
|
||||||
|
lastPointer = { x: event.clientX, y: event.clientY };
|
||||||
const state = dragState;
|
const state = dragState;
|
||||||
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
|
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
|
||||||
if (holdTimer && !longLabel.value) {
|
if (holdTimer && !longLabel.value) {
|
||||||
@@ -223,23 +300,12 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
options.beginHistory();
|
options.beginHistory();
|
||||||
}
|
}
|
||||||
state.moved = true;
|
state.moved = true;
|
||||||
let phy = state.base.phy;
|
updateDraggedPosition(event.clientX, event.clientY);
|
||||||
for (const id of state.ids) {
|
scheduleAutoPan();
|
||||||
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 }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function up() {
|
function up() {
|
||||||
|
stopAutoPan();
|
||||||
window.removeEventListener("pointermove", move, true);
|
window.removeEventListener("pointermove", move, true);
|
||||||
window.removeEventListener("pointerup", up, true);
|
window.removeEventListener("pointerup", up, true);
|
||||||
if (panDrag) {
|
if (panDrag) {
|
||||||
@@ -323,12 +389,15 @@ export function usePhyCanvasPointer(options: Options) {
|
|||||||
}
|
}
|
||||||
if (holdActivated || moved) preClickTarget = null;
|
if (holdActivated || moved) preClickTarget = null;
|
||||||
dragState = null;
|
dragState = null;
|
||||||
|
lastPointer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function spaceUp(event: KeyboardEvent) {
|
function spaceUp(event: KeyboardEvent) {
|
||||||
if (event.code === "Space") spaceHeld.value = false;
|
if (event.code === "Space") spaceHeld.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(stopAutoPan);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canvasDown,
|
canvasDown,
|
||||||
down,
|
down,
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
export type Viewport = { zoom: number; pan: { x: number; y: number } };
|
export type Viewport = { zoom: number; pan: { x: number; y: number } };
|
||||||
export type ViewBounds = { x: number; y: number; width: number; height: 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(
|
export function fitViewport(
|
||||||
bounds: ViewBounds,
|
bounds: ViewBounds,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
treeIndex,
|
treeIndex,
|
||||||
visibleNodeIds,
|
visibleNodeIds,
|
||||||
} from "../src/ui/phy/logic";
|
} 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";
|
import type { PhyFile, Node as PhyNode } from "../src/domain/phy";
|
||||||
const phy: PhyFile = {
|
const phy: PhyFile = {
|
||||||
version: "0.1.0",
|
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.x - zoomed.pan.x) / zoomed.zoom).toBeCloseTo(world.x);
|
||||||
expect((cursor.y - zoomed.pan.y) / zoomed.zoom).toBeCloseTo(world.y);
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user