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:
2026-10-22 09:39:19 +09:00
co-authored by Claude Opus 5.5
parent 38954e9b36
commit e50722948d
4 changed files with 133 additions and 16 deletions
+84 -15
View File
@@ -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,
+20
View File
@@ -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,