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
48 changes: 48 additions & 0 deletions docs/color-picker-eyedropper-pr-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# ColorPicker EyeDropper PR Record

## Issue

Closes Tencent/tdesign-common#2568.

## Background

ColorPicker currently requires users to adjust colors manually in the panel. In scenarios such as uploading a background image and matching a page background color, users need to sample a color directly.

## Solution

This PR provides a common EyeDropper implementation for ColorPicker:

- Native mode uses the browser `EyeDropper API` for screen-level color sampling.
- Fallback mode uses `html2canvas-pro` to capture the current page and reads pixels from canvas when native EyeDropper is unavailable.
- The native path remains the default and does not need the fallback behavior unless framework components request `mode: 'fallback'`.
- The button is placed before the color sliders so it is close to the color picking interaction without compressing format inputs.
- Unsupported, canceled, aborted, or failed picking resolves to `null` and should not trigger ColorPicker change events.

## API Suggestion For Framework Repositories

```ts
type EyeDropperConfig =
| boolean
| {
mode?: 'native' | 'fallback';
showPreview?: boolean;
};
```

Recommended behavior:

- `false`: do not render the eyedropper button.
- `true`: use native EyeDropper only. Disable the button when unsupported.
- `{ mode: 'fallback' }`: use native EyeDropper first, then fall back to page-level canvas picking.
- Preserve alpha when `enableAlpha` is enabled because native EyeDropper returns opaque `#rrggbb`.
- In gradient mode, update the selected gradient stop instead of replacing the whole gradient value.
- Emit `context.trigger = 'eyedropper'` after successful picking.

## Compatibility Notes

Native EyeDropper can sample any visible screen area but is not supported by all browsers. Fallback mode is limited to the current page viewport and may be affected by cross-origin images, video, iframe content, WebGL, and complex CSS rendering.

## Verification

- `npm run test -- --run test/unit/color-picker/eyedropper.test.ts`
- `node_modules\\.bin\\tsc.cmd --noEmit`
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 color sampling. The button is rendered before the color sliders. By default, it uses the browser native EyeDropper API to pick a color from anywhere on the screen. A fallback mode can also be configured to capture the current page and read pixels from canvas when the native API is unavailable. The fallback mode is limited to the current page and can be affected by cross-origin images, videos, iframes, and complex rendering.

