refboard: merge crop mode polish with markdown editor

This commit is contained in:
Hiren Kangad
2026-03-13 11:21:28 +05:30
parent 04d499b755
commit b350951bf1
3 changed files with 126 additions and 11 deletions
+4
View File
@@ -33,6 +33,7 @@ export class CropOverlay extends Container {
private _drag: { mode: DragMode; startCrop: CropRect; startPoint: { x: number; y: number } } | null = null; private _drag: { mode: DragMode; startCrop: CropRect; startPoint: { x: number; y: number } } | null = null;
private _onConfirm: ((item: SceneItem, crop: CropRect) => void) | null = null; private _onConfirm: ((item: SceneItem, crop: CropRect) => void) | null = null;
private _onCancel: (() => void) | null = null; private _onCancel: (() => void) | null = null;
private _onStateChange: ((active: boolean) => void) | null = null;
private _keyHandler: ((e: KeyboardEvent) => void) | null = null; private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
// Bound references for dynamic event registration // Bound references for dynamic event registration
private _boundMove: ((e: FederatedPointerEvent) => void) | null = null; private _boundMove: ((e: FederatedPointerEvent) => void) | null = null;
@@ -79,6 +80,7 @@ export class CropOverlay extends Container {
set onConfirm(fn: (item: SceneItem, crop: CropRect) => void) { this._onConfirm = fn; } set onConfirm(fn: (item: SceneItem, crop: CropRect) => void) { this._onConfirm = fn; }
set onCancel(fn: () => void) { this._onCancel = fn; } set onCancel(fn: () => void) { this._onCancel = fn; }
set onStateChange(fn: (active: boolean) => void) { this._onStateChange = fn; }
/** Start cropping the given image item. */ /** Start cropping the given image item. */
start(item: SceneItem): void { start(item: SceneItem): void {
@@ -87,6 +89,7 @@ export class CropOverlay extends Container {
const imgData = item.data as ImageObject; const imgData = item.data as ImageObject;
this._crop = imgData.crop ? { ...imgData.crop } : { x: 0, y: 0, w: 1, h: 1 }; this._crop = imgData.crop ? { ...imgData.crop } : { x: 0, y: 0, w: 1, h: 1 };
this.visible = true; this.visible = true;
this._onStateChange?.(true);
// Ensure overlay renders on top of all scene items // Ensure overlay renders on top of all scene items
if (this.parent) { if (this.parent) {
this.parent.setChildIndex(this, this.parent.children.length - 1); this.parent.setChildIndex(this, this.parent.children.length - 1);
@@ -125,6 +128,7 @@ export class CropOverlay extends Container {
this._item = null; this._item = null;
this._drag = null; this._drag = null;
this.visible = false; this.visible = false;
this._onStateChange?.(false);
if (this._keyHandler) { if (this._keyHandler) {
window.removeEventListener('keydown', this._keyHandler); window.removeEventListener('keydown', this._keyHandler);
this._keyHandler = null; this._keyHandler = null;
+6 -1
View File
@@ -60,6 +60,7 @@ interface CanvasSetupDeps {
onTextPaste?: (data: { text: string; html: string; hasImage: boolean }) => void; onTextPaste?: (data: { text: string; html: string; hasImage: boolean }) => void;
onShortTextPaste?: (text: string) => void; onShortTextPaste?: (text: string) => void;
}; };
onCropModeChange?: (active: boolean) => void;
} }
/** /**
@@ -72,6 +73,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
pasteOpts, pasteOpts,
onCropModeChange,
} = deps; } = deps;
const dropCleanupRef = useRef<(() => void) | null>(null); const dropCleanupRef = useRef<(() => void) | null>(null);
@@ -465,6 +467,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
const cropOverlay = new CropOverlay(viewport); const cropOverlay = new CropOverlay(viewport);
viewport.addChild(cropOverlay); viewport.addChild(cropOverlay);
cropOverlayRef.current = cropOverlay; cropOverlayRef.current = cropOverlay;
cropOverlay.onStateChange = (active) => {
onCropModeChange?.(active);
};
cropOverlay.onConfirm = (item, crop) => { cropOverlay.onConfirm = (item, crop) => {
const imgData = item.data as any; const imgData = item.data as any;
@@ -529,7 +534,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
sharpnessCleanupRef.current = null; sharpnessCleanupRef.current = null;
disconnectSocket(); disconnectSocket();
}; };
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds]); }, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds, pasteOpts, onCropModeChange]);
return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current, textEditor: textEditorRef.current, cropOverlayRef, mdOverlay: mdOverlayRef.current }; return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current, textEditor: textEditorRef.current, cropOverlayRef, mdOverlay: mdOverlayRef.current };
} }
+116 -10
View File
@@ -127,6 +127,8 @@ export default function Editor({ isPublicView }: EditorProps) {
const [layerList, setLayerList] = useState<any[]>([]); const [layerList, setLayerList] = useState<any[]>([]);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]); const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null); const [selToolbar, setSelToolbar] = useState<{ x: number; y: number; count: number } | null>(null);
const [cropToolbar, setCropToolbar] = useState<{ x: number; y: number; name: string } | null>(null);
const [cropModeActive, setCropModeActive] = useState(false);
const [textToolbar, setTextToolbar] = useState< const [textToolbar, setTextToolbar] = useState<
| { kind: 'text'; x: number; y: number; fontFamily: string; fill: string; items: SceneItem[] } | { kind: 'text'; x: number; y: number; fontFamily: string; fill: string; items: SceneItem[] }
| { kind: 'sticky'; x: number; y: number; fontFamily: string; textColor: string; fill: string; stickyFontSize: number; items: SceneItem[] } | { kind: 'sticky'; x: number; y: number; fontFamily: string; textColor: string; fill: string; stickyFontSize: number; items: SceneItem[] }
@@ -243,6 +245,7 @@ export default function Editor({ isPublicView }: EditorProps) {
onTextPaste: handleTextPaste, onTextPaste: handleTextPaste,
onShortTextPaste: handleShortTextPaste, onShortTextPaste: handleShortTextPaste,
}, },
onCropModeChange: setCropModeActive,
}); });
// ── Markdown overlay — sync visible card IDs for React portal rendering ── // ── Markdown overlay — sync visible card IDs for React portal rendering ──
@@ -640,14 +643,31 @@ export default function Editor({ isPublicView }: EditorProps) {
const updateOverlays = useCallback(() => { const updateOverlays = useCallback(() => {
const selection = selectionRef.current; const selection = selectionRef.current;
const vp = canvasRef.current?.getViewport(); const vp = canvasRef.current?.getViewport();
if (!selection || !vp) { setSelToolbar(null); setVideoCtrl(null); return; } if (!selection || !vp) { setSelToolbar(null); setCropToolbar(null); setVideoCtrl(null); return; }
const items = selection.getSelectedItems(); const items = selection.getSelectedItems();
const cropActive = cropModeActive && (cropOverlayRef.current?.isActive ?? false);
setZoom(canvasRef.current?.getZoom() ?? 1); setZoom(canvasRef.current?.getZoom() ?? 1);
setCanvasTransform([vp.scale.x, 0, 0, vp.scale.y, vp.x, vp.y]); setCanvasTransform([vp.scale.x, 0, 0, vp.scale.y, vp.x, vp.y]);
if (cropActive && items.length === 1 && items[0].type === 'image') {
const b = getItemWorldBounds(items[0]);
const screenTL = vp.toScreen(b.x, b.y);
const screenTR = vp.toScreen(b.x + b.w, b.y);
setCropToolbar({
x: (screenTL.x + screenTR.x) / 2,
y: screenTL.y,
name: items[0].data.name || 'Image',
});
setSelToolbar(null);
setTextToolbar(null);
setVideoCtrl(null);
} else {
setCropToolbar(null);
}
// Video controls: show when exactly 1 video is selected // Video controls: show when exactly 1 video is selected
if (items.length === 1 && items[0].type === 'video' && items[0].displayObject instanceof VideoSprite) { if (!cropActive && items.length === 1 && items[0].type === 'video' && items[0].displayObject instanceof VideoSprite) {
const vs = items[0].displayObject as VideoSprite; const vs = items[0].displayObject as VideoSprite;
const b = getItemWorldBounds(items[0]); const b = getItemWorldBounds(items[0]);
const screenTL = vp.toScreen(b.x, b.y); const screenTL = vp.toScreen(b.x, b.y);
@@ -672,7 +692,7 @@ export default function Editor({ isPublicView }: EditorProps) {
: stickyItems.length === items.length ? stickyItems : stickyItems.length === items.length ? stickyItems
: null; // mixed or non-text selection → hide : null; // mixed or non-text selection → hide
if (formatItems && formatItems.length > 0) { if (!cropActive && formatItems && formatItems.length > 0) {
let minX2 = Infinity, minY2 = Infinity, maxX2 = -Infinity, maxY2 = -Infinity; let minX2 = Infinity, minY2 = Infinity, maxX2 = -Infinity, maxY2 = -Infinity;
for (const item of formatItems) { for (const item of formatItems) {
const b = getItemWorldBounds(item); const b = getItemWorldBounds(item);
@@ -712,7 +732,7 @@ export default function Editor({ isPublicView }: EditorProps) {
// Markdown format toolbar: show when exactly one markdown card is selected // Markdown format toolbar: show when exactly one markdown card is selected
const mdItems = items.filter((it) => it.type === 'markdown'); const mdItems = items.filter((it) => it.type === 'markdown');
if (mdItems.length === 1 && items.length === 1) { if (!cropActive && mdItems.length === 1 && items.length === 1) {
const b = getItemWorldBounds(mdItems[0]); const b = getItemWorldBounds(mdItems[0]);
const screenTL = vp.toScreen(b.x, b.y); const screenTL = vp.toScreen(b.x, b.y);
const screenTR = vp.toScreen(b.x + b.w, b.y); const screenTR = vp.toScreen(b.x + b.w, b.y);
@@ -721,7 +741,7 @@ export default function Editor({ isPublicView }: EditorProps) {
setMdToolbar(null); setMdToolbar(null);
} }
if (items.length < 2) { setSelToolbar(null); } else { if (cropActive || items.length < 2) { setSelToolbar(null); } else {
// Compute world bounding box of selection // Compute world bounding box of selection
let minX = Infinity, minY = Infinity, maxX = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity;
for (const item of items) { for (const item of items) {
@@ -764,7 +784,7 @@ export default function Editor({ isPublicView }: EditorProps) {
// Pin positions follow media automatically (children of displayObjects). // Pin positions follow media automatically (children of displayObjects).
// Only refresh on zoom to update counter-scale. // Only refresh on zoom to update counter-scale.
pinOverlay?.refresh(); pinOverlay?.refresh();
}, [pinOverlay]); }, [pinOverlay, cropOverlayRef, cropModeActive]);
// Listen to viewport moved event for overlay updates (throttled) // Listen to viewport moved event for overlay updates (throttled)
// Depends on objectCount so it re-runs after canvas init (viewport becomes available) // Depends on objectCount so it re-runs after canvas init (viewport becomes available)
@@ -789,7 +809,7 @@ export default function Editor({ isPublicView }: EditorProps) {
// Also update overlays when selection or scene changes // Also update overlays when selection or scene changes
useEffect(() => { useEffect(() => {
updateOverlays(); updateOverlays();
}, [selectedLayerIds, updateOverlays]); }, [selectedLayerIds, sceneVersion, cropModeActive, updateOverlays]);
// (PresenceOverlay removed — selection broadcast no longer needed) // (PresenceOverlay removed — selection broadcast no longer needed)
@@ -967,7 +987,7 @@ export default function Editor({ isPublicView }: EditorProps) {
)} )}
{/* Selection toolbar (floating, above selection) */} {/* Selection toolbar (floating, above selection) */}
{selToolbar && selToolbar.count >= 2 && activeTool === ToolType.SELECT && !contextMenu && ( {selToolbar && selToolbar.count >= 2 && activeTool === ToolType.SELECT && !contextMenu && !cropToolbar && (
<SelectionToolbar <SelectionToolbar
x={selToolbar.x} x={selToolbar.x}
y={selToolbar.y} y={selToolbar.y}
@@ -993,7 +1013,7 @@ export default function Editor({ isPublicView }: EditorProps) {
)} )}
{/* Contextual format toolbar (floating, above selected text/sticky items) */} {/* Contextual format toolbar (floating, above selected text/sticky items) */}
{textToolbar && activeTool === ToolType.SELECT && !contextMenu && ( {textToolbar && activeTool === ToolType.SELECT && !contextMenu && !cropToolbar && (
<TextFormatToolbar <TextFormatToolbar
kind={textToolbar.kind} kind={textToolbar.kind}
x={textToolbar.x} x={textToolbar.x}
@@ -1105,13 +1125,99 @@ export default function Editor({ isPublicView }: EditorProps) {
)} )}
{/* Video controls (floating, below selected video) */} {/* Video controls (floating, below selected video) */}
{videoCtrl && activeTool === ToolType.SELECT && !contextMenu && ( {videoCtrl && activeTool === ToolType.SELECT && !contextMenu && !cropToolbar && (
<VideoControls <VideoControls
videoSprite={videoCtrl.videoSprite} videoSprite={videoCtrl.videoSprite}
screenRect={videoCtrl.screenRect} screenRect={videoCtrl.screenRect}
/> />
)} )}
{cropToolbar && activeTool === ToolType.SELECT && !contextMenu && (
<div
style={{
position: 'absolute',
left: cropToolbar.x,
top: cropToolbar.y - 10,
transform: 'translate(-50%, -100%)',
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 10px',
background: 'rgba(18, 18, 18, 0.96)',
border: '1px solid #3a3a3a',
borderRadius: '12px',
backdropFilter: 'blur(12px)',
boxShadow: '0 8px 30px rgba(0,0,0,0.45)',
zIndex: 140,
pointerEvents: 'auto',
color: '#e8e8e8',
}}
onPointerDown={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', minWidth: '220px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
display: 'inline-flex',
alignItems: 'center',
padding: '2px 7px',
borderRadius: '999px',
background: '#214765',
color: '#9fd3ff',
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}>
Crop Mode
</span>
<span style={{ fontSize: '12px', color: '#b7b7b7', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{cropToolbar.name}
</span>
</div>
<div style={{ fontSize: '11px', color: '#8f8f8f' }}>
Drag inside to move. Drag handles to resize. Enter applies. Esc cancels.
</div>
</div>
<button
onClick={() => {
cropOverlayRef.current?.cancel();
updateOverlays();
}}
style={{
height: '30px',
padding: '0 12px',
background: 'transparent',
border: '1px solid #444',
borderRadius: '8px',
color: '#d0d0d0',
cursor: 'pointer',
fontSize: '12px',
}}
>
Cancel
</button>
<button
onClick={() => {
cropOverlayRef.current?.confirm();
updateOverlays();
}}
style={{
height: '30px',
padding: '0 12px',
background: '#4a90d9',
border: '1px solid #4a90d9',
borderRadius: '8px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 600,
}}
>
Apply
</button>
</div>
)}
{/* Minimap */} {/* Minimap */}
{showMinimap && !focusMode && objectCount > 0 && ( {showMinimap && !focusMode && objectCount > 0 && (
<Minimap <Minimap