Skip to content

Commit 945c3b1

Browse files
committed
fixes #118 and then some 🤘🏻
1 parent 7b9b135 commit 945c3b1

4 files changed

Lines changed: 136 additions & 11 deletions

File tree

src/components/GradientImportDialog.svelte

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
<script>
22
import { onMount } from 'svelte'
3-
import { parseGradient, ParseError } from '../lib/parseGradient'
3+
import { parseGradient, parseMultipleGradients, ParseError } from '../lib/parseGradient'
44
import { applyParsedToStores } from '../lib/importGradient'
5+
import { layers, addLayer } from '../store/layers'
6+
import { get } from 'svelte/store'
57
import ImportEditor from './import/ImportEditor.svelte'
68
import ImportActions from './import/ImportActions.svelte'
79
@@ -47,9 +49,15 @@
4749
4850
function validate() {
4951
try {
50-
parseGradient(gradientText)
51-
valid = true
52-
error = ''
52+
// Try parsing as multiple gradients first
53+
const gradients = parseMultipleGradients(gradientText)
54+
if (gradients.length > 0) {
55+
valid = true
56+
error = ''
57+
} else {
58+
valid = false
59+
error = 'No valid gradients found'
60+
}
5361
} catch (e) {
5462
valid = false
5563
error = e instanceof ParseError ? e.message : 'Invalid gradient'
@@ -59,8 +67,26 @@
5967
function onImportClick() {
6068
if (!valid || !gradientText.trim()) return
6169
try {
62-
const parsed = parseGradient(gradientText)
63-
applyParsedToStores(parsed)
70+
const gradients = parseMultipleGradients(gradientText)
71+
if (gradients.length === 0) {
72+
error = 'No valid gradients found'
73+
return
74+
}
75+
76+
// Clear existing layers if any, and import all gradients
77+
const currentLayers = get(layers)
78+
79+
// Import first gradient into current state
80+
applyParsedToStores(gradients[0])
81+
82+
// Import remaining gradients as new layers
83+
if (gradients.length > 1) {
84+
for (let i = 1; i < gradients.length; i++) {
85+
applyParsedToStores(gradients[i])
86+
addLayer({ seed: 'duplicate', position: 'bottom' })
87+
}
88+
}
89+
6490
close()
6591
} catch (e) {
6692
// Should be rare because button is disabled when invalid; keep safe

src/lib/importGradient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ function normalizeNamedPosition(name: string): string {
3737
return map[name] || name
3838
}
3939

40-
export function applyParsedToStores(parsed: ParsedGradient) {
40+
export function applyParsedToStores(parsed: ParsedGradient, opts?: { clearLayers?: boolean }) {
4141
gradient_type.set(parsed.type)
4242
if (parsed.space) gradient_space.set(parsed.space)
4343
if (parsed.interpolation) gradient_interpolation.set(parsed.interpolation)

src/lib/parseGradient.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,26 @@ const valid = [
2626
oklch(80% 0.3 89) 64%,
2727
oklch(90% 0.5 200)
2828
);`,
29+
// Multiple gradients (parser should take the first one)
30+
`linear-gradient(
31+
to top right in oklab,
32+
oklch(79% 0.21 182 / 0.5),
33+
oklch(66% 0.32 259 / 0.5)
34+
), linear-gradient(
35+
276deg in oklab,
36+
color(display-p3 77% 0% 52%),
37+
hsl(347 100% 81%) 39%,
38+
oklab(95% -0.03 0.40)
39+
)`,
40+
// Radial gradient with length-based size
41+
`radial-gradient(
42+
100px circle in oklab,
43+
color(display-p3 77% 0% 52%),
44+
hsl(347 100% 81%) 39%,
45+
oklab(95% -0.03 0.40)
46+
)`,
47+
// Color with alpha channel
48+
'linear-gradient(to right, oklch(79% 0.21 182 / 0.5), oklch(66% 0.32 259 / 0.5))',
2949
]
3050

3151
const invalid = [
@@ -100,5 +120,24 @@ describe('parseGradient', () => {
100120
expect(parsed.linear?.angleKeyword).toBe(kw)
101121
})
102122
})
123+
124+
it('parses multiple gradients and takes the first one', () => {
125+
const multiGradient = `linear-gradient(to top right in oklab, oklch(79% 0.21 182 / 0.5), oklch(66% 0.32 259 / 0.5)), linear-gradient(276deg, red, blue)`
126+
const parsed = parseGradient(multiGradient)
127+
expect(parsed.type).toBe('linear')
128+
expect(parsed.linear?.angleKeyword).toBe('to top right')
129+
expect(parsed.space).toBe('oklab')
130+
expect(parsed.stops.length).toBe(2)
131+
})
132+
133+
it('parses radial gradients with length-based sizes', () => {
134+
const withLength = parseGradient('radial-gradient(100px circle, red, blue)')
135+
expect(withLength.type).toBe('radial')
136+
expect(withLength.radial?.size).toBe('100px')
137+
expect(withLength.radial?.shape).toBe('circle')
138+
139+
const withPair = parseGradient('radial-gradient(50px 100px, red, blue)')
140+
expect(withPair.radial?.size).toBe('50px 100px')
141+
})
103142
})
104143

src/lib/parseGradient.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,50 @@ function innerContent(input: string): string {
8686
return input.slice(start + 1, end).trim()
8787
}
8888

89+
// Helper to extract individual gradient functions from comma-separated list
90+
function extractGradients(input: string): string[] {
91+
const results: string[] = []
92+
let depth = 0
93+
let current = ''
94+
let i = 0
95+
96+
while (i < input.length) {
97+
const ch = input[i]
98+
99+
if (ch === '(') depth++
100+
else if (ch === ')') depth--
101+
102+
if (ch === ',' && depth === 0) {
103+
const trimmed = current.trim()
104+
if (trimmed && /^(linear|radial|conic)-gradient\s*\(/i.test(trimmed)) {
105+
results.push(trimmed)
106+
}
107+
current = ''
108+
} else {
109+
current += ch
110+
}
111+
i++
112+
}
113+
114+
// Don't forget the last one
115+
const trimmed = current.trim()
116+
if (trimmed && /^(linear|radial|conic)-gradient\s*\(/i.test(trimmed)) {
117+
results.push(trimmed)
118+
}
119+
120+
return results.length > 0 ? results : [input.trim()]
121+
}
122+
89123
export function parseGradient(input: string): ParsedGradient {
90124
// Strip trailing semicolons that may be present when copying from CSS rules
91125
const cleanedInput = input.trim().replace(/;+$/, '')
92-
const type = classifyFunction(cleanedInput)
93-
const body = innerContent(cleanedInput)
126+
127+
// Extract first gradient from potentially multiple gradients
128+
const gradients = extractGradients(cleanedInput)
129+
const firstGradient = gradients[0]
130+
131+
const type = classifyFunction(firstGradient)
132+
const body = innerContent(firstGradient)
94133

95134
// split by top-level commas
96135
const segments = splitTopLevel(body, ',')
@@ -169,12 +208,18 @@ export function parseGradient(input: string): ParsedGradient {
169208
}
170209
} else if (type === 'radial') {
171210
const shape = /(circle|ellipse)/i.exec(prelude)?.[1]?.toLowerCase() as 'circle'|'ellipse'|undefined
172-
// size keywords or length pairs
211+
// size keywords or length/percentage values
173212
const sizeKw = /(closest-side|closest-corner|farthest-side|farthest-corner)/i.exec(prelude)?.[1]
174213
let size: string | undefined = sizeKw?.toLowerCase()
175214
if (!size) {
215+
// Try to match explicit size: single length (for circle) or pair of lengths (for ellipse)
216+
const singleSize = prelude.match(/\b(\d+(?:\.\d+)?(?:px|em|rem|vw|vh|%))(?!\s+\d)/i)
176217
const pair = prelude.match(/\b(\d+(?:\.\d+)?(?:%|px|em|rem|vw|vh))\s+(\d+(?:\.\d+)?(?:%|px|em|rem|vw|vh))\b/)
177-
if (pair) size = `${pair[1]} ${pair[2]}`
218+
if (pair) {
219+
size = `${pair[1]} ${pair[2]}`
220+
} else if (singleSize) {
221+
size = singleSize[1]
222+
}
178223
}
179224
const { namedPosition, position } = extractPosition(prelude)
180225
radial = {
@@ -273,3 +318,18 @@ export function parseGradient(input: string): ParsedGradient {
273318
}
274319
}
275320

321+
// Parse multiple gradients from a comma-separated string
322+
export function parseMultipleGradients(input: string): ParsedGradient[] {
323+
const cleanedInput = input.trim().replace(/;+$/, '')
324+
const gradients = extractGradients(cleanedInput)
325+
326+
return gradients.map(g => {
327+
try {
328+
return parseGradient(g)
329+
} catch (e) {
330+
// Skip invalid gradients
331+
return null
332+
}
333+
}).filter(Boolean) as ParsedGradient[]
334+
}
335+

0 commit comments

Comments
 (0)