Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

web clipboard handling #178

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[alias]
run-wasm = ["run", "--release", "--package", "run-wasm", "--"]
Copy link
Contributor Author

@Vrixyz Vrixyz May 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think cargo run-wasm is quite handy to easily test a wasm version ; I can make another PR if interested


# Credits to https://github.com/emilk/egui

# clipboard api is still unstable, so web-sys requires the below flag to be passed for copy (ctrl + c) to work
# https://rustwasm.github.io/docs/wasm-bindgen/web-sys/unstable-apis.html
# check status at https://developer.mozilla.org/en-US/docs/Web/API/Clipboard#browser_compatibility
#[build]
#target = "wasm32-unknown-unknown"

Vrixyz marked this conversation as resolved.
Show resolved Hide resolved
[target.wasm32-unknown-unknown]
rustflags = ["--cfg=web_sys_unstable_apis"]
25 changes: 24 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ default_fonts = ["egui/default_fonts"]
serde = ["egui/serde"]

[dependencies]
bevy = { version = "0.10", default-features = false, features = ["bevy_render", "bevy_asset"] }
bevy = { version = "0.10", default-features = false, features = [
"bevy_render",
"bevy_asset",
] }
egui = { version = "0.21.0", default-features = false, features = ["bytemuck"] }
webbrowser = { version = "0.8.2", optional = true }

Expand All @@ -39,3 +42,23 @@ bevy = { version = "0.10", default-features = false, features = [
"bevy_pbr",
"bevy_core_pipeline",
] }

[target.'cfg(target_arch = "wasm32")'.dependencies]
winit = "0.28"
web-sys = { version = "*", features = [
Vrixyz marked this conversation as resolved.
Show resolved Hide resolved
"Clipboard",
"ClipboardEvent",
"DataTransfer",
'Document',
'EventTarget',
"Window",
"Navigator",
] }
js-sys = "*"
wasm-bindgen = "0.2.84"
wasm-bindgen-futures = "*"
console_log = "*"
log = "0.4"

[workspace]
members = ["run-wasm"]
8 changes: 7 additions & 1 deletion examples/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ fn main() {
.insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0)))
.insert_resource(Msaa::Sample4)
.init_resource::<UiState>()
.add_plugins(DefaultPlugins)
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
prevent_default_event_handling: false,
..default()
}),
..default()
}))
.add_plugin(EguiPlugin)
.add_startup_system(configure_visuals_system)
.add_startup_system(configure_ui_state_system)
Expand Down
9 changes: 9 additions & 0 deletions run-wasm/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
name = "run-wasm"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
cargo-run-wasm = "0.2.0"
3 changes: 3 additions & 0 deletions run-wasm/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fn main() {
cargo_run_wasm::run_wasm_with_css("body { margin: 0px; }");
}
26 changes: 20 additions & 6 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ pub mod systems;
/// Egui render node.
pub mod egui_node;

/// Clipboard management for web
#[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))]
pub mod web_clipboard;

pub use egui;

