@@ -71,68 +71,116 @@ function stopsToStrings(stops: any[], { convert_colors, new_lines }: { convert_c
7171 return str + '%'
7272 }
7373
74+ function asNumberPercent ( p : any ) : number | null {
75+ if ( p == null ) return null
76+ // Accept numbers, unitless numeric strings, or percent strings
77+ if ( typeof p === 'number' && ! Number . isNaN ( p ) ) return p
78+ const str = String ( p ) . trim ( )
79+ if ( / ^ [ - + ] ? \d * \. ? \d + % $ / . test ( str ) ) return Number ( str . replace ( '%' , '' ) )
80+ if ( / ^ [ - + ] ? \d * \. ? \d + $ / . test ( str ) ) return Number ( str )
81+ return null
82+ }
83+
7484 function isPctZero ( p : any ) {
75- if ( p == null ) return false
76- const m = String ( p ) . match ( / ^ ( - ? \d + (?: \. \d + ) ? ) % $ / )
77- return ! ! ( m && Number ( m [ 1 ] ) === 0 )
85+ const n = asNumberPercent ( p )
86+ return n != null && Number ( n ) === 0
7887 }
7988
8089 function isPctHundred ( p : any ) {
81- if ( p == null ) return false
82- const m = String ( p ) . match ( / ^ ( - ? \d + (?: \. \d + ) ? ) % $ / )
83- return ! ! ( m && Number ( m [ 1 ] ) === 100 )
90+ const n = asNumberPercent ( p )
91+ return n != null && Number ( n ) === 100
8492 }
8593
8694 function isPctFifty ( p : any ) {
87- if ( p == null ) return false
88- const m = String ( p ) . match ( / ^ ( - ? \d + (?: \. \d + ) ? ) % $ / )
89- return ! ! ( m && Number ( m [ 1 ] ) === 50 )
95+ const n = asNumberPercent ( p )
96+ return n != null && Number ( n ) === 50
9097 }
9198
92- return stops
93- . map ( ( s , i ) => {
94- if ( s . kind === 'stop' ) {
95- let p1 = s . position1
96- let p2 = s . position2
99+ type StopOut = { kind : 'stop' ; color : string ; posA ?: string | null ; posB ?: string | null } | { kind : 'hint' ; text : string }
100+ const out : StopOut [ ] = [ ]
97101
98- // If first position equals computed auto position, omit it (keep explicit second positions)
99- if ( p1 != null && s . auto != null && String ( p1 ) == String ( s . auto ) ) p1 = null
102+ for ( let i = 0 ; i < stops . length ; i ++ ) {
103+ const s = stops [ i ]
104+ if ( ! s ) continue
105+ if ( s . kind === 'stop' ) {
106+ let p1 : any = s . position1
107+ let p2 : any = s . position2
100108
101- // Omit default endpoints
102- if ( i === firstStopIdx && isPctZero ( p1 ) ) p1 = null
103- if ( i === lastStopIdx && isPctHundred ( p2 ) ) p2 = null
109+ // If positions equal computed auto position, omit them
110+ if ( p1 != null && s . auto != null && String ( p1 ) == String ( s . auto ) ) p1 = null
111+ if ( p2 != null && s . auto != null && String ( p2 ) == String ( s . auto ) ) p2 = null
104112
105- if ( p1 != null && p2 != null ) {
106- const a = fmtPos ( p1 )
107- const b = fmtPos ( p2 )
108- if ( a !== b ) return maybeConvertColor ( s . color , convert_colors ) + ' ' + a + ' ' + b
109- return maybeConvertColor ( s . color , convert_colors ) + ' ' + a
110- }
113+ // Omit default endpoints regardless of whether value is in p1 or p2
114+ if ( i === firstStopIdx ) {
115+ if ( isPctZero ( p1 ) ) p1 = null
116+ if ( isPctZero ( p2 ) && p1 == null ) p2 = null
117+ }
118+ if ( i === lastStopIdx ) {
119+ if ( isPctHundred ( p2 ) ) p2 = null
120+ if ( isPctHundred ( p1 ) && p2 == null ) p1 = null
121+ }
111122
112- if ( p1 == null && p2 != null ) {
113- const b = fmtPos ( p2 )
114- return maybeConvertColor ( s . color , convert_colors ) + ' ' + b
115- }
123+ const colorStr = maybeConvertColor ( s . color , convert_colors )
116124
117- if ( p1 != null && p2 == null ) {
118- const a = fmtPos ( p1 )
119- return maybeConvertColor ( s . color , convert_colors ) + ' ' + a
125+ // Normalize: if both positions present and equal, reduce to one
126+ if ( p1 != null && p2 != null ) {
127+ const a = fmtPos ( p1 )
128+ const b = fmtPos ( p2 )
129+ if ( a === b ) {
130+ out . push ( { kind : 'stop' , color : colorStr , posA : a } )
131+ } else {
132+ out . push ( { kind : 'stop' , color : colorStr , posA : a , posB : b } )
120133 }
134+ continue
135+ }
121136
122- return maybeConvertColor ( s . color , convert_colors )
137+ if ( p1 == null && p2 != null ) {
138+ out . push ( { kind : 'stop' , color : colorStr , posA : fmtPos ( p2 ) } )
139+ continue
123140 }
124- else if ( s . kind === 'hint' ) {
125- // Omit default/auto hints (like 50%)
126- const pct = s . percentage
127- if ( pct == null ) return null
128- if ( s . auto != null && String ( pct ) == String ( s . auto ) ) return null
129- if ( isPctFifty ( pct ) ) return null
130- return pct + '%'
141+
142+ if ( p1 != null && p2 == null ) {
143+ out . push ( { kind : 'stop' , color : colorStr , posA : fmtPos ( p1 ) } )
144+ continue
131145 }
132- return null
133- } )
134- . filter ( Boolean )
135- . join ( new_lines === true ? ',\n ' : ', ' )
146+
147+ out . push ( { kind : 'stop' , color : colorStr } )
148+ }
149+ else if ( s . kind === 'hint' ) {
150+ // Omit default/auto hints (like 50%)
151+ const pct = s . percentage
152+ if ( pct == null ) continue
153+ if ( s . auto != null && String ( pct ) == String ( s . auto ) ) continue
154+ if ( isPctFifty ( pct ) ) continue
155+ out . push ( { kind : 'hint' , text : pct + '%' } )
156+ }
157+ }
158+
159+ // Decide whether to use multi-line formatting based on color token lengths
160+ const colorStops = out . filter ( ( x ) : x is Extract < StopOut , { kind : 'stop' } > => x . kind === 'stop' )
161+ const maxColorLen = colorStops . reduce ( ( m , s ) => Math . max ( m , s . color . length ) , 0 )
162+ const hasLongColor = maxColorLen >= 20 || colorStops . some ( s => / \( | \s / . test ( s . color ) )
163+ const useNewLines = new_lines === true || ( new_lines !== false && hasLongColor )
164+
165+ if ( ! useNewLines ) {
166+ return out . map ( s => {
167+ if ( s . kind === 'hint' ) return s . text
168+ const parts = [ s . color ]
169+ if ( s . posA ) parts . push ( s . posA )
170+ if ( s . posB ) parts . push ( s . posB )
171+ return parts . join ( ' ' )
172+ } ) . join ( ', ' )
173+ }
174+
175+ // Multi-line with aligned positions
176+ return out . map ( s => {
177+ if ( s . kind === 'hint' ) return s . text
178+ const pad = s . posA || s . posB ? ' ' . repeat ( Math . max ( 1 , maxColorLen - s . color . length + 1 ) ) : ''
179+ const parts = [ s . color ]
180+ if ( s . posA ) parts . push ( pad + s . posA )
181+ if ( s . posB ) parts . push ( ' ' + s . posB ) // second position separated by single space
182+ return parts . join ( '' )
183+ } ) . join ( ',\n ' )
136184}
137185
138186function linearAngleToken ( linear : LayerSnapshot [ 'linear' ] ) {
@@ -154,39 +202,39 @@ function linearAngleToken(linear: LayerSnapshot['linear']) {
154202function modernString ( layer : LayerSnapshot ) {
155203 if ( layer . type === 'linear' ) {
156204 const tokens = [ linearAngleToken ( layer . linear ) , spaceToString ( layer . space , layer . interpolation ) ] . filter ( Boolean ) . join ( ' ' )
157- return `linear-gradient(\n ${ tokens } ,\n ${ stopsToStrings ( layer . stops , { new_lines : false } ) } \n )`
205+ return `linear-gradient(\n ${ tokens } ,\n ${ stopsToStrings ( layer . stops ) } \n )`
158206 }
159207 else if ( layer . type === 'radial' ) {
160208 const pos = radialPositionToString ( layer . radial )
161209 const posPart = pos && pos !== 'center' ? 'at ' + pos : ''
162210 const tokens = [ layer . radial . size , layer . radial . shape , posPart , spaceToString ( layer . space , layer . interpolation ) ] . filter ( Boolean ) . join ( ' ' )
163- return `radial-gradient(\n ${ tokens } ,\n ${ stopsToStrings ( layer . stops , { new_lines : false } ) } \n )`
211+ return `radial-gradient(\n ${ tokens } ,\n ${ stopsToStrings ( layer . stops ) } \n )`
164212 }
165213 else {
166214 const pos = conicPositionToString ( layer . conic )
167215 const posPart = pos && pos !== 'center' ? 'at ' + pos : ''
168216 const fromPart = ( Number ( layer . conic . angle ) || 0 ) % 360 === 0 ? '' : `from ${ layer . conic . angle } deg`
169217 const tokens = [ fromPart , posPart , spaceToString ( layer . space , layer . interpolation ) ] . filter ( Boolean ) . join ( ' ' )
170- return `conic-gradient(\n ${ tokens } ,\n ${ stopsToStrings ( layer . stops , { new_lines : false } ) } \n )`
218+ return `conic-gradient(\n ${ tokens } ,\n ${ stopsToStrings ( layer . stops ) } \n )`
171219 }
172220}
173221
174222function classicString ( layer : LayerSnapshot ) {
175223 if ( layer . type === 'linear' ) {
176224 const angleToken = linearAngleToken ( layer . linear )
177225 const header = angleToken ? angleToken + ', ' : ''
178- return `linear-gradient(${ header } ${ stopsToStrings ( layer . stops , { convert_colors : true , new_lines : false } ) } )`
226+ return `linear-gradient(${ header } ${ stopsToStrings ( layer . stops , { convert_colors : true } ) } )`
179227 }
180228 else if ( layer . type === 'radial' ) {
181229 const pos = radialPositionToString ( layer . radial )
182230 const posPart = pos && pos !== 'center' ? ' at ' + pos : ''
183- return `radial-gradient(${ layer . radial . size } ${ layer . radial . shape } ${ posPart } , ${ stopsToStrings ( layer . stops , { convert_colors : true , new_lines : false } ) } )`
231+ return `radial-gradient(${ layer . radial . size } ${ layer . radial . shape } ${ posPart } , ${ stopsToStrings ( layer . stops , { convert_colors : true } ) } )`
184232 }
185233 else {
186234 const pos = conicPositionToString ( layer . conic )
187235 const posPart = pos && pos !== 'center' ? ' at ' + pos : ''
188236 const fromPart = ( Number ( layer . conic . angle ) || 0 ) % 360 === 0 ? '' : `from ${ layer . conic . angle } deg `
189- return `conic-gradient(${ fromPart . trim ( ) } ${ posPart } , ${ stopsToStrings ( layer . stops , { convert_colors : true , new_lines : false } ) } )`
237+ return `conic-gradient(${ fromPart . trim ( ) } ${ posPart } , ${ stopsToStrings ( layer . stops , { convert_colors : true } ) } )`
190238 }
191239}
192240
0 commit comments