feat(sticky): extend TextEditor to support sticky note editing

This commit is contained in:
Hiren Kangad
2026-03-12 22:09:12 +05:30
parent 6eb78a2608
commit e183e4b9d2
+124 -56
View File
@@ -10,6 +10,8 @@ import { Text } from 'pixi.js';
import type { Viewport } from 'pixi-viewport'; import type { Viewport } from 'pixi-viewport';
import type { SceneItem } from './SceneManager'; import type { SceneItem } from './SceneManager';
import type { TextObject } from './scene-format'; import type { TextObject } from './scene-format';
import type { StickyObject } from './scene-format';
import { StickySprite } from './sprites/StickySprite';
export class TextEditor { export class TextEditor {
private _textarea: HTMLTextAreaElement | null = null; private _textarea: HTMLTextAreaElement | null = null;
@@ -33,7 +35,7 @@ export class TextEditor {
} }
/** /**
* Open an inline textarea over the given text item. * Open an inline textarea over the given text or sticky item.
*/ */
startEditing( startEditing(
item: SceneItem, item: SceneItem,
@@ -41,10 +43,8 @@ export class TextEditor {
container: HTMLElement, container: HTMLElement,
onChange: () => void, onChange: () => void,
): void { ): void {
// Only text items // Accept text and sticky items
if (item.type !== 'text') return; if (item.type !== 'text' && item.type !== 'sticky') return;
const pixiText = item.displayObject;
if (!(pixiText instanceof Text)) return;
// Prevent double-open // Prevent double-open
if (this._textarea) this.stopEditing(false); if (this._textarea) this.stopEditing(false);
@@ -54,61 +54,112 @@ export class TextEditor {
this._container = container; this._container = container;
this._onChange = onChange; 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; const zoom = viewport.scale.x;
// Scaled font size // Type-specific config
const scaledFontSize = data.fontSize * zoom * Math.abs(item.data.sx); let text = '';
let fontSize = 14;
let fontFamily = 'sans-serif';
let color = '#ffffff';
let bgColor = 'transparent';
let padding = 0;
let screenX = 0;
let screenY = 0;
let taWidth = 'auto';
if (item.type === 'sticky') {
const data = item.data as StickyObject;
text = data.text;
fontSize = data.fontSize || 14;
fontFamily = data.fontFamily || 'Inter, system-ui, sans-serif';
color = data.textColor || '#1a1a1a';
bgColor = data.fill || '#ffd43b';
padding = (data.padding ?? 16) * zoom * Math.abs(item.data.sx);
// Position at sticky's world coords
const screen = viewport.toScreen(item.data.x, item.data.y);
screenX = screen.x + padding;
screenY = screen.y + padding;
// Fixed width matching the sticky content area
const scaledW = data.w * zoom * Math.abs(item.data.sx);
taWidth = `${scaledW - padding * 2}px`;
// Hide the StickySprite text child (keep bg visible)
if (item.displayObject instanceof StickySprite) {
item.displayObject.showText(false);
}
} else {
// Original text behavior
const data = item.data as TextObject;
text = data.text;
fontSize = data.fontSize;
fontFamily = data.fontFamily;
color = data.fill;
bgColor = 'transparent';
padding = 0;
const screen = viewport.toScreen(item.data.x, item.data.y);
screenX = screen.x;
screenY = screen.y;
taWidth = 'auto';
// Hide the PixiJS text while editing
const pixiText = item.displayObject;
if (pixiText instanceof Text) {
pixiText.visible = false;
}
}
this._originalText = text;
const scaledFontSize = fontSize * zoom * Math.abs(item.data.sx);
// Create textarea // Create textarea
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.value = data.text; ta.value = text;
ta.style.position = 'absolute'; ta.style.position = 'absolute';
ta.style.left = `${screen.x}px`; ta.style.left = `${screenX}px`;
ta.style.top = `${screen.y}px`; ta.style.top = `${screenY}px`;
ta.style.fontSize = `${scaledFontSize}px`; ta.style.fontSize = `${scaledFontSize}px`;
ta.style.fontFamily = data.fontFamily; ta.style.fontFamily = fontFamily;
ta.style.color = data.fill; ta.style.color = color;
ta.style.background = 'transparent'; ta.style.background = bgColor === 'transparent' ? 'transparent' : bgColor;
ta.style.border = 'none'; ta.style.border = 'none';
ta.style.outline = 'none'; ta.style.outline = 'none';
ta.style.resize = 'none'; ta.style.resize = 'none';
ta.style.overflow = 'hidden'; ta.style.overflow = 'hidden';
ta.style.padding = '0'; ta.style.padding = '0';
ta.style.margin = '0'; ta.style.margin = '0';
ta.style.lineHeight = '1.2'; ta.style.lineHeight = item.type === 'sticky' ? '1.4' : '1.2';
ta.style.whiteSpace = 'pre'; ta.style.whiteSpace = item.type === 'sticky' ? 'pre-wrap' : 'pre';
ta.style.zIndex = '1000'; ta.style.zIndex = '1000';
ta.style.minWidth = '20px'; ta.style.minWidth = '20px';
ta.style.minHeight = `${scaledFontSize * 1.3}px`; ta.style.minHeight = `${scaledFontSize * 1.3}px`;
ta.style.transformOrigin = 'top left'; ta.style.transformOrigin = 'top left';
// Apply rotation if any ta.style.boxSizing = 'border-box';
if (taWidth !== 'auto') {
ta.style.width = taWidth;
}
// Apply rotation
if (item.data.angle) { if (item.data.angle) {
ta.style.transform = `rotate(${item.data.angle}deg)`; ta.style.transform = `rotate(${item.data.angle}deg)`;
} }
// Auto-size the textarea to fit content // Auto-size
const autoSize = () => { const autoSize = () => {
ta.style.height = 'auto'; ta.style.height = 'auto';
ta.style.width = 'auto'; if (taWidth === 'auto') {
// Use a hidden measurement trick: set to scroll dimensions ta.style.width = 'auto';
ta.style.width = `${Math.max(ta.scrollWidth + 4, 20)}px`;
}
ta.style.height = `${ta.scrollHeight}px`; ta.style.height = `${ta.scrollHeight}px`;
ta.style.width = `${Math.max(ta.scrollWidth + 4, 20)}px`;
}; };
// Event handlers // Event handlers
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
e.stopPropagation(); // Prevent canvas shortcuts e.stopPropagation();
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
this.stopEditing(false); this.stopEditing(false);
@@ -119,7 +170,6 @@ export class TextEditor {
}; };
const onBlur = () => { const onBlur = () => {
// Small delay to allow Escape to fire first
setTimeout(() => { setTimeout(() => {
if (this._textarea === ta) { if (this._textarea === ta) {
this.stopEditing(true); this.stopEditing(true);
@@ -138,7 +188,6 @@ export class TextEditor {
container.appendChild(ta); container.appendChild(ta);
this._textarea = ta; this._textarea = ta;
// Initial size and focus
autoSize(); autoSize();
ta.focus(); ta.focus();
ta.select(); ta.select();
@@ -152,34 +201,53 @@ export class TextEditor {
const item = this._item; const item = this._item;
if (!ta || !item) return; if (!ta || !item) return;
const pixiText = item.displayObject;
if (!(pixiText instanceof Text)) return;
const data = item.data as TextObject;
if (save) { if (save) {
const newText = ta.value; // Allow empty — caller handles cleanup const newText = ta.value;
data.text = newText;
pixiText.text = newText;
// Update dimensions from measured PixiJS text bounds if (item.type === 'sticky' && item.displayObject instanceof StickySprite) {
pixiText.visible = true; const data = item.data as StickyObject;
const bounds = pixiText.getLocalBounds(); data.text = newText;
data.w = bounds.width; item.displayObject.updateFromData(data);
data.h = bounds.height; data.h = item.displayObject.computedHeight;
item.displayObject.showText(true);
this._onChange?.(); this._onChange?.();
} else if (item.type === 'text') {
const pixiText = item.displayObject;
if (pixiText instanceof Text) {
const data = item.data as TextObject;
data.text = newText;
pixiText.text = newText;
pixiText.visible = true;
const bounds = pixiText.getLocalBounds();
data.w = bounds.width;
data.h = bounds.height;
this._onChange?.();
}
}
} else { } else {
// Restore original text // Cancel — restore original
data.text = this._originalText; if (item.type === 'sticky' && item.displayObject instanceof StickySprite) {
pixiText.text = this._originalText; const data = item.data as StickyObject;
pixiText.visible = true; data.text = this._originalText;
item.displayObject.updateFromData(data);
data.h = item.displayObject.computedHeight;
item.displayObject.showText(true);
} else if (item.type === 'text') {
const pixiText = item.displayObject;
if (pixiText instanceof Text) {
const data = item.data as TextObject;
data.text = this._originalText;
pixiText.text = this._originalText;
pixiText.visible = true;
}
}
// Always notify on cancel too — the tool callback needs this
// to clean up empty newly-created items (sticky or text).
this._onChange?.();
} }
// Remove textarea from DOM // DOM cleanup
ta.remove(); ta.remove();
// Reset state
this._textarea = null; this._textarea = null;
this._item = null; this._item = null;
this._viewport = null; this._viewport = null;