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)
This commit is contained in:
@@ -26,15 +26,19 @@ export enum ToolType {
|
|||||||
export interface ToolOptions {
|
export interface ToolOptions {
|
||||||
color?: string;
|
color?: string;
|
||||||
strokeWidth?: number;
|
strokeWidth?: number;
|
||||||
fontSize?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOptions: ToolOptions = {
|
const defaultOptions: ToolOptions = {
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
strokeWidth: 4,
|
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;
|
type CleanupFn = (() => void) | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,7 +117,7 @@ export function activateTool(
|
|||||||
name: '',
|
name: '',
|
||||||
visible: true,
|
visible: true,
|
||||||
text: ' ', // placeholder — will be replaced by user input
|
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!,
|
fill: opts.color!,
|
||||||
fontFamily: 'sans-serif',
|
fontFamily: 'sans-serif',
|
||||||
};
|
};
|
||||||
@@ -322,8 +326,8 @@ export function activateTool(
|
|||||||
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
|
|
||||||
const zoom = viewport.scale.x;
|
const zoom = viewport.scale.x;
|
||||||
const cardW = screenToWorld(220, zoom, 120, 400);
|
const cardW = screenToWorld(DEFAULT_STICKY_SCREEN_WIDTH, zoom, 120, 400);
|
||||||
const cardH = screenToWorld(66, zoom, 40, 200);
|
const cardH = screenToWorld(DEFAULT_STICKY_SCREEN_HEIGHT, zoom, 40, 200);
|
||||||
const stickyData = {
|
const stickyData = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: 'sticky' as const,
|
type: 'sticky' as const,
|
||||||
@@ -340,7 +344,7 @@ export function activateTool(
|
|||||||
name: '',
|
name: '',
|
||||||
visible: true,
|
visible: true,
|
||||||
text: '',
|
text: '',
|
||||||
fontSize: screenToWorld(opts.fontSize!, zoom, 10, 48),
|
fontSize: screenToWorld(DEFAULT_STICKY_SCREEN_FONT, zoom, 10, 48),
|
||||||
fontFamily: 'Inter, system-ui, sans-serif',
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
fill: '#ffd43b', // default yellow
|
fill: '#ffd43b', // default yellow
|
||||||
textColor: '#1a1a1a',
|
textColor: '#1a1a1a',
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
interface TextFormatToolbarProps {
|
interface TextFormatToolbarProps {
|
||||||
|
kind: 'text' | 'sticky';
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
|
/** Text color (both text and sticky). */
|
||||||
fill: string;
|
fill: string;
|
||||||
|
/** Note background color (sticky only). */
|
||||||
|
noteFill?: string;
|
||||||
position?: 'above' | 'below';
|
position?: 'above' | 'below';
|
||||||
onFontSizeChange: (size: number) => void;
|
onFontSizeChange: (size: number) => void;
|
||||||
onFontFamilyChange: (family: string) => void;
|
onFontFamilyChange: (family: string) => void;
|
||||||
onFillChange: (color: string) => void;
|
onFillChange: (color: string) => void;
|
||||||
|
/** Called when sticky note background color changes. */
|
||||||
|
onNoteFillChange?: (color: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FONT_FAMILIES = [
|
const FONT_FAMILIES = [
|
||||||
@@ -29,14 +35,15 @@ const PRESET_COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function TextFormatToolbar(props: TextFormatToolbarProps) {
|
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 [showFontMenu, setShowFontMenu] = useState(false);
|
||||||
const [showColorPicker, setShowColorPicker] = useState(false);
|
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||||
|
const [showNoteFillPicker, setShowNoteFillPicker] = useState(false);
|
||||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Close dropdowns on outside click
|
// Close dropdowns on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onDown = () => { setShowFontMenu(false); setShowColorPicker(false); };
|
const onDown = () => { setShowFontMenu(false); setShowColorPicker(false); setShowNoteFillPicker(false); };
|
||||||
window.addEventListener('pointerdown', onDown);
|
window.addEventListener('pointerdown', onDown);
|
||||||
return () => window.removeEventListener('pointerdown', onDown);
|
return () => window.removeEventListener('pointerdown', onDown);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -174,11 +181,11 @@ export default function TextFormatToolbar(props: TextFormatToolbarProps) {
|
|||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div style={{ width: '1px', height: '18px', background: '#333', margin: '0 2px', flexShrink: 0 }} />
|
<div style={{ width: '1px', height: '18px', background: '#333', margin: '0 2px', flexShrink: 0 }} />
|
||||||
|
|
||||||
{/* Color */}
|
{/* Text color */}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
style={{ ...btnStyle, width: '26px', padding: 0 }}
|
style={{ ...btnStyle, width: '26px', padding: 0 }}
|
||||||
onClick={(e) => { e.stopPropagation(); setShowColorPicker((v) => !v); setShowFontMenu(false); }}
|
onClick={(e) => { e.stopPropagation(); setShowColorPicker((v) => !v); setShowFontMenu(false); setShowNoteFillPicker(false); }}
|
||||||
title="Text color"
|
title="Text color"
|
||||||
{...hoverHandlers}
|
{...hoverHandlers}
|
||||||
>
|
>
|
||||||
@@ -227,6 +234,62 @@ export default function TextFormatToolbar(props: TextFormatToolbarProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Note fill color (sticky only) */}
|
||||||
|
{kind === 'sticky' && noteFill != null && onNoteFillChange && (<>
|
||||||
|
<div style={{ width: '1px', height: '18px', background: '#333', margin: '0 2px', flexShrink: 0 }} />
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
style={{ ...btnStyle, width: '26px', padding: 0 }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowNoteFillPicker((v) => !v); setShowFontMenu(false); setShowColorPicker(false); }}
|
||||||
|
title="Note color"
|
||||||
|
{...hoverHandlers}
|
||||||
|
>
|
||||||
|
<div style={{ width: '14px', height: '14px', borderRadius: '6px', background: noteFill, border: '1px solid #555' }} />
|
||||||
|
</button>
|
||||||
|
{showNoteFillPicker && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
right: 0,
|
||||||
|
marginTop: '4px',
|
||||||
|
background: 'rgba(22, 22, 22, 0.98)',
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
zIndex: 200,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '4px', marginBottom: '8px' }}>
|
||||||
|
{PRESET_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => { onNoteFillChange(c); setShowNoteFillPicker(false); }}
|
||||||
|
style={{
|
||||||
|
width: '22px',
|
||||||
|
height: '22px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: c,
|
||||||
|
border: c === noteFill ? '2px solid #4a90d9' : '1px solid #444',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={noteFill}
|
||||||
|
onChange={(e) => { onNoteFillChange(e.target.value); }}
|
||||||
|
style={{ width: '100%', height: '24px', border: 'none', background: 'transparent', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ interface ToolbarProps {
|
|||||||
onColorChange: (color: string) => void;
|
onColorChange: (color: string) => void;
|
||||||
strokeWidth: number;
|
strokeWidth: number;
|
||||||
onStrokeWidthChange: (width: number) => void;
|
onStrokeWidthChange: (width: number) => void;
|
||||||
fontSize: number;
|
|
||||||
onFontSizeChange: (size: number) => void;
|
|
||||||
zoom: number;
|
zoom: number;
|
||||||
onFitAll: () => void;
|
onFitAll: () => void;
|
||||||
onZoomIn?: () => void;
|
onZoomIn?: () => void;
|
||||||
@@ -146,8 +144,6 @@ export default function Toolbar({
|
|||||||
onColorChange,
|
onColorChange,
|
||||||
strokeWidth,
|
strokeWidth,
|
||||||
onStrokeWidthChange,
|
onStrokeWidthChange,
|
||||||
fontSize,
|
|
||||||
onFontSizeChange,
|
|
||||||
zoom,
|
zoom,
|
||||||
onFitAll,
|
onFitAll,
|
||||||
onZoomIn,
|
onZoomIn,
|
||||||
@@ -169,7 +165,6 @@ export default function Toolbar({
|
|||||||
reviewMode,
|
reviewMode,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
const showStroke = activeTool === ToolType.PEN;
|
const showStroke = activeTool === ToolType.PEN;
|
||||||
const showFontSize = activeTool === ToolType.TEXT || activeTool === ToolType.STICKY;
|
|
||||||
|
|
||||||
// Determine active hint
|
// Determine active hint
|
||||||
const activeToolDef = toolButtons.find((t) => t.tool === activeTool);
|
const activeToolDef = toolButtons.find((t) => t.tool === activeTool);
|
||||||
@@ -274,20 +269,6 @@ export default function Toolbar({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Font size (text) */}
|
|
||||||
{showFontSize && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<span style={{ fontSize: '10px', color: '#555', marginLeft: '4px' }}>Size</span>
|
|
||||||
<input
|
|
||||||
type="range" min={12} max={72} value={fontSize}
|
|
||||||
onChange={(e) => onFontSizeChange(Number(e.target.value))}
|
|
||||||
style={{ width: '60px', height: '3px', accentColor: '#4a9eff', cursor: 'pointer' }}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: '10px', color: '#666', minWidth: '18px', textAlign: 'center' }}>{fontSize}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Undo/Redo */}
|
{/* Undo/Redo */}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { UploadManager } from '../stores/uploadManager';
|
|||||||
import { AnnotationStore } from '../stores/annotationStore';
|
import { AnnotationStore } from '../stores/annotationStore';
|
||||||
import { PinOverlay } from '../canvas/PinOverlay';
|
import { PinOverlay } from '../canvas/PinOverlay';
|
||||||
import { CropOverlay } from '../canvas/CropOverlay';
|
import { CropOverlay } from '../canvas/CropOverlay';
|
||||||
|
import { TextSprite } from '../canvas/sprites/TextSprite';
|
||||||
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
|
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
|
||||||
import { connectSocket, disconnectSocket } from '../socket';
|
import { connectSocket, disconnectSocket } from '../socket';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
@@ -194,6 +195,26 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
onCanvasChange(itemIds); // broadcasts elements + saves + undo + spatial refresh
|
onCanvasChange(itemIds); // broadcasts elements + saves + undo + spatial refresh
|
||||||
};
|
};
|
||||||
selection.transformBox.onDragEnd = (itemIds) => {
|
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);
|
onCanvasChange(itemIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ import { InboxZone } from '../canvas/InboxZone';
|
|||||||
import { getItemWorldBounds } from '../canvas/SceneManager';
|
import { getItemWorldBounds } from '../canvas/SceneManager';
|
||||||
import { getPointAnchorWorld } from '../canvas/reviewAnchors';
|
import { getPointAnchorWorld } from '../canvas/reviewAnchors';
|
||||||
import { resolveReviewTargetAtPoint } from '../canvas/reviewTargeting';
|
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 { VideoSprite } from '../canvas/sprites/VideoSprite';
|
||||||
import { TextSprite } from '../canvas/sprites/TextSprite';
|
import { TextSprite } from '../canvas/sprites/TextSprite';
|
||||||
|
import { StickySprite } from '../canvas/sprites/StickySprite';
|
||||||
import * as ops from '../canvas/operations';
|
import * as ops from '../canvas/operations';
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
@@ -91,7 +92,6 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const [activeTool, setActiveTool] = useState<ToolType>(ToolType.SELECT);
|
const [activeTool, setActiveTool] = useState<ToolType>(ToolType.SELECT);
|
||||||
const [color, setColor] = useState('#ffffff');
|
const [color, setColor] = useState('#ffffff');
|
||||||
const [strokeWidth, setStrokeWidth] = useState(4);
|
const [strokeWidth, setStrokeWidth] = useState(4);
|
||||||
const [fontSize, setFontSize] = useState(24);
|
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [objectCount, setObjectCount] = useState(0);
|
const [objectCount, setObjectCount] = useState(0);
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('saved');
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('saved');
|
||||||
@@ -121,7 +121,11 @@ 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 [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 [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null);
|
||||||
const [showMinimap, setShowMinimap] = useState(true);
|
const [showMinimap, setShowMinimap] = useState(true);
|
||||||
const [minimapData, setMinimapData] = useState<{ items: any[]; viewportBounds: any; contentBounds: any }>({
|
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
|
// reviewMode is read at click time via getter so it stays current
|
||||||
Object.defineProperty(ctx, 'reviewMode', { get: () => reviewModeRef.current });
|
Object.defineProperty(ctx, 'reviewMode', { get: () => reviewModeRef.current });
|
||||||
toolCleanupRef.current = activateTool(ctx, activeTool, { color, strokeWidth, fontSize });
|
toolCleanupRef.current = activateTool(ctx, activeTool, { color, strokeWidth });
|
||||||
}, [activeTool, color, strokeWidth, fontSize, onCanvasChange, textEditor]);
|
}, [activeTool, color, strokeWidth, onCanvasChange, textEditor]);
|
||||||
|
|
||||||
// Layer panel
|
// Layer panel
|
||||||
const { refreshLayers: refreshLayerData, layerHandlers } = useLayerPanel({ canvasRef, selectionRef, onCanvasChange });
|
const { refreshLayers: refreshLayerData, layerHandlers } = useLayerPanel({ canvasRef, selectionRef, onCanvasChange });
|
||||||
@@ -587,13 +591,16 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
setVideoCtrl(null);
|
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');
|
const textItems = items.filter((it) => it.type === 'text');
|
||||||
if (textItems.length > 0 && textItems.length === items.length) {
|
const stickyItems = items.filter((it) => it.type === 'sticky');
|
||||||
// Use first item's properties as representative
|
const formatItems = textItems.length === items.length ? textItems
|
||||||
const td = textItems[0].data as TextObject;
|
: 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;
|
let minX2 = Infinity, minY2 = Infinity, maxX2 = -Infinity, maxY2 = -Infinity;
|
||||||
for (const item of textItems) {
|
for (const item of formatItems) {
|
||||||
const b = getItemWorldBounds(item);
|
const b = getItemWorldBounds(item);
|
||||||
if (b.x < minX2) minX2 = b.x;
|
if (b.x < minX2) minX2 = b.x;
|
||||||
if (b.y < minY2) minY2 = b.y;
|
if (b.y < minY2) minY2 = b.y;
|
||||||
@@ -604,16 +611,26 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const screenTR2 = vp.toScreen(maxX2, minY2);
|
const screenTR2 = vp.toScreen(maxX2, minY2);
|
||||||
const screenBL2 = vp.toScreen(minX2, maxY2);
|
const screenBL2 = vp.toScreen(minX2, maxY2);
|
||||||
const screenBR2 = vp.toScreen(maxX2, maxY2);
|
const screenBR2 = vp.toScreen(maxX2, maxY2);
|
||||||
// Single text: show above; multiple: show below (selection toolbar is above)
|
const useBottom = formatItems.length >= 2;
|
||||||
const useBottom = textItems.length >= 2;
|
const posX = useBottom ? (screenBL2.x + screenBR2.x) / 2 : (screenTL2.x + screenTR2.x) / 2;
|
||||||
setTextToolbar({
|
const posY = useBottom ? (screenBL2.y + screenBR2.y) / 2 + 8 : screenTL2.y;
|
||||||
x: useBottom ? (screenBL2.x + screenBR2.x) / 2 : (screenTL2.x + screenTR2.x) / 2,
|
|
||||||
y: useBottom ? (screenBL2.y + screenBR2.y) / 2 + 8 : screenTL2.y,
|
if (formatItems === textItems) {
|
||||||
fontSize: td.fontSize,
|
const td = textItems[0].data as TextObject;
|
||||||
fontFamily: td.fontFamily,
|
setTextToolbar({
|
||||||
fill: td.fill,
|
kind: 'text', x: posX, y: posY,
|
||||||
items: textItems,
|
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 {
|
} else {
|
||||||
setTextToolbar(null);
|
setTextToolbar(null);
|
||||||
}
|
}
|
||||||
@@ -767,8 +784,6 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
onColorChange={setColor}
|
onColorChange={setColor}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
onStrokeWidthChange={setStrokeWidth}
|
onStrokeWidthChange={setStrokeWidth}
|
||||||
fontSize={fontSize}
|
|
||||||
onFontSizeChange={setFontSize}
|
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
onFitAll={() => canvasRef.current?.fitAll()}
|
onFitAll={() => canvasRef.current?.fitAll()}
|
||||||
canUndo={canUndo}
|
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 && (
|
{textToolbar && activeTool === ToolType.SELECT && !contextMenu && (
|
||||||
<TextFormatToolbar
|
<TextFormatToolbar
|
||||||
|
kind={textToolbar.kind}
|
||||||
x={textToolbar.x}
|
x={textToolbar.x}
|
||||||
y={textToolbar.y}
|
y={textToolbar.y}
|
||||||
fontSize={textToolbar.fontSize}
|
fontSize={textToolbar.fontSize}
|
||||||
fontFamily={textToolbar.fontFamily}
|
fontFamily={textToolbar.fontFamily}
|
||||||
fill={textToolbar.fill}
|
fill={textToolbar.kind === 'sticky' ? textToolbar.textColor : textToolbar.fill}
|
||||||
|
noteFill={textToolbar.kind === 'sticky' ? textToolbar.fill : undefined}
|
||||||
position={textToolbar.items.length >= 2 ? 'below' : 'above'}
|
position={textToolbar.items.length >= 2 ? 'below' : 'above'}
|
||||||
onFontSizeChange={(size) => {
|
onFontSizeChange={(size) => {
|
||||||
const scene = canvasRef.current?.getScene();
|
const scene = canvasRef.current?.getScene();
|
||||||
for (const item of textToolbar.items) {
|
for (const item of textToolbar.items) {
|
||||||
const d = item.data as TextObject;
|
if (textToolbar.kind === 'text') {
|
||||||
d.fontSize = size;
|
const d = item.data as TextObject;
|
||||||
if (item.displayObject instanceof TextSprite) {
|
d.fontSize = size;
|
||||||
item.displayObject.updateFromData(d);
|
if (item.displayObject instanceof TextSprite) {
|
||||||
d.w = item.displayObject.measuredWidth;
|
item.displayObject.updateFromData(d);
|
||||||
d.h = item.displayObject.measuredHeight;
|
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);
|
if (scene) scene.updateSpatialEntry(item);
|
||||||
}
|
}
|
||||||
@@ -919,12 +945,21 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
onFontFamilyChange={(family) => {
|
onFontFamilyChange={(family) => {
|
||||||
const scene = canvasRef.current?.getScene();
|
const scene = canvasRef.current?.getScene();
|
||||||
for (const item of textToolbar.items) {
|
for (const item of textToolbar.items) {
|
||||||
const d = item.data as TextObject;
|
if (textToolbar.kind === 'text') {
|
||||||
d.fontFamily = family;
|
const d = item.data as TextObject;
|
||||||
if (item.displayObject instanceof TextSprite) {
|
d.fontFamily = family;
|
||||||
item.displayObject.updateFromData(d);
|
if (item.displayObject instanceof TextSprite) {
|
||||||
d.w = item.displayObject.measuredWidth;
|
item.displayObject.updateFromData(d);
|
||||||
d.h = item.displayObject.measuredHeight;
|
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);
|
if (scene) scene.updateSpatialEntry(item);
|
||||||
}
|
}
|
||||||
@@ -932,17 +967,36 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
onCanvasChange(textToolbar.items.map(i => i.id));
|
onCanvasChange(textToolbar.items.map(i => i.id));
|
||||||
updateOverlays();
|
updateOverlays();
|
||||||
}}
|
}}
|
||||||
onFillChange={(color) => {
|
onFillChange={(textColor) => {
|
||||||
for (const item of textToolbar.items) {
|
for (const item of textToolbar.items) {
|
||||||
const d = item.data as TextObject;
|
if (textToolbar.kind === 'text') {
|
||||||
d.fill = color;
|
const d = item.data as TextObject;
|
||||||
if (item.displayObject instanceof TextSprite) {
|
d.fill = textColor;
|
||||||
item.displayObject.updateFromData(d);
|
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));
|
onCanvasChange(textToolbar.items.map(i => i.id));
|
||||||
updateOverlays();
|
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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user