Add a toolbar for images and iframes (#688)
* Add a toolbar for images and iframes * Insert embed code via modal
This commit is contained in:
parent
408027dd6a
commit
71880dfc98
|
@ -12,7 +12,7 @@ import StarterKit from '@tiptap/starter-kit'
|
||||||
import { Image } from '@tiptap/extension-image'
|
import { Image } from '@tiptap/extension-image'
|
||||||
import { Link } from '@tiptap/extension-link'
|
import { Link } from '@tiptap/extension-link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Linkify } from './linkify'
|
import { Linkify } from './linkify'
|
||||||
import { uploadImage } from 'web/lib/firebase/storage'
|
import { uploadImage } from 'web/lib/firebase/storage'
|
||||||
import { useMutation } from 'react-query'
|
import { useMutation } from 'react-query'
|
||||||
|
@ -20,6 +20,12 @@ import { exhibitExts } from 'common/util/parse'
|
||||||
import { FileUploadButton } from './file-upload-button'
|
import { FileUploadButton } from './file-upload-button'
|
||||||
import { linkClass } from './site-link'
|
import { linkClass } from './site-link'
|
||||||
import Iframe from 'common/util/tiptap-iframe'
|
import Iframe from 'common/util/tiptap-iframe'
|
||||||
|
import { CodeIcon, PhotographIcon } from '@heroicons/react/solid'
|
||||||
|
import { Modal } from './layout/modal'
|
||||||
|
import { Col } from './layout/col'
|
||||||
|
import { Button } from './button'
|
||||||
|
import { Row } from './layout/row'
|
||||||
|
import { Spacer } from './layout/spacer'
|
||||||
|
|
||||||
const proseClass = clsx(
|
const proseClass = clsx(
|
||||||
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
|
'prose prose-p:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed',
|
||||||
|
@ -36,7 +42,7 @@ export function useTextEditor(props: {
|
||||||
|
|
||||||
const editorClass = clsx(
|
const editorClass = clsx(
|
||||||
proseClass,
|
proseClass,
|
||||||
'box-content min-h-[6em] textarea textarea-bordered text-base'
|
'min-h-[6em] resize-none outline-none border-none pt-3 px-4 focus:ring-0'
|
||||||
)
|
)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
@ -78,8 +84,7 @@ export function useTextEditor(props: {
|
||||||
|
|
||||||
// If the pasted content is iframe code, directly inject it
|
// If the pasted content is iframe code, directly inject it
|
||||||
const text = event.clipboardData?.getData('text/plain').trim() ?? ''
|
const text = event.clipboardData?.getData('text/plain').trim() ?? ''
|
||||||
const isValidIframe = /^<iframe.*<\/iframe>$/.test(text)
|
if (isValidIframe(text)) {
|
||||||
if (isValidIframe) {
|
|
||||||
editor.chain().insertContent(text).run()
|
editor.chain().insertContent(text).run()
|
||||||
return true // Prevent the code from getting pasted as text
|
return true // Prevent the code from getting pasted as text
|
||||||
}
|
}
|
||||||
|
@ -96,16 +101,21 @@ export function useTextEditor(props: {
|
||||||
return { editor, upload }
|
return { editor, upload }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidIframe(text: string) {
|
||||||
|
return /^<iframe.*<\/iframe>$/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
export function TextEditor(props: {
|
export function TextEditor(props: {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
upload: ReturnType<typeof useUploadMutation>
|
upload: ReturnType<typeof useUploadMutation>
|
||||||
}) {
|
}) {
|
||||||
const { editor, upload } = props
|
const { editor, upload } = props
|
||||||
|
const [iframeOpen, setIframeOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* hide placeholder when focused */}
|
{/* hide placeholder when focused */}
|
||||||
<div className="w-full [&:focus-within_p.is-empty]:before:content-none">
|
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
|
||||||
{editor && (
|
{editor && (
|
||||||
<FloatingMenu
|
<FloatingMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
|
@ -121,7 +131,46 @@ export function TextEditor(props: {
|
||||||
images!
|
images!
|
||||||
</FloatingMenu>
|
</FloatingMenu>
|
||||||
)}
|
)}
|
||||||
<EditorContent editor={editor} />
|
<div className="overflow-hidden rounded-lg border border-gray-300 shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
{/* Spacer element to match the height of the toolbar */}
|
||||||
|
<div className="py-2" aria-hidden="true">
|
||||||
|
{/* Matches height of button in toolbar (1px border + 36px content height) */}
|
||||||
|
<div className="py-px">
|
||||||
|
<div className="h-9" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar, with buttons for image and embeds */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2">
|
||||||
|
<div className="flex items-center space-x-5">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FileUploadButton
|
||||||
|
onFiles={upload.mutate}
|
||||||
|
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
||||||
|
>
|
||||||
|
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Upload an image</span>
|
||||||
|
</FileUploadButton>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIframeOpen(true)}
|
||||||
|
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
||||||
|
>
|
||||||
|
<IframeModal
|
||||||
|
editor={editor}
|
||||||
|
open={iframeOpen}
|
||||||
|
setOpen={setIframeOpen}
|
||||||
|
/>
|
||||||
|
<CodeIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Embed an iframe</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{upload.isLoading && <span className="text-xs">Uploading image...</span>}
|
{upload.isLoading && <span className="text-xs">Uploading image...</span>}
|
||||||
{upload.isError && (
|
{upload.isError && (
|
||||||
|
@ -131,6 +180,65 @@ export function TextEditor(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IframeModal(props: {
|
||||||
|
editor: Editor | null
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { editor, open, setOpen } = props
|
||||||
|
const [embedCode, setEmbedCode] = useState('')
|
||||||
|
const valid = isValidIframe(embedCode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} setOpen={setOpen}>
|
||||||
|
<Col className="gap-2 rounded bg-white p-6">
|
||||||
|
<label
|
||||||
|
htmlFor="embed"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Embed a market, Youtube video, etc.
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="embed"
|
||||||
|
id="embed"
|
||||||
|
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder='e.g. <iframe src="..."></iframe>'
|
||||||
|
value={embedCode}
|
||||||
|
onChange={(e) => setEmbedCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview the embed if it's valid */}
|
||||||
|
{valid ? <RichContent content={embedCode} /> : <Spacer h={2} />}
|
||||||
|
|
||||||
|
<Row className="gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={!valid}
|
||||||
|
onClick={() => {
|
||||||
|
if (editor && valid) {
|
||||||
|
editor.chain().insertContent(embedCode).run()
|
||||||
|
setEmbedCode('')
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Embed
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="gray"
|
||||||
|
onClick={() => {
|
||||||
|
setEmbedCode('')
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const useUploadMutation = (editor: Editor | null) =>
|
const useUploadMutation = (editor: Editor | null) =>
|
||||||
useMutation(
|
useMutation(
|
||||||
(files: File[]) =>
|
(files: File[]) =>
|
||||||
|
@ -149,7 +257,7 @@ const useUploadMutation = (editor: Editor | null) =>
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function RichContent(props: { content: JSONContent }) {
|
function RichContent(props: { content: JSONContent | string }) {
|
||||||
const { content } = props
|
const { content } = props
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
editorProps: { attributes: { class: proseClass } },
|
editorProps: { attributes: { class: proseClass } },
|
||||||
|
|
Loading…
Reference in New Issue
Block a user