Skip to content

Commit 2a6639b

Browse files
author
DavidQ
committed
Add canvas drag pan to vector and collision tools - PR_26140_052-add-vector-canvas-drag-pan
1 parent 116565d commit 2a6639b

6 files changed

Lines changed: 258 additions & 6 deletions

File tree

tests/playwright/tools/CollisionInspectorV2.spec.mjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,60 @@ test.describe("Collision Inspector V2", () => {
288288
await expect(page.locator("#collisionZoomInput")).toHaveAttribute("step", "10");
289289
await expect(page.locator("#collisionZoomInput")).toHaveValue("100");
290290
await expect(page.locator("#zoomState")).toHaveText("100%");
291+
const viewportPanState = await page.locator("#collisionCanvas").evaluate((canvas) => {
292+
const app = window.__collisionInspectorV2App;
293+
const rect = canvas.getBoundingClientRect();
294+
const scaleX = rect.width / canvas.width;
295+
const scaleY = rect.height / canvas.height;
296+
const clientPoint = (point) => ({
297+
clientX: rect.left + point.x * scaleX,
298+
clientY: rect.top + point.y * scaleY
299+
});
300+
const pointerInit = {
301+
bubbles: true,
302+
button: 0,
303+
buttons: 1,
304+
cancelable: true,
305+
pointerId: 52,
306+
pointerType: "mouse"
307+
};
308+
const before = { ...app.renderer.viewportPan };
309+
canvas.dispatchEvent(new PointerEvent("pointerdown", { ...pointerInit, ...clientPoint({ x: 80, y: 80 }) }));
310+
window.dispatchEvent(new PointerEvent("pointermove", { ...pointerInit, ...clientPoint({ x: 150, y: 110 }) }));
311+
const draggingClassDuringPan = canvas.classList.contains("is-dragging");
312+
window.dispatchEvent(new PointerEvent("pointerup", { ...pointerInit, buttons: 0, ...clientPoint({ x: 150, y: 110 }) }));
313+
return {
314+
after: { ...app.renderer.viewportPan },
315+
before,
316+
draggingClassDuringPan
317+
};
318+
});
319+
expect(viewportPanState.draggingClassDuringPan).toBe(true);
320+
expect(viewportPanState.after.x).toBeLessThan(viewportPanState.before.x);
321+
expect(viewportPanState.after.y).toBeLessThan(viewportPanState.before.y);
322+
await expect(page.locator("#collisionLog")).toHaveValue(/Panned viewport/);
323+
await expect(page.locator("#zoomState")).toHaveText("100%");
324+
const postPanHit = await page.locator("#collisionCanvas").evaluate((canvas) => {
325+
const app = window.__collisionInspectorV2App;
326+
const pan = app.renderer.viewportPan;
327+
const rect = canvas.getBoundingClientRect();
328+
const viewportPoint = app.renderer.transform.worldPointToViewportPoint({
329+
x: app.instances.b.x - pan.x,
330+
y: app.instances.b.y - pan.y
331+
});
332+
const clientX = rect.left + (viewportPoint.x / canvas.width) * rect.width;
333+
const clientY = rect.top + (viewportPoint.y / canvas.height) * rect.height;
334+
const point = app.renderer.canvasPoint({ clientX, clientY });
335+
return {
336+
hit: app.renderer.hitObjectAt(point, app.lastResult),
337+
point
338+
};
339+
});
340+
expect(postPanHit.hit).toBe("b");
341+
await page.evaluate(() => {
342+
window.__collisionInspectorV2App.renderer.resetViewportPan();
343+
window.__collisionInspectorV2App.evaluateAndRender();
344+
});
291345
const aspectRatio = await page.locator("#collisionCanvas").evaluate((canvas) => {
292346
const rect = canvas.getBoundingClientRect();
293347
return rect.width / rect.height;

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2327,6 +2327,44 @@ test.describe("Workspace Manager V2 bootstrap", () => {
23272327
await expect(page.locator("#objectVectorStudioV2RenderSurface")).toHaveAttribute("viewBox", "-1600 -1100 3200 2200");
23282328
await expect(page.locator("#objectVectorStudioV2CoordinateDisplay")).toHaveText("Origin: 0, 0 | Canvas origin 0,0 centered | Zoom 100%");
23292329
await expect(page.locator("#objectVectorStudioV2RenderSurface [data-center-origin='0,0']")).toHaveCount(0);
2330+
const dragPanState = await page.locator("#objectVectorStudioV2RenderSurface").evaluate((surface) => {
2331+
const app = window.__objectVectorStudioV2App;
2332+
const rect = surface.getBoundingClientRect();
2333+
const start = {
2334+
clientX: rect.left + rect.width * 0.5,
2335+
clientY: rect.top + rect.height * 0.5
2336+
};
2337+
const end = {
2338+
clientX: start.clientX + 72,
2339+
clientY: start.clientY + 36
2340+
};
2341+
const pointerInit = {
2342+
bubbles: true,
2343+
button: 0,
2344+
buttons: 1,
2345+
cancelable: true,
2346+
pointerId: 41,
2347+
pointerType: "mouse"
2348+
};
2349+
const before = { ...app.viewport };
2350+
surface.dispatchEvent(new PointerEvent("pointerdown", { ...pointerInit, ...start }));
2351+
window.dispatchEvent(new PointerEvent("pointermove", { ...pointerInit, ...end }));
2352+
const panningClassDuringDrag = surface.classList.contains("is-canvas-panning");
2353+
window.dispatchEvent(new PointerEvent("pointerup", { ...pointerInit, buttons: 0, ...end }));
2354+
return {
2355+
after: { ...app.viewport },
2356+
before,
2357+
panningClassDuringDrag,
2358+
viewBox: surface.getAttribute("viewBox")
2359+
};
2360+
});
2361+
expect(dragPanState.panningClassDuringDrag).toBe(true);
2362+
expect(dragPanState.after.x).toBeLessThan(dragPanState.before.x);
2363+
expect(dragPanState.after.y).toBeLessThan(dragPanState.before.y);
2364+
expect(dragPanState.viewBox).not.toBe("-1600 -1100 3200 2200");
2365+
await expect(page.locator("#statusLog")).toHaveValue(/OK Viewport drag-pan set to/);
2366+
await page.locator("#objectVectorStudioV2ResetViewButton").click();
2367+
await expect(page.locator("#objectVectorStudioV2RenderSurface")).toHaveAttribute("viewBox", "-1600 -1100 3200 2200");
23302368
await page.locator("#objectVectorStudioV2CenterDotButton").click();
23312369
await expect(page.locator("#objectVectorStudioV2RenderSurface [data-center-origin='0,0']")).toHaveCount(1);
23322370
await expect(page.locator("#objectVectorStudioV2CenterDotButton")).toHaveAttribute("aria-pressed", "true");

tools/collision-inspector-v2/js/CollisionInspectorV2App.js

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export class CollisionInspectorV2App {
148148
this.screen = { width: screenResult.width, height: screenResult.height };
149149
this.controls.setViewportSize(this.screen.width, this.screen.height);
150150
this.renderer.setViewportSize(this.screen.width, this.screen.height);
151+
this.renderer.resetViewportPan();
151152
this.controls.setObjectOptions(this.objects);
152153
this.resetSimulation({ silent: true });
153154
const gameName = manifest?.game?.name || manifest?.name || manifest?.gameId || "Loaded manifest";
@@ -249,10 +250,22 @@ export class CollisionInspectorV2App {
249250
return;
250251
}
251252
const point = this.renderer.canvasPoint(event);
252-
const key = this.renderer.hitObjectAt(point, this.lastResult) || "a";
253+
const key = this.renderer.hitObjectAt(point, this.lastResult);
254+
if (!key) {
255+
this.dragState = {
256+
lastClient: { x: Number(event.clientX) || 0, y: Number(event.clientY) || 0 },
257+
mode: "pan",
258+
moved: false
259+
};
260+
this.renderer.setDragging(true);
261+
this.renderer.capturePointer(event.pointerId);
262+
event.preventDefault();
263+
return;
264+
}
253265
this.dragState = {
254266
key,
255-
lastPoint: point
267+
lastPoint: point,
268+
mode: "object"
256269
};
257270
this.renderer.setDragging(true);
258271
this.renderer.capturePointer(event.pointerId);
@@ -263,6 +276,20 @@ export class CollisionInspectorV2App {
263276
if (!this.dragState) {
264277
return;
265278
}
279+
if (this.dragState.mode === "pan") {
280+
const clientX = Number(event.clientX) || 0;
281+
const clientY = Number(event.clientY) || 0;
282+
const deltaX = clientX - this.dragState.lastClient.x;
283+
const deltaY = clientY - this.dragState.lastClient.y;
284+
this.dragState.lastClient = { x: clientX, y: clientY };
285+
if (deltaX || deltaY) {
286+
this.dragState.moved = true;
287+
this.renderer.panViewportByClientDelta(deltaX, deltaY);
288+
this.evaluateAndRender();
289+
}
290+
event.preventDefault();
291+
return;
292+
}
266293
const point = this.renderer.canvasPoint(event);
267294
const delta = {
268295
x: point.x - this.dragState.lastPoint.x,
@@ -279,6 +306,16 @@ export class CollisionInspectorV2App {
279306
if (!this.dragState) {
280307
return;
281308
}
309+
if (this.dragState.mode === "pan") {
310+
const pan = { ...this.renderer.viewportPan };
311+
const moved = this.dragState.moved;
312+
this.dragState = null;
313+
this.renderer.setDragging(false);
314+
if (moved) {
315+
this.logger.write(`INFO Panned viewport to ${roundNumber(pan.x)}, ${roundNumber(pan.y)}.`);
316+
}
317+
return;
318+
}
282319
const key = this.dragState.key;
283320
this.dragState = null;
284321
this.renderer.setDragging(false);

tools/collision-inspector-v2/js/CollisionInspectorV2Renderer.js

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export class CollisionInspectorV2Renderer {
1313
this.ctx = canvas.getContext("2d");
1414
this.transform = createWorldScreenTransform();
1515
this.zoom = COLLISION_ZOOM_DEFAULT;
16+
this.viewportPan = { x: 0, y: 0 };
1617
}
1718

1819
setViewportSize(width, height) {
@@ -47,15 +48,41 @@ export class CollisionInspectorV2Renderer {
4748
canvasPoint(event) {
4849
const rect = this.canvas.getBoundingClientRect();
4950
const screenPoint = this.transform.clientPointToScreenPoint(event, rect);
50-
return this.transform.screenPointToWorldWithUserZoom(screenPoint);
51+
const worldPoint = this.transform.screenPointToWorldWithUserZoom(screenPoint);
52+
return {
53+
x: worldPoint.x + this.viewportPan.x,
54+
y: worldPoint.y + this.viewportPan.y
55+
};
56+
}
57+
58+
panViewportByClientDelta(deltaX, deltaY) {
59+
const rect = this.canvas.getBoundingClientRect();
60+
if (!rect.width || !rect.height) {
61+
return { ...this.viewportPan };
62+
}
63+
const screenDeltaX = (Number(deltaX) / rect.width) * this.canvas.width;
64+
const screenDeltaY = (Number(deltaY) / rect.height) * this.canvas.height;
65+
this.viewportPan = {
66+
x: Number((this.viewportPan.x - screenDeltaX / this.zoom).toFixed(3)),
67+
y: Number((this.viewportPan.y - screenDeltaY / this.zoom).toFixed(3))
68+
};
69+
return { ...this.viewportPan };
70+
}
71+
72+
resetViewportPan() {
73+
this.viewportPan = { x: 0, y: 0 };
5174
}
5275

5376
setDragging(isDragging) {
5477
this.canvas.classList.toggle("is-dragging", isDragging);
5578
}
5679

5780
capturePointer(pointerId) {
58-
this.canvas.setPointerCapture?.(pointerId);
81+
try {
82+
this.canvas.setPointerCapture?.(pointerId);
83+
} catch {
84+
// Pointer capture is best-effort for synthetic validation events.
85+
}
5986
}
6087

6188
hitObjectAt(point, result) {
@@ -79,6 +106,7 @@ export class CollisionInspectorV2Renderer {
79106
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
80107
ctx.save();
81108
this.transform.applyViewportTransform(ctx);
109+
ctx.translate(-this.viewportPan.x, -this.viewportPan.y);
82110
this.drawGrid(ctx);
83111
this.drawGeometry(ctx, result.geometryA, {
84112
fill: "rgba(13, 148, 136, 0.18)",

tools/object-vector-studio-v2/js/ToolStarterApp.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,7 @@ export class ToolStarterApp {
534534
this.gridRenderEnabled = true;
535535
this.centerOriginVisible = this.window.sessionStorage?.getItem(CENTER_ORIGIN_SESSION_KEY) !== "0";
536536
this.schemaReady = false;
537+
this.viewportPan = null;
537538
this.viewport = { ...DEFAULT_VIEWPORT };
538539
}
539540

@@ -1033,12 +1034,14 @@ export class ToolStarterApp {
10331034
this.elements.renderSurface.addEventListener("dblclick", (event) => this.finishMultiPointDrawing("double-click", event));
10341035
this.window.addEventListener("pointermove", (event) => this.updatePreviewPointerEdit(event));
10351036
this.window.addEventListener("pointermove", (event) => this.updateDrawingPreview(event));
1037+
this.window.addEventListener("pointermove", (event) => this.updateViewportPan(event));
10361038
this.elements.renderSurface.addEventListener("wheel", (event) => {
10371039
event.preventDefault();
10381040
this.zoomViewportByStepAtPointer(event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP, event);
10391041
}, { passive: false });
10401042
this.window.addEventListener("pointerup", (event) => {
10411043
this.isPaintDragging = false;
1044+
this.finishViewportPan(event);
10421045
this.finishPreviewPointerEdit(event);
10431046
});
10441047
}
@@ -4761,6 +4764,9 @@ export class ToolStarterApp {
47614764
if (this.startPreviewObjectMove(event)) {
47624765
return;
47634766
}
4767+
if (this.startViewportPan(event)) {
4768+
return;
4769+
}
47644770
this.deselectShape("empty canvas");
47654771
}
47664772
}
@@ -4789,10 +4795,23 @@ export class ToolStarterApp {
47894795
return !targetIndexes.some((shapeIndex) => sortedShapes(object)[shapeIndex]?.locked);
47904796
}
47914797

4798+
canPanViewportFromEmptyCanvas() {
4799+
return !this.activeDrawing
4800+
&& !this.previewPointerEdit
4801+
&& this.activeTool === "select"
4802+
&& this.selectedShapeIndex < 0
4803+
&& !this.selectedShapeIndexes.size
4804+
&& !this.directSelectedShapeIndexes.size
4805+
&& !this.canDragSelectedObjectFromEmptyCanvas();
4806+
}
4807+
47924808
updateRenderSurfaceCursorState() {
47934809
const draggingObject = this.previewPointerEdit?.mode === "move-object";
4810+
const panningViewport = Boolean(this.viewportPan);
47944811
this.elements.renderSurface.classList.toggle("is-empty-canvas-object-drag-ready", this.canDragSelectedObjectFromEmptyCanvas());
47954812
this.elements.renderSurface.classList.toggle("is-empty-canvas-object-dragging", draggingObject);
4813+
this.elements.renderSurface.classList.toggle("is-canvas-pan-ready", this.canPanViewportFromEmptyCanvas());
4814+
this.elements.renderSurface.classList.toggle("is-canvas-panning", panningViewport);
47964815
}
47974816

47984817
deselectShape(sourceLabel = "selection") {
@@ -5092,6 +5111,80 @@ export class ToolStarterApp {
50925111
return true;
50935112
}
50945113

5114+
startViewportPan(event) {
5115+
if (event.button !== 0 || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey || !this.canPanViewportFromEmptyCanvas()) {
5116+
return false;
5117+
}
5118+
event.preventDefault();
5119+
event.stopPropagation();
5120+
this.viewportPan = {
5121+
dragThresholdMet: false,
5122+
pointerId: event.pointerId,
5123+
startClient: {
5124+
x: Number(event.clientX) || 0,
5125+
y: Number(event.clientY) || 0
5126+
},
5127+
startViewport: { ...this.viewport }
5128+
};
5129+
try {
5130+
this.elements.renderSurface.setPointerCapture?.(event.pointerId);
5131+
} catch {
5132+
// Pointer capture is best-effort for synthetic validation events.
5133+
}
5134+
this.updateRenderSurfaceCursorState();
5135+
return true;
5136+
}
5137+
5138+
updateViewportPan(event) {
5139+
const pan = this.viewportPan;
5140+
if (!pan || event.buttons !== 1) {
5141+
return;
5142+
}
5143+
const clientX = Number(event.clientX) || 0;
5144+
const clientY = Number(event.clientY) || 0;
5145+
const distance = Math.hypot(clientX - pan.startClient.x, clientY - pan.startClient.y);
5146+
if (!pan.dragThresholdMet && distance < PREVIEW_DRAG_START_THRESHOLD_PX) {
5147+
return;
5148+
}
5149+
pan.dragThresholdMet = true;
5150+
const bounds = this.elements.renderSurface.getBoundingClientRect();
5151+
if (!bounds.width || !bounds.height) {
5152+
return;
5153+
}
5154+
const viewWidth = DEFAULT_VIEWPORT.width / pan.startViewport.zoom;
5155+
const viewHeight = DEFAULT_VIEWPORT.height / pan.startViewport.zoom;
5156+
const viewportX = Number((pan.startViewport.x - ((clientX - pan.startClient.x) / bounds.width) * viewWidth).toFixed(3));
5157+
const viewportY = Number((pan.startViewport.y - ((clientY - pan.startClient.y) / bounds.height) * viewHeight).toFixed(3));
5158+
if (viewportX === this.viewport.x && viewportY === this.viewport.y) {
5159+
return;
5160+
}
5161+
this.viewport.x = viewportX;
5162+
this.viewport.y = viewportY;
5163+
this.updateViewport();
5164+
this.updateCoordinateDisplay(event);
5165+
}
5166+
5167+
finishViewportPan(event) {
5168+
const pan = this.viewportPan;
5169+
if (!pan) {
5170+
return false;
5171+
}
5172+
this.viewportPan = null;
5173+
try {
5174+
this.elements.renderSurface.releasePointerCapture?.(pan.pointerId);
5175+
} catch {
5176+
// Pointer capture is best-effort for synthetic validation events.
5177+
}
5178+
this.updateRenderSurfaceCursorState();
5179+
if (!pan.dragThresholdMet) {
5180+
this.deselectShape("empty canvas");
5181+
return true;
5182+
}
5183+
this.updateCoordinateDisplay(event);
5184+
this.statusLog.write(`OK Viewport drag-pan set to ${this.viewport.x}, ${this.viewport.y}.`);
5185+
return true;
5186+
}
5187+
50955188
startPreviewSelectionBoundsMove(event) {
50965189
this.startPreviewShapeMove(event, this.selectedShapeIndex, { fromSelectionBounds: true });
50975190
}

tools/object-vector-studio-v2/styles/toolStarter.css

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1505,11 +1505,13 @@ textarea:hover {
15051505
cursor: crosshair;
15061506
}
15071507

1508-
.object-vector-studio-v2__render-surface.is-empty-canvas-object-drag-ready {
1508+
.object-vector-studio-v2__render-surface.is-empty-canvas-object-drag-ready,
1509+
.object-vector-studio-v2__render-surface.is-canvas-pan-ready {
15091510
cursor: grab;
15101511
}
15111512

1512-
.object-vector-studio-v2__render-surface.is-empty-canvas-object-dragging {
1513+
.object-vector-studio-v2__render-surface.is-empty-canvas-object-dragging,
1514+
.object-vector-studio-v2__render-surface.is-canvas-panning {
15131515
cursor: grabbing;
15141516
}
15151517

0 commit comments

Comments
 (0)