diff --git a/frontend/src/lib/components/editor/AppColorOverrides.svelte b/frontend/src/lib/components/editor/AppColorOverrides.svelte index 67ce588..a30428d 100644 --- a/frontend/src/lib/components/editor/AppColorOverrides.svelte +++ b/frontend/src/lib/components/editor/AppColorOverrides.svelte @@ -6,8 +6,13 @@ getAppOverrides, clearAppOverridesForApp, removeAppOverride, + setAppOverride, } from '$lib/stores/theme.svelte'; - import {openOverrideColorPicker} from '$lib/stores/ui.svelte'; + import { + openOverrideColorPicker, + getColorDrag, + setColorDrag, + } from '$lib/stores/ui.svelte'; import {isLightColor, copyColor} from '$lib/utils/color'; import ContextMenu from '$lib/components/shared/ContextMenu.svelte'; import ExpandableSection from '$lib/components/shared/ExpandableSection.svelte'; @@ -113,6 +118,24 @@ return SHORT_LABELS[role] || role.replace(/_/g, ' '); } + let dragOverRole = $state(''); + + $effect(() => { + if (!getColorDrag()) dragOverRole = ''; + }); + + function onButtonMouseEnter(role: string) { + if (getColorDrag()) dragOverRole = role; + } + + function onButtonMouseUp(e: MouseEvent, role: string) { + const drag = getColorDrag(); + if (!drag || e.button !== 0) return; + setColorDrag(null); + setAppOverride(selectedApp, role, drag.color, true); + dragOverRole = ''; + } + let menu = $state({open: false, x: 0, y: 0, role: ''}); function openMenu(e: MouseEvent, role: string) { @@ -207,14 +230,19 @@ class="group relative flex h-9 cursor-pointer items-end justify-center overflow-hidden border px-1 transition-all duration-100 {isOverridden ? 'border-accent border-2' - : 'border-border hover:border-border-focus'}" + : dragOverRole === role + ? 'border-accent scale-[1.06] border-2 shadow-md' + : 'border-border hover:border-border-focus'}" style:background-color={display} onclick={() => openOverrideColorPicker(selectedApp, role)} oncontextmenu={e => openMenu(e, role)} + onmouseenter={() => onButtonMouseEnter(role)} + onmouseleave={() => (dragOverRole = '')} + onmouseup={e => onButtonMouseUp(e, role)} title="{role}{isOverridden ? ` · override ${appOverrides[role]}` - : ` · computed ${display}`}\nClick edit · Right-click for menu" + : ` · computed ${display}`}\nClick edit · Right-click for menu · Drag palette color to override" > + import {getColorDrag} from '$lib/stores/ui.svelte'; + + let drag = $derived(getColorDrag()); + let active = $derived(!!drag); + + $effect(() => { + if (!active) return; + const previous = document.body.style.cursor; + document.body.style.cursor = 'copy'; + return () => { + if (document.body.style.cursor === 'copy') + document.body.style.cursor = previous; + }; + }); + + +{#if drag} +
+{/if} diff --git a/frontend/src/lib/components/editor/ColorSwatch.svelte b/frontend/src/lib/components/editor/ColorSwatch.svelte index 0157f77..be1ed2a 100644 --- a/frontend/src/lib/components/editor/ColorSwatch.svelte +++ b/frontend/src/lib/components/editor/ColorSwatch.svelte @@ -9,7 +9,7 @@ contrastRatio, contrastLevel, } from '$lib/utils/color'; - import {setEyedropperActive} from '$lib/stores/ui.svelte'; + import {setEyedropperActive, setColorDrag} from '$lib/stores/ui.svelte'; import {onActivate} from '$lib/utils/keyboard'; import LockIcon from '$lib/components/shared/LockIcon.svelte'; import ContextMenu from '$lib/components/shared/ContextMenu.svelte'; @@ -99,6 +99,10 @@ } function handleClick(event: MouseEvent) { + if (didDrag) { + didDrag = false; + return; + } if (event.ctrlKey || event.metaKey) { event.preventDefault(); copyColor(color); @@ -113,6 +117,82 @@ } let light = $derived(isLightColor(color)); + + let isDragging = $state(false); + let didDrag = $state(false); + let pendingDrag: {startX: number; startY: number; color: string} | null = + null; + + function onMouseDown(e: MouseEvent) { + if (e.button !== 0) return; + clearDrag(); + didDrag = false; + if (e.ctrlKey || e.metaKey || e.shiftKey) return; + if ( + (e.target as Element).closest('[role="button"]') !== e.currentTarget + ) + return; + pendingDrag = {startX: e.clientX, startY: e.clientY, color}; + window.addEventListener('mousemove', onDragMove); + window.addEventListener('mouseup', onDragUp); + window.addEventListener('blur', clearDrag); + window.addEventListener('keydown', onDragKeydown); + } + + function onDragMove(e: MouseEvent) { + if (!(e.buttons & 1)) { + clearDrag(); + return; + } + if (!pendingDrag) return; + if (isDragging) { + e.preventDefault(); + setColorDrag({ + color: pendingDrag.color, + x: e.clientX, + y: e.clientY, + }); + return; + } + const dx = e.clientX - pendingDrag.startX; + const dy = e.clientY - pendingDrag.startY; + if (Math.hypot(dx, dy) >= 4) { + didDrag = true; + isDragging = true; + e.preventDefault(); + setColorDrag({ + color: pendingDrag.color, + x: e.clientX, + y: e.clientY, + }); + } + } + + function onDragKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + clearDrag(); + } + } + + function onDragUp(e: MouseEvent) { + if (e.button === 0) clearDrag(); + } + + function clearDrag() { + window.removeEventListener('mousemove', onDragMove); + window.removeEventListener('mouseup', onDragUp); + window.removeEventListener('blur', clearDrag); + window.removeEventListener('keydown', onDragKeydown); + if (pendingDrag || isDragging) setColorDrag(null); + pendingDrag = null; + isDragging = false; + } + + $effect(() => { + const _ = color; + return clearDrag; + }); @@ -123,16 +203,18 @@ ? 'border-border cursor-default' : selected ? 'border-accent cursor-pointer border-2' - : 'hover:border-accent border-border cursor-pointer hover:z-10 hover:scale-[1.04] hover:shadow-lg'}" + : 'hover:border-accent border-border cursor-pointer hover:z-10 hover:scale-[1.04] hover:shadow-lg'} + {isDragging ? 'opacity-60' : ''}" style:background-color={color} role="button" tabindex={focused ? 0 : -1} data-swatch-idx={index} onclick={handleClick} + onmousedown={onMouseDown} oncontextmenu={handleContextMenu} title={`${label}${role ? ` · ${role}` : ''}\n${color}${ showBadge ? `\nContrast vs BG: ${ratio.toFixed(2)}:1 (${level})` : '' - }\nClick edit · Ctrl+click copy · Shift+click select · Right-click for menu\n← → ↑ ↓ navigate · Enter open · L lock · C copy`} + }\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`} > {#if selected} setImageEditorOpen(false)} /> + diff --git a/frontend/src/lib/stores/history.svelte.ts b/frontend/src/lib/stores/history.svelte.ts index 6f1896e..d2a4474 100644 --- a/frontend/src/lib/stores/history.svelte.ts +++ b/frontend/src/lib/stores/history.svelte.ts @@ -19,6 +19,7 @@ export interface Snapshot { basePalette: string[]; extendedColors: Record; baseExtendedColors: Record; + appOverrides: Record>; adjustments: Adjustments; paletteCurvePoints: [number, number][]; extractionMode: string; @@ -50,6 +51,12 @@ export function copySnapshot(snapshot: Snapshot): Snapshot { basePalette: [...snapshot.basePalette], extendedColors: {...snapshot.extendedColors}, baseExtendedColors: {...snapshot.baseExtendedColors}, + appOverrides: Object.fromEntries( + Object.entries(snapshot.appOverrides).map(([app, colors]) => [ + app, + {...colors}, + ]) + ), adjustments: {...snapshot.adjustments}, paletteCurvePoints: snapshot.paletteCurvePoints.map(([x, y]) => [x, y]), extractionMode: snapshot.extractionMode, diff --git a/frontend/src/lib/stores/theme.svelte.ts b/frontend/src/lib/stores/theme.svelte.ts index 76250b4..db6af8a 100644 --- a/frontend/src/lib/stores/theme.svelte.ts +++ b/frontend/src/lib/stores/theme.svelte.ts @@ -180,6 +180,7 @@ export function getHistorySnapshot(): Snapshot { basePalette, extendedColors, baseExtendedColors, + appOverrides, adjustments, paletteCurvePoints, extractionMode, @@ -195,6 +196,7 @@ export function restoreHistorySnapshot(snapshot: Snapshot): void { basePalette = restored.basePalette; extendedColors = restored.extendedColors; baseExtendedColors = restored.baseExtendedColors; + appOverrides = restored.appOverrides; adjustments = restored.adjustments; paletteCurvePoints = restored.paletteCurvePoints; extractionMode = restored.extractionMode; @@ -379,8 +381,18 @@ export function setPaletteCurvePoints(pts: [number, number][]): void { invalidateThemeRequests(); paletteCurvePoints = pts.map(([x, y]) => [x, y]); } -export function setAppOverride(app: string, role: string, hex: string): void { +export function setAppOverride( + app: string, + role: string, + hex: string, + recordHistory = false +): void { const current = appOverrides[app] || {}; + if (current[role] === hex) return; + if (recordHistory) { + endColorEditSessions(); + pushState(getHistorySnapshot()); + } appOverrides = {...appOverrides, [app]: {...current, [role]: hex}}; } export function removeAppOverride(app: string, role: string): void { diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index 2773135..1812574 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -45,6 +45,7 @@ function writeBoolPref(key: string, value: boolean): void { } catch {} } let colorPickerOpen = $state(false); +let colorDrag = $state<{color: string; x: number; y: number} | null>(null); let colorPickerIndex = $state(-1); let colorPickerExtKey = $state(''); // non-empty = editing an extended color let colorPickerOverrideApp = $state(''); // non-empty = editing an app override @@ -276,3 +277,12 @@ export function getApplySaveDialogOpen(): boolean { export function setApplySaveDialogOpen(v: boolean): void { applySaveDialogOpen = v; } + +export function getColorDrag(): {color: string; x: number; y: number} | null { + return colorDrag; +} +export function setColorDrag( + v: {color: string; x: number; y: number} | null +): void { + colorDrag = v; +} diff --git a/frontend/tests/color-drag.test.ts b/frontend/tests/color-drag.test.ts new file mode 100644 index 0000000..83a0ee1 --- /dev/null +++ b/frontend/tests/color-drag.test.ts @@ -0,0 +1,175 @@ +import {afterEach, beforeEach, expect, test, vi} from 'vitest'; +import {flushSync} from 'svelte'; +import ColorSwatch from '../src/lib/components/editor/ColorSwatch.svelte'; +import AppColorOverrides from '../src/lib/components/editor/AppColorOverrides.svelte'; +import ColorDragGhost from '../src/lib/components/editor/ColorDragGhost.svelte'; +import * as theme from '../src/lib/stores/theme.svelte'; +import * as ui from '../src/lib/stores/ui.svelte'; +import * as history from '../src/lib/stores/history.svelte'; +import {undoAction, redoAction} from '../src/lib/actions/themeActions'; +import {render, settle} from './setup'; + +vi.mock('../wailsjs/go/main/App', () => ({ + GetTemplateColors: vi + .fn() + .mockResolvedValue({kitty: ['background', 'foreground']}), + ComputeVariables: vi + .fn() + .mockResolvedValue({background: '#000000', foreground: '#ffffff'}), +})); +vi.mock('../src/lib/stores/omarchy.svelte', () => ({ + getOmarchyAvailable: () => true, + getOmarchyCapabilities: () => ({overrideApps: ['kitty']}), + initOmarchyCapabilities: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(() => { + theme.reset(); + ui.setColorDrag(null); + ui.closeColorPicker(); + document.body.style.cursor = ''; +}); + +afterEach(() => { + window.dispatchEvent(new Event('blur')); + ui.setColorDrag(null); + document.body.style.cursor = ''; +}); + +function mouse(target: EventTarget, type: string, x = 20, y = 20, buttons = 1) { + target.dispatchEvent( + new MouseEvent(type, { + bubbles: true, + button: 0, + buttons, + clientX: x, + clientY: y, + }) + ); + flushSync(); +} + +function swatch() { + const onclick = vi.fn(); + const rendered = render(ColorSwatch, { + color: '#123456', + index: 1, + label: 'Red', + locked: false, + selected: false, + focused: true, + onclick, + }); + const element = rendered.target.querySelector( + '[data-swatch-idx="1"]' + )!; + return {...rendered, element, onclick}; +} + +function beginDrag(element: HTMLElement) { + mouse(element, 'mousedown'); + mouse(window, 'mousemove', 30, 30); + expect(ui.getColorDrag()?.color).toBe('#123456'); +} + +async function overrideTarget() { + const target = render(AppColorOverrides, {}); + target.target.querySelector('button')!.click(); + await settle(); + return target.target.querySelector( + 'button[title^="background"]' + )!; +} + +test('a subthreshold movement remains a normal picker click', () => { + const source = swatch(); + mouse(source.element, 'mousedown'); + mouse(window, 'mousemove', 21, 21); + expect(ui.getColorDrag()).toBeNull(); + mouse(source.element, 'mouseup', 21, 21, 0); + source.element.click(); + expect(source.onclick).toHaveBeenCalledTimes(1); +}); + +test('a drop creates one reversible Omarchy app override', async () => { + theme.setAppOverride('kitty', 'background', '#abcdef'); + const before = theme.getHistorySnapshot(); + const source = swatch(); + const role = await overrideTarget(); + beginDrag(source.element); + mouse(role, 'mouseup', 50, 50, 0); + expect(theme.getAppOverrides()).toEqual({kitty: {background: '#123456'}}); + expect(ui.getColorDrag()).toBeNull(); + expect(ui.getColorPickerOpen()).toBe(false); + undoAction(); + expect(theme.getHistorySnapshot()).toEqual(before); + expect(history.getCanUndo()).toBe(false); + redoAction(); + expect(theme.getAppOverrides()).toEqual({kitty: {background: '#123456'}}); +}); + +test('the first source click after an outside drop opens the picker', () => { + const source = swatch(); + beginDrag(source.element); + mouse(document.body, 'mouseup', 50, 50, 0); + mouse(source.element, 'mousedown'); + mouse(source.element, 'mouseup', 20, 20, 0); + source.element.click(); + expect(source.onclick).toHaveBeenCalledTimes(1); +}); + +test('a drag released over its source does not open the picker', () => { + const source = swatch(); + beginDrag(source.element); + mouse(source.element, 'mouseup', 30, 30, 0); + source.element.click(); + expect(source.onclick).not.toHaveBeenCalled(); +}); + +test.each(['blur', 'escape', 'released'] as const)( + '%s cancels an abandoned drag', + async action => { + const source = swatch(); + const role = await overrideTarget(); + beginDrag(source.element); + if (action === 'blur') window.dispatchEvent(new Event('blur')); + if (action === 'escape') + window.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'})); + if (action === 'released') mouse(window, 'mousemove', 40, 40, 0); + mouse(role, 'mousedown', 50, 50); + mouse(role, 'mouseup', 50, 50, 0); + expect(ui.getColorDrag()).toBeNull(); + expect(theme.getAppOverrides()).toEqual({}); + expect(history.getCanUndo()).toBe(false); + } +); + +test('source destruction clears drag state and restores the cursor', async () => { + document.body.style.cursor = 'crosshair'; + const source = swatch(); + render(ColorDragGhost, {}); + beginDrag(source.element); + expect(document.body.style.cursor).toBe('copy'); + await source.destroy(); + flushSync(); + expect(ui.getColorDrag()).toBeNull(); + expect(document.body.style.cursor).toBe('crosshair'); +}); + +test('destroying an unrelated swatch does not cancel the drag', async () => { + const source = swatch(); + const other = swatch(); + beginDrag(source.element); + await other.destroy(); + expect(ui.getColorDrag()?.color).toBe('#123456'); +}); + +test('the lock control does not start a drag', () => { + const source = swatch(); + mouse( + source.target.querySelector('[aria-label="Lock color"]')!, + 'mousedown' + ); + mouse(window, 'mousemove', 40, 40); + expect(ui.getColorDrag()).toBeNull(); +});