Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
5 changes: 5 additions & 0 deletions src-tauri/permissions/file-operations.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
35 changes: 35 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -120,6 +121,39 @@ fn get_launch_args() -> Vec<String> {
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<FileMetadata, String> {
let metadata = std::fs::metadata(path).map_err(|e| e.to_string())?;
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -30,6 +31,8 @@ function AppContent() {
handleSetCodeBlockLanguage,
isOutlineVisible,
handleToggleOutline,
isAboutVisible,
handleCloseAbout,
} = useEditorController();

return (
Expand Down Expand Up @@ -71,6 +74,7 @@ function AppContent() {
hasActiveDocument={hasActiveDocument}
hasExternalChangeWarning={activeDocument.hasExternalChangeWarning}
/>
{isAboutVisible && <AboutDialog onClose={handleCloseAbout} />}
</Layout>
);
}
Expand Down
47 changes: 46 additions & 1 deletion src/features/editor/useEditorController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand All @@ -94,6 +99,7 @@ export function useEditorController(): EditorController {
const startupReopenDoneRef = useRef(false);
const [persistedState, setPersistedState] = useState(getInitialPersistedState);
const [activeCodeBlockLanguage, setActiveCodeBlockLanguage] = useState<string | null>(null);
const [isAboutVisible, setIsAboutVisible] = useState(false);
const { isDirty, currentFilePath, isUntitled, hasActiveDocument } =
useDocumentStore();
const { confirm, notify, requestInput } = useAppUx();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -437,6 +472,8 @@ export function useEditorController(): EditorController {
persistedState.recentFiles,
persistedState.ui.isOutlineVisible,
handleToggleOutline,
handleOpenAbout,
handleOpenDocumentation,
]);

useEffect(() => {
Expand Down Expand Up @@ -559,6 +596,10 @@ export function useEditorController(): EditorController {
handleSetCodeBlockLanguage,
isOutlineVisible: persistedState.ui.isOutlineVisible,
handleToggleOutline,
isAboutVisible,
handleOpenAbout,
handleCloseAbout,
handleOpenDocumentation,
}),
[
editor,
Expand All @@ -581,6 +622,10 @@ export function useEditorController(): EditorController {
isDragOver,
persistedState.recentFiles,
persistedState.ui.isOutlineVisible,
isAboutVisible,
handleOpenAbout,
handleCloseAbout,
handleOpenDocumentation,
]
);
}
106 changes: 106 additions & 0 deletions src/features/ux/AboutDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement | null>(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 (
<div className="confirm-overlay" role="presentation" onClick={onClose}>
<div
className="about-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="about-title"
onClick={(e) => e.stopPropagation()}
>
<div className="about-header">
<img
src="/www/icon.png"
alt="mdedit logo"
className="about-logo"
/>
<div>
<h2 id="about-title" className="about-app-name">mdedit</h2>
<p className="about-version">Version {APP_VERSION}</p>
</div>
</div>

<p className="about-description">
A lightweight, cross-platform WYSIWYG Markdown editor built on
Tauri 2, React 18, and Tiptap.
</p>

<dl className="about-meta">
<dt>Author</dt>
<dd>Martin Dejmal</dd>

<dt>License</dt>
<dd>GNU General Public License v3.0</dd>

<dt>Source</dt>
<dd>
<button
type="button"
className="about-link"
onClick={() => handleLinkClick(MDEDIT_URL)}
>
github.com/MartinDejmal/mdedit
</button>
</dd>

<dt>Documentation</dt>
<dd>
<button
type="button"
className="about-link"
onClick={() => handleLinkClick(MDEDIT_URL)}
>
User documentation
</button>
</dd>
</dl>

<div className="confirm-actions">
<button
type="button"
ref={closeButtonRef}
className="primary"
onClick={onClose}
>
Close
</button>
</div>
</div>
</div>
);
}
5 changes: 5 additions & 0 deletions src/services/tauriBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ export async function getLaunchArgs(): Promise<string[]> {
return invoke<string[]>("get_launch_args");
}

/** Opens a URL in the system default browser. */
export async function openInBrowser(url: string): Promise<void> {
return invoke<void>("open_in_browser", { url });
}

/** Updates native window title. */
export async function setWindowTitle(title: string): Promise<void> {
await getCurrentWindow().setTitle(title);
Expand Down
Loading