Fix image uploads using the wrong format (#2380)
* fix(app): preserve image attachment MIME types Image intake discarded clipboard and desktop path metadata, then guessed JPEG when a later file object had no type. Resolve MIME once at the platform boundary and require it downstream. * fix(app): honor native picker MIME metadata
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
isRasterImageMimeType,
|
isRasterImageMimeType,
|
||||||
isRasterImagePath,
|
isRasterImagePath,
|
||||||
RASTER_IMAGE_FILE_EXTENSIONS,
|
RASTER_IMAGE_FILE_EXTENSIONS,
|
||||||
|
resolveRasterImageMimeType,
|
||||||
} from "./file-types";
|
} from "./file-types";
|
||||||
|
|
||||||
describe("attachment file types", () => {
|
describe("attachment file types", () => {
|
||||||
@@ -37,4 +38,28 @@ describe("attachment file types", () => {
|
|||||||
new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]),
|
new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses explicit raster MIME metadata before the filename", () => {
|
||||||
|
expect(
|
||||||
|
resolveRasterImageMimeType({ mimeType: "image/jpeg", path: "/tmp/screenshot.png" }),
|
||||||
|
).toBe("image/jpeg");
|
||||||
|
expect(
|
||||||
|
resolveRasterImageMimeType({
|
||||||
|
mimeType: "image/png; charset=binary",
|
||||||
|
path: "/tmp/screenshot.jpg",
|
||||||
|
}),
|
||||||
|
).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the filename only when MIME metadata is absent", () => {
|
||||||
|
expect(resolveRasterImageMimeType({ mimeType: "", path: "/tmp/screenshot.png" })).toBe(
|
||||||
|
"image/png",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveRasterImageMimeType({
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
path: "/tmp/screenshot.png",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,18 +41,31 @@ export function getRasterImageMimeTypeFromPath(path: string): string | null {
|
|||||||
return RASTER_IMAGE_MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? null;
|
return RASTER_IMAGE_MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveRasterImageMimeType(input: {
|
||||||
|
mimeType?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
}): string | null {
|
||||||
|
const suppliedMimeType = input.mimeType?.trim();
|
||||||
|
if (suppliedMimeType) {
|
||||||
|
const normalizedMimeType = suppliedMimeType.split(";", 1)[0]?.trim().toLowerCase();
|
||||||
|
if (normalizedMimeType === "image/jpg") {
|
||||||
|
return "image/jpeg";
|
||||||
|
}
|
||||||
|
return normalizedMimeType && RASTER_IMAGE_MIME_TYPES.has(normalizedMimeType)
|
||||||
|
? normalizedMimeType
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return input.path ? getRasterImageMimeTypeFromPath(input.path) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function isRasterImagePath(path: string): boolean {
|
export function isRasterImagePath(path: string): boolean {
|
||||||
return getRasterImageMimeTypeFromPath(path) !== null;
|
return getRasterImageMimeTypeFromPath(path) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRasterImageMimeType(mimeType: string | null | undefined): boolean {
|
export function isRasterImageMimeType(mimeType: string | null | undefined): boolean {
|
||||||
const normalized = mimeType?.split(";", 1)[0]?.trim().toLowerCase();
|
return resolveRasterImageMimeType({ mimeType }) !== null;
|
||||||
return Boolean(normalized && RASTER_IMAGE_MIME_TYPES.has(normalized));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRasterImageFile(file: Pick<File, "name" | "type">): boolean {
|
export function isRasterImageFile(file: Pick<File, "name" | "type">): boolean {
|
||||||
if (isRasterImageMimeType(file.type)) {
|
return resolveRasterImageMimeType({ mimeType: file.type, path: file.name }) !== null;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return file.type.trim().length === 0 && isRasterImagePath(file.name);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import type { ImageAttachment } from "@/composer/types";
|
|||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
|
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
|
||||||
import {
|
import {
|
||||||
getRasterImageMimeTypeFromPath,
|
|
||||||
isRasterImageFile,
|
isRasterImageFile,
|
||||||
isRasterImagePath,
|
isRasterImagePath,
|
||||||
|
resolveRasterImageMimeType,
|
||||||
} from "@/attachments/file-types";
|
} from "@/attachments/file-types";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
|
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
|
||||||
@@ -27,14 +27,21 @@ interface DesktopDragDropEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function filePathToImageAttachment(path: string): Promise<ImageAttachment> {
|
async function filePathToImageAttachment(path: string): Promise<ImageAttachment> {
|
||||||
const mimeType = getRasterImageMimeTypeFromPath(path) ?? "image/jpeg";
|
const mimeType = resolveRasterImageMimeType({ path });
|
||||||
|
if (!mimeType) {
|
||||||
|
throw new Error(`Unsupported image type for '${path}'.`);
|
||||||
|
}
|
||||||
return await persistAttachmentFromFileUri({ uri: path, mimeType });
|
return await persistAttachmentFromFileUri({ uri: path, mimeType });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
||||||
|
const mimeType = resolveRasterImageMimeType({ mimeType: file.type, path: file.name });
|
||||||
|
if (!mimeType) {
|
||||||
|
throw new Error(`Unsupported image type for '${file.name}'.`);
|
||||||
|
}
|
||||||
return await persistAttachmentFromBlob({
|
return await persistAttachmentFromBlob({
|
||||||
blob: file,
|
blob: file,
|
||||||
mimeType: file.type || "image/jpeg",
|
mimeType,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,7 +321,11 @@ describe("pickAndPersistImages", () => {
|
|||||||
const persister = createFakePersister();
|
const persister = createFakePersister();
|
||||||
const result = await pickAndPersistImages({
|
const result = await pickAndPersistImages({
|
||||||
pickImages: async () => [
|
pickImages: async () => [
|
||||||
{ source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null },
|
{
|
||||||
|
source: { kind: "file_uri", uri: "/tmp/x.jpg" },
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
fileName: null,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
persister,
|
persister,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export async function pickAndPersistImages(input: {
|
|||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
result.map(async (picked) => {
|
result.map(async (picked) => {
|
||||||
const fileName = picked.fileName ?? null;
|
const fileName = picked.fileName ?? null;
|
||||||
const mimeType = picked.mimeType || "image/jpeg";
|
const mimeType = picked.mimeType;
|
||||||
if (picked.source.kind === "blob") {
|
if (picked.source.kind === "blob") {
|
||||||
return await input.persister.persistFromBlob({
|
return await input.persister.persistFromBlob({
|
||||||
blob: picked.source.blob,
|
blob: picked.source.blob,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export async function normalizePickedImageAssets(
|
|||||||
return normalizePickedImageAssetsWith(assets, exportPickedImageAsPng);
|
return normalizePickedImageAssetsWith(assets, exportPickedImageAsPng);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openImagePathsWithDesktopDialog(_dialog?: unknown): Promise<string[]> {
|
export async function pickImagesWithDesktopDialog(
|
||||||
|
_dialog?: unknown,
|
||||||
|
): Promise<PickedImageAttachmentInput[]> {
|
||||||
throw new Error("Desktop dialog API is not available on native.");
|
throw new Error("Desktop dialog API is not available on native.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "@/desktop/host";
|
import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "@/desktop/host";
|
||||||
import {
|
import { normalizePickedImageAssets, pickImagesWithDesktopDialog } from "./image-attachment-picker";
|
||||||
normalizePickedImageAssets,
|
|
||||||
openImagePathsWithDesktopDialog,
|
|
||||||
} from "./image-attachment-picker";
|
|
||||||
|
|
||||||
function fakeDialogReturning(selection: string | string[] | null): {
|
function fakeDialogReturning(selection: string | string[] | null): {
|
||||||
dialog: DesktopDialogBridge;
|
dialog: DesktopDialogBridge;
|
||||||
@@ -42,6 +39,27 @@ describe("image-attachment-picker", () => {
|
|||||||
expect(result[0]?.mimeType).toBe("image/png");
|
expect(result[0]?.mimeType).toBe("image/png");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives the type of a type-less picked File from its name", async () => {
|
||||||
|
const file = new File(["image"], "picked.png");
|
||||||
|
|
||||||
|
const result = await normalizePickedImageAssets([
|
||||||
|
{
|
||||||
|
uri: "blob:test",
|
||||||
|
mimeType: null,
|
||||||
|
fileName: null,
|
||||||
|
file,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
source: { kind: "blob", blob: file },
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "picked.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps filesystem picker results as file uris", async () => {
|
it("keeps filesystem picker results as file uris", async () => {
|
||||||
const result = await normalizePickedImageAssets([
|
const result = await normalizePickedImageAssets([
|
||||||
{
|
{
|
||||||
@@ -78,7 +96,7 @@ describe("image-attachment-picker", () => {
|
|||||||
it("uses the desktop dialog api when available", async () => {
|
it("uses the desktop dialog api when available", async () => {
|
||||||
const { dialog, recordedOptions } = fakeDialogReturning(["/tmp/one.png", "/tmp/two.jpg"]);
|
const { dialog, recordedOptions } = fakeDialogReturning(["/tmp/one.png", "/tmp/two.jpg"]);
|
||||||
|
|
||||||
const result = await openImagePathsWithDesktopDialog(dialog);
|
const result = await pickImagesWithDesktopDialog(dialog);
|
||||||
|
|
||||||
expect(recordedOptions).toHaveLength(1);
|
expect(recordedOptions).toHaveLength(1);
|
||||||
expect(recordedOptions[0]).toMatchObject({
|
expect(recordedOptions[0]).toMatchObject({
|
||||||
@@ -86,14 +104,25 @@ describe("image-attachment-picker", () => {
|
|||||||
directory: false,
|
directory: false,
|
||||||
title: "Attach images",
|
title: "Attach images",
|
||||||
});
|
});
|
||||||
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
source: { kind: "file_uri", uri: "/tmp/one.png" },
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "one.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: { kind: "file_uri", uri: "/tmp/two.jpg" },
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
fileName: "two.jpg",
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when desktop dialog API is not available", async () => {
|
it("throws when desktop dialog API is not available", async () => {
|
||||||
await expect(openImagePathsWithDesktopDialog(null)).rejects.toThrow(
|
await expect(pickImagesWithDesktopDialog(null)).rejects.toThrow(
|
||||||
"Desktop dialog API is not available.",
|
"Desktop dialog API is not available.",
|
||||||
);
|
);
|
||||||
await expect(openImagePathsWithDesktopDialog({})).rejects.toThrow(
|
await expect(pickImagesWithDesktopDialog({})).rejects.toThrow(
|
||||||
"Desktop dialog API is not available.",
|
"Desktop dialog API is not available.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DesktopDialogBridge } from "@/desktop/host";
|
import type { DesktopDialogBridge } from "@/desktop/host";
|
||||||
import { RASTER_IMAGE_FILE_EXTENSIONS } from "@/attachments/file-types";
|
import { RASTER_IMAGE_FILE_EXTENSIONS, resolveRasterImageMimeType } from "@/attachments/file-types";
|
||||||
|
import { getFileNameFromPath } from "@/attachments/utils";
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
import { isAbsolutePath } from "@/utils/path";
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blo
|
|||||||
|
|
||||||
export interface PickedImageAttachmentInput {
|
export interface PickedImageAttachmentInput {
|
||||||
source: PickedImageSource;
|
source: PickedImageSource;
|
||||||
mimeType?: string | null;
|
mimeType: string;
|
||||||
fileName?: string | null;
|
fileName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,30 +31,52 @@ async function blobFromUri(uri: string): Promise<Blob> {
|
|||||||
return await response.blob();
|
return await response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requirePickedImageMimeType(input: {
|
||||||
|
mimeType?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
}): string {
|
||||||
|
const mimeType = resolveRasterImageMimeType(input);
|
||||||
|
if (!mimeType) {
|
||||||
|
throw new Error(`Unsupported image type for '${input.path ?? "selected image"}'.`);
|
||||||
|
}
|
||||||
|
return mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
export async function normalizePickedImageAssets(
|
export async function normalizePickedImageAssets(
|
||||||
assets: readonly ExpoImagePickerAssetLike[],
|
assets: readonly ExpoImagePickerAssetLike[],
|
||||||
): Promise<PickedImageAttachmentInput[]> {
|
): Promise<PickedImageAttachmentInput[]> {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
assets.map(async (asset) => {
|
assets.map(async (asset) => {
|
||||||
if (asset.file instanceof Blob) {
|
if (asset.file instanceof Blob) {
|
||||||
|
const fileName = asset.fileName ?? asset.file.name ?? null;
|
||||||
return {
|
return {
|
||||||
source: { kind: "blob", blob: asset.file },
|
source: { kind: "blob", blob: asset.file },
|
||||||
mimeType: asset.mimeType ?? asset.file.type ?? null,
|
mimeType: requirePickedImageMimeType({
|
||||||
fileName: asset.fileName ?? asset.file.name ?? null,
|
mimeType: asset.mimeType || asset.file.type,
|
||||||
|
path: fileName ?? asset.uri,
|
||||||
|
}),
|
||||||
|
fileName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldTreatAsFileUri(asset.uri)) {
|
if (shouldTreatAsFileUri(asset.uri)) {
|
||||||
return {
|
return {
|
||||||
source: { kind: "file_uri", uri: asset.uri },
|
source: { kind: "file_uri", uri: asset.uri },
|
||||||
mimeType: asset.mimeType ?? null,
|
mimeType: requirePickedImageMimeType({
|
||||||
|
mimeType: asset.mimeType,
|
||||||
|
path: asset.fileName ?? asset.uri,
|
||||||
|
}),
|
||||||
fileName: asset.fileName ?? null,
|
fileName: asset.fileName ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blob = await blobFromUri(asset.uri);
|
||||||
return {
|
return {
|
||||||
source: { kind: "blob", blob: await blobFromUri(asset.uri) },
|
source: { kind: "blob", blob },
|
||||||
mimeType: asset.mimeType ?? null,
|
mimeType: requirePickedImageMimeType({
|
||||||
|
mimeType: asset.mimeType || blob.type,
|
||||||
|
path: asset.fileName ?? asset.uri,
|
||||||
|
}),
|
||||||
fileName: asset.fileName ?? null,
|
fileName: asset.fileName ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -67,9 +90,9 @@ function normalizeDesktopDialogSelection(selection: string | string[] | null): s
|
|||||||
return Array.isArray(selection) ? selection : [selection];
|
return Array.isArray(selection) ? selection : [selection];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openImagePathsWithDesktopDialog(
|
export async function pickImagesWithDesktopDialog(
|
||||||
dialog: DesktopDialogBridge | null | undefined,
|
dialog: DesktopDialogBridge | null | undefined,
|
||||||
): Promise<string[]> {
|
): Promise<PickedImageAttachmentInput[]> {
|
||||||
const options = {
|
const options = {
|
||||||
directory: false,
|
directory: false,
|
||||||
multiple: true,
|
multiple: true,
|
||||||
@@ -87,5 +110,15 @@ export async function openImagePathsWithDesktopDialog(
|
|||||||
throw new Error("Desktop dialog API is not available.");
|
throw new Error("Desktop dialog API is not available.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeDesktopDialogSelection(await dialogOpen(options));
|
return normalizeDesktopDialogSelection(await dialogOpen(options)).map((path) => {
|
||||||
|
const mimeType = resolveRasterImageMimeType({ path });
|
||||||
|
if (!mimeType) {
|
||||||
|
throw new Error(`Unsupported image type for '${path}'.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
source: { kind: "file_uri" as const, uri: path },
|
||||||
|
mimeType,
|
||||||
|
fileName: getFileNameFromPath(path),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,30 @@ describe("native image attachment picker", () => {
|
|||||||
expect(recordedUris).toEqual([]);
|
expect(recordedUris).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses explicit native MIME metadata before the URI extension", async () => {
|
||||||
|
const { exportAsPng, recordedUris } = fakeExportAsPng();
|
||||||
|
|
||||||
|
const result = await normalizePickedImageAssetsWith(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
uri: "file:///photos/screenshot.jpg",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "screenshot.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exportAsPng,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
source: { kind: "file_uri", uri: "file:///photos/screenshot.jpg" },
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "screenshot.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(recordedUris).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
|
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
|
||||||
const { exportAsPng, recordedUris } = fakeExportAsPng();
|
const { exportAsPng, recordedUris } = fakeExportAsPng();
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blo
|
|||||||
|
|
||||||
export interface PickedImageAttachmentInput {
|
export interface PickedImageAttachmentInput {
|
||||||
source: PickedImageSource;
|
source: PickedImageSource;
|
||||||
mimeType?: string | null;
|
mimeType: string;
|
||||||
fileName?: string | null;
|
fileName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,13 +62,15 @@ function pickedAssetSupportedFormat(
|
|||||||
asset: ExpoImagePickerAssetLike,
|
asset: ExpoImagePickerAssetLike,
|
||||||
): SupportedPickedImageFormat | null {
|
): SupportedPickedImageFormat | null {
|
||||||
const uriExtension = extensionFromPath(asset.uri);
|
const uriExtension = extensionFromPath(asset.uri);
|
||||||
if (uriExtension) {
|
const uriFormat = supportedFormatForExtension(uriExtension);
|
||||||
return supportedFormatForExtension(uriExtension);
|
if (uriExtension && !uriFormat) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
supportedFormatForMimeType(asset.mimeType) ??
|
||||||
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
|
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
|
||||||
supportedFormatForMimeType(asset.mimeType)
|
uriFormat
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||||
import {
|
import {
|
||||||
normalizePickedImageAssets,
|
normalizePickedImageAssets,
|
||||||
openImagePathsWithDesktopDialog,
|
pickImagesWithDesktopDialog,
|
||||||
type PickedImageAttachmentInput,
|
type PickedImageAttachmentInput,
|
||||||
} from "@/hooks/image-attachment-picker";
|
} from "@/hooks/image-attachment-picker";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
@@ -51,15 +51,11 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isWeb && isElectronRuntime()) {
|
if (isWeb && isElectronRuntime()) {
|
||||||
const selectedPaths = await openImagePathsWithDesktopDialog(getDesktopHost()?.dialog);
|
const selectedImages = await pickImagesWithDesktopDialog(getDesktopHost()?.dialog);
|
||||||
if (selectedPaths.length === 0) {
|
if (selectedImages.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return selectedPaths.map((path) => ({
|
return selectedImages;
|
||||||
source: { kind: "file_uri" as const, uri: path },
|
|
||||||
mimeType: null,
|
|
||||||
fileName: null,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPermission = await ensurePermission();
|
const hasPermission = await ensurePermission();
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ describe("collectImageFilesFromClipboardData", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(files).toEqual([imagePng]);
|
expect(files).toEqual([{ file: imagePng, mimeType: "image/png" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores SVG clipboard files", () => {
|
it("ignores SVG clipboard files", () => {
|
||||||
@@ -127,7 +127,10 @@ describe("filesToImageAttachments", () => {
|
|||||||
type: "",
|
type: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const attachments = await filesToImageAttachments([pngFile, typeLessFile]);
|
const attachments = await filesToImageAttachments([
|
||||||
|
{ file: pngFile, mimeType: "image/png" },
|
||||||
|
{ file: typeLessFile, mimeType: "image/png" },
|
||||||
|
]);
|
||||||
|
|
||||||
expect(attachments).toEqual([
|
expect(attachments).toEqual([
|
||||||
{
|
{
|
||||||
@@ -141,7 +144,7 @@ describe("filesToImageAttachments", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "att-2",
|
id: "att-2",
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/png",
|
||||||
storageType: "web-indexeddb",
|
storageType: "web-indexeddb",
|
||||||
storageKey: "att-2",
|
storageKey: "att-2",
|
||||||
fileName: "second",
|
fileName: "second",
|
||||||
@@ -156,7 +159,7 @@ describe("filesToImageAttachments", () => {
|
|||||||
type: "image/png",
|
type: "image/png",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [attachment] = await filesToImageAttachments([large]);
|
const [attachment] = await filesToImageAttachments([{ file: large, mimeType: "image/png" }]);
|
||||||
|
|
||||||
expect(attachment?.storageType).toBe("web-indexeddb");
|
expect(attachment?.storageType).toBe("web-indexeddb");
|
||||||
expect(attachment?.byteSize).toBe(4 * 1024 * 1024);
|
expect(attachment?.byteSize).toBe(4 * 1024 * 1024);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import { persistAttachmentFromBlob } from "@/attachments/service";
|
import { persistAttachmentFromBlob } from "@/attachments/service";
|
||||||
import { isRasterImageMimeType } from "@/attachments/file-types";
|
import { resolveRasterImageMimeType } from "@/attachments/file-types";
|
||||||
|
|
||||||
export interface ClipboardItemLike {
|
export interface ClipboardItemLike {
|
||||||
kind?: string;
|
kind?: string;
|
||||||
@@ -14,40 +14,46 @@ export interface ClipboardDataLike {
|
|||||||
|
|
||||||
export type ImageAttachmentFromFile = AttachmentMetadata;
|
export type ImageAttachmentFromFile = AttachmentMetadata;
|
||||||
|
|
||||||
|
export interface ClipboardImageFile {
|
||||||
|
file: File;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function collectImageFilesFromClipboardData(
|
export function collectImageFilesFromClipboardData(
|
||||||
clipboardData?: ClipboardDataLike | null,
|
clipboardData?: ClipboardDataLike | null,
|
||||||
): File[] {
|
): ClipboardImageFile[] {
|
||||||
if (!clipboardData?.items) {
|
if (!clipboardData?.items) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const files: File[] = [];
|
const files: ClipboardImageFile[] = [];
|
||||||
for (const item of Array.from(clipboardData.items)) {
|
for (const item of Array.from(clipboardData.items)) {
|
||||||
if (item?.kind !== "file") {
|
if (item?.kind !== "file") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isRasterImageMimeType(item.type)) {
|
const mimeType = resolveRasterImageMimeType({ mimeType: item.type });
|
||||||
|
if (!mimeType) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const file = item.getAsFile?.();
|
const file = item.getAsFile?.();
|
||||||
if (!file) {
|
if (!file) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
files.push(file);
|
files.push({ file, mimeType });
|
||||||
}
|
}
|
||||||
|
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function filesToImageAttachments(
|
export async function filesToImageAttachments(
|
||||||
files: readonly File[],
|
files: readonly ClipboardImageFile[],
|
||||||
): Promise<ImageAttachmentFromFile[]> {
|
): Promise<ImageAttachmentFromFile[]> {
|
||||||
const attachments = await Promise.all(
|
const attachments = await Promise.all(
|
||||||
files.map(async (file) => {
|
files.map(async ({ file, mimeType }) => {
|
||||||
try {
|
try {
|
||||||
return await persistAttachmentFromBlob({
|
return await persistAttachmentFromBlob({
|
||||||
blob: file,
|
blob: file,
|
||||||
mimeType: file.type || "image/jpeg",
|
mimeType,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user