@@ -4,6 +4,10 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'reac
44import type { KeyboardEvent as ReactKeyboardEvent , MouseEvent as ReactMouseEvent , ReactNode } from 'react' ;
55import {
66 AlertTriangle ,
7+ ArrowDown ,
8+ ArrowLeft ,
9+ ArrowRight ,
10+ ArrowUp ,
711 Box ,
812 Camera ,
913 Check ,
@@ -55,7 +59,7 @@ import { InspectorClipboard } from '@/engine/inspectorClipboard';
5559import { SceneCommands , toModelValue } from '@/engine/SceneCommands' ;
5660import { ENTITY_SOURCES , createFromSource } from '@/engine/entitySources' ;
5761import { PlayInspect } from '@/engine/PlayInspect' ;
58- import { DimensionUnit , AnchorAxis , detectAnchorAxes , UIPositionType , parseLocaleTable , EasingType , INVALID_ENTITY } from 'esengine' ;
62+ import { DimensionUnit , AnchorAxis , detectAnchorAxes , UIPositionType , FlexDirection , FlexWrap , JustifyContent , AlignItems , parseLocaleTable , EasingType , INVALID_ENTITY } from 'esengine' ;
5963import type { SceneData , InputMapAsset , ActionType , Binding , LocaleTableAsset , PluralCategory , GearValue , GearTween } from 'esengine' ;
6064import { modelAddableComponentEntries , subscribeSchemas , getSchemaRevision , prettyLabel , hexToRgba , dynamicEnumOptions , boxGroupsFor , isRequiredEmpty , type BoxGroupDef } from '@/engine/schema' ;
6165import * as imap from '@/project/inputMapDoc' ;
@@ -248,6 +252,41 @@ export function VecControl({
248252 ) ;
249253}
250254
255+ // A four-edge box ({ left, top, right, bottom }) as four labelled wells — the padding
256+ // analog of VecControl. Edges are L·T·R·B; each edit writes ONLY its edge (NaN on the
257+ // others) so a single-edge change fanned across a multi-selection keeps the untouched
258+ // edges of the non-primary entities (see toModelValue 'sides').
259+ const SIDE_EDGES = [ 'l' , 't' , 'r' , 'b' ] as const ;
260+ export function SidesControl ( {
261+ value,
262+ mixed,
263+ onBegin,
264+ onEnd,
265+ onCancel,
266+ onChange,
267+ } : ControlGesture & { value : number [ ] ; mixed ?: boolean ; onChange : ( v : number [ ] ) => void } ) {
268+ return (
269+ < div className = "vec sides" >
270+ { SIDE_EDGES . map ( ( edge , i ) => (
271+ < VecField
272+ key = { edge }
273+ axis = { edge }
274+ value = { value [ i ] ?? 0 }
275+ mixed = { mixed }
276+ onBegin = { onBegin }
277+ onEnd = { onEnd }
278+ onCancel = { onCancel }
279+ onCommit = { ( v ) => {
280+ const next = [ NaN , NaN , NaN , NaN ] ;
281+ next [ i ] = v ;
282+ onChange ( next ) ;
283+ } }
284+ />
285+ ) ) }
286+ </ div >
287+ ) ;
288+ }
289+
251290// A named-int dropdown (e.g. Camera projection, body type) — a themed popover, not
252291// a native <select>, so the list matches the editor and searches when long. The
253292// stored value is the option's int; an unknown value shows a "(n)" placeholder.
@@ -1287,6 +1326,9 @@ function FieldRow({ entities, comp, field, write }: { entities: EntityId[]; comp
12871326 case 'dimension' :
12881327 control = < DimControl value = { field . value as DimensionValue } mixed = { mixed } onBegin = { begin } onEnd = { end } onChange = { apply } /> ;
12891328 break ;
1329+ case 'sides' :
1330+ control = < SidesControl value = { field . value as number [ ] } mixed = { mixed } onBegin = { begin } onEnd = { end } onCancel = { cancel } onChange = { apply } /> ;
1331+ break ;
12901332 case 'bool' :
12911333 control = < BoolControl value = { field . value as boolean } mixed = { mixed } onBegin = { begin } onEnd = { end } onChange = { apply } /> ;
12921334 break ;
@@ -1710,6 +1752,134 @@ function UILayoutControl({ entities, comp }: { entities: EntityId[]; comp: Inspe
17101752 ) ;
17111753}
17121754
1755+ // The FlexContainer "auto-layout" widget — direction, a 3×3 alignment grid, main-axis
1756+ // distribution and a cross-axis stretch toggle, in place of five stacked enum dropdowns.
1757+ // The grid's axes swap with the flow direction so the highlighted cell always reads as
1758+ // where the children actually pack (the Figma model). Gap + padding stay as normal
1759+ // fields below (padding via the reflected 'sides' control).
1760+ const FLEX_DIR_OPTS : SegmentedOption < string > [ ] = [
1761+ { value : String ( FlexDirection . Row ) , icon : < ArrowRight size = { 12 } strokeWidth = { 2.2 } /> , title : t ( 'det.flexRow' ) } ,
1762+ { value : String ( FlexDirection . Column ) , icon : < ArrowDown size = { 12 } strokeWidth = { 2.2 } /> , title : t ( 'det.flexColumn' ) } ,
1763+ { value : String ( FlexDirection . RowReverse ) , icon : < ArrowLeft size = { 12 } strokeWidth = { 2.2 } /> , title : t ( 'det.flexRowReverse' ) } ,
1764+ { value : String ( FlexDirection . ColumnReverse ) , icon : < ArrowUp size = { 12 } strokeWidth = { 2.2 } /> , title : t ( 'det.flexColumnReverse' ) } ,
1765+ ] ;
1766+ const FLEX_DISTRIBUTE_OPTS : SegmentedOption < string > [ ] = [
1767+ { value : 'packed' , label : t ( 'det.flexPacked' ) } ,
1768+ { value : String ( JustifyContent . SpaceBetween ) , label : t ( 'det.flexBetween' ) } ,
1769+ { value : String ( JustifyContent . SpaceAround ) , label : t ( 'det.flexAround' ) } ,
1770+ { value : String ( JustifyContent . SpaceEvenly ) , label : t ( 'det.flexEvenly' ) } ,
1771+ ] ;
1772+ const FLEX_MAIN_LABEL = [ t ( 'det.flexMainStart' ) , t ( 'det.flexMainCenter' ) , t ( 'det.flexMainEnd' ) ] ;
1773+ const FLEX_CROSS_LABEL = [ t ( 'det.flexCrossStart' ) , t ( 'det.flexCrossCenter' ) , t ( 'det.flexCrossEnd' ) ] ;
1774+ // Fields the widget owns, so the generic field flow skips them — gap, padding and the
1775+ // wrap-only alignContent stay as normal rows below (padding via the 'sides' control).
1776+ const FLEX_WIDGET_OWNED_FIELDS : ReadonlySet < string > = new Set ( [ 'direction' , 'justifyContent' , 'alignItems' , 'wrap' ] ) ;
1777+ // Place the cell's dot at its spatial position (col → left/centre/right, row →
1778+ // top/middle/bottom) so the grid previews where children land, Figma-style.
1779+ const FLEX_JUSTIFY_CSS = [ 'flex-start' , 'center' , 'flex-end' ] ;
1780+
1781+ function FlexLayoutControl ( { entities, comp } : { entities : EntityId [ ] ; comp : InspectorComponent } ) {
1782+ const field = ( key : string ) => comp . fields . find ( ( f ) => f . key === key ) ;
1783+ const fieldNum = ( key : string , dflt : number ) => {
1784+ const f = field ( key ) ;
1785+ return f && f . mixed !== true ? Number ( f . value ) : dflt ;
1786+ } ;
1787+ const dir = fieldNum ( 'direction' , FlexDirection . Row ) ;
1788+ const justify = fieldNum ( 'justifyContent' , JustifyContent . Start ) ;
1789+ const align = fieldNum ( 'alignItems' , AlignItems . Stretch ) ;
1790+ const wrap = fieldNum ( 'wrap' , FlexWrap . NoWrap ) ;
1791+ const horizontal = dir === FlexDirection . Row || dir === FlexDirection . RowReverse ;
1792+
1793+ const write = ( label : string , edits : Array < [ string , number ] > ) => {
1794+ SceneCommands . beginGesture ( label ) ;
1795+ for ( const id of entities ) for ( const [ k , v ] of edits ) SceneCommands . setField ( id , 'FlexContainer' , k , 'enum' , v ) ;
1796+ SceneCommands . endGesture ( ) ;
1797+ } ;
1798+
1799+ // 3×3 grid cell (col c, row r), c/r ∈ {0 Start, 1 Centre, 2 End}. The MAIN axis is
1800+ // columns when the flow is horizontal, rows when vertical — so a click packs the
1801+ // children where the cell sits. Space-mode justify has no single active cell.
1802+ const packed = justify <= JustifyContent . End ;
1803+ const activeMain = packed ? justify : null ;
1804+ const activeCross = align <= AlignItems . End ? align : null ; // Stretch → no single lane
1805+ const cell = ( c : number , r : number ) => {
1806+ const main = horizontal ? c : r ;
1807+ const cross = horizontal ? r : c ;
1808+ return { main, cross, on : activeMain === main && activeCross === cross } ;
1809+ } ;
1810+ const distributeValue = justify >= JustifyContent . SpaceBetween ? String ( justify ) : 'packed' ;
1811+ const stretched = align === AlignItems . Stretch ;
1812+
1813+ return (
1814+ < div className = "flex-block" >
1815+ < div className = "flex-row" >
1816+ < span className = "flex-lbl" > { t ( 'det.flexDirection' ) } </ span >
1817+ < Segmented
1818+ grow
1819+ ariaLabel = { t ( 'det.flexDirectionAria' ) }
1820+ value = { field ( 'direction' ) ?. mixed ? '' : String ( dir ) }
1821+ options = { FLEX_DIR_OPTS }
1822+ onChange = { ( v ) => write ( 'Flex Direction' , [ [ 'direction' , Number ( v ) ] ] ) }
1823+ />
1824+ </ div >
1825+ < div className = "flex-row flex-align-row" >
1826+ < span className = "flex-lbl" > { t ( 'det.flexAlign' ) } </ span >
1827+ < div className = "flex-grid" role = "group" aria-label = { t ( 'det.flexAlignGridAria' ) } >
1828+ { [ 0 , 1 , 2 ] . map ( ( r ) =>
1829+ [ 0 , 1 , 2 ] . map ( ( c ) => {
1830+ const st = cell ( c , r ) ;
1831+ return (
1832+ < button
1833+ key = { `${ c } -${ r } ` }
1834+ type = "button"
1835+ className = { `flex-cell${ st . on ? ' on' : '' } ${ packed ? '' : ' dim' } ` }
1836+ style = { { justifyContent : FLEX_JUSTIFY_CSS [ c ] , alignItems : FLEX_JUSTIFY_CSS [ r ] } }
1837+ title = { t ( 'det.flexAlignCell' , { main : FLEX_MAIN_LABEL [ st . main ] , cross : FLEX_CROSS_LABEL [ st . cross ] } ) }
1838+ aria-pressed = { st . on }
1839+ onClick = { ( ) => write ( 'Flex Align' , [ [ 'justifyContent' , st . main ] , [ 'alignItems' , st . cross ] ] ) }
1840+ >
1841+ < i />
1842+ </ button >
1843+ ) ;
1844+ } ) ,
1845+ ) }
1846+ </ div >
1847+ < button
1848+ type = "button"
1849+ className = { `flex-toggle${ stretched ? ' on' : '' } ` }
1850+ title = { t ( 'det.flexStretchAria' ) }
1851+ aria-pressed = { stretched }
1852+ onClick = { ( ) => write ( 'Flex Stretch' , [ [ 'alignItems' , stretched ? AlignItems . Center : AlignItems . Stretch ] ] ) }
1853+ >
1854+ { t ( 'det.flexStretch' ) }
1855+ </ button >
1856+ </ div >
1857+ < div className = "flex-row" >
1858+ < span className = "flex-lbl" > { t ( 'det.flexDistribute' ) } </ span >
1859+ < Segmented
1860+ grow
1861+ ariaLabel = { t ( 'det.flexDistributeAria' ) }
1862+ value = { distributeValue }
1863+ options = { FLEX_DISTRIBUTE_OPTS }
1864+ onChange = { ( val ) => write ( 'Flex Distribute' , [ [ 'justifyContent' , val === 'packed' ? JustifyContent . Start : Number ( val ) ] ] ) }
1865+ />
1866+ </ div >
1867+ < div className = "flex-row" >
1868+ < span className = "flex-lbl" > { t ( 'det.flexWrap' ) } </ span >
1869+ < button
1870+ type = "button"
1871+ className = { `flex-toggle${ wrap === FlexWrap . Wrap ? ' on' : '' } ` }
1872+ title = { t ( 'det.flexWrapAria' ) }
1873+ aria-pressed = { wrap === FlexWrap . Wrap }
1874+ onClick = { ( ) => write ( 'Flex Wrap' , [ [ 'wrap' , wrap === FlexWrap . Wrap ? FlexWrap . NoWrap : FlexWrap . Wrap ] ] ) }
1875+ >
1876+ { t ( 'det.flexWrap' ) }
1877+ </ button >
1878+ </ div >
1879+ </ div >
1880+ ) ;
1881+ }
1882+
17131883// One side of a box-model group: a lettered edge (L/R/T/B) + its Dimension well.
17141884// It commits through `fieldWriter` — the same door as FieldRow — so undo, mixed,
17151885// and reset behave identically; right-click keeps the per-field Copy/Paste/Reset.
@@ -3205,16 +3375,20 @@ function EditorDetails() {
32053375 extra = {
32063376 comp . name === 'UINode'
32073377 ? < UILayoutControl entities = { ids } comp = { comp } />
3208- : COMP_COLLIDER_SHAPE [ comp . name ]
3209- ? < ColliderShapeControl entities = { ids } current = { comp . name } />
3210- : undefined
3378+ : comp . name === 'FlexContainer'
3379+ ? < FlexLayoutControl entities = { ids } comp = { comp } />
3380+ : COMP_COLLIDER_SHAPE [ comp . name ]
3381+ ? < ColliderShapeControl entities = { ids } current = { comp . name } />
3382+ : undefined
32113383 }
32123384 hideFields = {
32133385 comp . name === 'UINode'
32143386 ? uiLayoutOwnedFields (
32153387 Number ( comp . fields . find ( ( f ) => f . key === 'position' ) ?. value ?? 0 ) === UIPositionType . Absolute ,
32163388 )
3217- : undefined
3389+ : comp . name === 'FlexContainer'
3390+ ? FLEX_WIDGET_OWNED_FIELDS
3391+ : undefined
32183392 }
32193393 />
32203394 ) ) }
0 commit comments