New from Clipboard, and Paste with nothing open makes a document (#368) (#458)

File › New from Clipboard (`file.newFromClipboard`, right after New…)
opens the clipboard image as a document of its own: sized to the image,
holding it as one layer, in the pixel format it was copied in. It has
no default shortcut; one can be assigned in Keyboard Shortcuts. Edit ›
Paste (Ctrl+V) with no document open does the same, so a screenshot is
one keystroke from the start screen. Both read the OS clipboard only
when invoked, stay enabled from the clipboard service like Paste, and
are a quiet no-op when it holds no image.

Agents reach it through MCP and `serve` too: the command only uses the
session clipboard, so it joins the automation allowlist of file
commands. The menu label is in every catalog.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-07 09:00:22 +07:00 committed by GitHub
parent c0a7f8e7e4
commit 3c6310e559
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 153 additions and 11 deletions

View File

@ -154,6 +154,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
let safe_file_command = matches!(
id,
"file.new"
| "file.newFromClipboard"
| "file.close"
| "file.closeAll"
| "file.closeOthers"

View File

@ -514,6 +514,31 @@ async fn bridge_command_batch_forwards_each_steps_wait() {
app.abort();
}
/// #368: agents can open the clipboard as a document of its own.
#[tokio::test(flavor = "multi_thread")]
async fn new_from_clipboard_opens_the_copy_as_a_document() {
let client = connect(PhotocraftMcp::headless()).await;
json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await);
let r = json_of(
&call(
&client,
"command_batch",
json!({"steps": [
{"id": "select.rect", "params": {"x": 5, "y": 5, "width": 12, "height": 7}},
{"id": "edit.copy"},
{"id": "file.newFromClipboard"}
]}),
)
.await,
);
assert_eq!(r["completed"], 3, "{r}");
let doc = json_of(&call(&client, "doc_inspect", json!({})).await);
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(12), Some(7)), "{doc}");
let sess = json_of(&call(&client, "session_list", json!({})).await);
assert_eq!(sess["documents"].as_array().unwrap().len(), 2);
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn command_batch_rejects_too_many_steps() {
let client = connect(PhotocraftMcp::headless()).await;

View File

@ -22,6 +22,11 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
/// Paste and New from Clipboard need only a clipboard: with no document open, they make one.
fn has_clip_only(s: &Session) -> std::result::Result<(), String> {
s.clipboard.as_ref().map(|_| ()).ok_or_else(|| "the clipboard is empty".into())
}
fn has_pixels(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?;
@ -114,7 +119,10 @@ pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Optio
/// on `center` (the view centre from the UI) or the canvas, unless they already overlap the canvas.
fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let Some(d) = s.active() else {
// Nothing open to paste into: the clipboard becomes a document of its own (#368).
return new_from_clipboard(s);
};
let canvas = d.doc.bounds();
let fmt = d.doc.pixel_format();
let (dx, dy) = if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
@ -139,6 +147,26 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
Ok(json!({"layer": id.0, "offset": [dx, dy]}))
}
/// A new document the size of the clipboard image, holding it as its one layer, in the pixel
/// format it was copied in (#368).
fn new_from_clipboard(s: &mut Session) -> Result<Value> {
let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?;
let b = clip.bounds;
let (w, h) = (b.width(), b.height());
if w == 0 || h == 0 {
return Err(EngineError::Other("the clipboard image has no size".into()));
}
let fmt = clip.surface.format();
let target = PixelFormat::new(fmt.mode, fmt.sample, true);
let moved = if b.x0 == 0 && b.y0 == 0 { clip.surface } else { photocraft_algo::resample::translate_surface(&clip.surface, -b.x0, -b.y0) };
let mut doc = Document::new("Untitled", photocraft_geom::Size::new(w, h), fmt.mode, fmt.sample);
let mut l = Layer::raster(doc.next_layer_name("Layer"), target);
*crate::pixels_mut(&mut l)? = if moved.format() == target { moved } else { moved.convert(target) };
doc.layers.push(l);
let i = s.add_document(doc, None);
Ok(json!({"document": i, "width": w, "height": h}))
}
fn layer_via(s: &mut Session, cut: bool) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let id = active_id(s)?;
@ -420,10 +448,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste",
&["Edit"],
Some("Cmd+V"),
r##"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas)}"##,
has_clip,
r##"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas)} (with no document open: a new document from the clipboard)"##,
has_clip_only,
|s, p| paste(s, p, false)
),
spec!(
"file.newFromClipboard",
"New from Clipboard",
&["File"],
None,
"{} (a new document the size of the clipboard image, holding it as one layer)",
has_clip_only,
|s, _| new_from_clipboard(s)
),
spec!("edit.pasteSpecial.pasteInPlace", "Paste in Place", &["Edit", "Paste Special"], Some("Cmd+Shift+V"), "{}", has_clip, |s, p| paste(s, p, true)),
spec!("layer.new.layerViaCopy", "Layer via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)),
spec!("layer.new.layerViaCut", "Layer via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)),
@ -465,6 +502,40 @@ mod tests {
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds()
}
/// #368: the clipboard as a document of its own, from New from Clipboard or a Paste with
/// nothing open, at every depth.
#[test]
fn new_from_clipboard_and_paste_with_no_document() {
for depth in [8, 16, 32] {
let mut s = Session::new();
assert!(!s.is_enabled("file.newFromClipboard") && !s.is_enabled("edit.paste"), "nothing copied yet");
assert!(s.execute("file.newFromClipboard", json!({})).is_err());
s.execute("file.new", json!({"width": 100, "height": 100, "depth": depth})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 15})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
// With a document open: a second document, sized to the copy, its pixels at the origin.
let r = s.execute("file.newFromClipboard", json!({})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(30), Some(15)), "{r}");
assert_eq!(s.documents().len(), 2);
let d = &s.active().unwrap().doc;
assert_eq!((d.size.width, d.size.height, d.layers.len()), (30, 15, 1));
assert_eq!(d.pixel_format().sample, s.documents()[0].doc.pixel_format().sample, "{depth}-bit kept");
assert_eq!(active_bounds(&s), Rect::new(0, 0, 30, 15));
let px = d.layers[0].surface().unwrap().rgba(0, 0);
assert!(px[0] > 0.99 && px[1] < 0.01 && px[3] > 0.99, "{px:?}");
// With nothing open, Paste makes the document too.
while s.active().is_some() {
s.execute("file.close", json!({})).unwrap();
}
assert!(s.is_enabled("edit.paste") && !s.is_enabled("edit.pasteSpecial.pasteInPlace"));
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(s.documents().len(), 1);
assert_eq!(active_bounds(&s), Rect::new(0, 0, 30, 15));
assert!(s.execute("edit.pasteSpecial.pasteInPlace", json!({})).is_ok(), "into the new document");
}
}
#[test]
fn copy_paste_in_place_and_centred() {
let mut s = session();

View File

@ -31,6 +31,7 @@
Help Nápověda
(coming soon) (připravujeme)
New… Nový…
New from Clipboard Nový ze schránky
Open… Otevřít…
Open As… Otevřít jako…
Open Recent Otevřít nedávné

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -23,6 +23,7 @@
Help Ayuda
(coming soon) (próximamente)
New… Nuevo…
New from Clipboard Nuevo desde el portapapeles
Open… Abrir…
Open As… Abrir como…
Open Recent Archivos recientes

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -27,6 +27,7 @@
Help ヘルプ
(coming soon) (近日対応)
New… 新規…
New from Clipboard クリップボードから新規作成
Open… 開く…
Open As… 形式を指定して開く…
Open Recent 最近使ったファイルを開く

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -31,6 +31,7 @@
# File
New… Создать…
New from Clipboard Создать из буфера обмена
New Создать
Open… Открыть…
Open Открыть

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -22,6 +22,7 @@
Help 帮助
(coming soon) (即将推出)
New… 新建…
New from Clipboard 从剪贴板新建
Open… 打开…
Open As… 打开为…
Open Recent 打开最近文件

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -31,6 +31,7 @@
Help 說明
(coming soon) (即將推出)
New… 新增…
New from Clipboard 從剪貼簿新增
Open… 開啟…
Open As… 開啟為…
Open Recent 最近開啟的檔案

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -493,11 +493,11 @@ impl PhotocraftApp {
}
let t0 = gpu_canvas::now_ms();
// The OS clipboard is read only on an explicit paste, never in the background (privacy, CPU).
if matches!(id, "edit.paste" | "edit.pasteSpecial.pasteInPlace") {
if matches!(id, "edit.paste" | "edit.pasteSpecial.pasteInPlace" | "file.newFromClipboard") {
if !clip_read {
self.import_os_clipboard();
}
if self.session.clipboard.is_none() && self.session.active().is_some() && self.services.clipboard_get_image.is_some() {
if self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() {
// Enabled on the strength of the OS clipboard, which held no image: a quiet no-op.
self.ui.status = "Nothing to paste: the clipboard holds no image".into();
self.ui.status_error = false;
@ -1327,6 +1327,40 @@ mod clipboard_tests {
assert!(!crate::menus::is_enabled(&plain, "edit.paste"));
}
/// #368: an image copied in another app opens as a document of its own, from File › New
/// from Clipboard or from Paste with nothing open.
#[test]
fn os_clipboard_image_becomes_a_new_document() {
let (os, reads) = (OsClip::default(), Arc::new(std::sync::atomic::AtomicUsize::new(0)));
let (b, n) = (os.clone(), Arc::clone(&reads));
let get = move || {
n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
b.lock().unwrap().clone()
};
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() });
let ctx = egui::Context::default();
// Listed right after File › New…, enabled without reading the clipboard.
let items = crate::menus::menu_items(&app);
let at = items.iter().position(|i| i.id == "file.new").unwrap();
assert_eq!(items[at + 1].id, "file.newFromClipboard");
assert!(items[at + 1].enabled && crate::menus::is_enabled(&app, "edit.paste"));
assert!(!crate::menus::is_enabled(&app, "edit.pasteSpecial.pasteInPlace"), "Paste in Place needs a document");
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0);
// An empty clipboard is a quiet no-op.
let r = crate::menus::invoke(&mut app, &ctx, "file.newFromClipboard", serde_json::json!({})).unwrap();
assert_eq!(r["pasted"], serde_json::json!(false));
assert!(app.session.documents().is_empty() && !app.ui.status_error);
// Paste with nothing open makes the document.
*os.lock().unwrap() = Some((5, 3, [0u8, 0, 255, 255].repeat(15)));
crate::menus::invoke(&mut app, &ctx, "edit.paste", serde_json::json!({})).unwrap();
let d = &app.session.active().unwrap().doc;
assert_eq!((d.size.width, d.size.height, d.layers.len()), (5, 3, 1));
assert_eq!(app.ui.views.len(), 1, "the new document has a view");
// New from Clipboard with a document open adds another.
crate::menus::invoke(&mut app, &ctx, "file.newFromClipboard", serde_json::json!({})).unwrap();
assert_eq!(app.session.documents().len(), 2);
}
#[test]
fn pasting_an_empty_or_non_image_os_clipboard_is_a_quiet_no_op() {
let (os, reads) = (OsClip::default(), Arc::default());

View File

@ -466,10 +466,10 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool {
"view.proofSetup.custom" => app.session.active().is_some(),
"view.rulers" | "view.show.grid" | "view.show.guides" | "view.snap" | "view.lockGuides" => true,
// An image copied in another app can only be seen by reading the OS clipboard, which happens
// on an explicit paste: with a clipboard service, Paste stays enabled whenever a document is open.
"edit.paste" | "edit.pasteSpecial.pasteInPlace" => {
app.session.is_enabled(id) || (app.services.clipboard_get_image.is_some() && app.session.active().is_some())
}
// on an explicit paste: with a clipboard service these stay enabled. Paste and New from
// Clipboard need no document (with none open, Paste makes one); Paste in Place does.
"edit.paste" | "file.newFromClipboard" => app.session.is_enabled(id) || app.services.clipboard_get_image.is_some(),
"edit.pasteSpecial.pasteInPlace" => app.session.is_enabled(id) || (app.services.clipboard_get_image.is_some() && app.session.active().is_some()),
"select.selectAndMask" => app.session.is_enabled("select.refineEdge"),
"select.transformSelection" => app.ui.transform.is_none() && app.session.is_enabled("select.transformSelection"),
i if (i.starts_with("view.zoom") || i == "view.fitOnScreen" || i == "view.actualPixels") || i == "window.newWindowForDocument" => {
@ -589,6 +589,9 @@ pub fn is_live(id: &str) -> bool {
|| crate::timeline_ui::handles(id)
}
/// Commands outside the catalogue that belong right after a catalogue item: `(id, after)`.
const PLACE_AFTER: &[(&str, &str)] = &[("file.newFromClipboard", "file.new")];
pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
// 1) Photoshop's full menu tree, in Photoshop order; live where we implement the command.
let known = is_live;
@ -631,9 +634,11 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
for e in extra {
let dup = items.iter().any(|i| i.id == e.id || (i.path == e.path && i.label.trim_end_matches('…') == e.label.trim_end_matches('…')));
if !dup {
// Insert after the last item of the same top-level menu, keeping menus contiguous.
// Insert after the item it belongs next to, else after the last item of the same
// top-level menu, keeping menus contiguous.
let top = e.path.first().cloned();
let at = items.iter().rposition(|i| i.path.first() == top.as_ref()).map_or(items.len(), |p| p + 1);
let after = PLACE_AFTER.iter().find(|(id, _)| *id == e.id).and_then(|(_, a)| items.iter().position(|i| i.id == *a));
let at = after.or_else(|| items.iter().rposition(|i| i.path.first() == top.as_ref())).map_or(items.len(), |p| p + 1);
items.insert(at, e);
}
}