Skip to content

Commit 501fa6f

Browse files
committed
feat: embed and compress images
Signed-off-by: Matt Krick <matt.krick@gmail.com>
1 parent 5c9cf74 commit 501fa6f

File tree

11 files changed

+206
-13
lines changed

11 files changed

+206
-13
lines changed

packages/client/components/ReflectionGroup/ReflectionGroup.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,15 @@ const ReflectionGroup = (props: Props) => {
186186
}
187187

188188
const watchForClick = useEventCallback((e: MouseEvent) => {
189-
const isClickOnGroup = e.composedPath().find((el) => el === groupRef.current)
189+
const target = e.target as Node
190+
const isClickOnGroup = groupRef.current?.contains(target)
190191
if (!isClickOnGroup) {
191-
document.removeEventListener('click', watchForClick)
192-
setIsEditing(false)
192+
const isClickInRoot = document.getElementById('root')?.contains(target)
193+
// If the click is in a portal, ignore it, it could be link editing inside tiptap, etc.
194+
if (isClickInRoot) {
195+
document.removeEventListener('click', watchForClick)
196+
setIsEditing(false)
197+
}
193198
}
194199
})
195200
const onClick = () => {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import graphql from 'babel-plugin-relay/macro'
2+
import {useMutation, type UseMutationConfig} from 'react-relay'
3+
import {useEmbedUserAssetMutation as TuseEmbedUserAssetMutation} from '../__generated__/useEmbedUserAssetMutation.graphql'
4+
5+
const mutation = graphql`
6+
mutation useEmbedUserAssetMutation($url: String!) {
7+
embedUserAsset(url: $url) {
8+
... on ErrorPayload {
9+
error {
10+
message
11+
}
12+
}
13+
... on UploadUserAssetSuccess {
14+
url
15+
}
16+
}
17+
}
18+
`
19+
20+
export const useEmbedUserAsset = () => {
21+
const [commit, submitting] = useMutation<TuseEmbedUserAssetMutation>(mutation)
22+
23+
const execute = (config: UseMutationConfig<TuseEmbedUserAssetMutation>) => {
24+
return commit(config)
25+
}
26+
return [execute, submitting] as const
27+
}

packages/client/tiptap/extensions/imageUpload/ImageSelector.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {Editor} from '@tiptap/core'
22
import {useState} from 'react'
33
import Tab from '../../../components/Tab/Tab'
44
import Tabs from '../../../components/Tabs/Tabs'
5+
import {ImageSelectorEmbedTab} from './ImageSelectorEmbedTab'
56
import {ImageSelectorUploadTab} from './ImageSelectorUploadTab'
67

78
interface Props {
@@ -17,7 +18,7 @@ const tabs = [
1718
{
1819
id: 'embedLink',
1920
label: 'Embed link',
20-
Component: ImageSelectorUploadTab
21+
Component: ImageSelectorEmbedTab
2122
},
2223
{
2324
id: 'addGif',
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {useRef} from 'react'
2+
import useAtmosphere from '../../../hooks/useAtmosphere'
3+
import {useEmbedUserAsset} from '../../../mutations/useEmbedUserAsset'
4+
import {Button} from '../../../ui/Button/Button'
5+
6+
interface Props {
7+
setImageURL: (url: string) => void
8+
}
9+
10+
export const ImageSelectorEmbedTab = (props: Props) => {
11+
const {setImageURL} = props
12+
const ref = useRef<HTMLInputElement>(null)
13+
const atmosphere = useAtmosphere()
14+
const [commit] = useEmbedUserAsset()
15+
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
16+
e.preventDefault()
17+
const url = ref.current?.value
18+
if (!url) return
19+
commit({
20+
variables: {url},
21+
onCompleted: (res) => {
22+
const {embedUserAsset} = res
23+
const {url} = embedUserAsset!
24+
const message = embedUserAsset?.error?.message
25+
if (message) {
26+
atmosphere.eventEmitter.emit('addSnackbar', {
27+
key: 'errorEmbeddingAsset',
28+
message,
29+
autoDismiss: 5
30+
})
31+
return
32+
}
33+
setImageURL(url!)
34+
}
35+
})
36+
}
37+
return (
38+
<form
39+
className='flex w-full min-w-44 flex-col items-center justify-center space-y-3 rounded-md bg-slate-100 p-2'
40+
onSubmit={onSubmit}
41+
>
42+
<input
43+
autoComplete='off'
44+
autoFocus
45+
placeholder='Paste the image link…'
46+
type='url'
47+
className='w-full outline-none focus:ring-2'
48+
ref={ref}
49+
/>
50+
<Button variant='outline' shape='pill' className='w-full' type='submit'>
51+
Embed image
52+
</Button>
53+
</form>
54+
)
55+
}

packages/client/tiptap/extensions/imageUpload/ImageSelectorUploadTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export const ImageSelectorUploadTab = (props: Props) => {
5656
}
5757
return (
5858
<div className='flex min-w-44 items-center justify-center rounded-md bg-slate-100 p-2'>
59-
<Button variant='outline' shape='pill' className='w-full' onClick={onClick}>
59+
<Button variant='outline' shape='pill' className='w-full' onClick={onClick} autoFocus>
6060
Upload file
6161
</Button>
6262
<input

packages/client/tiptap/extensions/imageUpload/ImageUploadMenu.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,29 @@ export const ImageUploadMenu = ({editor}: Props) => {
1111
const ref = useRef<HTMLDivElement>(null)
1212
const [transform, setTransform] = useState('')
1313
const isActive = editor.isActive('imageUpload')
14+
useEffect(() => {
15+
setOpen(isActive)
16+
}, [isActive])
1417

18+
const [open, setOpen] = useState(isActive)
19+
const onOpenChange = (willOpen: boolean) => {
20+
if (!willOpen) {
21+
setOpen(false)
22+
return
23+
}
24+
}
1525
useEffect(() => {
1626
if (!ref.current) return
1727
if (!isActive) return
1828
const coords = editor.view.coordsAtPos(editor.state.selection.from)
1929
const {left, top, right} = coords
2030
const childWidth = ref.current?.getBoundingClientRect().width ?? 0
21-
const widthDiff = (childWidth - (right - left)) / 2
31+
const widthDiff = childWidth ? (childWidth - (right - left)) / 2 : 0
2232
setTransform(`translate(${left - widthDiff}px,${top + 40}px)`)
2333
}, [isActive, ref.current])
2434

2535
return (
26-
<Popover.Root open={isActive}>
36+
<Popover.Root open={open} onOpenChange={onOpenChange}>
2737
<Popover.Trigger asChild />
2838
<Popover.Portal>
2939
<Popover.Content

packages/client/tiptap/extensions/imageUpload/ImageUploadView.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import ImageIcon from '@mui/icons-material/Image'
2-
import {NodeViewWrapper} from '@tiptap/react'
2+
import {NodeViewWrapper, type Editor} from '@tiptap/react'
3+
import {useCallback} from 'react'
4+
5+
interface Props {
6+
getPos(): number
7+
editor: Editor
8+
}
9+
10+
export const ImageUploadView = (props: Props) => {
11+
const {getPos, editor} = props
12+
const onClick = useCallback(() => {
13+
editor.commands.setNodeSelection(getPos())
14+
}, [getPos, editor.commands])
315

4-
export const ImageUploadView = () => {
516
return (
617
<NodeViewWrapper>
7-
<div className='m-0 p-0' data-drag-handle>
18+
<div className='m-0 p-0' data-drag-handle onClick={onClick}>
819
<div className='flex items-center rounded bg-slate-200 p-2'>
920
<ImageIcon className='size-6' />
1021
<span className='text-sm'>Add an image</span>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import mime from 'mime-types'
2+
import getFileStoreManager from '../../../fileStorage/getFileStoreManager'
3+
import {getUserId} from '../../../utils/authorization'
4+
import {compressImage} from '../../../utils/compressImage'
5+
import {MutationResolvers} from '../resolverTypes'
6+
7+
const fetchImage = async (url: string) => {
8+
try {
9+
const res = await fetch(url)
10+
if (!res.ok) {
11+
console.error('Failed to fetch the resource:', res.statusText)
12+
return null
13+
}
14+
15+
const contentType = res.headers.get('Content-Type')
16+
if (!contentType) {
17+
console.error('Content-Type header is missing')
18+
return null
19+
}
20+
21+
return {contentType, buffer: Buffer.from(await res.arrayBuffer())}
22+
} catch (error) {
23+
console.error('Error fetching the resource:', error)
24+
return null
25+
}
26+
}
27+
28+
const embedUserAsset: MutationResolvers['embedUserAsset'] = async (_, {url}, {authToken}) => {
29+
// AUTH
30+
const userId = getUserId(authToken)
31+
32+
// VALIDATION
33+
const asset = await fetchImage(url)
34+
if (!asset) {
35+
return {error: {message: 'Unable to fetch asset'}}
36+
}
37+
const {contentType, buffer} = asset
38+
const ext = mime.extension(contentType)
39+
if (!ext) {
40+
return {error: {message: `Unable to determine extension for ${contentType}`}}
41+
}
42+
const {buffer: compressedBuffer, extension} = await compressImage(buffer, ext)
43+
if (compressedBuffer.byteLength > 2 ** 23) {
44+
return {error: {message: `Max asset size is ${2 ** 23} bytes`}}
45+
}
46+
// RESOLUTION
47+
const manager = getFileStoreManager()
48+
const hostedUrl = await manager.putUserAsset(compressedBuffer, userId, extension)
49+
return {url: hostedUrl}
50+
}
51+
52+
export default embedUserAsset

packages/server/graphql/public/mutations/uploadUserAsset.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import mime from 'mime-types'
22
import getFileStoreManager from '../../../fileStorage/getFileStoreManager'
33
import {getUserId} from '../../../utils/authorization'
4+
import {compressImage} from '../../../utils/compressImage'
45
import {MutationResolvers} from '../resolverTypes'
56

67
const uploadUserAsset: MutationResolvers['uploadUserAsset'] = async (_, {file}, {authToken}) => {
@@ -14,12 +15,13 @@ const uploadUserAsset: MutationResolvers['uploadUserAsset'] = async (_, {file},
1415
if (!ext) {
1516
return {error: {message: `Unable to determine extension for ${contentType}`}}
1617
}
17-
if (buffer.byteLength > 2 ** 23) {
18+
const {buffer: compressedBuffer, extension} = await compressImage(buffer, ext)
19+
if (compressedBuffer.byteLength > 2 ** 23) {
1820
return {error: {message: `Max asset size is ${2 ** 23} bytes`}}
1921
}
2022
// RESOLUTION
2123
const manager = getFileStoreManager()
22-
const url = await manager.putUserAsset(buffer, userId, ext)
24+
const url = await manager.putUserAsset(compressedBuffer, userId, extension)
2325
return {url}
2426
}
2527

packages/server/graphql/public/typeDefs/Mutation.graphql

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1977,12 +1977,22 @@ type Mutation {
19771977
orgId: ID!
19781978
): UpdateOrgPayload!
19791979

1980+
"""
1981+
Take any asset & host it in the file store
1982+
"""
1983+
embedUserAsset(
1984+
"""
1985+
the asset URL
1986+
"""
1987+
url: String!
1988+
): UploadUserAssetPayload
1989+
19801990
"""
19811991
Upload any asset owned by a user
19821992
"""
19831993
uploadUserAsset(
19841994
"""
1985-
the user avatar image file
1995+
the asset file
19861996
"""
19871997
file: File!
19881998
): UploadUserAssetPayload
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import sharp from 'sharp'
2+
3+
export const compressImage = async (buffer: Buffer, ext: string) => {
4+
const extension = ext.replace(/^\./, '')
5+
if (extension === 'avif') return {buffer, extension}
6+
const animationFormats = ['gif', 'webp']
7+
if (animationFormats.includes(extension)) {
8+
const image = sharp(buffer, {animated: true})
9+
const {pages} = await image.metadata()
10+
if (pages && pages > 1) {
11+
if (extension === 'webp') return {buffer, extension}
12+
// animated AVIF isn't supported by sharp yet, so fallback to using webp
13+
const webpBuffer = await image.webp({quality: 80}).toBuffer()
14+
return {buffer: webpBuffer, extension: 'webp'}
15+
}
16+
}
17+
// AVIF is the goal format,
18+
const avifBuffer = await sharp(buffer).avif({quality: 70}).toBuffer()
19+
return {buffer: avifBuffer, extension: 'avif'}
20+
}

0 commit comments

Comments
 (0)