From 58da934ec19d13644b197d9d5e59748fd5d26728 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Wed, 11 Mar 2026 17:22:25 +0530 Subject: [PATCH] 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) --- frontend/src/canvas/CropOverlay.ts | 270 ++++++++++++++++++ frontend/src/canvas/SceneManager.ts | 46 +-- frontend/src/canvas/SelectionManager.ts | 8 + frontend/src/canvas/TextEditor.ts | 2 +- frontend/src/canvas/context-menu-items.ts | 4 + frontend/src/canvas/scene-format.ts | 9 + frontend/src/canvas/shortcut-definitions.ts | 10 + frontend/src/canvas/shortcuts.ts | 1 + frontend/src/canvas/sprites/ImageSprite.ts | 59 +++- frontend/src/canvas/tools.ts | 34 ++- frontend/src/components/TextFormatToolbar.tsx | 232 +++++++++++++++ frontend/src/hooks/useCanvasSetup.ts | 57 +++- frontend/src/hooks/useShortcutHandler.ts | 3 + frontend/src/pages/Editor.tsx | 100 ++++++- 14 files changed, 785 insertions(+), 50 deletions(-) create mode 100644 frontend/src/canvas/CropOverlay.ts create mode 100644 frontend/src/components/TextFormatToolbar.tsx diff --git a/frontend/src/canvas/CropOverlay.ts b/frontend/src/canvas/CropOverlay.ts new file mode 100644 index 0000000..e6b6c3b --- /dev/null +++ b/frontend/src/canvas/CropOverlay.ts @@ -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(); + 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 = { + 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 = { + 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; + } +} diff --git a/frontend/src/canvas/SceneManager.ts b/frontend/src/canvas/SceneManager.ts index 256186e..7cfdfb7 100644 --- a/frontend/src/canvas/SceneManager.ts +++ b/frontend/src/canvas/SceneManager.ts @@ -291,7 +291,9 @@ export class SceneManager { if (isGifAsset(imgData.asset)) { displayObject = new AnimatedGifSprite(imgData.asset, imgData.w, imgData.h, this.textures); } 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; } @@ -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) { - 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 = () => { - 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); + const alphaSpring = new Spring(0, data.opacity, PRESETS.snappy); alphaSpring.onUpdate = (v) => { if (!displayObject.destroyed) { displayObject.alpha = v; diff --git a/frontend/src/canvas/SelectionManager.ts b/frontend/src/canvas/SelectionManager.ts index 9492b9c..00eec27 100644 --- a/frontend/src/canvas/SelectionManager.ts +++ b/frontend/src/canvas/SelectionManager.ts @@ -64,6 +64,7 @@ export class SelectionManager { private static readonly DOUBLE_CLICK_MS = 400; private _onDoubleClickText: ((item: SceneItem) => void) | null = null; + private _onDoubleClickImage: ((item: SceneItem) => void) | null = null; private _enabled = true; @@ -142,6 +143,11 @@ export class SelectionManager { 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. */ selectOnly(id: string): void { this.selectedIds.clear(); @@ -357,6 +363,8 @@ export class SelectionManager { item.displayObject.togglePlayPause(); } else if (item.type === 'text') { this._onDoubleClickText?.(item); + } else if (item.type === 'image' || item.type === 'drawing') { + this._onDoubleClickImage?.(item); } this._lastClickTime = 0; this._lastClickItemId = null; diff --git a/frontend/src/canvas/TextEditor.ts b/frontend/src/canvas/TextEditor.ts index 4fa9f5a..f1674f2 100644 --- a/frontend/src/canvas/TextEditor.ts +++ b/frontend/src/canvas/TextEditor.ts @@ -150,7 +150,7 @@ export class TextEditor { const data = item.data as TextObject; if (save) { - const newText = ta.value || this._originalText; // Don't allow empty + const newText = ta.value; // Allow empty — caller handles cleanup data.text = newText; pixiText.text = newText; diff --git a/frontend/src/canvas/context-menu-items.ts b/frontend/src/canvas/context-menu-items.ts index 651ab31..26a68a2 100644 --- a/frontend/src/canvas/context-menu-items.ts +++ b/frontend/src/canvas/context-menu-items.ts @@ -20,6 +20,8 @@ export interface MenuItem { danger?: boolean; } +export type StartCropFn = (() => void) | null; + interface MenuContext { scene: SceneManager | null; selection: SelectionManager | null; @@ -31,6 +33,7 @@ interface MenuContext { handleGroup: () => void; handleUngroup: () => void; fitAll: () => void; + startCrop?: () => void; } export function buildContextMenuItems(ctx: MenuContext): MenuItem[] { @@ -152,6 +155,7 @@ export function buildContextMenuItems(ctx: MenuContext): MenuItem[] { // -- Image -- { 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: '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: '', shortcut: '', onClick: () => {}, divider: true }, diff --git a/frontend/src/canvas/scene-format.ts b/frontend/src/canvas/scene-format.ts index 2eac7bf..1d1ab13 100644 --- a/frontend/src/canvas/scene-format.ts +++ b/frontend/src/canvas/scene-format.ts @@ -21,10 +21,19 @@ export interface SceneObject { 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 { type: 'image'; asset: string; filters: string[]; + crop?: CropRect; } export interface VideoObject extends SceneObject { diff --git a/frontend/src/canvas/shortcut-definitions.ts b/frontend/src/canvas/shortcut-definitions.ts index 2a7f4d4..e898c09 100644 --- a/frontend/src/canvas/shortcut-definitions.ts +++ b/frontend/src/canvas/shortcut-definitions.ts @@ -715,4 +715,14 @@ export const shortcuts: ShortcutDef[] = [ ctx.toggleReviewMode?.(); }, }, + { + id: 'crop', + keys: { key: 'c' }, + category: 'image', + description: 'Crop image', + needsSelection: true, + handler: (ctx: ShortcutContext) => { + ctx.startCrop?.(); + }, + }, ]; diff --git a/frontend/src/canvas/shortcuts.ts b/frontend/src/canvas/shortcuts.ts index 7efaad1..481d3b9 100644 --- a/frontend/src/canvas/shortcuts.ts +++ b/frontend/src/canvas/shortcuts.ts @@ -48,6 +48,7 @@ export interface ShortcutContext { writeCanvasToClipboard: (items?: SceneItem[]) => Promise; pasteFromSystemClipboard: () => Promise; toggleReviewMode?: () => void; + startCrop?: () => void; } /** diff --git a/frontend/src/canvas/sprites/ImageSprite.ts b/frontend/src/canvas/sprites/ImageSprite.ts index bb2c902..1a38ddb 100644 --- a/frontend/src/canvas/sprites/ImageSprite.ts +++ b/frontend/src/canvas/sprites/ImageSprite.ts @@ -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 type { CropRect } from "../scene-format"; /** * 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 _sprite: Sprite | null = null; private _shadow: Graphics; + private _cropMask: Graphics | null = null; private _naturalWidth: number; private _naturalHeight: number; @@ -54,9 +56,14 @@ export class ImageSprite extends Container { // 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 { + const w = this._naturalWidth; + const h = this._naturalHeight; 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 }); } @@ -75,6 +82,48 @@ export class ImageSprite extends Container { 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. */ async loadTexture(): Promise { if (this.loaded || this.loading) return; @@ -93,6 +142,11 @@ export class ImageSprite extends Container { this.addChild(sprite); this.loaded = true; + // Apply crop mask if one exists + if (this._cropMask) { + sprite.mask = this._cropMask; + } + // Remove placeholder after successful load if (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 if (this._sprite) { + this._sprite.mask = null; this.removeChild(this._sprite); this._sprite.destroy(); this._sprite = null; diff --git a/frontend/src/canvas/tools.ts b/frontend/src/canvas/tools.ts index d3895bf..2be5519 100644 --- a/frontend/src/canvas/tools.ts +++ b/frontend/src/canvas/tools.ts @@ -13,6 +13,7 @@ import type { SelectionManager } from './SelectionManager'; import { Text, TextStyle } from 'pixi.js'; import { DrawingSprite } from './sprites/DrawingSprite'; import type { DrawingObject } from './scene-format'; +import { TextEditor } from './TextEditor'; export enum ToolType { SELECT = 'SELECT', @@ -44,6 +45,10 @@ export interface ToolContext { onChange: () => void; /** Broadcast only specific changed elements (lightweight, for live drawing). */ 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( @@ -95,7 +100,7 @@ export function activateTool( locked: false, name: '', visible: true, - text: 'Type here', + text: ' ', // placeholder — will be replaced by user input fontSize: opts.fontSize!, fill: opts.color!, fontFamily: 'sans-serif', @@ -106,6 +111,33 @@ export function activateTool( ctx.broadcastElements?.([textData.id]); 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 container.removeEventListener('pointerdown', onClick); }; diff --git a/frontend/src/components/TextFormatToolbar.tsx b/frontend/src/components/TextFormatToolbar.tsx new file mode 100644 index 0000000..396f77e --- /dev/null +++ b/frontend/src/components/TextFormatToolbar.tsx @@ -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(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) => { + e.currentTarget.style.background = '#333'; + e.currentTarget.style.color = '#fff'; + }, + onMouseLeave: (e: React.MouseEvent) => { + e.currentTarget.style.background = 'transparent'; + e.currentTarget.style.color = '#999'; + }, + }; + + return ( +
e.stopPropagation()} + > + {/* Font size: decrease / value / increase */} + + + {fontSize} + + + + {/* Divider */} +
+ + {/* Font family dropdown */} +
+ + {showFontMenu && ( +
e.stopPropagation()} + > + {FONT_FAMILIES.map((f) => ( + + ))} +
+ )} +
+ + {/* Divider */} +
+ + {/* Color */} +
+ + {showColorPicker && ( +
e.stopPropagation()} + > +
+ {PRESET_COLORS.map((c) => ( +
+ { onFillChange(e.target.value); }} + style={{ width: '100%', height: '24px', border: 'none', background: 'transparent', cursor: 'pointer' }} + /> +
+ )} +
+
+ ); +} diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index 0272aae..65ad298 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -8,9 +8,11 @@ import { UndoManager } from '../canvas/history'; import { InboxZone } from '../canvas/InboxZone'; import { LaserPointer } from '../canvas/LaserPointer'; import { VideoSprite } from '../canvas/sprites/VideoSprite'; +import { ImageSprite } from '../canvas/sprites/ImageSprite'; import { UploadManager } from '../stores/uploadManager'; import { AnnotationStore } from '../stores/annotationStore'; import { PinOverlay } from '../canvas/PinOverlay'; +import { CropOverlay } from '../canvas/CropOverlay'; // PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit import { connectSocket, disconnectSocket } from '../socket'; import api from '../api'; @@ -70,6 +72,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { const annotationStoreRef = useRef(null); if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore(); const pinOverlayRef = useRef(null); + const textEditorRef = useRef(null); + if (!textEditorRef.current) textEditorRef.current = new TextEditor(); + const cropOverlayRef = useRef(null); useEffect(() => { if (!boardData || !resolvedBoardId) return; @@ -102,7 +107,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { }; // Inline text editing on double-click - const textEditor = new TextEditor(); + const textEditor = textEditorRef.current!; // Find the DOM container for the canvas (parent of the element) const canvasElements = document.querySelectorAll('canvas'); let domContainer: HTMLElement | null = null; @@ -115,11 +120,35 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { selection.onDoubleClickText = (item) => { if (!domContainer) return; 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]); 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) scene.onItemDimensionsChanged = (itemId: string) => { 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 if (!isPublicView || user) { // 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; dropCleanupRef.current?.(); pasteCleanupRef.current?.(); + if (cropOverlayRef.current) { + cropOverlayRef.current.destroy(); + cropOverlayRef.current = null; + } if (pinOverlayRef.current) { (pinOverlayRef.current as any)._cleanup?.(); 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]); - return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current }; + return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current, textEditor: textEditorRef.current, cropOverlay: cropOverlayRef.current }; } diff --git a/frontend/src/hooks/useShortcutHandler.ts b/frontend/src/hooks/useShortcutHandler.ts index 654f73d..2136eb3 100644 --- a/frontend/src/hooks/useShortcutHandler.ts +++ b/frontend/src/hooks/useShortcutHandler.ts @@ -31,6 +31,7 @@ interface ShortcutHandlerDeps { setShowHelp: React.Dispatch>; setFocusMode: React.Dispatch>; setReviewMode: React.Dispatch>; + startCrop?: () => void; } /** @@ -43,6 +44,7 @@ export function useShortcutHandler(deps: ShortcutHandlerDeps) { handleGroup, handleUngroup, setActiveTool, setCanUndo, setCanRedo, setZoom, setShowGrid, setShowHelp, setFocusMode, setReviewMode, + startCrop, } = deps; useEffect(() => { @@ -91,6 +93,7 @@ export function useShortcutHandler(deps: ShortcutHandlerDeps) { toggleShowHelp: () => setShowHelp((v) => !v), toggleFocusMode: () => setFocusMode((v) => !v), toggleReviewMode: () => setReviewMode((v) => !v), + startCrop, pasteFromSystemClipboard: async (): Promise => { if (!resolvedBoardId) return 'No board'; const msg = await pasteFromSystemClipboard(scene, viewport, resolvedBoardId, onCanvasChange); diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 140a4b0..2c7b668 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -20,6 +20,7 @@ import UserCursors from '../components/UserCursors'; import ContextMenu from '../components/ContextMenu'; import LayerPanel from '../components/LayerPanel'; import SelectionToolbar from '../components/SelectionToolbar'; +import TextFormatToolbar from '../components/TextFormatToolbar'; import VideoControls from '../components/VideoControls'; import ShortcutsHelp from '../components/ShortcutsHelp'; import MattermostImport from '../components/MattermostImport'; @@ -30,6 +31,7 @@ import FeedbackPanel from '../components/feedback/FeedbackPanel'; import { UploadManager } from '../stores/uploadManager'; import { InboxZone } from '../canvas/InboxZone'; import { getItemWorldBounds } from '../canvas/SceneManager'; +import type { TextObject } from '../canvas/scene-format'; import { VideoSprite } from '../canvas/sprites/VideoSprite'; import * as ops from '../canvas/operations'; @@ -97,6 +99,7 @@ export default function Editor({ isPublicView }: EditorProps) { const [layerList, setLayerList] = useState([]); const [selectedLayerIds, setSelectedLayerIds] = useState([]); 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 [showMinimap, setShowMinimap] = useState(true); 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) - const { annotationStore, pinOverlay } = useCanvasSetup({ + const { annotationStore, pinOverlay, textEditor, cropOverlay } = useCanvasSetup({ boardData, resolvedBoardId, user, isPublicView, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, @@ -229,9 +232,11 @@ export default function Editor({ isPublicView }: EditorProps) { viewport, scene, selection, container: domContainer, onChange: onCanvasChange, broadcastElements: (ids) => syncRef.current?.broadcastElements(ids), + textEditor: textEditor ?? undefined, + switchToSelect: () => setActiveTool(ToolType.SELECT), }; toolCleanupRef.current = activateTool(ctx, activeTool, { color, strokeWidth, fontSize }); - }, [activeTool, color, strokeWidth, fontSize, onCanvasChange]); + }, [activeTool, color, strokeWidth, fontSize, onCanvasChange, textEditor]); // Layer panel const { refreshLayers: refreshLayerData, layerHandlers } = useLayerPanel({ canvasRef, selectionRef, onCanvasChange }); @@ -289,6 +294,17 @@ export default function Editor({ isPublicView }: EditorProps) { handleGroup, handleUngroup, setActiveTool, setCanUndo, setCanRedo, setZoom, 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 @@ -309,8 +325,16 @@ export default function Editor({ isPublicView }: EditorProps) { handleGroup, handleUngroup, 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) useEffect(() => { @@ -362,6 +386,37 @@ export default function Editor({ isPublicView }: EditorProps) { 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 { // Compute world bounding box of selection 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 && ( + = 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) */} {videoCtrl && activeTool === ToolType.SELECT && !contextMenu && (