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
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "1.2.11",
|
||||
"version": "1.2.12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PhDiamond,
|
||||
PhEye,
|
||||
PhEyeSlash,
|
||||
PhEyedropper,
|
||||
PhFlower,
|
||||
PhMaskHappy,
|
||||
PhWaveSine,
|
||||
@@ -23,8 +24,23 @@ import ActionEditor from './ActionEditor.vue'
|
||||
import FontPicker from './FontPicker.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||
import { useEyeDropper } from '@/composables/useEyeDropper'
|
||||
|
||||
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'
|
||||
|
||||
@@ -645,6 +661,16 @@ function deleteSelection() {
|
||||
@input="fillColor = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<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
|
||||
type="button"
|
||||
class="icon-toggle alpha-toggle-btn"
|
||||
@@ -667,6 +693,16 @@ function deleteSelection() {
|
||||
@input="strokeColor = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<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
|
||||
type="button"
|
||||
class="icon-toggle alpha-toggle-btn"
|
||||
@@ -721,6 +757,15 @@ function deleteSelection() {
|
||||
@input="shadowColorValue = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<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>
|
||||
</label>
|
||||
<label class="slider-label">
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -193,6 +193,7 @@ export const en = {
|
||||
'Selected layer is locked. Unlock the track (or its group) to edit properties.',
|
||||
fill: 'Fill',
|
||||
transparent: 'Transparent',
|
||||
eyedropper: 'Pick a colour from anywhere on the page',
|
||||
opacity: 'Opacity',
|
||||
edgeBlur: 'Edge blur',
|
||||
sections: {
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '1.2.11'
|
||||
export const APP_VERSION = '1.2.12'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user