Skip to content

Commit

Permalink
fix: image placeholders in tiptap (#10703)
Browse files Browse the repository at this point in the history
Signed-off-by: Matt Krick <[email protected]>
  • Loading branch information
mattkrick authored Jan 17, 2025
1 parent b67d07c commit 5c3f5f9
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 59 deletions.
6 changes: 3 additions & 3 deletions packages/client/hooks/useTipTapReflectionEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {LinkMenuState} from '../components/promptResponse/TipTapLinkMenu'
import {isEqualWhenSerialized} from '../shared/isEqualWhenSerialized'
import {mentionConfig, serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions'
import ImageBlock from '../tiptap/extensions/imageBlock/ImageBlock'
import ImageUpload from '../tiptap/extensions/imageUpload/ImageUpload'
import {ImageUpload} from '../tiptap/extensions/imageUpload/ImageUpload'
import {SlashCommand} from '../tiptap/extensions/slashCommand/SlashCommand'
import {tiptapEmojiConfig} from '../utils/tiptapEmojiConfig'
import {tiptapMentionConfig} from '../utils/tiptapMentionConfig'
Expand All @@ -27,7 +27,7 @@ const isValid = <T>(obj: T | undefined | null | boolean): obj is T => {
const isCursorMakingNode = (editor: Editor) => {
const from = editor.state.selection.$from
const nodeType = from.node().type.name
const parentType = from.node(-1).type.name
const parentType = from.node(-1)?.type.name
/*
Support cases (nodeType/parentType):
- Headings (heading/doc)
Expand Down Expand Up @@ -69,7 +69,7 @@ export const useTipTapReflectionEditor = (
'To-do list': false
}),
Focus,
ImageUpload.configure(),
ImageUpload,
ImageBlock,
LoomExtension,
Placeholder.configure({
Expand Down
37 changes: 37 additions & 0 deletions packages/client/shared/tiptap/extensions/ImageUploadBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {Node} from '@tiptap/react'

declare module '@tiptap/core' {
interface Commands<ReturnType> {
imageUpload: {
setImageUpload: () => ReturnType
}
}
}

export const ImageUploadBase = Node.create({
name: 'imageUpload',

isolating: true,

defining: true,

group: 'block',

draggable: true,

selectable: true,

inline: false,

parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`
}
]
},

renderHTML() {
return ['div', {'data-type': this.name}]
}
})
4 changes: 2 additions & 2 deletions packages/client/shared/tiptap/serverTipTapExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {TaskList} from '@tiptap/extension-task-list'
import StarterKit from '@tiptap/starter-kit'
import {LoomExtension} from '../../components/promptResponse/loomExtension'
import ImageBlock from '../../tiptap/extensions/imageBlock/ImageBlock'
import ImageUpload from '../../tiptap/extensions/imageUpload/ImageUpload'
import {tiptapTagConfig} from '../../utils/tiptapTagConfig'
import {ImageUploadBase} from './extensions/ImageUploadBase'

export const mentionConfig: Partial<MentionOptions<any, MentionNodeAttrs>> = {
renderText({node}) {
Expand All @@ -23,7 +23,7 @@ export const serverTipTapExtensions = [
TaskItem.configure({
nested: true
}),
ImageUpload.configure(),
ImageUploadBase,
ImageBlock,
LoomExtension,
Mention.configure(mentionConfig),
Expand Down
6 changes: 5 additions & 1 deletion packages/client/styles/theme/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@
}
}
.node-imageUpload {
@apply rounded border-2 border-dotted border-black border-opacity-10 p-2;
@apply relative rounded;
transition: border 160ms cubic-bezier(0.45, 0.05, 0.55, 0.95);

&:hover {
Expand All @@ -227,6 +227,10 @@
&.has-focus {
@apply border-opacity-40;
}
&.ProseMirror-selectednode::after {
content: '';
@apply absolute inset-0 h-full w-full rounded bg-[#2383e247];
}
}
.node-imageBlock {
& img {
Expand Down
66 changes: 30 additions & 36 deletions packages/client/tiptap/extensions/imageUpload/ImageUpload.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,43 @@
import {Node, ReactNodeViewRenderer} from '@tiptap/react'
import {ReactNodeViewRenderer} from '@tiptap/react'
import {EventEmitter} from 'eventemitter3'
import {ImageUploadBase} from '../../../shared/tiptap/extensions/ImageUploadBase'
import {ImageUploadView} from './ImageUploadView'

declare module '@tiptap/core' {
interface Commands<ReturnType> {
imageUpload: {
setImageUpload: () => ReturnType
export const ImageUpload = ImageUploadBase.extend({
addStorage() {
return {
emitter: new EventEmitter()
}
}
}

export const ImageUpload = Node.create({
name: 'imageUpload',

isolating: true,

defining: true,

group: 'block',

draggable: true,

selectable: true,

inline: false,

parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`
}
]
},

renderHTML() {
return ['div', {'data-type': this.name}]
addKeyboardShortcuts(this) {
return {
Enter: ({editor}) => {
// the open state of the menu is kept in ImageUploadView
// and we can't communicate with that component via props or state
// so we attach an event emitter on the editor, since that's shared
if (editor.isActive('imageUpload')) {
this.storage.emitter.emit('enter')
return true
}
return false
}
}
},

addCommands() {
return {
setImageUpload:
() =>
({commands}) =>
commands.insertContent(`<div data-type="${this.name}"></div>`)
({commands, editor}) => {
const to = editor.state.selection.to
const size = editor.state.doc.content.size
if (size - to <= 1) {
// if we're at the end of the doc, add an extra paragraph to make it easier to click below
return commands.insertContent(`<div data-type="${this.name}"></div><p></p>`)
} else {
return commands.insertContent(`<div data-type="${this.name}"></div>`)
}
}
}
},

Expand All @@ -50,5 +46,3 @@ export const ImageUpload = Node.create({
return ReactNodeViewRenderer(ImageUploadView)
}
})

export default ImageUpload
97 changes: 81 additions & 16 deletions packages/client/tiptap/extensions/imageUpload/ImageUploadView.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,91 @@
import ImageIcon from '@mui/icons-material/Image'
import {NodeViewWrapper, type Editor} from '@tiptap/react'
import {useCallback} from 'react'
import * as Popover from '@radix-ui/react-popover'
import {NodeViewWrapper, type NodeViewProps} from '@tiptap/react'
import {useEffect, useRef, useState} from 'react'
import useEventCallback from '../../../hooks/useEventCallback'
import {ImageSelector} from './ImageSelector'

interface Props {
getPos(): number
editor: Editor
const useHideWhenTriggerHidden = (setOpen: (open: boolean) => void) => {
const triggerRef = useRef(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) {
setOpen(false)
}
},
{
root: null,
threshold: 0.9 // Ensure the div is completely hidden
}
)

if (triggerRef.current) {
observer.observe(triggerRef.current)
}

return () => {
if (triggerRef.current) {
observer.unobserve(triggerRef.current)
}
}
}, [])
return triggerRef
}

export const ImageUploadView = (props: Props) => {
const {getPos, editor} = props
const onClick = useCallback(() => {
editor.commands.setNodeSelection(getPos())
}, [getPos, editor.commands])
export const ImageUploadView = (props: NodeViewProps) => {
const {editor} = props
const [open, setOpen] = useState(false)
const onOpenChange = (willOpen: boolean) => {
const {isEditable} = editor
if (!willOpen) {
if (isEditable) {
editor.commands.focus()
}
setOpen(false)
} else if (isEditable) {
setOpen(true)
}
}
const openPopover = useEventCallback(() => {
setOpen(true)
})

useEffect(() => {
editor.storage.imageUpload.emitter.on('enter', openPopover)
return () => {
editor.storage.imageUpload.emitter.off('enter', openPopover)
}
}, [])
const triggerRef = useHideWhenTriggerHidden(setOpen)

return (
<NodeViewWrapper>
<div className='m-0 p-0' onClick={onClick} contentEditable={false}>
<div className='flex items-center rounded bg-slate-200 p-2'>
<ImageIcon className='size-6' />
<span className='text-sm'>Add an image</span>
</div>
</div>
<Popover.Root open={open} onOpenChange={onOpenChange}>
<Popover.Trigger asChild>
<div className='m-0 p-0' contentEditable={false} ref={triggerRef}>
<div className='flex cursor-pointer items-center rounded bg-slate-200 p-2 hover:bg-slate-300'>
<ImageIcon className='size-6' />
<span className='text-sm'>Add an image</span>
</div>
</div>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
asChild
align='start'
alignOffset={8}
onOpenAutoFocus={(e) => {
e.preventDefault()
}}
>
{/* z-30 is for expanded reflection stacks using Zindex.DIALOG */}
<div className='absolute left-0 top-0 z-30'>
<ImageSelector editor={editor} />
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</NodeViewWrapper>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export const slashCommands = [
{
title: 'Divider',
description: 'Insert horizontal rule divider',
searchTerms: ['horizontal rule', 'hr'],
searchTerms: ['horizontal rule', 'hr', 'divider', 'rule'],
icon: HorizontalRuleIcon,
action: (editor: Editor) => editor.chain().focus().setHorizontalRule().run()
}
Expand Down

0 comments on commit 5c3f5f9

Please sign in to comment.