mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat(desktop): add element edit bubble (描述/文本/颜色/背景/Opacity/字体) with live preview + diff
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
677b6048c1
commit
83a1a6a789
63
desktop/src/preview-agent/editBubble.test.ts
Normal file
63
desktop/src/preview-agent/editBubble.test.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { snapshotEditableStyles, computeChange, createEditBubble, type EditableSnapshot } from './editBubble'
|
||||
|
||||
beforeEach(() => { document.body.innerHTML = `<h1 id="t" style="color:rgb(0,0,0)">Old</h1>` })
|
||||
afterEach(() => { document.documentElement.querySelectorAll('div').forEach((d) => { if (d.shadowRoot) d.remove() }) })
|
||||
|
||||
const $ = (b: { host: HTMLElement }, sel: string) => b.host.shadowRoot!.querySelector(sel) as HTMLInputElement & HTMLButtonElement
|
||||
|
||||
describe('computeChange', () => {
|
||||
it('returns only changed fields with from/to', () => {
|
||||
const orig: EditableSnapshot = { text: 'A', color: 'c', background: 'b', opacity: '1', fontFamily: 'f' }
|
||||
const cur: EditableSnapshot = { ...orig, text: 'B' }
|
||||
expect(computeChange(orig, cur)).toEqual({ text: { from: 'A', to: 'B' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshotEditableStyles', () => {
|
||||
it('captures text + relevant computed styles', () => {
|
||||
const el = document.getElementById('t')!
|
||||
vi.spyOn(window, 'getComputedStyle').mockReturnValue({ color: 'rgb(0,0,0)', backgroundColor: 'rgba(0,0,0,0)', opacity: '1', fontFamily: 'serif' } as unknown as CSSStyleDeclaration)
|
||||
const s = snapshotEditableStyles(el)
|
||||
expect(s.text).toBe('Old')
|
||||
expect(s.color).toBe('rgb(0,0,0)')
|
||||
expect(s.fontFamily).toBe('serif')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEditBubble', () => {
|
||||
it('prefills the text field and live-applies edits to the element', () => {
|
||||
const el = document.getElementById('t')!
|
||||
const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() })
|
||||
const textInput = $(bubble, '[data-field="text"]')
|
||||
expect(textInput.value).toBe('Old')
|
||||
textInput.value = 'New'
|
||||
textInput.dispatchEvent(new Event('input'))
|
||||
expect(el.textContent).toBe('New')
|
||||
bubble.destroy()
|
||||
})
|
||||
|
||||
it('confirm bundles the diff + description', () => {
|
||||
const el = document.getElementById('t')!
|
||||
const onConfirm = vi.fn()
|
||||
const bubble = createEditBubble(el, { onConfirm, onCancel: vi.fn() })
|
||||
const textInput = $(bubble, '[data-field="text"]'); textInput.value = 'New'; textInput.dispatchEvent(new Event('input'))
|
||||
const descInput = $(bubble, '[data-field="description"]'); descInput.value = '改积极点'; descInput.dispatchEvent(new Event('input'))
|
||||
$(bubble, '[data-action="confirm"]').click()
|
||||
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ text: { from: 'Old', to: 'New' }, description: '改积极点' }))
|
||||
bubble.destroy()
|
||||
})
|
||||
|
||||
it('cancel reverts live edits and fires onCancel', () => {
|
||||
const el = document.getElementById('t')!
|
||||
const onCancel = vi.fn(); const onConfirm = vi.fn()
|
||||
const bubble = createEditBubble(el, { onConfirm, onCancel })
|
||||
const textInput = $(bubble, '[data-field="text"]'); textInput.value = 'New'; textInput.dispatchEvent(new Event('input'))
|
||||
expect(el.textContent).toBe('New')
|
||||
$(bubble, '[data-action="cancel"]').click()
|
||||
expect(el.textContent).toBe('Old') // reverted
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
bubble.destroy()
|
||||
})
|
||||
})
|
||||
119
desktop/src/preview-agent/editBubble.ts
Normal file
119
desktop/src/preview-agent/editBubble.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { applyEdit, type EditDiff, type EditInput } from './popover'
|
||||
|
||||
export type EditableSnapshot = { text: string; color: string; background: string; opacity: string; fontFamily: string }
|
||||
export type EditBubbleChange = EditDiff & { description?: string }
|
||||
type Deps = { onConfirm: (change: EditBubbleChange) => void; onCancel: () => void }
|
||||
|
||||
const FIELDS: Array<{ key: keyof EditableSnapshot; label: string }> = [
|
||||
{ key: 'text', label: '文本' },
|
||||
{ key: 'color', label: '文字颜色' },
|
||||
{ key: 'background', label: '背景' },
|
||||
{ key: 'opacity', label: 'Opacity' },
|
||||
{ key: 'fontFamily', label: '字体' },
|
||||
]
|
||||
|
||||
export function snapshotEditableStyles(el: HTMLElement): EditableSnapshot {
|
||||
const cs = window.getComputedStyle(el)
|
||||
return {
|
||||
text: el.textContent ?? '',
|
||||
color: cs.color,
|
||||
background: cs.backgroundColor,
|
||||
opacity: cs.opacity,
|
||||
fontFamily: cs.fontFamily,
|
||||
}
|
||||
}
|
||||
|
||||
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 (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
|
||||
}
|
||||
|
||||
function buildPatch(key: keyof EditableSnapshot, value: string): EditInput {
|
||||
const patch: EditInput = {}
|
||||
if (key === 'text') patch.text = value
|
||||
else if (key === 'color') patch.color = value
|
||||
else if (key === 'background') patch.background = value
|
||||
else if (key === 'opacity') patch.opacity = value
|
||||
else if (key === 'fontFamily') patch.fontFamily = value
|
||||
return patch
|
||||
}
|
||||
|
||||
export function createEditBubble(el: HTMLElement, deps: Deps): { host: HTMLElement; destroy: () => void } {
|
||||
const original = snapshotEditableStyles(el)
|
||||
const current: EditableSnapshot = { ...original }
|
||||
let description = ''
|
||||
|
||||
const host = document.createElement('div')
|
||||
const rect = el.getBoundingClientRect()
|
||||
const top = Math.max(8, Math.min(rect.bottom + 8, window.innerHeight - 380))
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - 356))
|
||||
host.style.cssText = `position:fixed;top:${top}px;left:${left}px;z-index:2147483647`
|
||||
const shadow = host.attachShadow({ mode: 'open' })
|
||||
|
||||
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')
|
||||
|
||||
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 })
|
||||
wrap.appendChild(desc)
|
||||
|
||||
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')
|
||||
wrap.appendChild(tag)
|
||||
|
||||
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))
|
||||
})
|
||||
row.appendChild(lab)
|
||||
row.appendChild(inp)
|
||||
wrap.appendChild(row)
|
||||
}
|
||||
|
||||
const footer = document.createElement('div')
|
||||
footer.setAttribute('style', 'display:flex;justify-content:space-between;align-items:center;margin-top:12px')
|
||||
const cancelBtn = document.createElement('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')
|
||||
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')
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
applyEdit(el, original)
|
||||
deps.onCancel()
|
||||
})
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
deps.onConfirm({ ...computeChange(original, current), description: description || undefined })
|
||||
})
|
||||
|
||||
footer.appendChild(cancelBtn)
|
||||
footer.appendChild(confirmBtn)
|
||||
wrap.appendChild(footer)
|
||||
shadow.appendChild(wrap)
|
||||
document.documentElement.appendChild(host)
|
||||
|
||||
return { host, destroy: () => { host.remove() } }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user