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
|
||||
}
|
||||
}
|
||||
@@ -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 */}
|
||||
<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 */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AnnotationStore } from '../stores/annotationStore';
|
||||
import { PinOverlay } from '../canvas/PinOverlay';
|
||||
import { CropOverlay } from '../canvas/CropOverlay';
|
||||
import { TextSprite } from '../canvas/sprites/TextSprite';
|
||||
import { TextSharpnessManager } from '../canvas/textSharpness';
|
||||
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
|
||||
import { connectSocket, disconnectSocket } from '../socket';
|
||||
import api from '../api';
|
||||
@@ -70,6 +71,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
||||
const dropCleanupRef = useRef<(() => void) | null>(null);
|
||||
const pasteCleanupRef = useRef<(() => void) | null>(null);
|
||||
const laserCleanupRef = useRef<(() => void) | null>(null);
|
||||
const sharpnessCleanupRef = useRef<(() => void) | null>(null);
|
||||
const annotationStoreRef = useRef<AnnotationStore | null>(null);
|
||||
if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore();
|
||||
const pinOverlayRef = useRef<PinOverlay | null>(null);
|
||||
@@ -161,6 +163,17 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
||||
// Create UndoManager
|
||||
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
|
||||
const inboxZone = new InboxZone(scene.textures, scene.springs);
|
||||
viewport.addChild(inboxZone);
|
||||
@@ -475,6 +488,8 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
||||
pinOverlayRef.current = null;
|
||||
}
|
||||
annotationStoreRef.current?.clear();
|
||||
sharpnessCleanupRef.current?.();
|
||||
sharpnessCleanupRef.current = null;
|
||||
disconnectSocket();
|
||||
};
|
||||
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds]);
|
||||
|
||||
@@ -122,8 +122,8 @@ export default function Editor({ isPublicView }: EditorProps) {
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||
const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null);
|
||||
const [textToolbar, setTextToolbar] = useState<
|
||||
| { kind: 'text'; x: number; y: number; fontSize: number; fontFamily: string; fill: string; items: SceneItem[] }
|
||||
| { kind: 'sticky'; x: number; y: number; fontSize: number; fontFamily: string; textColor: string; fill: string; items: SceneItem[] }
|
||||
| { kind: 'text'; x: number; y: number; fontFamily: string; fill: string; items: SceneItem[] }
|
||||
| { kind: 'sticky'; x: number; y: number; fontFamily: string; textColor: string; fill: string; items: SceneItem[] }
|
||||
| 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;
|
||||
setTextToolbar({
|
||||
kind: 'text', x: posX, y: posY,
|
||||
fontSize: td.fontSize, fontFamily: td.fontFamily, fill: td.fill,
|
||||
fontFamily: td.fontFamily, fill: td.fill,
|
||||
items: textItems,
|
||||
});
|
||||
} else {
|
||||
const sd = stickyItems[0].data as StickyObject;
|
||||
setTextToolbar({
|
||||
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',
|
||||
items: stickyItems,
|
||||
});
|
||||
@@ -912,36 +912,10 @@ export default function Editor({ isPublicView }: EditorProps) {
|
||||
kind={textToolbar.kind}
|
||||
x={textToolbar.x}
|
||||
y={textToolbar.y}
|
||||
fontSize={textToolbar.fontSize}
|
||||
fontFamily={textToolbar.fontFamily}
|
||||
fill={textToolbar.kind === 'sticky' ? textToolbar.textColor : textToolbar.fill}
|
||||
noteFill={textToolbar.kind === 'sticky' ? textToolbar.fill : undefined}
|
||||
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) => {
|
||||
const scene = canvasRef.current?.getScene();
|
||||
for (const item of textToolbar.items) {
|
||||
|
||||
Reference in New Issue
Block a user