Skip to content
Closed
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 @@ -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.
Expand Down
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=true` 即可开启吸色功能,面板顶部会出现吸色按钮。点击后使用浏览器原生 EyeDropper API 从屏幕任意位置取色。当浏览器不支持时按钮自动禁用。

{{ eye-dropper }}
35 changes: 35 additions & 0 deletions js/color-picker/eyedropper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export interface EyeDropperResult {
sRGBHex: string;
}

export interface EyeDropperOpenOptions {
signal?: AbortSignal;
}

interface EyeDropperInstance {
open(options?: EyeDropperOpenOptions): Promise<EyeDropperResult>;
}

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<string | null> => {
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;
}
};
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';
1 change: 1 addition & 0 deletions js/global-config/locale/ar_KW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export default {
clearConfirmText: 'هل تريد مسح الألوان المستخدمة مؤخرًا؟',
singleColor: 'موحد',
gradientColor: 'متدرج',
eyeDropper: 'قطارة',
},
image: {
errorText: 'غير قادر على التحميل',
Expand Down
1 change: 1 addition & 0 deletions js/global-config/locale/en_US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export default {
clearConfirmText: 'Clear recently used colors?',
singleColor: 'Single',
gradientColor: 'Gradient',
eyeDropper: 'Eyedropper',
},
guide: {
finishButtonProps: {
Expand Down
1 change: 1 addition & 0 deletions js/global-config/locale/it_IT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions js/global-config/locale/ja_JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export default {
clearConfirmText: '最近使用した色をクリアにするのは確実ですか?',
singleColor: '単色',
gradientColor: 'グラデ',
eyeDropper: 'スポイト',
},
guide: {
finishButtonProps: {
Expand Down
1 change: 1 addition & 0 deletions js/global-config/locale/ko_KR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export default {
clearConfirmText: '최근에 사용한 색상을 지우시겠습니까?',
singleColor: '단색',
gradientColor: '그라데이션',
eyeDropper: '스포이트',
},
guide: {
finishButtonProps: {
Expand Down
1 change: 1 addition & 0 deletions js/global-config/locale/ru_RU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export default {
clearConfirmText: 'Вы уверены, что хотите очистить недавно использованные цвета?',
singleColor: 'Сплошной',
gradientColor: 'Градиент',
eyeDropper: 'Пипетка',
},
guide: {
finishButtonProps: {
Expand Down
1 change: 1 addition & 0 deletions js/global-config/locale/zh_CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export default {
clearConfirmText: '确定清空最近使用的颜色吗?',
singleColor: '单色',
gradientColor: '渐变',
eyeDropper: '吸色',
},
guide: {
finishButtonProps: {
Expand Down
1 change: 1 addition & 0 deletions js/global-config/locale/zh_TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export default {
clearConfirmText: '確定清空最近使用的顔色嗎?',
singleColor: '單色',
gradientColor: '漸變',
eyeDropper: '吸色',
},
guide: {
finishButtonProps: {
Expand Down
31 changes: 30 additions & 1 deletion style/web/components/color-picker/_index.less
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
101 changes: 101 additions & 0 deletions test/unit/color-picker/eyedropper.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});