diff --git a/client/package-lock.json b/client/package-lock.json index eab2051..3a0e67e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,6 +13,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", @@ -1618,6 +1619,19 @@ "@tiptap/pm": "3.22.4" } }, + "node_modules/@tiptap/extension-image": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.22.4.tgz", + "integrity": "sha512-ZDc+fLaratTQ4IgnKcJJwfUgUgpcHjbZSBi6UQAILJwkflMy1Zxj8mpbma5P934nLSI+uDnR5ret6ZZLNITKhA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.4" + } + }, "node_modules/@tiptap/extension-italic": { "version": "3.22.4", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.4.tgz", diff --git a/client/package.json b/client/package.json index b3aaac4..7e553d1 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src-tauri/src/commands.rs b/client/src-tauri/src/commands.rs index 07d3fe1..096d6d4 100644 --- a/client/src-tauri/src/commands.rs +++ b/client/src-tauri/src/commands.rs @@ -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, 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, CommandError> { + read_image_file(&path) +} + #[cfg(test)] mod tests { use super::*; @@ -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] diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 0a33ee7..092799e 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -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"); diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index 93affaf..c8758ad 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -14,7 +14,8 @@ { "title": "Nooto", "width": 800, - "height": 600 + "height": 600, + "dragDropEnabled": false } ], "security": { diff --git a/client/src/components/note/NoteEditor.css b/client/src/components/note/NoteEditor.css index 15a9693..5159b12 100644 --- a/client/src/components/note/NoteEditor.css +++ b/client/src/components/note/NoteEditor.css @@ -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; diff --git a/client/src/components/note/NoteEditor.tsx b/client/src/components/note/NoteEditor.tsx index 6401208..e8eee8d 100644 --- a/client/src/components/note/NoteEditor.tsx +++ b/client/src/components/note/NoteEditor.tsx @@ -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("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; @@ -49,8 +99,18 @@ export default function NoteEditor({ noteId, content, onChange, disabled }: Prop const isMountedRef = useRef(false); const lastContentRef = useRef(content); + const fileInputRef = useRef(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, @@ -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) => { + 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) { @@ -219,6 +333,23 @@ export default function NoteEditor({ noteId, content, onChange, disabled }: Prop + + + + + + + + + + + editor?.chain().focus().undo().run()} disabled={isDisabled || !editor?.can().undo()} diff --git a/client/src/components/note/__tests__/NoteEditor.test.tsx b/client/src/components/note/__tests__/NoteEditor.test.tsx new file mode 100644 index 0000000..1ab8f0b --- /dev/null +++ b/client/src/components/note/__tests__/NoteEditor.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { invoke } from "@tauri-apps/api/core"; +import NoteEditor from "../NoteEditor"; + +/** Stand-in for HTMLImageElement: jsdom doesn't decode images, so tests control dimensions directly. */ +class SmallFakeImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + naturalWidth = 200; + naturalHeight = 150; + private _src = ""; + + set src(value: string) { + this._src = value; + queueMicrotask(() => this.onload?.()); + } + + get src() { + return this._src; + } +} + +function makeImageFile(name = "photo.png") { + return new File([new Uint8Array(50)], name, { type: "image/png" }); +} + +describe("NoteEditor image insertion", () => { + const originalImage = globalThis.Image; + + afterEach(() => { + globalThis.Image = originalImage; + vi.restoreAllMocks(); + }); + + it("inserts an image via the toolbar button and syncs it back as markdown", async () => { + // @ts-expect-error test double for HTMLImageElement + globalThis.Image = SmallFakeImage; + const user = userEvent.setup(); + const onChange = vi.fn(); + + const { container } = render( + + ); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + expect(fileInput).toBeTruthy(); + + await user.upload(fileInput, makeImageFile("photo.png")); + + await waitFor(() => { + expect(container.querySelector(".ProseMirror img")).toBeTruthy(); + }); + + const img = container.querySelector(".ProseMirror img") as HTMLImageElement; + expect(img.getAttribute("src")).toMatch(/^data:image\/png;base64,/); + expect(img.getAttribute("alt")).toBe("photo.png"); + + await waitFor( + () => { + expect(onChange).toHaveBeenCalled(); + }, + { timeout: 1000 } + ); + const lastMarkdown = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string; + expect(lastMarkdown).toMatch(/!\[photo\.png\]\(data:image\/png;base64,[^)]+\)/); + }); + + it("inserts an image at the cursor position on paste", async () => { + // @ts-expect-error test double for HTMLImageElement + globalThis.Image = SmallFakeImage; + + const onChange = vi.fn(); + const { container } = render( + + ); + + await waitFor(() => { + expect(container.querySelector(".ProseMirror")).toBeTruthy(); + }); + const pm = container.querySelector(".ProseMirror") as HTMLElement; + + const file = makeImageFile("pasted.png"); + const clipboardData = { + items: [{ type: "image/png", getAsFile: () => file }], + files: [file], + types: ["Files"], + getData: () => "", + }; + const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(pasteEvent, "clipboardData", { value: clipboardData }); + pm.dispatchEvent(pasteEvent); + + await waitFor(() => { + expect(container.querySelector(".ProseMirror img")).toBeTruthy(); + }); + const img = container.querySelector(".ProseMirror img") as HTMLImageElement; + expect(img.getAttribute("alt")).toBe("pasted.png"); + }); + + it("disables the insert-image button when the editor is disabled", () => { + render(); + expect(screen.getByTitle("Insert image")).toBeDisabled(); + }); + + it("renders resize handles on an inserted image", async () => { + // @ts-expect-error test double for HTMLImageElement + globalThis.Image = SmallFakeImage; + const user = userEvent.setup(); + + const { container } = render( + + ); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, makeImageFile("photo.png")); + + await waitFor(() => { + expect(container.querySelectorAll("[data-resize-handle]").length).toBeGreaterThan(0); + }); + }); + + it("reads the file from disk when a drop only carries a file:// path (WebKitGTK)", async () => { + // @ts-expect-error test double for HTMLImageElement + globalThis.Image = SmallFakeImage; + vi.mocked(invoke).mockResolvedValue(Array.from(new Uint8Array([1, 2, 3, 4]))); + document.elementFromPoint = vi.fn(() => null); + + const { container } = render( + + ); + + await waitFor(() => { + expect(container.querySelector(".ProseMirror")).toBeTruthy(); + }); + const pm = container.querySelector(".ProseMirror") as HTMLElement; + + const dataTransfer = { + files: [], + getData: (type: string) => + type === "text/uri-list" ? "file:///home/clement/Downloads/3.png" : "", + }; + const dropEvent = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, "dataTransfer", { value: dataTransfer }); + Object.defineProperty(dropEvent, "clientX", { value: 0 }); + Object.defineProperty(dropEvent, "clientY", { value: 0 }); + pm.dispatchEvent(dropEvent); + + await waitFor(() => { + expect(container.querySelector(".ProseMirror img")).toBeTruthy(); + }); + + expect(invoke).toHaveBeenCalledWith("read_dropped_image", { + path: "/home/clement/Downloads/3.png", + }); + const img = container.querySelector(".ProseMirror img") as HTMLImageElement; + expect(img.getAttribute("alt")).toBe("3.png"); + }); +}); diff --git a/client/src/lib/__tests__/image.test.ts b/client/src/lib/__tests__/image.test.ts new file mode 100644 index 0000000..f6edbfe --- /dev/null +++ b/client/src/lib/__tests__/image.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + isSupportedImageType, + prepareImageForInsert, + ImageInputError, + mimeTypeForFilename, + fileUriToPath, +} from "../image"; + +function makeFile(name: string, type: string, sizeBytes = 100): File { + return new File([new Uint8Array(sizeBytes)], name, { type }); +} + +/** Stand-in for HTMLImageElement: jsdom doesn't decode images, so tests control dimensions directly. */ +class FakeImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + naturalWidth = 0; + naturalHeight = 0; + private _src = ""; + + set src(value: string) { + this._src = value; + queueMicrotask(() => this.onload?.()); + } + + get src() { + return this._src; + } +} + +describe("isSupportedImageType", () => { + it("accepts common image formats", () => { + expect(isSupportedImageType(makeFile("a.png", "image/png"))).toBe(true); + expect(isSupportedImageType(makeFile("a.jpg", "image/jpeg"))).toBe(true); + expect(isSupportedImageType(makeFile("a.webp", "image/webp"))).toBe(true); + expect(isSupportedImageType(makeFile("a.gif", "image/gif"))).toBe(true); + }); + + it("rejects non-image or unsupported formats", () => { + expect(isSupportedImageType(makeFile("a.pdf", "application/pdf"))).toBe(false); + expect(isSupportedImageType(makeFile("a.svg", "image/svg+xml"))).toBe(false); + expect(isSupportedImageType(makeFile("a.txt", ""))).toBe(false); + }); +}); + +describe("mimeTypeForFilename", () => { + it("maps known extensions case-insensitively", () => { + expect(mimeTypeForFilename("photo.PNG")).toBe("image/png"); + expect(mimeTypeForFilename("photo.jpg")).toBe("image/jpeg"); + expect(mimeTypeForFilename("photo.jpeg")).toBe("image/jpeg"); + expect(mimeTypeForFilename("photo.webp")).toBe("image/webp"); + expect(mimeTypeForFilename("photo.gif")).toBe("image/gif"); + }); + + it("returns null for unrecognized or missing extensions", () => { + expect(mimeTypeForFilename("document.pdf")).toBe(null); + expect(mimeTypeForFilename("noextension")).toBe(null); + }); +}); + +describe("fileUriToPath", () => { + it("returns null for non-file URIs", () => { + expect(fileUriToPath("https://example.com/a.png")).toBe(null); + }); + + it("decodes a plain unix path", () => { + expect(fileUriToPath("file:///home/clement/Downloads/3.png")).toBe( + "/home/clement/Downloads/3.png" + ); + }); + + it("decodes percent-encoded characters", () => { + expect(fileUriToPath("file:///home/clement/My%20Photos/3.png")).toBe( + "/home/clement/My Photos/3.png" + ); + }); + + it("strips the extra leading slash on Windows drive paths", () => { + expect(fileUriToPath("file:///C:/Users/clement/3.png")).toBe("C:/Users/clement/3.png"); + }); +}); + +describe("prepareImageForInsert", () => { + const originalImage = globalThis.Image; + + afterEach(() => { + globalThis.Image = originalImage; + vi.restoreAllMocks(); + }); + + it("rejects unsupported file types before reading", async () => { + await expect(prepareImageForInsert(makeFile("a.pdf", "application/pdf"))).rejects.toThrow( + ImageInputError + ); + }); + + it("rejects files above the raw size limit", async () => { + const file = makeFile("a.png", "image/png"); + Object.defineProperty(file, "size", { value: 21 * 1024 * 1024 }); + await expect(prepareImageForInsert(file)).rejects.toThrow(/too large/); + }); + + it("returns the original data URL unchanged when dimensions are within bounds", async () => { + class SmallImage extends FakeImage { + naturalWidth = 400; + naturalHeight = 300; + } + // @ts-expect-error test double for HTMLImageElement + globalThis.Image = SmallImage; + const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext"); + + const result = await prepareImageForInsert(makeFile("small.png", "image/png")); + + expect(result.startsWith("data:image/png;base64,")).toBe(true); + expect(getContextSpy).not.toHaveBeenCalled(); + }); + + it("downscales through canvas when dimensions exceed the max", async () => { + class LargeImage extends FakeImage { + naturalWidth = 4000; + naturalHeight = 2000; + } + // @ts-expect-error test double for HTMLImageElement + globalThis.Image = LargeImage; + + const drawImage = vi.fn(); + const toDataURL = vi.fn(() => "data:image/jpeg;base64,cmVzaXplZA=="); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue( + { drawImage } as unknown as CanvasRenderingContext2D + ); + vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockImplementation(toDataURL); + + const result = await prepareImageForInsert(makeFile("large.jpg", "image/jpeg")); + + expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1600, 800); + expect(toDataURL).toHaveBeenCalledWith("image/jpeg", 0.85); + expect(result).toBe("data:image/jpeg;base64,cmVzaXplZA=="); + }); + + it("never resizes GIFs, to preserve animation", async () => { + class LargeImage extends FakeImage { + naturalWidth = 4000; + naturalHeight = 4000; + } + // @ts-expect-error test double for HTMLImageElement + globalThis.Image = LargeImage; + const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext"); + + const result = await prepareImageForInsert(makeFile("anim.gif", "image/gif")); + + expect(getContextSpy).not.toHaveBeenCalled(); + expect(result.startsWith("data:image/gif;base64,")).toBe(true); + }); + + it("rejects when the compressed result is still too large", async () => { + class LargeImage extends FakeImage { + naturalWidth = 4000; + naturalHeight = 4000; + } + // @ts-expect-error test double for HTMLImageElement + globalThis.Image = LargeImage; + + const hugeBase64 = "A".repeat(12 * 1024 * 1024); // decodes to ~9MB, above the 8MB cap + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue( + { drawImage: vi.fn() } as unknown as CanvasRenderingContext2D + ); + vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue( + `data:image/jpeg;base64,${hugeBase64}` + ); + + await expect(prepareImageForInsert(makeFile("large.png", "image/png"))).rejects.toThrow( + /too large even after compression/ + ); + }); +}); diff --git a/client/src/lib/__tests__/resizableImage.test.ts b/client/src/lib/__tests__/resizableImage.test.ts new file mode 100644 index 0000000..d2e750d --- /dev/null +++ b/client/src/lib/__tests__/resizableImage.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { Markdown } from "@tiptap/markdown"; +import { ResizableImage } from "../resizableImage"; + +function makeEditor(content = "") { + return new Editor({ + element: document.createElement("div"), + extensions: [ + StarterKit, + Markdown, + ResizableImage.configure({ inline: false, allowBase64: true, resize: { enabled: true } }), + ], + content, + contentType: "markdown", + }); +} + +describe("ResizableImage markdown round-trip", () => { + it("uses plain markdown syntax when no width/height is set", () => { + const editor = makeEditor(); + editor.commands.insertContent({ + type: "image", + attrs: { src: "data:image/png;base64,AAAA", alt: "plain" }, + }); + + expect(editor.getMarkdown().trim()).toBe("![plain](data:image/png;base64,AAAA)"); + }); + + it("serializes a resized image as a raw tag with width/height", () => { + const editor = makeEditor(); + editor.commands.insertContent({ + type: "image", + attrs: { src: "data:image/png;base64,BBBB", alt: "resized", width: 400, height: 200 }, + }); + + expect(editor.getMarkdown().trim()).toBe( + 'resized' + ); + }); + + it("round-trips a resized image through markdown without losing its size", () => { + const editor = makeEditor(); + editor.commands.insertContent({ + type: "image", + attrs: { src: "data:image/png;base64,CCCC", alt: "note-image", width: 320, height: 180 }, + }); + + const markdown = editor.getMarkdown(); + editor.commands.setContent(markdown, { contentType: "markdown", emitUpdate: false }); + + const imageNode = editor.getJSON().content?.find((n) => n.type === "image"); + expect(imageNode?.attrs).toMatchObject({ width: 320, height: 180 }); + expect(editor.getMarkdown()).toBe(markdown); + }); +}); diff --git a/client/src/lib/image.ts b/client/src/lib/image.ts new file mode 100644 index 0000000..b0ef132 --- /dev/null +++ b/client/src/lib/image.ts @@ -0,0 +1,118 @@ +/** Raised for any invalid image input; message is safe to show to the user. */ +export class ImageInputError extends Error { + constructor(message: string) { + super(message); + this.name = "ImageInputError"; + } +} + +const SUPPORTED_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); + +const EXTENSION_MIME_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + webp: "image/webp", + gif: "image/gif", +}; + +/** Best-effort mime type for a filename based on its extension, or null if unrecognized. */ +export function mimeTypeForFilename(name: string): string | null { + const ext = name.split(".").pop()?.toLowerCase(); + return ext ? (EXTENSION_MIME_TYPES[ext] ?? null) : null; +} + +/** + * Extracts a local filesystem path from a `file://` URI, or null if `uri` isn't one. + * Handles the Windows case where `file:///C:/...` parses with a leading slash before the drive letter. + */ +export function fileUriToPath(uri: string): string | null { + if (!uri.startsWith("file://")) return null; + try { + const decoded = decodeURIComponent(new URL(uri).pathname); + return /^\/[A-Za-z]:\//.test(decoded) ? decoded.slice(1) : decoded; + } catch { + return null; + } +} + +// Notes are stored as a single encrypted blob (16MB server column limit), so embedded +// images need to stay well within budget alongside the note's text and other images. +const MAX_ORIGINAL_FILE_BYTES = 20 * 1024 * 1024; +const MAX_EMBEDDED_BYTES = 8 * 1024 * 1024; +const MAX_IMAGE_DIMENSION = 1600; +const JPEG_QUALITY = 0.85; + +export function isSupportedImageType(file: File): boolean { + return SUPPORTED_TYPES.has(file.type); +} + +export function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new ImageInputError("Failed to read image file.")); + reader.readAsDataURL(file); + }); +} + +function loadImageElement(dataUrl: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new ImageInputError("Failed to decode image.")); + img.src = dataUrl; + }); +} + +/** Returns the approximate decoded byte size of a base64 data URL. */ +function decodedByteSize(dataUrl: string): number { + const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1); + return Math.floor((base64.length * 3) / 4); +} + +/** + * Downscales `dataUrl` through a canvas so its longest side is at most `maxDimension`. + * GIFs are left untouched to preserve animation. Returns the original data URL unchanged + * if it's already small enough or if canvas isn't available. + */ +async function resizeIfNeeded(dataUrl: string, mimeType: string, maxDimension: number): Promise { + const img = await loadImageElement(dataUrl); + const { naturalWidth: width, naturalHeight: height } = img; + + if (mimeType === "image/gif" || (width <= maxDimension && height <= maxDimension)) { + return dataUrl; + } + + const scale = maxDimension / Math.max(width, height); + const canvas = document.createElement("canvas"); + canvas.width = Math.round(width * scale); + canvas.height = Math.round(height * scale); + + const ctx = canvas.getContext("2d"); + if (!ctx) return dataUrl; + + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + + const outputType = mimeType === "image/png" ? "image/png" : "image/jpeg"; + return canvas.toDataURL(outputType, JPEG_QUALITY); +} + +/** Validates, reads and downscales an image file, returning a data URL ready to embed in a note. */ +export async function prepareImageForInsert(file: File): Promise { + if (!isSupportedImageType(file)) { + throw new ImageInputError("Unsupported image format. Use PNG, JPEG, WebP or GIF."); + } + if (file.size > MAX_ORIGINAL_FILE_BYTES) { + throw new ImageInputError("Image is too large (max 20 MB)."); + } + + const original = await readFileAsDataUrl(file); + const resized = await resizeIfNeeded(original, file.type, MAX_IMAGE_DIMENSION); + + if (decodedByteSize(resized) > MAX_EMBEDDED_BYTES) { + throw new ImageInputError("Image is too large even after compression. Try a smaller image."); + } + + return resized; +} diff --git a/client/src/lib/resizableImage.ts b/client/src/lib/resizableImage.ts new file mode 100644 index 0000000..3afc8a7 --- /dev/null +++ b/client/src/lib/resizableImage.ts @@ -0,0 +1,34 @@ +import Image from "@tiptap/extension-image"; + +/** + * Plain markdown image syntax (`![alt](src)`) has no width/height field, so the stock + * renderMarkdown drops any resize the user did. Here it falls back to a raw `` tag + * when width or height is set; parseMarkdown already handles that via generic HTML + * parsing, so this stays a lossless round-trip. + */ +export const ResizableImage = Image.extend({ + renderMarkdown(node) { + const { src, alt, title, width, height } = node.attrs as { + src: string; + alt: string | null; + title: string | null; + width: number | null; + height: number | null; + }; + + if (!width && !height) { + return title ? `![${alt ?? ""}](${src} "${title}")` : `![${alt ?? ""}](${src})`; + } + + const attrs = [ + `src="${src}"`, + alt ? `alt="${alt}"` : null, + title ? `title="${title}"` : null, + width ? `width="${width}"` : null, + height ? `height="${height}"` : null, + ] + .filter(Boolean) + .join(" "); + return ``; + }, +});