diff --git a/frontend/src/canvas/PinOverlay.ts b/frontend/src/canvas/PinOverlay.ts index 3bbf1f5..08d1e6a 100644 --- a/frontend/src/canvas/PinOverlay.ts +++ b/frontend/src/canvas/PinOverlay.ts @@ -2,63 +2,64 @@ import { Container, Graphics, Text, TextStyle } from 'pixi.js'; import type { Viewport } from 'pixi-viewport'; import type { AnnotationStore } from '../stores/annotationStore'; import type { SceneManager } from './SceneManager'; +import { getItemWorldBounds } from './SceneManager'; import { getAuthorColorHex, getAuthorInitial } from '../utils/authorColors'; -const PIN_RADIUS = 12; +const PIN_RADIUS = 11; const PIN_COLOR_RESOLVED = 0x555555; +const TAIL_WIDTH = 6; +const TAIL_HEIGHT = 8; -// Tail direction: 225° (down-left) in standard math coords. -// In PixiJS screen coords (+y down), 225° maps to: -// cos(225°) = -√2/2 (left) -// sin(225°) = +√2/2 (down, because +y is down in screen space) -// So the circle center is offset UP-RIGHT from the anchor tip: -// cx_offset = +r * √2/2, cy_offset = -r * √2/2 -const SIN45 = Math.SQRT1_2; // √2/2 ≈ 0.7071 +function drawBubble(gfx: Graphics, r: number, color: number, alpha: number, scale: number): void { + const padding = r * 0.3; + const bubbleW = r * 2 + padding * 2; + const bubbleH = r * 2 + padding; + const cornerR = r * 0.4; + const tailW = TAIL_WIDTH * scale; + const tailH = TAIL_HEIGHT * scale; + const bx = -bubbleW / 2; + const by = -tailH - bubbleH; -/** - * Draws a Figma-style teardrop pin whose tip lands at (0, 0). - * The circle body is offset up-right; the tail points down-left to (0,0). - * - * @param gfx Graphics object (drawn in its own local space, tip = origin) - * @param r Pin radius (already scaled to world space) - * @param color Fill color (0xRRGGBB) - * @param alpha Fill alpha - */ -function drawTeardrop(gfx: Graphics, r: number, color: number, alpha: number): void { - // Circle center relative to tip - const cx = r * SIN45; - const cy = -r * SIN45; - - // Half-angle of the tail aperture from the tip (in radians). - // Controls how wide/sharp the tail looks. ~20° gives a nice taper. - const tailHalfAngle = (20 * Math.PI) / 180; - - // Angle from circle center toward tip = 225° in screen coords - const tipAngle = (225 * Math.PI) / 180; - - // The two "wing" angles on the circle where the tail edges start - const wingAngle1 = tipAngle - tailHalfAngle; - const wingAngle2 = tipAngle + tailHalfAngle; - - // Build the teardrop as a single filled path: - // start at wing1 on circle → arc around (the "long way" CCW past top) → wing2 → line to tip → close - gfx.moveTo(cx + r * Math.cos(wingAngle1), cy + r * Math.sin(wingAngle1)); - // Arc from wingAngle1 to wingAngle2 going CCW (counterclockwise = increasing angle in screen space) - // "long way" means going through the top of the circle (not through 225°). - // In PixiJS, arc() takes (cx, cy, r, startAngle, endAngle, anticlockwise). - // We want the arc that does NOT pass through 225°, so go anticlockwise from wingAngle1 to wingAngle2. - gfx.arc(cx, cy, r, wingAngle1, wingAngle2, true); - gfx.lineTo(0, 0); // tip at anchor + gfx.roundRect(bx, by, bubbleW, bubbleH, cornerR); + gfx.fill({ color, alpha }); + gfx.moveTo(-tailW, -tailH); + gfx.lineTo(0, 0); + gfx.lineTo(tailW, -tailH); gfx.closePath(); gfx.fill({ color, alpha }); + gfx.roundRect(bx, by, bubbleW, bubbleH, cornerR); + gfx.stroke({ color: 0xffffff, width: 1.2 * scale, alpha: alpha * 0.5 }); } +// Shared TextStyle to avoid recreating per pin +const sharedStyle = new TextStyle({ + fill: '#ffffff', + fontWeight: 'bold', + fontFamily: 'Inter, system-ui, sans-serif', +}); + +interface PinData { + container: Container; + gfx: Graphics; + initialText: Text; + badge: Graphics | null; + badgeText: Text | null; + version: string; // tracks thread data changes +} + +/** + * PinOverlay — comment bubbles in a separate overlay Container. + * + * Position: uses getItemWorldBounds() which reads item.data.x/y (always current). + * Visuals: Graphics redrawn in-place (clear + redraw), Text objects reused. + * No objects are created or destroyed during drag — only position.set() calls. + */ export class PinOverlay extends Container { - private _pins = new Map(); - private _pool: Container[] = []; + private _pins = new Map(); private _viewport: Viewport; private _scene: SceneManager; private _store: AnnotationStore; + private _lastScale = -1; constructor(viewport: Viewport, scene: SceneManager, store: AnnotationStore) { super(); @@ -67,124 +68,212 @@ export class PinOverlay extends Container { this._store = store; } - private _acquire(): Container { - const c = this._pool.pop() || new Container(); - c.removeChildren(); - c.visible = true; - c.eventMode = 'none'; - c.cursor = 'default'; - return c; + private _threadVersion(t: { status: string; created_by: string; comment_count: number }): string { + return `${t.status}|${t.created_by}|${t.comment_count}`; } - private _release(c: Container) { - c.visible = false; - this._pool.push(c); + private _getAnchorWorld(thread: { object_id: string; anchor_type: string; pin_x: number | null; pin_y: number | null }): { x: number; y: number } | null { + const item = this._scene.items.get(thread.object_id); + if (!item) return null; + const b = getItemWorldBounds(item); + if (thread.anchor_type === 'point' && thread.pin_x != null && thread.pin_y != null) { + return { x: b.x + thread.pin_x * b.w, y: b.y + thread.pin_y * b.h }; + } + return { x: b.x + b.w * 0.9, y: b.y }; } - /** Call on every viewport moved/zoomed event and on store change */ + /** + * Fast path: only update positions. Called during drag. + * Zero allocations, zero texture work. + */ + updatePositions() { + for (const [threadId, entry] of this._pins) { + const thread = this._store.threads.get(threadId); + if (!thread) continue; + const pos = this._getAnchorWorld(thread); + if (pos) { + entry.container.position.set(pos.x, pos.y); + } + } + } + + /** + * Full refresh: sync pins with store, rebuild visuals on zoom/data change. + * Call on zoom, store change, or scene change — NOT during drag (use updatePositions). + */ refresh(showResolved = false) { const scale = 1 / this._viewport.scale.x; - - // Return current pins to pool - for (const c of this._pins.values()) this._release(c); - this._pins.clear(); + const scaleChanged = Math.abs(scale - this._lastScale) > 0.0001; + this._lastScale = scale; const r = PIN_RADIUS * scale; + const active = new Set(); for (const thread of this._store.threads.values()) { if (thread.status === 'resolved' && !showResolved) continue; - const item = this._scene.items.get(thread.object_id); - if (!item || !item.displayObject) continue; + const pos = this._getAnchorWorld(thread); + if (!pos) continue; - const bounds = item.displayObject.getBounds(); - let wx: number, wy: number; + active.add(thread.id); + const version = this._threadVersion(thread); + const existing = this._pins.get(thread.id); - if (thread.anchor_type === 'point' && thread.pin_x != null && thread.pin_y != null) { - wx = bounds.x + thread.pin_x * bounds.width; - wy = bounds.y + thread.pin_y * bounds.height; - } else { - // Default: top-right corner of object bounds - wx = bounds.x + bounds.width; - wy = bounds.y; + if (existing) { + existing.container.position.set(pos.x, pos.y); + + if (existing.version !== version || scaleChanged) { + this._updateVisuals(existing, thread, r, scale); + existing.version = version; + } + continue; } - const isResolved = thread.status === 'resolved'; - const fillColor = isResolved ? PIN_COLOR_RESOLVED : getAuthorColorHex(thread.created_by); - const fillAlpha = isResolved ? 0.5 : 1.0; - - // Container whose origin = anchor tip - const pin = this._acquire(); - pin.position.set(wx, wy); - pin.eventMode = 'static'; - pin.cursor = 'pointer'; - (pin as any)._threadId = thread.id; - - // ── Teardrop body ── - const gfx = new Graphics(); - drawTeardrop(gfx, r, fillColor, fillAlpha); - // White stroke around the circle part only (drawn as a separate circle stroke) - gfx.circle(r * SIN45, -r * SIN45, r); - gfx.stroke({ color: 0xffffff, width: 1.5 * scale, alpha: isResolved ? 0.4 : 0.85 }); - pin.addChild(gfx); - - // ── Author initial ── - const authorName = thread.comments[0]?.author_name ?? ''; - const initial = getAuthorInitial(authorName); - const initialText = new Text({ - text: initial, - style: new TextStyle({ - fontSize: r * 1.1, - fill: '#ffffff', - fontWeight: 'bold', - fontFamily: 'Inter, system-ui, sans-serif', - }), - }); - initialText.anchor.set(0.5); - // Center on the circle body - initialText.position.set(r * SIN45, -r * SIN45); - pin.addChild(initialText); - - // ── Pin number label (#N) ── - const pinNum = this._store.getPinNumber(thread.id); - const numLabel = new Text({ - text: `#${pinNum}`, - style: new TextStyle({ - fontSize: r * 0.75, - fill: '#ffffff', - fontWeight: 'bold', - fontFamily: 'Inter, system-ui, sans-serif', - dropShadow: { - color: '#000000', - blur: 2 * scale, - distance: 0, - alpha: 0.6, - }, - }), - }); - numLabel.anchor.set(0, 1); - // Place just to the right of the circle top - numLabel.position.set(r * SIN45 + r * 1.1, -r * SIN45 - r * 0.9); - pin.addChild(numLabel); - - if (!pin.parent) this.addChild(pin); - this._pins.set(thread.id, pin); + // Create new pin — only happens on store change, never during drag + const entry = this._createPin(thread, r, scale, pos); + this._pins.set(thread.id, entry); } + + // Remove pins whose threads are gone + for (const [id, entry] of this._pins) { + if (!active.has(id)) { + this._destroyPin(entry); + this._pins.delete(id); + } + } + } + + private _createPin(thread: any, r: number, scale: number, pos: { x: number; y: number }): PinData { + const container = new Container(); + container.position.set(pos.x, pos.y); + container.eventMode = 'static'; + container.cursor = 'pointer'; + (container as any)._threadId = thread.id; + + const gfx = new Graphics(); + container.addChild(gfx); + + const initialText = new Text({ text: '', style: sharedStyle.clone() }); + initialText.anchor.set(0.5); + container.addChild(initialText); + + const badge = new Graphics(); + badge.visible = false; + container.addChild(badge); + + const badgeText = new Text({ text: '', style: sharedStyle.clone() }); + badgeText.anchor.set(0.5); + badgeText.visible = false; + container.addChild(badgeText); + + const entry: PinData = { container, gfx, initialText, badge, badgeText, version: '' }; + this._updateVisuals(entry, thread, r, scale); + entry.version = this._threadVersion(thread); + + this.addChild(container); + + // Entrance animation + container.alpha = 0; + container.scale.set(0.6); + this._animateEntrance(container); + + return entry; + } + + /** + * Update visuals IN PLACE — no object creation or destruction. + * Graphics are cleared and redrawn. Text content/style updated. + */ + private _updateVisuals(entry: PinData, thread: any, r: number, scale: number) { + const isResolved = thread.status === 'resolved'; + const fillColor = isResolved ? PIN_COLOR_RESOLVED : getAuthorColorHex(thread.created_by); + const fillAlpha = isResolved ? 0.45 : 0.92; + + // Redraw bubble — clear() keeps the same GPU buffer + entry.gfx.clear(); + drawBubble(entry.gfx, r, fillColor, fillAlpha, scale); + + // Update initial text + const authorName = thread.comments[0]?.author_name ?? ''; + const initial = getAuthorInitial(authorName); + const tailH = TAIL_HEIGHT * scale; + const bubbleH = r * 2 + r * 0.3; + const bubbleCenterY = -tailH - bubbleH / 2; + + entry.initialText.text = initial; + entry.initialText.style.fontSize = r * 1.05; + entry.initialText.position.set(0, bubbleCenterY); + + // Update badge + if (thread.comment_count > 1) { + const padding = r * 0.3; + const bubbleW = r * 2 + padding * 2; + const badgeR = r * 0.45; + const badgeX = bubbleW / 2 - badgeR * 0.5; + const badgeY = -tailH - bubbleH + badgeR * 0.5; + + entry.badge!.clear(); + entry.badge!.circle(badgeX, badgeY, badgeR); + entry.badge!.fill({ color: 0x000000, alpha: 0.6 }); + entry.badge!.circle(badgeX, badgeY, badgeR); + entry.badge!.stroke({ color: 0xffffff, width: 0.8 * scale, alpha: 0.5 }); + entry.badge!.visible = true; + + entry.badgeText!.text = `${thread.comment_count}`; + entry.badgeText!.style.fontSize = r * 0.55; + entry.badgeText!.position.set(badgeX, badgeY); + entry.badgeText!.visible = true; + } else { + entry.badge!.visible = false; + entry.badgeText!.visible = false; + } + } + + private _destroyPin(entry: PinData) { + if (!entry.container.destroyed) { + entry.container.destroy({ children: true }); + } + } + + private _animateEntrance(pin: Container) { + const tick = () => { + if (pin.destroyed) return; + let done = true; + if (pin.alpha < 0.99) { + pin.alpha += (1 - pin.alpha) * 0.3; + done = false; + } else { + pin.alpha = 1; + } + const s = pin.scale.x; + if (s < 0.99) { + pin.scale.set(s + (1 - s) * 0.3); + done = false; + } else { + pin.scale.set(1); + } + if (!done) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); } getThreadIdAtPoint(worldX: number, worldY: number): string | null { const scale = 1 / this._viewport.scale.x; const r = PIN_RADIUS * scale; - // Hit-test against the circle body of each pin (not the tail tip) - const cx_off = r * SIN45; - const cy_off = -r * SIN45; + const tailH = TAIL_HEIGHT * scale; + const bubbleH = r * 2 + r * 0.3; - for (const [threadId, pin] of this._pins) { - const circleCx = pin.position.x + cx_off; - const circleCy = pin.position.y + cy_off; - const dx = circleCx - worldX; - const dy = circleCy - worldY; - if (dx * dx + dy * dy <= r * r) { + for (const [threadId, entry] of this._pins) { + const bx = entry.container.position.x; + const by = entry.container.position.y; + const halfW = r + r * 0.3; + + if ( + worldX >= bx - halfW && + worldX <= bx + halfW && + worldY >= by - tailH - bubbleH && + worldY <= by + ) { return threadId; } } @@ -192,10 +281,10 @@ export class PinOverlay extends Container { } override destroy(options?: any) { - for (const c of this._pins.values()) c.destroy({ children: true }); - for (const c of this._pool) c.destroy({ children: true }); + for (const entry of this._pins.values()) { + this._destroyPin(entry); + } this._pins.clear(); - this._pool = []; super.destroy(options); } } diff --git a/frontend/src/canvas/SelectionManager.ts b/frontend/src/canvas/SelectionManager.ts index ba390a8..8f082ef 100644 --- a/frontend/src/canvas/SelectionManager.ts +++ b/frontend/src/canvas/SelectionManager.ts @@ -249,8 +249,8 @@ export class SelectionManager { // Lift shadow + spring scale on all selected image sprites this._applyLift(); - // Begin snap guide session - this._snapGuides.beginSession(this.selectedIds); + // Snap guides disabled — users found it annoying + // this._snapGuides.beginSession(this.selectedIds); } if (this._objectDragging) { @@ -266,11 +266,11 @@ export class SelectionManager { prospective.x += ddx; prospective.y += ddy; - // Snap to alignment guides - const snap = this._snapGuides.computeSnap(prospective, this._viewport); - ddx += snap.dx; - ddy += snap.dy; - this._snapGuides.drawGuides(snap.guides, this._viewport); + // Snap guides disabled + // const snap = this._snapGuides.computeSnap(prospective, this._viewport); + // ddx += snap.dx; + // ddy += snap.dy; + // this._snapGuides.drawGuides(snap.guides, this._viewport); // Move all selected items by corrected delta and broadcast all together for (const item of selected) { @@ -311,7 +311,7 @@ export class SelectionManager { if (this._objectDragging) { // End object drag — drop shadow + spring scale back this._applyDrop(); - this._snapGuides.endSession(); + // this._snapGuides.endSession(); this._objectDragging = false; // Resume viewport drag diff --git a/frontend/src/canvas/TransformBox.ts b/frontend/src/canvas/TransformBox.ts index 468fec1..7cafdf1 100644 --- a/frontend/src/canvas/TransformBox.ts +++ b/frontend/src/canvas/TransformBox.ts @@ -227,9 +227,9 @@ export class TransformBox extends Container { origTransforms, }; - // Begin snap session excluding current items - const itemIds = new Set(this._items.map((it) => it.id)); - this._snapGuides?.beginSession(itemIds); + // Snap guides disabled + // const itemIds = new Set(this._items.map((it) => it.id)); + // this._snapGuides?.beginSession(itemIds); } private _onHandleMove(e: FederatedPointerEvent): void { @@ -346,21 +346,11 @@ export class TransformBox extends Container { this.update(this._items); - // Snap guides during resize - if (this._snapGuides && this._viewport) { - const snap = this._snapGuides.computeSnap(this._bounds, this._viewport); - if (snap.dx !== 0 || snap.dy !== 0) { - // Apply snap correction to all items - for (const item of this._items) { - item.data.x += snap.dx; - item.data.y += snap.dy; - item.displayObject.position.set(item.data.x, item.data.y); - this._onItemTransform?.(item); - } - this.update(this._items); - } - this._snapGuides.drawGuides(snap.guides, this._viewport); - } + // Snap guides disabled + // if (this._snapGuides && this._viewport) { + // const snap = this._snapGuides.computeSnap(this._bounds, this._viewport); + // ... + // } // Show dimension label const bounds = this._bounds; @@ -399,7 +389,7 @@ export class TransformBox extends Container { this._drag = null; this._dimLabel.visible = false; this._dimLabelBg.visible = false; - this._snapGuides?.endSession(); + // this._snapGuides?.endSession(); // Notify that drag ended — persist/sync the final state this._onDragEnd?.(this._items.map(i => i.id)); } diff --git a/frontend/src/canvas/context-menu-items.ts b/frontend/src/canvas/context-menu-items.ts index 85237d0..651ab31 100644 --- a/frontend/src/canvas/context-menu-items.ts +++ b/frontend/src/canvas/context-menu-items.ts @@ -9,6 +9,7 @@ import type { Viewport } from 'pixi-viewport'; import type { GroupObject } from './scene-format'; import { FrameSprite } from './sprites/FrameSprite'; import * as ops from './operations'; +import { onArrangeAnimationDone } from './operations'; export interface MenuItem { label: string; @@ -135,11 +136,11 @@ export function buildContextMenuItems(ctx: MenuContext): MenuItem[] { { label: '', shortcut: '', onClick: () => {}, divider: true }, // -- Arrangement -- - { label: 'Arrange Pack', shortcut: 'Ctrl+Shift+P', onClick: () => { ops.arrangeOptimal(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel }, - { label: 'Arrange Grid', shortcut: '', onClick: () => { ops.arrangeGrid(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel }, - { label: 'Arrange Row', shortcut: '', onClick: () => { ops.arrangeRow(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel }, - { label: 'Arrange Column', shortcut: '', onClick: () => { ops.arrangeColumn(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel }, - { label: 'Stack', shortcut: 'Ctrl+Alt+S', onClick: () => { ops.stackObjects(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !multiSel }, + { label: 'Arrange Pack', shortcut: 'Ctrl+Shift+P', onClick: () => { onArrangeAnimationDone(() => selection?.transformBox.update(selected)); ops.arrangeOptimal(selected); ctx.onChange(ids); }, disabled: !multiSel }, + { label: 'Arrange Grid', shortcut: '', onClick: () => { onArrangeAnimationDone(() => selection?.transformBox.update(selected)); ops.arrangeGrid(selected); ctx.onChange(ids); }, disabled: !multiSel }, + { label: 'Arrange Row', shortcut: '', onClick: () => { onArrangeAnimationDone(() => selection?.transformBox.update(selected)); ops.arrangeRow(selected); ctx.onChange(ids); }, disabled: !multiSel }, + { label: 'Arrange Column', shortcut: '', onClick: () => { onArrangeAnimationDone(() => selection?.transformBox.update(selected)); ops.arrangeColumn(selected); ctx.onChange(ids); }, disabled: !multiSel }, + { label: 'Stack', shortcut: 'Ctrl+Alt+S', onClick: () => { onArrangeAnimationDone(() => selection?.transformBox.update(selected)); ops.stackObjects(selected); ctx.onChange(ids); }, disabled: !multiSel }, { label: '', shortcut: '', onClick: () => {}, divider: true }, // -- Normalize -- diff --git a/frontend/src/canvas/operations.ts b/frontend/src/canvas/operations.ts index 3a4d50d..67aba10 100644 --- a/frontend/src/canvas/operations.ts +++ b/frontend/src/canvas/operations.ts @@ -18,9 +18,66 @@ function scaledH(item: SceneItem): number { return item.data.h * item.data.sy; } -/** Sync displayObject position from item.data. */ +// ─── Animated position sync ─── + +let _animTargets = new Map(); +let _animRaf = 0; +let _animCallback: ((items: SceneItem[]) => void) | null = null; +const ANIM_DURATION = 320; // ms +const ANIM_STEP = 1000 / 60; + +function easeOutCubic(t: number): number { + return 1 - Math.pow(1 - t, 3); +} + +function _animTick() { + const dt = ANIM_STEP / ANIM_DURATION; + let done = true; + + for (const [item, anim] of _animTargets) { + anim.t = Math.min(1, anim.t + dt); + const e = easeOutCubic(anim.t); + item.displayObject.position.set( + anim.startX + (anim.endX - anim.startX) * e, + anim.startY + (anim.endY - anim.startY) * e, + ); + if (anim.t < 1) done = false; + } + + if (!done) { + _animRaf = requestAnimationFrame(_animTick); + } else { + // Snap to final positions and clean up + for (const [item, anim] of _animTargets) { + item.displayObject.position.set(anim.endX, anim.endY); + } + const items = Array.from(_animTargets.keys()); + _animTargets.clear(); + _animRaf = 0; + _animCallback?.(items); + } +} + +/** Sync displayObject position from item.data with smooth animation. */ function syncPosition(item: SceneItem): void { - item.displayObject.position.set(item.data.x, item.data.y); + _animTargets.set(item, { + startX: item.displayObject.x, + startY: item.displayObject.y, + endX: item.data.x, + endY: item.data.y, + t: 0, + }); + if (!_animRaf) { + _animRaf = requestAnimationFrame(_animTick); + } +} + +/** + * Register a callback that fires once when the current arrangement animation finishes. + * Use this to persist/broadcast final positions. + */ +export function onArrangeAnimationDone(cb: (items: SceneItem[]) => void): void { + _animCallback = cb; } /** Sync displayObject scale from item.data. */ diff --git a/frontend/src/canvas/shortcut-definitions.ts b/frontend/src/canvas/shortcut-definitions.ts index 20f1907..8aa2f92 100644 --- a/frontend/src/canvas/shortcut-definitions.ts +++ b/frontend/src/canvas/shortcut-definitions.ts @@ -99,6 +99,14 @@ async function _pasteInternal(ctx: ShortcutContext): Promise { } ctx.clipboardRef.current = newItems; ctx.scene._applyZOrder(); + + // Select the newly pasted items + ctx.selection.selectedIds.clear(); + for (const item of newItems) { + ctx.selection.selectedIds.add(item.id); + } + ctx.selection.transformBox.update(newItems); + ctx.onChange(); } @@ -110,6 +118,15 @@ function _opUpdate(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void ctx.onChange(items.map(i => i.id)); } +/** Like _opUpdate but defers transformBox update until animation completes */ +function _opUpdateAnimated(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void { + const items = ctx.selection.getSelectedItems(); + const { onArrangeAnimationDone } = require('./operations'); + onArrangeAnimationDone(() => ctx.selection.transformBox.update(items)); + op(items); + ctx.onChange(items.map(i => i.id)); +} + export const shortcuts: ShortcutDef[] = [ // ═══════════════════════════════════════ @@ -184,27 +201,27 @@ export const shortcuts: ShortcutDef[] = [ { id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true }, category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2, - handler: (ctx) => _opUpdate(ctx, ops.arrangeOptimal), + handler: (ctx) => _opUpdateAnimated(ctx, ops.arrangeOptimal), }, { id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true }, category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2, - handler: (ctx) => _opUpdate(ctx, ops.arrangeByName), + handler: (ctx) => _opUpdateAnimated(ctx, ops.arrangeByName), }, { id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true }, category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2, - handler: (ctx) => _opUpdate(ctx, ops.arrangeByZOrder), + handler: (ctx) => _opUpdateAnimated(ctx, ops.arrangeByZOrder), }, { id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true }, category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2, - handler: (ctx) => _opUpdate(ctx, ops.arrangeRandomly), + handler: (ctx) => _opUpdateAnimated(ctx, ops.arrangeRandomly), }, { id: 'stack', keys: { key: 's', ctrl: true, alt: true }, category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2, - handler: (ctx) => _opUpdate(ctx, ops.stackObjects), + handler: (ctx) => _opUpdateAnimated(ctx, ops.stackObjects), }, // ═══════════════════════════════════════ @@ -322,12 +339,12 @@ export const shortcuts: ShortcutDef[] = [ { id: 'equal-spacing-h', keys: { key: 'h', ctrl: true, shift: true }, category: 'arrangement', description: 'Equal horizontal spacing', needsSelection: true, minSelection: 2, - handler: (ctx) => _opUpdate(ctx, ops.equalSpacingH), + handler: (ctx) => _opUpdateAnimated(ctx, ops.equalSpacingH), }, { id: 'equal-spacing-v', keys: { key: 'v', ctrl: true, shift: true }, category: 'arrangement', description: 'Equal vertical spacing', needsSelection: true, minSelection: 2, - handler: (ctx) => _opUpdate(ctx, ops.equalSpacingV), + handler: (ctx) => _opUpdateAnimated(ctx, ops.equalSpacingV), }, // ═══════════════════════════════════════ diff --git a/frontend/src/canvas/tools.ts b/frontend/src/canvas/tools.ts index 123f2c8..d3895bf 100644 --- a/frontend/src/canvas/tools.ts +++ b/frontend/src/canvas/tools.ts @@ -279,10 +279,8 @@ export const toolShortcuts: Record = { h: ToolType.PAN, p: ToolType.PEN, t: ToolType.TEXT, - e: ToolType.ERASER, '1': ToolType.SELECT, '2': ToolType.PAN, '3': ToolType.PEN, '4': ToolType.TEXT, - '5': ToolType.ERASER, }; diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index a64f53d..9f93928 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -73,11 +73,13 @@ function IconText() { ); } -function IconEraser() { +function IconReview() { return ( - - + + + + ); } @@ -118,14 +120,15 @@ function IconLayers() { ); } -const toolButtons: { tool: ToolType; label: string; shortcut: string; numKey: string; Icon: React.FC }[] = [ - { tool: ToolType.SELECT, label: 'Select', shortcut: 'V', numKey: '1', Icon: IconSelect }, - { tool: ToolType.PAN, label: 'Pan', shortcut: 'H', numKey: '2', Icon: IconPan }, - { tool: ToolType.PEN, label: 'Draw', shortcut: 'P', numKey: '3', Icon: IconPen }, - { tool: ToolType.TEXT, label: 'Text', shortcut: 'T', numKey: '4', Icon: IconText }, - { tool: ToolType.ERASER, label: 'Eraser', shortcut: 'E', numKey: '5', Icon: IconEraser }, +const toolButtons: { tool: ToolType; label: string; shortcut: string; numKey: string; Icon: React.FC; hint?: string }[] = [ + { tool: ToolType.SELECT, label: 'Select', shortcut: 'V', numKey: '1', Icon: IconSelect, hint: 'Click to select, drag to move, Shift+click for multi-select' }, + { tool: ToolType.PAN, label: 'Pan', shortcut: 'H', numKey: '2', Icon: IconPan, hint: 'Click and drag to pan the canvas' }, + { tool: ToolType.PEN, label: 'Draw', shortcut: 'P', numKey: '3', Icon: IconPen, hint: 'Click and drag to draw freehand' }, + { tool: ToolType.TEXT, label: 'Text', shortcut: 'T', numKey: '4', Icon: IconText, hint: 'Click on the canvas to place text' }, ]; +const REVIEW_HINT = 'Click an image to leave a comment. Press . to toggle.'; + export default function Toolbar({ activeTool, onToolChange, @@ -158,26 +161,30 @@ export default function Toolbar({ const showStroke = activeTool === ToolType.PEN; const showFontSize = activeTool === ToolType.TEXT; + // Determine active hint + const activeToolDef = toolButtons.find((t) => t.tool === activeTool); + const activeHint = reviewMode ? REVIEW_HINT : activeToolDef?.hint || ''; + return ( +
{/* Tool buttons */}
{toolButtons.map(({ tool, label, shortcut, numKey, Icon }) => { - const active = activeTool === tool; + const active = activeTool === tool && !reviewMode; return ( ); })} + {/* Review mode — in tool group */} + {onToggleReview && ( + + )}
@@ -313,15 +350,6 @@ export default function Toolbar({ )} - {/* Review / Feedback */} - {onToggleReview && ( - - - - - - )} - {/* Spacer */}
@@ -370,6 +398,21 @@ export default function Toolbar({ )}
+ {/* Tool hint bar */} + {activeHint && ( +
+ {activeHint} +
+ )} +
); } diff --git a/frontend/src/components/feedback/CommentItem.tsx b/frontend/src/components/feedback/CommentItem.tsx index c93fe8d..1d67a5d 100644 --- a/frontend/src/components/feedback/CommentItem.tsx +++ b/frontend/src/components/feedback/CommentItem.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { Comment } from '../../stores/annotationStore'; import { getAuthorColor, getAuthorInitial } from '../../utils/authorColors'; -import { relativeTime } from '../../utils/relativeTime'; -import { TEXT_PRIMARY, TEXT_MUTED, BORDER } from './feedbackStyles'; +import { relativeTime, fullTimestamp } from '../../utils/relativeTime'; +import { TEXT_PRIMARY, TEXT_SECONDARY, TEXT_MUTED, BORDER, HOVER_BG } from './feedbackStyles'; interface CommentItemProps { comment: Comment; @@ -11,13 +11,23 @@ interface CommentItemProps { } export default function CommentItem({ comment, isOwn, onDelete }: CommentItemProps) { + const [hovered, setHovered] = React.useState(false); + return ( -
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + >
{getAuthorInitial(comment.author_name)} - + {comment.author_name} - + {relativeTime(comment.created_at)} {comment.edited_at && ( - (edited) + edited )}
- {isOwn && onDelete && ( + {isOwn && onDelete && hovered && ( @@ -62,10 +73,10 @@ export default function CommentItem({ comment, isOwn, onDelete }: CommentItemPro
+
{/* Header */}
(e.currentTarget.style.color = TEXT_PRIMARY)} + onMouseLeave={(e) => (e.currentTarget.style.color = TEXT_MUTED)} > ← - + #{pinNumber} {isOpen ? 'Open' : 'Resolved'} @@ -116,12 +108,17 @@ export default function ThreadDetail({ @@ -130,14 +127,18 @@ export default function ThreadDetail({ @@ -145,14 +146,18 @@ export default function ThreadDetail({ @@ -194,11 +199,16 @@ export default function ThreadDetail({ style={{ background: 'none', border: 'none', - color: '#633', + color: TEXT_MUTED, cursor: 'pointer', - fontSize: '13px', - padding: '2px', + fontSize: '14px', + padding: '2px 4px', + borderRadius: '4px', + opacity: 0.6, + transition: 'all 0.1s', }} + onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = STATUS_OPEN; }} + onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; e.currentTarget.style.color = TEXT_MUTED; }} title="Delete thread" > × @@ -207,7 +217,7 @@ export default function ThreadDetail({
{/* Comments */} -
+
{thread.comments.map((c) => ( {/* Reply input */} -
+
void; onSelectThread: (id: string) => void; onCollapse: () => void; - // New comment selectedObjectId: string | null; selectedObjectLabel: string; newCommentText: string; @@ -49,32 +49,21 @@ export default function ThreadList({ const [showOrphans, setShowOrphans] = React.useState(false); return ( -
+
{/* Header */}
- + + + + Feedback {openCount > 0 && ( @@ -82,12 +71,13 @@ export default function ThreadList({ style={{ background: STATUS_OPEN, color: '#fff', - fontSize: '11px', - fontWeight: 600, - padding: '1px 7px', + fontSize: '10px', + fontWeight: 700, + padding: '2px 7px', borderRadius: '10px', minWidth: '18px', textAlign: 'center', + lineHeight: '14px', }} > {openCount} @@ -102,8 +92,12 @@ export default function ThreadList({ cursor: 'pointer', fontSize: '16px', lineHeight: 1, - padding: '2px', + padding: '2px 4px', + borderRadius: '4px', + transition: 'color 0.1s', }} + onMouseEnter={(e) => (e.currentTarget.style.color = TEXT_PRIMARY)} + onMouseLeave={(e) => (e.currentTarget.style.color = TEXT_MUTED)} > × @@ -112,10 +106,10 @@ export default function ThreadList({ {/* Filter bar */}
{(['open', 'resolved', 'all', 'mine'] as const).map((f) => ( @@ -124,15 +118,16 @@ export default function ThreadList({ onClick={() => onFilterChange(f)} style={{ background: filter === f ? FILTER_ACTIVE_BG : 'transparent', - border: 'none', + border: filter === f ? `1px solid ${BORDER}` : '1px solid transparent', borderRadius: '6px', - color: filter === f ? '#fff' : TEXT_MUTED, + color: filter === f ? TEXT_PRIMARY : TEXT_MUTED, padding: '4px 10px', cursor: 'pointer', fontSize: '11px', fontWeight: filter === f ? 600 : 400, textTransform: 'capitalize', - transition: 'all 0.1s ease', + transition: 'all 0.15s ease', + lineHeight: '16px', }} > {f} @@ -142,9 +137,12 @@ export default function ThreadList({ {/* New comment input (when object selected) */} {selectedObjectId && ( -
-
- Comment on: {selectedObjectLabel} +
+
+ Comment on +
+
+ {selectedObjectLabel}
+
{threads.length === 0 && ( -
- - - -
+
+
+ + + +
+
{selectedObjectId ? 'No comments on this item.' - : 'No comments yet.\nSelect an image and add a comment.'} + : <>No comments yet.
Select an image to leave feedback.}
)} @@ -193,14 +190,14 @@ export default function ThreadList({
setShowOrphans(!showOrphans)} style={{ - padding: '10px 14px', + padding: '10px 16px', cursor: 'pointer', color: TEXT_MUTED, fontSize: '11px', borderTop: `1px solid ${BORDER}`, - transition: 'background 0.1s ease', + transition: 'background 0.15s ease', }} - onMouseEnter={(e) => (e.currentTarget.style.background = '#151515')} + onMouseEnter={(e) => (e.currentTarget.style.background = '#16161a')} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} > {showOrphans ? '\u25BE' : '\u25B8'} Deleted items ({orphanedThreads.length}) diff --git a/frontend/src/components/feedback/ThreadListItem.tsx b/frontend/src/components/feedback/ThreadListItem.tsx index b333058..bc26bcd 100644 --- a/frontend/src/components/feedback/ThreadListItem.tsx +++ b/frontend/src/components/feedback/ThreadListItem.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Thread } from '../../stores/annotationStore'; import { getAuthorColor, getAuthorInitial } from '../../utils/authorColors'; -import { relativeTime } from '../../utils/relativeTime'; +import { relativeTime, fullTimestamp } from '../../utils/relativeTime'; import { TEXT_PRIMARY, TEXT_SECONDARY, @@ -26,7 +26,7 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
{firstComment?.author_name || 'Unknown'} - + {relativeTime(thread.last_commented_at || thread.created_at)}
@@ -89,7 +89,7 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
)} - {/* Row 3: meta — pin #, replies, status dot */} + {/* Row 3: meta — replies, status */}
- - #{pinNumber} - {thread.comment_count > 1 && ( {thread.comment_count - 1} {thread.comment_count === 2 ? 'reply' : 'replies'} @@ -109,13 +106,15 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis )} + > + {thread.status} +
); diff --git a/frontend/src/components/feedback/feedbackStyles.ts b/frontend/src/components/feedback/feedbackStyles.ts index d840073..8b84b9c 100644 --- a/frontend/src/components/feedback/feedbackStyles.ts +++ b/frontend/src/components/feedback/feedbackStyles.ts @@ -1,18 +1,40 @@ // Design tokens for the feedback/annotation system -export const PANEL_WIDTH = 300; -export const PANEL_BG = '#0d0d0d'; -export const BORDER = '#1e1e1e'; +export const PANEL_WIDTH = 320; +export const PANEL_BG = 'rgba(12, 12, 14, 0.95)'; +export const PANEL_BG_SOLID = '#0c0c0e'; +export const BORDER = '#1a1a1e'; -export const TEXT_PRIMARY = '#e0e0e0'; -export const TEXT_SECONDARY = '#999'; -export const TEXT_MUTED = '#666'; +export const TEXT_PRIMARY = '#eaeaea'; +export const TEXT_SECONDARY = '#a0a0a8'; +export const TEXT_MUTED = '#5a5a64'; export const ACCENT = '#4a9eff'; -export const STATUS_OPEN = '#ef4444'; -export const STATUS_RESOLVED = '#22c55e'; +export const STATUS_OPEN = '#f04848'; +export const STATUS_RESOLVED = '#34d27b'; -export const INPUT_BG = '#141414'; -export const INPUT_BORDER = '#2a2a2a'; -export const INPUT_BORDER_FOCUS = '#3a3a3a'; -export const HOVER_BG = '#151515'; -export const FILTER_ACTIVE_BG = '#2a2a2a'; +export const INPUT_BG = '#131316'; +export const INPUT_BORDER = '#26262c'; +export const INPUT_BORDER_FOCUS = '#4a9eff44'; +export const HOVER_BG = '#16161a'; +export const FILTER_ACTIVE_BG = '#1e1e24'; + +// Shared panel container style +export const panelContainerStyle: React.CSSProperties = { + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + width: `${PANEL_WIDTH}px`, + background: PANEL_BG, + backdropFilter: 'blur(16px)', + WebkitBackdropFilter: 'blur(16px)', + borderLeft: `1px solid ${BORDER}`, + zIndex: 100, + display: 'flex', + flexDirection: 'column', + userSelect: 'none', + fontFamily: "'Inter', system-ui, -apple-system, sans-serif", +}; + +// Import this in components that need React.CSSProperties type +import type React from 'react'; diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index 23b7740..3fd32d7 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -135,21 +135,25 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { }, }); - // Wire live drag/resize transforms to sync broadcast (batched for multi-select) + // Wire live drag/resize transforms to sync broadcast + pin position update + // updatePositions() is the fast path — zero allocations, just position.set() selection.onItemsTransform = (items) => { syncRef.current?.broadcastTransform(items); + pinOverlayRef.current?.updatePositions(); }; selection.onItemTransform = (item) => { syncRef.current?.broadcastTransform(item); + pinOverlayRef.current?.updatePositions(); }; selection.transformBox.onItemTransform = (item) => { syncRef.current?.broadcastTransform(item); + pinOverlayRef.current?.updatePositions(); }; selection.onObjectDragEnd = (itemIds) => { onCanvasChange(itemIds); // broadcasts elements + saves + undo + spatial refresh }; selection.transformBox.onDragEnd = (itemIds) => { - onCanvasChange(itemIds); // broadcasts elements + saves + undo + spatial refresh + onCanvasChange(itemIds); }; socket.on('user:joined', (data: any) => { @@ -335,20 +339,19 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { socket.on('comment:delete', (data: any) => { annotationStoreRef.current?.onCommentDelete(data.threadId, data.commentId); }); - // ── Pin overlay (hidden by default, shown in Review Mode) ── + // ── Pin overlay — separate Container, positions from item.data directly ── const pinOverlay = new PinOverlay(viewport, scene, annotationStoreRef.current!); - pinOverlay.visible = false; + pinOverlay.visible = true; viewport.addChild(pinOverlay); pinOverlayRef.current = pinOverlay; - // Refresh overlay on viewport move + // Refresh on viewport move (zoom changes scale) and store changes const onViewportMoved = () => { - if (pinOverlay.visible) pinOverlay.refresh(); + pinOverlay.refresh(); }; viewport.on('moved', onViewportMoved); - // Refresh on store change (save unsub for cleanup) const unsubPinOverlay = annotationStoreRef.current!.subscribe(() => { - if (pinOverlay.visible) pinOverlay.refresh(); + pinOverlay.refresh(); }); (pinOverlay as any)._cleanup = () => { viewport.off('moved', onViewportMoved); diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 4ecbc63..565f0cf 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -176,27 +176,26 @@ export default function Editor({ isPublicView }: EditorProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [objectCount]); - // Toggle pin overlay visibility with review mode + wire pin clicks + // Pins are always visible; review mode just controls the panel + click-to-comment useEffect(() => { if (!pinOverlay) return; - pinOverlay.visible = reviewMode; - if (reviewMode) pinOverlay.refresh(); + pinOverlay.visible = true; + pinOverlay.refresh(); - // Pin click → expand thread in panel + // Pin click → expand thread in panel (listen on viewport since pins are item children) + const vp = canvasRef.current?.getViewport(); + if (!vp) return; const onClick = (e: any) => { - const vp = canvasRef.current?.getViewport(); - if (!vp) return; const worldPos = vp.toWorld(e.global); const threadId = pinOverlay.getThreadIdAtPoint(worldPos.x, worldPos.y); if (threadId) { + if (!reviewMode) setReviewMode(true); setFocusedThreadId(threadId); - // Reset so it can be triggered again for the same pin requestAnimationFrame(() => setFocusedThreadId(null)); } }; - pinOverlay.eventMode = 'static'; - pinOverlay.on('pointerdown', onClick); - return () => { pinOverlay.off('pointerdown', onClick); }; + vp.on('pointerdown', onClick); + return () => { vp.off('pointerdown', onClick); }; }, [reviewMode, pinOverlay]); // Tool activation @@ -386,8 +385,9 @@ export default function Editor({ isPublicView }: EditorProps) { }); } - // Refresh annotation pins so they follow transforms - if (pinOverlay?.visible) pinOverlay.refresh(); + // Pin positions follow media automatically (children of displayObjects). + // Only refresh on zoom to update counter-scale. + pinOverlay?.refresh(); }, [pinOverlay]); // Listen to viewport moved event for overlay updates (throttled) diff --git a/frontend/src/utils/relativeTime.ts b/frontend/src/utils/relativeTime.ts index 0f32420..1f6c99c 100644 --- a/frontend/src/utils/relativeTime.ts +++ b/frontend/src/utils/relativeTime.ts @@ -1,11 +1,25 @@ export function relativeTime(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); + const date = new Date(iso); + const diff = Date.now() - date.getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return 'now'; - if (mins < 60) return `${mins}m`; + if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h`; + if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); - if (days < 7) return `${days}d`; - return new Date(iso).toLocaleDateString(); + if (days < 7) return `${days}d ago`; + // Show date for older comments + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +/** Full timestamp for tooltip hover */ +export function fullTimestamp(iso: string): string { + const date = new Date(iso); + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); }