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:
1 parent
a2fdd1684a
commit
184842b2e0
6 files changed
+237
-2
No files matched your search
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user