From 168ef0bd6a7ecbd435c0e0884330371a3c9d72da Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Mon, 18 Nov 2024 16:18:46 -0800 Subject: [PATCH] feat: Upgrade TipTap Extensions (#10455) Signed-off-by: Matt Krick --- .gitignore | 1 + packages/client/components/Avatar/Avatar.tsx | 2 +- .../EditorLinkChangerTipTap.tsx | 59 ------ .../EditorLinkViewerTipTap.tsx | 37 ---- packages/client/components/EmojiDropdown.tsx | 79 +++++++ .../client/components/MentionDropdown.tsx | 76 +++++++ .../client/components/TaskTagDropdown.tsx | 82 ++++++++ .../TeamPrompt/TeamPromptDiscussionDrawer.tsx | 2 +- packages/client/components/TypeAheadLabel.tsx | 19 +- .../promptResponse/EmojiMenuTipTap.tsx | 78 ------- .../promptResponse/MentionsTipTap.tsx | 158 -------------- .../promptResponse/PromptResponseEditor.tsx | 194 +++++------------- .../promptResponse/TipTapLinkEditor.tsx | 69 +++++++ .../promptResponse/TipTapLinkMenu.tsx | 140 +++++++++++++ .../promptResponse/TipTapLinkPreview.tsx | 61 ++++++ .../promptResponse/TiptapLinkExtension.ts | 52 +++++ .../promptResponse/isTextSelected.ts | 20 ++ .../components/promptResponse/tiptapConfig.ts | 117 ----------- .../TeamPromptResponseSummaryCard.tsx | 4 +- packages/client/package.json | 1 + packages/client/styles/theme/global.css | 82 ++++++-- packages/client/ui/Button/Button.tsx | 17 +- packages/client/ui/Tooltip/TooltipContent.tsx | 4 +- packages/client/ui/cn.ts | 4 + packages/client/utils/tiptapEmojiConfig.ts | 74 +++++++ packages/client/utils/tiptapMentionConfig.ts | 109 ++++++++++ packages/client/utils/tiptapTagConfig.ts | 81 ++++++++ .../server/graphql/mutations/createTask.ts | 13 +- .../mutations/upsertTeamPromptResponse.ts | 5 +- packages/server/utils/convertToTipTap.ts | 34 +++ .../server/utils/serverTipTapExtensions.ts | 24 +++ yarn.lock | 7 + 32 files changed, 1061 insertions(+), 644 deletions(-) delete mode 100644 packages/client/components/EditorLinkChanger/EditorLinkChangerTipTap.tsx delete mode 100644 packages/client/components/EditorLinkViewer/EditorLinkViewerTipTap.tsx create mode 100644 packages/client/components/EmojiDropdown.tsx create mode 100644 packages/client/components/MentionDropdown.tsx create mode 100644 packages/client/components/TaskTagDropdown.tsx delete mode 100644 packages/client/components/promptResponse/EmojiMenuTipTap.tsx delete mode 100644 packages/client/components/promptResponse/MentionsTipTap.tsx create mode 100644 packages/client/components/promptResponse/TipTapLinkEditor.tsx create mode 100644 packages/client/components/promptResponse/TipTapLinkMenu.tsx create mode 100644 packages/client/components/promptResponse/TipTapLinkPreview.tsx create mode 100644 packages/client/components/promptResponse/TiptapLinkExtension.ts create mode 100644 packages/client/components/promptResponse/isTextSelected.ts delete mode 100644 packages/client/components/promptResponse/tiptapConfig.ts create mode 100644 packages/client/ui/cn.ts create mode 100644 packages/client/utils/tiptapEmojiConfig.ts create mode 100644 packages/client/utils/tiptapMentionConfig.ts create mode 100644 packages/client/utils/tiptapTagConfig.ts create mode 100644 packages/server/utils/convertToTipTap.ts create mode 100644 packages/server/utils/serverTipTapExtensions.ts diff --git a/.gitignore b/.gitignore index 17e64e722f4..ab6206019e8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.log *.heapprofile *.heapsnapshot +.npmrc .DS_Store .awcache/ .history/ diff --git a/packages/client/components/Avatar/Avatar.tsx b/packages/client/components/Avatar/Avatar.tsx index 3a0f9b2bac5..5130f1a844f 100644 --- a/packages/client/components/Avatar/Avatar.tsx +++ b/packages/client/components/Avatar/Avatar.tsx @@ -26,7 +26,7 @@ const Avatar = forwardRef((props, ref) => { className={clsx(`${onClick && 'cursor-pointer'}`, className)} > - + {alt diff --git a/packages/client/components/EditorLinkChanger/EditorLinkChangerTipTap.tsx b/packages/client/components/EditorLinkChanger/EditorLinkChangerTipTap.tsx deleted file mode 100644 index 0a3f8f1d2c5..00000000000 --- a/packages/client/components/EditorLinkChanger/EditorLinkChangerTipTap.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import {Editor} from '@tiptap/react' -import {useState} from 'react' -import {BBox} from '~/types/animations' -import EditorLinkChangerModal from './EditorLinkChangerModal' - -interface Props { - link: string | null - - originCoords: BBox - removeModal(allowFocus: boolean): void - - tiptapEditor: Editor - - text: string | null -} - -const EditorLinkChangerTipTap = (props: Props) => { - const {text, link, removeModal, originCoords, tiptapEditor} = props - const [selection] = useState(tiptapEditor.view.state.selection) - const handleSubmit = ({text: newText, href}: {text: string; href: string}) => { - tiptapEditor - .chain() - .focus() - .setTextSelection(selection) - .command(({tr}) => { - if (text !== newText) { - // Replace the existing text iff it was changed. - tr.insertText(newText) - } - - return true - }) - // Something weird happens with the selection when the Link extension's 'inclusive' attribute is 'false' and we - // change the text, so manually update the selection with what it should be based on the change in text length. - .setTextSelection({ - from: selection.from, - to: selection.to + newText.length - (text?.length ?? 0) - }) - .setLink({href}) - .run() - } - - const handleEscape = () => { - setTimeout(() => tiptapEditor.commands.focus(), 0) - } - - return ( - - ) -} - -export default EditorLinkChangerTipTap diff --git a/packages/client/components/EditorLinkViewer/EditorLinkViewerTipTap.tsx b/packages/client/components/EditorLinkViewer/EditorLinkViewerTipTap.tsx deleted file mode 100644 index e87a693876b..00000000000 --- a/packages/client/components/EditorLinkViewer/EditorLinkViewerTipTap.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import {Editor} from '@tiptap/react' -import {BBox} from '../../types/animations' -import EditorLinkViewer from './EditorLinkViewer' - -interface Props { - href: string - addHyperlink: () => void - tiptapEditor: Editor - originCoords: BBox - removeModal: () => void -} - -const EditorLinkViewerDraft = (props: Props) => { - const {href, addHyperlink, removeModal, tiptapEditor, originCoords} = props - - const handleRemove = () => { - const {from, to} = tiptapEditor.view.state.selection - if (to === from) { - // TipTap won't correctly extend to the full mark if the cursor is just to the right of the link. - tiptapEditor.commands.setTextSelection({to: to - 1, from: from - 1}) - } - - tiptapEditor.chain().extendMarkRange('link').unsetLink().run() - } - - return ( - - ) -} - -export default EditorLinkViewerDraft diff --git a/packages/client/components/EmojiDropdown.tsx b/packages/client/components/EmojiDropdown.tsx new file mode 100644 index 00000000000..467a80216d1 --- /dev/null +++ b/packages/client/components/EmojiDropdown.tsx @@ -0,0 +1,79 @@ +import {MentionNodeAttrs} from '@tiptap/extension-mention' +import {SuggestionProps} from '@tiptap/suggestion' +import React, {forwardRef, useEffect, useImperativeHandle, useRef, useState} from 'react' +import TypeAheadLabel from './TypeAheadLabel' + +export default forwardRef( + (props: SuggestionProps<{id: string; native: string}, MentionNodeAttrs>, ref) => { + const {items, query, editor, range} = props + const [selectedIndex, setSelectedIndex] = useState(0) + const activeRef = useRef(null) + const selectItem = (idx: number) => { + const item = items[idx] + if (!item) return + editor.commands.insertContentAt(range, item.native + ' ') + } + + const upHandler = () => { + setSelectedIndex((selectedIndex + items.length - 1) % items.length) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [items]) + useEffect(() => { + activeRef.current?.scrollIntoView({block: 'nearest'}) + }, [activeRef.current]) + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}: {event: React.KeyboardEvent}) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter' || event.key === 'Tab') { + enterHandler() + return true + } + return false + } + })) + + return ( +
+ {items.length ? ( + items.map((item, idx) => { + const isActive = idx === selectedIndex + return ( +
selectItem(idx)} + > + {item.native} + +
+ ) + }) + ) : ( +
+ )} +
+ ) + } +) diff --git a/packages/client/components/MentionDropdown.tsx b/packages/client/components/MentionDropdown.tsx new file mode 100644 index 00000000000..5c3c5d3732a --- /dev/null +++ b/packages/client/components/MentionDropdown.tsx @@ -0,0 +1,76 @@ +import {MentionNodeAttrs} from '@tiptap/extension-mention' +import {SuggestionProps} from '@tiptap/suggestion' +import React, {forwardRef, useEffect, useImperativeHandle, useState} from 'react' +import Avatar from './Avatar/Avatar' +import TypeAheadLabel from './TypeAheadLabel' + +export default forwardRef( + ( + props: SuggestionProps<{id: string; preferredName: string; picture: string}, MentionNodeAttrs>, + ref + ) => { + const {command, items, query} = props + const [selectedIndex, setSelectedIndex] = useState(0) + const selectItem = (idx: number) => { + const item = items[idx] + if (!item) return + command({id: item.id, label: item.preferredName}) + } + + const upHandler = () => { + setSelectedIndex((selectedIndex + items.length - 1) % items.length) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [items]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}: {event: React.KeyboardEvent}) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter' || event.key === 'Tab') { + enterHandler() + return true + } + return false + } + })) + + return ( +
+ {items.length ? ( + items.map((item, idx) => ( +
selectItem(idx)} + > + + +
+ )) + ) : ( +
+ )} +
+ ) + } +) diff --git a/packages/client/components/TaskTagDropdown.tsx b/packages/client/components/TaskTagDropdown.tsx new file mode 100644 index 00000000000..d55a87d4fbf --- /dev/null +++ b/packages/client/components/TaskTagDropdown.tsx @@ -0,0 +1,82 @@ +import {MentionNodeAttrs} from '@tiptap/extension-mention' +import {SuggestionProps} from '@tiptap/suggestion' +import React, {forwardRef, useEffect, useImperativeHandle, useRef, useState} from 'react' +import TypeAheadLabel from './TypeAheadLabel' + +export const TaskTagDropdown = forwardRef( + (props: SuggestionProps<{id: string; label: string}, MentionNodeAttrs>, ref) => { + const {command, items, query} = props + const [selectedIndex, setSelectedIndex] = useState(0) + const activeRef = useRef(null) + const selectItem = (idx: number) => { + const item = items[idx] + if (!item) return + command(item) + } + + const upHandler = () => { + setSelectedIndex((selectedIndex + items.length - 1) % items.length) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [items]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}: {event: React.KeyboardEvent}) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter' || event.key === 'Tab') { + enterHandler() + return true + } + return false + } + })) + + return ( +
+ {items.length ? ( + items.map((item, idx) => { + const isActive = idx === selectedIndex + return ( +
selectItem(idx)} + > + + {item.label} +
+ ) + }) + ) : ( +
+ )} +
+ ) + } +) diff --git a/packages/client/components/TeamPrompt/TeamPromptDiscussionDrawer.tsx b/packages/client/components/TeamPrompt/TeamPromptDiscussionDrawer.tsx index 22a82e13173..18f65270e4e 100644 --- a/packages/client/components/TeamPrompt/TeamPromptDiscussionDrawer.tsx +++ b/packages/client/components/TeamPrompt/TeamPromptDiscussionDrawer.tsx @@ -179,7 +179,7 @@ const TeamPromptDiscussionDrawer = ({meetingRef, onToggleDrawer}: Props) => { width={'100%'} header={ - + } diff --git a/packages/client/components/TypeAheadLabel.tsx b/packages/client/components/TypeAheadLabel.tsx index 3f98dd91162..e6baff21726 100644 --- a/packages/client/components/TypeAheadLabel.tsx +++ b/packages/client/components/TypeAheadLabel.tsx @@ -1,30 +1,23 @@ -import styled from '@emotion/styled' import * as DOMPurify from 'dompurify' -import {PALETTE} from '~/styles/paletteV3' +import {twMerge} from 'tailwind-merge' import getSafeRegex from '~/utils/getSafeRegex' interface Props { query: string label: string highlight?: boolean + className?: string } -const Span = styled('span')({ - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap' -}) - const TypeAheadLabel = (props: Props) => { - const {query, label, highlight} = props - const queryHtml = highlight - ? `$&` - : `$&` + const {query, label, highlight, className} = props + const queryHtml = highlight ? `$&` : `$&` const cleanInnerHtml = DOMPurify.sanitize( query ? label.replace(getSafeRegex(query, 'gi'), queryHtml) : label ) return ( - { - const {tiptapEditor} = props - const [openEmojiMenu, setOpenEmojiMenu] = useState(false) - const [emojiQuery, setEmojiQuery] = useState('') - const [range, setRange] = useState(null) - - useEffect(() => { - if (tiptapEditor.isDestroyed) { - return - } - - const plugin = Suggestion({ - // The 'pluginKey' type definition seems to be a mismatch between prosemirror and this - // extension, so cast to 'any' to get around it. - // :TODO: (jmtaber129): get these type definitions to play nice. - pluginKey: pluginKey as any, - editor: tiptapEditor, - char: ':', - render: () => ({ - onStart: ({range}) => { - setRange(range) - setOpenEmojiMenu(true) - }, - onExit: () => { - setRange(null) - setOpenEmojiMenu(false) - }, - onUpdate: ({query, range}) => { - setOpenEmojiMenu(true) - setRange(range) - setEmojiQuery(query) - } - }) - }) as any - - tiptapEditor.registerPlugin(plugin) - return () => { - tiptapEditor.unregisterPlugin(pluginKey) - } - }, [tiptapEditor, setOpenEmojiMenu, setEmojiQuery]) - - const onSelectEmoji = (emoji: string) => { - if (!range) return - tiptapEditor - .chain() - .focus() - .setTextSelection(range) - .command(({tr}) => { - tr.insertText(emoji) - - return true - }) - .run() - } - - return openEmojiMenu && tiptapEditor.isFocused ? ( - - ) : null -} - -export default EmojiMenuTipTap diff --git a/packages/client/components/promptResponse/MentionsTipTap.tsx b/packages/client/components/promptResponse/MentionsTipTap.tsx deleted file mode 100644 index 6ff69235a0d..00000000000 --- a/packages/client/components/promptResponse/MentionsTipTap.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import {Editor, Range} from '@tiptap/core' -import Suggestion from '@tiptap/suggestion' -import {PluginKey} from 'prosemirror-state' -import {Suspense, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react' -import TeamMemberId from '../../shared/gqlIds/TeamMemberId' -import SuggestMentionableUsersRoot from '../SuggestMentionableUsersRoot' -import {MentionSuggestion} from '../TaskEditor/useSuggestions' -import {getSelectionBoundingBox} from './tiptapConfig' - -interface Props { - tiptapEditor: Editor - teamId: string -} - -const pluginKey = new PluginKey('mentionMenu') - -const MentionsTipTap = (props: Props) => { - const {tiptapEditor, teamId} = props - const [openMentions, setOpenMentions] = useState(false) - const [mentionQuery, setMentionQuery] = useState('') - const [range, setRange] = useState(null) - const [active, setActive] = useState(0) - - const [suggestions, setSuggestions] = useState([]) - - useEffect(() => setActive(0), [suggestions]) - - const onSelectMention = useCallback( - (item: MentionSuggestion) => { - if (!range) return - const nodeAfter = tiptapEditor.view.state.selection.$to.nodeAfter - const overrideSpace = nodeAfter?.text?.startsWith(' ') - - if (overrideSpace) { - range.to += 1 - } - - const {userId} = TeamMemberId.split(item.id) - - tiptapEditor - .chain() - .focus() - .insertContentAt(range, [ - { - type: 'mention', - attrs: { - id: userId, - label: item.preferredName - } - }, - { - type: 'text', - text: ' ' - } - ]) - .run() - }, - [tiptapEditor, range] - ) - - const keyHandlerRef = useRef(null) - - useImperativeHandle( - keyHandlerRef, - () => ({ - upHandler: () => { - setActive((active + suggestions.length - 1) % suggestions.length) - }, - downHandler: () => { - setActive((active + 1) % suggestions.length) - }, - enterHandler: () => { - onSelectMention(suggestions[active]!) - } - }), - [setActive, active, onSelectMention, suggestions] - ) - - useEffect(() => { - if (tiptapEditor.isDestroyed) { - return - } - - const plugin = Suggestion({ - // The 'pluginKey' type definition seems to be a mismatch between prosemirror and this - // extension, so cast to 'any' to get around it. - // :TODO: (jmtaber129): get these type definitions to play nice. - pluginKey: pluginKey as any, - editor: tiptapEditor, - char: '@', - render: () => ({ - onStart: ({range}) => { - setRange(range) - setOpenMentions(true) - }, - onExit: () => { - setRange(null) - setOpenMentions(false) - }, - onUpdate: ({query, range}) => { - setOpenMentions(true) - setRange(range) - setMentionQuery(query) - }, - onKeyDown: ({event}) => { - if (event.key === 'Escape') { - setOpenMentions(false) - return true - } - - if (!keyHandlerRef.current) { - return false - } - - if (event.key === 'ArrowUp') { - keyHandlerRef.current.upHandler() - return true - } - - if (event.key === 'ArrowDown') { - keyHandlerRef.current.downHandler() - return true - } - - if (event.key === 'Enter' || event.key === 'Tab') { - keyHandlerRef.current.enterHandler() - return true - } - - return false - } - }) - }) as any - - // Other plugins that tiptap adds will try to handle the certain keydown events without giving - // us a chance to handle them here, so bump up the priority for us. - tiptapEditor.registerPlugin(plugin, (newPlugin, plugins) => [newPlugin, ...plugins]) - return () => { - tiptapEditor.unregisterPlugin(pluginKey) - } - }, [tiptapEditor, setOpenMentions, setMentionQuery]) - - return openMentions && tiptapEditor.isFocused ? ( - - - - ) : null -} - -export default MentionsTipTap diff --git a/packages/client/components/promptResponse/PromptResponseEditor.tsx b/packages/client/components/promptResponse/PromptResponseEditor.tsx index 323713cc9f6..04a8c4a20e2 100644 --- a/packages/client/components/promptResponse/PromptResponseEditor.tsx +++ b/packages/client/components/promptResponse/PromptResponseEditor.tsx @@ -1,18 +1,22 @@ import styled from '@emotion/styled' import {Link} from '@mui/icons-material' import {Editor as EditorState} from '@tiptap/core' +import Mention from '@tiptap/extension-mention' +import Placeholder from '@tiptap/extension-placeholder' import {BubbleMenu, EditorContent, JSONContent, useEditor} from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' import areEqual from 'fbjs/lib/areEqual' import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {PALETTE} from '~/styles/paletteV3' import {Radius} from '~/types/constEnums' +import useAtmosphere from '../../hooks/useAtmosphere' +import {tiptapEmojiConfig} from '../../utils/tiptapEmojiConfig' +import {tiptapMentionConfig} from '../../utils/tiptapMentionConfig' import BaseButton from '../BaseButton' -import EditorLinkChangerTipTap from '../EditorLinkChanger/EditorLinkChangerTipTap' -import EditorLinkViewerTipTap from '../EditorLinkViewer/EditorLinkViewerTipTap' -import EmojiMenuTipTap from './EmojiMenuTipTap' -import MentionsTipTap from './MentionsTipTap' -import {unfurlLoomLinks} from './loomExtension' -import {LinkMenuProps, LinkPreviewProps, createEditorExtensions, getLinkProps} from './tiptapConfig' +import isTextSelected from './isTextSelected' +import {LoomExtension, unfurlLoomLinks} from './loomExtension' +import {TiptapLinkExtension} from './TiptapLinkExtension' +import TipTapLinkMenu, {LinkMenuState} from './TipTapLinkMenu' const LinkIcon = styled(Link)({ height: 18, @@ -29,7 +33,7 @@ const BubbleMenuWrapper = styled('div')({ padding: '4px' }) -const BubbleMenuButton = styled(BaseButton)<{isActive: boolean}>(({isActive}) => ({ +const BubbleMenuButton = styled(BaseButton)<{isActive?: boolean}>(({isActive}) => ({ height: '20px', width: '22px', padding: '4px 0px 4px 0px', @@ -65,66 +69,9 @@ const CancelButton = styled(SubmitButton)({ color: PALETTE.SLATE_700 }) -const StyledEditor = styled('div')` - .ProseMirror { - min-height: 40px; - line-height: 1.25; - } - - .ProseMirror :is(ul, ol) { - list-style-position: outside; - padding-inline-start: 16px; - margin-block-start: 4px; - margin-block-end: 4px; - } - - .ProseMirror :is(ol) { - margin-inline-start: 2px; - } - - .ProseMirror p.is-editor-empty:first-child::before { - color: #adb5bd; - content: attr(data-placeholder); - float: left; - height: 0; - pointer-events: none; - } - - .ProseMirror [data-type='mention'] { - background-color: ${PALETTE.GOLD_100}; - border-radius: 2; - font-weight: 600; - } - - .ProseMirror-focused:focus { - outline: none; - } - - a { - text-decoration: underline; - color: ${PALETTE.SLATE_700}; - :hover { - cursor: pointer; - } - } - - .ProseMirror p { - margin-block-start: 4px; - margin-block-end: 4px; - } - - hr.ProseMirror-selectednode { - border-top: 1px solid #68cef8; - } - - hr { - border-top: 1px solid ${PALETTE.SLATE_400}; - } -` - interface Props { autoFocus?: boolean - teamId?: string + teamId: string content: JSONContent | null handleSubmit?: (editor: EditorState) => void readOnly: boolean @@ -142,6 +89,7 @@ const PromptResponseEditor = (props: Props) => { teamId, draftStorageKey } = props + const atmosphere = useAtmosphere() const [isEditing, setIsEditing] = useState(false) const [autoFocus, setAutoFocus] = useState(autoFocusProp) @@ -150,31 +98,6 @@ const PromptResponseEditor = (props: Props) => { [rawContent, readOnly] ) - const [linkOverlayProps, setLinkOverlayProps] = useState< - | { - linkMenuProps: LinkMenuProps - linkPreviewProps: undefined - } - | { - linkMenuProps: undefined - linkPreviewProps: LinkPreviewProps - } - | undefined - >() - - const setLinkMenuProps = useCallback( - (props: LinkMenuProps) => { - setLinkOverlayProps({linkMenuProps: props, linkPreviewProps: undefined}) - }, - [setLinkOverlayProps] - ) - const setLinkPreviewProps = useCallback( - (props: LinkPreviewProps) => { - setLinkOverlayProps({linkPreviewProps: props, linkMenuProps: undefined}) - }, - [setLinkOverlayProps] - ) - const editorRef = useRef(null) const setEditing = useCallback( @@ -218,28 +141,36 @@ const PromptResponseEditor = (props: Props) => { } } + const [linkState, setLinkState] = useState(null) + + const openLinkEditor = () => { + setLinkState('edit') + } + const editor = useEditor( { content, - extensions: createEditorExtensions( - setLinkMenuProps, - setLinkPreviewProps, - setLinkOverlayProps, - placeholder - ), + extensions: [ + StarterKit, + LoomExtension, + Placeholder.configure({ + showOnlyWhenEditable: false, + placeholder + }), + Mention.configure(tiptapMentionConfig(atmosphere, teamId)), + Mention.extend({name: 'emojiMention'}).configure(tiptapEmojiConfig), + TiptapLinkExtension.configure({ + openOnClick: false, + popover: { + setLinkState + } + }) + ], autofocus: autoFocus, onUpdate, editable: !readOnly }, - [ - content, - readOnly, - setLinkMenuProps, - setLinkPreviewProps, - setLinkOverlayProps, - onSubmit, - onUpdate - ] + [content, readOnly, onSubmit, onUpdate] ) useEffect(() => { @@ -260,21 +191,22 @@ const PromptResponseEditor = (props: Props) => { editor?.commands.setContent(draftContent) }, [editor]) - const onAddHyperlink = () => { - if (!editor) { - return - } - - setLinkMenuProps(getLinkProps(editor)) + const shouldShowBubbleMenu = () => { + if (!editor || editor.isActive('link')) return false + return isTextSelected(editor) } return ( <> - +
{editor && !readOnly && ( <>
- + editor.chain().focus().toggleBold().run()} @@ -294,40 +226,24 @@ const PromptResponseEditor = (props: Props) => { > S - +
- - {teamId && } - {linkOverlayProps?.linkMenuProps && ( - { - setLinkOverlayProps(undefined) - }} - /> - )} - {linkOverlayProps?.linkPreviewProps && ( - { - setLinkOverlayProps(undefined) - }} - /> - )} + { + editor.commands.focus() + setLinkState(linkState) + }} + linkState={linkState} + /> )} - +
{!readOnly && ( // The render conditions for these buttons *should* only be true when 'readOnly' is false, but let's be explicit // about it. diff --git a/packages/client/components/promptResponse/TipTapLinkEditor.tsx b/packages/client/components/promptResponse/TipTapLinkEditor.tsx new file mode 100644 index 00000000000..8fdfa7c4e68 --- /dev/null +++ b/packages/client/components/promptResponse/TipTapLinkEditor.tsx @@ -0,0 +1,69 @@ +import {Link, Title} from '@mui/icons-material' +import {useCallback, useMemo, useState} from 'react' +import {Button} from '../../ui/Button/Button' +import linkify from '../../utils/linkify' + +export type props = { + initialUrl: string + initialText: string + onSetLink: (link: {text: string; url: string}) => void +} + +export const TipTapLinkEditor = (props: props) => { + const {onSetLink, initialUrl, initialText} = props + + const [url, setUrl] = useState(initialUrl) + const [text, setText] = useState(initialText) + const onChangeURL = useCallback((event: React.ChangeEvent) => { + setUrl(event.target.value) + }, []) + const onChangeText = useCallback((event: React.ChangeEvent) => { + setText(event.target.value) + }, []) + const isValidUrl = useMemo(() => (!url ? false : linkify.match(url)), [url]) + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + if (!isValidUrl) return + onSetLink({text, url}) + }, + [url, text, isValidUrl, onSetLink] + ) + return ( +
+
+ +