refboard: stabilize image flip and crop transforms

This commit is contained in:
Hiren Kangad
2026-03-13 11:21:27 +05:30
parent c40304464f
commit 12410c0098
11 changed files with 359 additions and 98 deletions
+32 -6
View File
@@ -132,6 +132,7 @@ export class CropOverlay extends Container {
if (!this._item) return; if (!this._item) return;
const data = this._item.data; const data = this._item.data;
const zoom = this._viewport.scale.x; const zoom = this._viewport.scale.x;
const viewCrop = this._getViewCrop();
// Image world rect // Image world rect
const ix = data.x; const ix = data.x;
@@ -140,10 +141,10 @@ export class CropOverlay extends Container {
const ih = data.h * data.sy; const ih = data.h * data.sy;
// Crop rect in world space // Crop rect in world space
const cx = ix + this._crop.x * iw; const cx = ix + viewCrop.x * iw;
const cy = iy + this._crop.y * ih; const cy = iy + viewCrop.y * ih;
const cw = this._crop.w * iw; const cw = viewCrop.w * iw;
const ch = this._crop.h * ih; const ch = viewCrop.h * ih;
// Dim: draw 4 rectangles around the crop area // Dim: draw 4 rectangles around the crop area
this._dim.clear(); this._dim.clear();
@@ -210,7 +211,7 @@ export class CropOverlay extends Container {
private _onDown(e: FederatedPointerEvent, id: HandleId): void { private _onDown(e: FederatedPointerEvent, id: HandleId): void {
e.stopPropagation(); 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) // Register move/up on the stage (single handler, not per-handle)
this._removeDragListeners(); this._removeDragListeners();
@@ -288,7 +289,7 @@ export class CropOverlay extends Container {
break; break;
} }
this._crop = crop; this._setViewCrop(crop);
this._draw(); this._draw();
} }
@@ -296,4 +297,29 @@ export class CropOverlay extends Container {
this._drag = null; this._drag = null;
this._removeDragListeners(); 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,
};
}
} }
+55 -26
View File
@@ -18,6 +18,15 @@ import { TextSprite } from './sprites/TextSprite';
import { SpringManager, Spring, PRESETS } from './spring'; import { SpringManager, Spring, PRESETS } from './spring';
import { reparentGroupChildren } from './grouping'; import { reparentGroupChildren } from './grouping';
import { SpatialGrid } from './SpatialGrid'; import { SpatialGrid } from './SpatialGrid';
import {
applyImageDisplayTransform,
getBoundsFromPoints,
getImageTransformedCorners,
getImageWorldBounds,
getRectTransformedCorners,
normalizeImageTransformData,
transformPoints,
} from './imageTransforms';
import type { import type {
SceneData, SceneData,
AnySceneObject, AnySceneObject,
@@ -72,9 +81,10 @@ export function isGroupChild(id: string): boolean {
} }
/** Single source of truth for an item's world-space bounding rect. /** 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 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 } { export function getItemWorldBounds(item: SceneItem): { x: number; y: number; w: number; h: number } {
if (item.data.type === 'group') { if (item.data.type === 'group') {
return _getGroupWorldBounds(item); 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 // If this item is a child of a group, convert local → world
const parent = item.displayObject.parent; const parent = item.displayObject.parent;
if (parent && parent.label && _groupChildIds?.has(item.id)) { if (parent?.label && _groupChildIds?.has(item.id)) {
const px = parent.position.x; const parentTransform = {
const py = parent.position.y; x: parent.position.x,
const psx = parent.scale.x; y: parent.position.y,
const psy = parent.scale.y; sx: parent.scale.x,
return { sy: parent.scale.y,
x: px + item.data.x * psx, angle: parent.angle,
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),
}; };
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 { return {
@@ -296,6 +311,7 @@ export class SceneManager {
switch (data.type) { switch (data.type) {
case 'image': { case 'image': {
const imgData = data as ImageObject; const imgData = data as ImageObject;
normalizeImageTransformData(imgData);
if (isGifAsset(imgData.asset)) { if (isGifAsset(imgData.asset)) {
displayObject = new AnimatedGifSprite(imgData.asset, imgData.w, imgData.h, this.textures); displayObject = new AnimatedGifSprite(imgData.asset, imgData.w, imgData.h, this.textures);
} else { } else {
@@ -357,9 +373,13 @@ export class SceneManager {
} }
// Common properties // Common properties
displayObject.position.set(data.x, data.y); if (data.type === 'image') {
displayObject.scale.set(data.sx, data.sy); applyImageDisplayTransform(displayObject, data as ImageObject);
displayObject.angle = data.angle; } else {
displayObject.position.set(data.x, data.y);
displayObject.scale.set(data.sx, data.sy);
displayObject.angle = data.angle;
}
displayObject.alpha = data.opacity; displayObject.alpha = data.opacity;
displayObject.visible = data.visible; displayObject.visible = data.visible;
displayObject.eventMode = data.locked ? 'none' : 'static'; displayObject.eventMode = data.locked ? 'none' : 'static';
@@ -401,9 +421,14 @@ export class SceneManager {
_updateItem(item: SceneItem, data: AnySceneObject): void { _updateItem(item: SceneItem, data: AnySceneObject): void {
const obj = item.displayObject; const obj = item.displayObject;
obj.position.set(data.x, data.y); if (data.type === 'image') {
obj.scale.set(data.sx, data.sy); normalizeImageTransformData(data as ImageObject);
obj.angle = data.angle; 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.alpha = data.opacity;
obj.visible = data.visible; obj.visible = data.visible;
obj.eventMode = data.locked ? 'none' : 'static'; obj.eventMode = data.locked ? 'none' : 'static';
@@ -532,19 +557,23 @@ export class SceneManager {
this.spatialGrid.remove(id); this.spatialGrid.remove(id);
this.items.delete(id); this.items.delete(id);
// Scale toward center on removal // Scale toward center on removal.
const halfW = item.data.w * item.data.sx / 2; const startX = obj.x;
const halfH = item.data.h * item.data.sy / 2; const startY = obj.y;
const startX = item.data.x; const startScaleX = obj.scale.x;
const startY = item.data.y; 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); const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy);
scaleSpring.onUpdate = (v) => { scaleSpring.onUpdate = (v) => {
if (!obj.destroyed) { 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( obj.position.set(
startX + halfW * (1 - v), startX + halfW * (1 - v) * dirX,
startY + halfH * (1 - v), startY + halfH * (1 - v) * dirY,
); );
} }
}; };
+10 -4
View File
@@ -12,6 +12,8 @@ import { TransformBox } from './TransformBox';
import { SnapGuides } from './SnapGuides'; import { SnapGuides } from './SnapGuides';
import { ImageSprite } from './sprites/ImageSprite'; import { ImageSprite } from './sprites/ImageSprite';
import { VideoSprite } from './sprites/VideoSprite'; import { VideoSprite } from './sprites/VideoSprite';
import type { ImageObject } from './scene-format';
import { applyImageDisplayTransform } from './imageTransforms';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -289,10 +291,14 @@ export class SelectionManager {
// Move all selected items by corrected delta and broadcast all together // Move all selected items by corrected delta and broadcast all together
for (const item of selected) { for (const item of selected) {
item.displayObject.x += ddx; item.data.x += ddx;
item.displayObject.y += ddy; item.data.y += ddy;
item.data.x = item.displayObject.x; if (item.type === 'image') {
item.data.y = item.displayObject.y; applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
} else {
item.displayObject.x = item.data.x;
item.displayObject.y = item.data.y;
}
} }
if (this._onItemsTransform) { if (this._onItemsTransform) {
this._onItemsTransform(selected); this._onItemsTransform(selected);
+8 -3
View File
@@ -10,6 +10,8 @@ import { Container, Graphics, FederatedPointerEvent, Text, TextStyle } from 'pix
import type { Viewport } from 'pixi-viewport'; import type { Viewport } from 'pixi-viewport';
import { type SceneItem, getItemWorldBounds } from './SceneManager'; import { type SceneItem, getItemWorldBounds } from './SceneManager';
import type { SnapGuides } from './SnapGuides'; import type { SnapGuides } from './SnapGuides';
import { applyImageDisplayTransform } from './imageTransforms';
import type { ImageObject } from './scene-format';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -347,9 +349,12 @@ export class TransformBox extends Container {
item.data.sx = orig.sx * fx; item.data.sx = orig.sx * fx;
item.data.sy = orig.sy * fy; item.data.sy = orig.sy * fy;
item.displayObject.scale.set(item.data.sx, item.data.sy); if (item.type === 'image') {
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
item.displayObject.position.set(item.data.x, item.data.y); } else {
item.displayObject.scale.set(item.data.sx, item.data.sy);
item.displayObject.position.set(item.data.x, item.data.y);
}
this._onItemTransform?.(item); this._onItemTransform?.(item);
} }
+12 -11
View File
@@ -53,32 +53,32 @@ export async function writeCanvasToClipboard(
y: number; y: number;
sx: number; sx: number;
sy: 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<string, { x: number; y: number; w: number; h: number }>();
for (const item of items) {
worldBoundsMap.set(item.id, getItemWorldBounds(item));
}
for (const item of items) { for (const item of items) {
const obj = item.displayObject; const obj = item.displayObject;
const globalOrigin = obj.parent?.toGlobal(obj.position) ?? obj.position;
saved.set(item.id, { saved.set(item.id, {
parent: obj.parent as Container, parent: obj.parent as Container,
x: obj.x, x: obj.x,
y: obj.y, y: obj.y,
sx: obj.scale.x, sx: obj.scale.x,
sy: obj.scale.y, sy: obj.scale.y,
angle: obj.angle,
globalX: globalOrigin.x,
globalY: globalOrigin.y,
}); });
// Remove from current parent // Remove from current parent
obj.parent?.removeChild(obj); obj.parent?.removeChild(obj);
// Position using world bounds (correct for both top-level and group children) const s = saved.get(item.id)!;
const wb = worldBoundsMap.get(item.id)!; obj.position.set(s.globalX - minX + pad, s.globalY - minY + pad);
obj.position.set(wb.x - minX + pad, wb.y - minY + pad); obj.scale.set(s.sx, s.sy);
// Use data scale (not viewport-affected scale) obj.angle = s.angle;
obj.scale.set(item.data.sx, item.data.sy);
tempContainer.addChild(obj); tempContainer.addChild(obj);
} }
@@ -111,6 +111,7 @@ export async function writeCanvasToClipboard(
s.parent.addChild(item.displayObject); s.parent.addChild(item.displayObject);
item.displayObject.position.set(s.x, s.y); item.displayObject.position.set(s.x, s.y);
item.displayObject.scale.set(s.sx, s.sy); item.displayObject.scale.set(s.sx, s.sy);
item.displayObject.angle = s.angle;
} }
tempContainer.destroy(); tempContainer.destroy();
texture?.destroy(true); texture?.destroy(true);
+10 -10
View File
@@ -44,24 +44,23 @@ function renderToCanvas(
// Reparent items into temp container // Reparent items into temp container
const tempContainer = new Container(); const tempContainer = new Container();
const saved = new Map<string, { parent: Container; x: number; y: number; sx: number; sy: number }>(); const saved = new Map<string, { parent: Container; x: number; y: number; sx: number; sy: number; angle: number; globalX: number; globalY: number }>();
const worldBoundsMap = new Map<string, { x: number; y: number; w: number; h: number }>();
for (const item of items) {
worldBoundsMap.set(item.id, getItemWorldBounds(item));
}
for (const item of items) { for (const item of items) {
const obj = item.displayObject; const obj = item.displayObject;
const globalOrigin = obj.parent?.toGlobal(obj.position) ?? obj.position;
saved.set(item.id, { saved.set(item.id, {
parent: obj.parent as Container, parent: obj.parent as Container,
x: obj.x, y: obj.y, x: obj.x, y: obj.y,
sx: obj.scale.x, sy: obj.scale.y, sx: obj.scale.x, sy: obj.scale.y,
angle: obj.angle,
globalX: globalOrigin.x,
globalY: globalOrigin.y,
}); });
obj.parent?.removeChild(obj); obj.parent?.removeChild(obj);
const wb = worldBoundsMap.get(item.id)!; const s = saved.get(item.id)!;
obj.position.set(wb.x - minX + pad, wb.y - minY + pad); obj.position.set(s.globalX - minX + pad, s.globalY - minY + pad);
obj.scale.set(item.data.sx, item.data.sy); obj.scale.set(s.sx, s.sy);
obj.angle = s.angle;
tempContainer.addChild(obj); tempContainer.addChild(obj);
} }
@@ -91,6 +90,7 @@ function renderToCanvas(
s.parent.addChild(item.displayObject); s.parent.addChild(item.displayObject);
item.displayObject.position.set(s.x, s.y); item.displayObject.position.set(s.x, s.y);
item.displayObject.scale.set(s.sx, s.sy); item.displayObject.scale.set(s.sx, s.sy);
item.displayObject.angle = s.angle;
} }
tempContainer.destroy(); tempContainer.destroy();
texture?.destroy(true); texture?.destroy(true);
+26 -9
View File
@@ -12,8 +12,9 @@ import type { Viewport } from 'pixi-viewport';
import type { SceneManager, SceneItem } from './SceneManager'; import type { SceneManager, SceneItem } from './SceneManager';
import { getItemWorldBounds, rebuildGroupChildSet } from './SceneManager'; import { getItemWorldBounds, rebuildGroupChildSet } from './SceneManager';
import type { SelectionManager } from './SelectionManager'; import type { SelectionManager } from './SelectionManager';
import type { GroupObject } from './scene-format'; import type { GroupObject, ImageObject } from './scene-format';
import { randomFrameColor } from './sprites/FrameSprite'; import { randomFrameColor } from './sprites/FrameSprite';
import { applyImageDisplayTransform, transformPoint } from './imageTransforms';
/** /**
* Group selected items into a single group container. * Group selected items into a single group container.
@@ -84,8 +85,12 @@ export function groupItems(
// Reparent display object // Reparent display object
obj.parent?.removeChild(obj); obj.parent?.removeChild(obj);
obj.position.set(localX, localY);
groupItem.displayObject.addChild(obj); groupItem.displayObject.addChild(obj);
if (child.type === 'image') {
applyImageDisplayTransform(obj, child.data as ImageObject);
} else {
obj.position.set(localX, localY);
}
} }
selection.clear(); selection.clear();
@@ -128,8 +133,12 @@ export function ungroupItems(
const obj = childItem.displayObject; const obj = childItem.displayObject;
// Convert local position to world space, accounting for group scale // Convert local position to world space, accounting for group scale
const worldX = groupX + childItem.data.x * groupSx; const worldOrigin = transformPoint(
const worldY = groupY + childItem.data.y * groupSy; { 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 // Propagate group scale to child
childItem.data.sx *= groupSx; childItem.data.sx *= groupSx;
@@ -145,9 +154,13 @@ export function ungroupItems(
// Reparent display object // Reparent display object
obj.parent?.removeChild(obj); obj.parent?.removeChild(obj);
viewport.addChild(obj); viewport.addChild(obj);
obj.position.set(worldX, worldY); if (childItem.type === 'image') {
obj.scale.set(childItem.data.sx, childItem.data.sy); applyImageDisplayTransform(obj, childItem.data as ImageObject);
obj.angle = childItem.data.angle; } else {
obj.position.set(worldX, worldY);
obj.scale.set(childItem.data.sx, childItem.data.sy);
obj.angle = childItem.data.angle;
}
childIds.push(childId); childIds.push(childId);
} }
@@ -188,9 +201,13 @@ export function reparentGroupChildren(scene: SceneManager): void {
// Only reparent if currently in viewport (not already in a group) // Only reparent if currently in viewport (not already in a group)
if (obj.parent !== groupContainer) { if (obj.parent !== groupContainer) {
obj.parent?.removeChild(obj); 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); 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);
}
} }
} }
} }
+148
View File
@@ -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<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY'>): 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<ImageObject, 'w' | 'h' | 'crop'>): { 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<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY'>): 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<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY'>, 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<RectTransformData, 'x' | 'y' | 'sx' | 'sy' | 'angle'>): 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<RectTransformData, 'x' | 'y' | 'sx' | 'sy' | 'angle'>): 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<ImageObject, 'x' | 'y'>, dx: number, dy: number): void {
data.x += dx;
data.y += dy;
}
export function getImageTransformedCorners(data: Pick<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): 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<ImageObject, 'x' | 'y' | 'w' | 'h' | 'sx' | 'sy' | 'angle' | 'flipX' | 'flipY' | 'crop'>): { x: number; y: number; w: number; h: number } {
return getBoundsFromPoints(getImageTransformedCorners(data));
}
+29 -5
View File
@@ -7,6 +7,7 @@
import type { SceneItem } from './SceneManager'; import type { SceneItem } from './SceneManager';
import type { ImageObject } from './scene-format'; import type { ImageObject } from './scene-format';
import { ColorMatrixFilter } from 'pixi.js'; import { ColorMatrixFilter } from 'pixi.js';
import { applyImageDisplayTransform, getImageDisplayTransform } from './imageTransforms';
// ─── Helpers ─── // ─── Helpers ───
@@ -60,11 +61,14 @@ function _animTick() {
/** Sync displayObject position from item.data with smooth animation. */ /** Sync displayObject position from item.data with smooth animation. */
function syncPosition(item: SceneItem): void { 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, { _animTargets.set(item, {
startX: item.displayObject.x, startX: item.displayObject.x,
startY: item.displayObject.y, startY: item.displayObject.y,
endX: item.data.x, endX: target.x,
endY: item.data.y, endY: target.y,
t: 0, t: 0,
}); });
if (!_animRaf) { if (!_animRaf) {
@@ -82,6 +86,12 @@ export function onArrangeAnimationDone(cb: (items: SceneItem[]) => void): void {
/** Sync displayObject scale from item.data. */ /** Sync displayObject scale from item.data. */
function syncScale(item: SceneItem): void { 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); 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[]) { export function flipHorizontal(objects: SceneItem[]) {
objects.forEach((item) => { objects.forEach((item) => {
item.data.flipX = !item.data.flipX; 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[]) { export function flipVertical(objects: SceneItem[]) {
objects.forEach((item) => { objects.forEach((item) => {
item.data.flipY = !item.data.flipY; 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) { export function rotate90(objects: SceneItem[], clockwise: boolean) {
objects.forEach((item) => { objects.forEach((item) => {
item.data.angle = ((item.data.angle + (clockwise ? 90 : -90)) % 360 + 360) % 360; 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;
}
}); });
} }
+9 -4
View File
@@ -1,6 +1,7 @@
import type { Socket } from 'socket.io-client'; import type { Socket } from 'socket.io-client';
import type { SceneManager, SceneItem } from './SceneManager'; 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. * Sync protocol v3 — Excalidraw-inspired incremental element sync.
@@ -225,9 +226,13 @@ export function setupSync(
item.data.sy = t.sy; item.data.sy = t.sy;
item.data.angle = t.angle; item.data.angle = t.angle;
const obj = item.displayObject; const obj = item.displayObject;
obj.position.set(t.x, t.y); if (item.type === 'image') {
obj.scale.set(t.sx, t.sy); applyImageDisplayTransform(obj, item.data as ImageObject);
obj.angle = t.angle; } else {
obj.position.set(t.x, t.y);
obj.scale.set(t.sx, t.sy);
obj.angle = t.angle;
}
options?.onRemoteTransform?.(item); options?.onRemoteTransform?.(item);
} }
+20 -20
View File
@@ -493,6 +493,23 @@ export default function Editor({ isPublicView }: EditorProps) {
} }
}, [showToast]); }, [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 // Keyboard shortcuts
useShortcutHandler({ useShortcutHandler({
canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId, canvasRef, selectionRef, undoRef, clipboardRef, resolvedBoardId,
@@ -500,17 +517,7 @@ export default function Editor({ isPublicView }: EditorProps) {
handleGroup, handleUngroup, handleGroup, handleUngroup,
setActiveTool, setCanUndo, setCanRedo, setZoom, setActiveTool, setCanUndo, setCanRedo, setZoom,
setShowGrid, setShowHelp, setFocusMode, setReviewMode, setShowGrid, setShowHelp, setFocusMode, setReviewMode,
startCrop: () => { startCrop: () => startCropForSelection(true),
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]);
},
}); });
// Context menu // Context menu
@@ -531,16 +538,9 @@ export default function Editor({ isPublicView }: EditorProps) {
handleGroup, handleGroup,
handleUngroup, handleUngroup,
fitAll: () => canvasRef.current?.fitAll(), fitAll: () => canvasRef.current?.fitAll(),
startCrop: () => { startCrop: () => startCropForSelection(false),
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]);
},
}); });
}, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, refreshLayers]); }, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, refreshLayers, startCropForSelection]);
// Save on page unload (only for users with edit access) // Save on page unload (only for users with edit access)
useEffect(() => { useEffect(() => {