diff --git a/craft/package.json b/craft/package.json index 5789f0c..094c141 100644 --- a/craft/package.json +++ b/craft/package.json @@ -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": { diff --git a/craft/scripts/e2e-color-pick.mjs b/craft/scripts/e2e-color-pick.mjs new file mode 100644 index 0000000..5ab08ec --- /dev/null +++ b/craft/scripts/e2e-color-pick.mjs @@ -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 = ` +
+
token bg
+
brand bg
+
swatch bg
+
plain + border
+
nested transparent
+
alpha
+

muted text on frame bg

+
+
editor chrome
+`; + +// 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); diff --git a/craft/src/utils/color-pick.test.ts b/craft/src/utils/color-pick.test.ts new file mode 100644 index 0000000..0c5c17a --- /dev/null +++ b/craft/src/utils/color-pick.test.ts @@ -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 => ({ ...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).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).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 = ` +
+
+ hi +
+

x

+
+
out
`; + 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(); + }); +}); diff --git a/craft/src/utils/color-pick.ts b/craft/src/utils/color-pick.ts new file mode 100644 index 0000000..ba0c6a4 --- /dev/null +++ b/craft/src/utils/color-pick.ts @@ -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 { + 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 { + 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 { + 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((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; + }); +}