diff --git a/cross-platform/package-lock.json b/cross-platform/package-lock.json index f15eb5e..6e8b303 100644 --- a/cross-platform/package-lock.json +++ b/cross-platform/package-lock.json @@ -1,12 +1,12 @@ { "name": "pinstick-cross", - "version": "2.6.0", + "version": "2.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pinstick-cross", - "version": "2.6.0", + "version": "2.8.0", "devDependencies": { "@tauri-apps/cli": "1.5.10" } diff --git a/cross-platform/package.json b/cross-platform/package.json index 49c5c33..ede4d1a 100644 --- a/cross-platform/package.json +++ b/cross-platform/package.json @@ -1,6 +1,6 @@ { "name": "pinstick-cross", - "version": "2.6.0", + "version": "2.8.0", "private": true, "type": "module", "scripts": { diff --git a/cross-platform/src-tauri/Cargo.lock b/cross-platform/src-tauri/Cargo.lock index 9b60cdd..b51a6e1 100644 --- a/cross-platform/src-tauri/Cargo.lock +++ b/cross-platform/src-tauri/Cargo.lock @@ -285,8 +285,24 @@ dependencies = [ "block", "cocoa-foundation", "core-foundation", - "core-graphics", - "foreign-types", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics 0.23.2", + "foreign-types 0.5.0", "libc", "objc", ] @@ -352,7 +368,20 @@ dependencies = [ "bitflags 1.3.2", "core-foundation", "core-graphics-types", - "foreign-types", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", "libc", ] @@ -722,7 +751,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -731,6 +781,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2000,12 +2056,16 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pinstick" -version = "2.6.0" +version = "2.8.0" dependencies = [ + "cocoa 0.25.0", + "raw-window-handle", "serde", "serde_json", "tauri", "tauri-build", + "windows 0.48.0", + "x11", ] [[package]] @@ -2752,9 +2812,9 @@ dependencies = [ "bitflags 1.3.2", "cairo-rs", "cc", - "cocoa", + "cocoa 0.24.1", "core-foundation", - "core-graphics", + "core-graphics 0.22.3", "crossbeam-channel", "dispatch", "gdk", @@ -2825,7 +2885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae1f57c291a6ab8e1d2e6b8ad0a35ff769c9925deb8a89de85425ff08762d0c" dependencies = [ "anyhow", - "cocoa", + "cocoa 0.24.1", "dirs-next", "dunce", "embed_plist", @@ -2952,7 +3012,7 @@ version = "0.14.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce361fec1e186705371f1c64ae9dd2a3a6768bc530d0a2d5e75a634bb416ad4d" dependencies = [ - "cocoa", + "cocoa 0.24.1", "gtk", "percent-encoding", "rand 0.8.5", @@ -3949,8 +4009,8 @@ checksum = "4a2a144c3ab5e83e04724bc8e67cea552ffae413185fda459fafdae173fd985d" dependencies = [ "base64 0.13.1", "block", - "cocoa", - "core-graphics", + "cocoa 0.24.1", + "core-graphics 0.22.3", "crossbeam-channel", "dunce", "gdk", diff --git a/cross-platform/src-tauri/Cargo.toml b/cross-platform/src-tauri/Cargo.toml index daa7b77..0e06faa 100644 --- a/cross-platform/src-tauri/Cargo.toml +++ b/cross-platform/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinstick" -version = "2.6.0" +version = "2.8.0" description = "PinStick cross-platform note pinning app" authors = ["SillyLittleTech"] edition = "2021" @@ -8,7 +8,17 @@ edition = "2021" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tauri = { version = "1.5", features = ["custom-protocol"] } +tauri = { version = "1.5", features = ["custom-protocol", "macos-private-api"] } + +[target.'cfg(target_os = "macos")'.dependencies] +cocoa = "0.25" + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.48", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] } + +[target.'cfg(target_os = "linux")'.dependencies] +raw-window-handle = "0.5" +x11 = { version = "2", features = ["xlib"] } [features] default = ["custom-protocol"] diff --git a/cross-platform/src-tauri/src/main.rs b/cross-platform/src-tauri/src/main.rs index 3b996da..f4c4169 100644 --- a/cross-platform/src-tauri/src/main.rs +++ b/cross-platform/src-tauri/src/main.rs @@ -10,28 +10,343 @@ struct PinState { pinned: bool, } -/// Stores pin state; Mutex provides thread-safe interior mutability for concurrent access. -/// Poison recovery via `unwrap_or_else` is safe because the bool has no invariants and can be safely overwritten. +#[derive(Serialize)] +struct OverlayStateResponse { + enabled: bool, + opacity: f32, +} + +#[derive(Serialize)] +struct OverlayInputAvailability { + click_through: bool, + platform: String, + message: Option, +} + +#[derive(Clone)] +struct OverlayState { + enabled: bool, + opacity: f32, + saved_pin: bool, +} + +impl Default for OverlayState { + fn default() -> Self { + Self { + enabled: false, + opacity: 0.7, + saved_pin: false, + } + } +} + struct PinStore(Mutex); +struct OverlayStore(Mutex); + +fn clamp_opacity(opacity: f32) -> f32 { + opacity.clamp(0.4, 1.0) +} + +fn platform_name() -> &'static str { + #[cfg(target_os = "macos")] + { + return "macos"; + } + #[cfg(target_os = "windows")] + { + return "windows"; + } + #[cfg(target_os = "linux")] + { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + return "linux-wayland"; + } + return "linux-x11"; + } + #[allow(unreachable_code)] + "unknown" +} + +fn overlay_input_available() -> OverlayInputAvailability { + #[cfg(target_os = "linux")] + { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + return OverlayInputAvailability { + click_through: false, + platform: "linux-wayland".into(), + message: Some( + "Full click-through isn't available on Wayland. Use the toolbar to exit or change opacity.".into(), + ), + }; + } + } + + OverlayInputAvailability { + click_through: true, + platform: platform_name().into(), + message: None, + } +} + +#[cfg(target_os = "macos")] +fn set_window_opacity(window: &tauri::Window, opacity: f32) -> Result<(), String> { + use cocoa::appkit::NSWindow; + use cocoa::base::id; + + let ns_window = window.ns_window().map_err(|e| e.to_string())? as id; + unsafe { + ns_window.setAlphaValue_(opacity as _); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn set_window_opacity(_window: &tauri::Window, _opacity: f32) -> Result<(), String> { + // Opacity is applied via CSS on Windows (avoids HWND API version conflicts with Tauri). + Ok(()) +} + +#[cfg(target_os = "linux")] +fn set_window_opacity(_window: &tauri::Window, _opacity: f32) -> Result<(), String> { + // Opacity is applied via CSS on Linux. + Ok(()) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +fn set_window_opacity(_window: &tauri::Window, _opacity: f32) -> Result<(), String> { + Ok(()) +} + +fn apply_overlay_window( + window: &tauri::Window, + enabled: bool, + opacity: f32, + click_through: bool, +) -> Result<(), String> { + if enabled { + window + .set_always_on_top(true) + .map_err(|e| format!("Failed to set always on top: {e}"))?; + set_window_opacity(window, opacity)?; + if click_through { + window + .set_ignore_cursor_events(true) + .map_err(|e| format!("Failed to set click-through: {e}"))?; + } else { + window + .set_ignore_cursor_events(false) + .map_err(|e| format!("Failed to disable click-through: {e}"))?; + } + } else { + set_window_opacity(window, 1.0)?; + window + .set_ignore_cursor_events(false) + .map_err(|e| format!("Failed to restore click-through: {e}"))?; + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn get_cursor_position_impl(window: &tauri::Window) -> Result<(f64, f64), String> { + use cocoa::appkit::{NSEvent, NSWindow}; + use cocoa::base::{id, nil}; + use cocoa::foundation::NSRect; + + let ns_window = window.ns_window().map_err(|e| e.to_string())? as id; + let scale = window.scale_factor().map_err(|e| e.to_string())?; + + unsafe { + let mouse = NSEvent::mouseLocation(nil); + let frame: NSRect = ns_window.frame(); + let x = (mouse.x - frame.origin.x) / scale; + let y = (frame.origin.y + frame.size.height - mouse.y) / scale; + Ok((x, y)) + } +} + +#[cfg(target_os = "windows")] +fn get_cursor_position_impl(window: &tauri::Window) -> Result<(f64, f64), String> { + use windows::Win32::Foundation::POINT; + use windows::Win32::UI::WindowsAndMessaging::GetCursorPos; + + let outer = window + .outer_position() + .map_err(|e| format!("Failed to get window position: {e}"))?; + let scale = window.scale_factor().map_err(|e| e.to_string())?; + + unsafe { + let mut point = POINT::default(); + GetCursorPos(&mut point) + .as_bool() + .then_some(()) + .ok_or_else(|| "GetCursorPos failed".to_string())?; + let x = (point.x as f64 - outer.x as f64) / scale; + let y = (point.y as f64 - outer.y as f64) / scale; + Ok((x, y)) + } +} + +#[cfg(target_os = "linux")] +fn get_cursor_position_impl(window: &tauri::Window) -> Result<(f64, f64), String> { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + return Err("wayland_cursor_unavailable".into()); + } + + use raw_window_handle::{HasRawWindowHandle, RawWindowHandle}; // trait for raw_window_handle() + use x11::xlib; + + let handle = window + .raw_window_handle() + .map_err(|e| format!("raw_window_handle failed: {e}"))?; + + match handle { + RawWindowHandle::Xlib(xlib_handle) => unsafe { + let display = xlib_handle.display as *mut xlib::Display; + let mut root_return: xlib::Window = 0; + let mut child_return: xlib::Window = 0; + let mut root_x: i32 = 0; + let mut root_y: i32 = 0; + let mut win_x: i32 = 0; + let mut win_y: i32 = 0; + let mut mask: u32 = 0; + + let ok = xlib::XQueryPointer( + display, + xlib_handle.window as u64, + &mut root_return, + &mut child_return, + &mut root_x, + &mut root_y, + &mut win_x, + &mut win_y, + &mut mask, + ); + if ok == 0 { + return Err("XQueryPointer failed".into()); + } + + let scale = window.scale_factor().map_err(|e| e.to_string())?; + Ok((win_x as f64 / scale, win_y as f64 / scale)) + }, + RawWindowHandle::Wayland(_) => Err("wayland_cursor_unavailable".into()), + _ => Err("unsupported_linux_display".into()), + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +fn get_cursor_position_impl(_window: &tauri::Window) -> Result<(f64, f64), String> { + Err("cursor_position_unavailable".into()) +} #[tauri::command] -fn toggle_pin(window: tauri::Window, store: State) -> Result { - // Recover from a poisoned mutex by taking the inner value. - let mut pinned = store.0.lock().unwrap_or_else(|e| e.into_inner()); +fn toggle_pin( + window: tauri::Window, + pin_store: State, + overlay_store: State, +) -> Result { + let overlay = overlay_store.0.lock().unwrap_or_else(|e| e.into_inner()); + if overlay.enabled { + return Err("Disable overlay before changing pin state.".into()); + } + drop(overlay); + let mut pinned = pin_store.0.lock().unwrap_or_else(|e| e.into_inner()); let next = !*pinned; window .set_always_on_top(next) .map_err(|e| format!("Failed to toggle pin: {e}"))?; - *pinned = next; Ok(PinState { pinned: next }) } +#[tauri::command] +fn toggle_overlay( + window: tauri::Window, + pin_store: State, + overlay_store: State, +) -> Result { + let availability = overlay_input_available(); + let mut overlay = overlay_store.0.lock().unwrap_or_else(|e| e.into_inner()); + let mut pinned = pin_store.0.lock().unwrap_or_else(|e| e.into_inner()); + + let next = !overlay.enabled; + if next { + overlay.saved_pin = *pinned; + overlay.enabled = true; + apply_overlay_window(&window, true, overlay.opacity, availability.click_through)?; + } else { + overlay.enabled = false; + apply_overlay_window(&window, false, overlay.opacity, availability.click_through)?; + window + .set_always_on_top(overlay.saved_pin) + .map_err(|e| format!("Failed to restore pin state: {e}"))?; + *pinned = overlay.saved_pin; + } + + Ok(OverlayStateResponse { + enabled: overlay.enabled, + opacity: overlay.opacity, + }) +} + +#[tauri::command] +fn set_overlay_opacity( + opacity: f32, + window: tauri::Window, + overlay_store: State, +) -> Result { + let mut overlay = overlay_store.0.lock().unwrap_or_else(|e| e.into_inner()); + overlay.opacity = clamp_opacity(opacity); + + if overlay.enabled { + set_window_opacity(&window, overlay.opacity)?; + } + + Ok(OverlayStateResponse { + enabled: overlay.enabled, + opacity: overlay.opacity, + }) +} + +#[tauri::command] +fn set_ignore_cursor_events(ignore: bool, window: tauri::Window) -> Result<(), String> { + window + .set_ignore_cursor_events(ignore) + .map_err(|e| format!("Failed to set ignore cursor events: {e}")) +} + +#[tauri::command] +fn get_cursor_position(window: tauri::Window) -> Result<(f64, f64), String> { + get_cursor_position_impl(&window) +} + +#[tauri::command] +fn check_overlay_input_available() -> OverlayInputAvailability { + overlay_input_available() +} + +#[tauri::command] +fn get_overlay_state(overlay_store: State) -> Result { + let overlay = overlay_store.0.lock().unwrap_or_else(|e| e.into_inner()); + Ok(OverlayStateResponse { + enabled: overlay.enabled, + opacity: overlay.opacity, + }) +} + fn main() { tauri::Builder::default() .manage(PinStore(Mutex::new(false))) - .invoke_handler(tauri::generate_handler![toggle_pin]) + .manage(OverlayStore(Mutex::new(OverlayState::default()))) + .invoke_handler(tauri::generate_handler![ + toggle_pin, + toggle_overlay, + set_overlay_opacity, + set_ignore_cursor_events, + get_cursor_position, + check_overlay_input_available, + get_overlay_state, + ]) .setup(|app| { if let Some(window) = app.get_window("main") { window.set_title("PinStick")?; diff --git a/cross-platform/src-tauri/tauri.conf.json b/cross-platform/src-tauri/tauri.conf.json index 48c197d..d417f76 100644 --- a/cross-platform/src-tauri/tauri.conf.json +++ b/cross-platform/src-tauri/tauri.conf.json @@ -8,11 +8,12 @@ }, "package": { "productName": "PinStick", - "version": "2.6.0" + "version": "2.8.0" }, "tauri": { + "macOSPrivateApi": true, "security": { - "csp": "default-src 'self' tauri: asset: data: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data: blob:" + "csp": "default-src 'self' tauri: asset: data: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data: blob: https:; media-src 'self' asset: data: blob: https:" }, "bundle": { "active": true, @@ -33,7 +34,8 @@ "height": 320, "resizable": true, "fullscreen": false, - "center": true + "center": true, + "transparent": true } ] } diff --git a/cross-platform/src/index.html b/cross-platform/src/index.html index 13a7231..1b264f7 100644 --- a/cross-platform/src/index.html +++ b/cross-platform/src/index.html @@ -7,16 +7,47 @@ -
+
+ +
+
+ + + 70%
Ready
+
- +
+ + + +
diff --git a/cross-platform/src/main.js b/cross-platform/src/main.js index 7d84f1e..740faf2 100644 --- a/cross-platform/src/main.js +++ b/cross-platform/src/main.js @@ -1,6 +1,36 @@ const pinBtn = document.getElementById("pin-btn"); +const overlayBtn = document.getElementById("overlay-btn"); +const headerControlsEl = document.getElementById("header-controls"); +const overlayOpacitySliderEl = document.getElementById("overlay-opacity-slider"); +const overlayOpacityValueEl = document.getElementById("overlay-opacity-value"); +const overlayNoticeEl = document.getElementById("overlay-notice"); const statusEl = document.getElementById("status"); const noteEl = document.getElementById("note"); +const noteSurfaceEl = document.getElementById("note-surface"); +const mediaStageEl = document.getElementById("media-stage"); +const mediaFileInputEl = document.getElementById("media-file-input"); + +const NOTE_STORAGE_KEY = "pinstick-note"; +const LOCAL_MEDIA_STORAGE_KEY = "pinstick-local-media"; +const OVERLAY_OPACITY_STORAGE_KEY = "pinstick-overlay-opacity"; + +const OVERLAY_POLL_MS = 60; +const OVERLAY_POLL_FAIL_MAX = 3; +const DEFAULT_OVERLAY_OPACITY = 0.7; + +let overlayActive = false; +let overlayPollId = null; +let overlayOpacity = DEFAULT_OVERLAY_OPACITY; +let overlayClickThroughAvailable = true; +let overlayPollFailures = 0; + +const IMAGE_EXTENSIONS = /\.(avif|bmp|gif|jpe?g|png|svg|webp)(\?.*)?(#.*)?$/i; +const VIDEO_EXTENSIONS = /\.(m4v|mov|mp4|ogg|ogv|webm)(\?.*)?(#.*)?$/i; +const URL_PATTERN = /https?:\/\/[^\s<>"')\]]+/gi; +const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/gi; +const TRAILING_PUNCTUATION_PATTERN = /[),.!?;:]+$/; +const LOCAL_IMAGE_MIME = /^image\//i; +const LOCAL_VIDEO_MIME = /^video\//i; const TAURI = window.__TAURI__; const invoke = TAURI && TAURI.tauri ? TAURI.tauri.invoke : null; @@ -9,30 +39,489 @@ function setStatus(message) { statusEl.textContent = message; } +function normalizeUrl(rawUrl) { + return rawUrl.replace(TRAILING_PUNCTUATION_PATTERN, ""); +} + +function classifyMediaUrl(url) { + if (IMAGE_EXTENSIONS.test(url)) { + return "image"; + } + if (VIDEO_EXTENSIONS.test(url)) { + return "video"; + } + return null; +} + +function classifyLocalFile(file) { + if (LOCAL_IMAGE_MIME.test(file.type)) { + return "image"; + } + if (LOCAL_VIDEO_MIME.test(file.type)) { + return "video"; + } + const name = file.name.toLowerCase(); + if (IMAGE_EXTENSIONS.test(name)) { + return "image"; + } + if (VIDEO_EXTENSIONS.test(name)) { + return "video"; + } + return null; +} + +function collectMediaUrls(noteText) { + const seen = new Set(); + const mediaItems = []; + + MARKDOWN_IMAGE_PATTERN.lastIndex = 0; + URL_PATTERN.lastIndex = 0; + + let match = MARKDOWN_IMAGE_PATTERN.exec(noteText); + while (match) { + const url = normalizeUrl(match[1]); + const type = classifyMediaUrl(url); + if (type && !seen.has(url)) { + seen.add(url); + mediaItems.push({ source: "remote", url, type }); + } + match = MARKDOWN_IMAGE_PATTERN.exec(noteText); + } + + match = URL_PATTERN.exec(noteText); + while (match) { + const url = normalizeUrl(match[0]); + const type = classifyMediaUrl(url); + if (type && !seen.has(url)) { + seen.add(url); + mediaItems.push({ source: "remote", url, type }); + } + match = URL_PATTERN.exec(noteText); + } + + return mediaItems; +} + +function loadLocalMedia() { + try { + const raw = localStorage.getItem(LOCAL_MEDIA_STORAGE_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw); + if ( + parsed && + (parsed.type === "image" || parsed.type === "video") && + typeof parsed.dataUrl === "string" + ) { + return { source: "local", type: parsed.type, dataUrl: parsed.dataUrl }; + } + } catch (err) { + console.warn("Unable to read local media:", err); + } + return null; +} + +function saveLocalMedia(mediaItem) { + // Large videos may exceed localStorage quota; IndexedDB would be a future improvement. + localStorage.setItem( + LOCAL_MEDIA_STORAGE_KEY, + JSON.stringify({ type: mediaItem.type, dataUrl: mediaItem.dataUrl }), + ); +} + +function clearLocalMedia() { + localStorage.removeItem(LOCAL_MEDIA_STORAGE_KEY); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function removeUrlFromNote(noteText, url) { + let updated = noteText; + const markdownPattern = new RegExp( + `!\\[[^\\]]*]\\(${escapeRegExp(url)}\\)\\s*`, + "g", + ); + updated = updated.replace(markdownPattern, ""); + updated = updated.split(url).join(""); + return updated.replace(/\n{3,}/g, "\n\n").trim(); +} + +function hasActiveMedia(noteText) { + if (loadLocalMedia()) { + return true; + } + return collectMediaUrls(noteText).length > 0; +} + +function updateEditedState(noteText) { + const edited = noteText.trim().length > 0 || hasActiveMedia(noteText); + document.title = edited ? "PinStick • Edited" : "PinStick"; + setStatus(edited ? "Edited" : "Ready"); +} + +function saveNote(value) { + localStorage.setItem(NOTE_STORAGE_KEY, value); +} + +function createMediaEmbed(mediaItem) { + const embedEl = document.createElement("article"); + embedEl.className = "media-embed"; + embedEl.title = "Double-click to remove"; + + const src = mediaItem.source === "local" ? mediaItem.dataUrl : mediaItem.url; + const errorEl = document.createElement("p"); + errorEl.className = "media-error"; + errorEl.hidden = true; + errorEl.textContent = + mediaItem.type === "image" + ? "Unable to load this image." + : "Unable to load this video."; + + const mediaEl = document.createElement(mediaItem.type === "image" ? "img" : "video"); + mediaEl.src = src; + if (mediaItem.source === "remote") { + mediaEl.referrerPolicy = "no-referrer"; + } + + if (mediaItem.type === "image") { + mediaEl.alt = "Embedded image"; + mediaEl.loading = "lazy"; + } else { + mediaEl.controls = true; + mediaEl.preload = "metadata"; + mediaEl.playsInline = true; + } + + mediaEl.addEventListener( + "error", + () => { + errorEl.hidden = false; + }, + { once: true }, + ); + + embedEl.addEventListener("dblclick", (event) => { + event.preventDefault(); + if (mediaItem.source === "local") { + clearLocalMedia(); + } else { + const updated = removeUrlFromNote(noteEl.value, mediaItem.url); + noteEl.value = updated; + saveNote(updated); + } + syncNoteView(noteEl.value); + }); + + embedEl.appendChild(mediaEl); + embedEl.appendChild(errorEl); + return embedEl; +} + +function syncNoteView(noteText) { + if (!noteSurfaceEl || !mediaStageEl) { + return; + } + + const remoteItems = collectMediaUrls(noteText); + const localItem = loadLocalMedia(); + const mediaItems = localItem ? [localItem, ...remoteItems] : remoteItems; + const inMediaMode = mediaItems.length > 0; + + noteSurfaceEl.classList.toggle("media-mode", inMediaMode); + mediaStageEl.hidden = !inMediaMode; + mediaStageEl.textContent = ""; + + if (inMediaMode) { + const fragment = document.createDocumentFragment(); + mediaItems.forEach((item) => { + fragment.appendChild(createMediaEmbed(item)); + }); + mediaStageEl.appendChild(fragment); + } + + updateEditedState(noteText); +} + function loadNote() { let saved = ""; try { - const stored = localStorage.getItem("pinstick-note"); + const stored = localStorage.getItem(NOTE_STORAGE_KEY); saved = stored === null ? "" : stored; } catch (err) { console.warn("Unable to read saved note:", err); setStatus("Failed to load note; starting empty"); } noteEl.value = saved; - setEdited(saved.length > 0); + syncNoteView(saved); } -function setEdited(isEdited) { - document.title = isEdited ? "PinStick • Edited" : "PinStick"; - setStatus(isEdited ? "Edited" : "Ready"); +function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); } -function saveNote(value) { - localStorage.setItem("pinstick-note", value); +async function handleLocalFileSelected(file) { + const type = classifyLocalFile(file); + if (!type) { + setStatus("Unsupported file type"); + return; + } + + try { + const dataUrl = await readFileAsDataUrl(file); + saveLocalMedia({ type, dataUrl }); + syncNoteView(noteEl.value); + setStatus("Media added"); + } catch (err) { + console.error(err); + setStatus("Failed to load file"); + } +} + +function openMediaFilePicker() { + if (!mediaFileInputEl) { + return; + } + mediaFileInputEl.value = ""; + mediaFileInputEl.click(); +} + +function loadOverlayOpacity() { + try { + const stored = localStorage.getItem(OVERLAY_OPACITY_STORAGE_KEY); + if (stored !== null) { + const parsed = Number.parseFloat(stored); + if (!Number.isNaN(parsed)) { + overlayOpacity = Math.min(1, Math.max(0.4, parsed)); + } + } + } catch (err) { + console.warn("Unable to read overlay opacity:", err); + } + applyOverlayOpacityCss(overlayOpacity); + syncOverlayOpacityUi(); +} + +function saveOverlayOpacity(value) { + overlayOpacity = Math.min(1, Math.max(0.4, value)); + localStorage.setItem(OVERLAY_OPACITY_STORAGE_KEY, String(overlayOpacity)); + applyOverlayOpacityCss(overlayOpacity); +} + +function applyOverlayOpacityCss(value) { + document.documentElement.style.setProperty("--overlay-opacity", String(value)); +} + +function opacityToSliderPercent(opacity) { + return Math.round(opacity * 100); +} + +function sliderPercentToOpacity(percent) { + return Math.min(1, Math.max(0.4, percent / 100)); +} + +function syncOverlayOpacityUi() { + if (!overlayOpacitySliderEl) { + return; + } + const percent = opacityToSliderPercent(overlayOpacity); + overlayOpacitySliderEl.value = String(percent); + if (overlayOpacityValueEl) { + overlayOpacityValueEl.textContent = `${percent}%`; + } +} + +function showOverlayNotice(message) { + if (!overlayNoticeEl || !message) { + return; + } + overlayNoticeEl.textContent = message; + overlayNoticeEl.hidden = false; +} + +function hideOverlayNotice() { + if (overlayNoticeEl) { + overlayNoticeEl.hidden = true; + overlayNoticeEl.textContent = ""; + } +} + +function setOverlayUi(enabled) { + overlayActive = enabled; + document.body.classList.toggle("overlay-mode", enabled); + if (overlayBtn) { + overlayBtn.classList.toggle("overlay-active", enabled); + overlayBtn.title = enabled ? "Exit overlay mode" : "Overlay mode"; + overlayBtn.setAttribute("aria-label", enabled ? "Exit overlay mode" : "Overlay mode"); + } + if (pinBtn) { + pinBtn.disabled = enabled; + } + if (noteEl) { + noteEl.disabled = enabled; + } + if (!enabled) { + hideOverlayNotice(); + overlayPollFailures = 0; + } + syncOverlayOpacityUi(); +} + +function isPointInRect(cursorX, cursorY, rect) { + return ( + cursorX >= rect.left && + cursorX <= rect.right && + cursorY >= rect.top && + cursorY <= rect.bottom + ); +} + +function isCursorOverInteractiveControls(cursorX, cursorY) { + if (headerControlsEl) { + const headerRect = headerControlsEl.getBoundingClientRect(); + if (isPointInRect(cursorX, cursorY, headerRect)) { + return true; + } + } + return false; +} + +async function handleOverlayPollFailure(err) { + overlayPollFailures += 1; + console.warn("Overlay click-through sync failed:", err); + if (overlayPollFailures >= OVERLAY_POLL_FAIL_MAX) { + stopOverlayPoll(); + if (invoke) { + try { + await invoke("set_ignore_cursor_events", { ignore: false }); + } catch (innerErr) { + console.warn("Failed to restore cursor events:", innerErr); + } + } + showOverlayNotice( + "Overlay click-through is limited. Use the toolbar to exit or change opacity.", + ); + } +} + +async function syncOverlayClickThrough() { + if (!invoke || !overlayActive || !overlayClickThroughAvailable) { + return; + } + try { + const [x, y] = await invoke("get_cursor_position"); + const overControl = isCursorOverInteractiveControls(x, y); + await invoke("set_ignore_cursor_events", { ignore: !overControl }); + overlayPollFailures = 0; + } catch (err) { + await handleOverlayPollFailure(err); + } +} + +function startOverlayPoll() { + stopOverlayPoll(); + overlayPollId = window.setInterval(syncOverlayClickThrough, OVERLAY_POLL_MS); +} + +function stopOverlayPoll() { + if (overlayPollId !== null) { + window.clearInterval(overlayPollId); + overlayPollId = null; + } +} + +async function applyOverlayOpacity(value) { + saveOverlayOpacity(value); + syncOverlayOpacityUi(); + if (!invoke || !overlayActive) { + return; + } + try { + await invoke("set_overlay_opacity", { opacity: overlayOpacity }); + } catch (err) { + console.error(err); + setStatus("Failed to set overlay opacity"); + } +} + +async function loadOverlayAvailability() { + if (!invoke) { + overlayClickThroughAvailable = false; + return; + } + try { + const availability = await invoke("check_overlay_input_available"); + overlayClickThroughAvailable = Boolean( + availability && availability.click_through, + ); + if (availability && availability.message) { + showOverlayNotice(availability.message); + } + } catch (err) { + console.warn("Unable to check overlay availability:", err); + overlayClickThroughAvailable = true; + } +} + +async function toggleOverlay() { + if (!invoke) { + return; + } + const togglingOn = !overlayActive; + if (togglingOn) { + overlayBtn.disabled = true; + } + setStatus("Toggling overlay…"); + try { + if (togglingOn) { + await loadOverlayAvailability(); + } + await invoke("set_overlay_opacity", { opacity: overlayOpacity }); + const result = await invoke("toggle_overlay"); + const enabled = Boolean(result && result.enabled); + if (result && typeof result.opacity === "number") { + saveOverlayOpacity(result.opacity); + } + setOverlayUi(enabled); + if (enabled) { + await invoke("set_ignore_cursor_events", { ignore: false }); + await invoke("set_overlay_opacity", { opacity: overlayOpacity }); + overlayPollFailures = 0; + if (overlayClickThroughAvailable) { + hideOverlayNotice(); + startOverlayPoll(); + } else if (invoke) { + await invoke("set_ignore_cursor_events", { ignore: false }); + } + setStatus("Overlay on"); + } else { + stopOverlayPoll(); + await invoke("set_ignore_cursor_events", { ignore: false }); + setStatus("Overlay off"); + } + } catch (err) { + console.error(err); + setStatus("Failed to toggle overlay"); + } finally { + if (togglingOn) { + overlayBtn.disabled = false; + } + } } async function togglePin() { if (!invoke) return; + if (overlayActive) { + setStatus("Disable overlay before pinning"); + return; + } pinBtn.disabled = true; setStatus("Toggling pin…"); try { @@ -55,25 +544,76 @@ async function togglePin() { function init() { loadNote(); + loadOverlayOpacity(); if (!invoke) { pinBtn.disabled = true; + if (overlayBtn) { + overlayBtn.disabled = true; + } setStatus("Tauri API unavailable"); } else if (TAURI && TAURI.app) { - // Briefly show the app version so users can verify which build is running - TAURI.app.getVersion().then((version) => { - const previous = statusEl.textContent; - setStatus(`v${version}`); - setTimeout(() => setStatus(previous), 2000); - }).catch(() => { /* non-critical */ }); + TAURI.app + .getVersion() + .then((version) => { + const previous = statusEl.textContent; + setStatus(`v${version}`); + setTimeout(() => setStatus(previous), 2000); + }) + .catch(() => { + /* non-critical */ + }); } noteEl.addEventListener("input", (e) => { const value = e.target.value; saveNote(value); - setEdited(value.length > 0); + syncNoteView(value); + }); + + noteEl.addEventListener("dblclick", () => { + if (noteEl.value.trim() !== "" || hasActiveMedia(noteEl.value)) { + return; + } + openMediaFilePicker(); }); + if (mediaFileInputEl) { + mediaFileInputEl.addEventListener("change", () => { + const file = mediaFileInputEl.files && mediaFileInputEl.files[0]; + if (file) { + handleLocalFileSelected(file); + } + }); + } + pinBtn.addEventListener("click", togglePin); + + if (overlayBtn) { + overlayBtn.addEventListener("click", toggleOverlay); + } + + if (overlayOpacitySliderEl) { + overlayOpacitySliderEl.addEventListener("input", () => { + const percent = Number.parseInt(overlayOpacitySliderEl.value, 10); + if (Number.isNaN(percent)) { + return; + } + applyOverlayOpacity(sliderPercentToOpacity(percent)); + if (overlayActive) { + syncOverlayClickThrough(); + } + }); + overlayOpacitySliderEl.addEventListener("pointerdown", async () => { + if (!invoke || !overlayActive) { + return; + } + try { + await invoke("set_ignore_cursor_events", { ignore: false }); + } catch (err) { + console.warn("Failed to enable slider interaction:", err); + } + }); + } } document.addEventListener("DOMContentLoaded", init); diff --git a/cross-platform/src/styles.css b/cross-platform/src/styles.css index 4fd2447..9647594 100644 --- a/cross-platform/src/styles.css +++ b/cross-platform/src/styles.css @@ -2,23 +2,103 @@ box-sizing: border-box; } +:root { + color-scheme: light dark; + --bg: #f8f8f8; + --fg: #222222; + --header-bg: #ffffff; + --header-bg-overlay: rgba(255, 255, 255, 0.92); + --header-border: #e0e0e0; + --control-hover: rgba(0, 0, 0, 0.07); + --control-active-pin: rgba(0, 0, 0, 0.1); + --control-active-pin-border: rgba(0, 0, 0, 0.15); + --control-active-overlay: rgba(0, 80, 200, 0.15); + --control-active-overlay-border: rgba(0, 80, 200, 0.35); + --status-fg: #666666; + --note-bg: #ffffff; + --note-border: #d0d0d0; + --slider-track: #c8c8c8; + --slider-thumb: #ffffff; + --slider-thumb-border: #888888; + --notice-fg: #5a4a12; + --notice-bg: #fff8e6; + --notice-border: #ecd88a; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1e1e1e; + --fg: #e0e0e0; + --header-bg: #2a2a2a; + --header-bg-overlay: rgba(42, 42, 42, 0.92); + --header-border: #3a3a3a; + --control-hover: rgba(255, 255, 255, 0.1); + --control-active-pin: rgba(255, 255, 255, 0.15); + --control-active-pin-border: rgba(255, 255, 255, 0.2); + --control-active-overlay: rgba(100, 160, 255, 0.2); + --control-active-overlay-border: rgba(100, 160, 255, 0.4); + --status-fg: #888888; + --note-bg: #2a2a2a; + --note-border: #3a3a3a; + --slider-track: #555555; + --slider-thumb: #e0e0e0; + --slider-thumb-border: #888888; + --notice-fg: #e8d9a8; + --notice-bg: #3a3420; + --notice-border: #5a5030; + } +} + +html { + background: transparent; +} + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #f8f8f8; - color: #222; + background: var(--bg); + color: var(--fg); height: 100vh; display: flex; flex-direction: column; } -header { +body.overlay-mode { + background: color-mix(in srgb, var(--bg) calc(var(--overlay-opacity, 0.7) * 100%), transparent); +} + +body.overlay-mode main { + pointer-events: none; +} + +body.overlay-mode #header-controls { + pointer-events: auto; + background: var(--header-bg-overlay); + border-bottom: 1px solid var(--header-border); +} + +.overlay-notice { + padding: 8px 12px; + font-size: 12px; + line-height: 1.4; + color: var(--notice-fg); + background: var(--notice-bg); + border-bottom: 1px solid var(--notice-border); +} + +.overlay-notice[hidden] { + display: none; +} + +#header-controls { display: flex; align-items: center; justify-content: space-between; + gap: 8px; padding: 3px 8px; - background: #ffffff; - border-bottom: 1px solid #e0e0e0; + background: var(--header-bg); + border-bottom: 1px solid var(--header-border); + flex-shrink: 0; } header button { @@ -26,6 +106,7 @@ header button { font-size: 14px; border: 1px solid transparent; background: transparent; + color: var(--fg); border-radius: 5px; cursor: pointer; line-height: 1.4; @@ -33,12 +114,27 @@ header button { } header button:hover { - background: rgba(0, 0, 0, 0.07); + background: var(--control-hover); } header button.pinned { - background: rgba(0, 0, 0, 0.1); - border-color: rgba(0, 0, 0, 0.15); + background: var(--control-active-pin); + border-color: var(--control-active-pin-border); +} + +header button.overlay-active { + background: var(--control-active-overlay); + border-color: var(--control-active-overlay-border); +} + +header .left { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +header .right { + flex-shrink: 0; } header button:disabled { @@ -46,56 +142,127 @@ header button:disabled { cursor: not-allowed; } +.overlay-opacity-control { + display: none; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + max-width: 200px; + margin: 0 auto; +} + +body.overlay-mode .overlay-opacity-control { + display: flex; +} + +.overlay-opacity-label { + font-size: 11px; + color: var(--status-fg); + white-space: nowrap; + flex-shrink: 0; +} + +.overlay-opacity-value { + font-size: 11px; + color: var(--status-fg); + min-width: 2.5em; + text-align: right; + flex-shrink: 0; +} + +#overlay-opacity-slider { + flex: 1; + min-width: 60px; + margin: 0; + accent-color: #3b7ddd; + cursor: pointer; +} + +@media (prefers-color-scheme: dark) { + #overlay-opacity-slider { + accent-color: #6a9fff; + } +} + main { flex: 1; padding: 8px; + display: flex; + flex-direction: column; + min-height: 0; +} + +.note-surface { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; } -textarea { +.note-surface textarea { width: 100%; - height: 100%; + flex: 1; + min-height: 0; resize: none; padding: 10px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 15px; - border: 1px solid #d0d0d0; + border: 1px solid var(--note-border); border-radius: 8px; - background: #ffffff; - color: #222; + background: var(--note-bg); + color: var(--fg); } -#status { - font-size: 11px; - color: #666; +.note-surface.media-mode textarea { + display: none; } -@media (prefers-color-scheme: dark) { - body { - background: #1e1e1e; - color: #e0e0e0; - } +.media-stage { + display: none; + flex: 1; + flex-direction: column; + gap: 8px; + min-height: 0; + overflow: auto; + border: 1px solid var(--note-border); + border-radius: 8px; + background: #000; +} - header { - background: #2a2a2a; - border-bottom-color: #3a3a3a; - } +.note-surface.media-mode .media-stage { + display: flex; +} - header button:hover { - background: rgba(255, 255, 255, 0.1); - } +.media-embed { + flex: 1; + min-height: 120px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; +} - header button.pinned { - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.2); - } +.media-embed img, +.media-embed video { + max-width: 100%; + max-height: 100%; + width: 100%; + height: 100%; + object-fit: contain; +} - textarea { - background: #2a2a2a; - color: #e0e0e0; - border-color: #3a3a3a; - } +.media-error { + margin: 0; + padding: 8px; + font-size: 12px; + color: #f19797; + text-align: center; +} - #status { - color: #888; - } +#status { + font-size: 11px; + color: var(--status-fg); }