Skip to content

Commit 23d7010

Browse files
Add reversible color drag for template overrides
Copy palette colors into app overrides with drag cancellation and undo support. Preserve the current Go and Wails versions. Fixes #115.
1 parent 9dc3028 commit 23d7010

8 files changed

Lines changed: 348 additions & 7 deletions

File tree

‎frontend/src/lib/components/editor/AppColorOverrides.svelte‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@
66
getAppOverrides,
77
clearAppOverridesForApp,
88
removeAppOverride,
9+
setAppOverride,
910
} from '$lib/stores/theme.svelte';
10-
import {openOverrideColorPicker} from '$lib/stores/ui.svelte';
11+
import {
12+
openOverrideColorPicker,
13+
getColorDrag,
14+
setColorDrag,
15+
} from '$lib/stores/ui.svelte';
1116
import {isLightColor, copyColor} from '$lib/utils/color';
1217
import ContextMenu from '$lib/components/shared/ContextMenu.svelte';
1318
import ExpandableSection from '$lib/components/shared/ExpandableSection.svelte';
@@ -113,6 +118,24 @@
113118
return SHORT_LABELS[role] || role.replace(/_/g, ' ');
114119
}
115120
121+
let dragOverRole = $state('');
122+
123+
$effect(() => {
124+
if (!getColorDrag()) dragOverRole = '';
125+
});
126+
127+
function onButtonMouseEnter(role: string) {
128+
if (getColorDrag()) dragOverRole = role;
129+
}
130+
131+
function onButtonMouseUp(e: MouseEvent, role: string) {
132+
const drag = getColorDrag();
133+
if (!drag || e.button !== 0) return;
134+
setColorDrag(null);
135+
setAppOverride(selectedApp, role, drag.color, true);
136+
dragOverRole = '';
137+
}
138+
116139
let menu = $state({open: false, x: 0, y: 0, role: ''});
117140
118141
function openMenu(e: MouseEvent, role: string) {
@@ -207,14 +230,19 @@
207230
class="group relative flex h-9 cursor-pointer items-end justify-center overflow-hidden border px-1 transition-all duration-100
208231
{isOverridden
209232
? 'border-accent border-2'
210-
: 'border-border hover:border-border-focus'}"
233+
: dragOverRole === role
234+
? 'border-accent scale-[1.06] border-2 shadow-md'
235+
: 'border-border hover:border-border-focus'}"
211236
style:background-color={display}
212237
onclick={() =>
213238
openOverrideColorPicker(selectedApp, role)}
214239
oncontextmenu={e => openMenu(e, role)}
240+
onmouseenter={() => onButtonMouseEnter(role)}
241+
onmouseleave={() => (dragOverRole = '')}
242+
onmouseup={e => onButtonMouseUp(e, role)}
215243
title="{role}{isOverridden
216244
? ` · override ${appOverrides[role]}`
217-
: ` · computed ${display}`}\nClick edit · Right-click for menu"
245+
: ` · computed ${display}`}\nClick edit · Right-click for menu · Drag palette color to override"
218246
>
219247
<span
220248
class="block w-full select-none truncate pb-0.5 text-center text-[8px] leading-none opacity-80 transition-opacity group-hover:opacity-100
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script lang="ts">
2+
import {getColorDrag} from '$lib/stores/ui.svelte';
3+
4+
let drag = $derived(getColorDrag());
5+
let active = $derived(!!drag);
6+
7+
$effect(() => {
8+
if (!active) return;
9+
const previous = document.body.style.cursor;
10+
document.body.style.cursor = 'copy';
11+
return () => {
12+
if (document.body.style.cursor === 'copy')
13+
document.body.style.cursor = previous;
14+
};
15+
});
16+
</script>
17+
18+
{#if drag}
19+
<div
20+
class="pointer-events-none fixed z-[9999] h-7 w-7 border-2 border-white shadow-lg"
21+
style:background-color={drag.color}
22+
style:left="{drag.x + 12}px"
23+
style:top="{drag.y + 12}px"
24+
></div>
25+
{/if}

‎frontend/src/lib/components/editor/ColorSwatch.svelte‎

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
contrastRatio,
1010
contrastLevel,
1111
} from '$lib/utils/color';
12-
import {setEyedropperActive} from '$lib/stores/ui.svelte';
12+
import {setEyedropperActive, setColorDrag} from '$lib/stores/ui.svelte';
1313
import {onActivate} from '$lib/utils/keyboard';
1414
import LockIcon from '$lib/components/shared/LockIcon.svelte';
1515
import ContextMenu from '$lib/components/shared/ContextMenu.svelte';
@@ -99,6 +99,10 @@
9999
}
100100
101101
function handleClick(event: MouseEvent) {
102+
if (didDrag) {
103+
didDrag = false;
104+
return;
105+
}
102106
if (event.ctrlKey || event.metaKey) {
103107
event.preventDefault();
104108
copyColor(color);
@@ -113,6 +117,82 @@
113117
}
114118
115119
let light = $derived(isLightColor(color));
120+
121+
let isDragging = $state(false);
122+
let didDrag = $state(false);
123+
let pendingDrag: {startX: number; startY: number; color: string} | null =
124+
null;
125+
126+
function onMouseDown(e: MouseEvent) {
127+
if (e.button !== 0) return;
128+
clearDrag();
129+
didDrag = false;
130+
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
131+
if (
132+
(e.target as Element).closest('[role="button"]') !== e.currentTarget
133+
)
134+
return;
135+
pendingDrag = {startX: e.clientX, startY: e.clientY, color};
136+
window.addEventListener('mousemove', onDragMove);
137+
window.addEventListener('mouseup', onDragUp);
138+
window.addEventListener('blur', clearDrag);
139+
window.addEventListener('keydown', onDragKeydown);
140+
}
141+
142+
function onDragMove(e: MouseEvent) {
143+
if (!(e.buttons & 1)) {
144+
clearDrag();
145+
return;
146+
}
147+
if (!pendingDrag) return;
148+
if (isDragging) {
149+
e.preventDefault();
150+
setColorDrag({
151+
color: pendingDrag.color,
152+
x: e.clientX,
153+
y: e.clientY,
154+
});
155+
return;
156+
}
157+
const dx = e.clientX - pendingDrag.startX;
158+
const dy = e.clientY - pendingDrag.startY;
159+
if (Math.hypot(dx, dy) >= 4) {
160+
didDrag = true;
161+
isDragging = true;
162+
e.preventDefault();
163+
setColorDrag({
164+
color: pendingDrag.color,
165+
x: e.clientX,
166+
y: e.clientY,
167+
});
168+
}
169+
}
170+
171+
function onDragKeydown(e: KeyboardEvent) {
172+
if (e.key === 'Escape') {
173+
e.preventDefault();
174+
clearDrag();
175+
}
176+
}
177+
178+
function onDragUp(e: MouseEvent) {
179+
if (e.button === 0) clearDrag();
180+
}
181+
182+
function clearDrag() {
183+
window.removeEventListener('mousemove', onDragMove);
184+
window.removeEventListener('mouseup', onDragUp);
185+
window.removeEventListener('blur', clearDrag);
186+
window.removeEventListener('keydown', onDragKeydown);
187+
if (pendingDrag || isDragging) setColorDrag(null);
188+
pendingDrag = null;
189+
isDragging = false;
190+
}
191+
192+
$effect(() => {
193+
const _ = color;
194+
return clearDrag;
195+
});
116196
</script>
117197

118198
<!-- Keyboard handling lives on the parent ColorPaletteGrid (roving tabindex). -->
@@ -123,16 +203,18 @@
123203
? 'border-border cursor-default'
124204
: selected
125205
? 'border-accent cursor-pointer border-2'
126-
: 'hover:border-accent border-border cursor-pointer hover:z-10 hover:scale-[1.04] hover:shadow-lg'}"
206+
: 'hover:border-accent border-border cursor-pointer hover:z-10 hover:scale-[1.04] hover:shadow-lg'}
207+
{isDragging ? 'opacity-60' : ''}"
127208
style:background-color={color}
128209
role="button"
129210
tabindex={focused ? 0 : -1}
130211
data-swatch-idx={index}
131212
onclick={handleClick}
213+
onmousedown={onMouseDown}
132214
oncontextmenu={handleContextMenu}
133215
title={`${label}${role ? ` · ${role}` : ''}\n${color}${
134216
showBadge ? `\nContrast vs BG: ${ratio.toFixed(2)}:1 (${level})` : ''
135-
}\nClick edit · Ctrl+click copy · Shift+click select · Right-click for menu\n← → ↑ ↓ navigate · Enter open · L lock · C copy`}
217+
}\nClick edit · Ctrl+click copy · Shift+click select · Right-click for menu\nDrag to copy into a template override\n← → ↑ ↓ navigate · Enter open · L lock · C copy`}
136218
>
137219
{#if selected}
138220
<span

‎frontend/src/lib/components/editor/ThemeEditor.svelte‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import SettingsSidebar from '../sidebar/SettingsSidebar.svelte';
1010
import ColorPickerDialog from '../color-picker/ColorPickerDialog.svelte';
1111
import WallpaperEditor from '../wallpaper-editor/WallpaperEditor.svelte';
12+
import ColorDragGhost from './ColorDragGhost.svelte';
1213
import {getWallpaperPath, getPalette} from '$lib/stores/theme.svelte';
1314
import {
1415
getSidebarVisible,
@@ -74,4 +75,5 @@
7475
open={getImageEditorOpen()}
7576
onclose={() => setImageEditorOpen(false)}
7677
/>
78+
<ColorDragGhost />
7779
</div>

‎frontend/src/lib/stores/history.svelte.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface Snapshot {
1919
basePalette: string[];
2020
extendedColors: Record<string, string>;
2121
baseExtendedColors: Record<string, string>;
22+
appOverrides: Record<string, Record<string, string>>;
2223
adjustments: Adjustments;
2324
paletteCurvePoints: [number, number][];
2425
extractionMode: string;
@@ -50,6 +51,12 @@ export function copySnapshot(snapshot: Snapshot): Snapshot {
5051
basePalette: [...snapshot.basePalette],
5152
extendedColors: {...snapshot.extendedColors},
5253
baseExtendedColors: {...snapshot.baseExtendedColors},
54+
appOverrides: Object.fromEntries(
55+
Object.entries(snapshot.appOverrides).map(([app, colors]) => [
56+
app,
57+
{...colors},
58+
])
59+
),
5360
adjustments: {...snapshot.adjustments},
5461
paletteCurvePoints: snapshot.paletteCurvePoints.map(([x, y]) => [x, y]),
5562
extractionMode: snapshot.extractionMode,

‎frontend/src/lib/stores/theme.svelte.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export function getHistorySnapshot(): Snapshot {
180180
basePalette,
181181
extendedColors,
182182
baseExtendedColors,
183+
appOverrides,
183184
adjustments,
184185
paletteCurvePoints,
185186
extractionMode,
@@ -195,6 +196,7 @@ export function restoreHistorySnapshot(snapshot: Snapshot): void {
195196
basePalette = restored.basePalette;
196197
extendedColors = restored.extendedColors;
197198
baseExtendedColors = restored.baseExtendedColors;
199+
appOverrides = restored.appOverrides;
198200
adjustments = restored.adjustments;
199201
paletteCurvePoints = restored.paletteCurvePoints;
200202
extractionMode = restored.extractionMode;
@@ -379,8 +381,18 @@ export function setPaletteCurvePoints(pts: [number, number][]): void {
379381
invalidateThemeRequests();
380382
paletteCurvePoints = pts.map(([x, y]) => [x, y]);
381383
}
382-
export function setAppOverride(app: string, role: string, hex: string): void {
384+
export function setAppOverride(
385+
app: string,
386+
role: string,
387+
hex: string,
388+
recordHistory = false
389+
): void {
383390
const current = appOverrides[app] || {};
391+
if (current[role] === hex) return;
392+
if (recordHistory) {
393+
endColorEditSessions();
394+
pushState(getHistorySnapshot());
395+
}
384396
appOverrides = {...appOverrides, [app]: {...current, [role]: hex}};
385397
}
386398
export function removeAppOverride(app: string, role: string): void {

‎frontend/src/lib/stores/ui.svelte.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function writeBoolPref(key: string, value: boolean): void {
4545
} catch {}
4646
}
4747
let colorPickerOpen = $state<boolean>(false);
48+
let colorDrag = $state<{color: string; x: number; y: number} | null>(null);
4849
let colorPickerIndex = $state<number>(-1);
4950
let colorPickerExtKey = $state<string>(''); // non-empty = editing an extended color
5051
let colorPickerOverrideApp = $state<string>(''); // non-empty = editing an app override
@@ -276,3 +277,12 @@ export function getApplySaveDialogOpen(): boolean {
276277
export function setApplySaveDialogOpen(v: boolean): void {
277278
applySaveDialogOpen = v;
278279
}
280+
281+
export function getColorDrag(): {color: string; x: number; y: number} | null {
282+
return colorDrag;
283+
}
284+
export function setColorDrag(
285+
v: {color: string; x: number; y: number} | null
286+
): void {
287+
colorDrag = v;
288+
}

0 commit comments

Comments
 (0)