Skip to content

Commit 1d74286

Browse files
committed
fancy new color pickers
1 parent e58e6c6 commit 1d74286

File tree

6 files changed

+375
-13
lines changed

6 files changed

+375
-13
lines changed

src/locales/en.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,5 +385,19 @@
385385
"string": "Light",
386386
"context": "Light mode option"
387387
}
388+
},
389+
"color": {
390+
"color": {
391+
"string": "Color",
392+
"context": "Refers to the hue of a color"
393+
},
394+
"saturation": {
395+
"string": "Saturation",
396+
"context": "Refers to the saturation of a color"
397+
},
398+
"brightness": {
399+
"string": "Brightness",
400+
"context": "Refers to the brightness of a color"
401+
}
388402
}
389403
}

src/p4/ColorPicker.svelte

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,194 @@
11
<script>
2+
import ColorSlider from './ColorSlider.svelte';
3+
import {hexToRGb, rgbToHex, rgbToHsv, hsvToRgb} from './color-utils';
4+
import {_} from '../locales';
5+
26
export let value;
7+
export let defaultColor;
8+
9+
let editorElement;
10+
let valueAsHsv;
11+
let open = false;
12+
13+
const handleOpen = () => {
14+
open = !open;
15+
};
16+
const checkClickedOutside = (e) => {
17+
if (open && editorElement && !editorElement.contains(e.target)) {
18+
open = false;
19+
}
20+
};
21+
22+
const setHsv = (hsv) => {
23+
const rgb = hsvToRgb(...hsv);
24+
const hex = rgbToHex(...rgb);
25+
value = hex;
26+
valueAsHsv = hsv;
27+
};
28+
const setHex = (hex) => {
29+
const valueAsRgb = hexToRGb(hex);
30+
value = hex;
31+
valueAsHsv = rgbToHsv(...valueAsRgb);
32+
};
33+
setHex(value);
34+
35+
const hsvChangeFactory = (index) => (e) => {
36+
const newColor = [...valueAsHsv];
37+
newColor[index] = e.detail;
38+
setHsv(newColor);
39+
};
40+
const generateHueSteps = (hsv) => {
41+
const result = [];
42+
for (let i = 0; i < 1; i += 0.1) {
43+
result.push(rgbToHex(...hsvToRgb(i, hsv[1], hsv[2])));
44+
}
45+
return result;
46+
};
47+
const generateSaturationSteps = (hsv) => {
48+
const result = [];
49+
for (let i = 0; i < 1; i += 0.1) {
50+
result.push(rgbToHex(...hsvToRgb(hsv[0], i, hsv[2])));
51+
}
52+
return result;
53+
};
54+
const generateBrightnessSteps = (hsv) => {
55+
const result = [];
56+
for (let i = 0; i < 1; i += 0.1) {
57+
result.push(rgbToHex(...hsvToRgb(hsv[0], hsv[1], i)));
58+
}
59+
return result;
60+
};
61+
62+
const handleSetHex = (e) => {
63+
setHex(e.target.value);
64+
};
65+
const reset = () => {
66+
setHex(defaultColor);
67+
};
368
</script>
469

