Skip to content
Open
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
14 changes: 14 additions & 0 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-log": "^2.7.0",
"@tauri-apps/plugin-opener": "^2",
"@tiptap/extension-image": "^3.22.1",
"@tiptap/markdown": "^3.22.1",
"@tiptap/react": "^3.22.1",
"@tiptap/starter-kit": "^3.22.1",
Expand Down
65 changes: 65 additions & 0 deletions client/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,31 @@ pub async fn handle_conflict(
Ok(())
}

const MAX_DROPPED_IMAGE_BYTES: u64 = 20 * 1024 * 1024;

/// Reads and size-checks a local image file. Kept separate from the command wrapper so it's testable without a Tauri runtime.
fn read_image_file(path: &str) -> Result<Vec<u8>, CommandError> {
let metadata = std::fs::metadata(path)
.map_err(|e| CommandError::invalid_input(format!("Cannot read '{path}': {e}")))?;

if !metadata.is_file() {
return Err(CommandError::invalid_input(format!("'{path}' is not a file")));
}
if metadata.len() > MAX_DROPPED_IMAGE_BYTES {
return Err(CommandError::invalid_input("Image is too large (max 20 MB)"));
}

std::fs::read(path).map_err(|e| CommandError::invalid_input(format!("Cannot read '{path}': {e}")))
}

/// Reads a dropped image's raw bytes from disk. WebKitGTK doesn't expose OS file drops
/// through the browser's File API, only a `file://` path, so the frontend resolves that
/// path and reads the bytes through this command instead.
#[tauri::command(rename_all = "snake_case")]
pub async fn read_dropped_image(path: String) -> Result<Vec<u8>, CommandError> {
read_image_file(&path)
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -724,6 +749,46 @@ mod tests {
}
}

fn unique_temp_path(name: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("nooto-test-{nanos}-{name}"))
}

// --- read_image_file ---

#[test]
fn read_image_file_returns_bytes_for_existing_file() {
let path = unique_temp_path("small.png");
std::fs::write(&path, b"fake-image-bytes").unwrap();

let result = read_image_file(path.to_str().unwrap());
std::fs::remove_file(&path).ok();

assert_eq!(result.unwrap(), b"fake-image-bytes".to_vec());
}

#[test]
fn read_image_file_rejects_missing_file() {
let result = read_image_file("/nonexistent/nooto-test-path.png");
assert!(matches!(result, Err(CommandError { kind: ErrorKind::InvalidInput, .. })));
}

#[test]
fn read_image_file_rejects_oversized_file() {
let path = unique_temp_path("big.png");
let file = std::fs::File::create(&path).unwrap();
file.set_len(MAX_DROPPED_IMAGE_BYTES + 1).unwrap();
drop(file);

let result = read_image_file(path.to_str().unwrap());
std::fs::remove_file(&path).ok();

assert!(matches!(result, Err(CommandError { kind: ErrorKind::InvalidInput, .. })));
}

// --- CommandError constructors ---

#[test]
Expand Down
3 changes: 2 additions & 1 deletion client/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ pub fn run() {
commands::restore_note,
commands::create_folder,
commands::get_latest_note_id,
commands::handle_conflict
commands::handle_conflict,
commands::read_dropped_image
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
3 changes: 2 additions & 1 deletion client/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
{
"title": "Nooto",
"width": 800,
"height": 600
"height": 600,
"dragDropEnabled": false
}
],
"security": {
Expand Down
54 changes: 54 additions & 0 deletions client/src/components/note/NoteEditor.css
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,60 @@
margin: 1.25rem 0;
}

/* !important overrides the resize handle's inline px height so shrinking the pane rescales proportionally instead of stretching */
.note-editor .ProseMirror [data-resize-wrapper] img {
max-width: 100%;
height: auto !important;
border-radius: 8px;
border: 1px solid #334155;
display: block;
}

.note-editor .ProseMirror [data-resize-container] {
max-width: 100%;
margin-bottom: 0.75rem;
}

.note-editor .ProseMirror [data-resize-container].ProseMirror-selectednode img {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}

.note-editor .ProseMirror [data-resize-handle] {
width: 10px;
height: 10px;
background: #60a5fa;
border: 1px solid #0f172a;
border-radius: 2px;
opacity: 0;
transition: opacity 0.15s;
}

.note-editor .ProseMirror [data-resize-container]:hover [data-resize-handle],
.note-editor .ProseMirror [data-resize-container].ProseMirror-selectednode [data-resize-handle] {
opacity: 1;
}

.note-editor .ProseMirror [data-resize-handle="top-left"] {
cursor: nwse-resize;
transform: translate(-50%, -50%);
}

.note-editor .ProseMirror [data-resize-handle="bottom-right"] {
cursor: nwse-resize;
transform: translate(50%, 50%);
}

.note-editor .ProseMirror [data-resize-handle="top-right"] {
cursor: nesw-resize;
transform: translate(50%, -50%);
}

.note-editor .ProseMirror [data-resize-handle="bottom-left"] {
cursor: nesw-resize;
transform: translate(-50%, 50%);
}

