mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-04 12:13:27 +00:00
[ENG-1508] Launch Spacedrop (#1893)
* Shit UI * refactor a bit * wip * yeet * farming the wry but it's stale * Real-time hover event * Hook with `Platform` + fix broken window-state plugin * `DragAndDropDebug` * Clippy * revert Tauri v2 stuff * minor * probs not gonna work * undo last commit * a * b * c * d * e * f * g * long shot * 1 * no 7 * da hell * large bruh moment * lol * zzzz * SSH into CI * yeet * Tauri mouse position without new Wry * go for gold * Correctly lock `ort` version * minor fixes * debounce hover events * WTF Tauri * Replace DND hooks with goated versions * wip frontend stuff * fix ts * disable library p2p stuff * remove Spacedrop dialog + hook up backend * `useOnDndLeave` working * Close popover when drag outside * Allow `openFilePickerDialog` for Spacedrop * couple of fixes * empty state * smh --------- Co-authored-by: Brendan Allan <brendonovich@outlook.com>
This commit is contained in:
parent
49bdbc4b60
commit
bef1ebcade
388
Cargo.lock
generated
388
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -90,6 +90,10 @@ if-watch = { git = "https://github.com/oscartbeaumont/if-watch.git", rev = "f732
|
|||
# Beta features
|
||||
rspc = { git = "https://github.com/spacedriveapp/rspc.git", rev = "f3347e2e8bfe3f37bfacc437ca329fe71cdcb048" }
|
||||
|
||||
# `cursor_position` method
|
||||
tauri = { git = "https://github.com/spacedriveapp/tauri.git", rev = "8409af71a83d631ff9d1cd876c441a57511a1cbd" }
|
||||
tao = { git = "https://github.com/spacedriveapp/tao", rev = "7880adbc090402c44fbcf006669458fa82623403" }
|
||||
|
||||
# Set the settings for build scripts and proc-macros.
|
||||
[profile.dev.build-override]
|
||||
opt-level = 3
|
||||
|
|
16
apps/desktop/crates/tauri-plugin-window-state/Cargo.toml
Normal file
16
apps/desktop/crates/tauri-plugin-window-state/Cargo.toml
Normal file
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "tauri-plugin-window-state"
|
||||
publish = false
|
||||
version = "0.1.0"
|
||||
license.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = "1"
|
||||
thiserror = "1"
|
||||
log = "0.4"
|
||||
bincode = "1.3"
|
||||
bitflags = "2"
|
1
apps/desktop/crates/tauri-plugin-window-state/REAMDE.md
Normal file
1
apps/desktop/crates/tauri-plugin-window-state/REAMDE.md
Normal file
|
@ -0,0 +1 @@
|
|||
Fork of [tauri-plugin-window-state]( https://github.com/tauri-apps/plugins-workspace/blob/v1/plugins/window-state).
|
28
apps/desktop/crates/tauri-plugin-window-state/src/cmd.rs
Normal file
28
apps/desktop/crates/tauri-plugin-window-state/src/cmd.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
use crate::{AppHandleExt, StateFlags, WindowExt};
|
||||
use tauri::{command, AppHandle, Manager, Runtime};
|
||||
|
||||
#[command]
|
||||
pub async fn save_window_state<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
flags: u32,
|
||||
) -> std::result::Result<(), String> {
|
||||
let flags = StateFlags::from_bits(flags)
|
||||
.ok_or_else(|| format!("Invalid state flags bits: {}", flags))?;
|
||||
app.save_window_state(flags).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn restore_state<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
label: String,
|
||||
flags: u32,
|
||||
) -> std::result::Result<(), String> {
|
||||
let flags = StateFlags::from_bits(flags)
|
||||
.ok_or_else(|| format!("Invalid state flags bits: {}", flags))?;
|
||||
app.get_window(&label)
|
||||
.ok_or_else(|| format!("Couldn't find window with label: {}", label))?
|
||||
.restore_state(flags)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
380
apps/desktop/crates/tauri-plugin-window-state/src/lib.rs
Normal file
380
apps/desktop/crates/tauri-plugin-window-state/src/lib.rs
Normal file
|
@ -0,0 +1,380 @@
|
|||
// Copyright 2021 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use bitflags::bitflags;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{
|
||||
plugin::{Builder as PluginBuilder, TauriPlugin},
|
||||
LogicalSize, Manager, Monitor, PhysicalPosition, PhysicalSize, RunEvent, Runtime, Window,
|
||||
WindowEvent,
|
||||
};
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs::{create_dir_all, File},
|
||||
io::Write,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
mod cmd;
|
||||
|
||||
pub const STATE_FILENAME: &str = ".window-state";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Tauri(#[from] tauri::Error),
|
||||
#[error(transparent)]
|
||||
TauriApi(#[from] tauri::api::Error),
|
||||
#[error(transparent)]
|
||||
Bincode(#[from] Box<bincode::ErrorKind>),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
bitflags! {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct StateFlags: u32 {
|
||||
const SIZE = 1 << 0;
|
||||
const POSITION = 1 << 1;
|
||||
const MAXIMIZED = 1 << 2;
|
||||
const VISIBLE = 1 << 3;
|
||||
const DECORATIONS = 1 << 4;
|
||||
const FULLSCREEN = 1 << 5;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StateFlags {
|
||||
fn default() -> Self {
|
||||
Self::all()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq)]
|
||||
struct WindowState {
|
||||
width: f64,
|
||||
height: f64,
|
||||
x: i32,
|
||||
y: i32,
|
||||
maximized: bool,
|
||||
visible: bool,
|
||||
decorated: bool,
|
||||
fullscreen: bool,
|
||||
}
|
||||
|
||||
impl Default for WindowState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: Default::default(),
|
||||
height: Default::default(),
|
||||
x: Default::default(),
|
||||
y: Default::default(),
|
||||
maximized: Default::default(),
|
||||
visible: true,
|
||||
decorated: true,
|
||||
fullscreen: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WindowStateCache(Arc<Mutex<HashMap<String, WindowState>>>);
|
||||
pub trait AppHandleExt {
|
||||
/// Saves all open windows state to disk
|
||||
fn save_window_state(&self, flags: StateFlags) -> Result<()>;
|
||||
}
|
||||
|
||||
impl<R: Runtime> AppHandleExt for tauri::AppHandle<R> {
|
||||
fn save_window_state(&self, flags: StateFlags) -> Result<()> {
|
||||
if let Some(app_dir) = self.path_resolver().app_config_dir() {
|
||||
let state_path = app_dir.join(STATE_FILENAME);
|
||||
let cache = self.state::<WindowStateCache>();
|
||||
let mut state = cache.0.lock().unwrap();
|
||||
for (label, s) in state.iter_mut() {
|
||||
if let Some(window) = self.get_window(label) {
|
||||
window.update_state(s, flags)?;
|
||||
}
|
||||
}
|
||||
|
||||
create_dir_all(&app_dir)
|
||||
.map_err(Error::Io)
|
||||
.and_then(|_| File::create(state_path).map_err(Into::into))
|
||||
.and_then(|mut f| {
|
||||
f.write_all(&bincode::serialize(&*state).map_err(Error::Bincode)?)
|
||||
.map_err(Into::into)
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait WindowExt {
|
||||
/// Restores this window state from disk
|
||||
fn restore_state(&self, flags: StateFlags) -> tauri::Result<()>;
|
||||
}
|
||||
|
||||
impl<R: Runtime> WindowExt for Window<R> {
|
||||
fn restore_state(&self, flags: StateFlags) -> tauri::Result<()> {
|
||||
let cache = self.state::<WindowStateCache>();
|
||||
let mut c = cache.0.lock().unwrap();
|
||||
|
||||
let mut should_show = true;
|
||||
|
||||
if let Some(state) = c.get(self.label()) {
|
||||
// avoid restoring the default zeroed state
|
||||
if *state == WindowState::default() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::DECORATIONS) {
|
||||
self.set_decorations(state.decorated)?;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::SIZE) {
|
||||
self.set_size(LogicalSize {
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
})?;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::POSITION) {
|
||||
// restore position to saved value if saved monitor exists
|
||||
// otherwise, let the OS decide where to place the window
|
||||
for m in self.available_monitors()? {
|
||||
if m.contains((state.x, state.y).into()) {
|
||||
self.set_position(PhysicalPosition {
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::MAXIMIZED) && state.maximized {
|
||||
self.maximize()?;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::FULLSCREEN) {
|
||||
self.set_fullscreen(state.fullscreen)?;
|
||||
}
|
||||
|
||||
should_show = state.visible;
|
||||
} else {
|
||||
let mut metadata = WindowState::default();
|
||||
|
||||
if flags.contains(StateFlags::SIZE) {
|
||||
let scale_factor = self
|
||||
.current_monitor()?
|
||||
.map(|m| m.scale_factor())
|
||||
.unwrap_or(1.);
|
||||
let size = self.inner_size()?.to_logical(scale_factor);
|
||||
metadata.width = size.width;
|
||||
metadata.height = size.height;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::POSITION) {
|
||||
let pos = self.outer_position()?;
|
||||
metadata.x = pos.x;
|
||||
metadata.y = pos.y;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::MAXIMIZED) {
|
||||
metadata.maximized = self.is_maximized()?;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::VISIBLE) {
|
||||
metadata.visible = self.is_visible()?;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::DECORATIONS) {
|
||||
metadata.decorated = self.is_decorated()?;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::FULLSCREEN) {
|
||||
metadata.fullscreen = self.is_fullscreen()?;
|
||||
}
|
||||
|
||||
c.insert(self.label().into(), metadata);
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::VISIBLE) && should_show {
|
||||
self.show()?;
|
||||
self.set_focus()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
trait WindowExtInternal {
|
||||
fn update_state(&self, state: &mut WindowState, flags: StateFlags) -> tauri::Result<()>;
|
||||
}
|
||||
|
||||
impl<R: Runtime> WindowExtInternal for Window<R> {
|
||||
fn update_state(&self, state: &mut WindowState, flags: StateFlags) -> tauri::Result<()> {
|
||||
let is_maximized = match flags.intersects(StateFlags::MAXIMIZED | StateFlags::SIZE) {
|
||||
true => self.is_maximized()?,
|
||||
false => false,
|
||||
};
|
||||
|
||||
if flags.contains(StateFlags::MAXIMIZED) {
|
||||
state.maximized = is_maximized;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::FULLSCREEN) {
|
||||
state.fullscreen = self.is_fullscreen()?;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::DECORATIONS) {
|
||||
state.decorated = self.is_decorated()?;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::VISIBLE) {
|
||||
state.visible = self.is_visible()?;
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::SIZE) {
|
||||
let scale_factor = self
|
||||
.current_monitor()?
|
||||
.map(|m| m.scale_factor())
|
||||
.unwrap_or(1.);
|
||||
let size = self.inner_size()?.to_logical(scale_factor);
|
||||
|
||||
// It doesn't make sense to save a self with 0 height or width
|
||||
if size.width > 0. && size.height > 0. && !is_maximized {
|
||||
state.width = size.width;
|
||||
state.height = size.height;
|
||||
}
|
||||
}
|
||||
|
||||
if flags.contains(StateFlags::POSITION) {
|
||||
let position = self.outer_position()?;
|
||||
if let Ok(Some(monitor)) = self.current_monitor() {
|
||||
// save only window positions that are inside the current monitor
|
||||
if monitor.contains(position) && !is_maximized {
|
||||
state.x = position.x;
|
||||
state.y = position.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Builder {
|
||||
denylist: HashSet<String>,
|
||||
skip_initial_state: HashSet<String>,
|
||||
state_flags: StateFlags,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
/// Sets the state flags to control what state gets restored and saved.
|
||||
pub fn with_state_flags(mut self, flags: StateFlags) -> Self {
|
||||
self.state_flags = flags;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a list of windows that shouldn't be tracked and managed by this plugin
|
||||
/// for example splash screen windows.
|
||||
pub fn with_denylist(mut self, denylist: &[&str]) -> Self {
|
||||
self.denylist = denylist.iter().map(|l| l.to_string()).collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds the given window label to a list of windows to skip initial state restore.
|
||||
pub fn skip_initial_state(mut self, label: &str) -> Self {
|
||||
self.skip_initial_state.insert(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
|
||||
let flags = self.state_flags;
|
||||
PluginBuilder::new("window-state")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
cmd::save_window_state,
|
||||
cmd::restore_state
|
||||
])
|
||||
.setup(|app| {
|
||||
let cache: Arc<Mutex<HashMap<String, WindowState>>> = if let Some(app_dir) =
|
||||
app.path_resolver().app_config_dir()
|
||||
{
|
||||
let state_path = app_dir.join(STATE_FILENAME);
|
||||
if state_path.exists() {
|
||||
Arc::new(Mutex::new(
|
||||
tauri::api::file::read_binary(state_path)
|
||||
.map_err(Error::TauriApi)
|
||||
.and_then(|state| bincode::deserialize(&state).map_err(Into::into))
|
||||
.unwrap_or_default(),
|
||||
))
|
||||
} else {
|
||||
Default::default()
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
app.manage(WindowStateCache(cache));
|
||||
Ok(())
|
||||
})
|
||||
.on_webview_ready(move |window| {
|
||||
if self.denylist.contains(window.label()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.skip_initial_state.contains(window.label()) {
|
||||
let _ = window.restore_state(self.state_flags);
|
||||
}
|
||||
|
||||
let cache = window.state::<WindowStateCache>();
|
||||
let cache = cache.0.clone();
|
||||
let label = window.label().to_string();
|
||||
let window_clone = window.clone();
|
||||
let flags = self.state_flags;
|
||||
|
||||
// insert a default state if this window should be tracked and
|
||||
// the disk cache doesn't have a state for it
|
||||
{
|
||||
cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(label.clone())
|
||||
.or_insert_with(WindowState::default);
|
||||
}
|
||||
|
||||
window.on_window_event(move |e| {
|
||||
if let WindowEvent::CloseRequested { .. } = e {
|
||||
let mut c = cache.lock().unwrap();
|
||||
if let Some(state) = c.get_mut(&label) {
|
||||
let _ = window_clone.update_state(state, flags);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.on_event(move |app, event| {
|
||||
if let RunEvent::Exit = event {
|
||||
let _ = app.save_window_state(flags);
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
trait MonitorExt {
|
||||
fn contains(&self, position: PhysicalPosition<i32>) -> bool;
|
||||
}
|
||||
|
||||
impl MonitorExt for Monitor {
|
||||
fn contains(&self, position: PhysicalPosition<i32>) -> bool {
|
||||
let PhysicalPosition { x, y } = *self.position();
|
||||
let PhysicalSize { width, height } = *self.size();
|
||||
|
||||
x < position.x as _
|
||||
&& position.x < (x + width as i32)
|
||||
&& y < position.y as _
|
||||
&& position.y < (y + height as i32)
|
||||
}
|
||||
}
|
|
@ -33,7 +33,7 @@ uuid = { workspace = true, features = ["serde"] }
|
|||
thiserror.workspace = true
|
||||
|
||||
opener = { version = "0.6.1", features = ["reveal"] }
|
||||
tauri = { version = "1.5.3", features = [
|
||||
tauri = { version = "=1.5.3", features = [
|
||||
"macos-private-api",
|
||||
"path-all",
|
||||
"protocol-all",
|
||||
|
@ -46,8 +46,7 @@ tauri = { version = "1.5.3", features = [
|
|||
"native-tls-vendored",
|
||||
"tracing",
|
||||
] }
|
||||
tauri-plugin-window-state = "0.1.0"
|
||||
|
||||
tauri-plugin-window-state = { path = "../crates/tauri-plugin-window-state" }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
sd-desktop-linux = { path = "../crates/linux" }
|
||||
|
|
|
@ -3,17 +3,24 @@
|
|||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use std::{fs, path::PathBuf, sync::Arc, time::Duration};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex, PoisonError},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use sd_core::{Node, NodeError};
|
||||
|
||||
use sd_fda::DiskAccess;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{
|
||||
api::path, ipc::RemoteDomainAccessScope, window::PlatformWebview, AppHandle, Manager,
|
||||
WindowEvent,
|
||||
api::path, ipc::RemoteDomainAccessScope, window::PlatformWebview, AppHandle, FileDropEvent,
|
||||
Manager, Window, WindowEvent,
|
||||
};
|
||||
use tauri_plugins::{sd_error_plugin, sd_server_plugin};
|
||||
use tauri_specta::ts;
|
||||
use tauri_specta::{collect_events, ts, Event};
|
||||
use tokio::time::sleep;
|
||||
use tracing::error;
|
||||
|
||||
|
@ -144,6 +151,19 @@ async fn open_logs_dir(node: tauri::State<'_, Arc<Node>>) -> Result<(), ()> {
|
|||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum DragAndDropEvent {
|
||||
Hovered { paths: Vec<String>, x: f64, y: f64 },
|
||||
Dropped { paths: Vec<String>, x: f64, y: f64 },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DragAndDropState {
|
||||
windows: HashMap<tauri::Window, tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
const CLIENT_ID: &str = "2abb241e-40b8-4517-a3e3-5594375c8fbb";
|
||||
|
||||
#[tokio::main]
|
||||
|
@ -212,6 +232,7 @@ async fn main() -> tauri::Result<()> {
|
|||
|
||||
let specta_builder = {
|
||||
let specta_builder = ts::builder()
|
||||
.events(collect_events![DragAndDropEvent])
|
||||
.commands(tauri_specta::collect_commands![
|
||||
app_ready,
|
||||
reset_spacedrive,
|
||||
|
@ -232,7 +253,6 @@ async fn main() -> tauri::Result<()> {
|
|||
updater::check_for_update,
|
||||
updater::install_update
|
||||
])
|
||||
// .events(tauri_specta::collect_events![])
|
||||
.config(specta::ts::ExportConfig::default().formatter(specta::ts::formatter::prettier));
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
|
@ -241,6 +261,7 @@ async fn main() -> tauri::Result<()> {
|
|||
specta_builder.into_plugin()
|
||||
};
|
||||
|
||||
let file_drop_status = Arc::new(Mutex::new(DragAndDropState::default()));
|
||||
let app = app
|
||||
.plugin(updater::plugin())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
|
@ -297,8 +318,83 @@ async fn main() -> tauri::Result<()> {
|
|||
Ok(())
|
||||
})
|
||||
.on_menu_event(menu::handle_menu_event)
|
||||
.on_window_event(|event| {
|
||||
if let WindowEvent::Resized(_) = event.event() {
|
||||
.on_window_event(move |event| match event.event() {
|
||||
WindowEvent::FileDrop(drop) => {
|
||||
let window = event.window();
|
||||
let mut file_drop_status = file_drop_status
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
|
||||
match drop {
|
||||
FileDropEvent::Hovered(paths) => {
|
||||
// Look this shouldn't happen but let's be sure we don't leak threads.
|
||||
if file_drop_status.windows.contains_key(window) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We setup a thread to keep emitting the updated position of the cursor
|
||||
// It will be killed when the `FileDropEvent` is finished or cancelled.
|
||||
let paths = paths.clone();
|
||||
file_drop_status.windows.insert(window.clone(), {
|
||||
let window = window.clone();
|
||||
tokio::spawn(async move {
|
||||
let (mut last_x, mut last_y) = (0.0, 0.0);
|
||||
loop {
|
||||
let (x, y) = mouse_position(&window);
|
||||
|
||||
let x_diff = difference(x, last_x);
|
||||
let y_diff = difference(y, last_y);
|
||||
|
||||
// If the mouse hasn't moved much we will "debounce" the event
|
||||
if x_diff > 28.0 || y_diff > 28.0 {
|
||||
last_x = x;
|
||||
last_y = y;
|
||||
|
||||
DragAndDropEvent::Hovered {
|
||||
paths: paths
|
||||
.iter()
|
||||
.filter_map(|x| x.to_str().map(|x| x.to_string()))
|
||||
.collect(),
|
||||
x,
|
||||
y,
|
||||
}
|
||||
.emit(&window)
|
||||
.ok();
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(125)).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
FileDropEvent::Dropped(paths) => {
|
||||
if let Some(handle) = file_drop_status.windows.remove(window) {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
let (x, y) = mouse_position(window);
|
||||
DragAndDropEvent::Dropped {
|
||||
paths: paths
|
||||
.iter()
|
||||
.filter_map(|x| x.to_str().map(|x| x.to_string()))
|
||||
.collect(),
|
||||
x,
|
||||
y,
|
||||
}
|
||||
.emit(window)
|
||||
.ok();
|
||||
}
|
||||
FileDropEvent::Cancelled => {
|
||||
if let Some(handle) = file_drop_status.windows.remove(window) {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
DragAndDropEvent::Cancelled.emit(window).ok();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
WindowEvent::Resized(_) => {
|
||||
let (_state, command) = if event
|
||||
.window()
|
||||
.is_fullscreen()
|
||||
|
@ -320,6 +416,7 @@ async fn main() -> tauri::Result<()> {
|
|||
unsafe { sd_desktop_macos::set_titlebar_style(&nswindow, _state) };
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.menu(menu::get_menu())
|
||||
.manage(updater::State::default())
|
||||
|
@ -328,3 +425,28 @@ async fn main() -> tauri::Result<()> {
|
|||
app.run(|_, _| {});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Get the mouse position relative to the window
|
||||
fn mouse_position(window: &Window) -> (f64, f64) {
|
||||
// We apply the OS scaling factor.
|
||||
// Tauri/Webkit *should* be responsible for this but it would seem it is bugged on the current webkit/tauri/wry/tao version.
|
||||
// Using newer Webkit did fix this automatically but I can't for the life of me work out how to get the right glibc versions in CI so we can't ship it.
|
||||
let scale_factor = window.scale_factor().unwrap();
|
||||
|
||||
let window_pos = window.outer_position().unwrap();
|
||||
let cursor_pos = window.cursor_position().unwrap();
|
||||
|
||||
(
|
||||
(cursor_pos.x - window_pos.x as f64) / scale_factor,
|
||||
(cursor_pos.y - window_pos.y as f64) / scale_factor,
|
||||
)
|
||||
}
|
||||
|
||||
// The distance between two numbers as a positive integer.
|
||||
fn difference(a: f64, b: f64) -> f64 {
|
||||
let x = a - b;
|
||||
if x < 0.0 {
|
||||
return x * -1.0;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
|
|
@ -15,11 +15,10 @@ import {
|
|||
TabsContext
|
||||
} from '@sd/interface';
|
||||
import { RouteTitleContext } from '@sd/interface/hooks/useRouteTitle';
|
||||
import { getSpacedropState } from '@sd/interface/hooks/useSpacedropState';
|
||||
|
||||
import '@sd/ui/style/style.scss';
|
||||
|
||||
import { commands } from './commands';
|
||||
import { commands, events } from './commands';
|
||||
import { platform } from './platform';
|
||||
import { queryClient } from './query';
|
||||
import { createMemoryRouterWithHistory } from './router';
|
||||
|
@ -47,15 +46,8 @@ export default function App() {
|
|||
document.dispatchEvent(new KeybindEvent(input.payload as string));
|
||||
});
|
||||
|
||||
const dropEventListener = appWindow.onFileDropEvent((event) => {
|
||||
if (event.payload.type === 'drop') {
|
||||
getSpacedropState().droppedFiles = event.payload.paths;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
keybindListener.then((unlisten) => unlisten());
|
||||
dropEventListener.then((unlisten) => unlisten());
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -178,9 +178,19 @@ export const commands = {
|
|||
}
|
||||
};
|
||||
|
||||
export const events = __makeEvents__<{
|
||||
dragAndDropEvent: DragAndDropEvent;
|
||||
}>({
|
||||
dragAndDropEvent: 'plugin:tauri-specta:drag-and-drop-event'
|
||||
});
|
||||
|
||||
/** user-defined types **/
|
||||
|
||||
export type AppThemeType = 'Auto' | 'Light' | 'Dark';
|
||||
export type DragAndDropEvent =
|
||||
| { type: 'Hovered'; paths: string[]; x: number; y: number }
|
||||
| { type: 'Dropped'; paths: string[]; x: number; y: number }
|
||||
| { type: 'Cancelled' };
|
||||
export type RevealItem =
|
||||
| { Location: { id: number } }
|
||||
| { FilePath: { id: number } }
|
||||
|
|
|
@ -4,7 +4,7 @@ import { homeDir } from '@tauri-apps/api/path';
|
|||
import { open } from '@tauri-apps/api/shell';
|
||||
import { OperatingSystem, Platform } from '@sd/interface';
|
||||
|
||||
import { commands } from './commands';
|
||||
import { commands, events } from './commands';
|
||||
import { env } from './env';
|
||||
import { createUpdater } from './updater';
|
||||
|
||||
|
@ -62,6 +62,10 @@ export const platform = {
|
|||
saveFilePickerDialog: (opts) => dialog.save(opts),
|
||||
showDevtools: () => invoke('show_devtools'),
|
||||
confirm: (msg, cb) => confirm(msg).then(cb),
|
||||
subscribeToDragAndDropEvents: (cb) =>
|
||||
events.dragAndDropEvent.listen((e) => {
|
||||
cb(e.payload);
|
||||
}),
|
||||
userHomeDir: homeDir,
|
||||
updater: window.__SD_UPDATER__ ? createUpdater() : undefined,
|
||||
auth: {
|
||||
|
|
|
@ -14,7 +14,7 @@ const DebugScreen = ({ navigation }: SettingsStackScreenProps<'Debug'>) => {
|
|||
<View style={tw`flex-1 p-4`}>
|
||||
<Card style={tw`gap-y-4 bg-app-box`}>
|
||||
<Text style={tw`font-semibold text-ink`}>Debug</Text>
|
||||
<Button onPress={() => toggleFeatureFlag(['p2pPairing', 'spacedrop'])}>
|
||||
<Button onPress={() => toggleFeatureFlag(['p2pPairing'])}>
|
||||
<Text style={tw`text-ink`}>Toggle P2P</Text>
|
||||
</Button>
|
||||
<Button onPress={() => (getDebugState().rspcLogger = !getDebugState().rspcLogger)}>
|
||||
|
|
|
@ -365,7 +365,7 @@ impl StatefulJob for MediaProcessorJobInit {
|
|||
|
||||
errors.push(e.to_string());
|
||||
} else if has_new_labels {
|
||||
// invalidate_query!(&ctx.library, "labels.count");
|
||||
// invalidate_query!(&ctx.library, "labels.count"); // TODO: This query doesn't exist on main yet
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -155,7 +155,7 @@ pub async fn shallow(
|
|||
"Failed to generate labels <file_path_id='{file_path_id}'>: {e:#?}"
|
||||
);
|
||||
} else if has_new_labels {
|
||||
// invalidate_query!(library, "labels.count");
|
||||
// invalidate_query!(library, "labels.count"); // TODO: This query doesn't exist on main yet
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
#![allow(unused)] // TODO: Remove this
|
||||
|
||||
use crate::library::{Libraries, Library, LibraryManagerEvent};
|
||||
|
||||
use sd_p2p::Service;
|
||||
|
@ -42,35 +44,36 @@ impl LibraryServices {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn start(manager: Arc<P2PManager>, libraries: Arc<Libraries>) {
|
||||
if let Err(err) = libraries
|
||||
.rx
|
||||
.clone()
|
||||
.subscribe(|msg| {
|
||||
let manager = manager.clone();
|
||||
async move {
|
||||
match msg {
|
||||
LibraryManagerEvent::InstancesModified(library)
|
||||
| LibraryManagerEvent::Load(library) => {
|
||||
manager
|
||||
.clone()
|
||||
.libraries
|
||||
.load_library(manager, &library)
|
||||
.await
|
||||
}
|
||||
LibraryManagerEvent::Edit(library) => {
|
||||
manager.libraries.edit_library(&library).await
|
||||
}
|
||||
LibraryManagerEvent::Delete(library) => {
|
||||
manager.libraries.delete_library(&library).await
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!("Core may become unstable! `LibraryServices::start` manager aborted with error: {err:?}");
|
||||
}
|
||||
pub(crate) async fn start(_manager: Arc<P2PManager>, _libraries: Arc<Libraries>) {
|
||||
warn!("P2PManager has library communication disabled.");
|
||||
// if let Err(err) = libraries
|
||||
// .rx
|
||||
// .clone()
|
||||
// .subscribe(|msg| {
|
||||
// let manager = manager.clone();
|
||||
// async move {
|
||||
// match msg {
|
||||
// LibraryManagerEvent::InstancesModified(library)
|
||||
// | LibraryManagerEvent::Load(library) => {
|
||||
// manager
|
||||
// .clone()
|
||||
// .libraries
|
||||
// .load_library(manager, &library)
|
||||
// .await
|
||||
// }
|
||||
// LibraryManagerEvent::Edit(library) => {
|
||||
// manager.libraries.edit_library(&library).await
|
||||
// }
|
||||
// LibraryManagerEvent::Delete(library) => {
|
||||
// manager.libraries.delete_library(&library).await
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// .await
|
||||
// {
|
||||
// error!("Core may become unstable! `LibraryServices::start` manager aborted with error: {err:?}");
|
||||
// }
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &Uuid) -> Option<Arc<Service<LibraryMetadata>>> {
|
||||
|
|
|
@ -23,4 +23,4 @@ pub use pairing::*;
|
|||
pub use peer_metadata::*;
|
||||
pub use protocol::*;
|
||||
|
||||
pub(super) const SPACEDRIVE_APP_ID: &str = "spacedrive";
|
||||
pub(super) const SPACEDRIVE_APP_ID: &str = "sd";
|
||||
|
|
|
@ -39,14 +39,14 @@ half = { version = "2.1", features = ['num-traits'] }
|
|||
# "gpu" means CUDA or TensorRT EP. Thus, the ort crate cannot download them at build time.
|
||||
# Ref: https://github.com/pykeio/ort/blob/d7defd1862969b4b44f7f3f4b9c72263690bd67b/build.rs#L148
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
ort = { version = "2.0.0-alpha.2", default-features = false, features = [
|
||||
ort = { version = "=2.0.0-alpha.2", default-features = false, features = [
|
||||
"ndarray",
|
||||
"half",
|
||||
"load-dynamic",
|
||||
"directml",
|
||||
] }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ort = { version = "2.0.0-alpha.2", default-features = false, features = [
|
||||
ort = { version = "=2.0.0-alpha.2", default-features = false, features = [
|
||||
"ndarray",
|
||||
"half",
|
||||
"load-dynamic",
|
||||
|
@ -63,7 +63,7 @@ ort = { version = "2.0.0-alpha.2", default-features = false, features = [
|
|||
# "armnn",
|
||||
# ] }
|
||||
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
|
||||
ort = { version = "2.0.0-alpha.2", features = [
|
||||
ort = { version = "=2.0.0-alpha.2", features = [
|
||||
"ndarray",
|
||||
"half",
|
||||
"load-dynamic",
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
Icon,
|
||||
Key,
|
||||
MonitorPlay,
|
||||
Planet,
|
||||
Rows,
|
||||
SidebarSimple,
|
||||
SlidersHorizontal,
|
||||
|
@ -16,6 +17,7 @@ import { toast } from '@sd/ui';
|
|||
import { useKeyMatcher } from '~/hooks';
|
||||
|
||||
import { KeyManager } from '../KeyManager';
|
||||
import { Spacedrop, SpacedropButton } from '../Spacedrop';
|
||||
import TopBarOptions, { ToolOption, TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
|
||||
import { useExplorerContext } from './Context';
|
||||
import OptionsPanel from './OptionsPanel';
|
||||
|
@ -108,6 +110,13 @@ export const useExplorerTopBarOptions = () => {
|
|||
});
|
||||
|
||||
const toolOptions = [
|
||||
{
|
||||
toolTipLabel: 'Spacedrop',
|
||||
icon: ({ triggerOpen }) => <SpacedropButton triggerOpen={triggerOpen} />,
|
||||
popOverComponent: ({ triggerClose }) => <Spacedrop triggerClose={triggerClose} />,
|
||||
individual: true,
|
||||
showAtResolution: 'xl:flex'
|
||||
},
|
||||
{
|
||||
toolTipLabel: 'Key Manager',
|
||||
icon: <Key className={TOP_BAR_ICON_STYLE} />,
|
||||
|
@ -127,7 +136,7 @@ export const useExplorerTopBarOptions = () => {
|
|||
individual: true,
|
||||
showAtResolution: 'xl:flex'
|
||||
}
|
||||
].filter(Boolean) as ToolOption[];
|
||||
] satisfies ToolOption[];
|
||||
|
||||
return {
|
||||
viewOptions,
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
Switch,
|
||||
usePopover
|
||||
} from '@sd/ui';
|
||||
import { toggleRenderRects } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import Setting from '../../settings/Setting';
|
||||
|
@ -149,6 +150,9 @@ export default () => {
|
|||
<Button size="sm" variant="gray" onClick={() => navigate('./debug/cache')}>
|
||||
Cache Debug
|
||||
</Button>
|
||||
<Button size="sm" variant="gray" onClick={() => toggleRenderRects()}>
|
||||
Toggle DND Rects
|
||||
</Button>
|
||||
|
||||
{/* {platform.showDevtools && (
|
||||
<SettingContainer
|
||||
|
|
165
interface/app/$libraryId/Spacedrop/index.tsx
Normal file
165
interface/app/$libraryId/Spacedrop/index.tsx
Normal file
|
@ -0,0 +1,165 @@
|
|||
import { Planet } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { proxy } from 'valtio';
|
||||
import { useBridgeMutation, useDiscoveredPeers, useP2PEvents, useSelector } from '@sd/client';
|
||||
import { toast } from '@sd/ui';
|
||||
import { Icon } from '~/components';
|
||||
import { useDropzone, useOnDndLeave } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
|
||||
import { useIncomingSpacedropToast, useSpacedropProgressToast } from './toast';
|
||||
|
||||
// TODO: This is super hacky so should probs be rewritten but for now it works.
|
||||
const hackyState = proxy({
|
||||
triggeredByDnd: false,
|
||||
openPanels: 0
|
||||
});
|
||||
|
||||
export function SpacedropProvider() {
|
||||
const incomingRequestToast = useIncomingSpacedropToast();
|
||||
const progressToast = useSpacedropProgressToast();
|
||||
|
||||
useP2PEvents((data) => {
|
||||
if (data.type === 'SpacedropRequest') {
|
||||
incomingRequestToast(data);
|
||||
} else if (data.type === 'SpacedropProgress') {
|
||||
progressToast(data);
|
||||
} else if (data.type === 'SpacedropRejected') {
|
||||
// TODO: Add more information to this like peer name, etc in future
|
||||
toast.warning('Spacedrop Rejected');
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SpacedropButton({ triggerOpen }: { triggerOpen: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dndState = useDropzone({
|
||||
ref,
|
||||
onHover: () => {
|
||||
hackyState.triggeredByDnd = true;
|
||||
triggerOpen();
|
||||
},
|
||||
extendBoundsBy: 10
|
||||
});
|
||||
const isPanelOpen = useSelector(hackyState, (s) => s.openPanels > 0);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={dndState === 'active' && !isPanelOpen ? 'animate-bounce' : ''}>
|
||||
<Planet className={TOP_BAR_ICON_STYLE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spacedrop({ triggerClose }: { triggerClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const discoveredPeers = useDiscoveredPeers();
|
||||
const doSpacedrop = useBridgeMutation('p2p.spacedrop');
|
||||
|
||||
// We keep track of how many instances of this component is rendering.
|
||||
// This is used by `SpacedropButton` to determine if the animation should stop.
|
||||
useEffect(() => {
|
||||
hackyState.openPanels += 1;
|
||||
return () => {
|
||||
hackyState.openPanels -= 1;
|
||||
};
|
||||
});
|
||||
|
||||
// This is intentionally not reactive.
|
||||
// We only want the value at the time of the initial render.
|
||||
// Then we reset it to false.
|
||||
const [wasTriggeredByDnd] = useState(() => hackyState.triggeredByDnd);
|
||||
useEffect(() => {
|
||||
hackyState.triggeredByDnd = false;
|
||||
}, []);
|
||||
|
||||
useOnDndLeave({
|
||||
ref,
|
||||
onLeave: () => {
|
||||
if (wasTriggeredByDnd) triggerClose();
|
||||
},
|
||||
extendBoundsBy: 30
|
||||
});
|
||||
|
||||
const onDropped = (id: string, files: string[]) => {
|
||||
if (doSpacedrop.isLoading) {
|
||||
toast.warning('Spacedrop already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
doSpacedrop
|
||||
.mutateAsync({
|
||||
identity: id,
|
||||
file_path: files
|
||||
})
|
||||
.then(() => triggerClose());
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex h-full max-w-[300px] flex-col">
|
||||
<div className="flex w-full flex-col items-center p-4">
|
||||
<Icon name="Spacedrop" size={56} />
|
||||
<span className="text-lg font-bold">Spacedrop</span>
|
||||
|
||||
<div className="flex flex-col space-y-4 pt-2">
|
||||
{discoveredPeers.size === 0 && (
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-sm text-gray-400">
|
||||
No Spacedrive nodes were
|
||||
<br /> found on your network
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.from(discoveredPeers).map(([id, meta]) => (
|
||||
<Node key={id} id={id} name={meta.name} onDropped={onDropped} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({
|
||||
id,
|
||||
name,
|
||||
onDropped
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
onDropped: (id: string, files: string[]) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const platform = usePlatform();
|
||||
|
||||
const state = useDropzone({
|
||||
ref,
|
||||
onDrop: (files) => onDropped(id, files)
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'border px-4 py-2',
|
||||
state === 'hovered' ? 'border-solid' : 'border-dashed'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!platform.openFilePickerDialog) {
|
||||
toast.warning('File picker not supported on this platform');
|
||||
return;
|
||||
}
|
||||
|
||||
platform.openFilePickerDialog?.().then((file) => {
|
||||
const files = Array.isArray(file) || file === null ? file : [file];
|
||||
if (files === null || files.length === 0) return;
|
||||
onDropped(id, files);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<h1>{name}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
117
interface/app/$libraryId/Spacedrop/toast.tsx
Normal file
117
interface/app/$libraryId/Spacedrop/toast.tsx
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { P2PEvent, useBridgeMutation, useSpacedropProgress } from '@sd/client';
|
||||
import { Input, ProgressBar, toast, ToastId } from '@sd/ui';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
const placeholder = '/Users/oscar/Desktop/demo.txt';
|
||||
|
||||
export function useIncomingSpacedropToast() {
|
||||
const platform = usePlatform();
|
||||
const acceptSpacedrop = useBridgeMutation('p2p.acceptSpacedrop');
|
||||
const filePathInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (data: Extract<P2PEvent, { type: 'SpacedropRequest' }>) =>
|
||||
toast.info(
|
||||
{
|
||||
title: 'Incoming Spacedrop',
|
||||
// TODO: Make this pretty
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
File '{data.files[0]}' from '{data.peer_name}'
|
||||
</p>
|
||||
{/* TODO: This will be removed in the future for now it's just a hack */}
|
||||
{platform.saveFilePickerDialog ? null : (
|
||||
<Input
|
||||
ref={filePathInput}
|
||||
name="file_path"
|
||||
size="sm"
|
||||
placeholder={placeholder}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
{/* TODO: Button to expand the toast and show the entire PeerID for manual verification? */}
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
duration: 30 * 1000,
|
||||
onClose: ({ event }) => {
|
||||
event !== 'on-action' && acceptSpacedrop.mutate([data.id, null]);
|
||||
},
|
||||
action: {
|
||||
label: 'Accept',
|
||||
async onClick() {
|
||||
let destinationFilePath = filePathInput.current?.value ?? placeholder;
|
||||
|
||||
if (data.files.length != 1) {
|
||||
if (platform.openDirectoryPickerDialog) {
|
||||
const result = await platform.openDirectoryPickerDialog({
|
||||
title: 'Save Spacedrop',
|
||||
multiple: false
|
||||
});
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
destinationFilePath = result;
|
||||
}
|
||||
} else {
|
||||
if (platform.saveFilePickerDialog) {
|
||||
const result = await platform.saveFilePickerDialog({
|
||||
title: 'Save Spacedrop',
|
||||
defaultPath: data.files?.[0]
|
||||
});
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
destinationFilePath = result;
|
||||
}
|
||||
}
|
||||
|
||||
if (destinationFilePath === '') return;
|
||||
await acceptSpacedrop.mutateAsync([data.id, destinationFilePath]);
|
||||
}
|
||||
},
|
||||
cancel: 'Reject'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function SpacedropProgress({ toastId, dropId }: { toastId: ToastId; dropId: string }) {
|
||||
const progress = useSpacedropProgress(dropId);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress === 100) {
|
||||
setTimeout(() => toast.dismiss(toastId), 750);
|
||||
}
|
||||
}, [progress, toastId]);
|
||||
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<ProgressBar percent={progress ?? 0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSpacedropProgressToast() {
|
||||
const cancelSpacedrop = useBridgeMutation(['p2p.cancelSpacedrop']);
|
||||
|
||||
return (data: Extract<P2PEvent, { type: 'SpacedropProgress' }>) => {
|
||||
toast.info(
|
||||
(id) => ({
|
||||
title: 'Spacedrop',
|
||||
body: <SpacedropProgress toastId={id} dropId={data.id} />
|
||||
}),
|
||||
{
|
||||
id: data.id,
|
||||
duration: Infinity,
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
onClick() {
|
||||
cancelSpacedrop.mutate(data.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
|
@ -18,7 +18,7 @@ const GroupTool = forwardRef<
|
|||
checkIcon
|
||||
{...props}
|
||||
>
|
||||
{tool.icon}
|
||||
{typeof tool.icon === 'function' ? tool.icon({ triggerOpen: () => {} }) : tool.icon}
|
||||
{tool.toolTipLabel}
|
||||
</TopBarButton>
|
||||
);
|
||||
|
@ -29,6 +29,7 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|||
}
|
||||
|
||||
export default ({ toolOptions, className }: Props) => {
|
||||
const popover = usePopover();
|
||||
const toolsNotSmFlex = toolOptions?.map((group) =>
|
||||
group.filter((tool) => tool.showAtResolution !== 'sm:flex')
|
||||
);
|
||||
|
@ -36,7 +37,7 @@ export default ({ toolOptions, className }: Props) => {
|
|||
return (
|
||||
<div className={className}>
|
||||
<Popover
|
||||
popover={usePopover()}
|
||||
popover={popover}
|
||||
trigger={
|
||||
<TopBarButton>
|
||||
<DotsThreeCircle className={TOP_BAR_ICON_STYLE} />
|
||||
|
@ -50,7 +51,10 @@ export default ({ toolOptions, className }: Props) => {
|
|||
{group.map((tool) => (
|
||||
<React.Fragment key={tool.toolTipLabel}>
|
||||
{tool.popOverComponent ? (
|
||||
<ToolPopover tool={tool} />
|
||||
<ToolPopover
|
||||
tool={tool}
|
||||
triggerClose={() => popover.setOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<GroupTool tool={tool} />
|
||||
)}
|
||||
|
@ -69,10 +73,14 @@ export default ({ toolOptions, className }: Props) => {
|
|||
);
|
||||
};
|
||||
|
||||
function ToolPopover({ tool }: { tool: ToolOption }) {
|
||||
function ToolPopover({ tool, triggerClose }: { tool: ToolOption; triggerClose: () => void }) {
|
||||
return (
|
||||
<Popover popover={usePopover()} trigger={<GroupTool tool={tool} />}>
|
||||
<div className="min-w-[250px]">{tool.popOverComponent}</div>
|
||||
<div className="min-w-[250px]">
|
||||
{typeof tool.popOverComponent === 'function'
|
||||
? tool.popOverComponent({ triggerClose })
|
||||
: tool.popOverComponent}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from 'clsx';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { ModifierKeys, Popover, Tooltip, usePopover } from '@sd/ui';
|
||||
import { useIsDark, useOperatingSystem } from '~/hooks';
|
||||
|
||||
|
@ -7,13 +7,13 @@ import TopBarButton from './TopBarButton';
|
|||
import TopBarMobile from './TopBarMobile';
|
||||
|
||||
export interface ToolOption {
|
||||
icon: JSX.Element;
|
||||
icon: JSX.Element | ((props: { triggerOpen: () => void }) => JSX.Element);
|
||||
onClick?: () => void;
|
||||
individual?: boolean;
|
||||
toolTipLabel: string;
|
||||
toolTipClassName?: string;
|
||||
topBarActive?: boolean;
|
||||
popOverComponent?: JSX.Element;
|
||||
popOverComponent?: JSX.Element | ((props: { triggerClose: () => void }) => JSX.Element);
|
||||
showAtResolution: ShowAtResolution;
|
||||
keybinds?: Array<String | ModifierKeys>;
|
||||
}
|
||||
|
@ -127,12 +127,20 @@ function ToolGroup({
|
|||
tooltipClassName={clsx('capitalize', toolTipClassName)}
|
||||
label={toolTipLabel}
|
||||
>
|
||||
{icon}
|
||||
{typeof icon === 'function'
|
||||
? icon({
|
||||
triggerOpen: () => popover.setOpen(true)
|
||||
})
|
||||
: icon}
|
||||
</Tooltip>
|
||||
</TopBarButton>
|
||||
}
|
||||
>
|
||||
<div className="block min-w-[250px] max-w-[500px]">{popOverComponent}</div>
|
||||
<div className="block min-w-[250px] max-w-[500px]">
|
||||
{typeof popOverComponent === 'function'
|
||||
? popOverComponent({ triggerClose: () => popover.setOpen(false) })
|
||||
: popOverComponent}
|
||||
</div>
|
||||
</Popover>
|
||||
) : (
|
||||
<TopBarButton
|
||||
|
@ -145,7 +153,7 @@ function ToolGroup({
|
|||
tooltipClassName={clsx('capitalize', toolTipClassName)}
|
||||
label={toolTipLabel}
|
||||
>
|
||||
{icon}
|
||||
{typeof icon === 'function' ? icon({ triggerOpen: () => {} }) : icon}
|
||||
</Tooltip>
|
||||
</TopBarButton>
|
||||
)}
|
||||
|
|
34
interface/app/$libraryId/debug/dnd.tsx
Normal file
34
interface/app/$libraryId/debug/dnd.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
export function DragAndDropDebug() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const platform = usePlatform();
|
||||
useEffect(() => {
|
||||
if (!platform.subscribeToDragAndDropEvents) return;
|
||||
|
||||
let finished = false;
|
||||
const unsub = platform.subscribeToDragAndDropEvents((event) => {
|
||||
if (finished) return;
|
||||
|
||||
console.log(JSON.stringify(event));
|
||||
if (!ref.current) return;
|
||||
|
||||
if (event.type === 'Hovered') {
|
||||
ref.current.classList.remove('hidden');
|
||||
ref.current.style.left = `${event.x}px`;
|
||||
ref.current.style.top = `${event.y}px`;
|
||||
} else if (event.type === 'Dropped' || event.type === 'Cancelled') {
|
||||
ref.current.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
finished = true;
|
||||
void unsub.then((unsub) => unsub());
|
||||
};
|
||||
}, [platform, ref]);
|
||||
|
||||
return <div ref={ref} className="absolute z-[500] hidden h-10 w-10 bg-red-500"></div>;
|
||||
}
|
|
@ -11,8 +11,9 @@ import { Dialogs, Toaster } from '@sd/ui';
|
|||
import { RouterErrorBoundary } from '~/ErrorFallback';
|
||||
import { useRoutingContext } from '~/RoutingContext';
|
||||
|
||||
import { Platform } from '..';
|
||||
import { Platform, usePlatform } from '..';
|
||||
import libraryRoutes from './$libraryId';
|
||||
import { DragAndDropDebug } from './$libraryId/debug/dnd';
|
||||
import { renderDemo } from './demo.solid';
|
||||
import onboardingRoutes from './onboarding';
|
||||
import { RootContext } from './RootContext';
|
||||
|
@ -43,6 +44,7 @@ export const createRoutes = (platform: Platform, cache: NormalisedCache) =>
|
|||
|
||||
return (
|
||||
<RootContext.Provider value={{ rawPath }}>
|
||||
{useFeatureFlag('debugDragAndDrop') ? <DragAndDropDebug /> : null}
|
||||
{useFeatureFlag('solidJsDemo') ? <RenderSolid /> : null}
|
||||
<Outlet />
|
||||
<Dialogs />
|
||||
|
|
|
@ -1,235 +0,0 @@
|
|||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
P2PEvent,
|
||||
useBridgeMutation,
|
||||
useBridgeQuery,
|
||||
useDiscoveredPeers,
|
||||
useP2PEvents,
|
||||
useSpacedropProgress,
|
||||
useZodForm
|
||||
} from '@sd/client';
|
||||
import {
|
||||
Dialog,
|
||||
dialogManager,
|
||||
Input,
|
||||
ProgressBar,
|
||||
SelectField,
|
||||
SelectOption,
|
||||
toast,
|
||||
ToastId,
|
||||
useDialog,
|
||||
UseDialogProps,
|
||||
z
|
||||
} from '@sd/ui';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import { getSpacedropState, subscribeSpacedropState } from '../../hooks/useSpacedropState';
|
||||
|
||||
function SpacedropProgress({ toastId, dropId }: { toastId: ToastId; dropId: string }) {
|
||||
const progress = useSpacedropProgress(dropId);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress === 100) {
|
||||
setTimeout(() => toast.dismiss(toastId), 750);
|
||||
}
|
||||
}, [progress, toastId]);
|
||||
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<ProgressBar percent={progress ?? 0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const placeholder = '/Users/oscar/Desktop/demo.txt';
|
||||
|
||||
function useIncomingSpacedropToast() {
|
||||
const platform = usePlatform();
|
||||
const acceptSpacedrop = useBridgeMutation('p2p.acceptSpacedrop');
|
||||
const filePathInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (data: Extract<P2PEvent, { type: 'SpacedropRequest' }>) =>
|
||||
toast.info(
|
||||
{
|
||||
title: 'Incoming Spacedrop',
|
||||
// TODO: Make this pretty
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
File '{data.files[0]}' from '{data.peer_name}'
|
||||
</p>
|
||||
{/* TODO: This will be removed in the future for now it's just a hack */}
|
||||
{platform.saveFilePickerDialog ? null : (
|
||||
<Input
|
||||
ref={filePathInput}
|
||||
name="file_path"
|
||||
size="sm"
|
||||
placeholder={placeholder}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
{/* TODO: Button to expand the toast and show the entire PeerID for manual verification? */}
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
duration: 30 * 1000,
|
||||
onClose: ({ event }) => {
|
||||
event !== 'on-action' && acceptSpacedrop.mutate([data.id, null]);
|
||||
},
|
||||
action: {
|
||||
label: 'Accept',
|
||||
async onClick() {
|
||||
let destinationFilePath = filePathInput.current?.value ?? placeholder;
|
||||
|
||||
if (data.files.length != 1) {
|
||||
if (platform.openDirectoryPickerDialog) {
|
||||
const result = await platform.openDirectoryPickerDialog({
|
||||
title: 'Save Spacedrop',
|
||||
multiple: false
|
||||
});
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
destinationFilePath = result;
|
||||
}
|
||||
} else {
|
||||
if (platform.saveFilePickerDialog) {
|
||||
const result = await platform.saveFilePickerDialog({
|
||||
title: 'Save Spacedrop',
|
||||
defaultPath: data.files?.[0]
|
||||
});
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
destinationFilePath = result;
|
||||
}
|
||||
}
|
||||
|
||||
if (destinationFilePath === '') return;
|
||||
await acceptSpacedrop.mutateAsync([data.id, destinationFilePath]);
|
||||
}
|
||||
},
|
||||
cancel: 'Reject'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function useSpacedropProgressToast() {
|
||||
const cancelSpacedrop = useBridgeMutation(['p2p.cancelSpacedrop']);
|
||||
|
||||
return (data: Extract<P2PEvent, { type: 'SpacedropProgress' }>) => {
|
||||
toast.info(
|
||||
(id) => ({
|
||||
title: 'Spacedrop',
|
||||
body: <SpacedropProgress toastId={id} dropId={data.id} />
|
||||
}),
|
||||
{
|
||||
id: data.id,
|
||||
duration: Infinity,
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
onClick() {
|
||||
cancelSpacedrop.mutate(data.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function SpacedropUI() {
|
||||
const node = useBridgeQuery(['nodeState']);
|
||||
const incomingRequestToast = useIncomingSpacedropToast();
|
||||
const progressToast = useSpacedropProgressToast();
|
||||
|
||||
useP2PEvents((data) => {
|
||||
if (data.type === 'SpacedropRequest') {
|
||||
incomingRequestToast(data);
|
||||
} else if (data.type === 'SpacedropProgress') {
|
||||
progressToast(data);
|
||||
} else if (data.type === 'SpacedropRejected') {
|
||||
// TODO: Add more information to this like peer name, etc in future
|
||||
toast.warning('Spacedrop Rejected');
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let open = false;
|
||||
|
||||
return subscribeSpacedropState(() => {
|
||||
if (node.data?.p2p_enabled === false) {
|
||||
toast.error({
|
||||
title: 'Spacedrop is disabled!',
|
||||
body: 'Please enable networking in settings!'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (open) return;
|
||||
open = true;
|
||||
dialogManager.create((dp) => <SpacedropDialog {...dp} />).then(() => (open = false));
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function SpacedropDialog(props: UseDialogProps) {
|
||||
const discoveredPeers = useDiscoveredPeers();
|
||||
const discoveredPeersArray = useMemo(() => [...discoveredPeers.entries()], [discoveredPeers]);
|
||||
const form = useZodForm({
|
||||
mode: 'onChange',
|
||||
// We aren't using this but it's required for the Dialog :(
|
||||
schema: z.object({
|
||||
// This field is actually required but the Zod validator is not working with select's so this is good enough for now.
|
||||
targetPeer: z.string().optional()
|
||||
})
|
||||
});
|
||||
const value = form.watch('targetPeer');
|
||||
|
||||
useEffect(() => {
|
||||
// If peer goes offline deselect it
|
||||
if (
|
||||
value !== undefined &&
|
||||
discoveredPeersArray.find(([peerId]) => peerId === value) === undefined
|
||||
)
|
||||
form.setValue('targetPeer', undefined);
|
||||
|
||||
const defaultValue = discoveredPeersArray[0]?.[0];
|
||||
// If no peer is selected, select the first one
|
||||
if (value === undefined && defaultValue) form.setValue('targetPeer', defaultValue);
|
||||
}, [form, value, discoveredPeersArray]);
|
||||
|
||||
const doSpacedrop = useBridgeMutation('p2p.spacedrop');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
// This `key` is a hack to workaround https://linear.app/spacedriveapp/issue/ENG-1208/improve-dialogs
|
||||
key={props.id}
|
||||
form={form}
|
||||
dialog={useDialog(props)}
|
||||
title="Spacedrop a File"
|
||||
loading={doSpacedrop.isLoading}
|
||||
ctaLabel="Send"
|
||||
closeLabel="Cancel"
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
doSpacedrop.mutateAsync({
|
||||
file_path: getSpacedropState().droppedFiles,
|
||||
identity: data.targetPeer! // `submitDisabled` ensures this
|
||||
})
|
||||
)}
|
||||
submitDisabled={value === undefined}
|
||||
>
|
||||
<div className="space-y-2 py-2">
|
||||
<SelectField name="targetPeer">
|
||||
{discoveredPeersArray.map(([peerId, metadata], index) => (
|
||||
<SelectOption key={peerId} value={peerId} default={index === 0}>
|
||||
{metadata.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</SelectField>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -3,9 +3,6 @@ import { useBridgeQuery, useFeatureFlag, useP2PEvents, withFeatureFlag } from '@
|
|||
import { toast } from '@sd/ui';
|
||||
|
||||
import { startPairing } from './pairing';
|
||||
import { SpacedropUI } from './Spacedrop';
|
||||
|
||||
export const SpacedropUI2 = withFeatureFlag('spacedrop', SpacedropUI);
|
||||
|
||||
// Entrypoint of P2P UI
|
||||
export function P2P() {
|
||||
|
@ -19,11 +16,7 @@ export function P2P() {
|
|||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SpacedropUI2 />
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useP2PErrorToast() {
|
||||
|
|
|
@ -382,3 +382,12 @@ body {
|
|||
transition-duration: 300ms;
|
||||
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1);
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%, 100% { transform: rotate(-1deg); }
|
||||
50% { transform: rotate(1deg); }
|
||||
}
|
||||
|
||||
.wiggle {
|
||||
animation: wiggle 200ms infinite;
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ export * from './useRedirectToNewLocation';
|
|||
export * from './useRouteTitle';
|
||||
export * from './useShortcut';
|
||||
export * from './useShowControls';
|
||||
export * from './useSpacedropState';
|
||||
export * from './useDragAndDropState';
|
||||
export * from './useTheme';
|
||||
export * from './useWindowState';
|
||||
export * from './useZodRouteParams';
|
||||
|
|
180
interface/hooks/useDragAndDropState.ts
Normal file
180
interface/hooks/useDragAndDropState.ts
Normal file
|
@ -0,0 +1,180 @@
|
|||
import { RefObject, useEffect, useId, useLayoutEffect, useState } from 'react';
|
||||
import { proxy, useSnapshot } from 'valtio';
|
||||
|
||||
import { usePlatform } from '..';
|
||||
|
||||
const dndState = proxy({
|
||||
renderRects: false
|
||||
});
|
||||
|
||||
export const toggleRenderRects = () => (dndState.renderRects = !dndState.renderRects);
|
||||
|
||||
type UseDropzoneProps = {
|
||||
// A ref to used to detect when the element is being hovered.
|
||||
// If the file drop's mouse position is above this ref it will return a 'hovered' state.
|
||||
// If none is set the 'hovered' state will never be returned.
|
||||
ref?: RefObject<HTMLDivElement>;
|
||||
// Handle the final file drop event.
|
||||
// If `ref === undefined` this will be called for every drop event.
|
||||
// If `ref !== undefined` this will only be called if the drop event is within the bounds of the ref.
|
||||
onDrop?: (paths: string[]) => void;
|
||||
// Called only once per each hover event.
|
||||
onHover?: () => void;
|
||||
// On each position of the move
|
||||
onMove?: (x: number, y: number) => void;
|
||||
// Added to the bounds of the shape and if the mouse is within it's counted as hovered.
|
||||
// This allows for the dropzone to be bigger than the actual element to make it easier to drop on.
|
||||
extendBoundsBy?: number;
|
||||
};
|
||||
|
||||
export function isWithinRect(x: number, y: number, rect: DOMRect) {
|
||||
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||
}
|
||||
|
||||
export function expandRect(rect: DOMRect, by: number) {
|
||||
return new DOMRect(rect.left - by, rect.top - by, rect.width + by * 2, rect.height + by * 2);
|
||||
}
|
||||
|
||||
export function useDropzone(opts?: UseDropzoneProps) {
|
||||
const id = useId();
|
||||
const platform = usePlatform();
|
||||
const [state, setState] = useState('idle' as 'idle' | 'active' | 'hovered');
|
||||
const debugRect = useSnapshot(dndState).renderRects;
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform.subscribeToDragAndDropEvents) return;
|
||||
|
||||
let elemBounds = opts?.ref?.current?.getBoundingClientRect();
|
||||
if (elemBounds && opts?.extendBoundsBy)
|
||||
elemBounds = expandRect(elemBounds, opts.extendBoundsBy);
|
||||
|
||||
const existingDebugRectElem = document.getElementById(id);
|
||||
if (existingDebugRectElem) existingDebugRectElem.remove();
|
||||
|
||||
if (debugRect) {
|
||||
const div = document.createElement('div');
|
||||
div.id = id;
|
||||
div.style.position = 'absolute';
|
||||
div.style.left = `${elemBounds?.left}px`;
|
||||
div.style.top = `${elemBounds?.top}px`;
|
||||
div.style.width = `${elemBounds?.width}px`;
|
||||
div.style.height = `${elemBounds?.height}px`;
|
||||
div.style.backgroundColor = 'rgba(255, 0, 0, 0.5)';
|
||||
div.style.pointerEvents = 'none';
|
||||
div.style.zIndex = '999';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
const unsub = platform.subscribeToDragAndDropEvents((event) => {
|
||||
if (finished) return;
|
||||
|
||||
if (event.type === 'Hovered') {
|
||||
const isHovered = elemBounds ? isWithinRect(event.x, event.y, elemBounds) : false;
|
||||
setState((state) => {
|
||||
// Only call it during the state transition from 'idle' -> 'active' when no `elemBounds`
|
||||
if (opts?.onHover) {
|
||||
if (elemBounds) {
|
||||
if ((state === 'idle' || state === 'active') && isHovered)
|
||||
opts.onHover();
|
||||
} else {
|
||||
if (state === 'idle') opts.onHover();
|
||||
}
|
||||
}
|
||||
|
||||
return isHovered ? 'hovered' : 'active';
|
||||
});
|
||||
|
||||
if (opts?.onMove) opts.onMove(event.x, event.y);
|
||||
} else if (event.type === 'Dropped') {
|
||||
setState('idle');
|
||||
|
||||
if (elemBounds && !isWithinRect(event.x, event.y, elemBounds)) return;
|
||||
if (opts?.onDrop) opts.onDrop(event.paths);
|
||||
} else if (event.type === 'Cancelled') {
|
||||
setState('idle');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
finished = true;
|
||||
void unsub.then((unsub) => unsub());
|
||||
};
|
||||
}, [platform, opts, debugRect, id]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
type UseOnDndEnterProps = {
|
||||
// Ref to the element that is being dragged over.
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
// Called when the file being actively drag and dropped leaves the bounds of the ref (+ `extendBoundsBy`).
|
||||
onLeave: () => void;
|
||||
// Added to the bounds of the shape and if the mouse is within it's counted as hovered.
|
||||
// This allows for the dropzone to be bigger than the actual element to make it easier to drop on.
|
||||
extendBoundsBy?: number;
|
||||
};
|
||||
|
||||
/// is responsible for running an action when the file being actively drag and dropped leaves the bounds of the ref.
|
||||
export function useOnDndLeave({ ref, onLeave, extendBoundsBy }: UseOnDndEnterProps) {
|
||||
const id = useId();
|
||||
const platform = usePlatform();
|
||||
const debugRect = useSnapshot(dndState).renderRects;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!platform.subscribeToDragAndDropEvents) return;
|
||||
|
||||
let finished = false;
|
||||
let mouseEnteredZone = false;
|
||||
let rect: DOMRect | null = null;
|
||||
|
||||
// This timeout is super important. It ensures we get the ref after it's properly rendered.
|
||||
// This is important if we render this component within a portal.
|
||||
setTimeout(() => {
|
||||
// We do this before the early return so when the element is removed the debug rect is removed.
|
||||
const existingDebugRectElem = document.getElementById(id);
|
||||
if (existingDebugRectElem) existingDebugRectElem.remove();
|
||||
|
||||
if (!ref.current) return;
|
||||
rect = ref.current.getBoundingClientRect();
|
||||
if (extendBoundsBy) rect = expandRect(rect, extendBoundsBy);
|
||||
|
||||
if (debugRect) {
|
||||
const div = document.createElement('div');
|
||||
div.id = id;
|
||||
div.style.position = 'absolute';
|
||||
div.style.left = `${rect.left}px`;
|
||||
div.style.top = `${rect.top}px`;
|
||||
div.style.width = `${rect.width}px`;
|
||||
div.style.height = `${rect.height}px`;
|
||||
div.style.backgroundColor = 'rgba(0, 255, 0, 0.5)';
|
||||
div.style.pointerEvents = 'none';
|
||||
div.style.zIndex = '999';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
});
|
||||
|
||||
const unsub = platform.subscribeToDragAndDropEvents((event) => {
|
||||
if (finished) return;
|
||||
|
||||
if (event.type === 'Hovered') {
|
||||
if (!rect) return;
|
||||
const isWithinRectNow = isWithinRect(event.x, event.y, rect);
|
||||
if (mouseEnteredZone) {
|
||||
if (!isWithinRectNow) onLeave();
|
||||
} else {
|
||||
mouseEnteredZone = isWithinRectNow;
|
||||
}
|
||||
} else if (event.type === 'Dropped') {
|
||||
mouseEnteredZone = false;
|
||||
} else if (event.type === 'Cancelled') {
|
||||
mouseEnteredZone = false;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
finished = true;
|
||||
void unsub.then((unsub) => unsub());
|
||||
};
|
||||
}, [platform, ref, onLeave, extendBoundsBy, debugRect, id]);
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
import { proxy, subscribe, useSnapshot } from 'valtio';
|
||||
|
||||
const state = proxy({
|
||||
droppedFiles: [] as string[]
|
||||
});
|
||||
|
||||
export const useSpacedropState = () => useSnapshot(state);
|
||||
|
||||
export const getSpacedropState = () => state;
|
||||
|
||||
export const subscribeSpacedropState = (callback: () => void) => subscribe(state, callback);
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { toast, TooltipProvider } from '@sd/ui';
|
||||
|
||||
import { createRoutes } from './app';
|
||||
import { SpacedropProvider } from './app/$libraryId/Spacedrop';
|
||||
import { P2P, useP2PErrorToast } from './app/p2p';
|
||||
import { Devtools } from './components/Devtools';
|
||||
import { WithPrismTheme } from './components/TextViewer/prism';
|
||||
|
@ -93,6 +94,7 @@ export function SpacedriveInterfaceRoot({ children }: PropsWithChildren) {
|
|||
<P2P />
|
||||
<Devtools />
|
||||
<WithPrismTheme />
|
||||
<SpacedropProvider />
|
||||
{children}
|
||||
</P2PContextProvider>
|
||||
</TooltipProvider>
|
||||
|
|
|
@ -3,6 +3,13 @@ import { auth } from '@sd/client';
|
|||
|
||||
export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown';
|
||||
|
||||
// This is copied from the Tauri Specta output.
|
||||
// It will only exist on desktop
|
||||
export type DragAndDropEvent =
|
||||
| { type: 'Hovered'; paths: string[]; x: number; y: number }
|
||||
| { type: 'Dropped'; paths: string[]; x: number; y: number }
|
||||
| { type: 'Cancelled' };
|
||||
|
||||
// Platform represents the underlying native layer the app is running on.
|
||||
// This could be Tauri or web.
|
||||
export type Platform = {
|
||||
|
@ -45,6 +52,7 @@ export type Platform = {
|
|||
refreshMenuBar?(): Promise<unknown>;
|
||||
setMenuBarItemState?(id: string, enabled: boolean): Promise<unknown>;
|
||||
lockAppTheme?(themeType: 'Auto' | 'Light' | 'Dark'): any;
|
||||
subscribeToDragAndDropEvents?(callback: (event: DragAndDropEvent) => void): Promise<() => void>;
|
||||
updater?: {
|
||||
useSnapshot: () => UpdateStore;
|
||||
checkForUpdate(): Promise<Update | null>;
|
||||
|
|
|
@ -6,12 +6,12 @@ import { valtioPersist } from '../lib/valito';
|
|||
import { nonLibraryClient, useBridgeQuery } from '../rspc';
|
||||
|
||||
export const features = [
|
||||
'spacedrop',
|
||||
'p2pPairing',
|
||||
'backups',
|
||||
'debugRoutes',
|
||||
'solidJsDemo',
|
||||
'hostedLocations'
|
||||
'hostedLocations',
|
||||
'debugDragAndDrop'
|
||||
] as const;
|
||||
|
||||
// This defines which backend feature flags show up in the UI.
|
||||
|
|
|
@ -128,7 +128,7 @@ importers:
|
|||
version: 2.9.0
|
||||
'@tauri-apps/cli':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
version: 1.5.8
|
||||
'@types/react':
|
||||
specifier: ^18.2.34
|
||||
version: 18.2.34
|
||||
|
@ -10790,8 +10790,8 @@ packages:
|
|||
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
|
||||
dev: false
|
||||
|
||||
/@tauri-apps/cli-darwin-arm64@1.5.6:
|
||||
resolution: {integrity: sha512-NNvG3XLtciCMsBahbDNUEvq184VZmOveTGOuy0So2R33b/6FDkuWaSgWZsR1mISpOuP034htQYW0VITCLelfqg==}
|
||||
/@tauri-apps/cli-darwin-arm64@1.5.8:
|
||||
resolution: {integrity: sha512-/AksDWfAt3NUSt8Rq2a3gTLASChKzldPVUjmJhcbtsuzFg2nx5g+hhOHxfBYzss2Te1K5mzlu+73LAMy1Sb9Gw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
@ -10799,8 +10799,8 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@tauri-apps/cli-darwin-x64@1.5.6:
|
||||
resolution: {integrity: sha512-nkiqmtUQw3N1j4WoVjv81q6zWuZFhBLya/RNGUL94oafORloOZoSY0uTZJAoeieb3Y1YK0rCHSDl02MyV2Fi4A==}
|
||||
/@tauri-apps/cli-darwin-x64@1.5.8:
|
||||
resolution: {integrity: sha512-gcfSh+BFRDdbIGpggZ1+5R5SgToz2A9LthH8P4ak3OHagDzDvI6ov6zy2UQE3XDWJKdnlna2rSR1dIuRZ0T9bA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
@ -10808,8 +10808,8 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@tauri-apps/cli-linux-arm-gnueabihf@1.5.6:
|
||||
resolution: {integrity: sha512-z6SPx+axZexmWXTIVPNs4Tg7FtvdJl9EKxYN6JPjOmDZcqA13iyqWBQal2DA/GMZ1Xqo3vyJf6EoEaKaliymPQ==}
|
||||
/@tauri-apps/cli-linux-arm-gnueabihf@1.5.8:
|
||||
resolution: {integrity: sha512-ZHQYuOBGvZubPnh5n8bNaN2VMxPBZWs26960FGQWamm9569UV/TNDHb6mD0Jjk9o0f9P+f98qNhuu5Y37P+vfQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
@ -10817,8 +10817,8 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@tauri-apps/cli-linux-arm64-gnu@1.5.6:
|
||||
resolution: {integrity: sha512-QuQjMQmpsCbzBrmtQiG4uhnfAbdFx3nzm+9LtqjuZlurc12+Mj5MTgqQ3AOwQedH3f7C+KlvbqD2AdXpwTg7VA==}
|
||||
/@tauri-apps/cli-linux-arm64-gnu@1.5.8:
|
||||
resolution: {integrity: sha512-FFs28Ew3R2EFPYKuyAIouTbp6YnR+shAmJGFNnVy7ibKHL0wxamVKqv1N5N9gUUr+EhbZu2syMBRfG9XQ5mgng==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
@ -10826,8 +10826,8 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@tauri-apps/cli-linux-arm64-musl@1.5.6:
|
||||
resolution: {integrity: sha512-8j5dH3odweFeom7bRGlfzDApWVOT4jIq8/214Wl+JeiNVehouIBo9lZGeghZBH3XKFRwEvU23i7sRVjuh2s8mg==}
|
||||
/@tauri-apps/cli-linux-arm64-musl@1.5.8:
|
||||
resolution: {integrity: sha512-dEYvNyLMmWD0jb30FNfVPXmBq6OGg6is3km+4RlGg8tZU5Zvq78ClUZtaZuER+N/hv27+Uc6UHl9X3hin8cGGw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
@ -10835,8 +10835,8 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@tauri-apps/cli-linux-x64-gnu@1.5.6:
|
||||
resolution: {integrity: sha512-gbFHYHfdEGW0ffk8SigDsoXks6USpilF6wR0nqB/JbWzbzFR/sBuLVNQlJl1RKNakyJHu+lsFxGy0fcTdoX8xA==}
|
||||
/@tauri-apps/cli-linux-x64-gnu@1.5.8:
|
||||
resolution: {integrity: sha512-ut3TDbtLXmZhz6Q4wim57PV02wG+AfuLSWRPhTL9MsPsg/E7Y6sJhv0bIMAq6SwC59RCH52ZGft6RH7samV2NQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
@ -10844,8 +10844,8 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@tauri-apps/cli-linux-x64-musl@1.5.6:
|
||||
resolution: {integrity: sha512-9v688ogoLkeFYQNgqiSErfhTreLUd8B3prIBSYUt+x4+5Kcw91zWvIh+VSxL1n3KCGGsM7cuXhkGPaxwlEh1ug==}
|
||||
/@tauri-apps/cli-linux-x64-musl@1.5.8:
|
||||
resolution: {integrity: sha512-k6ei7ETXVZlNpFOhl/8Cnj709UbEr+VuY9xKK/HgwvNfjA5f8HQ9TSKk/Um7oeT1Y61/eEcvcgF/hDURhFJDPQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
@ -10853,8 +10853,8 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@tauri-apps/cli-win32-arm64-msvc@1.5.6:
|
||||
resolution: {integrity: sha512-DRNDXFNZb6y5IZrw+lhTTA9l4wbzO4TNRBAlHAiXUrH+pRFZ/ZJtv5WEuAj9ocVSahVw2NaK5Yaold4NPAxHog==}
|
||||
/@tauri-apps/cli-win32-arm64-msvc@1.5.8:
|
||||
resolution: {integrity: sha512-l6zm31x1inkS2K5e7otUZ90XBoK+xr2KJObFCZbzmluBE+LM0fgIXCrj7xwH/f0RCUX3VY9HHx4EIo7eLGBXKQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
@ -10862,8 +10862,8 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@tauri-apps/cli-win32-ia32-msvc@1.5.6:
|
||||
resolution: {integrity: sha512-oUYKNR/IZjF4fsOzRpw0xesl2lOjhsQEyWlgbpT25T83EU113Xgck9UjtI7xemNI/OPCv1tPiaM1e7/ABdg5iA==}
|
||||
/@tauri-apps/cli-win32-ia32-msvc@1.5.8:
|
||||
resolution: {integrity: sha512-0k3YpWl6PKV4Qp2N52Sb45egXafSgQXcBaO7TIJG4EDfaEf5f6StN+hYSzdnrq9idrK5x9DDCPuebZTuJ+Q8EA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
@ -10871,8 +10871,8 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@tauri-apps/cli-win32-x64-msvc@1.5.6:
|
||||
resolution: {integrity: sha512-RmEf1os9C8//uq2hbjXi7Vgz9ne7798ZxqemAZdUwo1pv3oLVZSz1/IvZmUHPdy2e6zSeySqWu1D0Y3QRNN+dg==}
|
||||
/@tauri-apps/cli-win32-x64-msvc@1.5.8:
|
||||
resolution: {integrity: sha512-XjBg8VMswmD9JAHKlb10NRPfBVAZoiOJBbPRte+GP1BUQtqDnbIYcOLSnUCmNZoy3fUBJuKJUBT9tDCbkMr5fQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
@ -10880,21 +10880,21 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@tauri-apps/cli@1.5.6:
|
||||
resolution: {integrity: sha512-k4Y19oVCnt7WZb2TnDzLqfs7o98Jq0tUoVMv+JQSzuRDJqaVu2xMBZ8dYplEn+EccdR5SOMyzaLBJWu38TVK1A==}
|
||||
/@tauri-apps/cli@1.5.8:
|
||||
resolution: {integrity: sha512-c/mzk5vjjfxtH5uNXSc9h1eiprsolnoBcUwAa4/SZ3gxJ176CwrUKODz3cZBOnzs8omwagwgSN/j7K8NrdFL9g==}
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
optionalDependencies:
|
||||
'@tauri-apps/cli-darwin-arm64': 1.5.6
|
||||
'@tauri-apps/cli-darwin-x64': 1.5.6
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 1.5.6
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 1.5.6
|
||||
'@tauri-apps/cli-linux-arm64-musl': 1.5.6
|
||||
'@tauri-apps/cli-linux-x64-gnu': 1.5.6
|
||||
'@tauri-apps/cli-linux-x64-musl': 1.5.6
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 1.5.6
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 1.5.6
|
||||
'@tauri-apps/cli-win32-x64-msvc': 1.5.6
|
||||
'@tauri-apps/cli-darwin-arm64': 1.5.8
|
||||
'@tauri-apps/cli-darwin-x64': 1.5.8
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 1.5.8
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 1.5.8
|
||||
'@tauri-apps/cli-linux-arm64-musl': 1.5.8
|
||||
'@tauri-apps/cli-linux-x64-gnu': 1.5.8
|
||||
'@tauri-apps/cli-linux-x64-musl': 1.5.8
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 1.5.8
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 1.5.8
|
||||
'@tauri-apps/cli-win32-x64-msvc': 1.5.8
|
||||
dev: true
|
||||
|
||||
/@testing-library/dom@9.3.3:
|
||||
|
|
Loading…
Reference in a new issue