Attach images pasted into the composer, comments and chat
Some checks failed
DevPlace CI / test (pull_request) Has been cancelled

Pressing Ctrl+V with a screenshot on the clipboard now attaches it
immediately instead of requiring a trip through the file picker.

The clipboard reader lives in dp-upload behind a new opt-in `paste`
boolean attribute: with it set, the component binds one paste listener
on its closest form and routes the clipboard image files through the
same handleFiles path as the picker and the drop target, so validation,
limits, the terms gate and the hidden attachment_uids field are shared.
A paste carrying plain text is never swallowed.

It is opt-in rather than a form-wide default because a form may hold
several upload buttons - projects.html has cover and logo beside the
attachment one - and a default would attach one pasted screenshot to
all of them.

Set on _attachment_form.html, so every form including it inherits the
behaviour (post composer, post edit, gists, projects, issues,
screenshots), plus _comment_form.html, messages.html and the embed-mode
skeleton AppChat builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
blindxfish 2026-08-17 22:23:25 +02:00
parent e0672f896d
commit 076f55f380
12 changed files with 103 additions and 6 deletions

View File

@ -202,6 +202,7 @@ The farm refreshes live over the pub/sub bus (a watered build appears on the own
- **Emoji reactions** - react with **any** emoji on posts, comments, gists, and projects, separate from voting and carrying no ranking weight. A short quick-pick palette covers the common reactions, and a `+` button next to it opens the full searchable emoji picker (every standard emoji, including skin tones), so a reaction is never limited to a preset list. Emoji already used on an item are shown as counted chips beside the palette. - **Emoji reactions** - react with **any** emoji on posts, comments, gists, and projects, separate from voting and carrying no ranking weight. A short quick-pick palette covers the common reactions, and a `+` button next to it opens the full searchable emoji picker (every standard emoji, including skin tones), so a reaction is never limited to a preset list. Emoji already used on an item are shown as counted chips beside the palette.
- **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character. - **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character.
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none. - **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
- **Paste an image to attach it** - pressing Ctrl+V (Cmd+V) with a screenshot or copied image on the clipboard while writing a post, a comment, a direct message, an issue, a gist, or a project attaches it immediately, with no trip through the file picker. The upload, its limits, and the resulting attachment are identical to picking the file by hand.
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`. - **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
- **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page. - **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page.
- **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms. - **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms.

View File

@ -104,6 +104,8 @@ Every file-upload UI is the one custom element `dp-upload` (`static/js/component
- `direct` (the project file browser, `project_files.html`): uploads to a custom `endpoint` with `field-name` plus a settable `extraFields` (e.g. `{path}`) and emits `dp-upload:uploaded` / `dp-upload:done` / `dp-upload:error`. `ProjectFiles.uploadTo(dir)` sets `extraFields` then calls `widget.open()`, and refreshes the tree on `done`. - `direct` (the project file browser, `project_files.html`): uploads to a custom `endpoint` with `field-name` plus a settable `extraFields` (e.g. `{path}`) and emits `dp-upload:uploaded` / `dp-upload:done` / `dp-upload:error`. `ProjectFiles.uploadTo(dir)` sets `extraFields` then calls `widget.open()`, and refreshes the tree on `done`.
- `field` (create-post image, `feed.html`): wraps a real `<input type="file" name="image">` that submits with the form - no AJAX, inline-image flow unchanged. - `field` (create-post image, `feed.html`): wraps a real `<input type="file" name="image">` that submits with the form - no AJAX, inline-image flow unchanged.
**Paste-to-attach is the opt-in `paste` boolean attribute**, not a per-form handler. With it set, `dp-upload` binds ONE `paste` listener on its `closest("form")` and routes the clipboard's image files through the same `handleFiles` path as the picker and the drop target, so validation, limits, the terms gate and the hidden `attachment_uids` field are shared - never re-implement a clipboard reader in a page controller. It is opt-in because a form may hold several upload buttons (`projects.html` has cover + logo beside the attachment one) and a form-wide default would attach one pasted image to all of them; mark exactly the button that owns the form's content attachments. Set on `_attachment_form.html` (so every form including it - post composer, post edit, gists, projects, issues, screenshots - inherits it), `_comment_form.html`, `messages.html`, and the `mode="embed"` skeleton `AppChat` builds. Non-image clipboard payloads fall through untouched, so pasting text still types.
The component validates size/type/count and reports errors via `app.toast`. CSS is `.dp-upload-*` in `components.css`; the old `.attachment-upload-*` upload-widget styles were removed, but the `.attachment-gallery`/`.attachment-lightbox` display styles (for already-saved attachments) remain. The component validates size/type/count and reports errors via `app.toast`. CSS is `.dp-upload-*` in `components.css`; the old `.attachment-upload-*` upload-widget styles were removed, but the `.attachment-gallery`/`.attachment-lightbox` display styles (for already-saved attachments) remain.
## ReportDialog (`ReportDialog.js`, `app.reportDialog`) ## ReportDialog (`ReportDialog.js`, `app.reportDialog`)

View File

@ -199,6 +199,7 @@ export class AppChat extends Component {
this.upload = document.createElement("dp-upload"); this.upload = document.createElement("dp-upload");
this.upload.setAttribute("multiple", ""); this.upload.setAttribute("multiple", "");
this.upload.setAttribute("paste", "");
this.upload.setAttribute("max-files", String(this.maxAttachments)); this.upload.setAttribute("max-files", String(this.maxAttachments));
this.sendBtn = document.createElement("button"); this.sendBtn = document.createElement("button");

View File

@ -91,6 +91,9 @@ export class AppUpload extends Component {
this.pendingSubmitForm = null; this.pendingSubmitForm = null;
const form = this.closest("form"); const form = this.closest("form");
if (form) { if (form) {
if (this.boolAttr("paste")) {
form.addEventListener("paste", (event) => this.handlePaste(event));
}
form.addEventListener("submit", (event) => { form.addEventListener("submit", (event) => {
if (this.busyCount > 0) { if (this.busyCount > 0) {
event.preventDefault(); event.preventDefault();
@ -108,6 +111,19 @@ export class AppUpload extends Component {
this.input.click(); this.input.click();
} }
handlePaste(event) {
const data = event.clipboardData;
const files = Array.from(data ? data.files : [])
.filter((file) => file.type.startsWith("image/"));
if (!files.length) {
return;
}
if (!data.getData("text/plain")) {
event.preventDefault();
}
this.handleFiles(files);
}
clear() { clear() {
this.items = []; this.items = [];
if (this.mode === "field") { if (this.mode === "field") {

View File

@ -1,4 +1,4 @@
<dp-upload multiple <dp-upload multiple paste
max-size="{{ max_upload_size_mb() }}" max-size="{{ max_upload_size_mb() }}"
max-files="{{ max_attachments_per_resource() }}" max-files="{{ max_attachments_per_resource() }}"
allowed-types="{{ allowed_file_types() }}"></dp-upload> allowed-types="{{ allowed_file_types() }}"></dp-upload>

View File

@ -5,7 +5,7 @@
{% set _user = user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %} {% set _user = user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<textarea name="content" placeholder="Your opinion goes here..." required aria-required="true" aria-label="Comment" maxlength="125000" class="emoji-picker-target" data-mention></textarea> <textarea name="content" placeholder="Your opinion goes here..." required aria-required="true" aria-label="Comment" maxlength="125000" class="emoji-picker-target" data-mention></textarea>
<div class="comment-form-actions"> <div class="comment-form-actions">
<dp-upload multiple <dp-upload multiple paste
max-size="{{ max_upload_size_mb() }}" max-size="{{ max_upload_size_mb() }}"
max-files="{{ max_attachments_per_resource() }}" max-files="{{ max_attachments_per_resource() }}"
allowed-types="{{ allowed_file_types() }}"></dp-upload> allowed-types="{{ allowed_file_types() }}"></dp-upload>

View File

@ -25,6 +25,7 @@ Set with the `mode` attribute:
| `label` | (none) | Optional button text shown beside the icon. | | `label` | (none) | Optional button text shown beside the icon. |
| `multiple` | off | Allow selecting more than one file. | | `multiple` | off | Allow selecting more than one file. |
| `directory` | off | Allow selecting a whole directory (`webkitdirectory`). | | `directory` | off | Allow selecting a whole directory (`webkitdirectory`). |
| `paste` | off | Attach images pasted anywhere in the surrounding `<form>` (clipboard screenshots). |
| `accept` | (none) | Native accept filter, e.g. `image/*`. | | `accept` | (none) | Native accept filter, e.g. `image/*`. |
| `allowed-types` | (none) | Comma list of permitted extensions, e.g. `.jpg,.png`. | | `allowed-types` | (none) | Comma list of permitted extensions, e.g. `.jpg,.png`. |
| `max-size` | `10` | Maximum size per file, in MB. | | `max-size` | `10` | Maximum size per file, in MB. |

View File

@ -70,7 +70,7 @@
<form class="messages-input-area" method="POST" action="/messages/send" data-live-form> <form class="messages-input-area" method="POST" action="/messages/send" data-live-form>
<input type="hidden" name="receiver_uid" value="{{ other_user['uid'] }}"> <input type="hidden" name="receiver_uid" value="{{ other_user['uid'] }}">
<textarea name="content" placeholder="Type a message..." maxlength="2000" autocomplete="off" data-mention aria-label="Type a message" rows="1"></textarea> <textarea name="content" placeholder="Type a message..." maxlength="2000" autocomplete="off" data-mention aria-label="Type a message" rows="1"></textarea>
<dp-upload multiple <dp-upload multiple paste
max-size="{{ max_upload_size_mb() }}" max-size="{{ max_upload_size_mb() }}"
max-files="5" max-files="5"
allowed-types="{{ allowed_file_types() }}"></dp-upload> allowed-types="{{ allowed_file_types() }}"></dp-upload>

View File

@ -319,6 +319,31 @@ def login_user(page, user):
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded") page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
def paste_image(page, selector, name="pasted.png"):
import base64
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
page.eval_on_selector(
selector,
"""(target, [data, filename]) => {
const bytes = Uint8Array.from(atob(data), (ch) => ch.charCodeAt(0));
const transfer = new DataTransfer();
transfer.items.add(new File([bytes], filename, { type: "image/png" }));
target.dispatchEvent(
new ClipboardEvent("paste", {
clipboardData: transfer,
bubbles: true,
cancelable: true,
})
);
}""",
[base64.b64encode(buf.getvalue()).decode(), name],
)
def assert_share_copies(page, expected_fragment): def assert_share_copies(page, expected_fragment):
from playwright.sync_api import expect from playwright.sync_api import expect

View File

@ -1,6 +1,6 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL from tests.conftest import BASE_URL, paste_image
import time import time
import requests import requests
from playwright.sync_api import expect from playwright.sync_api import expect
@ -702,6 +702,24 @@ def test_feed_politics_topic(alice):
assert politics_link.is_visible() assert politics_link.is_visible()
def test_paste_image_attaches_in_post_composer(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.locator("#post-content").wait_for(state="visible", timeout=10000)
page.locator("#create-post-modal dp-upload .dp-upload-btn").first.wait_for(
state="visible", timeout=10000
)
paste_image(page, "#post-content")
page.locator("#create-post-modal dp-upload .dp-upload-count").first.wait_for(
state="visible", timeout=15000
)
expect(
page.locator("#create-post-modal dp-upload input[name='attachment_uids']")
).to_have_value(re.compile(r".+"))
def test_create_post_cancel_modal(alice): def test_create_post_cancel_modal(alice):
page, _ = alice page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")

View File

@ -3,7 +3,7 @@
import re import re
import time import time
from playwright.sync_api import expect from playwright.sync_api import expect
from tests.conftest import BASE_URL from tests.conftest import BASE_URL, paste_image
from devplacepy.database import get_table from devplacepy.database import get_table
import requests import requests
def _seed_news_seo(): def _seed_news_seo():
@ -183,6 +183,24 @@ def test_send_message_appears_in_thread(alice):
page.locator(f".message-bubble:has-text('{msg}')").first.wait_for(state="visible") page.locator(f".message-bubble:has-text('{msg}')").first.wait_for(state="visible")
def test_paste_image_attaches_in_chat(alice):
page, _ = alice
bob = get_table("users").find_one(username="bob_test")
page.goto(
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
)
page.locator(".messages-input-area dp-upload .dp-upload-btn").first.wait_for(
state="visible", timeout=10000
)
paste_image(page, ".messages-input-area textarea[name='content']")
page.locator(".messages-input-area dp-upload .dp-upload-count").first.wait_for(
state="visible", timeout=15000
)
expect(
page.locator(".messages-input-area dp-upload input[name='attachment_uids']")
).to_have_value(re.compile(r".+"))
def test_messages_page_loads(alice): def test_messages_page_loads(alice):
page, _ = alice page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")

View File

@ -2,7 +2,7 @@
import re import re
from playwright.sync_api import expect from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies from tests.conftest import BASE_URL, assert_share_copies, paste_image
def create_post(page, topic="random", content="Test post content", title=None): def create_post(page, topic="random", content="Test post content", title=None):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000) page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
@ -375,6 +375,21 @@ def test_emoji_picker_opens(alice):
assert page.locator("emoji-picker").first.is_visible() assert page.locator("emoji-picker").first.is_visible()
def test_paste_image_attaches_in_comment_form(alice):
page, _ = alice
create_post(page, "random", "Post for pasted comment attachment")
page.locator(".comment-form dp-upload .dp-upload-btn").first.wait_for(
state="visible", timeout=10000
)
paste_image(page, ".comment-form textarea[name='content']")
page.locator(".comment-form dp-upload .dp-upload-count").first.wait_for(
state="visible", timeout=15000
)
expect(
page.locator(".comment-form dp-upload input[name='attachment_uids']")
).to_have_value(re.compile(r".+"))
def test_attachment_upload_ui(alice): def test_attachment_upload_ui(alice):
import io import io
from PIL import Image from PIL import Image