design: upgrade global design of the app
This commit is contained in:
@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "image-viewer"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
@ -21,6 +21,7 @@
|
||||
"core:window:allow-set-cursor-visible",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-is-minimized",
|
||||
"core:window:allow-is-focused"
|
||||
"core:window:allow-is-focused",
|
||||
"core:window:allow-start-resize-dragging"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tauri::window::Color;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
use rusqlite::Connection;
|
||||
@ -494,6 +495,61 @@ pub fn toggle_quest_step(
|
||||
db::toggle_quest_step(&conn, &profile_id, &quest_name, step_index).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn percent_encode(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for byte in s.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
result.push(byte as char);
|
||||
}
|
||||
b => result.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_image_viewer(
|
||||
app: AppHandle,
|
||||
state: State<'_, DbState>,
|
||||
image_url: String,
|
||||
) -> Result<(), String> {
|
||||
if let Some(win) = app.get_webview_window("image-viewer") {
|
||||
win.emit("set-viewer-image", &image_url).map_err(|e| e.to_string())?;
|
||||
win.set_focus().map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (w, h, x, y) = {
|
||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
let w: f64 = db::get_setting(&conn, "viewer_width").and_then(|v| v.parse().ok()).unwrap_or(600.0);
|
||||
let h: f64 = db::get_setting(&conn, "viewer_height").and_then(|v| v.parse().ok()).unwrap_or(500.0);
|
||||
let x: Option<f64> = db::get_setting(&conn, "viewer_x").and_then(|v| v.parse().ok());
|
||||
let y: Option<f64> = db::get_setting(&conn, "viewer_y").and_then(|v| v.parse().ok());
|
||||
(w, h, x, y)
|
||||
};
|
||||
|
||||
let path = format!("/?viewer=1&imageUrl={}", percent_encode(&image_url));
|
||||
let mut builder = tauri::WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"image-viewer",
|
||||
tauri::WebviewUrl::App(path.into()),
|
||||
)
|
||||
.title("Image")
|
||||
.decorations(false)
|
||||
.resizable(true)
|
||||
.always_on_top(true)
|
||||
.background_color(Color(13, 17, 23, 255))
|
||||
.inner_size(w, h);
|
||||
|
||||
if let (Some(x), Some(y)) = (x, y) {
|
||||
builder = builder.position(x, y);
|
||||
}
|
||||
|
||||
builder.build().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_quest_names(data: &parser::GuideData) -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
for section in &data.sections {
|
||||
|
||||
@ -53,6 +53,7 @@ pub fn run() {
|
||||
commands::toggle_quest_step,
|
||||
commands::get_resource_inventory,
|
||||
commands::set_resource_quantity,
|
||||
commands::open_image_viewer,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
25
src/App.tsx
25
src/App.tsx
@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { useStore } from "./store";
|
||||
import TitleBar from "./components/TitleBar";
|
||||
import ResizeHandles from "./components/ResizeHandles";
|
||||
import HomeView from "./components/HomeView";
|
||||
import GuideView from "./components/GuideView";
|
||||
import SettingsPanel from "./components/SettingsPanel";
|
||||
@ -58,7 +60,27 @@ export default function App() {
|
||||
}, 500);
|
||||
});
|
||||
|
||||
return () => { unlisten.then(f => f()); };
|
||||
const unlistenFocus = win.listen("tauri://focus", async () => {
|
||||
const viewer = await WebviewWindow.getByLabel("image-viewer");
|
||||
if (viewer) {
|
||||
const isMin = await viewer.isMinimized();
|
||||
if (isMin) await viewer.unminimize();
|
||||
}
|
||||
});
|
||||
|
||||
const unlistenBlur = win.listen("tauri://blur", async () => {
|
||||
const isMin = await win.isMinimized();
|
||||
if (isMin) {
|
||||
const viewer = await WebviewWindow.getByLabel("image-viewer");
|
||||
if (viewer) await viewer.minimize();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then(f => f());
|
||||
unlistenFocus.then(f => f());
|
||||
unlistenBlur.then(f => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleInitialSync() {
|
||||
@ -68,6 +90,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<ResizeHandles />
|
||||
<TitleBar onOpenSettings={() => setShowSettings(s => !s)} />
|
||||
<div className="app-body">
|
||||
<main className="app-main">
|
||||
|
||||
74
src/components/ImageViewerWindow.tsx
Normal file
74
src/components/ImageViewerWindow.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import ResizeHandles from "./ResizeHandles";
|
||||
|
||||
export default function ImageViewerWindow() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [imageUrl, setImageUrl] = useState(decodeURIComponent(params.get("imageUrl") ?? ""));
|
||||
const win = getCurrentWindow();
|
||||
|
||||
useEffect(() => {
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const unlistenImage = win.listen<string>("set-viewer-image", (event) => {
|
||||
setImageUrl(event.payload);
|
||||
});
|
||||
|
||||
const unlistenResize = win.onResized(async () => {
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(async () => {
|
||||
const [size, factor] = await Promise.all([win.innerSize(), win.scaleFactor()]);
|
||||
await invoke("set_setting", { key: "viewer_width", value: String(Math.round(size.width / factor)) });
|
||||
await invoke("set_setting", { key: "viewer_height", value: String(Math.round(size.height / factor)) });
|
||||
}, 500);
|
||||
});
|
||||
|
||||
const unlistenMove = win.onMoved(async () => {
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(async () => {
|
||||
const [pos, factor] = await Promise.all([win.outerPosition(), win.scaleFactor()]);
|
||||
await invoke("set_setting", { key: "viewer_x", value: String(Math.round(pos.x / factor)) });
|
||||
await invoke("set_setting", { key: "viewer_y", value: String(Math.round(pos.y / factor)) });
|
||||
}, 500);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenImage.then(f => f());
|
||||
unlistenResize.then(f => f());
|
||||
unlistenMove.then(f => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ height: "100vh", display: "flex", flexDirection: "column", background: "#0d1117", overflow: "hidden" }}>
|
||||
<ResizeHandles />
|
||||
<div
|
||||
onMouseDown={e => { if (e.button === 0) win.startDragging(); }}
|
||||
style={{
|
||||
height: "28px",
|
||||
background: "#161b22",
|
||||
borderBottom: "1px solid #2d3748",
|
||||
cursor: "grab",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: "12px",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "11px", color: "#4a5568", pointerEvents: "none" }}>⠿ Image</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
style={{ width: "100%", display: "block" }}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -155,19 +155,21 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
|
||||
if (done) {
|
||||
const firstLine = step.text.split('\n').find(l => l.trim().length > 0) ?? step.text;
|
||||
return (
|
||||
<div key={step.index} style={{
|
||||
<div key={step.index} onClick={() => toggleStep(step.index)} style={{
|
||||
marginBottom: "4px",
|
||||
padding: "6px 12px",
|
||||
border: "1px solid rgba(74,222,128,0.1)",
|
||||
borderRadius: "7px",
|
||||
background: "rgba(74,222,128,0.03)",
|
||||
opacity: 0.5,
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={true}
|
||||
onChange={() => toggleStep(step.index)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ flexShrink: 0, cursor: "pointer" }}
|
||||
/>
|
||||
<span style={{
|
||||
@ -190,18 +192,20 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
|
||||
: step.text;
|
||||
|
||||
return (
|
||||
<div key={step.index} style={{
|
||||
<div key={step.index} onClick={() => toggleStep(step.index)} style={{
|
||||
marginBottom: "8px",
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: "1px solid #2d3748",
|
||||
borderRadius: "7px", padding: "10px 12px",
|
||||
transition: "all 0.15s",
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
<div style={{ display: "flex", gap: "10px", alignItems: "flex-start" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={false}
|
||||
onChange={() => toggleStep(step.index)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ marginTop: "2px", flexShrink: 0, cursor: "pointer" }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@ -214,7 +218,7 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
|
||||
|
||||
{needsTruncate && (
|
||||
<button
|
||||
onClick={() => toggleExpanded(step.index)}
|
||||
onClick={e => { e.stopPropagation(); toggleExpanded(step.index); }}
|
||||
style={{
|
||||
marginTop: "4px", background: "transparent", border: "none",
|
||||
color: "#4a9eff", fontSize: "11px", cursor: "pointer",
|
||||
@ -231,7 +235,7 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
|
||||
<img
|
||||
key={j}
|
||||
src={src}
|
||||
onClick={() => openUrl(src)}
|
||||
onClick={e => { e.stopPropagation(); invoke("open_image_viewer", { imageUrl: src }); }}
|
||||
style={{
|
||||
maxWidth: "100%", height: "auto",
|
||||
borderRadius: "6px", display: "block",
|
||||
@ -279,7 +283,7 @@ function QuestHeader({ step }: { step: QuestStep }) {
|
||||
<img
|
||||
key={j}
|
||||
src={src}
|
||||
onClick={() => openUrl(src)}
|
||||
onClick={() => invoke("open_image_viewer", { imageUrl: src })}
|
||||
style={{
|
||||
maxWidth: "100%", height: "auto",
|
||||
borderRadius: "6px", display: "block",
|
||||
|
||||
31
src/components/ResizeHandles.tsx
Normal file
31
src/components/ResizeHandles.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { ResizeEdge } from "@tauri-apps/api/window";
|
||||
|
||||
const S = 8; // grab zone size in px
|
||||
|
||||
const handles: { edge: ResizeEdge; style: React.CSSProperties }[] = [
|
||||
{ edge: "North", style: { top: 0, left: S, right: S, height: S, cursor: "n-resize" } },
|
||||
{ edge: "South", style: { bottom: 0, left: S, right: S, height: S, cursor: "s-resize" } },
|
||||
{ edge: "West", style: { top: S, left: 0, bottom: S, width: S, cursor: "w-resize" } },
|
||||
{ edge: "East", style: { top: S, right: 0, bottom: S, width: S, cursor: "e-resize" } },
|
||||
{ edge: "NorthWest", style: { top: 0, left: 0, width: S, height: S, cursor: "nw-resize" } },
|
||||
{ edge: "NorthEast", style: { top: 0, right: 0, width: S, height: S, cursor: "ne-resize" } },
|
||||
{ edge: "SouthWest", style: { bottom: 0, left: 0, width: S, height: S, cursor: "sw-resize" } },
|
||||
{ edge: "SouthEast", style: { bottom: 0, right: 0,width: S, height: S, cursor: "se-resize" } },
|
||||
];
|
||||
|
||||
export default function ResizeHandles() {
|
||||
const win = getCurrentWindow();
|
||||
|
||||
return (
|
||||
<>
|
||||
{handles.map(({ edge, style }) => (
|
||||
<div
|
||||
key={edge}
|
||||
onMouseDown={e => { if (e.button === 0) win.startResizeDragging(edge); }}
|
||||
style={{ position: "fixed", zIndex: 9999, ...style }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { useStore } from "../store";
|
||||
|
||||
function useWindowWidth() {
|
||||
@ -27,6 +28,10 @@ export default function TitleBar({ onOpenSettings }: Props) {
|
||||
}
|
||||
|
||||
async function handleClose() {
|
||||
const viewer = await WebviewWindow.getByLabel("image-viewer");
|
||||
if (viewer) {
|
||||
try { await viewer.close(); } catch (_) {}
|
||||
}
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import ImageViewerWindow from "./components/ImageViewerWindow";
|
||||
|
||||
// Block all document-level scrolling
|
||||
document.addEventListener("wheel", (e) => {
|
||||
@ -17,8 +18,10 @@ document.addEventListener("scroll", () => {
|
||||
window.scrollTo(0, 0);
|
||||
}, { passive: true });
|
||||
|
||||
const isViewer = new URLSearchParams(window.location.search).get("viewer") === "1";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
{isViewer ? <ImageViewerWindow /> : <App />}
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user