.note-editor .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
Expand Down
133 changes: 132 additions & 1 deletion client/src/components/note/NoteEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,59 @@
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "@tiptap/markdown";
import type { EditorView } from "@tiptap/pm/view";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useRef } from "react";
import { prepareImageForInsert, ImageInputError, mimeTypeForFilename, fileUriToPath } from "../../lib/image";
import { ResizableImage } from "../../lib/resizableImage";
import { useToasts } from "../../store/toasts";
import { handleCommandError } from "../../lib/errors";
import "./NoteEditor.css";

/** Inserts `file` as an image node at `pos` by dispatching directly on the view (used by
* paste/drop handlers, which only have access to the view, not the editor instance). */
async function insertImageAtPos(view: EditorView, file: File, pos: number) {
try {
const src = await prepareImageForInsert(file);
if (view.isDestroyed) return;
const node = view.state.schema.nodes.image.create({ src, alt: file.name });
view.dispatch(view.state.tr.insert(pos, node));
} catch (err) {
const message = err instanceof ImageInputError ? err.message : "Failed to insert image.";
useToasts.getState().addToast({ kind: "invalid_input", message });
}
}

/** Same as insertImageAtPos, but for OS file drops where WebKitGTK gives us only a
* `file://` path in dataTransfer, not a File — the bytes are read via a Tauri command. */
async function insertImageFromPathAtPos(view: EditorView, path: string, pos: number) {
const mimeType = mimeTypeForFilename(path);
if (!mimeType) {
useToasts.getState().addToast({
kind: "invalid_input",
message: "Unsupported image format. Use PNG, JPEG, WebP or GIF.",
});
return;
}

try {
const bytes = await invoke<number[]>("read_dropped_image", { path });
if (view.isDestroyed) return;
const name = path.split(/[/\\]/).pop() ?? "image";
const file = new File([new Uint8Array(bytes)], name, { type: mimeType });
const src = await prepareImageForInsert(file);
if (view.isDestroyed) return;
const node = view.state.schema.nodes.image.create({ src, alt: name });
view.dispatch(view.state.tr.insert(pos, node));
} catch (err) {
if (err instanceof ImageInputError) {
useToasts.getState().addToast({ kind: "invalid_input", message: err.message });
} else {
handleCommandError(err);
}
}
}

type Props = {
noteId: string;
content: string;
Expand Down Expand Up @@ -49,8 +99,18 @@ export default function NoteEditor({ noteId, content, onChange, disabled }: Prop
const isMountedRef = useRef(false);
const lastContentRef = useRef(content);

const fileInputRef = useRef<HTMLInputElement>(null);

const editor = useEditor({
extensions: [StarterKit, Markdown],
extensions: [
StarterKit,
Markdown,
ResizableImage.configure({
inline: false,
allowBase64: true,
resize: { enabled: true, alwaysPreserveAspectRatio: true, minWidth: 60, minHeight: 60 },
}),
],
content,
contentType: "markdown",
editable: !disabled,
Expand All @@ -65,8 +125,62 @@ export default function NoteEditor({ noteId, content, onChange, disabled }: Prop
onChange(markdown);
}, 400);
},
editorProps: {
handlePaste: (view, event) => {
if (!view.editable) return false;
const file = Array.from(event.clipboardData?.items ?? [])
.find((item) => item.type.startsWith("image/"))
?.getAsFile();
if (!file) return false;

event.preventDefault();
insertImageAtPos(view, file, view.state.selection.from);
return true;
},
handleDrop: (view, event) => {
if (!view.editable) return false;
const pos =
view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos ??
view.state.selection.from;

const file = Array.from(event.dataTransfer?.files ?? []).find((f) =>
f.type.startsWith("image/")
);
if (file) {
event.preventDefault();
insertImageAtPos(view, file, pos);
return true;
}

// WebKitGTK doesn't populate dataTransfer.files for OS drops, only a URI list.
const uriList =
event.dataTransfer?.getData("text/uri-list") || event.dataTransfer?.getData("text/plain");
const path = uriList ? fileUriToPath(uriList.split("\n")[0]?.trim() ?? "") : null;
if (!path) return false;

event.preventDefault();
insertImageFromPathAtPos(view, path, pos);
return true;
},
},
});

const handleInsertImageClick = () => fileInputRef.current?.click();

const handleFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || !editor) return;

try {
const src = await prepareImageForInsert(file);
editor.chain().focus().setImage({ src, alt: file.name }).run();
} catch (err) {
const message = err instanceof ImageInputError ? err.message : "Failed to insert image.";
useToasts.getState().addToast({ kind: "invalid_input", message });
}
};

// Reset content when switching to a different note, skip on initial mount
useEffect(() => {
if (!isMountedRef.current) {
Expand Down Expand Up @@ -219,6 +333,23 @@ export default function NoteEditor({ noteId, content, onChange, disabled }: Prop

<Divider />

<ToolbarButton onClick={handleInsertImageClick} disabled={isDisabled} title="Insert image">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" strokeWidth={2} />
<circle cx="8.5" cy="8.5" r="1.5" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 15l-5-5L5 21" />
</svg>
</ToolbarButton>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onChange={handleFileSelected}
className="hidden"
/>

<Divider />

<ToolbarButton
onClick={() => editor?.chain().focus().undo().run()}
disabled={isDisabled || !editor?.can().undo()}
Expand Down
Loading