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:
Hiren Kangad
2026-03-11 17:22:25 +05:30
parent 4ed475c62e
commit 58da934ec1
14 changed files with 785 additions and 50 deletions
+57 -2
View File
@@ -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<void> {
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;