@nexxtmove/ui 2.1.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/atoms/EditorSettingsBlock/EditorSettingsBlock.vue.d.ts +6 -0
- package/dist/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.vue.d.ts +2 -2
- package/dist/components/editor/molecules/EditorBlur/EditorBlur.vue.d.ts +106 -0
- package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +49 -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/molecules/ToggleGroup/ToggleGroup.vue.d.ts +7 -0
- package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +61 -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 +7 -1
- package/dist/index.js +3591 -2182
- package/dist/nuxt.js +1 -0
- package/package.json +1 -1
- package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue +15 -2
- package/src/components/editor/molecules/EditorBlur/EditorBlur.vue +160 -0
- package/src/components/editor/molecules/EditorColor/EditorColor.vue +90 -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 +18 -3
- package/src/components/organisms/ColorPicker/ColorPicker.vue +686 -257
- 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/components.json +1 -0
- package/src/index.ts +32 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/** The step of Shift (and PageUp/PageDown) on a stop handle, in percent. */
|
|
3
|
+
const STOP_STEP = 10
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<script lang="ts" setup>
|
|
7
|
+
import { computed, nextTick, useId, useTemplateRef } from 'vue'
|
|
8
|
+
import NexxtIcon from '../../atoms/Icon/Icon.vue'
|
|
9
|
+
import { CHECKERBOARD, fillMessage, stopBarCss } from './gradient'
|
|
10
|
+
import type { GradientCopy } from './gradient'
|
|
11
|
+
import type { GradientEditor, GradientEditorStop } from './useGradientEditor'
|
|
12
|
+
|
|
13
|
+
export interface NexxtGradientStopsProps {
|
|
14
|
+
/** The gradient state of the picker. */
|
|
15
|
+
editor: GradientEditor
|
|
16
|
+
/** Copy, already merged over the defaults. */
|
|
17
|
+
copy: GradientCopy
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The bar with a handle per stop, and under it the heading of the stop list
|
|
22
|
+
* with the add button. Each handle is a slider of its own (the APG pattern for a slider with
|
|
23
|
+
* several thumbs); reka's multi-thumb slider is not used because it sorts its
|
|
24
|
+
* values, so a handle would swap stops when two pass each other.
|
|
25
|
+
*/
|
|
26
|
+
defineOptions({ name: 'NexxtGradientStops' })
|
|
27
|
+
|
|
28
|
+
const { editor, copy } = defineProps<NexxtGradientStopsProps>()
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{
|
|
31
|
+
/** The add button was used; the parent adds the stop and focuses its row. */
|
|
32
|
+
add: []
|
|
33
|
+
/** Delete or Backspace on a handle; the parent removes the stop and moves the focus. */
|
|
34
|
+
remove: [id: number]
|
|
35
|
+
}>()
|
|
36
|
+
|
|
37
|
+
const REASON_ID = useId()
|
|
38
|
+
|
|
39
|
+
const bar = useTemplateRef<HTMLElement>('bar')
|
|
40
|
+
const handles = new Map<number, HTMLElement>()
|
|
41
|
+
|
|
42
|
+
const handleRef = (id: number) => (element: unknown) => {
|
|
43
|
+
if (element instanceof HTMLElement) handles.set(id, element)
|
|
44
|
+
else handles.delete(id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Focuses the handle of a stop once the DOM has caught up with the state. */
|
|
48
|
+
const focusStop = async (id: number) => {
|
|
49
|
+
await nextTick()
|
|
50
|
+
handles.get(id)?.focus()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
defineExpose({ focusStop })
|
|
54
|
+
|
|
55
|
+
const barStyle = computed(() => ({
|
|
56
|
+
backgroundImage: `${stopBarCss(editor.snapshot.value)}, ${CHECKERBOARD}`,
|
|
57
|
+
backgroundSize: 'auto, 10px 10px',
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
const handleStyle = (stop: GradientEditorStop) => ({
|
|
61
|
+
left: `${stop.position}%`,
|
|
62
|
+
backgroundImage: `linear-gradient(${stop.color}, ${stop.color}), ${CHECKERBOARD}`,
|
|
63
|
+
backgroundSize: 'auto, 8px 8px',
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const nameOf = (stop: GradientEditorStop) =>
|
|
67
|
+
fillMessage(copy.stop, {
|
|
68
|
+
n: editor.sorted.value.indexOf(stop) + 1,
|
|
69
|
+
total: editor.stops.value.length,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const valueTextOf = (stop: GradientEditorStop) => {
|
|
73
|
+
if (stop.preset) return `${stop.position} %, ${stop.preset.label}`
|
|
74
|
+
const hex = stop.color.slice(0, 7)
|
|
75
|
+
const alpha = stop.color.length === 9 ? parseInt(stop.color.slice(7), 16) / 255 : 1
|
|
76
|
+
const opacity =
|
|
77
|
+
alpha < 1 ? `, ${fillMessage(copy.stopOpacity, { n: Math.round(alpha * 100) })}` : ''
|
|
78
|
+
return `${stop.position} %, ${hex}${opacity}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const positionAt = (event: PointerEvent) => {
|
|
82
|
+
const rect = bar.value?.getBoundingClientRect()
|
|
83
|
+
if (!rect || rect.width === 0) return 0
|
|
84
|
+
return ((event.clientX - rect.left) / rect.width) * 100
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The stop a pointer is moving, until it is released. */
|
|
88
|
+
let dragged: number | null = null
|
|
89
|
+
|
|
90
|
+
const startDrag = (event: PointerEvent, id: number, capture: HTMLElement) => {
|
|
91
|
+
capture.setPointerCapture?.(event.pointerId)
|
|
92
|
+
dragged = id
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const onHandlePointerDown = (event: PointerEvent, id: number) => {
|
|
96
|
+
if (event.button !== 0) return
|
|
97
|
+
// No default: it would hand the focus back to whatever the browser picks.
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
event.stopPropagation()
|
|
100
|
+
editor.select(id)
|
|
101
|
+
handles.get(id)?.focus()
|
|
102
|
+
startDrag(event, id, event.currentTarget as HTMLElement)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A click on the bar adds a stop there, and keeps dragging it while the button is down. */
|
|
106
|
+
const onBarPointerDown = async (event: PointerEvent) => {
|
|
107
|
+
if (event.button !== 0) return
|
|
108
|
+
event.preventDefault()
|
|
109
|
+
const id = editor.addStop(positionAt(event))
|
|
110
|
+
if (id === null) return
|
|
111
|
+
startDrag(event, id, event.currentTarget as HTMLElement)
|
|
112
|
+
await focusStop(id)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
116
|
+
if (dragged !== null) editor.moveStop(dragged, positionAt(event))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const onPointerUp = async () => {
|
|
120
|
+
if (dragged === null) return
|
|
121
|
+
const id = dragged
|
|
122
|
+
dragged = null
|
|
123
|
+
editor.settle()
|
|
124
|
+
await focusStop(id)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const add = () => {
|
|
128
|
+
if (editor.canAdd.value) emit('add')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const KEY_MOVES: Record<string, (position: number, shift: boolean) => number> = {
|
|
132
|
+
ArrowLeft: (p, shift) => p - (shift ? STOP_STEP : 1),
|
|
133
|
+
ArrowDown: (p, shift) => p - (shift ? STOP_STEP : 1),
|
|
134
|
+
ArrowRight: (p, shift) => p + (shift ? STOP_STEP : 1),
|
|
135
|
+
ArrowUp: (p, shift) => p + (shift ? STOP_STEP : 1),
|
|
136
|
+
PageUp: (p) => p + STOP_STEP,
|
|
137
|
+
PageDown: (p) => p - STOP_STEP,
|
|
138
|
+
Home: () => 0,
|
|
139
|
+
End: () => 100,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const onHandleKeydown = async (event: KeyboardEvent, stop: GradientEditorStop) => {
|
|
143
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
144
|
+
event.preventDefault()
|
|
145
|
+
if (editor.canRemove.value) emit('remove', stop.id)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const move = KEY_MOVES[event.key]
|
|
149
|
+
if (!move) return
|
|
150
|
+
event.preventDefault()
|
|
151
|
+
editor.moveStop(stop.id, move(stop.position, event.shiftKey))
|
|
152
|
+
editor.settle()
|
|
153
|
+
await focusStop(stop.id)
|
|
154
|
+
}
|
|
155
|
+
</script>
|
|
156
|
+
|
|
157
|
+
<template>
|
|
158
|
+
<!-- `mx-2.5`: half a handle, so a stop at 0 % or 100 % stays inside the column. -->
|
|
159
|
+
<div
|
|
160
|
+
ref="bar"
|
|
161
|
+
data-gradient-bar
|
|
162
|
+
class="relative mx-2.5 h-5 touch-none select-none"
|
|
163
|
+
:class="editor.canAdd.value ? 'cursor-copy' : 'cursor-default'"
|
|
164
|
+
@pointerdown="onBarPointerDown"
|
|
165
|
+
@pointermove="onPointerMove"
|
|
166
|
+
@pointerup="onPointerUp"
|
|
167
|
+
@lostpointercapture="onPointerUp"
|
|
168
|
+
>
|
|
169
|
+
<div class="absolute inset-x-0 inset-y-1 rounded-full" :style="barStyle" />
|
|
170
|
+
<span
|
|
171
|
+
v-for="stop in editor.stops.value"
|
|
172
|
+
:key="stop.id"
|
|
173
|
+
:ref="handleRef(stop.id)"
|
|
174
|
+
role="slider"
|
|
175
|
+
tabindex="0"
|
|
176
|
+
aria-orientation="horizontal"
|
|
177
|
+
aria-valuemin="0"
|
|
178
|
+
aria-valuemax="100"
|
|
179
|
+
:aria-label="nameOf(stop)"
|
|
180
|
+
:aria-valuenow="stop.position"
|
|
181
|
+
:aria-valuetext="valueTextOf(stop)"
|
|
182
|
+
:data-selected="stop.id === editor.selectedId.value || undefined"
|
|
183
|
+
class="absolute top-0 size-5 -translate-x-1/2 cursor-grab rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.5)] outline-cornflower-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 active:cursor-grabbing"
|
|
184
|
+
:class="{ 'z-10 ring-2 ring-cornflower-blue-600': stop.id === editor.selectedId.value }"
|
|
185
|
+
:style="handleStyle(stop)"
|
|
186
|
+
@pointerdown="onHandlePointerDown($event, stop.id)"
|
|
187
|
+
@pointermove="onPointerMove"
|
|
188
|
+
@pointerup="onPointerUp"
|
|
189
|
+
@lostpointercapture="onPointerUp"
|
|
190
|
+
@focus="editor.select(stop.id)"
|
|
191
|
+
@keydown="onHandleKeydown($event, stop)"
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="flex h-6 items-center gap-1">
|
|
196
|
+
<p class="min-w-0 flex-1 extra-small-normal text-gray-900">
|
|
197
|
+
{{ copy.stops }} <span class="text-gray-700">{{ copy.max }}</span>
|
|
198
|
+
</p>
|
|
199
|
+
<!--
|
|
200
|
+
`aria-disabled`, not `disabled`: the button stays focusable, so the reason
|
|
201
|
+
it does nothing can be heard.
|
|
202
|
+
-->
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
data-gradient-add
|
|
206
|
+
:aria-label="copy.addName"
|
|
207
|
+
:aria-disabled="editor.canAdd.value ? undefined : 'true'"
|
|
208
|
+
:aria-describedby="editor.canAdd.value ? undefined : REASON_ID"
|
|
209
|
+
class="flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 extra-small-normal text-gray-900 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-cornflower-blue-600"
|
|
210
|
+
:class="!editor.canAdd.value && 'cursor-not-allowed opacity-50 hover:bg-transparent'"
|
|
211
|
+
@click="add"
|
|
212
|
+
>
|
|
213
|
+
<NexxtIcon name="plus" aria-hidden="true" />
|
|
214
|
+
{{ copy.add }}
|
|
215
|
+
</button>
|
|
216
|
+
<span :id="REASON_ID" hidden>{{ copy.maxReason }}</span>
|
|
217
|
+
</div>
|
|
218
|
+
</template>
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gradient of NexxtColorPicker's gradient mode, as plain data. The picker
|
|
3
|
+
* knows no class names: a wrapper such as NexxtEditorColor turns this into
|
|
4
|
+
* whatever it stores.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A named colour a stop can be bound to, such as a theme role. Always opaque. */
|
|
8
|
+
export interface ColorPickerPreset {
|
|
9
|
+
/** What the wrapper knows the preset by, such as `'main'`. */
|
|
10
|
+
key: string
|
|
11
|
+
/** Name of the preset as the user reads it, announced on the stop's handle. */
|
|
12
|
+
label: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ColorPickerGradientStop {
|
|
16
|
+
/** The colour as `#rrggbb`, or `#rrggbbaa` below full opacity; lower case. */
|
|
17
|
+
color: string
|
|
18
|
+
/** Whole percentage, 0–100. */
|
|
19
|
+
position: number
|
|
20
|
+
/** Set when the stop is bound to a preset; `color` is then the preset's hex. */
|
|
21
|
+
preset?: ColorPickerPreset
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ColorPickerGradient {
|
|
25
|
+
type: 'linear' | 'radial'
|
|
26
|
+
/** Whole degrees, 0–359, clockwise from the top. Only used by a linear gradient. */
|
|
27
|
+
angle: number
|
|
28
|
+
/** Centre in whole percentages from the left. Only used by a radial gradient. */
|
|
29
|
+
centerX: number
|
|
30
|
+
/** Centre in whole percentages from the top. Only used by a radial gradient. */
|
|
31
|
+
centerY: number
|
|
32
|
+
/** Two or three stops, in order of position. */
|
|
33
|
+
stops: ColorPickerGradientStop[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const MIN_STOPS = 2
|
|
37
|
+
export const MAX_STOPS = 3
|
|
38
|
+
export const DEFAULT_ANGLE = 180
|
|
39
|
+
export const DEFAULT_CENTER = 50
|
|
40
|
+
|
|
41
|
+
/** What the gradient mode shows while there is no readable gradient. */
|
|
42
|
+
export const DEFAULT_GRADIENT: ColorPickerGradient = {
|
|
43
|
+
type: 'linear',
|
|
44
|
+
angle: DEFAULT_ANGLE,
|
|
45
|
+
centerX: DEFAULT_CENTER,
|
|
46
|
+
centerY: DEFAULT_CENTER,
|
|
47
|
+
stops: [
|
|
48
|
+
{ color: '#00000000', position: 0 },
|
|
49
|
+
{ color: '#00000099', position: 100 },
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Rounds and clamps to a whole percentage. */
|
|
54
|
+
export const clampPercent = (value: number) => Math.min(100, Math.max(0, Math.round(value)))
|
|
55
|
+
|
|
56
|
+
/** Rounds and wraps to a whole angle, 0–359: 360 is 0 and -10 is 350. */
|
|
57
|
+
export const wrapAngle = (value: number) => ((Math.round(value) % 360) + 360) % 360
|
|
58
|
+
|
|
59
|
+
/** A stop as CSS needs it: any CSS colour, and a position in percent. */
|
|
60
|
+
export interface CssGradientStop {
|
|
61
|
+
color: string
|
|
62
|
+
position: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The shape of a gradient as CSS needs it. */
|
|
66
|
+
export type CssGradientShape =
|
|
67
|
+
{ type: 'linear'; angle: number } | { type: 'radial'; centerX: number; centerY: number }
|
|
68
|
+
|
|
69
|
+
const stopList = (stops: CssGradientStop[]) =>
|
|
70
|
+
stops.map(({ color, position }) => `${color} ${position}%`).join(', ')
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A gradient as a CSS `background-image`, always interpolated in oklab and with
|
|
74
|
+
* every position written. This is how the consuming app draws a gradient class
|
|
75
|
+
* (as inline CSS), so the preview shows exactly what the app renders. For
|
|
76
|
+
* `bg-linear-*` and `bg-radial` it is also what the Tailwind class renders;
|
|
77
|
+
* for `bg-radial-[at_…]` Tailwind would interpolate in sRGB, the app does not.
|
|
78
|
+
* The position comes before the interpolation method, as in
|
|
79
|
+
* `radial-gradient(at 30% 70% in oklab, …)`; a centred radial gradient leaves
|
|
80
|
+
* the position out.
|
|
81
|
+
*/
|
|
82
|
+
export const buildGradientCss = (shape: CssGradientShape, stops: CssGradientStop[]) => {
|
|
83
|
+
const list = stopList(stops)
|
|
84
|
+
if (shape.type === 'linear') return `linear-gradient(${shape.angle}deg in oklab, ${list})`
|
|
85
|
+
return shape.centerX === DEFAULT_CENTER && shape.centerY === DEFAULT_CENTER
|
|
86
|
+
? `radial-gradient(in oklab, ${list})`
|
|
87
|
+
: `radial-gradient(at ${shape.centerX}% ${shape.centerY}% in oklab, ${list})`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The picker's gradient as CSS; see `buildGradientCss`. */
|
|
91
|
+
export const gradientCss = (gradient: ColorPickerGradient) =>
|
|
92
|
+
buildGradientCss(
|
|
93
|
+
gradient.type === 'linear'
|
|
94
|
+
? { type: 'linear', angle: gradient.angle }
|
|
95
|
+
: { type: 'radial', centerX: gradient.centerX, centerY: gradient.centerY },
|
|
96
|
+
gradient.stops,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
/** The stops left to right in oklab, for the stop bar. */
|
|
100
|
+
export const stopBarCss = (gradient: ColorPickerGradient) =>
|
|
101
|
+
`linear-gradient(to right in oklab, ${stopList(gradient.stops)})`
|
|
102
|
+
|
|
103
|
+
/** The checkerboard that shows through a translucent colour. */
|
|
104
|
+
export const CHECKERBOARD = 'repeating-conic-gradient(#e5e7eb 0% 25%, #ffffff 0% 50%)'
|
|
105
|
+
|
|
106
|
+
// ── Colour maths ──────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
type Rgba = [r: number, g: number, b: number, a: number]
|
|
109
|
+
|
|
110
|
+
const parseHex = (hex: string): Rgba => {
|
|
111
|
+
const value = hex.replace('#', '')
|
|
112
|
+
const channel = (index: number) => parseInt(value.slice(index, index + 2), 16) / 255
|
|
113
|
+
return [channel(0), channel(2), channel(4), value.length === 8 ? channel(6) : 1]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const toHexPair = (value: number) =>
|
|
117
|
+
Math.round(Math.min(1, Math.max(0, value)) * 255)
|
|
118
|
+
.toString(16)
|
|
119
|
+
.padStart(2, '0')
|
|
120
|
+
|
|
121
|
+
/** `#rrggbb`, or `#rrggbbaa` below full opacity, in lower case. */
|
|
122
|
+
export const formatHex = ([r, g, b, a]: Rgba) => {
|
|
123
|
+
const alpha = toHexPair(a)
|
|
124
|
+
return `#${toHexPair(r)}${toHexPair(g)}${toHexPair(b)}${alpha === 'ff' ? '' : alpha}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A hex in the one spelling the picker reports: lower case, without an opaque `ff`. */
|
|
128
|
+
export const normalizeHex = (hex: string) => formatHex(parseHex(hex))
|
|
129
|
+
|
|
130
|
+
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
|
|
131
|
+
const fromLinear = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)
|
|
132
|
+
|
|
133
|
+
const srgbToOklab = (r: number, g: number, b: number): [number, number, number] => {
|
|
134
|
+
const [lr, lg, lb] = [toLinear(r), toLinear(g), toLinear(b)]
|
|
135
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb)
|
|
136
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb)
|
|
137
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb)
|
|
138
|
+
return [
|
|
139
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
140
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
141
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const oklabToSrgb = (L: number, A: number, B: number): [number, number, number] => {
|
|
146
|
+
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3
|
|
147
|
+
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3
|
|
148
|
+
const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3
|
|
149
|
+
return [
|
|
150
|
+
fromLinear(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
|
151
|
+
fromLinear(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
|
152
|
+
fromLinear(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Mixes two colours the way a CSS gradient does: in the given space, with
|
|
158
|
+
* premultiplied alpha, so a fade from a transparent black to a colour keeps the
|
|
159
|
+
* colour's hue all the way.
|
|
160
|
+
*/
|
|
161
|
+
export const mixColors = (from: string, to: string, t: number, space: 'oklab' | 'srgb') => {
|
|
162
|
+
const [r1, g1, b1, a1] = parseHex(from)
|
|
163
|
+
const [r2, g2, b2, a2] = parseHex(to)
|
|
164
|
+
const convert = space === 'oklab' ? srgbToOklab : (r: number, g: number, b: number) => [r, g, b]
|
|
165
|
+
const c1 = convert(r1, g1, b1)
|
|
166
|
+
const c2 = convert(r2, g2, b2)
|
|
167
|
+
const alpha = a1 + (a2 - a1) * t
|
|
168
|
+
const mixed = [0, 1, 2].map((index) => {
|
|
169
|
+
const premultiplied =
|
|
170
|
+
(c1[index] ?? 0) * a1 + ((c2[index] ?? 0) * a2 - (c1[index] ?? 0) * a1) * t
|
|
171
|
+
return alpha === 0 ? 0 : premultiplied / alpha
|
|
172
|
+
})
|
|
173
|
+
const [x = 0, y = 0, z = 0] = mixed
|
|
174
|
+
const [r, g, b] = space === 'oklab' ? oklabToSrgb(x, y, z) : [x, y, z]
|
|
175
|
+
return formatHex([r, g, b, alpha])
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The colour the gradient shows at `position`, as a hex: what a stop added
|
|
180
|
+
* there needs so that the gradient does not change. Interpolated in oklab, as
|
|
181
|
+
* every gradient is drawn. Before the first stop and after the last the
|
|
182
|
+
* gradient is flat.
|
|
183
|
+
*/
|
|
184
|
+
export const colorAt = (stops: ColorPickerGradientStop[], position: number) => {
|
|
185
|
+
const first = stops[0]
|
|
186
|
+
const last = stops[stops.length - 1]
|
|
187
|
+
if (!first || !last) return '#000000'
|
|
188
|
+
if (position <= first.position) return normalizeHex(first.color)
|
|
189
|
+
if (position >= last.position) return normalizeHex(last.color)
|
|
190
|
+
const after = stops.findIndex((stop) => stop.position > position)
|
|
191
|
+
const right = stops[after]
|
|
192
|
+
const left = stops[after - 1]
|
|
193
|
+
if (!left || !right) return normalizeHex(last.color)
|
|
194
|
+
const t = (position - left.position) / (right.position - left.position)
|
|
195
|
+
return mixColors(left.color, right.color, t, 'oklab')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Copy ──────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Copy of the gradient mode. Every key is optional and merged over the Dutch
|
|
202
|
+
* defaults. `{n}`, `{total}`, `{x}` and `{y}` are filled in by the picker.
|
|
203
|
+
*/
|
|
204
|
+
export interface ColorPickerGradientMessages {
|
|
205
|
+
/** Accessible name of the solid/gradient switch. Defaults to `'Vulling'`. */
|
|
206
|
+
mode?: string
|
|
207
|
+
/** Solid option of the switch. Defaults to `'Effen'`. */
|
|
208
|
+
solid?: string
|
|
209
|
+
/** Gradient option of the switch. Defaults to `'Verloop'`. */
|
|
210
|
+
gradient?: string
|
|
211
|
+
/** Accessible name of the type switch. Defaults to `'Type verloop'`. */
|
|
212
|
+
type?: string
|
|
213
|
+
/** Linear option of the type switch. Defaults to `'Lineair'`. */
|
|
214
|
+
linear?: string
|
|
215
|
+
/** Radial option of the type switch. Defaults to `'Radiaal'`. */
|
|
216
|
+
radial?: string
|
|
217
|
+
/** Accessible name of the angle point in the preview. Defaults to `'Hoek'`. */
|
|
218
|
+
angle?: string
|
|
219
|
+
/** Value text of the angle point. Defaults to `'{n} graden'`. */
|
|
220
|
+
angleValue?: string
|
|
221
|
+
/** Accessible name of the centre point in the preview. Defaults to `'Middelpunt'`. */
|
|
222
|
+
center?: string
|
|
223
|
+
/** Value text of the centre point. Defaults to `'{x} % van links, {y} % van boven'`. */
|
|
224
|
+
centerValue?: string
|
|
225
|
+
/** Accessible name of the reverse button. Defaults to `'Kleurstops omdraaien'`. */
|
|
226
|
+
reverse?: string
|
|
227
|
+
/** Heading above the stop list. Defaults to `'Kleurstops'`. */
|
|
228
|
+
stops?: string
|
|
229
|
+
/** Hint next to the heading. Defaults to `'max. 3'`. */
|
|
230
|
+
max?: string
|
|
231
|
+
/** Why no stop can be added, announced on the add button. Defaults to `'Een verloop heeft maximaal 3 kleurstops.'`. */
|
|
232
|
+
maxReason?: string
|
|
233
|
+
/** Visible text of the add button. Defaults to `'Toevoegen'`. */
|
|
234
|
+
add?: string
|
|
235
|
+
/** Accessible name of the add button. Defaults to `'Kleurstop toevoegen'`. */
|
|
236
|
+
addName?: string
|
|
237
|
+
/** Name of a stop's row and of its handle on the bar. Defaults to `'Kleurstop {n} van {total}'`. */
|
|
238
|
+
stop?: string
|
|
239
|
+
/** Accessible name of a row's position field. Defaults to `'Positie (%)'`. */
|
|
240
|
+
position?: string
|
|
241
|
+
/** Accessible name of a row's colour button. Defaults to `'Kleur kiezen'`. */
|
|
242
|
+
pick?: string
|
|
243
|
+
/** Accessible name of a row's hex field. Defaults to `'Hex'`. */
|
|
244
|
+
hex?: string
|
|
245
|
+
/** Accessible name of a row's remove button. Defaults to `'Verwijderen'`. */
|
|
246
|
+
remove?: string
|
|
247
|
+
/** Accessible name of a stop's colour popover. Defaults to `'Kleur van kleurstop {n}'`. */
|
|
248
|
+
dialog?: string
|
|
249
|
+
/** Opacity part of a handle's value text. Defaults to `'dekking {n} %'`. */
|
|
250
|
+
stopOpacity?: string
|
|
251
|
+
/** Hint under the locked opacity slider of a stop bound to a preset. Defaults to `'Huisstijlkleuren zijn altijd dekkend.'`. */
|
|
252
|
+
roleOpaque?: string
|
|
253
|
+
/** Trigger text for a linear gradient. Defaults to `'Lineair verloop'`. */
|
|
254
|
+
triggerLinear?: string
|
|
255
|
+
/** Trigger text for a radial gradient. Defaults to `'Radiaal verloop'`. */
|
|
256
|
+
triggerRadial?: string
|
|
257
|
+
/** Name of the trigger swatch for a linear gradient. Defaults to `'Geselecteerd verloop: lineair, {n} kleuren'`. */
|
|
258
|
+
swatchLinear?: string
|
|
259
|
+
/** Name of the trigger swatch for a radial gradient. Defaults to `'Geselecteerd verloop: radiaal, {n} kleuren'`. */
|
|
260
|
+
swatchRadial?: string
|
|
261
|
+
/** Name of the trigger swatch while there is no gradient. Defaults to `'Geen kleur geselecteerd'`. */
|
|
262
|
+
swatchEmpty?: string
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export const DEFAULT_GRADIENT_MESSAGES = {
|
|
266
|
+
mode: 'Vulling',
|
|
267
|
+
solid: 'Effen',
|
|
268
|
+
gradient: 'Verloop',
|
|
269
|
+
type: 'Type verloop',
|
|
270
|
+
linear: 'Lineair',
|
|
271
|
+
radial: 'Radiaal',
|
|
272
|
+
angle: 'Hoek',
|
|
273
|
+
angleValue: '{n} graden',
|
|
274
|
+
center: 'Middelpunt',
|
|
275
|
+
centerValue: '{x} % van links, {y} % van boven',
|
|
276
|
+
reverse: 'Kleurstops omdraaien',
|
|
277
|
+
stops: 'Kleurstops',
|
|
278
|
+
max: 'max. 3',
|
|
279
|
+
maxReason: 'Een verloop heeft maximaal 3 kleurstops.',
|
|
280
|
+
add: 'Toevoegen',
|
|
281
|
+
addName: 'Kleurstop toevoegen',
|
|
282
|
+
stop: 'Kleurstop {n} van {total}',
|
|
283
|
+
position: 'Positie (%)',
|
|
284
|
+
pick: 'Kleur kiezen',
|
|
285
|
+
hex: 'Hex',
|
|
286
|
+
remove: 'Verwijderen',
|
|
287
|
+
dialog: 'Kleur van kleurstop {n}',
|
|
288
|
+
stopOpacity: 'dekking {n} %',
|
|
289
|
+
roleOpaque: 'Huisstijlkleuren zijn altijd dekkend.',
|
|
290
|
+
triggerLinear: 'Lineair verloop',
|
|
291
|
+
triggerRadial: 'Radiaal verloop',
|
|
292
|
+
swatchLinear: 'Geselecteerd verloop: lineair, {n} kleuren',
|
|
293
|
+
swatchRadial: 'Geselecteerd verloop: radiaal, {n} kleuren',
|
|
294
|
+
swatchEmpty: 'Geen kleur geselecteerd',
|
|
295
|
+
} satisfies Required<ColorPickerGradientMessages>
|
|
296
|
+
|
|
297
|
+
export type GradientCopy = typeof DEFAULT_GRADIENT_MESSAGES
|
|
298
|
+
|
|
299
|
+
/** Fills `{key}` placeholders in a message. */
|
|
300
|
+
export const fillMessage = (message: string, values: Record<string, string | number>) =>
|
|
301
|
+
message.replace(/\{(\w+)\}/g, (match, key: string) =>
|
|
302
|
+
key in values ? String(values[key]) : match,
|
|
303
|
+
)
|