diff --git a/docs/web/api/color-picker.en-US.md b/docs/web/api/color-picker.en-US.md index 1423c7ba06..27ca0a89d2 100644 --- a/docs/web/api/color-picker.en-US.md +++ b/docs/web/api/color-picker.en-US.md @@ -49,3 +49,9 @@ The most recently used color can be configured through `recentColors`. A value o ### Read-only Color Selector {{ status-readonly }} + +### Color Picker with Screen Color Sampling + +With `eyeDropper` enabled, an eyedropper button in the panel header invokes the browser's native EyeDropper API to pick a color from anywhere on the screen. This relies on browser support (Chrome / Edge 95+); the button is disabled in unsupported environments. + +{{ eye-dropper }} diff --git a/docs/web/api/color-picker.md b/docs/web/api/color-picker.md index d78785a147..57a5dd8552 100644 --- a/docs/web/api/color-picker.md +++ b/docs/web/api/color-picker.md @@ -49,3 +49,9 @@ spline: form ### 只读状态的颜色选择器 {{ status-readonly }} + +### 吸管取色的颜色选择器 + +开启 `eyeDropper` 后,可通过面板顶部的吸管按钮调起浏览器原生 EyeDropper API,直接拾取屏幕上任意位置的颜色。该能力依赖浏览器支持(Chrome / Edge 95+),不支持的环境中按钮呈禁用态。 + +{{ eye-dropper }} diff --git a/js/color-picker/eyedropper.ts b/js/color-picker/eyedropper.ts new file mode 100644 index 0000000000..4a161e87ef --- /dev/null +++ b/js/color-picker/eyedropper.ts @@ -0,0 +1,41 @@ +/** + * 浏览器原生 EyeDropper API 封装 + * @see https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper + */ + +export interface OpenEyeDropperOptions { + /** 用于中断取色过程(如组件卸载时) */ + signal?: AbortSignal; +} + +interface NativeEyeDropper { + open(options?: { signal?: AbortSignal }): Promise<{ sRGBHex: string }>; +} + +type EyeDropperHost = Window & { EyeDropper?: new () => NativeEyeDropper }; + +/** + * 检测当前环境是否支持 EyeDropper API(SSR 环境返回 false)。 + */ +export function isEyeDropperSupported(): boolean { + if (typeof window === 'undefined') return false; + return typeof (window as EyeDropperHost).EyeDropper === 'function'; +} + +/** + * 调起系统吸色器。 + * @returns 用户选中的颜色(小写 `#rrggbb`,与 `` 的 value 语义一致); + * 环境不支持、用户取消(Esc / AbortSignal)或取色失败时返回 `null`,调用方无需 try/catch。 + */ +export async function openEyeDropper(options?: OpenEyeDropperOptions): Promise { + if (!isEyeDropperSupported()) return null; + const EyeDropperCtor = (window as EyeDropperHost).EyeDropper; + try { + const { sRGBHex } = await new EyeDropperCtor().open(options?.signal ? { signal: options.signal } : undefined); + // 规范未强制 sRGBHex 的大小写,统一转小写便于比较与存储 + return sRGBHex.toLowerCase(); + } catch (e) { + // AbortError(用户取消/中断)与并发取色冲突等场景统一按“未取到色”处理 + return null; + } +} diff --git a/js/color-picker/index.ts b/js/color-picker/index.ts index 7df636765b..121acb28e5 100644 --- a/js/color-picker/index.ts +++ b/js/color-picker/index.ts @@ -2,6 +2,7 @@ export * from './cmyk'; export * from './color'; export * from './constants'; export * from './draggable'; +export * from './eyedropper'; export * from './format'; export * from './gradient'; export * from './types'; diff --git a/style/web/components/color-picker/_index.less b/style/web/components/color-picker/_index.less index 2d04f49448..4bed428b1b 100644 --- a/style/web/components/color-picker/_index.less +++ b/style/web/components/color-picker/_index.less @@ -51,6 +51,17 @@ pointer-events: none; } } + + // 吸色按钮:视觉规则复用 &__icon(组件侧同时挂载两个类),此处仅补充原生 button 元素的复位与固定尺寸 + &__eyedropper { + width: @color-picker-swatch-icon-size; + height: @color-picker-swatch-icon-size; + padding: 0; + border: none; + outline: none; + background: none; + flex: none; + } } .@{prefix}-color-picker__head { diff --git a/test/unit/color-picker/eyedropper.test.ts b/test/unit/color-picker/eyedropper.test.ts new file mode 100644 index 0000000000..c55068fbd5 --- /dev/null +++ b/test/unit/color-picker/eyedropper.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { isEyeDropperSupported, openEyeDropper } from '../../../js/color-picker/eyedropper'; + +/** + * 以指定的 open 实现 stub 全局 EyeDropper 构造器,返回其 mock 以便断言调用参数 + */ +const stubEyeDropper = (impl: (options?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }>) => { + const openMock = vi.fn(impl); + vi.stubGlobal( + 'EyeDropper', + class { + open = openMock; + } + ); + return openMock; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('isEyeDropperSupported', () => { + it('returns false in environments without EyeDropper (SSR / old browsers)', () => { + expect(isEyeDropperSupported()).toBe(false); + }); + + it('returns false when the EyeDropper global is not a constructor', () => { + vi.stubGlobal('EyeDropper', {}); + expect(isEyeDropperSupported()).toBe(false); + }); + + it('returns true when EyeDropper is available', () => { + stubEyeDropper(() => Promise.resolve({ sRGBHex: '#000000' })); + expect(isEyeDropperSupported()).toBe(true); + }); +}); + +describe('openEyeDropper', () => { + it('returns null when the API is unavailable', async () => { + expect(await openEyeDropper()).toBeNull(); + }); + + it('returns the picked color normalized to lowercase hex', async () => { + // 规范未强制 sRGBHex 的大小写,这里用大写验证归一化 + stubEyeDropper(() => Promise.resolve({ sRGBHex: '#FF6600' })); + expect(await openEyeDropper()).toBe('#ff6600'); + }); + + it('passes the AbortSignal through to the native open()', async () => { + const openMock = stubEyeDropper(() => Promise.resolve({ sRGBHex: '#123456' })); + const controller = new AbortController(); + await openEyeDropper({ signal: controller.signal }); + expect(openMock).toHaveBeenCalledWith({ signal: controller.signal }); + }); + + it('calls open() without options when no signal is provided', async () => { + const openMock = stubEyeDropper(() => Promise.resolve({ sRGBHex: '#123456' })); + await openEyeDropper(); + expect(openMock).toHaveBeenCalledWith(undefined); + }); + + it('returns null when the user cancels picking (AbortError)', async () => { + stubEyeDropper(() => Promise.reject(new DOMException('The user canceled the selection.', 'AbortError'))); + expect(await openEyeDropper()).toBeNull(); + }); + + it('returns null on unexpected native errors', async () => { + stubEyeDropper(() => Promise.reject(new Error('unexpected'))); + expect(await openEyeDropper()).toBeNull(); + }); +});