ruffle/web/src/ui.rs

367 lines
11 KiB
Rust

use super::JavascriptPlayer;
use rfd::{AsyncFileDialog, FileHandle};
use ruffle_core::backend::ui::{
DialogLoaderError, DialogResultFuture, FileDialogResult, FileFilter,
};
use ruffle_core::backend::ui::{
FontDefinition, FullscreenError, LanguageIdentifier, MouseCursor, UiBackend, US_ENGLISH,
};
use ruffle_web_common::JsResult;
use std::borrow::Cow;
use url::Url;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{
Blob, HtmlCanvasElement, HtmlDocument, HtmlElement, HtmlTextAreaElement, Url as JsUrl,
};
use chrono::{DateTime, Utc};
use js_sys::{Array, Uint8Array};
#[allow(dead_code)]
#[derive(Debug)]
struct FullScreenError {
jsval: String,
}
impl std::fmt::Display for FullScreenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.jsval)
}
}
impl std::error::Error for FullScreenError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
None
}
}
pub struct WebFileDialogResult {
canceled: bool,
file_name: Option<String>,
modification_time: Option<DateTime<Utc>>,
contents: Vec<u8>,
}
impl WebFileDialogResult {
pub async fn new_pick(handle: Option<FileHandle>) -> Self {
let contents = if let Some(handle) = handle.as_ref() {
handle.read().await
} else {
Vec::new()
};
#[cfg(not(target_arch = "wasm32"))]
let file_name = None;
#[cfg(target_arch = "wasm32")]
let file_name = handle.as_ref().map(|handle| handle.file_name());
#[cfg(not(target_arch = "wasm32"))]
let modification_time = None;
#[cfg(target_arch = "wasm32")]
let modification_time = if let Some(ref handle) = handle {
DateTime::from_timestamp(handle.inner().last_modified() as i64, 0)
} else {
None
};
Self {
canceled: handle.is_none(),
file_name,
modification_time,
contents,
}
}
fn new_download(file_name: String) -> Self {
Self {
canceled: false,
file_name: Some(file_name),
modification_time: None,
contents: Vec::new(),
}
}
}
fn get_extension_from_filename(filename: &str) -> Option<String> {
std::path::Path::new(filename)
.extension()
.and_then(|x| x.to_str())
.map(|x| ".".to_owned() + x)
}
fn download_as_file(filename: Option<&str>, data: &[u8]) -> Result<(), JsValue> {
let array = Uint8Array::from(data);
let blob = Blob::new_with_u8_array_sequence(&Array::of1(&array))?;
let window = web_sys::window().ok_or(JsValue::from("no window"))?;
let document = window.document().ok_or(JsValue::from("no document"))?;
let a = document.create_element("a")?;
let url = JsUrl::create_object_url_with_blob(&blob)?;
a.set_attribute("href", &url)?;
a.set_attribute("download", filename.unwrap_or(""))?;
a.dyn_into::<HtmlElement>()
.map_err(|_| JsValue::from("not an HtmlElement"))?
.click();
JsUrl::revoke_object_url(&url)?;
Ok(())
}
impl FileDialogResult for WebFileDialogResult {
fn is_cancelled(&self) -> bool {
self.canceled
}
fn creation_time(&self) -> Option<DateTime<Utc>> {
// Creation time is not available in JS
None
}
fn modification_time(&self) -> Option<DateTime<Utc>> {
self.modification_time
}
fn file_name(&self) -> Option<String> {
self.file_name.clone()
}
fn size(&self) -> Option<u64> {
Some(self.contents.len() as u64)
}
fn file_type(&self) -> Option<String> {
if let Some(ref file_name) = self.file_name {
get_extension_from_filename(file_name)
} else {
None
}
}
fn contents(&self) -> &[u8] {
&self.contents
}
fn write_and_refresh(&mut self, data: &[u8]) {
self.contents = data.to_vec();
self.modification_time = Some(Utc::now());
if let Err(err) = download_as_file(self.file_name.as_deref(), &self.contents[..]) {
tracing::error!("Download failed: {:?}", err);
}
}
}
/// An implementation of `UiBackend` utilizing `web_sys` bindings to input APIs.
pub struct WebUiBackend {
js_player: JavascriptPlayer,
canvas: HtmlCanvasElement,
cursor_visible: bool,
cursor: MouseCursor,
language: LanguageIdentifier,
clipboard_content: String,
/// Is a dialog currently open
dialog_open: bool,
}
impl WebUiBackend {
pub fn new(js_player: JavascriptPlayer, canvas: &HtmlCanvasElement) -> Self {
let window = web_sys::window().expect("window()");
let preferred_language = window.navigator().language();
let language = preferred_language
.and_then(|l| l.parse().ok())
.unwrap_or_else(|| US_ENGLISH.clone());
Self {
js_player,
canvas: canvas.clone(),
cursor_visible: true,
cursor: MouseCursor::Arrow,
language,
clipboard_content: "".into(),
dialog_open: false,
}
}
fn update_mouse_cursor(&self) {
let cursor = if self.cursor_visible {
match self.cursor {
MouseCursor::Arrow => "auto",
MouseCursor::Hand => "pointer",
MouseCursor::IBeam => "text",
MouseCursor::Grab => "grab",
}
} else {
"none"
};
self.canvas
.style()
.set_property("cursor", cursor)
.warn_on_error();
}
}
impl UiBackend for WebUiBackend {
fn mouse_visible(&self) -> bool {
self.cursor_visible
}
fn set_mouse_visible(&mut self, visible: bool) {
self.cursor_visible = visible;
self.update_mouse_cursor();
}
fn set_mouse_cursor(&mut self, cursor: MouseCursor) {
self.cursor = cursor;
self.update_mouse_cursor();
}
fn clipboard_content(&mut self) -> String {
// On web, clipboard content is not directly accessible due to security restrictions,
// but pasting from the clipboard is supported via the JS `paste` event
self.clipboard_content.to_owned()
}
fn set_clipboard_content(&mut self, content: String) {
self.clipboard_content = content.to_owned();
// We use `document.execCommand("copy")` as `navigator.clipboard.writeText("string")`
// is available only in secure contexts (HTTPS).
if let Some(element) = self.canvas.parent_element() {
let window = web_sys::window().expect("window()");
let document: HtmlDocument = window
.document()
.expect("document()")
.dyn_into()
.expect("document() didn't give us a document");
let textarea: HtmlTextAreaElement = document
.create_element("textarea")
.expect("create_element() must succeed")
.dyn_into()
.expect("create_element(\"textarea\") didn't give us a textarea");
let editing_text = self.js_player.is_virtual_keyboard_focused();
textarea.set_value(&content);
let _ = element.append_child(&textarea);
textarea.select();
match document.exec_command("copy") {
Ok(success) => {
if !success {
tracing::warn!(
"Couldn't set clipboard contents: The browser rejected the call"
);
}
}
Err(e) => tracing::error!("Couldn't set clipboard contents: {:?}", e),
}
let _ = element.remove_child(&textarea);
if editing_text {
// Return focus to the text area
self.js_player.open_virtual_keyboard();
}
}
}
fn set_fullscreen(&mut self, is_full: bool) -> Result<(), FullscreenError> {
match self.js_player.set_fullscreen(is_full) {
Ok(_) => Ok(()),
Err(jsval) => Err(jsval
.as_string()
.map(Cow::Owned)
.unwrap_or_else(|| Cow::Borrowed("Failed to change full screen state"))),
}
}
fn display_root_movie_download_failed_message(&self, invalid_swf: bool) {
self.js_player
.display_root_movie_download_failed_message(invalid_swf)
}
fn message(&self, message: &str) {
self.js_player.display_message(message);
}
fn open_virtual_keyboard(&self) {
self.js_player.open_virtual_keyboard()
}
fn language(&self) -> LanguageIdentifier {
self.language.clone()
}
fn display_unsupported_video(&self, url: Url) {
self.js_player.display_unsupported_video(url.as_str());
}
fn load_device_font(
&self,
_name: &str,
_is_bold: bool,
_is_italic: bool,
_register: &mut dyn FnMut(FontDefinition),
) {
// Because fonts must be loaded instantly (no async),
// we actually just provide them all upfront at time of Player creation.
}
fn display_file_open_dialog(&mut self, filters: Vec<FileFilter>) -> Option<DialogResultFuture> {
// Prevent opening multiple dialogs at the same time
if self.dialog_open {
return None;
}
self.dialog_open = true;
// Create the dialog future
Some(Box::pin(async move {
let mut dialog = AsyncFileDialog::new();
for filter in filters {
let window = web_sys::window().expect("window()");
let navigator = window.navigator();
let platform = navigator.platform().expect("navigator.platform");
if platform.contains("Mac") && filter.mac_type.is_some() {
let mac_type = filter.mac_type.expect("Cant fail");
let extensions: Vec<&str> = mac_type.split(';').collect();
dialog = dialog.add_filter(&filter.description, &extensions);
} else {
let extensions: Vec<&str> = filter
.extensions
.split(';')
.map(|x| x.trim_start_matches("*."))
.collect();
dialog = dialog.add_filter(&filter.description, &extensions);
}
}
let result: Result<Box<dyn FileDialogResult>, DialogLoaderError> = Ok(Box::new(
WebFileDialogResult::new_pick(dialog.pick_file().await).await,
));
result
}))
}
fn close_file_dialog(&mut self) {
self.dialog_open = false;
}
fn display_file_save_dialog(
&mut self,
file_name: String,
_title: String,
) -> Option<DialogResultFuture> {
// Prevent opening multiple dialogs at the same time
if self.dialog_open {
return None;
}
self.dialog_open = true;
Some(Box::pin(async move {
let result: Result<Box<dyn FileDialogResult>, DialogLoaderError> =
Ok(Box::new(WebFileDialogResult::new_download(file_name)));
result
}))
}
}