Files
refboard-ayon/frontend/src/canvas/CropOverlay.ts
T
Hiren Kangad 9a4edd90c7 refboard: complete image geometry rework — consumer adoption + tests
- Fix grouping.ts ungroup: transform display position (with flip
  compensation) through group transform, then back-calculate canonical
  data.x/y. Fixes visual jump for flipped images in rotated groups.
- Fix clipboard.ts + export.ts resolution estimation: account for
  scale when computing effective rendered width for texture ratio.
- Add imageTransforms.test.ts: 32 unit tests covering source rect,
  visible local rect, display transform, display geometry, editor
  geometry anchor preservation, crop rect round-trips, coordinate
  conversions, negative scale normalization, and world bounds invariants.
- Add vitest as dev dependency with "test" script.
- Include canonical geometry layer (imageTransforms.ts) and crop
  session rewrite (CropOverlay.ts) from prior work.
- Add image geometry rework plan document.
2026-03-13 13:37:42 +05:30

393 lines
15 KiB
TypeScript

/**
* CropOverlay — interactive crop handles over an image.
*
* Shows a crop rectangle with 8 drag handles and a dimmed area outside.
* The overlay is positioned in world-space as a child of the viewport.
* All crop values are normalized 0-1 relative to the image's natural dimensions.
*/
import { Container, Graphics, FederatedPointerEvent, Polygon, Rectangle } from 'pixi.js';
import type { Viewport } from 'pixi-viewport';
import type { SceneItem } from './SceneManager';
import type { ImageObject, CropRect } from './scene-format';
import {
applyImageDisplayTransform,
displayCropRectToSourceCrop,
getImageDisplayCropRect,
getImageEditorGeometry,
getImageViewRectWorldCorners,
imageViewPointToWorld,
worldToImageViewPoint,
} from './imageTransforms';
import { ImageSprite } from './sprites/ImageSprite';
const HANDLE_SIZE = 8;
const HANDLE_HIT_SIZE = 22;
const HANDLE_FILL = 0xffffff;
const HANDLE_STROKE = 0x4a90d9;
const BORDER_COLOR = 0x4a90d9;
const DIM_ALPHA = 0.6;
const MIN_CROP = 0.05; // minimum 5% of image in each dimension
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br';
type DragMode = HandleId | 'move';
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
export class CropOverlay extends Container {
private _item: SceneItem | null = null;
private _viewport: Viewport;
private _dim: Graphics;
private _cropHitArea: Graphics;
private _border: Graphics;
private _handles = new Map<HandleId, Graphics>();
private _crop: CropRect = { x: 0, y: 0, w: 1, h: 1 };
private _originalCrop: CropRect | undefined;
private _editorData: ImageObject | null = null;
private _drag: { mode: DragMode; startCrop: CropRect; startPoint: { x: number; y: number } } | null = null;
private _onConfirm: ((item: SceneItem, crop: CropRect, anchorWorld: { x: number; y: number }) => void) | null = null;
private _onCancel: (() => void) | null = null;
private _onStateChange: ((active: boolean) => void) | null = null;
private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
private _domMove: ((e: PointerEvent) => void) | null = null;
private _domUp: ((e: PointerEvent) => void) | null = null;
constructor(viewport: Viewport) {
super();
this._viewport = viewport;
this.label = '__crop_overlay';
this.visible = false;
this.eventMode = 'static';
// Dim overlay (darkens area outside crop)
this._dim = new Graphics();
this._dim.eventMode = 'none';
this.addChild(this._dim);
this._cropHitArea = new Graphics();
this._cropHitArea.eventMode = 'static';
this._cropHitArea.cursor = 'move';
this._cropHitArea.on('pointerdown', (e: FederatedPointerEvent) => this._onDown(e, 'move'));
this.addChild(this._cropHitArea);
// Crop border
this._border = new Graphics();
this._border.eventMode = 'none';
this.addChild(this._border);
// Create handles — only pointerdown on each handle
const ids: HandleId[] = ['tl', 'tc', 'tr', 'ml', 'mr', 'bl', 'bc', 'br'];
const cursors: Record<HandleId, string> = {
tl: 'nwse-resize', tr: 'nesw-resize', bl: 'nesw-resize', br: 'nwse-resize',
tc: 'ns-resize', bc: 'ns-resize', ml: 'ew-resize', mr: 'ew-resize',
};
for (const id of ids) {
const h = new Graphics();
h.eventMode = 'static';
h.cursor = cursors[id];
h.on('pointerdown', (e: FederatedPointerEvent) => this._onDown(e, id));
this._handles.set(id, h);
this.addChild(h);
}
}
set onConfirm(fn: (item: SceneItem, crop: CropRect, anchorWorld: { x: number; y: number }) => void) { this._onConfirm = fn; }
set onCancel(fn: () => void) { this._onCancel = fn; }
set onStateChange(fn: (active: boolean) => void) { this._onStateChange = fn; }
/** Start cropping the given image item. */
start(item: SceneItem): void {
if (item.type !== 'image') return;
this._item = item;
const imgData = item.data as ImageObject;
this._originalCrop = imgData.crop ? { ...imgData.crop } : undefined;
const editor = getImageEditorGeometry(imgData);
this._editorData = editor.editorData;
this._crop = editor.sourceCrop;
if (item.displayObject instanceof ImageSprite) {
item.displayObject.applyCrop(undefined);
applyImageDisplayTransform(item.displayObject, this._editorData);
}
this._viewport.plugins.pause('drag');
this.visible = true;
this._onStateChange?.(true);
// Ensure overlay renders on top of all scene items
if (this.parent) {
this.parent.setChildIndex(this, this.parent.children.length - 1);
}
this._draw();
// Key bindings
this._keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); this.confirm(); }
if (e.key === 'Escape') { e.preventDefault(); this.cancel(); }
};
window.addEventListener('keydown', this._keyHandler);
}
/** Confirm the crop and apply it. */
confirm(): void {
if (!this._item) return;
const item = this._item;
const crop = { ...this._crop };
const data = this._getEditorData();
const viewCrop = getImageDisplayCropRect({ crop, flipX: data.flipX, flipY: data.flipY });
const anchorWorld = imageViewPointToWorld(data, viewCrop.x, viewCrop.y);
const isFullImage = crop.x < 0.001 && crop.y < 0.001 && crop.w > 0.999 && crop.h > 0.999;
this._cleanup();
this._onConfirm?.(item, isFullImage ? { x: 0, y: 0, w: 1, h: 1 } : crop, anchorWorld);
}
/** Cancel cropping — restore original state. */
cancel(): void {
this._cleanup();
this._onCancel?.();
}
get isActive(): boolean { return this._item !== null; }
/** Clean up all state and listeners. Safe to call multiple times. */
private _cleanup(): void {
this._removeDomDragListeners();
this._viewport.plugins.resume('drag');
this._restoreDisplayState();
this._item = null;
this._originalCrop = undefined;
this._editorData = null;
this._drag = null;
this.visible = false;
this._onStateChange?.(false);
if (this._keyHandler) {
window.removeEventListener('keydown', this._keyHandler);
this._keyHandler = null;
}
}
/** Override destroy to ensure cleanup runs. */
override destroy(options?: any): void {
this._cleanup();
super.destroy(options);
}
private _draw(): void {
if (!this._item) return;
const data = this._getEditorData();
const zoom = this._viewport.scale.x;
const viewCrop = getImageDisplayCropRect({ crop: this._crop, flipX: data.flipX, flipY: data.flipY });
const fullCorners = getImageViewRectWorldCorners(data, { x: 0, y: 0, w: 1, h: 1 });
const cropCorners = getImageViewRectWorldCorners(data, viewCrop);
// Dim: draw 4 rectangles around the crop area
this._dim.clear();
const dimQuads = [
[fullCorners[0], fullCorners[1], cropCorners[1], cropCorners[0]],
[fullCorners[1], fullCorners[2], cropCorners[2], cropCorners[1]],
[fullCorners[2], fullCorners[3], cropCorners[3], cropCorners[2]],
[fullCorners[3], fullCorners[0], cropCorners[0], cropCorners[3]],
];
for (const quad of dimQuads) {
this._drawPolygon(this._dim, quad);
this._dim.fill({ color: 0x000000, alpha: DIM_ALPHA });
}
// Border around crop area
this._cropHitArea.clear();
this._drawPolygon(this._cropHitArea, cropCorners);
this._cropHitArea.fill({ color: 0xffffff, alpha: 0.001 });
this._cropHitArea.hitArea = new Polygon(cropCorners.flatMap((point) => [point.x, point.y]));
this._border.clear();
this._drawPolygon(this._border, cropCorners);
this._border.stroke({ color: BORDER_COLOR, width: 1.5 / zoom });
// Rule of thirds lines
for (let i = 1; i <= 2; i++) {
const verticalX = viewCrop.x + (viewCrop.w * i) / 3;
const horizontalY = viewCrop.y + (viewCrop.h * i) / 3;
const vStart = imageViewPointToWorld(data, verticalX, viewCrop.y);
const vEnd = imageViewPointToWorld(data, verticalX, viewCrop.y + viewCrop.h);
const hStart = imageViewPointToWorld(data, viewCrop.x, horizontalY);
const hEnd = imageViewPointToWorld(data, viewCrop.x + viewCrop.w, horizontalY);
this._border.moveTo(vStart.x, vStart.y);
this._border.lineTo(vEnd.x, vEnd.y);
this._border.moveTo(hStart.x, hStart.y);
this._border.lineTo(hEnd.x, hEnd.y);
}
this._border.stroke({ color: 0xffffff, width: 0.5 / zoom, alpha: 0.3 });
// Position handles
const positions: Record<HandleId, { px: number; py: number }> = {
tl: { px: cropCorners[0].x, py: cropCorners[0].y },
tc: this._toHandlePosition(imageViewPointToWorld(data, viewCrop.x + viewCrop.w / 2, viewCrop.y)),
tr: { px: cropCorners[1].x, py: cropCorners[1].y },
ml: this._toHandlePosition(imageViewPointToWorld(data, viewCrop.x, viewCrop.y + viewCrop.h / 2)),
mr: this._toHandlePosition(imageViewPointToWorld(data, viewCrop.x + viewCrop.w, viewCrop.y + viewCrop.h / 2)),
bl: { px: cropCorners[3].x, py: cropCorners[3].y },
bc: this._toHandlePosition(imageViewPointToWorld(data, viewCrop.x + viewCrop.w / 2, viewCrop.y + viewCrop.h)),
br: { px: cropCorners[2].x, py: cropCorners[2].y },
};
const s = HANDLE_SIZE / zoom;
const half = s / 2;
const hitSize = HANDLE_HIT_SIZE / zoom;
const hitHalf = hitSize / 2;
for (const [id, handle] of this._handles) {
const pos = positions[id];
handle.clear();
handle.rect(-half, -half, s, s);
handle.fill(HANDLE_FILL);
handle.stroke({ color: HANDLE_STROKE, width: 1 / zoom });
handle.hitArea = new Rectangle(-hitHalf, -hitHalf, hitSize, hitSize);
handle.position.set(pos.px, pos.py);
}
}
// -- Handle drag (single handler, registered dynamically) --
private _onDown(e: FederatedPointerEvent, mode: DragMode): void {
if (!this._item) return;
e.stopPropagation();
if ('preventDefault' in e.nativeEvent && typeof e.nativeEvent.preventDefault === 'function') {
e.nativeEvent.preventDefault();
}
const data = this._getEditorData();
const world = this._viewport.toWorld(e.global.x, e.global.y);
const viewPoint = worldToImageViewPoint(data, world.x, world.y);
this._drag = {
mode,
startCrop: getImageDisplayCropRect({ crop: this._crop, flipX: data.flipX, flipY: data.flipY }),
startPoint: { x: clamp01(viewPoint.x), y: clamp01(viewPoint.y) },
};
this._removeDomDragListeners();
this._domMove = (ev: PointerEvent) => this._onDomMove(ev);
this._domUp = () => this._onUp();
window.addEventListener('pointermove', this._domMove);
window.addEventListener('pointerup', this._domUp, { once: true });
window.addEventListener('pointercancel', this._domUp, { once: true });
}
private _onMoveScreen(screenX: number, screenY: number): void {
if (!this._drag || !this._item) return;
const data = this._getEditorData();
const world = this._viewport.toWorld(screenX, screenY);
const viewPoint = worldToImageViewPoint(data, world.x, world.y);
const nx = clamp01(viewPoint.x);
const ny = clamp01(viewPoint.y);
const { mode, startCrop: sc, startPoint } = this._drag;
const crop = { ...sc };
if (mode === 'move') {
crop.x = Math.max(0, Math.min(1 - sc.w, sc.x + (nx - startPoint.x)));
crop.y = Math.max(0, Math.min(1 - sc.h, sc.y + (ny - startPoint.y)));
this._crop = displayCropRectToSourceCrop(data, crop);
this._draw();
return;
}
switch (mode) {
case 'tl':
crop.x = Math.max(0, Math.min(nx, sc.x + sc.w - MIN_CROP));
crop.y = Math.max(0, Math.min(ny, sc.y + sc.h - MIN_CROP));
crop.w = sc.x + sc.w - crop.x;
crop.h = sc.y + sc.h - crop.y;
break;
case 'tr':
crop.w = Math.max(MIN_CROP, Math.min(1 - sc.x, nx - sc.x));
crop.y = Math.max(0, Math.min(ny, sc.y + sc.h - MIN_CROP));
crop.h = sc.y + sc.h - crop.y;
break;
case 'bl':
crop.x = Math.max(0, Math.min(nx, sc.x + sc.w - MIN_CROP));
crop.w = sc.x + sc.w - crop.x;
crop.h = Math.max(MIN_CROP, Math.min(1 - sc.y, ny - sc.y));
break;
case 'br':
crop.w = Math.max(MIN_CROP, Math.min(1 - sc.x, nx - sc.x));
crop.h = Math.max(MIN_CROP, Math.min(1 - sc.y, ny - sc.y));
break;
case 'tc':
crop.y = Math.max(0, Math.min(ny, sc.y + sc.h - MIN_CROP));
crop.h = sc.y + sc.h - crop.y;
break;
case 'bc':
crop.h = Math.max(MIN_CROP, Math.min(1 - sc.y, ny - sc.y));
break;
case 'ml':
crop.x = Math.max(0, Math.min(nx, sc.x + sc.w - MIN_CROP));
crop.w = sc.x + sc.w - crop.x;
break;
case 'mr':
crop.w = Math.max(MIN_CROP, Math.min(1 - sc.x, nx - sc.x));
break;
}
this._crop = displayCropRectToSourceCrop(data, crop);
this._draw();
}
private _onDomMove(e: PointerEvent): void {
const point = this._clientToCanvasPoint(e.clientX, e.clientY);
if (!point) return;
this._onMoveScreen(point.x, point.y);
}
private _onUp(): void {
this._drag = null;
this._removeDomDragListeners();
}
private _drawPolygon(graphics: Graphics, points: Array<{ x: number; y: number }>): void {
if (points.length === 0) return;
graphics.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
graphics.lineTo(points[i].x, points[i].y);
}
graphics.closePath();
}
private _toHandlePosition(point: { x: number; y: number }): { px: number; py: number } {
return { px: point.x, py: point.y };
}
private _removeDomDragListeners(): void {
if (this._domMove) {
window.removeEventListener('pointermove', this._domMove);
this._domMove = null;
}
if (this._domUp) {
window.removeEventListener('pointerup', this._domUp);
window.removeEventListener('pointercancel', this._domUp);
this._domUp = null;
}
}
private _clientToCanvasPoint(clientX: number, clientY: number): { x: number; y: number } | null {
const domElement = (this._viewport as any)?.options?.events?.domElement as HTMLCanvasElement | undefined;
if (!domElement) return null;
const rect = domElement.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
return {
x: clientX - rect.left,
y: clientY - rect.top,
};
}
private _restoreDisplayState(): void {
if (!this._item || !(this._item.displayObject instanceof ImageSprite)) return;
const data = this._item.data as ImageObject;
this._item.displayObject.applyCrop(this._originalCrop);
applyImageDisplayTransform(this._item.displayObject, data);
}
private _getEditorData(): ImageObject {
if (!this._editorData) {
throw new Error('Crop editor data not initialized');
}
return this._editorData;
}
}