feat: paste/drag-drop image component, remove server-side Playwright capture button
This commit is contained in:
parent
1cd4b361de
commit
0409d5d0f7
3 changed files with 194 additions and 24 deletions
31
app/components/paste_image.py
Normal file
31
app/components/paste_image.py
Normal 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
|
||||
142
app/components/paste_image_ui/index.html
Normal file
142
app/components/paste_image_ui/index.html
Normal 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 & 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>
|
||||
|
|
@ -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,21 +103,18 @@ 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:
|
||||
# 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"],
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue