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..3003c4fba0 --- /dev/null +++ b/js/color-picker/eyedropper.ts @@ -0,0 +1,35 @@ +export interface EyeDropperResult { + sRGBHex: string; +} + +export interface EyeDropperOpenOptions { + signal?: AbortSignal; +} + +interface EyeDropperInstance { + open(options?: EyeDropperOpenOptions): Promise; +} + +interface EyeDropperConstructor { + new (): EyeDropperInstance; +} + +function getEyeDropperCtor(): EyeDropperConstructor | undefined { + if (typeof window === 'undefined') return undefined; + const { EyeDropper } = window as Window & { EyeDropper?: unknown }; + return typeof EyeDropper === 'function' ? (EyeDropper as EyeDropperConstructor) : undefined; +} + +export const isEyeDropperSupported = (): boolean => getEyeDropperCtor() !== undefined; + +export const openEyeDropper = async (signal?: AbortSignal): Promise => { + const Ctor = getEyeDropperCtor(); + if (!Ctor) return null; + try { + const result = await new Ctor().open(signal ? { signal } : undefined); + return result.sRGBHex; + } catch { + // user cancel (AbortError) or concurrent session - both safe to swallow + 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/js/global-config/locale/ar_KW.ts b/js/global-config/locale/ar_KW.ts index d866d4c604..4a00943ba0 100644 --- a/js/global-config/locale/ar_KW.ts +++ b/js/global-config/locale/ar_KW.ts @@ -208,6 +208,7 @@ export default { clearConfirmText: 'هل تريد مسح الألوان المستخدمة مؤخرًا؟', singleColor: 'موحد', gradientColor: 'متدرج', + eyeDropper: 'قطارة', }, image: { errorText: 'غير قادر على التحميل', diff --git a/js/global-config/locale/en_US.ts b/js/global-config/locale/en_US.ts index a10a0e3fef..767041209c 100644 --- a/js/global-config/locale/en_US.ts +++ b/js/global-config/locale/en_US.ts @@ -192,6 +192,7 @@ export default { clearConfirmText: 'Clear recently used colors?', singleColor: 'Single', gradientColor: 'Gradient', + eyeDropper: 'Eyedropper', }, guide: { finishButtonProps: { diff --git a/js/global-config/locale/it_IT.ts b/js/global-config/locale/it_IT.ts index 04662df124..4ccdb2aaca 100644 --- a/js/global-config/locale/it_IT.ts +++ b/js/global-config/locale/it_IT.ts @@ -191,6 +191,7 @@ export default { clearConfirmText: 'Sei sicuro di voler cancellare i colori usati di recente?', singleColor: 'Singolo', gradientColor: 'Gradiente', + eyeDropper: 'Contagocce', }, guide: { finishButtonProps: { diff --git a/js/global-config/locale/ja_JP.ts b/js/global-config/locale/ja_JP.ts index ffec41ae10..ae8414273b 100644 --- a/js/global-config/locale/ja_JP.ts +++ b/js/global-config/locale/ja_JP.ts @@ -191,6 +191,7 @@ export default { clearConfirmText: '最近使用した色をクリアにするのは確実ですか?', singleColor: '単色', gradientColor: 'グラデ', + eyeDropper: 'スポイト', }, guide: { finishButtonProps: { diff --git a/js/global-config/locale/ko_KR.ts b/js/global-config/locale/ko_KR.ts index 9aac56d11f..4924829f0d 100644 --- a/js/global-config/locale/ko_KR.ts +++ b/js/global-config/locale/ko_KR.ts @@ -191,6 +191,7 @@ export default { clearConfirmText: '최근에 사용한 색상을 지우시겠습니까?', singleColor: '단색', gradientColor: '그라데이션', + eyeDropper: '스포이트', }, guide: { finishButtonProps: { diff --git a/js/global-config/locale/ru_RU.ts b/js/global-config/locale/ru_RU.ts index bc20d79498..e0cea9d24c 100644 --- a/js/global-config/locale/ru_RU.ts +++ b/js/global-config/locale/ru_RU.ts @@ -204,6 +204,7 @@ export default { clearConfirmText: 'Вы уверены, что хотите очистить недавно использованные цвета?', singleColor: 'Сплошной', gradientColor: 'Градиент', + eyeDropper: 'Пипетка', }, guide: { finishButtonProps: { diff --git a/js/global-config/locale/zh_CN.ts b/js/global-config/locale/zh_CN.ts index 9d24e7ca80..f7366eaaf6 100644 --- a/js/global-config/locale/zh_CN.ts +++ b/js/global-config/locale/zh_CN.ts @@ -192,6 +192,7 @@ export default { clearConfirmText: '确定清空最近使用的颜色吗?', singleColor: '单色', gradientColor: '渐变', + eyeDropper: '吸色', }, guide: { finishButtonProps: { diff --git a/js/global-config/locale/zh_TW.ts b/js/global-config/locale/zh_TW.ts index bd9d731157..0188a3f34c 100644 --- a/js/global-config/locale/zh_TW.ts +++ b/js/global-config/locale/zh_TW.ts @@ -192,6 +192,7 @@ export default { clearConfirmText: '確定清空最近使用的顔色嗎?', singleColor: '單色', gradientColor: '漸變', + eyeDropper: '吸色', }, guide: { finishButtonProps: { 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 { diff --git a/test/unit/color-picker/eyedropper.test.ts b/test/unit/color-picker/eyedropper.test.ts new file mode 100644 index 0000000000..88592f3ffc --- /dev/null +++ b/test/unit/color-picker/eyedropper.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +/* eslint-disable max-classes-per-file */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { isEyeDropperSupported, openEyeDropper } from '../../../js/color-picker/eyedropper'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +class MockEyeDropper { + // eslint-disable-next-line no-useless-constructor, no-empty-function + constructor(private hex: string = '#aabbcc') {} + + open(_opts?: { signal?: AbortSignal }) { + return Promise.resolve({ sRGBHex: this.hex }); + } +} + +class AbortingEyeDropper { + // eslint-disable-next-line class-methods-use-this + open() { + return Promise.reject(new DOMException('User aborted', 'AbortError')); + } +} + +class FailingEyeDropper { + // eslint-disable-next-line class-methods-use-this + open() { + return Promise.reject(new Error('hardware error')); + } +} + +describe('isEyeDropperSupported', () => { + it('returns false when EyeDropper is absent from globalThis', () => { + // default Node test env has no EyeDropper + expect(isEyeDropperSupported()).toBe(false); + }); + + 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 a constructor on globalThis', () => { + vi.stubGlobal('EyeDropper', class {}); + expect(isEyeDropperSupported()).toBe(true); + }); +}); + +describe('openEyeDropper', () => { + it('returns null when EyeDropper is not available', async () => { + expect(await openEyeDropper()).toBeNull(); + }); + + 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('EyeDropper', AbortingEyeDropper); + expect(await openEyeDropper()).toBeNull(); + }); + + it('returns null on any unexpected error from open()', async () => { + vi.stubGlobal('EyeDropper', FailingEyeDropper); + expect(await openEyeDropper()).toBeNull(); + }); +});