From a20cab3fcd824b3d424d510d5f14d0910c250cd4 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Thu, 12 Mar 2026 23:53:06 +0530 Subject: [PATCH] refactor: extract TextCore shared text engine + sticky width-only resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextCore.ts: shared text rendering with dirty-check caching, zoom-bucket resolution, and optional word-wrap. TextSprite and StickySprite now compose TextCore instead of duplicating text logic. Sticky resize now bakes width only (fontSize stays stable), so dragging a sticky wider/narrower reflows text with auto-adjusted height — like a resizable text box. --- frontend/src/canvas/sprites/StickySprite.ts | 91 +++++-------- frontend/src/canvas/sprites/TextCore.ts | 140 ++++++++++++++++++++ frontend/src/canvas/sprites/TextSprite.ts | 92 ++++--------- frontend/src/hooks/useCanvasSetup.ts | 4 +- 4 files changed, 200 insertions(+), 127 deletions(-) create mode 100644 frontend/src/canvas/sprites/TextCore.ts diff --git a/frontend/src/canvas/sprites/StickySprite.ts b/frontend/src/canvas/sprites/StickySprite.ts index 3cc1ef5..4757d4d 100644 --- a/frontend/src/canvas/sprites/StickySprite.ts +++ b/frontend/src/canvas/sprites/StickySprite.ts @@ -1,10 +1,13 @@ /** * StickySprite — renders a sticky note card. * + * Composes TextCore for dirty-checked text rendering with word-wrap. + * Adds a rounded-rect background that auto-sizes to fit text. + * * Structure: * Container (this) * └─ _bg: Graphics (rounded rect background) - * └─ _text: Text (word-wrapped content) + * └─ TextCore.pixiText (word-wrapped content) * * Dimensions: * - width is fixed (from data.w) @@ -17,8 +20,9 @@ * - showText() never triggers layout or redraw */ -import { Container, Graphics, Text, TextStyle } from 'pixi.js'; +import { Container, Graphics } from 'pixi.js'; import type { StickyObject } from '../scene-format'; +import { TextCore } from './TextCore'; const DEFAULT_PADDING = 16; const DEFAULT_CORNER_RADIUS = 8; @@ -27,8 +31,7 @@ const MIN_HEIGHT = 60; export class StickySprite extends Container { private _bg: Graphics; - private _text: Text; - private _style: TextStyle; + private _core: TextCore; // Cached layout state private _cardW = 200; @@ -37,11 +40,7 @@ export class StickySprite extends Container { private _cornerRadius = DEFAULT_CORNER_RADIUS; private _bgColor = '#ffd43b'; - // Dirty-check cache: last known inputs to avoid redundant work - private _lastText = ''; - private _lastFontSize = 14; - private _lastFontFamily = 'Inter, system-ui, sans-serif'; - private _lastTextColor = '#1a1a1a'; + // Dirty-check cache for bg-only props (text props are in TextCore) private _lastFill = '#ffd43b'; private _lastW = 200; private _lastPadding = DEFAULT_PADDING; @@ -49,7 +48,6 @@ export class StickySprite extends Container { // Background redraw cache: skip Graphics.clear()+redraw when shape is unchanged private _bgCacheKey = ''; - private _zoomBucket = 1; /** After layout, the computed card height. Parent should sync to data.h. */ get computedHeight(): number { @@ -58,7 +56,7 @@ export class StickySprite extends Container { /** Show/hide the text child (used by TextEditor during editing). */ showText(visible: boolean): void { - this._text.visible = visible; + this._core.setVisible(visible); } /** @@ -66,9 +64,7 @@ export class StickySprite extends Container { * Only re-rasterizes when the bucket actually changes. */ setZoomBucket(bucket: number): void { - if (bucket === this._zoomBucket) return; - this._zoomBucket = bucket; - this._text.resolution = bucket; + this._core.setZoomBucket(bucket); } constructor(data: StickyObject) { @@ -83,26 +79,20 @@ export class StickySprite extends Container { this._bg = new Graphics(); this.addChild(this._bg); - // Text style + // Text via shared TextCore (word-wrapped) const fontSize = data.fontSize || 14; - this._style = new TextStyle({ + this._core = new TextCore({ + text: data.text || '', fontSize, fontFamily: data.fontFamily || 'Inter, system-ui, sans-serif', fill: data.textColor || '#1a1a1a', - wordWrap: true, wordWrapWidth: Math.max(data.w - this._padding * 2, 1), - lineHeight: fontSize * DEFAULT_LINE_HEIGHT, + lineHeightMultiplier: DEFAULT_LINE_HEIGHT, }); + this._core.pixiText.position.set(this._padding, this._padding); + this.addChild(this._core.pixiText); - this._text = new Text({ text: data.text || '', style: this._style }); - this._text.position.set(this._padding, this._padding); - this.addChild(this._text); - - // Snapshot initial state - this._lastText = data.text || ''; - this._lastFontSize = fontSize; - this._lastFontFamily = data.fontFamily || 'Inter, system-ui, sans-serif'; - this._lastTextColor = data.textColor || '#1a1a1a'; + // Snapshot initial state for bg-only dirty checks this._lastFill = this._bgColor; this._lastW = data.w; this._lastPadding = this._padding; @@ -120,17 +110,8 @@ export class StickySprite extends Container { const cornerRadius = data.cornerRadius ?? DEFAULT_CORNER_RADIUS; const bgColor = data.fill || '#ffd43b'; const w = data.w; - const text = data.text || ''; - const fontSize = data.fontSize || 14; - const fontFamily = data.fontFamily || 'Inter, system-ui, sans-serif'; - const textColor = data.textColor || '#1a1a1a'; - // Check if anything actually changed - const textChanged = text !== this._lastText; - const styleChanged = - fontSize !== this._lastFontSize || - fontFamily !== this._lastFontFamily || - textColor !== this._lastTextColor; + // Check bg/layout-only changes const layoutChanged = w !== this._lastW || padding !== this._lastPadding; @@ -138,41 +119,35 @@ export class StickySprite extends Container { bgColor !== this._lastFill || cornerRadius !== this._lastCornerRadius; + // Delegate text+style dirty-checking to TextCore + const textChanged = this._core.update({ + text: data.text || '', + fontSize: data.fontSize || 14, + fontFamily: data.fontFamily || 'Inter, system-ui, sans-serif', + fill: data.textColor || '#1a1a1a', + wordWrapWidth: Math.max(w - padding * 2, 1), + lineHeightMultiplier: DEFAULT_LINE_HEIGHT, + }); + // Nothing changed — skip all work - if (!textChanged && !styleChanged && !layoutChanged && !bgChanged) return; + if (!textChanged && !layoutChanged && !bgChanged) return; // Update cached state this._padding = padding; this._cornerRadius = cornerRadius; this._bgColor = bgColor; this._cardW = w; - this._lastText = text; - this._lastFontSize = fontSize; - this._lastFontFamily = fontFamily; - this._lastTextColor = textColor; this._lastFill = bgColor; this._lastW = w; this._lastPadding = padding; this._lastCornerRadius = cornerRadius; - // Only update text if content changed - if (textChanged) { - this._text.text = text; - } - - // Only update style if style/layout props changed - if (styleChanged || layoutChanged) { - this._style.fontSize = fontSize; - this._style.fontFamily = fontFamily; - this._style.fill = textColor; - this._style.wordWrapWidth = Math.max(w - padding * 2, 1); - this._style.lineHeight = fontSize * DEFAULT_LINE_HEIGHT; - this._text.style = this._style; - this._text.position.set(padding, padding); + if (layoutChanged) { + this._core.pixiText.position.set(padding, padding); } // Relayout if text, style, or dimensions changed (need remeasure) - if (textChanged || styleChanged || layoutChanged) { + if (textChanged || layoutChanged) { this._layout(); } else if (bgChanged) { // Only bg color/radius changed — redraw background without remeasuring text @@ -184,7 +159,7 @@ export class StickySprite extends Container { * Recompute layout: measure text height, resize card, redraw background. */ private _layout(): void { - const textBounds = this._text.getLocalBounds(); + const textBounds = this._core.pixiText.getLocalBounds(); const textH = textBounds.height; this._cardH = Math.max(textH + this._padding * 2, MIN_HEIGHT); diff --git a/frontend/src/canvas/sprites/TextCore.ts b/frontend/src/canvas/sprites/TextCore.ts new file mode 100644 index 0000000..0af281c --- /dev/null +++ b/frontend/src/canvas/sprites/TextCore.ts @@ -0,0 +1,140 @@ +/** + * TextCore — shared text rendering engine with dirty-check caching. + * + * Owns a PixiJS Text instance and guards every property mutation + * against redundant invalidation. Used by both TextSprite and + * StickySprite so text rendering logic lives in one place. + * + * PixiJS Text treats any property assignment as an invalidation + * trigger (re-rasterizes the text texture), even if the value is + * identical — so we must guard every mutation ourselves. + */ + +import { Text, TextStyle } from 'pixi.js'; + +export interface TextCoreOptions { + text: string; + fontSize: number; + fontFamily: string; + fill: string; + /** Enable word-wrap at this width. Omit or 0 for no wrap. */ + wordWrapWidth?: number; + /** Line height multiplier (default 1). Only used when wordWrap is enabled. */ + lineHeightMultiplier?: number; +} + +export class TextCore { + readonly pixiText: Text; + readonly style: TextStyle; + + // Dirty-check cache + private _lastContent: string; + private _lastFontSize: number; + private _lastFontFamily: string; + private _lastFill: string; + private _lastWordWrapWidth: number; + private _lastLineHeightMultiplier: number; + private _zoomBucket = 1; + + get measuredWidth(): number { + return this.pixiText.width; + } + + get measuredHeight(): number { + return this.pixiText.height; + } + + constructor(opts: TextCoreOptions) { + const { text, fontSize, fontFamily, fill } = opts; + const wordWrapWidth = opts.wordWrapWidth || 0; + const lineHeightMultiplier = opts.lineHeightMultiplier || 1; + + const styleOpts: ConstructorParameters[0] = { + fontSize, + fontFamily, + fill, + }; + if (wordWrapWidth > 0) { + styleOpts.wordWrap = true; + styleOpts.wordWrapWidth = wordWrapWidth; + styleOpts.lineHeight = fontSize * lineHeightMultiplier; + } + + this.style = new TextStyle(styleOpts); + this.pixiText = new Text({ text, style: this.style }); + + this._lastContent = text; + this._lastFontSize = fontSize; + this._lastFontFamily = fontFamily; + this._lastFill = fill; + this._lastWordWrapWidth = wordWrapWidth; + this._lastLineHeightMultiplier = lineHeightMultiplier; + } + + /** Show/hide the text (used by TextEditor during editing). */ + setVisible(visible: boolean): void { + this.pixiText.visible = visible; + } + + /** + * Update text rasterization resolution for zoom-bucket crisp rendering. + * Only re-rasterizes when the bucket actually changes. + */ + setZoomBucket(bucket: number): void { + if (bucket === this._zoomBucket) return; + this._zoomBucket = bucket; + this.pixiText.resolution = bucket; + } + + /** + * Update text content and style. Only touches PixiJS properties + * when the corresponding input has actually changed. + * Returns true if text content or style changed (dimensions may differ). + */ + update(opts: TextCoreOptions): boolean { + const { text, fontSize, fontFamily, fill } = opts; + const wordWrapWidth = opts.wordWrapWidth || 0; + const lineHeightMultiplier = opts.lineHeightMultiplier || 1; + + const contentChanged = text !== this._lastContent; + const styleChanged = + fontSize !== this._lastFontSize || + fontFamily !== this._lastFontFamily || + fill !== this._lastFill; + const wrapChanged = + wordWrapWidth !== this._lastWordWrapWidth || + lineHeightMultiplier !== this._lastLineHeightMultiplier; + + if (!contentChanged && !styleChanged && !wrapChanged) return false; + + // Update cache + this._lastContent = text; + this._lastFontSize = fontSize; + this._lastFontFamily = fontFamily; + this._lastFill = fill; + this._lastWordWrapWidth = wordWrapWidth; + this._lastLineHeightMultiplier = lineHeightMultiplier; + + if (contentChanged) { + this.pixiText.text = text; + } + + if (styleChanged || wrapChanged) { + this.style.fontSize = fontSize; + this.style.fontFamily = fontFamily; + this.style.fill = fill; + if (wordWrapWidth > 0) { + this.style.wordWrap = true; + this.style.wordWrapWidth = wordWrapWidth; + this.style.lineHeight = fontSize * lineHeightMultiplier; + } else { + this.style.wordWrap = false; + } + // Do NOT reassign pixiText.style — it's already the live reference. + // Mutating style properties is sufficient; reassigning triggers + // a redundant PixiJS invalidation. + } + + return true; + } +} diff --git a/frontend/src/canvas/sprites/TextSprite.ts b/frontend/src/canvas/sprites/TextSprite.ts index e3675e3..e36460d 100644 --- a/frontend/src/canvas/sprites/TextSprite.ts +++ b/frontend/src/canvas/sprites/TextSprite.ts @@ -1,43 +1,35 @@ /** - * TextSprite — wraps PixiJS Text with dirty-check caching. + * TextSprite — free-flowing text display object. * - * Prevents unnecessary PixiJS text invalidation by comparing - * incoming data against last-applied values. PixiJS Text treats - * any property assignment as an invalidation trigger, even if - * the value is identical — so we must guard every mutation. + * Composes TextCore for dirty-checked text rendering. + * No word-wrap — text flows freely, measured width/height + * reflect actual rendered bounds. * * Structure: * Container (this) - * └─ _text: Text + * └─ TextCore.pixiText */ -import { Container, Text, TextStyle } from 'pixi.js'; +import { Container } from 'pixi.js'; import type { TextObject } from '../scene-format'; +import { TextCore } from './TextCore'; export class TextSprite extends Container { - private _text: Text; - private _style: TextStyle; - - // Dirty-check cache - private _lastContent = ''; - private _lastFontSize = 24; - private _lastFontFamily = 'sans-serif'; - private _lastFill = '#ffffff'; - private _zoomBucket = 1; + private _core: TextCore; /** Measured width after last text change. */ get measuredWidth(): number { - return this._text.width; + return this._core.measuredWidth; } /** Measured height after last text change. */ get measuredHeight(): number { - return this._text.height; + return this._core.measuredHeight; } /** Show/hide the text (used by TextEditor during editing). */ showText(visible: boolean): void { - this._text.visible = visible; + this._core.setVisible(visible); } /** @@ -45,26 +37,19 @@ export class TextSprite extends Container { * Only re-rasterizes when the bucket actually changes. */ setZoomBucket(bucket: number): void { - if (bucket === this._zoomBucket) return; - this._zoomBucket = bucket; - this._text.resolution = bucket; + this._core.setZoomBucket(bucket); } constructor(data: TextObject) { super(); - const fontSize = data.fontSize || 24; - const fontFamily = data.fontFamily || 'sans-serif'; - const fill = data.fill || '#ffffff'; - - this._style = new TextStyle({ fontSize, fontFamily, fill }); - this._text = new Text({ text: data.text || '', style: this._style }); - this.addChild(this._text); - - this._lastContent = data.text || ''; - this._lastFontSize = fontSize; - this._lastFontFamily = fontFamily; - this._lastFill = fill; + this._core = new TextCore({ + text: data.text || '', + fontSize: data.fontSize || 24, + fontFamily: data.fontFamily || 'sans-serif', + fill: data.fill || '#ffffff', + }); + this.addChild(this._core.pixiText); } /** @@ -73,38 +58,11 @@ export class TextSprite extends Container { * Returns true if dimensions may have changed (text/style updated). */ updateFromData(data: TextObject): boolean { - const content = data.text ?? ''; - const fontSize = data.fontSize || 24; - const fontFamily = data.fontFamily || 'sans-serif'; - const fill = data.fill || '#ffffff'; - - const contentChanged = content !== this._lastContent; - const styleChanged = - fontSize !== this._lastFontSize || - fontFamily !== this._lastFontFamily || - fill !== this._lastFill; - - if (!contentChanged && !styleChanged) return false; - - // Update cache - this._lastContent = content; - this._lastFontSize = fontSize; - this._lastFontFamily = fontFamily; - this._lastFill = fill; - - if (contentChanged) { - this._text.text = content; - } - - if (styleChanged) { - this._style.fontSize = fontSize; - this._style.fontFamily = fontFamily; - this._style.fill = fill; - // Do NOT reassign this._text.style = this._style — it's already the live - // reference, and reassigning triggers a redundant PixiJS invalidation. - // Mutating style properties is sufficient. - } - - return true; + return this._core.update({ + text: data.text ?? '', + fontSize: data.fontSize || 24, + fontFamily: data.fontFamily || 'sans-serif', + fill: data.fill || '#ffffff', + }); } } diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index e661262..e45fade 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -231,9 +231,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { item.displayObject.scale.set(d.sx, d.sy); } else if (item.type === 'sticky') { const d = item.data as any; - // Scale card width and fontSize, then reset sx/sy so text re-rasterizes crisp + // Bake width only — text re-wraps and height auto-adjusts. + // fontSize stays stable so the card acts like a resizable text box. d.w = Math.round(d.w * absSx); - d.fontSize = Math.round((d.fontSize || 14) * ((absSx + absSy) / 2)); d.sx = item.data.sx > 0 ? 1 : -1; d.sy = item.data.sy > 0 ? 1 : -1; if (item.displayObject instanceof StickySprite) {