Skip to content

Commit

Permalink
Implement copy-screenshot-to-clipboard on Web (#8607)
Browse files Browse the repository at this point in the history
### Related
* Closes #8264
  • Loading branch information
emilk authored Jan 8, 2025
1 parent 0faf4a6 commit 4b8a487
Show file tree
Hide file tree
Showing 14 changed files with 66 additions and 114 deletions.
2 changes: 1 addition & 1 deletion Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6908,7 +6908,6 @@ version = "0.22.0-alpha.1+dev"
dependencies = [
"ahash",
"anyhow",
"arboard",
"arrow",
"bit-vec",
"bitflags 2.6.0",
Expand Down Expand Up @@ -6956,6 +6955,7 @@ dependencies = [
"thiserror 1.0.65",
"uuid",
"wasm-bindgen-futures",
"web-sys",
"wgpu",
]

Expand Down
1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ emath = "0.30.0"
# All of our direct external dependencies should be found here:
ahash = "0.8"
anyhow = { version = "1.0", default-features = false }
arboard = { version = "3.2", default-features = false }
argh = "0.1.12"
array-init = "2.1"
arrow = { version = "53.1", default-features = false }
Expand Down
24 changes: 13 additions & 11 deletions crates/viewer/re_context_menu/src/actions/screenshot_action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,32 @@ use re_viewer_context::{Item, PublishedViewInfo, ScreenshotTarget, ViewId, ViewR
use crate::{ContextMenuAction, ContextMenuContext};

/// View screenshot action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScreenshotAction {
/// Screenshot the view, and copy the results to clipboard.
#[cfg(not(target_arch = "wasm32"))] // TODO(#8264): copy-to-screenshot on web
CopyScreenshot,

/// Screenshot the view, and save the results to disk.
SaveScreenshot,
}

impl ContextMenuAction for ScreenshotAction {
/// Do we have a context menu for this selection?
fn supports_selection(&self, ctx: &ContextMenuContext<'_>) -> bool {
// Allow if there is a single view selected.
ctx.selection.len() == 1
&& ctx
.selection
.iter()
.all(|(item, _)| self.supports_item(ctx, item))
fn supports_multi_selection(&self, _ctx: &ContextMenuContext<'_>) -> bool {
match self {
Self::CopyScreenshot => false,
Self::SaveScreenshot => true,
}
}

/// Do we have a context menu for this item?
fn supports_item(&self, ctx: &ContextMenuContext<'_>, item: &Item) -> bool {
if *self == Self::CopyScreenshot && ctx.viewer_context.is_safari_browser() {
// Safari only allows access to clipboard on user action (e.g. on-click).
// However, the screenshot capture results arrives a frame later.
re_log::debug_once!("Copying screenshots not supported on Safari");
return false;
}

let Item::View(view_id) = item else {
return false;
};
Expand All @@ -39,7 +43,6 @@ impl ContextMenuAction for ScreenshotAction {

fn label(&self, _ctx: &ContextMenuContext<'_>) -> String {
match self {
#[cfg(not(target_arch = "wasm32"))] // TODO(#8264): copy-to-screenshot on web
Self::CopyScreenshot => "Copy screenshot".to_owned(),
Self::SaveScreenshot => "Save screenshot…".to_owned(),
}
Expand All @@ -65,7 +68,6 @@ impl ContextMenuAction for ScreenshotAction {
}

let target = match self {
#[cfg(not(target_arch = "wasm32"))] // TODO(#8264): copy-to-screenshot on web
Self::CopyScreenshot => ScreenshotTarget::CopyToClipboard,
Self::SaveScreenshot => ScreenshotTarget::SaveToDisk,
};
Expand Down
1 change: 0 additions & 1 deletion crates/viewer/re_context_menu/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ fn action_list(
Box::new(RemoveAction),
],
vec![
#[cfg(not(target_arch = "wasm32"))] // TODO(#8264): copy-to-screenshot on web
Box::new(actions::ScreenshotAction::CopyScreenshot),
Box::new(actions::ScreenshotAction::SaveScreenshot),
],
Expand Down
1 change: 0 additions & 1 deletion crates/viewer/re_data_ui/src/blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ pub fn blob_preview_and_save_ui(
);
}

#[cfg(not(target_arch = "wasm32"))]
if let Some(image) = image {
let image_stats = ctx
.cache
Expand Down
12 changes: 5 additions & 7 deletions crates/viewer/re_data_ui/src/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,18 @@ use re_viewer_context::{
};

/// Show a button letting the user copy the image
#[cfg(not(target_arch = "wasm32"))]
pub fn copy_image_button_ui(ui: &mut egui::Ui, image: &ImageInfo, data_range: egui::Rangef) {
if ui
.button("Copy image")
.on_hover_text("Copy image to system clipboard")
.clicked()
{
if let Some(rgba) = image.to_rgba8_image(data_range.into()) {
re_viewer_context::Clipboard::with(|clipboard| {
clipboard.set_image(
[rgba.width() as _, rgba.height() as _],
bytemuck::cast_slice(rgba.as_raw()),
);
});
let egui_image = egui::ColorImage::from_rgba_unmultiplied(
[rgba.width() as _, rgba.height() as _],
bytemuck::cast_slice(rgba.as_raw()),
);
ui.ctx().copy_image(egui_image);
} else {
re_log::error!("Invalid image");
}
Expand Down
1 change: 0 additions & 1 deletion crates/viewer/re_data_ui/src/instance_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,6 @@ fn preview_if_image_ui(
ui.horizontal(|ui| {
image_download_button_ui(ctx, ui, entity_path, &image, data_range);

#[cfg(not(target_arch = "wasm32"))]
crate::image::copy_image_button_ui(ui, &image, data_range);
});

Expand Down
4 changes: 0 additions & 4 deletions crates/viewer/re_ui/src/ui_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1125,10 +1125,6 @@ pub trait UiExt {
fn selectable_toggle<R>(&mut self, content: impl FnOnce(&mut egui::Ui) -> R) -> R {
let ui = self.ui_mut();

// ensure cursor is on an integer value, otherwise we get weird optical alignment of the text
//TODO(ab): fix when https://github.com/emilk/egui/issues/4928 is resolved
ui.add_space(-ui.cursor().min.y.fract());

egui::Frame {
inner_margin: egui::Margin::same(3),
stroke: design_tokens().bottom_bar_stroke,
Expand Down
44 changes: 25 additions & 19 deletions crates/viewer/re_viewer/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1590,7 +1590,7 @@ impl App {
) {
use re_viewer_context::ScreenshotInfo;

if let Some(info) = &user_data
if let Some(info) = user_data
.data
.as_ref()
.and_then(|data| data.downcast_ref::<ScreenshotInfo>())
Expand All @@ -1609,14 +1609,8 @@ impl App {
};

match target {
#[cfg(not(target_arch = "wasm32"))] // TODO(#8264): copy-to-screenshot on web
re_viewer_context::ScreenshotTarget::CopyToClipboard => {
re_viewer_context::Clipboard::with(|clipboard| {
clipboard.set_image(
[rgba.width(), rgba.height()],
bytemuck::cast_slice(rgba.as_raw()),
);
});
self.egui_ctx.copy_image((*rgba).clone());
}

re_viewer_context::ScreenshotTarget::SaveToDisk => {
Expand Down Expand Up @@ -1644,7 +1638,7 @@ impl App {
}
} else {
#[cfg(not(target_arch = "wasm32"))] // no full-app screenshotting on web
self.screenshotter.save(image);
self.screenshotter.save(&self.egui_ctx, image);
}
}
}
Expand Down Expand Up @@ -1929,17 +1923,29 @@ impl eframe::App for App {
// Return the `StoreHub` to the Viewer so we have it on the next frame
self.store_hub = Some(store_hub);

// Check for returned screenshot:
egui_ctx.input(|i| {
for event in &i.raw.events {
if let egui::Event::Screenshot {
image, user_data, ..
} = event
{
self.process_screenshot_result(image, user_data);
}
{
// Check for returned screenshots:
let screenshots: Vec<_> = egui_ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|event| {
if let egui::Event::Screenshot {
image, user_data, ..
} = event
{
Some((image.clone(), user_data.clone()))
} else {
None
}
})
.collect()
});

for (image, user_data) in screenshots {
self.process_screenshot_result(&image, &user_data);
}
});
}
}

#[cfg(target_arch = "wasm32")]
Expand Down
6 changes: 2 additions & 4 deletions crates/viewer/re_viewer/src/screenshotter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ impl Screenshotter {
self.countdown.is_some()
}

pub fn save(&mut self, image: &egui::ColorImage) {
pub fn save(&mut self, egui_ctx: &egui::Context, image: &egui::ColorImage) {
self.countdown = None;
if let Some(path) = self.target_path.take() {
let w = image.width() as _;
Expand All @@ -100,9 +100,7 @@ impl Screenshotter {
}
}
} else {
re_viewer_context::Clipboard::with(|cb| {
cb.set_image(image.size, bytemuck::cast_slice(&image.pixels));
});
egui_ctx.copy_image(image.clone());
}
}
}
Expand Down
4 changes: 1 addition & 3 deletions crates/viewer/re_viewer_context/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,9 @@ wgpu.workspace = true

# Native dependencies:
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
arboard = { workspace = true, default-features = false, features = [
"image-data",
] }
home.workspace = true

# Web dependencies:
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures.workspace = true
web-sys = { workspace = true, features = ["Window"] }
54 changes: 0 additions & 54 deletions crates/viewer/re_viewer_context/src/clipboard.rs

This file was deleted.

7 changes: 0 additions & 7 deletions crates/viewer/re_viewer_context/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,6 @@ pub use self::{

pub use re_ui::UiLayout; // Historical reasons

#[cfg(not(target_arch = "wasm32"))]
mod clipboard;

#[cfg(not(target_arch = "wasm32"))]
pub use clipboard::Clipboard;

pub mod external {
pub use nohash_hasher;
pub use {re_chunk_store, re_entity_db, re_log_types, re_query, re_ui};
Expand Down Expand Up @@ -152,7 +146,6 @@ pub struct ScreenshotInfo {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScreenshotTarget {
/// The screenshot will be copied to the clipboard.
#[cfg(not(target_arch = "wasm32"))] // TODO(#8264): copy-to-screenshot on web
CopyToClipboard,

/// The screenshot will be saved to disk.
Expand Down
19 changes: 19 additions & 0 deletions crates/viewer/re_viewer_context/src/viewer_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,25 @@ impl ViewerContext<'_> {
// The nice thing about this would be that we could always give out references (but updating said cache wouldn't be easy in that case).
re_types::reflection::generic_placeholder_for_datatype(&datatype)
}

/// Are we running inside the Safari browser?
pub fn is_safari_browser(&self) -> bool {
#![allow(clippy::unused_self)]

#[cfg(target_arch = "wasm32")]
fn is_safari_browser_inner() -> Option<bool> {
use web_sys::wasm_bindgen::JsValue;
let window = web_sys::window()?;
Some(window.has_own_property(&JsValue::from("safari")))
}

#[cfg(not(target_arch = "wasm32"))]
fn is_safari_browser_inner() -> Option<bool> {
None
}

is_safari_browser_inner().unwrap_or(false)
}
}

// ----------------------------------------------------------------------------
Expand Down

0 comments on commit 4b8a487

Please sign in to comment.