diff --git a/src-tauri/src/file_io.rs b/src-tauri/src/file_io.rs index fc6e27c..7e85f4d 100644 --- a/src-tauri/src/file_io.rs +++ b/src-tauri/src/file_io.rs @@ -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 { + 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}")) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cbe92a7..003c324 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src/features/editor/ui/CaptionButton.tsx b/src/features/editor/ui/CaptionButton.tsx new file mode 100644 index 0000000..86bf55d --- /dev/null +++ b/src/features/editor/ui/CaptionButton.tsx @@ -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()! + 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 + // 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).caption as string) || '' + onRequestOpen({ blockId: block.id, caption }) + }, [block, onRequestOpen]) + + if (block === undefined) return null + + return ( + } + /> + ) +} diff --git a/src/features/editor/ui/CaptionDialog.tsx b/src/features/editor/ui/CaptionDialog.tsx new file mode 100644 index 0000000..29fd47d --- /dev/null +++ b/src/features/editor/ui/CaptionDialog.tsx @@ -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 ( + { + if (!open) void handleDismiss() + }} + > + + + Edit Caption + + setCaption(e.target.value)} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + onKeyDown={handleKeyDown} + autoFocus + /> + + + + + + + ) +} diff --git a/src/features/editor/ui/DownloadButton.tsx b/src/features/editor/ui/DownloadButton.tsx new file mode 100644 index 0000000..d9dff2f --- /dev/null +++ b/src/features/editor/ui/DownloadButton.tsx @@ -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 + 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 + 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 ( + } + /> + ) +} diff --git a/src/features/editor/ui/Editor.tsx b/src/features/editor/ui/Editor.tsx index da99eb1..d9568e1 100644 --- a/src/features/editor/ui/Editor.tsx +++ b/src/features/editor/ui/Editor.tsx @@ -50,9 +50,16 @@ import { DEFAULT_BLOCKS } from '../lib/constants' import { rangeCheckToggleExtension } from '../lib/rangeCheckToggle' import { readOnlyGuardExtension, setReadOnly } from '../lib/readOnlyGuard' import { slashMenuEmacsKeysExtension } from '../lib/slashMenuEmacsKeys' +import type { CaptionDialogState } from './CaptionButton' +import { CaptionButton } from './CaptionButton' +import { CaptionDialog } from './CaptionDialog' import { CustomColorStyleButton } from './CustomColorStyleButton' import { CustomLinkToolbar } from './CustomLinkToolbar' +import { DownloadButton } from './DownloadButton' import { HighlightButton } from './HighlightButton' +import type { RenameDialogState } from './RenameButton' +import { RenameButton } from './RenameButton' +import { RenameDialog } from './RenameDialog' import { SearchReplacePanel } from './SearchReplacePanel' import '@blocknote/shadcn/style.css' import '@blocknote/core/fonts/inter.css' @@ -172,12 +179,7 @@ export interface EditorHandle { const PASS_THROUGH_KEYS = new Set([ 'blockTypeSelect', 'tableCellMergeButton', - 'fileCaptionButton', - 'replaceFileButton', - 'fileRenameButton', 'fileDeleteButton', - 'fileDownloadButton', - 'filePreviewButton', ]) /** @@ -232,6 +234,12 @@ export const Editor = forwardRef(function Editor( * to the editor inside the content-loading `useEffect`. */ const [contentReady, setContentReady] = useState(false) + /** Caption dialog state (null = closed, object = open for that block). */ + const [captionState, setCaptionState] = useState( + null + ) + /** Rename dialog state (null = closed, object = open for that block). */ + const [renameState, setRenameState] = useState(null) /** Resolved theme ("light" or "dark") passed to BlockNoteView. */ const { resolvedTheme } = useTheme() /** User-configured editor font size in pixels. */ @@ -255,14 +263,19 @@ export const Editor = forwardRef(function Editor( const formattingToolbarItems = useMemo(() => { const allItems = getFormattingToolbarItems() const itemMap = new Map() - const passThroughItems: React.ReactElement[] = [] + const leadingPassThrough: React.ReactElement[] = [] + let fileDeleteButton: React.ReactElement | null = null for (const item of allItems) { const key = item.key as string - if (PASS_THROUGH_KEYS.has(key)) { - passThroughItems.push(item) - } else { + if (!PASS_THROUGH_KEYS.has(key)) { itemMap.set(key, item) + continue + } + if (key === 'fileDeleteButton') { + fileDeleteButton = item + } else { + leadingPassThrough.push(item) } } @@ -279,7 +292,14 @@ export const Editor = forwardRef(function Editor( if (el) configuredItems.push(el) } - return [...passThroughItems, ...configuredItems] + return [ + ...leadingPassThrough, + , + , + , + ...(fileDeleteButton ? [fileDeleteButton] : []), + ...configuredItems, + ] }, [toolbarItemConfigs]) /** Debounced auto-save hook (500 ms delay). Only active when `noteId` is non-null. */ @@ -635,6 +655,14 @@ export const Editor = forwardRef(function Editor( )} /> + setCaptionState(null)} + /> + setRenameState(null)} + /> )} diff --git a/src/features/editor/ui/RenameButton.tsx b/src/features/editor/ui/RenameButton.tsx new file mode 100644 index 0000000..0ab3346 --- /dev/null +++ b/src/features/editor/ui/RenameButton.tsx @@ -0,0 +1,78 @@ +import { + useBlockNoteEditor, + useComponentsContext, + useEditorState, +} from '@blocknote/react' +import { Pencil } from 'lucide-react' +import { useCallback } from 'react' + +/** + * State payload used to open the rename dialog. + * + * @property blockId - The BlockNote block identifier of the target image/file block. + * @property name - The current file name to pre-populate in the dialog input. + */ +export interface RenameDialogState { + blockId: string + name: string +} + +/** + * Props for the {@link RenameButton} component. + * + * @property onRequestOpen - Callback invoked with the selected block's rename state + * when the user clicks the button. The parent renders {@link RenameDialog} accordingly. + */ +interface RenameButtonProps { + onRequestOpen: (state: RenameDialogState) => void +} + +/** + * Toolbar button that requests opening a rename dialog for the selected + * image/file block. + */ +export const RenameButton = ({ onRequestOpen }: RenameButtonProps) => { + const Components = useComponentsContext()! + const editor = useBlockNoteEditor() + + /** Resolves to the single selected block only when it is an image/file block + * that exposes both `url` and `name` 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 + // Only image/file blocks have both `url` and `name` props. + if (typeof props?.url === 'string' && typeof props?.name === 'string') { + return b + } + return + }, + }) + + /** Extracts the current name from the selected block and requests the + * parent to open the rename dialog. */ + const handleClick = useCallback(() => { + if (!block) return + const name = ((block.props as Record).name as string) || '' + onRequestOpen({ blockId: block.id, name }) + }, [block, onRequestOpen]) + + if (block === undefined) return null + + return ( + } + /> + ) +} diff --git a/src/features/editor/ui/RenameDialog.tsx b/src/features/editor/ui/RenameDialog.tsx new file mode 100644 index 0000000..62dee57 --- /dev/null +++ b/src/features/editor/ui/RenameDialog.tsx @@ -0,0 +1,120 @@ +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 { RenameDialogState } from './RenameButton' + +/** + * Props for the {@link RenameDialog} component. + * + * @property state - The rename 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 RenameDialogProps { + state: RenameDialogState | null + onDismiss: () => void +} + +/** + * Dialog for renaming image/file blocks. + * + * Rendered at the Editor component level (outside the FormattingToolbar) + * so it survives toolbar unmount cycles. + */ +export const RenameDialog = ({ state, onDismiss }: RenameDialogProps) => { + const editor = useBlockNoteEditor() + /** Current name text bound to the input field. */ + const [name, setName] = 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) + + useEffect(() => { + if (state) { + setName(state.name) + } + }, [state]) + + 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) + }, []) + + /** Closes the dialog without saving and returns focus to the editor. */ + const handleDismiss = useCallback(() => { + onDismiss() + editor.focus() + }, [editor, onDismiss]) + + /** Persists the updated name 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: { name } } as any) + } + onDismiss() + editor.focus() + }, [editor, state, name, onDismiss]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !composingRef.current) { + e.preventDefault() + handleSave() + } + }, + [handleSave] + ) + + return ( + { + if (!open) void handleDismiss() + }} + > + + + Rename + + setName(e.target.value)} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + onKeyDown={handleKeyDown} + autoFocus + /> + + + + + + + ) +}