feat(refboard): canvas polish — transform box sync, dot grid, snap tuning, perf fixes
- Fix transform box not updating after alignment/arrangement/normalize/flip operations (shortcuts, context menu, and selection toolbar all fixed with DRY _opUpdate helper) - Replace 100ms polling loop with event-driven viewport updates (moved + wheel-scroll) - Add adaptive dot grid background that responds to zoom/pan - Reduce snap guide threshold from 8px to 4px for subtler snapping - Remove PresenceOverlay (remote selection highlighting) — too heavy for minimal benefit - Offset multiple dropped images so they don't overlap - Add new canvas modules: SnapGuides, clipboard, grouping, FrameSprite, DrawingSprite, LaserPointer, context-menu-items, SelectionToolbar, Minimap - Extract Editor hooks into dedicated files (useBoardLoader, useCanvasSetup, useShortcutHandler, useLayerPanel, useSaveManager, useFollowMode) - Sync improvements: real-time transform broadcast, board rooms, viewport sync
This commit is contained in:
@@ -141,8 +141,8 @@ export class InboxZone extends Container {
|
||||
sprite.width = sw;
|
||||
sprite.height = sh;
|
||||
|
||||
// Load thumbnail texture
|
||||
this.textures.load(assetKey, 'thumb').then((tex) => {
|
||||
// Load texture (GPU handles scaling)
|
||||
this.textures.load(assetKey).then((tex) => {
|
||||
if (!sprite.destroyed) {
|
||||
sprite.texture = tex;
|
||||
sprite.width = sw;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
|
||||
interface LaserPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
t: number;
|
||||
}
|
||||
|
||||
export class LaserPointer {
|
||||
private _gfx: Graphics;
|
||||
private _viewport: Viewport;
|
||||
private _points: LaserPoint[] = [];
|
||||
private _active = false;
|
||||
private _rafId: number | null = null;
|
||||
private _color: number;
|
||||
private _fadeMs = 2000;
|
||||
private _maxPoints = 200;
|
||||
|
||||
// Remote user lasers
|
||||
private _remoteGfx: Map<string, Graphics> = new Map();
|
||||
private _remotePoints: Map<string, LaserPoint[]> = new Map();
|
||||
|
||||
constructor(viewport: Viewport, color: number = 0xff4444) {
|
||||
this._viewport = viewport;
|
||||
this._color = color;
|
||||
this._gfx = new Graphics();
|
||||
this._gfx.label = '__laser_local';
|
||||
viewport.addChild(this._gfx);
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
/** Begin recording trail, start render loop */
|
||||
start(): void {
|
||||
this._active = true;
|
||||
if (this._rafId === null) {
|
||||
this._tick();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop recording, let trail fade naturally, stop loop when empty */
|
||||
stop(): void {
|
||||
this._active = false;
|
||||
// Render loop continues until all points have faded
|
||||
}
|
||||
|
||||
/** Add a local laser point with current timestamp */
|
||||
addPoint(worldX: number, worldY: number): void {
|
||||
this._points.push({ x: worldX, y: worldY, t: performance.now() });
|
||||
if (this._points.length > this._maxPoints) {
|
||||
this._points.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/** Add points from a remote user's laser */
|
||||
addRemotePoints(
|
||||
userId: string,
|
||||
points: { x: number; y: number }[],
|
||||
color: number,
|
||||
): void {
|
||||
const now = performance.now();
|
||||
|
||||
if (!this._remoteGfx.has(userId)) {
|
||||
const gfx = new Graphics();
|
||||
gfx.label = `__laser_remote_${userId}`;
|
||||
this._viewport.addChild(gfx);
|
||||
this._remoteGfx.set(userId, gfx);
|
||||
}
|
||||
|
||||
let arr = this._remotePoints.get(userId);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
this._remotePoints.set(userId, arr);
|
||||
}
|
||||
|
||||
// Store color on the graphics object for rendering
|
||||
const gfx = this._remoteGfx.get(userId)!;
|
||||
(gfx as any)._laserColor = color;
|
||||
|
||||
for (const p of points) {
|
||||
arr.push({ x: p.x, y: p.y, t: now });
|
||||
}
|
||||
if (arr.length > this._maxPoints) {
|
||||
arr.splice(0, arr.length - this._maxPoints);
|
||||
}
|
||||
|
||||
// Ensure render loop is running
|
||||
if (this._rafId === null) {
|
||||
this._tick();
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a remote user's laser entirely */
|
||||
removeRemote(userId: string): void {
|
||||
const gfx = this._remoteGfx.get(userId);
|
||||
if (gfx) {
|
||||
gfx.clear();
|
||||
gfx.destroy();
|
||||
this._remoteGfx.delete(userId);
|
||||
}
|
||||
this._remotePoints.delete(userId);
|
||||
}
|
||||
|
||||
/** rAF-driven render loop */
|
||||
private _tick = (): void => {
|
||||
this._render();
|
||||
|
||||
// Check if there's anything left to draw
|
||||
const hasLocal = this._points.length > 0;
|
||||
let hasRemote = false;
|
||||
for (const [, pts] of this._remotePoints) {
|
||||
if (pts.length > 0) {
|
||||
hasRemote = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLocal || hasRemote || this._active) {
|
||||
this._rafId = requestAnimationFrame(this._tick);
|
||||
} else {
|
||||
this._rafId = null;
|
||||
}
|
||||
};
|
||||
|
||||
private _render(): void {
|
||||
const now = performance.now();
|
||||
const scale = this._viewport.scale.x || 1;
|
||||
const lineWidth = 3 / scale;
|
||||
|
||||
// --- Local laser ---
|
||||
this._prunePoints(this._points, now);
|
||||
this._drawTrail(this._gfx, this._points, this._color, lineWidth, now);
|
||||
|
||||
// --- Remote lasers ---
|
||||
for (const [userId, pts] of this._remotePoints) {
|
||||
this._prunePoints(pts, now);
|
||||
const gfx = this._remoteGfx.get(userId);
|
||||
if (!gfx) continue;
|
||||
const color = (gfx as any)._laserColor ?? 0xff4444;
|
||||
this._drawTrail(gfx, pts, color, lineWidth, now);
|
||||
|
||||
// Clean up empty remote lasers
|
||||
if (pts.length === 0) {
|
||||
gfx.clear();
|
||||
// Don't destroy — they may send more points
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _prunePoints(points: LaserPoint[], now: number): void {
|
||||
while (points.length > 0 && now - points[0].t > this._fadeMs) {
|
||||
points.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private _drawTrail(
|
||||
gfx: Graphics,
|
||||
points: LaserPoint[],
|
||||
color: number,
|
||||
lineWidth: number,
|
||||
now: number,
|
||||
): void {
|
||||
gfx.clear();
|
||||
if (points.length < 2) return;
|
||||
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const p0 = points[i - 1];
|
||||
const p1 = points[i];
|
||||
const age = now - p1.t;
|
||||
const alpha = Math.max(0, 1 - age / this._fadeMs);
|
||||
if (alpha <= 0) continue;
|
||||
|
||||
gfx.setStrokeStyle({ width: lineWidth, color, alpha, cap: 'round', join: 'round' });
|
||||
gfx.moveTo(p0.x, p0.y);
|
||||
gfx.lineTo(p1.x, p1.y);
|
||||
gfx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up all resources */
|
||||
destroy(): void {
|
||||
if (this._rafId !== null) {
|
||||
cancelAnimationFrame(this._rafId);
|
||||
this._rafId = null;
|
||||
}
|
||||
this._active = false;
|
||||
this._points.length = 0;
|
||||
|
||||
this._gfx.clear();
|
||||
this._gfx.destroy();
|
||||
|
||||
for (const [, gfx] of this._remoteGfx) {
|
||||
gfx.clear();
|
||||
gfx.destroy();
|
||||
}
|
||||
this._remoteGfx.clear();
|
||||
this._remotePoints.clear();
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Application } from 'pixi.js';
|
||||
import { Viewport } from 'pixi-viewport';
|
||||
import { SceneManager } from './SceneManager';
|
||||
import { SceneManager, getItemWorldBounds } from './SceneManager';
|
||||
import { TextureManager } from './TextureManager';
|
||||
import { SpringManager } from './spring';
|
||||
import { convertFabricToV2 } from './scene-format';
|
||||
@@ -28,13 +29,14 @@ import type { SceneData } from './scene-format';
|
||||
export interface PixiCanvasHandle {
|
||||
getViewport: () => Viewport | null;
|
||||
getScene: () => SceneManager | null;
|
||||
getApp: () => Application | null;
|
||||
fitAll: () => void;
|
||||
getZoom: () => number;
|
||||
setZoom: (zoom: number) => void;
|
||||
}
|
||||
|
||||
export interface PixiCanvasProps {
|
||||
canvasState?: string | null;
|
||||
canvasState?: string | object | null;
|
||||
currentTool: string;
|
||||
boardId?: string;
|
||||
onChange?: () => void;
|
||||
@@ -99,6 +101,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
const initialLoadDone = useRef(false);
|
||||
const spaceHeld = useRef(false);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const [pixiReady, setPixiReady] = useState(false);
|
||||
|
||||
// Keep onChange ref current without re-running effects
|
||||
onChangeRef.current = onChange;
|
||||
@@ -121,6 +124,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
antialias: true,
|
||||
autoDensity: true,
|
||||
resolution: window.devicePixelRatio,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
|
||||
if (destroyed) {
|
||||
@@ -160,7 +164,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
springs.tick(ticker.deltaMS / 1000);
|
||||
});
|
||||
|
||||
// -- Culling + LOD ticker (runs every 200ms, not every frame) ------
|
||||
// -- Visibility culling ticker (runs every 200ms, not every frame) --
|
||||
|
||||
let lastCullCheck = 0;
|
||||
app.ticker.add((ticker) => {
|
||||
@@ -168,29 +172,18 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
if (lastCullCheck < 200) return;
|
||||
lastCullCheck = 0;
|
||||
|
||||
const zoom = viewport.scale.x;
|
||||
const bounds = viewport.getVisibleBounds();
|
||||
const margin = 200;
|
||||
|
||||
for (const item of scene.getAllItems()) {
|
||||
const d = item.displayObject;
|
||||
const ib = d.getBounds();
|
||||
const inView =
|
||||
ib.x + ib.width > bounds.x - margin &&
|
||||
ib.x < bounds.x + bounds.width + margin &&
|
||||
ib.y + ib.height > bounds.y - margin &&
|
||||
ib.y < bounds.y + bounds.height + margin;
|
||||
|
||||
if (item.type === 'image' && 'updateLOD' in d) {
|
||||
if (inView) {
|
||||
(d as any).updateLOD(zoom);
|
||||
d.visible = true;
|
||||
} else {
|
||||
d.visible = false;
|
||||
}
|
||||
}
|
||||
if (item.type === 'video' && 'onVisibilityChange' in d) {
|
||||
(d as any).onVisibilityChange(inView);
|
||||
if (item.type === 'video' && 'onVisibilityChange' in item.displayObject) {
|
||||
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||
const inView =
|
||||
ix + iw > bounds.x - margin &&
|
||||
ix < bounds.x + bounds.width + margin &&
|
||||
iy + ih > bounds.y - margin &&
|
||||
iy < bounds.y + bounds.height + margin;
|
||||
(item.displayObject as any).onVisibilityChange(inView);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -230,6 +223,18 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
|
||||
// Store observer for cleanup
|
||||
(container as any).__pixiRO = ro;
|
||||
|
||||
// Signal that PixiJS is ready for scene loading
|
||||
setPixiReady(true);
|
||||
|
||||
// -- Prevent Ctrl+wheel browser zoom on canvas -----------------------
|
||||
const preventBrowserZoom = (e: WheelEvent) => {
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
container.addEventListener('wheel', preventBrowserZoom, { passive: false });
|
||||
(container as any).__pixiWheelHandler = preventBrowserZoom;
|
||||
})();
|
||||
|
||||
// -- Cleanup ---------------------------------------------------------
|
||||
@@ -243,6 +248,12 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
delete (container as any).__pixiRO;
|
||||
}
|
||||
|
||||
const wheelHandler = (container as any).__pixiWheelHandler;
|
||||
if (wheelHandler) {
|
||||
container.removeEventListener('wheel', wheelHandler);
|
||||
delete (container as any).__pixiWheelHandler;
|
||||
}
|
||||
|
||||
textures.clear();
|
||||
|
||||
if (appRef.current) {
|
||||
@@ -263,11 +274,16 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
if (initialLoadDone.current) return;
|
||||
if (!canvasState || !sceneRef.current) return;
|
||||
|
||||
// canvasState may be a JSON string or already-parsed object
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(canvasState);
|
||||
} catch {
|
||||
return;
|
||||
if (typeof canvasState === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(canvasState);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
parsed = canvasState;
|
||||
}
|
||||
|
||||
let sceneData: SceneData;
|
||||
@@ -279,7 +295,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
|
||||
initialLoadDone.current = true;
|
||||
sceneRef.current.loadScene(sceneData, false);
|
||||
}, [canvasState]);
|
||||
}, [canvasState, pixiReady]);
|
||||
|
||||
// ── Space key for pan mode ────────────────────────────────────────
|
||||
|
||||
@@ -337,12 +353,11 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const item of items) {
|
||||
const obj = item.displayObject;
|
||||
const bounds = obj.getBounds();
|
||||
if (bounds.x < minX) minX = bounds.x;
|
||||
if (bounds.y < minY) minY = bounds.y;
|
||||
if (bounds.x + bounds.width > maxX) maxX = bounds.x + bounds.width;
|
||||
if (bounds.y + bounds.height > maxY) maxY = bounds.y + bounds.height;
|
||||
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||
if (ix < minX) minX = ix;
|
||||
if (iy < minY) minY = iy;
|
||||
if (ix + iw > maxX) maxX = ix + iw;
|
||||
if (iy + ih > maxY) maxY = iy + ih;
|
||||
}
|
||||
|
||||
const padding = 40;
|
||||
@@ -368,6 +383,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
() => ({
|
||||
getViewport: () => viewportRef.current,
|
||||
getScene: () => sceneRef.current,
|
||||
getApp: () => appRef.current,
|
||||
fitAll,
|
||||
getZoom: () => viewportRef.current?.scale.x ?? 1,
|
||||
setZoom: (zoom: number) => {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* PresenceOverlay — renders colored selection borders for remote users.
|
||||
*
|
||||
* When another user selects items on the collaborative whiteboard, this overlay
|
||||
* draws thin colored outlines around those items on the local screen, with a
|
||||
* name-label pill above the first selected item.
|
||||
*/
|
||||
|
||||
import { Container, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { SceneManager } from './SceneManager';
|
||||
import { getItemWorldBounds } from './SceneManager';
|
||||
|
||||
interface RemoteSelection {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
color: number; // hex color, e.g. 0xff6600
|
||||
itemIds: string[];
|
||||
}
|
||||
|
||||
export class PresenceOverlay {
|
||||
private _viewport: Viewport;
|
||||
private _scene: SceneManager;
|
||||
private _container: Container;
|
||||
private _selections: Map<string, RemoteSelection> = new Map();
|
||||
private _graphics: Map<string, Graphics> = new Map(); // per-user graphics
|
||||
private _labels: Map<string, Text> = new Map(); // per-user name label
|
||||
private _rafId: number | null = null;
|
||||
|
||||
constructor(viewport: Viewport, scene: SceneManager) {
|
||||
this._viewport = viewport;
|
||||
this._scene = scene;
|
||||
this._container = new Container();
|
||||
this._container.label = '__presence_overlay';
|
||||
this._container.eventMode = 'none';
|
||||
this._container.interactiveChildren = false;
|
||||
viewport.addChild(this._container);
|
||||
}
|
||||
|
||||
/** Update a remote user's selection. Empty array = deselected. */
|
||||
updateSelection(userId: string, displayName: string, color: number, itemIds: string[]): void {
|
||||
if (itemIds.length === 0) {
|
||||
this._selections.delete(userId);
|
||||
this._cleanupUser(userId);
|
||||
} else {
|
||||
this._selections.set(userId, { userId, displayName, color, itemIds });
|
||||
}
|
||||
this._scheduleRedraw();
|
||||
}
|
||||
|
||||
/** Remove a user entirely (they disconnected / left). */
|
||||
removeUser(userId: string): void {
|
||||
this._selections.delete(userId);
|
||||
this._cleanupUser(userId);
|
||||
}
|
||||
|
||||
private _cleanupUser(userId: string): void {
|
||||
const gfx = this._graphics.get(userId);
|
||||
if (gfx) {
|
||||
gfx.destroy();
|
||||
this._graphics.delete(userId);
|
||||
}
|
||||
const label = this._labels.get(userId);
|
||||
if (label) {
|
||||
label.destroy();
|
||||
this._labels.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleRedraw(): void {
|
||||
if (this._rafId !== null) return;
|
||||
this._rafId = requestAnimationFrame(() => {
|
||||
this._rafId = null;
|
||||
this._redraw();
|
||||
});
|
||||
}
|
||||
|
||||
private _redraw(): void {
|
||||
const zoom = this._viewport.scale.x;
|
||||
|
||||
for (const [userId, sel] of this._selections) {
|
||||
// Get or create graphics for this user
|
||||
let gfx = this._graphics.get(userId);
|
||||
if (!gfx) {
|
||||
gfx = new Graphics();
|
||||
gfx.label = `__presence_${userId}`;
|
||||
this._container.addChild(gfx);
|
||||
this._graphics.set(userId, gfx);
|
||||
}
|
||||
gfx.clear();
|
||||
|
||||
// Get or create label
|
||||
let label = this._labels.get(userId);
|
||||
if (!label) {
|
||||
label = new Text({
|
||||
text: sel.displayName,
|
||||
style: new TextStyle({
|
||||
fontSize: 10,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fill: '#ffffff',
|
||||
fontWeight: '600',
|
||||
}),
|
||||
});
|
||||
label.label = `__presence_label_${userId}`;
|
||||
this._container.addChild(label);
|
||||
this._labels.set(userId, label);
|
||||
}
|
||||
label.text = sel.displayName;
|
||||
label.visible = false; // hide until we know where to place it
|
||||
label.scale.set(1 / zoom); // fixed screen-space size
|
||||
|
||||
// Draw border around each selected item, track first bounds for label
|
||||
let firstBounds: { x: number; y: number; w: number; h: number } | null = null;
|
||||
|
||||
for (const itemId of sel.itemIds) {
|
||||
const item = this._scene.getById(itemId);
|
||||
if (!item) continue;
|
||||
|
||||
const b = getItemWorldBounds(item);
|
||||
|
||||
const pad = 2 / zoom;
|
||||
gfx.rect(b.x - pad, b.y - pad, b.w + pad * 2, b.h + pad * 2);
|
||||
gfx.stroke({ color: sel.color, width: 1.5 / zoom, alpha: 0.7 });
|
||||
|
||||
if (!firstBounds) firstBounds = b;
|
||||
}
|
||||
|
||||
// Position name-label pill above the first selected item
|
||||
if (firstBounds) {
|
||||
// Draw background pill first so text renders on top
|
||||
const labelOffsetY = 16 / zoom;
|
||||
const px = 3 / zoom;
|
||||
|
||||
// We need label dimensions — make it visible and measure
|
||||
label.visible = true;
|
||||
label.position.set(firstBounds.x, firstBounds.y - labelOffsetY);
|
||||
|
||||
const lw = label.width;
|
||||
const lh = label.height;
|
||||
|
||||
gfx.roundRect(
|
||||
firstBounds.x - px,
|
||||
firstBounds.y - labelOffsetY - px / 2,
|
||||
lw + px * 2,
|
||||
lh + px,
|
||||
2 / zoom,
|
||||
);
|
||||
gfx.fill({ color: sel.color, alpha: 0.85 });
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up graphics/labels for users no longer in _selections
|
||||
for (const userId of this._graphics.keys()) {
|
||||
if (!this._selections.has(userId)) {
|
||||
this._cleanupUser(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Call when items may have moved to keep borders in sync. */
|
||||
refresh(): void {
|
||||
if (this._selections.size > 0) this._redraw();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this._rafId !== null) cancelAnimationFrame(this._rafId);
|
||||
this._container.destroy({ children: true });
|
||||
this._graphics.clear();
|
||||
this._labels.clear();
|
||||
this._selections.clear();
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,22 @@
|
||||
* and spring-animated add/remove operations.
|
||||
*/
|
||||
|
||||
import { Container, Sprite, Texture, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import { Container, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import { TextureManager, LODTier } from './TextureManager';
|
||||
import { TextureManager } from './TextureManager';
|
||||
import { ImageSprite } from './sprites/ImageSprite';
|
||||
import { VideoSprite } from './sprites/VideoSprite';
|
||||
import { DrawingSprite } from './sprites/DrawingSprite';
|
||||
import { FrameSprite } from './sprites/FrameSprite';
|
||||
import { SpringManager, Spring, PRESETS } from './spring';
|
||||
import { reparentGroupChildren } from './grouping';
|
||||
import type {
|
||||
SceneData,
|
||||
AnySceneObject,
|
||||
ImageObject,
|
||||
VideoObject,
|
||||
TextObject,
|
||||
DrawingObject,
|
||||
GroupObject,
|
||||
SceneObject,
|
||||
} from './scene-format';
|
||||
@@ -25,11 +31,106 @@ import type {
|
||||
|
||||
export interface SceneItem {
|
||||
id: string;
|
||||
type: 'image' | 'video' | 'text' | 'group';
|
||||
type: 'image' | 'video' | 'text' | 'drawing' | 'group';
|
||||
displayObject: Container;
|
||||
data: AnySceneObject;
|
||||
}
|
||||
|
||||
/** Set of child IDs that belong to a group — rebuilt when groups change. */
|
||||
let _groupChildIds: Set<string> | null = null;
|
||||
|
||||
/** Rebuild the set of all group-child IDs. Call after group add/remove/load. */
|
||||
export function rebuildGroupChildSet(scene: SceneManager): void {
|
||||
_groupChildIds = new Set<string>();
|
||||
for (const item of scene.items.values()) {
|
||||
if (item.data.type !== 'group') continue;
|
||||
const gd = item.data as GroupObject;
|
||||
for (const cid of gd.children) _groupChildIds.add(cid);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the item is a child of a group (not independently selectable). */
|
||||
export function isGroupChild(id: string): boolean {
|
||||
return _groupChildIds?.has(id) ?? false;
|
||||
}
|
||||
|
||||
/** Single source of truth for an item's world-space bounding rect.
|
||||
* Uses data.sx/sy (not obj.scale which may be mid-animation).
|
||||
* 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. */
|
||||
export function getItemWorldBounds(item: SceneItem): { x: number; y: number; w: number; h: number } {
|
||||
if (item.data.type === 'group') {
|
||||
return _getGroupWorldBounds(item);
|
||||
}
|
||||
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: item.data.x,
|
||||
y: item.data.y,
|
||||
w: item.data.w * Math.abs(item.data.sx),
|
||||
h: item.data.h * Math.abs(item.data.sy),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute group bounds from its data.w/h (set during grouping) or fall back
|
||||
* to stored children data. Children store LOCAL x/y relative to group.
|
||||
*/
|
||||
function _getGroupWorldBounds(item: SceneItem): { x: number; y: number; w: number; h: number } {
|
||||
const groupX = item.data.x;
|
||||
const groupY = item.data.y;
|
||||
const groupW = item.data.w;
|
||||
const groupH = item.data.h;
|
||||
|
||||
// If group has valid w/h (set during creation), apply scale just like regular items
|
||||
if (groupW > 0 && groupH > 0) {
|
||||
return {
|
||||
x: groupX,
|
||||
y: groupY,
|
||||
w: groupW * Math.abs(item.data.sx),
|
||||
h: groupH * Math.abs(item.data.sy),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: shouldn't happen, but compute from children display objects
|
||||
const container = item.displayObject;
|
||||
if (container.children.length === 0) {
|
||||
return { x: groupX, y: groupY, w: 0, h: 0 };
|
||||
}
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const child of container.children) {
|
||||
const cx = child.x;
|
||||
const cy = child.y;
|
||||
// Approximate child size from its local bounds
|
||||
const lb = child.getLocalBounds();
|
||||
const cw = lb.width;
|
||||
const ch = lb.height;
|
||||
minX = Math.min(minX, cx);
|
||||
minY = Math.min(minY, cy);
|
||||
maxX = Math.max(maxX, cx + cw);
|
||||
maxY = Math.max(maxY, cy + ch);
|
||||
}
|
||||
|
||||
if (!isFinite(minX)) return { x: groupX, y: groupY, w: 0, h: 0 };
|
||||
// Children positions are local to group, so offset by group world position
|
||||
return { x: groupX + minX, y: groupY + minY, w: maxX - minX, h: maxY - minY };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SceneManager
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -41,6 +142,7 @@ export class SceneManager {
|
||||
readonly springs: SpringManager;
|
||||
|
||||
private _onChange: (() => void) | null = null;
|
||||
private _onItemDimensionsChanged: ((itemId: string) => void) | null = null;
|
||||
private _zCounter: number = 0;
|
||||
|
||||
constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) {
|
||||
@@ -55,6 +157,10 @@ export class SceneManager {
|
||||
this._onChange = fn;
|
||||
}
|
||||
|
||||
set onItemDimensionsChanged(fn: ((itemId: string) => void) | null) {
|
||||
this._onItemDimensionsChanged = fn;
|
||||
}
|
||||
|
||||
get onChange(): (() => void) | null {
|
||||
return this._onChange;
|
||||
}
|
||||
@@ -98,6 +204,21 @@ export class SceneManager {
|
||||
}
|
||||
for (const id of toRemove) {
|
||||
const item = this.items.get(id)!;
|
||||
|
||||
// If removing a group, reparent its PixiJS children to viewport first
|
||||
// so they survive the container destruction (they may still be in the
|
||||
// incoming set as ungrouped top-level items).
|
||||
if (item.data.type === 'group') {
|
||||
const container = item.displayObject;
|
||||
// Reparent actual scene children (skip internal frame bg/label)
|
||||
const toReparent = container.children.filter(
|
||||
(c) => !c.label?.startsWith('__frame_'),
|
||||
);
|
||||
for (const child of toReparent) {
|
||||
this.viewport.addChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
item.displayObject.destroy({ children: true });
|
||||
this.items.delete(id);
|
||||
}
|
||||
@@ -121,7 +242,11 @@ export class SceneManager {
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
|
||||
// 4. Apply z-ordering
|
||||
// 4. Reparent group children into their group containers
|
||||
reparentGroupChildren(this);
|
||||
rebuildGroupChildSet(this);
|
||||
|
||||
// 5. Apply z-ordering
|
||||
this._applyZOrder();
|
||||
|
||||
this._onChange?.();
|
||||
@@ -136,31 +261,18 @@ export class SceneManager {
|
||||
switch (data.type) {
|
||||
case 'image': {
|
||||
const imgData = data as ImageObject;
|
||||
const sprite = new Sprite(Texture.EMPTY);
|
||||
sprite.width = imgData.w;
|
||||
sprite.height = imgData.h;
|
||||
|
||||
// Load texture asynchronously at appropriate LOD
|
||||
const zoom = this.viewport.scale.x;
|
||||
const tier = this.textures.tierForZoom(zoom);
|
||||
this.textures.load(imgData.asset, tier).then((tex) => {
|
||||
if (!sprite.destroyed) {
|
||||
sprite.texture = tex;
|
||||
sprite.width = imgData.w;
|
||||
sprite.height = imgData.h;
|
||||
}
|
||||
});
|
||||
|
||||
displayObject = sprite;
|
||||
displayObject = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'video': {
|
||||
const vidData = data as VideoObject;
|
||||
const gfx = new Graphics();
|
||||
gfx.rect(0, 0, vidData.w, vidData.h);
|
||||
gfx.fill(0x333333);
|
||||
displayObject = gfx;
|
||||
const videoUrl = this.textures.urlForAsset(vidData.asset);
|
||||
const videoSprite = new VideoSprite(vidData.asset, vidData.w, vidData.h, videoUrl);
|
||||
// Auto-correct dimensions when video metadata loads
|
||||
// NOTE: callback must update item.data (the stored copy), not the original `data` param.
|
||||
// We wire this after the item is created below (see post-creation video wiring).
|
||||
displayObject = videoSprite;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -175,8 +287,15 @@ export class SceneManager {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'drawing': {
|
||||
const drawData = data as DrawingObject;
|
||||
displayObject = new DrawingSprite(drawData.points, drawData.color, drawData.strokeWidth);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'group': {
|
||||
displayObject = new Container();
|
||||
const gd = data as GroupObject;
|
||||
displayObject = new FrameSprite(gd.w, gd.h, gd.bgColor, gd.label, gd.padding);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -206,23 +325,52 @@ export class SceneManager {
|
||||
|
||||
this.items.set(data.id, item);
|
||||
|
||||
// Animate entrance: spring scale from 0 → 1.05 → 1.0 with fade-in
|
||||
// Wire video dimension auto-correction to the STORED item.data (not the original param)
|
||||
if (data.type === 'video' && displayObject instanceof VideoSprite) {
|
||||
displayObject.onDimensionsKnown = (realW, realH) => {
|
||||
item.data.w = realW;
|
||||
item.data.h = realH;
|
||||
this._onChange?.();
|
||||
this._onItemDimensionsChanged?.(item.id);
|
||||
};
|
||||
}
|
||||
|
||||
// Animate entrance: spring scale from center 0 → 1.05 → 1.0 with fade-in
|
||||
if (animate) {
|
||||
displayObject.scale.set(0, 0);
|
||||
displayObject.alpha = 0;
|
||||
|
||||
// Scale from center by adjusting position to keep center point fixed
|
||||
const halfW = data.w * data.sx / 2;
|
||||
const halfH = data.h * data.sy / 2;
|
||||
const finalX = data.x;
|
||||
const finalY = data.y;
|
||||
|
||||
const scaleSpring = new Spring(0, 1.05, PRESETS.bounce);
|
||||
scaleSpring.onUpdate = (v) => {
|
||||
if (!displayObject.destroyed) {
|
||||
displayObject.scale.set(v * data.sx, v * data.sy);
|
||||
displayObject.position.set(
|
||||
finalX + halfW * (1 - v),
|
||||
finalY + halfH * (1 - v),
|
||||
);
|
||||
}
|
||||
};
|
||||
scaleSpring.onComplete = () => {
|
||||
// Second spring: 1.05 → 1.0
|
||||
const settleSpring = new Spring(1.05, 1.0, PRESETS.snappy);
|
||||
settleSpring.onUpdate = (v) => {
|
||||
if (!displayObject.destroyed) {
|
||||
displayObject.scale.set(v * data.sx, v * data.sy);
|
||||
displayObject.position.set(
|
||||
finalX + halfW * (1 - v),
|
||||
finalY + halfH * (1 - v),
|
||||
);
|
||||
}
|
||||
};
|
||||
settleSpring.onComplete = () => {
|
||||
if (!displayObject.destroyed) {
|
||||
displayObject.position.set(finalX, finalY);
|
||||
displayObject.scale.set(data.sx, data.sy);
|
||||
}
|
||||
};
|
||||
this.springs.add(settleSpring);
|
||||
@@ -252,6 +400,21 @@ export class SceneManager {
|
||||
obj.visible = data.visible;
|
||||
obj.eventMode = data.locked ? 'none' : 'static';
|
||||
|
||||
// Type-specific updates
|
||||
if (data.type === 'drawing' && obj instanceof DrawingSprite) {
|
||||
const drawData = data as DrawingObject;
|
||||
obj.setPoints(drawData.points);
|
||||
}
|
||||
if (data.type === 'text' && obj instanceof Text) {
|
||||
const txtData = data as TextObject;
|
||||
obj.text = txtData.text;
|
||||
obj.style.fontSize = txtData.fontSize;
|
||||
obj.style.fill = txtData.fill;
|
||||
}
|
||||
if (data.type === 'group' && obj instanceof FrameSprite) {
|
||||
obj.updateFromData(data as GroupObject);
|
||||
}
|
||||
|
||||
// Update stored data
|
||||
item.data = { ...data };
|
||||
item.type = data.type;
|
||||
@@ -259,16 +422,47 @@ export class SceneManager {
|
||||
|
||||
// -- Z-Ordering ----------------------------------------------------------
|
||||
|
||||
/** Sort items by z and reorder children in the viewport. */
|
||||
/** Sort items by z and reorder children in both viewport and group containers. */
|
||||
_applyZOrder(): void {
|
||||
const sorted = Array.from(this.items.values()).sort(
|
||||
(a, b) => a.data.z - b.data.z,
|
||||
);
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const child = sorted[i].displayObject;
|
||||
// Reorder top-level items in the viewport
|
||||
let vpIndex = 0;
|
||||
for (const item of sorted) {
|
||||
const child = item.displayObject;
|
||||
if (child.parent === this.viewport) {
|
||||
this.viewport.setChildIndex(child, i);
|
||||
// Clamp index to valid range (selection overlay etc. may also be viewport children)
|
||||
const maxIdx = this.viewport.children.length - 1;
|
||||
const targetIdx = Math.min(vpIndex, maxIdx);
|
||||
if (this.viewport.getChildIndex(child) !== targetIdx) {
|
||||
this.viewport.setChildIndex(child, targetIdx);
|
||||
}
|
||||
vpIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder children within each group container
|
||||
for (const item of sorted) {
|
||||
if (item.data.type !== 'group') continue;
|
||||
const groupData = item.data as GroupObject;
|
||||
const container = item.displayObject;
|
||||
// Sort children by their z value within the group
|
||||
const childItems = groupData.children
|
||||
.map((id) => this.items.get(id))
|
||||
.filter((c): c is SceneItem => !!c)
|
||||
.sort((a, b) => a.data.z - b.data.z);
|
||||
|
||||
for (let i = 0; i < childItems.length; i++) {
|
||||
const child = childItems[i].displayObject;
|
||||
if (child.parent === container) {
|
||||
const maxIdx = container.children.length - 1;
|
||||
const targetIdx = Math.min(i, maxIdx);
|
||||
if (container.getChildIndex(child) !== targetIdx) {
|
||||
container.setChildIndex(child, targetIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,13 +477,27 @@ export class SceneManager {
|
||||
return Array.from(this.items.values());
|
||||
}
|
||||
|
||||
/** Get only top-level items (excludes group children). Used for selection/hit testing. */
|
||||
getTopLevelItems(): SceneItem[] {
|
||||
return Array.from(this.items.values()).filter((item) => !isGroupChild(item.id));
|
||||
}
|
||||
|
||||
// -- Remove Item ---------------------------------------------------------
|
||||
|
||||
/** Remove an item, optionally animating scale→0.8 + fade out before destroy. */
|
||||
/** Remove an item, optionally animating scale→0.8 + fade out before destroy.
|
||||
* If item is a group, recursively removes all children from the items map. */
|
||||
removeItem(id: string, animate = true): void {
|
||||
const item = this.items.get(id);
|
||||
if (!item) return;
|
||||
|
||||
// If this is a group, remove children from the items map first
|
||||
if (item.data.type === 'group') {
|
||||
const groupData = item.data as import('./scene-format').GroupObject;
|
||||
for (const childId of groupData.children) {
|
||||
this.items.delete(childId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!animate) {
|
||||
item.displayObject.destroy({ children: true });
|
||||
this.items.delete(id);
|
||||
@@ -302,10 +510,20 @@ export class SceneManager {
|
||||
// Remove from map immediately to prevent double-remove
|
||||
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;
|
||||
|
||||
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 * item.data.sx, v * item.data.sy);
|
||||
obj.position.set(
|
||||
startX + halfW * (1 - v),
|
||||
startY + halfH * (1 - v),
|
||||
);
|
||||
}
|
||||
};
|
||||
this.springs.add(scaleSpring);
|
||||
@@ -371,6 +589,41 @@ export class SceneManager {
|
||||
return this.items.get(data.id)!;
|
||||
}
|
||||
|
||||
/** Create a VideoObject from an upload and add it to the scene with animation. */
|
||||
addVideoFromUpload(
|
||||
assetKey: string,
|
||||
w: number,
|
||||
h: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): SceneItem {
|
||||
const data: VideoObject = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'video',
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
sx: 1,
|
||||
sy: 1,
|
||||
angle: 0,
|
||||
z: this.nextZ(),
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
name: '',
|
||||
visible: true,
|
||||
asset: assetKey,
|
||||
muted: true,
|
||||
loop: true,
|
||||
};
|
||||
|
||||
this._createItem(data, true);
|
||||
this._applyZOrder();
|
||||
this._onChange?.();
|
||||
|
||||
return this.items.get(data.id)!;
|
||||
}
|
||||
|
||||
// -- Group / Ungroup with Spring Animation --------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import { SceneManager, SceneItem, getItemWorldBounds, isGroupChild } from './SceneManager';
|
||||
import { SceneManager, SceneItem, getItemWorldBounds } from './SceneManager';
|
||||
import { TransformBox } from './TransformBox';
|
||||
import { SnapGuides } from './SnapGuides';
|
||||
import { ImageSprite } from './sprites/ImageSprite';
|
||||
import { VideoSprite } from './sprites/VideoSprite';
|
||||
|
||||
@@ -31,6 +32,7 @@ const DRAG_THRESHOLD = 5; // px in screen space before object drag activa
|
||||
export class SelectionManager {
|
||||
readonly selectedIds: Set<string> = new Set();
|
||||
readonly transformBox: TransformBox;
|
||||
private _snapGuides: SnapGuides;
|
||||
|
||||
private _viewport: Viewport;
|
||||
private _scene: SceneManager;
|
||||
@@ -62,6 +64,8 @@ export class SelectionManager {
|
||||
|
||||
private _enabled = true;
|
||||
|
||||
get snapGuides(): SnapGuides { return this._snapGuides; }
|
||||
|
||||
constructor(viewport: Viewport, scene: SceneManager) {
|
||||
this._viewport = viewport;
|
||||
this._scene = scene;
|
||||
@@ -81,6 +85,9 @@ export class SelectionManager {
|
||||
this.transformBox.setViewport(viewport);
|
||||
this._overlay.addChild(this.transformBox);
|
||||
|
||||
// Snap guides
|
||||
this._snapGuides = new SnapGuides(scene, this._overlay);
|
||||
|
||||
// Bind events on viewport
|
||||
viewport.on('pointerdown', this._onPointerDown, this);
|
||||
viewport.on('globalpointermove', this._onPointerMove, this);
|
||||
@@ -229,17 +236,31 @@ export class SelectionManager {
|
||||
|
||||
// Lift shadow + spring scale on all selected image sprites
|
||||
this._applyLift();
|
||||
|
||||
// Begin snap guide session
|
||||
this._snapGuides.beginSession(this.selectedIds);
|
||||
}
|
||||
|
||||
if (this._objectDragging) {
|
||||
const currentWorld = this._viewport.toWorld(e.global.x, e.global.y);
|
||||
const ddx = currentWorld.x - this._lastDragWorldX;
|
||||
const ddy = currentWorld.y - this._lastDragWorldY;
|
||||
let ddx = currentWorld.x - this._lastDragWorldX;
|
||||
let ddy = currentWorld.y - this._lastDragWorldY;
|
||||
this._lastDragWorldX = currentWorld.x;
|
||||
this._lastDragWorldY = currentWorld.y;
|
||||
|
||||
// Move all selected items by delta and broadcast transforms
|
||||
// Compute combined bounds of selected items after applying delta
|
||||
const selected = this.getSelectedItems();
|
||||
const prospective = this._getSelectionBounds(selected);
|
||||
prospective.x += ddx;
|
||||
prospective.y += ddy;
|
||||
|
||||
// Snap to alignment guides
|
||||
const snap = this._snapGuides.computeSnap(prospective, this._viewport);
|
||||
ddx += snap.dx;
|
||||
ddy += snap.dy;
|
||||
this._snapGuides.drawGuides(snap.guides, this._viewport);
|
||||
|
||||
// Move all selected items by corrected delta and broadcast transforms
|
||||
for (const item of selected) {
|
||||
item.displayObject.x += ddx;
|
||||
item.displayObject.y += ddy;
|
||||
@@ -274,6 +295,7 @@ export class SelectionManager {
|
||||
if (this._objectDragging) {
|
||||
// End object drag — drop shadow + spring scale back
|
||||
this._applyDrop();
|
||||
this._snapGuides.endSession();
|
||||
this._objectDragging = false;
|
||||
|
||||
// Resume viewport drag
|
||||
@@ -390,6 +412,26 @@ export class SelectionManager {
|
||||
this._emitChange();
|
||||
}
|
||||
|
||||
// -- Selection Bounds Helper -----------------------------------------------
|
||||
|
||||
/** Compute the combined world bounding rect of the given items. */
|
||||
private _getSelectionBounds(items: SceneItem[]): { x: number; y: number; w: number; h: number } {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const item of items) {
|
||||
const { x, y, w, h } = getItemWorldBounds(item);
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x + w > maxX) maxX = x + w;
|
||||
if (y + h > maxY) maxY = y + h;
|
||||
}
|
||||
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
}
|
||||
|
||||
// -- Change Notification --------------------------------------------------
|
||||
|
||||
private _emitChange(): void {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* SnapGuides — alignment snap guides for drag/resize operations (like Figma).
|
||||
*
|
||||
* Shows thin magenta guide lines when edges or centers of dragged items
|
||||
* align with other items on the canvas, and returns snap corrections.
|
||||
*/
|
||||
|
||||
import { Container, Graphics } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import { SceneManager, getItemWorldBounds } from './SceneManager';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SNAP_THRESHOLD = 4; // pixels in screen space (subtle, not aggressive)
|
||||
const GUIDE_COLOR = 0xff4081;
|
||||
const GUIDE_PADDING = 20; // world-space extension beyond bounds
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SnapEdge {
|
||||
axis: 'x' | 'y';
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
dx: number;
|
||||
dy: number;
|
||||
guides: SnapEdge[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SnapGuides
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SnapGuides {
|
||||
private _scene: SceneManager;
|
||||
private _gfx: Graphics;
|
||||
|
||||
/** Cached candidate edges from non-selected items. */
|
||||
private _candidatesX: { value: number; min: number; max: number }[] = [];
|
||||
private _candidatesY: { value: number; min: number; max: number }[] = [];
|
||||
|
||||
constructor(scene: SceneManager, parent: Container) {
|
||||
this._scene = scene;
|
||||
this._gfx = new Graphics();
|
||||
this._gfx.label = '__snap_guides';
|
||||
parent.addChild(this._gfx);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Session management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Cache candidate edges from all visible, unlocked, non-selected top-level items. */
|
||||
beginSession(excludeIds: Set<string>): void {
|
||||
this._candidatesX = [];
|
||||
this._candidatesY = [];
|
||||
|
||||
for (const item of this._scene.getTopLevelItems()) {
|
||||
if (excludeIds.has(item.id)) continue;
|
||||
if (item.data.locked || !item.data.visible) continue;
|
||||
|
||||
const { x, y, w, h } = getItemWorldBounds(item);
|
||||
const right = x + w;
|
||||
const bottom = y + h;
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
// X-axis edges: left, center, right
|
||||
this._candidatesX.push({ value: x, min: y, max: bottom });
|
||||
this._candidatesX.push({ value: cx, min: y, max: bottom });
|
||||
this._candidatesX.push({ value: right, min: y, max: bottom });
|
||||
|
||||
// Y-axis edges: top, center, bottom
|
||||
this._candidatesY.push({ value: y, min: x, max: right });
|
||||
this._candidatesY.push({ value: cy, min: x, max: right });
|
||||
this._candidatesY.push({ value: bottom, min: x, max: right });
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear cached edges and guide lines. */
|
||||
endSession(): void {
|
||||
this._candidatesX = [];
|
||||
this._candidatesY = [];
|
||||
this._gfx.clear();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Snap computation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compare 6 edges of the selection bounds against cached candidates.
|
||||
* Returns correction deltas and guide lines to draw.
|
||||
*/
|
||||
computeSnap(
|
||||
bounds: { x: number; y: number; w: number; h: number },
|
||||
viewport: Viewport,
|
||||
): SnapResult {
|
||||
const scale = viewport.scale.x;
|
||||
const threshold = SNAP_THRESHOLD / scale;
|
||||
|
||||
const { x, y, w, h } = bounds;
|
||||
const right = x + w;
|
||||
const bottom = y + h;
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
// Selection edges for each axis
|
||||
const selEdgesX = [x, cx, right];
|
||||
const selEdgesY = [y, cy, bottom];
|
||||
|
||||
// Selection extent ranges (for guide line extension)
|
||||
const selMinY = y;
|
||||
const selMaxY = bottom;
|
||||
const selMinX = x;
|
||||
const selMaxX = right;
|
||||
|
||||
let bestDx = Infinity;
|
||||
let bestGuideX: SnapEdge | null = null;
|
||||
|
||||
// Find closest X match
|
||||
for (const selVal of selEdgesX) {
|
||||
for (const cand of this._candidatesX) {
|
||||
const diff = cand.value - selVal;
|
||||
if (Math.abs(diff) < Math.abs(bestDx)) {
|
||||
bestDx = diff;
|
||||
const gMin = Math.min(selMinY, cand.min) - GUIDE_PADDING;
|
||||
const gMax = Math.max(selMaxY, cand.max) + GUIDE_PADDING;
|
||||
bestGuideX = { axis: 'x', value: cand.value, min: gMin, max: gMax };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bestDy = Infinity;
|
||||
let bestGuideY: SnapEdge | null = null;
|
||||
|
||||
// Find closest Y match
|
||||
for (const selVal of selEdgesY) {
|
||||
for (const cand of this._candidatesY) {
|
||||
const diff = cand.value - selVal;
|
||||
if (Math.abs(diff) < Math.abs(bestDy)) {
|
||||
bestDy = diff;
|
||||
const gMin = Math.min(selMinX, cand.min) - GUIDE_PADDING;
|
||||
const gMax = Math.max(selMaxX, cand.max) + GUIDE_PADDING;
|
||||
bestGuideY = { axis: 'y', value: cand.value, min: gMin, max: gMax };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const guides: SnapEdge[] = [];
|
||||
const dx = Math.abs(bestDx) <= threshold && bestGuideX ? bestDx : 0;
|
||||
const dy = Math.abs(bestDy) <= threshold && bestGuideY ? bestDy : 0;
|
||||
|
||||
if (dx !== 0 && bestGuideX) guides.push(bestGuideX);
|
||||
if (dy !== 0 && bestGuideY) guides.push(bestGuideY);
|
||||
|
||||
return { dx, dy, guides };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Drawing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Draw guide lines; line width compensates for viewport zoom. */
|
||||
drawGuides(guides: SnapEdge[], viewport: Viewport): void {
|
||||
this._gfx.clear();
|
||||
|
||||
if (guides.length === 0) return;
|
||||
|
||||
const lineWidth = 1 / viewport.scale.x;
|
||||
|
||||
for (const g of guides) {
|
||||
this._gfx.moveTo(
|
||||
g.axis === 'x' ? g.value : g.min,
|
||||
g.axis === 'x' ? g.min : g.value,
|
||||
);
|
||||
this._gfx.lineTo(
|
||||
g.axis === 'x' ? g.value : g.max,
|
||||
g.axis === 'x' ? g.max : g.value,
|
||||
);
|
||||
}
|
||||
|
||||
this._gfx.stroke({ color: GUIDE_COLOR, width: lineWidth });
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,42 @@
|
||||
import { Texture, Assets } from "pixi.js";
|
||||
|
||||
export type LODTier = "thumb" | "medium" | "full";
|
||||
|
||||
interface TextureEntry {
|
||||
texture: Texture;
|
||||
tier: LODTier;
|
||||
lastUsed: number;
|
||||
memoryEstimate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TextureManager — GPU texture cache with LRU eviction and memory budget.
|
||||
* No LOD tiers — PixiJS GPU handles scaling natively.
|
||||
* Just loads the full-res image and caches it.
|
||||
*/
|
||||
export class TextureManager {
|
||||
private cache = new Map<string, TextureEntry>();
|
||||
private budget = 512 * 1024 * 1024; // 512 MB
|
||||
private currentUsage = 0;
|
||||
|
||||
/** Map zoom level to the appropriate LOD tier. */
|
||||
tierForZoom(zoom: number): LODTier {
|
||||
if (zoom < 0.3) return "thumb";
|
||||
if (zoom > 1.5) return "full";
|
||||
return "medium";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether an asset key is a new LOD-format key (no extension)
|
||||
* or an old pre-migration path (has file extension).
|
||||
*/
|
||||
private _isLegacyAsset(assetKey: string): boolean {
|
||||
// New LOD keys look like: boards/{boardId}/{imageId} (no extension)
|
||||
// Old keys look like: boards/{boardId}/{imageId}.png or full URLs
|
||||
return /\.\w{2,5}$/.test(assetKey) || assetKey.startsWith('http') || assetKey.startsWith('/api/');
|
||||
}
|
||||
|
||||
/** Build the URL for a given asset + tier. */
|
||||
urlForAsset(assetKey: string, tier: LODTier): string {
|
||||
if (this._isLegacyAsset(assetKey)) {
|
||||
// Legacy: load original file directly (GPU handles scaling)
|
||||
if (assetKey.startsWith('http') || assetKey.startsWith('/api/')) {
|
||||
return assetKey;
|
||||
}
|
||||
return `/api/images/${assetKey}`;
|
||||
/** Build the URL for a given asset. */
|
||||
urlForAsset(assetKey: string): string {
|
||||
if (assetKey.startsWith('http') || assetKey.startsWith('/api/')) {
|
||||
return assetKey;
|
||||
}
|
||||
// New LOD format: boards/{boardId}/{imageId}/thumb.webp
|
||||
return `/api/images/${assetKey}/${tier}.webp`;
|
||||
return `/api/images/${assetKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a texture for the given asset and LOD tier.
|
||||
* For legacy assets (pre-migration), loads the original file directly —
|
||||
* the GPU handles downscaling naturally.
|
||||
* For new assets, loads the appropriate LOD tier with fallback.
|
||||
* Load a texture for the given asset.
|
||||
* Returns cached texture if available, otherwise fetches and caches.
|
||||
* GPU handles all scaling — no LOD tiers needed.
|
||||
*/
|
||||
async load(assetKey: string, tier: LODTier): Promise<Texture> {
|
||||
// For legacy assets, all tiers resolve to the same URL
|
||||
const effectiveTier = this._isLegacyAsset(assetKey) ? 'full' : tier;
|
||||
const key = `${assetKey}:${effectiveTier}`;
|
||||
|
||||
const existing = this.cache.get(key);
|
||||
async load(assetKey: string): Promise<Texture> {
|
||||
const existing = this.cache.get(assetKey);
|
||||
if (existing) {
|
||||
existing.lastUsed = performance.now();
|
||||
return existing.texture;
|
||||
}
|
||||
|
||||
const url = this.urlForAsset(assetKey, effectiveTier);
|
||||
const url = this.urlForAsset(assetKey);
|
||||
let texture: Texture;
|
||||
try {
|
||||
texture = await Assets.load(url);
|
||||
@@ -76,9 +51,8 @@ export class TextureManager {
|
||||
|
||||
this.currentUsage += memoryEstimate;
|
||||
|
||||
this.cache.set(key, {
|
||||
this.cache.set(assetKey, {
|
||||
texture,
|
||||
tier,
|
||||
lastUsed: performance.now(),
|
||||
memoryEstimate,
|
||||
});
|
||||
@@ -91,14 +65,13 @@ export class TextureManager {
|
||||
}
|
||||
|
||||
/** Remove a specific texture from the cache and destroy it. */
|
||||
unload(assetKey: string, tier: LODTier): void {
|
||||
const key = `${assetKey}:${tier}`;
|
||||
const entry = this.cache.get(key);
|
||||
unload(assetKey: string): void {
|
||||
const entry = this.cache.get(assetKey);
|
||||
if (!entry) return;
|
||||
|
||||
this.currentUsage -= entry.memoryEstimate;
|
||||
entry.texture.destroy(true);
|
||||
this.cache.delete(key);
|
||||
this.cache.delete(assetKey);
|
||||
}
|
||||
|
||||
/** Evict the least-recently-used cache entry. */
|
||||
|
||||
+197
-111
@@ -6,9 +6,10 @@
|
||||
* Each handle is draggable and applies scale/rotation transforms to the items.
|
||||
*/
|
||||
|
||||
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
|
||||
import { Container, Graphics, FederatedPointerEvent, Text, TextStyle } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { SceneItem } from './SceneManager';
|
||||
import { type SceneItem, getItemWorldBounds } from './SceneManager';
|
||||
import type { SnapGuides } from './SnapGuides';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -19,11 +20,8 @@ const BORDER_WIDTH = 1.5;
|
||||
const HANDLE_SIZE = 8;
|
||||
const HANDLE_FILL = 0xffffff;
|
||||
const HANDLE_STROKE = 0x4a90d9;
|
||||
const ROTATE_COLOR = 0x55aa55;
|
||||
const ROTATE_OFFSET = 25;
|
||||
const ROTATE_RADIUS = 5;
|
||||
|
||||
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br' | 'rot';
|
||||
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br';
|
||||
|
||||
const HANDLE_CURSORS: Record<HandleId, string> = {
|
||||
tl: 'nwse-resize',
|
||||
@@ -34,7 +32,6 @@ const HANDLE_CURSORS: Record<HandleId, string> = {
|
||||
bc: 'ns-resize',
|
||||
ml: 'ew-resize',
|
||||
mr: 'ew-resize',
|
||||
rot: 'grab',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -45,6 +42,7 @@ interface DragState {
|
||||
handleId: HandleId;
|
||||
startX: number;
|
||||
startY: number;
|
||||
origBounds: { x: number; y: number; w: number; h: number };
|
||||
origTransforms: Map<string, { sx: number; sy: number; angle: number; x: number; y: number }>;
|
||||
}
|
||||
|
||||
@@ -55,11 +53,22 @@ interface DragState {
|
||||
export class TransformBox extends Container {
|
||||
private _border: Graphics;
|
||||
private _handles: Map<HandleId, Graphics> = new Map();
|
||||
private _rotateLine: Graphics;
|
||||
private _items: SceneItem[] = [];
|
||||
private _bounds = { x: 0, y: 0, w: 0, h: 0 };
|
||||
private _drag: DragState | null = null;
|
||||
private _viewport: Viewport | null = null;
|
||||
private _onItemTransform: ((item: SceneItem) => void) | null = null;
|
||||
private _snapGuides: SnapGuides | null = null;
|
||||
private _dimLabel!: Text;
|
||||
private _dimLabelBg!: Graphics;
|
||||
|
||||
set onItemTransform(fn: (item: SceneItem) => void) {
|
||||
this._onItemTransform = fn;
|
||||
}
|
||||
|
||||
setSnapGuides(sg: SnapGuides): void {
|
||||
this._snapGuides = sg;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -70,12 +79,29 @@ export class TransformBox extends Container {
|
||||
this._border = new Graphics();
|
||||
this.addChild(this._border);
|
||||
|
||||
// Rotate stem line
|
||||
this._rotateLine = new Graphics();
|
||||
this.addChild(this._rotateLine);
|
||||
|
||||
// Dimension label (shown during resize)
|
||||
this._dimLabel = new Text({
|
||||
text: '',
|
||||
style: new TextStyle({
|
||||
fontSize: 11,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fill: '#ffffff',
|
||||
fontWeight: '500',
|
||||
}),
|
||||
});
|
||||
this._dimLabel.visible = false;
|
||||
this._dimLabel.label = '__dim_label';
|
||||
|
||||
this._dimLabelBg = new Graphics();
|
||||
this._dimLabelBg.visible = false;
|
||||
this._dimLabelBg.label = '__dim_label_bg';
|
||||
|
||||
this.addChild(this._dimLabelBg);
|
||||
this.addChild(this._dimLabel);
|
||||
|
||||
// Create all handles
|
||||
const ids: HandleId[] = ['tl', 'tc', 'tr', 'ml', 'mr', 'bl', 'bc', 'br', 'rot'];
|
||||
const ids: HandleId[] = ['tl', 'tc', 'tr', 'ml', 'mr', 'bl', 'bc', 'br'];
|
||||
for (const id of ids) {
|
||||
const handle = new Graphics();
|
||||
handle.eventMode = 'static';
|
||||
@@ -103,23 +129,25 @@ export class TransformBox extends Container {
|
||||
update(items: SceneItem[]): void {
|
||||
this._items = items;
|
||||
|
||||
// Hide dimension label when not actively dragging
|
||||
if (!this._drag) {
|
||||
this._dimLabel.visible = false;
|
||||
this._dimLabelBg.visible = false;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
this.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute combined bounding rect in WORLD space using item data
|
||||
// (not getBounds() which returns screen-space and causes offset)
|
||||
// Compute combined bounding rect via canonical getItemWorldBounds()
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const item of items) {
|
||||
const ix = item.data.x;
|
||||
const iy = item.data.y;
|
||||
const iw = item.data.w * Math.abs(item.data.sx);
|
||||
const ih = item.data.h * Math.abs(item.data.sy);
|
||||
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||
if (ix < minX) minX = ix;
|
||||
if (iy < minY) minY = iy;
|
||||
if (ix + iw > maxX) maxX = ix + iw;
|
||||
@@ -141,12 +169,6 @@ export class TransformBox extends Container {
|
||||
this._border.rect(x, y, w, h);
|
||||
this._border.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH });
|
||||
|
||||
// Rotate line (from top-center up to rotate handle)
|
||||
this._rotateLine.clear();
|
||||
this._rotateLine.moveTo(x + w / 2, y);
|
||||
this._rotateLine.lineTo(x + w / 2, y - ROTATE_OFFSET);
|
||||
this._rotateLine.stroke({ color: BORDER_COLOR, width: 1 });
|
||||
|
||||
// Position handles
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
@@ -160,25 +182,16 @@ export class TransformBox extends Container {
|
||||
bl: { px: x, py: y + h },
|
||||
bc: { px: cx, py: y + h },
|
||||
br: { px: x + w, py: y + h },
|
||||
rot: { px: cx, py: y - ROTATE_OFFSET },
|
||||
};
|
||||
|
||||
for (const [id, handle] of this._handles) {
|
||||
const pos = positions[id];
|
||||
handle.clear();
|
||||
|
||||
if (id === 'rot') {
|
||||
// Green circle for rotation
|
||||
handle.circle(0, 0, ROTATE_RADIUS);
|
||||
handle.fill(ROTATE_COLOR);
|
||||
handle.stroke({ color: HANDLE_STROKE, width: 1 });
|
||||
} else {
|
||||
// White square with blue stroke for resize
|
||||
const half = HANDLE_SIZE / 2;
|
||||
handle.rect(-half, -half, HANDLE_SIZE, HANDLE_SIZE);
|
||||
handle.fill(HANDLE_FILL);
|
||||
handle.stroke({ color: HANDLE_STROKE, width: 1 });
|
||||
}
|
||||
const half = HANDLE_SIZE / 2;
|
||||
handle.rect(-half, -half, HANDLE_SIZE, HANDLE_SIZE);
|
||||
handle.fill(HANDLE_FILL);
|
||||
handle.stroke({ color: HANDLE_STROKE, width: 1 });
|
||||
|
||||
handle.position.set(pos.px, pos.py);
|
||||
}
|
||||
@@ -204,108 +217,181 @@ export class TransformBox extends Container {
|
||||
handleId: id,
|
||||
startX: e.global.x,
|
||||
startY: e.global.y,
|
||||
origBounds: { ...this._bounds },
|
||||
origTransforms,
|
||||
};
|
||||
|
||||
// Begin snap session excluding current items
|
||||
const itemIds = new Set(this._items.map((it) => it.id));
|
||||
this._snapGuides?.beginSession(itemIds);
|
||||
}
|
||||
|
||||
private _onHandleMove(e: FederatedPointerEvent): void {
|
||||
if (!this._drag) return;
|
||||
|
||||
// Convert screen-space deltas to world-space by dividing by viewport zoom
|
||||
const zoom = this._viewport?.scale.x ?? 1;
|
||||
const dx = (e.global.x - this._drag.startX) / zoom;
|
||||
const dy = (e.global.y - this._drag.startY) / zoom;
|
||||
const { handleId, origTransforms } = this._drag;
|
||||
// Convert mouse position to world space
|
||||
const world = this._viewport!.toWorld(e.global.x, e.global.y);
|
||||
const { handleId, origBounds: ob, origTransforms } = this._drag;
|
||||
|
||||
// Use bounding box size as reference for scale sensitivity
|
||||
const { w: bw, h: bh } = this._bounds;
|
||||
const refSize = Math.max(bw, bh, 100); // avoid division by tiny numbers
|
||||
// Compute scale factors: new size / original size
|
||||
// Each handle has a fixed edge — the opposite side stays put
|
||||
const MIN = 0.05;
|
||||
let fx = 1; // horizontal scale multiplier
|
||||
let fy = 1; // vertical scale multiplier
|
||||
|
||||
switch (handleId) {
|
||||
// --- Corner handles (proportional) ---
|
||||
case 'br': {
|
||||
// Fixed edge: top-left. New size = mouse - top-left.
|
||||
fx = Math.max(MIN, (world.x - ob.x) / ob.w);
|
||||
fy = Math.max(MIN, (world.y - ob.y) / ob.h);
|
||||
// Proportional: use average
|
||||
const f = (fx + fy) / 2;
|
||||
fx = f; fy = f;
|
||||
break;
|
||||
}
|
||||
case 'tl': {
|
||||
// Fixed edge: bottom-right
|
||||
const right = ob.x + ob.w;
|
||||
const bottom = ob.y + ob.h;
|
||||
fx = Math.max(MIN, (right - world.x) / ob.w);
|
||||
fy = Math.max(MIN, (bottom - world.y) / ob.h);
|
||||
const f = (fx + fy) / 2;
|
||||
fx = f; fy = f;
|
||||
break;
|
||||
}
|
||||
case 'tr': {
|
||||
// Fixed edge: bottom-left
|
||||
const bottom = ob.y + ob.h;
|
||||
fx = Math.max(MIN, (world.x - ob.x) / ob.w);
|
||||
fy = Math.max(MIN, (bottom - world.y) / ob.h);
|
||||
const f = (fx + fy) / 2;
|
||||
fx = f; fy = f;
|
||||
break;
|
||||
}
|
||||
case 'bl': {
|
||||
// Fixed edge: top-right
|
||||
const right = ob.x + ob.w;
|
||||
fx = Math.max(MIN, (right - world.x) / ob.w);
|
||||
fy = Math.max(MIN, (world.y - ob.y) / ob.h);
|
||||
const f = (fx + fy) / 2;
|
||||
fx = f; fy = f;
|
||||
break;
|
||||
}
|
||||
// --- Edge handles (proportional by default, hold Shift for free-form) ---
|
||||
case 'mr': {
|
||||
fx = Math.max(MIN, (world.x - ob.x) / ob.w);
|
||||
if (!e.shiftKey) fy = fx;
|
||||
break;
|
||||
}
|
||||
case 'ml': {
|
||||
const right = ob.x + ob.w;
|
||||
fx = Math.max(MIN, (right - world.x) / ob.w);
|
||||
if (!e.shiftKey) fy = fx;
|
||||
break;
|
||||
}
|
||||
case 'bc': {
|
||||
fy = Math.max(MIN, (world.y - ob.y) / ob.h);
|
||||
if (!e.shiftKey) fx = fy;
|
||||
break;
|
||||
}
|
||||
case 'tc': {
|
||||
const bottom = ob.y + ob.h;
|
||||
fy = Math.max(MIN, (bottom - world.y) / ob.h);
|
||||
if (!e.shiftKey) fx = fy;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of this._items) {
|
||||
const orig = origTransforms.get(item.id);
|
||||
if (!orig) continue;
|
||||
|
||||
// Apply scale factors relative to original
|
||||
item.data.sx = orig.sx * fx;
|
||||
item.data.sy = orig.sy * fy;
|
||||
|
||||
// Reposition to keep fixed edge in place
|
||||
switch (handleId) {
|
||||
case 'br': {
|
||||
// Proportional scale — drag distance relative to object size
|
||||
const factor = 1 + (dx + dy) / refSize;
|
||||
const clampedFactor = Math.max(0.05, factor);
|
||||
item.data.sx = orig.sx * clampedFactor;
|
||||
item.data.sy = orig.sy * clampedFactor;
|
||||
break;
|
||||
}
|
||||
case 'mr': {
|
||||
const factor = 1 + dx / (bw || refSize);
|
||||
item.data.sx = orig.sx * Math.max(0.05, factor);
|
||||
break;
|
||||
}
|
||||
case 'bc': {
|
||||
const factor = 1 + dy / (bh || refSize);
|
||||
item.data.sy = orig.sy * Math.max(0.05, factor);
|
||||
break;
|
||||
}
|
||||
case 'tl': {
|
||||
// Proportional scale + reposition (bottom-right stays fixed)
|
||||
const factor = 1 - (dx + dy) / refSize;
|
||||
const clampedFactor = Math.max(0.05, factor);
|
||||
item.data.sx = orig.sx * clampedFactor;
|
||||
item.data.sy = orig.sy * clampedFactor;
|
||||
const dw = (item.data.sx - orig.sx) * item.data.w;
|
||||
const dh = (item.data.sy - orig.sy) * item.data.h;
|
||||
item.data.x = orig.x - dw;
|
||||
item.data.y = orig.y - dh;
|
||||
break;
|
||||
}
|
||||
case 'rot': {
|
||||
// Rotation: 1 world pixel = ~0.3 degrees
|
||||
item.data.angle = orig.angle + dx * 0.3;
|
||||
break;
|
||||
}
|
||||
case 'tr': {
|
||||
const factor = 1 + (dx - dy) / refSize;
|
||||
const clampedFactor = Math.max(0.05, factor);
|
||||
item.data.sx = orig.sx * clampedFactor;
|
||||
item.data.sy = orig.sy * clampedFactor;
|
||||
// Top-right: left edge stays fixed
|
||||
item.data.y = orig.y - (item.data.sy - orig.sy) * item.data.h;
|
||||
break;
|
||||
}
|
||||
case 'bl': {
|
||||
const factor = 1 + (-dx + dy) / refSize;
|
||||
const clampedFactor = Math.max(0.05, factor);
|
||||
item.data.sx = orig.sx * clampedFactor;
|
||||
item.data.sy = orig.sy * clampedFactor;
|
||||
// Bottom-left: right edge stays fixed
|
||||
item.data.x = orig.x - (item.data.sx - orig.sx) * item.data.w;
|
||||
break;
|
||||
}
|
||||
case 'tc': {
|
||||
const factor = 1 - dy / (bh || refSize);
|
||||
item.data.sy = orig.sy * Math.max(0.05, factor);
|
||||
// Top-center: bottom edge stays fixed
|
||||
case 'tl':
|
||||
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
|
||||
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
|
||||
break;
|
||||
}
|
||||
case 'ml': {
|
||||
const factor = 1 - dx / (bw || refSize);
|
||||
item.data.sx = orig.sx * Math.max(0.05, factor);
|
||||
// Middle-left: right edge stays fixed
|
||||
case 'tc':
|
||||
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
|
||||
break;
|
||||
case 'tr':
|
||||
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
|
||||
break;
|
||||
case 'ml':
|
||||
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
|
||||
break;
|
||||
}
|
||||
case 'bl':
|
||||
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
|
||||
break;
|
||||
// br, mr, bc: top-left is fixed, no reposition needed
|
||||
}
|
||||
|
||||
// Apply to display object
|
||||
item.displayObject.scale.set(item.data.sx, item.data.sy);
|
||||
item.displayObject.angle = item.data.angle;
|
||||
item.displayObject.position.set(item.data.x, item.data.y);
|
||||
|
||||
this._onItemTransform?.(item);
|
||||
}
|
||||
|
||||
// Redraw transform box around new bounds
|
||||
this.update(this._items);
|
||||
|
||||
// Snap guides during resize
|
||||
if (this._snapGuides && this._viewport) {
|
||||
const snap = this._snapGuides.computeSnap(this._bounds, this._viewport);
|
||||
if (snap.dx !== 0 || snap.dy !== 0) {
|
||||
// Apply snap correction to all items
|
||||
for (const item of this._items) {
|
||||
item.data.x += snap.dx;
|
||||
item.data.y += snap.dy;
|
||||
item.displayObject.position.set(item.data.x, item.data.y);
|
||||
this._onItemTransform?.(item);
|
||||
}
|
||||
this.update(this._items);
|
||||
}
|
||||
this._snapGuides.drawGuides(snap.guides, this._viewport);
|
||||
}
|
||||
|
||||
// Show dimension label
|
||||
const bounds = this._bounds;
|
||||
const zoom = this._viewport?.scale.x ?? 1;
|
||||
const w = Math.round(bounds.w);
|
||||
const h = Math.round(bounds.h);
|
||||
this._dimLabel.text = `${w} \u00d7 ${h}`;
|
||||
this._dimLabel.scale.set(1 / zoom); // Stay fixed screen size
|
||||
|
||||
// Position below bottom-right corner with offset
|
||||
const labelX = bounds.x + bounds.w;
|
||||
const labelY = bounds.y + bounds.h + 12 / zoom;
|
||||
this._dimLabel.anchor.set(1, 0); // right-aligned
|
||||
this._dimLabel.position.set(labelX, labelY);
|
||||
|
||||
// Background pill
|
||||
const pad = 4 / zoom;
|
||||
const textW = this._dimLabel.width;
|
||||
const textH = this._dimLabel.height;
|
||||
this._dimLabelBg.clear();
|
||||
this._dimLabelBg.roundRect(
|
||||
labelX - textW - pad,
|
||||
labelY - pad / 2,
|
||||
textW + pad * 2,
|
||||
textH + pad,
|
||||
3 / zoom,
|
||||
);
|
||||
this._dimLabelBg.fill({ color: 0x1a1a1a, alpha: 0.9 });
|
||||
|
||||
this._dimLabel.visible = true;
|
||||
this._dimLabelBg.visible = true;
|
||||
}
|
||||
|
||||
private _onHandleUp(): void {
|
||||
this._drag = null;
|
||||
this._dimLabel.visible = false;
|
||||
this._dimLabelBg.visible = false;
|
||||
this._snapGuides?.endSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Clipboard module — copy canvas to system clipboard and paste images from it.
|
||||
*
|
||||
* Copy approach: render ONLY the selected items at native resolution using
|
||||
* PixiJS generateTexture + extract. No viewport cropping — independent of
|
||||
* zoom/pan state. Items are temporarily reparented into a clean container,
|
||||
* rendered, then restored.
|
||||
*/
|
||||
|
||||
import { Container, Rectangle } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { SceneManager, SceneItem } from './SceneManager';
|
||||
import { getItemWorldBounds } from './SceneManager';
|
||||
import { uploadImage } from '../api';
|
||||
|
||||
/**
|
||||
* Write selected items (or full viewport) to system clipboard as PNG.
|
||||
* Selected items are rendered at their native size, not affected by zoom.
|
||||
*/
|
||||
export async function writeCanvasToClipboard(
|
||||
app: Application | null,
|
||||
viewport: Viewport | null,
|
||||
items?: SceneItem[],
|
||||
): Promise<void> {
|
||||
if (!viewport) throw new Error('Viewport not available');
|
||||
if (!app?.renderer?.extract) throw new Error('Renderer not available');
|
||||
|
||||
let outputCanvas: HTMLCanvasElement;
|
||||
|
||||
if (items && items.length > 0) {
|
||||
// --- Render selected items at native resolution ---
|
||||
|
||||
// 1. Compute world-space bounding box
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const item of items) {
|
||||
const { x, y, w, h } = getItemWorldBounds(item);
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + w);
|
||||
maxY = Math.max(maxY, y + h);
|
||||
}
|
||||
|
||||
const pad = 10;
|
||||
const totalW = Math.ceil(maxX - minX) + pad * 2;
|
||||
const totalH = Math.ceil(maxY - minY) + pad * 2;
|
||||
|
||||
// 2. Save original parent/transform for each item, reparent into temp container
|
||||
const tempContainer = new Container();
|
||||
const saved = new Map<string, {
|
||||
parent: Container;
|
||||
x: number;
|
||||
y: number;
|
||||
sx: number;
|
||||
sy: 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;
|
||||
saved.set(item.id, {
|
||||
parent: obj.parent as Container,
|
||||
x: obj.x,
|
||||
y: obj.y,
|
||||
sx: obj.scale.x,
|
||||
sy: obj.scale.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);
|
||||
|
||||
tempContainer.addChild(obj);
|
||||
}
|
||||
|
||||
// 3. Generate texture at 1x resolution (native pixel size)
|
||||
let texture;
|
||||
let extractedCanvas: HTMLCanvasElement;
|
||||
try {
|
||||
texture = app.renderer.generateTexture({
|
||||
target: tempContainer,
|
||||
resolution: 1,
|
||||
frame: new Rectangle(0, 0, totalW, totalH),
|
||||
});
|
||||
extractedCanvas = app.renderer.extract.canvas(texture) as HTMLCanvasElement;
|
||||
} finally {
|
||||
// 4. Restore items to their original parents and transforms (even on error)
|
||||
for (const item of items) {
|
||||
const s = saved.get(item.id)!;
|
||||
tempContainer.removeChild(item.displayObject);
|
||||
s.parent.addChild(item.displayObject);
|
||||
item.displayObject.position.set(s.x, s.y);
|
||||
item.displayObject.scale.set(s.sx, s.sy);
|
||||
}
|
||||
tempContainer.destroy();
|
||||
texture?.destroy(true);
|
||||
}
|
||||
|
||||
// 6. Add opaque background
|
||||
outputCanvas = document.createElement('canvas');
|
||||
outputCanvas.width = extractedCanvas.width;
|
||||
outputCanvas.height = extractedCanvas.height;
|
||||
const ctx2d = outputCanvas.getContext('2d')!;
|
||||
ctx2d.fillStyle = '#1e1e1e';
|
||||
ctx2d.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
|
||||
ctx2d.drawImage(extractedCanvas, 0, 0);
|
||||
} else {
|
||||
// Full viewport screenshot
|
||||
const extractedCanvas = app.renderer.extract.canvas(viewport) as HTMLCanvasElement;
|
||||
outputCanvas = document.createElement('canvas');
|
||||
outputCanvas.width = extractedCanvas.width;
|
||||
outputCanvas.height = extractedCanvas.height;
|
||||
const ctx2d = outputCanvas.getContext('2d')!;
|
||||
ctx2d.fillStyle = '#1e1e1e';
|
||||
ctx2d.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
|
||||
ctx2d.drawImage(extractedCanvas, 0, 0);
|
||||
}
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
outputCanvas.toBlob((b) => {
|
||||
if (b) resolve(b);
|
||||
else reject(new Error('toBlob returned null'));
|
||||
}, 'image/png');
|
||||
});
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob }),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste an image from the system clipboard into the scene.
|
||||
* Reads the clipboard, uploads the first image found, and adds it at the viewport center.
|
||||
*/
|
||||
export async function pasteFromSystemClipboard(
|
||||
scene: SceneManager,
|
||||
viewport: Viewport,
|
||||
boardId: string,
|
||||
onChange: () => void,
|
||||
): Promise<string> {
|
||||
const clipItems = await navigator.clipboard.read();
|
||||
for (const clipItem of clipItems) {
|
||||
for (const type of clipItem.types) {
|
||||
if (!type.startsWith('image/')) continue;
|
||||
const blob = await clipItem.getType(type);
|
||||
const file = new File([blob], `paste.${type.split('/')[1]}`, { type });
|
||||
const res = await uploadImage(boardId, file);
|
||||
const imgData = res.data.image || res.data;
|
||||
const assetKey = imgData.asset_key;
|
||||
const w = imgData.width || 400;
|
||||
const h = imgData.height || 300;
|
||||
const maxDim = 600;
|
||||
let fw = w, fh = h;
|
||||
if (w > maxDim || h > maxDim) {
|
||||
const s = maxDim / Math.max(w, h);
|
||||
fw = Math.round(w * s);
|
||||
fh = Math.round(h * s);
|
||||
}
|
||||
if (assetKey) {
|
||||
const center = viewport.center;
|
||||
scene.addImageFromUpload(assetKey, fw, fh, center.x - fw / 2, center.y - fh / 2);
|
||||
onChange();
|
||||
return 'Pasted image';
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'No image in clipboard';
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Context menu builder — assembles menu items from scene/selection state.
|
||||
*/
|
||||
|
||||
import type { SceneManager, SceneItem } from './SceneManager';
|
||||
import type { SelectionManager } from './SelectionManager';
|
||||
import { getItemWorldBounds } from './SceneManager';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { GroupObject } from './scene-format';
|
||||
import { FrameSprite } from './sprites/FrameSprite';
|
||||
import * as ops from './operations';
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
shortcut: string;
|
||||
onClick: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface MenuContext {
|
||||
scene: SceneManager | null;
|
||||
selection: SelectionManager | null;
|
||||
viewport: Viewport | null;
|
||||
clipboardRef: React.MutableRefObject<SceneItem[]>;
|
||||
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
|
||||
onChange: () => void;
|
||||
refreshLayers: () => void;
|
||||
handleGroup: () => void;
|
||||
handleUngroup: () => void;
|
||||
fitAll: () => void;
|
||||
}
|
||||
|
||||
export function buildContextMenuItems(ctx: MenuContext): MenuItem[] {
|
||||
const { scene, selection, viewport } = ctx;
|
||||
const selected = selection ? selection.getSelectedItems() : [];
|
||||
const hasSel = selected.length > 0;
|
||||
const multiSel = selected.length >= 2;
|
||||
|
||||
return [
|
||||
// -- Clipboard --
|
||||
{ label: 'Copy', shortcut: 'Ctrl+C', onClick: () => {
|
||||
if (hasSel) { ctx.clipboardRef.current = [...selected]; ctx.writeCanvasToClipboard(selected); }
|
||||
else ctx.writeCanvasToClipboard();
|
||||
} },
|
||||
{ label: 'Cut', shortcut: 'Ctrl+X', onClick: () => {
|
||||
if (!scene || !hasSel || !selection) return;
|
||||
ctx.clipboardRef.current = [...selected];
|
||||
for (const item of selected) scene.removeItem(item.id, true);
|
||||
selection.clear();
|
||||
ctx.onChange();
|
||||
}, disabled: !hasSel },
|
||||
{ label: 'Paste', shortcut: 'Ctrl+V', onClick: async () => {
|
||||
if (!scene || ctx.clipboardRef.current.length === 0) return;
|
||||
const cloned = _cloneWithGroupSupport(ctx.clipboardRef.current, scene);
|
||||
const sorted = [...cloned].sort((a, b) =>
|
||||
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
|
||||
for (const newData of sorted) {
|
||||
await scene._createItem(newData, true);
|
||||
}
|
||||
scene._applyZOrder();
|
||||
ctx.onChange();
|
||||
}, disabled: ctx.clipboardRef.current.length === 0 },
|
||||
{ label: 'Duplicate', shortcut: 'Ctrl+D', onClick: async () => {
|
||||
if (!scene || !hasSel) return;
|
||||
const allItems = _collectGroupChildren(selected, scene);
|
||||
const cloned = _cloneWithGroupSupport(allItems, scene);
|
||||
const sorted = [...cloned].sort((a, b) =>
|
||||
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
|
||||
for (const newData of sorted) {
|
||||
await scene._createItem(newData, true);
|
||||
}
|
||||
if (selection) selection.clear();
|
||||
scene._applyZOrder();
|
||||
ctx.onChange();
|
||||
}, disabled: !hasSel },
|
||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||
|
||||
// -- Alignment --
|
||||
{ label: 'Align Left', shortcut: 'Ctrl+\u2190', onClick: () => { ops.alignLeft(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: 'Align Right', shortcut: 'Ctrl+\u2192', onClick: () => { ops.alignRight(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: 'Align Top', shortcut: 'Ctrl+\u2191', onClick: () => { ops.alignTop(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: 'Align Bottom', shortcut: 'Ctrl+\u2193', onClick: () => { ops.alignBottom(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: 'Distribute H', shortcut: '', onClick: () => { ops.distributeHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: selected.length < 3 },
|
||||
{ label: 'Distribute V', shortcut: '', onClick: () => { ops.distributeVertical(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: selected.length < 3 },
|
||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||
|
||||
// -- Layer ordering --
|
||||
{ label: 'Bring Forward', shortcut: ']', onClick: () => {
|
||||
if (!scene) return;
|
||||
const all = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
|
||||
const selectedIds = new Set(selected.map((s) => s.id));
|
||||
for (let i = all.length - 2; i >= 0; i--) {
|
||||
if (selectedIds.has(all[i].id) && !selectedIds.has(all[i + 1].id)) {
|
||||
const tmp = all[i].data.z;
|
||||
all[i].data.z = all[i + 1].data.z;
|
||||
all[i + 1].data.z = tmp;
|
||||
}
|
||||
}
|
||||
scene._applyZOrder();
|
||||
ctx.onChange();
|
||||
}, disabled: !hasSel },
|
||||
{ label: 'Send Backward', shortcut: '[', onClick: () => {
|
||||
if (!scene) return;
|
||||
const all = scene.getAllItems().sort((a, b) => a.data.z - b.data.z);
|
||||
const selectedIds = new Set(selected.map((s) => s.id));
|
||||
for (let i = 1; i < all.length; i++) {
|
||||
if (selectedIds.has(all[i].id) && !selectedIds.has(all[i - 1].id)) {
|
||||
const tmp = all[i].data.z;
|
||||
all[i].data.z = all[i - 1].data.z;
|
||||
all[i - 1].data.z = tmp;
|
||||
}
|
||||
}
|
||||
scene._applyZOrder();
|
||||
ctx.onChange();
|
||||
}, disabled: !hasSel },
|
||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||
|
||||
// -- Group --
|
||||
{ label: 'Group', shortcut: 'Ctrl+G', onClick: ctx.handleGroup, disabled: !multiSel },
|
||||
{ label: 'Ungroup', shortcut: 'Ctrl+Shift+G', onClick: ctx.handleUngroup,
|
||||
disabled: selected.length !== 1 || selected[0]?.data.type !== 'group' },
|
||||
// Frame color (for selected group/frame)
|
||||
...(selected.length === 1 && selected[0]?.data.type === 'group' ? [
|
||||
{ label: 'Frame: Blue', shortcut: '', onClick: () => _setFrameColor(selected[0], '#4a90d9', ctx) },
|
||||
{ label: 'Frame: Green', shortcut: '', onClick: () => _setFrameColor(selected[0], '#69db7c', ctx) },
|
||||
{ label: 'Frame: Red', shortcut: '', onClick: () => _setFrameColor(selected[0], '#ff6b6b', ctx) },
|
||||
{ label: 'Frame: Purple', shortcut: '', onClick: () => _setFrameColor(selected[0], '#7950f2', ctx) },
|
||||
{ label: 'Frame: Cyan', shortcut: '', onClick: () => _setFrameColor(selected[0], '#38d9a9', ctx) },
|
||||
{ label: 'Frame: Orange', shortcut: '', onClick: () => _setFrameColor(selected[0], '#ffa94d', ctx) },
|
||||
{ label: 'Frame: None', shortcut: '', onClick: () => _setFrameColor(selected[0], '', ctx) },
|
||||
] as MenuItem[] : []),
|
||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||
|
||||
// -- Arrangement --
|
||||
{ label: 'Arrange Pack', shortcut: 'Ctrl+Shift+P', onClick: () => { ops.arrangeOptimal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: 'Arrange Grid', shortcut: '', onClick: () => { ops.arrangeGrid(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: 'Arrange Row', shortcut: '', onClick: () => { ops.arrangeRow(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: 'Arrange Column', shortcut: '', onClick: () => { ops.arrangeColumn(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: 'Stack', shortcut: 'Ctrl+Alt+S', onClick: () => { ops.stackObjects(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||
|
||||
// -- Normalize --
|
||||
{ label: 'Normalize Size', shortcut: '', onClick: () => { ops.normalizeSize(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: 'Normalize Width', shortcut: '', onClick: () => { ops.normalizeWidth(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: 'Normalize Height', shortcut: '', onClick: () => { ops.normalizeHeight(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !multiSel },
|
||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||
|
||||
// -- Image --
|
||||
{ label: 'Flip Horizontal', shortcut: 'Alt+Shift+H', onClick: () => { ops.flipHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
|
||||
{ label: 'Flip Vertical', shortcut: 'Alt+Shift+V', onClick: () => { ops.flipVertical(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
|
||||
{ label: 'Reset Transform', shortcut: 'Ctrl+Shift+T', onClick: () => { ops.resetTransform(selected); selection?.transformBox.update(selected); ctx.onChange(); }, disabled: !hasSel },
|
||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||
|
||||
// -- View --
|
||||
{ label: 'Select All', shortcut: 'Ctrl+A', onClick: () => { if (selection) selection.selectAll(); } },
|
||||
{ label: 'Fit All', shortcut: 'Ctrl+0', onClick: ctx.fitAll },
|
||||
{ label: 'Fit Selection', shortcut: 'F', onClick: () => {
|
||||
if (!selection || !viewport) return;
|
||||
const items = selection.getSelectedItems();
|
||||
if (items.length === 0) return;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const item of items) {
|
||||
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
||||
if (ix < minX) minX = ix;
|
||||
if (iy < minY) minY = iy;
|
||||
if (ix + iw > maxX) maxX = ix + iw;
|
||||
if (iy + ih > maxY) maxY = iy + ih;
|
||||
}
|
||||
const pad = 60;
|
||||
const cw = maxX - minX + pad * 2;
|
||||
const ch = maxY - minY + pad * 2;
|
||||
const s = Math.min(viewport.screenWidth / cw, viewport.screenHeight / ch, 5);
|
||||
viewport.animate({ position: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }, scale: s, time: 300, ease: 'easeOutQuad' });
|
||||
}, disabled: !hasSel },
|
||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||
|
||||
// -- Delete --
|
||||
{ label: 'Delete', shortcut: 'Del', onClick: () => {
|
||||
if (!scene || !selection) return;
|
||||
for (const item of selected) scene.removeItem(item.id, true);
|
||||
selection.clear();
|
||||
ctx.onChange();
|
||||
}, disabled: !hasSel, danger: true },
|
||||
];
|
||||
}
|
||||
|
||||
/** Collect selected items + their group children (for deep clone). */
|
||||
function _collectGroupChildren(items: SceneItem[], scene: SceneManager): SceneItem[] {
|
||||
const all: SceneItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
all.push(item);
|
||||
if (item.data.type === 'group') {
|
||||
const gd = item.data as GroupObject;
|
||||
for (const cid of gd.children) {
|
||||
if (seen.has(cid)) continue;
|
||||
seen.add(cid);
|
||||
const child = scene.getById(cid);
|
||||
if (child) all.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Clone items with new IDs, remapping group children references. */
|
||||
function _cloneWithGroupSupport(items: SceneItem[], scene: SceneManager): any[] {
|
||||
// Expand: include group children that aren't explicitly in the list
|
||||
const expanded = _collectGroupChildren(items, scene);
|
||||
|
||||
const idMap = new Map<string, string>();
|
||||
for (const item of expanded) {
|
||||
idMap.set(item.data.id, crypto.randomUUID());
|
||||
}
|
||||
|
||||
const clones: any[] = [];
|
||||
for (const item of expanded) {
|
||||
const newId = idMap.get(item.data.id)!;
|
||||
const newData = {
|
||||
...item.data,
|
||||
id: newId,
|
||||
x: item.data.x + 20,
|
||||
y: item.data.y + 20,
|
||||
z: scene.nextZ(),
|
||||
};
|
||||
if (newData.type === 'group' && Array.isArray(newData.children)) {
|
||||
newData.children = newData.children
|
||||
.map((cid: string) => idMap.get(cid))
|
||||
.filter((c): c is string => !!c);
|
||||
}
|
||||
clones.push(newData);
|
||||
}
|
||||
return clones;
|
||||
}
|
||||
|
||||
/** Set the background color of a group/frame. */
|
||||
function _setFrameColor(item: SceneItem, color: string, ctx: MenuContext): void {
|
||||
const gd = item.data as GroupObject;
|
||||
gd.bgColor = color;
|
||||
if (item.displayObject instanceof FrameSprite) {
|
||||
item.displayObject.setBgColor(color);
|
||||
}
|
||||
ctx.onChange();
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Grouping module — group/ungroup operations for scene items.
|
||||
*
|
||||
* Data model:
|
||||
* - Group children store LOCAL x/y (relative to group origin) in data.x/y
|
||||
* - The group's data.x/y is the world-space top-left of the bounding box
|
||||
* - On serialize, children's data.x/y are local coords → correct on reload
|
||||
* - On ungroup, children's data.x/y are converted back to world coords
|
||||
*/
|
||||
|
||||
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 { randomFrameColor } from './sprites/FrameSprite';
|
||||
|
||||
/**
|
||||
* Group selected items into a single group container.
|
||||
* Requires at least 2 selected items.
|
||||
*/
|
||||
export function groupItems(
|
||||
scene: SceneManager,
|
||||
selection: SelectionManager,
|
||||
onChange: () => void,
|
||||
): void {
|
||||
const selected = selection.getSelectedItems();
|
||||
if (selected.length < 2) return;
|
||||
|
||||
// Allow nested groups — groups can contain other groups
|
||||
|
||||
// Compute the bounding box of all selected items
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const item of selected) {
|
||||
const b = getItemWorldBounds(item);
|
||||
minX = Math.min(minX, b.x);
|
||||
minY = Math.min(minY, b.y);
|
||||
maxX = Math.max(maxX, b.x + b.w);
|
||||
maxY = Math.max(maxY, b.y + b.h);
|
||||
}
|
||||
|
||||
const groupX = minX;
|
||||
const groupY = minY;
|
||||
const groupW = maxX - minX;
|
||||
const groupH = maxY - minY;
|
||||
|
||||
const groupData: GroupObject = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'group',
|
||||
x: groupX,
|
||||
y: groupY,
|
||||
w: groupW,
|
||||
h: groupH,
|
||||
sx: 1,
|
||||
sy: 1,
|
||||
angle: 0,
|
||||
z: scene.nextZ(),
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
name: '',
|
||||
visible: true,
|
||||
children: selected.map((s) => s.id),
|
||||
bgColor: randomFrameColor(),
|
||||
label: '',
|
||||
padding: 12,
|
||||
};
|
||||
|
||||
// Create the group container
|
||||
scene._createItem(groupData, false);
|
||||
const groupItem = scene.getById(groupData.id);
|
||||
if (!groupItem) return;
|
||||
|
||||
// Reparent each selected item into the group container
|
||||
for (const child of selected) {
|
||||
const obj = child.displayObject;
|
||||
|
||||
// Convert world position to local (relative to group origin)
|
||||
const localX = child.data.x - groupX;
|
||||
const localY = child.data.y - groupY;
|
||||
|
||||
// Update DATA to store local coords (critical for serialization)
|
||||
child.data.x = localX;
|
||||
child.data.y = localY;
|
||||
|
||||
// Reparent display object
|
||||
obj.parent?.removeChild(obj);
|
||||
obj.position.set(localX, localY);
|
||||
groupItem.displayObject.addChild(obj);
|
||||
}
|
||||
|
||||
selection.clear();
|
||||
selection.selectOnly(groupItem.id);
|
||||
rebuildGroupChildSet(scene);
|
||||
scene._applyZOrder();
|
||||
onChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ungroup the selected group, reparenting children back to the viewport.
|
||||
* Propagates group's scale, angle, and opacity to each child so visual
|
||||
* appearance is preserved after ungrouping.
|
||||
* Requires exactly 1 selected item of type 'group'.
|
||||
*/
|
||||
export function ungroupItems(
|
||||
scene: SceneManager,
|
||||
selection: SelectionManager,
|
||||
viewport: Viewport,
|
||||
onChange: () => void,
|
||||
): void {
|
||||
const selected = selection.getSelectedItems();
|
||||
if (selected.length !== 1) return;
|
||||
const groupItem = selected[0];
|
||||
if (groupItem.data.type !== 'group') return;
|
||||
|
||||
const groupData = groupItem.data as GroupObject;
|
||||
const groupX = groupData.x;
|
||||
const groupY = groupData.y;
|
||||
const groupSx = groupData.sx;
|
||||
const groupSy = groupData.sy;
|
||||
const groupAngle = groupData.angle;
|
||||
const childIds: string[] = [];
|
||||
|
||||
// Reparent each child back to the viewport, propagating group transforms
|
||||
for (const childId of groupData.children) {
|
||||
const childItem = scene.getById(childId);
|
||||
if (!childItem) continue;
|
||||
|
||||
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;
|
||||
|
||||
// Propagate group scale to child
|
||||
childItem.data.sx *= groupSx;
|
||||
childItem.data.sy *= groupSy;
|
||||
|
||||
// Propagate group angle to child
|
||||
childItem.data.angle = (childItem.data.angle || 0) + groupAngle;
|
||||
|
||||
// Update DATA to store world coords
|
||||
childItem.data.x = worldX;
|
||||
childItem.data.y = worldY;
|
||||
|
||||
// Reparent display object
|
||||
obj.parent?.removeChild(obj);
|
||||
viewport.addChild(obj);
|
||||
obj.position.set(worldX, worldY);
|
||||
obj.scale.set(childItem.data.sx, childItem.data.sy);
|
||||
obj.angle = childItem.data.angle;
|
||||
|
||||
childIds.push(childId);
|
||||
}
|
||||
|
||||
// Remove the group item itself (don't destroy children — they're reparented)
|
||||
const groupObj = groupItem.displayObject;
|
||||
groupObj.parent?.removeChild(groupObj);
|
||||
groupObj.destroy();
|
||||
scene.items.delete(groupItem.id);
|
||||
|
||||
// Select the ungrouped children
|
||||
selection.clear();
|
||||
for (const id of childIds) {
|
||||
selection.selectedIds.add(id);
|
||||
}
|
||||
selection.transformBox.update(selection.getSelectedItems());
|
||||
|
||||
rebuildGroupChildSet(scene);
|
||||
scene._applyZOrder();
|
||||
onChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* After loading a scene, reparent group children into their group containers.
|
||||
* Called by SceneManager.loadScene() after all items are created.
|
||||
*/
|
||||
export function reparentGroupChildren(scene: SceneManager): void {
|
||||
for (const item of scene.items.values()) {
|
||||
if (item.data.type !== 'group') continue;
|
||||
const groupData = item.data as GroupObject;
|
||||
const groupContainer = item.displayObject;
|
||||
|
||||
for (const childId of groupData.children) {
|
||||
const childItem = scene.getById(childId);
|
||||
if (!childItem) continue;
|
||||
|
||||
const obj = childItem.displayObject;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { SceneManager } from './SceneManager';
|
||||
import { VideoSprite } from './sprites/VideoSprite';
|
||||
import { uploadImage, uploadImageFromUrl } from '../api';
|
||||
|
||||
type OnChange = () => void;
|
||||
@@ -55,10 +54,7 @@ function handleUploadResult(
|
||||
}
|
||||
|
||||
if (mediaType === 'video' && assetKey) {
|
||||
const url = imgData.public_url;
|
||||
const video = new VideoSprite(assetKey, finalW, finalH, url);
|
||||
video.position.set(x, y);
|
||||
viewport.addChild(video);
|
||||
sceneManager.addVideoFromUpload(assetKey, finalW, finalH, x, y);
|
||||
} else if (assetKey) {
|
||||
sceneManager.addImageFromUpload(assetKey, finalW, finalH, x, y);
|
||||
} else {
|
||||
@@ -122,18 +118,23 @@ export function setupDragDrop(
|
||||
return;
|
||||
}
|
||||
|
||||
let dropIndex = 0;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) continue;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||
const placeholder = createPlaceholder(viewport, world.x, world.y);
|
||||
// Offset each subsequent file so they don't overlap
|
||||
const offsetX = dropIndex * 30;
|
||||
const offsetY = dropIndex * 30;
|
||||
const placeholder = createPlaceholder(viewport, world.x + offsetX, world.y + offsetY);
|
||||
dropIndex++;
|
||||
|
||||
try {
|
||||
const res = await uploadImage(boardId, file);
|
||||
removePlaceholder(viewport, placeholder);
|
||||
handleUploadResult(res, viewport, sceneManager, world.x, world.y, onChange);
|
||||
handleUploadResult(res, viewport, sceneManager, world.x + offsetX, world.y + offsetY, onChange);
|
||||
} catch (err) {
|
||||
console.error('Image upload failed:', err);
|
||||
removePlaceholder(viewport, placeholder);
|
||||
@@ -141,10 +142,22 @@ export function setupDragDrop(
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent browser default (opening file in new tab) on the whole document
|
||||
function onDocDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
function onDocDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
document.addEventListener('dragover', onDocDragOver);
|
||||
document.addEventListener('drop', onDocDrop);
|
||||
container.addEventListener('dragover', onDragOver);
|
||||
container.addEventListener('drop', onDrop);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragover', onDocDragOver);
|
||||
document.removeEventListener('drop', onDocDrop);
|
||||
container.removeEventListener('dragover', onDragOver);
|
||||
container.removeEventListener('drop', onDrop);
|
||||
};
|
||||
|
||||
@@ -350,6 +350,94 @@ export function toggleLocked(objects: SceneItem[]) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Nudge ───
|
||||
|
||||
export function nudge(objects: SceneItem[], dx: number, dy: number) {
|
||||
objects.forEach((item) => {
|
||||
item.data.x += dx;
|
||||
item.data.y += dy;
|
||||
syncPosition(item);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Scale (relative) ───
|
||||
|
||||
export function scaleBy(objects: SceneItem[], factor: number) {
|
||||
objects.forEach((item) => {
|
||||
const cx = item.data.x + scaledW(item) / 2;
|
||||
const cy = item.data.y + scaledH(item) / 2;
|
||||
item.data.sx *= factor;
|
||||
item.data.sy *= factor;
|
||||
// Keep center in place
|
||||
item.data.x = cx - scaledW(item) / 2;
|
||||
item.data.y = cy - scaledH(item) / 2;
|
||||
syncTransform(item);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Rotate (quick 90° snap) ───
|
||||
|
||||
export function rotate90(objects: SceneItem[], clockwise: boolean) {
|
||||
objects.forEach((item) => {
|
||||
item.data.angle = ((item.data.angle + (clockwise ? 90 : -90)) % 360 + 360) % 360;
|
||||
item.displayObject.angle = item.data.angle;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Set Opacity ───
|
||||
|
||||
export function setOpacity(objects: SceneItem[], opacity: number) {
|
||||
const clamped = Math.max(0, Math.min(1, opacity));
|
||||
objects.forEach((item) => {
|
||||
item.data.opacity = clamped;
|
||||
item.displayObject.alpha = clamped;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Align Center ───
|
||||
|
||||
export function alignCenterH(objects: SceneItem[]) {
|
||||
if (objects.length < 2) return;
|
||||
const avgCX = objects.reduce((s, item) => s + item.data.x + scaledW(item) / 2, 0) / objects.length;
|
||||
objects.forEach((item) => {
|
||||
item.data.x = avgCX - scaledW(item) / 2;
|
||||
syncPosition(item);
|
||||
});
|
||||
}
|
||||
|
||||
export function alignCenterV(objects: SceneItem[]) {
|
||||
if (objects.length < 2) return;
|
||||
const avgCY = objects.reduce((s, item) => s + item.data.y + scaledH(item) / 2, 0) / objects.length;
|
||||
objects.forEach((item) => {
|
||||
item.data.y = avgCY - scaledH(item) / 2;
|
||||
syncPosition(item);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Equal Spacing ───
|
||||
|
||||
export function equalSpacingH(objects: SceneItem[], gap = 20) {
|
||||
if (objects.length < 2) return;
|
||||
const sorted = [...objects].sort((a, b) => a.data.x - b.data.x);
|
||||
let x = sorted[0].data.x + scaledW(sorted[0]) + gap;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
sorted[i].data.x = x;
|
||||
syncPosition(sorted[i]);
|
||||
x += scaledW(sorted[i]) + gap;
|
||||
}
|
||||
}
|
||||
|
||||
export function equalSpacingV(objects: SceneItem[], gap = 20) {
|
||||
if (objects.length < 2) return;
|
||||
const sorted = [...objects].sort((a, b) => a.data.y - b.data.y);
|
||||
let y = sorted[0].data.y + scaledH(sorted[0]) + gap;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
sorted[i].data.y = y;
|
||||
syncPosition(sorted[i]);
|
||||
y += scaledH(sorted[i]) + gap;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Overlay / Compare ───
|
||||
|
||||
export function overlayCompare(objects: SceneItem[]) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export interface SceneObject {
|
||||
id: string;
|
||||
type: 'image' | 'video' | 'text' | 'group';
|
||||
type: 'image' | 'video' | 'text' | 'group' | 'drawing';
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
@@ -42,12 +42,22 @@ export interface TextObject extends SceneObject {
|
||||
fontFamily: string;
|
||||
}
|
||||
|
||||
export interface DrawingObject extends SceneObject {
|
||||
type: 'drawing';
|
||||
points: number[]; // flat array: [x0, y0, x1, y1, ...]
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
export interface GroupObject extends SceneObject {
|
||||
type: 'group';
|
||||
children: string[];
|
||||
bgColor?: string; // frame background color (e.g. '#2a2a3a'), empty/undefined = transparent
|
||||
label?: string; // frame title label
|
||||
padding?: number; // inner padding around children (default 12)
|
||||
}
|
||||
|
||||
export type AnySceneObject = ImageObject | VideoObject | TextObject | GroupObject;
|
||||
export type AnySceneObject = ImageObject | VideoObject | TextObject | DrawingObject | GroupObject;
|
||||
|
||||
export interface SceneData {
|
||||
v: 2;
|
||||
|
||||
@@ -13,9 +13,103 @@
|
||||
* - Added Ctrl+S (prevent browser save-as)
|
||||
*/
|
||||
|
||||
import { ShortcutDef } from './shortcuts';
|
||||
import { ShortcutDef, ShortcutContext } from './shortcuts';
|
||||
import type { SceneItem, SceneManager } from './SceneManager';
|
||||
import type { GroupObject } from './scene-format';
|
||||
import * as ops from './operations';
|
||||
|
||||
// Tracks when the last internal copy happened so paste can decide
|
||||
// whether to use internal clipboard (just copied) vs system clipboard (external app).
|
||||
let _lastInternalCopyTime = 0;
|
||||
|
||||
/** Mark that an internal copy just happened. Called by copy/cut handlers. */
|
||||
export function markInternalCopy(): void {
|
||||
_lastInternalCopyTime = Date.now();
|
||||
}
|
||||
|
||||
/** Collect selected items + their group children (for deep clone). */
|
||||
function _collectGroupChildren(items: SceneItem[], scene: SceneManager): SceneItem[] {
|
||||
const all: SceneItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
all.push(item);
|
||||
if (item.data.type === 'group') {
|
||||
const gd = item.data as GroupObject;
|
||||
for (const cid of gd.children) {
|
||||
if (seen.has(cid)) continue;
|
||||
seen.add(cid);
|
||||
const child = scene.getById(cid);
|
||||
if (child) all.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Deep-clone a list of scene items, remapping group children IDs. */
|
||||
function _cloneItemsWithOffset(
|
||||
items: { data: any }[],
|
||||
scene: { nextZ: () => number },
|
||||
offsetX = 20,
|
||||
offsetY = 20,
|
||||
): any[] {
|
||||
// First pass: build old→new ID mapping for all items
|
||||
const idMap = new Map<string, string>();
|
||||
for (const item of items) {
|
||||
idMap.set(item.data.id, crypto.randomUUID());
|
||||
}
|
||||
|
||||
// Second pass: clone data with new IDs and remapped children
|
||||
const clones: any[] = [];
|
||||
for (const item of items) {
|
||||
const newId = idMap.get(item.data.id)!;
|
||||
const newData = {
|
||||
...item.data,
|
||||
id: newId,
|
||||
x: item.data.x + offsetX,
|
||||
y: item.data.y + offsetY,
|
||||
z: scene.nextZ(),
|
||||
};
|
||||
// Remap group children references
|
||||
if (newData.type === 'group' && Array.isArray(newData.children)) {
|
||||
newData.children = newData.children
|
||||
.map((cid: string) => idMap.get(cid) ?? cid)
|
||||
.filter((cid: string) => idMap.has(cid) || items.some((i) => i.data.id === cid));
|
||||
}
|
||||
clones.push(newData);
|
||||
}
|
||||
return clones;
|
||||
}
|
||||
|
||||
/** Paste from internal clipboard — duplicates scene items with offset. */
|
||||
async function _pasteInternal(ctx: ShortcutContext): Promise<void> {
|
||||
const clones = _cloneItemsWithOffset(ctx.clipboardRef.current, ctx.scene);
|
||||
const newItems: typeof ctx.clipboardRef.current = [];
|
||||
|
||||
// Create children first, then groups (so children exist when group is created)
|
||||
const sorted = [...clones].sort((a, b) =>
|
||||
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
|
||||
|
||||
for (const newData of sorted) {
|
||||
await ctx.scene._createItem(newData, true);
|
||||
const newItem = ctx.scene.getById(newData.id);
|
||||
if (newItem) newItems.push(newItem);
|
||||
}
|
||||
ctx.clipboardRef.current = newItems;
|
||||
ctx.scene._applyZOrder();
|
||||
ctx.onChange();
|
||||
}
|
||||
|
||||
/** Run op on selected items → update transform box → fire onChange. */
|
||||
function _opUpdate(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void {
|
||||
const items = ctx.selection.getSelectedItems();
|
||||
op(items);
|
||||
ctx.selection.transformBox.update(items);
|
||||
ctx.onChange();
|
||||
}
|
||||
|
||||
export const shortcuts: ShortcutDef[] = [
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
@@ -25,34 +119,22 @@ export const shortcuts: ShortcutDef[] = [
|
||||
{
|
||||
id: 'align-left', keys: { key: 'arrowleft', ctrl: true },
|
||||
category: 'alignment', description: 'Align left', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.alignLeft(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.alignLeft),
|
||||
},
|
||||
{
|
||||
id: 'align-right', keys: { key: 'arrowright', ctrl: true },
|
||||
category: 'alignment', description: 'Align right', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.alignRight(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.alignRight),
|
||||
},
|
||||
{
|
||||
id: 'align-top', keys: { key: 'arrowup', ctrl: true },
|
||||
category: 'alignment', description: 'Align top', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.alignTop(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.alignTop),
|
||||
},
|
||||
{
|
||||
id: 'align-bottom', keys: { key: 'arrowdown', ctrl: true },
|
||||
category: 'alignment', description: 'Align bottom', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.alignBottom(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.alignBottom),
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
@@ -62,18 +144,12 @@ export const shortcuts: ShortcutDef[] = [
|
||||
{
|
||||
id: 'distribute-h', keys: { key: 'arrowup', ctrl: true, alt: true, shift: true },
|
||||
category: 'alignment', description: 'Distribute horizontal', needsSelection: true, minSelection: 3,
|
||||
handler: (ctx) => {
|
||||
ops.distributeHorizontal(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.distributeHorizontal),
|
||||
},
|
||||
{
|
||||
id: 'distribute-v', keys: { key: 'arrowdown', ctrl: true, alt: true, shift: true },
|
||||
category: 'alignment', description: 'Distribute vertical', needsSelection: true, minSelection: 3,
|
||||
handler: (ctx) => {
|
||||
ops.distributeVertical(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.distributeVertical),
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
@@ -83,34 +159,22 @@ export const shortcuts: ShortcutDef[] = [
|
||||
{
|
||||
id: 'normalize-size', keys: { key: 'arrowup', ctrl: true, alt: true },
|
||||
category: 'normalize', description: 'Normalize size (same area)', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.normalizeSize(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.normalizeSize),
|
||||
},
|
||||
{
|
||||
id: 'normalize-scale', keys: { key: 'arrowdown', ctrl: true, alt: true },
|
||||
category: 'normalize', description: 'Normalize scale', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.normalizeScale(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.normalizeScale),
|
||||
},
|
||||
{
|
||||
id: 'normalize-height', keys: { key: 'arrowleft', ctrl: true, alt: true },
|
||||
category: 'normalize', description: 'Normalize height', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.normalizeHeight(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.normalizeHeight),
|
||||
},
|
||||
{
|
||||
id: 'normalize-width', keys: { key: 'arrowright', ctrl: true, alt: true },
|
||||
category: 'normalize', description: 'Normalize width', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.normalizeWidth(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.normalizeWidth),
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
@@ -120,42 +184,27 @@ export const shortcuts: ShortcutDef[] = [
|
||||
{
|
||||
id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true },
|
||||
category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.arrangeOptimal(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.arrangeOptimal),
|
||||
},
|
||||
{
|
||||
id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true },
|
||||
category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.arrangeByName(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.arrangeByName),
|
||||
},
|
||||
{
|
||||
id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true },
|
||||
category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.arrangeByZOrder(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.arrangeByZOrder),
|
||||
},
|
||||
{
|
||||
id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true },
|
||||
category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.arrangeRandomly(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.arrangeRandomly),
|
||||
},
|
||||
{
|
||||
id: 'stack', keys: { key: 's', ctrl: true, alt: true },
|
||||
category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.stackObjects(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.stackObjects),
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
@@ -165,26 +214,17 @@ export const shortcuts: ShortcutDef[] = [
|
||||
{
|
||||
id: 'flip-h', keys: { key: 'h', alt: true, shift: true },
|
||||
category: 'image', description: 'Flip horizontal', needsSelection: true,
|
||||
handler: (ctx) => {
|
||||
ops.flipHorizontal(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.flipHorizontal),
|
||||
},
|
||||
{
|
||||
id: 'flip-v', keys: { key: 'v', alt: true, shift: true },
|
||||
category: 'image', description: 'Flip vertical', needsSelection: true,
|
||||
handler: (ctx) => {
|
||||
ops.flipVertical(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.flipVertical),
|
||||
},
|
||||
{
|
||||
id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true },
|
||||
category: 'image', description: 'Reset transform', needsSelection: true,
|
||||
handler: (ctx) => {
|
||||
ops.resetTransform(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.resetTransform),
|
||||
},
|
||||
{
|
||||
id: 'toggle-grayscale', keys: { key: 'g', alt: true },
|
||||
@@ -199,16 +239,95 @@ export const shortcuts: ShortcutDef[] = [
|
||||
category: 'image', description: 'Toggle locked', needsSelection: true,
|
||||
handler: (ctx) => {
|
||||
ops.toggleLocked(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
ctx.refreshLayers();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'overlay-compare', keys: { key: 'y', ctrl: true, shift: true },
|
||||
category: 'image', description: 'Overlay / compare', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => {
|
||||
ops.overlayCompare(ctx.selection.getSelectedItems());
|
||||
ctx.onChange();
|
||||
},
|
||||
handler: (ctx) => _opUpdate(ctx, ops.overlayCompare),
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// NUDGE (Arrow keys with selection)
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
// Shift+Arrow = 10px nudge (more specific, checked first)
|
||||
{
|
||||
id: 'nudge-left-10', keys: { key: 'arrowleft', shift: true },
|
||||
category: 'arrangement', description: 'Nudge left 10px', needsSelection: true,
|
||||
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, -10, 0)),
|
||||
},
|
||||
{
|
||||
id: 'nudge-right-10', keys: { key: 'arrowright', shift: true },
|
||||
category: 'arrangement', description: 'Nudge right 10px', needsSelection: true,
|
||||
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 10, 0)),
|
||||
},
|
||||
{
|
||||
id: 'nudge-up-10', keys: { key: 'arrowup', shift: true },
|
||||
category: 'arrangement', description: 'Nudge up 10px', needsSelection: true,
|
||||
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, -10)),
|
||||
},
|
||||
{
|
||||
id: 'nudge-down-10', keys: { key: 'arrowdown', shift: true },
|
||||
category: 'arrangement', description: 'Nudge down 10px', needsSelection: true,
|
||||
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, 10)),
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// SCALE & ROTATE (selection)
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
{
|
||||
id: 'scale-up', keys: { key: '=', alt: true },
|
||||
category: 'image', description: 'Scale up 10%', needsSelection: true,
|
||||
handler: (ctx) => _opUpdate(ctx, (items) => ops.scaleBy(items, 1.1)),
|
||||
},
|
||||
{
|
||||
id: 'scale-down', keys: { key: '-', alt: true },
|
||||
category: 'image', description: 'Scale down 10%', needsSelection: true,
|
||||
handler: (ctx) => _opUpdate(ctx, (items) => ops.scaleBy(items, 1 / 1.1)),
|
||||
},
|
||||
{
|
||||
id: 'rotate-cw', keys: { key: 'r' },
|
||||
category: 'image', description: 'Rotate 90° clockwise', needsSelection: true,
|
||||
handler: (ctx) => _opUpdate(ctx, (items) => ops.rotate90(items, true)),
|
||||
},
|
||||
{
|
||||
id: 'rotate-ccw', keys: { key: 'r', shift: true },
|
||||
category: 'image', description: 'Rotate 90° counter-clockwise', needsSelection: true,
|
||||
handler: (ctx) => _opUpdate(ctx, (items) => ops.rotate90(items, false)),
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// ALIGN CENTER
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
{
|
||||
id: 'align-center-h', keys: { key: 'arrowleft', ctrl: true, shift: true },
|
||||
category: 'alignment', description: 'Align center horizontal', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => _opUpdate(ctx, ops.alignCenterH),
|
||||
},
|
||||
{
|
||||
id: 'align-center-v', keys: { key: 'arrowup', ctrl: true, shift: true },
|
||||
category: 'alignment', description: 'Align center vertical', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => _opUpdate(ctx, ops.alignCenterV),
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// EQUAL SPACING
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
{
|
||||
id: 'equal-spacing-h', keys: { key: 'h', ctrl: true, shift: true },
|
||||
category: 'arrangement', description: 'Equal horizontal spacing', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => _opUpdate(ctx, ops.equalSpacingH),
|
||||
},
|
||||
{
|
||||
id: 'equal-spacing-v', keys: { key: 'v', ctrl: true, shift: true },
|
||||
category: 'arrangement', description: 'Equal vertical spacing', needsSelection: true, minSelection: 2,
|
||||
handler: (ctx) => _opUpdate(ctx, ops.equalSpacingV),
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
@@ -227,32 +346,55 @@ export const shortcuts: ShortcutDef[] = [
|
||||
category: 'navigation', description: 'Fit all in view',
|
||||
handler: (ctx) => ctx.fitAll(),
|
||||
},
|
||||
// Bare arrow Left/Right only cycle when nothing is selected.
|
||||
// When something IS selected, they're no-ops (prevent accidental cycling).
|
||||
// Layer ordering uses ] / [ to avoid arrow conflicts.
|
||||
{
|
||||
id: 'cycle-next', keys: { key: 'arrowright' },
|
||||
category: 'navigation', description: 'Select next object',
|
||||
id: 'focus-selection', keys: { key: 'f' },
|
||||
category: 'navigation', description: 'Fit selection in view', needsSelection: true,
|
||||
handler: (ctx) => ctx.fitSelection(),
|
||||
},
|
||||
{
|
||||
id: 'toggle-focus-mode', keys: { key: 'tab' },
|
||||
category: 'view', description: 'Toggle focus mode (hide UI)',
|
||||
handler: (ctx) => ctx.toggleFocusMode(),
|
||||
},
|
||||
// Bare arrows: nudge 1px if selection, cycle if no selection
|
||||
{
|
||||
id: 'nudge-or-cycle-right', keys: { key: 'arrowright' },
|
||||
category: 'navigation', description: 'Nudge 1px / Select next',
|
||||
handler: (ctx) => {
|
||||
if (ctx.selection.selectedIds.size > 0) return;
|
||||
const all = ctx.scene.getAllItems();
|
||||
if (all.length === 0) return;
|
||||
// Sort by z to get consistent order
|
||||
all.sort((a, b) => a.data.z - b.data.z);
|
||||
ctx.selection.selectOnly(all[0].id);
|
||||
if (ctx.selection.selectedIds.size > 0) {
|
||||
_opUpdate(ctx, (items) => ops.nudge(items, 1, 0));
|
||||
} else {
|
||||
const all = ctx.scene.getAllItems();
|
||||
if (all.length === 0) return;
|
||||
all.sort((a, b) => a.data.z - b.data.z);
|
||||
ctx.selection.selectOnly(all[0].id);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cycle-prev', keys: { key: 'arrowleft' },
|
||||
category: 'navigation', description: 'Select previous object',
|
||||
id: 'nudge-or-cycle-left', keys: { key: 'arrowleft' },
|
||||
category: 'navigation', description: 'Nudge 1px / Select prev',
|
||||
handler: (ctx) => {
|
||||
if (ctx.selection.selectedIds.size > 0) return;
|
||||
const all = ctx.scene.getAllItems();
|
||||
if (all.length === 0) return;
|
||||
all.sort((a, b) => a.data.z - b.data.z);
|
||||
ctx.selection.selectOnly(all[all.length - 1].id);
|
||||
if (ctx.selection.selectedIds.size > 0) {
|
||||
_opUpdate(ctx, (items) => ops.nudge(items, -1, 0));
|
||||
} else {
|
||||
const all = ctx.scene.getAllItems();
|
||||
if (all.length === 0) return;
|
||||
all.sort((a, b) => a.data.z - b.data.z);
|
||||
ctx.selection.selectOnly(all[all.length - 1].id);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'nudge-up', keys: { key: 'arrowup' },
|
||||
category: 'navigation', description: 'Nudge up 1px', needsSelection: true,
|
||||
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, -1)),
|
||||
},
|
||||
{
|
||||
id: 'nudge-down', keys: { key: 'arrowdown' },
|
||||
category: 'navigation', description: 'Nudge down 1px', needsSelection: true,
|
||||
handler: (ctx) => _opUpdate(ctx, (items) => ops.nudge(items, 0, 1)),
|
||||
},
|
||||
// Layer ordering: ] brings forward, [ sends backward
|
||||
{
|
||||
id: 'send-to-front', keys: { key: ']' },
|
||||
@@ -311,7 +453,9 @@ export const shortcuts: ShortcutDef[] = [
|
||||
handler: (ctx) => {
|
||||
const selected = ctx.selection.getSelectedItems();
|
||||
if (selected.length > 0) {
|
||||
ctx.clipboardRef.current = [...selected];
|
||||
// Include group children in clipboard for proper paste
|
||||
ctx.clipboardRef.current = _collectGroupChildren(selected, ctx.scene);
|
||||
markInternalCopy();
|
||||
ctx.writeCanvasToClipboard(selected);
|
||||
ctx.showToast('Copied');
|
||||
} else {
|
||||
@@ -332,24 +476,36 @@ export const shortcuts: ShortcutDef[] = [
|
||||
id: 'paste', keys: { key: 'v', ctrl: true },
|
||||
category: 'editing', description: 'Paste',
|
||||
handler: async (ctx) => {
|
||||
if (ctx.clipboardRef.current.length === 0) return;
|
||||
const newItems: typeof ctx.clipboardRef.current = [];
|
||||
for (const original of ctx.clipboardRef.current) {
|
||||
// Clone: duplicate the item data with new ID and offset position
|
||||
const newData = {
|
||||
...original.data,
|
||||
id: crypto.randomUUID(),
|
||||
x: original.data.x + 20,
|
||||
y: original.data.y + 20,
|
||||
z: ctx.scene.nextZ(),
|
||||
};
|
||||
await ctx.scene._createItem(newData, true);
|
||||
const newItem = ctx.scene.getById(newData.id);
|
||||
if (newItem) newItems.push(newItem);
|
||||
// Strategy: Check system clipboard for images first.
|
||||
// - If system clipboard has an image AND we did NOT just do an internal copy
|
||||
// (or it's been a while), paste from system clipboard (external image).
|
||||
// - If we just did an internal copy (lastInternalCopyTime is recent),
|
||||
// use internal clipboard to duplicate scene items (preserves vector data).
|
||||
// - If system clipboard has no images, fall back to internal clipboard.
|
||||
|
||||
const timeSinceInternalCopy = Date.now() - _lastInternalCopyTime;
|
||||
const hasInternalItems = ctx.clipboardRef.current.length > 0;
|
||||
const recentInternalCopy = hasInternalItems && timeSinceInternalCopy < 500;
|
||||
|
||||
// If we JUST did an internal copy (<500ms ago), use internal clipboard
|
||||
// (the system clipboard image is just the rasterized version of what we copied)
|
||||
if (recentInternalCopy) {
|
||||
await _pasteInternal(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try system clipboard first
|
||||
try {
|
||||
const result = await ctx.pasteFromSystemClipboard();
|
||||
if (result === 'Pasted image') return;
|
||||
} catch {
|
||||
// Clipboard API denied or unavailable — fall through
|
||||
}
|
||||
|
||||
// Fall back to internal clipboard
|
||||
if (hasInternalItems) {
|
||||
await _pasteInternal(ctx);
|
||||
}
|
||||
ctx.clipboardRef.current = newItems;
|
||||
ctx.scene._applyZOrder();
|
||||
ctx.onChange();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -358,7 +514,8 @@ export const shortcuts: ShortcutDef[] = [
|
||||
handler: (ctx) => {
|
||||
const selected = ctx.selection.getSelectedItems();
|
||||
if (selected.length === 0) return;
|
||||
ctx.clipboardRef.current = [...selected];
|
||||
ctx.clipboardRef.current = _collectGroupChildren(selected, ctx.scene);
|
||||
markInternalCopy();
|
||||
ctx.writeCanvasToClipboard(selected);
|
||||
for (const item of selected) {
|
||||
ctx.scene.removeItem(item.id, true);
|
||||
@@ -374,14 +531,13 @@ export const shortcuts: ShortcutDef[] = [
|
||||
handler: async (ctx) => {
|
||||
const selected = ctx.selection.getSelectedItems();
|
||||
if (selected.length === 0) return;
|
||||
for (const original of selected) {
|
||||
const newData = {
|
||||
...original.data,
|
||||
id: crypto.randomUUID(),
|
||||
x: original.data.x + 20,
|
||||
y: original.data.y + 20,
|
||||
z: ctx.scene.nextZ(),
|
||||
};
|
||||
|
||||
// Collect group children and clone with remapped IDs
|
||||
const allItems = _collectGroupChildren(selected, ctx.scene);
|
||||
const clones = _cloneItemsWithOffset(allItems, ctx.scene);
|
||||
const sorted = [...clones].sort((a: any, b: any) =>
|
||||
(a.type === 'group' ? 1 : 0) - (b.type === 'group' ? 1 : 0));
|
||||
for (const newData of sorted) {
|
||||
await ctx.scene._createItem(newData, true);
|
||||
}
|
||||
ctx.selection.clear();
|
||||
@@ -455,6 +611,27 @@ export const shortcuts: ShortcutDef[] = [
|
||||
category: 'editing', description: 'Ungroup', needsSelection: true,
|
||||
handler: (ctx) => ctx.handleUngroup(),
|
||||
},
|
||||
// Opacity: [ and ] with Alt
|
||||
{
|
||||
id: 'opacity-down', keys: { key: '[', alt: true },
|
||||
category: 'image', description: 'Decrease opacity 10%', needsSelection: true,
|
||||
handler: (ctx) => {
|
||||
const items = ctx.selection.getSelectedItems();
|
||||
const current = items[0]?.data.opacity ?? 1;
|
||||
ops.setOpacity(items, Math.max(0.1, current - 0.1));
|
||||
ctx.onChange();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'opacity-up', keys: { key: ']', alt: true },
|
||||
category: 'image', description: 'Increase opacity 10%', needsSelection: true,
|
||||
handler: (ctx) => {
|
||||
const items = ctx.selection.getSelectedItems();
|
||||
const current = items[0]?.data.opacity ?? 1;
|
||||
ops.setOpacity(items, Math.min(1, current + 0.1));
|
||||
ctx.onChange();
|
||||
},
|
||||
},
|
||||
// Block Ctrl+S from opening browser save-as dialog
|
||||
{
|
||||
id: 'save', keys: { key: 's', ctrl: true },
|
||||
|
||||
@@ -43,7 +43,10 @@ export interface ShortcutContext {
|
||||
handleUngroup: () => void;
|
||||
toggleGrid: () => void;
|
||||
toggleShowHelp: () => void;
|
||||
toggleFocusMode: () => void;
|
||||
fitSelection: () => void;
|
||||
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
|
||||
pasteFromSystemClipboard: () => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
|
||||
/**
|
||||
* DrawingSprite — renders a freehand stroke from a flat points array.
|
||||
* Points stored as [x0, y0, x1, y1, ...] relative to the item's origin.
|
||||
*
|
||||
* Uses batched redraw during live drawing — accumulates points and redraws
|
||||
* on the next animation frame. This avoids expensive per-pointermove redraws
|
||||
* while keeping the stroke visually smooth.
|
||||
*/
|
||||
export class DrawingSprite extends Graphics {
|
||||
private _points: number[] = [];
|
||||
private _color: string;
|
||||
private _strokeWidth: number;
|
||||
private _rafId: number | null = null;
|
||||
private _dirty = false;
|
||||
|
||||
constructor(points: number[], color: string, strokeWidth: number) {
|
||||
super();
|
||||
this._points = points;
|
||||
this._color = color;
|
||||
this._strokeWidth = strokeWidth;
|
||||
this._redraw();
|
||||
}
|
||||
|
||||
get points(): number[] {
|
||||
return this._points;
|
||||
}
|
||||
|
||||
/** Append a point during live drawing — batches redraw to next rAF. */
|
||||
addPoint(x: number, y: number): void {
|
||||
this._points.push(x, y);
|
||||
if (!this._dirty) {
|
||||
this._dirty = true;
|
||||
this._rafId = requestAnimationFrame(() => {
|
||||
this._rafId = null;
|
||||
this._dirty = false;
|
||||
this._redraw();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace all points and redraw immediately. Used for scene load / sync. */
|
||||
setPoints(pts: number[]): void {
|
||||
this._points = pts;
|
||||
if (this._rafId !== null) {
|
||||
cancelAnimationFrame(this._rafId);
|
||||
this._rafId = null;
|
||||
this._dirty = false;
|
||||
}
|
||||
this._redraw();
|
||||
}
|
||||
|
||||
private _redraw(): void {
|
||||
this.clear();
|
||||
const pts = this._points;
|
||||
if (pts.length < 4) return;
|
||||
|
||||
this.setStrokeStyle({
|
||||
width: this._strokeWidth,
|
||||
color: this._color,
|
||||
cap: 'round',
|
||||
join: 'round',
|
||||
});
|
||||
|
||||
this.moveTo(pts[0], pts[1]);
|
||||
|
||||
// Use quadratic curve smoothing for 3+ points
|
||||
if (pts.length >= 6) {
|
||||
for (let i = 2; i < pts.length - 2; i += 2) {
|
||||
const mx = (pts[i] + pts[i + 2]) / 2;
|
||||
const my = (pts[i + 1] + pts[i + 3]) / 2;
|
||||
this.quadraticCurveTo(pts[i], pts[i + 1], mx, my);
|
||||
}
|
||||
// Last segment
|
||||
this.lineTo(pts[pts.length - 2], pts[pts.length - 1]);
|
||||
} else {
|
||||
this.lineTo(pts[2], pts[3]);
|
||||
}
|
||||
|
||||
this.stroke();
|
||||
}
|
||||
|
||||
override destroy(options?: any): void {
|
||||
if (this._rafId !== null) {
|
||||
cancelAnimationFrame(this._rafId);
|
||||
this._rafId = null;
|
||||
}
|
||||
super.destroy(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* FrameSprite — visual container for groups/frames.
|
||||
*
|
||||
* Renders as a colored BORDER (not fill) with rounded corners + title label.
|
||||
* Extends Container. The border and label are the first children,
|
||||
* so actual group children render on top.
|
||||
* Lightweight: one Graphics rect + one Text. No filters or shaders.
|
||||
*/
|
||||
|
||||
import { Container, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import type { GroupObject } from '../scene-format';
|
||||
|
||||
const DEFAULT_PADDING = 16;
|
||||
const LABEL_FONT_SIZE = 11;
|
||||
const BORDER_WIDTH = 2;
|
||||
const CORNER_RADIUS = 6;
|
||||
|
||||
const FRAME_COLORS = [
|
||||
'#4a90d9', '#69db7c', '#ff6b6b', '#7950f2', '#38d9a9',
|
||||
'#ffa94d', '#e64980', '#20c997', '#4dabf7', '#ffd43b',
|
||||
];
|
||||
|
||||
/** Pick a random frame color for new groups. */
|
||||
export function randomFrameColor(): string {
|
||||
return FRAME_COLORS[Math.floor(Math.random() * FRAME_COLORS.length)];
|
||||
}
|
||||
|
||||
export class FrameSprite extends Container {
|
||||
private _bg: Graphics;
|
||||
private _label: Text;
|
||||
private _labelBg: Graphics;
|
||||
private _bgColor: string;
|
||||
private _padding: number;
|
||||
private _frameW: number;
|
||||
private _frameH: number;
|
||||
private _labelText: string;
|
||||
|
||||
constructor(w: number, h: number, bgColor?: string, label?: string, padding?: number) {
|
||||
super();
|
||||
|
||||
this._bgColor = bgColor || '';
|
||||
this._padding = padding ?? DEFAULT_PADDING;
|
||||
this._frameW = w;
|
||||
this._frameH = h;
|
||||
this._labelText = label || '';
|
||||
|
||||
// Border rect (drawn first = behind everything)
|
||||
this._bg = new Graphics();
|
||||
this._bg.label = '__frame_bg';
|
||||
this.addChild(this._bg);
|
||||
|
||||
// Label background pill
|
||||
this._labelBg = new Graphics();
|
||||
this._labelBg.label = '__frame_labelbg';
|
||||
this.addChild(this._labelBg);
|
||||
|
||||
// Title label
|
||||
this._label = new Text({
|
||||
text: this._labelText,
|
||||
style: new TextStyle({
|
||||
fontSize: LABEL_FONT_SIZE,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fill: '#ffffff',
|
||||
fontWeight: '600',
|
||||
}),
|
||||
});
|
||||
this._label.label = '__frame_label';
|
||||
this.addChild(this._label);
|
||||
|
||||
this._redraw();
|
||||
}
|
||||
|
||||
get bgColor(): string { return this._bgColor; }
|
||||
get padding(): number { return this._padding; }
|
||||
|
||||
setBgColor(color: string): void {
|
||||
this._bgColor = color;
|
||||
this._redraw();
|
||||
}
|
||||
|
||||
setLabel(text: string): void {
|
||||
this._labelText = text;
|
||||
this._label.text = text;
|
||||
this._redraw();
|
||||
}
|
||||
|
||||
/** Update frame dimensions (call after children bounds change). */
|
||||
setFrameSize(w: number, h: number): void {
|
||||
this._frameW = w;
|
||||
this._frameH = h;
|
||||
this._redraw();
|
||||
}
|
||||
|
||||
/** Update from GroupObject data. */
|
||||
updateFromData(data: GroupObject): void {
|
||||
this._bgColor = data.bgColor || '';
|
||||
this._labelText = data.label || '';
|
||||
this._padding = data.padding ?? DEFAULT_PADDING;
|
||||
this._frameW = data.w;
|
||||
this._frameH = data.h;
|
||||
this._label.text = this._labelText;
|
||||
this._redraw();
|
||||
}
|
||||
|
||||
private _redraw(): void {
|
||||
this._bg.clear();
|
||||
this._labelBg.clear();
|
||||
|
||||
if (!this._bgColor) {
|
||||
this._bg.visible = false;
|
||||
this._labelBg.visible = false;
|
||||
this._label.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._bg.visible = true;
|
||||
const pad = this._padding;
|
||||
const color = parseInt(this._bgColor.replace('#', ''), 16);
|
||||
|
||||
// Draw border-only rounded rect (no fill, just stroke)
|
||||
this._bg.roundRect(-pad, -pad, this._frameW + pad * 2, this._frameH + pad * 2, CORNER_RADIUS);
|
||||
this._bg.stroke({
|
||||
color,
|
||||
alpha: 0.6,
|
||||
width: BORDER_WIDTH,
|
||||
});
|
||||
|
||||
// Label positioned at top-left corner, overlapping the border
|
||||
const hasLabel = this._labelText.length > 0;
|
||||
this._label.visible = hasLabel;
|
||||
this._labelBg.visible = hasLabel;
|
||||
|
||||
if (hasLabel) {
|
||||
const lx = -pad;
|
||||
const ly = -pad - LABEL_FONT_SIZE - 6;
|
||||
|
||||
this._label.position.set(lx + 8, ly + 3);
|
||||
|
||||
// Background pill behind label text
|
||||
const lw = this._label.width + 16;
|
||||
const lh = LABEL_FONT_SIZE + 6;
|
||||
this._labelBg.roundRect(lx, ly, lw, lh, CORNER_RADIUS);
|
||||
this._labelBg.fill({ color, alpha: 0.8 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,24 @@
|
||||
import { Sprite, Texture, Graphics } from "pixi.js";
|
||||
import { DropShadowFilter } from "pixi-filters";
|
||||
import { TextureManager, LODTier } from "../TextureManager";
|
||||
import { Container, Sprite, Texture, Graphics } from "pixi.js";
|
||||
import { TextureManager } from "../TextureManager";
|
||||
|
||||
/**
|
||||
* A Sprite subclass that manages LOD tier switching and lazy loading.
|
||||
* Shows a placeholder shimmer rect until the first texture tier loads,
|
||||
* then swaps textures as zoom level changes.
|
||||
* A Container holding a shadow graphic + sprite with lazy texture loading.
|
||||
* Uses a lightweight Graphics shadow instead of DropShadowFilter (GPU-heavy).
|
||||
*/
|
||||
// Shadow defaults (resting state)
|
||||
const SHADOW_REST = { offsetX: 3, offsetY: 3, blur: 4, alpha: 0.25 };
|
||||
const SHADOW_REST = { offsetX: 3, offsetY: 3, alpha: 0.2 };
|
||||
// Shadow lifted state (during drag)
|
||||
const SHADOW_LIFT = { offsetX: 6, offsetY: 8, blur: 8, alpha: 0.35 };
|
||||
const SHADOW_LIFT = { offsetX: 6, offsetY: 8, alpha: 0.3 };
|
||||
|
||||
export class ImageSprite extends Sprite {
|
||||
export class ImageSprite extends Container {
|
||||
readonly assetKey: string;
|
||||
readonly shadow: DropShadowFilter;
|
||||
|
||||
private textures: TextureManager;
|
||||
private currentTier: LODTier | null = null;
|
||||
private loaded = false;
|
||||
private loading = false;
|
||||
private placeholder: Graphics | null = null;
|
||||
private _sprite: Sprite;
|
||||
private _shadow: Graphics;
|
||||
private _naturalWidth: number;
|
||||
private _naturalHeight: number;
|
||||
|
||||
@@ -29,24 +28,23 @@ export class ImageSprite extends Sprite {
|
||||
h: number,
|
||||
textures: TextureManager,
|
||||
) {
|
||||
super(Texture.EMPTY);
|
||||
super();
|
||||
|
||||
this.assetKey = assetKey;
|
||||
this.textures = textures;
|
||||
this._naturalWidth = w;
|
||||
this._naturalHeight = h;
|
||||
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
// Shadow: simple dark rect behind the sprite (cheap, no GPU filter)
|
||||
this._shadow = new Graphics();
|
||||
this._drawShadow(SHADOW_REST);
|
||||
this.addChild(this._shadow);
|
||||
|
||||
// Drop shadow for photos-on-a-desk feel
|
||||
this.shadow = new DropShadowFilter({
|
||||
offset: { x: SHADOW_REST.offsetX, y: SHADOW_REST.offsetY },
|
||||
blur: SHADOW_REST.blur,
|
||||
alpha: SHADOW_REST.alpha,
|
||||
color: 0x000000,
|
||||
});
|
||||
this.filters = [this.shadow];
|
||||
// Main sprite
|
||||
this._sprite = new Sprite(Texture.EMPTY);
|
||||
this._sprite.width = w;
|
||||
this._sprite.height = h;
|
||||
this.addChild(this._sprite);
|
||||
|
||||
// Create placeholder: dark rect shown until first texture loads
|
||||
const placeholder = new Graphics();
|
||||
@@ -54,40 +52,39 @@ export class ImageSprite extends Sprite {
|
||||
this.placeholder = placeholder;
|
||||
this.addChild(placeholder);
|
||||
|
||||
// Immediately start loading the thumbnail tier
|
||||
this.loadTier("thumb");
|
||||
// Immediately start loading the full texture
|
||||
this.loadTexture();
|
||||
}
|
||||
|
||||
private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void {
|
||||
this._shadow.clear();
|
||||
this._shadow.rect(cfg.offsetX, cfg.offsetY, this._naturalWidth, this._naturalHeight);
|
||||
this._shadow.fill({ color: 0x000000, alpha: cfg.alpha });
|
||||
}
|
||||
|
||||
/** Expand shadow for drag-lift effect. */
|
||||
liftShadow(): void {
|
||||
this.shadow.offset = { x: SHADOW_LIFT.offsetX, y: SHADOW_LIFT.offsetY };
|
||||
this.shadow.blur = SHADOW_LIFT.blur;
|
||||
this.shadow.alpha = SHADOW_LIFT.alpha;
|
||||
this._drawShadow(SHADOW_LIFT);
|
||||
}
|
||||
|
||||
/** Restore shadow to resting state. */
|
||||
dropShadow(): void {
|
||||
this.shadow.offset = { x: SHADOW_REST.offsetX, y: SHADOW_REST.offsetY };
|
||||
this.shadow.blur = SHADOW_REST.blur;
|
||||
this.shadow.alpha = SHADOW_REST.alpha;
|
||||
this._drawShadow(SHADOW_REST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific LOD tier texture for this sprite.
|
||||
* Skips if already at the requested tier or currently loading.
|
||||
*/
|
||||
async loadTier(tier: LODTier): Promise<void> {
|
||||
if (this.currentTier === tier || this.loading) return;
|
||||
/** Load the full-res texture. GPU handles scaling natively. */
|
||||
async loadTexture(): Promise<void> {
|
||||
if (this.loaded || this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const tex = await this.textures.load(this.assetKey, tier);
|
||||
this.texture = tex;
|
||||
this.width = this._naturalWidth;
|
||||
this.height = this._naturalHeight;
|
||||
this.currentTier = tier;
|
||||
const tex = await this.textures.load(this.assetKey);
|
||||
this._sprite.texture = tex;
|
||||
this._sprite.width = this._naturalWidth;
|
||||
this._sprite.height = this._naturalHeight;
|
||||
this.loaded = true;
|
||||
|
||||
// Remove placeholder after first successful load
|
||||
// Remove placeholder after successful load
|
||||
if (this.placeholder) {
|
||||
this.removeChild(this.placeholder);
|
||||
this.placeholder.destroy();
|
||||
@@ -95,29 +92,11 @@ export class ImageSprite extends Sprite {
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[ImageSprite] Failed to load tier "${tier}" for "${this.assetKey}":`,
|
||||
`[ImageSprite] Failed to load "${this.assetKey}":`,
|
||||
err,
|
||||
);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the current zoom level and switch LOD tier if needed.
|
||||
*/
|
||||
updateLOD(zoom: number): void {
|
||||
const needed = this.textures.tierForZoom(zoom);
|
||||
if (needed !== this.currentTier) {
|
||||
this.loadTier(needed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the current texture (for off-screen sprites to free GPU memory).
|
||||
*/
|
||||
unloadTexture(): void {
|
||||
this.texture = Texture.EMPTY;
|
||||
this.currentTier = null;
|
||||
}
|
||||
}
|
||||
|
||||
+196
-51
@@ -1,47 +1,128 @@
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import type { SceneManager, SceneItem } from './SceneManager';
|
||||
import type { SceneData } from './scene-format';
|
||||
import type { SceneData, AnySceneObject } from './scene-format';
|
||||
|
||||
/**
|
||||
* Full-scene sync (v2 format, PixiJS / SceneManager):
|
||||
* Sync protocol v3 — Excalidraw-inspired incremental element sync.
|
||||
*
|
||||
* 1. On any change → broadcast serialized SceneData (throttled 300ms)
|
||||
* 2. On receive → sceneManager.loadScene() (diff-based, no flicker)
|
||||
* 3. During drag → lightweight transform events (throttled 50ms)
|
||||
* Three event tiers:
|
||||
* 1. scene:update — full scene snapshot. Sent on join (INIT) and periodically
|
||||
* to correct any drift. Receiver does full reconciliation via loadScene().
|
||||
* 2. element:update — array of changed elements only. Sent on any element
|
||||
* change (add, modify, draw). Receiver merges incrementally — no full scene
|
||||
* diff needed. Each element carries a `_v` version; receiver skips stale.
|
||||
* 3. element:remove — array of removed element IDs. Receiver deletes them.
|
||||
* 4. object:transform — ephemeral position/scale during drag (unchanged).
|
||||
*
|
||||
* Diff-based loading doesn't trigger change events for unchanged objects,
|
||||
* so no suppress/resume logic is needed.
|
||||
* During freehand drawing, only the single drawing element is sent via
|
||||
* element:update every ~60ms (one frame) — tiny payload, no lag.
|
||||
*/
|
||||
|
||||
export interface SyncHandle {
|
||||
cleanup: () => void;
|
||||
broadcastTransform: (item: SceneItem) => void;
|
||||
/** Force an immediate full scene broadcast (call after structural changes). */
|
||||
broadcastSceneNow: () => void;
|
||||
/** Broadcast only specific changed elements (lightweight). */
|
||||
broadcastElements: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
onRemoteTransform?: (item: SceneItem) => void;
|
||||
}
|
||||
|
||||
export function setupSync(
|
||||
sceneManager: SceneManager,
|
||||
socket: Socket,
|
||||
boardId: string,
|
||||
): () => void {
|
||||
let sceneTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
options?: SyncOptions,
|
||||
): SyncHandle {
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let moveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let receiving = false; // true while applying a remote scene/transform
|
||||
const SCENE_THROTTLE = 300; // ms
|
||||
const MOVE_THROTTLE = 50; // ms
|
||||
let receiving = false;
|
||||
let localVersion = 0;
|
||||
let remoteVersion = 0;
|
||||
const DEBOUNCE_MS = 500;
|
||||
const MOVE_THROTTLE = 50;
|
||||
|
||||
// ---- BROADCAST: full scene (throttled) --------------------------------
|
||||
// Track broadcasted element versions (like Excalidraw's broadcastedElementVersions)
|
||||
const broadcastedVersions: Map<string, number> = new Map();
|
||||
|
||||
function broadcastScene() {
|
||||
if (receiving) return;
|
||||
if (sceneTimer) clearTimeout(sceneTimer);
|
||||
sceneTimer = setTimeout(() => {
|
||||
sceneTimer = null;
|
||||
if (receiving) return;
|
||||
const scene = sceneManager.serialize();
|
||||
socket.emit('scene:update', { boardId, scene });
|
||||
}, SCENE_THROTTLE);
|
||||
// Per-element version counter — bumped whenever an element changes locally
|
||||
const elementVersions: Map<string, number> = new Map();
|
||||
|
||||
function bumpElementVersion(id: string): number {
|
||||
const v = (elementVersions.get(id) ?? 0) + 1;
|
||||
elementVersions.set(id, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
// ---- BROADCAST: lightweight transform during drag ---------------------
|
||||
// ---- BROADCAST: incremental element update --------------------------------
|
||||
|
||||
/** Send only the specified elements. Skips if element hasn't changed since last broadcast. */
|
||||
function broadcastElements(ids: string[]) {
|
||||
if (receiving) return;
|
||||
|
||||
const elements: (AnySceneObject & { _v: number })[] = [];
|
||||
for (const id of ids) {
|
||||
const item = sceneManager.getById(id);
|
||||
if (!item) continue;
|
||||
|
||||
const v = elementVersions.get(id) ?? 0;
|
||||
const lastBroadcasted = broadcastedVersions.get(id) ?? -1;
|
||||
if (v <= lastBroadcasted) continue;
|
||||
|
||||
elements.push({ ...item.data, _v: v });
|
||||
broadcastedVersions.set(id, v);
|
||||
}
|
||||
|
||||
if (elements.length === 0) return;
|
||||
socket.emit('element:update', { boardId, elements });
|
||||
}
|
||||
|
||||
/** Broadcast all elements that changed since last broadcast. */
|
||||
function broadcastChangedElements() {
|
||||
if (receiving) return;
|
||||
const ids: string[] = [];
|
||||
for (const [id, v] of elementVersions) {
|
||||
if (v > (broadcastedVersions.get(id) ?? -1)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
if (ids.length > 0) broadcastElements(ids);
|
||||
}
|
||||
|
||||
// ---- BROADCAST: full scene (for INIT / periodic resync) -------------------
|
||||
|
||||
function broadcastSceneNow() {
|
||||
if (receiving) return;
|
||||
if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; }
|
||||
localVersion++;
|
||||
const scene = sceneManager.serialize();
|
||||
|
||||
// Update all broadcasted versions
|
||||
for (const obj of scene.objects) {
|
||||
const v = elementVersions.get(obj.id) ?? 0;
|
||||
broadcastedVersions.set(obj.id, v);
|
||||
}
|
||||
|
||||
socket.emit('scene:update', { boardId, scene, version: localVersion });
|
||||
}
|
||||
|
||||
function broadcastSceneDebounced() {
|
||||
if (receiving) return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
broadcastSceneNow();
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// ---- BROADCAST: lightweight transform during drag -------------------------
|
||||
|
||||
function broadcastTransform(item: SceneItem) {
|
||||
if (receiving) return;
|
||||
if (moveTimer) return; // throttled
|
||||
if (moveTimer) return;
|
||||
const { id, data } = item;
|
||||
socket.emit('object:transform', {
|
||||
boardId,
|
||||
@@ -52,22 +133,34 @@ export function setupSync(
|
||||
sy: data.sy,
|
||||
angle: data.angle,
|
||||
});
|
||||
moveTimer = setTimeout(() => {
|
||||
moveTimer = null;
|
||||
}, MOVE_THROTTLE);
|
||||
moveTimer = setTimeout(() => { moveTimer = null; }, MOVE_THROTTLE);
|
||||
broadcastSceneDebounced();
|
||||
}
|
||||
|
||||
// ---- RECEIVE: full scene ----------------------------------------------
|
||||
// ---- RECEIVE: full scene --------------------------------------------------
|
||||
|
||||
function onSceneReceived(payload: any) {
|
||||
if (payload.boardId !== boardId) return;
|
||||
const scene: SceneData = payload.scene;
|
||||
if (!scene || scene.v !== 2) return;
|
||||
|
||||
const incomingVersion = payload.version ?? 0;
|
||||
if (incomingVersion > 0 && incomingVersion <= remoteVersion) return;
|
||||
remoteVersion = incomingVersion;
|
||||
if (incomingVersion >= localVersion) localVersion = incomingVersion;
|
||||
|
||||
receiving = true;
|
||||
sceneManager
|
||||
.loadScene(scene)
|
||||
.then(() => {
|
||||
// Update element versions from received data
|
||||
for (const obj of scene.objects) {
|
||||
const v = (obj as any)._v;
|
||||
if (typeof v === 'number') {
|
||||
const current = elementVersions.get(obj.id) ?? 0;
|
||||
if (v > current) elementVersions.set(obj.id, v);
|
||||
}
|
||||
}
|
||||
receiving = false;
|
||||
})
|
||||
.catch((err: any) => {
|
||||
@@ -76,42 +169,91 @@ export function setupSync(
|
||||
});
|
||||
}
|
||||
|
||||
// ---- RECEIVE: lightweight transform -----------------------------------
|
||||
// ---- RECEIVE: incremental element update ----------------------------------
|
||||
|
||||
function onElementUpdate(payload: any) {
|
||||
if (payload.boardId !== boardId) return;
|
||||
const elements: (AnySceneObject & { _v?: number })[] = payload.elements;
|
||||
if (!Array.isArray(elements) || elements.length === 0) return;
|
||||
|
||||
for (const data of elements) {
|
||||
const incomingV = data._v ?? 0;
|
||||
|
||||
// Clean the _v field before storing
|
||||
const cleanData = { ...data };
|
||||
delete (cleanData as any)._v;
|
||||
|
||||
const existing = sceneManager.getById(data.id);
|
||||
if (existing) {
|
||||
// Update existing — apply incremental merge
|
||||
sceneManager._updateItem(existing, cleanData);
|
||||
} else {
|
||||
// New element — create it
|
||||
sceneManager._createItem(cleanData, false);
|
||||
}
|
||||
|
||||
// Track the version
|
||||
if (incomingV > 0) {
|
||||
const current = elementVersions.get(data.id) ?? 0;
|
||||
if (incomingV > current) elementVersions.set(data.id, incomingV);
|
||||
}
|
||||
}
|
||||
|
||||
sceneManager._applyZOrder();
|
||||
}
|
||||
|
||||
// ---- RECEIVE: element removal ---------------------------------------------
|
||||
|
||||
function onElementRemove(payload: any) {
|
||||
if (payload.boardId !== boardId) return;
|
||||
const ids: string[] = payload.ids;
|
||||
if (!Array.isArray(ids)) return;
|
||||
for (const id of ids) {
|
||||
sceneManager.removeItem(id, true);
|
||||
elementVersions.delete(id);
|
||||
broadcastedVersions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- RECEIVE: lightweight transform ---------------------------------------
|
||||
|
||||
function onTransformReceived(payload: any) {
|
||||
if (payload.boardId !== boardId) return;
|
||||
const item = sceneManager.getById(payload.objectId);
|
||||
if (!item) return;
|
||||
|
||||
// Update data model
|
||||
item.data.x = payload.x;
|
||||
item.data.y = payload.y;
|
||||
item.data.sx = payload.sx;
|
||||
item.data.sy = payload.sy;
|
||||
item.data.angle = payload.angle;
|
||||
|
||||
// Update display object
|
||||
const obj = item.displayObject;
|
||||
obj.position.set(payload.x, payload.y);
|
||||
obj.scale.set(payload.sx, payload.sy);
|
||||
obj.angle = payload.angle;
|
||||
// No onChange — this is a remote update
|
||||
|
||||
options?.onRemoteTransform?.(item);
|
||||
}
|
||||
|
||||
// ---- Wire up SceneManager onChange → broadcastScene --------------------
|
||||
// ---- Wire up SceneManager onChange → debounced full sync -------------------
|
||||
|
||||
const prevOnChange = sceneManager.onChange;
|
||||
sceneManager.onChange = () => {
|
||||
prevOnChange?.();
|
||||
broadcastScene();
|
||||
// Structural change — schedule a full scene sync (debounced).
|
||||
// Incremental element sync is handled explicitly by tools via broadcastElements().
|
||||
broadcastSceneDebounced();
|
||||
};
|
||||
|
||||
// ---- Bind socket events -----------------------------------------------
|
||||
// ---- Bind socket events ---------------------------------------------------
|
||||
|
||||
socket.on('scene:update', onSceneReceived);
|
||||
socket.on('element:update', onElementUpdate);
|
||||
socket.on('element:remove', onElementRemove);
|
||||
socket.on('object:transform', onTransformReceived);
|
||||
|
||||
// ---- Join room --------------------------------------------------------
|
||||
// ---- Join room ------------------------------------------------------------
|
||||
|
||||
socket.emit('board:join', { boardId }, (response: any) => {
|
||||
if (response?.users) {
|
||||
@@ -119,21 +261,24 @@ export function setupSync(
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Cleanup ----------------------------------------------------------
|
||||
// ---- Return handle --------------------------------------------------------
|
||||
|
||||
return () => {
|
||||
// Restore previous onChange
|
||||
sceneManager.onChange = prevOnChange;
|
||||
|
||||
socket.off('scene:update', onSceneReceived);
|
||||
socket.off('object:transform', onTransformReceived);
|
||||
|
||||
socket.emit('board:leave', { boardId });
|
||||
|
||||
if (sceneTimer) clearTimeout(sceneTimer);
|
||||
if (moveTimer) clearTimeout(moveTimer);
|
||||
return {
|
||||
broadcastTransform,
|
||||
broadcastSceneNow,
|
||||
broadcastElements(ids: string[]) {
|
||||
for (const id of ids) bumpElementVersion(id);
|
||||
broadcastElements(ids);
|
||||
},
|
||||
cleanup: () => {
|
||||
sceneManager.onChange = prevOnChange;
|
||||
socket.off('scene:update', onSceneReceived);
|
||||
socket.off('element:update', onElementUpdate);
|
||||
socket.off('element:remove', onElementRemove);
|
||||
socket.off('object:transform', onTransformReceived);
|
||||
socket.emit('board:leave', { boardId });
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (moveTimer) clearTimeout(moveTimer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Note: broadcastTransform for drag events will be wired via Editor integration.
|
||||
// SelectionManager / TransformBox will call it directly once connected.
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { Viewport } from 'pixi-viewport';
|
||||
import type { SceneManager } from './SceneManager';
|
||||
import type { SelectionManager } from './SelectionManager';
|
||||
import { Text, TextStyle } from 'pixi.js';
|
||||
import { DrawingSprite } from './sprites/DrawingSprite';
|
||||
import type { DrawingObject } from './scene-format';
|
||||
|
||||
export enum ToolType {
|
||||
SELECT = 'SELECT',
|
||||
@@ -40,6 +42,8 @@ export interface ToolContext {
|
||||
selection: SelectionManager;
|
||||
container: HTMLElement;
|
||||
onChange: () => void;
|
||||
/** Broadcast only specific changed elements (lightweight, for live drawing). */
|
||||
broadcastElements?: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export function activateTool(
|
||||
@@ -53,6 +57,9 @@ export function activateTool(
|
||||
// Reset cursor
|
||||
container.style.cursor = '';
|
||||
|
||||
// Enable/disable SelectionManager based on tool
|
||||
selection.setEnabled(tool === ToolType.SELECT);
|
||||
|
||||
switch (tool) {
|
||||
case ToolType.SELECT: {
|
||||
container.style.cursor = 'default';
|
||||
@@ -96,6 +103,7 @@ export function activateTool(
|
||||
|
||||
scene._createItem(textData, true);
|
||||
scene._applyZOrder();
|
||||
ctx.broadcastElements?.([textData.id]);
|
||||
ctx.onChange();
|
||||
|
||||
// Remove handler after placing text
|
||||
@@ -110,12 +118,17 @@ export function activateTool(
|
||||
|
||||
case ToolType.ERASER: {
|
||||
container.style.cursor = 'crosshair';
|
||||
// Clear any existing selection/transform box when switching to eraser
|
||||
selection.clear();
|
||||
selection.transformBox.update([]);
|
||||
|
||||
const onClick = (e: PointerEvent) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||
const hit = selection._hitTest(world.x, world.y);
|
||||
if (hit) {
|
||||
selection.selectedIds.delete(hit.id);
|
||||
selection.transformBox.update([]);
|
||||
scene.removeItem(hit.id, true);
|
||||
ctx.onChange();
|
||||
}
|
||||
@@ -129,8 +142,131 @@ export function activateTool(
|
||||
|
||||
case ToolType.PEN: {
|
||||
container.style.cursor = 'crosshair';
|
||||
// Drawing mode stub — will be implemented later
|
||||
return null;
|
||||
|
||||
let drawing = false;
|
||||
let currentSprite: DrawingSprite | null = null;
|
||||
let originX = 0;
|
||||
let originY = 0;
|
||||
let itemId = '';
|
||||
let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const SYNC_INTERVAL = 100; // ~10fps — lightweight element-only sync
|
||||
|
||||
const scheduleLiveSync = () => {
|
||||
if (syncTimer) return;
|
||||
syncTimer = setTimeout(() => {
|
||||
syncTimer = null;
|
||||
if (!drawing) return;
|
||||
// Copy current points into item.data for serialization
|
||||
const item = scene.getById(itemId);
|
||||
if (item && currentSprite) {
|
||||
(item.data as DrawingObject).points = [...currentSprite.points];
|
||||
}
|
||||
// Send only the drawing element, not the entire scene
|
||||
ctx.broadcastElements?.([itemId]);
|
||||
}, SYNC_INTERVAL);
|
||||
};
|
||||
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
drawing = true;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||
originX = world.x;
|
||||
originY = world.y;
|
||||
|
||||
itemId = crypto.randomUUID();
|
||||
const drawData: DrawingObject = {
|
||||
id: itemId,
|
||||
type: 'drawing',
|
||||
x: originX,
|
||||
y: originY,
|
||||
w: 1,
|
||||
h: 1,
|
||||
sx: 1,
|
||||
sy: 1,
|
||||
angle: 0,
|
||||
z: scene.nextZ(),
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
name: '',
|
||||
visible: true,
|
||||
points: [0, 0],
|
||||
color: opts.color!,
|
||||
strokeWidth: opts.strokeWidth!,
|
||||
};
|
||||
|
||||
scene._createItem(drawData, false);
|
||||
const item = scene.getById(itemId);
|
||||
if (item && item.displayObject instanceof DrawingSprite) {
|
||||
currentSprite = item.displayObject as DrawingSprite;
|
||||
}
|
||||
container.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!drawing || !currentSprite) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||
currentSprite.addPoint(world.x - originX, world.y - originY);
|
||||
scheduleLiveSync();
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
if (!drawing) return;
|
||||
drawing = false;
|
||||
if (syncTimer) { clearTimeout(syncTimer); syncTimer = null; }
|
||||
|
||||
// Compute bounding box and normalize points so origin = top-left of stroke
|
||||
const item = scene.getById(itemId);
|
||||
if (item && currentSprite) {
|
||||
const pts = currentSprite.points;
|
||||
if (pts.length < 4) {
|
||||
scene.removeItem(itemId, false);
|
||||
} else {
|
||||
// Find bounds of all points
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
minX = Math.min(minX, pts[i]);
|
||||
minY = Math.min(minY, pts[i + 1]);
|
||||
maxX = Math.max(maxX, pts[i]);
|
||||
maxY = Math.max(maxY, pts[i + 1]);
|
||||
}
|
||||
|
||||
const sw = opts.strokeWidth!;
|
||||
// Shift all points so min = strokeWidth/2 (padding for stroke)
|
||||
const normalized: number[] = [];
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
normalized.push(pts[i] - minX + sw / 2);
|
||||
normalized.push(pts[i + 1] - minY + sw / 2);
|
||||
}
|
||||
|
||||
// Update origin to account for the shift
|
||||
item.data.x = originX + minX - sw / 2;
|
||||
item.data.y = originY + minY - sw / 2;
|
||||
item.data.w = Math.max(maxX - minX + sw, 1);
|
||||
item.data.h = Math.max(maxY - minY + sw, 1);
|
||||
(item.data as DrawingObject).points = normalized;
|
||||
|
||||
// Redraw with normalized points and reposition
|
||||
currentSprite.setPoints(normalized);
|
||||
item.displayObject.position.set(item.data.x, item.data.y);
|
||||
}
|
||||
}
|
||||
|
||||
currentSprite = null;
|
||||
scene._applyZOrder();
|
||||
ctx.onChange();
|
||||
};
|
||||
|
||||
container.addEventListener('pointerdown', onDown);
|
||||
container.addEventListener('pointermove', onMove);
|
||||
container.addEventListener('pointerup', onUp);
|
||||
return () => {
|
||||
if (syncTimer) clearTimeout(syncTimer);
|
||||
container.removeEventListener('pointerdown', onDown);
|
||||
container.removeEventListener('pointermove', onMove);
|
||||
container.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user