feat(refboard): canvas polish — transform box sync, dot grid, snap tuning, perf fixes
- Fix transform box not updating after alignment/arrangement/normalize/flip operations (shortcuts, context menu, and selection toolbar all fixed with DRY _opUpdate helper) - Replace 100ms polling loop with event-driven viewport updates (moved + wheel-scroll) - Add adaptive dot grid background that responds to zoom/pan - Reduce snap guide threshold from 8px to 4px for subtler snapping - Remove PresenceOverlay (remote selection highlighting) — too heavy for minimal benefit - Offset multiple dropped images so they don't overlap - Add new canvas modules: SnapGuides, clipboard, grouping, FrameSprite, DrawingSprite, LaserPointer, context-menu-items, SelectionToolbar, Minimap - Extract Editor hooks into dedicated files (useBoardLoader, useCanvasSetup, useShortcutHandler, useLayerPanel, useSaveManager, useFollowMode) - Sync improvements: real-time transform broadcast, board rooms, viewport sync
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
interface MinimapItem {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
type: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Bounds {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
interface MinimapProps {
|
||||
items: MinimapItem[];
|
||||
viewportBounds: Bounds; // world-space visible area
|
||||
contentBounds: Bounds; // world-space all-content bounds
|
||||
onNavigate: (worldX: number, worldY: number) => void;
|
||||
}
|
||||
|
||||
const MAP_W = 180;
|
||||
const MAP_H = 120;
|
||||
const PADDING_RATIO = 0.1;
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
image: '#4a9eff',
|
||||
video: '#ff6b6b',
|
||||
text: '#69db7c',
|
||||
drawing: '#ffd43b',
|
||||
group: '#7950f2',
|
||||
};
|
||||
|
||||
function getColor(type: string): string {
|
||||
return TYPE_COLORS[type] ?? '#888';
|
||||
}
|
||||
|
||||
/** Compute the union of two bounding boxes. */
|
||||
function unionBounds(a: Bounds, b: Bounds): Bounds {
|
||||
const x1 = Math.min(a.x, b.x);
|
||||
const y1 = Math.min(a.y, b.y);
|
||||
const x2 = Math.max(a.x + a.w, b.x + b.w);
|
||||
const y2 = Math.max(a.y + a.h, b.y + b.h);
|
||||
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
|
||||
}
|
||||
|
||||
export default function Minimap({ items, viewportBounds, contentBounds, onNavigate }: MinimapProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
// Compute the mapping from world space to minimap pixel space.
|
||||
// Returns { offsetX, offsetY, scale } so that:
|
||||
// minimapX = (worldX - offsetX) * scale
|
||||
// minimapY = (worldY - offsetY) * scale
|
||||
const getMapping = useCallback(() => {
|
||||
const scene = unionBounds(contentBounds, viewportBounds);
|
||||
|
||||
// Add 10% padding
|
||||
const padX = scene.w * PADDING_RATIO;
|
||||
const padY = scene.h * PADDING_RATIO;
|
||||
const padded: Bounds = {
|
||||
x: scene.x - padX,
|
||||
y: scene.y - padY,
|
||||
w: scene.w + padX * 2,
|
||||
h: scene.h + padY * 2,
|
||||
};
|
||||
|
||||
// Avoid division by zero
|
||||
if (padded.w === 0 || padded.h === 0) {
|
||||
return { offsetX: padded.x, offsetY: padded.y, scale: 1 };
|
||||
}
|
||||
|
||||
const scale = Math.min(MAP_W / padded.w, MAP_H / padded.h);
|
||||
return { offsetX: padded.x, offsetY: padded.y, scale };
|
||||
}, [contentBounds, viewportBounds]);
|
||||
|
||||
// Convert minimap pixel coords to world coords
|
||||
const minimapToWorld = useCallback((mx: number, my: number): { wx: number; wy: number } => {
|
||||
const { offsetX, offsetY, scale } = getMapping();
|
||||
return {
|
||||
wx: mx / scale + offsetX,
|
||||
wy: my / scale + offsetY,
|
||||
};
|
||||
}, [getMapping]);
|
||||
|
||||
// Handle pointer interaction (click / drag to navigate)
|
||||
const handlePointerEvent = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const { wx, wy } = minimapToWorld(mx, my);
|
||||
onNavigate(wx, wy);
|
||||
}, [minimapToWorld, onNavigate]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
draggingRef.current = true;
|
||||
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
|
||||
handlePointerEvent(e);
|
||||
}, [handlePointerEvent]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draggingRef.current) return;
|
||||
handlePointerEvent(e);
|
||||
}, [handlePointerEvent]);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
draggingRef.current = false;
|
||||
(e.target as HTMLCanvasElement).releasePointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
// Draw minimap
|
||||
useEffect(() => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = MAP_W * dpr;
|
||||
canvas.height = MAP_H * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, MAP_W, MAP_H);
|
||||
|
||||
const { offsetX, offsetY, scale } = getMapping();
|
||||
|
||||
const toX = (wx: number) => (wx - offsetX) * scale;
|
||||
const toY = (wy: number) => (wy - offsetY) * scale;
|
||||
|
||||
// Draw items
|
||||
for (const item of items) {
|
||||
const rx = toX(item.x);
|
||||
const ry = toY(item.y);
|
||||
const rw = Math.max(item.w * scale, 2);
|
||||
const rh = Math.max(item.h * scale, 2);
|
||||
ctx.fillStyle = getColor(item.type);
|
||||
ctx.fillRect(rx, ry, rw, rh);
|
||||
}
|
||||
|
||||
// Draw viewport frustum
|
||||
const vx = toX(viewportBounds.x);
|
||||
const vy = toY(viewportBounds.y);
|
||||
const vw = viewportBounds.w * scale;
|
||||
const vh = viewportBounds.h * scale;
|
||||
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
|
||||
ctx.fillRect(vx, vy, vw, vh);
|
||||
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(vx + 0.5, vy + 0.5, vw, vh);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [items, viewportBounds, contentBounds, getMapping]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
background: 'rgba(20, 20, 20, 0.9)',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 8,
|
||||
cursor: 'crosshair',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 100,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SelectionToolbarProps {
|
||||
/** Screen-space position of the selection's top-center */
|
||||
x: number;
|
||||
y: number;
|
||||
count: number;
|
||||
onAlignLeft: () => void;
|
||||
onAlignCenterH: () => void;
|
||||
onAlignRight: () => void;
|
||||
onAlignTop: () => void;
|
||||
onAlignCenterV: () => void;
|
||||
onAlignBottom: () => void;
|
||||
onDistributeH: () => void;
|
||||
onDistributeV: () => void;
|
||||
onPack: () => void;
|
||||
onGrid: () => void;
|
||||
onRow: () => void;
|
||||
onColumn: () => void;
|
||||
onStack: () => void;
|
||||
onFlipH: () => void;
|
||||
onFlipV: () => void;
|
||||
onGroup: () => void;
|
||||
onNormSize: () => void;
|
||||
}
|
||||
|
||||
// Tiny SVG icons for each action
|
||||
function IcoAlignL() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="2" y1="1" x2="2" y2="13" /><rect x="4" y="3" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="4" y="8" width="5" height="3" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||
}
|
||||
function IcoAlignCH() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="7" y1="1" x2="7" y2="13" strokeDasharray="1.5 1.5" /><rect x="2" y="3" width="10" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="3.5" y="8" width="7" height="3" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||
}
|
||||
function IcoAlignR() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="12" y1="1" x2="12" y2="13" /><rect x="2" y="3" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="5" y="8" width="5" height="3" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||
}
|
||||
function IcoAlignT() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="1" y1="2" x2="13" y2="2" /><rect x="3" y="4" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="8" y="4" width="3" height="5" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||
}
|
||||
function IcoAlignCV() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="1" y1="7" x2="13" y2="7" strokeDasharray="1.5 1.5" /><rect x="3" y="2" width="3" height="10" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="8" y="3.5" width="3" height="7" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||
}
|
||||
function IcoAlignB() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><line x1="1" y1="12" x2="13" y2="12" /><rect x="3" y="2" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="8" y="5" width="3" height="5" rx="0.5" fill="currentColor" opacity="0.3" /></svg>;
|
||||
}
|
||||
function IcoDistH() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="3" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="5.5" y="3" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="10" y="3" width="3" height="8" rx="0.5" fill="currentColor" opacity="0.3" /><path d="M4.5 7h1M9 7h1" strokeWidth="1" /></svg>;
|
||||
}
|
||||
function IcoDistV() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="3" y="1" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="3" y="5.5" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><rect x="3" y="10" width="8" height="3" rx="0.5" fill="currentColor" opacity="0.3" /><path d="M7 4.5v1M7 9v1" strokeWidth="1" /></svg>;
|
||||
}
|
||||
function IcoPack() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="1" width="5" height="6" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="7" y="1" width="6" height="4" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="1" y="8" width="4" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="6" y="6" width="7" height="7" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
|
||||
}
|
||||
function IcoGrid() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="1" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="8" y="1" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="1" y="8" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="8" y="8" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
|
||||
}
|
||||
function IcoRow() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="4" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="5.5" y="4" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="10" y="4" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
|
||||
}
|
||||
function IcoCol() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="4" y="1" width="6" height="3" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="4" y="5.5" width="6" height="3" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="4" y="10" width="6" height="3" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
|
||||
}
|
||||
function IcoStack() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="2" y="2" width="10" height="10" rx="0.5" fill="currentColor" opacity="0.15" /><rect x="3" y="3" width="8" height="8" rx="0.5" fill="currentColor" opacity="0.15" /><rect x="4" y="4" width="6" height="6" rx="0.5" fill="currentColor" opacity="0.2" /></svg>;
|
||||
}
|
||||
function IcoFlipH() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3"><line x1="7" y1="1" x2="7" y2="13" strokeDasharray="2 1" /><path d="M5 4H2L5 10V4Z" fill="currentColor" opacity="0.3" /><path d="M9 4H12L9 10V4Z" fill="currentColor" opacity="0.15" /></svg>;
|
||||
}
|
||||
function IcoFlipV() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3"><line x1="1" y1="7" x2="13" y2="7" strokeDasharray="2 1" /><path d="M4 5V2L10 5H4Z" fill="currentColor" opacity="0.3" /><path d="M4 9V12L10 9H4Z" fill="currentColor" opacity="0.15" /></svg>;
|
||||
}
|
||||
function IcoGroup() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="1" width="12" height="12" rx="1.5" strokeDasharray="2 1.5" /><rect x="3" y="3" width="4" height="4" rx="0.5" fill="currentColor" opacity="0.25" /><rect x="7" y="7" width="4" height="4" rx="0.5" fill="currentColor" opacity="0.25" /></svg>;
|
||||
}
|
||||
function IcoNormSize() {
|
||||
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1" y="4" width="5" height="8" rx="0.5" fill="currentColor" opacity="0.2" /><rect x="8" y="2" width="5" height="10" rx="0.5" fill="currentColor" opacity="0.2" /><path d="M3.5 1v2M10.5 1v2" strokeLinecap="round" /><line x1="3.5" y1="1.5" x2="10.5" y2="1.5" strokeLinecap="round" /></svg>;
|
||||
}
|
||||
|
||||
interface BtnDef {
|
||||
icon: React.FC;
|
||||
label: string;
|
||||
shortcut: string;
|
||||
onClick: () => void;
|
||||
minItems?: number;
|
||||
}
|
||||
|
||||
export default function SelectionToolbar(props: SelectionToolbarProps) {
|
||||
const { x, y, count } = props;
|
||||
|
||||
const groups: { label: string; items: BtnDef[] }[] = [
|
||||
{
|
||||
label: 'Align',
|
||||
items: [
|
||||
{ icon: IcoAlignL, label: 'Align left', shortcut: 'Ctrl+\u2190', onClick: props.onAlignLeft },
|
||||
{ icon: IcoAlignCH, label: 'Align center H', shortcut: 'Ctrl+Alt+H', onClick: props.onAlignCenterH },
|
||||
{ icon: IcoAlignR, label: 'Align right', shortcut: 'Ctrl+\u2192', onClick: props.onAlignRight },
|
||||
{ icon: IcoAlignT, label: 'Align top', shortcut: 'Ctrl+\u2191', onClick: props.onAlignTop },
|
||||
{ icon: IcoAlignCV, label: 'Align center V', shortcut: 'Ctrl+Alt+V', onClick: props.onAlignCenterV },
|
||||
{ icon: IcoAlignB, label: 'Align bottom', shortcut: 'Ctrl+\u2193', onClick: props.onAlignBottom },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Distribute',
|
||||
items: [
|
||||
{ icon: IcoDistH, label: 'Distribute H', shortcut: 'Ctrl+Shift+H', onClick: props.onDistributeH, minItems: 3 },
|
||||
{ icon: IcoDistV, label: 'Distribute V', shortcut: 'Ctrl+Shift+V', onClick: props.onDistributeV, minItems: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Arrange',
|
||||
items: [
|
||||
{ icon: IcoPack, label: 'Pack', shortcut: 'Ctrl+Shift+P', onClick: props.onPack },
|
||||
{ icon: IcoGrid, label: 'Grid', shortcut: '', onClick: props.onGrid },
|
||||
{ icon: IcoRow, label: 'Row', shortcut: '', onClick: props.onRow },
|
||||
{ icon: IcoCol, label: 'Column', shortcut: '', onClick: props.onColumn },
|
||||
{ icon: IcoStack, label: 'Stack', shortcut: 'Ctrl+Alt+S', onClick: props.onStack },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Transform',
|
||||
items: [
|
||||
{ icon: IcoFlipH, label: 'Flip H', shortcut: 'Alt+Shift+H', onClick: props.onFlipH },
|
||||
{ icon: IcoFlipV, label: 'Flip V', shortcut: 'Alt+Shift+V', onClick: props.onFlipV },
|
||||
{ icon: IcoGroup, label: 'Group', shortcut: 'Ctrl+G', onClick: props.onGroup },
|
||||
{ icon: IcoNormSize, label: 'Same size', shortcut: '', onClick: props.onNormSize },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y - 8,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
display: 'flex',
|
||||
gap: '1px',
|
||||
padding: '3px',
|
||||
background: 'rgba(22, 22, 22, 0.96)',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '10px',
|
||||
backdropFilter: 'blur(12px)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
|
||||
zIndex: 100,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{groups.map((group, gi) => (
|
||||
<React.Fragment key={group.label}>
|
||||
{gi > 0 && (
|
||||
<div style={{ width: '1px', background: '#333', margin: '2px 2px', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '1px' }}>
|
||||
{group.items.map((btn) => {
|
||||
const disabled = (btn.minItems ?? 2) > count;
|
||||
const Icon = btn.icon;
|
||||
return (
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={btn.onClick}
|
||||
disabled={disabled}
|
||||
title={btn.shortcut ? `${btn.label} (${btn.shortcut})` : btn.label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '26px',
|
||||
height: '26px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
color: disabled ? '#444' : '#999',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
padding: 0,
|
||||
transition: 'all 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.background = '#333';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = disabled ? '#444' : '#999';
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ interface ToolbarProps {
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onlineUsers: OnlineUser[];
|
||||
onUserClick?: (userId: string, displayName: string) => void;
|
||||
followingUserId?: string | null;
|
||||
onShareClick?: () => void;
|
||||
onToggleLayers?: () => void;
|
||||
showLayers?: boolean;
|
||||
@@ -139,6 +141,8 @@ export default function Toolbar({
|
||||
onUndo,
|
||||
onRedo,
|
||||
onlineUsers,
|
||||
onUserClick,
|
||||
followingUserId,
|
||||
onShareClick,
|
||||
onToggleLayers,
|
||||
showLayers,
|
||||
@@ -306,11 +310,16 @@ export default function Toolbar({
|
||||
background: `linear-gradient(135deg, ${u.color}, ${u.color}dd)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '10px', fontWeight: 700, color: '#fff',
|
||||
border: '2px solid #1a1a1a',
|
||||
border: followingUserId === u.userId ? '2px solid #4a9eff' : '2px solid #1a1a1a',
|
||||
marginLeft: i > 0 ? '-6px' : '0',
|
||||
zIndex: 5 - i,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
boxShadow: followingUserId === u.userId ? '0 0 6px rgba(74,144,217,0.6)' : '0 1px 3px rgba(0,0,0,0.3)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
onClick={() => onUserClick?.(u.userId, u.displayName)}
|
||||
title={`${u.displayName}${followingUserId === u.userId ? ' (following)' : ' — click to follow'}`}
|
||||
>
|
||||
{(u.displayName || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -91,10 +91,11 @@ export default function UserCursors({ socket, boardId, canvasTransform }: UserCu
|
||||
key={cursor.userId}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: screenX,
|
||||
top: screenY,
|
||||
transform: 'translate(-2px, -2px)',
|
||||
transition: 'left 0.1s, top 0.1s',
|
||||
left: 0,
|
||||
top: 0,
|
||||
transform: `translate(${screenX - 2}px, ${screenY - 2}px)`,
|
||||
transition: 'transform 50ms linear',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="20" viewBox="0 0 16 20" fill="none">
|
||||
|
||||
Reference in New Issue
Block a user