Add inline text editing on double-click for RefBoard text elements

Introduces TextEditor utility that overlays a styled <textarea> matching
the PixiJS Text object's font, size, and color. Hides the PixiJS text
while editing, commits on blur/Enter, cancels on Escape, and updates
dimensions from measured bounds on save.
This commit is contained in:
Hiren Kangad
2026-03-10 02:50:47 +05:30
parent 9542daa6cc
commit 8a32119997
3 changed files with 471 additions and 49 deletions
+73 -49
View File
@@ -7,10 +7,10 @@
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
import type { Viewport } from 'pixi-viewport';
import { SceneManager, SceneItem } from './SceneManager';
import { SceneManager, SceneItem, getItemWorldBounds, isGroupChild } from './SceneManager';
import { TransformBox } from './TransformBox';
import { ImageSprite } from './sprites/ImageSprite';
import { Spring, PRESETS } from './spring';
import { VideoSprite } from './sprites/VideoSprite';
// ---------------------------------------------------------------------------
// Constants
@@ -23,7 +23,6 @@ const BAND_STROKE_ALPHA = 0.6;
const BAND_STROKE_WIDTH = 1;
const RUBBER_BAND_THRESHOLD = 5; // px in screen space before rubber band activates
const DRAG_THRESHOLD = 5; // px in screen space before object drag activates
const DRAG_LIFT_SCALE = 1.03; // scale during drag
// ---------------------------------------------------------------------------
// SelectionManager
@@ -38,6 +37,7 @@ export class SelectionManager {
private _overlay: Container;
private _bandGfx: Graphics;
private _onSelectionChange: ((ids: string[]) => void) | null = null;
private _onItemTransform: ((item: SceneItem) => void) | null = null;
// Pointer state
private _pointerDown = false;
@@ -53,6 +53,15 @@ export class SelectionManager {
private _lastDragWorldX = 0;
private _lastDragWorldY = 0;
// Double-click detection
private _lastClickTime = 0;
private _lastClickItemId: string | null = null;
private static readonly DOUBLE_CLICK_MS = 400;
private _onDoubleClickText: ((item: SceneItem) => void) | null = null;
private _enabled = true;
constructor(viewport: Viewport, scene: SceneManager) {
this._viewport = viewport;
this._scene = scene;
@@ -79,12 +88,32 @@ export class SelectionManager {
viewport.on('pointerupoutside', this._onPointerUp, this);
}
/** Enable/disable selection interaction (disable during draw/text/eraser tools). */
setEnabled(enabled: boolean): void {
this._enabled = enabled;
if (!enabled) {
this._pointerDown = false;
this._rubberBanding = false;
this._bandGfx.visible = false;
}
}
// -- Public API -----------------------------------------------------------
set onSelectionChange(fn: (ids: string[]) => void) {
this._onSelectionChange = fn;
}
/** Called during drag with each moved item for live sync broadcast. */
set onItemTransform(fn: (item: SceneItem) => void) {
this._onItemTransform = fn;
}
/** Called on double-click of a text item (for inline editing). */
set onDoubleClickText(fn: (item: SceneItem) => void) {
this._onDoubleClickText = fn;
}
/** Select only this item, deselecting everything else. */
selectOnly(id: string): void {
this.selectedIds.clear();
@@ -105,7 +134,7 @@ export class SelectionManager {
/** Select all unlocked, visible items. */
selectAll(): void {
this.selectedIds.clear();
for (const item of this._scene.getAllItems()) {
for (const item of this._scene.getTopLevelItems()) {
if (!item.data.locked && item.data.visible) {
this.selectedIds.add(item.id);
}
@@ -134,7 +163,7 @@ export class SelectionManager {
/** Check all scene items in reverse z-order; return first whose world bounds contain (wx, wy). */
_hitTest(wx: number, wy: number): SceneItem | null {
const all = this._scene.getAllItems();
const all = this._scene.getTopLevelItems();
// Sort by z descending (topmost first)
all.sort((a, b) => b.data.z - a.data.z);
@@ -142,11 +171,7 @@ export class SelectionManager {
if (item.data.locked) continue;
if (!item.data.visible) continue;
// Use item.data (world-space) instead of getBounds() (screen-space)
const ix = item.data.x;
const iy = item.data.y;
const iw = item.data.w * Math.abs(item.data.sx);
const ih = item.data.h * Math.abs(item.data.sy);
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
if (ix <= wx && wx <= ix + iw && iy <= wy && wy <= iy + ih) {
return item;
@@ -158,6 +183,7 @@ export class SelectionManager {
// -- Pointer Handlers -----------------------------------------------------
private _onPointerDown(e: FederatedPointerEvent): void {
if (!this._enabled) return;
// Only handle primary button
if (e.button !== 0) return;
@@ -212,14 +238,16 @@ export class SelectionManager {
this._lastDragWorldX = currentWorld.x;
this._lastDragWorldY = currentWorld.y;
// Move all selected items by delta
for (const item of this.getSelectedItems()) {
// Move all selected items by delta and broadcast transforms
const selected = this.getSelectedItems();
for (const item of selected) {
item.displayObject.x += ddx;
item.displayObject.y += ddy;
item.data.x = item.displayObject.x;
item.data.y = item.displayObject.y;
this._onItemTransform?.(item);
}
this.transformBox.update(this.getSelectedItems());
this.transformBox.update(selected);
}
return;
}
@@ -268,6 +296,26 @@ export class SelectionManager {
if (!e.shiftKey) {
this.clear();
}
} else if (this._hitItemOnDown && !this._objectDragging) {
// Click (no drag) on an item — check for double-click
const now = Date.now();
const item = this._hitItemOnDown;
if (
this._lastClickItemId === item.id &&
now - this._lastClickTime < SelectionManager.DOUBLE_CLICK_MS
) {
// Double-click detected
if (item.displayObject instanceof VideoSprite) {
item.displayObject.togglePlayPause();
} else if (item.type === 'text') {
this._onDoubleClickText?.(item);
}
this._lastClickTime = 0;
this._lastClickItemId = null;
} else {
this._lastClickTime = now;
this._lastClickItemId = item.id;
}
}
this._hitItemOnDown = null;
@@ -275,43 +323,23 @@ export class SelectionManager {
// -- Drag Shadow Lift / Drop ----------------------------------------------
/** Lift shadows and spring-scale selected items up for drag. */
/** Lift shadows on selected items for drag. */
private _applyLift(): void {
const items = this.getSelectedItems();
for (const item of items) {
const dobj = item.displayObject;
// Lift shadow on ImageSprites
if (dobj instanceof ImageSprite) {
dobj.liftShadow();
for (const item of this.getSelectedItems()) {
const obj = item.displayObject;
if (obj instanceof ImageSprite || obj instanceof VideoSprite) {
obj.liftShadow();
}
// Spring scale 1.0 → 1.03
const spring = new Spring(1.0, DRAG_LIFT_SCALE, PRESETS.snappy);
spring.onUpdate = (v) => {
dobj.scale.set(v, v);
};
this._scene.springs.add(spring);
}
}
/** Drop shadows and spring-scale selected items back to rest. */
/** Drop shadows on selected items after drag. */
private _applyDrop(): void {
const items = this.getSelectedItems();
for (const item of items) {
const dobj = item.displayObject;
// Drop shadow on ImageSprites
if (dobj instanceof ImageSprite) {
dobj.dropShadow();
for (const item of this.getSelectedItems()) {
const obj = item.displayObject;
if (obj instanceof ImageSprite || obj instanceof VideoSprite) {
obj.dropShadow();
}
// Spring scale 1.03 → 1.0
const spring = new Spring(DRAG_LIFT_SCALE, 1.0, PRESETS.snappy);
spring.onUpdate = (v) => {
dobj.scale.set(v, v);
};
this._scene.springs.add(spring);
}
}
@@ -342,14 +370,10 @@ export class SelectionManager {
this.selectedIds.clear();
}
for (const item of this._scene.getAllItems()) {
for (const item of this._scene.getTopLevelItems()) {
if (item.data.locked || !item.data.visible) continue;
// Use world-space data instead of screen-space getBounds()
const ix = item.data.x;
const iy = item.data.y;
const iw = item.data.w * Math.abs(item.data.sx);
const ih = item.data.h * Math.abs(item.data.sy);
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
// Check intersection between rubber band rect and item world bounds
const intersects =
+182
View File
@@ -0,0 +1,182 @@
/**
* TextEditor — inline text editing overlay for PixiJS Text objects.
*
* Shows an absolutely-positioned <textarea> over the text item,
* matching its font, size (scaled by zoom), and color.
* Commits on blur/Enter, cancels on Escape.
*/
import { Text } from 'pixi.js';
import type { Viewport } from 'pixi-viewport';
import type { SceneItem } from './SceneManager';
import type { TextObject } from './scene-format';
export class TextEditor {
private _textarea: HTMLTextAreaElement | null = null;
private _item: SceneItem | null = null;
private _viewport: Viewport | null = null;
private _container: HTMLElement | null = null;
private _onChange: (() => void) | null = null;
private _originalText: string = '';
/** True when a textarea is open. */
get isEditing(): boolean {
return this._textarea !== null;
}
/**
* Open an inline textarea over the given text item.
*/
startEditing(
item: SceneItem,
viewport: Viewport,
container: HTMLElement,
onChange: () => void,
): void {
// Only text items
if (item.type !== 'text') return;
const pixiText = item.displayObject;
if (!(pixiText instanceof Text)) return;
// Prevent double-open
if (this._textarea) this.stopEditing(false);
this._item = item;
this._viewport = viewport;
this._container = container;
this._onChange = onChange;
const data = item.data as TextObject;
this._originalText = data.text;
// Hide the PixiJS text while editing
pixiText.visible = false;
// Compute screen position of the text
const worldPos = pixiText.getGlobalPosition();
const renderer = viewport.parent?.parent; // stage -> app (not reliable)
// Use viewport.toScreen to convert world → screen coords
const screen = viewport.toScreen(item.data.x, item.data.y);
const zoom = viewport.scale.x;
// Scaled font size
const scaledFontSize = data.fontSize * zoom * Math.abs(item.data.sx);
// Create textarea
const ta = document.createElement('textarea');
ta.value = data.text;
ta.style.position = 'absolute';
ta.style.left = `${screen.x}px`;
ta.style.top = `${screen.y}px`;
ta.style.fontSize = `${scaledFontSize}px`;
ta.style.fontFamily = data.fontFamily;
ta.style.color = data.fill;
ta.style.background = 'transparent';
ta.style.border = 'none';
ta.style.outline = 'none';
ta.style.resize = 'none';
ta.style.overflow = 'hidden';
ta.style.padding = '0';
ta.style.margin = '0';
ta.style.lineHeight = '1.2';
ta.style.whiteSpace = 'pre';
ta.style.zIndex = '1000';
ta.style.minWidth = '20px';
ta.style.minHeight = `${scaledFontSize * 1.3}px`;
ta.style.transformOrigin = 'top left';
// Apply rotation if any
if (item.data.angle) {
ta.style.transform = `rotate(${item.data.angle}deg)`;
}
// Auto-size the textarea to fit content
const autoSize = () => {
ta.style.height = 'auto';
ta.style.width = 'auto';
// Use a hidden measurement trick: set to scroll dimensions
ta.style.height = `${ta.scrollHeight}px`;
ta.style.width = `${Math.max(ta.scrollWidth + 4, 20)}px`;
};
// Event handlers
const onKeyDown = (e: KeyboardEvent) => {
e.stopPropagation(); // Prevent canvas shortcuts
if (e.key === 'Escape') {
e.preventDefault();
this.stopEditing(false);
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.stopEditing(true);
}
};
const onBlur = () => {
// Small delay to allow Escape to fire first
setTimeout(() => {
if (this._textarea === ta) {
this.stopEditing(true);
}
}, 0);
};
const onInput = () => {
autoSize();
};
ta.addEventListener('keydown', onKeyDown);
ta.addEventListener('blur', onBlur);
ta.addEventListener('input', onInput);
container.appendChild(ta);
this._textarea = ta;
// Initial size and focus
autoSize();
ta.focus();
ta.select();
}
/**
* Close the textarea. If save=true, commit the text; otherwise restore original.
*/
stopEditing(save: boolean): void {
const ta = this._textarea;
const item = this._item;
if (!ta || !item) return;
const pixiText = item.displayObject;
if (!(pixiText instanceof Text)) return;
const data = item.data as TextObject;
if (save) {
const newText = ta.value || this._originalText; // Don't allow empty
data.text = newText;
pixiText.text = newText;
// Update dimensions from measured PixiJS text bounds
pixiText.visible = true;
const bounds = pixiText.getLocalBounds();
data.w = bounds.width;
data.h = bounds.height;
this._onChange?.();
} else {
// Restore original text
data.text = this._originalText;
pixiText.text = this._originalText;
pixiText.visible = true;
}
// Remove textarea from DOM
ta.remove();
// Reset state
this._textarea = null;
this._item = null;
this._viewport = null;
this._container = null;
this._onChange = null;
this._originalText = '';
}
}