Colour picking: native EyeDropper + in-canvas pick mode with brand/token matching, real-browser e2e
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54920dff08
commit
179aaebe49
4 files changed
+556
No files matched your search
@@ -13,6 +13,7 @@
|
||||
"test:unit": "vitest run",
|
||||
"test:browser:bg": "node scripts/e2e-background.mjs",
|
||||
"test:browser:canvas": "node scripts/e2e-canvas-parity.mjs",
|
||||
"test:browser:colorpick": "node scripts/e2e-color-pick.mjs",
|
||||
"test:unit:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Real-browser test (headless Firefox AND Chromium) for src/utils/color-pick.ts:
|
||||
* pickFromCanvas must return the exact computed hexes + site/brand matches,
|
||||
* Esc must cancel, a pick click must NOT reach the clicked element (no craft
|
||||
* node selection), and EyeDropper availability is reported per browser.
|
||||
* npm run test:browser:colorpick (CHROMIUM_PATH / FIREFOX_PATH override binaries)
|
||||
*/
|
||||
import { build } from 'esbuild';
|
||||
import { chromium, firefox } from '@playwright/test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const out = await build({
|
||||
entryPoints: [path.join(root, 'src/utils/color-pick.ts')],
|
||||
bundle: true, write: false, format: 'iife', globalName: 'cp', platform: 'browser', logLevel: 'error',
|
||||
});
|
||||
const js = out.outputFiles[0].text;
|
||||
|
||||
const design = {
|
||||
primaryColor: '#3b82f6', secondaryColor: '#64748b', accentColor: '#f59e0b', linkColor: '#2563eb',
|
||||
successColor: '#16a34a', warningColor: '#d97706', errorColor: '#dc2626',
|
||||
backgroundColor: '#ffffff', textColor: '#111827', mutedTextColor: '#6b7280', borderColor: '#e5e7eb',
|
||||
swatches: [
|
||||
{ id: 'b1', label: 'Logo orange', hex: '#FF6600', brand: true, slot: 3 },
|
||||
{ id: 'p1', label: 'Sand', hex: '#c8b08a', brand: false },
|
||||
],
|
||||
};
|
||||
|
||||
const html = `<!doctype html><html><head><style>
|
||||
body{margin:0;font:16px sans-serif}
|
||||
.canvas-device-frame{--wsb-primary:#3b82f6;background:#ffffff;color:#111827;padding:10px;width:700px}
|
||||
.box{height:60px;margin:6px 0;padding:4px}
|
||||
</style></head><body>
|
||||
<div class="canvas-device-frame">
|
||||
<div id="tok" class="box" style="background:var(--wsb-primary);color:#ffffff">token bg</div>
|
||||
<div id="brand" class="box" style="background:#ff6600;color:#111827">brand bg</div>
|
||||
<div id="sand" class="box" style="background:rgba(200,176,138,1);color:#111827">swatch bg</div>
|
||||
<div id="plain" class="box" style="background:#123456;color:#fedcba;border:3px solid #0a0b0c">plain + border</div>
|
||||
<div id="outer" class="box" style="background:#112233;color:#445566"><div id="nested" style="color:#778899"><span id="deep">nested transparent</span></div></div>
|
||||
<div id="alpha" class="box" style="background:rgba(18,52,86,.5);color:#111827">alpha</div>
|
||||
<p id="txt" style="color:#6b7280;margin:6px 0">muted text on frame bg</p>
|
||||
</div>
|
||||
<div id="chrome" style="padding:10px">editor chrome</div>
|
||||
</body></html>`;
|
||||
|
||||
// Served over http://localhost (a secure context) so EyeDropper availability is reported truthfully.
|
||||
const server = http.createServer((_q, r) => { r.setHeader('content-type', 'text/html'); r.end(html); });
|
||||
await new Promise((res) => server.listen(0, '127.0.0.1', res));
|
||||
const url = `http://localhost:${server.address().port}/`;
|
||||
|
||||
async function run(name, launcher, exe) {
|
||||
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
|
||||
const page = await browser.newPage({ viewport: { width: 900, height: 900 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(String(e)));
|
||||
await page.goto(url);
|
||||
await page.addScriptTag({ content: js });
|
||||
await page.evaluate((d) => {
|
||||
window.__design = d;
|
||||
window.__leaks = [];
|
||||
for (const el of document.querySelectorAll('.canvas-device-frame, .canvas-device-frame *'))
|
||||
for (const t of ['click', 'mousedown', 'mouseup', 'pointerdown', 'pointerup'])
|
||||
el.addEventListener(t, () => window.__leaks.push(t + '#' + el.id));
|
||||
}, design);
|
||||
|
||||
let failed = 0;
|
||||
const check = (label, ok, detail) => {
|
||||
if (!ok) failed++;
|
||||
console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${detail !== undefined ? ': ' + detail : ''}`);
|
||||
};
|
||||
const eq = (label, got, want) => check(label, JSON.stringify(got) === JSON.stringify(want), JSON.stringify(got) + (JSON.stringify(got) === JSON.stringify(want) ? '' : ' expected ' + JSON.stringify(want)));
|
||||
|
||||
async function pick(id, { hover } = {}) {
|
||||
await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); });
|
||||
const box = await page.locator('#' + id).boundingBox();
|
||||
const x = box.x + 8, y = box.y + 8;
|
||||
await page.mouse.move(x, y);
|
||||
let tip = null;
|
||||
if (hover) tip = await page.evaluate(() => document.querySelector('[data-wsb-pick=tip]')?.textContent ?? null);
|
||||
await page.mouse.click(x, y);
|
||||
await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 });
|
||||
return { res: await page.evaluate(() => window.__res), tip };
|
||||
}
|
||||
const cand = (res, k) => res.candidates.find((c) => c.kind === k);
|
||||
|
||||
// 1. token-driven background (CSS variable)
|
||||
let { res, tip } = await pick('tok', { hover: true });
|
||||
eq('token bg: hex', res.hex, '#3b82f6');
|
||||
eq('token bg: background match', cand(res, 'background').match, { kind: 'token', ref: 'var(--wsb-primary)', label: 'Primary' });
|
||||
eq('token bg: text candidate', cand(res, 'text').hex, '#ffffff');
|
||||
check('token bg: hover tooltip shows live colour', tip === '#3b82f6', String(tip));
|
||||
eq('token bg: no border candidate', cand(res, 'border'), undefined);
|
||||
|
||||
// 2. brand swatch (case-insensitive hex)
|
||||
({ res } = await pick('brand'));
|
||||
eq('brand: hex', res.hex, '#ff6600');
|
||||
eq('brand: match', cand(res, 'background').match, { kind: 'swatch', ref: 'var(--wsb-brand-3)', label: 'Logo orange' });
|
||||
|
||||
// 3. plain saved swatch via rgba(...,1)
|
||||
({ res } = await pick('sand'));
|
||||
eq('swatch: hex', res.hex, '#c8b08a');
|
||||
eq('swatch: match', cand(res, 'background').match, { kind: 'swatch', ref: '#c8b08a', label: 'Sand' });
|
||||
|
||||
// 4. plain bg + text + border, no match
|
||||
({ res } = await pick('plain'));
|
||||
eq('plain: hex = bg', res.hex, '#123456');
|
||||
eq('plain: text', cand(res, 'text').hex, '#fedcba');
|
||||
eq('plain: border', cand(res, 'border').hex, '#0a0b0c');
|
||||
eq('plain: no matches', res.candidates.map((c) => c.match), [undefined, undefined, undefined]);
|
||||
|
||||
// 5. nested transparent child: text colour wins, background = painted ancestor
|
||||
({ res } = await pick('deep'));
|
||||
eq('nested: hex = inherited text colour', res.hex, '#778899');
|
||||
eq('nested: background = nearest painted ancestor', cand(res, 'background').hex, '#112233');
|
||||
eq('nested: no border', cand(res, 'border'), undefined);
|
||||
|
||||
// 6. alpha background -> 8-digit hex
|
||||
({ res } = await pick('alpha'));
|
||||
eq('alpha: hex', res.hex, '#12345680');
|
||||
|
||||
// 7. text on frame bg (frame background is the painted ancestor, matches Background token)
|
||||
({ res } = await pick('txt'));
|
||||
eq('text: hex', res.hex, '#6b7280');
|
||||
eq('text: text match', cand(res, 'text').match, { kind: 'token', ref: 'var(--wsb-muted)', label: 'Muted text' });
|
||||
eq('text: background match', cand(res, 'background').match, { kind: 'token', ref: 'var(--wsb-bg)', label: 'Background' });
|
||||
|
||||
// 8. none of those pick clicks reached the elements (no node selection)
|
||||
eq('clicks never reached targets', await page.evaluate(() => window.__leaks), []);
|
||||
|
||||
// 9. Esc cancels; overlay cleaned up
|
||||
await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 });
|
||||
const esc = await page.evaluate(() => window.__res);
|
||||
check('Esc cancels', esc.ok === false && esc.reason === 'cancelled', JSON.stringify(esc));
|
||||
eq('overlay removed', await page.evaluate(() => document.querySelectorAll('[data-wsb-pick]').length), 0);
|
||||
|
||||
// 10. click outside the frame cancels
|
||||
await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); });
|
||||
const cb = await page.locator('#chrome').boundingBox();
|
||||
await page.mouse.click(cb.x + 5, cb.y + 5);
|
||||
await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 });
|
||||
check('outside click cancels', (await page.evaluate(() => window.__res)).reason === 'cancelled');
|
||||
|
||||
// 11. after a pick finishes, normal clicks flow again
|
||||
await page.evaluate(() => { window.__leaks.length = 0; });
|
||||
await page.locator('#tok').click();
|
||||
check('clicks flow normally after picking', (await page.evaluate(() => window.__leaks)).includes('click#tok'));
|
||||
|
||||
// 12. EyeDropper availability (report)
|
||||
const eye = await page.evaluate(() => ({ present: 'EyeDropper' in window, available: cp.eyedropperAvailable(), kind: cp.screenPickKind() }));
|
||||
console.log(`[${name}] INFO EyeDropper: window.EyeDropper=${eye.present} eyedropperAvailable()=${eye.available} screenPickKind()=${eye.kind}`);
|
||||
check('secure context', await page.evaluate(() => isSecureContext));
|
||||
if (name === 'firefox') check('firefox: no screen pick (verdict C)', eye.kind === 'none');
|
||||
else check('chromium: screenPickKind matches window.EyeDropper', eye.kind === (eye.present ? 'eyedropper' : 'none'));
|
||||
|
||||
if (errors.length) { failed++; console.log('page errors:', errors.join('\n')); }
|
||||
await browser.close();
|
||||
return failed;
|
||||
}
|
||||
let total = 0;
|
||||
total += await run('chromium', chromium, process.env.CHROMIUM_PATH);
|
||||
total += await run('firefox', firefox, process.env.FIREFOX_PATH);
|
||||
server.close();
|
||||
console.log(total ? `FAILED (${total})` : 'ALL PASS');
|
||||
process.exit(total ? 1 : 0);
|
||||
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
|
||||
import {
|
||||
eyedropperAvailable, parseComputedColor, pickFromCanvas, pickFromScreen, pickWithEyeDropper, rgbToHex, screenPickKind,
|
||||
} from './color-pick';
|
||||
|
||||
const design = (over: Partial<SiteDesign> = {}): SiteDesign => ({ ...DEFAULT_SITE_DESIGN, ...over });
|
||||
|
||||
describe('rgbToHex / parseComputedColor', () => {
|
||||
it('rgbToHex', () => {
|
||||
expect(rgbToHex(255, 0, 16)).toBe('#ff0010');
|
||||
expect(rgbToHex(0, 0, 0, 1)).toBe('#000000');
|
||||
expect(rgbToHex(18, 52, 86, 0.5)).toBe('#12345680');
|
||||
expect(rgbToHex(300, -4, 1.4)).toBe('#ff0001');
|
||||
});
|
||||
it('parses computed rgb()/rgba() in comma and space syntax', () => {
|
||||
expect(parseComputedColor('rgb(18, 52, 86)')).toEqual({ hex: '#123456', alpha: 1 });
|
||||
expect(parseComputedColor('rgba(18, 52, 86, 0.5)')).toEqual({ hex: '#12345680', alpha: 0.5 });
|
||||
expect(parseComputedColor('rgb(18 52 86 / 50%)')).toEqual({ hex: '#12345680', alpha: 0.5 });
|
||||
expect(parseComputedColor('rgba(0, 0, 0, 0)')).toEqual({ hex: '#00000000', alpha: 0 });
|
||||
expect(parseComputedColor('transparent')).toEqual({ hex: '#00000000', alpha: 0 });
|
||||
expect(parseComputedColor('rgba(18, 52, 86, 1)')).toEqual({ hex: '#123456', alpha: 1 });
|
||||
});
|
||||
it('rejects junk', () => {
|
||||
expect(parseComputedColor('')).toBeNull();
|
||||
expect(parseComputedColor('red')).toBeNull();
|
||||
expect(parseComputedColor('var(--x)')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('eyedropper / screen kind', () => {
|
||||
afterEach(() => { delete (window as unknown as Record<string, unknown>).EyeDropper; });
|
||||
it('reports unsupported when window.EyeDropper is absent', async () => {
|
||||
expect(eyedropperAvailable()).toBe(false);
|
||||
expect(screenPickKind()).toBe('none');
|
||||
const r = await pickWithEyeDropper();
|
||||
expect(r).toMatchObject({ ok: false, reason: 'unsupported' });
|
||||
expect(await pickFromScreen()).toMatchObject({ ok: false, reason: 'unsupported' });
|
||||
});
|
||||
it('uses a native EyeDropper (lowercased hex) and maps abort to cancelled', async () => {
|
||||
(window as unknown as Record<string, unknown>).EyeDropper = class {
|
||||
open(o?: { signal?: AbortSignal }) {
|
||||
return new Promise((res, rej) => {
|
||||
if (o?.signal) o.signal.addEventListener('abort', () => rej(new DOMException('x', 'AbortError')));
|
||||
else res({ sRGBHex: '#ABCDEF' });
|
||||
});
|
||||
}
|
||||
};
|
||||
expect(eyedropperAvailable()).toBe(true);
|
||||
expect(screenPickKind()).toBe('eyedropper');
|
||||
expect(await pickWithEyeDropper()).toEqual({ ok: true, hex: '#abcdef', source: 'eyedropper' });
|
||||
const ac = new AbortController();
|
||||
const p = pickWithEyeDropper(ac.signal);
|
||||
ac.abort();
|
||||
expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickFromCanvas (jsdom, inline styles)', () => {
|
||||
afterEach(() => { document.body.innerHTML = ''; document.head.querySelectorAll('style[data-wsb-pick]').forEach((s) => s.remove()); });
|
||||
const mount = () => {
|
||||
document.body.innerHTML = `
|
||||
<div class="canvas-device-frame" style="background-color:rgb(255,255,255);color:rgb(17,24,39)">
|
||||
<section id="card" style="background-color:rgb(59,130,246);color:rgb(255,255,255);border:2px solid rgb(1,2,3)">
|
||||
<span id="inner">hi</span>
|
||||
</section>
|
||||
<p id="plain" style="color:rgb(10,20,30)">x</p>
|
||||
</div>
|
||||
<div id="outside">out</div>`;
|
||||
return (id: string) => document.getElementById(id)!;
|
||||
};
|
||||
const click = (el: Element) => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 1, clientY: 1 }));
|
||||
|
||||
it('returns background/text/border candidates with default hex = painted background', async () => {
|
||||
const $ = mount();
|
||||
const p = pickFromCanvas({ design: design({ primaryColor: '#3b82f6' }) });
|
||||
click($('card'));
|
||||
const r = await p;
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.source).toBe('canvas');
|
||||
expect(r.hex).toBe('#3b82f6');
|
||||
const by = Object.fromEntries(r.candidates.map((c) => [c.kind, c]));
|
||||
expect(by.background.hex).toBe('#3b82f6');
|
||||
expect(by.background.match).toMatchObject({ kind: 'token', ref: 'var(--wsb-primary)' });
|
||||
expect(by.text.hex).toBe('#ffffff');
|
||||
expect(by.border.hex).toBe('#010203');
|
||||
expect(by.border.match).toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-painting element: hex is the text colour; background is the nearest painted ancestor', async () => {
|
||||
const $ = mount();
|
||||
const p = pickFromCanvas({ design: design() });
|
||||
click($('inner'));
|
||||
const r = await p;
|
||||
if (!r.ok) throw new Error('expected ok');
|
||||
expect(r.hex).toBe('#ffffff');
|
||||
expect(r.candidates.find((c) => c.kind === 'background')!.hex).toBe('#3b82f6');
|
||||
expect(r.candidates.find((c) => c.kind === 'border')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers a published brand swatch over a token match', async () => {
|
||||
const $ = mount();
|
||||
const d = design({
|
||||
primaryColor: '#3b82f6',
|
||||
swatches: [{ id: 'a', label: 'Logo blue', hex: '#3B82F6', brand: true, slot: 2 }],
|
||||
});
|
||||
const p = pickFromCanvas({ design: d });
|
||||
click($('card'));
|
||||
const r = await p;
|
||||
if (!r.ok) throw new Error('expected ok');
|
||||
expect(r.candidates[0].match).toEqual({ kind: 'swatch', ref: 'var(--wsb-brand-2)', label: 'Logo blue' });
|
||||
});
|
||||
|
||||
it('matches a non-brand swatch by hex ref', async () => {
|
||||
const $ = mount();
|
||||
const p = pickFromCanvas({ design: design({ swatches: [{ id: 'a', label: 'Sky', hex: '#0a141e', brand: false }] }) });
|
||||
click($('plain'));
|
||||
const r = await p;
|
||||
if (!r.ok) throw new Error('expected ok');
|
||||
expect(r.hex).toBe('#0a141e');
|
||||
expect(r.candidates.find((c) => c.kind === 'text')!.match).toEqual({ kind: 'swatch', ref: '#0a141e', label: 'Sky' });
|
||||
});
|
||||
|
||||
it('swallows the click (capture phase) so craft nodes are not selected', async () => {
|
||||
const $ = mount();
|
||||
let leaked = 0;
|
||||
$('card').addEventListener('click', () => leaked++);
|
||||
$('card').addEventListener('mousedown', () => leaked++);
|
||||
const p = pickFromCanvas({ design: design() });
|
||||
$('card').dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||||
click($('card'));
|
||||
await p;
|
||||
expect(leaked).toBe(0);
|
||||
});
|
||||
|
||||
it('Esc cancels, abort cancels, outside click cancels', async () => {
|
||||
mount();
|
||||
let p = pickFromCanvas({ design: design() });
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
|
||||
|
||||
const ac = new AbortController();
|
||||
p = pickFromCanvas({ design: design(), signal: ac.signal });
|
||||
ac.abort();
|
||||
expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
|
||||
|
||||
p = pickFromCanvas({ design: design() });
|
||||
click(document.getElementById('outside')!);
|
||||
expect(await p).toMatchObject({ ok: false, reason: 'cancelled' });
|
||||
});
|
||||
|
||||
it('no frame -> error; cleans up its overlay and listeners', async () => {
|
||||
expect(await pickFromCanvas({ design: design() })).toMatchObject({ ok: false, reason: 'error' });
|
||||
const $ = mount();
|
||||
const p = pickFromCanvas({ design: design() });
|
||||
expect(document.querySelector('[data-wsb-pick]')).not.toBeNull();
|
||||
click($('plain'));
|
||||
await p;
|
||||
expect(document.querySelector('[data-wsb-pick]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* "Pick a colour from anywhere" engines (no React). Two sources:
|
||||
* - the native EyeDropper API (Chromium only; Firefox/Safari have none), and
|
||||
* - pickFromCanvas: an in-page pick mode over the editor canvas
|
||||
* (`.canvas-device-frame`) that reads the clicked element's COMPUTED colours.
|
||||
* Firefox has no screen-sampling fallback: see
|
||||
* scratchpad/color/firefox-spike-verdict.md (tab capture is not viable there).
|
||||
*/
|
||||
import type { SiteDesign } from '../state/SiteDesignContext';
|
||||
import { COLOR_TOKENS, resolveTokenValue, tokenRef } from './tokens';
|
||||
import { findSwatchByHex, normalizeHex, swatchValue, type SavedSwatch } from './swatches';
|
||||
|
||||
export type PickFailReason = 'cancelled' | 'unsupported' | 'cross-origin' | 'error';
|
||||
export type PickResult =
|
||||
| { ok: true; hex: string; source: 'canvas' | 'image' | 'eyedropper' | 'tab-capture' }
|
||||
| { ok: false; reason: PickFailReason; message: string };
|
||||
|
||||
export type CandidateKind = 'background' | 'text' | 'border';
|
||||
export interface PickMatch { kind: 'token' | 'swatch'; ref: string; label: string }
|
||||
export interface CanvasCandidate { kind: CandidateKind; hex: string; match?: PickMatch }
|
||||
export type CanvasPickResult =
|
||||
| { ok: true; hex: string; source: 'canvas'; candidates: CanvasCandidate[] }
|
||||
| { ok: false; reason: PickFailReason; message: string };
|
||||
|
||||
const fail = (reason: PickFailReason, message: string): { ok: false; reason: PickFailReason; message: string } => ({ ok: false, reason, message });
|
||||
const CANCELLED = 'Colour picking was cancelled.';
|
||||
|
||||
// ------------------------------------------------------------------ colour parsing
|
||||
const h2 = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
|
||||
|
||||
/** r,g,b 0-255 (+ optional alpha 0-1) -> lowercase #rrggbb, or #rrggbbaa only when alpha < 1. */
|
||||
export function rgbToHex(r: number, g: number, b: number, a = 1): string {
|
||||
const base = `#${h2(r)}${h2(g)}${h2(b)}`;
|
||||
return a >= 1 ? base : base + h2(a * 255);
|
||||
}
|
||||
|
||||
const RGB_RE = /^rgba?\(\s*([\d.]+)(?:\s*,\s*|\s+)([\d.]+)(?:\s*,\s*|\s+)([\d.]+)(?:\s*(?:,|\/)\s*([\d.]+)(%?))?\s*\)$/i;
|
||||
|
||||
/** Parses a COMPUTED colour (`rgb()`/`rgba()`/`transparent`). Null for anything else. */
|
||||
export function parseComputedColor(v: string): { hex: string; alpha: number } | null {
|
||||
const s = String(v ?? '').trim();
|
||||
if (s.toLowerCase() === 'transparent') return { hex: '#00000000', alpha: 0 };
|
||||
const m = RGB_RE.exec(s);
|
||||
if (!m) return null;
|
||||
let alpha = m[4] === undefined ? 1 : Number(m[4]);
|
||||
if (m[5] === '%') alpha /= 100;
|
||||
alpha = Math.max(0, Math.min(1, alpha));
|
||||
return { hex: rgbToHex(Number(m[1]), Number(m[2]), Number(m[3]), alpha), alpha };
|
||||
}
|
||||
|
||||
/** #rgb/#rgba -> #rrggbb[aa]; lowercase; null when invalid. */
|
||||
function canonHex(v: string): string | null {
|
||||
const h = normalizeHex(v);
|
||||
if (!h) return null;
|
||||
return h.length === 4 || h.length === 5 ? '#' + [...h.slice(1)].map((c) => c + c).join('') : h;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ site/brand matching
|
||||
/** Does `hex` equal a brand swatch, design token or saved swatch? Brand swatch > token > plain swatch. */
|
||||
export function matchSiteColor(hex: string, design: SiteDesign): PickMatch | undefined {
|
||||
const want = canonHex(hex);
|
||||
if (!want) return undefined;
|
||||
const swatches: SavedSwatch[] = Array.isArray(design.swatches) ? design.swatches : [];
|
||||
const canon = swatches.map((s) => ({ ...s, hex: canonHex(s.hex) ?? s.hex }));
|
||||
const sw = findSwatchByHex(canon, want);
|
||||
const brandSw = sw && sw.brand ? sw : undefined;
|
||||
const label = (s: SavedSwatch) => s.label.trim() || s.hex;
|
||||
if (brandSw) {
|
||||
const ref = swatchValue(brandSw);
|
||||
if (ref !== brandSw.hex) return { kind: 'swatch', ref, label: label(brandSw) };
|
||||
}
|
||||
for (const t of COLOR_TOKENS) {
|
||||
const val = canonHex(resolveTokenValue(tokenRef(t.key), design));
|
||||
if (val === want) return { kind: 'token', ref: tokenRef(t.key), label: t.label };
|
||||
}
|
||||
if (sw) return { kind: 'swatch', ref: swatchValue(sw), label: label(sw) };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ native EyeDropper
|
||||
interface EyeDropperLike { open(o?: { signal?: AbortSignal }): Promise<{ sRGBHex: string }> }
|
||||
type EyeDropperCtor = new () => EyeDropperLike;
|
||||
const eyeCtor = (): EyeDropperCtor | undefined =>
|
||||
typeof window === 'undefined' ? undefined : (window as unknown as { EyeDropper?: EyeDropperCtor }).EyeDropper;
|
||||
|
||||
export function eyedropperAvailable(): boolean {
|
||||
return typeof eyeCtor() === 'function';
|
||||
}
|
||||
|
||||
export async function pickWithEyeDropper(signal?: AbortSignal): Promise<PickResult> {
|
||||
const Ctor = eyeCtor();
|
||||
if (!Ctor) return fail('unsupported', "This browser can't pick colours from the screen.");
|
||||
try {
|
||||
const r = await new Ctor().open(signal ? { signal } : undefined);
|
||||
const hex = normalizeHex(r.sRGBHex);
|
||||
return hex ? { ok: true, hex, source: 'eyedropper' } : fail('error', 'The eyedropper returned an unreadable colour.');
|
||||
} catch (e) {
|
||||
const name = (e as { name?: string })?.name;
|
||||
if (name === 'AbortError' || signal?.aborted) return fail('cancelled', CANCELLED);
|
||||
return fail('error', 'The eyedropper could not pick a colour.');
|
||||
}
|
||||
}
|
||||
|
||||
/** What "pick from the screen" can do here. Firefox: none (tab capture is not viable, see the spike verdict). */
|
||||
export function screenPickKind(): 'eyedropper' | 'tab-capture' | 'none' {
|
||||
return eyedropperAvailable() ? 'eyedropper' : 'none';
|
||||
}
|
||||
|
||||
export function pickFromScreen(signal?: AbortSignal): Promise<PickResult> {
|
||||
return pickWithEyeDropper(signal);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ canvas pick mode
|
||||
function readCandidates(el: Element, frame: Element, design: SiteDesign): { candidates: CanvasCandidate[]; hex: string | null } {
|
||||
const win = el.ownerDocument.defaultView!;
|
||||
const cs = win.getComputedStyle(el);
|
||||
const out: CanvasCandidate[] = [];
|
||||
const add = (kind: CandidateKind, hex: string) => { const m = matchSiteColor(hex, design); out.push(m ? { kind, hex, match: m } : { kind, hex }); };
|
||||
|
||||
const own = parseComputedColor(cs.backgroundColor);
|
||||
const ownPaints = !!own && own.alpha > 0;
|
||||
// Nearest painted background: the element, else its ancestors up to (and including) the frame.
|
||||
let bg: string | null = null;
|
||||
for (let n: Element | null = el; n; n = n === frame ? null : n.parentElement) {
|
||||
const c = parseComputedColor(win.getComputedStyle(n).backgroundColor);
|
||||
if (c && c.alpha > 0) { bg = c.hex; break; }
|
||||
}
|
||||
if (bg) add('background', bg);
|
||||
const txt = parseComputedColor(cs.color);
|
||||
if (txt) add('text', txt.hex);
|
||||
const bw = parseFloat(cs.borderTopWidth) || 0;
|
||||
const bc = parseComputedColor(cs.borderTopColor);
|
||||
if (bw > 0 && cs.borderTopStyle !== 'none' && cs.borderTopStyle !== 'hidden' && bc && bc.alpha > 0) add('border', bc.hex);
|
||||
|
||||
const primary = ownPaints && bg ? bg : (txt?.hex ?? bg);
|
||||
return { candidates: out, hex: primary };
|
||||
}
|
||||
|
||||
const BLOCKED = ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'dblclick', 'auxclick', 'contextmenu', 'dragstart', 'touchstart', 'touchend'];
|
||||
|
||||
let activeCancel: (() => void) | null = null;
|
||||
|
||||
export function pickFromCanvas(opts: { frameSelector?: string; design: SiteDesign; signal?: AbortSignal }): Promise<CanvasPickResult> {
|
||||
const { design, signal } = opts;
|
||||
const selector = opts.frameSelector ?? '.canvas-device-frame';
|
||||
if (typeof document === 'undefined') return Promise.resolve(fail('unsupported', 'Colour picking needs a browser.'));
|
||||
const found = document.querySelector(selector);
|
||||
if (!found) return Promise.resolve(fail('error', "Couldn't find the page canvas to pick from."));
|
||||
if (signal?.aborted) return Promise.resolve(fail('cancelled', CANCELLED));
|
||||
const frame: Element = found;
|
||||
activeCancel?.();
|
||||
|
||||
return new Promise<CanvasPickResult>((resolve) => {
|
||||
const style = document.createElement('style');
|
||||
style.setAttribute('data-wsb-pick', 'style');
|
||||
style.textContent = `${selector},${selector} *{cursor:crosshair !important}`;
|
||||
const outline = document.createElement('div');
|
||||
outline.setAttribute('data-wsb-pick', 'outline');
|
||||
outline.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483646;outline:2px solid #fff;box-shadow:0 0 0 2px #1d4ed8;display:none';
|
||||
const tip = document.createElement('div');
|
||||
tip.setAttribute('data-wsb-pick', 'tip');
|
||||
tip.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483647;display:none;align-items:center;gap:6px;padding:3px 7px;border-radius:6px;background:#111827;color:#fff;font:12px/1.2 system-ui,sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.4)';
|
||||
const chip = document.createElement('span');
|
||||
chip.style.cssText = 'width:14px;height:14px;border-radius:3px;border:1px solid #fff;display:inline-block';
|
||||
const tipText = document.createElement('span');
|
||||
tip.append(chip, tipText);
|
||||
document.head.appendChild(style);
|
||||
document.body.append(outline, tip);
|
||||
|
||||
let done = false;
|
||||
const inFrame = (t: EventTarget | null): t is Element => t instanceof Element && frame.contains(t);
|
||||
const finish = (r: CanvasPickResult) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
for (const t of BLOCKED) window.removeEventListener(t, block, true);
|
||||
window.removeEventListener('click', onClick, true);
|
||||
window.removeEventListener('mousemove', onMove, true);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
style.remove(); outline.remove(); tip.remove();
|
||||
if (activeCancel === cancel) activeCancel = null;
|
||||
resolve(r);
|
||||
};
|
||||
const cancel = () => finish(fail('cancelled', CANCELLED));
|
||||
const swallow = (e: Event) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); };
|
||||
function block(e: Event) {
|
||||
if (inFrame(e.target)) swallow(e);
|
||||
else if (e.type === 'mousedown' || e.type === 'pointerdown') cancel();
|
||||
}
|
||||
function onClick(e: Event) {
|
||||
if (!inFrame(e.target)) { cancel(); return; }
|
||||
swallow(e);
|
||||
const { candidates, hex } = readCandidates(e.target, frame, design);
|
||||
if (!hex) { finish(fail('error', "Couldn't read a colour from that spot.")); return; }
|
||||
finish({ ok: true, hex, source: 'canvas', candidates });
|
||||
}
|
||||
function onMove(e: Event) {
|
||||
const t = e.target;
|
||||
if (!inFrame(t)) { outline.style.display = 'none'; tip.style.display = 'none'; return; }
|
||||
const r = t.getBoundingClientRect();
|
||||
Object.assign(outline.style, { display: 'block', left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
|
||||
const { hex } = readCandidates(t, frame, design);
|
||||
const me = e as MouseEvent;
|
||||
if (hex) {
|
||||
chip.style.background = hex;
|
||||
tipText.textContent = hex;
|
||||
Object.assign(tip.style, { display: 'flex', left: `${(me.clientX || 0) + 14}px`, top: `${(me.clientY || 0) + 14}px` });
|
||||
}
|
||||
}
|
||||
function onKey(e: Event) {
|
||||
if ((e as KeyboardEvent).key === 'Escape') { swallow(e); cancel(); }
|
||||
}
|
||||
|
||||
function onAbort() { cancel(); }
|
||||
|
||||
for (const t of BLOCKED) window.addEventListener(t, block, true);
|
||||
window.addEventListener('click', onClick, true);
|
||||
window.addEventListener('mousemove', onMove, true);
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
activeCancel = cancel;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user