From 822930901bad693a3c8df661e7c04b84863f7198 Mon Sep 17 00:00:00 2001 From: yzh1002 Date: Tue, 14 Jul 2026 18:59:55 +0800 Subject: [PATCH 1/2] feat(descriptions): optimize borderless label spacing --- style/web/components/descriptions/_index.less | 18 ++++++++++++++++-- style/web/components/descriptions/_var.less | 2 ++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/style/web/components/descriptions/_index.less b/style/web/components/descriptions/_index.less index 6a45304e96..2ecdd31b12 100644 --- a/style/web/components/descriptions/_index.less +++ b/style/web/components/descriptions/_index.less @@ -23,6 +23,17 @@ font: @font-body-medium; width: 100%; + > tbody { + > tr { + > td { + vertical-align: top; + box-sizing: border-box; + overflow-wrap: break-word; + word-break: break-word; + } + } + } + .generate-padding(l, @descriptions-borderless-padding-l); .generate-padding(m, @descriptions-borderless-padding-m); .generate-padding(s, @descriptions-borderless-padding-s); @@ -57,9 +68,12 @@ } } - :not(&--fixed):not(&--border) { + &:not(&--fixed):not(&--border) { @{root}__label { - padding-right: @comp-paddingLR-l; + width: 1px; + color: @descriptions-borderless-label-color; + padding-right: @descriptions-borderless-label-gap; + white-space: nowrap; } } } diff --git a/style/web/components/descriptions/_var.less b/style/web/components/descriptions/_var.less index 7b92b2a931..1686ce14f9 100644 --- a/style/web/components/descriptions/_var.less +++ b/style/web/components/descriptions/_var.less @@ -10,6 +10,8 @@ @descriptions-borderless-padding-l: @comp-paddingTB-l 0; @descriptions-borderless-padding-m: @comp-paddingTB-m 0; @descriptions-borderless-padding-s: @comp-paddingTB-s 0; +@descriptions-borderless-label-gap: @comp-paddingLR-s; +@descriptions-borderless-label-color: @text-color-secondary; // border @descriptions-td-border: 1px solid @component-border; From f9a99ee0185f286e350239a26a1f3b4ca7d34f31 Mon Sep 17 00:00:00 2001 From: yzh1002 Date: Tue, 21 Jul 2026 00:45:58 +0800 Subject: [PATCH 2/2] feat(color-picker): add eyedropper utility and styles --- docs/color-picker-eyedropper-pr-record.md | 48 ++++ docs/web/api/color-picker.en-US.md | 6 + docs/web/api/color-picker.md | 6 + js/color-picker/eyedropper.ts | 234 ++++++++++++++++++ js/color-picker/html2canvas-pro.d.ts | 18 ++ js/color-picker/index.ts | 1 + package.json | 1 + style/web/components/color-picker/_index.less | 48 ++++ test/unit/color-picker/eyedropper.test.ts | 148 +++++++++++ 9 files changed, 510 insertions(+) create mode 100644 docs/color-picker-eyedropper-pr-record.md create mode 100644 js/color-picker/eyedropper.ts create mode 100644 js/color-picker/html2canvas-pro.d.ts create mode 100644 test/unit/color-picker/eyedropper.test.ts diff --git a/docs/color-picker-eyedropper-pr-record.md b/docs/color-picker-eyedropper-pr-record.md new file mode 100644 index 0000000000..7bfbb44d68 --- /dev/null +++ b/docs/color-picker-eyedropper-pr-record.md @@ -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` diff --git a/docs/web/api/color-picker.en-US.md b/docs/web/api/color-picker.en-US.md index 1423c7ba06..bae1a281b1 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 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. diff --git a/docs/web/api/color-picker.md b/docs/web/api/color-picker.md index d78785a147..d87c4eff25 100644 --- a/docs/web/api/color-picker.md +++ b/docs/web/api/color-picker.md @@ -12,6 +12,12 @@ spline: form {{ panel }} +### 支持吸色的颜色选择器 + +设置 `eyeDropper=true` 即可开启吸色功能,颜色条前会出现吸色按钮。默认使用浏览器原生 EyeDropper API 从屏幕任意位置取色;也可以配置 fallback 模式,在不支持原生 API 时通过页面截图和 canvas 读取像素实现页面内取色。fallback 模式受跨域图片、视频、iframe 和复杂渲染影响,能力边界与原生 API 不同。 + +{{ eye-dropper }} + ### 带触发元素的颜色选择器 通过触发器触发显示选择器面板,透传全部属性到面板选择器组件。 diff --git a/js/color-picker/eyedropper.ts b/js/color-picker/eyedropper.ts new file mode 100644 index 0000000000..29ea9233e3 --- /dev/null +++ b/js/color-picker/eyedropper.ts @@ -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; + +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; +} + +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 { + 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): boolean { + if (isNativeEyeDropperSupported()) return true; + return options?.mode === 'fallback' && canUseFallback(); +} + +export async function openNativeEyeDropper(signal?: AbortSignal): Promise { + 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 { + 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 { + const normalizedOptions = normalizeOptions(options); + + if (isNativeEyeDropperSupported()) { + return openNativeEyeDropper(normalizedOptions.signal); + } + + return normalizedOptions.mode === 'fallback' ? openFallbackEyeDropper(normalizedOptions) : null; +} diff --git a/js/color-picker/html2canvas-pro.d.ts b/js/color-picker/html2canvas-pro.d.ts new file mode 100644 index 0000000000..c56af32b37 --- /dev/null +++ b/js/color-picker/html2canvas-pro.d.ts @@ -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; + + export default html2canvas; +} 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/package.json b/package.json index fca926c6c3..d4c3ddbb43 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ } }, "dependencies": { + "html2canvas-pro": "^2.2.4", "lodash-es": "^4.17.21", "mitt": "^3.0.0", "tinycolor2": "^1.4.2" diff --git a/style/web/components/color-picker/_index.less b/style/web/components/color-picker/_index.less index 2d04f49448..b7dfccda11 100644 --- a/style/web/components/color-picker/_index.less +++ b/style/web/components/color-picker/_index.less @@ -51,6 +51,22 @@ pointer-events: none; } } + + &__eyedropper { + width: @color-picker-swatch-icon-size; + height: @color-picker-swatch-icon-size; + padding: 0; + border: none; + outline: none; + + &:hover { + color: @text-color-primary; + } + + &.@{prefix}-is-disabled { + cursor: not-allowed; + } + } } .@{prefix}-color-picker__head { @@ -217,6 +233,13 @@ margin: @color-picker-margin 0; } +.@{prefix}-color-picker__sliders-eyedropper { + flex-shrink: 0; + width: @comp-size-xs; + height: @comp-size-xs; + margin-right: @comp-margin-s; +} + .@{prefix}-color-picker__sliders { width: 100%; } @@ -579,6 +602,31 @@ } } +.@{prefix}-color-picker__eyedropper-mask { + position: fixed; + inset: 0; + z-index: 999999; + overflow: hidden; + cursor: crosshair; +} + +.@{prefix}-color-picker__eyedropper-canvas { + display: block; + width: 100vw; + height: 100vh; +} + +.@{prefix}-color-picker__eyedropper-preview { + position: fixed; + width: @comp-size-m; + height: @comp-size-m; + border: 2px solid @text-color-anti; + border-radius: @border-radius-circle; + box-shadow: @shadow-1; + transform: translate(@comp-margin-s, @comp-margin-s); + pointer-events: none; +} + // 覆盖select-option 默认样式,使其更紧凑点 .@{prefix}-color-picker__select-options { > .@{prefix}-popup__content { diff --git a/test/unit/color-picker/eyedropper.test.ts b/test/unit/color-picker/eyedropper.test.ts new file mode 100644 index 0000000000..6728d43949 --- /dev/null +++ b/test/unit/color-picker/eyedropper.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +/* eslint-disable max-classes-per-file */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + isEyeDropperSupported, + isNativeEyeDropperSupported, + openEyeDropper, + openFallbackEyeDropper, +} from '../../../js/color-picker/eyedropper'; + +afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ''; +}); + +class MockEyeDropper { + // eslint-disable-next-line class-methods-use-this + open(_options?: { signal?: AbortSignal }) { + return Promise.resolve({ sRGBHex: '#AaBbCc' }); + } +} + +class AbortingEyeDropper { + // eslint-disable-next-line class-methods-use-this + open() { + return Promise.reject(new DOMException('User aborted', 'AbortError')); + } +} + +describe('EyeDropper support detection', () => { + it('detects missing native EyeDropper API', () => { + expect(isNativeEyeDropperSupported()).toBe(false); + expect(isEyeDropperSupported()).toBe(false); + }); + + it('detects native EyeDropper API', () => { + vi.stubGlobal('EyeDropper', MockEyeDropper); + + expect(isNativeEyeDropperSupported()).toBe(true); + expect(isEyeDropperSupported()).toBe(true); + }); + + it('treats fallback mode as supported in browser-like environment', () => { + expect(isEyeDropperSupported({ mode: 'fallback' })).toBe(true); + }); +}); + +describe('openEyeDropper', () => { + it('returns the selected native color in lowercase', async () => { + vi.stubGlobal('EyeDropper', MockEyeDropper); + + expect(await openEyeDropper()).toBe('#aabbcc'); + }); + + it('forwards AbortSignal to native EyeDropper.open()', async () => { + const open = vi.fn().mockResolvedValue({ sRGBHex: '#112233' }); + vi.stubGlobal( + 'EyeDropper', + class { + open = open; + } + ); + + const controller = new AbortController(); + await openEyeDropper(controller.signal); + + expect(open).toHaveBeenCalledWith({ signal: controller.signal }); + }); + + it('returns null when the user cancels native picking', async () => { + vi.stubGlobal('EyeDropper', AbortingEyeDropper); + + expect(await openEyeDropper()).toBeNull(); + }); + + it('does not fall back when native picking is canceled', async () => { + const html2canvas = vi.fn(); + vi.stubGlobal('EyeDropper', AbortingEyeDropper); + + expect(await openEyeDropper({ mode: 'fallback', html2canvas })).toBeNull(); + expect(html2canvas).not.toHaveBeenCalled(); + }); +}); + +describe('openFallbackEyeDropper', () => { + function createCanvas(color: [number, number, number]) { + const canvas = document.createElement('canvas'); + canvas.width = 10; + canvas.height = 10; + canvas.getContext = vi.fn().mockReturnValue({ + getImageData: vi.fn().mockReturnValue({ + data: new Uint8ClampedArray([...color, 255]), + }), + }); + return canvas; + } + + it('picks a color from the fallback canvas', async () => { + const html2canvas = vi.fn().mockResolvedValue(createCanvas([17, 34, 51])); + + const promise = openFallbackEyeDropper({ html2canvas }); + await vi.waitFor(() => { + expect(document.querySelector('.t-color-picker__eyedropper-mask')).toBeTruthy(); + }); + document.querySelector('.t-color-picker__eyedropper-mask')?.dispatchEvent( + new MouseEvent('click', { + clientX: 1, + clientY: 1, + bubbles: true, + }) + ); + + expect(await promise).toBe('#112233'); + expect(document.querySelector('.t-color-picker__eyedropper-mask')).toBeNull(); + }); + + it('returns null when fallback picking is aborted', async () => { + const html2canvas = vi.fn().mockResolvedValue(createCanvas([17, 34, 51])); + const controller = new AbortController(); + + const promise = openFallbackEyeDropper({ html2canvas, signal: controller.signal }); + await vi.waitFor(() => { + expect(document.querySelector('.t-color-picker__eyedropper-mask')).toBeTruthy(); + }); + controller.abort(); + + expect(await promise).toBeNull(); + expect(document.querySelector('.t-color-picker__eyedropper-mask')).toBeNull(); + }); + + it('uses fallback when native API is unavailable and fallback mode is requested', async () => { + const html2canvas = vi.fn().mockResolvedValue(createCanvas([255, 102, 0])); + + const promise = openEyeDropper({ mode: 'fallback', html2canvas }); + await vi.waitFor(() => { + expect(document.querySelector('.t-color-picker__eyedropper-mask')).toBeTruthy(); + }); + document.querySelector('.t-color-picker__eyedropper-mask')?.dispatchEvent( + new MouseEvent('click', { + clientX: 1, + clientY: 1, + bubbles: true, + }) + ); + + expect(await promise).toBe('#ff6600'); + }); +});