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
+208
-63
No files matched your search
+6
-3
@@ -368,9 +368,12 @@ selected class's effective settings (per-class override where set, else the glob
|
||||
container = the checkbox state) on the clipboard as **YAML** — one block per class, fields
|
||||
named after the row labels (`conf`, `iou`, `minbox`, `maxbox`, `container`), through
|
||||
`clipboard.js`'s `copyText` (Clipboard API with an `execCommand` fallback for insecure
|
||||
contexts) — read-only, no network, no setting changed. **Paste** reads the clipboard back
|
||||
(`readClipboardText`, no legacy fallback, so a blocked read is reported) and fills the
|
||||
overrides for the classes selected in this modal; `container` diffs go out as one
|
||||
contexts) — read-only, no network, no setting changed. **Paste** opens `PasteYamlDialog` — a textarea plus a live review line, nothing written until
|
||||
Apply, and Apply disabled while the text does not parse. It prefills from the clipboard when
|
||||
the browser allows a read and otherwise waits for Ctrl+V, which is what makes paste work on
|
||||
plain http on a LAN address (`readText` needs a secure origin; a keyboard paste does not).
|
||||
`planPaste()` in `ClassParamsTable.jsx` is what produces that review line; `applyPasted()`
|
||||
writes it. `container` diffs go out as one
|
||||
`PATCH /api/projects/{id} { containers: { classId: bool } }` and revert together on
|
||||
rejection. Parsing is `parseClassYaml` in the same file — a strict subset reader for exactly
|
||||
what Copy emits, no YAML dependency, all-or-nothing with a `line N: …` error. Because
|
||||
|
||||
+16
-11
@@ -160,22 +160,27 @@ changes.
|
||||
review assist filter), surfaces as `MaxBox` in the params table, and is included in the
|
||||
REQ-186 copy line. Size bounds are ordinary per-class tuning: the `container` flag
|
||||
(REQ-184) stays purely the NMS containment carve-out and gains no size semantics.
|
||||
- **REQ-190** — The same table has a **Paste YAML** button that reads the clipboard and fills
|
||||
the per-class overrides from it, so a tuned configuration moves between projects without
|
||||
retyping. It applies only the classes currently **selected in this modal**; a pasted class
|
||||
that is not selected is reported as ignored rather than written, because `buildClassParams`
|
||||
walks every entry and an unseen override would otherwise ride along in `class_params`.
|
||||
Numbers merge per field (a pasted block overwrites the four numbers, leaves other state
|
||||
alone); `container` goes out as one `PATCH /projects/{id} {containers: {classId: bool}}`
|
||||
covering every flag that changed, reverted together with a message if the save is rejected.
|
||||
- **REQ-190** — The same table has a **Paste YAML** button that opens a **dialog** holding the
|
||||
YAML in a textarea, so a tuned configuration moves between projects without retyping. The
|
||||
textarea is the real path and must work **anywhere the app is served** — no https, no
|
||||
localhost, no permission prompt: `navigator.clipboard.readText()` only exists on a secure
|
||||
origin, but a keyboard paste into a focused textarea is an ordinary user gesture and is not
|
||||
gated. The dialog therefore *prefills* from the clipboard when the browser allows it and
|
||||
otherwise just waits for Ctrl+V. Before anything is written, a live line reports what the
|
||||
text would do — the classes it matched, the `container` flags it would flip, the classes it
|
||||
would ignore — and any parse error, with **Apply disabled** while the text does not parse.
|
||||
It applies only the classes currently **selected in this modal**; a pasted class that is not
|
||||
selected is reported as ignored rather than written, because `buildClassParams` walks every
|
||||
entry and an unseen override would otherwise ride along in `class_params`. Numbers merge per
|
||||
field (only the fields the block mentions are overwritten, omitted ones keep their value);
|
||||
`container` goes out as one `PATCH /projects/{id} {containers: {classId: bool}}` covering
|
||||
every flag that changed, reverted together with a message if the save is rejected.
|
||||
Parsing is strict on purpose — a subset reader for exactly what Copy emits (`#` comments,
|
||||
`class:` at the left margin, `key: value` indented under it) rather than a general YAML
|
||||
engine, and **any** malformed line throws with its line number and applies **nothing**, so a
|
||||
half-understood paste cannot quietly set wrong parameters. A value must *look* like a number,
|
||||
not merely start like one (`0.5abc` is rejected rather than read as `0.5`), and values
|
||||
outside 0–1 are rejected the same way. Clipboard **read** has no legacy fallback, so a blocked or denied read (the app
|
||||
served over plain http on a LAN address, or the browser prompt refused) is reported in the
|
||||
status line instead of failing silently. Paste never touches the global sliders.
|
||||
outside 0–1 are rejected the same way. Paste never touches the global sliders.
|
||||
|
||||
## E. Review & correction
|
||||
|
||||
|
||||
+34
-15
@@ -1407,8 +1407,8 @@ read it.
|
||||
|
||||
## Task — Copy/Paste YAML for per-class overrides (REQ-186 amended, REQ-190) `[DONE]`
|
||||
|
||||
> Code, parser and build verified below; the **clipboard round trip in a real browser is still
|
||||
> owed** (the Clipboard API cannot be exercised from the shell).
|
||||
> Code, parser and build verified below; the **paste round trip in a real browser is still
|
||||
> owed** (neither a keyboard paste nor a `Ctrl+V` prefill can be exercised from the shell).
|
||||
|
||||
1. **Icons** `frontend/src/components/Icons.jsx` — `CopyIcon` and `ClipboardPasteIcon` added
|
||||
in Lucide geometry, inheriting `currentColor`; the shared `Icon` wrapper already sets
|
||||
@@ -1435,18 +1435,36 @@ read it.
|
||||
matching the text against `NUMBER` before `parseFloat`. Same harness after the fix:
|
||||
`0.5abc`, `Infinity`, `NaN`, `0.5z`, `" 0.5 "` all throw; `1e-2`, tabs, CRLF, an inline
|
||||
`# comment` and a quoted name containing ` #` all parse. Repeated class blocks merge.
|
||||
4. **Paste** `frontend/src/components/ClassParamsTable.jsx` — reads the clipboard
|
||||
(`readClipboardText` in `frontend/src/clipboard.js`), applies only the classes selected in
|
||||
this modal, and names the ignored ones in the status line. `container` diffs are applied
|
||||
through `applyContainers()`, which folds the per-toggle logic into one `PATCH
|
||||
/projects/{id} {containers: {classId: bool}}` (`backend/projects.py:232` iterates the dict)
|
||||
and reverts all of them together on rejection, the same functional-revert trick the checkbox
|
||||
already used.
|
||||
→ verify: **[DONE]** `backend/projects.py` read: one PATCH covers N class ids; build green.
|
||||
**Owed, user browser**: copy → paste back → same numbers; paste a block for a deselected
|
||||
class → named as ignored and nothing written; `conf: 40` → `line 2: conf must be between 0
|
||||
and 1, got 40`, table untouched; container flag survives a modal reopen; serve over a LAN IP
|
||||
→ status line explains the blocked clipboard read.
|
||||
4. **Paste opens a review dialog** `frontend/src/components/PasteYamlDialog.jsx` (new, 83 lines)
|
||||
— a textarea plus a live review line, nothing written until Apply. Why a dialog and not a
|
||||
direct read: `navigator.clipboard.readText()` only exists in a secure context, so on plain
|
||||
http over a LAN address it is *absent*, not merely refused — while a keyboard paste into a
|
||||
focused textarea is an ordinary user gesture and is not gated at all. The dialog therefore
|
||||
prefills when the browser allows and otherwise says so and waits for Ctrl+V.
|
||||
`planPaste(text, classNames, containers)` (exported from `ClassParamsTable.jsx`) produces
|
||||
the review line — matched classes, `container` flips, ignored classes, or the parse error —
|
||||
and is what disables Apply. `applyPasted()` in the table does the writing: only classes
|
||||
selected in this modal, per-field merge, and `container` diffs through `applyContainers()`,
|
||||
which folds the per-toggle logic into one `PATCH /projects/{id} {containers: {classId: bool}}`
|
||||
(`backend/projects.py:232` iterates the dict) and reverts all of them together on rejection,
|
||||
the same functional-revert trick the checkbox already used.
|
||||
→ verify: **[DONE]** `planPaste` and `parseClassYaml` exercised through
|
||||
`esbuild --bundle --platform=node`, both exported from the real module: a three-block document
|
||||
returns `2 classes: sack, box · container: sack → true · ignored: trailer`; a flag already in
|
||||
the wanted state is not reported as a flip (`1 class: box`); empty, `conf: 40`, `conf: 0.5abc`,
|
||||
`- sack`, and a document naming only an unselected class each return the error that keeps
|
||||
Apply disabled. The merge was checked too: pasting `conf`+`iou` over an existing
|
||||
`box: {threshold: 0.55, iou_threshold: 0.8}` leaves `iou` at `0.8` and sends no phantom
|
||||
zeros — `buildClassParams` strips the empty entries. First harness run showed `sack` written
|
||||
empty, which turned out to be a bug in the harness itself (`{ key }` destructured out of
|
||||
`['threshold']`), not in the component; the re-run confirms the merge. `Esc` closes and
|
||||
returns focus to the trigger; Enter is deliberately not bound, YAML needs newlines; a nested
|
||||
`position: fixed` overlay is safe here because nothing above `ClassParamsTable` has a
|
||||
`transform`/`filter`/`backdrop-filter` to become its containing block (checked).
|
||||
**Owed, user browser**: dialog opens focused, Ctrl+V fills it over plain http on a LAN
|
||||
address, live line names the classes, Apply fills the four inputs and ticks Container, status
|
||||
line reports the ignored class; `conf: 40` shows `line 2: …` with Apply disabled; Container
|
||||
survives a modal reopen; `Esc` closes and focus is back on the button.
|
||||
5. **Button design** `frontend/src/app.css` — `.class-params-tools` right-aligned wrapping row,
|
||||
`.btn` at `0.76rem` with `cursor: pointer` and a `progress` cursor while disabled (the old
|
||||
`btn-ghost` text button had neither), `.class-params-note` status line that turns `--danger`
|
||||
@@ -1455,7 +1473,8 @@ read it.
|
||||
`--text-muted` measures 6.98:1 and `--danger` 4.71:1. Hover/focus-visible come from the
|
||||
shared `.btn` (`theme.css:114`, `:108`).
|
||||
→ verify: **[DONE]** `npm run build` green; contrast ratios computed from the tokens;
|
||||
`ClassParamsTable.jsx` 271 lines, inside the 400-line rule.
|
||||
`ClassParamsTable.jsx` 300 lines and `PasteYamlDialog.jsx` 83, both inside the 400-line
|
||||
rule.
|
||||
|
||||
## Task — Configurable Track Forward + track-to-end (REQ-189) `[DONE]`
|
||||
|
||||
|
||||
+21
-9
@@ -468,10 +468,10 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable).
|
||||
with revert on rejection — preview and batch job read the same stored flag.
|
||||
- **Copy / Paste YAML (REQ-186, REQ-190)** — a small right-aligned toolbar above the overrides
|
||||
block: two real buttons, `Copy YAML` and `Paste YAML`, each an inline SVG icon (Lucide copy /
|
||||
clipboard-paste, no emoji) plus its label, `btn` styling at `0.76rem`, both disabled while a
|
||||
paste is in flight. A `role="status"` line beside them carries the outcome — `Copied`,
|
||||
`Pasted 3 classes`, `Pasted 2 classes — ignored crane`, or the failure in `--danger` — success
|
||||
text clears itself after 2 s, a failure stays until the next action.
|
||||
clipboard-paste, no emoji) plus its label, `btn` styling at `0.76rem`. A `role="status"` line
|
||||
beside them carries the outcome — `Copied`, `Pasted 3 classes`, `Pasted 2 classes — ignored
|
||||
crane`, or the failure in `--danger` — success text clears itself after 2 s, a failure stays
|
||||
until the next action.
|
||||
Copy puts one YAML block per selected class on the clipboard:
|
||||
|
||||
```yaml
|
||||
@@ -487,8 +487,21 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable).
|
||||
Field names are the row labels as written on screen; a name is quoted only when YAML would
|
||||
misread it (`"weird: name":`). An empty input copies the global value, so the block is a
|
||||
complete configuration, not a diff.
|
||||
Paste reads the clipboard and fills the overrides for the classes **selected in this modal**;
|
||||
an unselected class is named in the status line and never written. Numbers merge per field.
|
||||
Paste opens a dialog and never applies blind. A dark overlay card (`rgba(0,0,0,0.85)`, above
|
||||
the auto-annotate modal in z-order, `role="dialog" aria-modal="true"`), the title *Paste
|
||||
per-class overrides*, a monospace `textarea` focused on open, and a live review line under it
|
||||
(`role="status"`, recomputed per keystroke, no debounce): the matched classes, the `container`
|
||||
flags it would flip, the classes it would ignore — or the parse error in `--danger`.
|
||||
**Apply stays disabled while the text does not parse**, so a typo cannot be clicked through;
|
||||
*Cancel* is a ghost button and a backdrop click cancels. Keyboard paste works everywhere: the
|
||||
dialog prefills from the clipboard only when the browser permits a read (localhost or https)
|
||||
and otherwise says so and waits for Ctrl+V — no permission prompt and no https requirement.
|
||||
`Esc` closes and returns focus to the *Paste YAML* button; **Enter is never bound to Apply**,
|
||||
because YAML needs newlines. Focus moves into the textarea on open; no focus trap (nothing in
|
||||
this app has one).
|
||||
Apply fills the overrides for the classes **selected in this modal**; an unselected class is
|
||||
named in the review line and the status line, and never written. Numbers merge per field — the
|
||||
fields the block mentions are overwritten, the ones it omits keep their value.
|
||||
`container` is saved to the server like ticking the box by hand (one PATCH for all changed
|
||||
classes, reverted together on rejection, and the status line says the flags were not saved).
|
||||
Parsing is strict and **atomic**: `#` comments (whole lines, or trailing on a value), `class:`
|
||||
@@ -496,9 +509,7 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable).
|
||||
(`0.5abc` is a typo, not `0.5`), `container` `true|false|yes|no|1|0`; a list item, a nested
|
||||
map, a top-level scalar, an unknown key, a malformed number, an out-of-range value or a
|
||||
key before any class throws `line N: …` and applies **nothing**. A class repeated in the same
|
||||
document merges rather than replacing. Where the browser blocks the clipboard read — the
|
||||
app on plain http over a LAN address, or the permission prompt refused — the status line says
|
||||
so rather than doing nothing.
|
||||
document merges rather than replacing.
|
||||
Both buttons live in the shared per-class table, so they exist in the engine-chooser modal
|
||||
**and** the mass modal.
|
||||
- `ClassPromptPanel`:
|
||||
@@ -1161,6 +1172,7 @@ Handlers are attached in the **capture** phase on `document`, and:
|
||||
| hold `S` | SAM3 assist mode while held | — |
|
||||
| `Enter` | apply the exemplar preview (panel open) | — |
|
||||
| `Esc` | discard the exemplar preview (panel open) | — |
|
||||
| `Esc` | cancel the Paste YAML dialog, focus back on its trigger | — |
|
||||
|
||||
**In select mode the marquee owns `Delete` and the digits.** Otherwise a 40-box selection is
|
||||
thrown away by one keystroke meant for it.
|
||||
|
||||
@@ -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,17 +177,12 @@ 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())
|
||||
// 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)
|
||||
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 = []
|
||||
@@ -181,19 +200,15 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
|
||||
}
|
||||
}
|
||||
onChange(next)
|
||||
setPasting(false)
|
||||
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)
|
||||
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