refboard: stabilize image flip and crop transforms
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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;
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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) {
|
||||
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);
|
||||
|
||||
@@ -44,24 +44,23 @@ function renderToCanvas(
|
||||
|
||||
// Reparent items into temp container
|
||||
const tempContainer = new Container();
|
||||
const saved = new Map<string, { parent: Container; x: number; y: number; sx: number; sy: 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));
|
||||
}
|
||||
|
||||
const saved = new Map<string, { parent: Container; x: number; y: number; sx: number; sy: number; angle: number; globalX: number; globalY: number }>();
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user