diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 37dd3b6..9abaf6a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,7 @@ "allow-save-html-file-dialog", "allow-save-pdf-file-dialog", "allow-get-file-metadata", - "allow-get-launch-args" + "allow-get-launch-args", + "allow-open-in-browser" ] } diff --git a/src-tauri/permissions/file-operations.toml b/src-tauri/permissions/file-operations.toml index d781add..1a474b1 100644 --- a/src-tauri/permissions/file-operations.toml +++ b/src-tauri/permissions/file-operations.toml @@ -40,3 +40,8 @@ commands.allow = ["get_file_metadata"] identifier = "allow-get-launch-args" description = "Allows invoking the get_launch_args command." commands.allow = ["get_launch_args"] + +[[permission]] +identifier = "allow-open-in-browser" +description = "Allows invoking the open_in_browser command." +commands.allow = ["open_in_browser"] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 26ce8c6..8061b4a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ use base64::Engine; use rfd::AsyncFileDialog; use serde::Serialize; use std::time::UNIX_EPOCH; +use std::process::Command; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -120,6 +121,39 @@ fn get_launch_args() -> Vec { std::env::args().skip(1).collect() } +/// Opens a URL in the system default browser. +/// +/// Uses platform-specific mechanisms: `open` on macOS, `xdg-open` on Linux, +/// and `rundll32 url.dll,FileProtocolHandler` on Windows. +/// +/// Only `http://` and `https://` URLs are accepted to prevent misuse. +#[tauri::command] +fn open_in_browser(url: String) -> Result<(), String> { + if !url.starts_with("https://") && !url.starts_with("http://") { + return Err("Only http:// and https:// URLs are supported.".to_string()); + } + + #[cfg(target_os = "macos")] + Command::new("open") + .arg(&url) + .spawn() + .map_err(|e| e.to_string())?; + + #[cfg(target_os = "linux")] + Command::new("xdg-open") + .arg(&url) + .spawn() + .map_err(|e| e.to_string())?; + + #[cfg(target_os = "windows")] + Command::new("rundll32") + .args(["url.dll,FileProtocolHandler", &url]) + .spawn() + .map_err(|e| e.to_string())?; + + Ok(()) +} + #[tauri::command] async fn get_file_metadata(path: String) -> Result { let metadata = std::fs::metadata(path).map_err(|e| e.to_string())?; @@ -137,6 +171,7 @@ pub fn run() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ get_launch_args, + open_in_browser, open_file_dialog, read_text_file, save_text_file, diff --git a/src/app/App.tsx b/src/app/App.tsx index e7c7725..53adc1c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2,6 +2,7 @@ import Layout from "../components/Layout"; import Toolbar from "../components/Toolbar"; import EditorArea from "../features/editor/Editor"; import StatusBar from "../components/StatusBar"; +import AboutDialog from "../features/ux/AboutDialog"; import { useDocumentStore } from "../stores/documentStore"; import { useEditorController } from "../features/editor/useEditorController"; @@ -30,6 +31,8 @@ function AppContent() { handleSetCodeBlockLanguage, isOutlineVisible, handleToggleOutline, + isAboutVisible, + handleCloseAbout, } = useEditorController(); return ( @@ -71,6 +74,7 @@ function AppContent() { hasActiveDocument={hasActiveDocument} hasExternalChangeWarning={activeDocument.hasExternalChangeWarning} /> + {isAboutVisible && } ); } diff --git a/src/features/editor/useEditorController.ts b/src/features/editor/useEditorController.ts index 2e70d32..6e39a10 100644 --- a/src/features/editor/useEditorController.ts +++ b/src/features/editor/useEditorController.ts @@ -48,6 +48,7 @@ import { useDragDropHandler } from "./useDragDropHandler"; const APP_VERSION = __APP_VERSION__; const APP_NAME = `mdedit v${APP_VERSION}`; const RELOAD_ACCELERATOR = "CmdOrCtrl+Alt+R"; +const DOCUMENTATION_URL = "https://github.com/MartinDejmal/mdedit"; const UNTITLED_NAME = "Untitled"; @@ -85,6 +86,10 @@ export interface EditorController { handleSetCodeBlockLanguage: (language: string) => void; isOutlineVisible: boolean; handleToggleOutline: () => void; + isAboutVisible: boolean; + handleOpenAbout: () => void; + handleCloseAbout: () => void; + handleOpenDocumentation: () => void; } export function useEditorController(): EditorController { @@ -94,6 +99,7 @@ export function useEditorController(): EditorController { const startupReopenDoneRef = useRef(false); const [persistedState, setPersistedState] = useState(getInitialPersistedState); const [activeCodeBlockLanguage, setActiveCodeBlockLanguage] = useState(null); + const [isAboutVisible, setIsAboutVisible] = useState(false); const { isDirty, currentFilePath, isUntitled, hasActiveDocument } = useDocumentStore(); const { confirm, notify, requestInput } = useAppUx(); @@ -236,6 +242,18 @@ export function useEditorController(): EditorController { }); }, []); + const handleOpenAbout = useCallback(() => { + setIsAboutVisible(true); + }, []); + + const handleCloseAbout = useCallback(() => { + setIsAboutVisible(false); + }, []); + + const handleOpenDocumentation = useCallback(() => { + void bridge.openInBrowser(DOCUMENTATION_URL); + }, []); + useEffect(() => { if (!editor) { setActiveCodeBlockLanguage(null); @@ -407,8 +425,25 @@ export function useEditorController(): EditorController { ], }); + const helpSubmenu = await Submenu.new({ + text: "Help", + items: [ + { + id: "help-documentation", + text: "Documentation", + action: () => handleOpenDocumentation(), + }, + await PredefinedMenuItem.new({ item: "Separator" }), + { + id: "help-about", + text: "About mdedit", + action: () => handleOpenAbout(), + }, + ], + }); + const menu = await Menu.new({ - items: [fileSubmenu, editSubmenu, viewSubmenu], + items: [fileSubmenu, editSubmenu, viewSubmenu, helpSubmenu], }); if (!disposed) { @@ -437,6 +472,8 @@ export function useEditorController(): EditorController { persistedState.recentFiles, persistedState.ui.isOutlineVisible, handleToggleOutline, + handleOpenAbout, + handleOpenDocumentation, ]); useEffect(() => { @@ -559,6 +596,10 @@ export function useEditorController(): EditorController { handleSetCodeBlockLanguage, isOutlineVisible: persistedState.ui.isOutlineVisible, handleToggleOutline, + isAboutVisible, + handleOpenAbout, + handleCloseAbout, + handleOpenDocumentation, }), [ editor, @@ -581,6 +622,10 @@ export function useEditorController(): EditorController { isDragOver, persistedState.recentFiles, persistedState.ui.isOutlineVisible, + isAboutVisible, + handleOpenAbout, + handleCloseAbout, + handleOpenDocumentation, ] ); } diff --git a/src/features/ux/AboutDialog.tsx b/src/features/ux/AboutDialog.tsx new file mode 100644 index 0000000..ea90b86 --- /dev/null +++ b/src/features/ux/AboutDialog.tsx @@ -0,0 +1,106 @@ +/** + * About dialog – displays app name, version, license and author information. + */ +import { useEffect, useRef } from "react"; +import * as bridge from "../../services/tauriBridge"; + +const APP_VERSION = __APP_VERSION__; + +const MDEDIT_URL = "https://github.com/MartinDejmal/mdedit"; + +interface AboutDialogProps { + onClose: () => void; +} + +export default function AboutDialog({ onClose }: AboutDialogProps) { + const closeButtonRef = useRef(null); + + useEffect(() => { + closeButtonRef.current?.focus(); + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const handleLinkClick = (url: string) => { + void bridge.openInBrowser(url); + }; + + return ( +
+
e.stopPropagation()} + > +
+ mdedit logo +
+

