Add floating menu (bold, italic, link) (#867)

* Add floating menu (bold, italic, link)
* Sanitize and href-ify user input
This commit is contained in:
Sinclair Chen 2022-09-12 16:10:32 -07:00 committed by GitHub
parent 2351403674
commit 22d2248951
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 136 additions and 0 deletions

View File

@ -23,8 +23,15 @@ import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention' import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe' import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type' import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs'
import { uniq } from 'lodash' import { uniq } from 'lodash'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
export function getUrl(text: string) {
const results = find(text, 'url')
return results.length ? results[0].href : null
}
export function parseTags(text: string) { export function parseTags(text: string) {
const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi
const matches = (text.match(regex) || []).map((match) => const matches = (text.match(regex) || []).map((match) =>

View File

@ -2,6 +2,7 @@ import CharacterCount from '@tiptap/extension-character-count'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import { import {
useEditor, useEditor,
BubbleMenu,
EditorContent, EditorContent,
JSONContent, JSONContent,
Content, Content,
@ -24,13 +25,19 @@ import Iframe from 'common/util/tiptap-iframe'
import TiptapTweet from './editor/tiptap-tweet' import TiptapTweet from './editor/tiptap-tweet'
import { EmbedModal } from './editor/embed-modal' import { EmbedModal } from './editor/embed-modal'
import { import {
CheckIcon,
CodeIcon, CodeIcon,
PhotographIcon, PhotographIcon,
PresentationChartLineIcon, PresentationChartLineIcon,
TrashIcon,
} from '@heroicons/react/solid' } from '@heroicons/react/solid'
import { MarketModal } from './editor/market-modal' import { MarketModal } from './editor/market-modal'
import { insertContent } from './editor/utils' import { insertContent } from './editor/utils'
import { Tooltip } from './tooltip' import { Tooltip } from './tooltip'
import BoldIcon from 'web/lib/icons/bold-icon'
import ItalicIcon from 'web/lib/icons/italic-icon'
import LinkIcon from 'web/lib/icons/link-icon'
import { getUrl } from 'common/util/parse'
const DisplayImage = Image.configure({ const DisplayImage = Image.configure({
HTMLAttributes: { HTMLAttributes: {
@ -141,6 +148,66 @@ function isValidIframe(text: string) {
return /^<iframe.*<\/iframe>$/.test(text) return /^<iframe.*<\/iframe>$/.test(text)
} }
function FloatingMenu(props: { editor: Editor | null }) {
const { editor } = props
const [url, setUrl] = useState<string | null>(null)
if (!editor) return null
// current selection
const isBold = editor.isActive('bold')
const isItalic = editor.isActive('italic')
const isLink = editor.isActive('link')
const setLink = () => {
const href = url && getUrl(url)
if (href) {
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
}
}
const unsetLink = () => editor.chain().focus().unsetLink().run()
return (
<BubbleMenu
editor={editor}
className="flex gap-2 rounded-sm bg-slate-700 p-1 text-white"
>
{url === null ? (
<>
<button onClick={() => editor.chain().focus().toggleBold().run()}>
<BoldIcon className={clsx('h-5', isBold && 'text-indigo-200')} />
</button>
<button onClick={() => editor.chain().focus().toggleItalic().run()}>
<ItalicIcon
className={clsx('h-5', isItalic && 'text-indigo-200')}
/>
</button>
<button onClick={() => (isLink ? unsetLink() : setUrl(''))}>
<LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} />
</button>
</>
) : (
<>
<input
type="text"
className="h-5 border-0 bg-inherit text-sm !shadow-none !ring-0"
placeholder="Type or paste a link"
onChange={(e) => setUrl(e.target.value)}
/>
<button onClick={() => (setLink(), setUrl(null))}>
<CheckIcon className="h-5 w-5" />
</button>
<button onClick={() => (unsetLink(), setUrl(null))}>
<TrashIcon className="h-5 w-5" />
</button>
</>
)}
</BubbleMenu>
)
}
export function TextEditor(props: { export function TextEditor(props: {
editor: Editor | null editor: Editor | null
upload: ReturnType<typeof useUploadMutation> upload: ReturnType<typeof useUploadMutation>
@ -155,6 +222,7 @@ export function TextEditor(props: {
{/* hide placeholder when focused */} {/* hide placeholder when focused */}
<div className="relative w-full [&:focus-within_p.is-empty]:before:content-none"> <div className="relative w-full [&:focus-within_p.is-empty]:before:content-none">
<div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> <div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
<FloatingMenu editor={editor} />
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{/* Toolbar, with buttons for images and embeds */} {/* Toolbar, with buttons for images and embeds */}
<div className="flex h-9 items-center gap-5 pl-4 pr-1"> <div className="flex h-9 items-center gap-5 pl-4 pr-1">

View File

@ -0,0 +1,20 @@
// from Feather: https://feathericons.com/
export default function BoldIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
</svg>
)
}

View File

@ -0,0 +1,21 @@
// from Feather: https://feathericons.com/
export default function ItalicIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<line x1="19" y1="4" x2="10" y2="4"></line>
<line x1="14" y1="20" x2="5" y2="20"></line>
<line x1="15" y1="4" x2="9" y2="20"></line>
</svg>
)
}

View File

@ -0,0 +1,20 @@
// from Feather: https://feathericons.com/
export default function LinkIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
)
}