@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.
- package/dist/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorBlur/EditorBlur.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +44 -20
- package/dist/components/editor/molecules/EditorColor/_ColorField.vue.d.ts +66 -0
- package/dist/components/editor/molecules/EditorColor/colorClass.d.ts +46 -0
- package/dist/components/editor/molecules/EditorColor/gradientClass.d.ts +80 -0
- package/dist/components/editor/molecules/EditorGap/EditorGap.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorShadow/EditorShadow.vue.d.ts +2 -2
- package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +58 -2
- package/dist/components/organisms/ColorPicker/_GradientField.vue.d.ts +23 -0
- package/dist/components/organisms/ColorPicker/_GradientPreview.vue.d.ts +11 -0
- package/dist/components/organisms/ColorPicker/_GradientStopRow.vue.d.ts +21 -0
- package/dist/components/organisms/ColorPicker/_GradientStops.vue.d.ts +19 -0
- package/dist/components/organisms/ColorPicker/gradient.d.ts +189 -0
- package/dist/components/organisms/ColorPicker/gradientGeometry.d.ts +101 -0
- package/dist/components/organisms/ColorPicker/useGradientEditor.d.ts +89 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3392 -2082
- package/package.json +1 -1
- package/src/components/editor/molecules/EditorColor/EditorColor.vue +83 -171
- package/src/components/editor/molecules/EditorColor/_ColorField.vue +445 -0
- package/src/components/editor/molecules/EditorColor/colorClass.ts +61 -0
- package/src/components/editor/molecules/EditorColor/gradientClass.ts +225 -0
- package/src/components/molecules/ToggleGroup/ToggleGroup.vue +6 -2
- package/src/components/organisms/ColorPicker/ColorPicker.vue +673 -271
- package/src/components/organisms/ColorPicker/_GradientField.vue +93 -0
- package/src/components/organisms/ColorPicker/_GradientPreview.vue +401 -0
- package/src/components/organisms/ColorPicker/_GradientStopRow.vue +181 -0
- package/src/components/organisms/ColorPicker/_GradientStops.vue +218 -0
- package/src/components/organisms/ColorPicker/gradient.ts +303 -0
- package/src/components/organisms/ColorPicker/gradientGeometry.ts +216 -0
- package/src/components/organisms/ColorPicker/useGradientEditor.ts +358 -0
- 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,
|