feat: text tool UX, image crop, text format toolbar, double-click focus

- Text tool: click-to-place immediately opens inline editor, auto-switches
  back to select tool. Empty text cleanup on save.
- Crop: select image + press C (or right-click > Crop) to enter crop mode.
  8 drag handles with rule-of-thirds grid, dimmed outside area.
  Enter confirms, Escape cancels. Non-destructive (stored as normalized rect).
- Text format toolbar: appears when text items selected, with font size +/-,
  font family dropdown, and color picker with presets.
- Double-click image: zoom-to-fit (PureRef-style focus)
- Entrance animation: replaced bounce with simple fade-in (no delay before
  items become interactive)
- TextEditor: allow empty text (caller handles cleanup)
This commit is contained in:
Hiren Kangad
2026-03-11 17:22:25 +05:30
parent 4ed475c62e
commit 58da934ec1
14 changed files with 785 additions and 50 deletions
+55 -2
View File
@@ -8,9 +8,11 @@ import { UndoManager } from '../canvas/history';
import { InboxZone } from '../canvas/InboxZone';
import { LaserPointer } from '../canvas/LaserPointer';
import { VideoSprite } from '../canvas/sprites/VideoSprite';
import { ImageSprite } from '../canvas/sprites/ImageSprite';
import { UploadManager } from '../stores/uploadManager';
import { AnnotationStore } from '../stores/annotationStore';
import { PinOverlay } from '../canvas/PinOverlay';
import { CropOverlay } from '../canvas/CropOverlay';
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
import { connectSocket, disconnectSocket } from '../socket';
import api from '../api';
@@ -70,6 +72,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
const annotationStoreRef = useRef<AnnotationStore | null>(null);
if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore();
const pinOverlayRef = useRef<PinOverlay | null>(null);
const textEditorRef = useRef<TextEditor | null>(null);
if (!textEditorRef.current) textEditorRef.current = new TextEditor();
const cropOverlayRef = useRef<CropOverlay | null>(null);
useEffect(() => {
if (!boardData || !resolvedBoardId) return;
@@ -102,7 +107,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
};
// Inline text editing on double-click
const textEditor = new TextEditor();
const textEditor = textEditorRef.current!;
// Find the DOM container for the canvas (parent of the <canvas> element)
const canvasElements = document.querySelectorAll('canvas');
let domContainer: HTMLElement | null = null;
@@ -115,11 +120,35 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
selection.onDoubleClickText = (item) => {
if (!domContainer) return;
textEditor.startEditing(item, viewport, domContainer, () => {
// If user saved empty text, remove the item
const text = (item.data as any).text?.trim();
if (!text) {
scene.removeItem(item.id, true);
}
syncRef.current?.broadcastElements([item.id]);
onCanvasChange();
});
};
// Double-click image: zoom-to-fit (PureRef-style focus)
selection.onDoubleClickImage = (item) => {
const bounds = item.displayObject.getBounds();
const padding = 80; // screen pixels of padding around the image
const screenW = viewport.screenWidth;
const screenH = viewport.screenHeight;
const scaleX = (screenW - padding * 2) / bounds.width;
const scaleY = (screenH - padding * 2) / bounds.height;
const targetScale = Math.min(scaleX, scaleY, 3); // cap at 3x
const cx = item.data.x + (item.data.w * item.data.sx) / 2;
const cy = item.data.y + (item.data.h * item.data.sy) / 2;
viewport.animate({
time: 300,
position: { x: cx, y: cy },
scale: targetScale,
ease: 'easeOutQuad',
});
};
// Refresh transform box when item dimensions change (e.g. video metadata loaded)
scene.onItemDimensionsChanged = (itemId: string) => {
if (selection.selectedIds.has(itemId)) {
@@ -364,6 +393,26 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
};
}
// Crop overlay
const cropOverlay = new CropOverlay(viewport);
viewport.addChild(cropOverlay);
cropOverlayRef.current = cropOverlay;
cropOverlay.onConfirm = (item, crop) => {
const imgData = item.data as any;
const isFullImage = crop.x < 0.001 && crop.y < 0.001 && crop.w > 0.999 && crop.h > 0.999;
imgData.crop = isFullImage ? undefined : crop;
if (item.displayObject instanceof ImageSprite) {
item.displayObject.applyCrop(imgData.crop);
}
selection.setEnabled(true);
onCanvasChange([item.id]);
};
cropOverlay.onCancel = () => {
selection.setEnabled(true);
};
// Setup drag/drop and paste
if (!isPublicView || user) {
// Use the ref first, fall back to DOM query for the canvas parent
@@ -393,6 +442,10 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
laserCleanupRef.current = null;
dropCleanupRef.current?.();
pasteCleanupRef.current?.();
if (cropOverlayRef.current) {
cropOverlayRef.current.destroy();
cropOverlayRef.current = null;
}
if (pinOverlayRef.current) {
(pinOverlayRef.current as any)._cleanup?.();
pinOverlayRef.current.destroy();
@@ -403,5 +456,5 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
};
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds]);
return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current };
return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current, textEditor: textEditorRef.current, cropOverlay: cropOverlayRef.current };
}
+3
View File
@@ -31,6 +31,7 @@ interface ShortcutHandlerDeps {
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>;
setFocusMode: React.Dispatch<React.SetStateAction<boolean>>;
setReviewMode: React.Dispatch<React.SetStateAction<boolean>>;
startCrop?: () => void;
}
/**
@@ -43,6 +44,7 @@ export function useShortcutHandler(deps: ShortcutHandlerDeps) {
handleGroup, handleUngroup,
setActiveTool, setCanUndo, setCanRedo, setZoom,
setShowGrid, setShowHelp, setFocusMode, setReviewMode,
startCrop,
} = deps;
useEffect(() => {
@@ -91,6 +93,7 @@ export function useShortcutHandler(deps: ShortcutHandlerDeps) {
toggleShowHelp: () => setShowHelp((v) => !v),
toggleFocusMode: () => setFocusMode((v) => !v),
toggleReviewMode: () => setReviewMode((v) => !v),
startCrop,
pasteFromSystemClipboard: async (): Promise<string> => {
if (!resolvedBoardId) return 'No board';
const msg = await pasteFromSystemClipboard(scene, viewport, resolvedBoardId, onCanvasChange);