feat: custom scrollbars, portable storage, window state persistence
- Custom scrollbar CSS using ::-webkit-scrollbar for Tauri's Chromium WebView - Portable storage: all data written next to exe in data/ folder instead of AppData - Rust get_portable_data_dir command with runtime FS scope for exe directory - Window size/position/maximized saved to settings on close, restored on startup
This commit is contained in:
@@ -17,8 +17,9 @@
|
|||||||
"dialog:default",
|
"dialog:default",
|
||||||
"shell:default",
|
"shell:default",
|
||||||
"fs:default",
|
"fs:default",
|
||||||
"fs:allow-appdata-read-recursive",
|
"core:window:allow-set-size",
|
||||||
"fs:allow-appdata-write-recursive",
|
"core:window:allow-set-position",
|
||||||
"fs:allow-appdata-meta-recursive"
|
"core:window:allow-outer-size",
|
||||||
|
"core:window:allow-outer-position"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
use tauri_plugin_fs::FsExt;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_portable_data_dir() -> Result<String, String> {
|
||||||
|
let exe_path = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||||
|
let exe_dir = exe_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "Failed to get exe directory".to_string())?;
|
||||||
|
let data_dir = exe_dir.join("data");
|
||||||
|
Ok(data_dir.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -5,6 +17,27 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.setup(|app| {
|
||||||
|
// Get portable data directory next to the exe
|
||||||
|
let exe_path =
|
||||||
|
std::env::current_exe().expect("Failed to get exe path");
|
||||||
|
let exe_dir = exe_path
|
||||||
|
.parent()
|
||||||
|
.expect("Failed to get exe directory");
|
||||||
|
let data_dir = exe_dir.join("data");
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
std::fs::create_dir_all(&data_dir)
|
||||||
|
.expect("Failed to create portable data directory");
|
||||||
|
|
||||||
|
// Allow FS plugin access to the portable data directory
|
||||||
|
app.fs_scope()
|
||||||
|
.allow_directory(&data_dir, true)
|
||||||
|
.expect("Failed to allow data directory in FS scope");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![get_portable_data_dir])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/App.tsx
39
src/App.tsx
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { getCurrentWindow, LogicalSize, LogicalPosition } from "@tauri-apps/api/window";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { springs, fadeSlideLeft, fadeSlideRight } from "@/lib/motion";
|
import { springs, fadeSlideLeft, fadeSlideRight } from "@/lib/motion";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { useBoardStore } from "@/stores/board-store";
|
import { useBoardStore } from "@/stores/board-store";
|
||||||
|
import { saveSettings } from "@/lib/storage";
|
||||||
import { AppShell } from "@/components/layout/AppShell";
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
import { BoardList } from "@/components/boards/BoardList";
|
import { BoardList } from "@/components/boards/BoardList";
|
||||||
import { BoardView } from "@/components/board/BoardView";
|
import { BoardView } from "@/components/board/BoardView";
|
||||||
@@ -21,13 +23,46 @@ export default function App() {
|
|||||||
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
|
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
init();
|
init().then(() => {
|
||||||
|
// Restore window state after settings are loaded
|
||||||
|
const { settings } = useAppStore.getState();
|
||||||
|
const ws = settings.windowState;
|
||||||
|
if (ws) {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
if (ws.maximized) {
|
||||||
|
appWindow.maximize();
|
||||||
|
} else {
|
||||||
|
appWindow.setSize(new LogicalSize(ws.width, ws.height));
|
||||||
|
appWindow.setPosition(new LogicalPosition(ws.x, ws.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}, [init]);
|
}, [init]);
|
||||||
|
|
||||||
// Flush pending board saves before the app window closes
|
// Save window state + flush board saves before the app window closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleBeforeUnload() {
|
function handleBeforeUnload() {
|
||||||
useBoardStore.getState().closeBoard();
|
useBoardStore.getState().closeBoard();
|
||||||
|
|
||||||
|
// Save window state synchronously-ish (fire and forget)
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
Promise.all([
|
||||||
|
appWindow.outerSize(),
|
||||||
|
appWindow.outerPosition(),
|
||||||
|
appWindow.isMaximized(),
|
||||||
|
]).then(([size, position, maximized]) => {
|
||||||
|
const settings = useAppStore.getState().settings;
|
||||||
|
saveSettings({
|
||||||
|
...settings,
|
||||||
|
windowState: {
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
maximized,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -148,6 +148,39 @@
|
|||||||
.dark * {
|
.dark * {
|
||||||
scrollbar-color: oklch(80% 0 0 / 15%) transparent;
|
scrollbar-color: oklch(80% 0 0 / 15%) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for Chromium/WebKit (Tauri WebView) */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(50% 0 0 / 20%);
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(50% 0 0 / 35%);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(80% 0 0 / 15%);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(80% 0 0 / 30%);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: "Satoshi", system-ui, -apple-system, sans-serif;
|
font-family: "Satoshi", system-ui, -apple-system, sans-serif;
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ export const boardSchema = z.object({
|
|||||||
settings: boardSettingsSchema.default({ attachmentMode: "link", background: "none" }),
|
settings: boardSettingsSchema.default({ attachmentMode: "link", background: "none" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const windowStateSchema = z.object({
|
||||||
|
x: z.number(),
|
||||||
|
y: z.number(),
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
maximized: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
export const appSettingsSchema = z.object({
|
export const appSettingsSchema = z.object({
|
||||||
theme: z.enum(["light", "dark", "system"]).default("system"),
|
theme: z.enum(["light", "dark", "system"]).default("system"),
|
||||||
dataDirectory: z.string().nullable().default(null),
|
dataDirectory: z.string().nullable().default(null),
|
||||||
@@ -65,4 +73,5 @@ export const appSettingsSchema = z.object({
|
|||||||
uiZoom: z.number().min(0.75).max(1.5).default(1),
|
uiZoom: z.number().min(0.75).max(1.5).default(1),
|
||||||
density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"),
|
density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"),
|
||||||
defaultColumnWidth: z.enum(["narrow", "standard", "wide"]).default("standard"),
|
defaultColumnWidth: z.enum(["narrow", "standard", "wide"]).default("standard"),
|
||||||
|
windowState: windowStateSchema.nullable().default(null),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ import {
|
|||||||
remove,
|
remove,
|
||||||
copyFile,
|
copyFile,
|
||||||
} from "@tauri-apps/plugin-fs";
|
} from "@tauri-apps/plugin-fs";
|
||||||
import { appDataDir, join } from "@tauri-apps/api/path";
|
import { join } from "@tauri-apps/api/path";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { boardSchema, appSettingsSchema } from "./schemas";
|
import { boardSchema, appSettingsSchema } from "./schemas";
|
||||||
import type { Board, BoardMeta } from "@/types/board";
|
import type { Board, BoardMeta } from "@/types/board";
|
||||||
import type { AppSettings } from "@/types/settings";
|
import type { AppSettings } from "@/types/settings";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Path helpers
|
// Path helpers — portable: all data lives next to the exe
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function getBaseDir(): Promise<string> {
|
async function getBaseDir(): Promise<string> {
|
||||||
const base = await appDataDir();
|
return invoke<string>("get_portable_data_dir");
|
||||||
return join(base, "openpylon");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBoardsDir(): Promise<string> {
|
async function getBoardsDir(): Promise<string> {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
uiZoom: 1,
|
uiZoom: 1,
|
||||||
density: "comfortable",
|
density: "comfortable",
|
||||||
defaultColumnWidth: "standard",
|
defaultColumnWidth: "standard",
|
||||||
|
windowState: null,
|
||||||
},
|
},
|
||||||
boards: [],
|
boards: [],
|
||||||
view: { type: "board-list" },
|
view: { type: "board-list" },
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { ColumnWidth } from "./board";
|
import type { ColumnWidth } from "./board";
|
||||||
|
|
||||||
|
export interface WindowState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
maximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
theme: "light" | "dark" | "system";
|
theme: "light" | "dark" | "system";
|
||||||
dataDirectory: string | null;
|
dataDirectory: string | null;
|
||||||
@@ -8,4 +16,5 @@ export interface AppSettings {
|
|||||||
uiZoom: number;
|
uiZoom: number;
|
||||||
density: "compact" | "comfortable" | "spacious";
|
density: "compact" | "comfortable" | "spacious";
|
||||||
defaultColumnWidth: ColumnWidth;
|
defaultColumnWidth: ColumnWidth;
|
||||||
|
windowState: WindowState | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user