diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts index a27c71d826..4eee6a72fb 100644 --- a/src/lib/media/util.ts +++ b/src/lib/media/util.ts @@ -5,3 +5,69 @@ export function extractDataUriMime(uri: string): string { export function getDataUriSize(uri: string): number { return Math.round((uri.length * 3) / 4) // very rough estimate } + +// TODO: Can we consolidate this with an existing type? +interface ImageInfo { + uri: string + width: number + height: number +} + +/** + * Given a File instance (like one pulled from a paste event), return + * a data URI and the image dimensions for the pasted file. Returns + * width 0, height 0 for non-images + */ +export function getImageInfoFromFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onloadend = () => { + const maybeUri = reader.result + + // reader.result can be different data types depending on what + // readAs... method is called. We're using readAsDataUrl which + // will read as a string. If someone were to change that + // accidentally or otherwise, then reader.result might not be + // a string. In which case, we'll bail out and yell at the dev. + if (typeof maybeUri === 'string') { + const uri = maybeUri + + // Non-images proooobably shouldn't even be accepted in this function + // TODO: Maybe just reject for non-images? + if (!file.type.startsWith('image/')) { + return resolve({ + uri, + width: 0, + height: 0, + }) + } + + // Get the dimensions of said file + const img = new Image() + img.src = uri + img.onload = () => { + return resolve({ + uri, + width: img.width, + height: img.height, + }) + } + + return + } + + reject( + new Error( + 'File was not called with .readAsDataURL(...). ' + + 'This absolutely should not happen. Make sure ' + + 'the reader instance is calling .readAsDataURL() ' + + 'on the file', + ), + ) + } + + reader.onerror = reject + reader.onabort = () => reject(new Error('File read aborted')) + }) +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 6009debdd1..bf04643879 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -130,9 +130,17 @@ export const ComposePost = observer(function ComposePost({ if (selectedPhotos.length >= 4) { return } - onSelectPhotos([...selectedPhotos, uri]) + + setSelectedPhotos(sp => { + if (sp.length >= 4) { + return sp + } + + track('Composer:SelectedPhotos') + return [...sp, uri] + }) }, - [selectedPhotos, onSelectPhotos], + [selectedPhotos, setSelectedPhotos, track], ) const onPressPublish = React.useCallback(async () => { diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index ad891fa5bf..80ff5849fe 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -11,6 +11,15 @@ import {Text} from '@tiptap/extension-text' import isEqual from 'lodash.isequal' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {createSuggestion} from './web/Autocomplete' +import {Transaction} from '@tiptap/pm/state' +import {cropAndCompressFlow} from 'lib/media/picker' +import {useStores} from 'state/index' +import { + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, + POST_IMG_MAX_WIDTH, +} from 'lib/constants' +import {getImageInfoFromFile} from 'lib/media/util' export interface TextInputRef { focus: () => void @@ -36,12 +45,28 @@ export const TextInput = React.forwardRef( suggestedLinks, autocompleteView, setRichText, - // onPhotoPasted, TODO + onPhotoPasted, onSuggestedLinksChanged, }: // onError, TODO TextInputProps, ref, ) => { + const store = useStores() + const processClipboardItemAsPhoto = async (item: DataTransferItem) => { + const file = item.getAsFile() + + if (file && file.type && file.type.startsWith('image/')) { + const {uri, width, height} = await getImageInfoFromFile(file) + const croppedUri = await cropAndCompressFlow( + store, + uri, + {width, height}, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ) + onPhotoPasted(croppedUri) + } + } const editor = useEditor({ extensions: [ Document, @@ -65,6 +90,29 @@ export const TextInput = React.forwardRef( autofocus: true, editable: true, injectCSS: true, + editorProps: { + handlePaste(_, event) { + // It's possible to copy a single screenshot or multiple files + // In the case of a single screenshot (e.g. cmd+shift+4), this + // list will be length 1. In the case of selecting multiple + // files in a file explorer and copying/pasting, this will be + // those list of copied files + const items = event.clipboardData?.items + + // Let tiptap know to fallback to default paste behavior + if (!items || items.length === 0) { + return false + } + + // For any pasted images, bring them through the crop and compress flow + for (const item of Array.from(items)) { + processClipboardItemAsPhoto(item) + } + + // Let tiptap know that we handled the paste event + return true + }, + }, onUpdate({editor: editorProp}) { const json = editorProp.getJSON()