From 4ccdbed711f2f947ec0706e7e19ff5bffc5e3ffb Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 29 Mar 2026 03:04:29 +0200 Subject: [PATCH] feat: add label position (inside/outside) and text size to Zone modal - Label position toggle: inside (default) or outside the border - Outside mode renders the label above/below the zone based on text_position - Text size selector: 10/12/14/16/18/20px (default 12) - Both fields persisted in custom_colors (no backend schema change needed) - 8 new frontend tests, 1 new backend test --- backend/tests/test_canvas.py | 19 +++++ frontend/src/App.tsx | 6 ++ .../components/canvas/nodes/GroupRectNode.tsx | 47 +++++++++-- .../src/components/modals/GroupRectModal.tsx | 71 +++++++++++++++++ .../modals/__tests__/GroupRectModal.test.tsx | 78 +++++++++++++++++++ 5 files changed, 215 insertions(+), 6 deletions(-) diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 8187e06..b4c6633 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -104,6 +104,25 @@ async def test_save_canvas_persists_custom_colors(client: AsyncClient, headers: assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"} +async def test_save_canvas_persists_zone_label_position_and_text_size(client: AsyncClient, headers: dict): + """label_position and text_size are stored in custom_colors and returned unchanged.""" + n1 = node_payload(custom_colors={ + "border": "#00d4ff", + "border_style": "solid", + "border_width": 3, + "label_position": "outside", + "text_size": 16, + "text_color": "#e6edf3", + }) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + cc = canvas["nodes"][0]["custom_colors"] + assert cc["label_position"] == "outside" + assert cc["text_size"] == 16 + assert cc["border_width"] == 3 + + async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict): n1 = node_payload() n2 = node_payload() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6e58be9..b0da8db 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -186,6 +186,8 @@ export default function App() { background: data.background_color, text_color: data.text_color, text_position: data.text_position, + text_size: data.text_size, + label_position: data.label_position, font: data.font, z_order: data.z_order, }, @@ -211,6 +213,8 @@ export default function App() { background: data.background_color, text_color: data.text_color, text_position: data.text_position, + text_size: data.text_size, + label_position: data.label_position, font: data.font, z_order: data.z_order, }, @@ -462,6 +466,8 @@ export default function App() { border_style: rc.border_style ?? 'solid', border_width: rc.border_width ?? 2, background_color: rc.background ?? '#00d4ff0d', + text_size: rc.text_size ?? 12, + label_position: rc.label_position ?? 'inside', z_order: rc.z_order ?? 1, } })()} diff --git a/frontend/src/components/canvas/nodes/GroupRectNode.tsx b/frontend/src/components/canvas/nodes/GroupRectNode.tsx index 45f2879..999eb95 100644 --- a/frontend/src/components/canvas/nodes/GroupRectNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupRectNode.tsx @@ -35,10 +35,31 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) const borderWidth = rc.border_width ?? 2 const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)' const textColor = rc.text_color ?? '#e6edf3' + const textSize: number = rc.text_size ?? 12 + const labelPosition: string = rc.label_position ?? 'inside' const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter const textPos = (rc.text_position ?? 'top-left') as TextPosition const posStyle = POSITION_STYLES[textPos] + const outsideJustify = textPos.includes('right') ? 'flex-end' + : (textPos.includes('center') || textPos === 'center') ? 'center' + : 'flex-start' + + const isOutsideBottom = textPos.startsWith('bottom') + const outsideOffset = textSize + 16 + const outsideVertical: React.CSSProperties = isOutsideBottom + ? { bottom: -outsideOffset } + : { top: -outsideOffset } + + const sharedTextStyle: React.CSSProperties = { + color: textColor, + fontFamily, + fontSize: textSize, + fontWeight: 500, + userSelect: 'none', + whiteSpace: 'pre-wrap', + } + return ( <> >) />
>) background: backgroundColor, border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`, borderRadius: 10, - fontFamily, - color: textColor, - fontSize: 12, - fontWeight: 500, boxSizing: 'border-box', cursor: 'default', }} @@ -77,8 +96,24 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) setEditingGroupRectId(id) }} > - {data.label && ( - + {labelPosition === 'outside' && data.label && ( + + {data.label} + + )} + {labelPosition === 'inside' && data.label && ( + {data.label} )} diff --git a/frontend/src/components/modals/GroupRectModal.tsx b/frontend/src/components/modals/GroupRectModal.tsx index b437b67..b9a4027 100644 --- a/frontend/src/components/modals/GroupRectModal.tsx +++ b/frontend/src/components/modals/GroupRectModal.tsx @@ -8,11 +8,15 @@ import type { TextPosition } from '@/types' export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none' +export type LabelPosition = 'inside' | 'outside' + export interface GroupRectFormData { label: string font: string text_color: string text_position: TextPosition + text_size: number + label_position: LabelPosition border_color: string border_style: BorderStyle border_width: number @@ -28,6 +32,20 @@ const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] = { value: 'none', label: 'None', preview: ' ' }, ] +const TEXT_SIZES: { value: number; label: string }[] = [ + { value: 10, label: '10' }, + { value: 12, label: '12' }, + { value: 14, label: '14' }, + { value: 16, label: '16' }, + { value: 18, label: '18' }, + { value: 20, label: '20' }, +] + +const LABEL_POSITIONS: { value: LabelPosition; label: string }[] = [ + { value: 'inside', label: 'Inside' }, + { value: 'outside', label: 'Outside' }, +] + const BORDER_WIDTHS: { value: number; label: string }[] = [ { value: 1, label: '1px' }, { value: 2, label: '2px' }, @@ -41,6 +59,8 @@ const DEFAULT_FORM: GroupRectFormData = { font: 'inter', text_color: '#e6edf3', text_position: 'top-left', + text_size: 12, + label_position: 'inside', border_color: '#00d4ff', border_style: 'solid', border_width: 2, @@ -155,6 +175,31 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
+ {/* Label position */} +
+ +
+ {LABEL_POSITIONS.map(({ value, label }) => { + const isSelected = form.label_position === value + return ( + + ) + })} +
+
+ {/* Colors */}
@@ -179,6 +224,32 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
+ {/* Text size */} +
+ +
+ {TEXT_SIZES.map(({ value, label }) => { + const isSelected = form.text_size === value + return ( + + ) + })} +
+
+ {/* Border style */}
diff --git a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx index 40f1528..7c7233c 100644 --- a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx +++ b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx @@ -124,6 +124,84 @@ describe('GroupRectModal', () => { expect(submitted.border_style).toBe('dotted') }) + it('renders Label Position section with inside/outside options', () => { + render() + expect(screen.getByText('Label Position')).toBeDefined() + expect(screen.getByText('Inside')).toBeDefined() + expect(screen.getByText('Outside')).toBeDefined() + }) + + it('defaults label_position to inside', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.label_position).toBe('inside') + }) + + it('selects outside label position on click', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Outside')) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.label_position).toBe('outside') + }) + + it('pre-fills label_position from initial prop', () => { + const onSubmit = vi.fn() + render( + + ) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.label_position).toBe('outside') + }) + + it('renders Text Size section with 6 options', () => { + render() + expect(screen.getByText('Text Size')).toBeDefined() + expect(screen.getByText('10')).toBeDefined() + expect(screen.getByText('20')).toBeDefined() + }) + + it('defaults text_size to 12', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.text_size).toBe(12) + }) + + it('selects text size on click', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('18')) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.text_size).toBe(18) + }) + + it('pre-fills text_size from initial prop', () => { + const onSubmit = vi.fn() + render( + + ) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.text_size).toBe(16) + }) + it('renders Border Width section with 5 options', () => { render() expect(screen.getByText('Border Width')).toBeDefined()