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

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ruwio", "name": "ruwio",
"private": true, "private": true,
"version": "1.2.11", "version": "1.2.12",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+45
View File
@@ -6,6 +6,7 @@ import {
PhDiamond, PhDiamond,
PhEye, PhEye,
PhEyeSlash, PhEyeSlash,
PhEyedropper,
PhFlower, PhFlower,
PhMaskHappy, PhMaskHappy,
PhWaveSine, PhWaveSine,
@@ -23,8 +24,23 @@ import ActionEditor from './ActionEditor.vue'
import FontPicker from './FontPicker.vue' import FontPicker from './FontPicker.vue'
import { t } from '@/i18n' import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat' import { useTimeFormat } from '@/composables/useTimeFormat'
import { useEyeDropper } from '@/composables/useEyeDropper'
const { formatTime, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat() const { formatTime, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
const { supported: eyeDropperSupported, pick: pickColor } = useEyeDropper()
async function pickFillColor() {
const c = await pickColor()
if (c) fillColor.value = c
}
async function pickStrokeColor() {
const c = await pickColor()
if (c) strokeColor.value = c
}
async function pickShadowColor() {
const c = await pickColor()
if (c) shadowColorValue.value = c
}
const SECTION_STORAGE_KEY = 'animatr:propSections' const SECTION_STORAGE_KEY = 'animatr:propSections'
@@ -645,6 +661,16 @@ function deleteSelection() {
@input="fillColor = ($event.target as HTMLInputElement).value" @input="fillColor = ($event.target as HTMLInputElement).value"
/> />
<span class="color-hex">{{ fillTransparent ? '—' : fillColor.toUpperCase() }}</span> <span class="color-hex">{{ fillTransparent ? '—' : fillColor.toUpperCase() }}</span>
<button
v-if="eyeDropperSupported"
type="button"
class="icon-toggle eyedropper-btn"
:disabled="fillTransparent"
v-tooltip="t('properties.eyedropper')"
@click="pickFillColor"
>
<PhEyedropper :size="13" />
</button>
<button <button
type="button" type="button"
class="icon-toggle alpha-toggle-btn" class="icon-toggle alpha-toggle-btn"
@@ -667,6 +693,16 @@ function deleteSelection() {
@input="strokeColor = ($event.target as HTMLInputElement).value" @input="strokeColor = ($event.target as HTMLInputElement).value"
/> />
<span class="color-hex">{{ strokeTransparent ? '—' : strokeColor.toUpperCase() }}</span> <span class="color-hex">{{ strokeTransparent ? '—' : strokeColor.toUpperCase() }}</span>
<button
v-if="eyeDropperSupported"
type="button"
class="icon-toggle eyedropper-btn"
:disabled="strokeTransparent"
v-tooltip="t('properties.eyedropper')"
@click="pickStrokeColor"
>
<PhEyedropper :size="13" />
</button>
<button <button
type="button" type="button"
class="icon-toggle alpha-toggle-btn" class="icon-toggle alpha-toggle-btn"
@@ -721,6 +757,15 @@ function deleteSelection() {
@input="shadowColorValue = ($event.target as HTMLInputElement).value" @input="shadowColorValue = ($event.target as HTMLInputElement).value"
/> />
<span class="color-hex">{{ shadowColorValue.toUpperCase() }}</span> <span class="color-hex">{{ shadowColorValue.toUpperCase() }}</span>
<button
v-if="eyeDropperSupported"
type="button"
class="icon-toggle eyedropper-btn"
v-tooltip="t('properties.eyedropper')"
@click="pickShadowColor"
>
<PhEyedropper :size="13" />
</button>
</div> </div>
</label> </label>
<label class="slider-label"> <label class="slider-label">
+40
View File
@@ -0,0 +1,40 @@
// Wraps the browser's EyeDropper API (Chromium 95+). Provides a
// reactive `supported` flag plus a `pick()` helper that resolves to
// the chosen sRGBHex string (or null on cancel/abort). Callers
// should hide the pipette button when `supported` is false to keep
// Safari + Firefox UX clean.
import { ref } from 'vue'
interface EyeDropperResult {
sRGBHex: string
}
interface EyeDropperConstructor {
new (): { open(opts?: { signal?: AbortSignal }): Promise<EyeDropperResult> }
}
function detectSupport(): boolean {
return (
typeof window !== 'undefined' &&
typeof (window as unknown as { EyeDropper?: unknown }).EyeDropper === 'function'
)
}
export function useEyeDropper() {
// Re-detect on every call so a test (or a polyfill that loads
// late) can see updates without juggling Vite's module cache.
const supported = ref(detectSupport())
async function pick(): Promise<string | null> {
if (!detectSupport()) return null
const Ctor = (window as unknown as { EyeDropper: EyeDropperConstructor }).EyeDropper
try {
const result = await new Ctor().open()
return result.sRGBHex
} catch {
// User pressed Esc / dismissed the picker — treat as cancel.
return null
}
}
return { supported, pick }
}
+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()
}
})
})
})
} }
+1
View File
@@ -193,6 +193,7 @@ export const en = {
'Selected layer is locked. Unlock the track (or its group) to edit properties.', 'Selected layer is locked. Unlock the track (or its group) to edit properties.',
fill: 'Fill', fill: 'Fill',
transparent: 'Transparent', transparent: 'Transparent',
eyedropper: 'Pick a colour from anywhere on the page',
opacity: 'Opacity', opacity: 'Opacity',
edgeBlur: 'Edge blur', edgeBlur: 'Edge blur',
sections: { sections: {
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.2.11' export const APP_VERSION = '1.2.12'
export const APP_NAME = 'RUWIO' export const APP_NAME = 'RUWIO'