From 16ceae2a809f7c5d76191164aa358b203389f003 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Thu, 2 Jul 2026 07:54:07 +0000 Subject: [PATCH 1/5] feat/client: embed images inline in notes Images can now be inserted anywhere in a note via a toolbar button, paste, or drag-and-drop, and render at the position the user placed them. Images are embedded as base64 data URIs directly in the markdown content (standard `![alt](data:...)` syntax), so they flow through the existing note encryption and sync pipeline unchanged: no new server endpoints, tables, or sync logic. Uploaded images are downscaled/recompressed client-side (max 1600px, JPEG quality 0.85) to keep the encrypted note blob within the server's 16MB column limit; GIFs are left untouched to preserve animation, and oversized originals or still-too-large results are rejected with a toast before insertion. --- client/package-lock.json | 14 ++ client/package.json | 1 + client/src/components/note/NoteEditor.css | 14 ++ client/src/components/note/NoteEditor.tsx | 80 ++++++++++- .../note/__tests__/NoteEditor.test.tsx | 105 ++++++++++++++ client/src/lib/__tests__/image.test.ts | 133 ++++++++++++++++++ client/src/lib/image.ts | 90 ++++++++++++ 7 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 client/src/components/note/__tests__/NoteEditor.test.tsx create mode 100644 client/src/lib/__tests__/image.test.ts create mode 100644 client/src/lib/image.ts 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/components/note/NoteEditor.css b/client/src/components/note/NoteEditor.css index 15a9693..4c81df7 100644 --- a/client/src/components/note/NoteEditor.css +++ b/client/src/components/note/NoteEditor.css @@ -118,6 +118,20 @@ margin: 1.25rem 0; } +.note-editor .ProseMirror img { + max-width: 100%; + height: auto; + border-radius: 8px; + border: 1px solid #334155; + margin-bottom: 0.75rem; + display: block; +} + +.note-editor .ProseMirror img.ProseMirror-selectednode { + outline: 2px solid #60a5fa; + outline-offset: 2px; +} + .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..2db4cd7 100644 --- a/client/src/components/note/NoteEditor.tsx +++ b/client/src/components/note/NoteEditor.tsx @@ -1,9 +1,27 @@ import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "@tiptap/markdown"; +import Image from "@tiptap/extension-image"; +import type { EditorView } from "@tiptap/pm/view"; import { useEffect, useRef } from "react"; +import { prepareImageForInsert, ImageInputError } from "../../lib/image"; +import { useToasts } from "../../store/toasts"; 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 }); + } +} + type Props = { noteId: string; content: string; @@ -49,8 +67,10 @@ 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, Image.configure({ inline: false, allowBase64: true })], content, contentType: "markdown", editable: !disabled, @@ -65,8 +85,49 @@ 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 file = Array.from(event.dataTransfer?.files ?? []).find((f) => + f.type.startsWith("image/") + ); + if (!file) return false; + + event.preventDefault(); + const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos; + insertImageAtPos(view, file, pos ?? view.state.selection.from); + 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 +280,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..236d80e --- /dev/null +++ b/client/src/components/note/__tests__/NoteEditor.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +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(); + }); +}); diff --git a/client/src/lib/__tests__/image.test.ts b/client/src/lib/__tests__/image.test.ts new file mode 100644 index 0000000..2e7306e --- /dev/null +++ b/client/src/lib/__tests__/image.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { isSupportedImageType, prepareImageForInsert, ImageInputError } 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("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/image.ts b/client/src/lib/image.ts new file mode 100644 index 0000000..826287c --- /dev/null +++ b/client/src/lib/image.ts @@ -0,0 +1,90 @@ +/** 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"]); + +// 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; +} From d5ad271bcbf09c80d56cfde9d59dd47b10383089 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Thu, 2 Jul 2026 11:38:20 +0000 Subject: [PATCH 2/5] fix/client: disable native drag-drop interception so in-editor image drop works Tauri v2 enables its own OS-level drag-drop handling by default (dragDropEnabled: true), which intercepts drag/drop at the WebView layer and prevents the browser's native HTML5 drop event from carrying real file data through dataTransfer.files. This is why the editor's dropcursor indicator showed correctly (dragover still fires) but the actual drop silently did nothing: handleDrop's dataTransfer lookup found no files. Nothing in the app uses Tauri's native onDragDropEvent API, so disabling it is safe and restores standard browser drag-and-drop behavior for the note editor. Ref: https://github.com/tauri-apps/tauri/issues/14373 --- client/src-tauri/tauri.conf.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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": { From 445c5349de8e01f35099ca687d7bb1b88e983af6 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Thu, 2 Jul 2026 11:38:28 +0000 Subject: [PATCH 3/5] feat/client: make embedded images resizable Enables tiptap's built-in resize handles on note images (drag a corner, aspect ratio locked). The default renderMarkdown for images only emits plain `![alt](src)`, which has no field for width/height, so a resize would silently revert on the next save. ResizableImage overrides renderMarkdown to fall back to a raw `` tag whenever a size is set, and plain markdown syntax otherwise; parseMarkdown already round-trips raw HTML images via the schema's normal HTML parsing, so this is lossless in both directions. --- client/src/components/note/NoteEditor.css | 45 ++++++++++++++- client/src/components/note/NoteEditor.tsx | 12 +++- .../note/__tests__/NoteEditor.test.tsx | 17 ++++++ .../src/lib/__tests__/resizableImage.test.ts | 57 +++++++++++++++++++ client/src/lib/resizableImage.ts | 34 +++++++++++ 5 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 client/src/lib/__tests__/resizableImage.test.ts create mode 100644 client/src/lib/resizableImage.ts diff --git a/client/src/components/note/NoteEditor.css b/client/src/components/note/NoteEditor.css index 4c81df7..3d18feb 100644 --- a/client/src/components/note/NoteEditor.css +++ b/client/src/components/note/NoteEditor.css @@ -118,20 +118,59 @@ margin: 1.25rem 0; } -.note-editor .ProseMirror img { +.note-editor .ProseMirror [data-resize-wrapper] img { max-width: 100%; height: auto; border-radius: 8px; border: 1px solid #334155; - margin-bottom: 0.75rem; display: block; } -.note-editor .ProseMirror img.ProseMirror-selectednode { +.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 2db4cd7..68792f6 100644 --- a/client/src/components/note/NoteEditor.tsx +++ b/client/src/components/note/NoteEditor.tsx @@ -1,10 +1,10 @@ import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "@tiptap/markdown"; -import Image from "@tiptap/extension-image"; import type { EditorView } from "@tiptap/pm/view"; import { useEffect, useRef } from "react"; import { prepareImageForInsert, ImageInputError } from "../../lib/image"; +import { ResizableImage } from "../../lib/resizableImage"; import { useToasts } from "../../store/toasts"; import "./NoteEditor.css"; @@ -70,7 +70,15 @@ export default function NoteEditor({ noteId, content, onChange, disabled }: Prop const fileInputRef = useRef(null); const editor = useEditor({ - extensions: [StarterKit, Markdown, Image.configure({ inline: false, allowBase64: true })], + extensions: [ + StarterKit, + Markdown, + ResizableImage.configure({ + inline: false, + allowBase64: true, + resize: { enabled: true, alwaysPreserveAspectRatio: true, minWidth: 60, minHeight: 60 }, + }), + ], content, contentType: "markdown", editable: !disabled, diff --git a/client/src/components/note/__tests__/NoteEditor.test.tsx b/client/src/components/note/__tests__/NoteEditor.test.tsx index 236d80e..6322283 100644 --- a/client/src/components/note/__tests__/NoteEditor.test.tsx +++ b/client/src/components/note/__tests__/NoteEditor.test.tsx @@ -102,4 +102,21 @@ describe("NoteEditor image insertion", () => { 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); + }); + }); }); 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/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 ``; + }, +}); From 9bf25fa9fa2ac5a9da7b14416ebb1eb0af6f5ef0 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Thu, 2 Jul 2026 19:26:00 +0000 Subject: [PATCH 4/5] fix/client: stop resized images from stretching when the pane shrinks The resize node view sets width/height as inline styles directly on the , which beats any CSS class rule (including height: auto) short of !important. When the note pane got narrower than a resized image's stored width, max-width: 100% clamped the rendered width but the inline height stayed pinned at its old value, since a plain height: auto rule can't win against an inline style, producing the squished/stretched look. Forcing height: auto with !important makes the rendered height always follow the (possibly clamped) width via the image's own intrinsic aspect ratio instead of trusting a separately-tracked pixel value that can drift out of sync with it. --- client/src/components/note/NoteEditor.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/components/note/NoteEditor.css b/client/src/components/note/NoteEditor.css index 3d18feb..5159b12 100644 --- a/client/src/components/note/NoteEditor.css +++ b/client/src/components/note/NoteEditor.css @@ -118,9 +118,10 @@ 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; + height: auto !important; border-radius: 8px; border: 1px solid #334155; display: block; From 0f740f54a508c925169270f694320530533ea30b Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Fri, 3 Jul 2026 13:31:49 +0000 Subject: [PATCH 5/5] fix/client: read dropped image bytes via disk path when WebKitGTK omits Files With dragDropEnabled disabled, OS file drops now reach the browser's drop event, but WebKitGTK still doesn't populate dataTransfer.files for them: it only exposes a file:// URI via text/uri-list (this is what showed up as inserted text before this fix). There's no way to read a file's bytes from the frontend given just this path due to browser sandboxing, so handleDrop now falls back to extracting that path and reading it through a new read_dropped_image Tauri command, then feeds the resulting bytes through the same prepareImageForInsert pipeline used everywhere else. Adds: - read_image_file in commands.rs (sync, size-capped at 20MB, unit tested) plus the read_dropped_image command wrapper - fileUriToPath/mimeTypeForFilename in lib/image.ts to resolve the URI and infer a mime type from the extension, since raw bytes alone don't carry one --- client/src-tauri/src/commands.rs | 65 +++++++++++++++++++ client/src-tauri/src/lib.rs | 3 +- client/src/components/note/NoteEditor.tsx | 53 +++++++++++++-- .../note/__tests__/NoteEditor.test.tsx | 38 +++++++++++ client/src/lib/__tests__/image.test.ts | 45 ++++++++++++- client/src/lib/image.ts | 28 ++++++++ 6 files changed, 226 insertions(+), 6 deletions(-) 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/components/note/NoteEditor.tsx b/client/src/components/note/NoteEditor.tsx index 68792f6..e8eee8d 100644 --- a/client/src/components/note/NoteEditor.tsx +++ b/client/src/components/note/NoteEditor.tsx @@ -2,10 +2,12 @@ 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 } from "../../lib/image"; +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 @@ -22,6 +24,36 @@ async function insertImageAtPos(view: EditorView, file: File, pos: number) { } } +/** 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; @@ -107,14 +139,27 @@ export default function NoteEditor({ noteId, content, onChange, disabled }: Prop }, 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) return false; + 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(); - const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos; - insertImageAtPos(view, file, pos ?? view.state.selection.from); + insertImageFromPathAtPos(view, path, pos); return true; }, }, diff --git a/client/src/components/note/__tests__/NoteEditor.test.tsx b/client/src/components/note/__tests__/NoteEditor.test.tsx index 6322283..1ab8f0b 100644 --- a/client/src/components/note/__tests__/NoteEditor.test.tsx +++ b/client/src/components/note/__tests__/NoteEditor.test.tsx @@ -1,6 +1,7 @@ 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. */ @@ -119,4 +120,41 @@ describe("NoteEditor image insertion", () => { 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 index 2e7306e..f6edbfe 100644 --- a/client/src/lib/__tests__/image.test.ts +++ b/client/src/lib/__tests__/image.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { isSupportedImageType, prepareImageForInsert, ImageInputError } from "../image"; +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 }); @@ -38,6 +44,43 @@ describe("isSupportedImageType", () => { }); }); +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; diff --git a/client/src/lib/image.ts b/client/src/lib/image.ts index 826287c..b0ef132 100644 --- a/client/src/lib/image.ts +++ b/client/src/lib/image.ts @@ -8,6 +8,34 @@ export class ImageInputError extends Error { 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;