From 9fac16e6a40d7eea269104dd83aa1a65c81475a2 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Thu, 12 Mar 2026 22:47:48 +0530 Subject: [PATCH] feat: contextual text formatting toolbar + resize fontSize baking - Remove persistent font-size slider from main toolbar (tools only) - Lock in zoom-aware creation defaults as named constants - Split contextual toolbar by object type: text vs sticky - Text: fontSize + fontFamily + text color - Sticky: fontSize + fontFamily + text color + note fill color - Mixed selection: hide toolbar - Bake scale into fontSize on text resize (reset sx/sy to 1) - Sticky resize stays layout-driven (no font change) --- frontend/src/canvas/tools.ts | 16 ++- frontend/src/components/TextFormatToolbar.tsx | 71 ++++++++- frontend/src/components/Toolbar.tsx | 19 --- frontend/src/hooks/useCanvasSetup.ts | 21 +++ frontend/src/pages/Editor.tsx | 136 ++++++++++++------ 5 files changed, 193 insertions(+), 70 deletions(-) diff --git a/frontend/src/canvas/tools.ts b/frontend/src/canvas/tools.ts index 4177571..904030d 100644 --- a/frontend/src/canvas/tools.ts +++ b/frontend/src/canvas/tools.ts @@ -26,15 +26,19 @@ export enum ToolType { export interface ToolOptions { color?: string; strokeWidth?: number; - fontSize?: number; } const defaultOptions: ToolOptions = { color: '#ffffff', strokeWidth: 4, - fontSize: 24, }; +// Creation defaults — zoom-aware via screenToWorld() +const DEFAULT_TEXT_SCREEN_FONT = 24; +const DEFAULT_STICKY_SCREEN_FONT = 14; +const DEFAULT_STICKY_SCREEN_WIDTH = 220; +const DEFAULT_STICKY_SCREEN_HEIGHT = 66; + type CleanupFn = (() => void) | null; /** @@ -113,7 +117,7 @@ export function activateTool( name: '', visible: true, text: ' ', // placeholder — will be replaced by user input - fontSize: screenToWorld(opts.fontSize!, zoom, 10, 72), + fontSize: screenToWorld(DEFAULT_TEXT_SCREEN_FONT, zoom, 10, 72), fill: opts.color!, fontFamily: 'sans-serif', }; @@ -322,8 +326,8 @@ export function activateTool( const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top); const zoom = viewport.scale.x; - const cardW = screenToWorld(220, zoom, 120, 400); - const cardH = screenToWorld(66, zoom, 40, 200); + const cardW = screenToWorld(DEFAULT_STICKY_SCREEN_WIDTH, zoom, 120, 400); + const cardH = screenToWorld(DEFAULT_STICKY_SCREEN_HEIGHT, zoom, 40, 200); const stickyData = { id: crypto.randomUUID(), type: 'sticky' as const, @@ -340,7 +344,7 @@ export function activateTool( name: '', visible: true, text: '', - fontSize: screenToWorld(opts.fontSize!, zoom, 10, 48), + fontSize: screenToWorld(DEFAULT_STICKY_SCREEN_FONT, zoom, 10, 48), fontFamily: 'Inter, system-ui, sans-serif', fill: '#ffd43b', // default yellow textColor: '#1a1a1a', diff --git a/frontend/src/components/TextFormatToolbar.tsx b/frontend/src/components/TextFormatToolbar.tsx index 396f77e..52f3dc4 100644 --- a/frontend/src/components/TextFormatToolbar.tsx +++ b/frontend/src/components/TextFormatToolbar.tsx @@ -1,15 +1,21 @@ import React, { useState, useRef, useEffect } from 'react'; interface TextFormatToolbarProps { + kind: 'text' | 'sticky'; x: number; y: number; fontSize: number; fontFamily: string; + /** Text color (both text and sticky). */ fill: string; + /** Note background color (sticky only). */ + noteFill?: string; position?: 'above' | 'below'; onFontSizeChange: (size: number) => void; onFontFamilyChange: (family: string) => void; onFillChange: (color: string) => void; + /** Called when sticky note background color changes. */ + onNoteFillChange?: (color: string) => void; } const FONT_FAMILIES = [ @@ -29,14 +35,15 @@ const PRESET_COLORS = [ ]; export default function TextFormatToolbar(props: TextFormatToolbarProps) { - const { x, y, fontSize, fontFamily, fill, position = 'above', onFontSizeChange, onFontFamilyChange, onFillChange } = props; + const { kind, x, y, fontSize, fontFamily, fill, noteFill, position = 'above', onFontSizeChange, onFontFamilyChange, onFillChange, onNoteFillChange } = props; const [showFontMenu, setShowFontMenu] = useState(false); const [showColorPicker, setShowColorPicker] = useState(false); + const [showNoteFillPicker, setShowNoteFillPicker] = useState(false); const colorInputRef = useRef(null); // Close dropdowns on outside click useEffect(() => { - const onDown = () => { setShowFontMenu(false); setShowColorPicker(false); }; + const onDown = () => { setShowFontMenu(false); setShowColorPicker(false); setShowNoteFillPicker(false); }; window.addEventListener('pointerdown', onDown); return () => window.removeEventListener('pointerdown', onDown); }, []); @@ -174,11 +181,11 @@ export default function TextFormatToolbar(props: TextFormatToolbarProps) { {/* Divider */}
- {/* Color */} + {/* Text color */}
)}
+ + {/* Note fill color (sticky only) */} + {kind === 'sticky' && noteFill != null && onNoteFillChange && (<> +
+
+ + {showNoteFillPicker && ( +
e.stopPropagation()} + > +
+ {PRESET_COLORS.map((c) => ( +
+ { onNoteFillChange(e.target.value); }} + style={{ width: '100%', height: '24px', border: 'none', background: 'transparent', cursor: 'pointer' }} + /> +
+ )} +
+ )}
); } diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 7923f05..0c6f059 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -15,8 +15,6 @@ interface ToolbarProps { onColorChange: (color: string) => void; strokeWidth: number; onStrokeWidthChange: (width: number) => void; - fontSize: number; - onFontSizeChange: (size: number) => void; zoom: number; onFitAll: () => void; onZoomIn?: () => void; @@ -146,8 +144,6 @@ export default function Toolbar({ onColorChange, strokeWidth, onStrokeWidthChange, - fontSize, - onFontSizeChange, zoom, onFitAll, onZoomIn, @@ -169,7 +165,6 @@ export default function Toolbar({ reviewMode, }: ToolbarProps) { const showStroke = activeTool === ToolType.PEN; - const showFontSize = activeTool === ToolType.TEXT || activeTool === ToolType.STICKY; // Determine active hint const activeToolDef = toolButtons.find((t) => t.tool === activeTool); @@ -274,20 +269,6 @@ export default function Toolbar({ )} - {/* Font size (text) */} - {showFontSize && ( - <> - - Size - onFontSizeChange(Number(e.target.value))} - style={{ width: '60px', height: '3px', accentColor: '#4a9eff', cursor: 'pointer' }} - /> - {fontSize} - - )} - {/* Undo/Redo */} diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index a120a10..2157025 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -13,6 +13,7 @@ import { UploadManager } from '../stores/uploadManager'; import { AnnotationStore } from '../stores/annotationStore'; import { PinOverlay } from '../canvas/PinOverlay'; import { CropOverlay } from '../canvas/CropOverlay'; +import { TextSprite } from '../canvas/sprites/TextSprite'; // PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit import { connectSocket, disconnectSocket } from '../socket'; import api from '../api'; @@ -194,6 +195,26 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { onCanvasChange(itemIds); // broadcasts elements + saves + undo + spatial refresh }; selection.transformBox.onDragEnd = (itemIds) => { + // Bake scale into fontSize for plain text items after resize + for (const id of itemIds) { + const item = scene.getById(id); + if (!item || item.type !== 'text') continue; + const absSx = Math.abs(item.data.sx); + const absSy = Math.abs(item.data.sy); + if (absSx === 1 && absSy === 1) continue; + // Use average scale as font multiplier + const scale = (absSx + absSy) / 2; + const d = item.data as any; + d.fontSize = Math.round(d.fontSize * scale); + d.sx = item.data.sx > 0 ? 1 : -1; + d.sy = item.data.sy > 0 ? 1 : -1; + if (item.displayObject instanceof TextSprite) { + item.displayObject.updateFromData(d); + d.w = item.displayObject.measuredWidth; + d.h = item.displayObject.measuredHeight; + } + item.displayObject.scale.set(d.sx, d.sy); + } onCanvasChange(itemIds); }; diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 97ebff6..ec421a8 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -35,9 +35,10 @@ import { InboxZone } from '../canvas/InboxZone'; import { getItemWorldBounds } from '../canvas/SceneManager'; import { getPointAnchorWorld } from '../canvas/reviewAnchors'; import { resolveReviewTargetAtPoint } from '../canvas/reviewTargeting'; -import type { TextObject } from '../canvas/scene-format'; +import type { TextObject, StickyObject } from '../canvas/scene-format'; import { VideoSprite } from '../canvas/sprites/VideoSprite'; import { TextSprite } from '../canvas/sprites/TextSprite'; +import { StickySprite } from '../canvas/sprites/StickySprite'; import * as ops from '../canvas/operations'; // Hooks @@ -91,7 +92,6 @@ export default function Editor({ isPublicView }: EditorProps) { const [activeTool, setActiveTool] = useState(ToolType.SELECT); const [color, setColor] = useState('#ffffff'); const [strokeWidth, setStrokeWidth] = useState(4); - const [fontSize, setFontSize] = useState(24); const [zoom, setZoom] = useState(1); const [objectCount, setObjectCount] = useState(0); const [saveStatus, setSaveStatus] = useState('saved'); @@ -121,7 +121,11 @@ 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 [textToolbar, setTextToolbar] = useState<{ x: number; y: number; fontSize: number; fontFamily: string; fill: string; items: SceneItem[] } | null>(null); + const [textToolbar, setTextToolbar] = useState< + | { kind: 'text'; x: number; y: number; fontSize: number; fontFamily: string; fill: string; items: SceneItem[] } + | { kind: 'sticky'; x: number; y: number; fontSize: number; fontFamily: string; textColor: string; fill: string; items: SceneItem[] } + | null + >(null); const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null); const [showMinimap, setShowMinimap] = useState(true); const [minimapData, setMinimapData] = useState<{ items: any[]; viewportBounds: any; contentBounds: any }>({ @@ -436,8 +440,8 @@ export default function Editor({ isPublicView }: EditorProps) { }; // reviewMode is read at click time via getter so it stays current Object.defineProperty(ctx, 'reviewMode', { get: () => reviewModeRef.current }); - toolCleanupRef.current = activateTool(ctx, activeTool, { color, strokeWidth, fontSize }); - }, [activeTool, color, strokeWidth, fontSize, onCanvasChange, textEditor]); + toolCleanupRef.current = activateTool(ctx, activeTool, { color, strokeWidth }); + }, [activeTool, color, strokeWidth, onCanvasChange, textEditor]); // Layer panel const { refreshLayers: refreshLayerData, layerHandlers } = useLayerPanel({ canvasRef, selectionRef, onCanvasChange }); @@ -587,13 +591,16 @@ export default function Editor({ isPublicView }: EditorProps) { setVideoCtrl(null); } - // Text format toolbar: show when all selected items are text (1 or more) + // Contextual format toolbar: show when all selected items are the same text-like type const textItems = items.filter((it) => it.type === 'text'); - if (textItems.length > 0 && textItems.length === items.length) { - // Use first item's properties as representative - const td = textItems[0].data as TextObject; + const stickyItems = items.filter((it) => it.type === 'sticky'); + const formatItems = textItems.length === items.length ? textItems + : stickyItems.length === items.length ? stickyItems + : null; // mixed or non-text selection → hide + + if (formatItems && formatItems.length > 0) { let minX2 = Infinity, minY2 = Infinity, maxX2 = -Infinity, maxY2 = -Infinity; - for (const item of textItems) { + for (const item of formatItems) { const b = getItemWorldBounds(item); if (b.x < minX2) minX2 = b.x; if (b.y < minY2) minY2 = b.y; @@ -604,16 +611,26 @@ export default function Editor({ isPublicView }: EditorProps) { const screenTR2 = vp.toScreen(maxX2, minY2); const screenBL2 = vp.toScreen(minX2, maxY2); const screenBR2 = vp.toScreen(maxX2, maxY2); - // Single text: show above; multiple: show below (selection toolbar is above) - const useBottom = textItems.length >= 2; - setTextToolbar({ - x: useBottom ? (screenBL2.x + screenBR2.x) / 2 : (screenTL2.x + screenTR2.x) / 2, - y: useBottom ? (screenBL2.y + screenBR2.y) / 2 + 8 : screenTL2.y, - fontSize: td.fontSize, - fontFamily: td.fontFamily, - fill: td.fill, - items: textItems, - }); + const useBottom = formatItems.length >= 2; + const posX = useBottom ? (screenBL2.x + screenBR2.x) / 2 : (screenTL2.x + screenTR2.x) / 2; + const posY = useBottom ? (screenBL2.y + screenBR2.y) / 2 + 8 : screenTL2.y; + + if (formatItems === textItems) { + const td = textItems[0].data as TextObject; + setTextToolbar({ + kind: 'text', x: posX, y: posY, + fontSize: td.fontSize, fontFamily: td.fontFamily, fill: td.fill, + items: textItems, + }); + } else { + const sd = stickyItems[0].data as StickyObject; + setTextToolbar({ + kind: 'sticky', x: posX, y: posY, + fontSize: sd.fontSize || 14, fontFamily: sd.fontFamily || 'Inter, system-ui, sans-serif', + textColor: sd.textColor || '#1a1a1a', fill: sd.fill || '#ffd43b', + items: stickyItems, + }); + } } else { setTextToolbar(null); } @@ -767,8 +784,6 @@ export default function Editor({ isPublicView }: EditorProps) { onColorChange={setColor} strokeWidth={strokeWidth} onStrokeWidthChange={setStrokeWidth} - fontSize={fontSize} - onFontSizeChange={setFontSize} zoom={zoom} onFitAll={() => canvasRef.current?.fitAll()} canUndo={canUndo} @@ -891,24 +906,35 @@ export default function Editor({ isPublicView }: EditorProps) { /> )} - {/* Text format toolbar (floating, above selected text items) */} + {/* Contextual format toolbar (floating, above selected text/sticky items) */} {textToolbar && activeTool === ToolType.SELECT && !contextMenu && ( = 2 ? 'below' : 'above'} onFontSizeChange={(size) => { const scene = canvasRef.current?.getScene(); for (const item of textToolbar.items) { - const d = item.data as TextObject; - d.fontSize = size; - if (item.displayObject instanceof TextSprite) { - item.displayObject.updateFromData(d); - d.w = item.displayObject.measuredWidth; - d.h = item.displayObject.measuredHeight; + if (textToolbar.kind === 'text') { + const d = item.data as TextObject; + d.fontSize = size; + if (item.displayObject instanceof TextSprite) { + item.displayObject.updateFromData(d); + d.w = item.displayObject.measuredWidth; + d.h = item.displayObject.measuredHeight; + } + } else { + const d = item.data as StickyObject; + d.fontSize = size; + if (item.displayObject instanceof StickySprite) { + item.displayObject.updateFromData(d); + d.h = item.displayObject.computedHeight; + } } if (scene) scene.updateSpatialEntry(item); } @@ -919,12 +945,21 @@ export default function Editor({ isPublicView }: EditorProps) { onFontFamilyChange={(family) => { const scene = canvasRef.current?.getScene(); for (const item of textToolbar.items) { - const d = item.data as TextObject; - d.fontFamily = family; - if (item.displayObject instanceof TextSprite) { - item.displayObject.updateFromData(d); - d.w = item.displayObject.measuredWidth; - d.h = item.displayObject.measuredHeight; + if (textToolbar.kind === 'text') { + const d = item.data as TextObject; + d.fontFamily = family; + if (item.displayObject instanceof TextSprite) { + item.displayObject.updateFromData(d); + d.w = item.displayObject.measuredWidth; + d.h = item.displayObject.measuredHeight; + } + } else { + const d = item.data as StickyObject; + d.fontFamily = family; + if (item.displayObject instanceof StickySprite) { + item.displayObject.updateFromData(d); + d.h = item.displayObject.computedHeight; + } } if (scene) scene.updateSpatialEntry(item); } @@ -932,17 +967,36 @@ export default function Editor({ isPublicView }: EditorProps) { onCanvasChange(textToolbar.items.map(i => i.id)); updateOverlays(); }} - onFillChange={(color) => { + onFillChange={(textColor) => { for (const item of textToolbar.items) { - const d = item.data as TextObject; - d.fill = color; - if (item.displayObject instanceof TextSprite) { - item.displayObject.updateFromData(d); + if (textToolbar.kind === 'text') { + const d = item.data as TextObject; + d.fill = textColor; + if (item.displayObject instanceof TextSprite) { + item.displayObject.updateFromData(d); + } + } else { + const d = item.data as StickyObject; + d.textColor = textColor; + if (item.displayObject instanceof StickySprite) { + item.displayObject.updateFromData(d); + } } } onCanvasChange(textToolbar.items.map(i => i.id)); updateOverlays(); }} + onNoteFillChange={textToolbar.kind === 'sticky' ? (fillColor) => { + for (const item of textToolbar.items) { + const d = item.data as StickyObject; + d.fill = fillColor; + if (item.displayObject instanceof StickySprite) { + item.displayObject.updateFromData(d); + } + } + onCanvasChange(textToolbar.items.map(i => i.id)); + updateOverlays(); + } : undefined} /> )}