feat(panels): split text Layout and Typography into separate sections

- Add readOnly prop to NumberInput for non-editable dimension display
- Extract text Layout section (dimensions, fill/hug, resizing toggles)
  into text-layout-section.tsx
- Enhance Typography section with line height, letter spacing, vertical
  alignment controls
- Reorder property panel: Size → Layout → Appearance → Fill → Stroke →
  Typography → Effects
- Add hideWH prop to SizeSection to avoid duplicate W/H inputs
This commit is contained in:
Fini 2026-02-22 08:17:12 +08:00
parent 154b4a57a4
commit 8df0b27d2b
5 changed files with 411 additions and 50 deletions

View file

@ -12,6 +12,7 @@ import FillSection from './fill-section'
import StrokeSection from './stroke-section'
import AppearanceSection from './appearance-section'
import TextSection from './text-section'
import TextLayoutSection from './text-layout-section'
import EffectsSection from './effects-section'
import ExportSection from './export-section'
@ -255,6 +256,7 @@ export default function PropertyPanel() {
cornerRadius={
'cornerRadius' in displayNode ? displayNode.cornerRadius : undefined
}
hideWH={hasLayout || isText}
/>
</div>
@ -267,6 +269,21 @@ export default function PropertyPanel() {
</>
)}
{isText && displayNode.type === 'text' && (
<>
<Separator />
<div className="px-3 py-2">
<TextLayoutSection node={displayNode} onUpdate={handleUpdate} />
</div>
</>
)}
<Separator />
<div className="px-3 py-2">
<AppearanceSection node={displayNode} onUpdate={handleUpdate} />
</div>
<Separator />
{hasFill && (
@ -293,9 +310,11 @@ export default function PropertyPanel() {
</>
)}
<div className="px-3 py-2">
<AppearanceSection node={displayNode} onUpdate={handleUpdate} />
</div>
{isText && displayNode.type === 'text' && (
<div className="px-3 py-2">
<TextSection node={displayNode} onUpdate={handleUpdate} />
</div>
)}
{hasEffects && (
<>
@ -309,15 +328,6 @@ export default function PropertyPanel() {
</>
)}
{isText && displayNode.type === 'text' && (
<>
<Separator />
<div className="px-3 py-2">
<TextSection node={displayNode} onUpdate={handleUpdate} />
</div>
</>
)}
<Separator />
<div className="px-3 py-2">
<ExportSection nodeId={node.id} nodeName={node.name ?? node.type} />

View file

@ -8,6 +8,7 @@ interface SizeSectionProps {
onUpdate: (updates: Partial<PenNode>) => void
hasCornerRadius?: boolean
cornerRadius?: number | [number, number, number, number]
hideWH?: boolean
}
export default function SizeSection({
@ -15,6 +16,7 @@ export default function SizeSection({
onUpdate,
hasCornerRadius,
cornerRadius,
hideWH,
}: SizeSectionProps) {
const info = nodeRenderInfo.get(node.id)
const offsetX = info?.parentOffsetX ?? 0
@ -40,7 +42,12 @@ export default function SizeSection({
: 0
return (
<div className="space-y-3">
<span className=" text-[11px] font-medium text-foreground ">
Position
</span>
<div className="grid grid-cols-2 gap-1">
<NumberInput
label="X"
value={Math.round(x)}
@ -51,7 +58,7 @@ export default function SizeSection({
value={Math.round(y)}
onChange={(v) => onUpdate({ y: v - offsetY })}
/>
{width !== undefined && (
{!hideWH && width !== undefined && (
<NumberInput
label="W"
value={Math.round(width)}
@ -61,7 +68,7 @@ export default function SizeSection({
min={1}
/>
)}
{height !== undefined && (
{!hideWH && height !== undefined && (
<NumberInput
label="H"
value={Math.round(height)}
@ -88,5 +95,6 @@ export default function SizeSection({
/>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,235 @@
import NumberInput from '@/components/shared/number-input'
import SectionHeader from '@/components/shared/section-header'
import {
MoveHorizontal,
WrapText,
Maximize2,
Check,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { PenNode, TextNode, SizingBehavior } from '@/types/pen'
interface TextLayoutSectionProps {
node: TextNode
onUpdate: (updates: Partial<PenNode>) => void
}
type TextResizing = 'auto' | 'fixed-width' | 'fixed-width-height'
function resolveTextGrowth(node: TextNode): TextResizing {
if (node.textGrowth) return node.textGrowth
const w = node.width
if (typeof w === 'number' && w > 0) return 'fixed-width'
if (typeof w === 'string' && w.startsWith('fill_container')) return 'fixed-width'
return 'auto'
}
function extractNumericSize(value: SizingBehavior | undefined): number {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const match = value.match(/\((\d+)\)/)
if (match) return parseInt(match[1], 10)
}
return 100
}
function ResizingToggle({
active,
onClick,
children,
title,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
title: string
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className={cn(
'h-7 flex-1 flex items-center justify-center gap-1 rounded text-[10px] transition-colors',
active
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-accent',
)}
>
{children}
</button>
)
}
function SizingCheckbox({
label,
checked,
onChange,
}: {
label: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<label className="flex items-center gap-1.5 cursor-pointer group">
<button
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
'w-4 h-4 rounded-[3px] border-[1.5px] flex items-center justify-center transition-colors shrink-0',
checked
? 'bg-primary border-primary'
: 'border-muted-foreground/40 group-hover:border-muted-foreground',
)}
>
{checked && (
<Check className="w-3 h-3 text-primary-foreground" strokeWidth={3} />
)}
</button>
<span className="text-[11px] text-muted-foreground select-none">
{label}
</span>
</label>
)
}
export default function TextLayoutSection({
node,
onUpdate,
}: TextLayoutSectionProps) {
const resizing = resolveTextGrowth(node)
const widthStr = typeof node.width === 'string' ? node.width : ''
const heightStr = typeof node.height === 'string' ? node.height : ''
const fillWidth = widthStr.startsWith('fill_container')
const fillHeight = heightStr.startsWith('fill_container')
const numericWidth = typeof node.width === 'number' && node.width > 0 ? node.width : undefined
const numericHeight = typeof node.height === 'number' && node.height > 0 ? node.height : undefined
const fallbackW = extractNumericSize(node.width)
const fallbackH = extractNumericSize(node.height)
const handleResizingChange = (mode: TextResizing) => {
const updates: Record<string, unknown> = { textGrowth: mode }
switch (mode) {
case 'auto':
updates.width = 0
updates.height = 0
break
case 'fixed-width':
if (!fillWidth && (typeof node.width !== 'number' || node.width <= 0)) {
updates.width = fallbackW > 0 ? fallbackW : 200
}
if (!fillHeight && (typeof node.height !== 'number' || node.height <= 0)) {
updates.height = fallbackH > 0 ? fallbackH : 44
}
break
case 'fixed-width-height':
if (!fillWidth && (typeof node.width !== 'number' || node.width <= 0)) {
updates.width = fallbackW > 0 ? fallbackW : 200
}
if (!fillHeight && (typeof node.height !== 'number' || node.height <= 0)) {
updates.height = fallbackH > 0 ? fallbackH : 100
}
break
}
onUpdate(updates as Partial<PenNode>)
}
// Always show dimensions — read-only when not directly editable
const canEditWidth = resizing !== 'auto' && !fillWidth && numericWidth !== undefined
const canEditHeight = resizing === 'fixed-width-height' && !fillHeight && numericHeight !== undefined
// Display value: prefer numeric, fallback to extracted size from fill_container(N)
const displayW = numericWidth ?? fallbackW
const displayH = numericHeight ?? fallbackH
return (
<div className="space-y-1.5">
<SectionHeader title="Layout" />
{/* Dimensions — always visible, read-only when auto/fill */}
<div>
<span className="text-[10px] text-muted-foreground mb-1 block">
Dimensions
</span>
<div className="grid grid-cols-2 gap-1">
<NumberInput
label="W"
value={Math.round(displayW)}
onChange={(v) =>
onUpdate({ width: v } as Partial<PenNode>)
}
min={1}
readOnly={!canEditWidth}
/>
<NumberInput
label="H"
value={Math.round(displayH)}
onChange={(v) =>
onUpdate({ height: v } as Partial<PenNode>)
}
min={1}
readOnly={!canEditHeight}
/>
</div>
</div>
{/* Fill Width / Fill Height */}
{resizing !== 'auto' && (
<div className="grid grid-cols-2 gap-y-1.5">
<SizingCheckbox
label="Fill Width"
checked={fillWidth}
onChange={(v) =>
onUpdate({
width: v ? 'fill_container' : (fallbackW > 0 ? fallbackW : 200),
} as Partial<PenNode>)
}
/>
<SizingCheckbox
label="Fill Height"
checked={fillHeight}
onChange={(v) =>
onUpdate({
height: v ? 'fill_container' : (fallbackH > 0 ? fallbackH : 100),
} as Partial<PenNode>)
}
/>
</div>
)}
{/* Resizing mode */}
<div>
<span className="text-[10px] text-muted-foreground mb-1 block">
Resizing
</span>
<div className="flex gap-0.5">
<ResizingToggle
active={resizing === 'auto'}
onClick={() => handleResizingChange('auto')}
title="Auto Width — text expands horizontally"
>
<MoveHorizontal className="w-3 h-3" />
<span>Auto W</span>
</ResizingToggle>
<ResizingToggle
active={resizing === 'fixed-width'}
onClick={() => handleResizingChange('fixed-width')}
title="Auto Height — fixed width, height auto-sizes"
>
<WrapText className="w-3 h-3" />
<span>Auto H</span>
</ResizingToggle>
<ResizingToggle
active={resizing === 'fixed-width-height'}
onClick={() => handleResizingChange('fixed-width-height')}
title="Fixed Size — both width and height are fixed"
>
<Maximize2 className="w-3 h-3" />
<span>Fixed</span>
</ResizingToggle>
</div>
</div>
</div>
)
}

View file

@ -7,7 +7,15 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { AlignLeft, AlignCenter, AlignRight, AlignJustify } from 'lucide-react'
import {
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
AlignVerticalJustifyStart,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { PenNode, TextNode } from '@/types/pen'
@ -35,13 +43,58 @@ const WEIGHT_OPTIONS = [
{ value: '900', label: 'Black' },
]
const ALIGN_OPTIONS = [
const H_ALIGN_OPTIONS = [
{ value: 'left', icon: AlignLeft, label: 'Align left' },
{ value: 'center', icon: AlignCenter, label: 'Align center' },
{ value: 'right', icon: AlignRight, label: 'Align right' },
{ value: 'justify', icon: AlignJustify, label: 'Justify' },
]
const V_ALIGN_OPTIONS = [
{ value: 'top', icon: AlignVerticalJustifyStart, label: 'Top' },
{ value: 'middle', icon: AlignVerticalJustifyCenter, label: 'Middle' },
{ value: 'bottom', icon: AlignVerticalJustifyEnd, label: 'Bottom' },
]
const LineHeightIcon = (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<line x1="1" y1="2" x2="1" y2="10" />
<polyline points="3,4 1,2 -1,4" transform="translate(0,0)" />
<polyline points="3,8 1,10 -1,8" transform="translate(0,0)" />
<line x1="5" y1="6" x2="11" y2="6" />
<line x1="5" y1="3" x2="9" y2="3" />
<line x1="5" y1="9" x2="9" y2="9" />
</svg>
)
function AlignButton({
active,
onClick,
icon: Icon,
label,
}: {
active: boolean
onClick: () => void
icon: React.ComponentType<{ className?: string }>
label: string
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={cn(
'h-6 w-6 flex items-center justify-center rounded transition-colors',
active
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50',
)}
>
<Icon className="w-3.5 h-3.5" />
</button>
)
}
export default function TextSection({
node,
onUpdate,
@ -49,12 +102,16 @@ export default function TextSection({
const fontFamily = node.fontFamily ?? 'Inter, sans-serif'
const fontSize = node.fontSize ?? 16
const fontWeight = String(node.fontWeight ?? '400')
const lineHeight = node.lineHeight ?? 1.2
const letterSpacing = node.letterSpacing ?? 0
const textAlign = node.textAlign ?? 'left'
const textAlignVertical = node.textAlignVertical ?? 'top'
return (
<div className="space-y-1.5">
<SectionHeader title="Text" />
<SectionHeader title="Typography" />
{/* Font family */}
<Select
value={fontFamily}
onValueChange={(v) =>
@ -73,15 +130,8 @@ export default function TextSection({
</SelectContent>
</Select>
{/* Weight + Size */}
<div className="grid grid-cols-2 gap-1">
<NumberInput
value={fontSize}
onChange={(v) =>
onUpdate({ fontSize: v } as Partial<PenNode>)
}
min={1}
max={999}
/>
<Select
value={fontWeight}
onValueChange={(v) =>
@ -99,29 +149,80 @@ export default function TextSection({
))}
</SelectContent>
</Select>
<NumberInput
label="S"
value={fontSize}
onChange={(v) =>
onUpdate({ fontSize: v } as Partial<PenNode>)
}
min={1}
max={999}
/>
</div>
<div className="flex items-center gap-0.5">
{ALIGN_OPTIONS.map(({ value, icon: Icon, label }) => (
<button
key={value}
type="button"
aria-label={label}
onClick={() =>
onUpdate({
textAlign: value as TextNode['textAlign'],
} as Partial<PenNode>)
}
className={cn(
'h-6 w-6 flex items-center justify-center rounded transition-colors',
textAlign === value
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50',
)}
>
<Icon className="w-3.5 h-3.5" />
</button>
))}
{/* Line height + Letter spacing */}
<div className="flex items-center justify-between text-[9px] text-muted-foreground px-0.5">
<span>Line height</span>
<span>Letter spacing</span>
</div>
<div className="grid grid-cols-2 gap-1">
<NumberInput
icon={LineHeightIcon}
value={Math.round(lineHeight * 100)}
onChange={(v) =>
onUpdate({ lineHeight: v / 100 } as Partial<PenNode>)
}
min={50}
max={400}
suffix="%"
/>
<NumberInput
label="|A|"
value={letterSpacing}
onChange={(v) =>
onUpdate({ letterSpacing: v } as Partial<PenNode>)
}
/>
</div>
{/* Horizontal alignment */}
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground">Horizontal</span>
<div className="flex items-center gap-0.5">
{H_ALIGN_OPTIONS.map(({ value, icon, label }) => (
<AlignButton
key={value}
active={textAlign === value}
onClick={() =>
onUpdate({
textAlign: value as TextNode['textAlign'],
} as Partial<PenNode>)
}
icon={icon}
label={label}
/>
))}
</div>
</div>
{/* Vertical alignment */}
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground">Vertical</span>
<div className="flex items-center gap-0.5">
{V_ALIGN_OPTIONS.map(({ value, icon, label }) => (
<AlignButton
key={value}
active={textAlignVertical === value}
onClick={() =>
onUpdate({
textAlignVertical: value as TextNode['textAlignVertical'],
} as Partial<PenNode>)
}
icon={icon}
label={label}
/>
))}
</div>
</div>
</div>
)

View file

@ -13,6 +13,7 @@ interface NumberInputProps {
icon?: React.ReactNode
suffix?: string
className?: string
readOnly?: boolean
}
export default function NumberInput({
@ -25,6 +26,7 @@ export default function NumberInput({
icon,
suffix,
className = '',
readOnly = false,
}: NumberInputProps) {
const [localValue, setLocalValue] = useState(String(value))
const [isDragging, setIsDragging] = useState(false)
@ -69,6 +71,7 @@ export default function NumberInput({
}
const handleMouseDown = (e: React.MouseEvent) => {
if (readOnly) return
if (e.target instanceof HTMLInputElement) return
setIsDragging(true)
dragStartY.current = e.clientY
@ -116,10 +119,14 @@ export default function NumberInput({
<input
type="text"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="w-full bg-transparent text-foreground text-[11px] px-1 py-0.5 focus:outline-none tabular-nums"
onChange={(e) => !readOnly && setLocalValue(e.target.value)}
onBlur={readOnly ? undefined : handleBlur}
onKeyDown={readOnly ? undefined : handleKeyDown}
readOnly={readOnly}
className={cn(
'w-full bg-transparent text-[11px] px-1 py-0.5 focus:outline-none tabular-nums',
readOnly ? 'text-muted-foreground cursor-default' : 'text-foreground',
)}
/>
{suffix && (
<span className="text-[10px] text-muted-foreground pr-1.5 shrink-0">