From b350951bf16ec4c138ace04102f867a01cf8924a Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Fri, 13 Mar 2026 10:20:49 +0530 Subject: [PATCH] refboard: merge crop mode polish with markdown editor --- frontend/src/canvas/CropOverlay.ts | 4 + frontend/src/hooks/useCanvasSetup.ts | 7 +- frontend/src/pages/Editor.tsx | 126 ++++++++++++++++++++++++--- 3 files changed, 126 insertions(+), 11 deletions(-) diff --git a/frontend/src/canvas/CropOverlay.ts b/frontend/src/canvas/CropOverlay.ts index 88861f9..2cc852c 100644 --- a/frontend/src/canvas/CropOverlay.ts +++ b/frontend/src/canvas/CropOverlay.ts @@ -33,6 +33,7 @@ export class CropOverlay extends Container { private _drag: { mode: DragMode; startCrop: CropRect; startPoint: { x: number; y: number } } | null = null; private _onConfirm: ((item: SceneItem, crop: CropRect) => void) | null = null; private _onCancel: (() => void) | null = null; + private _onStateChange: ((active: boolean) => void) | null = null; private _keyHandler: ((e: KeyboardEvent) => void) | null = null; // Bound references for dynamic event registration 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 onCancel(fn: () => void) { this._onCancel = fn; } + set onStateChange(fn: (active: boolean) => void) { this._onStateChange = fn; } /** Start cropping the given image item. */ start(item: SceneItem): void { @@ -87,6 +89,7 @@ export class CropOverlay extends Container { const imgData = item.data as ImageObject; this._crop = imgData.crop ? { ...imgData.crop } : { x: 0, y: 0, w: 1, h: 1 }; this.visible = true; + this._onStateChange?.(true); // Ensure overlay renders on top of all scene items if (this.parent) { this.parent.setChildIndex(this, this.parent.children.length - 1); @@ -125,6 +128,7 @@ export class CropOverlay extends Container { this._item = null; this._drag = null; this.visible = false; + this._onStateChange?.(false); if (this._keyHandler) { window.removeEventListener('keydown', this._keyHandler); this._keyHandler = null; diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index e99f1fb..34d2bfc 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -60,6 +60,7 @@ interface CanvasSetupDeps { onTextPaste?: (data: { text: string; html: string; hasImage: boolean }) => void; onShortTextPaste?: (text: string) => void; }; + onCropModeChange?: (active: boolean) => void; } /** @@ -72,6 +73,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, pasteOpts, + onCropModeChange, } = deps; const dropCleanupRef = useRef<(() => void) | null>(null); @@ -465,6 +467,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { const cropOverlay = new CropOverlay(viewport); viewport.addChild(cropOverlay); cropOverlayRef.current = cropOverlay; + cropOverlay.onStateChange = (active) => { + onCropModeChange?.(active); + }; cropOverlay.onConfirm = (item, crop) => { const imgData = item.data as any; @@ -529,7 +534,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { sharpnessCleanupRef.current = null; 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 }; } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index e03503a..ee2365e 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -127,6 +127,8 @@ export default function Editor({ isPublicView }: EditorProps) { const [layerList, setLayerList] = useState([]); const [selectedLayerIds, setSelectedLayerIds] = useState([]); 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< | { 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[] } @@ -243,6 +245,7 @@ export default function Editor({ isPublicView }: EditorProps) { onTextPaste: handleTextPaste, onShortTextPaste: handleShortTextPaste, }, + onCropModeChange: setCropModeActive, }); // ── Markdown overlay — sync visible card IDs for React portal rendering ── @@ -640,14 +643,31 @@ export default function Editor({ isPublicView }: EditorProps) { const updateOverlays = useCallback(() => { const selection = selectionRef.current; 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 cropActive = cropModeActive && (cropOverlayRef.current?.isActive ?? false); setZoom(canvasRef.current?.getZoom() ?? 1); 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 - 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 b = getItemWorldBounds(items[0]); const screenTL = vp.toScreen(b.x, b.y); @@ -672,7 +692,7 @@ export default function Editor({ isPublicView }: EditorProps) { : stickyItems.length === items.length ? stickyItems : 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; for (const item of formatItems) { 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 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 screenTL = vp.toScreen(b.x, b.y); const screenTR = vp.toScreen(b.x + b.w, b.y); @@ -721,7 +741,7 @@ export default function Editor({ isPublicView }: EditorProps) { setMdToolbar(null); } - if (items.length < 2) { setSelToolbar(null); } else { + if (cropActive || items.length < 2) { setSelToolbar(null); } else { // Compute world bounding box of selection let minX = Infinity, minY = Infinity, maxX = -Infinity; for (const item of items) { @@ -764,7 +784,7 @@ export default function Editor({ isPublicView }: EditorProps) { // Pin positions follow media automatically (children of displayObjects). // Only refresh on zoom to update counter-scale. pinOverlay?.refresh(); - }, [pinOverlay]); + }, [pinOverlay, cropOverlayRef, cropModeActive]); // Listen to viewport moved event for overlay updates (throttled) // 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 useEffect(() => { updateOverlays(); - }, [selectedLayerIds, updateOverlays]); + }, [selectedLayerIds, sceneVersion, cropModeActive, updateOverlays]); // (PresenceOverlay removed — selection broadcast no longer needed) @@ -967,7 +987,7 @@ export default function Editor({ isPublicView }: EditorProps) { )} {/* Selection toolbar (floating, above selection) */} - {selToolbar && selToolbar.count >= 2 && activeTool === ToolType.SELECT && !contextMenu && ( + {selToolbar && selToolbar.count >= 2 && activeTool === ToolType.SELECT && !contextMenu && !cropToolbar && ( )} + {cropToolbar && activeTool === ToolType.SELECT && !contextMenu && ( +
e.stopPropagation()} + > +
+
+ + Crop Mode + + + {cropToolbar.name} + +
+
+ Drag inside to move. Drag handles to resize. Enter applies. Esc cancels. +
+
+ + +
+ )} + {/* Minimap */} {showMinimap && !focusMode && objectCount > 0 && (