Skip to content

Commit 366ec3e

Browse files
committed
fix: support transformed image field uploads
1 parent 2d8ca73 commit 366ec3e

6 files changed

Lines changed: 128 additions & 4 deletions

File tree

packages/decap-cms-core/src/lib/__tests__/imageTransformations.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,41 @@ import {
44
getImageTransformationsConfig,
55
shouldTransformImage,
66
sortTransformationFilesForSelection,
7+
transformImage,
78
} from '../imageTransformations';
89

10+
const mockPhotonImage = {
11+
get_width: jest.fn(() => 100),
12+
get_height: jest.fn(() => 50),
13+
get_bytes_webp: jest.fn(() => new Uint8Array([1, 2, 3])),
14+
get_bytes_jpeg: jest.fn(() => new Uint8Array([4, 5, 6])),
15+
get_bytes: jest.fn(() => new Uint8Array([7, 8, 9])),
16+
free: jest.fn(),
17+
};
18+
19+
const mockNewFromByteslice = jest.fn(() => mockPhotonImage);
20+
const mockNewFromBlob = jest.fn();
21+
22+
jest.mock(
23+
'@silvia-odwyer/photon',
24+
() => ({
25+
__esModule: true,
26+
default: jest.fn(() => Promise.resolve()),
27+
PhotonImage: {
28+
new_from_byteslice: mockNewFromByteslice,
29+
new_from_blob: mockNewFromBlob,
30+
},
31+
resize: jest.fn(),
32+
SamplingFilter: { Lanczos3: 'Lanczos3' },
33+
}),
34+
{ virtual: true },
35+
);
36+
937
describe('imageTransformations', () => {
38+
beforeEach(() => {
39+
jest.clearAllMocks();
40+
});
41+
1042
describe('getImageTransformationsConfig', () => {
1143
it('normalizes array shorthand and keeps the original by default', () => {
1244
expect(
@@ -105,4 +137,25 @@ describe('imageTransformations', () => {
105137
expect(sortTransformationFilesForSelection([original, small])).toEqual([small, original]);
106138
});
107139
});
140+
141+
describe('transformImage', () => {
142+
it('creates the Photon image from file bytes instead of blob', async () => {
143+
const file = new File([new Uint8Array([255, 216, 255])], 'image.jpg', {
144+
type: 'image/jpeg',
145+
});
146+
147+
const files = await transformImage(file, 'uploads/image.jpg', {
148+
keepOriginal: false,
149+
variants: [{ name: 'webp', format: 'webp', keep_original_size: true }],
150+
});
151+
152+
expect(mockNewFromByteslice).toHaveBeenCalledTimes(1);
153+
expect(mockNewFromByteslice.mock.calls[0][0]).toEqual(new Uint8Array([255, 216, 255]));
154+
expect(mockNewFromBlob).not.toHaveBeenCalled();
155+
expect(files).toHaveLength(1);
156+
expect(files[0].file.name).toBe('image.webp');
157+
expect(files[0].path).toBe('uploads/_transformations/webp/image.webp');
158+
expect(mockPhotonImage.free).toHaveBeenCalledTimes(1);
159+
});
160+
});
108161
});

packages/decap-cms-core/src/lib/imageTransformations.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,19 @@ function getImageBytes(image: PhotonImage, format: string, quality = 75) {
174174
return image.get_bytes();
175175
}
176176

177+
async function getFileBytes(file: File) {
178+
if (typeof file.arrayBuffer === 'function') {
179+
return new Uint8Array(await file.arrayBuffer());
180+
}
181+
182+
return new Promise<Uint8Array>((resolve, reject) => {
183+
const reader = new FileReader();
184+
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
185+
reader.onerror = () => reject(reader.error);
186+
reader.readAsArrayBuffer(file);
187+
});
188+
}
189+
177190
export async function transformImage(
178191
file: File,
179192
originalPath: string,
@@ -183,7 +196,8 @@ export async function transformImage(
183196
const initPhoton = (photon as unknown as { default: () => Promise<unknown> }).default;
184197
await initPhoton();
185198

186-
const originalImage = photon.PhotonImage.new_from_blob(file);
199+
const imageBytes = await getFileBytes(file);
200+
const originalImage = photon.PhotonImage.new_from_byteslice(imageBytes);
187201

188202
try {
189203
const transformedFiles = config.variants.map(variant => {

packages/decap-cms-core/src/reducers/__tests__/entries.spec.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,18 @@ describe('entries', () => {
549549
).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png');
550550
});
551551

552+
it('should preserve generated transformation paths', () => {
553+
expect(
554+
selectMediaFilePublicPath(
555+
{ public_folder: '/uploads' },
556+
null,
557+
'public/uploads/_transformations/webp/kittens.webp',
558+
undefined,
559+
undefined,
560+
),
561+
).toBe('/uploads/_transformations/webp/kittens.webp');
562+
});
563+
552564
it('should handle file public_folder', () => {
553565
const entry = fromJS({
554566
path: 'src/posts/index.md',

packages/decap-cms-core/src/reducers/__tests__/mediaLibrary.spec.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,35 @@ describe('mediaLibrary', () => {
108108
expect(selectMediaFolder).toHaveBeenCalledWith(state.config, collection, entry, imageField);
109109
});
110110

111+
it('should select draft transformation media files from collection when editing a draft', () => {
112+
const { selectEditingDraft, selectMediaFolder } = require('../../reducers/entries');
113+
114+
selectEditingDraft.mockReturnValue(true);
115+
selectMediaFolder.mockReturnValue('static/images/posts');
116+
117+
const imageField = fromJS({ name: 'image' });
118+
const collection = fromJS({ fields: [imageField] });
119+
const entry = fromJS({
120+
collection: 'posts',
121+
mediaFiles: [
122+
{ id: 1, path: 'static/images/posts/_transformations/webp/logo.webp' },
123+
{ id: 2, path: 'static/images/other/_transformations/webp/image.webp' },
124+
],
125+
data: {},
126+
});
127+
const state = {
128+
config: {},
129+
collections: fromJS({ posts: collection }),
130+
entryDraft: fromJS({
131+
entry,
132+
}),
133+
};
134+
135+
expect(selectMediaFiles(state, imageField)).toEqual([
136+
{ id: 1, key: 1, path: 'static/images/posts/_transformations/webp/logo.webp' },
137+
]);
138+
});
139+
111140
it('should select global media files when not editing a draft', () => {
112141
const { selectEditingDraft } = require('../../reducers/entries');
113142

packages/decap-cms-core/src/reducers/entries.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,17 @@ function getFileField(collectionFiles: CollectionFiles, slug: string | undefined
547547
return file;
548548
}
549549

550+
function getMediaPublicPathSegment(mediaPath: string) {
551+
const normalizedPath = trim(mediaPath, '/');
552+
const transformationIndex = normalizedPath.indexOf('_transformations/');
553+
554+
if (transformationIndex >= 0) {
555+
return normalizedPath.slice(transformationIndex);
556+
}
557+
558+
return basename(mediaPath);
559+
}
560+
550561
function hasCustomFolder(
551562
folderKey: 'media_folder' | 'public_folder',
552563
collection: Collection | null,
@@ -806,10 +817,10 @@ export function selectMediaFilePublicPath(
806817
}
807818

808819
if (isAbsolutePath(publicFolder)) {
809-
return joinUrlPath(publicFolder, basename(mediaPath));
820+
return joinUrlPath(publicFolder, getMediaPublicPathSegment(mediaPath));
810821
}
811822

812-
return join(publicFolder, basename(mediaPath));
823+
return join(publicFolder, getMediaPublicPathSegment(mediaPath));
813824
}
814825

815826
export function selectEditingDraft(state: EntryDraft) {

packages/decap-cms-core/src/reducers/mediaLibrary.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ import type {
3434
EntryField,
3535
} from '../types/redux';
3636

37+
function isMediaFileInFolder(filePath: string, mediaFolder: string) {
38+
const fileFolder = dirname(filePath);
39+
return fileFolder === mediaFolder || fileFolder.startsWith(`${mediaFolder}/_transformations/`);
40+
}
41+
3742
const defaultState: {
3843
isVisible: boolean;
3944
showMediaButton: boolean;
@@ -270,7 +275,7 @@ export function selectMediaFiles(state: State, field?: EntryField) {
270275
const collection = state.collections.get(entry?.get('collection'));
271276
const mediaFolder = selectMediaFolder(state.config, collection, entry, field);
272277
files = entryFiles
273-
.filter(f => dirname(f.path) === mediaFolder)
278+
.filter(f => isMediaFileInFolder(f.path, mediaFolder))
274279
.map(file => ({ key: file.id, ...file }));
275280
} else {
276281
files = mediaLibrary.get('files') || [];

0 commit comments

Comments
 (0)