feat: paste/drag-drop image component, remove server-side Playwright capture button
Some checks failed
CI / test (pull_request) Has been cancelled

This commit is contained in:
pyr0ball 2026-03-03 14:40:47 -08:00
parent cddb2b6419
commit 558e186c66
3 changed files with 194 additions and 24 deletions

View file

@ -0,0 +1,31 @@
"""
Paste-from-clipboard / drag-and-drop image component.
Uses st.components.v1.declare_component so JS can return image bytes to Python
(st.components.v1.html() is one-way only). No build step required the
frontend is a single index.html file.
"""
from __future__ import annotations
import base64
from pathlib import Path
import streamlit.components.v1 as components
_FRONTEND = Path(__file__).parent / "paste_image_ui"
_paste_image = components.declare_component("paste_image", path=str(_FRONTEND))
def paste_image_component(key: str | None = None) -> bytes | None:
"""
Render the paste/drop zone. Returns PNG/JPEG bytes when an image is
pasted or dropped, or None if nothing has been submitted yet.
"""
result = _paste_image(key=key)
if result:
try:
return base64.b64decode(result)
except Exception:
return None
return None

View file

@ -0,0 +1,142 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Source Sans Pro", sans-serif;
background: transparent;
}
.zone {
width: 100%;
min-height: 72px;
border: 2px dashed var(--border, #ccc);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 6px;
padding: 12px 16px;
cursor: pointer;
outline: none;
transition: border-color 0.15s, background 0.15s;
color: var(--text-muted, #888);
font-size: 13px;
text-align: center;
user-select: none;
}
.zone:focus { border-color: var(--primary, #ff4b4b); background: var(--primary-faint, rgba(255,75,75,0.06)); }
.zone.dragover { border-color: var(--primary, #ff4b4b); background: var(--primary-faint, rgba(255,75,75,0.06)); }
.zone.done { border-style: solid; border-color: #00c853; color: #00c853; }
.icon { font-size: 22px; line-height: 1; }
.hint { font-size: 11px; opacity: 0.7; }
.status { margin-top: 5px; font-size: 11px; text-align: center; color: var(--text-muted, #888); min-height: 16px; }
</style>
</head>
<body>
<div class="zone" id="zone" tabindex="0" role="button"
aria-label="Click to focus, then paste with Ctrl+V, or drag and drop an image">
<span class="icon">📋</span>
<span id="mainMsg"><strong>Click here</strong>, then <strong>Ctrl+V</strong> to paste</span>
<span class="hint" id="hint">or drag &amp; drop an image file</span>
</div>
<div class="status" id="status"></div>
<script>
const zone = document.getElementById('zone');
const status = document.getElementById('status');
const mainMsg = document.getElementById('mainMsg');
const hint = document.getElementById('hint');
// ── Streamlit handshake ─────────────────────────────────────────────────
window.parent.postMessage({ type: "streamlit:componentReady", apiVersion: 1 }, "*");
function setHeight() {
const h = document.body.scrollHeight + 4;
window.parent.postMessage({ type: "streamlit:setFrameHeight", height: h }, "*");
}
setHeight();
// ── Theme ───────────────────────────────────────────────────────────────
window.addEventListener("message", (e) => {
if (e.data && e.data.type === "streamlit:render") {
const t = e.data.args && e.data.args.theme;
if (!t) return;
const r = document.documentElement;
r.style.setProperty("--primary", t.primaryColor || "#ff4b4b");
r.style.setProperty("--primary-faint", (t.primaryColor || "#ff4b4b") + "10");
r.style.setProperty("--text-muted", t.textColor ? t.textColor + "99" : "#888");
r.style.setProperty("--border", t.textColor ? t.textColor + "33" : "#ccc");
document.body.style.background = t.backgroundColor || "transparent";
}
});
// ── Image handling ──────────────────────────────────────────────────────
function markDone() {
zone.classList.add('done');
// Clear children and rebuild with safe DOM methods
while (zone.firstChild) zone.removeChild(zone.firstChild);
const icon = document.createElement('span');
icon.className = 'icon';
icon.textContent = '\u2705';
const msg = document.createElement('span');
msg.textContent = 'Image ready \u2014 remove or replace below';
zone.appendChild(icon);
zone.appendChild(msg);
setHeight();
}
function sendImage(blob) {
const reader = new FileReader();
reader.onload = function(ev) {
const dataUrl = ev.target.result;
const b64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
window.parent.postMessage({ type: "streamlit:setComponentValue", value: b64 }, "*");
markDone();
};
reader.readAsDataURL(blob);
}
function findImageItem(items) {
if (!items) return null;
for (let i = 0; i < items.length; i++) {
if (items[i].type && items[i].type.indexOf('image/') === 0) return items[i];
}
return null;
}
// Ctrl+V paste (works over HTTP — uses paste event, not Clipboard API)
document.addEventListener('paste', function(e) {
const item = findImageItem(e.clipboardData && e.clipboardData.items);
if (item) { sendImage(item.getAsFile()); e.preventDefault(); }
});
// Drag and drop
zone.addEventListener('dragover', function(e) {
e.preventDefault();
zone.classList.add('dragover');
});
zone.addEventListener('dragleave', function() {
zone.classList.remove('dragover');
});
zone.addEventListener('drop', function(e) {
e.preventDefault();
zone.classList.remove('dragover');
const files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length) {
for (let i = 0; i < files.length; i++) {
if (files[i].type.indexOf('image/') === 0) { sendImage(files[i]); return; }
}
}
// Fallback: dataTransfer items (e.g. dragged from browser)
const item = findImageItem(e.dataTransfer && e.dataTransfer.items);
if (item) sendImage(item.getAsFile());
});
// Click to focus so Ctrl+V lands in this iframe
zone.addEventListener('click', function() { zone.focus(); });
</script>
</body>
</html>

View file

@ -35,8 +35,7 @@ def _feedback_dialog(page: str) -> None:
"""Two-step feedback dialog: form → consent/attachments → submit."""
from scripts.feedback_api import (
collect_context, collect_logs, collect_listings,
build_issue_body, create_forgejo_issue,
upload_attachment, screenshot_page,
build_issue_body, create_forgejo_issue, upload_attachment,
)
from scripts.db import DEFAULT_DB
@ -104,29 +103,26 @@ def _feedback_dialog(page: str) -> None:
# ── Screenshot ────────────────────────────────────────────────────────
st.divider()
st.caption("**Screenshot** (optional)")
col_cap, col_up = st.columns(2)
with col_cap:
if st.button("📸 Capture current view"):
with st.spinner("Capturing page…"):
png = screenshot_page()
if png:
st.session_state.fb_screenshot = png
else:
st.warning(
"Playwright not available — install it with "
"`playwright install chromium`, or upload a screenshot instead."
)
from app.components.paste_image import paste_image_component
with col_up:
uploaded = st.file_uploader(
"Upload screenshot",
type=["png", "jpg", "jpeg"],
label_visibility="collapsed",
key="fb_upload",
)
if uploaded:
st.session_state.fb_screenshot = uploaded.read()
# Keyed so we can reset the component when the user removes the image
if "fb_paste_key" not in st.session_state:
st.session_state.fb_paste_key = 0
pasted = paste_image_component(key=f"fb_paste_{st.session_state.fb_paste_key}")
if pasted:
st.session_state.fb_screenshot = pasted
st.caption("or upload a file:")
uploaded = st.file_uploader(
"Upload screenshot",
type=["png", "jpg", "jpeg"],
label_visibility="collapsed",
key="fb_upload",
)
if uploaded:
st.session_state.fb_screenshot = uploaded.read()
if st.session_state.get("fb_screenshot"):
st.image(
@ -136,6 +132,7 @@ def _feedback_dialog(page: str) -> None:
)
if st.button("🗑 Remove screenshot"):
st.session_state.pop("fb_screenshot", None)
st.session_state.fb_paste_key = st.session_state.get("fb_paste_key", 0) + 1
# no st.rerun() — button click already re-renders the dialog
# ── Attribution consent ───────────────────────────────────────────────
@ -217,7 +214,7 @@ def _submit(page, include_diag, submitter, collect_context, collect_logs,
def _clear_feedback_state() -> None:
for key in [
"fb_step", "fb_type", "fb_title", "fb_desc", "fb_repro",
"fb_diag", "fb_upload", "fb_attr", "fb_screenshot",
"fb_diag", "fb_upload", "fb_attr", "fb_screenshot", "fb_paste_key",
]:
st.session_state.pop(key, None)