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:
1 parent
4575c46f4c
commit
3eeb529e3d
6 files changed
+229
-84
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user