feat(panels): rewrite layout-section with full Flex Layout panel

- 3x3 alignment grid with context-aware behavior per layout direction
- Gap section with numeric/space-between/space-around radio modes
- Multi-mode padding: single, 2-axis (V/H), 4-individual (T/R/B/L)
  with gear popover
- Dimensions (W/H) and sizing checkboxes (fill/hug/clip) integrated
  into layout panel
This commit is contained in:
Fini 2026-02-22 08:17:20 +08:00
parent 8df0b27d2b
commit aaa1d603d7

View file

@ -1,19 +1,13 @@
import { useState, useRef, useEffect } from 'react'
import NumberInput from '@/components/shared/number-input'
import DropdownSelect from '@/components/shared/dropdown-select'
import VariablePicker from '@/components/shared/variable-picker'
import { isVariableRef } from '@/variables/resolve-variables'
import type { PenNode, ContainerProps } from '@/types/pen'
import type { PenNode, ContainerProps, SizingBehavior } from '@/types/pen'
import { cn } from '@/lib/utils'
import {
Rows3,
Columns3,
Rows3,
LayoutGrid,
AlignStartHorizontal,
AlignCenterHorizontal,
AlignEndHorizontal,
AlignStartVertical,
AlignCenterVertical,
AlignEndVertical,
Settings,
Check,
} from 'lucide-react'
interface LayoutSectionProps {
@ -21,19 +15,34 @@ interface LayoutSectionProps {
onUpdate: (updates: Partial<PenNode>) => void
}
const JUSTIFY_OPTIONS = [
{ value: 'start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'end', label: 'End' },
{ value: 'space_between', label: 'Between' },
{ value: 'space_around', label: 'Around' },
]
const POSITIONS = ['start', 'center', 'end'] as const
const ALIGN_OPTIONS = [
{ value: 'start', label: 'Start' },
{ value: 'center', label: 'Center' },
{ value: 'end', label: 'End' },
]
type GapMode = 'numeric' | 'space_between' | 'space_around'
type PaddingMode = 'single' | 'axis' | 'individual'
// ---------------------------------------------------------------------------
// Padding Icons (small SVG indicators for V/H padding)
// ---------------------------------------------------------------------------
const PadVIcon = (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor">
<rect x="2.5" y="3.5" width="7" height="5" strokeWidth="1.2" rx="0.5" />
<line x1="4" y1="1" x2="8" y2="1" strokeWidth="1.4" strokeLinecap="round" />
<line x1="4" y1="11" x2="8" y2="11" strokeWidth="1.4" strokeLinecap="round" />
</svg>
)
const PadHIcon = (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor">
<rect x="2.5" y="2.5" width="7" height="7" strokeWidth="1.2" rx="0.5" />
<line x1="1" y1="4" x2="1" y2="8" strokeWidth="1.4" strokeLinecap="round" />
<line x1="11" y1="4" x2="11" y2="8" strokeWidth="1.4" strokeLinecap="round" />
</svg>
)
// ---------------------------------------------------------------------------
// ToggleButton
// ---------------------------------------------------------------------------
function ToggleButton({
active,
@ -52,7 +61,7 @@ function ToggleButton({
title={title}
onClick={onClick}
className={cn(
'h-6 w-6 flex items-center justify-center rounded transition-colors',
'h-7 w-7 flex items-center justify-center rounded transition-colors',
active
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-accent',
@ -63,140 +72,701 @@ function ToggleButton({
)
}
export default function LayoutSection({ node, onUpdate }: LayoutSectionProps) {
const layout = node.layout ?? 'none'
const rawGap = node.gap
const rawPadding = node.padding
const gapIsBound = typeof rawGap === 'string' && isVariableRef(rawGap)
const paddingIsBound = typeof rawPadding === 'string' && isVariableRef(rawPadding)
const gap = typeof rawGap === 'number' ? rawGap : 0
const padding = typeof rawPadding === 'number'
? rawPadding
: Array.isArray(rawPadding)
? rawPadding[0]
: 0
const justifyContent = node.justifyContent ?? 'start'
const alignItems = node.alignItems ?? 'start'
const hasLayout = layout !== 'none'
// ---------------------------------------------------------------------------
// RadioCircle
// ---------------------------------------------------------------------------
function RadioCircle({
selected,
onClick,
}: {
selected: boolean
onClick?: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'w-[14px] h-[14px] rounded-full border-[1.5px] flex items-center justify-center shrink-0 transition-colors',
selected ? 'border-primary' : 'border-muted-foreground/40',
)}
>
{selected && <div className="w-2 h-2 rounded-full bg-primary" />}
</button>
)
}
// ---------------------------------------------------------------------------
// AlignmentGrid — 3×3 interactive alignment picker
// ---------------------------------------------------------------------------
function AlignmentGrid({
layout,
justifyContent,
alignItems,
isSpaceMode,
onUpdate,
}: {
layout: 'none' | 'vertical' | 'horizontal'
justifyContent: string
alignItems: string
isSpaceMode: boolean
onUpdate: (updates: Partial<PenNode>) => void
}) {
const isFreedom = layout === 'none'
const isVertical = layout === 'vertical'
return (
<div className="space-y-1.5">
<span className="text-[10px] font-medium text-muted-foreground tracking-wider">
Layout
</span>
<div className="grid grid-cols-3 gap-[3px] p-2 bg-secondary rounded">
{[0, 1, 2].map((row) =>
[0, 1, 2].map((col) => {
const rowPos = POSITIONS[row]
const colPos = POSITIONS[col]
const cellJustify = isVertical ? rowPos : colPos
const cellAlign = isVertical ? colPos : rowPos
const isActive =
!isFreedom &&
!isSpaceMode &&
justifyContent === cellJustify &&
alignItems === cellAlign
const cellCrossPos = isVertical ? colPos : rowPos
const isOnActiveCross =
isSpaceMode && cellCrossPos === alignItems
{/* Layout direction */}
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground w-8 shrink-0">Dir</span>
<div className="flex gap-0.5">
<ToggleButton
active={layout === 'none'}
onClick={() => onUpdate({ layout: 'none' } as Partial<PenNode>)}
title="No layout"
>
<LayoutGrid className="w-3 h-3" />
</ToggleButton>
<ToggleButton
active={layout === 'vertical'}
onClick={() => onUpdate({ layout: 'vertical' } as Partial<PenNode>)}
title="Vertical layout"
>
<Rows3 className="w-3 h-3" />
</ToggleButton>
<ToggleButton
active={layout === 'horizontal'}
onClick={() => onUpdate({ layout: 'horizontal' } as Partial<PenNode>)}
title="Horizontal layout"
>
<Columns3 className="w-3 h-3" />
</ToggleButton>
</div>
</div>
{hasLayout && (
<>
{/* Gap & Padding */}
<div className="space-y-1">
<div className="flex items-center gap-1">
<div className="flex-1">
{gapIsBound ? (
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
{String(rawGap)}
</div>
) : (
<NumberInput
label="Gap"
value={gap}
onChange={(v) => onUpdate({ gap: v } as Partial<PenNode>)}
min={0}
/>
)}
</div>
<VariablePicker
type="number"
currentValue={gapIsBound ? String(rawGap) : undefined}
onBind={(ref) => onUpdate({ gap: ref as unknown as number } as Partial<PenNode>)}
onUnbind={(val) => onUpdate({ gap: Number(val) } as Partial<PenNode>)}
/>
</div>
<div className="flex items-center gap-1">
<div className="flex-1">
{paddingIsBound ? (
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
{String(rawPadding)}
</div>
) : (
<NumberInput
label="Pad"
value={padding}
onChange={(v) => onUpdate({ padding: v } as Partial<PenNode>)}
min={0}
/>
)}
</div>
<VariablePicker
type="number"
currentValue={paddingIsBound ? String(rawPadding) : undefined}
onBind={(ref) => onUpdate({ padding: ref as unknown as number } as Partial<PenNode>)}
onUnbind={(val) => onUpdate({ padding: Number(val) } as Partial<PenNode>)}
/>
</div>
</div>
{/* Justify Content */}
<DropdownSelect
label="Justify"
value={justifyContent}
options={JUSTIFY_OPTIONS}
onChange={(v) =>
onUpdate({ justifyContent: v } as Partial<PenNode>)
}
/>
{/* Align Items */}
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground w-8 shrink-0">Align</span>
<div className="flex gap-0.5">
{ALIGN_OPTIONS.map((opt) => {
const icons = layout === 'horizontal'
? { start: AlignStartVertical, center: AlignCenterVertical, end: AlignEndVertical }
: { start: AlignStartHorizontal, center: AlignCenterHorizontal, end: AlignEndHorizontal }
const Icon = icons[opt.value as keyof typeof icons]
return (
<ToggleButton
key={opt.value}
active={alignItems === opt.value}
onClick={() => onUpdate({ alignItems: opt.value } as Partial<PenNode>)}
title={`Align ${opt.label}`}
>
<Icon className="w-3 h-3" />
</ToggleButton>
)
})}
</div>
</div>
</>
return (
<button
key={`${row}-${col}`}
type="button"
disabled={isFreedom}
className={cn(
'w-7 h-5 rounded-[2px] flex items-center justify-center transition-colors',
isFreedom && 'cursor-default',
!isFreedom && 'cursor-pointer hover:bg-accent/50',
)}
onClick={() => {
if (isFreedom) return
if (isSpaceMode) {
onUpdate({
alignItems: cellAlign,
} as Partial<PenNode>)
} else {
onUpdate({
justifyContent: cellJustify,
alignItems: cellAlign,
} as Partial<PenNode>)
}
}}
>
{isFreedom ? (
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground/30" />
) : isSpaceMode && isOnActiveCross ? (
<div
className={cn(
'rounded-[1px] bg-primary',
isVertical
? 'w-[10px] h-[2px]'
: 'w-[2px] h-[10px]',
)}
/>
) : isActive ? (
<div className="w-2.5 h-2.5 rounded-[2px] bg-primary" />
) : (
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground/40" />
)}
</button>
)
}),
)}
</div>
)
}
// ---------------------------------------------------------------------------
// GapSection — Radio: Numeric / Space Between / Space Around
// ---------------------------------------------------------------------------
function GapSection({
gap,
gapMode,
onGapModeChange,
onUpdate,
}: {
gap: number
gapMode: GapMode
onGapModeChange: (mode: GapMode) => void
onUpdate: (updates: Partial<PenNode>) => void
}) {
return (
<div className="space-y-1.5">
<div
className="flex items-center gap-1.5 cursor-pointer"
onClick={() => onGapModeChange('numeric')}
>
<RadioCircle selected={gapMode === 'numeric'} />
<div
className="flex-1"
onClick={(e) => e.stopPropagation()}
>
<NumberInput
value={gap}
onChange={(v) =>
onUpdate({ gap: v } as Partial<PenNode>)
}
min={0}
/>
</div>
</div>
<div
className="flex items-center gap-1.5 cursor-pointer"
onClick={() => onGapModeChange('space_between')}
>
<RadioCircle selected={gapMode === 'space_between'} />
<span className="text-[10px] text-muted-foreground select-none">
Space Between
</span>
</div>
<div
className="flex items-center gap-1.5 cursor-pointer"
onClick={() => onGapModeChange('space_around')}
>
<RadioCircle selected={gapMode === 'space_around'} />
<span className="text-[10px] text-muted-foreground select-none">
Space Around
</span>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// PaddingSection — Uniform / V-H / T-R-B-L with gear popover
// ---------------------------------------------------------------------------
function parsePaddingValues(
padding:
| number
| [number, number]
| [number, number, number, number]
| string
| undefined,
): { mode: PaddingMode; values: [number, number, number, number] } {
if (typeof padding === 'string' || padding === undefined) {
return { mode: 'single', values: [0, 0, 0, 0] }
}
if (typeof padding === 'number') {
return {
mode: 'single',
values: [padding, padding, padding, padding],
}
}
if (padding.length === 2) {
return {
mode: 'axis',
values: [padding[0], padding[1], padding[0], padding[1]],
}
}
if (padding[0] === padding[2] && padding[1] === padding[3]) {
return {
mode: 'axis',
values: [padding[0], padding[1], padding[2], padding[3]],
}
}
return {
mode: 'individual',
values: [padding[0], padding[1], padding[2], padding[3]],
}
}
function PaddingSection({
padding,
onUpdate,
}: {
padding:
| number
| [number, number]
| [number, number, number, number]
| string
| undefined
onUpdate: (updates: Partial<PenNode>) => void
}) {
const parsed = parsePaddingValues(padding)
const [mode, setMode] = useState<PaddingMode>(parsed.mode)
const [popoverOpen, setPopoverOpen] = useState(false)
const popoverRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setMode(parsePaddingValues(padding).mode)
}, [padding])
useEffect(() => {
if (!popoverOpen) return
const handler = (e: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node)
) {
setPopoverOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [popoverOpen])
const handleModeChange = (newMode: PaddingMode) => {
setMode(newMode)
setPopoverOpen(false)
const vals = parsed.values
switch (newMode) {
case 'single':
onUpdate({ padding: vals[0] } as Partial<PenNode>)
break
case 'axis':
onUpdate({
padding: [vals[0], vals[1]],
} as Partial<PenNode>)
break
case 'individual':
onUpdate({
padding: [vals[0], vals[1], vals[2], vals[3]],
} as Partial<PenNode>)
break
}
}
const MODES = [
{ value: 'single' as const, label: 'One value for all sides' },
{ value: 'axis' as const, label: 'Horizontal/Vertical' },
{ value: 'individual' as const, label: 'Top/Right/Bottom/Left' },
]
return (
<div className="space-y-1.5">
{/* Label row: "Padding" left, gear right */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
Padding
</span>
<div ref={popoverRef} className="relative">
<button
type="button"
title="Padding mode"
onClick={() => setPopoverOpen(!popoverOpen)}
className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Settings className="w-3.5 h-3.5" />
</button>
{popoverOpen && (
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border border-border rounded-lg shadow-md p-3 min-w-[190px]">
<div className="text-[12px] font-medium mb-3 text-foreground">Padding Values</div>
<div className="space-y-2.5">
{MODES.map((opt) => (
<div
key={opt.value}
className="flex items-center gap-2 cursor-pointer"
onClick={() => handleModeChange(opt.value)}
>
<RadioCircle selected={mode === opt.value} />
<span className="text-[12px] text-foreground leading-none">{opt.label}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Padding inputs */}
{mode === 'single' && (
<NumberInput
icon={PadVIcon}
value={parsed.values[0]}
onChange={(v) =>
onUpdate({ padding: v } as Partial<PenNode>)
}
min={0}
/>
)}
{mode === 'axis' && (
<div className="grid grid-cols-2 gap-1">
<NumberInput
icon={PadHIcon}
value={parsed.values[1]}
onChange={(v) =>
onUpdate({
padding: [parsed.values[0], v],
} as Partial<PenNode>)
}
min={0}
/>
<NumberInput
icon={PadVIcon}
value={parsed.values[0]}
onChange={(v) =>
onUpdate({
padding: [v, parsed.values[1]],
} as Partial<PenNode>)
}
min={0}
/>
</div>
)}
{mode === 'individual' && (
<div className="grid grid-cols-2 gap-1">
<NumberInput
label="T"
value={parsed.values[0]}
onChange={(v) =>
onUpdate({
padding: [
v,
parsed.values[1],
parsed.values[2],
parsed.values[3],
],
} as Partial<PenNode>)
}
min={0}
/>
<NumberInput
label="R"
value={parsed.values[1]}
onChange={(v) =>
onUpdate({
padding: [
parsed.values[0],
v,
parsed.values[2],
parsed.values[3],
],
} as Partial<PenNode>)
}
min={0}
/>
<NumberInput
label="B"
value={parsed.values[2]}
onChange={(v) =>
onUpdate({
padding: [
parsed.values[0],
parsed.values[1],
v,
parsed.values[3],
],
} as Partial<PenNode>)
}
min={0}
/>
<NumberInput
label="L"
value={parsed.values[3]}
onChange={(v) =>
onUpdate({
padding: [
parsed.values[0],
parsed.values[1],
parsed.values[2],
v,
],
} as Partial<PenNode>)
}
min={0}
/>
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// SizingCheckbox
// ---------------------------------------------------------------------------
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>
)
}
// ---------------------------------------------------------------------------
// SizingCheckboxes — Fill / Hug per axis + Clip Content
// ---------------------------------------------------------------------------
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 SizingCheckboxes({
node,
onUpdate,
}: {
node: PenNode & ContainerProps
onUpdate: (updates: Partial<PenNode>) => void
}) {
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 hugWidth = widthStr.startsWith('fit_content')
const hugHeight = heightStr.startsWith('fit_content')
const clipContent = node.clipContent === true
const fallbackW = extractNumericSize(node.width)
const fallbackH = extractNumericSize(node.height)
return (
<div className="space-y-1.5">
<div className="grid grid-cols-2 gap-y-1.5">
<SizingCheckbox
label="Fill Width"
checked={fillWidth}
onChange={(v) =>
onUpdate({
width: v ? 'fill_container' : fallbackW,
} as Partial<PenNode>)
}
/>
<SizingCheckbox
label="Fill Height"
checked={fillHeight}
onChange={(v) =>
onUpdate({
height: v ? 'fill_container' : fallbackH,
} as Partial<PenNode>)
}
/>
<SizingCheckbox
label="Hug Width"
checked={hugWidth}
onChange={(v) =>
onUpdate({
width: v ? 'fit_content' : fallbackW,
} as Partial<PenNode>)
}
/>
<SizingCheckbox
label="Hug Height"
checked={hugHeight}
onChange={(v) =>
onUpdate({
height: v ? 'fit_content' : fallbackH,
} as Partial<PenNode>)
}
/>
</div>
<SizingCheckbox
label="Clip Content"
checked={clipContent}
onChange={(v) =>
onUpdate({ clipContent: v } as Partial<PenNode>)
}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Main LayoutSection
// ---------------------------------------------------------------------------
export default function LayoutSection({
node,
onUpdate,
}: LayoutSectionProps) {
const layout = node.layout ?? 'none'
const hasLayout = layout !== 'none'
const justifyContent = node.justifyContent ?? 'start'
const alignItems = node.alignItems ?? 'start'
const rawGap = node.gap
const gap = typeof rawGap === 'number' ? rawGap : 0
const gapMode: GapMode =
justifyContent === 'space_between'
? 'space_between'
: justifyContent === 'space_around'
? 'space_around'
: 'numeric'
const isSpaceMode =
gapMode === 'space_between' || gapMode === 'space_around'
const handleGapModeChange = (mode: GapMode) => {
switch (mode) {
case 'numeric':
onUpdate({
justifyContent: 'start',
} as Partial<PenNode>)
break
case 'space_between':
onUpdate({
justifyContent: 'space_between',
} as Partial<PenNode>)
break
case 'space_around':
onUpdate({
justifyContent: 'space_around',
} as Partial<PenNode>)
break
}
}
const width =
typeof node.width === 'number' ? node.width : undefined
const height =
typeof node.height === 'number' ? node.height : undefined
return (
<div className="space-y-3">
{/* Header */}
<span className="text-[11px] font-medium text-foreground">
Flex Layout
</span>
{/* Direction row — no label, just buttons */}
<div className="flex jusfity-between gap-0.5">
<ToggleButton
active={layout === 'none'}
onClick={() =>
onUpdate({ layout: 'none' } as Partial<PenNode>)
}
title="Freedom (no layout)"
>
<LayoutGrid className="w-3.5 h-3.5" />
</ToggleButton>
<ToggleButton
active={layout === 'vertical'}
onClick={() =>
onUpdate({ layout: 'vertical' } as Partial<PenNode>)
}
title="Vertical layout"
>
<Rows3 className="w-3.5 h-3.5" />
</ToggleButton>
<ToggleButton
active={layout === 'horizontal'}
onClick={() =>
onUpdate({
layout: 'horizontal',
} as Partial<PenNode>)
}
title="Horizontal layout"
>
<Columns3 className="w-3.5 h-3.5" />
</ToggleButton>
</div>
{/* Alignment + Gap side by side */}
{hasLayout && (
<>
<div className="flex gap-2">
{/* Left: Alignment */}
<div className="w-[160px]">
<span className="text-[10px] w-full text-muted-foreground mb-1.5 block">
Alignment
</span>
<AlignmentGrid
layout={layout}
justifyContent={justifyContent}
alignItems={alignItems}
isSpaceMode={isSpaceMode}
onUpdate={onUpdate}
/>
</div>
{/* Right: Gap */}
<div>
<span className="text-[10px] text-muted-foreground mb-1.5 block">
Gap
</span>
<GapSection
gap={gap}
gapMode={gapMode}
onGapModeChange={handleGapModeChange}
onUpdate={onUpdate}
/>
</div>
</div>
{/* Padding */}
<PaddingSection
padding={node.padding}
onUpdate={onUpdate}
/>
</>
)}
{/* Dimensions */}
{(width !== undefined || height !== undefined) && (
<div>
<span className="text-[10px] text-muted-foreground mb-1.5 block">
Dimensions
</span>
<div className="grid grid-cols-2 gap-1">
{width !== undefined && (
<NumberInput
label="W"
value={Math.round(width)}
onChange={(v) =>
onUpdate({ width: v } as Partial<PenNode>)
}
min={1}
/>
)}
{height !== undefined && (
<NumberInput
label="H"
value={Math.round(height)}
onChange={(v) =>
onUpdate({ height: v } as Partial<PenNode>)
}
min={1}
/>
)}
</div>
</div>
)}
{/* Sizing checkboxes */}
<SizingCheckboxes node={node} onUpdate={onUpdate} />
</div>
)
}