Skip to content
Merged
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
67 changes: 67 additions & 0 deletions src-tauri/src/file_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,70 @@ pub fn write_text_file(path: String, content: String) -> Result<(), String> {
}
std::fs::write(&path, &content).map_err(|e| format!("failed to write file: {e}"))
}

/// Downloads a file from a URL and saves it to the given path.
///
/// Handles both remote URLs (via HTTP) and local asset-protocol URLs
/// (by extracting the file path and copying).
///
/// # Arguments
///
/// * `url` - The source URL (remote HTTP or local `asset://localhost/…`).
/// * `dest_path` - Absolute path where the downloaded file should be saved.
///
/// # Errors
///
/// Returns a `String` if the download or file write fails.
#[tauri::command]
pub async fn download_file(url: String, dest_path: String) -> Result<(), String> {
let data = if let Some(path) = strip_asset_prefix(&url) {
let decoded = urldecode(path)?;
std::fs::read(&decoded).map_err(|e| format!("failed to read local file: {e}"))?
} else {
let response = reqwest::get(&url)
.await
.map_err(|e| format!("failed to download: {e}"))?;
response
.bytes()
.await
.map_err(|e| format!("failed to read response: {e}"))?
.to_vec()
};

// Ensure parent directory exists
if let Some(parent) = std::path::Path::new(&dest_path).parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create directories: {e}"))?;
}
std::fs::write(&dest_path, &data).map_err(|e| format!("failed to write file: {e}"))
}

/// Strips a recognised asset-protocol prefix from `url`, returning the
/// encoded path suffix (including the leading `/`), or `None` if the URL
/// is not an asset-protocol URL.
fn strip_asset_prefix(url: &str) -> Option<&str> {
url.strip_prefix("asset://localhost")
.or_else(|| url.strip_prefix("https://asset.localhost"))
}

/// Percent-decodes a URL path component.
fn urldecode(s: &str) -> Result<String, String> {
let mut result = Vec::new();
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (
char::from(bytes[i + 1]).to_digit(16),
char::from(bytes[i + 2]).to_digit(16),
) {
result.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
result.push(bytes[i]);
i += 1;
}
String::from_utf8(result).map_err(|e| format!("invalid UTF-8 in asset URL path: {e}"))
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pub fn run() {
link_preview::fetch_link_title,
file_io::read_text_file,
file_io::write_text_file,
file_io::download_file,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
85 changes: 85 additions & 0 deletions src/features/editor/ui/CaptionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
useBlockNoteEditor,
useComponentsContext,
useEditorState,
} from '@blocknote/react'
import { Captions } from 'lucide-react'
import { useCallback } from 'react'

/**
* State payload used to open the caption-editing dialog.
*
* @property blockId - The BlockNote block identifier of the target image/file block.
* @property caption - The current caption text to pre-populate in the dialog input.
*/
export interface CaptionDialogState {
blockId: string
caption: string
}

/**
* Props for the {@link CaptionButton} component.
*
* @property onRequestOpen - Callback invoked with the selected block's caption state
* when the user clicks the button. The parent renders {@link CaptionDialog} accordingly.
*/
interface CaptionButtonProps {
onRequestOpen: (state: CaptionDialogState) => void
}

/**
* Toolbar button that requests opening a caption-editing dialog for the
* selected image/file block.
*
* The actual dialog is rendered by {@link CaptionDialog} at the Editor
* component level so it survives FormattingToolbar unmount cycles.
*/
export const CaptionButton = ({ onRequestOpen }: CaptionButtonProps) => {
const Components = useComponentsContext()!

Check warning on line 38 in src/features/editor/ui/CaptionButton.tsx

View workflow job for this annotation

GitHub Actions / Frontend Lint & Format

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const editor = useBlockNoteEditor()

/** Resolves to the single selected block only when it is an image/file block
* that exposes both `url` and `caption` string props. Returns `undefined`
* when no suitable block is selected, causing the button to self-hide. */
const block = useEditorState({
editor,
selector: ({ editor }) => {
if (!editor.isEditable) return
const blocks = editor.getSelection()?.blocks || [
editor.getTextCursorPosition().block,
]
if (blocks.length !== 1) return
const b = blocks[0]
const props = b.props as Record<string, unknown>
// Only image/file blocks have both `url` and `caption` props.
if (
typeof props?.url === 'string' &&
typeof props?.caption === 'string'
) {
return b
}
return
},
})

/** Extracts the current caption from the selected block and requests the
* parent to open the caption dialog. */
const handleClick = useCallback(() => {
if (!block) return
const caption =
((block.props as Record<string, unknown>).caption as string) || ''
onRequestOpen({ blockId: block.id, caption })
}, [block, onRequestOpen])

if (block === undefined) return null

return (
<Components.FormattingToolbar.Button
className="bn-button"
onClick={handleClick}
label="Edit caption"
mainTooltip="Edit caption"
icon={<Captions size={18} />}
/>
)
}
121 changes: 121 additions & 0 deletions src/features/editor/ui/CaptionDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useBlockNoteEditor } from '@blocknote/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import type { CaptionDialogState } from './CaptionButton'

/**
* Props for the {@link CaptionDialog} component.
*
* @property state - The caption dialog state. When non-null the dialog is open;
* when null it is closed.
* @property onDismiss - Callback invoked when the dialog is dismissed (cancel or backdrop click).
*/
interface CaptionDialogProps {
state: CaptionDialogState | null
onDismiss: () => void
}

