diff --git a/src/components/CwlEditor.test.tsx b/src/components/CwlEditor.test.tsx index aa937f34..376b36d7 100644 --- a/src/components/CwlEditor.test.tsx +++ b/src/components/CwlEditor.test.tsx @@ -121,7 +121,7 @@ describe('inline image helper (used by paste/drop/upload)', () => { maxDimension: 0, quality: 0.85, }), - ).rejects.toThrow(/exceeds/); + ).rejects.toThrow(/too large to insert/); }); }); @@ -363,7 +363,7 @@ describe('CwlEditor onImageError (paste/drop commercial path)', () => { expect(handled).toBe(true); await waitFor(() => expect(onImageError).toHaveBeenCalled()); - expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i); + expect(String(onImageError.mock.calls[0]![0])).toMatch(/too large to insert/i); expect(ed!.getHTML()).not.toContain('data:image'); }); @@ -448,7 +448,7 @@ describe('CwlEditor onImageError (paste/drop commercial path)', () => { expect(handled).toBe(true); await waitFor(() => expect(onImageError).toHaveBeenCalled()); - expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i); + expect(String(onImageError.mock.calls[0]![0])).toMatch(/too large to insert/i); }); }); diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx index aeaf6895..a79d5a71 100644 --- a/src/components/Toolbar.test.tsx +++ b/src/components/Toolbar.test.tsx @@ -112,7 +112,7 @@ describe('Toolbar', () => { const italic = screen.getByRole('button', { name: /Italic/ }); const insertTable = screen.getByRole('button', { name: /^Insert table$/ }); const insertImage = screen.getByRole('button', { - name: /Insert inline \(base64\) image/, + name: /Insert inline image/, }); const enabledButtons = ( screen.getAllByRole('button') as HTMLButtonElement[] @@ -149,7 +149,7 @@ describe('Toolbar', () => { const bold = screen.getByRole('button', { name: /Bold/ }); const insertImage = screen.getByRole('button', { - name: /Insert inline \(base64\) image/, + name: /Insert inline image/, }); fireEvent.focus(insertImage); expect(insertImage).toHaveAttribute('tabindex', '0'); @@ -276,7 +276,9 @@ describe('Toolbar', () => { fireEvent.change(fileInput(), { target: { files: [file] } }); await waitFor(() => expect(onImageError).toHaveBeenCalled()); expect(editor.getHTML()).not.toContain('data:image'); - expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i); + expect(String(onImageError.mock.calls[0]![0])).toMatch( + /too large to insert/i, + ); }); it('does not throw when oversized and no onImageError is wired', async () => { diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 55136e55..08e7b12b 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -368,7 +368,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { onClick={() => editor.chain().focus().deleteTable().run()} /> fileInputRef.current?.click()} /> diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts index 13586cb2..1b62fe99 100644 --- a/src/converter/base64.fallbacks.test.ts +++ b/src/converter/base64.fallbacks.test.ts @@ -76,7 +76,7 @@ describe('readBlobBytes environment fallbacks', () => { vi.stubGlobal('FileReader', NullErrorReader); const fakeBlob = { type: 'image/png' } as unknown as Blob; await expect(blobToDataUri(fakeBlob)).rejects.toThrow( - /FileReader failed to read Blob/, + /This file couldn't be read/, ); }); diff --git a/src/converter/base64.test.ts b/src/converter/base64.test.ts index 15748b47..8bb3af76 100644 --- a/src/converter/base64.test.ts +++ b/src/converter/base64.test.ts @@ -214,6 +214,21 @@ describe('data URI parsing & decoding', () => { const uri = bytesToDataUri(PNG_BYTES); expect(() => dataUriToBytes(uri, { maxBytes: 4 })).toThrow(Base64SizeError); }); + it('size guidance names the inclusive limit in exact human units', () => { + const renderLimit = (maxBytes: number): string => + new Base64SizeError(PNG_BYTES.byteLength, maxBytes).message; + // Sub-kilobyte limits stay in bytes; whole KB and MB limits use the unit + // users reason about, so the next action ("choose a file at or below N") + // matches the inclusive size guard without mental arithmetic. + expect(() => + bytesToDataUri(new Uint8Array(4), { maxBytes: 4 }), + ).not.toThrow(); + expect(renderLimit(4)).toContain('at or below 4 bytes'); + expect(renderLimit(2048)).toContain('at or below 2 KB'); + expect(renderLimit(3 * 1024 * 1024)).toContain('at or below 3 MB'); + // Non-aligned limits fall back to exact bytes rather than rounding. + expect(renderLimit(1500)).toContain('at or below 1500 bytes'); + }); }); describe('full round-trip', () => { diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 76313e28..59420db0 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -19,7 +19,7 @@ export class Base64SizeError extends Error { readonly maxBytes: number; constructor(bytes: number, maxBytes: number) { super( - `Payload of ${bytes} bytes exceeds the configured limit of ${maxBytes} bytes.`, + `This file is too large to insert. Choose a file at or below ${formatByteLimit(maxBytes)}.`, ); this.name = 'Base64SizeError'; this.bytes = bytes; @@ -27,6 +27,17 @@ export class Base64SizeError extends Error { } } +/** Render a byte limit in the largest exact unit users reason about. */ +function formatByteLimit(maxBytes: number): string { + if (maxBytes >= 1024 * 1024 && maxBytes % (1024 * 1024) === 0) { + return `${maxBytes / (1024 * 1024)} MB`; + } + if (maxBytes >= 1024 && maxBytes % 1024 === 0) { + return `${maxBytes / 1024} KB`; + } + return `${maxBytes} bytes`; +} + /** Error thrown when a string is not a well-formed data URI. */ export class DataUriParseError extends Error { constructor(message: string) { @@ -224,7 +235,10 @@ async function readBlobBytes(blob: Blob): Promise { reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer)); reader.onerror = () => - reject(reader.error ?? new Error('FileReader failed to read Blob.')); + reject( + reader.error ?? + new Error("This file couldn't be read. Try again or choose a different file."), + ); reader.readAsArrayBuffer(blob); }); } diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index e506d02c..f14f0e28 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -115,10 +115,25 @@ export async function imageFileToInlineDataUri( return dataUri; } -/** Normalize a caught value to the Error contract exposed to hosts. */ -function normalizeImageError(error: unknown): Error { - /* v8 ignore next -- all shipped validation and conversion paths throw Error. */ - return error instanceof Error ? error : new Error('Image processing failed.'); +/** + * Normalize a caught value to the Error contract exposed to hosts. + * + * Native Errors may carry actionable Inkspan guidance and public subclass + * metadata, so preserve them unchanged. `structuredClone` performs the + * platform's native Error brand check without walking an untrusted value's + * prototype chain; proxies, non-Errors, and runtimes without structured clone + * fail closed to the bounded customer-facing fallback below. + */ +export function normalizeImageError(error: unknown): Error { + try { + const cloned = structuredClone(error); + if (Object.prototype.toString.call(cloned) === '[object Error]') { + return error as Error; + } + } catch { + // Hostile proxies and unavailable structured-clone implementations fall through. + } + return new Error("This image couldn't be inserted. Try a different image file."); } export const Base64Image = Image.extend({ diff --git a/src/extensions/Base64ImageHostileErrorNormalization.test.ts b/src/extensions/Base64ImageHostileErrorNormalization.test.ts new file mode 100644 index 00000000..b0eb1b84 --- /dev/null +++ b/src/extensions/Base64ImageHostileErrorNormalization.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeImageError } from './Base64Image.js'; + +describe('Base64Image hostile error normalization', () => { + it('does not inspect the prototype of an untrusted rejected value', () => { + const privateSentinel = new Error('private image rejection sentinel'); + let prototypeReads = 0; + const hostile = new Proxy( + {}, + { + getPrototypeOf() { + prototypeReads += 1; + throw privateSentinel; + }, + }, + ); + let normalized: Error | undefined; + + expect(() => { + normalized = normalizeImageError(hostile); + }).not.toThrow(); + expect(prototypeReads).toBe(0); + expect(normalized).toBeInstanceOf(Error); + expect(normalized?.message).toBe( + "This image couldn't be inserted. Try a different image file.", + ); + expect(normalized?.message).not.toContain(privateSentinel.message); + }); +}); diff --git a/src/extensions/Base64ImageSourcePolicy.test.tsx b/src/extensions/Base64ImageSourcePolicy.test.tsx index 783d9bd6..703d6019 100644 --- a/src/extensions/Base64ImageSourcePolicy.test.tsx +++ b/src/extensions/Base64ImageSourcePolicy.test.tsx @@ -10,6 +10,7 @@ import type { CwlEditorHandle } from '../types.js'; import { Base64Image, Base64ImageSourceError, + normalizeImageError, validateInlineImageSource, } from './Base64Image.js'; import { buildExtensions } from './kit.js'; @@ -293,4 +294,21 @@ describe('defense-in-depth image rendering', () => { 'true', ); }); -}); \ No newline at end of file +}); +describe('host error normalization', () => { + it('passes real Error rejections through unchanged', () => { + const rejection = new Base64SizeError(10, 5); + expect(normalizeImageError(rejection)).toBe(rejection); + }); + + it('converts hostile non-Error rejections into actionable guidance', () => { + // Host promise chains may reject with plain strings; users still need a + // next action instead of "[object Object]". + const normalized = normalizeImageError('boom'); + expect(normalized).toBeInstanceOf(Error); + expect(normalized.message).toBe( + "This image couldn't be inserted. Try a different image file.", + ); + expect(normalized.message.toLowerCase()).not.toContain('boom'); + }); +}); diff --git a/src/extensions/SafeClipboard.coverageContract.test.ts b/src/extensions/SafeClipboard.coverageContract.test.ts index 5d3bcd0b..34f7db10 100644 --- a/src/extensions/SafeClipboard.coverageContract.test.ts +++ b/src/extensions/SafeClipboard.coverageContract.test.ts @@ -32,7 +32,7 @@ describe('SafeClipboard fail-closed coverage contract', () => { ).toThrowError( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", }), ); }); @@ -59,7 +59,7 @@ describe('SafeClipboard fail-closed coverage contract', () => { it('keeps the redacted sanitizer error class stable', () => { expect(new ClipboardSanitizationError('invalid_html')).toMatchObject({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", name: 'ClipboardSanitizationError', }); }); diff --git a/src/extensions/SafeClipboard.test.ts b/src/extensions/SafeClipboard.test.ts index 95e1c73a..5b6bed9f 100644 --- a/src/extensions/SafeClipboard.test.ts +++ b/src/extensions/SafeClipboard.test.ts @@ -165,7 +165,7 @@ describe('sanitizeRichClipboardHtml', () => { ).toThrowError( expect.objectContaining({ code: 'input_too_large', - message: 'Rich clipboard HTML exceeds the configured byte limit.', + message: 'The pasted content is too large to insert. Try pasting less content at once.', }), ); }); @@ -176,7 +176,7 @@ describe('sanitizeRichClipboardHtml', () => { ).toThrowError( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", }), ); }); @@ -373,7 +373,7 @@ describe('SafeClipboard extension', () => { expect(onError).toHaveBeenCalledWith( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", }), ); diff --git a/src/extensions/SafeClipboard.ts b/src/extensions/SafeClipboard.ts index a3eaef4c..05c54f97 100644 --- a/src/extensions/SafeClipboard.ts +++ b/src/extensions/SafeClipboard.ts @@ -49,13 +49,15 @@ const ERROR_MESSAGES: Readonly> = Object.freeze({ dom_unavailable: 'Rich clipboard sanitization requires a DOM-capable document.', - input_too_large: 'Rich clipboard HTML exceeds the configured byte limit.', + input_too_large: + 'The pasted content is too large to insert. Try pasting less content at once.', node_limit_exceeded: - 'Rich clipboard HTML exceeds the configured node limit.', + 'The pasted content is too complex to insert. Try pasting less content at once.', depth_limit_exceeded: - 'Rich clipboard HTML exceeds the configured depth limit.', + 'The pasted content is too deeply nested to insert. Try pasting less content at once.', invalid_configuration: 'Rich clipboard configuration is invalid.', - invalid_html: 'Rich clipboard HTML could not be sanitized.', + invalid_html: + "This content can't be inserted here. Try pasting as plain text instead.", }); /** Error whose stable code and message never disclose clipboard content. */ diff --git a/src/extensions/SafeClipboardExtension.test.ts b/src/extensions/SafeClipboardExtension.test.ts index 6932326a..44df393d 100644 --- a/src/extensions/SafeClipboardExtension.test.ts +++ b/src/extensions/SafeClipboardExtension.test.ts @@ -114,7 +114,7 @@ describe('SafeClipboard TipTap v2 adapter', () => { expect(onError).toHaveBeenCalledWith( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", }), ); expect(String(onError.mock.calls[0]?.[0])).not.toContain('private option'); diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index 7d50f0e4..20616296 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -25,7 +25,7 @@ export class Base64ImageSourceError extends Error { constructor(source: unknown) { const sourcePreview = redactImageSource(source); super( - `Image source must be a strict inline base64 raster data URI (${sourcePreview}).`, + "This image format can't be inserted. Use a PNG, JPEG, GIF, WebP, AVIF, BMP, or ICO image.", ); this.name = 'Base64ImageSourceError'; this.sourcePreview = sourcePreview;