@nexxtmove/ui 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue.d.ts +2 -2
  2. package/dist/components/editor/molecules/EditorBlur/EditorBlur.vue.d.ts +2 -2
  3. package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +44 -20
  4. package/dist/components/editor/molecules/EditorColor/_ColorField.vue.d.ts +66 -0
  5. package/dist/components/editor/molecules/EditorColor/colorClass.d.ts +46 -0
  6. package/dist/components/editor/molecules/EditorColor/gradientClass.d.ts +80 -0
  7. package/dist/components/editor/molecules/EditorGap/EditorGap.vue.d.ts +2 -2
  8. package/dist/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue.d.ts +2 -2
  9. package/dist/components/editor/molecules/EditorShadow/EditorShadow.vue.d.ts +2 -2
  10. package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +58 -2
  11. package/dist/components/organisms/ColorPicker/_GradientField.vue.d.ts +23 -0
  12. package/dist/components/organisms/ColorPicker/_GradientPreview.vue.d.ts +11 -0
  13. package/dist/components/organisms/ColorPicker/_GradientStopRow.vue.d.ts +21 -0
  14. package/dist/components/organisms/ColorPicker/_GradientStops.vue.d.ts +19 -0
  15. package/dist/components/organisms/ColorPicker/gradient.d.ts +189 -0
  16. package/dist/components/organisms/ColorPicker/gradientGeometry.d.ts +101 -0
  17. package/dist/components/organisms/ColorPicker/useGradientEditor.d.ts +89 -0
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.js +3392 -2082
  20. package/package.json +1 -1
  21. package/src/components/editor/molecules/EditorColor/EditorColor.vue +83 -171
  22. package/src/components/editor/molecules/EditorColor/_ColorField.vue +445 -0
  23. package/src/components/editor/molecules/EditorColor/colorClass.ts +61 -0
  24. package/src/components/editor/molecules/EditorColor/gradientClass.ts +225 -0
  25. package/src/components/molecules/ToggleGroup/ToggleGroup.vue +6 -2
  26. package/src/components/organisms/ColorPicker/ColorPicker.vue +673 -271
  27. package/src/components/organisms/ColorPicker/_GradientField.vue +93 -0
  28. package/src/components/organisms/ColorPicker/_GradientPreview.vue +401 -0
  29. package/src/components/organisms/ColorPicker/_GradientStopRow.vue +181 -0
  30. package/src/components/organisms/ColorPicker/_GradientStops.vue +218 -0
  31. package/src/components/organisms/ColorPicker/gradient.ts +303 -0
  32. package/src/components/organisms/ColorPicker/gradientGeometry.ts +216 -0
  33. package/src/components/organisms/ColorPicker/useGradientEditor.ts +358 -0
  34. package/src/index.ts +25 -0
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Geometry of the gradient preview: where a stop renders in a square of side
3
+ * `S`, and which angle, centre and positions a set of points stands for. The
4
+ * square's origin is its top-left corner with y pointing down; its centre is
5
+ * C = (S/2, S/2). Every derivation works on the rounded values that are
6
+ * written to the model, so the points and the rendered gradient always agree.
7
+ *
8
+ * Linear (CSS: 0° is up, clockwise): direction d = (sin θ, −cos θ), length of
9
+ * the gradient line L = S·(|sin θ| + |cos θ|), and a point P sits at
10
+ * t = 0.5 + ((P − C)·d) / L.
11
+ *
12
+ * Radial (`ellipse farthest-corner`): an ellipse with the proportions of
13
+ * `closest-side` that runs through the farthest corner, and t is the
14
+ * normalized distance from the centre.
15
+ */
16
+
17
+ export interface Point {
18
+ x: number
19
+ y: number
20
+ }
21
+
22
+ const toRadians = (degrees: number) => (degrees * Math.PI) / 180
23
+
24
+ /** A rounded percentage, clamped to 0–100. */
25
+ const toPercent = (t: number) => Math.min(100, Math.max(0, Math.round(100 * t)))
26
+
27
+ // ── Linear ────────────────────────────────────────────────────────────────
28
+
29
+ export const linearDirection = (angle: number): Point => {
30
+ const radians = toRadians(angle)
31
+ return { x: Math.sin(radians), y: -Math.cos(radians) }
32
+ }
33
+
34
+ /** Length of the CSS gradient line for `angle` in a square of side `size`. */
35
+ export const linearLength = (angle: number, size: number) => {
36
+ const radians = toRadians(angle)
37
+ return size * (Math.abs(Math.sin(radians)) + Math.abs(Math.cos(radians)))
38
+ }
39
+
40
+ /** Where on the gradient line a point projects, 0–1 inside the square (unclamped). */
41
+ export const linearT = (angle: number, point: Point, size: number) => {
42
+ const d = linearDirection(angle)
43
+ const c = size / 2
44
+ return 0.5 + ((point.x - c) * d.x + (point.y - c) * d.y) / linearLength(angle, size)
45
+ }
46
+
47
+ /** The position a point stands for: a whole percentage, 0–100. */
48
+ export const linearPosition = (angle: number, point: Point, size: number) =>
49
+ toPercent(linearT(angle, point, size))
50
+
51
+ /** The canonical point of a position: on the line through the middle. */
52
+ export const linearPoint = (angle: number, position: number, size: number): Point => {
53
+ const d = linearDirection(angle)
54
+ const offset = (position / 100 - 0.5) * linearLength(angle, size)
55
+ return { x: size / 2 + offset * d.x, y: size / 2 + offset * d.y }
56
+ }
57
+
58
+ /** Whether a point lies in the square (edges included). */
59
+ export const isInSquare = (point: Point, size: number) =>
60
+ point.x >= 0 && point.x <= size && point.y >= 0 && point.y <= size
61
+
62
+ /** A pointer clamped onto the square, so a dragged point never leaves it. */
63
+ export const clampToSquare = (point: Point, size: number): Point => ({
64
+ x: Math.min(size, Math.max(0, point.x)),
65
+ y: Math.min(size, Math.max(0, point.y)),
66
+ })
67
+
68
+ /**
69
+ * Where the handle of a position is drawn: its canonical point, or -- when that
70
+ * lies outside the square, as 0 % and 100 % do at angles off the axes -- where
71
+ * the gradient line leaves the square. The handle is a grip; the class and the
72
+ * numbers stay the real CSS values.
73
+ */
74
+ export const linearHandlePoint = (angle: number, position: number, size: number): Point => {
75
+ const d = linearDirection(angle)
76
+ const half = size / 2
77
+ const reach = Math.min(
78
+ Math.abs(d.x) > 1e-9 ? half / Math.abs(d.x) : Infinity,
79
+ Math.abs(d.y) > 1e-9 ? half / Math.abs(d.y) : Infinity,
80
+ )
81
+ const offset = (position / 100 - 0.5) * linearLength(angle, size)
82
+ const clamped = Math.min(reach, Math.max(-reach, offset))
83
+ return { x: half + clamped * d.x, y: half + clamped * d.y }
84
+ }
85
+
86
+ /**
87
+ * The angle from the from-point to the to-point, in whole degrees 0–359. Two
88
+ * points closer than `minDistance` say nothing about a direction, so the
89
+ * previous angle stays.
90
+ */
91
+ export const angleBetween = (from: Point, to: Point, previous: number, minDistance: number) => {
92
+ const dx = to.x - from.x
93
+ const dy = to.y - from.y
94
+ if (Math.hypot(dx, dy) < minDistance) return previous
95
+ const degrees = Math.round((Math.atan2(dx, -dy) * 180) / Math.PI)
96
+ return ((degrees % 360) + 360) % 360
97
+ }
98
+
99
+ /** How far a point lies along the segment from `a` to `b`, clamped to 0–1. */
100
+ export const projectOnSegment = (point: Point, a: Point, b: Point) => {
101
+ const dx = b.x - a.x
102
+ const dy = b.y - a.y
103
+ const lengthSquared = dx * dx + dy * dy
104
+ if (lengthSquared === 0) return 0.5
105
+ const f = ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSquared
106
+ return Math.min(1, Math.max(0, f))
107
+ }
108
+
109
+ /** The point at fraction `f` from `a` to `b`. */
110
+ export const lerpPoint = (a: Point, b: Point, f: number): Point => ({
111
+ x: a.x + f * (b.x - a.x),
112
+ y: a.y + f * (b.y - a.y),
113
+ })
114
+
115
+ /** Where the via stop sits between from and to: (via − from) / (to − from), or 0.5. */
116
+ export const viaFraction = (from: number, via: number, to: number) =>
117
+ to === from ? 0.5 : (via - from) / (to - from)
118
+
119
+ /**
120
+ * The angle and positions a set of points stands for: the angle from the
121
+ * from-point to the to-point, each position the projection of its point on the
122
+ * line of that rounded angle, and via at fraction `f` of the segment.
123
+ */
124
+ export const linearFromPoints = (options: {
125
+ from: Point
126
+ to: Point
127
+ viaFraction?: number
128
+ previousAngle: number
129
+ size: number
130
+ minDistance: number
131
+ }) => {
132
+ const { from, to, viaFraction: f, previousAngle, size, minDistance } = options
133
+ const angle = angleBetween(from, to, previousAngle, minDistance)
134
+ return {
135
+ angle,
136
+ from: linearPosition(angle, from, size),
137
+ via: f === undefined ? undefined : linearPosition(angle, lerpPoint(from, to, f), size),
138
+ to: linearPosition(angle, to, size),
139
+ }
140
+ }
141
+
142
+ // ── Radial ────────────────────────────────────────────────────────────────
143
+
144
+ export interface RadialEllipse {
145
+ /** Centre in px. */
146
+ x: number
147
+ y: number
148
+ /** Radii in px. */
149
+ rx: number
150
+ ry: number
151
+ }
152
+
153
+ /**
154
+ * Clamps a centre percentage to 1–99: at 0 or 100 the closest side is 0 and
155
+ * CSS degenerates the ellipse.
156
+ */
157
+ export const clampCenter = (value: number) => Math.min(99, Math.max(1, Math.round(value)))
158
+
159
+ /** The `farthest-corner` ellipse of a centre at (cx %, cy %) in a square of side `size`. */
160
+ export const radialEllipse = (cx: number, cy: number, size: number): RadialEllipse => {
161
+ const x = (clampCenter(cx) * size) / 100
162
+ const y = (clampCenter(cy) * size) / 100
163
+ const k = Math.min(x, size - x) / Math.min(y, size - y)
164
+ const dx = Math.max(x, size - x)
165
+ const dy = Math.max(y, size - y)
166
+ const ry = Math.sqrt((dx / k) ** 2 + dy ** 2)
167
+ return { x, y, rx: k * ry, ry }
168
+ }
169
+
170
+ /** The normalized distance of a point from the centre; 1 on the ellipse (unclamped). */
171
+ export const radialT = (ellipse: RadialEllipse, point: Point) =>
172
+ Math.hypot((point.x - ellipse.x) / ellipse.rx, (point.y - ellipse.y) / ellipse.ry)
173
+
174
+ export const radialPosition = (ellipse: RadialEllipse, point: Point) =>
175
+ toPercent(radialT(ellipse, point))
176
+
177
+ /** The direction of the ray through a point, in the ellipse's own coordinates. */
178
+ export const radialAngle = (ellipse: RadialEllipse, point: Point) =>
179
+ Math.atan2((point.y - ellipse.y) / ellipse.ry, (point.x - ellipse.x) / ellipse.rx)
180
+
181
+ /** The ray towards the farthest corner, where 100 % falls exactly on the corner. */
182
+ export const farthestCornerAngle = (ellipse: RadialEllipse, size: number) =>
183
+ radialAngle(ellipse, {
184
+ x: ellipse.x <= size / 2 ? size : 0,
185
+ y: ellipse.y <= size / 2 ? size : 0,
186
+ })
187
+
188
+ /**
189
+ * The ray the stops are drawn on: `psi` while the farthest stop (`position`)
190
+ * still lies inside the square, otherwise the ray to the farthest corner. That
191
+ * one always fits: 100 % lands exactly on the corner. A ψ from a drag, or one
192
+ * left over after the centre moved, could otherwise put a point outside the
193
+ * preview, over the controls around it.
194
+ */
195
+ export const radialRay = (
196
+ ellipse: RadialEllipse,
197
+ psi: number | null,
198
+ position: number,
199
+ size: number,
200
+ ) => {
201
+ if (psi === null) return farthestCornerAngle(ellipse, size)
202
+ const end = radialPoint(ellipse, psi, position)
203
+ const slack = size * 1e-6
204
+ const inside =
205
+ end.x >= -slack && end.x <= size + slack && end.y >= -slack && end.y <= size + slack
206
+ return inside ? psi : farthestCornerAngle(ellipse, size)
207
+ }
208
+
209
+ /** The point of a position on the ray `psi`: M + t·(E − M). */
210
+ export const radialPoint = (ellipse: RadialEllipse, psi: number, position: number): Point => {
211
+ const t = position / 100
212
+ return {
213
+ x: ellipse.x + t * ellipse.rx * Math.cos(psi),
214
+ y: ellipse.y + t * ellipse.ry * Math.sin(psi),
215
+ }
216
+ }
@@ -0,0 +1,358 @@
1
+ import { computed, ref, watch } from 'vue'
2
+ import type { Ref } from 'vue'
3
+ import {
4
+ DEFAULT_ANGLE,
5
+ DEFAULT_CENTER,
6
+ DEFAULT_GRADIENT,
7
+ MAX_STOPS,
8
+ MIN_STOPS,
9
+ clampPercent,
10
+ colorAt,
11
+ normalizeHex,
12
+ wrapAngle,
13
+ } from './gradient'
14
+ import type { ColorPickerGradient, ColorPickerPreset } from './gradient'
15
+ import { clampCenter } from './gradientGeometry'
16
+
17
+ /** A stop as the editor keeps it: with an id, so it stays itself while it moves. */
18
+ export interface GradientEditorStop {
19
+ id: number
20
+ color: string
21
+ position: number
22
+ preset?: ColorPickerPreset
23
+ }
24
+
25
+ /**
26
+ * Compares two gradients on what they would store: a linear gradient has no
27
+ * centre and a radial one no angle, so those are left out.
28
+ */
29
+ const keyOf = (gradient: ColorPickerGradient) =>
30
+ JSON.stringify([
31
+ gradient.type,
32
+ gradient.type === 'linear' ? gradient.angle : [gradient.centerX, gradient.centerY],
33
+ gradient.stops.map(({ color, position, preset }) => [
34
+ normalizeHex(color),
35
+ position,
36
+ preset?.key ?? null,
37
+ preset?.label ?? null,
38
+ ]),
39
+ ])
40
+
41
+ /**
42
+ * State and actions of the picker's gradient mode. The stops are kept in the
43
+ * order of their handles in the DOM, which is only brought back in line with
44
+ * their positions once an action is over (`settle`): a dragged stop that passes
45
+ * another keeps its id, colour, selection and focus. What reaches the model is
46
+ * always in order of position, ties in the order they had before.
47
+ *
48
+ * `value` is the model. A change from outside is merged into the stops that are
49
+ * there, so ids survive a round trip through the parent; a different number of
50
+ * stops starts over. While `value` is unset `start` (or the default gradient)
51
+ * is shown and nothing is written until the first action.
52
+ */
53
+ export const useGradientEditor = (options: {
54
+ value: Ref<ColorPickerGradient | undefined>
55
+ selectedIndex: Ref<number>
56
+ /** What to show while `value` is unset; the default gradient without it. */
57
+ start?: () => ColorPickerGradient | undefined
58
+ }) => {
59
+ const { value, selectedIndex, start } = options
60
+
61
+ const type = ref<ColorPickerGradient['type']>(DEFAULT_GRADIENT.type)
62
+ const angle = ref(DEFAULT_ANGLE)
63
+ const centerX = ref(DEFAULT_CENTER)
64
+ const centerY = ref(DEFAULT_CENTER)
65
+ const stops = ref<GradientEditorStop[]>([])
66
+ const selectedId = ref(0)
67
+ /** In a radial gradient the centre can be the selected point instead of a stop. */
68
+ const centerSelected = ref(false)
69
+
70
+ let nextId = 1
71
+ let lastKey: string | null = null
72
+
73
+ /** The stops in order of position; equal positions keep their order. */
74
+ const sorted = computed(() =>
75
+ stops.value
76
+ .map((stop, index) => ({ stop, index }))
77
+ .sort((a, b) => a.stop.position - b.stop.position || a.index - b.index)
78
+ .map(({ stop }) => stop),
79
+ )
80
+
81
+ const snapshot = computed<ColorPickerGradient>(() => ({
82
+ type: type.value,
83
+ angle: angle.value,
84
+ centerX: centerX.value,
85
+ centerY: centerY.value,
86
+ stops: sorted.value.map(({ color, position, preset }) =>
87
+ preset ? { color, position, preset } : { color, position },
88
+ ),
89
+ }))
90
+
91
+ const selected = computed(() => stops.value.find((stop) => stop.id === selectedId.value))
92
+ const selectedOrder = computed(() => sorted.value.findIndex((s) => s.id === selectedId.value))
93
+ const canAdd = computed(() => stops.value.length < MAX_STOPS)
94
+ const canRemove = computed(() => stops.value.length > MIN_STOPS)
95
+
96
+ /** The colour of the next stop on position, or of the one before for the last. */
97
+ const neighbourColor = computed(() => {
98
+ const index = selectedOrder.value
99
+ const neighbour = sorted.value[index + 1] ?? sorted.value[index - 1]
100
+ return neighbour?.color
101
+ })
102
+
103
+ const syncSelectedIndex = () => {
104
+ const index = selectedOrder.value
105
+ if (index !== -1 && selectedIndex.value !== index) selectedIndex.value = index
106
+ }
107
+
108
+ const commit = () => {
109
+ value.value = snapshot.value
110
+ lastKey = keyOf(snapshot.value)
111
+ syncSelectedIndex()
112
+ }
113
+
114
+ const selectIndex = (index: number) => {
115
+ const stop = sorted.value[Math.min(Math.max(index, 0), sorted.value.length - 1)]
116
+ if (stop) selectedId.value = stop.id
117
+ }
118
+
119
+ const load = (gradient: ColorPickerGradient) => {
120
+ type.value = gradient.type
121
+ angle.value = gradient.angle
122
+ centerX.value = gradient.centerX
123
+ centerY.value = gradient.centerY
124
+ stops.value = gradient.stops.map((stop) => ({ ...stop, id: nextId++ }))
125
+ selectIndex(selectedIndex.value)
126
+ // A different number of stops can clamp the index (3 → 2 after an undo):
127
+ // the parent reads it, so it gets the clamped one back.
128
+ syncSelectedIndex()
129
+ }
130
+
131
+ const merge = (gradient: ColorPickerGradient) => {
132
+ if (keyOf(gradient) === lastKey) return
133
+ lastKey = keyOf(gradient)
134
+ if (gradient.stops.length !== stops.value.length) return load(gradient)
135
+ type.value = gradient.type
136
+ if (gradient.type === 'linear') angle.value = gradient.angle
137
+ else {
138
+ centerX.value = gradient.centerX
139
+ centerY.value = gradient.centerY
140
+ }
141
+ const current = sorted.value
142
+ gradient.stops.forEach((incoming, index) => {
143
+ const stop = current[index]
144
+ if (!stop) return
145
+ stop.color = incoming.color
146
+ stop.position = incoming.position
147
+ stop.preset = incoming.preset
148
+ })
149
+ settle()
150
+ }
151
+
152
+ watch(
153
+ [value, () => (value.value ? undefined : start?.())],
154
+ ([gradient, startValue]) => {
155
+ if (gradient) return merge(gradient)
156
+ lastKey = null
157
+ load(startValue ?? DEFAULT_GRADIENT)
158
+ },
159
+ { immediate: true },
160
+ )
161
+
162
+ // An index from outside can be out of range (7 with three stops): it is
163
+ // clamped, and the parent gets the clamped index back.
164
+ watch(selectedIndex, (index) => {
165
+ if (index === selectedOrder.value) return
166
+ selectIndex(index)
167
+ syncSelectedIndex()
168
+ })
169
+
170
+ /** Puts the handles back in order of position, once an action is over. */
171
+ const settle = () => {
172
+ const order = sorted.value
173
+ if (order.some((stop, index) => stop !== stops.value[index])) stops.value = [...order]
174
+ }
175
+
176
+ const select = (id: number) => {
177
+ selectedId.value = id
178
+ centerSelected.value = false
179
+ syncSelectedIndex()
180
+ }
181
+
182
+ const selectCenter = () => {
183
+ centerSelected.value = true
184
+ }
185
+
186
+ const setType = (next: ColorPickerGradient['type']) => {
187
+ if (next === type.value) return
188
+ type.value = next
189
+ centerSelected.value = false
190
+ commit()
191
+ }
192
+
193
+ const setAngle = (next: number) => {
194
+ const wrapped = wrapAngle(next)
195
+ if (wrapped === angle.value && value.value) return
196
+ angle.value = wrapped
197
+ commit()
198
+ }
199
+
200
+ /** Sets the centre of a radial gradient, clamped to 1–99 (at 0 or 100 CSS degenerates it). */
201
+ const setCenter = (x: number, y: number) => {
202
+ const nextX = clampCenter(x)
203
+ const nextY = clampCenter(y)
204
+ if (nextX === centerX.value && nextY === centerY.value && value.value) return
205
+ centerX.value = nextX
206
+ centerY.value = nextY
207
+ commit()
208
+ }
209
+
210
+ const moveStop = (id: number, position: number) => {
211
+ const stop = stops.value.find((candidate) => candidate.id === id)
212
+ const next = clampPercent(position)
213
+ if (!stop || (stop.position === next && value.value)) return
214
+ stop.position = next
215
+ commit()
216
+ }
217
+
218
+ /**
219
+ * Sets several positions at once, and the angle with them: a drag in the
220
+ * preview moves the points of a whole gradient in one step.
221
+ */
222
+ const setGeometry = (positions: Map<number, number>, nextAngle?: number) => {
223
+ let changed = false
224
+ positions.forEach((position, id) => {
225
+ const stop = stops.value.find((candidate) => candidate.id === id)
226
+ const next = clampPercent(position)
227
+ if (stop && stop.position !== next) {
228
+ stop.position = next
229
+ changed = true
230
+ }
231
+ })
232
+ if (nextAngle !== undefined && wrapAngle(nextAngle) !== angle.value) {
233
+ angle.value = wrapAngle(nextAngle)
234
+ changed = true
235
+ }
236
+ if (changed || !value.value) commit()
237
+ }
238
+
239
+ /**
240
+ * Adds a stop at `position`, or halfway between the first and the last, in
241
+ * the colour the gradient already has there so it does not change. Between
242
+ * two stops bound to the same preset the new stop is bound to it too.
243
+ * Returns the new stop's id, or `null` when there are already three.
244
+ */
245
+ const addStop = (position?: number) => {
246
+ if (!canAdd.value) return null
247
+ const order = sorted.value
248
+ const first = order[0]
249
+ const last = order[order.length - 1]
250
+ if (!first || !last) return null
251
+ const at = clampPercent(position ?? (first.position + last.position) / 2)
252
+ const preset = first.preset && first.preset.key === last.preset?.key ? first.preset : undefined
253
+ const stop: GradientEditorStop = {
254
+ id: nextId++,
255
+ color: preset ? first.color : colorAt(order, at),
256
+ position: at,
257
+ ...(preset ? { preset } : {}),
258
+ }
259
+ stops.value = [...stops.value, stop]
260
+ settle()
261
+ selectedId.value = stop.id
262
+ commit()
263
+ return stop.id
264
+ }
265
+
266
+ /**
267
+ * Removes a stop; only possible with three. The other two keep their
268
+ * positions. Returns the id of the neighbour that takes the selection -- the
269
+ * one before, or the one after when the first was removed -- or `null`.
270
+ */
271
+ const removeStop = (id: number) => {
272
+ if (!canRemove.value) return null
273
+ const order = sorted.value
274
+ const index = order.findIndex((stop) => stop.id === id)
275
+ if (index === -1) return null
276
+ const neighbour = order[index - 1] ?? order[index + 1]
277
+ if (!neighbour) return null
278
+ stops.value = order.filter((stop) => stop.id !== id)
279
+ selectedId.value = neighbour.id
280
+ commit()
281
+ return neighbour.id
282
+ }
283
+
284
+ /**
285
+ * Mirrors the stops: every position p becomes 100 - p and the order turns
286
+ * around. Angle, centre, selection and focus stay. A gradient that is its own
287
+ * mirror image is left alone, so the consumer records no step.
288
+ */
289
+ const reverse = () => {
290
+ const mirrored = [...sorted.value]
291
+ .reverse()
292
+ .map((stop) => ({ ...stop, position: 100 - stop.position }))
293
+ const before = keyOf(snapshot.value)
294
+ const after = keyOf({ ...snapshot.value, stops: mirrored })
295
+ if (before === after && value.value) return
296
+ stops.value = mirrored
297
+ commit()
298
+ }
299
+
300
+ /** Gives a stop a colour, or binds it to a preset in that preset's colour. */
301
+ const setColorOf = (id: number, color: string, preset?: ColorPickerPreset) => {
302
+ const stop = stops.value.find((candidate) => candidate.id === id)
303
+ if (!stop) return
304
+ const hex = normalizeHex(color)
305
+ if (stop.color === hex && stop.preset?.key === preset?.key && value.value) return
306
+ stop.color = hex
307
+ stop.preset = preset
308
+ commit()
309
+ }
310
+
311
+ /** Gives the selected stop a colour, or binds it to a preset in that preset's colour. */
312
+ const setStopColor = (color: string, preset?: ColorPickerPreset) =>
313
+ setColorOf(selectedId.value, color, preset)
314
+
315
+ /**
316
+ * Forgets what is only remembered for the panel session: the centre of a
317
+ * linear gradient and the angle of a radial one.
318
+ */
319
+ const resetSession = () => {
320
+ if (type.value === 'linear') {
321
+ centerX.value = DEFAULT_CENTER
322
+ centerY.value = DEFAULT_CENTER
323
+ } else angle.value = DEFAULT_ANGLE
324
+ }
325
+
326
+ return {
327
+ type,
328
+ angle,
329
+ centerX,
330
+ centerY,
331
+ stops,
332
+ sorted,
333
+ snapshot,
334
+ selectedId,
335
+ centerSelected,
336
+ selected,
337
+ selectedOrder,
338
+ canAdd,
339
+ canRemove,
340
+ neighbourColor,
341
+ select,
342
+ selectCenter,
343
+ settle,
344
+ setType,
345
+ setAngle,
346
+ setCenter,
347
+ moveStop,
348
+ setGeometry,
349
+ addStop,
350
+ removeStop,
351
+ reverse,
352
+ setStopColor,
353
+ setColorOf,
354
+ resetSession,
355
+ }
356
+ }
357
+
358
+ export type GradientEditor = ReturnType<typeof useGradientEditor>
package/src/index.ts CHANGED
@@ -45,6 +45,26 @@ export type {
45
45
  NexxtPlaceholderInputMessages,
46
46
  NexxtPlaceholderInputProps,
47
47
  } from './components/molecules/PlaceholderInput/PlaceholderInput.vue'
