The tile tab zoomed freely, so the pixels of a tile could end up
unevenly wide at odd factors. Scene view, container tab and animation
preview already snap to FREE_ZOOM_STEPS. The tile tab now does the
same, so every pixel editor zooms alike.
- The wheel steps through FREE_ZOOM_STEPS, counted in device pixels.
The board position is rounded to whole device pixels.
- New wheelSteps() in src/viewport/view.ts turns wheel events into
steps: one notch is one step, a trackpad gathers 50 px per step.
FreeViewport uses it too, so the rule exists only once.
- Fit takes the largest step at which the whole board fits. Refitting
on a new tab or board size stays as before.
- panAndZoom takes an optional zoom rule instead of limits. Graph and
node board keep zooming freely.
- A saved view from before the steps snaps to the nearest step.
- The cap parameter of frameBoxes from 4027afc has no user anymore.
It is removed together with its unit test.
Fit rounds down, and below 100 percent the steps are far apart. In the
small e2e window the 48 px board lands at 25 instead of 41 percent,
the 16 px board at 100 instead of 124 percent.
Tests:
- check.sh: 161 files, 2370 tests, exit 0 (-1 cap test,
+1 wheelSteps test)
- e2e 1-5 and 39: all passed, 169 checks. Three checks in 39 now
expect steps: Fit on a step with the next one too large, one notch
goes 0.25 to 0.5, 16 px tiles refit to at least 100 percent.
- Counter check: a free Fit factor fails exactly those three checks
(zoom 0.413 and 1.2396).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
6.8 KiB
TypeScript
183 lines
6.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
boundsOf,
|
||
type Box,
|
||
boxesInBand,
|
||
dragOffset,
|
||
frameBoxes,
|
||
resizeSize,
|
||
} from '../../src/editor/framing';
|
||
|
||
const LIMITS = { min: 0.25, max: 2.5 };
|
||
|
||
/** Two nodes side by side, the right one twice as tall. */
|
||
const boxes: Box[] = [
|
||
{ id: 'a', x: 0, y: 0, w: 100, h: 60 },
|
||
{ id: 'b', x: 200, y: 0, w: 100, h: 120 },
|
||
];
|
||
|
||
describe('rubber band', () => {
|
||
it('catches a node the band merely touches, not only one it encloses', () => {
|
||
// The band clips a's right edge and never reaches b.
|
||
expect(boxesInBand(boxes, { x1: 90, y1: 10, x2: 150, y2: 20 })).toEqual(['a']);
|
||
});
|
||
|
||
it('catches both when the band spans them', () => {
|
||
expect(boxesInBand(boxes, { x1: -10, y1: -10, x2: 310, y2: 130 })).toEqual(['a', 'b']);
|
||
});
|
||
|
||
it('reads a band dragged up and to the left the same way', () => {
|
||
expect(boxesInBand(boxes, { x1: 310, y1: 130, x2: -10, y2: -10 })).toEqual(['a', 'b']);
|
||
});
|
||
|
||
it('catches nothing in empty space', () => {
|
||
expect(boxesInBand(boxes, { x1: 120, y1: 200, x2: 180, y2: 260 })).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('drag offset', () => {
|
||
const STEP = 24;
|
||
|
||
it('rounds to whole pixels without snapping', () => {
|
||
expect(dragOffset({ x: 10, y: 10 }, 12.4, -7.6, 0)).toEqual({ x: 12, y: -8 });
|
||
});
|
||
|
||
it('puts a dragged node on the grid, wherever it started', () => {
|
||
// Started off-grid at 10: the drop is a multiple of 24, not 10 plus a multiple.
|
||
const off = dragOffset({ x: 10, y: 10 }, 50, 50, STEP);
|
||
expect({ x: 10 + off.x, y: 10 + off.y }).toEqual({ x: 72, y: 72 });
|
||
});
|
||
|
||
it('gives two differently placed nodes different offsets, so both land on the grid', () => {
|
||
// The same gesture over a selection: 10 and 17 are 7 apart and both end up on a grid line.
|
||
const a = dragOffset({ x: 10, y: 0 }, 50, 0, STEP);
|
||
const b = dragOffset({ x: 17, y: 0 }, 50, 0, STEP);
|
||
expect(a.x).not.toBe(b.x);
|
||
expect(10 + a.x).toBe(72);
|
||
expect(17 + b.x).toBe(72);
|
||
});
|
||
|
||
it('takes the nearest grid line, in both directions', () => {
|
||
expect(dragOffset({ x: 0, y: 0 }, 11, 13, STEP)).toEqual({ x: 0, y: 24 });
|
||
expect(dragOffset({ x: 0, y: 0 }, -11, -13, STEP)).toEqual({ x: 0, y: -24 });
|
||
});
|
||
|
||
it('still moves a node that starts on the grid', () => {
|
||
// A snapped drag shorter than half a step is the one case that could deadlock into "no move".
|
||
expect(dragOffset({ x: 48, y: 48 }, 20, 4, STEP)).toEqual({ x: 24, y: 0 });
|
||
});
|
||
});
|
||
|
||
describe('resize size', () => {
|
||
const STEP = 24;
|
||
const MIN = { w: 80, h: 48 };
|
||
|
||
it('follows the pointer exactly without snapping', () => {
|
||
expect(resizeSize({ x: 10, y: 10, w: 100, h: 60 }, 12.4, -7.6, 0, MIN)).toEqual({
|
||
w: 112.4,
|
||
h: 52.4,
|
||
});
|
||
});
|
||
|
||
it('puts the dragged corner on the grid, wherever the box starts', () => {
|
||
// Off-grid at 10: the far edge is a multiple of 24, so the width is not.
|
||
const size = resizeSize({ x: 10, y: 10, w: 100, h: 60 }, 5, 5, STEP, MIN);
|
||
expect({ right: 10 + size.w, bottom: 10 + size.h }).toEqual({ right: 120, bottom: 72 });
|
||
});
|
||
|
||
it('leaves the near corner alone – only the size changes', () => {
|
||
const start = { x: 24, y: 24, w: 120, h: 72 };
|
||
const size = resizeSize(start, -30, -30, STEP, MIN);
|
||
expect(size).toEqual({ w: 96, h: 48 });
|
||
});
|
||
|
||
it('honours the minimum in whole steps, so a shrunk box stays on the grid', () => {
|
||
// Dragged far past the minimum: 80 wide would leave the right edge between two grid lines.
|
||
const size = resizeSize({ x: 0, y: 0, w: 320, h: 200 }, -400, -400, STEP, MIN);
|
||
expect(size).toEqual({ w: 96, h: 48 });
|
||
expect(size.w).toBeGreaterThanOrEqual(MIN.w);
|
||
});
|
||
});
|
||
|
||
describe('fit', () => {
|
||
/** Where a graph point lands on screen under a framing. */
|
||
const toScreen = (f: { zoom: number; panX: number; panY: number }, x: number, y: number) => ({
|
||
x: x * f.zoom + f.panX,
|
||
y: y * f.zoom + f.panY,
|
||
});
|
||
|
||
it('leaves no node outside the view – the reported bug', () => {
|
||
// Wider than the view at zoom 1, so framing has to zoom out rather than just centre.
|
||
const view = { w: 200, h: 200 };
|
||
const f = frameBoxes(boxes, view, 24, LIMITS);
|
||
expect(f.zoom).toBeLessThan(1);
|
||
for (const b of boxes) {
|
||
const topLeft = toScreen(f, b.x, b.y);
|
||
const bottomRight = toScreen(f, b.x + b.w, b.y + b.h);
|
||
expect(topLeft.x).toBeGreaterThanOrEqual(0);
|
||
expect(topLeft.y).toBeGreaterThanOrEqual(0);
|
||
expect(bottomRight.x).toBeLessThanOrEqual(view.w);
|
||
expect(bottomRight.y).toBeLessThanOrEqual(view.h);
|
||
}
|
||
});
|
||
|
||
it('measures each node by its own height', () => {
|
||
// b is twice as tall as a: pretending both were 60 high would frame too little.
|
||
const tall = frameBoxes([{ id: 'b', x: 0, y: 0, w: 100, h: 400 }], { w: 200, h: 200 }, 24, LIMITS);
|
||
const short = frameBoxes([{ id: 'a', x: 0, y: 0, w: 100, h: 60 }], { w: 200, h: 200 }, 24, LIMITS);
|
||
expect(tall.zoom).toBeLessThan(short.zoom);
|
||
});
|
||
|
||
it('centres what it frames', () => {
|
||
const view = { w: 200, h: 200 };
|
||
const f = frameBoxes(boxes, view, 24, LIMITS);
|
||
const centre = toScreen(f, 150, 60);
|
||
expect(centre.x).toBeCloseTo(100);
|
||
expect(centre.y).toBeCloseTo(100);
|
||
});
|
||
|
||
it('does not blow a small graph up past its natural size', () => {
|
||
const f = frameBoxes([{ id: 'a', x: 0, y: 0, w: 10, h: 10 }], { w: 800, h: 600 }, 24, LIMITS);
|
||
expect(f.zoom).toBe(1);
|
||
});
|
||
|
||
it('never zooms out past the limit, however far the nodes are apart', () => {
|
||
const far: Box[] = [
|
||
{ id: 'a', x: 0, y: 0, w: 100, h: 60 },
|
||
{ id: 'b', x: 100000, y: 0, w: 100, h: 60 },
|
||
];
|
||
expect(frameBoxes(far, { w: 200, h: 200 }, 24, LIMITS).zoom).toBe(LIMITS.min);
|
||
});
|
||
|
||
it('centres the view on an empty graph instead of returning nonsense', () => {
|
||
const f = frameBoxes([], { w: 200, h: 100 }, 24, LIMITS);
|
||
expect(f).toEqual({ zoom: 1, panX: 100, panY: 50 });
|
||
});
|
||
});
|
||
|
||
describe('boundsOf', () => {
|
||
it('encloses every box', () => {
|
||
const boxes: Box[] = [
|
||
{ id: 'a', x: 10, y: 20, w: 100, h: 60 },
|
||
{ id: 'b', x: -40, y: 5, w: 20, h: 10 },
|
||
];
|
||
expect(boundsOf(boxes)).toEqual({ minX: -40, minY: 5, maxX: 110, maxY: 80 });
|
||
});
|
||
|
||
it('reports nothing for no boxes at all', () => {
|
||
expect(boundsOf([])).toBeNull();
|
||
});
|
||
|
||
/** The scene view seeds the origin so a template drawn far to one side cannot hide it. */
|
||
it('keeps a seed inside the result even when every box is far away', () => {
|
||
const far: Box[] = [{ id: 'a', x: 500, y: 500, w: 10, h: 10 }];
|
||
const origin = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||
expect(boundsOf(far, origin)).toEqual({ minX: 0, minY: 0, maxX: 510, maxY: 510 });
|
||
});
|
||
|
||
it('still answers for no boxes when a seed is given', () => {
|
||
const origin = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||
expect(boundsOf([], origin)).toEqual(origin);
|
||
});
|
||
});
|