1.2.12: eyedropper — sample colours from anywhere on the page

Tiny progressive-enhancement feature using the browser's native
EyeDropper API (Chromium 95+). Adds a small pipette button next
to every colour swatch in the properties panel; clicking it opens
the OS picker and applies the chosen sRGBHex to the bound colour
property.

- src/composables/useEyeDropper.ts: returns { supported, pick }.
  `supported` re-detects on every call (not a singleton) so a late-
  loaded polyfill or a test-time monkey-patch is observed without
  juggling Vite's module cache. `pick()` resolves to the chosen
  hex string, returns null on cancel / abort / missing API.
- PropertiesPanel: PhEyedropper button on each of the three colour
  rows (fill, stroke, shadow). v-if="eyeDropperSupported" hides
  the button entirely on Safari + Firefox — clean PE. Fill and
  stroke buttons disable when their "Transparent" toggle is on.
  Picked colour writes through the same fillColor / strokeColor /
  shadowColorValue computeds, so the keyframe path / asset writer
  is identical to a color-input drag.
- properties.eyedropper i18n string.

Tests (7 new under "eyedropper — color sampling from anywhere on the page"):
- supported reflects window.EyeDropper presence at call time.
- pick() resolves with the sRGBHex from a stubbed EyeDropper.
- pick() returns null on AbortError.
- pick() returns null when the API is missing.
- PropertiesPanel renders eyedropper buttons when supported.
- PropertiesPanel hides them when EyeDropper is undefined.
- Clicking the fill eyedropper applies the picked colour through
  the layer's keyframe / asset writer.

`claude.txt` backlog complete (wiggle / motion-paths / eyedropper
all shipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-26 21:21:14 +02:00
1 parent a2fdd1684a
commit 184842b2e0
6 files changed
+237 -2

No files matched your search

+149
View File
@@ -16039,4 +16039,153 @@ export function registerAllTests(): void {
}
})
})
suite('eyedropper — color sampling from anywhere on the page', () => {
function withEyeDropper(stub: unknown, fn: () => Promise<void>): Promise<void> {
const real = (window as unknown as { EyeDropper?: unknown }).EyeDropper
;(window as unknown as { EyeDropper: unknown }).EyeDropper = stub
return fn().finally(() => {
if (real === undefined) {
delete (window as unknown as { EyeDropper?: unknown }).EyeDropper
} else {
(window as unknown as { EyeDropper: unknown }).EyeDropper = real
}
})
}
function withoutEyeDropper(fn: () => Promise<void>): Promise<void> {
const real = (window as unknown as { EyeDropper?: unknown }).EyeDropper
delete (window as unknown as { EyeDropper?: unknown }).EyeDropper
return fn().finally(() => {
if (real !== undefined) {
(window as unknown as { EyeDropper: unknown }).EyeDropper = real
}
})
}
test('useEyeDropper.supported reflects window.EyeDropper presence at call time', async () => {
const { useEyeDropper } = await import('@/composables/useEyeDropper')
await withoutEyeDropper(async () => {
expect(useEyeDropper().supported.value).toBe(false)
})
await withEyeDropper(function () { return {} }, async () => {
expect(useEyeDropper().supported.value).toBe(true)
})
})
test('useEyeDropper.pick() resolves to the chosen sRGBHex string', async () => {
const { useEyeDropper } = await import('@/composables/useEyeDropper')
await withEyeDropper(class {
open() { return Promise.resolve({ sRGBHex: '#abcdef' }) }
}, async () => {
const { pick } = useEyeDropper()
expect(await pick()).toBe('#abcdef')
})
})
test('useEyeDropper.pick() returns null when the user cancels', async () => {
const { useEyeDropper } = await import('@/composables/useEyeDropper')
await withEyeDropper(class {
open() { return Promise.reject(new DOMException('aborted', 'AbortError')) }
}, async () => {
const { pick } = useEyeDropper()
expect(await pick()).toBe(null)
})
})
test('useEyeDropper.pick() returns null when EyeDropper is missing', async () => {
const { useEyeDropper } = await import('@/composables/useEyeDropper')
await withoutEyeDropper(async () => {
const { pick } = useEyeDropper()
expect(await pick()).toBe(null)
})
})
test('PropertiesPanel: eyedropper buttons render when EyeDropper is supported', async () => {
await withEyeDropper(class {
open() { return Promise.resolve({ sRGBHex: '#112233' }) }
}, async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#ff0000' })
const layer = s.addLayer(a.id)!
s.selectedLayerId = layer.id
s.selectedLayerIds = [layer.id]
const handle = mountComponent(PropertiesPanel, {})
try {
await nextTick()
const headers = Array.from(handle.host.querySelectorAll<HTMLButtonElement>('.group-header'))
const appearance = headers.find((b) => (b.textContent ?? '').toLowerCase().includes('appearance'))
if (appearance && !handle.host.querySelector('.color-row')) {
appearance.click()
await nextTick()
}
const eyedropperBtns = handle.host.querySelectorAll('.eyedropper-btn')
// rect → fill + stroke rows, both get an eyedropper.
expect(eyedropperBtns.length).toBeGreaterThan(0)
} finally {
handle.unmount()
}
})
})
test('PropertiesPanel: eyedropper buttons disappear when EyeDropper is missing', async () => {
await withoutEyeDropper(async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#ff0000' })
const layer = s.addLayer(a.id)!
s.selectedLayerId = layer.id
s.selectedLayerIds = [layer.id]
const handle = mountComponent(PropertiesPanel, {})
try {
await nextTick()
const headers = Array.from(handle.host.querySelectorAll<HTMLButtonElement>('.group-header'))
const appearance = headers.find((b) => (b.textContent ?? '').toLowerCase().includes('appearance'))
if (appearance && !handle.host.querySelector('.color-row')) {
appearance.click()
await nextTick()
}
expect(handle.host.querySelectorAll('.eyedropper-btn').length).toBe(0)
} finally {
handle.unmount()
}
})
})
test('PropertiesPanel: clicking the fill eyedropper applies the picked colour', async () => {
await withEyeDropper(class {
open() { return Promise.resolve({ sRGBHex: '#aabbcc' }) }
}, async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#ff0000' })
const layer = s.addLayer(a.id)!
s.selectedLayerId = layer.id
s.selectedLayerIds = [layer.id]
const handle = mountComponent(PropertiesPanel, {})
try {
await nextTick()
const headers = Array.from(handle.host.querySelectorAll<HTMLButtonElement>('.group-header'))
const appearance = headers.find((b) => (b.textContent ?? '').toLowerCase().includes('appearance'))
if (appearance && !handle.host.querySelector('.color-row')) {
appearance.click()
await nextTick()
}
// First color-row in the appearance section is fill.
const fillEyedrop = handle.host.querySelector('.color-row .eyedropper-btn') as HTMLButtonElement | null
expect(!!fillEyedrop).toBe(true)
fillEyedrop!.click()
// Wait for the EyeDropper open() promise + Vue state propagation.
await nextTick()
await tick(20)
await nextTick()
// The fill writer upserts a keyframe with the new colour.
const sampled = sampleLayer(layer, s.playhead)
// The change either lands on a keyframe or on the asset.
const assetFill = (s.scene.assets.find((x) => x.id === a.id) as { fill?: string }).fill
const wrote = sampled.fill === '#aabbcc' || assetFill === '#aabbcc'
expect(wrote).toBe(true)
} finally {
handle.unmount()
}
})
})
})
}