48
+ export type {
49
+ ColorFormat,
50
+ ColorPickerGradient,
51
+ ColorPickerGradientMessages,
52
+ ColorPickerGradientStop,
53
+ ColorPickerFillMode,
54
+ ColorPickerPreset,
55
+ ColorSuggestions,
56
+ } from './components/organisms/ColorPicker/ColorPicker.vue'
57
+ export {
58
+ gradientClassToCss,
59
+ looksLikeGradientClass,
60
+ parseGradientClass,
61
+ } from './components/editor/molecules/EditorColor/gradientClass'
62
+ export type {
63
+ GradientClass,
64
+ GradientClassColor,
65
+ GradientClassStop,
66
+ GradientClassType,
67
+ } from './components/editor/molecules/EditorColor/gradientClass'
48
68
  export { EDITOR_ALIGNMENT_POSITIONS } from './components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.vue'
49
69
  export type {
50
70
  EditorAlignmentDirection,
@@ -87,9 +107,14 @@ export type {
87
107
  EditorGapValue,
88
108
  } from './components/editor/molecules/EditorGap/EditorGap.vue'
89
109
  export type {
110
+ EditorColorFillMode,
111
+ EditorColorGradientMessages,
112
+ EditorColorGradientValue,
113
+ EditorColorLinearGradientValue,
90
114
  EditorColorLiteralValue,
91
115
  EditorColorMessages,
92
116
  EditorColorProperty,
117
+ EditorColorRadialGradientValue,
93
118
  EditorColorRole,
94
119
  EditorColorRoleValue,
95
120
  EditorColorThemeRole,