mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(desktop): stop dropping confirmed preview selections
Confirming the edit bubble did nothing: the page emitted picker-exited
before selection, so both host-side guards added by 8ec8833be disarmed
first and threw the selection away — the main process returned before
even capturing, and the renderer's pickerActive check would have dropped
it too. selection already implies "this pick is over" and both hosts
reset their picker state on it, so the confirm path now only cleans up
locally; picker-exited stays on the cancel/abort paths that produce no
selection. The authorization semantics are unchanged.
The bubble is also rebuilt: colour fields get a picker swatch plus hex
(with a chequerboard for transparent), opacity becomes a slider, font
becomes a select, styles move to a constructable stylesheet so a strict
style-src CSP can no longer blank them, and the panel follows the system
colour scheme. Text editing now reads and writes .value on form controls
— it was always blank on inputs — and is withheld from containers where
it would flatten the subtree.
Also fixes opacity '0' being swallowed as falsy in applyEdit, and a
phantom diff when the picker re-picked an already-set colour.
The confirm path had no test at all, which is how the ordering bug
shipped; add page-level coverage of the real message sequence plus
ordering contracts on both hosts.
This commit is contained in:
@@ -653,6 +653,42 @@ describe('Electron preview service', () => {
|
||||
expect(renderer.sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('forwards the selection emitted by a confirmed edit bubble, then disarms on picker-exited', async () => {
|
||||
const view = new FakeView()
|
||||
const renderer = new FakeWebContents()
|
||||
const service = new ElectronPreviewService({
|
||||
createView: () => view,
|
||||
previewScriptPath: previewScript(),
|
||||
})
|
||||
await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
})
|
||||
const selection = JSON.stringify({
|
||||
v: 1,
|
||||
type: 'selection',
|
||||
payload: {
|
||||
pageUrl: 'https://example.com',
|
||||
element: { selector: '#todo', tag: 'input', classes: [] },
|
||||
screenshot: { kind: 'region' },
|
||||
},
|
||||
})
|
||||
|
||||
// 页面确认时的真实顺序:selection 先走,picker-exited 只在取消路径出现。
|
||||
await service.message({ v: 1, type: 'enter-picker' })
|
||||
await service.sendMessageToRenderer(view.webContents, selection, renderer)
|
||||
expect(renderer.sent).toHaveLength(1)
|
||||
expect(renderer.sent[0]!.payload).toMatchObject({ type: 'selection' })
|
||||
|
||||
// selection 已消费掉授权,随后的重放(无论是否夹着 picker-exited)都进不来。
|
||||
await service.sendMessageToRenderer(view.webContents, JSON.stringify({ v: 1, type: 'picker-exited' }), renderer)
|
||||
await service.sendMessageToRenderer(view.webContents, selection, renderer)
|
||||
expect(renderer.sent).toHaveLength(2)
|
||||
expect(renderer.sent[1]!.payload).toMatchObject({ type: 'picker-exited' })
|
||||
})
|
||||
|
||||
it('rejects host messages before a preview view exists', async () => {
|
||||
const service = new ElectronPreviewService({
|
||||
createView: () => new FakeView(),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -84,6 +84,19 @@ describe('subscribePreviewEvents', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the selection that arrives before picker-exited on the confirm path', async () => {
|
||||
useBrowserPanelStore.getState().open('s1', 'http://x/a')
|
||||
useBrowserPanelStore.getState().setPicker('s1', true)
|
||||
await subscribePreviewEvents('s1')
|
||||
|
||||
// 确认编辑气泡时页面只发 selection;picker-exited 若抢在前面会解除 picker 态并丢弃选区。
|
||||
previewHandler!(JSON.stringify({ v: 1, type: 'selection', payload: { pageUrl: 'http://x/', element: { selector: '#t', tag: 'h1', classes: [] }, screenshot: { dataUrl: 'data:image/png;base64,AAAA', kind: 'region' } } }))
|
||||
previewHandler!(JSON.stringify({ v: 1, type: 'picker-exited' }))
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1)
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores selection events when the host picker is not active', async () => {
|
||||
useBrowserPanelStore.getState().open('s1', 'http://x/a')
|
||||
await subscribePreviewEvents('s1')
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatCssColor, isDarkColor, parseCssColor, toHexColor } from './color'
|
||||
|
||||
describe('parseCssColor', () => {
|
||||
it('解析 getComputedStyle 常见的 rgb / rgba 写法', () => {
|
||||
expect(parseCssColor('rgb(26, 29, 41)')).toEqual({ r: 26, g: 29, b: 41, a: 1 })
|
||||
expect(parseCssColor('rgba(255, 255, 255, 0.5)')).toEqual({ r: 255, g: 255, b: 255, a: 0.5 })
|
||||
expect(parseCssColor('rgba(0, 0, 0, 0)')).toEqual({ r: 0, g: 0, b: 0, a: 0 })
|
||||
})
|
||||
|
||||
it('解析 hex 的 3/4/6/8 位写法', () => {
|
||||
expect(parseCssColor('#abc')).toEqual({ r: 170, g: 187, b: 204, a: 1 })
|
||||
expect(parseCssColor('#1a1d29')).toEqual({ r: 26, g: 29, b: 41, a: 1 })
|
||||
expect(parseCssColor('#ff000080')).toEqual({ r: 255, g: 0, b: 0, a: 128 / 255 })
|
||||
expect(parseCssColor('#F00')).toEqual({ r: 255, g: 0, b: 0, a: 1 })
|
||||
})
|
||||
|
||||
it('解析 transparent 与现代空格 / 斜杠语法', () => {
|
||||
expect(parseCssColor('transparent')).toEqual({ r: 0, g: 0, b: 0, a: 0 })
|
||||
expect(parseCssColor('rgb(10 20 30)')).toEqual({ r: 10, g: 20, b: 30, a: 1 })
|
||||
expect(parseCssColor('rgb(10 20 30 / 50%)')).toEqual({ r: 10, g: 20, b: 30, a: 0.5 })
|
||||
})
|
||||
|
||||
it('把越界通道夹回 0-255、alpha 夹回 0-1', () => {
|
||||
expect(parseCssColor('rgb(300, -20, 12)')).toEqual({ r: 255, g: 0, b: 12, a: 1 })
|
||||
expect(parseCssColor('rgba(0, 0, 0, 2)')).toEqual({ r: 0, g: 0, b: 0, a: 1 })
|
||||
})
|
||||
|
||||
it('认不出来的写法返回 null,不猜颜色', () => {
|
||||
expect(parseCssColor('')).toBeNull()
|
||||
expect(parseCssColor(' ')).toBeNull()
|
||||
expect(parseCssColor('rebeccapurple')).toBeNull()
|
||||
expect(parseCssColor('#ab')).toBeNull()
|
||||
expect(parseCssColor('#abcde')).toBeNull()
|
||||
expect(parseCssColor('rgb(1, 2)')).toBeNull()
|
||||
expect(parseCssColor('rgb(1, 2, 3, 4, 5)')).toBeNull()
|
||||
expect(parseCssColor('rgb(a, b, c)')).toBeNull()
|
||||
expect(parseCssColor('color(srgb 0 0 0)')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toHexColor', () => {
|
||||
it('补零输出 6 位 hex 并丢弃 alpha', () => {
|
||||
expect(toHexColor({ r: 26, g: 29, b: 41, a: 1 })).toBe('#1a1d29')
|
||||
expect(toHexColor({ r: 0, g: 0, b: 0, a: 0 })).toBe('#000000')
|
||||
expect(toHexColor({ r: 255, g: 255, b: 255, a: 0.2 })).toBe('#ffffff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCssColor', () => {
|
||||
it('不透明写 hex、半透明写 rgba、全透明写 transparent', () => {
|
||||
expect(formatCssColor({ r: 26, g: 29, b: 41, a: 1 })).toBe('#1a1d29')
|
||||
expect(formatCssColor({ r: 255, g: 0, b: 0, a: 0.5 })).toBe('rgba(255, 0, 0, 0.5)')
|
||||
expect(formatCssColor({ r: 12, g: 34, b: 56, a: 0 })).toBe('transparent')
|
||||
})
|
||||
|
||||
it('alpha 保留三位小数避免浮点噪声', () => {
|
||||
expect(formatCssColor({ r: 0, g: 0, b: 0, a: 128 / 255 })).toBe('rgba(0, 0, 0, 0.502)')
|
||||
})
|
||||
|
||||
it('与 parseCssColor 往返一致', () => {
|
||||
for (const value of ['#1a1d29', 'rgba(255, 0, 0, 0.5)', 'transparent']) {
|
||||
expect(formatCssColor(parseCssColor(value)!)).toBe(value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDarkColor', () => {
|
||||
it('按感知亮度判断深浅', () => {
|
||||
expect(isDarkColor({ r: 0, g: 0, b: 0, a: 1 })).toBe(true)
|
||||
expect(isDarkColor({ r: 26, g: 29, b: 41, a: 1 })).toBe(true)
|
||||
expect(isDarkColor({ r: 255, g: 255, b: 255, a: 1 })).toBe(false)
|
||||
expect(isDarkColor({ r: 255, g: 255, b: 0, a: 1 })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
export type Rgba = { r: number; g: number; b: number; a: number }
|
||||
|
||||
const HEX_PATTERN = /^#([0-9a-f]+)$/i
|
||||
const FUNCTIONAL_PATTERN = /^rgba?\(([^)]*)\)$/i
|
||||
|
||||
function clampChannel(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(255, Math.max(0, Math.round(value)))
|
||||
}
|
||||
|
||||
function clampAlpha(value: number): number {
|
||||
if (!Number.isFinite(value)) return 1
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
function parseChannel(token: string): number | null {
|
||||
const raw = token.trim()
|
||||
if (!raw) return null
|
||||
const percent = raw.endsWith('%')
|
||||
const parsed = Number.parseFloat(percent ? raw.slice(0, -1) : raw)
|
||||
if (Number.isNaN(parsed)) return null
|
||||
return clampChannel(percent ? (parsed / 100) * 255 : parsed)
|
||||
}
|
||||
|
||||
function parseAlpha(token: string | undefined): number | null {
|
||||
if (token === undefined) return 1
|
||||
const raw = token.trim()
|
||||
if (!raw) return null
|
||||
const percent = raw.endsWith('%')
|
||||
const parsed = Number.parseFloat(percent ? raw.slice(0, -1) : raw)
|
||||
if (Number.isNaN(parsed)) return null
|
||||
return clampAlpha(percent ? parsed / 100 : parsed)
|
||||
}
|
||||
|
||||
function expand(hexDigit: string): number {
|
||||
return Number.parseInt(hexDigit + hexDigit, 16)
|
||||
}
|
||||
|
||||
function parseHex(digits: string): Rgba | null {
|
||||
if (digits.length === 3 || digits.length === 4) {
|
||||
return {
|
||||
r: expand(digits[0]!),
|
||||
g: expand(digits[1]!),
|
||||
b: expand(digits[2]!),
|
||||
a: digits.length === 4 ? expand(digits[3]!) / 255 : 1,
|
||||
}
|
||||
}
|
||||
if (digits.length === 6 || digits.length === 8) {
|
||||
return {
|
||||
r: Number.parseInt(digits.slice(0, 2), 16),
|
||||
g: Number.parseInt(digits.slice(2, 4), 16),
|
||||
b: Number.parseInt(digits.slice(4, 6), 16),
|
||||
a: digits.length === 8 ? Number.parseInt(digits.slice(6, 8), 16) / 255 : 1,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 getComputedStyle 会回吐的颜色写法(rgb/rgba/hex/transparent),
|
||||
* 也接受用户手输的现代空格语法 `rgb(0 0 0 / 50%)`。认不出来时返回 null,
|
||||
* 调用方应保留用户原始输入而不是猜一个颜色。
|
||||
*/
|
||||
export function parseCssColor(input: string): Rgba | null {
|
||||
const value = input.trim().toLowerCase()
|
||||
if (!value) return null
|
||||
if (value === 'transparent') return { r: 0, g: 0, b: 0, a: 0 }
|
||||
|
||||
const hex = HEX_PATTERN.exec(value)
|
||||
if (hex) return parseHex(hex[1]!)
|
||||
|
||||
const functional = FUNCTIONAL_PATTERN.exec(value)
|
||||
if (!functional) return null
|
||||
|
||||
const [channelPart, slashAlpha, ...extraSlashes] = functional[1]!.split('/')
|
||||
if (extraSlashes.length > 0) return null
|
||||
const tokens = channelPart!.trim().split(/[\s,]+/).filter(Boolean)
|
||||
const [red, green, blue, commaAlpha] = tokens
|
||||
if (!red || !green || !blue) return null
|
||||
if (slashAlpha !== undefined && commaAlpha !== undefined) return null
|
||||
if (tokens.length > 4) return null
|
||||
|
||||
const r = parseChannel(red)
|
||||
const g = parseChannel(green)
|
||||
const b = parseChannel(blue)
|
||||
const a = parseAlpha(slashAlpha ?? commaAlpha)
|
||||
if (r === null || g === null || b === null || a === null) return null
|
||||
return { r, g, b, a }
|
||||
}
|
||||
|
||||
/** `<input type="color">` 只认 #rrggbb,alpha 在这里被丢弃。 */
|
||||
export function toHexColor(color: Rgba): string {
|
||||
const channel = (value: number) => clampChannel(value).toString(16).padStart(2, '0')
|
||||
return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`
|
||||
}
|
||||
|
||||
/** 回写页面与 diff 用的规范写法:全透明写 transparent,半透明写 rgba(),其余写 hex。 */
|
||||
export function formatCssColor(color: Rgba): string {
|
||||
const alpha = clampAlpha(color.a)
|
||||
if (alpha <= 0) return 'transparent'
|
||||
if (alpha >= 1) return toHexColor(color)
|
||||
return `rgba(${clampChannel(color.r)}, ${clampChannel(color.g)}, ${clampChannel(color.b)}, ${Number(alpha.toFixed(3))})`
|
||||
}
|
||||
|
||||
/** 深色底上要用浅色前景 —— 用来决定色块边框/棋盘格的对比色。 */
|
||||
export function isDarkColor(color: Rgba): boolean {
|
||||
const luminance = (0.299 * color.r + 0.587 * color.g + 0.114 * color.b) / 255
|
||||
return luminance < 0.5
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { snapshotEditableStyles, computeChange, createEditBubble, type EditableSnapshot } from './editBubble'
|
||||
|
||||
@@ -25,6 +27,34 @@ describe('snapshotEditableStyles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** vitest 以 desktop/ 为 root(见 vitest.config.ts)。 */
|
||||
const SOURCE_PATH = join(process.cwd(), 'src/preview-agent/editBubble.ts')
|
||||
|
||||
describe('注入样式的 token 自洽', () => {
|
||||
// 气泡活在第三方页面的 Shadow DOM 里,globals.css 够不到它,所以 var(--x) 必须
|
||||
// 由这段样式自己定义 —— 拼错一个名字只会静默失效(背景透明、边框变 currentColor)。
|
||||
it('每个 var(--token) 都在同一段样式里定义过', () => {
|
||||
const source = readFileSync(SOURCE_PATH, 'utf8')
|
||||
const defined = new Set([...source.matchAll(/^\s*(--[a-zA-Z0-9-]+)\s*:/gm)].map((m) => m[1]))
|
||||
const referenced = [...source.matchAll(/var\(\s*(--[a-zA-Z0-9-]+)/g)].map((m) => m[1]!)
|
||||
|
||||
expect([...new Set(referenced)].filter((token) => !defined.has(token))).toEqual([])
|
||||
expect(referenced.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('浅色与暗色定义了同一组 token,暗色不会漏掉某个变量', () => {
|
||||
const source = readFileSync(SOURCE_PATH, 'utf8')
|
||||
const darkBlock = /@media \(prefers-color-scheme: dark\) \{([\s\S]*?)\n\}/.exec(source)?.[1] ?? ''
|
||||
const darkTokens = new Set([...darkBlock.matchAll(/(--[a-zA-Z0-9-]+)\s*:/g)].map((m) => m[1]))
|
||||
|
||||
// 暗色只覆写与明暗有关的那些;结构性 token(如 --mono)不该出现在暗色块里
|
||||
expect(darkTokens.has('--bg')).toBe(true)
|
||||
expect(darkTokens.has('--fg')).toBe(true)
|
||||
expect(darkTokens.has('--swatch-border')).toBe(true)
|
||||
expect(darkTokens.has('--mono')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEditBubble', () => {
|
||||
it('positions controls inside the viewport when selecting a low element', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
|
||||
@@ -70,6 +100,118 @@ describe('createEditBubble', () => {
|
||||
bubble.destroy()
|
||||
})
|
||||
|
||||
it('颜色行给出取色器色块,初值按 hex 展示', () => {
|
||||
const el = document.getElementById('t')!
|
||||
vi.spyOn(window, 'getComputedStyle').mockReturnValue({ color: 'rgb(26, 29, 41)', backgroundColor: 'rgba(0, 0, 0, 0)', opacity: '1', fontFamily: 'Arial' } as unknown as CSSStyleDeclaration)
|
||||
const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() })
|
||||
|
||||
expect($(bubble, '[data-field="color"]').value).toBe('#1a1d29')
|
||||
expect($(bubble, '[data-field="background"]').value).toBe('transparent')
|
||||
const swatches = bubble.host.shadowRoot!.querySelectorAll<HTMLInputElement>('input[type="color"]')
|
||||
expect(swatches).toHaveLength(2)
|
||||
expect(swatches[0]!.value).toBe('#1a1d29')
|
||||
// 透明背景要露出棋盘格,而不是画成黑色
|
||||
expect(swatches[1]!.parentElement!.dataset.transparent).toBe('true')
|
||||
bubble.destroy()
|
||||
})
|
||||
|
||||
it('取色器选色后写回元素并计入 diff', () => {
|
||||
const el = document.getElementById('t')!
|
||||
const onConfirm = vi.fn()
|
||||
const bubble = createEditBubble(el, { onConfirm, onCancel: vi.fn() })
|
||||
|
||||
const swatch = bubble.host.shadowRoot!.querySelector<HTMLInputElement>('input[type="color"]')!
|
||||
swatch.value = '#ff0000'
|
||||
swatch.dispatchEvent(new Event('input'))
|
||||
|
||||
expect(el.style.color).toBe('rgb(255, 0, 0)')
|
||||
expect($(bubble, '[data-field="color"]').value).toBe('#ff0000')
|
||||
$(bubble, '[data-action="confirm"]').click()
|
||||
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ color: expect.objectContaining({ to: '#ff0000' }) }))
|
||||
bubble.destroy()
|
||||
})
|
||||
|
||||
it('选中与原值相同的颜色不产生假 diff', () => {
|
||||
const el = document.getElementById('t')!
|
||||
vi.spyOn(window, 'getComputedStyle').mockReturnValue({ color: 'rgb(26, 29, 41)', backgroundColor: 'rgba(0, 0, 0, 0)', opacity: '1', fontFamily: 'Arial' } as unknown as CSSStyleDeclaration)
|
||||
const onConfirm = vi.fn()
|
||||
const bubble = createEditBubble(el, { onConfirm, onCancel: vi.fn() })
|
||||
|
||||
const swatch = bubble.host.shadowRoot!.querySelector<HTMLInputElement>('input[type="color"]')!
|
||||
swatch.value = '#1a1d29' // 与 rgb(26, 29, 41) 是同一个颜色
|
||||
swatch.dispatchEvent(new Event('input'))
|
||||
|
||||
$(bubble, '[data-action="confirm"]').click()
|
||||
expect(onConfirm).toHaveBeenCalledWith({ description: undefined })
|
||||
bubble.destroy()
|
||||
})
|
||||
|
||||
it('不透明度用滑块,按百分比读写', () => {
|
||||
const el = document.getElementById('t')!
|
||||
vi.spyOn(window, 'getComputedStyle').mockReturnValue({ color: 'rgb(0,0,0)', backgroundColor: 'rgba(0,0,0,0)', opacity: '0.5', fontFamily: 'Arial' } as unknown as CSSStyleDeclaration)
|
||||
const onConfirm = vi.fn()
|
||||
const bubble = createEditBubble(el, { onConfirm, onCancel: vi.fn() })
|
||||
|
||||
const slider = $(bubble, '[data-field="opacity"]')
|
||||
expect(slider.type).toBe('range')
|
||||
expect(slider.value).toBe('50')
|
||||
|
||||
slider.value = '20'
|
||||
slider.dispatchEvent(new Event('input'))
|
||||
expect(el.style.opacity).toBe('0.2')
|
||||
$(bubble, '[data-action="confirm"]').click()
|
||||
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ opacity: { from: '0.5', to: '0.2' } }))
|
||||
bubble.destroy()
|
||||
})
|
||||
|
||||
it('字体用下拉,首项是元素当前字体', () => {
|
||||
const el = document.getElementById('t')!
|
||||
vi.spyOn(window, 'getComputedStyle').mockReturnValue({ color: 'rgb(0,0,0)', backgroundColor: 'rgba(0,0,0,0)', opacity: '1', fontFamily: 'Georgia, serif' } as unknown as CSSStyleDeclaration)
|
||||
const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() })
|
||||
|
||||
const select = bubble.host.shadowRoot!.querySelector<HTMLSelectElement>('[data-field="fontFamily"]')!
|
||||
expect(select.tagName).toBe('SELECT')
|
||||
expect(select.value).toBe('Georgia, serif')
|
||||
expect(select.options[0]!.textContent).toContain('Georgia')
|
||||
|
||||
select.value = select.options[1]!.value
|
||||
select.dispatchEvent(new Event('change'))
|
||||
expect(el.style.fontFamily).toBeTruthy()
|
||||
bubble.destroy()
|
||||
})
|
||||
|
||||
it('选中输入框时文本字段读的是 value', () => {
|
||||
document.body.innerHTML = '<input id="f" value="待办内容">'
|
||||
const field = document.getElementById('f')!
|
||||
const bubble = createEditBubble(field, { onConfirm: vi.fn(), onCancel: vi.fn() })
|
||||
expect($(bubble, '[data-field="text"]').value).toBe('待办内容')
|
||||
bubble.destroy()
|
||||
})
|
||||
|
||||
it('选中含子元素的容器时不给文本字段(改它会夷平子树)', () => {
|
||||
document.body.innerHTML = '<div id="d"><span>a</span><span>b</span></div>'
|
||||
const container = document.getElementById('d')!
|
||||
const bubble = createEditBubble(container, { onConfirm: vi.fn(), onCancel: vi.fn() })
|
||||
expect(bubble.host.shadowRoot!.querySelector('[data-field="text"]')).toBeNull()
|
||||
expect(bubble.host.shadowRoot!.querySelector('[data-field="color"]')).not.toBeNull()
|
||||
bubble.destroy()
|
||||
})
|
||||
|
||||
it('Escape 取消、Cmd/Ctrl+Enter 确认', () => {
|
||||
const el = document.getElementById('t')!
|
||||
const onCancel = vi.fn()
|
||||
const escBubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel })
|
||||
escBubble.host.shadowRoot!.querySelector('.bubble')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
escBubble.destroy()
|
||||
|
||||
const onConfirm = vi.fn()
|
||||
const enterBubble = createEditBubble(el, { onConfirm, onCancel: vi.fn() })
|
||||
enterBubble.host.shadowRoot!.querySelector('.bubble')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }))
|
||||
expect(onConfirm).toHaveBeenCalled()
|
||||
enterBubble.destroy()
|
||||
})
|
||||
|
||||
it('cancel reverts live edits and fires onCancel', () => {
|
||||
const el = document.getElementById('t')!
|
||||
const onCancel = vi.fn(); const onConfirm = vi.fn()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { applyEdit, type EditDiff, type EditInput } from './popover'
|
||||
import { applyEdit, canEditText, readElementText, type EditDiff, type EditInput } from './popover'
|
||||
import { formatCssColor, parseCssColor, toHexColor } from './color'
|
||||
import { buildSelector } from './selector'
|
||||
|
||||
export type EditableSnapshot = { text: string; color: string; background: string; opacity: string; fontFamily: string }
|
||||
export type EditBubbleChange = EditDiff & { description?: string }
|
||||
@@ -9,19 +11,160 @@ const BUBBLE_GAP = 8
|
||||
const BUBBLE_WIDTH = 340
|
||||
const BUBBLE_ESTIMATED_HEIGHT = 380
|
||||
const BUBBLE_MIN_HEIGHT = 160
|
||||
const TEXT_AREA_MAX_HEIGHT = 96
|
||||
|
||||
const FIELDS: Array<{ key: keyof EditableSnapshot; label: string }> = [
|
||||
{ key: 'text', label: '文本' },
|
||||
{ key: 'color', label: '文字颜色' },
|
||||
{ key: 'background', label: '背景' },
|
||||
{ key: 'opacity', label: 'Opacity' },
|
||||
{ key: 'fontFamily', label: '字体' },
|
||||
const FONT_PRESETS: Array<{ label: string; value: string }> = [
|
||||
{ label: '系统默认', value: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif' },
|
||||
{ label: '无衬线', value: '"Helvetica Neue", Helvetica, Arial, "PingFang SC", sans-serif' },
|
||||
{ label: '衬线', value: 'Georgia, "Times New Roman", "Songti SC", serif' },
|
||||
{ label: '等宽', value: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace' },
|
||||
]
|
||||
|
||||
const STYLE = `
|
||||
.bubble {
|
||||
--bg: #ffffff;
|
||||
--fg: #14161a;
|
||||
--muted: #6b7280;
|
||||
--border: #ebedf1;
|
||||
--field-bg: #f7f8fa;
|
||||
--field-border: #e2e5ea;
|
||||
--accent: #2f7bff;
|
||||
--accent-soft: rgba(47, 123, 255, .12);
|
||||
--accent-ring: rgba(47, 123, 255, .18);
|
||||
--checker: #d6dae1;
|
||||
--swatch-border: rgba(0, 0, 0, .2);
|
||||
--shadow: 0 16px 40px rgba(15, 18, 25, .18), 0 2px 8px rgba(15, 18, 25, .08);
|
||||
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
||||
|
||||
box-sizing: border-box;
|
||||
width: ${BUBBLE_WIDTH}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
box-shadow: var(--shadow);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
line-height: 1.5;
|
||||
letter-spacing: normal;
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bubble {
|
||||
--bg: #1b1d23;
|
||||
--fg: #e9ebef;
|
||||
--muted: #9098a4;
|
||||
--border: #2b2f37;
|
||||
--field-bg: #23262d;
|
||||
--field-border: #343a44;
|
||||
--checker: #3a3f48;
|
||||
--swatch-border: rgba(255, 255, 255, .26);
|
||||
--shadow: 0 16px 40px rgba(0, 0, 0, .5), 0 2px 8px rgba(0, 0, 0, .35);
|
||||
}
|
||||
}
|
||||
|
||||
.head { display: flex; align-items: center; gap: 7px; padding: 12px 14px 0; }
|
||||
.tag {
|
||||
flex: none; padding: 2px 7px; border-radius: 6px;
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
font-family: var(--mono); font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.selector {
|
||||
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
color: var(--muted); font-family: var(--mono); font-size: 11px;
|
||||
}
|
||||
|
||||
.describe {
|
||||
box-sizing: border-box; width: 100%; display: block; resize: none; overflow: hidden;
|
||||
border: none; outline: none; background: transparent; color: var(--fg);
|
||||
font: inherit; font-size: 14px; line-height: 1.45; padding: 8px 14px 10px;
|
||||
}
|
||||
.describe::placeholder { color: var(--muted); }
|
||||
|
||||
.body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 8px 14px 2px; border-top: 1px solid var(--border); }
|
||||
.body::-webkit-scrollbar { width: 9px; }
|
||||
.body::-webkit-scrollbar-thumb {
|
||||
background: var(--field-border); border-radius: 5px;
|
||||
border: 3px solid transparent; background-clip: content-box;
|
||||
}
|
||||
|
||||
.row { display: flex; align-items: center; gap: 10px; padding: 2px 0; }
|
||||
.row-text { align-items: flex-start; }
|
||||
.row-text .row-label { padding-top: 6px; }
|
||||
.row-label { flex: none; width: 58px; color: var(--muted); font-size: 12px; }
|
||||
.control { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.input {
|
||||
flex: 1; min-width: 0; box-sizing: border-box;
|
||||
border: 1px solid var(--field-border); border-radius: 9px;
|
||||
background: var(--field-bg); color: var(--fg);
|
||||
font: inherit; font-size: 12px; padding: 5px 9px; outline: none;
|
||||
transition: border-color .12s ease, box-shadow .12s ease, background .12s ease;
|
||||
}
|
||||
.input:focus { border-color: var(--accent); background: var(--bg); box-shadow: 0 0 0 3px var(--accent-ring); }
|
||||
|
||||
.text-area { resize: none; overflow: hidden; line-height: 1.5; max-height: ${TEXT_AREA_MAX_HEIGHT}px; }
|
||||
.color-value { font-family: var(--mono); font-size: 11.5px; }
|
||||
|
||||
.swatch-slot {
|
||||
flex: none; position: relative; width: 26px; height: 26px;
|
||||
border: 1px solid var(--swatch-border); border-radius: 8px; overflow: hidden;
|
||||
}
|
||||
.swatch-slot[data-transparent="true"] {
|
||||
background-image:
|
||||
linear-gradient(45deg, var(--checker) 25%, transparent 25%, transparent 75%, var(--checker) 75%),
|
||||
linear-gradient(45deg, var(--checker) 25%, transparent 25%, transparent 75%, var(--checker) 75%);
|
||||
background-size: 10px 10px;
|
||||
background-position: 0 0, 5px 5px;
|
||||
}
|
||||
.swatch { display: block; width: 100%; height: 100%; padding: 0; border: none; background: none; cursor: pointer; }
|
||||
.swatch::-webkit-color-swatch-wrapper { padding: 0; }
|
||||
.swatch::-webkit-color-swatch { border: none; border-radius: 0; }
|
||||
.swatch-slot[data-transparent="true"] .swatch::-webkit-color-swatch { opacity: 0; }
|
||||
|
||||
.slider {
|
||||
flex: 1; min-width: 0; height: 4px; border-radius: 999px; outline: none; cursor: pointer;
|
||||
-webkit-appearance: none; appearance: none; background: var(--field-border);
|
||||
}
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%;
|
||||
background: #fff; border: 1px solid rgba(0, 0, 0, .18); box-shadow: 0 1px 3px rgba(0, 0, 0, .25); cursor: grab;
|
||||
}
|
||||
.slider:focus-visible { box-shadow: 0 0 0 3px var(--accent-ring); }
|
||||
.readout { flex: none; width: 36px; text-align: right; color: var(--muted); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.select-slot { position: relative; flex: 1; min-width: 0; display: flex; }
|
||||
.select { -webkit-appearance: none; appearance: none; padding-right: 26px; cursor: pointer; }
|
||||
.select-slot::after {
|
||||
content: ""; position: absolute; right: 11px; top: 50%; width: 5px; height: 5px; pointer-events: none;
|
||||
border-right: 1.5px solid var(--muted); border-bottom: 1.5px solid var(--muted);
|
||||
transform: translateY(-70%) rotate(45deg);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 9px 14px; margin-top: 8px; border-top: 1px solid var(--border);
|
||||
}
|
||||
/* 内容被压缩到需要滚动时,给页脚一道投影,提示上方还有没看完的行 */
|
||||
.bubble[data-scrollable="true"] .footer { box-shadow: 0 -7px 12px -8px rgba(15, 18, 25, .45); }
|
||||
.btn { border: none; border-radius: 999px; font: inherit; font-size: 12.5px; cursor: pointer; transition: background .12s ease, color .12s ease; }
|
||||
.btn-ghost { padding: 7px 14px; background: transparent; color: var(--muted); }
|
||||
.btn-ghost:hover { background: var(--field-bg); color: var(--fg); }
|
||||
.btn-primary { padding: 7px 18px; background: var(--accent); color: #fff; font-weight: 500; }
|
||||
.btn-primary:hover { background: #1f6bf0; }
|
||||
`
|
||||
|
||||
export function snapshotEditableStyles(el: HTMLElement): EditableSnapshot {
|
||||
const cs = window.getComputedStyle(el)
|
||||
return {
|
||||
text: el.textContent ?? '',
|
||||
text: readElementText(el),
|
||||
color: cs.color,
|
||||
background: cs.backgroundColor,
|
||||
opacity: cs.opacity,
|
||||
@@ -29,11 +172,20 @@ export function snapshotEditableStyles(el: HTMLElement): EditableSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/** 颜色框把 rgb() 显示成 hex,所以比较必须按语义走,否则「选了同一个颜色」会变成一条假变更。 */
|
||||
function sameColorValue(a: string, b: string): boolean {
|
||||
if (a === b) return true
|
||||
const left = parseCssColor(a)
|
||||
const right = parseCssColor(b)
|
||||
if (!left || !right) return false
|
||||
return left.r === right.r && left.g === right.g && left.b === right.b && Math.abs(left.a - right.a) < 0.001
|
||||
}
|
||||
|
||||
export function computeChange(original: EditableSnapshot, current: EditableSnapshot): EditDiff {
|
||||
const d: EditDiff = {}
|
||||
if (current.text !== original.text) d.text = { from: original.text, to: current.text }
|
||||
if (current.color !== original.color) d.color = { from: original.color, to: current.color }
|
||||
if (current.background !== original.background) d.background = { from: original.background, to: current.background }
|
||||
if (!sameColorValue(current.color, original.color)) d.color = { from: original.color, to: current.color }
|
||||
if (!sameColorValue(current.background, original.background)) d.background = { from: original.background, to: current.background }
|
||||
if (current.opacity !== original.opacity) d.opacity = { from: original.opacity, to: current.opacity }
|
||||
if (current.fontFamily !== original.fontFamily) d.fontFamily = { from: original.fontFamily, to: current.fontFamily }
|
||||
return d
|
||||
@@ -78,86 +230,270 @@ function computeBubbleLayout(rect: DOMRect, contentHeight: number) {
|
||||
return { top, left, maxHeight }
|
||||
}
|
||||
|
||||
export function createEditBubble(el: HTMLElement, deps: Deps): { host: HTMLElement; destroy: () => void } {
|
||||
const original = snapshotEditableStyles(el)
|
||||
/** Constructable stylesheet 走 CSSOM,不会被页面的 style-src CSP 拦掉;不支持时回落 <style>。 */
|
||||
function adoptStyles(shadow: ShadowRoot, css: string): void {
|
||||
const SheetCtor = (globalThis as { CSSStyleSheet?: typeof CSSStyleSheet }).CSSStyleSheet
|
||||
if (SheetCtor && typeof SheetCtor.prototype.replaceSync === 'function') {
|
||||
try {
|
||||
const sheet = new SheetCtor()
|
||||
sheet.replaceSync(css)
|
||||
shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, sheet]
|
||||
return
|
||||
} catch {
|
||||
// 环境不支持构造式样式表,落到 <style>
|
||||
}
|
||||
}
|
||||
const style = document.createElement('style')
|
||||
style.textContent = css
|
||||
shadow.appendChild(style)
|
||||
}
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag)
|
||||
if (className) node.className = className
|
||||
return node
|
||||
}
|
||||
|
||||
function autoGrow(area: HTMLTextAreaElement, maxHeight: number): void {
|
||||
area.style.height = 'auto'
|
||||
area.style.height = `${Math.min(area.scrollHeight || 0, maxHeight)}px`
|
||||
}
|
||||
|
||||
function primaryFontLabel(fontFamily: string): string {
|
||||
const first = fontFamily.split(',')[0]?.trim().replace(/^["']|["']$/g, '') ?? ''
|
||||
return first || '当前'
|
||||
}
|
||||
|
||||
function toPercent(opacity: string): number {
|
||||
const parsed = Number.parseFloat(opacity)
|
||||
if (Number.isNaN(parsed)) return 100
|
||||
return Math.min(100, Math.max(0, Math.round(parsed * 100)))
|
||||
}
|
||||
|
||||
function fromPercent(percent: number): string {
|
||||
return String(Number((percent / 100).toFixed(2)))
|
||||
}
|
||||
|
||||
function buildRow(label: string): { row: HTMLElement; control: HTMLElement } {
|
||||
const row = el('div', 'row')
|
||||
const name = el('span', 'row-label')
|
||||
name.textContent = label
|
||||
const control = el('div', 'control')
|
||||
row.appendChild(name)
|
||||
row.appendChild(control)
|
||||
return { row, control }
|
||||
}
|
||||
|
||||
function buildColorRow(
|
||||
label: string,
|
||||
field: 'color' | 'background',
|
||||
initial: string,
|
||||
onCommit: (value: string) => void,
|
||||
): HTMLElement {
|
||||
const { row, control } = buildRow(label)
|
||||
const slot = el('span', 'swatch-slot')
|
||||
const swatch = el('input', 'swatch')
|
||||
swatch.type = 'color'
|
||||
swatch.setAttribute('aria-label', label)
|
||||
const value = el('input', 'input color-value')
|
||||
value.type = 'text'
|
||||
value.spellcheck = false
|
||||
value.setAttribute('data-field', field)
|
||||
|
||||
const reflect = (raw: string) => {
|
||||
const parsed = parseCssColor(raw)
|
||||
slot.dataset.transparent = parsed && parsed.a <= 0 ? 'true' : 'false'
|
||||
if (parsed) swatch.value = toHexColor(parsed)
|
||||
}
|
||||
|
||||
const parsedInitial = parseCssColor(initial)
|
||||
value.value = parsedInitial ? formatCssColor(parsedInitial) : initial
|
||||
reflect(initial)
|
||||
|
||||
value.addEventListener('input', () => { reflect(value.value); onCommit(value.value) })
|
||||
// 取色器只给得出实色,所以选色即视为「不透明」——想要透明仍可在右侧手输 transparent
|
||||
swatch.addEventListener('input', () => { value.value = swatch.value; slot.dataset.transparent = 'false'; onCommit(swatch.value) })
|
||||
|
||||
slot.appendChild(swatch)
|
||||
control.appendChild(slot)
|
||||
control.appendChild(value)
|
||||
return row
|
||||
}
|
||||
|
||||
function buildOpacityRow(initial: string, onCommit: (value: string) => void): HTMLElement {
|
||||
const { row, control } = buildRow('不透明度')
|
||||
const slider = el('input', 'slider')
|
||||
slider.type = 'range'
|
||||
slider.min = '0'
|
||||
slider.max = '100'
|
||||
slider.step = '1'
|
||||
slider.setAttribute('data-field', 'opacity')
|
||||
slider.setAttribute('aria-label', '不透明度')
|
||||
const readout = el('span', 'readout')
|
||||
|
||||
// 原生 range 没法给已走过的轨道上色,用渐变背景补出进度反馈
|
||||
const paintTrack = (percent: number) => {
|
||||
slider.style.setProperty(
|
||||
'background',
|
||||
`linear-gradient(to right, var(--accent) ${percent}%, var(--field-border) ${percent}%)`,
|
||||
)
|
||||
}
|
||||
|
||||
const percent = toPercent(initial)
|
||||
slider.value = String(percent)
|
||||
readout.textContent = `${percent}%`
|
||||
paintTrack(percent)
|
||||
|
||||
slider.addEventListener('input', () => {
|
||||
const next = Number(slider.value)
|
||||
readout.textContent = `${next}%`
|
||||
paintTrack(next)
|
||||
onCommit(fromPercent(next))
|
||||
})
|
||||
|
||||
control.appendChild(slider)
|
||||
control.appendChild(readout)
|
||||
return row
|
||||
}
|
||||
|
||||
function buildFontRow(initial: string, onCommit: (value: string) => void): HTMLElement {
|
||||
const { row, control } = buildRow('字体')
|
||||
const slot = el('div', 'select-slot')
|
||||
const select = el('select', 'input select')
|
||||
select.setAttribute('data-field', 'fontFamily')
|
||||
select.setAttribute('aria-label', '字体')
|
||||
|
||||
const currentOption = el('option')
|
||||
currentOption.value = initial
|
||||
currentOption.textContent = `当前 · ${primaryFontLabel(initial)}`
|
||||
select.appendChild(currentOption)
|
||||
for (const preset of FONT_PRESETS) {
|
||||
const option = el('option')
|
||||
option.value = preset.value
|
||||
option.textContent = preset.label
|
||||
select.appendChild(option)
|
||||
}
|
||||
select.value = initial
|
||||
|
||||
select.addEventListener('change', () => onCommit(select.value))
|
||||
|
||||
slot.appendChild(select)
|
||||
control.appendChild(slot)
|
||||
return row
|
||||
}
|
||||
|
||||
export function createEditBubble(target: HTMLElement, deps: Deps): { host: HTMLElement; destroy: () => void } {
|
||||
const original = snapshotEditableStyles(target)
|
||||
const current: EditableSnapshot = { ...original }
|
||||
let description = ''
|
||||
|
||||
const host = document.createElement('div')
|
||||
const rect = el.getBoundingClientRect()
|
||||
host.style.cssText = 'position:fixed;top:0;left:0;z-index:2147483647;visibility:hidden'
|
||||
const commit = (key: keyof EditableSnapshot, value: string) => {
|
||||
current[key] = value
|
||||
applyEdit(target, buildPatch(key, value))
|
||||
}
|
||||
|
||||
const host = el('div')
|
||||
const rect = target.getBoundingClientRect()
|
||||
// 用 CSSOM 而非 style 属性,页面的 style-src CSP 才拦不住浮层定位
|
||||
host.style.setProperty('position', 'fixed')
|
||||
host.style.setProperty('top', '0')
|
||||
host.style.setProperty('left', '0')
|
||||
host.style.setProperty('z-index', '2147483647')
|
||||
host.style.setProperty('visibility', 'hidden')
|
||||
const shadow = host.attachShadow({ mode: 'open' })
|
||||
adoptStyles(shadow, STYLE)
|
||||
|
||||
const wrap = document.createElement('div')
|
||||
wrap.setAttribute('style', 'width:340px;box-sizing:border-box;background:#fff;border-radius:14px;box-shadow:0 10px 34px rgba(0,0,0,.2);padding:12px;font:13px/1.45 -apple-system,system-ui,sans-serif;color:#111;display:flex;flex-direction:column;overflow:hidden')
|
||||
const wrap = el('div', 'bubble')
|
||||
|
||||
const body = document.createElement('div')
|
||||
body.setAttribute('style', 'min-height:0;overflow:auto')
|
||||
const head = el('div', 'head')
|
||||
const tag = el('span', 'tag')
|
||||
tag.textContent = target.tagName.toLowerCase()
|
||||
const selector = el('span', 'selector')
|
||||
selector.textContent = buildSelector(target)
|
||||
selector.title = selector.textContent
|
||||
head.appendChild(tag)
|
||||
head.appendChild(selector)
|
||||
|
||||
const desc = document.createElement('input')
|
||||
desc.setAttribute('data-field', 'description')
|
||||
desc.placeholder = '描述这些更改…'
|
||||
desc.setAttribute('style', 'width:100%;box-sizing:border-box;border:none;outline:none;font-size:14px;padding:6px 4px')
|
||||
desc.addEventListener('input', () => { description = desc.value })
|
||||
body.appendChild(desc)
|
||||
const describe = el('textarea', 'describe')
|
||||
describe.setAttribute('data-field', 'description')
|
||||
describe.rows = 1
|
||||
describe.placeholder = '描述这些更改…'
|
||||
describe.addEventListener('input', () => {
|
||||
description = describe.value
|
||||
autoGrow(describe, TEXT_AREA_MAX_HEIGHT)
|
||||
refreshScrollHint()
|
||||
})
|
||||
|
||||
const tag = document.createElement('div')
|
||||
tag.textContent = el.tagName.toLowerCase()
|
||||
tag.setAttribute('style', 'color:#8a8a8a;border-top:1px solid #eee;margin-top:6px;padding:8px 4px 4px;font-weight:600')
|
||||
body.appendChild(tag)
|
||||
const body = el('div', 'body')
|
||||
const refreshScrollHint = () => {
|
||||
if (body.scrollHeight > body.clientHeight) wrap.dataset.scrollable = 'true'
|
||||
else delete wrap.dataset.scrollable
|
||||
}
|
||||
|
||||
for (const f of FIELDS) {
|
||||
const row = document.createElement('label')
|
||||
row.setAttribute('style', 'display:flex;align-items:center;gap:10px;padding:5px 4px')
|
||||
const lab = document.createElement('span')
|
||||
lab.textContent = f.label
|
||||
lab.setAttribute('style', 'width:74px;color:#555;flex:none')
|
||||
const inp = document.createElement('input')
|
||||
inp.setAttribute('data-field', f.key)
|
||||
inp.value = original[f.key]
|
||||
inp.setAttribute('style', 'flex:1;min-width:0;border:1px solid #e2e2e2;border-radius:8px;padding:5px 9px;font:inherit')
|
||||
const fieldKey = f.key
|
||||
inp.addEventListener('input', () => {
|
||||
current[fieldKey] = inp.value
|
||||
applyEdit(el, buildPatch(fieldKey, inp.value))
|
||||
if (canEditText(target)) {
|
||||
const { row, control } = buildRow('文本')
|
||||
row.classList.add('row-text')
|
||||
const area = el('textarea', 'input text-area')
|
||||
area.setAttribute('data-field', 'text')
|
||||
area.rows = 1
|
||||
area.value = original.text
|
||||
area.addEventListener('input', () => {
|
||||
autoGrow(area, TEXT_AREA_MAX_HEIGHT)
|
||||
refreshScrollHint()
|
||||
commit('text', area.value)
|
||||
})
|
||||
row.appendChild(lab)
|
||||
row.appendChild(inp)
|
||||
control.appendChild(area)
|
||||
body.appendChild(row)
|
||||
}
|
||||
|
||||
const footer = document.createElement('div')
|
||||
body.appendChild(buildColorRow('文字颜色', 'color', original.color, (value) => commit('color', value)))
|
||||
body.appendChild(buildColorRow('背景', 'background', original.background, (value) => commit('background', value)))
|
||||
body.appendChild(buildOpacityRow(original.opacity, (value) => commit('opacity', value)))
|
||||
body.appendChild(buildFontRow(original.fontFamily, (value) => commit('fontFamily', value)))
|
||||
|
||||
const footer = el('div', 'footer')
|
||||
footer.setAttribute('data-region', 'footer')
|
||||
footer.setAttribute('style', 'display:flex;justify-content:space-between;align-items:center;margin-top:12px')
|
||||
const cancelBtn = document.createElement('button')
|
||||
const cancelBtn = el('button', 'btn btn-ghost')
|
||||
cancelBtn.type = 'button'
|
||||
cancelBtn.setAttribute('data-action', 'cancel')
|
||||
cancelBtn.textContent = '取消'
|
||||
cancelBtn.setAttribute('style', 'border:none;background:#f1f1f1;border-radius:18px;padding:7px 16px;cursor:pointer;font:inherit')
|
||||
const confirmBtn = document.createElement('button')
|
||||
const confirmBtn = el('button', 'btn btn-primary')
|
||||
confirmBtn.type = 'button'
|
||||
confirmBtn.setAttribute('data-action', 'confirm')
|
||||
confirmBtn.textContent = '✓'
|
||||
confirmBtn.setAttribute('style', 'border:none;background:#2f7bff;color:#fff;border-radius:50%;width:34px;height:34px;cursor:pointer;font-size:16px')
|
||||
confirmBtn.textContent = '发送'
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
applyEdit(el, original)
|
||||
deps.onCancel()
|
||||
})
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
deps.onConfirm({ ...computeChange(original, current), description: description || undefined })
|
||||
const cancel = () => { applyEdit(target, original); deps.onCancel() }
|
||||
const confirm = () => deps.onConfirm({ ...computeChange(original, current), description: description || undefined })
|
||||
cancelBtn.addEventListener('click', cancel)
|
||||
confirmBtn.addEventListener('click', confirm)
|
||||
|
||||
// 键盘快捷键就地消化,避免漏给页面自己的监听器造成误触
|
||||
wrap.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); cancel(); return }
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); event.stopPropagation(); confirm() }
|
||||
})
|
||||
|
||||
footer.appendChild(cancelBtn)
|
||||
footer.appendChild(confirmBtn)
|
||||
wrap.appendChild(head)
|
||||
wrap.appendChild(describe)
|
||||
wrap.appendChild(body)
|
||||
wrap.appendChild(footer)
|
||||
shadow.appendChild(wrap)
|
||||
document.documentElement.appendChild(host)
|
||||
|
||||
autoGrow(describe, TEXT_AREA_MAX_HEIGHT)
|
||||
const textArea = shadow.querySelector<HTMLTextAreaElement>('.text-area')
|
||||
if (textArea) autoGrow(textArea, TEXT_AREA_MAX_HEIGHT)
|
||||
|
||||
const measuredHeight = wrap.getBoundingClientRect().height || wrap.scrollHeight || BUBBLE_ESTIMATED_HEIGHT
|
||||
const layout = computeBubbleLayout(rect, measuredHeight)
|
||||
host.style.top = `${layout.top}px`
|
||||
host.style.left = `${layout.left}px`
|
||||
host.style.visibility = 'visible'
|
||||
wrap.style.maxHeight = `${layout.maxHeight}px`
|
||||
host.style.setProperty('top', `${layout.top}px`)
|
||||
host.style.setProperty('left', `${layout.left}px`)
|
||||
host.style.setProperty('visibility', 'visible')
|
||||
wrap.style.setProperty('max-height', `${layout.maxHeight}px`)
|
||||
refreshScrollHint()
|
||||
describe.focus({ preventScroll: true }) // 页面不该因为浮层拿焦点而跳位
|
||||
|
||||
return { host, destroy: () => { host.remove() } }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
// 宿主两侧(主进程 pickerArmed、渲染进程 pickerActive)都把 picker-exited 当作
|
||||
// 「解除本次拾取授权」,并在其后丢弃 selection。这里跑的是真实注入脚本,用来锁住
|
||||
// 「确认时只发 selection、取消时才发 picker-exited」这条跨进程契约。
|
||||
type PostedMessage = { v: number; type: string; payload?: { element?: { tag?: string }; change?: Record<string, unknown> } }
|
||||
|
||||
const posted: PostedMessage[] = []
|
||||
|
||||
type AgentWindow = typeof window & {
|
||||
__DESKTOP_PREVIEW_POST__?: (raw: string) => void
|
||||
__PREVIEW_BRIDGE__?: { handleHostRaw: (raw: string) => void }
|
||||
}
|
||||
|
||||
const agentWindow = window as AgentWindow
|
||||
|
||||
function sendFromHost(type: 'enter-picker' | 'exit-picker'): void {
|
||||
agentWindow.__PREVIEW_BRIDGE__!.handleHostRaw(JSON.stringify({ v: 1, type }))
|
||||
}
|
||||
|
||||
function bubbleButton(action: 'confirm' | 'cancel'): HTMLButtonElement {
|
||||
for (const host of document.documentElement.querySelectorAll('div')) {
|
||||
const button = host.shadowRoot?.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)
|
||||
if (button) return button
|
||||
}
|
||||
throw new Error(`edit bubble button not found: ${action}`)
|
||||
}
|
||||
|
||||
function pick(el: Element): void {
|
||||
el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
}
|
||||
|
||||
const types = () => posted.map((m) => m.type)
|
||||
|
||||
beforeAll(async () => {
|
||||
// IIFE 在 import 时立即执行,post 钩子必须先于它挂上(静态 import 会被提升,故用动态 import)
|
||||
agentWindow.__DESKTOP_PREVIEW_POST__ = (raw: string) => { posted.push(JSON.parse(raw) as PostedMessage) }
|
||||
await import('./index')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<h1 id="t" style="color:rgb(0,0,0)">Old</h1>'
|
||||
for (const host of [...document.documentElement.querySelectorAll('div')]) {
|
||||
if (host.shadowRoot || host.dataset.previewSelectionAnnotationRoot) host.remove()
|
||||
}
|
||||
sendFromHost('exit-picker') // 复位上一个用例可能残留的 picker 态
|
||||
posted.length = 0
|
||||
})
|
||||
|
||||
describe('preview agent picker flow', () => {
|
||||
it('confirm 发出 selection,且此前不发 picker-exited(否则宿主会丢弃选区)', () => {
|
||||
sendFromHost('enter-picker')
|
||||
pick(document.getElementById('t')!)
|
||||
|
||||
bubbleButton('confirm').click()
|
||||
|
||||
expect(types()).toContain('selection')
|
||||
const beforeSelection = types().slice(0, types().indexOf('selection'))
|
||||
expect(beforeSelection).not.toContain('picker-exited')
|
||||
})
|
||||
|
||||
it('confirm 带上编辑内容一起送出选区', () => {
|
||||
sendFromHost('enter-picker')
|
||||
pick(document.getElementById('t')!)
|
||||
|
||||
const text = bubbleButton('confirm').getRootNode() as ShadowRoot
|
||||
const textInput = text.querySelector<HTMLInputElement>('[data-field="text"]')!
|
||||
textInput.value = 'New'
|
||||
textInput.dispatchEvent(new Event('input'))
|
||||
bubbleButton('confirm').click()
|
||||
|
||||
const selection = posted.find((m) => m.type === 'selection')!
|
||||
expect(selection.payload?.element?.tag).toBe('h1')
|
||||
expect(selection.payload?.change).toMatchObject({ text: { from: 'Old', to: 'New' } })
|
||||
})
|
||||
|
||||
it('cancel 发 picker-exited 解除宿主授权,且不发 selection', () => {
|
||||
sendFromHost('enter-picker')
|
||||
pick(document.getElementById('t')!)
|
||||
|
||||
bubbleButton('cancel').click()
|
||||
|
||||
expect(types()).toContain('picker-exited')
|
||||
expect(types()).not.toContain('selection')
|
||||
})
|
||||
|
||||
it('确认后 picker 停止工作,页面无法再自行送出第二个选区', () => {
|
||||
sendFromHost('enter-picker')
|
||||
pick(document.getElementById('t')!)
|
||||
bubbleButton('confirm').click()
|
||||
posted.length = 0
|
||||
|
||||
pick(document.getElementById('t')!)
|
||||
|
||||
expect(types()).not.toContain('selection')
|
||||
})
|
||||
})
|
||||
@@ -44,11 +44,18 @@ import { createEditBubble } from './editBubble'
|
||||
let activeBubble: { destroy: () => void } | null = null
|
||||
const picker = createPicker({ onSelect: () => {} })
|
||||
|
||||
const teardown = () => {
|
||||
// 只做页面侧清理。宿主的 picker 授权由 selection / picker-exited 之一消费,
|
||||
// 所以产出 selection 的路径必须走这个函数,绝不能再补发 picker-exited。
|
||||
const closePicker = () => {
|
||||
activeBubble?.destroy()
|
||||
activeBubble = null
|
||||
pickerOn = false
|
||||
picker.exit()
|
||||
}
|
||||
|
||||
// 本次拾取结束但没有产出 selection:通知宿主解除授权、复位按钮态。
|
||||
const teardown = () => {
|
||||
closePicker()
|
||||
bridge.send({ type: 'picker-exited' })
|
||||
}
|
||||
|
||||
@@ -88,7 +95,9 @@ import { createEditBubble } from './editBubble'
|
||||
pickerOn = false // stop hovering; keep highlight on the selected element while the bubble is open
|
||||
if (!(el instanceof HTMLElement)) { teardown(); return }
|
||||
activeBubble = createEditBubble(el, {
|
||||
onConfirm: (change) => { teardown(); void emitSelection(el, change) },
|
||||
// selection 自带「本次拾取结束」的语义,宿主收到后会自行复位 picker 态。
|
||||
// 若这里先发 picker-exited,宿主会把授权解除在前、selection 到达在后而丢弃它。
|
||||
onConfirm: (change) => { closePicker(); void emitSelection(el, change) },
|
||||
onCancel: () => { teardown() },
|
||||
})
|
||||
}, true)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { applyEdit } from './popover'
|
||||
import { applyEdit, canEditText, readElementText, writeElementText } from './popover'
|
||||
|
||||
beforeEach(() => { document.body.innerHTML = `<h1 id="t" style="color:rgb(0,0,0)">Old</h1>` })
|
||||
|
||||
@@ -12,4 +12,43 @@ describe('applyEdit', () => {
|
||||
expect(diff.text).toEqual({ from: 'Old', to: 'New' })
|
||||
expect(diff.color?.to).toBe('rgb(255,0,0)')
|
||||
})
|
||||
|
||||
it('把不透明度 0 当作有效值写进去(而不是被 falsy 判断吃掉)', () => {
|
||||
const el = document.getElementById('t')!
|
||||
const diff = applyEdit(el, { opacity: '0' })
|
||||
expect(el.style.opacity).toBe('0')
|
||||
expect(diff.opacity?.to).toBe('0')
|
||||
})
|
||||
|
||||
it('改表单控件的文本写 value 而不是 textContent', () => {
|
||||
document.body.innerHTML = '<input id="f" value="旧值">'
|
||||
const field = document.getElementById('f') as HTMLInputElement
|
||||
const diff = applyEdit(field, { text: '新值' })
|
||||
expect(field.value).toBe('新值')
|
||||
expect(diff.text).toEqual({ from: '旧值', to: '新值' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readElementText / writeElementText', () => {
|
||||
it('表单控件读写 value,其余元素读写 textContent', () => {
|
||||
document.body.innerHTML = '<input id="f" value="v"><p id="p">t</p>'
|
||||
const field = document.getElementById('f') as HTMLInputElement
|
||||
const paragraph = document.getElementById('p')!
|
||||
expect(readElementText(field)).toBe('v')
|
||||
expect(readElementText(paragraph)).toBe('t')
|
||||
|
||||
writeElementText(field, 'v2')
|
||||
writeElementText(paragraph, 't2')
|
||||
expect(field.value).toBe('v2')
|
||||
expect(paragraph.textContent).toBe('t2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('canEditText', () => {
|
||||
it('对表单控件和无子元素的节点开放,对容器关闭(避免夷平子树)', () => {
|
||||
document.body.innerHTML = '<input id="f"><p id="p">t</p><div id="d"><span>a</span></div>'
|
||||
expect(canEditText(document.getElementById('f')!)).toBe(true)
|
||||
expect(canEditText(document.getElementById('p')!)).toBe(true)
|
||||
expect(canEditText(document.getElementById('d')!)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,13 +4,36 @@ export type EditDiff = {
|
||||
background?: { from: string; to: string }; opacity?: { from: string; to: string }; fontFamily?: { from: string; to: string }
|
||||
}
|
||||
|
||||
function isTextFormControl(el: HTMLElement): el is HTMLInputElement | HTMLTextAreaElement {
|
||||
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
|
||||
}
|
||||
|
||||
/** 表单控件的文本在 value 上,textContent 恒为空 —— 直接读 textContent 会让输入框的文本字段永远空白。 */
|
||||
export function readElementText(el: HTMLElement): string {
|
||||
return isTextFormControl(el) ? el.value : el.textContent ?? ''
|
||||
}
|
||||
|
||||
export function writeElementText(el: HTMLElement, text: string): void {
|
||||
if (isTextFormControl(el)) el.value = text
|
||||
else el.textContent = text
|
||||
}
|
||||
|
||||
/** 改文本会把子树夷平成一段纯文本,所以只对表单控件和无元素子节点的元素开放。 */
|
||||
export function canEditText(el: HTMLElement): boolean {
|
||||
return isTextFormControl(el) || el.children.length === 0
|
||||
}
|
||||
|
||||
export function applyEdit(el: HTMLElement, input: EditInput): EditDiff {
|
||||
const cs = window.getComputedStyle(el)
|
||||
const diff: EditDiff = {}
|
||||
if (input.text != null && input.text !== el.textContent) { diff.text = { from: el.textContent ?? '', to: input.text }; el.textContent = input.text }
|
||||
if (input.text != null) {
|
||||
const from = readElementText(el)
|
||||
if (input.text !== from) { diff.text = { from, to: input.text }; writeElementText(el, input.text) }
|
||||
}
|
||||
if (input.color) { diff.color = { from: cs.color, to: input.color }; el.style.color = input.color }
|
||||
if (input.background) { diff.background = { from: cs.backgroundColor, to: input.background }; el.style.background = input.background }
|
||||
if (input.opacity) { diff.opacity = { from: cs.opacity, to: input.opacity }; el.style.opacity = input.opacity }
|
||||
// '0' 是合法且有意义的不透明度,不能被 falsy 判断吃掉
|
||||
if (input.opacity != null && input.opacity !== '') { diff.opacity = { from: cs.opacity, to: input.opacity }; el.style.opacity = input.opacity }
|
||||
if (input.fontFamily) { diff.fontFamily = { from: cs.fontFamily, to: input.fontFamily }; el.style.fontFamily = input.fontFamily }
|
||||
return diff
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ describe('css custom property usage', () => {
|
||||
const unresolved: string[] = []
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
// preview-agent 是注入到第三方页面的独立脚本:它的样式活在 Shadow DOM 里,
|
||||
// 页面上不存在 globals.css,token 由那段样式自己定义自己消费。自洽性由
|
||||
// preview-agent/editBubble.test.ts 用同等强度的检查守住。
|
||||
if (file.includes('/preview-agent/')) continue
|
||||
|
||||
const lines = readFileSync(file, 'utf8').split('\n')
|
||||
lines.forEach((line, index) => {
|
||||
for (const match of line.matchAll(/var\(\s*(--[a-zA-Z0-9-]+)/g)) {
|
||||
|
||||
Reference in New Issue
Block a user