feat: paste overrides through a reviewable dialog, any origin (REQ-190)

navigator.clipboard.readText() only exists in a secure context, so on plain http
over a LAN address it is absent, not merely refused — the previous one-click
paste could never work there. A keyboard paste into a focused textarea is an
ordinary user gesture and is not gated, so paste now opens a dialog: the YAML
goes in a textarea, prefilled only when the browser allows the read.

Nothing is written until Apply, and Apply stays disabled while the text does not
parse, so a typo cannot be clicked through. A live line names the classes the
block matched, the container flags it would flip and the classes it would ignore.
Esc closes and returns focus to the button; Enter is left to the textarea,
because YAML needs newlines.

planPaste() produces that review line and applyPasted() does the writing, keeping
the selected-classes-only rule and the single batched container PATCH.
This commit is contained in:
asus committed 2026-10-05 14:41:22 +07:00
1 parent 4575c46f4c
commit 3eeb529e3d
6 files changed
+229 -84

No files matched your search

+68 -46
View File
@@ -1,7 +1,8 @@
import React, { useState } from 'react'
import React, { useRef, useState } from 'react'
import { api } from '../api'
import { copyText, readClipboardText } from '../clipboard'
import { copyText } from '../clipboard'
import { ClipboardPasteIcon, CopyIcon } from './Icons'
import PasteYamlDialog from './PasteYamlDialog'
// Per-class overrides of the job's globals (REQ-181). Empty input = inherit.
const KEYS = [
@@ -79,6 +80,28 @@ export function buildClassParams(classParams) {
return Object.keys(out).length > 0 ? out : undefined
}
// What the pasted text would do, with nothing written yet — the dialog runs this
// on every keystroke. Selected classes only: an override for a deselected class
// would ride along in class_params unseen (buildClassParams walks all of them).
export function planPaste(text, classNames, containers) {
let parsed
try {
parsed = parseClassYaml(text)
} catch (err) {
return { error: err.message }
}
const names = Object.keys(parsed)
if (!names.length) return { error: 'No classes in the pasted YAML' }
const known = names.filter((name) => classNames.includes(name))
if (!known.length) return { error: `None of these classes are selected here: ${names.join(', ')}` }
const unknown = names.filter((name) => !classNames.includes(name))
const flags = known.filter((name) => parsed[name].container !== undefined && parsed[name].container !== containers.has(name))
const parts = [`${known.length} ${known.length === 1 ? 'class' : 'classes'}: ${known.join(', ')}`]
if (flags.length) parts.push(`container: ${flags.map((name) => `${name} → ${parsed[name].container}`).join(', ')}`)
if (unknown.length) parts.push(`ignored: ${unknown.join(', ')}`)
return { summary: parts.join(' · ') }
}
export default function ClassParamsTable({ classNames, globals, value, onChange, project }) {
// Container flag (REQ-184): saved immediately on toggle, like the prompt
// saves in the modals; there is no error slot in this component, so a
@@ -87,7 +110,8 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
new Set((project?.classes || []).filter(c => c.container).map(c => c.name))
)
const [note, setNote] = useState({ text: '', failed: false })
const [busy, setBusy] = useState(false)
const [pasting, setPasting] = useState(false)
const triggerRef = useRef(null)
const say = (text, failed = false) => {
setNote({ text, failed })
if (!failed) setTimeout(() => setNote((n) => (n.text === text ? { text: '', failed: false } : n)), 2000)
@@ -153,47 +177,38 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
say('Could not copy — your browser refused the clipboard', true)
}
}
const paste = async () => {
setBusy(true)
try {
const parsed = parseClassYaml(await readClipboardText())
const names = Object.keys(parsed)
if (!names.length) throw new Error('No classes in the pasted YAML')
const known = names.filter((name) => classNames.includes(name))
if (!known.length) throw new Error(`None of these classes are selected here: ${names.join(', ')}`)
const unknown = names.filter((name) => !classNames.includes(name))
// Only selected classes: an override for a deselected class would ride
// along in class_params unseen (buildClassParams walks all of them).
const next = { ...value }
const flags = new Set(containers)
const changes = []
for (const name of known) {
const merged = { ...(next[name] || {}) }
for (const { key } of KEYS) {
if (parsed[name][key] !== undefined) merged[key] = String(parsed[name][key])
}
next[name] = merged
const on = parsed[name].container
if (on !== undefined && on !== flags.has(name)) {
if (on) flags.add(name)
else flags.delete(name)
changes.push([name, on])
}
// Only reachable once planPaste() said the text parses, so no re-validation here.
const applyPasted = async (text) => {
const parsed = parseClassYaml(text)
const names = Object.keys(parsed)
const known = names.filter((name) => classNames.includes(name))
const unknown = names.filter((name) => !classNames.includes(name))
const next = { ...value }
const flags = new Set(containers)
const changes = []
for (const name of known) {
const merged = { ...(next[name] || {}) }
for (const { key } of KEYS) {
if (parsed[name][key] !== undefined) merged[key] = String(parsed[name][key])
}
next[name] = merged
const on = parsed[name].container
if (on !== undefined && on !== flags.has(name)) {
if (on) flags.add(name)
else flags.delete(name)
changes.push([name, on])
}
onChange(next)
const saved = await applyContainers(flags, changes)
const suffix = unknown.length ? ` — ignored ${unknown.join(', ')}` : ''
say(
saved
? `Pasted ${known.length} ${known.length === 1 ? 'class' : 'classes'}${suffix}`
: `Pasted ${known.length} ${known.length === 1 ? 'class' : 'classes'}${suffix} — container flags not saved`,
!saved
)
} catch (err) {
say(err.message, true)
} finally {
setBusy(false)
}
onChange(next)
setPasting(false)
const saved = await applyContainers(flags, changes)
const suffix = unknown.length ? ` — ignored ${unknown.join(', ')}` : ''
const what = `${known.length} ${known.length === 1 ? 'class' : 'classes'}${suffix}`
say(saved ? `Pasted ${what}` : `Pasted ${what} — container flags not saved`, !saved)
}
const closeDialog = () => {
setPasting(false)
triggerRef.current?.focus()
}
return (
<>
@@ -202,7 +217,6 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
type="button"
className="btn"
onClick={copy}
disabled={busy}
title="Copy these per-class overrides as YAML"
>
<CopyIcon /> Copy YAML
@@ -210,9 +224,9 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
<button
type="button"
className="btn"
onClick={paste}
disabled={busy}
title="Fill these overrides from YAML on the clipboard"
ref={triggerRef}
onClick={() => setPasting(true)}
title="Fill these overrides from YAML — paste the text into a box"
>
<ClipboardPasteIcon /> Paste YAML
</button>
@@ -220,6 +234,14 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
{note.text}
</span>
</div>
{pasting && (
<PasteYamlDialog
describe={(text) => planPaste(text, classNames, containers)}
placeholder={`# auto-annotate per-class overrides\n${classNames[0] || 'class'}:\n conf: 0.45\n container: false`}
onCancel={closeDialog}
onApply={applyPasted}
/>
)}
{/* Stacked rows (REQ-181): the name line carries the container flag, the
grid below it the four overrides, so a wide class list never squeezes
the numbers or strands the checkbox away from its class. */}
@@ -0,0 +1,84 @@
import React, { useEffect, useRef, useState } from 'react'
import { readClipboardText } from '../clipboard'
// Clipboard *read* only exists on a secure origin, but Ctrl+V into a textarea is
// a plain user gesture, so prefill is a nicety and the box is the real path.
export default function PasteYamlDialog({ describe, placeholder, onCancel, onApply }) {
const [text, setText] = useState('')
const [hint, setHint] = useState('')
const boxRef = useRef(null)
useEffect(() => {
boxRef.current?.focus()
readClipboardText().then(setText, () =>
setHint('Press Ctrl+V — the browser only lets this page read the clipboard on localhost or https')
)
}, [])
// Escape closes; Enter is left to the textarea because YAML needs newlines.
useEffect(() => {
function onKey(event) {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
onCancel()
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [onCancel])
// describe(text) -> { error } | { summary }. Recomputed per keystroke: a few
// hundred bytes through one pure function is not worth debouncing.
const review = describe(text)
const failed = Boolean(review.error)
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 10000, padding: 16 }}
onClick={onCancel}
>
<div
className="panel"
role="dialog"
aria-modal="true"
aria-label="Paste per-class overrides"
onClick={(event) => event.stopPropagation()}
style={{ width: 560, maxWidth: '96vw', background: '#18181b', padding: 18,
border: '1px solid rgba(168, 85, 247, 0.4)', borderRadius: 8 }}
>
<h3 style={{ margin: '0 0 4px', color: '#e4e4e7', fontSize: '0.95rem' }}>
Paste per-class overrides
</h3>
<p className="hint" style={{ margin: '0 0 10px', fontSize: '0.76rem' }}>
{hint}
</p>
<textarea
ref={boxRef}
className="mono"
rows={11}
value={text}
spellCheck={false}
placeholder={placeholder}
aria-label="Per-class override YAML"
aria-describedby="yaml-review"
onChange={(event) => setText(event.target.value)}
style={{ resize: 'vertical', fontSize: '0.78rem', lineHeight: 1.45 }}
/>
<p
id="yaml-review"
role="status"
style={{ margin: '8px 0 0', minHeight: '1.2em', fontSize: '0.76rem',
color: failed ? 'var(--danger)' : 'var(--text-muted)' }}
>
{failed ? review.error : review.summary}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 14 }}>
<button type="button" className="btn btn-ghost" onClick={onCancel}>Cancel</button>
<button type="button" className="btn" disabled={failed || !text.trim()} onClick={() => onApply(text)}>
Apply
</button>
</div>
</div>
</div>
)
}