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:
Hiren Kangad
2026-03-12 23:23:30 +05:30
parent 9fac16e6a4
commit 1ac727ea60
7 changed files with 177 additions and 57 deletions
+6
View File
@@ -161,6 +161,7 @@ export class SceneManager {
private _onChange: (() => void) | null = null; private _onChange: (() => void) | null = null;
private _onItemDimensionsChanged: ((itemId: string) => void) | null = null; private _onItemDimensionsChanged: ((itemId: string) => void) | null = null;
private _onItemCreated: ((item: SceneItem) => void) | null = null;
private _zCounter: number = 0; private _zCounter: number = 0;
constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) { constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) {
@@ -190,6 +191,10 @@ export class SceneManager {
this._onItemDimensionsChanged = fn; this._onItemDimensionsChanged = fn;
} }
set onItemCreated(fn: ((item: SceneItem) => void) | null) {
this._onItemCreated = fn;
}
get onChange(): (() => void) | null { get onChange(): (() => void) | null {
return this._onChange; return this._onChange;
} }
@@ -387,6 +392,7 @@ export class SceneManager {
} }
// No entrance animation — items must be visible and draggable immediately. // No entrance animation — items must be visible and draggable immediately.
this._onItemCreated?.(item);
} }
// -- Item Update --------------------------------------------------------- // -- Item Update ---------------------------------------------------------
@@ -49,6 +49,7 @@ export class StickySprite extends Container {
// Background redraw cache: skip Graphics.clear()+redraw when shape is unchanged // Background redraw cache: skip Graphics.clear()+redraw when shape is unchanged
private _bgCacheKey = ''; private _bgCacheKey = '';
private _zoomBucket = 1;
/** After layout, the computed card height. Parent should sync to data.h. */ /** After layout, the computed card height. Parent should sync to data.h. */
get computedHeight(): number { get computedHeight(): number {
@@ -60,6 +61,16 @@ export class StickySprite extends Container {
this._text.visible = visible; 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) { constructor(data: StickyObject) {
super(); super();
+11
View File
@@ -23,6 +23,7 @@ export class TextSprite extends Container {
private _lastFontSize = 24; private _lastFontSize = 24;
private _lastFontFamily = 'sans-serif'; private _lastFontFamily = 'sans-serif';
private _lastFill = '#ffffff'; private _lastFill = '#ffffff';
private _zoomBucket = 1;
/** Measured width after last text change. */ /** Measured width after last text change. */
get measuredWidth(): number { get measuredWidth(): number {
@@ -39,6 +40,16 @@ export class TextSprite extends Container {
this._text.visible = visible; 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) { constructor(data: TextObject) {
super(); super();
+129
View File
@@ -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
}
}
+1 -27
View File
@@ -4,14 +4,12 @@ interface TextFormatToolbarProps {
kind: 'text' | 'sticky'; kind: 'text' | 'sticky';
x: number; x: number;
y: number; y: number;
fontSize: number;
fontFamily: string; fontFamily: string;
/** Text color (both text and sticky). */ /** Text color (both text and sticky). */
fill: string; fill: string;
/** Note background color (sticky only). */ /** Note background color (sticky only). */
noteFill?: string; noteFill?: string;
position?: 'above' | 'below'; position?: 'above' | 'below';
onFontSizeChange: (size: number) => void;
onFontFamilyChange: (family: string) => void; onFontFamilyChange: (family: string) => void;
onFillChange: (color: string) => void; onFillChange: (color: string) => void;
/** Called when sticky note background color changes. */ /** Called when sticky note background color changes. */
@@ -35,7 +33,7 @@ const PRESET_COLORS = [
]; ];
export default function TextFormatToolbar(props: TextFormatToolbarProps) { 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 [showFontMenu, setShowFontMenu] = useState(false);
const [showColorPicker, setShowColorPicker] = useState(false); const [showColorPicker, setShowColorPicker] = useState(false);
const [showNoteFillPicker, setShowNoteFillPicker] = useState(false); const [showNoteFillPicker, setShowNoteFillPicker] = useState(false);
@@ -97,30 +95,6 @@ export default function TextFormatToolbar(props: TextFormatToolbarProps) {
}} }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
> >
{/* Font size: decrease / value / increase */}
<button
style={{ ...btnStyle, width: '26px', padding: 0 }}
onClick={() => onFontSizeChange(Math.max(8, fontSize - 2))}
title="Decrease font size"
{...hoverHandlers}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="2" y1="6" x2="10" y2="6" /></svg>
</button>
<span style={{ color: '#ccc', fontSize: '11px', minWidth: '24px', textAlign: 'center', userSelect: 'none' }}>
{fontSize}
</span>
<button
style={{ ...btnStyle, width: '26px', padding: 0 }}
onClick={() => onFontSizeChange(Math.min(200, fontSize + 2))}
title="Increase font size"
{...hoverHandlers}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="2" y1="6" x2="10" y2="6" /><line x1="6" y1="2" x2="6" y2="10" /></svg>
</button>
{/* Divider */}
<div style={{ width: '1px', height: '18px', background: '#333', margin: '0 2px', flexShrink: 0 }} />
{/* Font family dropdown */} {/* Font family dropdown */}
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<button <button
+15
View File
@@ -14,6 +14,7 @@ import { AnnotationStore } from '../stores/annotationStore';
import { PinOverlay } from '../canvas/PinOverlay'; import { PinOverlay } from '../canvas/PinOverlay';
import { CropOverlay } from '../canvas/CropOverlay'; import { CropOverlay } from '../canvas/CropOverlay';
import { TextSprite } from '../canvas/sprites/TextSprite'; import { TextSprite } from '../canvas/sprites/TextSprite';
import { TextSharpnessManager } from '../canvas/textSharpness';
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit // PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
import { connectSocket, disconnectSocket } from '../socket'; import { connectSocket, disconnectSocket } from '../socket';
import api from '../api'; import api from '../api';
@@ -70,6 +71,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
const dropCleanupRef = useRef<(() => void) | null>(null); const dropCleanupRef = useRef<(() => void) | null>(null);
const pasteCleanupRef = useRef<(() => void) | null>(null); const pasteCleanupRef = useRef<(() => void) | null>(null);
const laserCleanupRef = useRef<(() => void) | null>(null); const laserCleanupRef = useRef<(() => void) | null>(null);
const sharpnessCleanupRef = useRef<(() => void) | null>(null);
const annotationStoreRef = useRef<AnnotationStore | null>(null); const annotationStoreRef = useRef<AnnotationStore | null>(null);
if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore(); if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore();
const pinOverlayRef = useRef<PinOverlay | null>(null); const pinOverlayRef = useRef<PinOverlay | null>(null);
@@ -161,6 +163,17 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
// Create UndoManager // Create UndoManager
undoRef.current = new UndoManager(scene); undoRef.current = new UndoManager(scene);
// Zoom-bucket text sharpness — re-rasterize visible text when zoom crosses bucket boundary
const sharpness = new TextSharpnessManager(viewport, scene);
const onZoomBucketCheck = () => { sharpness.check(); };
viewport.on('zoomed', onZoomBucketCheck);
scene.onItemCreated = (item) => { sharpness.applyToItem(item); };
sharpnessCleanupRef.current = () => {
viewport.off('zoomed', onZoomBucketCheck);
scene.onItemCreated = null;
sharpness.destroy();
};
// Create InboxZone and add to viewport // Create InboxZone and add to viewport
const inboxZone = new InboxZone(scene.textures, scene.springs); const inboxZone = new InboxZone(scene.textures, scene.springs);
viewport.addChild(inboxZone); viewport.addChild(inboxZone);
@@ -475,6 +488,8 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
pinOverlayRef.current = null; pinOverlayRef.current = null;
} }
annotationStoreRef.current?.clear(); annotationStoreRef.current?.clear();
sharpnessCleanupRef.current?.();
sharpnessCleanupRef.current = null;
disconnectSocket(); disconnectSocket();
}; };
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds]); }, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds]);
+4 -30
View File
@@ -122,8 +122,8 @@ export default function Editor({ isPublicView }: EditorProps) {
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]); const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null); const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null);
const [textToolbar, setTextToolbar] = useState< const [textToolbar, setTextToolbar] = useState<
| { kind: 'text'; x: number; y: number; fontSize: number; fontFamily: string; fill: string; items: SceneItem[] } | { kind: 'text'; x: number; y: number; fontFamily: string; fill: string; items: SceneItem[] }
| { kind: 'sticky'; x: number; y: number; fontSize: number; fontFamily: string; textColor: string; fill: string; items: SceneItem[] } | { kind: 'sticky'; x: number; y: number; fontFamily: string; textColor: string; fill: string; items: SceneItem[] }
| null | null
>(null); >(null);
const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null); const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null);
@@ -619,14 +619,14 @@ export default function Editor({ isPublicView }: EditorProps) {
const td = textItems[0].data as TextObject; const td = textItems[0].data as TextObject;
setTextToolbar({ setTextToolbar({
kind: 'text', x: posX, y: posY, kind: 'text', x: posX, y: posY,
fontSize: td.fontSize, fontFamily: td.fontFamily, fill: td.fill, fontFamily: td.fontFamily, fill: td.fill,
items: textItems, items: textItems,
}); });
} else { } else {
const sd = stickyItems[0].data as StickyObject; const sd = stickyItems[0].data as StickyObject;
setTextToolbar({ setTextToolbar({
kind: 'sticky', x: posX, y: posY, kind: 'sticky', x: posX, y: posY,
fontSize: sd.fontSize || 14, fontFamily: sd.fontFamily || 'Inter, system-ui, sans-serif', fontFamily: sd.fontFamily || 'Inter, system-ui, sans-serif',
textColor: sd.textColor || '#1a1a1a', fill: sd.fill || '#ffd43b', textColor: sd.textColor || '#1a1a1a', fill: sd.fill || '#ffd43b',
items: stickyItems, items: stickyItems,
}); });
@@ -912,36 +912,10 @@ export default function Editor({ isPublicView }: EditorProps) {
kind={textToolbar.kind} kind={textToolbar.kind}
x={textToolbar.x} x={textToolbar.x}
y={textToolbar.y} y={textToolbar.y}
fontSize={textToolbar.fontSize}
fontFamily={textToolbar.fontFamily} fontFamily={textToolbar.fontFamily}
fill={textToolbar.kind === 'sticky' ? textToolbar.textColor : textToolbar.fill} fill={textToolbar.kind === 'sticky' ? textToolbar.textColor : textToolbar.fill}
noteFill={textToolbar.kind === 'sticky' ? textToolbar.fill : undefined} noteFill={textToolbar.kind === 'sticky' ? textToolbar.fill : undefined}
position={textToolbar.items.length >= 2 ? 'below' : 'above'} position={textToolbar.items.length >= 2 ? 'below' : 'above'}
onFontSizeChange={(size) => {
const scene = canvasRef.current?.getScene();
for (const item of textToolbar.items) {
if (textToolbar.kind === 'text') {
const d = item.data as TextObject;
d.fontSize = size;
if (item.displayObject instanceof TextSprite) {
item.displayObject.updateFromData(d);
d.w = item.displayObject.measuredWidth;
d.h = item.displayObject.measuredHeight;
}
} else {
const d = item.data as StickyObject;
d.fontSize = size;
if (item.displayObject instanceof StickySprite) {
item.displayObject.updateFromData(d);
d.h = item.displayObject.computedHeight;
}
}
if (scene) scene.updateSpatialEntry(item);
}
selectionRef.current?.transformBox.update(textToolbar.items);
onCanvasChange(textToolbar.items.map(i => i.id));
updateOverlays();
}}
onFontFamilyChange={(family) => { onFontFamilyChange={(family) => {
const scene = canvasRef.current?.getScene(); const scene = canvasRef.current?.getScene();
for (const item of textToolbar.items) { for (const item of textToolbar.items) {