From 7a3586527912a8df05dfe0ca585c12c1ecd05b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=99=A8=20=28Leo=20Cheng=29?= Date: Tue, 7 Jul 2026 10:56:52 +0800 Subject: [PATCH 1/5] feat(color-picker): add EyeDropper color sampling support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 林晨 (Leo Cheng) --- docs/web/api/color-picker.en-US.md | 6 ++++ docs/web/api/color-picker.md | 6 ++++ js/color-picker/eyedropper.ts | 36 +++++++++++++++++++ js/color-picker/index.ts | 1 + style/web/components/color-picker/_index.less | 31 +++++++++++++++- 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 js/color-picker/eyedropper.ts diff --git a/docs/web/api/color-picker.en-US.md b/docs/web/api/color-picker.en-US.md index 1423c7ba06..6b62c2443e 100644 --- a/docs/web/api/color-picker.en-US.md +++ b/docs/web/api/color-picker.en-US.md @@ -12,6 +12,12 @@ There is no trigger and the color picker panel is displayed directly. {{ panel }} +### Color picker with EyeDropper support + +Set `eyeDropper=true` to enable screen color sampling. An eyedropper button appears in the panel header. Clicking it invokes the browser's native EyeDropper API to pick any color from the screen. The button is automatically disabled when the browser does not support the API. + +{{ eye-dropper }} + ### Color Picker with Trigger Element Trigger the display selector panel through the trigger, and transparently transfer all attributes to the panel selector component. diff --git a/docs/web/api/color-picker.md b/docs/web/api/color-picker.md index d78785a147..511d113aff 100644 --- a/docs/web/api/color-picker.md +++ b/docs/web/api/color-picker.md @@ -49,3 +49,9 @@ spline: form ### 只读状态的颜色选择器 {{ status-readonly }} + +### 支持吸色的颜色选择器 + +设置 `eyeDropper=true` 即可开启吸色功能,面板顶部会出现吸色按钮。点击后使用浏览器原生 EyeDropper API 从屏幕任意位置取色。当浏览器不支持时按钮自动禁用。 + +{{ eye-dropper }} diff --git a/js/color-picker/eyedropper.ts b/js/color-picker/eyedropper.ts new file mode 100644 index 0000000000..cbfa342759 --- /dev/null +++ b/js/color-picker/eyedropper.ts @@ -0,0 +1,36 @@ +/** + * EyeDropper API 封装,提供吸色功能支持 + * + * 规范参考:https://developer.mozilla.org/zh-CN/docs/Web/API/EyeDropper + */ + +declare class EyeDropper { + open(options?: { signal?: AbortSignal }): Promise<{ sRGBHex: string }>; +} + +declare global { + interface Window { + EyeDropper?: typeof EyeDropper; + } +} + +/** + * 检测当前浏览器是否支持 EyeDropper API + */ +export const isEyeDropperSupported = (): boolean => typeof window !== 'undefined' && 'EyeDropper' in window; + +/** + * 打开吸色工具,让用户从屏幕上取色 + * @returns 返回选中颜色的十六进制字符串(如 `#rrggbb`),用户取消或不支持时返回 null + */ +export const openEyeDropper = async (): Promise => { + if (!isEyeDropperSupported()) return null; + try { + const eyeDropper = new window.EyeDropper!(); + const result = await eyeDropper.open(); + return result.sRGBHex; + } catch { + // 用户取消(AbortError)或其他异常均返回 null + 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..3d59b59743 100644 --- a/style/web/components/color-picker/_index.less +++ b/style/web/components/color-picker/_index.less @@ -51,6 +51,35 @@ pointer-events: none; } } + + &__eyedropper { + display: flex; + align-items: center; + justify-content: center; + width: @color-picker-swatch-icon-size; + height: @color-picker-swatch-icon-size; + font-size: @color-picker-icon-font-size; + background: transparent; + border: none; + padding: 0; + transition: @anim-duration-base linear; + color: @text-color-secondary; + border-radius: @color-picker-icon-radius; + cursor: pointer; + outline: none; + + &:hover { + background: @bg-color-container-hover; + color: @text-color-primary; + transition: @anim-duration-base linear; + } + + &.@{prefix}-is-disabled { + color: @text-color-disabled; + cursor: not-allowed; + pointer-events: none; + } + } } .@{prefix}-color-picker__head { @@ -565,7 +594,7 @@ .@{prefix}-color-picker__saturation, .@{prefix}-color-picker__slider, .@{prefix}-color-picker__swatches--item { - opacity: .8; + opacity: 0.8; cursor: not-allowed; } .@{prefix}-color-picker__gradient-slider { From add7bb7cfe07f82cc101025f79f7edd207ef5bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=99=A8=20=28Leo=20Cheng=29?= Date: Tue, 7 Jul 2026 11:48:41 +0800 Subject: [PATCH 2/5] style(color-picker): remove explanatory comments from eyedropper module. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 林晨 (Leo Cheng) --- js/color-picker/eyedropper.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/js/color-picker/eyedropper.ts b/js/color-picker/eyedropper.ts index cbfa342759..f0436ee475 100644 --- a/js/color-picker/eyedropper.ts +++ b/js/color-picker/eyedropper.ts @@ -1,9 +1,3 @@ -/** - * EyeDropper API 封装,提供吸色功能支持 - * - * 规范参考:https://developer.mozilla.org/zh-CN/docs/Web/API/EyeDropper - */ - declare class EyeDropper { open(options?: { signal?: AbortSignal }): Promise<{ sRGBHex: string }>; } @@ -14,15 +8,8 @@ declare global { } } -/** - * 检测当前浏览器是否支持 EyeDropper API - */ export const isEyeDropperSupported = (): boolean => typeof window !== 'undefined' && 'EyeDropper' in window; -/** - * 打开吸色工具,让用户从屏幕上取色 - * @returns 返回选中颜色的十六进制字符串(如 `#rrggbb`),用户取消或不支持时返回 null - */ export const openEyeDropper = async (): Promise => { if (!isEyeDropperSupported()) return null; try { @@ -30,7 +17,7 @@ export const openEyeDropper = async (): Promise => { const result = await eyeDropper.open(); return result.sRGBHex; } catch { - // 用户取消(AbortError)或其他异常均返回 null + // user cancel (AbortError) or unsupported - both safe to swallow return null; } }; From 9033947783bbcbdad2c6a4c2c81364325a854b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=99=A8=20=28Leo=20Cheng=29?= Date: Tue, 7 Jul 2026 12:08:30 +0800 Subject: [PATCH 3/5] test(color-picker): add EyeDropper unit tests. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 林晨 (Leo Cheng) --- test/unit/color-picker/eyedropper.test.ts | 65 +++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 test/unit/color-picker/eyedropper.test.ts diff --git a/test/unit/color-picker/eyedropper.test.ts b/test/unit/color-picker/eyedropper.test.ts new file mode 100644 index 0000000000..bfa4ec7a7b --- /dev/null +++ b/test/unit/color-picker/eyedropper.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { isEyeDropperSupported, openEyeDropper } from '../../../js/color-picker/eyedropper'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +class MockEyeDropper { + constructor(private hex: string) {} + open() { + return Promise.resolve({ sRGBHex: this.hex }); + } +} + +class AbortingEyeDropper { + open() { + return Promise.reject(new DOMException('User aborted', 'AbortError')); + } +} + +class FailingEyeDropper { + open() { + return Promise.reject(new Error('hardware error')); + } +} + +describe('isEyeDropperSupported', () => { + it('returns false in SSR (no window)', () => { + // default Node test env has no window + expect(isEyeDropperSupported()).toBe(false); + }); + + it('returns false when window exists but EyeDropper is absent', () => { + vi.stubGlobal('window', {}); + expect(isEyeDropperSupported()).toBe(false); + }); + + it('returns true when EyeDropper is present on window', () => { + vi.stubGlobal('window', { EyeDropper: class {} }); + expect(isEyeDropperSupported()).toBe(true); + }); +}); + +describe('openEyeDropper', () => { + it('returns null when EyeDropper is not supported', async () => { + // no window stub → SSR-like environment + expect(await openEyeDropper()).toBeNull(); + }); + + it('returns the selected hex color on success', async () => { + vi.stubGlobal('window', { EyeDropper: class { open() { return Promise.resolve({ sRGBHex: '#ff6600' }); } } }); + const result = await openEyeDropper(); + expect(result).toBe('#ff6600'); + }); + + it('returns null when user cancels (AbortError)', async () => { + vi.stubGlobal('window', { EyeDropper: AbortingEyeDropper }); + expect(await openEyeDropper()).toBeNull(); + }); + + it('returns null on any unexpected error from open()', async () => { + vi.stubGlobal('window', { EyeDropper: FailingEyeDropper }); + expect(await openEyeDropper()).toBeNull(); + }); +}); From 694144c3b7d270027178c4a66cbb9653f922fb39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=99=A8=20=28Leo=20Cheng=29?= Date: Tue, 7 Jul 2026 12:15:49 +0800 Subject: [PATCH 4/5] refactor(color-picker): use globalThis for EyeDropper detection, add signal param. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 林晨 (Leo Cheng) --- js/color-picker/eyedropper.ts | 36 +++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/js/color-picker/eyedropper.ts b/js/color-picker/eyedropper.ts index f0436ee475..8d05875de5 100644 --- a/js/color-picker/eyedropper.ts +++ b/js/color-picker/eyedropper.ts @@ -1,23 +1,35 @@ -declare class EyeDropper { - open(options?: { signal?: AbortSignal }): Promise<{ sRGBHex: string }>; +export interface EyeDropperResult { + sRGBHex: string; } -declare global { - interface Window { - EyeDropper?: typeof EyeDropper; - } +export interface EyeDropperOpenOptions { + signal?: AbortSignal; +} + +interface EyeDropperInstance { + open(options?: EyeDropperOpenOptions): Promise; +} + +interface EyeDropperConstructor { + new(): EyeDropperInstance; +} + +function getEyeDropperCtor(): EyeDropperConstructor | undefined { + if (typeof globalThis === 'undefined') return undefined; + const { EyeDropper } = globalThis as unknown as { EyeDropper?: unknown }; + return typeof EyeDropper === 'function' ? (EyeDropper as EyeDropperConstructor) : undefined; } -export const isEyeDropperSupported = (): boolean => typeof window !== 'undefined' && 'EyeDropper' in window; +export const isEyeDropperSupported = (): boolean => getEyeDropperCtor() !== undefined; -export const openEyeDropper = async (): Promise => { - if (!isEyeDropperSupported()) return null; +export const openEyeDropper = async (signal?: AbortSignal): Promise => { + const Ctor = getEyeDropperCtor(); + if (!Ctor) return null; try { - const eyeDropper = new window.EyeDropper!(); - const result = await eyeDropper.open(); + const result = await new Ctor().open(signal ? { signal } : undefined); return result.sRGBHex; } catch { - // user cancel (AbortError) or unsupported - both safe to swallow + // user cancel (AbortError) or concurrent session - both safe to swallow return null; } }; From 5b8a1e6ef4480b00190f2e0e27d44edb871e3b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=99=A8=20=28Leo=20Cheng=29?= Date: Tue, 7 Jul 2026 12:16:08 +0800 Subject: [PATCH 5/5] test(color-picker): update EyeDropper tests for globalThis detection and signal. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 林晨 (Leo Cheng) --- test/unit/color-picker/eyedropper.test.ts | 47 +++++++++++++++-------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/test/unit/color-picker/eyedropper.test.ts b/test/unit/color-picker/eyedropper.test.ts index bfa4ec7a7b..eedf45cb3b 100644 --- a/test/unit/color-picker/eyedropper.test.ts +++ b/test/unit/color-picker/eyedropper.test.ts @@ -6,8 +6,8 @@ afterEach(() => { }); class MockEyeDropper { - constructor(private hex: string) {} - open() { + constructor(private hex: string = '#aabbcc') {} + open(_opts?: { signal?: AbortSignal }) { return Promise.resolve({ sRGBHex: this.hex }); } } @@ -25,41 +25,56 @@ class FailingEyeDropper { } describe('isEyeDropperSupported', () => { - it('returns false in SSR (no window)', () => { - // default Node test env has no window + it('returns false when EyeDropper is absent from globalThis', () => { + // default Node test env has no EyeDropper expect(isEyeDropperSupported()).toBe(false); }); - it('returns false when window exists but EyeDropper is absent', () => { - vi.stubGlobal('window', {}); + it('returns false when the EyeDropper global is not a function', () => { + vi.stubGlobal('EyeDropper', 'not-a-constructor'); expect(isEyeDropperSupported()).toBe(false); }); - it('returns true when EyeDropper is present on window', () => { - vi.stubGlobal('window', { EyeDropper: class {} }); + it('returns true when EyeDropper is a constructor on globalThis', () => { + vi.stubGlobal('EyeDropper', class {}); expect(isEyeDropperSupported()).toBe(true); }); }); describe('openEyeDropper', () => { - it('returns null when EyeDropper is not supported', async () => { - // no window stub → SSR-like environment + it('returns null when EyeDropper is not available', async () => { expect(await openEyeDropper()).toBeNull(); }); - it('returns the selected hex color on success', async () => { - vi.stubGlobal('window', { EyeDropper: class { open() { return Promise.resolve({ sRGBHex: '#ff6600' }); } } }); - const result = await openEyeDropper(); - expect(result).toBe('#ff6600'); + it('returns the picked hex color on success', async () => { + vi.stubGlobal('EyeDropper', class extends MockEyeDropper { + constructor() { super('#ff6600'); } + }); + expect(await openEyeDropper()).toBe('#ff6600'); + }); + + it('forwards an AbortSignal to open()', async () => { + const openSpy = vi.fn().mockResolvedValue({ sRGBHex: '#112233' }); + vi.stubGlobal('EyeDropper', class { open = openSpy; }); + const controller = new AbortController(); + await openEyeDropper(controller.signal); + expect(openSpy).toHaveBeenCalledWith({ signal: controller.signal }); + }); + + it('calls open() without options when no signal provided', async () => { + const openSpy = vi.fn().mockResolvedValue({ sRGBHex: '#112233' }); + vi.stubGlobal('EyeDropper', class { open = openSpy; }); + await openEyeDropper(); + expect(openSpy).toHaveBeenCalledWith(undefined); }); it('returns null when user cancels (AbortError)', async () => { - vi.stubGlobal('window', { EyeDropper: AbortingEyeDropper }); + vi.stubGlobal('EyeDropper', AbortingEyeDropper); expect(await openEyeDropper()).toBeNull(); }); it('returns null on any unexpected error from open()', async () => { - vi.stubGlobal('window', { EyeDropper: FailingEyeDropper }); + vi.stubGlobal('EyeDropper', FailingEyeDropper); expect(await openEyeDropper()).toBeNull(); }); });