feat: annotation UX overhaul, animated packing, toolbar redesign
- 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
This commit is contained in:
+223
-134
@@ -2,63 +2,64 @@ import { Container, Graphics, Text, TextStyle } from 'pixi.js';
|
|||||||
import type { Viewport } from 'pixi-viewport';
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import type { AnnotationStore } from '../stores/annotationStore';
|
import type { AnnotationStore } from '../stores/annotationStore';
|
||||||
import type { SceneManager } from './SceneManager';
|
import type { SceneManager } from './SceneManager';
|
||||||
|
import { getItemWorldBounds } from './SceneManager';
|
||||||
import { getAuthorColorHex, getAuthorInitial } from '../utils/authorColors';
|
import { getAuthorColorHex, getAuthorInitial } from '../utils/authorColors';
|
||||||
|
|
||||||
const PIN_RADIUS = 12;
|
const PIN_RADIUS = 11;
|
||||||
const PIN_COLOR_RESOLVED = 0x555555;
|
const PIN_COLOR_RESOLVED = 0x555555;
|
||||||
|
const TAIL_WIDTH = 6;
|
||||||
|
const TAIL_HEIGHT = 8;
|
||||||
|
|
||||||
// Tail direction: 225° (down-left) in standard math coords.
|
function drawBubble(gfx: Graphics, r: number, color: number, alpha: number, scale: number): void {
|
||||||
// In PixiJS screen coords (+y down), 225° maps to:
|
const padding = r * 0.3;
|
||||||
// cos(225°) = -√2/2 (left)
|
const bubbleW = r * 2 + padding * 2;
|
||||||
// sin(225°) = +√2/2 (down, because +y is down in screen space)
|
const bubbleH = r * 2 + padding;
|
||||||
// So the circle center is offset UP-RIGHT from the anchor tip:
|
const cornerR = r * 0.4;
|
||||||
// cx_offset = +r * √2/2, cy_offset = -r * √2/2
|
const tailW = TAIL_WIDTH * scale;
|
||||||
const SIN45 = Math.SQRT1_2; // √2/2 ≈ 0.7071
|
const tailH = TAIL_HEIGHT * scale;
|
||||||
|
const bx = -bubbleW / 2;
|
||||||
|
const by = -tailH - bubbleH;
|
||||||
|
|
||||||
/**
|
gfx.roundRect(bx, by, bubbleW, bubbleH, cornerR);
|
||||||
* Draws a Figma-style teardrop pin whose tip lands at (0, 0).
|
gfx.fill({ color, alpha });
|
||||||
* The circle body is offset up-right; the tail points down-left to (0,0).
|
gfx.moveTo(-tailW, -tailH);
|
||||||
*
|
gfx.lineTo(0, 0);
|
||||||
* @param gfx Graphics object (drawn in its own local space, tip = origin)
|
gfx.lineTo(tailW, -tailH);
|
||||||
* @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.closePath();
|
gfx.closePath();
|
||||||
gfx.fill({ color, alpha });
|
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 {
|
export class PinOverlay extends Container {
|
||||||
private _pins = new Map<string, Container>();
|
private _pins = new Map<string, PinData>();
|
||||||
private _pool: Container[] = [];
|
|
||||||
private _viewport: Viewport;
|
private _viewport: Viewport;
|
||||||
private _scene: SceneManager;
|
private _scene: SceneManager;
|
||||||
private _store: AnnotationStore;
|
private _store: AnnotationStore;
|
||||||
|
private _lastScale = -1;
|
||||||
|
|
||||||
constructor(viewport: Viewport, scene: SceneManager, store: AnnotationStore) {
|
constructor(viewport: Viewport, scene: SceneManager, store: AnnotationStore) {
|
||||||
super();
|
super();
|
||||||
@@ -67,124 +68,212 @@ export class PinOverlay extends Container {
|
|||||||
this._store = store;
|
this._store = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _acquire(): Container {
|
private _threadVersion(t: { status: string; created_by: string; comment_count: number }): string {
|
||||||
const c = this._pool.pop() || new Container();
|
return `${t.status}|${t.created_by}|${t.comment_count}`;
|
||||||
c.removeChildren();
|
|
||||||
c.visible = true;
|
|
||||||
c.eventMode = 'none';
|
|
||||||
c.cursor = 'default';
|
|
||||||
return c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _release(c: Container) {
|
private _getAnchorWorld(thread: { object_id: string; anchor_type: string; pin_x: number | null; pin_y: number | null }): { x: number; y: number } | null {
|
||||||
c.visible = false;
|
const item = this._scene.items.get(thread.object_id);
|
||||||
this._pool.push(c);
|
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) {
|
refresh(showResolved = false) {
|
||||||
const scale = 1 / this._viewport.scale.x;
|
const scale = 1 / this._viewport.scale.x;
|
||||||
|
const scaleChanged = Math.abs(scale - this._lastScale) > 0.0001;
|
||||||
// Return current pins to pool
|
this._lastScale = scale;
|
||||||
for (const c of this._pins.values()) this._release(c);
|
|
||||||
this._pins.clear();
|
|
||||||
|
|
||||||
const r = PIN_RADIUS * scale;
|
const r = PIN_RADIUS * scale;
|
||||||
|
const active = new Set<string>();
|
||||||
|
|
||||||
for (const thread of this._store.threads.values()) {
|
for (const thread of this._store.threads.values()) {
|
||||||
if (thread.status === 'resolved' && !showResolved) continue;
|
if (thread.status === 'resolved' && !showResolved) continue;
|
||||||
|
|
||||||
const item = this._scene.items.get(thread.object_id);
|
const pos = this._getAnchorWorld(thread);
|
||||||
if (!item || !item.displayObject) continue;
|
if (!pos) continue;
|
||||||
|
|
||||||
const bounds = item.displayObject.getBounds();
|
active.add(thread.id);
|
||||||
let wx: number, wy: number;
|
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) {
|
if (existing) {
|
||||||
wx = bounds.x + thread.pin_x * bounds.width;
|
existing.container.position.set(pos.x, pos.y);
|
||||||
wy = bounds.y + thread.pin_y * bounds.height;
|
|
||||||
} else {
|
if (existing.version !== version || scaleChanged) {
|
||||||
// Default: top-right corner of object bounds
|
this._updateVisuals(existing, thread, r, scale);
|
||||||
wx = bounds.x + bounds.width;
|
existing.version = version;
|
||||||
wy = bounds.y;
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 isResolved = thread.status === 'resolved';
|
||||||
const fillColor = isResolved ? PIN_COLOR_RESOLVED : getAuthorColorHex(thread.created_by);
|
const fillColor = isResolved ? PIN_COLOR_RESOLVED : getAuthorColorHex(thread.created_by);
|
||||||
const fillAlpha = isResolved ? 0.5 : 1.0;
|
const fillAlpha = isResolved ? 0.45 : 0.92;
|
||||||
|
|
||||||
// Container whose origin = anchor tip
|
// Redraw bubble — clear() keeps the same GPU buffer
|
||||||
const pin = this._acquire();
|
entry.gfx.clear();
|
||||||
pin.position.set(wx, wy);
|
drawBubble(entry.gfx, r, fillColor, fillAlpha, scale);
|
||||||
pin.eventMode = 'static';
|
|
||||||
pin.cursor = 'pointer';
|
|
||||||
(pin as any)._threadId = thread.id;
|
|
||||||
|
|
||||||
// ── Teardrop body ──
|
// Update initial text
|
||||||
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 authorName = thread.comments[0]?.author_name ?? '';
|
||||||
const initial = getAuthorInitial(authorName);
|
const initial = getAuthorInitial(authorName);
|
||||||
const initialText = new Text({
|
const tailH = TAIL_HEIGHT * scale;
|
||||||
text: initial,
|
const bubbleH = r * 2 + r * 0.3;
|
||||||
style: new TextStyle({
|
const bubbleCenterY = -tailH - bubbleH / 2;
|
||||||
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) ──
|
entry.initialText.text = initial;
|
||||||
const pinNum = this._store.getPinNumber(thread.id);
|
entry.initialText.style.fontSize = r * 1.05;
|
||||||
const numLabel = new Text({
|
entry.initialText.position.set(0, bubbleCenterY);
|
||||||
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);
|
// Update badge
|
||||||
this._pins.set(thread.id, pin);
|
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 {
|
getThreadIdAtPoint(worldX: number, worldY: number): string | null {
|
||||||
const scale = 1 / this._viewport.scale.x;
|
const scale = 1 / this._viewport.scale.x;
|
||||||
const r = PIN_RADIUS * scale;
|
const r = PIN_RADIUS * scale;
|
||||||
// Hit-test against the circle body of each pin (not the tail tip)
|
const tailH = TAIL_HEIGHT * scale;
|
||||||
const cx_off = r * SIN45;
|
const bubbleH = r * 2 + r * 0.3;
|
||||||
const cy_off = -r * SIN45;
|
|
||||||
|
|
||||||
for (const [threadId, pin] of this._pins) {
|
for (const [threadId, entry] of this._pins) {
|
||||||
const circleCx = pin.position.x + cx_off;
|
const bx = entry.container.position.x;
|
||||||
const circleCy = pin.position.y + cy_off;
|
const by = entry.container.position.y;
|
||||||
const dx = circleCx - worldX;
|
const halfW = r + r * 0.3;
|
||||||
const dy = circleCy - worldY;
|
|
||||||
if (dx * dx + dy * dy <= r * r) {
|
if (
|
||||||
|
worldX >= bx - halfW &&
|
||||||
|
worldX <= bx + halfW &&
|
||||||
|
worldY >= by - tailH - bubbleH &&
|
||||||
|
worldY <= by
|
||||||
|
) {
|
||||||
return threadId;
|
return threadId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,10 +281,10 @@ export class PinOverlay extends Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override destroy(options?: any) {
|
override destroy(options?: any) {
|
||||||
for (const c of this._pins.values()) c.destroy({ children: true });
|
for (const entry of this._pins.values()) {
|
||||||
for (const c of this._pool) c.destroy({ children: true });
|
this._destroyPin(entry);
|
||||||
|
}
|
||||||
this._pins.clear();
|
this._pins.clear();
|
||||||
this._pool = [];
|
|
||||||
super.destroy(options);
|
super.destroy(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,8 +249,8 @@ export class SelectionManager {
|
|||||||
// Lift shadow + spring scale on all selected image sprites
|
// Lift shadow + spring scale on all selected image sprites
|
||||||
this._applyLift();
|
this._applyLift();
|
||||||
|
|
||||||
// Begin snap guide session
|
// Snap guides disabled — users found it annoying
|
||||||
this._snapGuides.beginSession(this.selectedIds);
|
// this._snapGuides.beginSession(this.selectedIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._objectDragging) {
|
if (this._objectDragging) {
|
||||||
@@ -266,11 +266,11 @@ export class SelectionManager {
|
|||||||
prospective.x += ddx;
|
prospective.x += ddx;
|
||||||
prospective.y += ddy;
|
prospective.y += ddy;
|
||||||
|
|
||||||
// Snap to alignment guides
|
// Snap guides disabled
|
||||||
const snap = this._snapGuides.computeSnap(prospective, this._viewport);
|
// const snap = this._snapGuides.computeSnap(prospective, this._viewport);
|
||||||
ddx += snap.dx;
|
// ddx += snap.dx;
|
||||||
ddy += snap.dy;
|
// ddy += snap.dy;
|
||||||
this._snapGuides.drawGuides(snap.guides, this._viewport);
|
// this._snapGuides.drawGuides(snap.guides, this._viewport);
|
||||||
|
|
||||||
// Move all selected items by corrected delta and broadcast all together
|
// Move all selected items by corrected delta and broadcast all together
|
||||||
for (const item of selected) {
|
for (const item of selected) {
|
||||||
@@ -311,7 +311,7 @@ export class SelectionManager {
|
|||||||
if (this._objectDragging) {
|
if (this._objectDragging) {
|
||||||
// End object drag — drop shadow + spring scale back
|
// End object drag — drop shadow + spring scale back
|
||||||
this._applyDrop();
|
this._applyDrop();
|
||||||
this._snapGuides.endSession();
|
// this._snapGuides.endSession();
|
||||||
this._objectDragging = false;
|
this._objectDragging = false;
|
||||||
|
|
||||||
// Resume viewport drag
|
// Resume viewport drag
|
||||||
|
|||||||
@@ -227,9 +227,9 @@ export class TransformBox extends Container {
|
|||||||
origTransforms,
|
origTransforms,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Begin snap session excluding current items
|
// Snap guides disabled
|
||||||
const itemIds = new Set(this._items.map((it) => it.id));
|
// const itemIds = new Set(this._items.map((it) => it.id));
|
||||||
this._snapGuides?.beginSession(itemIds);
|
// this._snapGuides?.beginSession(itemIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onHandleMove(e: FederatedPointerEvent): void {
|
private _onHandleMove(e: FederatedPointerEvent): void {
|
||||||
@@ -346,21 +346,11 @@ export class TransformBox extends Container {
|
|||||||
|
|
||||||
this.update(this._items);
|
this.update(this._items);
|
||||||
|
|
||||||
// Snap guides during resize
|
// Snap guides disabled
|
||||||
if (this._snapGuides && this._viewport) {
|
// if (this._snapGuides && this._viewport) {
|
||||||
const snap = this._snapGuides.computeSnap(this._bounds, 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show dimension label
|
// Show dimension label
|
||||||
const bounds = this._bounds;
|
const bounds = this._bounds;
|
||||||
@@ -399,7 +389,7 @@ export class TransformBox extends Container {
|
|||||||
this._drag = null;
|
this._drag = null;
|
||||||
this._dimLabel.visible = false;
|
this._dimLabel.visible = false;
|
||||||
this._dimLabelBg.visible = false;
|
this._dimLabelBg.visible = false;
|
||||||
this._snapGuides?.endSession();
|
// this._snapGuides?.endSession();
|
||||||
// Notify that drag ended — persist/sync the final state
|
// Notify that drag ended — persist/sync the final state
|
||||||
this._onDragEnd?.(this._items.map(i => i.id));
|
this._onDragEnd?.(this._items.map(i => i.id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { Viewport } from 'pixi-viewport';
|
|||||||
import type { GroupObject } from './scene-format';
|
import type { GroupObject } from './scene-format';
|
||||||
import { FrameSprite } from './sprites/FrameSprite';
|
import { FrameSprite } from './sprites/FrameSprite';
|
||||||
import * as ops from './operations';
|
import * as ops from './operations';
|
||||||
|
import { onArrangeAnimationDone } from './operations';
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -135,11 +136,11 @@ export function buildContextMenuItems(ctx: MenuContext): MenuItem[] {
|
|||||||
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
|
|
||||||
// -- Arrangement --
|
// -- Arrangement --
|
||||||
{ label: 'Arrange Pack', shortcut: 'Ctrl+Shift+P', onClick: () => { ops.arrangeOptimal(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: () => { ops.arrangeGrid(selected); selection?.transformBox.update(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: () => { ops.arrangeRow(selected); selection?.transformBox.update(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: () => { ops.arrangeColumn(selected); selection?.transformBox.update(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: () => { ops.stackObjects(selected); selection?.transformBox.update(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 },
|
{ label: '', shortcut: '', onClick: () => {}, divider: true },
|
||||||
|
|
||||||
// -- Normalize --
|
// -- Normalize --
|
||||||
|
|||||||
@@ -18,9 +18,66 @@ function scaledH(item: SceneItem): number {
|
|||||||
return item.data.h * item.data.sy;
|
return item.data.h * item.data.sy;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sync displayObject position from item.data. */
|
// ─── Animated position sync ───
|
||||||
|
|
||||||
|
let _animTargets = new Map<SceneItem, { startX: number; startY: number; endX: number; endY: number; t: number }>();
|
||||||
|
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 {
|
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. */
|
/** Sync displayObject scale from item.data. */
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ async function _pasteInternal(ctx: ShortcutContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
ctx.clipboardRef.current = newItems;
|
ctx.clipboardRef.current = newItems;
|
||||||
ctx.scene._applyZOrder();
|
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();
|
ctx.onChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +118,15 @@ function _opUpdate(ctx: ShortcutContext, op: (items: SceneItem[]) => void): void
|
|||||||
ctx.onChange(items.map(i => i.id));
|
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[] = [
|
export const shortcuts: ShortcutDef[] = [
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
@@ -184,27 +201,27 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
{
|
{
|
||||||
id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true },
|
id: 'arrange-optimal', keys: { key: 'p', ctrl: true, shift: true },
|
||||||
category: 'arrangement', description: 'Arrange optimal (pack)', needsSelection: true, minSelection: 2,
|
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 },
|
id: 'arrange-by-name', keys: { key: 'n', ctrl: true, alt: true },
|
||||||
category: 'arrangement', description: 'Arrange by name', needsSelection: true, minSelection: 2,
|
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 },
|
id: 'arrange-by-order', keys: { key: 'o', ctrl: true, alt: true },
|
||||||
category: 'arrangement', description: 'Arrange by z-order', needsSelection: true, minSelection: 2,
|
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 },
|
id: 'arrange-random', keys: { key: 'r', ctrl: true, alt: true },
|
||||||
category: 'arrangement', description: 'Arrange randomly', needsSelection: true, minSelection: 2,
|
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 },
|
id: 'stack', keys: { key: 's', ctrl: true, alt: true },
|
||||||
category: 'arrangement', description: 'Stack (pile on top)', needsSelection: true, minSelection: 2,
|
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 },
|
id: 'equal-spacing-h', keys: { key: 'h', ctrl: true, shift: true },
|
||||||
category: 'arrangement', description: 'Equal horizontal spacing', needsSelection: true, minSelection: 2,
|
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 },
|
id: 'equal-spacing-v', keys: { key: 'v', ctrl: true, shift: true },
|
||||||
category: 'arrangement', description: 'Equal vertical spacing', needsSelection: true, minSelection: 2,
|
category: 'arrangement', description: 'Equal vertical spacing', needsSelection: true, minSelection: 2,
|
||||||
handler: (ctx) => _opUpdate(ctx, ops.equalSpacingV),
|
handler: (ctx) => _opUpdateAnimated(ctx, ops.equalSpacingV),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
|
|||||||
@@ -279,10 +279,8 @@ export const toolShortcuts: Record<string, ToolType> = {
|
|||||||
h: ToolType.PAN,
|
h: ToolType.PAN,
|
||||||
p: ToolType.PEN,
|
p: ToolType.PEN,
|
||||||
t: ToolType.TEXT,
|
t: ToolType.TEXT,
|
||||||
e: ToolType.ERASER,
|
|
||||||
'1': ToolType.SELECT,
|
'1': ToolType.SELECT,
|
||||||
'2': ToolType.PAN,
|
'2': ToolType.PAN,
|
||||||
'3': ToolType.PEN,
|
'3': ToolType.PEN,
|
||||||
'4': ToolType.TEXT,
|
'4': ToolType.TEXT,
|
||||||
'5': ToolType.ERASER,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,11 +73,13 @@ function IconText() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IconEraser() {
|
function IconReview() {
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
<path d="M6.5 14H14M2 10l4.5-4.5 4 4L6 14H3l-1-1v-3z" strokeLinejoin="round" />
|
<path d="M2.5 3A1.5 1.5 0 014 1.5h8A1.5 1.5 0 0113.5 3v7A1.5 1.5 0 0112 11.5H7l-3.5 3V11.5H4A1.5 1.5 0 012.5 10V3z" />
|
||||||
<path d="M6.5 5.5L14 2" strokeLinecap="round" />
|
<circle cx="6" cy="6.5" r="0.7" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="8" cy="6.5" r="0.7" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="10" cy="6.5" r="0.7" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,14 +120,15 @@ function IconLayers() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolButtons: { tool: ToolType; label: string; shortcut: string; numKey: string; Icon: React.FC }[] = [
|
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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ tool: ToolType.TEXT, label: 'Text', shortcut: 'T', numKey: '4', Icon: IconText, hint: 'Click on the canvas to place text' },
|
||||||
{ tool: ToolType.ERASER, label: 'Eraser', shortcut: 'E', numKey: '5', Icon: IconEraser },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const REVIEW_HINT = 'Click an image to leave a comment. Press . to toggle.';
|
||||||
|
|
||||||
export default function Toolbar({
|
export default function Toolbar({
|
||||||
activeTool,
|
activeTool,
|
||||||
onToolChange,
|
onToolChange,
|
||||||
@@ -158,26 +161,30 @@ export default function Toolbar({
|
|||||||
const showStroke = activeTool === ToolType.PEN;
|
const showStroke = activeTool === ToolType.PEN;
|
||||||
const showFontSize = activeTool === ToolType.TEXT;
|
const showFontSize = activeTool === ToolType.TEXT;
|
||||||
|
|
||||||
|
// Determine active hint
|
||||||
|
const activeToolDef = toolButtons.find((t) => t.tool === activeTool);
|
||||||
|
const activeHint = reviewMode ? REVIEW_HINT : activeToolDef?.hint || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '2px',
|
gap: '2px',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
background: '#1a1a1a',
|
background: '#1a1a1a',
|
||||||
borderBottom: '1px solid #2a2a2a',
|
borderBottom: activeHint ? 'none' : '1px solid #2a2a2a',
|
||||||
flexShrink: 0,
|
|
||||||
height: '44px',
|
height: '44px',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
}}>
|
}}>
|
||||||
{/* Tool buttons */}
|
{/* Tool buttons */}
|
||||||
<div style={{ display: 'flex', gap: '1px', background: '#222', borderRadius: '8px', padding: '2px' }}>
|
<div style={{ display: 'flex', gap: '1px', background: '#222', borderRadius: '8px', padding: '2px' }}>
|
||||||
{toolButtons.map(({ tool, label, shortcut, numKey, Icon }) => {
|
{toolButtons.map(({ tool, label, shortcut, numKey, Icon }) => {
|
||||||
const active = activeTool === tool;
|
const active = activeTool === tool && !reviewMode;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tool}
|
key={tool}
|
||||||
onClick={() => onToolChange(tool)}
|
onClick={() => { onToolChange(tool); if (reviewMode && onToggleReview) onToggleReview(); }}
|
||||||
title={`${label} (${shortcut} or ${numKey})`}
|
title={`${label} (${shortcut} or ${numKey})`}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
@@ -205,6 +212,36 @@ export default function Toolbar({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Review mode — in tool group */}
|
||||||
|
{onToggleReview && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleReview}
|
||||||
|
title="Review (.)"
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: '4px', height: '32px', padding: '0 10px',
|
||||||
|
background: reviewMode ? 'linear-gradient(135deg, #f97316, #ea580c)' : 'transparent',
|
||||||
|
border: 'none', borderRadius: '6px',
|
||||||
|
color: reviewMode ? '#fff' : '#777',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
boxShadow: reviewMode ? '0 1px 4px rgba(249,115,22,0.3)' : 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!reviewMode) { e.currentTarget.style.background = '#2a2a2a'; e.currentTarget.style.color = '#bbb'; } }}
|
||||||
|
onMouseLeave={(e) => { if (!reviewMode) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#777'; } }}
|
||||||
|
>
|
||||||
|
<IconReview />
|
||||||
|
<span style={{ fontSize: '11px', fontWeight: reviewMode ? 600 : 400, letterSpacing: '0.2px' }}>Review</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '9px', color: reviewMode ? 'rgba(255,255,255,0.5)' : '#555',
|
||||||
|
background: reviewMode ? 'rgba(255,255,255,0.1)' : '#1a1a1a',
|
||||||
|
padding: '1px 4px', borderRadius: '3px', fontWeight: 500,
|
||||||
|
lineHeight: '14px', minWidth: '14px', textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -313,15 +350,6 @@ export default function Toolbar({
|
|||||||
</ActionBtn>
|
</ActionBtn>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Review / Feedback */}
|
|
||||||
{onToggleReview && (
|
|
||||||
<ActionBtn onClick={onToggleReview} title="Review Mode (.)" active={reviewMode}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
|
||||||
<path d="M2 2.5A1.5 1.5 0 013.5 1h7A1.5 1.5 0 0112 2.5v6A1.5 1.5 0 0110.5 10H6l-3 3v-3H3.5A1.5 1.5 0 012 8.5v-6z" />
|
|
||||||
</svg>
|
|
||||||
</ActionBtn>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
@@ -370,6 +398,21 @@ export default function Toolbar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Tool hint bar */}
|
||||||
|
{activeHint && (
|
||||||
|
<div style={{
|
||||||
|
padding: '4px 16px',
|
||||||
|
background: '#141416',
|
||||||
|
borderBottom: '1px solid #2a2a2a',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#666',
|
||||||
|
letterSpacing: '0.2px',
|
||||||
|
lineHeight: '16px',
|
||||||
|
}}>
|
||||||
|
{activeHint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Comment } from '../../stores/annotationStore';
|
import { Comment } from '../../stores/annotationStore';
|
||||||
import { getAuthorColor, getAuthorInitial } from '../../utils/authorColors';
|
import { getAuthorColor, getAuthorInitial } from '../../utils/authorColors';
|
||||||
import { relativeTime } from '../../utils/relativeTime';
|
import { relativeTime, fullTimestamp } from '../../utils/relativeTime';
|
||||||
import { TEXT_PRIMARY, TEXT_MUTED, BORDER } from './feedbackStyles';
|
import { TEXT_PRIMARY, TEXT_SECONDARY, TEXT_MUTED, BORDER, HOVER_BG } from './feedbackStyles';
|
||||||
|
|
||||||
interface CommentItemProps {
|
interface CommentItemProps {
|
||||||
comment: Comment;
|
comment: Comment;
|
||||||
@@ -11,13 +11,23 @@ interface CommentItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CommentItem({ comment, isOwn, onDelete }: CommentItemProps) {
|
export default function CommentItem({ comment, isOwn, onDelete }: CommentItemProps) {
|
||||||
|
const [hovered, setHovered] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '10px 0', borderBottom: `1px solid ${BORDER}` }}>
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 0',
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
transition: 'background 0.1s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
width: '22px',
|
width: '24px',
|
||||||
height: '22px',
|
height: '24px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: getAuthorColor(comment.user_id),
|
background: getAuthorColor(comment.user_id),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -31,30 +41,31 @@ export default function CommentItem({ comment, isOwn, onDelete }: CommentItemPro
|
|||||||
>
|
>
|
||||||
{getAuthorInitial(comment.author_name)}
|
{getAuthorInitial(comment.author_name)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: TEXT_PRIMARY, fontSize: '12px', fontWeight: 600 }}>
|
<span style={{ color: TEXT_PRIMARY, fontSize: '12px', fontWeight: 600, letterSpacing: '0.2px' }}>
|
||||||
{comment.author_name}
|
{comment.author_name}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>
|
<span style={{ color: TEXT_MUTED, fontSize: '10px' }} title={fullTimestamp(comment.created_at)}>
|
||||||
{relativeTime(comment.created_at)}
|
{relativeTime(comment.created_at)}
|
||||||
</span>
|
</span>
|
||||||
{comment.edited_at && (
|
{comment.edited_at && (
|
||||||
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>(edited)</span>
|
<span style={{ color: TEXT_MUTED, fontSize: '10px', fontStyle: 'italic' }}>edited</span>
|
||||||
)}
|
)}
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
{isOwn && onDelete && (
|
{isOwn && onDelete && hovered && (
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'rgba(240, 72, 72, 0.08)',
|
||||||
border: 'none',
|
border: '1px solid rgba(240, 72, 72, 0.12)',
|
||||||
color: '#633',
|
borderRadius: '4px',
|
||||||
|
color: TEXT_MUTED,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
opacity: 0.6,
|
padding: '2px 6px',
|
||||||
transition: 'opacity 0.1s',
|
transition: 'all 0.1s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#f04848'; e.currentTarget.style.background = 'rgba(240, 72, 72, 0.15)'; }}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0.6')}
|
onMouseLeave={(e) => { e.currentTarget.style.color = TEXT_MUTED; e.currentTarget.style.background = 'rgba(240, 72, 72, 0.08)'; }}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -62,10 +73,10 @@ export default function CommentItem({ comment, isOwn, onDelete }: CommentItemPro
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
color: '#ccc',
|
color: TEXT_SECONDARY,
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
marginLeft: '30px',
|
marginLeft: '32px',
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { Thread, AnnotationStore } from '../../stores/annotationStore';
|
|||||||
import CommentItem from './CommentItem';
|
import CommentItem from './CommentItem';
|
||||||
import CommentInput from './CommentInput';
|
import CommentInput from './CommentInput';
|
||||||
import {
|
import {
|
||||||
PANEL_BG,
|
panelContainerStyle,
|
||||||
PANEL_WIDTH,
|
|
||||||
BORDER,
|
BORDER,
|
||||||
TEXT_PRIMARY,
|
TEXT_PRIMARY,
|
||||||
|
TEXT_SECONDARY,
|
||||||
TEXT_MUTED,
|
TEXT_MUTED,
|
||||||
ACCENT,
|
ACCENT,
|
||||||
|
HOVER_BG,
|
||||||
STATUS_OPEN,
|
STATUS_OPEN,
|
||||||
STATUS_RESOLVED,
|
STATUS_RESOLVED,
|
||||||
} from './feedbackStyles';
|
} from './feedbackStyles';
|
||||||
@@ -58,25 +59,11 @@ export default function ThreadDetail({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={panelContainerStyle}>
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: `${PANEL_WIDTH}px`,
|
|
||||||
background: PANEL_BG,
|
|
||||||
borderLeft: `1px solid ${BORDER}`,
|
|
||||||
zIndex: 100,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 14px',
|
padding: '14px 16px',
|
||||||
borderBottom: `1px solid ${BORDER}`,
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -90,13 +77,17 @@ export default function ThreadDetail({
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
color: TEXT_MUTED,
|
color: TEXT_MUTED,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
fontSize: '16px',
|
||||||
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)}
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
<span style={{ color: TEXT_MUTED, fontSize: '11px', fontFamily: 'monospace' }}>
|
<span style={{ color: TEXT_SECONDARY, fontSize: '11px', fontFamily: 'monospace', fontWeight: 500 }}>
|
||||||
#{pinNumber}
|
#{pinNumber}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -105,8 +96,9 @@ export default function ThreadDetail({
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
background: isOpen ? '#2a1515' : '#152a15',
|
background: isOpen ? 'rgba(240, 72, 72, 0.1)' : 'rgba(52, 210, 123, 0.1)',
|
||||||
color: isOpen ? STATUS_OPEN : STATUS_RESOLVED,
|
color: isOpen ? STATUS_OPEN : STATUS_RESOLVED,
|
||||||
|
letterSpacing: '0.3px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isOpen ? 'Open' : 'Resolved'}
|
{isOpen ? 'Open' : 'Resolved'}
|
||||||
@@ -116,12 +108,17 @@ export default function ThreadDetail({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onJumpToObject(thread.object_id)}
|
onClick={() => onJumpToObject(thread.object_id)}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'rgba(74, 158, 255, 0.08)',
|
||||||
border: 'none',
|
border: `1px solid rgba(74, 158, 255, 0.15)`,
|
||||||
|
borderRadius: '6px',
|
||||||
color: ACCENT,
|
color: ACCENT,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.15)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.08)')}
|
||||||
>
|
>
|
||||||
Jump
|
Jump
|
||||||
</button>
|
</button>
|
||||||
@@ -130,14 +127,18 @@ export default function ThreadDetail({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onResolve(thread.id, 'resolved')}
|
onClick={() => onResolve(thread.id, 'resolved')}
|
||||||
style={{
|
style={{
|
||||||
background: '#152a15',
|
background: 'rgba(52, 210, 123, 0.1)',
|
||||||
border: 'none',
|
border: `1px solid rgba(52, 210, 123, 0.15)`,
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
color: STATUS_RESOLVED,
|
color: STATUS_RESOLVED,
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(52, 210, 123, 0.18)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(52, 210, 123, 0.1)')}
|
||||||
>
|
>
|
||||||
Resolve
|
Resolve
|
||||||
</button>
|
</button>
|
||||||
@@ -145,14 +146,18 @@ export default function ThreadDetail({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onResolve(thread.id, 'open')}
|
onClick={() => onResolve(thread.id, 'open')}
|
||||||
style={{
|
style={{
|
||||||
background: '#1a1a2a',
|
background: 'rgba(74, 158, 255, 0.08)',
|
||||||
border: 'none',
|
border: `1px solid rgba(74, 158, 255, 0.15)`,
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
color: '#88f',
|
color: ACCENT,
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.15)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.08)')}
|
||||||
>
|
>
|
||||||
Reopen
|
Reopen
|
||||||
</button>
|
</button>
|
||||||
@@ -194,11 +199,16 @@ export default function ThreadDetail({
|
|||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: '#633',
|
color: TEXT_MUTED,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '13px',
|
fontSize: '14px',
|
||||||
padding: '2px',
|
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"
|
title="Delete thread"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -207,7 +217,7 @@ export default function ThreadDetail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 14px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px', scrollbarWidth: 'thin', scrollbarColor: '#2a2a2e transparent' }}>
|
||||||
{thread.comments.map((c) => (
|
{thread.comments.map((c) => (
|
||||||
<CommentItem
|
<CommentItem
|
||||||
key={c.id}
|
key={c.id}
|
||||||
@@ -219,7 +229,7 @@ export default function ThreadDetail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reply input */}
|
{/* Reply input */}
|
||||||
<div style={{ padding: '10px 14px', borderTop: `1px solid ${BORDER}` }}>
|
<div style={{ padding: '12px 16px', borderTop: `1px solid ${BORDER}` }}>
|
||||||
<CommentInput
|
<CommentInput
|
||||||
value={replyText}
|
value={replyText}
|
||||||
onChange={setReplyText}
|
onChange={setReplyText}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { Thread, AnnotationStore } from '../../stores/annotationStore';
|
|||||||
import ThreadListItem from './ThreadListItem';
|
import ThreadListItem from './ThreadListItem';
|
||||||
import CommentInput from './CommentInput';
|
import CommentInput from './CommentInput';
|
||||||
import {
|
import {
|
||||||
PANEL_BG,
|
panelContainerStyle,
|
||||||
PANEL_WIDTH,
|
|
||||||
BORDER,
|
BORDER,
|
||||||
TEXT_PRIMARY,
|
TEXT_PRIMARY,
|
||||||
|
TEXT_SECONDARY,
|
||||||
TEXT_MUTED,
|
TEXT_MUTED,
|
||||||
STATUS_OPEN,
|
STATUS_OPEN,
|
||||||
FILTER_ACTIVE_BG,
|
FILTER_ACTIVE_BG,
|
||||||
|
ACCENT,
|
||||||
} from './feedbackStyles';
|
} from './feedbackStyles';
|
||||||
|
|
||||||
export type FilterType = 'open' | 'resolved' | 'all' | 'mine';
|
export type FilterType = 'open' | 'resolved' | 'all' | 'mine';
|
||||||
@@ -23,7 +24,6 @@ interface ThreadListProps {
|
|||||||
onFilterChange: (f: FilterType) => void;
|
onFilterChange: (f: FilterType) => void;
|
||||||
onSelectThread: (id: string) => void;
|
onSelectThread: (id: string) => void;
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
// New comment
|
|
||||||
selectedObjectId: string | null;
|
selectedObjectId: string | null;
|
||||||
selectedObjectLabel: string;
|
selectedObjectLabel: string;
|
||||||
newCommentText: string;
|
newCommentText: string;
|
||||||
@@ -49,32 +49,21 @@ export default function ThreadList({
|
|||||||
const [showOrphans, setShowOrphans] = React.useState(false);
|
const [showOrphans, setShowOrphans] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={panelContainerStyle}>
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: `${PANEL_WIDTH}px`,
|
|
||||||
background: PANEL_BG,
|
|
||||||
borderLeft: `1px solid ${BORDER}`,
|
|
||||||
zIndex: 100,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 14px',
|
padding: '14px 16px',
|
||||||
borderBottom: `1px solid ${BORDER}`,
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '10px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: TEXT_PRIMARY, fontSize: '14px', fontWeight: 600, flex: 1 }}>
|
<svg width="16" height="16" viewBox="0 0 14 14" fill="none" stroke={TEXT_SECONDARY} strokeWidth="1.2" style={{ flexShrink: 0 }}>
|
||||||
|
<path d="M2 2.5A1.5 1.5 0 013.5 1h7A1.5 1.5 0 0112 2.5v6A1.5 1.5 0 0110.5 10H6l-3 3v-3H3.5A1.5 1.5 0 012 8.5v-6z" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ color: TEXT_PRIMARY, fontSize: '13px', fontWeight: 600, flex: 1, letterSpacing: '0.3px' }}>
|
||||||
Feedback
|
Feedback
|
||||||
</span>
|
</span>
|
||||||
{openCount > 0 && (
|
{openCount > 0 && (
|
||||||
@@ -82,12 +71,13 @@ export default function ThreadList({
|
|||||||
style={{
|
style={{
|
||||||
background: STATUS_OPEN,
|
background: STATUS_OPEN,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: '11px',
|
fontSize: '10px',
|
||||||
fontWeight: 600,
|
fontWeight: 700,
|
||||||
padding: '1px 7px',
|
padding: '2px 7px',
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
minWidth: '18px',
|
minWidth: '18px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
lineHeight: '14px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{openCount}
|
{openCount}
|
||||||
@@ -102,8 +92,12 @@ export default function ThreadList({
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
lineHeight: 1,
|
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)}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -112,10 +106,10 @@ export default function ThreadList({
|
|||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 14px',
|
padding: '8px 16px',
|
||||||
borderBottom: `1px solid ${BORDER}`,
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '6px',
|
gap: '4px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(['open', 'resolved', 'all', 'mine'] as const).map((f) => (
|
{(['open', 'resolved', 'all', 'mine'] as const).map((f) => (
|
||||||
@@ -124,15 +118,16 @@ export default function ThreadList({
|
|||||||
onClick={() => onFilterChange(f)}
|
onClick={() => onFilterChange(f)}
|
||||||
style={{
|
style={{
|
||||||
background: filter === f ? FILTER_ACTIVE_BG : 'transparent',
|
background: filter === f ? FILTER_ACTIVE_BG : 'transparent',
|
||||||
border: 'none',
|
border: filter === f ? `1px solid ${BORDER}` : '1px solid transparent',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
color: filter === f ? '#fff' : TEXT_MUTED,
|
color: filter === f ? TEXT_PRIMARY : TEXT_MUTED,
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: filter === f ? 600 : 400,
|
fontWeight: filter === f ? 600 : 400,
|
||||||
textTransform: 'capitalize',
|
textTransform: 'capitalize',
|
||||||
transition: 'all 0.1s ease',
|
transition: 'all 0.15s ease',
|
||||||
|
lineHeight: '16px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{f}
|
{f}
|
||||||
@@ -142,9 +137,12 @@ export default function ThreadList({
|
|||||||
|
|
||||||
{/* New comment input (when object selected) */}
|
{/* New comment input (when object selected) */}
|
||||||
{selectedObjectId && (
|
{selectedObjectId && (
|
||||||
<div style={{ padding: '10px 14px', borderBottom: `1px solid ${BORDER}` }}>
|
<div style={{ padding: '12px 16px', borderBottom: `1px solid ${BORDER}`, background: 'rgba(74, 158, 255, 0.03)' }}>
|
||||||
<div style={{ color: TEXT_MUTED, fontSize: '10px', marginBottom: '6px' }}>
|
<div style={{ color: TEXT_MUTED, fontSize: '10px', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||||
Comment on: <span style={{ color: TEXT_PRIMARY }}>{selectedObjectLabel}</span>
|
Comment on
|
||||||
|
</div>
|
||||||
|
<div style={{ color: TEXT_PRIMARY, fontSize: '12px', marginBottom: '10px', fontWeight: 500 }}>
|
||||||
|
{selectedObjectLabel}
|
||||||
</div>
|
</div>
|
||||||
<CommentInput
|
<CommentInput
|
||||||
value={newCommentText}
|
value={newCommentText}
|
||||||
@@ -157,24 +155,23 @@ export default function ThreadList({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Thread list */}
|
{/* Thread list */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
<div style={{ flex: 1, overflowY: 'auto', scrollbarWidth: 'thin', scrollbarColor: '#2a2a2e transparent' }}>
|
||||||
{threads.length === 0 && (
|
{threads.length === 0 && (
|
||||||
<div style={{ padding: '32px 20px', textAlign: 'center' }}>
|
<div style={{ padding: '40px 24px', textAlign: 'center' }}>
|
||||||
<svg
|
<div style={{
|
||||||
width="32"
|
width: '48px', height: '48px', borderRadius: '50%',
|
||||||
height="32"
|
background: 'rgba(74, 158, 255, 0.06)', border: `1px solid rgba(74, 158, 255, 0.1)`,
|
||||||
viewBox="0 0 14 14"
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fill="none"
|
margin: '0 auto 16px',
|
||||||
stroke="#333"
|
}}>
|
||||||
strokeWidth="1"
|
<svg width="20" height="20" viewBox="0 0 14 14" fill="none" stroke={TEXT_MUTED} strokeWidth="1">
|
||||||
style={{ marginBottom: '12px' }}
|
|
||||||
>
|
|
||||||
<path d="M2 2.5A1.5 1.5 0 013.5 1h7A1.5 1.5 0 0112 2.5v6A1.5 1.5 0 0110.5 10H6l-3 3v-3H3.5A1.5 1.5 0 012 8.5v-6z" />
|
<path d="M2 2.5A1.5 1.5 0 013.5 1h7A1.5 1.5 0 0112 2.5v6A1.5 1.5 0 0110.5 10H6l-3 3v-3H3.5A1.5 1.5 0 012 8.5v-6z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div style={{ color: TEXT_MUTED, fontSize: '12px', lineHeight: 1.5 }}>
|
</div>
|
||||||
|
<div style={{ color: TEXT_MUTED, fontSize: '12px', lineHeight: 1.6 }}>
|
||||||
{selectedObjectId
|
{selectedObjectId
|
||||||
? 'No comments on this item.'
|
? 'No comments on this item.'
|
||||||
: 'No comments yet.\nSelect an image and add a comment.'}
|
: <>No comments yet.<br /><span style={{ color: TEXT_SECONDARY }}>Select an image to leave feedback.</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -193,14 +190,14 @@ export default function ThreadList({
|
|||||||
<div
|
<div
|
||||||
onClick={() => setShowOrphans(!showOrphans)}
|
onClick={() => setShowOrphans(!showOrphans)}
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 14px',
|
padding: '10px 16px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: TEXT_MUTED,
|
color: TEXT_MUTED,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
borderTop: `1px solid ${BORDER}`,
|
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')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
>
|
>
|
||||||
{showOrphans ? '\u25BE' : '\u25B8'} Deleted items ({orphanedThreads.length})
|
{showOrphans ? '\u25BE' : '\u25B8'} Deleted items ({orphanedThreads.length})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Thread } from '../../stores/annotationStore';
|
import { Thread } from '../../stores/annotationStore';
|
||||||
import { getAuthorColor, getAuthorInitial } from '../../utils/authorColors';
|
import { getAuthorColor, getAuthorInitial } from '../../utils/authorColors';
|
||||||
import { relativeTime } from '../../utils/relativeTime';
|
import { relativeTime, fullTimestamp } from '../../utils/relativeTime';
|
||||||
import {
|
import {
|
||||||
TEXT_PRIMARY,
|
TEXT_PRIMARY,
|
||||||
TEXT_SECONDARY,
|
TEXT_SECONDARY,
|
||||||
@@ -26,7 +26,7 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
|
|||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 14px',
|
padding: '12px 16px',
|
||||||
borderBottom: `1px solid ${BORDER}`,
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'background 0.1s ease',
|
transition: 'background 0.1s ease',
|
||||||
@@ -67,7 +67,7 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
|
|||||||
>
|
>
|
||||||
{firstComment?.author_name || 'Unknown'}
|
{firstComment?.author_name || 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: TEXT_MUTED, fontSize: '10px', flexShrink: 0 }}>
|
<span style={{ color: TEXT_MUTED, fontSize: '10px', flexShrink: 0 }} title={fullTimestamp(thread.last_commented_at || thread.created_at)}>
|
||||||
{relativeTime(thread.last_commented_at || thread.created_at)}
|
{relativeTime(thread.last_commented_at || thread.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,7 +89,7 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Row 3: meta — pin #, replies, status dot */}
|
{/* Row 3: meta — replies, status */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -99,9 +99,6 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
|
|||||||
marginLeft: '32px',
|
marginLeft: '32px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: TEXT_MUTED, fontSize: '10px', fontFamily: 'monospace' }}>
|
|
||||||
#{pinNumber}
|
|
||||||
</span>
|
|
||||||
{thread.comment_count > 1 && (
|
{thread.comment_count > 1 && (
|
||||||
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>
|
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>
|
||||||
{thread.comment_count - 1} {thread.comment_count === 2 ? 'reply' : 'replies'}
|
{thread.comment_count - 1} {thread.comment_count === 2 ? 'reply' : 'replies'}
|
||||||
@@ -109,13 +106,15 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
|
|||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
width: '6px',
|
fontSize: '10px',
|
||||||
height: '6px',
|
fontWeight: 500,
|
||||||
borderRadius: '50%',
|
color: isResolved ? STATUS_RESOLVED : STATUS_OPEN,
|
||||||
background: isResolved ? STATUS_RESOLVED : STATUS_OPEN,
|
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
|
textTransform: 'capitalize',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{thread.status}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
// Design tokens for the feedback/annotation system
|
// Design tokens for the feedback/annotation system
|
||||||
export const PANEL_WIDTH = 300;
|
export const PANEL_WIDTH = 320;
|
||||||
export const PANEL_BG = '#0d0d0d';
|
export const PANEL_BG = 'rgba(12, 12, 14, 0.95)';
|
||||||
export const BORDER = '#1e1e1e';
|
export const PANEL_BG_SOLID = '#0c0c0e';
|
||||||
|
export const BORDER = '#1a1a1e';
|
||||||
|
|
||||||
export const TEXT_PRIMARY = '#e0e0e0';
|
export const TEXT_PRIMARY = '#eaeaea';
|
||||||
export const TEXT_SECONDARY = '#999';
|
export const TEXT_SECONDARY = '#a0a0a8';
|
||||||
export const TEXT_MUTED = '#666';
|
export const TEXT_MUTED = '#5a5a64';
|
||||||
|
|
||||||
export const ACCENT = '#4a9eff';
|
export const ACCENT = '#4a9eff';
|
||||||
export const STATUS_OPEN = '#ef4444';
|
export const STATUS_OPEN = '#f04848';
|
||||||
export const STATUS_RESOLVED = '#22c55e';
|
export const STATUS_RESOLVED = '#34d27b';
|
||||||
|
|
||||||
export const INPUT_BG = '#141414';
|
export const INPUT_BG = '#131316';
|
||||||
export const INPUT_BORDER = '#2a2a2a';
|
export const INPUT_BORDER = '#26262c';
|
||||||
export const INPUT_BORDER_FOCUS = '#3a3a3a';
|
export const INPUT_BORDER_FOCUS = '#4a9eff44';
|
||||||
export const HOVER_BG = '#151515';
|
export const HOVER_BG = '#16161a';
|
||||||
export const FILTER_ACTIVE_BG = '#2a2a2a';
|
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';
|
||||||
|
|||||||
@@ -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) => {
|
selection.onItemsTransform = (items) => {
|
||||||
syncRef.current?.broadcastTransform(items);
|
syncRef.current?.broadcastTransform(items);
|
||||||
|
pinOverlayRef.current?.updatePositions();
|
||||||
};
|
};
|
||||||
selection.onItemTransform = (item) => {
|
selection.onItemTransform = (item) => {
|
||||||
syncRef.current?.broadcastTransform(item);
|
syncRef.current?.broadcastTransform(item);
|
||||||
|
pinOverlayRef.current?.updatePositions();
|
||||||
};
|
};
|
||||||
selection.transformBox.onItemTransform = (item) => {
|
selection.transformBox.onItemTransform = (item) => {
|
||||||
syncRef.current?.broadcastTransform(item);
|
syncRef.current?.broadcastTransform(item);
|
||||||
|
pinOverlayRef.current?.updatePositions();
|
||||||
};
|
};
|
||||||
selection.onObjectDragEnd = (itemIds) => {
|
selection.onObjectDragEnd = (itemIds) => {
|
||||||
onCanvasChange(itemIds); // broadcasts elements + saves + undo + spatial refresh
|
onCanvasChange(itemIds); // broadcasts elements + saves + undo + spatial refresh
|
||||||
};
|
};
|
||||||
selection.transformBox.onDragEnd = (itemIds) => {
|
selection.transformBox.onDragEnd = (itemIds) => {
|
||||||
onCanvasChange(itemIds); // broadcasts elements + saves + undo + spatial refresh
|
onCanvasChange(itemIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on('user:joined', (data: any) => {
|
socket.on('user:joined', (data: any) => {
|
||||||
@@ -335,20 +339,19 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
socket.on('comment:delete', (data: any) => {
|
socket.on('comment:delete', (data: any) => {
|
||||||
annotationStoreRef.current?.onCommentDelete(data.threadId, data.commentId);
|
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!);
|
const pinOverlay = new PinOverlay(viewport, scene, annotationStoreRef.current!);
|
||||||
pinOverlay.visible = false;
|
pinOverlay.visible = true;
|
||||||
viewport.addChild(pinOverlay);
|
viewport.addChild(pinOverlay);
|
||||||
pinOverlayRef.current = pinOverlay;
|
pinOverlayRef.current = pinOverlay;
|
||||||
|
|
||||||
// Refresh overlay on viewport move
|
// Refresh on viewport move (zoom changes scale) and store changes
|
||||||
const onViewportMoved = () => {
|
const onViewportMoved = () => {
|
||||||
if (pinOverlay.visible) pinOverlay.refresh();
|
pinOverlay.refresh();
|
||||||
};
|
};
|
||||||
viewport.on('moved', onViewportMoved);
|
viewport.on('moved', onViewportMoved);
|
||||||
// Refresh on store change (save unsub for cleanup)
|
|
||||||
const unsubPinOverlay = annotationStoreRef.current!.subscribe(() => {
|
const unsubPinOverlay = annotationStoreRef.current!.subscribe(() => {
|
||||||
if (pinOverlay.visible) pinOverlay.refresh();
|
pinOverlay.refresh();
|
||||||
});
|
});
|
||||||
(pinOverlay as any)._cleanup = () => {
|
(pinOverlay as any)._cleanup = () => {
|
||||||
viewport.off('moved', onViewportMoved);
|
viewport.off('moved', onViewportMoved);
|
||||||
|
|||||||
@@ -176,27 +176,26 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [objectCount]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!pinOverlay) return;
|
if (!pinOverlay) return;
|
||||||
pinOverlay.visible = reviewMode;
|
pinOverlay.visible = true;
|
||||||
if (reviewMode) pinOverlay.refresh();
|
pinOverlay.refresh();
|
||||||
|
|
||||||
// Pin click → expand thread in panel
|
// Pin click → expand thread in panel (listen on viewport since pins are item children)
|
||||||
const onClick = (e: any) => {
|
|
||||||
const vp = canvasRef.current?.getViewport();
|
const vp = canvasRef.current?.getViewport();
|
||||||
if (!vp) return;
|
if (!vp) return;
|
||||||
|
const onClick = (e: any) => {
|
||||||
const worldPos = vp.toWorld(e.global);
|
const worldPos = vp.toWorld(e.global);
|
||||||
const threadId = pinOverlay.getThreadIdAtPoint(worldPos.x, worldPos.y);
|
const threadId = pinOverlay.getThreadIdAtPoint(worldPos.x, worldPos.y);
|
||||||
if (threadId) {
|
if (threadId) {
|
||||||
|
if (!reviewMode) setReviewMode(true);
|
||||||
setFocusedThreadId(threadId);
|
setFocusedThreadId(threadId);
|
||||||
// Reset so it can be triggered again for the same pin
|
|
||||||
requestAnimationFrame(() => setFocusedThreadId(null));
|
requestAnimationFrame(() => setFocusedThreadId(null));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
pinOverlay.eventMode = 'static';
|
vp.on('pointerdown', onClick);
|
||||||
pinOverlay.on('pointerdown', onClick);
|
return () => { vp.off('pointerdown', onClick); };
|
||||||
return () => { pinOverlay.off('pointerdown', onClick); };
|
|
||||||
}, [reviewMode, pinOverlay]);
|
}, [reviewMode, pinOverlay]);
|
||||||
|
|
||||||
// Tool activation
|
// Tool activation
|
||||||
@@ -386,8 +385,9 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh annotation pins so they follow transforms
|
// Pin positions follow media automatically (children of displayObjects).
|
||||||
if (pinOverlay?.visible) pinOverlay.refresh();
|
// Only refresh on zoom to update counter-scale.
|
||||||
|
pinOverlay?.refresh();
|
||||||
}, [pinOverlay]);
|
}, [pinOverlay]);
|
||||||
|
|
||||||
// Listen to viewport moved event for overlay updates (throttled)
|
// Listen to viewport moved event for overlay updates (throttled)
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
export function relativeTime(iso: string): string {
|
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);
|
const mins = Math.floor(diff / 60000);
|
||||||
if (mins < 1) return 'now';
|
if (mins < 1) return 'now';
|
||||||
if (mins < 60) return `${mins}m`;
|
if (mins < 60) return `${mins}m ago`;
|
||||||
const hrs = Math.floor(mins / 60);
|
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);
|
const days = Math.floor(hrs / 24);
|
||||||
if (days < 7) return `${days}d`;
|
if (days < 7) return `${days}d ago`;
|
||||||
return new Date(iso).toLocaleDateString();
|
// 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',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user