mdedit

+

Version {APP_VERSION}

+
+
+ +

+ A lightweight, cross-platform WYSIWYG Markdown editor built on + Tauri 2, React 18, and Tiptap. +

+ +
+
Author
+
Martin Dejmal
+ +
License
+
GNU General Public License v3.0
+ +
Source
+
+ +
+ +
Documentation
+
+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/services/tauriBridge.ts b/src/services/tauriBridge.ts index 5adcb59..c932f7e 100644 --- a/src/services/tauriBridge.ts +++ b/src/services/tauriBridge.ts @@ -86,6 +86,11 @@ export async function getLaunchArgs(): Promise { return invoke("get_launch_args"); } +/** Opens a URL in the system default browser. */ +export async function openInBrowser(url: string): Promise { + return invoke("open_in_browser", { url }); +} + /** Updates native window title. */ export async function setWindowTitle(title: string): Promise { await getCurrentWindow().setTitle(title); diff --git a/src/styles.css b/src/styles.css index 120f814..d0053af 100644 --- a/src/styles.css +++ b/src/styles.css @@ -679,6 +679,85 @@ body { color: #fff; } +/* ============================================================ + About dialog + ============================================================ */ + +.about-dialog { + width: min(460px, calc(100vw - 32px)); + background: #fff; + border-radius: 10px; + border: 1px solid var(--color-border); + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.24); + padding: 24px 20px 16px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.about-header { + display: flex; + align-items: center; + gap: 16px; +} + +.about-logo { + width: 56px; + height: 56px; + border-radius: 10px; + flex-shrink: 0; +} + +.about-app-name { + font-size: 20px; + font-weight: 700; + color: #1f2937; +} + +.about-version { + font-size: 13px; + color: var(--color-text-muted); + margin-top: 2px; +} + +.about-description { + font-size: 13px; + color: #555; + line-height: 1.5; +} + +.about-meta { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 14px; + font-size: 13px; +} + +.about-meta dt { + color: var(--color-text-muted); + font-weight: 600; + white-space: nowrap; +} + +.about-meta dd { + color: var(--color-text); +} + +.about-link { + background: none; + border: none; + padding: 0; + color: var(--color-accent); + cursor: pointer; + font-size: 13px; + text-decoration: underline; + text-underline-offset: 2px; +} + +.about-link:hover { + color: #2563eb; +} + /* ============================================================ Drag-drop overlay ============================================================ */