/**
* Dialog for editing image/file block captions.
*
* Rendered at the Editor component level (outside the FormattingToolbar)
* so it survives toolbar unmount cycles. Controlled via
* {@link CaptionDialogState} passed as {@link state}.
*/
export const CaptionDialog = ({ state, onDismiss }: CaptionDialogProps) => {
const editor = useBlockNoteEditor()
/** Current caption text bound to the input field. */
const [caption, setCaption] = useState('')
/**
* Tracks whether an IME composition (e.g. Japanese input) is in progress.
*
* Prevents the `Enter` keydown handler from saving while the user is
* mid-composition. Chromium fires `compositionend` before the `keydown`
* for the composition-confirming Enter, so a delayed reset via `setTimeout`
* is used to keep the guard active long enough for the keydown handler to
* see `composingRef.current === true` and skip.
*/
const composingRef = useRef(false)

const handleCompositionStart = useCallback(() => {
composingRef.current = true
}, [])

const handleCompositionEnd = useCallback(() => {
// Delay reset: in Chromium compositionend fires *before* the keydown
// for the Enter that confirms the composition, so the ref must stay
// true long enough for the subsequent keydown handler to see it.
setTimeout(() => {
composingRef.current = false
}, 50)
}, [])

useEffect(() => {
if (state) {
setCaption(state.caption)
}
}, [state])

/** Closes the dialog without saving and returns focus to the editor. */
const handleDismiss = useCallback(() => {
onDismiss()
editor.focus()
}, [editor, onDismiss])

/** Persists the updated caption to the block and closes the dialog. */
const handleSave = useCallback(() => {
if (state) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editor.updateBlock(state.blockId, { props: { caption } } as any)
}
onDismiss()
editor.focus()
}, [editor, state, caption, onDismiss])

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !composingRef.current) {
e.preventDefault()
handleSave()
}
},
[handleSave]
)

return (
<Dialog
open={state !== null}
onOpenChange={(open) => {
if (!open) void handleDismiss()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Caption</DialogTitle>
</DialogHeader>
<Input
value={caption}
onChange={(e) => setCaption(e.target.value)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={handleDismiss}>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
101 changes: 101 additions & 0 deletions src/features/editor/ui/DownloadButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
useBlockNoteEditor,
useComponentsContext,
useEditorState,
} from '@blocknote/react'
import { invoke } from '@tauri-apps/api/core'
import { save } from '@tauri-apps/plugin-dialog'
import { Download } from 'lucide-react'
import { useCallback } from 'react'
import { toast } from 'sonner'

/** Extracts the file extension from a filename or URL (without dot). */
function getExtension(str: string): string | undefined {
// Strip query string / hash
const path = str.split('?')[0]!.split('#')[0]!
const lastDot = path.lastIndexOf('.')
if (lastDot === -1) return
const ext = path.slice(lastDot + 1).toLowerCase()
// Reject if extension looks non-standard (e.g. more than 5 chars, or empty)
if (ext.length === 0 || ext.length > 5) return
return ext
}

/**
* Toolbar button that downloads the selected image/file block.
*
* Replaces BlockNote's built-in {@code FileDownloadButton} which uses
* {@code window.open()} — blocked in Tauri's webview.
*
* Delegates the actual download to the Rust backend via
* {@code download_file} command, which handles both remote URLs
* (HTTP download) and local asset-protocol URLs (file copy).
*/
export const DownloadButton = () => {
const Components = useComponentsContext()!
const editor = useBlockNoteEditor()

/** Resolves to the single selected block only when it has a `url` string
* prop (i.e. an image or file block). Returns `undefined` when no such
* block is selected, causing the button to self-hide. */
const block = useEditorState({
editor,
selector: ({ editor }) => {
const blocks = editor.getSelection()?.blocks || [
editor.getTextCursorPosition().block,
]
if (blocks.length !== 1) return
const b = blocks[0]
const props = b.props as Record<string, unknown>
if (typeof props?.url === 'string') {
return b
}
return
},
})

/**
* Opens a native save-file dialog and delegates the actual download to the
* Rust backend via the `download_file` Tauri command.
*
* The file extension is derived from the block's `name` prop first, then
* from the URL, and finally falls back to `"png"`. Errors are surfaced as
* toast notifications.
*/
const handleDownload = useCallback(async () => {
if (!block) return
const props = block.props as Record<string, unknown>
const url = props.url as string
const name = (props.name as string) || 'image'

// Extract extension from name or URL
const ext = getExtension(name) || getExtension(url) || 'png'
const baseName = name.includes('.') ? name : `${name}.${ext}`

try {
const path = await save({
defaultPath: baseName,
filters: [{ name: 'Image', extensions: [ext] }],
})
if (!path) return

await invoke('download_file', { url, destPath: path })
} catch (e) {
if (e instanceof Error && e.message) {
toast.error(`Download failed: ${e.message}`)
}
}
}, [block])

if (block === undefined) return null

return (
<Components.FormattingToolbar.Button
className="bn-button"
onClick={handleDownload}
label="Download"
mainTooltip="Download"
icon={<Download size={18} />}
/>
)
}
Loading
Loading