5-
<input type="color" bind:value />
70+
<style>
71+
.container {
72+
position: relative;
73+
margin-right: 4px;
74+
}
75+
.button, .editor {
76+
border: 1px solid rgb(160, 160, 160);
77+
}
78+
.button {
79+
background-color: #eee;
80+
width: 40px;
81+
height: 2em;
82+
border-radius: 2px;
83+
margin: 0;
84+
padding: 3px;
85+
box-sizing: border-box;
86+
}
87+
.button:hover {
88+
background-color: #ddd;
89+
}
90+
.button:active {
91+
background-color: #ccc;
92+
}
93+
.color-preview {
94+
width: 100%;
95+
height: 100%;
96+
}
97+
.editor {
98+
background: white;
99+
position: absolute;
100+
z-index: 10;
101+
top: 100%;
102+
border-radius: 4px;
103+
padding: 8px;
104+
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5);
105+
}
106+
.slider-section {
107+
margin-bottom: 12px;
108+
}
109+
.label {
110+
display: flex;
111+
font-weight: bold;
112+
}
113+
.value {
114+
font-weight: normal;
115+
margin-left: 12px;
116+
}
117+
.bottom-section {
118+
display: flex;
119+
align-items: center;
120+
}
121+
.hex-text {
122+
width: 60px;
123+
}
124+
.reset-button {
125+
margin-left: 8px;
126+
}
127+
128+
:global([theme="dark"]) .button,
129+
:global([theme="dark"]) .editor {
130+
background-color: #333;
131+
border-color: #888;
132+
}
133+
:global([theme="dark"]) .button:hover {
134+
background-color: #444;
135+
}
136+
:global([theme="dark"]) .button:active {
137+
background-color: #555;
138+
}
139+
</style>
140+
141+
<svelte:body on:mousedown={checkClickedOutside} />
142+
143+
<div class="container" bind:this={editorElement}>
144+
<button class="button" on:click={handleOpen}>
145+
<div class="color-preview" style={`background-color: ${value}`}></div>
146+
</button>
147+
{#if open}
148+
<div class="editor">
149+
<div class="slider-section">
150+
<div class="label">
151+
{$_('color.color')}
152+
<div class="value">{Math.round(valueAsHsv[0] * 100)}</div>
153+
</div>
154+
<div class="slider">
155+
<ColorSlider
156+
value={valueAsHsv[0]}
157+
steps={generateHueSteps(valueAsHsv)}
158+
on:change={hsvChangeFactory(0)}
159+
/>
160+
</div>
161+
</div>
162+
<div class="slider-section">
163+
<div class="label">
164+
{$_('color.saturation')}
165+
<div class="value">{Math.round(valueAsHsv[1] * 100)}</div>
166+
</div>
167+
<div class="slider">
168+
<ColorSlider
169+
value={valueAsHsv[1]}
170+
steps={generateSaturationSteps(valueAsHsv)}
171+
on:change={hsvChangeFactory(1)}
172+
/>
173+
</div>
174+
</div>
175+
<div class="slider-section">
176+
<div class="label">
177+
{$_('color.brightness')}
178+
<div class="value">{Math.round(valueAsHsv[2] * 100)}</div>
179+
</div>
180+
<div class="slider">
181+
<ColorSlider
182+
value={valueAsHsv[2]}
183+
steps={generateBrightnessSteps(valueAsHsv)}
184+
on:change={hsvChangeFactory(2)}
185+
/>
186+
</div>
187+
</div>
188+
<div class="bottom-section">
189+
<input class="hex-text" type="text" value={value} on:input={handleSetHex} />
190+
<button class="reset-button" on:click={reset}>{$_('options.reset')}</button>
191+
</div>
192+
</div>
193+
{/if}
194+
</div>

src/p4/ColorSlider.svelte

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<script>
2+
import {createEventDispatcher} from 'svelte';
3+
4+
export let value = 0;
5+
export let steps = [];
6+
7+
const dispatch = createEventDispatcher();
8+
9+
let rect;
10+
let element;
11+
let isMoving = false;
12+
13+
$: background = `linear-gradient(to right, ${steps.join(',')})`;
14+
15+
const setMousePosition = (clientX) => {
16+
const unclamped = (clientX - rect.left - 10) / rect.width;
17+
value = Math.max(0, Math.min(1, unclamped));
18+
dispatch('change', value);
19+
};
20+
const onMouseMove = (e) => {
21+
if (!isMoving) return;
22+
setMousePosition(e.clientX);
23+
};
24+
const onMouseDown = (e) => {
25+
rect = element.getBoundingClientRect();
26+
isMoving = true;
27+
setMousePosition(e.clientX);
28+
};
29+
const onMouseUp = (e) => {
30+
if (!isMoving) return;
31+
isMoving = false;
32+
}
33+
</script>
34+
35+
<style>
36+
.slider {
37+
position: relative;
38+
width: 150px;
39+
height: 25px;
40+
border-radius: 12px;
41+
cursor: pointer;
42+
}
43+
.knob {
44+
position: absolute;
45+
width: 25px;
46+
height: 25px;
47+
border-radius: 100%;
48+
background: white;
49+
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.3);
50+
}
51+
</style>
52+
53+
<svelte:body on:mousemove={onMouseMove} on:mouseup={onMouseUp} />
54+
55+
<div
56+
class="slider"
57+
on:mousedown|preventDefault={onMouseDown}
58+
bind:this={element}
59+
style={`background: ${background}`}
60+
>
61+
<div class="knob" style={`left: ${value * 100}%; transform: translateX(-50%);`}></div>
62+
</div>

