feat: zoom-bucket text sharpness + remove fontSize toolbar controls
Add TextSharpnessManager for crisp text rendering at all zoom levels: - Discrete zoom buckets (0.5–3x) avoid texture churn on small zoom changes - Initial bucket applied to all items on setup and newly created items via SceneManager.onItemCreated callback (fixes blurry-on-load) - Visibility check uses getItemWorldBounds for correct grouped item coords - TextSprite/StickySprite gain setZoomBucket() for resolution control Remove fontSize +/- controls from contextual toolbar — font size is now controlled purely through direct manipulation (resize → bake).
This commit is contained in:
@@ -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 ---------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user