feat: text tool UX, image crop, text format toolbar, double-click focus
- Text tool: click-to-place immediately opens inline editor, auto-switches back to select tool. Empty text cleanup on save. - Crop: select image + press C (or right-click > Crop) to enter crop mode. 8 drag handles with rule-of-thirds grid, dimmed outside area. Enter confirms, Escape cancels. Non-destructive (stored as normalized rect). - Text format toolbar: appears when text items selected, with font size +/-, font family dropdown, and color picker with presets. - Double-click image: zoom-to-fit (PureRef-style focus) - Entrance animation: replaced bounce with simple fade-in (no delay before items become interactive) - TextEditor: allow empty text (caller handles cleanup)
This commit is contained in:
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* CropOverlay — interactive crop handles over an image.
|
||||||
|
*
|
||||||
|
* Shows a crop rectangle with 8 drag handles and a dimmed area outside.
|
||||||
|
* The overlay is positioned in world-space as a child of the viewport.
|
||||||
|
* All crop values are normalized 0-1 relative to the image's natural dimensions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
|
||||||
|
import type { Viewport } from 'pixi-viewport';
|
||||||
|
import type { SceneItem } from './SceneManager';
|
||||||
|
import type { ImageObject, CropRect } from './scene-format';
|
||||||
|
import { ImageSprite } from './sprites/ImageSprite';
|
||||||
|
|
||||||
|
const HANDLE_SIZE = 8;
|
||||||
|
const HANDLE_FILL = 0xffffff;
|
||||||
|
const HANDLE_STROKE = 0x4a90d9;
|
||||||
|
const BORDER_COLOR = 0x4a90d9;
|
||||||
|
const DIM_ALPHA = 0.6;
|
||||||
|
const MIN_CROP = 0.05; // minimum 5% of image in each dimension
|
||||||
|
|
||||||
|
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br';
|
||||||
|
|
||||||
|
export class CropOverlay extends Container {
|
||||||
|
private _item: SceneItem | null = null;
|
||||||
|
private _viewport: Viewport;
|
||||||
|
private _dim: Graphics;
|
||||||
|
private _border: Graphics;
|
||||||
|
private _handles = new Map<HandleId, Graphics>();
|
||||||
|
private _crop: CropRect = { x: 0, y: 0, w: 1, h: 1 };
|
||||||
|
private _drag: { handleId: HandleId; startCrop: CropRect } | null = null;
|
||||||
|
private _onConfirm: ((item: SceneItem, crop: CropRect) => void) | null = null;
|
||||||
|
private _onCancel: (() => void) | null = null;
|
||||||
|
private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(viewport: Viewport) {
|
||||||
|
super();
|
||||||
|
this._viewport = viewport;
|
||||||
|
this.label = '__crop_overlay';
|
||||||
|
this.visible = false;
|
||||||
|
this.eventMode = 'static';
|
||||||
|
|
||||||
|
// Dim overlay (darkens area outside crop)
|
||||||
|
this._dim = new Graphics();
|
||||||
|
this._dim.eventMode = 'none';
|
||||||
|
this.addChild(this._dim);
|
||||||
|
|
||||||
|
// Crop border
|
||||||
|
this._border = new Graphics();
|
||||||
|
this._border.eventMode = 'none';
|
||||||
|
this.addChild(this._border);
|
||||||
|
|
||||||
|
// Create handles
|
||||||
|
const ids: HandleId[] = ['tl', 'tc', 'tr', 'ml', 'mr', 'bl', 'bc', 'br'];
|
||||||
|
const cursors: Record<HandleId, string> = {
|
||||||
|
tl: 'nwse-resize', tr: 'nesw-resize', bl: 'nesw-resize', br: 'nwse-resize',
|
||||||
|
tc: 'ns-resize', bc: 'ns-resize', ml: 'ew-resize', mr: 'ew-resize',
|
||||||
|
};
|
||||||
|
for (const id of ids) {
|
||||||
|
const h = new Graphics();
|
||||||
|
h.eventMode = 'static';
|
||||||
|
h.cursor = cursors[id];
|
||||||
|
h.on('pointerdown', (e: FederatedPointerEvent) => this._onDown(e, id));
|
||||||
|
h.on('globalpointermove', (e: FederatedPointerEvent) => this._onMove(e));
|
||||||
|
h.on('pointerup', () => this._onUp());
|
||||||
|
h.on('pointerupoutside', () => this._onUp());
|
||||||
|
this._handles.set(id, h);
|
||||||
|
this.addChild(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set onConfirm(fn: (item: SceneItem, crop: CropRect) => void) { this._onConfirm = fn; }
|
||||||
|
set onCancel(fn: () => void) { this._onCancel = fn; }
|
||||||
|
|
||||||
|
/** Start cropping the given image item. */
|
||||||
|
start(item: SceneItem): void {
|
||||||
|
if (item.type !== 'image') return;
|
||||||
|
this._item = item;
|
||||||
|
const imgData = item.data as ImageObject;
|
||||||
|
this._crop = imgData.crop ? { ...imgData.crop } : { x: 0, y: 0, w: 1, h: 1 };
|
||||||
|
this.visible = true;
|
||||||
|
this._draw();
|
||||||
|
|
||||||
|
// Key bindings
|
||||||
|
this._keyHandler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); this.confirm(); }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); this.cancel(); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', this._keyHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm the crop and apply it. */
|
||||||
|
confirm(): void {
|
||||||
|
if (!this._item) return;
|
||||||
|
const item = this._item;
|
||||||
|
const crop = { ...this._crop };
|
||||||
|
// If crop is full image, remove it
|
||||||
|
const isFullImage = crop.x < 0.001 && crop.y < 0.001 && crop.w > 0.999 && crop.h > 0.999;
|
||||||
|
this._cleanup();
|
||||||
|
this._onConfirm?.(item, isFullImage ? { x: 0, y: 0, w: 1, h: 1 } : crop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel cropping — restore original state. */
|
||||||
|
cancel(): void {
|
||||||
|
this._cleanup();
|
||||||
|
this._onCancel?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
get isActive(): boolean { return this._item !== null; }
|
||||||
|
|
||||||
|
private _cleanup(): void {
|
||||||
|
this._item = null;
|
||||||
|
this._drag = null;
|
||||||
|
this.visible = false;
|
||||||
|
if (this._keyHandler) {
|
||||||
|
window.removeEventListener('keydown', this._keyHandler);
|
||||||
|
this._keyHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _draw(): void {
|
||||||
|
if (!this._item) return;
|
||||||
|
const data = this._item.data;
|
||||||
|
const zoom = this._viewport.scale.x;
|
||||||
|
|
||||||
|
// Image world rect
|
||||||
|
const ix = data.x;
|
||||||
|
const iy = data.y;
|
||||||
|
const iw = data.w * data.sx;
|
||||||
|
const ih = data.h * data.sy;
|
||||||
|
|
||||||
|
// Crop rect in world space
|
||||||
|
const cx = ix + this._crop.x * iw;
|
||||||
|
const cy = iy + this._crop.y * ih;
|
||||||
|
const cw = this._crop.w * iw;
|
||||||
|
const ch = this._crop.h * ih;
|
||||||
|
|
||||||
|
// Dim: draw full image rect, then cut out the crop area using inverted mask approach
|
||||||
|
// Simpler: draw 4 dim rectangles around the crop area
|
||||||
|
this._dim.clear();
|
||||||
|
// Top
|
||||||
|
if (cy > iy) {
|
||||||
|
this._dim.rect(ix, iy, iw, cy - iy);
|
||||||
|
this._dim.fill({ color: 0x000000, alpha: DIM_ALPHA });
|
||||||
|
}
|
||||||
|
// Bottom
|
||||||
|
const cropBottom = cy + ch;
|
||||||
|
if (cropBottom < iy + ih) {
|
||||||
|
this._dim.rect(ix, cropBottom, iw, iy + ih - cropBottom);
|
||||||
|
this._dim.fill({ color: 0x000000, alpha: DIM_ALPHA });
|
||||||
|
}
|
||||||
|
// Left
|
||||||
|
if (cx > ix) {
|
||||||
|
this._dim.rect(ix, cy, cx - ix, ch);
|
||||||
|
this._dim.fill({ color: 0x000000, alpha: DIM_ALPHA });
|
||||||
|
}
|
||||||
|
// Right
|
||||||
|
const cropRight = cx + cw;
|
||||||
|
if (cropRight < ix + iw) {
|
||||||
|
this._dim.rect(cropRight, cy, ix + iw - cropRight, ch);
|
||||||
|
this._dim.fill({ color: 0x000000, alpha: DIM_ALPHA });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Border around crop area
|
||||||
|
this._border.clear();
|
||||||
|
this._border.rect(cx, cy, cw, ch);
|
||||||
|
this._border.stroke({ color: BORDER_COLOR, width: 1.5 / zoom });
|
||||||
|
|
||||||
|
// Rule of thirds lines
|
||||||
|
const thirdW = cw / 3;
|
||||||
|
const thirdH = ch / 3;
|
||||||
|
for (let i = 1; i <= 2; i++) {
|
||||||
|
this._border.moveTo(cx + thirdW * i, cy);
|
||||||
|
this._border.lineTo(cx + thirdW * i, cy + ch);
|
||||||
|
this._border.moveTo(cx, cy + thirdH * i);
|
||||||
|
this._border.lineTo(cx + cw, cy + thirdH * i);
|
||||||
|
}
|
||||||
|
this._border.stroke({ color: 0xffffff, width: 0.5 / zoom, alpha: 0.3 });
|
||||||
|
|
||||||
|
// Position handles
|
||||||
|
const positions: Record<HandleId, { px: number; py: number }> = {
|
||||||
|
tl: { px: cx, py: cy },
|
||||||
|
tc: { px: cx + cw / 2, py: cy },
|
||||||
|
tr: { px: cx + cw, py: cy },
|
||||||
|
ml: { px: cx, py: cy + ch / 2 },
|
||||||
|
mr: { px: cx + cw, py: cy + ch / 2 },
|
||||||
|
bl: { px: cx, py: cy + ch },
|
||||||
|
bc: { px: cx + cw / 2, py: cy + ch },
|
||||||
|
br: { px: cx + cw, py: cy + ch },
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = HANDLE_SIZE / zoom;
|
||||||
|
const half = s / 2;
|
||||||
|
for (const [id, handle] of this._handles) {
|
||||||
|
const pos = positions[id];
|
||||||
|
handle.clear();
|
||||||
|
handle.rect(-half, -half, s, s);
|
||||||
|
handle.fill(HANDLE_FILL);
|
||||||
|
handle.stroke({ color: HANDLE_STROKE, width: 1 / zoom });
|
||||||
|
handle.position.set(pos.px, pos.py);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Handle drag --
|
||||||
|
|
||||||
|
private _onDown(e: FederatedPointerEvent, id: HandleId): void {
|
||||||
|
e.stopPropagation();
|
||||||
|
this._drag = { handleId: id, startCrop: { ...this._crop } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onMove(e: FederatedPointerEvent): void {
|
||||||
|
if (!this._drag || !this._item) return;
|
||||||
|
|
||||||
|
const data = this._item.data;
|
||||||
|
const iw = data.w * data.sx;
|
||||||
|
const ih = data.h * data.sy;
|
||||||
|
const world = this._viewport.toWorld(e.global.x, e.global.y);
|
||||||
|
|
||||||
|
// Convert world position to normalized image coordinates
|
||||||
|
const nx = (world.x - data.x) / iw;
|
||||||
|
const ny = (world.y - data.y) / ih;
|
||||||
|
|
||||||
|
const { handleId, startCrop: sc } = this._drag;
|
||||||
|
const crop = { ...sc };
|
||||||
|
|
||||||
|
switch (handleId) {
|
||||||
|
case 'tl':
|
||||||
|
crop.x = Math.max(0, Math.min(nx, sc.x + sc.w - MIN_CROP));
|
||||||
|
crop.y = Math.max(0, Math.min(ny, sc.y + sc.h - MIN_CROP));
|
||||||
|
crop.w = sc.x + sc.w - crop.x;
|
||||||
|
crop.h = sc.y + sc.h - crop.y;
|
||||||
|
break;
|
||||||
|
case 'tr':
|
||||||
|
crop.w = Math.max(MIN_CROP, Math.min(1 - sc.x, nx - sc.x));
|
||||||
|
crop.y = Math.max(0, Math.min(ny, sc.y + sc.h - MIN_CROP));
|
||||||
|
crop.h = sc.y + sc.h - crop.y;
|
||||||
|
break;
|
||||||
|
case 'bl':
|
||||||
|
crop.x = Math.max(0, Math.min(nx, sc.x + sc.w - MIN_CROP));
|
||||||
|
crop.w = sc.x + sc.w - crop.x;
|
||||||
|
crop.h = Math.max(MIN_CROP, Math.min(1 - sc.y, ny - sc.y));
|
||||||
|
break;
|
||||||
|
case 'br':
|
||||||
|
crop.w = Math.max(MIN_CROP, Math.min(1 - sc.x, nx - sc.x));
|
||||||
|
crop.h = Math.max(MIN_CROP, Math.min(1 - sc.y, ny - sc.y));
|
||||||
|
break;
|
||||||
|
case 'tc':
|
||||||
|
crop.y = Math.max(0, Math.min(ny, sc.y + sc.h - MIN_CROP));
|
||||||
|
crop.h = sc.y + sc.h - crop.y;
|
||||||
|
break;
|
||||||
|
case 'bc':
|
||||||
|
crop.h = Math.max(MIN_CROP, Math.min(1 - sc.y, ny - sc.y));
|
||||||
|
break;
|
||||||
|
case 'ml':
|
||||||
|
crop.x = Math.max(0, Math.min(nx, sc.x + sc.w - MIN_CROP));
|
||||||
|
crop.w = sc.x + sc.w - crop.x;
|
||||||
|
break;
|
||||||
|
case 'mr':
|
||||||
|
crop.w = Math.max(MIN_CROP, Math.min(1 - sc.x, nx - sc.x));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._crop = crop;
|
||||||
|
this._draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onUp(): void {
|
||||||
|
this._drag = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -291,7 +291,9 @@ export class SceneManager {
|
|||||||
if (isGifAsset(imgData.asset)) {
|
if (isGifAsset(imgData.asset)) {
|
||||||
displayObject = new AnimatedGifSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
displayObject = new AnimatedGifSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
||||||
} else {
|
} else {
|
||||||
displayObject = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
const imgSprite = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
||||||
|
if (imgData.crop) imgSprite.applyCrop(imgData.crop);
|
||||||
|
displayObject = imgSprite;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -378,49 +380,11 @@ export class SceneManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate entrance: spring scale from center 0 → 1.05 → 1.0 with fade-in
|
// Animate entrance: quick fade-in (no bounce — items are immediately interactive)
|
||||||
if (animate) {
|
if (animate) {
|
||||||
displayObject.scale.set(0, 0);
|
|
||||||
displayObject.alpha = 0;
|
displayObject.alpha = 0;
|
||||||
|
|
||||||
// Scale from center by adjusting position to keep center point fixed
|
const alphaSpring = new Spring(0, data.opacity, PRESETS.snappy);
|
||||||
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 = () => {
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
this.springs.add(scaleSpring);
|
|
||||||
|
|
||||||
const alphaSpring = new Spring(0, data.opacity, PRESETS.gentle);
|
|
||||||
alphaSpring.onUpdate = (v) => {
|
alphaSpring.onUpdate = (v) => {
|
||||||
if (!displayObject.destroyed) {
|
if (!displayObject.destroyed) {
|
||||||
displayObject.alpha = v;
|
displayObject.alpha = v;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export class SelectionManager {
|
|||||||
private static readonly DOUBLE_CLICK_MS = 400;
|
private static readonly DOUBLE_CLICK_MS = 400;
|
||||||
|
|
||||||
private _onDoubleClickText: ((item: SceneItem) => void) | null = null;
|
private _onDoubleClickText: ((item: SceneItem) => void) | null = null;
|
||||||
|
private _onDoubleClickImage: ((item: SceneItem) => void) | null = null;
|
||||||
|
|
||||||
private _enabled = true;
|
private _enabled = true;
|
||||||
|
|
||||||
@@ -142,6 +143,11 @@ export class SelectionManager {
|
|||||||
this._onDoubleClickText = fn;
|
this._onDoubleClickText = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Called on double-click of an image item (zoom-to-fit). */
|
||||||
|
set onDoubleClickImage(fn: (item: SceneItem) => void) {
|
||||||
|
this._onDoubleClickImage = fn;
|
||||||
|
}
|
||||||
|
|
||||||
/** Select only this item, deselecting everything else. */
|
/** Select only this item, deselecting everything else. */
|
||||||
selectOnly(id: string): void {
|
selectOnly(id: string): void {
|
||||||
this.selectedIds.clear();
|
this.selectedIds.clear();
|
||||||
@@ -357,6 +363,8 @@ export class SelectionManager {
|
|||||||
item.displayObject.togglePlayPause();
|
item.displayObject.togglePlayPause();
|
||||||
} else if (item.type === 'text') {
|
} else if (item.type === 'text') {
|
||||||
this._onDoubleClickText?.(item);
|
this._onDoubleClickText?.(item);
|
||||||
|
} else if (item.type === 'image' || item.type === 'drawing') {
|
||||||
|
this._onDoubleClickImage?.(item);
|
||||||
}
|
}
|
||||||
this._lastClickTime = 0;
|
this._lastClickTime = 0;
|
||||||
this._lastClickItemId = null;
|
this._lastClickItemId = null;
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export class TextEditor {
|
|||||||
const data = item.data as TextObject;
|
const data = item.data as TextObject;
|
||||||
|
|
||||||
if (save) {
|
if (save) {
|
||||||
const newText = ta.value || this._originalText; // Don't allow empty
|
const newText = ta.value; // Allow empty — caller handles cleanup
|
||||||
data.text = newText;
|
data.text = newText;
|
||||||
pixiText.text = newText;
|
pixiText.text = newText;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface MenuItem {
|
|||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StartCropFn = (() => void) | null;
|
||||||
|
|
||||||
interface MenuContext {
|
interface MenuContext {
|
||||||
scene: SceneManager | null;
|
scene: SceneManager | null;
|
||||||
selection: SelectionManager | null;
|
selection: SelectionManager | null;
|
||||||
@@ -31,6 +33,7 @@ interface MenuContext {
|
|||||||
handleGroup: () => void;
|
handleGroup: () => void;
|
||||||
handleUngroup: () => void;
|
handleUngroup: () => void;
|
||||||
fitAll: () => void;
|
fitAll: () => void;
|
||||||
|
startCrop?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildContextMenuItems(ctx: MenuContext): MenuItem[] {
|
export function buildContextMenuItems(ctx: MenuContext): MenuItem[] {
|
||||||
@@ -152,6 +155,7 @@ export function buildContextMenuItems(ctx: MenuContext): MenuItem[] {
|
|||||||
// -- Image --
|
// -- Image --
|
||||||
{ label: 'Flip Horizontal', shortcut: 'Alt+Shift+H', onClick: () => { ops.flipHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
|
{ label: 'Flip Horizontal', shortcut: 'Alt+Shift+H', onClick: () => { ops.flipHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
|
||||||
{ label: 'Flip Vertical', shortcut: 'Alt+Shift+V', onClick: () => { ops.flipVertical(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
|
{ label: 'Flip Vertical', shortcut: 'Alt+Shift+V', onClick: () => { ops.flipVertical(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
|
||||||
|
{ label: 'Crop', shortcut: 'C', onClick: () => ctx.startCrop?.(), disabled: selected.length !== 1 || selected[0]?.data.type !== 'image' },
|
||||||
{ label: 'Reset Transform', shortcut: 'Ctrl+Shift+T', onClick: () => { ops.resetTransform(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
|
{ label: 'Reset Transform', shortcut: 'Ctrl+Shift+T', onClick: () => { ops.resetTransform(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
|
||||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,19 @@ export interface SceneObject {
|
|||||||
flipY?: boolean;
|
flipY?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CropRect {
|
||||||
|
/** Normalized 0-1 values relative to original image dimensions */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImageObject extends SceneObject {
|
export interface ImageObject extends SceneObject {
|
||||||
type: 'image';
|
type: 'image';
|
||||||
asset: string;
|
asset: string;
|
||||||
filters: string[];
|
filters: string[];
|
||||||
|
crop?: CropRect;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoObject extends SceneObject {
|
export interface VideoObject extends SceneObject {
|
||||||
|
|||||||
@@ -715,4 +715,14 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
ctx.toggleReviewMode?.();
|
ctx.toggleReviewMode?.();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'crop',
|
||||||
|
keys: { key: 'c' },
|
||||||
|
category: 'image',
|
||||||
|
description: 'Crop image',
|
||||||
|
needsSelection: true,
|
||||||
|
handler: (ctx: ShortcutContext) => {
|
||||||
|
ctx.startCrop?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface ShortcutContext {
|
|||||||
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
|
writeCanvasToClipboard: (items?: SceneItem[]) => Promise<void>;
|
||||||
pasteFromSystemClipboard: () => Promise<string>;
|
pasteFromSystemClipboard: () => Promise<string>;
|
||||||
toggleReviewMode?: () => void;
|
toggleReviewMode?: () => void;
|
||||||
|
startCrop?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Container, Sprite, Texture, Graphics } from "pixi.js";
|
import { Container, Sprite, Texture, Graphics, Rectangle } from "pixi.js";
|
||||||
import { TextureManager } from "../TextureManager";
|
import { TextureManager } from "../TextureManager";
|
||||||
|
import type { CropRect } from "../scene-format";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Container holding a shadow graphic + sprite with lazy texture loading.
|
* A Container holding a shadow graphic + sprite with lazy texture loading.
|
||||||
@@ -24,6 +25,7 @@ export class ImageSprite extends Container {
|
|||||||
private placeholder: Graphics | null = null;
|
private placeholder: Graphics | null = null;
|
||||||
private _sprite: Sprite | null = null;
|
private _sprite: Sprite | null = null;
|
||||||
private _shadow: Graphics;
|
private _shadow: Graphics;
|
||||||
|
private _cropMask: Graphics | null = null;
|
||||||
private _naturalWidth: number;
|
private _naturalWidth: number;
|
||||||
private _naturalHeight: number;
|
private _naturalHeight: number;
|
||||||
|
|
||||||
@@ -54,9 +56,14 @@ export class ImageSprite extends Container {
|
|||||||
// NOTE: Sprite is created lazily in loadTexture() — NOT added here.
|
// NOTE: Sprite is created lazily in loadTexture() — NOT added here.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get naturalWidth(): number { return this._naturalWidth; }
|
||||||
|
get naturalHeight(): number { return this._naturalHeight; }
|
||||||
|
|
||||||
private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void {
|
private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void {
|
||||||
|
const w = this._naturalWidth;
|
||||||
|
const h = this._naturalHeight;
|
||||||
this._shadow.clear();
|
this._shadow.clear();
|
||||||
this._shadow.rect(cfg.offsetX, cfg.offsetY, this._naturalWidth, this._naturalHeight);
|
this._shadow.rect(cfg.offsetX, cfg.offsetY, w, h);
|
||||||
this._shadow.fill({ color: 0x000000, alpha: cfg.alpha });
|
this._shadow.fill({ color: 0x000000, alpha: cfg.alpha });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +82,48 @@ export class ImageSprite extends Container {
|
|||||||
return this._sprite?.texture ?? Texture.EMPTY;
|
return this._sprite?.texture ?? Texture.EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a crop mask. Normalized 0-1 values relative to natural dimensions.
|
||||||
|
* Pass null/undefined to remove crop.
|
||||||
|
*/
|
||||||
|
applyCrop(crop: CropRect | undefined): void {
|
||||||
|
if (!crop) {
|
||||||
|
// Remove crop
|
||||||
|
if (this._cropMask) {
|
||||||
|
if (this._sprite) this._sprite.mask = null;
|
||||||
|
this.removeChild(this._cropMask);
|
||||||
|
this._cropMask.destroy();
|
||||||
|
this._cropMask = null;
|
||||||
|
}
|
||||||
|
// Restore shadow to full size
|
||||||
|
this._drawShadow(SHADOW_REST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const px = crop.x * this._naturalWidth;
|
||||||
|
const py = crop.y * this._naturalHeight;
|
||||||
|
const pw = crop.w * this._naturalWidth;
|
||||||
|
const ph = crop.h * this._naturalHeight;
|
||||||
|
|
||||||
|
if (!this._cropMask) {
|
||||||
|
this._cropMask = new Graphics();
|
||||||
|
this.addChild(this._cropMask);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._cropMask.clear();
|
||||||
|
this._cropMask.rect(px, py, pw, ph);
|
||||||
|
this._cropMask.fill(0xffffff);
|
||||||
|
|
||||||
|
if (this._sprite) {
|
||||||
|
this._sprite.mask = this._cropMask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update shadow to match cropped area
|
||||||
|
this._shadow.clear();
|
||||||
|
this._shadow.rect(px + SHADOW_REST.offsetX, py + SHADOW_REST.offsetY, pw, ph);
|
||||||
|
this._shadow.fill({ color: 0x000000, alpha: SHADOW_REST.alpha });
|
||||||
|
}
|
||||||
|
|
||||||
/** Load the full-res texture. Called by viewport culling when near viewport. */
|
/** Load the full-res texture. Called by viewport culling when near viewport. */
|
||||||
async loadTexture(): Promise<void> {
|
async loadTexture(): Promise<void> {
|
||||||
if (this.loaded || this.loading) return;
|
if (this.loaded || this.loading) return;
|
||||||
@@ -93,6 +142,11 @@ export class ImageSprite extends Container {
|
|||||||
this.addChild(sprite);
|
this.addChild(sprite);
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
|
|
||||||
|
// Apply crop mask if one exists
|
||||||
|
if (this._cropMask) {
|
||||||
|
sprite.mask = this._cropMask;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove placeholder after successful load
|
// Remove placeholder after successful load
|
||||||
if (this.placeholder) {
|
if (this.placeholder) {
|
||||||
this.removeChild(this.placeholder);
|
this.removeChild(this.placeholder);
|
||||||
@@ -117,6 +171,7 @@ export class ImageSprite extends Container {
|
|||||||
|
|
||||||
// Remove sprite from display tree entirely — avoids PixiJS v8 render crash
|
// Remove sprite from display tree entirely — avoids PixiJS v8 render crash
|
||||||
if (this._sprite) {
|
if (this._sprite) {
|
||||||
|
this._sprite.mask = null;
|
||||||
this.removeChild(this._sprite);
|
this.removeChild(this._sprite);
|
||||||
this._sprite.destroy();
|
this._sprite.destroy();
|
||||||
this._sprite = null;
|
this._sprite = null;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { SelectionManager } from './SelectionManager';
|
|||||||
import { Text, TextStyle } from 'pixi.js';
|
import { Text, TextStyle } from 'pixi.js';
|
||||||
import { DrawingSprite } from './sprites/DrawingSprite';
|
import { DrawingSprite } from './sprites/DrawingSprite';
|
||||||
import type { DrawingObject } from './scene-format';
|
import type { DrawingObject } from './scene-format';
|
||||||
|
import { TextEditor } from './TextEditor';
|
||||||
|
|
||||||
export enum ToolType {
|
export enum ToolType {
|
||||||
SELECT = 'SELECT',
|
SELECT = 'SELECT',
|
||||||
@@ -44,6 +45,10 @@ export interface ToolContext {
|
|||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
/** Broadcast only specific changed elements (lightweight, for live drawing). */
|
/** Broadcast only specific changed elements (lightweight, for live drawing). */
|
||||||
broadcastElements?: (ids: string[]) => void;
|
broadcastElements?: (ids: string[]) => void;
|
||||||
|
/** Text editor instance for inline editing. */
|
||||||
|
textEditor?: TextEditor;
|
||||||
|
/** Switch back to select tool after placing text. */
|
||||||
|
switchToSelect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activateTool(
|
export function activateTool(
|
||||||
@@ -95,7 +100,7 @@ export function activateTool(
|
|||||||
locked: false,
|
locked: false,
|
||||||
name: '',
|
name: '',
|
||||||
visible: true,
|
visible: true,
|
||||||
text: 'Type here',
|
text: ' ', // placeholder — will be replaced by user input
|
||||||
fontSize: opts.fontSize!,
|
fontSize: opts.fontSize!,
|
||||||
fill: opts.color!,
|
fill: opts.color!,
|
||||||
fontFamily: 'sans-serif',
|
fontFamily: 'sans-serif',
|
||||||
@@ -106,6 +111,33 @@ export function activateTool(
|
|||||||
ctx.broadcastElements?.([textData.id]);
|
ctx.broadcastElements?.([textData.id]);
|
||||||
ctx.onChange();
|
ctx.onChange();
|
||||||
|
|
||||||
|
// Immediately open inline editor on the new text item
|
||||||
|
const item = scene.getById(textData.id);
|
||||||
|
if (item && ctx.textEditor) {
|
||||||
|
// Find the canvas DOM container
|
||||||
|
const canvasEl = container.querySelector('canvas');
|
||||||
|
const domContainer = canvasEl?.parentElement ?? container;
|
||||||
|
|
||||||
|
// Small delay to let PixiJS render the text sprite
|
||||||
|
setTimeout(() => {
|
||||||
|
ctx.textEditor!.startEditing(item, viewport, domContainer, () => {
|
||||||
|
// If user saved empty text, remove the item
|
||||||
|
const text = (item.data as any).text?.trim();
|
||||||
|
if (!text) {
|
||||||
|
scene.removeItem(item.id, true);
|
||||||
|
}
|
||||||
|
ctx.broadcastElements?.([item.id]);
|
||||||
|
ctx.onChange();
|
||||||
|
});
|
||||||
|
// Clear placeholder so user types from scratch
|
||||||
|
const ta = document.querySelector('textarea');
|
||||||
|
if (ta) { ta.value = ''; }
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch back to select tool after placing
|
||||||
|
ctx.switchToSelect?.();
|
||||||
|
|
||||||
// Remove handler after placing text
|
// Remove handler after placing text
|
||||||
container.removeEventListener('pointerdown', onClick);
|
container.removeEventListener('pointerdown', onClick);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface TextFormatToolbarProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
fontSize: number;
|
||||||
|
fontFamily: string;
|
||||||
|
fill: string;
|
||||||
|
position?: 'above' | 'below';
|
||||||
|
onFontSizeChange: (size: number) => void;
|
||||||
|
onFontFamilyChange: (family: string) => void;
|
||||||
|
onFillChange: (color: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_FAMILIES = [
|
||||||
|
'sans-serif',
|
||||||
|
'serif',
|
||||||
|
'monospace',
|
||||||
|
'Arial',
|
||||||
|
'Georgia',
|
||||||
|
'Courier New',
|
||||||
|
'Impact',
|
||||||
|
'Comic Sans MS',
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
'#ffffff', '#000000', '#ff6b6b', '#ffa94d', '#ffd43b',
|
||||||
|
'#69db7c', '#4dabf7', '#7950f2', '#e64980', '#868e96',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TextFormatToolbar(props: TextFormatToolbarProps) {
|
||||||
|
const { x, y, fontSize, fontFamily, fill, position = 'above', onFontSizeChange, onFontFamilyChange, onFillChange } = props;
|
||||||
|
const [showFontMenu, setShowFontMenu] = useState(false);
|
||||||
|
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||||
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Close dropdowns on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = () => { setShowFontMenu(false); setShowColorPicker(false); };
|
||||||
|
window.addEventListener('pointerdown', onDown);
|
||||||
|
return () => window.removeEventListener('pointerdown', onDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const btnStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '26px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '5px',
|
||||||
|
color: '#999',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0 6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
transition: 'all 0.1s',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hoverHandlers = {
|
||||||
|
onMouseEnter: (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.currentTarget.style.background = '#333';
|
||||||
|
e.currentTarget.style.color = '#fff';
|
||||||
|
},
|
||||||
|
onMouseLeave: (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
e.currentTarget.style.color = '#999';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: x,
|
||||||
|
top: y - 8,
|
||||||
|
transform: position === 'below' ? 'translate(-50%, 0)' : 'translate(-50%, -100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1px',
|
||||||
|
padding: '3px',
|
||||||
|
background: 'rgba(22, 22, 22, 0.96)',
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
|
||||||
|
zIndex: 100,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Font size: decrease / value / increase */}
|
||||||
|
<button
|
||||||
|
style={{ ...btnStyle, width: '26px', padding: 0 }}
|
||||||
|
onClick={() => onFontSizeChange(Math.max(8, fontSize - 2))}
|
||||||
|
title="Decrease font size"
|
||||||
|
{...hoverHandlers}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="2" y1="6" x2="10" y2="6" /></svg>
|
||||||
|
</button>
|
||||||
|
<span style={{ color: '#ccc', fontSize: '11px', minWidth: '24px', textAlign: 'center', userSelect: 'none' }}>
|
||||||
|
{fontSize}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
style={{ ...btnStyle, width: '26px', padding: 0 }}
|
||||||
|
onClick={() => onFontSizeChange(Math.min(200, fontSize + 2))}
|
||||||
|
title="Increase font size"
|
||||||
|
{...hoverHandlers}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="2" y1="6" x2="10" y2="6" /><line x1="6" y1="2" x2="6" y2="10" /></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ width: '1px', height: '18px', background: '#333', margin: '0 2px', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Font family dropdown */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
style={{ ...btnStyle, maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowFontMenu((v) => !v); setShowColorPicker(false); }}
|
||||||
|
title="Font family"
|
||||||
|
{...hoverHandlers}
|
||||||
|
>
|
||||||
|
{fontFamily}
|
||||||
|
</button>
|
||||||
|
{showFontMenu && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
marginTop: '4px',
|
||||||
|
background: 'rgba(22, 22, 22, 0.98)',
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '4px',
|
||||||
|
minWidth: '120px',
|
||||||
|
zIndex: 200,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{FONT_FAMILIES.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => { onFontFamilyChange(f); setShowFontMenu(false); }}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: f === fontFamily ? '#333' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: f === fontFamily ? '#fff' : '#999',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: f,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#333'; e.currentTarget.style.color = '#fff'; }}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = f === fontFamily ? '#333' : 'transparent';
|
||||||
|
e.currentTarget.style.color = f === fontFamily ? '#fff' : '#999';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ width: '1px', height: '18px', background: '#333', margin: '0 2px', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Color */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
style={{ ...btnStyle, width: '26px', padding: 0 }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowColorPicker((v) => !v); setShowFontMenu(false); }}
|
||||||
|
title="Text color"
|
||||||
|
{...hoverHandlers}
|
||||||
|
>
|
||||||
|
<div style={{ width: '14px', height: '14px', borderRadius: '3px', background: fill, border: '1px solid #555' }} />
|
||||||
|
</button>
|
||||||
|
{showColorPicker && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
right: 0,
|
||||||
|
marginTop: '4px',
|
||||||
|
background: 'rgba(22, 22, 22, 0.98)',
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
zIndex: 200,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '4px', marginBottom: '8px' }}>
|
||||||
|
{PRESET_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => { onFillChange(c); setShowColorPicker(false); }}
|
||||||
|
style={{
|
||||||
|
width: '22px',
|
||||||
|
height: '22px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: c,
|
||||||
|
border: c === fill ? '2px solid #4a90d9' : '1px solid #444',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={colorInputRef}
|
||||||
|
type="color"
|
||||||
|
value={fill}
|
||||||
|
onChange={(e) => { onFillChange(e.target.value); }}
|
||||||
|
style={{ width: '100%', height: '24px', border: 'none', background: 'transparent', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ import { UndoManager } from '../canvas/history';
|
|||||||
import { InboxZone } from '../canvas/InboxZone';
|
import { InboxZone } from '../canvas/InboxZone';
|
||||||
import { LaserPointer } from '../canvas/LaserPointer';
|
import { LaserPointer } from '../canvas/LaserPointer';
|
||||||
import { VideoSprite } from '../canvas/sprites/VideoSprite';
|
import { VideoSprite } from '../canvas/sprites/VideoSprite';
|
||||||
|
import { ImageSprite } from '../canvas/sprites/ImageSprite';
|
||||||
import { UploadManager } from '../stores/uploadManager';
|
import { UploadManager } from '../stores/uploadManager';
|
||||||
import { AnnotationStore } from '../stores/annotationStore';
|
import { AnnotationStore } from '../stores/annotationStore';
|
||||||
import { PinOverlay } from '../canvas/PinOverlay';
|
import { PinOverlay } from '../canvas/PinOverlay';
|
||||||
|
import { CropOverlay } from '../canvas/CropOverlay';
|
||||||
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
|
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
|
||||||
import { connectSocket, disconnectSocket } from '../socket';
|
import { connectSocket, disconnectSocket } from '../socket';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
@@ -70,6 +72,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
const annotationStoreRef = useRef<AnnotationStore | null>(null);
|
const annotationStoreRef = useRef<AnnotationStore | null>(null);
|
||||||
if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore();
|
if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore();
|
||||||
const pinOverlayRef = useRef<PinOverlay | null>(null);
|
const pinOverlayRef = useRef<PinOverlay | null>(null);
|
||||||
|
const textEditorRef = useRef<TextEditor | null>(null);
|
||||||
|
if (!textEditorRef.current) textEditorRef.current = new TextEditor();
|
||||||
|
const cropOverlayRef = useRef<CropOverlay | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!boardData || !resolvedBoardId) return;
|
if (!boardData || !resolvedBoardId) return;
|
||||||
@@ -102,7 +107,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Inline text editing on double-click
|
// Inline text editing on double-click
|
||||||
const textEditor = new TextEditor();
|
const textEditor = textEditorRef.current!;
|
||||||
// Find the DOM container for the canvas (parent of the <canvas> element)
|
// Find the DOM container for the canvas (parent of the <canvas> element)
|
||||||
const canvasElements = document.querySelectorAll('canvas');
|
const canvasElements = document.querySelectorAll('canvas');
|
||||||
let domContainer: HTMLElement | null = null;
|
let domContainer: HTMLElement | null = null;
|
||||||
@@ -115,11 +120,35 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
selection.onDoubleClickText = (item) => {
|
selection.onDoubleClickText = (item) => {
|
||||||
if (!domContainer) return;
|
if (!domContainer) return;
|
||||||
textEditor.startEditing(item, viewport, domContainer, () => {
|
textEditor.startEditing(item, viewport, domContainer, () => {
|
||||||
|
// If user saved empty text, remove the item
|
||||||
|
const text = (item.data as any).text?.trim();
|
||||||
|
if (!text) {
|
||||||
|
scene.removeItem(item.id, true);
|
||||||
|
}
|
||||||
syncRef.current?.broadcastElements([item.id]);
|
syncRef.current?.broadcastElements([item.id]);
|
||||||
onCanvasChange();
|
onCanvasChange();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Double-click image: zoom-to-fit (PureRef-style focus)
|
||||||
|
selection.onDoubleClickImage = (item) => {
|
||||||
|
const bounds = item.displayObject.getBounds();
|
||||||
|
const padding = 80; // screen pixels of padding around the image
|
||||||
|
const screenW = viewport.screenWidth;
|
||||||
|
const screenH = viewport.screenHeight;
|
||||||
|
const scaleX = (screenW - padding * 2) / bounds.width;
|
||||||
|
const scaleY = (screenH - padding * 2) / bounds.height;
|
||||||
|
const targetScale = Math.min(scaleX, scaleY, 3); // cap at 3x
|
||||||
|
const cx = item.data.x + (item.data.w * item.data.sx) / 2;
|
||||||
|
const cy = item.data.y + (item.data.h * item.data.sy) / 2;
|
||||||
|
viewport.animate({
|
||||||
|
time: 300,
|
||||||
|
position: { x: cx, y: cy },
|
||||||
|
scale: targetScale,
|
||||||
|
ease: 'easeOutQuad',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Refresh transform box when item dimensions change (e.g. video metadata loaded)
|
// Refresh transform box when item dimensions change (e.g. video metadata loaded)
|
||||||
scene.onItemDimensionsChanged = (itemId: string) => {
|
scene.onItemDimensionsChanged = (itemId: string) => {
|
||||||
if (selection.selectedIds.has(itemId)) {
|
if (selection.selectedIds.has(itemId)) {
|
||||||
@@ -364,6 +393,26 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Crop overlay
|
||||||
|
const cropOverlay = new CropOverlay(viewport);
|
||||||
|
viewport.addChild(cropOverlay);
|
||||||
|
cropOverlayRef.current = cropOverlay;
|
||||||
|
|
||||||
|
cropOverlay.onConfirm = (item, crop) => {
|
||||||
|
const imgData = item.data as any;
|
||||||
|
const isFullImage = crop.x < 0.001 && crop.y < 0.001 && crop.w > 0.999 && crop.h > 0.999;
|
||||||
|
imgData.crop = isFullImage ? undefined : crop;
|
||||||
|
if (item.displayObject instanceof ImageSprite) {
|
||||||
|
item.displayObject.applyCrop(imgData.crop);
|
||||||
|
}
|
||||||
|
selection.setEnabled(true);
|
||||||
|
onCanvasChange([item.id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
cropOverlay.onCancel = () => {
|
||||||
|
selection.setEnabled(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Setup drag/drop and paste
|
// Setup drag/drop and paste
|
||||||
if (!isPublicView || user) {
|
if (!isPublicView || user) {
|
||||||
// Use the ref first, fall back to DOM query for the canvas parent
|
// Use the ref first, fall back to DOM query for the canvas parent
|
||||||
@@ -393,6 +442,10 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
laserCleanupRef.current = null;
|
laserCleanupRef.current = null;
|
||||||
dropCleanupRef.current?.();
|
dropCleanupRef.current?.();
|
||||||
pasteCleanupRef.current?.();
|
pasteCleanupRef.current?.();
|
||||||
|
if (cropOverlayRef.current) {
|
||||||
|
cropOverlayRef.current.destroy();
|
||||||
|
cropOverlayRef.current = null;
|
||||||
|
}
|
||||||
if (pinOverlayRef.current) {
|
if (pinOverlayRef.current) {
|
||||||
(pinOverlayRef.current as any)._cleanup?.();
|
(pinOverlayRef.current as any)._cleanup?.();
|
||||||
pinOverlayRef.current.destroy();
|
pinOverlayRef.current.destroy();
|
||||||
@@ -403,5 +456,5 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
};
|
};
|
||||||
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds]);
|
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds]);
|
||||||
|
|
||||||
return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current };
|
return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current, textEditor: textEditorRef.current, cropOverlay: cropOverlayRef.current };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface ShortcutHandlerDeps {
|
|||||||
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setFocusMode: React.Dispatch<React.SetStateAction<boolean>>;
|
setFocusMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setReviewMode: React.Dispatch<React.SetStateAction<boolean>>;
|
setReviewMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
startCrop?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +44,7 @@ export function useShortcutHandler(deps: ShortcutHandlerDeps) {
|
|||||||
handleGroup, handleUngroup,
|
handleGroup, handleUngroup,
|
||||||
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
||||||
setShowGrid, setShowHelp, setFocusMode, setReviewMode,
|
setShowGrid, setShowHelp, setFocusMode, setReviewMode,
|
||||||
|
startCrop,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,6 +93,7 @@ export function useShortcutHandler(deps: ShortcutHandlerDeps) {
|
|||||||
toggleShowHelp: () => setShowHelp((v) => !v),
|
toggleShowHelp: () => setShowHelp((v) => !v),
|
||||||
toggleFocusMode: () => setFocusMode((v) => !v),
|
toggleFocusMode: () => setFocusMode((v) => !v),
|
||||||
toggleReviewMode: () => setReviewMode((v) => !v),
|
toggleReviewMode: () => setReviewMode((v) => !v),
|
||||||
|
startCrop,
|
||||||
pasteFromSystemClipboard: async (): Promise<string> => {
|
pasteFromSystemClipboard: async (): Promise<string> => {
|
||||||
if (!resolvedBoardId) return 'No board';
|
if (!resolvedBoardId) return 'No board';
|
||||||
const msg = await pasteFromSystemClipboard(scene, viewport, resolvedBoardId, onCanvasChange);
|
const msg = await pasteFromSystemClipboard(scene, viewport, resolvedBoardId, onCanvasChange);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import UserCursors from '../components/UserCursors';
|
|||||||
import ContextMenu from '../components/ContextMenu';
|
import ContextMenu from '../components/ContextMenu';
|
||||||
import LayerPanel from '../components/LayerPanel';
|
import LayerPanel from '../components/LayerPanel';
|
||||||
import SelectionToolbar from '../components/SelectionToolbar';
|
import SelectionToolbar from '../components/SelectionToolbar';
|
||||||
|
import TextFormatToolbar from '../components/TextFormatToolbar';
|
||||||
import VideoControls from '../components/VideoControls';
|
import VideoControls from '../components/VideoControls';
|
||||||
import ShortcutsHelp from '../components/ShortcutsHelp';
|
import ShortcutsHelp from '../components/ShortcutsHelp';
|
||||||
import MattermostImport from '../components/MattermostImport';
|
import MattermostImport from '../components/MattermostImport';
|
||||||
@@ -30,6 +31,7 @@ import FeedbackPanel from '../components/feedback/FeedbackPanel';
|
|||||||
import { UploadManager } from '../stores/uploadManager';
|
import { UploadManager } from '../stores/uploadManager';
|
||||||
import { InboxZone } from '../canvas/InboxZone';
|
import { InboxZone } from '../canvas/InboxZone';
|
||||||
import { getItemWorldBounds } from '../canvas/SceneManager';
|
import { getItemWorldBounds } from '../canvas/SceneManager';
|
||||||
|
import type { TextObject } from '../canvas/scene-format';
|
||||||
import { VideoSprite } from '../canvas/sprites/VideoSprite';
|
import { VideoSprite } from '../canvas/sprites/VideoSprite';
|
||||||
import * as ops from '../canvas/operations';
|
import * as ops from '../canvas/operations';
|
||||||
|
|
||||||
@@ -97,6 +99,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const [layerList, setLayerList] = useState<any[]>([]);
|
const [layerList, setLayerList] = useState<any[]>([]);
|
||||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||||
const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null);
|
const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null);
|
||||||
|
const [textToolbar, setTextToolbar] = useState<{ x: number; y: number; fontSize: number; fontFamily: string; fill: string; items: SceneItem[] } | null>(null);
|
||||||
const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null);
|
const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null);
|
||||||
const [showMinimap, setShowMinimap] = useState(true);
|
const [showMinimap, setShowMinimap] = useState(true);
|
||||||
const [minimapData, setMinimapData] = useState<{ items: any[]; viewportBounds: any; contentBounds: any }>({
|
const [minimapData, setMinimapData] = useState<{ items: any[]; viewportBounds: any; contentBounds: any }>({
|
||||||
@@ -166,7 +169,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox, annotations)
|
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox, annotations)
|
||||||
const { annotationStore, pinOverlay } = useCanvasSetup({
|
const { annotationStore, pinOverlay, textEditor, cropOverlay } = useCanvasSetup({
|
||||||
boardData, resolvedBoardId, user, isPublicView,
|
boardData, resolvedBoardId, user, isPublicView,
|
||||||
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
|
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
|
||||||
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
||||||
@@ -229,9 +232,11 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
viewport, scene, selection, container: domContainer,
|
viewport, scene, selection, container: domContainer,
|
||||||
onChange: onCanvasChange,
|
onChange: onCanvasChange,
|
||||||
broadcastElements: (ids) => syncRef.current?.broadcastElements(ids),
|
broadcastElements: (ids) => syncRef.current?.broadcastElements(ids),
|
||||||
|
textEditor: textEditor ?? undefined,
|
||||||
|
switchToSelect: () => setActiveTool(ToolType.SELECT),
|
||||||
};
|
};
|
||||||
toolCleanupRef.current = activateTool(ctx, activeTool, { color, strokeWidth, fontSize });
|
toolCleanupRef.current = activateTool(ctx, activeTool, { color, strokeWidth, fontSize });
|
||||||
}, [activeTool, color, strokeWidth, fontSize, onCanvasChange]);
|
}, [activeTool, color, strokeWidth, fontSize, onCanvasChange, textEditor]);
|
||||||
|
|
||||||
// Layer panel
|
// Layer panel
|
||||||
const { refreshLayers: refreshLayerData, layerHandlers } = useLayerPanel({ canvasRef, selectionRef, onCanvasChange });
|
const { refreshLayers: refreshLayerData, layerHandlers } = useLayerPanel({ canvasRef, selectionRef, onCanvasChange });
|
||||||
@@ -289,6 +294,17 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
handleGroup, handleUngroup,
|
handleGroup, handleUngroup,
|
||||||
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
setActiveTool, setCanUndo, setCanRedo, setZoom,
|
||||||
setShowGrid, setShowHelp, setFocusMode, setReviewMode,
|
setShowGrid, setShowHelp, setFocusMode, setReviewMode,
|
||||||
|
startCrop: () => {
|
||||||
|
const selection = selectionRef.current;
|
||||||
|
if (!selection || !cropOverlay) return;
|
||||||
|
const items = selection.getSelectedItems();
|
||||||
|
if (items.length !== 1 || items[0].type !== 'image') {
|
||||||
|
showToast('Select a single image to crop');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selection.setEnabled(false);
|
||||||
|
cropOverlay.start(items[0]);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Context menu
|
// Context menu
|
||||||
@@ -309,8 +325,16 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
handleGroup,
|
handleGroup,
|
||||||
handleUngroup,
|
handleUngroup,
|
||||||
fitAll: () => canvasRef.current?.fitAll(),
|
fitAll: () => canvasRef.current?.fitAll(),
|
||||||
|
startCrop: () => {
|
||||||
|
const selection = selectionRef.current;
|
||||||
|
if (!selection || !cropOverlay) return;
|
||||||
|
const items = selection.getSelectedItems();
|
||||||
|
if (items.length !== 1 || items[0].type !== 'image') return;
|
||||||
|
selection.setEnabled(false);
|
||||||
|
cropOverlay.start(items[0]);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, refreshLayers]);
|
}, [writeCanvasToClipboard, onCanvasChange, handleGroup, handleUngroup, refreshLayers, cropOverlay]);
|
||||||
|
|
||||||
// Save on page unload (only for users with edit access)
|
// Save on page unload (only for users with edit access)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -362,6 +386,37 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
setVideoCtrl(null);
|
setVideoCtrl(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Text format toolbar: show when all selected items are text (1 or more)
|
||||||
|
const textItems = items.filter((it) => it.type === 'text');
|
||||||
|
if (textItems.length > 0 && textItems.length === items.length) {
|
||||||
|
// Use first item's properties as representative
|
||||||
|
const td = textItems[0].data as TextObject;
|
||||||
|
let minX2 = Infinity, minY2 = Infinity, maxX2 = -Infinity, maxY2 = -Infinity;
|
||||||
|
for (const item of textItems) {
|
||||||
|
const b = getItemWorldBounds(item);
|
||||||
|
if (b.x < minX2) minX2 = b.x;
|
||||||
|
if (b.y < minY2) minY2 = b.y;
|
||||||
|
if (b.x + b.w > maxX2) maxX2 = b.x + b.w;
|
||||||
|
if (b.y + b.h > maxY2) maxY2 = b.y + b.h;
|
||||||
|
}
|
||||||
|
const screenTL2 = vp.toScreen(minX2, minY2);
|
||||||
|
const screenTR2 = vp.toScreen(maxX2, minY2);
|
||||||
|
const screenBL2 = vp.toScreen(minX2, maxY2);
|
||||||
|
const screenBR2 = vp.toScreen(maxX2, maxY2);
|
||||||
|
// Single text: show above; multiple: show below (selection toolbar is above)
|
||||||
|
const useBottom = textItems.length >= 2;
|
||||||
|
setTextToolbar({
|
||||||
|
x: useBottom ? (screenBL2.x + screenBR2.x) / 2 : (screenTL2.x + screenTR2.x) / 2,
|
||||||
|
y: useBottom ? (screenBL2.y + screenBR2.y) / 2 + 8 : screenTL2.y,
|
||||||
|
fontSize: td.fontSize,
|
||||||
|
fontFamily: td.fontFamily,
|
||||||
|
fill: td.fill,
|
||||||
|
items: textItems,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTextToolbar(null);
|
||||||
|
}
|
||||||
|
|
||||||
if (items.length < 2) { setSelToolbar(null); } else {
|
if (items.length < 2) { setSelToolbar(null); } else {
|
||||||
// Compute world bounding box of selection
|
// Compute world bounding box of selection
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity;
|
let minX = Infinity, minY = Infinity, maxX = -Infinity;
|
||||||
@@ -635,6 +690,45 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Text format toolbar (floating, above selected text items) */}
|
||||||
|
{textToolbar && activeTool === ToolType.SELECT && !contextMenu && (
|
||||||
|
<TextFormatToolbar
|
||||||
|
x={textToolbar.x}
|
||||||
|
y={textToolbar.y}
|
||||||
|
fontSize={textToolbar.fontSize}
|
||||||
|
fontFamily={textToolbar.fontFamily}
|
||||||
|
fill={textToolbar.fill}
|
||||||
|
position={textToolbar.items.length >= 2 ? 'below' : 'above'}
|
||||||
|
onFontSizeChange={(size) => {
|
||||||
|
for (const item of textToolbar.items) {
|
||||||
|
(item.data as TextObject).fontSize = size;
|
||||||
|
const s = (item.displayObject as any)?.style;
|
||||||
|
if (s) s.fontSize = size;
|
||||||
|
}
|
||||||
|
onCanvasChange(textToolbar.items.map(i => i.id));
|
||||||
|
updateOverlays();
|
||||||
|
}}
|
||||||
|
onFontFamilyChange={(family) => {
|
||||||
|
for (const item of textToolbar.items) {
|
||||||
|
(item.data as TextObject).fontFamily = family;
|
||||||
|
const s = (item.displayObject as any)?.style;
|
||||||
|
if (s) s.fontFamily = family;
|
||||||
|
}
|
||||||
|
onCanvasChange(textToolbar.items.map(i => i.id));
|
||||||
|
updateOverlays();
|
||||||
|
}}
|
||||||
|
onFillChange={(color) => {
|
||||||
|
for (const item of textToolbar.items) {
|
||||||
|
(item.data as TextObject).fill = color;
|
||||||
|
const s = (item.displayObject as any)?.style;
|
||||||
|
if (s) s.fill = color;
|
||||||
|
}
|
||||||
|
onCanvasChange(textToolbar.items.map(i => i.id));
|
||||||
|
updateOverlays();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Video controls (floating, below selected video) */}
|
{/* Video controls (floating, below selected video) */}
|
||||||
{videoCtrl && activeTool === ToolType.SELECT && !contextMenu && (
|
{videoCtrl && activeTool === ToolType.SELECT && !contextMenu && (
|
||||||
<VideoControls
|
<VideoControls
|
||||||
|
|||||||
Reference in New Issue
Block a user