src/p4/PackagerOptions.svelte

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@
160160
display: block;
161161
margin-bottom: 4px;
162162
}
163+
.color {
164+
display: flex;
165+
align-items: center;
166+
margin-bottom: 4px;
167+
}
163168
input[type=number] {
164169
width: 60px;
165170
}
@@ -303,21 +308,18 @@
303308
<p>{$_('options.controlsHelp')}</p>
304309

305310
<h3>{$_('options.colors')}</h3>
306-
<!-- svelte-ignore a11y-label-has-associated-control -->
307-
<label>
308-
<ColorPicker bind:value={$options.appearance.background} />
311+
<div class="color">
312+
<ColorPicker bind:value={$options.appearance.background} defaultColor={defaultOptions.appearance.background} />
309313
{$_('options.backgroundColor')}
310-
</label>
311-
<!-- svelte-ignore a11y-label-has-associated-control -->
312-
<label>
313-
<ColorPicker bind:value={$options.appearance.foreground} />
314+
</div>
315+
<div class="color">
316+
<ColorPicker bind:value={$options.appearance.foreground} defaultColor={defaultOptions.appearance.foreground} />
314317
{$_('options.foregroundColor')}
315-
</label>
316-
<!-- svelte-ignore a11y-label-has-associated-control -->
317-
<label>
318-
<ColorPicker bind:value={$options.appearance.accent} />
318+
</div>
319+
<div class="color">
320+
<ColorPicker bind:value={$options.appearance.accent} defaultColor={defaultOptions.appearance.accent} />
319321
{$_('options.accentColor')}
320-
</label>
322+
</div>
321323

322324
<h3>{$_('options.interaction')}</h3>
323325
<div class="option-group">

src/p4/color-utils.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// For all color operations:
2+
// - The R, G, B channels of RGB[A] are in [0-255]
3+
// - The H, S, V channels of HSV[A] are in [0-1]
4+
// - The A channel is always in [0-1]
5+
6+
export const hexToRGb = (hex) => {
7+
hex = hex.trim();
8+
if (hex.startsWith('#')) {
9+
hex = hex.substr(1);
10+
}
11+
if (hex.length === 3) {
12+
hex = hex.split('').map(i => i + i).join('');
13+
}
14+
const parsed = parseInt(hex, 16);
15+
return [
16+
(parsed >> 16) & 0xff,
17+
(parsed >> 8) & 0xff,
18+
parsed & 0xff
19+
];
20+
};
21+
22+
export const rgbToHex = (r, g, b) => {
23+
return '#' + Math.round(r).toString(16).padStart(2, '0') + Math.round(b).toString(16).padStart(2, '0') + Math.round(g).toString(16).padStart(2, '0');
24+
};
25+
26+
export const rgbToHsv = (r, g, b) => {
27+
r /= 255;
28+
g /= 255;
29+
b /= 255;
30+
var max = Math.max(r, g, b), min = Math.min(r, g, b);
31+
var h, s, v = max;
32+
var d = max - min;
33+
s = max == 0 ? 0 : d / max;
34+
if (max == min) {
35+
h = 0;
36+
if (min === 0 || min === 1) {
37+
// Saturation does not matter in the case of pure white or black
38+
// In these cases we'll set saturation 1 to provide a better editing experience
39+
s = 1;
40+
}
41+
} else {
42+
switch (max) {
43+
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
44+
case g: h = (b - r) / d + 2; break;
45+
case b: h = (r - g) / d + 4; break;
46+
}
47+
h /= 6;
48+
}
49+
return [h, s, v];
50+
};
51+
52+
export const hsvToRgb = (h, s, v) => {
53+
var r, g, b;
54+
var i = Math.floor(h * 6);
55+
var f = h * 6 - i;
56+
var p = v * (1 - s);
57+
var q = v * (1 - f * s);
58+
var t = v * (1 - (1 - f) * s);
59+
switch (i % 6) {
60+
case 0: r = v, g = t, b = p; break;
61+
case 1: r = q, g = v, b = p; break;
62+
case 2: r = p, g = v, b = t; break;
63+
case 3: r = p, g = q, b = v; break;
64+
case 4: r = t, g = p, b = v; break;
65+
case 5: r = v, g = p, b = q; break;
66+
}
67+
return [r * 255, g * 255, b * 255];
68+
};

0 commit comments

Comments
 (0)