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 globalThis === 'undefined') return undefined;
const { EyeDropper } = globalThis as unknown as { 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';
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
80 changes: 80 additions & 0 deletions test/unit/color-picker/eyedropper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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 = '#aabbcc') {}
open(_opts?: { signal?: AbortSignal }) {
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 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();
});
});