use crate::{
Expand Down Expand Up @@ -147,7 +151,7 @@ pub struct EguiClipboard {
#[cfg(not(target_arch = "wasm32"))]
clipboard: ThreadLocal<Option<RefCell<Clipboard>>>,
#[cfg(target_arch = "wasm32")]
clipboard: String,
clipboard: web_clipboard::WebClipboardPaste,
}

#[cfg(feature = "manage_clipboard")]
Expand All @@ -159,12 +163,20 @@ impl EguiClipboard {

/// Gets clipboard contents. Returns [`None`] if clipboard provider is unavailable or returns an error.
#[must_use]
#[cfg(not(target_arch = "wasm32"))]
pub fn get_contents(&self) -> Option<String> {
self.get_contents_impl()
}

/// Gets clipboard contents. Returns [`None`] if clipboard provider is unavailable or returns an error.
#[must_use]
#[cfg(target_arch = "wasm32")]
pub fn get_contents(&mut self) -> Option<String> {
self.get_contents_impl()
}

#[cfg(not(target_arch = "wasm32"))]
fn set_contents_impl(&self, contents: &str) {
fn set_contents_impl(&mut self, contents: &str) {
Vrixyz marked this conversation as resolved.
Show resolved Hide resolved
if let Some(mut clipboard) = self.get() {
if let Err(err) = clipboard.set_text(contents.to_owned()) {
log::error!("Failed to set clipboard contents: {:?}", err);
Expand All @@ -173,8 +185,8 @@ impl EguiClipboard {
}

#[cfg(target_arch = "wasm32")]
fn set_contents_impl(&mut self, contents: &str) {
self.clipboard = contents.to_owned();
fn set_contents_impl(&self, contents: &str) {
web_clipboard::clipboard_copy(contents.to_owned());
}

#[cfg(not(target_arch = "wasm32"))]
Expand All @@ -190,8 +202,8 @@ impl EguiClipboard {

#[cfg(target_arch = "wasm32")]
#[allow(clippy::unnecessary_wraps)]
fn get_contents_impl(&self) -> Option<String> {
Some(self.clipboard.clone())
fn get_contents_impl(&mut self) -> Option<String> {
self.clipboard.try_read_clipboard_event()
}
vladbat00 marked this conversation as resolved.
Show resolved Hide resolved

#[cfg(not(target_arch = "wasm32"))]
Expand Down Expand Up @@ -533,6 +545,8 @@ impl Plugin for EguiPlugin {
world.init_resource::<EguiUserTextures>();
world.init_resource::<EguiMousePosition>();

#[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))]
app.add_startup_system(web_clipboard::startup);
app.add_startup_systems(
(
setup_new_windows_system,
Expand Down
39 changes: 25 additions & 14 deletions src/systems.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ impl<'w, 's> InputEvents<'w, 's> {
#[allow(missing_docs)]
#[derive(SystemParam)]
pub struct InputResources<'w, 's> {
#[cfg(feature = "manage_clipboard")]
#[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))]
pub egui_clipboard: ResMut<'w, crate::EguiClipboard>,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see #178 (comment) ; to be noted we can have mut only for wasm if we want.

#[cfg(all(feature = "manage_clipboard", not(target_arch = "wasm32")))]
pub egui_clipboard: Res<'w, crate::EguiClipboard>,
pub keyboard_input: Res<'w, Input<KeyCode>>,
#[system_param(ignore)]
Expand All @@ -72,7 +74,7 @@ pub struct ContextSystemParams<'w, 's> {
/// Processes Bevy input and feeds it to Egui.
pub fn process_input_system(
mut input_events: InputEvents,
input_resources: InputResources,
mut input_resources: InputResources,
mut context_params: ContextSystemParams,
egui_settings: Res<EguiSettings>,
mut egui_mouse_position: ResMut<EguiMousePosition>,
Expand Down Expand Up @@ -250,20 +252,29 @@ pub fn process_input_system(
// We also check that it's an `ButtonState::Pressed` event, as we don't want to
// copy, cut or paste on the key release.
#[cfg(feature = "manage_clipboard")]
if command && pressed {
match key {
egui::Key::C => {
focused_input.events.push(egui::Event::Copy);
}
egui::Key::X => {
focused_input.events.push(egui::Event::Cut);
}
egui::Key::V => {
if let Some(contents) = input_resources.egui_clipboard.get_contents() {
focused_input.events.push(egui::Event::Text(contents))
{
if command && pressed {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ on wasm mac, this is wrong, because it maps to ctrl key, but it should map to command key...

(to be clear, this is not a but introduced in this PR)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, good catch. This makes me wonder how we can detect running on MacOS in wasm.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eframe uses events for all those, deferring the responsibility to the browser: https://github.com/emilk/egui/blob/307565efa55158cfa6b82d2e8fdc4c4914b954ed/crates/eframe/src/web/events.rs#L184

Copy link
Owner

@vladbat00 vladbat00 May 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like we should adopt the same approach. Let's keep the whole block enabled only for non-wasm targets, whereas for wasm we'll just read from channels (which in turn will get messages on web_sys events).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is important to fix, as atm on MacOS, users need to do Ctrl+C to copy and Cmd+V to paste. I'd also be happy if we added the Select All (Ctrl/Cmd+A) support for WASM, to avoid the same inconsistency issue. That's extending the scope of this PR a bit, but hotkey consistency feels important here.

Copy link
Contributor Author

@Vrixyz Vrixyz May 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like it means we would bypass winit/bevy key inputs in wasm :/ could this possibly fixed upstream 🤔 ?

for the record, ctrl-A works (kinda) on wasm; on azerty it's mapped to ctrl-Q though, and on mac to ctrl where we'd want it to be cmd.

In all hindsight, I'd suggest a simpler approach where we detect if we run on mac or not through https://docs.rs/web-sys/latest/web_sys/struct.Navigator.html#method.platform, and adapt our meta key from there in case of cfg!(target_os = "wasm32") to consolidate following code: https://github.com/mvlabat/bevy_egui/blob/c51fc5640e2e431fa2169c98e6d252914c614904/src/systems.rs#L104-L110

Is it ok for you?

match key {
egui::Key::C => {
focused_input.events.push(egui::Event::Copy);
}
egui::Key::X => {
focused_input.events.push(egui::Event::Cut);
}
egui::Key::V => {
#[cfg(not(target_arch = "wasm32"))]
if let Some(contents) =
input_resources.egui_clipboard.get_contents()
{
focused_input.events.push(egui::Event::Text(contents))
}
}
_ => {}
}
_ => {}
}
#[cfg(target_arch = "wasm32")]
if let Some(contents) = input_resources.egui_clipboard.get_contents() {
focused_input.events.push(egui::Event::Text(contents));
}
}
}
Expand Down
106 changes: 106 additions & 0 deletions src/web_clipboard.rs
Vrixyz marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
use std::sync::{
mpsc::{self, Receiver, Sender},
Arc, Mutex,
};

use bevy::prelude::*;
use wasm_bindgen_futures::spawn_local;

use crate::EguiClipboard;

/// startup system for bevy to initialize clipboard.
pub fn startup(mut clipboard_channel: ResMut<EguiClipboard>) {
setup_clipboard_paste(&mut clipboard_channel.clipboard)
}

fn setup_clipboard_paste(clipboard_channel: &mut WebClipboardPaste) {
let (tx, rx): (Sender<String>, Receiver<String>) = mpsc::channel();

use wasm_bindgen::closure::Closure;
use wasm_bindgen::prelude::*;

let closure = Closure::<dyn FnMut(_)>::new(move |event: web_sys::ClipboardEvent| {
// TODO: maybe we should check if current canvas is selected ? not sure it's possible,
// but reacting to event at the document level will lead to problems if multiple games are on the same page.
Vrixyz marked this conversation as resolved.
Show resolved Hide resolved
match event
.clipboard_data()
.expect("could not get clipboard data.")
.get_data("text/plain")
{
Ok(data) => {
tx.send(data);
}
_ => {
info!("Not implemented.");
}
}
info!("{:?}", event.clipboard_data())
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these logs useful ? I suspect they would be noisy for end user.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think all the infos! are maybe a bit too much

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed most and/or changed for errors/warn

});

// TODO: a lot of unwraps ; it's using documents because paste event set on a canvas do not trigger (also tested on firefox in vanilla javascript)
web_sys::window()
.unwrap()
.document()
.unwrap()
.add_event_listener_with_callback("paste", closure.as_ref().unchecked_ref())
.expect("Could not edd paste event listener.");
closure.forget();
*clipboard_channel = WebClipboardPaste {
rx: Some(Arc::new(Mutex::new(rx))),
};

info!("setup_clipboard_paste OK");
}

/// To get data from web paste events
#[derive(Default)]
pub struct WebClipboardPaste {
rx: Option<Arc<Mutex<Receiver<String>>>>,
Vrixyz marked this conversation as resolved.
Show resolved Hide resolved
}

impl WebClipboardPaste {
/// Only returns Some if user explicitly triggered a paste event.
/// We are not querying the clipboard data without user input here (it would require permissions).
pub fn try_read_clipboard_event(&mut self) -> Option<String> {
match &mut self.rx {
Some(rx) => {
let Ok(unlock) = rx.try_lock() else {
info!("fail lock");
return None;
};
if let Ok(clipboard_string) = unlock.try_recv() {
info!("received: {}", clipboard_string);
return Some(clipboard_string);
}
None
}
None => {
info!("no arc");
None
}
}
}
}

/// Puts argument string to the web clipboard
pub fn clipboard_copy(text: String) {
spawn_local(async move {
let window = web_sys::window().expect("window"); // { obj: val };
Vrixyz marked this conversation as resolved.
Show resolved Hide resolved
Vrixyz marked this conversation as resolved.
Show resolved Hide resolved

let nav = window.navigator();

let clipboard = nav.clipboard();
match clipboard {
Some(a) => {
let p = a.write_text(&text);
let _result = wasm_bindgen_futures::JsFuture::from(p)
.await
.expect("clipboard populated");
info!("copy to clipboard worked");
}
None => {
warn!("failed to write clipboard data");
}
};
});
}