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;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user