From dd1578110b4ac272767cdaf03df98aae3c13016e Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Fri, 13 Mar 2026 10:11:59 +0530 Subject: [PATCH] refboard: support rotated image crop editing --- frontend/src/canvas/CropOverlay.ts | 136 +++++++++++++++---------- frontend/src/canvas/imageTransforms.ts | 75 ++++++++++++++ frontend/src/pages/Editor.tsx | 7 +- 3 files changed, 157 insertions(+), 61 deletions(-) diff --git a/frontend/src/canvas/CropOverlay.ts b/frontend/src/canvas/CropOverlay.ts index 444f3e9..88861f9 100644 --- a/frontend/src/canvas/CropOverlay.ts +++ b/frontend/src/canvas/CropOverlay.ts @@ -10,6 +10,7 @@ import { Container, Graphics, FederatedPointerEvent } from 'pixi.js'; import type { Viewport } from 'pixi-viewport'; import type { SceneItem } from './SceneManager'; import type { ImageObject, CropRect } from './scene-format'; +import { getImageViewRectWorldCorners, imageViewPointToWorld, worldToImageViewPoint } from './imageTransforms'; const HANDLE_SIZE = 8; const HANDLE_FILL = 0xffffff; @@ -19,15 +20,17 @@ 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'; 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(); private _crop: CropRect = { x: 0, y: 0, w: 1, h: 1 }; - private _drag: { handleId: HandleId; startCrop: CropRect } | null = null; + private _drag: { mode: DragMode; startCrop: CropRect; startPoint: { x: number; y: number } } | null = null; private _onConfirm: ((item: SceneItem, crop: CropRect) => void) | null = null; private _onCancel: (() => void) | null = null; private _keyHandler: ((e: KeyboardEvent) => void) | null = null; @@ -47,6 +50,12 @@ export class CropOverlay extends Container { 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'; @@ -130,69 +139,59 @@ export class CropOverlay extends Container { private _draw(): void { if (!this._item) return; - const data = this._item.data; + const data = this._item.data as ImageObject; const zoom = this._viewport.scale.x; const viewCrop = this._getViewCrop(); - - // Image world rect - const ix = data.x; - const iy = data.y; - const iw = data.w * data.sx; - const ih = data.h * data.sy; - - // Crop rect in world space - const cx = ix + viewCrop.x * iw; - const cy = iy + viewCrop.y * ih; - const cw = viewCrop.w * iw; - const ch = viewCrop.h * ih; + 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(); - if (cy > iy) { - this._dim.rect(ix, iy, iw, cy - iy); - this._dim.fill({ color: 0x000000, alpha: DIM_ALPHA }); - } - const cropBottom = cy + ch; - if (cropBottom < iy + ih) { - this._dim.rect(ix, cropBottom, iw, iy + ih - cropBottom); - this._dim.fill({ color: 0x000000, alpha: DIM_ALPHA }); - } - if (cx > ix) { - this._dim.rect(ix, cy, cx - ix, ch); - this._dim.fill({ color: 0x000000, alpha: DIM_ALPHA }); - } - const cropRight = cx + cw; - if (cropRight < ix + iw) { - this._dim.rect(cropRight, cy, ix + iw - cropRight, ch); + 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._border.clear(); - this._border.rect(cx, cy, cw, ch); + this._drawPolygon(this._border, cropCorners); this._border.stroke({ color: BORDER_COLOR, width: 1.5 / zoom }); // Rule of thirds lines - const thirdW = cw / 3; - const thirdH = ch / 3; for (let i = 1; i <= 2; i++) { - this._border.moveTo(cx + thirdW * i, cy); - this._border.lineTo(cx + thirdW * i, cy + ch); - this._border.moveTo(cx, cy + thirdH * i); - this._border.lineTo(cx + cw, cy + thirdH * 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 = { - tl: { px: cx, py: cy }, - tc: { px: cx + cw / 2, py: cy }, - tr: { px: cx + cw, py: cy }, - ml: { px: cx, py: cy + ch / 2 }, - mr: { px: cx + cw, py: cy + ch / 2 }, - bl: { px: cx, py: cy + ch }, - bc: { px: cx + cw / 2, py: cy + ch }, - br: { px: cx + cw, py: cy + ch }, + 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; @@ -209,9 +208,17 @@ export class CropOverlay extends Container { // -- Handle drag (single handler, registered dynamically) -- - private _onDown(e: FederatedPointerEvent, id: HandleId): void { + private _onDown(e: FederatedPointerEvent, mode: DragMode): void { + if (!this._item) return; e.stopPropagation(); - this._drag = { handleId: id, startCrop: this._getViewCrop() }; + const data = this._item.data as ImageObject; + const world = this._viewport.toWorld(e.global.x, e.global.y); + const viewPoint = worldToImageViewPoint(data, world.x, world.y); + this._drag = { + mode, + startCrop: this._getViewCrop(), + startPoint: viewPoint, + }; // Register move/up on the stage (single handler, not per-handle) this._removeDragListeners(); @@ -241,18 +248,24 @@ export class CropOverlay extends Container { private _onMove(e: FederatedPointerEvent): void { if (!this._drag || !this._item) return; - const data = this._item.data; - const iw = data.w * data.sx; - const ih = data.h * data.sy; + const data = this._item.data as ImageObject; const world = this._viewport.toWorld(e.global.x, e.global.y); + const viewPoint = worldToImageViewPoint(data, world.x, world.y); + const nx = viewPoint.x; + const ny = viewPoint.y; - const nx = (world.x - data.x) / iw; - const ny = (world.y - data.y) / ih; - - const { handleId, startCrop: sc } = this._drag; + const { mode, startCrop: sc, startPoint } = this._drag; const crop = { ...sc }; - switch (handleId) { + 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._setViewCrop(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)); @@ -322,4 +335,17 @@ export class CropOverlay extends Container { h: viewCrop.h, }; } + + 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 }; + } } diff --git a/frontend/src/canvas/imageTransforms.ts b/frontend/src/canvas/imageTransforms.ts index 9fe2e55..e457992 100644 --- a/frontend/src/canvas/imageTransforms.ts +++ b/frontend/src/canvas/imageTransforms.ts @@ -101,6 +101,20 @@ export function transformPoint(point: Point2D, transform: Pick): Point2D { + const rad = (transform.angle * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = point.x - transform.x; + const dy = point.y - transform.y; + const rotatedX = dx * cos + dy * sin; + const rotatedY = -dx * sin + dy * cos; + return { + x: transform.sx === 0 ? 0 : rotatedX / transform.sx, + y: transform.sy === 0 ? 0 : rotatedY / transform.sy, + }; +} + export function transformPoints(points: Point2D[], transform: Pick): Point2D[] { return points.map((point) => transformPoint(point, transform)); } @@ -133,6 +147,67 @@ export function offsetImageDataPosition(data: Pick, dx: data.y += dy; } +function getImageLocalPointFromViewNormalized( + data: Pick, + viewX: number, + viewY: number, +): Point2D { + return { + x: (data.flipX ? 1 - viewX : viewX) * data.w, + y: (data.flipY ? 1 - viewY : viewY) * data.h, + }; +} + +function getImageViewNormalizedFromLocal( + data: Pick, + localX: number, + localY: number, +): Point2D { + const normX = data.w === 0 ? 0 : localX / data.w; + const normY = data.h === 0 ? 0 : localY / data.h; + return { + x: data.flipX ? 1 - normX : normX, + y: data.flipY ? 1 - normY : normY, + }; +} + +export function imageViewPointToWorld( + data: Pick, + viewX: number, + viewY: number, +): Point2D { + const local = getImageLocalPointFromViewNormalized(data, viewX, viewY); + return transformLocalPoint(data, local.x, local.y); +} + +export function worldToImageViewPoint( + data: Pick, + worldX: number, + worldY: number, +): Point2D { + const t = getImageDisplayTransform(data); + const local = inverseTransformPoint({ x: worldX, y: worldY }, { + x: t.x, + y: t.y, + sx: t.scaleX, + sy: t.scaleY, + angle: t.angle, + }); + return getImageViewNormalizedFromLocal(data, local.x, local.y); +} + +export function getImageViewRectWorldCorners( + data: Pick, + rect: CropRect, +): Point2D[] { + return [ + imageViewPointToWorld(data, rect.x, rect.y), + imageViewPointToWorld(data, rect.x + rect.w, rect.y), + imageViewPointToWorld(data, rect.x + rect.w, rect.y + rect.h), + imageViewPointToWorld(data, rect.x, rect.y + rect.h), + ]; +} + export function getImageTransformedCorners(data: Pick): Point2D[] { const rect = getVisibleLocalRect(data); return [ diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 69192a3..99ee353 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -501,13 +501,8 @@ export default function Editor({ isPublicView }: EditorProps) { if (showInvalidToast) showToast('Select a single image to crop'); return; } - const image = items[0]; - if (Math.abs(image.data.angle % 360) > 0.001) { - showToast('Crop for rotated images is not supported yet'); - return; - } selection.setEnabled(false); - cropOverlayRef.current.start(image); + cropOverlayRef.current.start(items[0]); }, [showToast, cropOverlayRef]); // Keyboard shortcuts