{{ 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 @@ -12,6 +12,12 @@ spline: form

{{ panel }}

### 支持吸色的颜色选择器

设置 `eyeDropper=true` 即可开启吸色功能,颜色条前会出现吸色按钮。默认使用浏览器原生 EyeDropper API 从屏幕任意位置取色;也可以配置 fallback 模式,在不支持原生 API 时通过页面截图和 canvas 读取像素实现页面内取色。fallback 模式受跨域图片、视频、iframe 和复杂渲染影响,能力边界与原生 API 不同。

{{ eye-dropper }}

### 带触发元素的颜色选择器

通过触发器触发显示选择器面板,透传全部属性到面板选择器组件。
Expand Down
234 changes: 234 additions & 0 deletions js/color-picker/eyedropper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
type Html2Canvas = (
element: HTMLElement,
options?: {
allowTaint?: boolean;
backgroundColor?: string | null;
height?: number;
scale?: number;
scrollX?: number;
scrollY?: number;
useCORS?: boolean;
width?: number;
x?: number;
y?: number;
}
) => Promise<HTMLCanvasElement>;

export interface EyeDropperResult {
sRGBHex: string;
}

export interface NativeEyeDropperOpenOptions {
signal?: AbortSignal;
}

export type EyeDropperMode = 'native' | 'fallback';

export interface OpenEyeDropperOptions {
mode?: EyeDropperMode;
showPreview?: boolean;
signal?: AbortSignal;
root?: HTMLElement;
html2canvas?: Html2Canvas;
}

export type EyeDropperConfig = boolean | OpenEyeDropperOptions;

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

interface EyeDropperConstructor {
new (): EyeDropperInstance;
}

const MASK_CLASS_NAME = 't-color-picker__eyedropper-mask';
const CANVAS_CLASS_NAME = 't-color-picker__eyedropper-canvas';
const PREVIEW_CLASS_NAME = 't-color-picker__eyedropper-preview';
const MAX_FALLBACK_SCALE = 2;

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;
}

function canUseFallback(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined' && Boolean(document.body);
}

function normalizeOptions(options?: OpenEyeDropperOptions | AbortSignal): OpenEyeDropperOptions {
if (!options) return {};
if (typeof AbortSignal !== 'undefined' && options instanceof AbortSignal) {
return { signal: options };
}
return options as OpenEyeDropperOptions;
}

function rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b].map((value) => value.toString(16).padStart(2, '0')).join('')}`;
}

async function getHtml2Canvas(options: OpenEyeDropperOptions): Promise<Html2Canvas | null> {
if (options.html2canvas) return options.html2canvas;

try {
const module = await import('html2canvas-pro');
return module.default as Html2Canvas;
} catch {
return null;
}
}

function appendFallbackLayer(canvas: HTMLCanvasElement, showPreview: boolean) {
const mask = document.createElement('div');
const preview = document.createElement('div');

mask.className = MASK_CLASS_NAME;
canvas.classList.add(CANVAS_CLASS_NAME);
preview.className = PREVIEW_CLASS_NAME;

mask.appendChild(canvas);
if (showPreview) mask.appendChild(preview);
document.body.appendChild(mask);

return { mask, preview };
}

function pickCanvasColor(canvas: HTMLCanvasElement, x: number, y: number, scale: number): string | null {
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) return null;

try {
const pixelX = Math.min(Math.max(Math.round(x * scale), 0), canvas.width - 1);
const pixelY = Math.min(Math.max(Math.round(y * scale), 0), canvas.height - 1);
const [r, g, b] = context.getImageData(pixelX, pixelY, 1, 1).data;

return rgbToHex(r, g, b);
} catch {
return null;
}
}

export function isNativeEyeDropperSupported(): boolean {
return getEyeDropperCtor() !== undefined;
}

export function isEyeDropperSupported(options?: Pick<OpenEyeDropperOptions, 'mode'>): boolean {
if (isNativeEyeDropperSupported()) return true;
return options?.mode === 'fallback' && canUseFallback();
}

export async function openNativeEyeDropper(signal?: AbortSignal): Promise<string | null> {
const EyeDropperCtor = getEyeDropperCtor();
if (!EyeDropperCtor) return null;

try {
const result = await new EyeDropperCtor().open(signal ? { signal } : undefined);
return result.sRGBHex.toLowerCase();
} catch {
return null;
}
}

export async function openFallbackEyeDropper(options: OpenEyeDropperOptions = {}): Promise<string | null> {
if (!canUseFallback()) return null;

try {
const html2canvas = await getHtml2Canvas(options);
if (!html2canvas) return null;

const root = options.root || document.body;
const scale = Math.min(window.devicePixelRatio || 1, MAX_FALLBACK_SCALE);
const canvas = await html2canvas(root, {
allowTaint: false,
backgroundColor: null,
height: window.innerHeight,
scale,
scrollX: window.scrollX,
scrollY: window.scrollY,
useCORS: true,
width: window.innerWidth,
x: window.scrollX,
y: window.scrollY,
});
const { mask, preview } = appendFallbackLayer(canvas, options.showPreview !== false);

return await new Promise((resolve) => {
let rafId = 0;
let lastMouseEvent: MouseEvent | null = null;

const cleanup = () => {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
options.signal?.removeEventListener('abort', handleAbort);
window.removeEventListener('keydown', handleKeyDown);
mask.removeEventListener('mousemove', handleMouseMove);
mask.removeEventListener('click', handleClick);
mask.remove();
};

const finish = (value: string | null) => {
cleanup();
resolve(value);
};

const handleAbort = () => finish(null);

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
finish(null);
}
};

const updatePreview = () => {
rafId = 0;
if (!lastMouseEvent) return;
const { clientX, clientY } = lastMouseEvent;
const color = pickCanvasColor(canvas, clientX, clientY, scale);
if (!color || !preview.parentElement) return;

preview.style.backgroundColor = color;
preview.style.left = `${clientX}px`;
preview.style.top = `${clientY}px`;
};

const handleMouseMove = (event: MouseEvent) => {
lastMouseEvent = event;
if (!rafId) {
rafId = window.requestAnimationFrame(updatePreview);
}
};

const handleClick = (event: MouseEvent) => {
event.preventDefault();
finish(pickCanvasColor(canvas, event.clientX, event.clientY, scale));
};

if (options.signal?.aborted) {
finish(null);
return;
}

options.signal?.addEventListener('abort', handleAbort, { once: true });
window.addEventListener('keydown', handleKeyDown);
mask.addEventListener('mousemove', handleMouseMove);
mask.addEventListener('click', handleClick);
});
} catch {
return null;
}
}

export async function openEyeDropper(options?: OpenEyeDropperOptions | AbortSignal): Promise<string | null> {
const normalizedOptions = normalizeOptions(options);

if (isNativeEyeDropperSupported()) {
return openNativeEyeDropper(normalizedOptions.signal);
}

return normalizedOptions.mode === 'fallback' ? openFallbackEyeDropper(normalizedOptions) : null;
}
18 changes: 18 additions & 0 deletions js/color-picker/html2canvas-pro.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
declare module 'html2canvas-pro' {
interface Html2CanvasOptions {
allowTaint?: boolean;
backgroundColor?: string | null;
height?: number;
scale?: number;
scrollX?: number;
scrollY?: number;
useCORS?: boolean;
width?: number;
x?: number;
y?: number;
}

const html2canvas: (element: HTMLElement, options?: Html2CanvasOptions) => Promise<HTMLCanvasElement>;

export default html2canvas;
}
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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
}
},
"dependencies": {
"html2canvas-pro": "^2.2.4",
"lodash-es": "^4.17.21",
"mitt": "^3.0.0",
"tinycolor2": "^1.4.2"
Expand Down
Loading