Add floating menu (bold, italic, link) (#867)
* Add floating menu (bold, italic, link) * Sanitize and href-ify user input
This commit is contained in:
parent
2351403674
commit
22d2248951
|
@ -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) =>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
20
web/lib/icons/bold-icon.tsx
Normal file
20
web/lib/icons/bold-icon.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
21
web/lib/icons/italic-icon.tsx
Normal file
21
web/lib/icons/italic-icon.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
20
web/lib/icons/link-icon.tsx
Normal file
20
web/lib/icons/link-icon.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user