diff --git a/frontend/src/canvas/SceneManager.ts b/frontend/src/canvas/SceneManager.ts index 6feb111..de8cecf 100644 --- a/frontend/src/canvas/SceneManager.ts +++ b/frontend/src/canvas/SceneManager.ts @@ -161,6 +161,7 @@ export class SceneManager { private _onChange: (() => void) | null = null; private _onItemDimensionsChanged: ((itemId: string) => void) | null = null; + private _onItemCreated: ((item: SceneItem) => void) | null = null; private _zCounter: number = 0; constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) { @@ -190,6 +191,10 @@ export class SceneManager { this._onItemDimensionsChanged = fn; } + set onItemCreated(fn: ((item: SceneItem) => void) | null) { + this._onItemCreated = fn; + } + get onChange(): (() => void) | null { return this._onChange; } @@ -387,6 +392,7 @@ export class SceneManager { } // No entrance animation — items must be visible and draggable immediately. + this._onItemCreated?.(item); } // -- Item Update --------------------------------------------------------- diff --git a/frontend/src/canvas/sprites/StickySprite.ts b/frontend/src/canvas/sprites/StickySprite.ts index a1092ff..3cc1ef5 100644 --- a/frontend/src/canvas/sprites/StickySprite.ts +++ b/frontend/src/canvas/sprites/StickySprite.ts @@ -49,6 +49,7 @@ 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 { @@ -60,6 +61,16 @@ export class StickySprite extends Container { this._text.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._text.resolution = bucket; + } + constructor(data: StickyObject) { super(); diff --git a/frontend/src/canvas/sprites/TextSprite.ts b/frontend/src/canvas/sprites/TextSprite.ts index c65dafb..e3675e3 100644 --- a/frontend/src/canvas/sprites/TextSprite.ts +++ b/frontend/src/canvas/sprites/TextSprite.ts @@ -23,6 +23,7 @@ export class TextSprite extends Container { private _lastFontSize = 24; private _lastFontFamily = 'sans-serif'; private _lastFill = '#ffffff'; + private _zoomBucket = 1; /** Measured width after last text change. */ get measuredWidth(): number { @@ -39,6 +40,16 @@ export class TextSprite extends Container { this._text.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._text.resolution = bucket; + } + constructor(data: TextObject) { super(); diff --git a/frontend/src/canvas/textSharpness.ts b/frontend/src/canvas/textSharpness.ts new file mode 100644 index 0000000..72a1970 --- /dev/null +++ b/frontend/src/canvas/textSharpness.ts @@ -0,0 +1,129 @@ +/** + * textSharpness — zoom-bucket-aware text rendering. + * + * Tracks the viewport zoom level and groups it into discrete buckets. + * When the bucket changes, visible text/sticky sprites are re-rasterized + * at the new resolution for crisp rendering. Small zoom movements within + * the same bucket are ignored to avoid unnecessary texture churn. + * + * This does NOT mutate scene data (fontSize stays stable). + * It only changes the render resolution of text textures. + */ + +import type { Viewport } from 'pixi-viewport'; +import type { SceneManager, SceneItem } from './SceneManager'; +import { getItemWorldBounds } from './SceneManager'; +import { TextSprite } from './sprites/TextSprite'; +import { StickySprite } from './sprites/StickySprite'; + +/** + * Map continuous zoom to discrete bucket. + * Fewer buckets = fewer re-rasterizations = better perf. + */ +export function getTextZoomBucket(scale: number): number { + if (scale < 0.4) return 0.5; + if (scale < 0.75) return 0.75; + if (scale < 1.25) return 1; + if (scale < 1.75) return 1.5; + if (scale < 2.5) return 2; + return 3; +} + +export class TextSharpnessManager { + private _viewport: Viewport; + private _scene: SceneManager; + private _currentBucket = 1; + + constructor(viewport: Viewport, scene: SceneManager) { + this._viewport = viewport; + this._scene = scene; + this._currentBucket = getTextZoomBucket(viewport.scale.x); + // Apply initial bucket to all existing text/sticky items + this._refreshAll(); + } + + /** + * Call on viewport zoom/move. Returns true if bucket changed. + */ + check(): boolean { + const bucket = getTextZoomBucket(this._viewport.scale.x); + if (bucket === this._currentBucket) return false; + this._currentBucket = bucket; + this._refreshVisible(); + return true; + } + + /** + * Apply current zoom bucket to a single item. + * Call this when a new text/sticky sprite is created. + */ + applyToItem(item: SceneItem): void { + if (item.displayObject instanceof TextSprite) { + item.displayObject.setZoomBucket(this._currentBucket); + } else if (item.displayObject instanceof StickySprite) { + item.displayObject.setZoomBucket(this._currentBucket); + } + } + + /** Refresh all text/sticky items (used on initial setup). */ + private _refreshAll(): void { + const bucket = this._currentBucket; + for (const item of this._scene.getAllItems()) { + if (item.type !== 'text' && item.type !== 'sticky') continue; + if (item.displayObject instanceof TextSprite) { + item.displayObject.setZoomBucket(bucket); + } else if (item.displayObject instanceof StickySprite) { + item.displayObject.setZoomBucket(bucket); + } + } + } + + /** Refresh visible text/sticky items at current bucket (on zoom change). */ + private _refreshVisible(): void { + const bucket = this._currentBucket; + const items = this._scene.getAllItems(); + const vb = this._getViewportWorldBounds(); + + for (const item of items) { + if (item.type !== 'text' && item.type !== 'sticky') continue; + // Skip off-screen items + if (vb && !this._intersects(item, vb)) continue; + + if (item.displayObject instanceof TextSprite) { + item.displayObject.setZoomBucket(bucket); + } else if (item.displayObject instanceof StickySprite) { + item.displayObject.setZoomBucket(bucket); + } + } + } + + /** Get viewport bounds in world coordinates. */ + private _getViewportWorldBounds(): { x: number; y: number; w: number; h: number } | null { + const vp = this._viewport; + if (!vp.screenWidth || !vp.screenHeight) return null; + const tl = vp.toWorld(0, 0); + const br = vp.toWorld(vp.screenWidth, vp.screenHeight); + return { x: tl.x, y: tl.y, w: br.x - tl.x, h: br.y - tl.y }; + } + + /** Check if item intersects viewport bounds (with margin). Uses world bounds for group children. */ + private _intersects(item: SceneItem, vb: { x: number; y: number; w: number; h: number }): boolean { + const margin = 200; // world-space margin to include near-screen items + const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item); + return ( + ix + iw >= vb.x - margin && + iy + ih >= vb.y - margin && + ix <= vb.x + vb.w + margin && + iy <= vb.y + vb.h + margin + ); + } + + /** Current bucket value (for initial setup of new items). */ + get currentBucket(): number { + return this._currentBucket; + } + + destroy(): void { + // nothing to clean up currently + } +} diff --git a/frontend/src/components/TextFormatToolbar.tsx b/frontend/src/components/TextFormatToolbar.tsx index 52f3dc4..9e31eac 100644 --- a/frontend/src/components/TextFormatToolbar.tsx +++ b/frontend/src/components/TextFormatToolbar.tsx @@ -4,14 +4,12 @@ interface TextFormatToolbarProps { kind: 'text' | 'sticky'; x: number; y: number; - fontSize: number; fontFamily: string; /** Text color (both text and sticky). */ fill: string; /** Note background color (sticky only). */ noteFill?: string; position?: 'above' | 'below'; - onFontSizeChange: (size: number) => void; onFontFamilyChange: (family: string) => void; onFillChange: (color: string) => void; /** Called when sticky note background color changes. */ @@ -35,7 +33,7 @@ const PRESET_COLORS = [ ]; export default function TextFormatToolbar(props: TextFormatToolbarProps) { - const { kind, x, y, fontSize, fontFamily, fill, noteFill, position = 'above', onFontSizeChange, onFontFamilyChange, onFillChange, onNoteFillChange } = props; + const { kind, x, y, fontFamily, fill, noteFill, position = 'above', onFontFamilyChange, onFillChange, onNoteFillChange } = props; const [showFontMenu, setShowFontMenu] = useState(false); const [showColorPicker, setShowColorPicker] = useState(false); const [showNoteFillPicker, setShowNoteFillPicker] = useState(false); @@ -97,30 +95,6 @@ export default function TextFormatToolbar(props: TextFormatToolbarProps) { }} onPointerDown={(e) => e.stopPropagation()} > - {/* Font size: decrease / value / increase */} - - - {fontSize} - - - - {/* Divider */} -
- {/* Font family dropdown */}