queueMicrotask fires before the click event settles, causing the
textarea to blur immediately and trigger empty-text cleanup.
requestAnimationFrame waits one paint frame — enough for the pointer
event to fully resolve without the sluggishness of setTimeout(50).
Replace setTimeout(50) with queueMicrotask for TEXT tool editor open
(matching STICKY tool). Add screenToWorld helper for zoom-aware font
sizes and sticky card dimensions. Remove unused Text/TextStyle imports.
- Replace 50ms setTimeout with queueMicrotask for editor open
- Skip updateFromData() entirely when no inputs changed
- Cache background redraw inputs, skip Graphics clear+redraw when shape unchanged
- Separate text-only, style-only, and bg-only update paths
Clear editor state before scene callbacks to prevent blur timer or
startEditing re-entry from calling updateFromData on a destroyed sprite.
Guard all display object access with .destroyed checks.
New reviewTargeting.ts provides resolveReviewTargetAtPoint() which:
- descends into groups to find the deepest commentable child under the click
- walks children in z-order (front-to-back) for correct visual stacking
- handles nested groups recursively
- returns null if no valid child exists under the point
Editor.tsx review click handler now delegates to the resolver instead of
raw queryRegion + topmost-z pick. Defensive guard rejects group objectIds
before draft creation.
Existing group-anchored threads continue to render and open normally.
1. TEXT and ERASER tool clicks now suppressed during review mode via
reviewMode flag on ToolContext (read from ref for live value)
2. Object-comment input hidden when draftPin is active — only one
creation input visible at a time
3. PinOverlay entrance animation removed — focus styling (scale 1.15x,
alpha changes) no longer fights with fade-in animation loop
Items were fading in from alpha 0 via spring animation, preventing
immediate interaction. Removed the entrance animation entirely — items
now appear at full opacity and are movable on first frame.
CropOverlay was hidden behind images because SceneManager._applyZOrder()
reorders viewport children by z-index, pushing non-scene children behind.
Now start() moves itself to the top of the viewport child list.
- CropOverlay: register move/up handlers dynamically on drag start (single
handler instead of 8x per-handle), remove on drag end. Override destroy()
to call _cleanup() preventing keyboard listener leaks.
- Text format toolbar: re-measure text bounds (w/h) after fontSize/fontFamily
changes, update spatial index and transform box.
- TextEditor: add clearText() method; tools.ts uses it instead of fragile
document.querySelector('textarea').
- SceneManager._updateItem: apply crop mask on remote sync for image items.
- useCanvasSetup: stop TextEditor on unmount to prevent orphaned textarea.
- Double-click zoom: use item.data dimensions instead of getBounds() (which
includes shadow offset).
- Text tool: click-to-place immediately opens inline editor, auto-switches
back to select tool. Empty text cleanup on save.
- Crop: select image + press C (or right-click > Crop) to enter crop mode.
8 drag handles with rule-of-thirds grid, dimmed outside area.
Enter confirms, Escape cancels. Non-destructive (stored as normalized rect).
- Text format toolbar: appears when text items selected, with font size +/-,
font family dropdown, and color picker with presets.
- Double-click image: zoom-to-fit (PureRef-style focus)
- Entrance animation: replaced bounce with simple fade-in (no delay before
items become interactive)
- TextEditor: allow empty text (caller handles cleanup)
- Transform handles counter-scale by 1/zoom so they stay constant screen size
- Selection border stroke also counter-scales
- Multi-select resize scales items as a unit from bounding box anchor
- Redraw transform box on viewport zoom/pan via 'moved' listener
- Fix 403 on save for public collection viewers (return role in GET board response)
- Add read-only status indicator (StatusBar + StatusIndicator)
- Fix beforeunload save to use fetch+keepalive with auth header
- Socket reconnect now rejoins board room automatically
- Canvas setup uses polling instead of brittle 200ms timer
- Fix double user:left on disconnect (use disconnecting event, snapshot rooms)
- Thread + comment creation wrapped in db.transaction
- Prevent owner downgrade via addCollectionMember (check existing member)
- Bound redirect depth in downloadImage to 5
- Arrangement operations anchor to bounding box top-left (no drift)
- Distribute H/V also anchor to top-left
- Fix annotations fetch to use axios api instance (401 interceptor)
- Replace require() with static import in shortcut-definitions
- PinOverlay: zero-allocation rendering with in-place Graphics/Text updates,
fix WebGL texture crash (addressModeU null) from orphaned Text objects
- Pins always visible on media, track during drag via updatePositions() fast path
- Replace eraser with Review mode in toolbar tool group (orange gradient)
- Add tool instruction hints bar below toolbar (context-sensitive per tool)
- Feedback panel: glassmorphism, improved ThreadDetail/CommentItem/ThreadListItem
- Timestamps: relative with "ago" suffix, full date on hover tooltip
- De-emphasize pin numbers, show status text in thread list
- Animated arrangement transitions (easeOutCubic, 320ms) for pack/grid/row/column
- Disable snap guides (user requested)
- Paste now selects newly created items
- Remove eraser keyboard shortcuts, remap tool shortcuts
- Orphaned thread cleanup: safe destroy check for deleted media pins
1. toggleVote wrapped in transaction (race condition fix)
2. FeedbackPanel fetch calls now surface errors via onError/toast
3. Extracted resolveBoard/hasCollectionRole to shared board-access.js
4. AnnotationStore uses monotonic version counter for snapshots
5. PinOverlay uses object pool instead of destroy/recreate on refresh
6. canvasObjects prop memoized with useMemo
7. PinOverlay store subscription cleaned up on unmount
8. Comment content capped at 5000 chars (backend validation)
10. anchor_type validated to 'object' or 'point'
Multi-file drops now show queued/uploading distinction. Jobs start
as 'queued' and transition to 'uploading' when the HTTP request
begins. Queued items show a cancel button in the upload panel.
Cancelled jobs are skipped in the sequential upload loop.
Override destroy() in AnimatedGifSprite to call releaseGif() when
a loaded GIF item is deleted from the board. Without this, the
gifCache refCount stays elevated until full canvas teardown.
Browsers sometimes report empty or non-standard MIME types for
dragged files. Now checks file extension as fallback when MIME
doesn't match the allowlist. Only shows rejection feedback for
files that are recognizably media but unsupported.
URLs with media-like extensions (.avif, .tiff, .heic, .bmp, etc.)
that aren't in our supported set now show a failed entry in the
upload panel. Plain webpage URLs without media extensions are still
silently ignored (not our concern).
Replace broad image/*/video/* prefix matching with explicit MIME
allowlist matching backend. Unsupported files (BMP, TIFF, AVIF,
HEIC, PDF, etc.) now show as failed rows in the upload panel
with a clear "Unsupported format: .ext" message instead of being
silently ignored. Applies to both drag-drop and clipboard paste.
1. Move GIF loading behind TextureManager.loadGif/releaseGif with
ref-counting (fixes shared-source invalidation on duplicate GIFs)
2. Strip query strings/fragments before .gif extension check
3. GIFs now load paused — separate MAX_PLAYING_GIFS=8 budget in
culling system, only nearest N animate (others show static frame)
Register GifAsset extension for .gif loading, create AnimatedGifSprite
wrapper with lazy load/unload matching ImageSprite pattern, and detect
.gif assets in SceneManager to use animated rendering. GIFs stored as
type 'image' in scene format — no schema changes needed.
New ExportDialog component with scope (selection/all), filename,
format (PNG/JPEG/WebP), quality slider, scale multiplier, and
background color picker. Wired into Toolbar and Editor.
1. URL imports now participate in upload manager pipeline:
addUrlJob() creates a job row, uploadComplete/setFailed called
on success/failure — consistent with local file uploads
2. activeCount only counts uploading/processing jobs, not failed.
Failed jobs no longer inflate the "Uploads (n)" header count.
3. uploadManager.clear() called on boardId change so stale jobs
from a previous board don't linger in the next board's panel.
- UploadManager store: tracks jobs through uploading → processing → done/failed
- UploadPanel component: floating bottom-left widget showing upload progress,
file names, sizes, status with auto-dismiss for completed items
- axios onUploadProgress wired for real-time upload percentage
- Video jobs transition to "processing" after upload, then "done" when
media:job:update socket event arrives (reuses existing pipeline)
- Failed uploads show error message from server response
- Clear button to dismiss finished/failed items
Pass SelectionManager to setupDragDrop and setupPaste. After each
upload completes, the newly created scene items are selected:
- Single file/URL: selectOnly
- Multi-file drop/paste: select all new items
- Background media worker: polls media_jobs table, runs ffprobe+ffmpeg
with concurrency limit, generates video posters, emits socket events
- Non-blocking video upload: stores file + enqueues job, returns immediately
- Poster hydration on board load: GET /boards/:id injects poster/dimensions
from DB into canvas_state video objects
- SpatialGrid: fixed-cell (512px) spatial hash for O(nearby) culling instead
of O(all) item scanning, eliminates setTimeout violations
- Canvas-based video rendering: draws video frames to offscreen canvas then
uploads to GPU, completely eliminates GL_INVALID_OPERATION errors from
PixiJS VideoSource auto-update mechanism
- Server poster upgrade path: culling ticker and applyProcessedMedia() both
upgrade client-captured posters to server posters when available
- Pause restores server poster (paused video behaves like an image)
- Selection drag-end persistence: onObjectDragEnd broadcasts + saves + undo
- Live media:job:update socket handler patches scene data + VideoSprite
dimensions without broadcast fanout
Add _serverPosterLoading flag so repeated 200ms culling ticks don't
call textures.load() multiple times before the first resolves. Also
release the texture ref if the sprite was destroyed or started playing
during the async load.
1. Server posters no longer auto-load in VideoSprite constructor.
loadServerPoster() is now public and called by the culling system
only when within MAX_POSTER_VIDEOS budget. Videos outside the
budget stay as placeholders regardless of server poster availability.
2. video-utils cleanup() now uses statSync to distinguish files from
directories, unlinks files first, then rmdirs. Previously tmpDir
was passed through path.dirname() which resolved to /tmp instead
of the created temp directory.
Upload pipeline now extracts poster frame (JPEG) and metadata
(width, height, duration) from videos at upload time using ffmpeg.
Server:
- Add ffmpeg to Docker image (Alpine)
- video-utils.js: probeVideo() and extractPoster() using ffprobe/ffmpeg
- Upload response includes poster_asset_key and duration
Frontend:
- VideoObject gains poster and duration fields in scene format
- VideoSprite accepts posterAssetKey + TextureManager, loads poster
as a regular image texture on construction (no <video> needed)
- Server poster loaded/released via TextureManager ref counting
- addVideoFromUpload passes poster and duration through to scene data
- image-drop.ts forwards poster_asset_key from upload response
Result: videos with server posters render as images by default.
Zero <video> elements needed for thumbnails. Only explicit play
creates a media element.
preload='metadata' doesn't decode video frames, so readyState stays
at 1 (HAVE_METADATA) and loadeddata never fires. capturePoster() now
detects this state and forces a seek to 0.1s via _trySeekCapture(),
which triggers frame decode and captures on the 'seeked' event.
Video memory:
- Lazy <video> creation: constructor makes placeholder only, initVideo()
creates element with preload='metadata' when near viewport
- Tiered budgets: 1 playing / 6 initialized / 4 poster textures max
- Aggressive teardown: offscreen videos lose <video> element, poster,
and all textures — zero memory for offscreen videos
- Poster textures dropped while playing (don't keep both resident)
- Dimension caching in scene data (nativeW/nativeH) avoids re-init
Server:
- HTTP Range support (206 Partial Content) for video seeking
- Proper end clamping and invalid range rejection (416)
Sync:
- Remove redundant broadcastSceneDebounced() from broadcastTransform()
- Remove duplicate broadcastElements() from drag-end handler
VideoControls:
- Event-driven (timeupdate/play/pause/seeked) instead of rAF polling
- Tracks actual HTMLVideoElement reference, rebinds on init/teardown
- Seek uses ref-based commit on pointerUp, not conditional onChange
When duplicated images share the same assetKey, Assets.unload()
was destroying the shared texture source — crashing all sprites
using it (null addressModeU/alphaMode in WebGL renderer).
TextureManager now tracks refCount per asset. Only calls
Assets.unload() when the last reference is released. ImageSprite
calls release() instead of unload() on texture teardown.