diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 234b8592e..37eb53914 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -176,7 +176,7 @@ function Editor(props: Props, ref: React.RefObject | null) { (file) => !AttachmentValidation.imageContentTypes.includes(file.type) ); - insertFiles(view, event, pos, files, { + return insertFiles(view, event, pos, files, { uploadFile: handleUploadFile, onFileUploadStart: props.onFileUploadStart, onFileUploadStop: props.onFileUploadStop, diff --git a/app/editor/components/SuggestionsMenu.tsx b/app/editor/components/SuggestionsMenu.tsx index 407a75209..5e7adc660 100644 --- a/app/editor/components/SuggestionsMenu.tsx +++ b/app/editor/components/SuggestionsMenu.tsx @@ -341,7 +341,9 @@ function SuggestionsMenu(props: Props) { setInsertItem(item); }; - const handleFilesPicked = (event: React.ChangeEvent) => { + const handleFilesPicked = async ( + event: React.ChangeEvent + ) => { const { uploadFile, onFileUploadStart, onFileUploadStop } = props; const files = getEventFiles(event); const parent = findParentNode((node) => !!node)(view.state.selection); @@ -353,7 +355,7 @@ function SuggestionsMenu(props: Props) { } if (parent) { - insertFiles(view, event, parent.pos, files, { + await insertFiles(view, event, parent.pos, files, { uploadFile, onFileUploadStart, onFileUploadStop, diff --git a/app/scenes/Document/components/CommentForm.tsx b/app/scenes/Document/components/CommentForm.tsx index 6c4dadc67..89464655a 100644 --- a/app/scenes/Document/components/CommentForm.tsx +++ b/app/scenes/Document/components/CommentForm.tsx @@ -206,7 +206,8 @@ function CommentForm({ if (!files.length) { return; } - editorRef.current?.insertFiles(event, files); + + return editorRef.current?.insertFiles(event, files); }; const handleImageUpload = (event: React.MouseEvent) => { diff --git a/package.json b/package.json index e01cab4ff..bb491c4ca 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "pg": "^8.11.1", "pg-tsquery": "^8.4.1", "pluralize": "^8.0.0", + "png-chunks-extract": "^1.0.0", "polished": "^4.2.2", "prosemirror-codemark": "^0.4.2", "prosemirror-commands": "^1.5.2", @@ -279,6 +280,7 @@ "@types/nodemailer": "^6.4.14", "@types/passport-oauth2": "^1.4.15", "@types/pluralize": "^0.0.33", + "@types/png-chunks-extract": "^1.0.2", "@types/quoted-printable": "^1.0.2", "@types/randomstring": "^1.1.11", "@types/react": "^17.0.34", diff --git a/shared/editor/commands/insertFiles.ts b/shared/editor/commands/insertFiles.ts index 0af48d777..a03bbc6d3 100644 --- a/shared/editor/commands/insertFiles.ts +++ b/shared/editor/commands/insertFiles.ts @@ -29,7 +29,7 @@ export type Options = { }; }; -const insertFiles = function ( +const insertFiles = async function ( view: EditorView, event: | Event @@ -38,7 +38,7 @@ const insertFiles = function ( pos: number, files: File[], options: Options -): void { +) { const { dictionary, uploadFile, onFileUploadStart, onFileUploadStop } = options; @@ -54,14 +54,31 @@ const insertFiles = function ( // we'll use this to track of how many files have succeeded or failed let complete = 0; - const filesToUpload = files.map((file) => ({ - id: `upload-${uuidv4()}`, - isImage: - FileHelper.isImage(file) && !options.isAttachment && !!schema.nodes.image, - isVideo: - FileHelper.isVideo(file) && !options.isAttachment && !!schema.nodes.video, - file, - })); + const filesToUpload = await Promise.all( + files.map(async (file) => { + const isImage = + FileHelper.isImage(file) && + !options.isAttachment && + !!schema.nodes.image; + const isVideo = + FileHelper.isVideo(file) && + !options.isAttachment && + !!schema.nodes.video; + const getDimensions = isImage + ? FileHelper.getImageDimensions + : isVideo + ? FileHelper.getVideoDimensions + : undefined; + + return { + id: `upload-${uuidv4()}`, + dimensions: await getDimensions?.(file), + isImage, + isVideo, + file, + }; + }) + ); // the user might have dropped multiple files at once, we need to loop for (const upload of filesToUpload) { @@ -86,11 +103,12 @@ const insertFiles = function ( } if (upload.isImage) { const newImg = new Image(); - newImg.onload = () => { + newImg.onload = async () => { const result = findPlaceholder(view.state, upload.id); if (result === null) { return; } + if (view.isDestroyed) { return; } @@ -101,7 +119,11 @@ const insertFiles = function ( .replaceWith( from, to || from, - schema.nodes.image.create({ src, ...options.attrs }) + schema.nodes.image.create({ + src, + ...(upload.dimensions ?? {}), + ...options.attrs, + }) ) .setMeta(uploadPlaceholderPlugin, { remove: { id: upload.id } }) ); @@ -119,7 +141,6 @@ const insertFiles = function ( } const [from, to] = result; - const dimensions = await FileHelper.getVideoDimensions(upload.file); if (view.isDestroyed) { return; @@ -133,8 +154,7 @@ const insertFiles = function ( schema.nodes.video.create({ src, title: upload.file.name ?? dictionary.untitled, - width: dimensions.width, - height: dimensions.height, + ...upload.dimensions, ...options.attrs, }) ) diff --git a/shared/editor/lib/FileHelper.ts b/shared/editor/lib/FileHelper.ts index b7665295c..4ab69da3b 100644 --- a/shared/editor/lib/FileHelper.ts +++ b/shared/editor/lib/FileHelper.ts @@ -1,3 +1,5 @@ +import extract from "png-chunks-extract"; + export default class FileHelper { /** * Checks if a file is an image. @@ -31,6 +33,7 @@ export default class FileHelper { return new Promise((resolve, reject) => { const video = document.createElement("video"); video.preload = "metadata"; + video.crossOrigin = "anonymous"; video.onloadedmetadata = () => { window.URL.revokeObjectURL(video.src); resolve({ width: video.videoWidth, height: video.videoHeight }); @@ -39,4 +42,60 @@ export default class FileHelper { video.src = URL.createObjectURL(file); }); } + + /** + * Loads the dimensions of an image file – currently only PNGs are supported – but we mainly use + * this to get the "real" dimensions of a retina image. + * + * @param file The file to load the dimensions for + * @returns The dimensions of the image, if known. + */ + static async getImageDimensions( + file: File + ): Promise<{ width: number; height: number } | undefined> { + if (file.type !== "image/png") { + return; + } + + function parsePhys(view: DataView) { + return { + ppux: view.getUint32(0), + ppuy: view.getUint32(4), + unit: view.getUint8(4), + }; + } + + function parseIHDR(view: DataView) { + return { + width: view.getUint32(0), + height: view.getUint32(4), + }; + } + + try { + const buffer = await file.arrayBuffer(); + const chunks = extract(new Uint8Array(buffer)); + const pHYsChunk = chunks.find((chunk) => chunk.name === "pHYs"); + const iHDRChunk = chunks.find((chunk) => chunk.name === "IHDR"); + + if (!pHYsChunk || !iHDRChunk) { + return; + } + + const idhrData = parseIHDR(new DataView(iHDRChunk.data.buffer)); + const physData = parsePhys(new DataView(pHYsChunk.data.buffer)); + + if (physData.unit === 0 && physData.ppux === physData.ppuy) { + const pixelRatio = Math.round(physData.ppux / 2834.5); + return { + width: idhrData.width / pixelRatio, + height: idhrData.height / pixelRatio, + }; + } + } catch { + return undefined; + } + + return undefined; + } } diff --git a/shared/editor/lib/uploadPlaceholder.tsx b/shared/editor/lib/uploadPlaceholder.tsx index 5bfe3a72a..7732f7105 100644 --- a/shared/editor/lib/uploadPlaceholder.tsx +++ b/shared/editor/lib/uploadPlaceholder.tsx @@ -61,6 +61,8 @@ const uploadPlaceholder = new Plugin({ const img = document.createElement("img"); img.src = URL.createObjectURL(action.add.file); + img.width = action.add.dimensions?.width; + img.height = action.add.dimensions?.height; element.appendChild(img); @@ -77,6 +79,8 @@ const uploadPlaceholder = new Plugin({ video.src = URL.createObjectURL(action.add.file); video.autoplay = false; video.controls = false; + video.width = action.add.dimensions?.width; + video.height = action.add.dimensions?.height; element.appendChild(video); diff --git a/shared/editor/lib/uploadPlugin.ts b/shared/editor/lib/uploadPlugin.ts index e0fc9e44e..5235c04ef 100644 --- a/shared/editor/lib/uploadPlugin.ts +++ b/shared/editor/lib/uploadPlugin.ts @@ -47,7 +47,7 @@ const uploadPlugin = (options: Options) => } const pos = tr.selection.from; - insertFiles(view, event, pos, files, options); + void insertFiles(view, event, pos, files, options); return true; }, drop(view, event: DragEvent): boolean { @@ -71,7 +71,7 @@ const uploadPlugin = (options: Options) => }); if (result) { - insertFiles(view, event, result.pos, files, options); + void insertFiles(view, event, result.pos, files, options); return true; } diff --git a/shared/editor/nodes/SimpleImage.tsx b/shared/editor/nodes/SimpleImage.tsx index d5243eccb..d84fdf527 100644 --- a/shared/editor/nodes/SimpleImage.tsx +++ b/shared/editor/nodes/SimpleImage.tsx @@ -142,7 +142,7 @@ export default class SimpleImage extends Node { inputElement.accept = AttachmentValidation.imageContentTypes.join(", "); inputElement.onchange = (event) => { const files = getEventFiles(event); - insertFiles(view, event, state.selection.from, files, { + void insertFiles(view, event, state.selection.from, files, { uploadFile, onFileUploadStart, onFileUploadStop, diff --git a/yarn.lock b/yarn.lock index 5a5213a7e..4ee573f03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3266,6 +3266,11 @@ resolved "https://registry.yarnpkg.com/@types/pluralize/-/pluralize-0.0.33.tgz#8ad9018368c584d268667dd9acd5b3b806e8c82a" integrity sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg== +"@types/png-chunks-extract@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/png-chunks-extract/-/png-chunks-extract-1.0.2.tgz#31dd8d74d6ba879ace317c1e042dcdabc6300d6e" + integrity sha512-z6djfFIbrrddtunoMJBOPlyZrnmeuG1kkvHUNi2QfpOb+JMMLuLliHHTmMyRi7k7LiTAut0HbdGCF6ibDtQAHQ== + "@types/prismjs@*": version "1.26.0" resolved "https://registry.yarnpkg.com/@types/prismjs/-/prismjs-1.26.0.tgz#a1c3809b0ad61c62cac6d4e0c56d610c910b7654" @@ -5105,6 +5110,11 @@ cosmiconfig@8.3.6: parse-json "^5.2.0" path-type "^4.0.0" +crc-32@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-0.3.0.tgz#6a3d3687f5baec41f7e9b99fe1953a2e5d19775e" + integrity sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA== + create-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" @@ -10484,6 +10494,13 @@ pluralize@^8.0.0: resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== +png-chunks-extract@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz#fad4a905e66652197351c65e35b92c64311e472d" + integrity sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q== + dependencies: + crc-32 "^0.3.0" + polished@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/polished/-/polished-4.2.2.tgz#2529bb7c3198945373c52e34618c8fe7b1aa84d1"