Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/web/api/color-picker.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
6 changes: 6 additions & 0 deletions docs/web/api/color-picker.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,9 @@ spline: form
### 只读状态的颜色选择器

{{ status-readonly }}

### 吸管取色的颜色选择器

开启 `eyeDropper` 后,可通过面板顶部的吸管按钮调起浏览器原生 EyeDropper API,直接拾取屏幕上任意位置的颜色。该能力依赖浏览器支持(Chrome / Edge 95+),不支持的环境中按钮呈禁用态。

{{ eye-dropper }}
41 changes: 41 additions & 0 deletions js/color-picker/eyedropper.ts
Original file line number Diff line number Diff line change
@@ -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`,与 `<input type="color">` 的 value 语义一致);
* 环境不支持、用户取消(Esc / AbortSignal)或取色失败时返回 `null`,调用方无需 try/catch。
*/
export async function openEyeDropper(options?: OpenEyeDropperOptions): Promise<string | null> {
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;
}
}
1 change: 1 addition & 0 deletions js/color-picker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
11 changes: 11 additions & 0 deletions style/web/components/color-picker/_index.less
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
74 changes: 74 additions & 0 deletions test/unit/color-picker/eyedropper.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});