fix: address code review findings from crop/text commit

- CropOverlay: register move/up handlers dynamically on drag start (single
  handler instead of 8x per-handle), remove on drag end. Override destroy()
  to call _cleanup() preventing keyboard listener leaks.
- Text format toolbar: re-measure text bounds (w/h) after fontSize/fontFamily
  changes, update spatial index and transform box.
- TextEditor: add clearText() method; tools.ts uses it instead of fragile
  document.querySelector('textarea').
- SceneManager._updateItem: apply crop mask on remote sync for image items.
- useCanvasSetup: stop TextEditor on unmount to prevent orphaned textarea.
- Double-click zoom: use item.data dimensions instead of getBounds() (which
  includes shadow offset).
This commit is contained in:
Hiren Kangad
2026-03-11 17:28:52 +05:30
parent 58da934ec1
commit 01f22c9d43
6 changed files with 72 additions and 21 deletions
+39 -14
View File
@@ -10,7 +10,6 @@ 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;
@@ -32,6 +31,9 @@ export class CropOverlay extends Container {
private _onConfirm: ((item: SceneItem, crop: CropRect) => void) | null = null;
private _onCancel: (() => void) | null = null;
private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
// Bound references for dynamic event registration
private _boundMove: ((e: FederatedPointerEvent) => void) | null = null;
private _boundUp: (() => void) | null = null;
constructor(viewport: Viewport) {
super();
@@ -50,7 +52,7 @@ export class CropOverlay extends Container {
this._border.eventMode = 'none';
this.addChild(this._border);
// Create handles
// Create handles — only pointerdown on each handle
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',
@@ -61,9 +63,6 @@ export class CropOverlay extends Container {
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);
}
@@ -94,7 +93,6 @@ export class CropOverlay extends Container {
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);
@@ -108,7 +106,9 @@ export class CropOverlay extends Container {
get isActive(): boolean { return this._item !== null; }
/** Clean up all state and listeners. Safe to call multiple times. */
private _cleanup(): void {
this._removeDragListeners();
this._item = null;
this._drag = null;
this.visible = false;
@@ -118,6 +118,12 @@ export class CropOverlay extends Container {
}
}
/** Override destroy to ensure cleanup runs. */
override destroy(options?: any): void {
this._cleanup();
super.destroy(options);
}
private _draw(): void {
if (!this._item) return;
const data = this._item.data;
@@ -135,26 +141,21 @@ export class CropOverlay extends Container {
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
// Dim: draw 4 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);
@@ -201,11 +202,35 @@ export class CropOverlay extends Container {
}
}
// -- Handle drag --
// -- Handle drag (single handler, registered dynamically) --
private _onDown(e: FederatedPointerEvent, id: HandleId): void {
e.stopPropagation();
this._drag = { handleId: id, startCrop: { ...this._crop } };
// Register move/up on the stage (single handler, not per-handle)
this._removeDragListeners();
this._boundMove = (ev: FederatedPointerEvent) => this._onMove(ev);
this._boundUp = () => this._onUp();
const stage = this._viewport.parent;
if (stage) {
stage.on('globalpointermove', this._boundMove);
stage.on('pointerup', this._boundUp);
stage.on('pointerupoutside', this._boundUp);
}
}
private _removeDragListeners(): void {
const stage = this._viewport.parent;
if (stage) {
if (this._boundMove) stage.off('globalpointermove', this._boundMove);
if (this._boundUp) {
stage.off('pointerup', this._boundUp);
stage.off('pointerupoutside', this._boundUp);
}
}
this._boundMove = null;
this._boundUp = null;
}
private _onMove(e: FederatedPointerEvent): void {
@@ -216,7 +241,6 @@ export class CropOverlay extends Container {
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;
@@ -266,5 +290,6 @@ export class CropOverlay extends Container {
private _onUp(): void {
this._drag = null;
this._removeDragListeners();
}
}