Adds support for pasting images on the web (#313)
This commit is contained in:
committed by
Paul Frazee
parent
9652d994dd
commit
0d26763e11
@@ -5,3 +5,69 @@ export function extractDataUriMime(uri: string): string {
|
|||||||
export function getDataUriSize(uri: string): number {
|
export function getDataUriSize(uri: string): number {
|
||||||
return Math.round((uri.length * 3) / 4) // very rough estimate
|
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<ImageInfo> {
|
||||||
|
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'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,9 +130,17 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
if (selectedPhotos.length >= 4) {
|
if (selectedPhotos.length >= 4) {
|
||||||
return
|
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 () => {
|
const onPressPublish = React.useCallback(async () => {
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ import {Text} from '@tiptap/extension-text'
|
|||||||
import isEqual from 'lodash.isequal'
|
import isEqual from 'lodash.isequal'
|
||||||
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
|
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
|
||||||
import {createSuggestion} from './web/Autocomplete'
|
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 {
|
export interface TextInputRef {
|
||||||
focus: () => void
|
focus: () => void
|
||||||
@@ -36,12 +45,28 @@ export const TextInput = React.forwardRef(
|
|||||||
suggestedLinks,
|
suggestedLinks,
|
||||||
autocompleteView,
|
autocompleteView,
|
||||||
setRichText,
|
setRichText,
|
||||||
// onPhotoPasted, TODO
|
onPhotoPasted,
|
||||||
onSuggestedLinksChanged,
|
onSuggestedLinksChanged,
|
||||||
}: // onError, TODO
|
}: // onError, TODO
|
||||||
TextInputProps,
|
TextInputProps,
|
||||||
ref,
|
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({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
Document,
|
Document,
|
||||||
@@ -65,6 +90,29 @@ export const TextInput = React.forwardRef(
|
|||||||
autofocus: true,
|
autofocus: true,
|
||||||
editable: true,
|
editable: true,
|
||||||
injectCSS: 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}) {
|
onUpdate({editor: editorProp}) {
|
||||||
const json = editorProp.getJSON()
|
const json = editorProp.getJSON()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user