From 12410c0098a9668f5968023cd6bc7a9cf55dd411 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Fri, 13 Mar 2026 09:56:41 +0530 Subject: [PATCH] refboard: stabilize image flip and crop transforms --- frontend/src/canvas/CropOverlay.ts | 38 +++++- frontend/src/canvas/SceneManager.ts | 81 ++++++++----- frontend/src/canvas/SelectionManager.ts | 14 ++- frontend/src/canvas/TransformBox.ts | 11 +- frontend/src/canvas/clipboard.ts | 23 ++-- frontend/src/canvas/export.ts | 20 ++-- frontend/src/canvas/grouping.ts | 35 ++++-- frontend/src/canvas/imageTransforms.ts | 148 ++++++++++++++++++++++++ frontend/src/canvas/operations.ts | 34 +++++- frontend/src/canvas/sync.ts | 13 ++- frontend/src/pages/Editor.tsx | 40 +++---- 11 files changed, 359 insertions(+), 98 deletions(-) create mode 100644 frontend/src/canvas/imageTransforms.ts diff --git a/frontend/src/canvas/CropOverlay.ts b/frontend/src/canvas/CropOverlay.ts index 2d76380..444f3e9 100644 --- a/frontend/src/canvas/CropOverlay.ts +++ b/frontend/src/canvas/CropOverlay.ts @@ -132,6 +132,7 @@ export class CropOverlay extends Container { if (!this._item) return; const data = this._item.data; const zoom = this._viewport.scale.x; + const viewCrop = this._getViewCrop(); // Image world rect const ix = data.x; @@ -140,10 +141,10 @@ export class CropOverlay extends Container { const ih = data.h * data.sy; // Crop rect in world space - const cx = ix + this._crop.x * iw; - const cy = iy + this._crop.y * ih; - const cw = this._crop.w * iw; - const ch = this._crop.h * ih; + const cx = ix + viewCrop.x * iw; + const cy = iy + viewCrop.y * ih; + const cw = viewCrop.w * iw; + const ch = viewCrop.h * ih; // Dim: draw 4 rectangles around the crop area this._dim.clear(); @@ -210,7 +211,7 @@ export class CropOverlay extends Container { private _onDown(e: FederatedPointerEvent, id: HandleId): void { e.stopPropagation(); - this._drag = { handleId: id, startCrop: { ...this._crop } }; + this._drag = { handleId: id, startCrop: this._getViewCrop() }; // Register move/up on the stage (single handler, not per-handle) this._removeDragListeners(); @@ -288,7 +289,7 @@ export class CropOverlay extends Container { break; } - this._crop = crop; + this._setViewCrop(crop); this._draw(); } @@ -296,4 +297,29 @@ export class CropOverlay extends Container { this._drag = null; this._removeDragListeners(); } + + private _getViewCrop(): CropRect { + if (!this._item) return { ...this._crop }; + const data = this._item.data as ImageObject; + return { + x: data.flipX ? 1 - (this._crop.x + this._crop.w) : this._crop.x, + y: data.flipY ? 1 - (this._crop.y + this._crop.h) : this._crop.y, + w: this._crop.w, + h: this._crop.h, + }; + } + + private _setViewCrop(viewCrop: CropRect): void { + if (!this._item) { + this._crop = { ...viewCrop }; + return; + } + const data = this._item.data as ImageObject; + this._crop = { + x: data.flipX ? 1 - (viewCrop.x + viewCrop.w) : viewCrop.x, + y: data.flipY ? 1 - (viewCrop.y + viewCrop.h) : viewCrop.y, + w: viewCrop.w, + h: viewCrop.h, + }; + } } diff --git a/frontend/src/canvas/SceneManager.ts b/frontend/src/canvas/SceneManager.ts index de8cecf..82b98ee 100644 --- a/frontend/src/canvas/SceneManager.ts +++ b/frontend/src/canvas/SceneManager.ts @@ -18,6 +18,15 @@ import { TextSprite } from './sprites/TextSprite'; import { SpringManager, Spring, PRESETS } from './spring'; import { reparentGroupChildren } from './grouping'; import { SpatialGrid } from './SpatialGrid'; +import { + applyImageDisplayTransform, + getBoundsFromPoints, + getImageTransformedCorners, + getImageWorldBounds, + getRectTransformedCorners, + normalizeImageTransformData, + transformPoints, +} from './imageTransforms'; import type { SceneData, AnySceneObject, @@ -72,9 +81,10 @@ export function isGroupChild(id: string): boolean { } /** Single source of truth for an item's world-space bounding rect. - * Uses data.sx/sy (not obj.scale which may be mid-animation). + * Uses canonical scene data for top-level items. * For groups: computes the union of children bounds (w/h on group data may be 0). - * For group children: converts local coords to world using parent group transforms. */ + * For group children: uses composed Pixi world bounds so parent transforms, + * image flips, and crop masks stay aligned. */ export function getItemWorldBounds(item: SceneItem): { x: number; y: number; w: number; h: number } { if (item.data.type === 'group') { return _getGroupWorldBounds(item); @@ -82,17 +92,22 @@ export function getItemWorldBounds(item: SceneItem): { x: number; y: number; w: // If this item is a child of a group, convert local → world const parent = item.displayObject.parent; - if (parent && parent.label && _groupChildIds?.has(item.id)) { - const px = parent.position.x; - const py = parent.position.y; - const psx = parent.scale.x; - const psy = parent.scale.y; - return { - x: px + item.data.x * psx, - y: py + item.data.y * psy, - w: item.data.w * Math.abs(item.data.sx * psx), - h: item.data.h * Math.abs(item.data.sy * psy), + if (parent?.label && _groupChildIds?.has(item.id)) { + const parentTransform = { + x: parent.position.x, + y: parent.position.y, + sx: parent.scale.x, + sy: parent.scale.y, + angle: parent.angle, }; + const localCorners = item.data.type === 'image' + ? getImageTransformedCorners(item.data as ImageObject) + : getRectTransformedCorners(item.data); + return getBoundsFromPoints(transformPoints(localCorners, parentTransform)); + } + + if (item.data.type === 'image') { + return getImageWorldBounds(item.data as ImageObject); } return { @@ -296,6 +311,7 @@ export class SceneManager { switch (data.type) { case 'image': { const imgData = data as ImageObject; + normalizeImageTransformData(imgData); if (isGifAsset(imgData.asset)) { displayObject = new AnimatedGifSprite(imgData.asset, imgData.w, imgData.h, this.textures); } else { @@ -357,9 +373,13 @@ export class SceneManager { } // Common properties - displayObject.position.set(data.x, data.y); - displayObject.scale.set(data.sx, data.sy); - displayObject.angle = data.angle; + if (data.type === 'image') { + applyImageDisplayTransform(displayObject, data as ImageObject); + } else { + displayObject.position.set(data.x, data.y); + displayObject.scale.set(data.sx, data.sy); + displayObject.angle = data.angle; + } displayObject.alpha = data.opacity; displayObject.visible = data.visible; displayObject.eventMode = data.locked ? 'none' : 'static'; @@ -401,9 +421,14 @@ export class SceneManager { _updateItem(item: SceneItem, data: AnySceneObject): void { const obj = item.displayObject; - obj.position.set(data.x, data.y); - obj.scale.set(data.sx, data.sy); - obj.angle = data.angle; + if (data.type === 'image') { + normalizeImageTransformData(data as ImageObject); + applyImageDisplayTransform(obj, data as ImageObject); + } else { + obj.position.set(data.x, data.y); + obj.scale.set(data.sx, data.sy); + obj.angle = data.angle; + } obj.alpha = data.opacity; obj.visible = data.visible; obj.eventMode = data.locked ? 'none' : 'static'; @@ -532,19 +557,23 @@ export class SceneManager { this.spatialGrid.remove(id); this.items.delete(id); - // Scale toward center on removal - const halfW = item.data.w * item.data.sx / 2; - const halfH = item.data.h * item.data.sy / 2; - const startX = item.data.x; - const startY = item.data.y; + // Scale toward center on removal. + const startX = obj.x; + const startY = obj.y; + const startScaleX = obj.scale.x; + const startScaleY = obj.scale.y; + const dirX = startScaleX < 0 ? -1 : 1; + const dirY = startScaleY < 0 ? -1 : 1; + const halfW = item.data.w * Math.abs(item.data.sx) / 2; + const halfH = item.data.h * Math.abs(item.data.sy) / 2; const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy); scaleSpring.onUpdate = (v) => { if (!obj.destroyed) { - obj.scale.set(v * item.data.sx, v * item.data.sy); + obj.scale.set(v * Math.abs(startScaleX) * dirX, v * Math.abs(startScaleY) * dirY); obj.position.set( - startX + halfW * (1 - v), - startY + halfH * (1 - v), + startX + halfW * (1 - v) * dirX, + startY + halfH * (1 - v) * dirY, ); } }; diff --git a/frontend/src/canvas/SelectionManager.ts b/frontend/src/canvas/SelectionManager.ts index 4c28437..ae541b8 100644 --- a/frontend/src/canvas/SelectionManager.ts +++ b/frontend/src/canvas/SelectionManager.ts @@ -12,6 +12,8 @@ import { TransformBox } from './TransformBox'; import { SnapGuides } from './SnapGuides'; import { ImageSprite } from './sprites/ImageSprite'; import { VideoSprite } from './sprites/VideoSprite'; +import type { ImageObject } from './scene-format'; +import { applyImageDisplayTransform } from './imageTransforms'; // --------------------------------------------------------------------------- // Constants @@ -289,10 +291,14 @@ export class SelectionManager { // Move all selected items by corrected delta and broadcast all together for (const item of selected) { - item.displayObject.x += ddx; - item.displayObject.y += ddy; - item.data.x = item.displayObject.x; - item.data.y = item.displayObject.y; + item.data.x += ddx; + item.data.y += ddy; + if (item.type === 'image') { + applyImageDisplayTransform(item.displayObject, item.data as ImageObject); + } else { + item.displayObject.x = item.data.x; + item.displayObject.y = item.data.y; + } } if (this._onItemsTransform) { this._onItemsTransform(selected); diff --git a/frontend/src/canvas/TransformBox.ts b/frontend/src/canvas/TransformBox.ts index fae7cd8..e9f383d 100644 --- a/frontend/src/canvas/TransformBox.ts +++ b/frontend/src/canvas/TransformBox.ts @@ -10,6 +10,8 @@ import { Container, Graphics, FederatedPointerEvent, Text, TextStyle } from 'pix import type { Viewport } from 'pixi-viewport'; import { type SceneItem, getItemWorldBounds } from './SceneManager'; import type { SnapGuides } from './SnapGuides'; +import { applyImageDisplayTransform } from './imageTransforms'; +import type { ImageObject } from './scene-format'; // --------------------------------------------------------------------------- // Constants @@ -347,9 +349,12 @@ export class TransformBox extends Container { item.data.sx = orig.sx * fx; item.data.sy = orig.sy * fy; - item.displayObject.scale.set(item.data.sx, item.data.sy); - - item.displayObject.position.set(item.data.x, item.data.y); + if (item.type === 'image') { + applyImageDisplayTransform(item.displayObject, item.data as ImageObject); + } else { + item.displayObject.scale.set(item.data.sx, item.data.sy); + item.displayObject.position.set(item.data.x, item.data.y); + } this._onItemTransform?.(item); } diff --git a/frontend/src/canvas/clipboard.ts b/frontend/src/canvas/clipboard.ts index a1a705a..c7c8251 100644 --- a/frontend/src/canvas/clipboard.ts +++ b/frontend/src/canvas/clipboard.ts @@ -53,32 +53,32 @@ export async function writeCanvasToClipboard( y: number; sx: number; sy: number; + angle: number; + globalX: number; + globalY: number; }>(); - // Pre-compute world bounds for each item (handles group children with local coords) - const worldBoundsMap = new Map(); - for (const item of items) { - worldBoundsMap.set(item.id, getItemWorldBounds(item)); - } - for (const item of items) { const obj = item.displayObject; + const globalOrigin = obj.parent?.toGlobal(obj.position) ?? obj.position; saved.set(item.id, { parent: obj.parent as Container, x: obj.x, y: obj.y, sx: obj.scale.x, sy: obj.scale.y, + angle: obj.angle, + globalX: globalOrigin.x, + globalY: globalOrigin.y, }); // Remove from current parent obj.parent?.removeChild(obj); - // Position using world bounds (correct for both top-level and group children) - const wb = worldBoundsMap.get(item.id)!; - obj.position.set(wb.x - minX + pad, wb.y - minY + pad); - // Use data scale (not viewport-affected scale) - obj.scale.set(item.data.sx, item.data.sy); + const s = saved.get(item.id)!; + obj.position.set(s.globalX - minX + pad, s.globalY - minY + pad); + obj.scale.set(s.sx, s.sy); + obj.angle = s.angle; tempContainer.addChild(obj); } @@ -111,6 +111,7 @@ export async function writeCanvasToClipboard( s.parent.addChild(item.displayObject); item.displayObject.position.set(s.x, s.y); item.displayObject.scale.set(s.sx, s.sy); + item.displayObject.angle = s.angle; } tempContainer.destroy(); texture?.destroy(true); diff --git a/frontend/src/canvas/export.ts b/frontend/src/canvas/export.ts index 8492efc..1afd438 100644 --- a/frontend/src/canvas/export.ts +++ b/frontend/src/canvas/export.ts @@ -44,24 +44,23 @@ function renderToCanvas( // Reparent items into temp container const tempContainer = new Container(); - const saved = new Map(); - const worldBoundsMap = new Map(); - - for (const item of items) { - worldBoundsMap.set(item.id, getItemWorldBounds(item)); - } - + const saved = new Map(); for (const item of items) { const obj = item.displayObject; + const globalOrigin = obj.parent?.toGlobal(obj.position) ?? obj.position; saved.set(item.id, { parent: obj.parent as Container, x: obj.x, y: obj.y, sx: obj.scale.x, sy: obj.scale.y, + angle: obj.angle, + globalX: globalOrigin.x, + globalY: globalOrigin.y, }); obj.parent?.removeChild(obj); - const wb = worldBoundsMap.get(item.id)!; - obj.position.set(wb.x - minX + pad, wb.y - minY + pad); - obj.scale.set(item.data.sx, item.data.sy); + const s = saved.get(item.id)!; + obj.position.set(s.globalX - minX + pad, s.globalY - minY + pad); + obj.scale.set(s.sx, s.sy); + obj.angle = s.angle; tempContainer.addChild(obj); } @@ -91,6 +90,7 @@ function renderToCanvas( s.parent.addChild(item.displayObject); item.displayObject.position.set(s.x, s.y); item.displayObject.scale.set(s.sx, s.sy); + item.displayObject.angle = s.angle; } tempContainer.destroy(); texture?.destroy(true); diff --git a/frontend/src/canvas/grouping.ts b/frontend/src/canvas/grouping.ts index 0ac518e..97c02dc 100644 --- a/frontend/src/canvas/grouping.ts +++ b/frontend/src/canvas/grouping.ts @@ -12,8 +12,9 @@ import type { Viewport } from 'pixi-viewport'; import type { SceneManager, SceneItem } from './SceneManager'; import { getItemWorldBounds, rebuildGroupChildSet } from './SceneManager'; import type { SelectionManager } from './SelectionManager'; -import type { GroupObject } from './scene-format'; +import type { GroupObject, ImageObject } from './scene-format'; import { randomFrameColor } from './sprites/FrameSprite'; +import { applyImageDisplayTransform, transformPoint } from './imageTransforms'; /** * Group selected items into a single group container. @@ -84,8 +85,12 @@ export function groupItems( // Reparent display object obj.parent?.removeChild(obj); - obj.position.set(localX, localY); groupItem.displayObject.addChild(obj); + if (child.type === 'image') { + applyImageDisplayTransform(obj, child.data as ImageObject); + } else { + obj.position.set(localX, localY); + } } selection.clear(); @@ -128,8 +133,12 @@ export function ungroupItems( const obj = childItem.displayObject; // Convert local position to world space, accounting for group scale - const worldX = groupX + childItem.data.x * groupSx; - const worldY = groupY + childItem.data.y * groupSy; + const worldOrigin = transformPoint( + { x: childItem.data.x, y: childItem.data.y }, + { x: groupX, y: groupY, sx: groupSx, sy: groupSy, angle: groupAngle }, + ); + const worldX = worldOrigin.x; + const worldY = worldOrigin.y; // Propagate group scale to child childItem.data.sx *= groupSx; @@ -145,9 +154,13 @@ export function ungroupItems( // Reparent display object obj.parent?.removeChild(obj); viewport.addChild(obj); - obj.position.set(worldX, worldY); - obj.scale.set(childItem.data.sx, childItem.data.sy); - obj.angle = childItem.data.angle; + if (childItem.type === 'image') { + applyImageDisplayTransform(obj, childItem.data as ImageObject); + } else { + obj.position.set(worldX, worldY); + obj.scale.set(childItem.data.sx, childItem.data.sy); + obj.angle = childItem.data.angle; + } childIds.push(childId); } @@ -188,9 +201,13 @@ export function reparentGroupChildren(scene: SceneManager): void { // Only reparent if currently in viewport (not already in a group) if (obj.parent !== groupContainer) { obj.parent?.removeChild(obj); - // data.x/y are already local coords (saved that way) - obj.position.set(childItem.data.x, childItem.data.y); groupContainer.addChild(obj); + // data.x/y are already local coords (saved that way) + if (childItem.type === 'image') { + applyImageDisplayTransform(obj, childItem.data as ImageObject); + } else { + obj.position.set(childItem.data.x, childItem.data.y); + } } } } diff --git a/frontend/src/canvas/imageTransforms.ts b/frontend/src/canvas/imageTransforms.ts new file mode 100644 index 0000000..9fe2e55 --- /dev/null +++ b/frontend/src/canvas/imageTransforms.ts @@ -0,0 +1,148 @@ +import type { Container } from 'pixi.js'; +import type { CropRect, ImageObject } from './scene-format'; + +export interface ImageDisplayTransform { + x: number; + y: number; + scaleX: number; + scaleY: number; + angle: number; +} + +export interface Point2D { + x: number; + y: number; +} + +export interface RectTransformData { + x: number; + y: number; + w: number; + h: number; + sx: number; + sy: number; + angle: number; +} + +/** + * Normalize legacy negative image scales into positive scale magnitude plus flip flags. + * This keeps image scene data canonical while preserving visual orientation. + */ +export function normalizeImageTransformData(data: ImageObject): void { + if (data.sx < 0) { + data.sx = Math.abs(data.sx); + data.flipX = !data.flipX; + } + if (data.sy < 0) { + data.sy = Math.abs(data.sy); + data.flipY = !data.flipY; + } +} + +/** + * Compute the actual Pixi display transform for an image from canonical scene data. + * Images render from a top-left local origin, so flip uses a compensating position + * shift to keep the visible unrotated bounds anchored at data.x/data.y. + */ +export function getImageDisplayTransform(data: Pick): ImageDisplayTransform { + const sx = Math.abs(data.sx); + const sy = Math.abs(data.sy); + const scaleX = sx * (data.flipX ? -1 : 1); + const scaleY = sy * (data.flipY ? -1 : 1); + return { + x: data.x + (data.flipX ? data.w * sx : 0), + y: data.y + (data.flipY ? data.h * sy : 0), + scaleX, + scaleY, + angle: data.angle, + }; +} + +function getVisibleLocalRect(data: Pick): { x: number; y: number; w: number; h: number } { + const crop = data.crop; + if (!crop) { + return { x: 0, y: 0, w: data.w, h: data.h }; + } + return { + x: crop.x * data.w, + y: crop.y * data.h, + w: crop.w * data.w, + h: crop.h * data.h, + }; +} + +export function applyImageDisplayTransform(displayObject: Container, data: Pick): void { + const t = getImageDisplayTransform(data); + displayObject.position.set(t.x, t.y); + displayObject.scale.set(t.scaleX, t.scaleY); + displayObject.angle = t.angle; +} + +function transformLocalPoint(data: Pick, localX: number, localY: number): Point2D { + const t = getImageDisplayTransform(data); + return transformPoint({ x: localX, y: localY }, { + x: t.x, + y: t.y, + sx: t.scaleX, + sy: t.scaleY, + angle: t.angle, + }); +} + +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.sx; + const dy = point.y * transform.sy; + return { + x: transform.x + dx * cos - dy * sin, + y: transform.y + dx * sin + dy * cos, + }; +} + +export function transformPoints(points: Point2D[], transform: Pick): Point2D[] { + return points.map((point) => transformPoint(point, transform)); +} + +export function getRectTransformedCorners(data: RectTransformData): Point2D[] { + return [ + transformPoint({ x: 0, y: 0 }, data), + transformPoint({ x: data.w, y: 0 }, data), + transformPoint({ x: data.w, y: data.h }, data), + transformPoint({ x: 0, y: data.h }, data), + ]; +} + +export function getBoundsFromPoints(points: Point2D[]): { x: number; y: number; w: number; h: number } { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const pt of points) { + if (pt.x < minX) minX = pt.x; + if (pt.y < minY) minY = pt.y; + if (pt.x > maxX) maxX = pt.x; + if (pt.y > maxY) maxY = pt.y; + } + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; +} + +export function offsetImageDataPosition(data: Pick, dx: number, dy: number): void { + data.x += dx; + data.y += dy; +} + +export function getImageTransformedCorners(data: Pick): Point2D[] { + const rect = getVisibleLocalRect(data); + return [ + transformLocalPoint(data, rect.x, rect.y), + transformLocalPoint(data, rect.x + rect.w, rect.y), + transformLocalPoint(data, rect.x + rect.w, rect.y + rect.h), + transformLocalPoint(data, rect.x, rect.y + rect.h), + ]; +} + +export function getImageWorldBounds(data: Pick): { x: number; y: number; w: number; h: number } { + return getBoundsFromPoints(getImageTransformedCorners(data)); +} diff --git a/frontend/src/canvas/operations.ts b/frontend/src/canvas/operations.ts index a586051..d6fba90 100644 --- a/frontend/src/canvas/operations.ts +++ b/frontend/src/canvas/operations.ts @@ -7,6 +7,7 @@ import type { SceneItem } from './SceneManager'; import type { ImageObject } from './scene-format'; import { ColorMatrixFilter } from 'pixi.js'; +import { applyImageDisplayTransform, getImageDisplayTransform } from './imageTransforms'; // ─── Helpers ─── @@ -60,11 +61,14 @@ function _animTick() { /** Sync displayObject position from item.data with smooth animation. */ function syncPosition(item: SceneItem): void { + const target = item.type === 'image' + ? getImageDisplayTransform(item.data as ImageObject) + : { x: item.data.x, y: item.data.y }; _animTargets.set(item, { startX: item.displayObject.x, startY: item.displayObject.y, - endX: item.data.x, - endY: item.data.y, + endX: target.x, + endY: target.y, t: 0, }); if (!_animRaf) { @@ -82,6 +86,12 @@ export function onArrangeAnimationDone(cb: (items: SceneItem[]) => void): void { /** Sync displayObject scale from item.data. */ function syncScale(item: SceneItem): void { + if (item.type === 'image') { + const t = getImageDisplayTransform(item.data as ImageObject); + item.displayObject.scale.set(t.scaleX, t.scaleY); + item.displayObject.angle = t.angle; + return; + } item.displayObject.scale.set(item.data.sx, item.data.sy); } @@ -358,14 +368,22 @@ function layoutAsGrid(sorted: SceneItem[], anchor: { x: number; y: number }) { export function flipHorizontal(objects: SceneItem[]) { objects.forEach((item) => { item.data.flipX = !item.data.flipX; - item.displayObject.scale.x = item.data.sx * (item.data.flipX ? -1 : 1); + if (item.type === 'image') { + applyImageDisplayTransform(item.displayObject, item.data as ImageObject); + } else { + item.displayObject.scale.x = item.data.sx * (item.data.flipX ? -1 : 1); + } }); } export function flipVertical(objects: SceneItem[]) { objects.forEach((item) => { item.data.flipY = !item.data.flipY; - item.displayObject.scale.y = item.data.sy * (item.data.flipY ? -1 : 1); + if (item.type === 'image') { + applyImageDisplayTransform(item.displayObject, item.data as ImageObject); + } else { + item.displayObject.scale.y = item.data.sy * (item.data.flipY ? -1 : 1); + } }); } @@ -446,7 +464,13 @@ export function scaleBy(objects: SceneItem[], factor: number) { export function rotate90(objects: SceneItem[], clockwise: boolean) { objects.forEach((item) => { item.data.angle = ((item.data.angle + (clockwise ? 90 : -90)) % 360 + 360) % 360; - item.displayObject.angle = item.data.angle; + if (item.type === 'image') { + const t = getImageDisplayTransform(item.data as ImageObject); + item.displayObject.angle = t.angle; + item.displayObject.scale.set(t.scaleX, t.scaleY); + } else { + item.displayObject.angle = item.data.angle; + } }); } diff --git a/frontend/src/canvas/sync.ts b/frontend/src/canvas/sync.ts index a52f202..52e8424 100644 --- a/frontend/src/canvas/sync.ts +++ b/frontend/src/canvas/sync.ts @@ -1,6 +1,7 @@ import type { Socket } from 'socket.io-client'; import type { SceneManager, SceneItem } from './SceneManager'; -import type { SceneData, AnySceneObject } from './scene-format'; +import type { SceneData, AnySceneObject, ImageObject } from './scene-format'; +import { applyImageDisplayTransform } from './imageTransforms'; /** * Sync protocol v3 — Excalidraw-inspired incremental element sync. @@ -225,9 +226,13 @@ export function setupSync( item.data.sy = t.sy; item.data.angle = t.angle; const obj = item.displayObject; - obj.position.set(t.x, t.y); - obj.scale.set(t.sx, t.sy); - obj.angle = t.angle; + if (item.type === 'image') { + applyImageDisplayTransform(obj, item.data as ImageObject); + } else { + obj.position.set(t.x, t.y); + obj.scale.set(t.sx, t.sy); + obj.angle = t.angle; + } options?.onRemoteTransform?.(item); } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index b5896e7..69192a3 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -493,6 +493,23 @@ export default function Editor({ isPublicView }: EditorProps) { } }, [showToast]); + const startCropForSelection = useCallback((showInvalidToast = false) => { + const selection = selectionRef.current; + if (!selection || !cropOverlayRef.current) return; + const items = selection.getSelectedItems(); + if (items.length !== 1 || items[0].type !== 'image') { + 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); + }, [showToast, cropOverlayRef]); + // Keyboard shortcuts useShortcutHandler({ canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId, @@ -500,17 +517,7 @@ export default function Editor({ isPublicView }: EditorProps) { handleGroup, handleUngroup, setActiveTool, setCanUndo, setCanRedo, setZoom, setShowGrid, setShowHelp, setFocusMode, setReviewMode, - startCrop: () => { - const selection = selectionRef.current; - if (!selection || !cropOverlayRef.current) return; - const items = selection.getSelectedItems(); - if (items.length !== 1 || items[0].type !== 'image') { - showToast('Select a single image to crop'); - return; - } - selection.setEnabled(false); - cropOverlayRef.current.start(items[0]); - }, + startCrop: () => startCropForSelection(true), }); // Context menu @@ -531,16 +538,9 @@ export default function Editor({ isPublicView }: EditorProps) { handleGroup, handleUngroup, fitAll: () => canvasRef.current?.fitAll(), - startCrop: () => { - const selection = selectionRef.current; - if (!selection || !cropOverlayRef.current) return; - const items = selection.getSelectedItems(); - if (items.length !== 1 || items[0].type !== 'image') return; - selection.setEnabled(false); - cropOverlayRef.current.start(items[0]); - }, + startCrop: () => startCropForSelection(false), }); - }, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, refreshLayers]); + }, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, refreshLayers, startCropForSelection]); // Save on page unload (only for users with edit access) useEffect(() => {