From 7678afbc0b64067e8aa6039390ce1cacdd76ec0b Mon Sep 17 00:00:00 2001 From: Matt Krick Date: Wed, 18 Dec 2024 17:27:36 -0800 Subject: [PATCH] feat: Reflections to TipTap (#10616) Signed-off-by: Matt Krick --- packages/client/README.md | 30 +- .../components/AndroidEditorFallback.tsx | 88 ----- .../client/components/EditorInputWrapper.tsx | 204 ----------- .../EditorLinkChangerDraftjs.tsx | 117 ------ .../EditorLinkChangerModal.tsx | 146 -------- .../EditorLinkViewer/EditorLinkViewer.tsx | 78 ---- .../EditorLinkViewerDraft.tsx | 33 -- .../EditorSuggestions/EditorSuggestions.tsx | 65 ---- packages/client/components/Mentioned.tsx | 16 +- packages/client/components/Menu.tsx | 1 - .../ReflectionCard/ReflectionCard.tsx | 174 ++++----- .../ReflectionCardDeleteButton.tsx | 5 +- .../components/ReflectionEditorWrapper.tsx | 291 --------------- .../ReflectionGroup/RemoteReflection.tsx | 26 +- .../RetroReflectPhase/PhaseItemColumn.tsx | 3 +- .../RetroReflectPhase/PhaseItemEditor.tsx | 170 +++------ .../client/components/SendCommentButton.tsx | 9 - .../components/SuggestMentionableUsers.tsx | 81 ---- .../SuggestMentionableUsersRoot.tsx | 43 --- .../client/components/TaskEditor/Draft.css | 11 - .../components/TaskEditor/EditorLink.tsx | 57 --- .../TaskEditor/EmojiMenuContainer.tsx | 34 -- .../client/components/TaskEditor/Hashtag.tsx | 24 -- .../client/components/TaskEditor/Mention.tsx | 24 -- .../components/TaskEditor/SearchHighlight.tsx | 23 -- .../TaskEditor/TruncatedEllipsis.tsx | 29 -- .../components/TaskEditor/blockStyleFn.ts | 13 - .../components/TaskEditor/customStyleMap.js | 7 - .../components/TaskEditor/decorators.ts | 79 ---- .../components/TaskEditor/getAllEntities.js | 22 -- .../TaskEditor/getAnchorLocation.ts | 13 - .../components/TaskEditor/getSelectionLink.ts | 37 -- .../components/TaskEditor/getSelectionText.ts | 15 - .../client/components/TaskEditor/getWordAt.ts | 31 -- .../client/components/TaskEditor/resolvers.ts | 9 - .../TaskEditor/useCommentPlugins.ts | 77 ---- .../components/TaskEditor/useEmojis.tsx | 94 ----- .../client/components/TaskEditor/useLinks.tsx | 323 ---------------- .../components/TaskEditor/useSuggestions.tsx | 196 ---------- .../promptResponse/TipTapEditor.tsx | 2 +- .../hooks/useDraggableReflectionCard.tsx | 3 +- packages/client/hooks/useEditorState.ts | 59 --- .../client/hooks/useKeyboardShortcuts.tsx | 53 --- packages/client/hooks/useMarkdown.ts | 345 ------------------ .../client/hooks/useTipTapEditorContent.ts | 1 - .../client/hooks/useTipTapReflectionEditor.ts | 99 +++++ .../modules/demo/ClientGraphQLServer.ts | 13 +- .../modules/demo/handleCompletedDemoStage.ts | 3 +- packages/client/modules/demo/initBotScript.ts | 46 ++- packages/client/modules/demo/initDB.ts | 13 +- .../components/SummaryEmail/ExportToCSV.tsx | 11 +- .../EmailReflectionCard.tsx | 23 +- .../NewCheckInQuestion.tsx | 1 - .../mutations/CreateReflectionMutation.ts | 5 +- .../UpdateReflectionContentMutation.ts | 13 +- packages/client/package.json | 1 + packages/client/styles/theme/global.css | 17 +- packages/client/types/draft.d.ts | 3 - packages/client/utils/draftjs/addSpace.ts | 12 - .../client/utils/draftjs/completeEntity.tsx | 113 ------ .../utils/draftjs/convertToTaskContent.ts | 29 -- .../client/utils/draftjs/dontTellDraft.ts | 6 - packages/client/utils/draftjs/entitizeText.ts | 48 --- .../draftjs/extractTextFromDraftString.ts | 10 - .../utils/draftjs/getFullLinkSelection.ts | 54 --- packages/client/utils/draftjs/isAndroid.ts | 5 - packages/client/utils/draftjs/isRichDraft.ts | 16 - packages/client/utils/draftjs/makeAddLink.ts | 25 -- .../client/utils/draftjs/makeEditorState.ts | 11 - packages/client/utils/draftjs/makeEmptyStr.ts | 5 - .../client/utils/draftjs/remountDecorators.ts | 13 - packages/client/utils/draftjs/removeLink.ts | 20 - packages/client/utils/draftjs/splitBlock.ts | 11 - .../client/utils/draftjs/splitDraftContent.ts | 18 - packages/client/utils/getDraftCoords.ts | 19 - packages/client/utils/mergeServerContent.ts | 51 --- .../utils/smartGroup/getGroupSmartTitle.ts | 2 +- .../smartGroup/getTitleFromComputedGroup.ts | 6 +- .../utils/smartGroup/groupReflections.ts | 4 +- .../client/validation/normalizeRawDraftJS.ts | 36 -- .../PushReflection/PushReflectionModal.tsx | 30 +- packages/mattermost-plugin/package.json | 6 +- .../graphql/mutations/createReflection.ts | 11 +- .../helpers/removeEmptyReflections.ts | 3 +- .../updateSmartGroupTitle.ts | 3 +- .../mutations/updateReflectionContent.ts | 13 +- .../public/typeDefs/AddCommentInput.graphql | 2 +- .../typeDefs/CreateReflectionInput.graphql | 2 +- .../graphql/public/typeDefs/Mutation.graphql | 4 +- .../public/typeDefs/RetroReflection.graphql | 2 +- .../graphql/public/types/RetroReflection.ts | 9 + .../graphql/types/CreateReflectionInput.ts | 2 +- packages/server/types/modules.d.ts | 2 - scripts/webpack/dev.clientdll.config.js | 1 - yarn.lock | 86 +---- 95 files changed, 413 insertions(+), 3686 deletions(-) delete mode 100644 packages/client/components/AndroidEditorFallback.tsx delete mode 100644 packages/client/components/EditorInputWrapper.tsx delete mode 100644 packages/client/components/EditorLinkChanger/EditorLinkChangerDraftjs.tsx delete mode 100644 packages/client/components/EditorLinkChanger/EditorLinkChangerModal.tsx delete mode 100644 packages/client/components/EditorLinkViewer/EditorLinkViewer.tsx delete mode 100644 packages/client/components/EditorLinkViewer/EditorLinkViewerDraft.tsx delete mode 100644 packages/client/components/EditorSuggestions/EditorSuggestions.tsx delete mode 100644 packages/client/components/ReflectionEditorWrapper.tsx delete mode 100644 packages/client/components/SuggestMentionableUsers.tsx delete mode 100644 packages/client/components/SuggestMentionableUsersRoot.tsx delete mode 100644 packages/client/components/TaskEditor/Draft.css delete mode 100644 packages/client/components/TaskEditor/EditorLink.tsx delete mode 100644 packages/client/components/TaskEditor/EmojiMenuContainer.tsx delete mode 100644 packages/client/components/TaskEditor/Hashtag.tsx delete mode 100644 packages/client/components/TaskEditor/Mention.tsx delete mode 100644 packages/client/components/TaskEditor/SearchHighlight.tsx delete mode 100644 packages/client/components/TaskEditor/TruncatedEllipsis.tsx delete mode 100644 packages/client/components/TaskEditor/blockStyleFn.ts delete mode 100644 packages/client/components/TaskEditor/customStyleMap.js delete mode 100644 packages/client/components/TaskEditor/decorators.ts delete mode 100644 packages/client/components/TaskEditor/getAllEntities.js delete mode 100644 packages/client/components/TaskEditor/getAnchorLocation.ts delete mode 100644 packages/client/components/TaskEditor/getSelectionLink.ts delete mode 100644 packages/client/components/TaskEditor/getSelectionText.ts delete mode 100644 packages/client/components/TaskEditor/getWordAt.ts delete mode 100644 packages/client/components/TaskEditor/resolvers.ts delete mode 100644 packages/client/components/TaskEditor/useCommentPlugins.ts delete mode 100644 packages/client/components/TaskEditor/useEmojis.tsx delete mode 100644 packages/client/components/TaskEditor/useLinks.tsx delete mode 100644 packages/client/components/TaskEditor/useSuggestions.tsx delete mode 100644 packages/client/hooks/useEditorState.ts delete mode 100644 packages/client/hooks/useKeyboardShortcuts.tsx delete mode 100644 packages/client/hooks/useMarkdown.ts create mode 100644 packages/client/hooks/useTipTapReflectionEditor.ts delete mode 100644 packages/client/types/draft.d.ts delete mode 100644 packages/client/utils/draftjs/addSpace.ts delete mode 100644 packages/client/utils/draftjs/completeEntity.tsx delete mode 100644 packages/client/utils/draftjs/convertToTaskContent.ts delete mode 100644 packages/client/utils/draftjs/dontTellDraft.ts delete mode 100644 packages/client/utils/draftjs/entitizeText.ts delete mode 100644 packages/client/utils/draftjs/extractTextFromDraftString.ts delete mode 100644 packages/client/utils/draftjs/getFullLinkSelection.ts delete mode 100644 packages/client/utils/draftjs/isAndroid.ts delete mode 100644 packages/client/utils/draftjs/isRichDraft.ts delete mode 100644 packages/client/utils/draftjs/makeAddLink.ts delete mode 100644 packages/client/utils/draftjs/makeEditorState.ts delete mode 100644 packages/client/utils/draftjs/makeEmptyStr.ts delete mode 100644 packages/client/utils/draftjs/remountDecorators.ts delete mode 100644 packages/client/utils/draftjs/removeLink.ts delete mode 100644 packages/client/utils/draftjs/splitBlock.ts delete mode 100644 packages/client/utils/draftjs/splitDraftContent.ts delete mode 100644 packages/client/utils/getDraftCoords.ts delete mode 100644 packages/client/utils/mergeServerContent.ts delete mode 100644 packages/client/validation/normalizeRawDraftJS.ts diff --git a/packages/client/README.md b/packages/client/README.md index dfad4979c16..42f6e5cb18a 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -37,7 +37,7 @@ Tailwind CSS is a style-agnostic, build time, utility CSS framework that provide Tailwind CSS breakpoints use `@media (min-width: ...) { ... }`, which makes supporting various screen sizes effortless. The example below shows how easy it is to apply different styles for various breakpoints. Text will be small on the small screen, and it’ll get bigger when reaching the subsequent breakpoints. The same mechanism makes it very easy to support things like [dark mode.](https://tailwindcss.com/docs/dark-mode) too. ```jsx -
+
I should scale and change color depeding on the theme
``` @@ -71,25 +71,14 @@ Tailwind CSS is a style-agnostic, build time, utility CSS framework that provide The easiest way is to use `@apply` directive. See the example in Parabol’s repo here: [https://github.com/ParabolInc/parabol/pull/7597/files#diff-10d9decef8eb3746d2eabf83b8f35b380c852e39e0726e7392808ace853d93b2R150](https://github.com/ParabolInc/parabol/pull/7597/files#diff-10d9decef8eb3746d2eabf83b8f35b380c852e39e0726e7392808ace853d93b2R150) ```css - /** Customize draft-js */ - .draft-blockquote { - @apply my-[8px] mx-0 border-l-[2px] border-slate-500 px-[8px] py-0 italic; - } - - .draft-codeblock { - @apply m-0 border-[1px] border-l-[2px] border-slate-500 bg-slate-200 px-0 py-[8px] font-mono font-[13px] leading-normal; - } - /* ---------------------------------------------------- */ - - /** Customize daypicker styles * - .rdp { - @apply m-[8px]; - --rdp-cell-size: 36px; - --rdp-accent-color: theme(colors.grape.500); - --rdp-background-color: theme(colors.grape.500 / 30%); - --rdp-accent-color-dark: theme(colors.grape.500); - --rdp-background-color-dark: theme(colors.grape.500 / 30%); - } + .rdp { + @apply m-[8px]; + --rdp-cell-size: 36px; + --rdp-accent-color: theme(colors.grape.500); + --rdp-background-color: theme(colors.grape.500 / 30%); + --rdp-accent-color-dark: theme(colors.grape.500); + --rdp-background-color-dark: theme(colors.grape.500 / 30%); + } ``` More info [https://tailwindcss.com/docs/functions-and-directives#apply](https://tailwindcss.com/docs/functions-and-directives#apply) @@ -288,7 +277,6 @@ Anything in the `tailwind.config.js` can be configured to use a CSS variable. Th 3. Gradually improve our `tailwind.config.js` i.e. if you see a spacing value, color, breakpoint or something we use for styling in many places, configure the value in the config and use it via Tailwind CSS generated classes. 4. When migrating exisiting Emotion components, it might be useful to use Chat GPT with a given prompt: TODO - #### Examples of using Tailwind CSS 1. [shadcn/ui](https://ui.shadcn.com/) diff --git a/packages/client/components/AndroidEditorFallback.tsx b/packages/client/components/AndroidEditorFallback.tsx deleted file mode 100644 index 83daad665f2..00000000000 --- a/packages/client/components/AndroidEditorFallback.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import styled from '@emotion/styled' -import {EditorState} from 'draft-js' -import * as React from 'react' -import {ChangeEvent, ClipboardEvent, RefObject, useEffect, useState} from 'react' -import TextArea from 'react-textarea-autosize' -import {PALETTE} from '../styles/paletteV3' -import {Card, Gutters} from '../types/constEnums' - -interface Props { - className?: string - ariaLabel?: string - editorState: EditorState - onBlur?: (e: React.FocusEvent) => void - onFocus?: (e: React.FocusEvent) => void - onKeyDown?: (e: React.KeyboardEvent) => void - onPastedText?: (text: string) => void - placeholder?: string - editorRef?: RefObject - onChange?: () => void -} - -const TextAreaStyles = styled(TextArea)({ - backgroundColor: 'transparent', - border: 0, - color: PALETTE.SLATE_700, - display: 'block', - fontSize: Card.FONT_SIZE, - lineHeight: Card.LINE_HEIGHT, - overflow: 'hidden', - outline: 0, - padding: `${Gutters.REFLECTION_INNER_GUTTER_VERTICAL} ${Gutters.REFLECTION_INNER_GUTTER_HORIZONTAL}`, - resize: 'none', - width: '100%' -}) - -const AndroidEditorFallback = (props: Props) => { - const { - className, - ariaLabel, - editorState, - onBlur, - onFocus, - onKeyDown, - onPastedText, - placeholder, - editorRef - } = props - const [value, setValue] = useState('') - const [height, setHeight] = useState(44) - - useEffect(() => { - const currentContent = editorState.getCurrentContent() - const text = currentContent.getPlainText() - setValue(text) - }, [editorState]) - - const onChange = (e: ChangeEvent) => { - setValue(e.target.value) - } - - const handlePaste = (e: ClipboardEvent) => { - if (onPastedText) { - const clipboardData = e.clipboardData - const pastedText = clipboardData.getData('Text') - onPastedText(pastedText) - } - } - - return ( - setHeight(height)} - style={{height}} - inputRef={editorRef} - spellCheck - placeholder={placeholder} - value={value} - onChange={onChange} - onBlur={onBlur} - onFocus={onFocus} - onPaste={handlePaste} - onKeyDown={onKeyDown} - /> - ) -} - -export default AndroidEditorFallback diff --git a/packages/client/components/EditorInputWrapper.tsx b/packages/client/components/EditorInputWrapper.tsx deleted file mode 100644 index df19f1e0763..00000000000 --- a/packages/client/components/EditorInputWrapper.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import styled from '@emotion/styled' -import { - DraftEditorCommand, - DraftHandleValue, - Editor, - EditorProps, - EditorState, - getDefaultKeyBinding -} from 'draft-js' -import * as React from 'react' -import {MutableRefObject, Suspense, useRef} from 'react' -import useKeyboardShortcuts from '../hooks/useKeyboardShortcuts' -import useMarkdown from '../hooks/useMarkdown' -import {SetEditorState} from '../types/draft' -import {textTags} from '../utils/constants' -import entitizeText from '../utils/draftjs/entitizeText' -import isAndroid from '../utils/draftjs/isAndroid' -import isRichDraft from '../utils/draftjs/isRichDraft' -import lazyPreload from '../utils/lazyPreload' -import './TaskEditor/Draft.css' -import blockStyleFn from './TaskEditor/blockStyleFn' - -type Handlers = Pick< - EditorProps, - 'handleBeforeInput' | 'handleKeyCommand' | 'handleReturn' | 'keyBindingFn' -> - -interface Props extends Handlers { - ariaLabel: string - editorRef: MutableRefObject - editorState: EditorState - setEditorState: SetEditorState - readOnly: boolean - placeholder: string - setEditorStateFallback?: () => void -} - -interface EntityPasteOffset { - anchorOffset: number - anchorKey: string -} - -const AndroidEditorFallback = lazyPreload( - () => import(/* webpackChunkName: 'AndroidEditorFallback' */ './AndroidEditorFallback') -) - -const EditorFallback = styled(AndroidEditorFallback)({ - padding: 0, - fontSize: 24, - lineHeight: '32px' -}) - -const EditorInputWrapper = (props: Props) => { - const { - ariaLabel, - setEditorState, - editorState, - editorRef, - handleReturn, - placeholder, - readOnly, - setEditorStateFallback - } = props - const entityPasteStartRef = useRef(undefined) - const ks = useKeyboardShortcuts(editorState, setEditorState, { - handleKeyCommand: props.handleKeyCommand, - keyBindingFn: props.keyBindingFn - }) - const { - handleBeforeInput, - handleKeyCommand, - keyBindingFn, - onChange: handleChange - } = useMarkdown(editorState, setEditorState, { - handleKeyCommand: ks.handleKeyCommand, - keyBindingFn: ks.keyBindingFn, - handleBeforeInput: props.handleBeforeInput - }) - - const onChange = (editorState: EditorState) => { - const {current: entityPasteStart} = entityPasteStartRef - if (entityPasteStart) { - const {anchorOffset, anchorKey} = entityPasteStart - const selectionState = editorState.getSelection().merge({ - anchorOffset, - anchorKey - }) - const contentState = entitizeText(editorState.getCurrentContent(), selectionState) - entityPasteStartRef.current = undefined - if (contentState) { - setEditorState(EditorState.push(editorState, contentState, 'apply-entity')) - return - } - } - if (editorState.getSelection().getHasFocus() && handleChange) { - handleChange(editorState) - } - setEditorState(editorState) - } - - const onReturn = (e: React.KeyboardEvent) => { - if (handleReturn) { - return handleReturn(e, editorState) - } - if (e.shiftKey || !editorRef.current) { - return 'not-handled' - } - editorRef.current.blur() - return 'handled' - } - - const nextKeyCommand = (command: DraftEditorCommand) => { - if (handleKeyCommand) { - return handleKeyCommand(command, editorState, Date.now()) - } - return 'not-handled' - } - - const onKeyBindingFn = (e: React.KeyboardEvent) => { - if (keyBindingFn) { - const result = keyBindingFn(e) - if (result) { - return result - } - } - return getDefaultKeyBinding(e) - } - - const onBeforeInput = (char: string) => { - if (handleBeforeInput) { - return handleBeforeInput(char, editorState, Date.now()) - } - return 'not-handled' - } - - const onPastedText = (text: string): DraftHandleValue => { - if (text) { - for (let i = 0; i < textTags.length; i++) { - const tag = textTags[i]! - if (text.indexOf(tag) !== -1) { - const selection = editorState.getSelection() - entityPasteStartRef.current = { - anchorOffset: selection.getAnchorOffset(), - anchorKey: selection.getAnchorKey() - } - } - } - } - return 'not-handled' - } - - const onKeyDownFallback = (e: React.KeyboardEvent) => { - if (e.key !== 'Enter' || e.shiftKey) return - e.preventDefault() - } - - // Update question on blur of the editor field. - const handleBlur = () => { - if (isAndroid && setEditorStateFallback) { - setEditorStateFallback() - } - } - - const useFallback = isAndroid && !readOnly - const showFallback = useFallback && !isRichDraft(editorState) - - // Make use of AndroidEditorFallback for android users. - // Usage Reference {@see ./TaskEditor/CommentEditor.tsx} - return ( - <> - {showFallback ? ( - }> - - - ) : ( - - )} - - ) -} - -export default EditorInputWrapper diff --git a/packages/client/components/EditorLinkChanger/EditorLinkChangerDraftjs.tsx b/packages/client/components/EditorLinkChanger/EditorLinkChangerDraftjs.tsx deleted file mode 100644 index c50363c2008..00000000000 --- a/packages/client/components/EditorLinkChanger/EditorLinkChangerDraftjs.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import {ContentState, EditorState, Modifier, SelectionState} from 'draft-js' -import {RefObject} from 'react' -import {UseTaskChild} from '../../hooks/useTaskChildFocus' -import {BBox} from '../../types/animations' -import completeEntity from '../../utils/draftjs/completeEntity' -import EditorLinkChangerModal from './EditorLinkChangerModal' - -interface Props { - editorState: EditorState - editorRef?: RefObject - - link: string | null - - originCoords: BBox - removeModal(allowFocus: boolean): void - - selectionState: SelectionState - - setEditorState(editorState: EditorState): void - - text: string | null - - useTaskChild: UseTaskChild -} - -const contentStateWithFocusAtEnd = ( - givenContentState: ContentState, - givenSelectionState: SelectionState -) => { - const endKey = givenContentState.getSelectionAfter().getEndKey() - const endOffset = givenContentState.getSelectionAfter().getEndOffset() - const collapsedSelectionState = givenSelectionState.merge({ - anchorKey: endKey, - anchorOffset: endOffset, - focusKey: endKey, - focusOffset: endOffset - }) - return givenContentState.merge({ - selectionAfter: collapsedSelectionState - }) as ContentState -} - -const EditorLinkChangerDraftjs = (props: Props) => { - const { - editorState, - editorRef, - selectionState, - setEditorState, - text, - link, - removeModal, - originCoords, - useTaskChild - } = props - useTaskChild('editor-link-changer') - const handleSubmit = ({text, href}: {text: string; href: string}) => { - // don't include leading and trailing whitespace in the link text - const nonWhitespaceFromStart = text.search(/\S/) - const nonWhitespaceFromEnd = text.search(/\s*$/) - const startStr = nonWhitespaceFromStart === -1 ? '' : text.slice(0, nonWhitespaceFromStart) - const endStr = nonWhitespaceFromEnd === -1 ? '' : text.slice(nonWhitespaceFromEnd) - const hasTextTitle = !!text.trim() - // trim link text if it contains any non-whitespace characters, otherwise keep it verbatim - const trimmedText = hasTextTitle ? text.trim() : text - const focusedEditorState = EditorState.forceSelection(editorState, selectionState) - let newEditorState = focusedEditorState - if (hasTextTitle) { - const contentState = focusedEditorState.getCurrentContent() - const expandedSelectionState = focusedEditorState.getSelection() - const contentStateAfterStartStr: ContentState = expandedSelectionState.isCollapsed() - ? Modifier.insertText(contentState, expandedSelectionState, startStr) - : Modifier.replaceText(contentState, expandedSelectionState, startStr) - newEditorState = EditorState.push( - focusedEditorState, - contentStateWithFocusAtEnd(contentStateAfterStartStr, expandedSelectionState), - 'insert-characters' - ) - } - newEditorState = completeEntity(newEditorState, 'LINK', {href}, trimmedText, { - keepSelection: true - }) - const selectionStateAfterTrimmedText = newEditorState.getSelection() - let contentStateAfterEndStr = newEditorState.getCurrentContent() - if (hasTextTitle) { - const contentWithEntity = newEditorState.getCurrentContent() - contentStateAfterEndStr = Modifier.insertText( - contentWithEntity, - selectionStateAfterTrimmedText, - endStr - ) - } - newEditorState = EditorState.push( - editorState, - contentStateWithFocusAtEnd(contentStateAfterEndStr, selectionStateAfterTrimmedText), - 'apply-entity' - ) - setEditorState(newEditorState) - setTimeout(() => editorRef?.current && editorRef.current.focus(), 0) - } - - const handleEscape = () => { - setTimeout(() => editorRef?.current && editorRef.current.focus(), 0) - } - - return ( - - ) -} - -export default EditorLinkChangerDraftjs diff --git a/packages/client/components/EditorLinkChanger/EditorLinkChangerModal.tsx b/packages/client/components/EditorLinkChanger/EditorLinkChangerModal.tsx deleted file mode 100644 index 748e5f5955a..00000000000 --- a/packages/client/components/EditorLinkChanger/EditorLinkChangerModal.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import styled from '@emotion/styled' -import * as React from 'react' -import {useEffect} from 'react' -import {MenuPosition} from '../../hooks/useCoords' -import useForm from '../../hooks/useForm' -import useMenu from '../../hooks/useMenu' -import {PALETTE} from '../../styles/paletteV3' -import {BBox} from '../../types/animations' -import linkify from '../../utils/linkify' -import Legitity from '../../validation/Legitity' -import BasicInput from '../InputField/BasicInput' -import RaisedButton from '../RaisedButton' - -const ModalBoundary = styled('div')({ - color: PALETTE.SLATE_700, - padding: '.5rem 1rem .5rem 1rem', - minWidth: '20rem', - outline: 0 -}) - -const TextBlock = styled('div')({ - // use baseline so errors don't bump it off center - alignItems: 'top', - display: 'flex', - marginBottom: '.5rem' -}) - -const InputBlock = styled('div')({ - display: 'flex', - flex: 1, - flexDirection: 'column' -}) - -const InputLabel = styled('span')({ - display: 'block', - fontSize: 15, - fontWeight: 600, - lineHeight: '40px', - marginRight: '.5rem' -}) - -const ButtonBlock = styled('div')({ - display: 'flex', - justifyContent: 'flex-end' -}) - -interface Props { - link: string | null - - originCoords: BBox - removeModal(allowFocus: boolean): void - - handleSubmit({text, href}: {text: string; href: string}): void - handleEscape?(): void - - text: string | null -} - -const EditorLinkChangerModal = (props: Props) => { - const {originCoords, removeModal, link, text, handleSubmit, handleEscape} = props - const {menuPortal, openPortal} = useMenu(MenuPosition.UPPER_LEFT, { - isDropdown: true, - originCoords - }) - const {setDirtyField, onChange, validateField, fields} = useForm({ - text: { - getDefault: () => text, - validate: (value) => - new Legitity(value) - .required() - .min(1, 'Maybe give it a name?') - .max(100, 'That name is looking pretty long') - }, - link: { - getDefault: () => link, - validate: (value) => - new Legitity(value).test((maybeUrl) => { - if (!maybeUrl) return 'No link provided' - const links = linkify.match(maybeUrl) - return !links ? 'Not looking too linky' : undefined - }) - } - }) - useEffect(openPortal, []) - const onSubmit = (e: React.FormEvent) => { - e.preventDefault() - setDirtyField() - const {link: linkRes, text: textRes} = validateField() - if (!linkRes || linkRes.error || !textRes || textRes.error) return - const link = linkRes.value as string - const text = textRes.value as string - const href = linkify.match(link)![0]!.url - removeModal(true) - handleSubmit({text, href}) - } - - const handleBlur = (e: React.FocusEvent) => { - if (!e.currentTarget.contains(e.relatedTarget as any)) { - removeModal(true) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - removeModal(true) - handleEscape && handleEscape() - } - } - - const hasError = !!(fields.text.error || fields.link.error) - const label = !!text ? 'Update' : 'Add' - return menuPortal( - -
- {text !== null && ( - - {'Text'} - - - - - )} - - {'Link'} - - - - - - - {label} - - -
-
- ) -} - -export default EditorLinkChangerModal diff --git a/packages/client/components/EditorLinkViewer/EditorLinkViewer.tsx b/packages/client/components/EditorLinkViewer/EditorLinkViewer.tsx deleted file mode 100644 index 92319c56d10..00000000000 --- a/packages/client/components/EditorLinkViewer/EditorLinkViewer.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import styled from '@emotion/styled' -import {useEffect} from 'react' -import {MenuPosition} from '../../hooks/useCoords' -import useMenu from '../../hooks/useMenu' -import textOverflow from '../../styles/helpers/textOverflow' -import {PALETTE} from '../../styles/paletteV3' -import {BBox} from '../../types/animations' -import dontTellDraft from '../../utils/draftjs/dontTellDraft' -import FlatButton from '../FlatButton' - -const UrlSpan = styled('span')({ - ...textOverflow, - alignItems: 'center', - borderRight: `1px solid ${PALETTE.GRAPE_700}`, - display: 'flex', - flexShrink: 2, - fontSize: 14, - lineHeight: '32px', - marginRight: 8, - padding: '0 12px' -}) - -const LinkText = styled('a')({ - ...textOverflow, - marginRight: 8, - maxWidth: 320 -}) - -const MenuStyles = styled('div')({ - alignItems: 'center', - color: PALETTE.SLATE_700, - display: 'flex', - fontSize: 20, - padding: '0 8px' -}) - -interface Props { - href: string - addHyperlink: () => void - originCoords: BBox - onRemove: () => void - removeModal: () => void -} - -const EditorLinkViewer = (props: Props) => { - const {href, addHyperlink, removeModal, onRemove, originCoords} = props - const {menuPortal, openPortal} = useMenu(MenuPosition.UPPER_LEFT, { - isDropdown: true, - originCoords - }) - useEffect(openPortal, []) - const handleRemove = () => { - onRemove() - removeModal() - } - - const changeLink = () => { - addHyperlink() - } - - return menuPortal( - - - - {href} - - - - {'Change'} - - - {'Remove'} - - - ) -} - -export default EditorLinkViewer diff --git a/packages/client/components/EditorLinkViewer/EditorLinkViewerDraft.tsx b/packages/client/components/EditorLinkViewer/EditorLinkViewerDraft.tsx deleted file mode 100644 index fa33f17ee4a..00000000000 --- a/packages/client/components/EditorLinkViewer/EditorLinkViewerDraft.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import {EditorState} from 'draft-js' -import {BBox} from '../../types/animations' -import removeLink from '../../utils/draftjs/removeLink' -import EditorLinkViewer from './EditorLinkViewer' - -interface Props { - href: string - addHyperlink: () => void - editorState: EditorState - originCoords: BBox - setEditorState: (newEditorState: EditorState) => void - removeModal: () => void -} - -const EditorLinkViewerDraft = (props: Props) => { - const {href, addHyperlink, editorState, removeModal, setEditorState, originCoords} = props - - const handleRemove = () => { - setEditorState(removeLink(editorState)) - } - - return ( - - ) -} - -export default EditorLinkViewerDraft diff --git a/packages/client/components/EditorSuggestions/EditorSuggestions.tsx b/packages/client/components/EditorSuggestions/EditorSuggestions.tsx deleted file mode 100644 index 097527d5dee..00000000000 --- a/packages/client/components/EditorSuggestions/EditorSuggestions.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import styled from '@emotion/styled' -import * as React from 'react' -import {useLayoutEffect} from 'react' -import {MenuPosition} from '../../hooks/useCoords' -import useMenu from '../../hooks/useMenu' -import {PALETTE} from '../../styles/paletteV3' -import {BBox} from '../../types/animations' -import MentionTag from '../MentionTag/MentionTag' -import MentionUser from '../MentionUser/MentionUser' -import {BaseSuggestion} from '../TaskEditor/useSuggestions' - -const dontTellDraft = (e: React.MouseEvent) => { - e.preventDefault() -} - -const suggestionTypes = { - tag: MentionTag, - mention: MentionUser -} - -export type TaskSuggestionType = keyof typeof suggestionTypes - -const MentionMenu = styled('div')({ - color: PALETTE.SLATE_700 -}) - -interface Props { - active: number - handleSelect: (item: T) => void - originCoords: BBox - suggestions: T[] - suggestionType: TaskSuggestionType -} - -const EditorSuggestions = (props: Props) => { - const {active, handleSelect, suggestions, suggestionType, originCoords} = props - const {menuPortal, openPortal} = useMenu(MenuPosition.UPPER_LEFT, { - originCoords, - isDropdown: true - }) - const SuggestionItem = suggestionTypes[suggestionType] - useLayoutEffect(openPortal, []) - return suggestions.length > 0 - ? menuPortal( - - {suggestions.map((suggestion, idx) => { - return ( -
{ - e.preventDefault() - handleSelect(suggestion) - }} - > - -
- ) - })} -
- ) - : null -} - -export default EditorSuggestions diff --git a/packages/client/components/Mentioned.tsx b/packages/client/components/Mentioned.tsx index 0b5618a59ab..8d7d9655ce5 100644 --- a/packages/client/components/Mentioned.tsx +++ b/packages/client/components/Mentioned.tsx @@ -1,13 +1,13 @@ +import {generateHTML} from '@tiptap/core' import graphql from 'babel-plugin-relay/macro' -import {Editor} from 'draft-js' import {useEffect} from 'react' import {useFragment} from 'react-relay' import NotificationAction from '~/components/NotificationAction' import useAtmosphere from '~/hooks/useAtmosphere' import anonymousAvatar from '~/styles/theme/images/anonymous-avatar.svg' import {Mentioned_notification$key} from '../__generated__/Mentioned_notification.graphql' -import useEditorState from '../hooks/useEditorState' import useRouter from '../hooks/useRouter' +import {serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions' import SendClientSideEvent from '../utils/SendClientSideEvent' import NotificationTemplate from './NotificationTemplate' @@ -80,7 +80,9 @@ const Mentioned = (props: Props) => { history.push(actionUrl) } - const [editorState] = useEditorState(previewContent) + const htmlContent = previewContent + ? generateHTML(JSON.parse(previewContent), serverTipTapExtensions) + : '' return ( { > {previewContent && (
- { - /*noop*/ - }} - /> +
)}
diff --git a/packages/client/components/Menu.tsx b/packages/client/components/Menu.tsx index cb6b14bd38a..bda1e276358 100644 --- a/packages/client/components/Menu.tsx +++ b/packages/client/components/Menu.tsx @@ -25,7 +25,6 @@ const MenuStyles = styled('div')({ maxHeight: 224, maxWidth: 400, outline: 0, - // VERY important! If not present, draft-js gets confused & thinks the menu is the selection rectangle userSelect: 'none' }) diff --git a/packages/client/components/ReflectionCard/ReflectionCard.tsx b/packages/client/components/ReflectionCard/ReflectionCard.tsx index 4ebad1d8f95..893b16a08b4 100644 --- a/packages/client/components/ReflectionCard/ReflectionCard.tsx +++ b/packages/client/components/ReflectionCard/ReflectionCard.tsx @@ -1,7 +1,5 @@ import styled from '@emotion/styled' import graphql from 'babel-plugin-relay/macro' -import {convertToRaw, EditorProps} from 'draft-js' -import * as React from 'react' import {MouseEvent, useEffect, useRef, useState} from 'react' import {commitLocalUpdate, useFragment} from 'react-relay' import { @@ -14,23 +12,22 @@ import {ReflectionCard_reflection$key} from '../../__generated__/ReflectionCard_ import useAtmosphere from '../../hooks/useAtmosphere' import useBreakpoint from '../../hooks/useBreakpoint' import {MenuPosition} from '../../hooks/useCoords' -import useEditorState from '../../hooks/useEditorState' import useMutationProps from '../../hooks/useMutationProps' +import {useTipTapReflectionEditor} from '../../hooks/useTipTapReflectionEditor' import useTooltip from '../../hooks/useTooltip' import EditReflectionMutation from '../../mutations/EditReflectionMutation' import RemoveReflectionMutation from '../../mutations/RemoveReflectionMutation' import UpdateReflectionContentMutation from '../../mutations/UpdateReflectionContentMutation' +import {isEqualWhenSerialized} from '../../shared/isEqualWhenSerialized' import {PALETTE} from '../../styles/paletteV3' import {Breakpoint, ZIndex} from '../../types/constEnums' -import convertToTaskContent from '../../utils/draftjs/convertToTaskContent' -import isAndroid from '../../utils/draftjs/isAndroid' -import remountDecorators from '../../utils/draftjs/remountDecorators' +import {cn} from '../../ui/cn' import isPhaseComplete from '../../utils/meetings/isPhaseComplete' import isTempId from '../../utils/relay/isTempId' import CardButton from '../CardButton' import {OpenSpotlight} from '../GroupingKanbanColumn' import IconLabel from '../IconLabel' -import ReflectionEditorWrapper from '../ReflectionEditorWrapper' +import {TipTapEditor} from '../promptResponse/TipTapEditor' import StyledError from '../StyledError' import ColorBadge from './ColorBadge' import ReactjiSection from './ReactjiSection' @@ -87,7 +84,7 @@ const getReadOnly = ( if (isSpotlightSource) return true if (phases && isPhaseComplete('group', phases)) return true if (!isViewerCreator || isTempId(id)) return true - if (phaseType === 'reflect') return stackCount && stackCount > 1 + if (phaseType === 'reflect') return stackCount ? stackCount > 1 : false if (phaseType === 'group' && isEditing) return false return true } @@ -182,8 +179,18 @@ const ReflectionCard = (props: Props) => { const atmosphere = useAtmosphere() const reflectionDivRef = useRef(null) const {onCompleted, submitting, submitMutation, error, onError} = useMutationProps() - const editorRef = useRef(null) - const [editorState, setEditorState] = useEditorState(content) + const readOnly = getReadOnly( + reflection, + phaseType as NewMeetingPhaseTypeEnum, + stackCount, + phases, + isSpotlightSource + ) + const {editor, linkState, setLinkState} = useTipTapReflectionEditor(content, { + atmosphere, + teamId, + readOnly: !!readOnly + }) const [isHovering, setIsHovering] = useState(false) const isDesktop = useBreakpoint(Breakpoint.SIDEBAR_LEFT) const { @@ -206,61 +213,37 @@ const ReflectionCard = (props: Props) => { } useEffect(() => { - if (isViewerCreator && !editorState.getCurrentContent().hasText()) { + if (isViewerCreator && editor?.isEmpty) { updateIsEditing(true) } return () => updateIsEditing(false) }, []) useEffect(() => { - const refreshedState = remountDecorators(() => editorState, spotlightSearchQuery) - setEditorState(refreshedState) + if (!editor) return + editor.commands.setSearchTerm(spotlightSearchQuery || '') }, [spotlightSearchQuery]) const handleContentUpdate = () => { - if (isAndroid) { - const editorEl = editorRef.current - if (!editorEl || editorEl.type !== 'textarea') return - const {value} = editorEl - if (!value) { - RemoveReflectionMutation(atmosphere, {reflectionId}, {meetingId, onError, onCompleted}) - } else { - const initialContentState = editorState.getCurrentContent() - const initialText = initialContentState.getPlainText() - if (initialText === value) return - submitMutation() - UpdateReflectionContentMutation( - atmosphere, - {content: convertToTaskContent(value), reflectionId}, - {onError, onCompleted} - ) - commitLocalUpdate(atmosphere, (store) => { - const reflection = store.get(reflectionId) - if (!reflection) return - reflection.setValue(false, 'isEditing') - }) - } - return - } - const contentState = editorState.getCurrentContent() - if (contentState.hasText()) { - const nextContent = JSON.stringify(convertToRaw(contentState)) - if (content === nextContent) return - submitMutation() - UpdateReflectionContentMutation( - atmosphere, - {content: nextContent, reflectionId}, - {onError, onCompleted} - ) - commitLocalUpdate(atmosphere, (store) => { - const reflection = store.get(reflectionId) - if (!reflection) return - reflection.setValue(false, 'isEditing') - }) - } else { + if (!editor) return + if (editor.isEmpty) { submitMutation() RemoveReflectionMutation(atmosphere, {reflectionId}, {meetingId, onError, onCompleted}) + return } + const nextContentJSON = editor.getJSON() + if (isEqualWhenSerialized(nextContentJSON, JSON.parse(content))) return + submitMutation() + UpdateReflectionContentMutation( + atmosphere, + {content: JSON.stringify(nextContentJSON), reflectionId}, + {onError, onCompleted} + ) + commitLocalUpdate(atmosphere, (store) => { + const reflection = store.get(reflectionId) + if (!reflection) return + reflection.setValue(false, 'isEditing') + }) } const handleEditorBlur = () => { @@ -269,36 +252,6 @@ const ReflectionCard = (props: Props) => { EditReflectionMutation(atmosphere, {isEditing: false, meetingId, promptId}) } - const handleReturn: EditorProps['handleReturn'] = (e) => { - if (e.shiftKey) return 'not-handled' - editorRef.current && editorRef.current.blur() - return 'handled' - } - - const handleKeyDownFallback = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - editorRef.current && editorRef.current.blur() - } else if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - const {value} = e.currentTarget - if (!value) return - editorRef.current && editorRef.current.blur() - } - } - - const readOnly = getReadOnly( - reflection, - phaseType as NewMeetingPhaseTypeEnum, - stackCount, - phases, - isSpotlightSource - ) - const userSelect = readOnly - ? phaseType === 'discuss' || phaseType === 'vote' - ? 'text' - : 'none' - : undefined - const onToggleReactji = (emojiId: string) => { if (submitting) return const isRemove = !!reactjis.find((reactji) => { @@ -335,6 +288,14 @@ const ReflectionCard = (props: Props) => { !isComplete && !isDemoRoute() && (isHovering || !isDesktop) + const scrollRef = useRef(null) + useEffect(() => { + const el = scrollRef.current + if (el) { + el.scrollTop = isClipped ? el.scrollHeight : 0 + } + }, [isClipped]) + if (!editor) return null return ( { ref={reflectionDivRef} > - + +
+ +
{error && {error.message}} {!readOnly && ( - + )} {disableAnonymity && {creator?.preferredName}} {showReactji && } diff --git a/packages/client/components/ReflectionCard/ReflectionCardDeleteButton.tsx b/packages/client/components/ReflectionCard/ReflectionCardDeleteButton.tsx index a0efe71030a..5f7b172453e 100644 --- a/packages/client/components/ReflectionCard/ReflectionCardDeleteButton.tsx +++ b/packages/client/components/ReflectionCard/ReflectionCardDeleteButton.tsx @@ -14,7 +14,6 @@ import PlainButton from '../PlainButton/PlainButton' interface Props extends WithMutationProps { meetingId: string reflectionId: string - dataCy: string } const DeleteButton = styled(PlainButton)({ @@ -59,11 +58,11 @@ const ReflectionCardDeleteButton = (props: Props) => { RemoveReflectionMutation(atmosphere, {reflectionId}, {meetingId, onError, onCompleted}) } - const {submitting, dataCy} = props + const {submitting} = props const userLabel = 'Delete this reflection card' if (submitting) return null return ( - + diff --git a/packages/client/components/ReflectionEditorWrapper.tsx b/packages/client/components/ReflectionEditorWrapper.tsx deleted file mode 100644 index ccf6dea288c..00000000000 --- a/packages/client/components/ReflectionEditorWrapper.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import styled from '@emotion/styled' -import { - ContentBlock, - DraftEditorCommand, - DraftHandleValue, - Editor, - EditorProps, - EditorState, - getDefaultKeyBinding -} from 'draft-js' -import {RefObject, Suspense, useEffect, useRef} from 'react' -import {PALETTE} from '~/styles/paletteV3' -import {Card, ElementHeight, Gutters} from '../types/constEnums' -import {textTags} from '../utils/constants' -import completeEntity from '../utils/draftjs/completeEntity' -import entitizeText from '../utils/draftjs/entitizeText' -import isAndroid from '../utils/draftjs/isAndroid' -import isRichDraft from '../utils/draftjs/isRichDraft' -import lazyPreload from '../utils/lazyPreload' -import linkify from '../utils/linkify' -import './TaskEditor/Draft.css' -import useCommentPlugins from './TaskEditor/useCommentPlugins' - -const EditorStyles = styled('div')(({useFallback, userSelect, isClipped}: any) => ({ - color: PALETTE.SLATE_700, - fontSize: Card.FONT_SIZE, - lineHeight: useFallback ? '14px' : Card.LINE_HEIGHT, - maxHeight: isClipped ? 44 : ElementHeight.REFLECTION_CARD_MAX, - minHeight: 16, - overflow: 'auto', - position: 'relative', - userSelect, - width: '100%' -})) as any - -const AndroidEditorFallback = lazyPreload( - () => import(/* webpackChunkName: 'AndroidEditorFallback' */ './AndroidEditorFallback') -) - -type DraftProps = Pick< - EditorProps, - | 'editorState' - | 'handleBeforeInput' - | 'handleKeyCommand' - | 'keyBindingFn' - | 'readOnly' - | 'onFocus' - | 'onBlur' - | 'ariaLabel' -> - -interface Props extends DraftProps { - editorRef?: RefObject - placeholder?: string - setEditorState?: (newEditorState: EditorState) => void - teamId: string - isClipped?: boolean - isPhaseItemEditor?: boolean - handleKeyDownFallback?: (e: any) => void - handleReturn: (e: any, editorState: EditorState) => DraftHandleValue - userSelect?: string - disableAnonymity?: boolean -} - -const blockStyleFn = (contentBlock: ContentBlock) => { - const type = contentBlock.getType() - if (type === 'blockquote') { - return 'italic border-l-4 border-solid my-4 px-2 border-slate-500' - } else if (type === 'code-block') { - return 'bg-slate-200 text-tomato-600 font-mono text-[13px] leading-6 m-0 px-2' - } - return '' -} - -const ReflectionEditorWrapper = (props: Props) => { - const { - isClipped, - isPhaseItemEditor, - ariaLabel, - editorRef, - editorState, - onBlur, - onFocus, - placeholder, - handleKeyDownFallback, - readOnly, - userSelect, - disableAnonymity, - setEditorState, - handleReturn - } = props - const entityPasteStartRef = useRef<{anchorOffset: number; anchorKey: string} | undefined>() - const styleRef = useRef(null) - - useEffect(() => { - if (isPhaseItemEditor) return - - if (!editorState.getCurrentContent().hasText()) { - const timeoutId = setTimeout(() => { - try { - editorRef?.current && editorRef.current.focus() - } catch (e) { - // DraftEditor was unmounted before this was called - } - }) - - // Cleanup function to clear the timeout - return () => clearTimeout(timeoutId) - } - - return - }, [editorState, isPhaseItemEditor, editorRef]) - - useEffect(() => { - const el = styleRef.current - if (el) { - el.scrollTop = isClipped ? el.scrollHeight : 0 - } - }, [isClipped]) - - const { - removeModal, - renderModal, - handleChange, - handleReturn: handleSuggestionsReturn, - handleBeforeInput, - handleKeyCommand, - keyBindingFn - } = useCommentPlugins({ - ...props, - setEditorState: - setEditorState ?? - (() => { - /* noop */ - }), - handleReturn: undefined - }) - - const onRemoveModal = () => { - if (renderModal && removeModal) { - removeModal() - } - } - - const onChange = (editorState: EditorState) => { - const {current: entityPasteStart} = entityPasteStartRef - if (entityPasteStart) { - const {anchorOffset, anchorKey} = entityPasteStart - const selectionState = editorState.getSelection().merge({ - anchorOffset, - anchorKey - }) - const contentState = entitizeText(editorState.getCurrentContent(), selectionState) - entityPasteStartRef.current = undefined - if (contentState) { - setEditorState?.(EditorState.push(editorState, contentState, 'apply-entity')) - return - } - } - if (!editorState.getSelection().getHasFocus()) { - onRemoveModal() - } else if (handleChange) { - handleChange(editorState) - } - setEditorState?.(editorState) - } - - const onReturn: EditorProps['handleReturn'] = (e) => { - if (renderModal && handleSuggestionsReturn) { - return handleSuggestionsReturn(e, editorState) - } - - return handleReturn(e, editorState) - } - - const nextKeyCommand = (command: DraftEditorCommand) => { - if (handleKeyCommand) { - return handleKeyCommand(command, editorState, Date.now()) - } - return 'not-handled' - } - - const onKeyBindingFn: EditorProps['keyBindingFn'] = (e) => { - if (keyBindingFn) { - const result = keyBindingFn(e) - if (result) { - return result - } - } - if (e.key === 'Escape') { - e.preventDefault() - if (renderModal) { - onRemoveModal() - } else { - // add to callback queue so we can check activeElement and - // determine whether modal should close in expandedReflectionStack - setTimeout(() => { - const el = editorRef?.current - el?.blur() - }) - } - return null - } - return getDefaultKeyBinding(e) - } - - const onBeforeInput = (char: string) => { - if (handleBeforeInput) { - return handleBeforeInput(char, editorState, Date.now()) - } - return 'not-handled' - } - - const handlePastedText = (text: string): DraftHandleValue => { - if (text) { - textTags.forEach((tag) => { - if (text.indexOf(tag) !== -1) { - const selection = editorState.getSelection() - entityPasteStartRef.current = { - anchorOffset: selection.getAnchorOffset(), - anchorKey: selection.getAnchorKey() - } - } - }) - } - const links = linkify.match(text) - const url = links && links[0]!.url.trim() - const trimmedText = text.trim() - if (url === trimmedText) { - const nextEditorState = completeEntity(editorState, 'LINK', {href: url}, trimmedText, { - keepSelection: true - }) - setEditorState?.(nextEditorState) - return 'handled' - } - return 'not-handled' - } - - const useFallback = isAndroid && !readOnly - const showFallback = useFallback && !isRichDraft(editorState) - return ( - - {showFallback ? ( - }> - - - ) : ( - - )} - {renderModal && renderModal()} - - ) -} - -export default ReflectionEditorWrapper diff --git a/packages/client/components/ReflectionGroup/RemoteReflection.tsx b/packages/client/components/ReflectionGroup/RemoteReflection.tsx index 30a2ae63333..0cef11baf90 100644 --- a/packages/client/components/ReflectionGroup/RemoteReflection.tsx +++ b/packages/client/components/ReflectionGroup/RemoteReflection.tsx @@ -11,15 +11,16 @@ import { RemoteReflection_reflection$key } from '../../__generated__/RemoteReflection_reflection.graphql' import useAtmosphere from '../../hooks/useAtmosphere' -import useEditorState from '../../hooks/useEditorState' +import {useTipTapReflectionEditor} from '../../hooks/useTipTapReflectionEditor' import {Elevation} from '../../styles/elevation' import {BezierCurve, DragAttribute, ElementWidth, Times, ZIndex} from '../../types/constEnums' import {DeepNonNullable} from '../../types/generics' +import {cn} from '../../ui/cn' import {VOTE} from '../../utils/constants' import {getMinTop} from '../../utils/retroGroup/updateClonePosition' +import {TipTapEditor} from '../promptResponse/TipTapEditor' import ReflectionCardAuthor from '../ReflectionCard/ReflectionCardAuthor' import ReflectionCardRoot from '../ReflectionCard/ReflectionCardRoot' -import ReflectionEditorWrapper from '../ReflectionEditorWrapper' import getBBox from '../RetroReflectPhase/getBBox' import UserDraggingHeader, {RemoteReflectionArrow} from '../UserDraggingHeader' @@ -203,19 +204,17 @@ const RemoteReflection = (props: Props) => { isConnected } } - teamId } `, meetingRef ) const {id: reflectionId, content, isDropping, reflectionGroupId, creator} = reflection - const {meetingMembers, localPhase, disableAnonymity, teamId} = meeting + const {meetingMembers, localPhase, disableAnonymity} = meeting const remoteDrag = reflection.remoteDrag as DeepNonNullable< RemoteReflection_reflection$data['remoteDrag'] > const ref = useRef(null) - const editorRef = useRef(null) - const [editorState] = useEditorState(content) + const {editor} = useTipTapReflectionEditor(content, {readOnly: true}) const timeoutRef = useRef(0) const atmosphere = useAtmosphere() const spotlightResultGroups = useSpotlightResults(meeting) @@ -254,7 +253,7 @@ const RemoteReflection = (props: Props) => { } }, [remoteDrag, meetingMembers]) - if (!remoteDrag) return null + if (!remoteDrag || !editor) return null const {dragUserId, dragUserName, isSpotlight} = remoteDrag const {nextStyle, transform, minTop} = getStyle(remoteDrag, isDropping, isSpotlight, style) @@ -272,13 +271,12 @@ const RemoteReflection = (props: Props) => { > {!headerTransform && } - 'handled'} + {disableAnonymity && ( {creator?.preferredName} diff --git a/packages/client/components/RetroReflectPhase/PhaseItemColumn.tsx b/packages/client/components/RetroReflectPhase/PhaseItemColumn.tsx index 7584f378666..1925ee76cb3 100644 --- a/packages/client/components/RetroReflectPhase/PhaseItemColumn.tsx +++ b/packages/client/components/RetroReflectPhase/PhaseItemColumn.tsx @@ -1,6 +1,5 @@ import styled from '@emotion/styled' import graphql from 'babel-plugin-relay/macro' -import {EditorState} from 'draft-js' import {RefObject, useEffect, useMemo, useRef} from 'react' import {useFragment} from 'react-relay' import {PhaseItemColumn_prompt$key} from '~/__generated__/PhaseItemColumn_prompt.graphql' @@ -126,7 +125,7 @@ const ChitSection = styled('div')<{isDesktop: boolean}>(({isDesktop}) => ({ export interface ReflectColumnCardInFlight { key: string - editorState: EditorState + html: string transform: string isStart: boolean } diff --git a/packages/client/components/RetroReflectPhase/PhaseItemEditor.tsx b/packages/client/components/RetroReflectPhase/PhaseItemEditor.tsx index 5ae526bc493..32d3f122856 100644 --- a/packages/client/components/RetroReflectPhase/PhaseItemEditor.tsx +++ b/packages/client/components/RetroReflectPhase/PhaseItemEditor.tsx @@ -1,24 +1,24 @@ import styled from '@emotion/styled' +import {useEventCallback} from '@mui/material' +import {generateHTML} from '@tiptap/core' import graphql from 'babel-plugin-relay/macro' -import {ContentState, EditorState, convertFromRaw, convertToRaw} from 'draft-js' -import {Stack} from 'immutable' import * as React from 'react' import {MutableRefObject, RefObject, useEffect, useRef, useState} from 'react' import {useFragment} from 'react-relay' import {PhaseItemEditor_meeting$key} from '../../__generated__/PhaseItemEditor_meeting.graphql' import useAtmosphere from '../../hooks/useAtmosphere' -import useEditorState from '../../hooks/useEditorState' import useMutationProps from '../../hooks/useMutationProps' import usePortal from '../../hooks/usePortal' +import {useTipTapReflectionEditor} from '../../hooks/useTipTapReflectionEditor' import CreateReflectionMutation from '../../mutations/CreateReflectionMutation' import EditReflectionMutation from '../../mutations/EditReflectionMutation' +import {serverTipTapExtensions} from '../../shared/tiptap/serverTipTapExtensions' import {Elevation} from '../../styles/elevation' -import {PALETTE} from '../../styles/paletteV3' import {BezierCurve, ZIndex} from '../../types/constEnums' -import convertToTaskContent from '../../utils/draftjs/convertToTaskContent' +import {cn} from '../../ui/cn' import ReflectionCardAuthor from '../ReflectionCard/ReflectionCardAuthor' import ReflectionCardRoot from '../ReflectionCard/ReflectionCardRoot' -import ReflectionEditorWrapper from '../ReflectionEditorWrapper' +import {TipTapEditor} from '../promptResponse/TipTapEditor' import {ReflectColumnCardInFlight} from './PhaseItemColumn' import getBBox from './getBBox' @@ -34,21 +34,6 @@ const CardInFlightStyles = styled(ReflectionCardRoot)<{transform: string; isStar }) ) -const EnterHint = styled('div')<{visible: boolean}>(({visible}) => ({ - color: PALETTE.SLATE_600, - fontSize: 14, - fontStyle: 'italic', - fontWeight: 400, - lineHeight: '20px', - paddingLeft: 16, - cursor: 'pointer', - visibility: visible ? undefined : 'hidden', - opacity: visible ? 1 : 0, - height: visible ? 28 : 0, - overflow: 'hidden', - transition: 'height 300ms, opacity 300ms' -})) - interface Props { cardsInFlightRef: MutableRefObject forceUpdateColumn: () => void @@ -76,38 +61,6 @@ const PhaseItemEditor = (props: Props) => { meetingRef } = props const atmosphere = useAtmosphere() - const {onCompleted, onError, submitMutation} = useMutationProps() - const [editorState, setEditorState] = useEditorState() - const [isEditing, setIsEditing] = useState(false) - const idleTimerIdRef = useRef() - const {terminatePortal, openPortal, portal} = usePortal({noClose: true, id: 'phaseItemEditor'}) - useEffect(() => { - return () => { - window.clearTimeout(idleTimerIdRef.current) - } - }, [idleTimerIdRef]) - - const [isFocused, setIsFocused] = useState(false) - const [enterHint, setEnterHint] = useState('') - const hindTimerRef = useRef() - // delay setting the enterHint slightly, so when someone presses on the inFocus hint, it doesn't - // change to the !inFocus one during the transition - useEffect(() => { - const visible = !isEditing && editorState.getCurrentContent().hasText() - if (visible) { - const newEnterHint = isFocused - ? 'Press enter to add' - : 'Forgot to press enter? Click here to add 👆' - hindTimerRef.current = window.setTimeout(() => setEnterHint(newEnterHint), 500) - return () => { - window.clearTimeout(hindTimerRef.current) - } - } else { - setEnterHint('') - return undefined - } - }, [isFocused, isEditing, editorState.getCurrentContent().hasText()]) - const meeting = useFragment( graphql` fragment PhaseItemEditor_meeting on RetrospectiveMeeting { @@ -124,8 +77,11 @@ const PhaseItemEditor = (props: Props) => { ) const {disableAnonymity, viewerMeetingMember, teamId} = meeting - - const handleSubmit = (content: string) => { + const {onCompleted, onError, submitMutation} = useMutationProps() + const handleSubmit = useEventCallback(() => { + if (!editor) return + const contentJSON = editor?.getJSON() + const content = JSON.stringify(contentJSON) const input = { content, meetingId, @@ -137,16 +93,13 @@ const PhaseItemEditor = (props: Props) => { const {top, left} = getBBox(phaseEditorRef.current)! const cardInFlight = { transform: `translate(${left}px,${top}px)`, - editorState: EditorState.push( - editorState, - convertFromRaw(JSON.parse(content)), - 'remove-range' - ), + html: generateHTML(contentJSON, serverTipTapExtensions), key: content, isStart: true } openPortal() cardsInFlightRef.current = [...cardsInFlightRef.current, cardInFlight] + editor.commands.clearContent() forceUpdateColumn() requestAnimationFrame(() => { const stackBBox = getBBox(stackTopRef.current) @@ -165,31 +118,25 @@ const PhaseItemEditor = (props: Props) => { forceUpdateColumn() setTimeout(removeCardInFlight(content), FLIGHT_TIME) }) - // move focus to end is very important! otherwise ghost chars appear - setEditorState( - EditorState.set( - EditorState.moveFocusToEnd( - EditorState.push(editorState, ContentState.createFromText(''), 'remove-range') - ), - {undoStack: Stack(), redoStack: Stack()} - ) - ) - } - - const handleKeyDownFallback = (e: React.KeyboardEvent) => { - if (e.key !== 'Enter' || e.shiftKey) return - e.preventDefault() - const {value} = e.currentTarget - if (!value) return - handleSubmit(convertToTaskContent(value)) - } - - const handleKeydown = () => { - // do not throttle based on submitting or they can't submit very quickly - const content = editorState.getCurrentContent() - if (!content.hasText()) return - handleSubmit(JSON.stringify(convertToRaw(content))) - } + }) + const {editor, linkState, setLinkState} = useTipTapReflectionEditor( + JSON.stringify({type: 'doc', content: [{type: 'paragraph'}]}), + { + atmosphere, + placeholder: 'My reflection… (press enter to add)', + teamId, + readOnly: !!readOnly, + onEnter: handleSubmit + } + ) + const [isEditing, setIsEditing] = useState(false) + const idleTimerIdRef = useRef() + const {terminatePortal, openPortal, portal} = usePortal({noClose: true, id: 'phaseItemEditor'}) + useEffect(() => { + return () => { + window.clearTimeout(idleTimerIdRef.current) + } + }, [idleTimerIdRef]) const ensureNotEditing = () => { if (!isEditing) return @@ -219,21 +166,13 @@ const PhaseItemEditor = (props: Props) => { }, 5000) } const onFocus = () => { - setIsFocused(true) ensureEditing() return null } const onBlur = () => { - setIsFocused(false) ensureNotEditing() } - const handleReturn = (e: React.KeyboardEvent) => { - if (e.shiftKey) return 'not-handled' - handleKeydown() - return 'handled' - } - const removeCardInFlight = (content: string) => () => { const idx = cardsInFlightRef.current.findIndex((card) => card.key === content) if (idx === -1) return @@ -246,51 +185,44 @@ const PhaseItemEditor = (props: Props) => { forceUpdateColumn() } - const editorRef = useRef(null) - + if (!editor) return null return ( <> - - + {disableAnonymity && ( {viewerMeetingMember?.user.preferredName} )} - - {enterHint} - {portal( <> {cardsInFlightRef.current.map((card) => { return ( - 'handled'} - /> +
+
+
{disableAnonymity && ( {viewerMeetingMember?.user.preferredName} diff --git a/packages/client/components/SendCommentButton.tsx b/packages/client/components/SendCommentButton.tsx index 518f0d716a8..a34f0028b74 100644 --- a/packages/client/components/SendCommentButton.tsx +++ b/packages/client/components/SendCommentButton.tsx @@ -1,8 +1,6 @@ import styled from '@emotion/styled' import {ArrowUpward} from '@mui/icons-material' -import * as React from 'react' import {PALETTE} from '~/styles/paletteV3' -import isAndroid from '~/utils/draftjs/isAndroid' import {MenuPosition} from '../hooks/useCoords' import useTooltip from '../hooks/useTooltip' import PlainButton from './PlainButton/PlainButton' @@ -57,17 +55,10 @@ const SendCommentButton = (props: Props) => { const isDisabled = commentSubmitState === 'idle' - const handleTouched = (e: React.TouchEvent) => { - if (!isAndroid) return - e.preventDefault() - onSubmit() - } - return ( <> void - suggestions: MentionSuggestion[] - setSuggestions: (suggestions: MentionSuggestion[]) => void - originCoords: BBox - triggerWord: string - queryRef: PreloadedQuery -} - -const query = graphql` - query SuggestMentionableUsersQuery($teamId: ID!) { - viewer { - team(teamId: $teamId) { - teamMembers(sortBy: "preferredName") { - id - picture - preferredName - } - } - } - } -` - -const SuggestMentionableUsers = (props: Props) => { - const {active, handleSelect, originCoords, suggestions, setSuggestions, triggerWord, queryRef} = - props - const data = usePreloadedQuery(query, queryRef) - const {viewer} = data - const {team} = viewer - const teamMembers = team ? team.teamMembers : null - - useEffect(() => { - if (!teamMembers) { - setSuggestions([]) - return - } - - if (!triggerWord) { - setSuggestions(teamMembers?.slice(0, 6) ?? []) - return - } - - const suggestions = teamMembers - .map((teamMember) => { - const score = stringScore(teamMember.preferredName, triggerWord) - return { - ...teamMember, - score - } - }) - .sort((a, b) => (a.score < b.score ? 1 : -1)) - .slice(0, 6) - // If you type "Foo" and the options are "Foo" and "Giraffe", remove "Giraffe" - .filter((obj, _idx, arr) => obj.score > 0 && arr[0]!.score - obj.score < 0.3) - - setSuggestions(suggestions) - }, [triggerWord, teamMembers]) - - if (!team) return null - return ( - handleSelect(item)} - originCoords={originCoords} - suggestions={suggestions} - suggestionType={'mention'} - /> - ) -} - -export default SuggestMentionableUsers diff --git a/packages/client/components/SuggestMentionableUsersRoot.tsx b/packages/client/components/SuggestMentionableUsersRoot.tsx deleted file mode 100644 index 9b2a3d2854c..00000000000 --- a/packages/client/components/SuggestMentionableUsersRoot.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import {Suspense} from 'react' -import suggestMentionableUsersQuery, { - SuggestMentionableUsersQuery -} from '../__generated__/SuggestMentionableUsersQuery.graphql' -import useQueryLoaderNow from '../hooks/useQueryLoaderNow' -import {BBox} from '../types/animations' -import SuggestMentionableUsers from './SuggestMentionableUsers' -import {MentionSuggestion} from './TaskEditor/useSuggestions' - -interface Props { - active: number - handleSelect: (item: MentionSuggestion) => void - suggestions: MentionSuggestion[] - setSuggestions: (suggestions: MentionSuggestion[]) => void - originCoords: BBox - triggerWord: string - teamId: string -} - -const SuggestMentionableUsersRoot = (props: Props) => { - const {active, handleSelect, originCoords, setSuggestions, suggestions, triggerWord, teamId} = - props - const queryRef = useQueryLoaderNow(suggestMentionableUsersQuery, { - teamId - }) - return ( - - {queryRef && ( - - )} - - ) -} - -export default SuggestMentionableUsersRoot diff --git a/packages/client/components/TaskEditor/Draft.css b/packages/client/components/TaskEditor/Draft.css deleted file mode 100644 index a986126803f..00000000000 --- a/packages/client/components/TaskEditor/Draft.css +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Draft v0.10.1 - * - * Copyright (c) 2013-present, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - */ -.DraftEditor-editorContainer,.DraftEditor-root,.public-DraftEditor-content{height:inherit;text-align:initial}.public-DraftEditor-content[contenteditable=true]{-webkit-user-modify:read-write-plaintext-only}.DraftEditor-root{position:relative}.DraftEditor-editorContainer{background-color:rgba(255,255,255,0);border-left:.1px solid transparent;position:relative;z-index:1}.public-DraftEditor-block{position:relative}.DraftEditor-alignLeft .public-DraftStyleDefault-block{text-align:left}.DraftEditor-alignLeft .public-DraftEditorPlaceholder-root{left:0;text-align:left}.DraftEditor-alignCenter .public-DraftStyleDefault-block{text-align:center}.DraftEditor-alignCenter .public-DraftEditorPlaceholder-root{margin:0 auto;text-align:center;width:100%}.DraftEditor-alignRight .public-DraftStyleDefault-block{text-align:right}.DraftEditor-alignRight .public-DraftEditorPlaceholder-root{right:0;text-align:right}.public-DraftEditorPlaceholder-root{color:#9197a3;position:absolute;z-index:0}.public-DraftEditorPlaceholder-hasFocus{color:#bdc1c9}.DraftEditorPlaceholder-hidden{display:none}.public-DraftStyleDefault-block{position:relative;white-space:pre-wrap}.public-DraftStyleDefault-ltr{direction:ltr;text-align:left}.public-DraftStyleDefault-rtl{direction:rtl;text-align:right}.public-DraftStyleDefault-listLTR{direction:ltr}.public-DraftStyleDefault-listRTL{direction:rtl}.public-DraftStyleDefault-ol,.public-DraftStyleDefault-ul{margin:16px 0;padding:0}.public-DraftStyleDefault-depth0.public-DraftStyleDefault-listLTR{margin-left:1.5em}.public-DraftStyleDefault-depth0.public-DraftStyleDefault-listRTL{margin-right:1.5em}.public-DraftStyleDefault-depth1.public-DraftStyleDefault-listLTR{margin-left:3em}.public-DraftStyleDefault-depth1.public-DraftStyleDefault-listRTL{margin-right:3em}.public-DraftStyleDefault-depth2.public-DraftStyleDefault-listLTR{margin-left:4.5em}.public-DraftStyleDefault-depth2.public-DraftStyleDefault-listRTL{margin-right:4.5em}.public-DraftStyleDefault-depth3.public-DraftStyleDefault-listLTR{margin-left:6em}.public-DraftStyleDefault-depth3.public-DraftStyleDefault-listRTL{margin-right:6em}.public-DraftStyleDefault-depth4.public-DraftStyleDefault-listLTR{margin-left:7.5em}.public-DraftStyleDefault-depth4.public-DraftStyleDefault-listRTL{margin-right:7.5em}.public-DraftStyleDefault-unorderedListItem{list-style-type:square;position:relative}.public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth0{list-style-type:disc}.public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth1{list-style-type:circle}.public-DraftStyleDefault-orderedListItem{list-style-type:none;position:relative}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listLTR:before{left:-36px;position:absolute;text-align:right;width:30px}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listRTL:before{position:absolute;right:-36px;text-align:left;width:30px}.public-DraftStyleDefault-orderedListItem:before{content:counter(ol0) ". ";counter-increment:ol0}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth1:before{content:counter(ol1) ". ";counter-increment:ol1}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth2:before{content:counter(ol2) ". ";counter-increment:ol2}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth3:before{content:counter(ol3) ". ";counter-increment:ol3}.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth4:before{content:counter(ol4) ". ";counter-increment:ol4}.public-DraftStyleDefault-depth0.public-DraftStyleDefault-reset{counter-reset:ol0}.public-DraftStyleDefault-depth1.public-DraftStyleDefault-reset{counter-reset:ol1}.public-DraftStyleDefault-depth2.public-DraftStyleDefault-reset{counter-reset:ol2}.public-DraftStyleDefault-depth3.public-DraftStyleDefault-reset{counter-reset:ol3}.public-DraftStyleDefault-depth4.public-DraftStyleDefault-reset{counter-reset:ol4} \ No newline at end of file diff --git a/packages/client/components/TaskEditor/EditorLink.tsx b/packages/client/components/TaskEditor/EditorLink.tsx deleted file mode 100644 index b56f9897c5d..00000000000 --- a/packages/client/components/TaskEditor/EditorLink.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import {ContentState, EditorState} from 'draft-js' -import {Component, MouseEvent, ReactNode} from 'react' -import {PALETTE} from '../../styles/paletteV3' - -const baseStyle = { - color: PALETTE.SLATE_700 -} - -interface Props { - children: ReactNode - contentState: ContentState - entityKey: string - offsetkey: string -} - -const EditorLink = (getEditorState: () => EditorState | undefined) => - class InnerEditorLink extends Component { - state = {hasFocus: false} - - onClick = (e: MouseEvent) => { - const hasFocus = getEditorState()?.getSelection()?.getHasFocus() - if (hasFocus) return - e.preventDefault() - const {contentState, entityKey} = this.props - const {href} = contentState.getEntity(entityKey).getData() - window.open(href, '_blank', 'noreferrer') - } - - onMouseOver = () => { - const hasFocus = getEditorState()?.getSelection()?.getHasFocus() - if (this.state.hasFocus !== hasFocus) { - this.setState({hasFocus}) - } - } - - render() { - const {offsetkey, children} = this.props - const {hasFocus} = this.state - const style = { - ...baseStyle, - cursor: hasFocus ? 'text' : 'pointer', - textDecoration: 'underline' - } - return ( - - {children} - - ) - } - } - -export default EditorLink diff --git a/packages/client/components/TaskEditor/EmojiMenuContainer.tsx b/packages/client/components/TaskEditor/EmojiMenuContainer.tsx deleted file mode 100644 index 842f8c2ff6a..00000000000 --- a/packages/client/components/TaskEditor/EmojiMenuContainer.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import {Ref, useEffect} from 'react' -import {MenuPosition} from '../../hooks/useCoords' -import useMenu from '../../hooks/useMenu' -import lazyPreload from '../../utils/lazyPreload' - -interface Props { - originCoords: ClientRect - onSelectEmoji: (emoji: string) => void - query: string - menuRef?: Ref - removeModal?: () => void -} - -const EmojiMenu = lazyPreload(() => import(/* webpackChunkName: 'EmojiMenu' */ '../EmojiMenu')) - -const EmojiMenuContainer = (props: Props) => { - const {originCoords, removeModal, onSelectEmoji, query, menuRef} = props - const {menuProps, menuPortal, togglePortal} = useMenu(MenuPosition.UPPER_LEFT, { - originCoords, - onClose: removeModal - }) - - useEffect(togglePortal, []) - return menuPortal( - - ) -} - -export default EmojiMenuContainer diff --git a/packages/client/components/TaskEditor/Hashtag.tsx b/packages/client/components/TaskEditor/Hashtag.tsx deleted file mode 100644 index 00a7ac65b18..00000000000 --- a/packages/client/components/TaskEditor/Hashtag.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import {ReactNode} from 'react' -import {PALETTE} from '../../styles/paletteV3' - -// inline styles so oy-vey doesn't barf when making emails using draft-js cards -const style = { - color: PALETTE.SLATE_700, - fontWeight: 600 -} - -interface Props { - children: ReactNode - offsetkey: string -} - -const Hashtag = (props: Props) => { - const {offsetkey, children} = props - return ( - - {children} - - ) -} - -export default Hashtag diff --git a/packages/client/components/TaskEditor/Mention.tsx b/packages/client/components/TaskEditor/Mention.tsx deleted file mode 100644 index d8f9f7a82d0..00000000000 --- a/packages/client/components/TaskEditor/Mention.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import {ReactNode} from 'react' -import {PALETTE} from '../../styles/paletteV3' - -const style = { - backgroundColor: PALETTE.GOLD_100, - borderRadius: 2, - fontWeight: 600 -} - -interface Props { - children: ReactNode - offsetkey: string -} - -const Mention = (props: Props) => { - const {offsetkey, children} = props - return ( - - {children} - - ) -} - -export default Mention diff --git a/packages/client/components/TaskEditor/SearchHighlight.tsx b/packages/client/components/TaskEditor/SearchHighlight.tsx deleted file mode 100644 index dc07d8647a9..00000000000 --- a/packages/client/components/TaskEditor/SearchHighlight.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import {ReactNode} from 'react' -import {PALETTE} from '../../styles/paletteV3' - -const style = { - backgroundColor: PALETTE.GOLD_HIGHLIGHT, - borderRadius: 2 -} - -interface Props { - children: ReactNode - offsetkey: string -} - -const SearchHighlight = (props: Props) => { - const {offsetkey, children} = props - return ( - - {children} - - ) -} - -export default SearchHighlight diff --git a/packages/client/components/TaskEditor/TruncatedEllipsis.tsx b/packages/client/components/TaskEditor/TruncatedEllipsis.tsx deleted file mode 100644 index 6c8fb015dd9..00000000000 --- a/packages/client/components/TaskEditor/TruncatedEllipsis.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import {ContentState, EditorState} from 'draft-js' -import {Component, ReactNode} from 'react' - -interface Props { - contentState: ContentState - offsetkey: string - entityKey: string - children: ReactNode -} - -const TruncatedEllipsis = (setEditorState?: (editorState: EditorState) => void) => - class TruncatedEllipsisClass extends Component { - onClick = () => { - const {contentState, entityKey} = this.props - const {value} = contentState.getEntity(entityKey).getData() - setEditorState && setEditorState(value) - } - - render() { - const {offsetkey, children} = this.props - return ( - - {children} - - ) - } - } - -export default TruncatedEllipsis diff --git a/packages/client/components/TaskEditor/blockStyleFn.ts b/packages/client/components/TaskEditor/blockStyleFn.ts deleted file mode 100644 index 31029176ea6..00000000000 --- a/packages/client/components/TaskEditor/blockStyleFn.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {ContentBlock} from 'draft-js' - -const blockStyleFn = (contentBlock: ContentBlock) => { - const type = contentBlock.getType() - if (type === 'blockquote') { - return 'draft-blockquote' - } else if (type === 'code-block') { - return 'draft-codeblock' - } - return '' -} - -export default blockStyleFn diff --git a/packages/client/components/TaskEditor/customStyleMap.js b/packages/client/components/TaskEditor/customStyleMap.js deleted file mode 100644 index ffa1d84fb60..00000000000 --- a/packages/client/components/TaskEditor/customStyleMap.js +++ /dev/null @@ -1,7 +0,0 @@ -const customStyleMap = { - STRIKETHROUGH: { - textDecoration: 'line-through' - } -} - -export default customStyleMap diff --git a/packages/client/components/TaskEditor/decorators.ts b/packages/client/components/TaskEditor/decorators.ts deleted file mode 100644 index c3a79992d95..00000000000 --- a/packages/client/components/TaskEditor/decorators.ts +++ /dev/null @@ -1,79 +0,0 @@ -import {CompositeDecorator, ContentBlock, ContentState, EditorState} from 'draft-js' -import {SetEditorState} from '../../types/draft' -import EditorLink from './EditorLink' -import Hashtag from './Hashtag' -import Mention from './Mention' -import SearchHighlight from './SearchHighlight' -import TruncatedEllipsis from './TruncatedEllipsis' - -const findEntity = - (entityType: string) => - ( - contentBlock: ContentBlock, - callback: (start: number, end: number) => void, - contentState: ContentState - ) => { - contentBlock.findEntityRanges((character) => { - const entityKey = character.getEntity() - return entityKey !== null && contentState.getEntity(entityKey).getType() === entityType - }, callback) - } - -const decorators = ( - getEditorState: () => EditorState | undefined, - setEditorState?: SetEditorState, - searchQuery?: string | null -) => { - const compositeDecorator = [ - { - strategy: findEntity('LINK'), - component: EditorLink(getEditorState) - }, - { - strategy: findEntity('TAG'), - component: Hashtag - }, - { - strategy: findEntity('MENTION'), - component: Mention - }, - { - strategy: findEntity('TRUNCATED_ELLIPSIS'), - component: TruncatedEllipsis(setEditorState) - } - ] - - if (searchQuery) { - const findMatchingText = ( - contentBlock: ContentBlock, - callback: (startIdx: number, endIdx: number) => void - ) => { - if (!searchQuery) { - return - } - const textLower = contentBlock.getText().toLowerCase() - const searchQueryLower = searchQuery.toLowerCase() - - let start = 0 - let foundAll = false - while (!foundAll) { - const index = textLower.indexOf(searchQueryLower, start) - if (index === -1) { - foundAll = true - } else { - start = index + 1 - callback(index, index + searchQueryLower.length) - } - } - } - - compositeDecorator.push({ - strategy: findMatchingText, - component: SearchHighlight - }) - } - - return new CompositeDecorator(compositeDecorator) -} - -export default decorators diff --git a/packages/client/components/TaskEditor/getAllEntities.js b/packages/client/components/TaskEditor/getAllEntities.js deleted file mode 100644 index e5e5fbc8004..00000000000 --- a/packages/client/components/TaskEditor/getAllEntities.js +++ /dev/null @@ -1,22 +0,0 @@ -// hopefully draft adds this to their API because it's just silly -// keeping this around in case we need to use this instead of the raw object -const getAllEntities = (contentState) => { - const entityIds = new Set() - const blockMap = contentState.getBlockMap() - blockMap.forEach((block) => { - const charList = block.getCharacterList() - charList.forEach((charMeta) => { - const entityId = charMeta.getEntity() - if (entityId) { - entityIds.add(entityId) - } - }) - }) - const entities = [] - entityIds.forEach((id) => { - entities.push(contentState.getEntity(id)) - }) - return entities -} - -export default getAllEntities diff --git a/packages/client/components/TaskEditor/getAnchorLocation.ts b/packages/client/components/TaskEditor/getAnchorLocation.ts deleted file mode 100644 index 035a2778626..00000000000 --- a/packages/client/components/TaskEditor/getAnchorLocation.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {EditorState} from 'draft-js' - -const getAnchorLocation = (editorState: EditorState) => { - const selection = editorState.getSelection() - const currentContent = editorState.getCurrentContent() - const anchorKey = selection.getAnchorKey() - return { - anchorOffset: selection.getAnchorOffset(), - block: currentContent.getBlockForKey(anchorKey) - } -} - -export default getAnchorLocation diff --git a/packages/client/components/TaskEditor/getSelectionLink.ts b/packages/client/components/TaskEditor/getSelectionLink.ts deleted file mode 100644 index 9a434bedb1c..00000000000 --- a/packages/client/components/TaskEditor/getSelectionLink.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {EditorState, SelectionState} from 'draft-js' - -const getSelectionLink = (editorState: EditorState, selection: SelectionState) => { - const startKey = selection.getStartKey() - const endKey = selection.getEndKey() - const currentContent = editorState.getCurrentContent() - let currentKey = endKey - let currentBlock = currentContent.getBlockForKey(endKey) - let i = 0 - while (currentBlock) { - i++ - const startChar = currentKey === startKey ? selection.getStartOffset() : 0 - const charList = currentBlock.getCharacterList() - const endChar = currentKey === endKey ? selection.getEndOffset() : charList.size - 1 - const subset = charList.slice(startChar, endChar) - const lastLinkChar = subset.findLast((value) => { - return ( - (value && - value.getEntity() && - currentContent.getEntity(value.getEntity()).getType() === 'LINK') || - false - ) - }) - if (lastLinkChar) { - return currentContent.getEntity(lastLinkChar.getEntity()).getData().href as string - } - if (currentKey === startKey) return null - currentKey = currentContent.getKeyBefore(currentKey) - currentBlock = currentContent.getBlockForKey(currentKey) - if (i >= 1000) { - break - } - } - return null -} - -export default getSelectionLink diff --git a/packages/client/components/TaskEditor/getSelectionText.ts b/packages/client/components/TaskEditor/getSelectionText.ts deleted file mode 100644 index eab13f3f266..00000000000 --- a/packages/client/components/TaskEditor/getSelectionText.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {EditorState, SelectionState} from 'draft-js' - -const getSelectionText = (editorState: EditorState, selection: SelectionState) => { - const anchorKey = selection.getAnchorKey() - const focusKey = selection.getFocusKey() - if (anchorKey !== focusKey) { - return null - } - const content = editorState.getCurrentContent() - const block = content.getBlockForKey(anchorKey) - const blockText = block.getText() - return blockText.substring(selection.getStartOffset(), selection.getEndOffset()) -} - -export default getSelectionText diff --git a/packages/client/components/TaskEditor/getWordAt.ts b/packages/client/components/TaskEditor/getWordAt.ts deleted file mode 100644 index c34af972d05..00000000000 --- a/packages/client/components/TaskEditor/getWordAt.ts +++ /dev/null @@ -1,31 +0,0 @@ -const getWordAt = (str: string, pos: number, willBreakAfterAnchor?: boolean) => { - // find the last nonwhitespace char before the position. if str ends in a space, this is -1 - const left = str.slice(0, pos + 1).search(/\S+$/) - - if (left === -1) { - return { - word: '', - begin: pos, - end: pos - } - } - // if i move to the beginning of a word & then type a url, when i hit space, i want it to end where the space WILL be - const right = willBreakAfterAnchor ? 1 : str.slice(pos).search(/\s/) - // The last word in the string is a special case. - if (right < 0) { - return { - word: str.slice(left), - begin: left, - end: str.length - } - } - - // Return the word, using the located bounds to extract it from the string. - return { - word: str.slice(left, right + pos), - begin: left, - end: right + pos - } -} - -export default getWordAt diff --git a/packages/client/components/TaskEditor/resolvers.ts b/packages/client/components/TaskEditor/resolvers.ts deleted file mode 100644 index 93e63f52d0b..00000000000 --- a/packages/client/components/TaskEditor/resolvers.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {tags} from '../../utils/constants' - -export const resolveHashTag = async (query: string) => { - return tags.filter((tag) => tag.name.startsWith(query)).map((tag) => ({id: tag.name, ...tag})) -} - -export default { - tag: resolveHashTag -} diff --git a/packages/client/components/TaskEditor/useCommentPlugins.ts b/packages/client/components/TaskEditor/useCommentPlugins.ts deleted file mode 100644 index f633205195f..00000000000 --- a/packages/client/components/TaskEditor/useCommentPlugins.ts +++ /dev/null @@ -1,77 +0,0 @@ -import {EditorProps, EditorState} from 'draft-js' -import {RefObject} from 'react' -import useKeyboardShortcuts from '../../hooks/useKeyboardShortcuts' -import useMarkdown from '../../hooks/useMarkdown' -import {SetEditorState} from '../../types/draft' -import useEmojis from './useEmojis' -import useLinks from './useLinks' -import useSuggestions from './useSuggestions' - -type Handlers = Pick< - EditorProps, - 'handleKeyCommand' | 'keyBindingFn' | 'handleBeforeInput' | 'handleReturn' -> - -interface CustomProps { - editorState: EditorState - setEditorState: SetEditorState - editorRef?: RefObject - teamId: string -} - -type Props = Handlers & CustomProps - -const useCommentPlugins = (props: Props) => { - const { - editorState, - handleReturn, - keyBindingFn, - handleKeyCommand, - handleBeforeInput, - setEditorState, - editorRef, - teamId - } = props - const ks = useKeyboardShortcuts(editorState, setEditorState, {handleKeyCommand, keyBindingFn}) - const md = useMarkdown(editorState, setEditorState, { - handleKeyCommand: ks.handleKeyCommand, - keyBindingFn: ks.keyBindingFn, - handleBeforeInput - }) - const sug = useSuggestions( - editorState, - setEditorState, - { - handleReturn, - teamId, - keyBindingFn: md.keyBindingFn, - onChange: md.onChange - }, - {excludeTags: true} - ) - const emoji = useEmojis(editorState, setEditorState, { - keyBindingFn: sug.keyBindingFn, - renderModal: sug.renderModal, - removeModal: sug.removeModal, - onChange: sug.onChange - }) - const lnk = useLinks(editorState, setEditorState, { - onChange: emoji.onChange, - keyBindingFn: emoji.keyBindingFn, - handleBeforeInput: md.handleBeforeInput, - handleKeyCommand: md.handleKeyCommand, - removeModal: emoji.removeModal, - renderModal: emoji.renderModal, - editorRef, - useTaskChild: () => { - /* noop */ - } - }) - - return { - handleReturn: sug.handleReturn, - ...lnk - } -} - -export default useCommentPlugins diff --git a/packages/client/components/TaskEditor/useEmojis.tsx b/packages/client/components/TaskEditor/useEmojis.tsx deleted file mode 100644 index 270a5f28fc2..00000000000 --- a/packages/client/components/TaskEditor/useEmojis.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import {EditorProps, EditorState} from 'draft-js' -import * as React from 'react' -import {ReactNode, useRef, useState} from 'react' -import {SetEditorState} from '../../types/draft' -import {autoCompleteEmoji} from '../../utils/draftjs/completeEntity' -import getDraftCoords from '../../utils/getDraftCoords' -import EmojiMenuContainer from './EmojiMenuContainer' -import getAnchorLocation from './getAnchorLocation' -import getWordAt from './getWordAt' - -type Handlers = Pick & { - renderModal?: () => ReactNode | null - removeModal?: () => void -} - -interface MenuRef { - handleKeyDown: (e: React.KeyboardEvent) => 'handled' | 'not-handled' -} - -const useEmojis = ( - editorState: EditorState, - setEditorState: SetEditorState, - handlers: Handlers -) => { - const {keyBindingFn, onChange, renderModal, removeModal} = handlers - const menuRef = useRef(null) - const cachedCoordsRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - const [query, setQuery] = useState('') - const [focusedEditorState, setFocusedEditorState] = useState(editorState) - if (focusedEditorState !== editorState && editorState.getSelection().getHasFocus()) { - setFocusedEditorState(editorState) - } - - const handleKeyBindingFn: Handlers['keyBindingFn'] = (e) => { - if (keyBindingFn) { - const result = keyBindingFn(e) - if (result) return result - } - if (menuRef.current) { - const handled = menuRef.current.handleKeyDown(e) - if (handled) return handled - } - return null - } - const onSelectEmoji = (emoji: string) => { - const nextEditorState = autoCompleteEmoji(focusedEditorState, emoji) - setEditorState(nextEditorState) - } - - const onRemoveModal = () => { - setIsOpen(false) - setQuery('') - } - - const handleChange = (editorState: EditorState) => { - if (onChange) { - onChange(editorState) - } - const {block, anchorOffset} = getAnchorLocation(editorState) - const blockText = block.getText() - const entityKey = block.getEntityAt(anchorOffset - 1) - const {word} = getWordAt(blockText, anchorOffset - 1) - - const inASuggestion = word && !entityKey && word[0] === ':' - if (inASuggestion) { - setIsOpen(true) - setQuery(word.slice(1)) - } else if (isOpen) { - onRemoveModal() - } - } - - const onRenderModal = () => { - cachedCoordsRef.current = getDraftCoords() || cachedCoordsRef.current - return ( - - ) - } - return { - onChange: handleChange, - renderModal: isOpen ? onRenderModal : renderModal, - removeModal: isOpen ? onRemoveModal : removeModal, - keyBindingFn: isOpen ? handleKeyBindingFn : keyBindingFn - } -} - -export default useEmojis diff --git a/packages/client/components/TaskEditor/useLinks.tsx b/packages/client/components/TaskEditor/useLinks.tsx deleted file mode 100644 index 962a4cd2b52..00000000000 --- a/packages/client/components/TaskEditor/useLinks.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { - DraftEditorCommand, - EditorProps, - EditorState, - KeyBindingUtil, - SelectionState -} from 'draft-js' -import {ReactNode, RefObject, Suspense, useRef, useState} from 'react' -import useForceUpdate from '../../hooks/useForceUpdate' -import {UseTaskChild} from '../../hooks/useTaskChildFocus' -import {SetEditorState} from '../../types/draft' -import addSpace from '../../utils/draftjs/addSpace' -import getFullLinkSelection from '../../utils/draftjs/getFullLinkSelection' -import makeAddLink from '../../utils/draftjs/makeAddLink' -import splitBlock from '../../utils/draftjs/splitBlock' -import getDraftCoords from '../../utils/getDraftCoords' -import lazyPreload from '../../utils/lazyPreload' -import linkify from '../../utils/linkify' -import getAnchorLocation from './getAnchorLocation' -import getSelectionLink from './getSelectionLink' -import getSelectionText from './getSelectionText' -import getWordAt from './getWordAt' - -const EditorLinkChanger = lazyPreload( - () => - import( - /* webpackChunkName: 'EditorLinkChanger' */ - '../EditorLinkChanger/EditorLinkChangerDraftjs' - ) -) - -const EditorLinkViewerDraft = lazyPreload( - () => - import( - /* webpackChunkName: 'EditorLinkViewerDraft' */ - '../EditorLinkViewer/EditorLinkViewerDraft' - ) -) - -const getEntityKeyAtCaret = (editorState: EditorState) => { - const selectionState = editorState.getSelection() - const contentState = editorState.getCurrentContent() - const anchorOffset = selectionState.getAnchorOffset() - const blockKey = selectionState.getAnchorKey() - const block = contentState.getBlockForKey(blockKey) - return block.getEntityAt(anchorOffset - 1) -} - -const getCtrlKSelection = (editorState: EditorState) => { - const selectionState = editorState.getSelection() - if (selectionState.isCollapsed()) { - const entityKey = getEntityKeyAtCaret(editorState) - if (entityKey) { - return getFullLinkSelection(editorState) - } - const {block, anchorOffset} = getAnchorLocation(editorState) - const blockText = block.getText() - const {word, begin, end} = getWordAt(blockText, anchorOffset - 1) - if (word) { - return selectionState.merge({ - anchorOffset: begin, - focusOffset: end - }) as SelectionState - } - } - return selectionState -} - -const {hasCommandModifier} = KeyBindingUtil - -interface CustomProps { - removeModal?: () => void - renderModal?: () => ReactNode - editorRef?: RefObject - useTaskChild: UseTaskChild -} - -type Handlers = Pick< - EditorProps, - 'handleBeforeInput' | 'onChange' | 'handleKeyCommand' | 'keyBindingFn' -> & - CustomProps - -interface ViewerData { - href: string - text: string - selectionState: SelectionState -} - -const useLinks = (editorState: EditorState, setEditorState: SetEditorState, handlers: Handlers) => { - const { - handleBeforeInput, - editorRef, - keyBindingFn, - handleKeyCommand, - onChange, - removeModal, - renderModal, - useTaskChild - } = handlers - const undoLinkRef = useRef(false) - const cachedCoordsRef = useRef(null) - const [linkViewerData, setLinkViewerData] = useState() - const [linkChangerData, setLinkChangerData] = useState< - undefined | {link: string | null; selectionState: SelectionState; text: string | null} - >() - const forceUpdate = useForceUpdate() - const onRemoveModal = (allowFocus?: boolean) => { - // LinkChanger can take focus, so sometimes we don't want to blur - if (!linkChangerData || allowFocus) { - cachedCoordsRef.current = null - setLinkChangerData(undefined) - setLinkViewerData(undefined) - } - } - - const getMaybeLinkifiedState = (getNextState: () => EditorState, editorState: EditorState) => { - undoLinkRef.current = false - const {block, anchorOffset} = getAnchorLocation(editorState) - const blockText = block.getText() - // -1 to remove the link from the current caret state - const {begin, end, word} = getWordAt(blockText, anchorOffset - 1, true) - if (!word) return undefined - const entityKey = block.getEntityAt(anchorOffset - 1) - - if (entityKey) { - const contentState = editorState.getCurrentContent() - const entity = contentState.getEntity(entityKey) - if (entity.getType() === 'LINK') { - // the character that is to the left of the caret is a link - // const {begin, end, word} = getWordAt(blockText, anchorOffset, true); - const entityKeyToRight = block.getEntityAt(anchorOffset) - // if they're putting a space within the link, keep it contiguous - if (entityKey !== entityKeyToRight) { - // hitting space should close the modal - if (removeModal) { - if (renderModal) { - removeModal() - } else if (linkViewerData || linkChangerData) { - onRemoveModal() - } - } - return getNextState() - } - } - } else { - const links = linkify.match(word) - // make sure the link starts at the beginning of the word otherwise we get conflicts with markdown and junk - if (links && links[0]!.index === 0) { - const {url} = links[0]! - const linkifier = makeAddLink(block.getKey(), begin, end, url) - undoLinkRef.current = true - // getNextState is a thunk because 99% of the time, we won't ever use it, - return linkifier(getNextState()) - } - } - return undefined - } - - const onHandleBeforeInput: Handlers['handleBeforeInput'] = (char: string) => { - if (handleBeforeInput) { - const result = handleBeforeInput(char, editorState, Date.now()) - // @ts-ignore - if (result === 'handled' || result === true) { - return result - } - } - if (char === ' ') { - const getNextState = () => addSpace(editorState) - const nextEditorState = getMaybeLinkifiedState(getNextState, editorState) - if (nextEditorState) { - setEditorState(nextEditorState) - return 'handled' - } - } - return 'not-handled' - } - - const handleChange = (editorState: EditorState) => { - if (onChange) { - onChange(editorState) - } - undoLinkRef.current = false - const {block, anchorOffset} = getAnchorLocation(editorState) - const entityKey = block.getEntityAt(Math.max(0, anchorOffset - 1)) - if (entityKey && !linkChangerData) { - const contentState = editorState.getCurrentContent() - const entity = contentState.getEntity(entityKey) - if (entity.getType() === 'LINK') { - setLinkViewerData(entity.getData()) - return - } - } - if (linkViewerData) { - onRemoveModal() - } - } - - const addHyperlink = () => { - const selectionState = getCtrlKSelection(editorState) - const text = getSelectionText(editorState, selectionState) - const link = getSelectionLink(editorState, selectionState) - setLinkViewerData(undefined) - setLinkChangerData({ - link, - text, - selectionState - }) - } - - const onKeyCommand: Handlers['handleKeyCommand'] = ( - command: DraftEditorCommand | 'add-hyperlink' - ) => { - if (handleKeyCommand) { - const result = handleKeyCommand(command, editorState, Date.now()) - // @ts-ignore - if (result === 'handled' || result === true) { - return result - } - } - - if (command === 'split-block') { - const getNextState = () => splitBlock(editorState) - const nextEditorState = getMaybeLinkifiedState(getNextState, editorState) - if (nextEditorState) { - setEditorState(nextEditorState) - return 'handled' - } - } - - if (command === 'backspace' && undoLinkRef.current) { - setEditorState(EditorState.undo(editorState)) - undoLinkRef.current = false - return 'handled' - } - - if (command === 'add-hyperlink') { - addHyperlink() - return 'handled' - } - return 'not-handled' - } - - const renderChangerModal = () => { - if (!linkChangerData) return null - const {text, link, selectionState} = linkChangerData - const coords = getDraftCoords() - // in this case, coords can be good, then bad as soon as the changer takes focus - // so, the container must handle bad then good as well as good then bad - if (coords) { - cachedCoordsRef.current = coords - } - if (!cachedCoordsRef.current) { - setTimeout(forceUpdate) - return null - } - // keys are very important because all modals feed into the same renderModal, which could replace 1 with the other - return ( - - - - ) - } - - const renderViewerModal = () => { - const coords = getDraftCoords() - if (!coords) { - setTimeout(forceUpdate) - return null - } - if (!linkViewerData) return null - return ( - - - - ) - } - - const handleKeyBindingFn: Handlers['keyBindingFn'] = (e) => { - if (keyBindingFn) { - const result = keyBindingFn(e) - if (result) { - return result - } - } - if (e.key === 'k' && hasCommandModifier(e)) { - return 'add-hyperlink' - } - return null - } - - return { - handleBeforeInput: onHandleBeforeInput, - handleChange, - handleKeyCommand: onKeyCommand, - keyBindingFn: handleKeyBindingFn, - renderModal: linkViewerData - ? renderViewerModal - : linkChangerData - ? renderChangerModal - : renderModal, - removeModal: linkViewerData || linkChangerData ? onRemoveModal : removeModal - } -} - -export default useLinks diff --git a/packages/client/components/TaskEditor/useSuggestions.tsx b/packages/client/components/TaskEditor/useSuggestions.tsx deleted file mode 100644 index 9db13e2199e..00000000000 --- a/packages/client/components/TaskEditor/useSuggestions.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import {EditorProps, EditorState} from 'draft-js' -import {Suspense, lazy, useState} from 'react' -import useForceUpdate from '../../hooks/useForceUpdate' -import {SetEditorState} from '../../types/draft' -import completeEntity from '../../utils/draftjs/completeEntity' -import getDraftCoords from '../../utils/getDraftCoords' -import getAnchorLocation from './getAnchorLocation' -import getWordAt from './getWordAt' -import resolvers from './resolvers' - -const EditorSuggestions = lazy( - () => import(/* webpackChunkName: 'EditorSuggestions' */ '../EditorSuggestions/EditorSuggestions') -) - -const SuggestMentionableUsersRoot = lazy( - () => - import(/* webpackChunkName: 'SuggestMentionableUsersRoot' */ '../SuggestMentionableUsersRoot') -) - -type Handlers = Pick - -interface CustomProps { - teamId: string -} - -export interface BaseSuggestion { - id: string -} - -interface TagSuggestion extends BaseSuggestion { - name: string -} - -export interface MentionSuggestion extends BaseSuggestion { - preferredName: string -} - -interface Options { - excludeTags?: boolean -} -export type DraftSuggestion = MentionSuggestion | TagSuggestion - -type SuggestionType = 'tag' | 'mention' -const useSuggestions = ( - editorState: EditorState, - setEditorState: SetEditorState, - handlers: Handlers & CustomProps, - options: Options = {} -) => { - const {keyBindingFn, handleReturn, teamId, onChange} = handlers - const {excludeTags} = options - const [active, setActive] = useState(undefined) - const [suggestions, _setSuggestions] = useState([]) - const [suggestionType, setSuggestionType] = useState(undefined) - const [triggerWord, setTriggerWord] = useState('') - const forceUpdate = useForceUpdate() - const onRemoveModal = () => { - setActive(undefined) - _setSuggestions([]) - setSuggestionType(undefined) - } - - const setSuggestions = (suggestions: DraftSuggestion[]) => { - if (suggestions.length === 0) { - onRemoveModal() - } else { - _setSuggestions(suggestions) - } - } - - const handleSelect = (item: BaseSuggestion) => { - if (suggestionType === 'tag') { - const {name} = item as TagSuggestion - setEditorState(completeEntity(editorState, 'TAG', {value: name}, `#${name}`)) - } else if (suggestionType === 'mention') { - const {id, preferredName} = item as MentionSuggestion - // team is derived from the task itself, so userId is the real useful thing here - const [userId] = id.split('::') - setEditorState(completeEntity(editorState, 'MENTION', {userId}, preferredName)) - } - onRemoveModal() - } - - const handleKeyBindingFn: Handlers['keyBindingFn'] = (e) => { - if (active === undefined) return null - if (keyBindingFn) { - keyBindingFn(e) - } - if (e.key === 'ArrowUp') { - e.preventDefault() - setActive(Math.max(active - 1, 0)) - } else if (e.key === 'ArrowDown') { - e.preventDefault() - setActive(Math.min(active + 1, suggestions!.length - 1)) - } else if (e.key === 'Tab') { - e.preventDefault() - handleSelect(suggestions[active]!) - } - return null - } - - const onHandleReturn: Handlers['handleReturn'] = (e) => { - if (handleReturn) { - handleReturn(e, editorState) - } - if (active === undefined) return 'not-handled' - handleSelect(suggestions[active]!) - return 'handled' - } - - const checkForSuggestions = (word: string) => { - const trigger = word[0] - const nextTriggerWord = word.slice(1) - if (trigger === '#' && !excludeTags) { - makeSuggestions(nextTriggerWord, 'tag') - return true - } else if (trigger === '@') { - setActive(0) - setTriggerWord(nextTriggerWord) - _setSuggestions([]) - setSuggestionType('mention') - return true - } - return false - } - - const makeSuggestions = async (triggerWord: string, resolveType: 'tag') => { - const resolve = resolvers[resolveType] - const suggestions = await resolve(triggerWord) - if (suggestions.length > 0) { - setActive(0) - _setSuggestions(suggestions) - setSuggestionType(resolveType) - } else { - onRemoveModal() - } - } - - const handleChange = (editorState: EditorState) => { - if (onChange) { - onChange(editorState) - } - const {block, anchorOffset} = getAnchorLocation(editorState) - const blockText = block.getText() - const entityKey = block.getEntityAt(anchorOffset - 1) - const {word} = getWordAt(blockText, anchorOffset - 1) - - const inASuggestion = word && !entityKey && checkForSuggestions(word) - if (!inASuggestion && suggestionType) { - onRemoveModal() - } - } - - const renderModal = () => { - const coords = getDraftCoords() - if (!coords) { - setTimeout(forceUpdate) - return null - } - if (suggestionType === 'mention') { - return ( - - - - ) - } - return ( - - - - ) - } - return { - onChange: handleChange, - renderModal: suggestionType ? renderModal : undefined, - removeModal: suggestionType ? onRemoveModal : undefined, - keyBindingFn: suggestionType ? handleKeyBindingFn : keyBindingFn, - handleReturn: suggestionType ? onHandleReturn : handleReturn - } -} - -export default useSuggestions diff --git a/packages/client/components/promptResponse/TipTapEditor.tsx b/packages/client/components/promptResponse/TipTapEditor.tsx index 597b94225c4..59a5d5f5dbe 100644 --- a/packages/client/components/promptResponse/TipTapEditor.tsx +++ b/packages/client/components/promptResponse/TipTapEditor.tsx @@ -33,7 +33,7 @@ export const TipTapEditor = (props: Props) => { ref={ref as any} {...rest} editor={editor} - className={cn('min-h-10 cursor-text px-4 text-sm leading-5', className)} + className={cn('min-h-10 px-4 text-sm leading-5', className)} /> ) diff --git a/packages/client/hooks/useDraggableReflectionCard.tsx b/packages/client/hooks/useDraggableReflectionCard.tsx index 35fb59d29c7..c860c501734 100644 --- a/packages/client/hooks/useDraggableReflectionCard.tsx +++ b/packages/client/hooks/useDraggableReflectionCard.tsx @@ -335,8 +335,9 @@ const useDragAndDrop = ( // We want to clone the reflection card after the properties were set correctly, especially isDragging, thus the mutation needs to run first. // in some cases, e.g. moving a card out of a reflection group, the component tree is shuffled which will set the drag.ref to null. const dragRef = drag.ref - StartDraggingReflectionMutation(atmosphere, {reflectionId, dragId: drag.id}) + // clone must come first because once isViewerDragging gets set then the tiptap element disappears if dragging from an expanded stack ??? drag.clone = cloneReflection(dragRef, reflectionId) + StartDraggingReflectionMutation(atmosphere, {reflectionId, dragId: drag.id}) } if (!drag.clone) return drag.clientY = clientY diff --git a/packages/client/hooks/useEditorState.ts b/packages/client/hooks/useEditorState.ts deleted file mode 100644 index f11441b9658..00000000000 --- a/packages/client/hooks/useEditorState.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as Sentry from '@sentry/browser' -import {convertFromRaw, EditorState} from 'draft-js' -import {MutableRefObject, useEffect, useRef, useState} from 'react' -import makeEditorState from '../utils/draftjs/makeEditorState' -import mergeServerContent from '../utils/mergeServerContent' - -const useEditorState = (content?: string | null | undefined) => { - const [editorState, setEditorState] = useState(() => - makeEditorState(content, () => editorStateRef.current!) - ) - const editorStateRef = useRef(editorState) as MutableRefObject - const isErrorSentToSentryRef = useRef(false) - const lastFiredRef = useRef(null) - const initialRender = useRef(true) - useEffect(() => { - if (initialRender.current) { - initialRender.current = false - return - } - if (!content) return - const parsedContent = JSON.parse(content) - if (!parsedContent.blocks) return - const editorStateContent = editorStateRef.current.getCurrentContent() - const editorStateBlock = editorStateContent.getLastBlock() - const editorStateKey = editorStateBlock.getKey() - const editorStateText = editorStateContent.getPlainText() - const nextContentState = convertFromRaw(parsedContent) - const newContentState = mergeServerContent(editorState, nextContentState) - editorStateRef.current = EditorState.push(editorState, newContentState, 'insert-characters') - - const now = new Date() - const diff = lastFiredRef.current && now.getTime() - lastFiredRef.current.getTime() - - // prevent nasty infinite loop bug: https://sentry.io/organizations/parabol/issues/1641789488 - const minTime = 20 - if (diff && diff < minTime) { - if (!isErrorSentToSentryRef.current) { - const error = { - parsedContent, - editorStateText, - editorStateKey, - timeSinceLastRender: diff - } - Sentry.captureException( - new Error(`useEditorState fired in last ${minTime}ms. ${JSON.stringify(error)}`) - ) - isErrorSentToSentryRef.current = true - } - return - } - - lastFiredRef.current = now - setEditorState(editorStateRef.current) - }, [content]) - - return [editorState, setEditorState] as [EditorState, (editorState: EditorState) => void] -} - -export default useEditorState diff --git a/packages/client/hooks/useKeyboardShortcuts.tsx b/packages/client/hooks/useKeyboardShortcuts.tsx deleted file mode 100644 index 8393e534343..00000000000 --- a/packages/client/hooks/useKeyboardShortcuts.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import {DraftEditorCommand, EditorProps, EditorState, KeyBindingUtil, RichUtils} from 'draft-js' -import {SetEditorState} from '../types/draft' - -const {hasCommandModifier} = KeyBindingUtil - -type Handlers = Pick -const useKeyboardShortcuts = ( - editorState: EditorState, - setEditorState: SetEditorState, - {handleKeyCommand, keyBindingFn}: Handlers -) => { - const nextHandleKeyCommand: Handlers['handleKeyCommand'] = (command: DraftEditorCommand) => { - if (handleKeyCommand) { - const result = handleKeyCommand(command, editorState, Date.now()) - // @ts-ignore - if (result === 'handled' || result === true) { - return result - } - } - - if (command === 'strikethrough') { - setEditorState(RichUtils.toggleInlineStyle(editorState, 'STRIKETHROUGH')) - return 'handled' - } - - const newState = RichUtils.handleKeyCommand(editorState, command) - if (newState) { - setEditorState(newState) - return 'handled' - } - return 'not-handled' - } - - const nextKeyBindingFn: Handlers['keyBindingFn'] = (e) => { - if (keyBindingFn) { - const result = keyBindingFn(e) - if (result) { - return result - } - } - if (hasCommandModifier(e) && e.shiftKey && e.key === 'x') { - return 'strikethrough' - } - return null - } - - return { - handleKeyCommand: nextHandleKeyCommand, - keyBindingFn: nextKeyBindingFn - } -} - -export default useKeyboardShortcuts diff --git a/packages/client/hooks/useMarkdown.ts b/packages/client/hooks/useMarkdown.ts deleted file mode 100644 index b5f8db3455b..00000000000 --- a/packages/client/hooks/useMarkdown.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { - ContentBlock, - ContentState, - DraftEditorCommand, - EditorProps, - EditorState, - Modifier, - SelectionState -} from 'draft-js' -import {List, Map, OrderedSet} from 'immutable' -import {useRef} from 'react' -import getAnchorLocation from '../components/TaskEditor/getAnchorLocation' -import {SetEditorState} from '../types/draft' -import addSpace from '../utils/draftjs/addSpace' -import splitBlock from '../utils/draftjs/splitBlock' -import linkify from '../utils/linkify' - -const inlineMatchers = { - CODE: {regex: /`([^`]+)`/, matchIdx: 1}, - BOLD: {regex: /(\*\*|__)(.*?)\1/, matchIdx: 2}, - ITALIC: {regex: /([*_])(.*?)\1/, matchIdx: 2}, - STRIKETHROUGH: {regex: /(~+)([^~\s]+)\1/, matchIdx: 2} -} - -const blockQuoteRegex = /^(\s*>\s*)(.*)$/ -const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/ - -const CODE_FENCE = '```' - -const styles = Object.keys(inlineMatchers) as (keyof typeof inlineMatchers)[] - -const extractStyle = ( - editorState: EditorState, - getNextState: () => EditorState, - style: keyof typeof inlineMatchers, - blockKey: string, - extractedStyles: typeof styles -) => { - const {regex, matchIdx} = inlineMatchers[style] - const blockText = editorState.getCurrentContent().getBlockForKey(blockKey).getText() - const result = regex.exec(blockText) - if (result) { - const es = extractedStyles.length === 0 ? getNextState() : editorState - const contentState = es.getCurrentContent() - const selectionState = es.getSelection() - const beforePhrase = result[0]! - const afterPhrase = result[matchIdx] ?? '' - const selectionToReplace = selectionState.merge({ - anchorKey: blockKey, - focusKey: blockKey, - anchorOffset: result.index, - focusOffset: result.index + beforePhrase.length - }) as SelectionState - // style **`b`** not `**b**` - if (extractedStyles.indexOf('CODE') !== -1) { - const hasCode = contentState - .getBlockForKey(blockKey) - .getInlineStyleAt(result.index) - .has('CODE') - if (hasCode) return editorState - } - extractedStyles.push(style) - - const phraseShrink = beforePhrase.length - afterPhrase.length - // if it's a split block, then go to 0 - const nextCaret = - selectionState.getAnchorKey() === blockKey - ? selectionState.getFocusOffset() - phraseShrink - : 0 - const selectionAfter = selectionState.merge({ - anchorOffset: nextCaret, - focusOffset: nextCaret - }) - - const markdownedContent = Modifier.replaceText( - contentState, - selectionToReplace, - afterPhrase, - OrderedSet.of(...extractedStyles) - ).merge({ - selectionAfter - }) as ContentState - return EditorState.push(es, markdownedContent, 'change-inline-style') - } - return editorState -} - -const extractMarkdownStyles = ( - editorState: EditorState, - getNextState: () => EditorState, - blockKey: string -) => { - const extractedStyles = [] as any[] - let es = editorState - styles.forEach((style) => { - es = extractStyle(es, getNextState, style, blockKey, extractedStyles) - }) - if (es !== editorState) { - // squash the undo stack so hitting undo (or transitively backspace) undoes all the styling - const undoStack = es.getUndoStack() - return EditorState.set(es, { - undoStack: undoStack.slice(extractedStyles.length - 1), - currentContent: es.getCurrentContent().merge({ - selectionBefore: undoStack.get(extractedStyles.length - 1).getSelectionAfter() - }), - inlineStyleOverride: OrderedSet() - }) - } - return undefined -} - -type Handlers = Pick & { - onChange?: EditorProps['onChange'] -} -const useMarkdown = ( - editorState: EditorState, - setEditorState: SetEditorState, - {handleKeyCommand, keyBindingFn, handleBeforeInput, onChange}: Handlers -) => { - const undoMarkdownRef = useRef(false) - - const getMaybeCodeBlockState = (editorState: EditorState) => { - const contentState = editorState.getCurrentContent() - const selectionState = editorState.getSelection() - const currentBlockKey = selectionState.getAnchorKey() - const currentBlock = contentState.getBlockForKey(currentBlockKey) - const currentBlockText = currentBlock.getText() - if (!currentBlockText.startsWith(CODE_FENCE) || selectionState.getAnchorOffset() < 3) { - return undefined - } - const lastCodeBlock = contentState.getBlockBefore(currentBlockKey) - let cb = lastCodeBlock - while (cb) { - if (cb.getText().startsWith(CODE_FENCE)) { - break - } - cb = contentState.getBlockBefore(cb.getKey()) - } - if (!cb || !lastCodeBlock) return undefined - const blockMap = contentState.getBlockMap() - const updatedLastline = blockMap.get(currentBlockKey).merge({ - text: '', - characterList: List(), - data: Map() - }) as ContentBlock - const contentStateWithoutFences = contentState.merge({ - blockMap: blockMap.set(currentBlockKey, updatedLastline).delete(cb.getKey()) as ContentState - }) as ContentState - const firstCodeBlock = contentState.getBlockAfter(cb.getKey())! - const selectedCode = selectionState.merge({ - anchorOffset: 0, - focusOffset: lastCodeBlock.getLength(), - anchorKey: firstCodeBlock.getKey(), - focusKey: lastCodeBlock.getKey() - }) as SelectionState - - const styledContent = Modifier.setBlockType( - contentStateWithoutFences, - selectedCode, - 'code-block' - ).merge({ - selectionAfter: selectionState.merge({ - anchorOffset: 0, - focusOffset: 0 - }) - }) as ContentState - return EditorState.push(editorState, styledContent, 'change-block-type') - } - - const getMaybeMarkdownState = (getNextState: () => EditorState, editorState: EditorState) => { - undoMarkdownRef.current = undefined - const {block, anchorOffset} = getAnchorLocation(editorState) - const blockKey = block.getKey() - const entityKey = block.getEntityAt(anchorOffset - 1) - if (!entityKey) { - const result = extractMarkdownStyles(editorState, getNextState, blockKey) - if (result) { - undoMarkdownRef.current = true - return result - } - } - return getMaybeCodeBlockState(editorState) - } - - const getMaybeBlockquote = (editorState: EditorState, command: DraftEditorCommand | 'space') => { - const initialContentState = editorState.getCurrentContent() - const initialSelectionState = editorState.getSelection() - const currentBlockKey = initialSelectionState.getAnchorKey() - const currentBlock = initialContentState.getBlockForKey(currentBlockKey) - const currentBlockText = currentBlock.getText() - const matchedBlockQuote = blockQuoteRegex.exec(currentBlockText) - if (!matchedBlockQuote) return undefined - // now that we're doing something, let's spend the cycles and manually exec the command - const addWhiteSpace = command === 'split-block' ? splitBlock : addSpace - const preSplitES = addWhiteSpace(editorState) - const startingEditorState = EditorState.set(preSplitES, { - selection: editorState.getSelection() - // currentContent: preSplitES.getCurrentContent().merge({ - // selectionAfter: editorState.getSelection() - // }) - }) - const contentState = startingEditorState.getCurrentContent() - const selectionState = startingEditorState.getSelection() - const triggerPhrase = matchedBlockQuote[1] - const selectionToRemove = selectionState.merge({ - anchorOffset: 0, - focusOffset: triggerPhrase?.length - }) as SelectionState - const contentWithoutTrigger = Modifier.removeRange(contentState, selectionToRemove, 'forward') - const fullBlockSelection = selectionToRemove.merge({ - focusOffset: currentBlock.getLength() - }) as SelectionState - const styledContent = Modifier.setBlockType( - contentWithoutTrigger, - fullBlockSelection, - 'blockquote' - ).merge({ - selectionAfter: fullBlockSelection.merge({ - anchorOffset: fullBlockSelection.getFocusOffset() - }) - }) as ContentState - return EditorState.push(startingEditorState, styledContent, 'change-block-type') - } - - const getMaybeLink = (editorState: EditorState, command: DraftEditorCommand | 'space') => { - const initialContentState = editorState.getCurrentContent() - const selectionState = editorState.getSelection() - const currentBlockKey = selectionState.getAnchorKey() - const currentBlock = initialContentState.getBlockForKey(currentBlockKey) - const textToLeft = currentBlock.getText().slice(0, selectionState.getAnchorOffset()) - const matchedLink = linkRegex.exec(textToLeft) - if (!matchedLink) return undefined - // now that we're doing something, let's spend the cycles and manually exec the command - const [phrase, text, link] = matchedLink as string[] as [string, string, string] - const addWhiteSpace = command === 'split-block' ? splitBlock : addSpace - const preSplitES = addWhiteSpace(editorState) - const contentState = preSplitES.getCurrentContent() - const selectionToRemove = selectionState.merge({ - anchorOffset: matchedLink.index, - focusOffset: matchedLink.index + phrase.length - }) as SelectionState - const href = linkify.match(link)![0]!.url - const contentStateWithEntity = contentState.createEntity('LINK', 'MUTABLE', {href}) - const entityKey = contentStateWithEntity.getLastCreatedEntityKey() - const linkifiedContent = Modifier.replaceText( - contentState, - selectionToRemove, - text, - undefined, - entityKey - ) - - const selectionAfter = - command === 'split-block' - ? preSplitES.getSelection() - : linkifiedContent.getSelectionAfter().merge({ - anchorOffset: linkifiedContent.getSelectionAfter().getAnchorOffset() + 1, - focusOffset: linkifiedContent.getSelectionAfter().getAnchorOffset() + 1 - }) - const adjustedSelectionContent = linkifiedContent.merge({ - selectionAfter, - selectionBefore: selectionAfter - }) as ContentState - undoMarkdownRef.current = true - return EditorState.push(preSplitES, adjustedSelectionContent, 'apply-entity') - } - - const nextHandleKeyCommand: EditorProps['handleKeyCommand'] = (command: DraftEditorCommand) => { - if (handleKeyCommand) { - // @ts-ignore - const result = handleKeyCommand(command) - // @ts-ignore - if (result === 'handled' || result === true) { - return result - } - } - if (command === 'split-block') { - const getNextState = () => splitBlock(editorState) - const updatedEditorState = - getMaybeMarkdownState(getNextState, editorState) || - getMaybeBlockquote(editorState, command) || - getMaybeLink(editorState, command) - if (updatedEditorState) { - setEditorState(updatedEditorState) - return 'handled' - } - } - - if (command === 'backspace' && undoMarkdownRef.current) { - setEditorState(EditorState.undo(editorState)) - undoMarkdownRef.current = undefined - return 'handled' - } - return 'not-handled' - } - - const nextKeyBindingFn: EditorProps['keyBindingFn'] = (e) => { - if (keyBindingFn) { - const result = keyBindingFn(e) - if (result) { - return result - } - } - return null - } - - const nextHandleBeforeInput: EditorProps['handleBeforeInput'] = (char) => { - if (handleBeforeInput) { - // @ts-ignore - const result = handleBeforeInput(char) - // @ts-ignore - if (result === 'handled' || result === true) { - return result - } - } - if (char === ' ') { - const getNextState = () => addSpace(editorState) - const updatedEditorState = - getMaybeMarkdownState(getNextState, editorState) || - getMaybeBlockquote(editorState, 'space') || - getMaybeLink(editorState, 'space') - if (updatedEditorState) { - setEditorState(updatedEditorState) - return 'handled' - } - } - return 'not-handled' - } - - const nextOnChange: EditorProps['onChange'] = (editorState: EditorState) => { - if (onChange) { - onChange(editorState) - } - undoMarkdownRef.current = undefined - } - - return { - onChange: nextOnChange, - keyBindingFn: nextKeyBindingFn, - handleBeforeInput: nextHandleBeforeInput, - handleKeyCommand: nextHandleKeyCommand - } -} - -export default useMarkdown diff --git a/packages/client/hooks/useTipTapEditorContent.ts b/packages/client/hooks/useTipTapEditorContent.ts index 7560304f9d6..f5f5cca9b20 100644 --- a/packages/client/hooks/useTipTapEditorContent.ts +++ b/packages/client/hooks/useTipTapEditorContent.ts @@ -9,7 +9,6 @@ export const useTipTapEditorContent = (content: string) => { // Unnecessary re-renders mess up things like the coordinates of the link menu const contentJSON = useMemo(() => { const newContentJSON = JSON.parse(content) as JSONContent - // use HTML because text won't include data that we don't see (e.g. mentions) and JSON key order is non-deterministic >:-( const oldContentJSON = editorRef.current ? editorRef.current.getJSON() : {} if (!isEqualWhenSerialized(newContentJSON, oldContentJSON)) { contentJSONRef.current = newContentJSON diff --git a/packages/client/hooks/useTipTapReflectionEditor.ts b/packages/client/hooks/useTipTapReflectionEditor.ts new file mode 100644 index 00000000000..cdad9e9ba5c --- /dev/null +++ b/packages/client/hooks/useTipTapReflectionEditor.ts @@ -0,0 +1,99 @@ +import {SearchAndReplace} from '@sereneinserenade/tiptap-search-and-replace' +import Mention from '@tiptap/extension-mention' +import Placeholder from '@tiptap/extension-placeholder' +import {Extension, generateText, useEditor} from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import {useEffect, useRef, useState} from 'react' +import Atmosphere from '../Atmosphere' +import {LoomExtension} from '../components/promptResponse/loomExtension' +import {TiptapLinkExtension} from '../components/promptResponse/TiptapLinkExtension' +import {LinkMenuState} from '../components/promptResponse/TipTapLinkMenu' +import {isEqualWhenSerialized} from '../shared/isEqualWhenSerialized' +import {mentionConfig, serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions' +import {BlurOnSubmit} from '../utils/tiptap/BlurOnSubmit' +import {tiptapEmojiConfig} from '../utils/tiptapEmojiConfig' +import {tiptapMentionConfig} from '../utils/tiptapMentionConfig' +import {tiptapTagConfig} from '../utils/tiptapTagConfig' + +const isValid = (obj: T | undefined | null | boolean): obj is T => { + return !!obj +} + +export const useTipTapReflectionEditor = ( + content: string, + options: { + atmosphere?: Atmosphere + teamId?: string + readOnly?: boolean + placeholder?: string + onEnter?: () => void + // onEscape?: () => void + } +) => { + const {atmosphere, teamId, readOnly, placeholder, onEnter} = options + const [linkState, setLinkState] = useState(null) + const [contentJSON] = useState(() => JSON.parse(content)) + const placeholderRef = useRef(placeholder) + placeholderRef.current = placeholder + const editor = useEditor( + { + content: contentJSON, + extensions: [ + StarterKit, + LoomExtension, + Placeholder.configure({ + showOnlyWhenEditable: false, + placeholder: () => { + return placeholderRef.current || '*New Reflection*' + } + }), + Mention.extend({name: 'taskTag'}).configure(tiptapTagConfig), + Mention.configure( + atmosphere && teamId ? tiptapMentionConfig(atmosphere, teamId) : mentionConfig + ), + Mention.extend({name: 'emojiMention'}).configure(tiptapEmojiConfig), + TiptapLinkExtension.configure({ + openOnClick: false, + popover: { + setLinkState + } + }), + SearchAndReplace.configure(), + onEnter && + Extension.create({ + name: 'commentKeyboardShortcuts', + addKeyboardShortcuts(this) { + return { + Enter: () => { + onEnter() + return true + } + // Escape: () => { + // onEscape() + // return true + // } + } + } + }), + !onEnter && BlurOnSubmit + ].filter(isValid), + autofocus: generateText(contentJSON, serverTipTapExtensions).length === 0, + editable: !readOnly + }, + [] + ) + useEffect(() => { + if (!editor) return + const oldDoc = editor.getJSON() + const newDoc = JSON.parse(content) + if (isEqualWhenSerialized(oldDoc, newDoc)) return + editor.commands.setContent(newDoc) + }, [content]) + + useEffect(() => { + if (!editor) return + editor.setEditable(!readOnly) + }, [readOnly]) + + return {editor, linkState, setLinkState} +} diff --git a/packages/client/modules/demo/ClientGraphQLServer.ts b/packages/client/modules/demo/ClientGraphQLServer.ts index 9a3810b6839..1135f132712 100644 --- a/packages/client/modules/demo/ClientGraphQLServer.ts +++ b/packages/client/modules/demo/ClientGraphQLServer.ts @@ -1,4 +1,4 @@ -import {generateHTML, generateJSON, generateText} from '@tiptap/core' +import {generateHTML, generateJSON, generateText, type JSONContent} from '@tiptap/core' import EventEmitter from 'eventemitter3' import {parse, stringify} from 'flatted' import ms from 'ms' @@ -28,14 +28,12 @@ import { } from '../../types/constEnums' import {DISCUSS, GROUP, REFLECT, VOTE} from '../../utils/constants' import dndNoise from '../../utils/dndNoise' -import extractTextFromDraftString from '../../utils/draftjs/extractTextFromDraftString' import findStageById from '../../utils/meetings/findStageById' import sleep from '../../utils/sleep' import getGroupSmartTitle from '../../utils/smartGroup/getGroupSmartTitle' import startStage_ from '../../utils/startStage_' import unlockAllStagesForPhase from '../../utils/unlockAllStagesForPhase' import unlockNextStages from '../../utils/unlockNextStages' -import normalizeRawDraftJS from '../../validation/normalizeRawDraftJS' import LocalAtmosphere from './LocalAtmosphere' import entityLookup from './entityLookup' import getDemoEntities from './getDemoEntities' @@ -551,8 +549,8 @@ class ClientGraphQLServer extends (EventEmitter as GQLDemoEmitter) { const prompt = reflectPhase.reflectPrompts.find((prompt) => prompt.id === promptId) const reflectionGroupId = groupId || this.getTempId('refGroup') const reflectionId = id || this.getTempId('ref') - const normalizedContent = normalizeRawDraftJS(content) - const plaintextContent = extractTextFromDraftString(normalizedContent) + const normalizedContent = JSON.parse(content) as JSONContent + const plaintextContent = generateText(normalizedContent, serverTipTapExtensions) let entities = [] as GoogleAnalyzedEntity[] if (userId !== demoViewerId) { entities = entityLookup[reflectionId as keyof typeof entityLookup].entities @@ -567,7 +565,8 @@ class ClientGraphQLServer extends (EventEmitter as GQLDemoEmitter) { reflectionId, createdAt: now, creatorId: userId, - content: normalizedContent, + creator: this.db.users.find((user) => user.id === userId), + content: JSON.stringify(normalizedContent), groupColor: PALETTE.JADE_400, plaintextContent, dragContext: null, @@ -742,7 +741,7 @@ class ClientGraphQLServer extends (EventEmitter as GQLDemoEmitter) { const reflection = this.db.reflections.find((reflection) => reflection.id === reflectionId)! reflection.content = content reflection.updatedAt = new Date().toJSON() - const plaintextContent = extractTextFromDraftString(content) + const plaintextContent = generateText(JSON.parse(content), serverTipTapExtensions) const isVeryDifferent = stringSimilarity.compareTwoStrings(plaintextContent, reflection.plaintextContent) < 0.9 const entities = isVeryDifferent diff --git a/packages/client/modules/demo/handleCompletedDemoStage.ts b/packages/client/modules/demo/handleCompletedDemoStage.ts index 896f2a4ded9..f905a36289c 100644 --- a/packages/client/modules/demo/handleCompletedDemoStage.ts +++ b/packages/client/modules/demo/handleCompletedDemoStage.ts @@ -1,6 +1,5 @@ import {ReactableEnum} from '~/__generated__/AddReactjiToReactableMutation.graphql' import {ACTIVE, GROUP, REFLECT, VOTE} from '../../utils/constants' -import extractTextFromDraftString from '../../utils/draftjs/extractTextFromDraftString' import mapGroupsToStages from '../../utils/makeGroupsToStages' import clientTempId from '../../utils/relay/clientTempId' import DemoDiscussStage from './DemoDiscussStage' @@ -14,7 +13,7 @@ const removeEmptyReflections = (db: RetroDemoDB) => { const emptyReflectionGroupIds: string[] = [] const emptyReflectionIds: string[] = [] reflections.forEach((reflection) => { - const text = extractTextFromDraftString(reflection.content) + const text = reflection.plaintextContent if (text.length === 0) { emptyReflectionGroupIds.push(reflection.reflectionGroupId) emptyReflectionIds.push(reflection.id) diff --git a/packages/client/modules/demo/initBotScript.ts b/packages/client/modules/demo/initBotScript.ts index 151cc3524a2..a70aaf9d512 100644 --- a/packages/client/modules/demo/initBotScript.ts +++ b/packages/client/modules/demo/initBotScript.ts @@ -1,5 +1,7 @@ +import {generateJSON} from '@tiptap/core' import {DragReflectionDropTargetTypeEnum} from '~/__generated__/EndDraggingReflectionMutation.graphql' import {RetroDemo} from '~/types/constEnums' +import {serverTipTapExtensions} from '../../shared/tiptap/serverTipTapExtensions' import {demoTeamId} from './initDB' // 3 -> 1 @@ -44,7 +46,12 @@ const initBotScript = () => { input: { id: 'botRef1', groupId: 'botGroup1', - content: `{"blocks":[{"key":"2t965","text":"I'd like to give our interns and junior staff more space to share their ideas & fresh thinking","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}`, + content: JSON.stringify( + generateJSON( + `

I'd like to give our interns and junior staff more space to share their ideas & fresh thinking

`, + serverTipTapExtensions + ) + ), promptId: 'startId', sortOrder: 0 } @@ -58,7 +65,9 @@ const initBotScript = () => { input: { id: 'botRef2', groupId: 'botGroup2', - content: `{"blocks":[{"key":"2t966","text":"Writing down our processes","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}`, + content: JSON.stringify( + generateJSON(`

Writing down our processes

`, serverTipTapExtensions) + ), promptId: 'startId', sortOrder: 0 } @@ -90,7 +99,12 @@ const initBotScript = () => { input: { id: 'botRef3', groupId: 'botGroup3', - content: `{"blocks":[{"key":"2t967","text":"Some people always take all the air time. It's hard to get my ideas on the floor","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}`, + content: JSON.stringify( + generateJSON( + `

Some people always take all the air time. It's hard to get my ideas on the floor

`, + serverTipTapExtensions + ) + ), promptId: 'stopId', sortOrder: 1 } @@ -122,7 +136,9 @@ const initBotScript = () => { input: { id: 'botRef4', groupId: 'botGroup4', - content: `{"blocks":[{"key":"2t968","text":"Making important decisions in chat","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}`, + content: JSON.stringify( + generateJSON(`

Making important decisions in chat

`, serverTipTapExtensions) + ), promptId: 'stopId', sortOrder: 1 } @@ -136,7 +152,12 @@ const initBotScript = () => { input: { id: 'botRef5', groupId: 'botGroup5', - content: `{"blocks":[{"key":"2t969","text":"Having debates that go nowhere over group chat","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}`, + content: JSON.stringify( + generateJSON( + `

Having debates that go nowhere over group chat

`, + serverTipTapExtensions + ) + ), promptId: 'stopId', sortOrder: 2 } @@ -150,7 +171,9 @@ const initBotScript = () => { input: { id: 'botRef6', groupId: 'botGroup6', - content: `{"blocks":[{"key":"2t970","text":"Having so many meetings","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}`, + content: JSON.stringify( + generateJSON(`

Having so many meetings

`, serverTipTapExtensions) + ), promptId: 'stopId', sortOrder: 2 } @@ -164,7 +187,12 @@ const initBotScript = () => { input: { id: 'botRef7', groupId: 'botGroup7', - content: `{"blocks":[{"key":"2t971","text":" Prioritizing so much work every sprint, we can't get it all done!","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}`, + content: JSON.stringify( + generateJSON( + `

Prioritizing so much work every sprint, we can't get it all done!

`, + serverTipTapExtensions + ) + ), promptId: 'stopId', sortOrder: 2 } @@ -214,7 +242,9 @@ const initBotScript = () => { input: { id: 'botRef8', groupId: 'botGroup8', - content: `{"blocks":[{"key":"2t971","text":"Team retreats every quarter","type":"unstyled","depth":0,"inlineStyleRanges":[],"entityRanges":[],"data":{}}],"entityMap":{}}`, + content: JSON.stringify( + generateJSON(`

Team retreats every quarter

`, serverTipTapExtensions) + ), promptId: 'continueId', sortOrder: 3 } diff --git a/packages/client/modules/demo/initDB.ts b/packages/client/modules/demo/initDB.ts index fd825dcb911..6bffa8f4a3e 100644 --- a/packages/client/modules/demo/initDB.ts +++ b/packages/client/modules/demo/initDB.ts @@ -297,7 +297,12 @@ const initDemoOrg = () => { activeUserCount: 5, inactiveUserCount: 0 }, - showConversionModal: false + hasSuggestGroupsFlag: false, + hasZoomFlag: false, + tierLimitExceededAt: null, + showConversionModal: false, + useAI: true + // viewerOrganizationUser } as const } @@ -500,6 +505,7 @@ const initNewMeeting = ( createdAt: now, createdBy: demoViewerId, createdByUser: viewerMeetingMember?.user, + disableAnonymity: false, endedAt: null, facilitatorStageId: RetroDemo.REFLECT_STAGE_ID, facilitatorUserId: demoViewerId, @@ -523,7 +529,10 @@ const initNewMeeting = ( summary: `The team are feeling the strain of too many meetings and over-packed sprints, which is stifling creativity, especially for the interns and junior staff. Clarifying processes, reducing unproductive group chats, and giving everyone more space to share ideas should help.`, totalVotes: MeetingSettingsThreshold.RETROSPECTIVE_TOTAL_VOTES_DEFAULT, maxVotesPerGroup: MeetingSettingsThreshold.RETROSPECTIVE_MAX_VOTES_PER_GROUP_DEFAULT, - teamId: demoTeamId + teamId: demoTeamId, + videoMeetingURL: null, + transcription: null, + locked: false } as Partial } diff --git a/packages/client/modules/email/components/SummaryEmail/ExportToCSV.tsx b/packages/client/modules/email/components/SummaryEmail/ExportToCSV.tsx index 4da7ed8eff3..d519adb6d72 100644 --- a/packages/client/modules/email/components/SummaryEmail/ExportToCSV.tsx +++ b/packages/client/modules/email/components/SummaryEmail/ExportToCSV.tsx @@ -4,7 +4,6 @@ import type {Parser as JSON2CSVParser} from 'json2csv' import Parser from 'json2csv/lib/JSON2CSVParser' // only grab the sync parser import {ExportToCSVQuery} from 'parabol-client/__generated__/ExportToCSVQuery.graphql' import {PALETTE} from 'parabol-client/styles/paletteV3' -import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' import withMutationProps, {WithMutationProps} from 'parabol-client/utils/relay/withMutationProps' import {useEffect} from 'react' import useAtmosphere from '~/hooks/useAtmosphere' @@ -223,6 +222,7 @@ const ExportToCSV = (props: Props) => { const {prompt, content} = reflection const createdAt = reflection.createdAt! const {question} = prompt + const contentJSON = JSON.parse(content!) rows.push({ reflectionGroup: title!, author: 'Anonymous', @@ -231,7 +231,7 @@ const ExportToCSV = (props: Props) => { createdAt, discussionThread: '', prompt: question, - content: extractTextFromDraftString(content) + content: generateText(contentJSON, serverTipTapExtensions) }) }) }) @@ -261,6 +261,7 @@ const ExportToCSV = (props: Props) => { replies.forEach((reply) => { const {createdAt, createdByUser} = reply const author = createdByUser?.preferredName ?? 'Anonymous' + const contentJSON = JSON.parse(reply.content!) rows.push({ reflectionGroup: title!, author, @@ -269,7 +270,7 @@ const ExportToCSV = (props: Props) => { createdAt, discussionThread, prompt: '', - content: extractTextFromDraftString(reply.content!) + content: generateText(contentJSON, serverTipTapExtensions) }) }) }) @@ -292,7 +293,7 @@ const ExportToCSV = (props: Props) => { const {node} = edge const {createdAt, createdByUser, __typename: type, replies, content} = node const author = createdByUser?.preferredName ?? 'Anonymous' - const discussionThread = extractTextFromDraftString(content!) + const discussionThread = generateText(JSON.parse(content!), serverTipTapExtensions) rows.push({ author, status: 'present', @@ -312,7 +313,7 @@ const ExportToCSV = (props: Props) => { type: reply.__typename === 'Task' ? 'Task' : 'Reply', createdAt, discussionThread, - content: extractTextFromDraftString(reply.content!) + content: generateText(JSON.parse(reply.content!), serverTipTapExtensions) }) }) }) diff --git a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/EmailReflectionCard.tsx b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/EmailReflectionCard.tsx index ee5d99fa6f6..b41a90c6ea4 100644 --- a/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/EmailReflectionCard.tsx +++ b/packages/client/modules/email/components/SummaryEmail/MeetingSummaryEmail/EmailReflectionCard.tsx @@ -1,12 +1,11 @@ +import {generateHTML} from '@tiptap/html' import graphql from 'babel-plugin-relay/macro' -import {convertFromRaw, Editor, EditorState} from 'draft-js' import {EmailReflectionCard_reflection$key} from 'parabol-client/__generated__/EmailReflectionCard_reflection.graphql' -import editorDecorators from 'parabol-client/components/TaskEditor/decorators' import {PALETTE} from 'parabol-client/styles/paletteV3' import {FONT_FAMILY} from 'parabol-client/styles/typographyV2' import * as React from 'react' -import {useMemo, useRef} from 'react' import {useFragment} from 'react-relay' +import {serverTipTapExtensions} from '../../../../../shared/tiptap/serverTipTapExtensions' interface Props { reflection: EmailReflectionCard_reflection$key @@ -56,15 +55,7 @@ const EmailReflectionCard = (props: Props) => { ) const {content, prompt} = reflection const {question} = prompt - const contentState = useMemo(() => convertFromRaw(JSON.parse(content)), [content]) - const editorStateRef = useRef() - const getEditorState = () => { - return editorStateRef.current - } - editorStateRef.current = EditorState.createWithContent( - contentState, - editorDecorators(getEditorState) - ) + const htmlContent = generateHTML(JSON.parse(content), serverTipTapExtensions) return ( @@ -76,13 +67,7 @@ const EmailReflectionCard = (props: Props) => { - { - /**/ - }} - /> +
diff --git a/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx b/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx index 84b5211d648..34d2702f5d2 100644 --- a/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx +++ b/packages/client/modules/meeting/components/MeetingCheckInPrompt/NewCheckInQuestion.tsx @@ -10,7 +10,6 @@ import { useModifyCheckInQuestionMutation$data as TModifyCheckInQuestion$data } from '../../../../__generated__/useModifyCheckInQuestionMutation.graphql' import PlainButton from '../../../../components/PlainButton/PlainButton' -import '../../../../components/TaskEditor/Draft.css' import useAtmosphere from '../../../../hooks/useAtmosphere' import useMutationProps from '../../../../hooks/useMutationProps' import {useTipTapIcebreakerEditor} from '../../../../hooks/useTipTapIcebreakerEditor' diff --git a/packages/client/mutations/CreateReflectionMutation.ts b/packages/client/mutations/CreateReflectionMutation.ts index a341f69653b..91c4c90e4f6 100644 --- a/packages/client/mutations/CreateReflectionMutation.ts +++ b/packages/client/mutations/CreateReflectionMutation.ts @@ -2,12 +2,13 @@ * Creates a reflection for the retrospective meeting. * */ +import {generateJSON} from '@tiptap/core' import graphql from 'babel-plugin-relay/macro' import {commitMutation} from 'react-relay' import {CreateReflectionMutation_meeting$data} from '~/__generated__/CreateReflectionMutation_meeting.graphql' import {CreateReflectionMutation as TCreateReflectionMutation} from '../__generated__/CreateReflectionMutation.graphql' +import {serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions' import {SharedUpdater, StandardMutation} from '../types/relayMutations' -import makeEmptyStr from '../utils/draftjs/makeEmptyStr' import clientTempId from '../utils/relay/clientTempId' import createProxyRecord from '../utils/relay/createProxyRecord' import handleAddReflectionGroups from './handlers/handleAddReflectionGroups' @@ -73,7 +74,7 @@ const CreateReflectionMutation: StandardMutation = ( const nowISO = new Date().toJSON() const optimisticReflection = { id: clientTempId(), - content: input.content || makeEmptyStr(), + content: input.content || JSON.stringify(generateJSON('

', serverTipTapExtensions)), createdAt: nowISO, creatorId: viewerId, isActive: true, diff --git a/packages/client/mutations/UpdateReflectionContentMutation.ts b/packages/client/mutations/UpdateReflectionContentMutation.ts index 6905ae3ca67..cff42ca99b4 100644 --- a/packages/client/mutations/UpdateReflectionContentMutation.ts +++ b/packages/client/mutations/UpdateReflectionContentMutation.ts @@ -6,7 +6,6 @@ import graphql from 'babel-plugin-relay/macro' import {commitMutation} from 'react-relay' import {UpdateReflectionContentMutation as TUpdateReflectionContentMutation} from '../__generated__/UpdateReflectionContentMutation.graphql' import {StandardMutation} from '../types/relayMutations' -import updateProxyRecord from '../utils/relay/updateProxyRecord' graphql` fragment UpdateReflectionContentMutation_meeting on UpdateReflectionContentPayload { @@ -43,17 +42,7 @@ const UpdateReflectionContentMutation: StandardMutation { - const {reflectionId, content} = variables - const reflectionProxy = store.get(reflectionId)! - const nowISO = new Date().toJSON() - const optimisticReflection = { - content, - updatedAt: nowISO - } - updateProxyRecord(reflectionProxy, optimisticReflection) - } + onError }) } diff --git a/packages/client/package.json b/packages/client/package.json index 11da44633d0..77b3969c078 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -87,6 +87,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.3", "@sentry/browser": "^5.8.0", + "@sereneinserenade/tiptap-search-and-replace": "^0.1.1", "@stripe/react-stripe-js": "^1.16.5", "@stripe/stripe-js": "^4.0.0", "@tiptap/core": "^2.9.1", diff --git a/packages/client/styles/theme/global.css b/packages/client/styles/theme/global.css index 0d36add3f84..89ff8807ec4 100644 --- a/packages/client/styles/theme/global.css +++ b/packages/client/styles/theme/global.css @@ -161,15 +161,6 @@ --rdp-background-color-dark: theme(colors.grape.500 / 30%); } -/** Customize draft-js */ -.draft-blockquote { - @apply mx-0 my-[8px] border-l-2 border-solid border-l-slate-500 px-[8px] py-0 italic; -} - -.draft-codeblock { - @apply m-0 rounded-[1px] border-l-2 border-solid border-l-slate-500 bg-slate-200 px-[8px] py-0 font-mono font-[13px] leading-normal; -} - .link-style a { @apply text-sky-500; text-decoration: underline; @@ -177,6 +168,14 @@ /** Customize TipTap */ +.ProseMirror { + width: 100%; +} + +.ProseMirror .search-result { + background-color: rgba(255, 217, 0, 0.5); +} + .ProseMirror :is(ul, ol) { list-style-position: outside; padding-inline-start: 16px; diff --git a/packages/client/types/draft.d.ts b/packages/client/types/draft.d.ts deleted file mode 100644 index 02fb0aeffd7..00000000000 --- a/packages/client/types/draft.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {EditorState} from 'draft-js' - -export type SetEditorState = (editorState: EditorState) => void diff --git a/packages/client/utils/draftjs/addSpace.ts b/packages/client/utils/draftjs/addSpace.ts deleted file mode 100644 index e5d897b12ba..00000000000 --- a/packages/client/utils/draftjs/addSpace.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {EditorState, Modifier} from 'draft-js' - -const addSpace = (editorState: EditorState) => { - const contentState = Modifier.insertText( - editorState.getCurrentContent(), - editorState.getSelection(), - ' ' - ) - return EditorState.push(editorState, contentState, 'insert-characters') -} - -export default addSpace diff --git a/packages/client/utils/draftjs/completeEntity.tsx b/packages/client/utils/draftjs/completeEntity.tsx deleted file mode 100644 index 6ef17a2560e..00000000000 --- a/packages/client/utils/draftjs/completeEntity.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import {ContentState, DraftEntityMutability, EditorState, Modifier, SelectionState} from 'draft-js' -import getAnchorLocation from '../../components/TaskEditor/getAnchorLocation' -import getWordAt from '../../components/TaskEditor/getWordAt' - -type ENTITY_NAME = 'EMOJI' | 'TAG' | 'LINK' | 'MENTION' - -const OPERATION_TYPES: Record< - ENTITY_NAME, - {editorChangeType: 'apply-entity'; entityType: DraftEntityMutability} -> = { - EMOJI: { - editorChangeType: 'apply-entity', - entityType: 'IMMUTABLE' - }, - TAG: { - editorChangeType: 'apply-entity', - entityType: 'IMMUTABLE' - }, - LINK: { - editorChangeType: 'apply-entity', - entityType: 'MUTABLE' - }, - MENTION: { - editorChangeType: 'apply-entity', - entityType: 'SEGMENTED' - } -} - -const getExpandedSelectionState = (editorState: EditorState) => { - const selectionState = editorState.getSelection() - if (selectionState.isCollapsed()) { - const {block, anchorOffset} = getAnchorLocation(editorState) - const {begin, end} = getWordAt(block.getText(), anchorOffset - 1) - return selectionState.merge({ - anchorOffset: begin, - focusOffset: end - }) as SelectionState - } - return selectionState as SelectionState -} - -export const makeContentWithEntity = ( - contentState: ContentState, - selectionState: SelectionState, - mention: string, - entityKey: string -) => { - if (!mention) { - // anchorKey && focusKey should be different here (used for EditorLinkChanger) - return Modifier.applyEntity(contentState, selectionState, entityKey) - } - return Modifier.replaceText(contentState, selectionState, mention, undefined, entityKey) -} - -export const autoCompleteEmoji = (editorState: EditorState, emoji: string) => { - const contentState = editorState.getCurrentContent() - const expandedSelectionState = getExpandedSelectionState(editorState) - - const nextContentState = Modifier.replaceText(contentState, expandedSelectionState, emoji) - const endKey = nextContentState.getSelectionAfter().getEndKey() - const endOffset = nextContentState.getSelectionAfter().getEndOffset() - const collapsedSelectionState = expandedSelectionState.merge({ - anchorKey: endKey, - anchorOffset: endOffset, - focusKey: endKey, - focusOffset: endOffset - }) - const finalContent = nextContentState.merge({ - selectionAfter: collapsedSelectionState - // selectionBefore: collapsedSelectionState, - }) as ContentState - return EditorState.push(editorState, finalContent, 'remove-characters' as any) -} - -interface Options { - keepSelection?: boolean -} -const completeEntity = ( - editorState: EditorState, - entityName: ENTITY_NAME, - entityData: any, - mention: string, - options: Options = {} -) => { - const {keepSelection} = options - const {editorChangeType, entityType} = OPERATION_TYPES[entityName] - const contentState = editorState.getCurrentContent() - const contentStateWithEntity = contentState.createEntity(entityName, entityType, entityData) - const entityKey = contentStateWithEntity.getLastCreatedEntityKey() - const expandedSelectionState = keepSelection - ? editorState.getSelection() - : getExpandedSelectionState(editorState) - const contentWithEntity = makeContentWithEntity( - contentState, - expandedSelectionState, - mention, - entityKey - ) - const endKey = contentWithEntity.getSelectionAfter().getEndKey() - const endOffset = contentWithEntity.getSelectionAfter().getEndOffset() - const collapsedSelectionState = expandedSelectionState.merge({ - anchorKey: endKey, - anchorOffset: endOffset, - focusKey: endKey, - focusOffset: endOffset - }) - const finalContent = contentWithEntity.merge({ - selectionAfter: collapsedSelectionState - }) as ContentState - return EditorState.push(editorState, finalContent, editorChangeType) -} - -export default completeEntity diff --git a/packages/client/utils/draftjs/convertToTaskContent.ts b/packages/client/utils/draftjs/convertToTaskContent.ts deleted file mode 100644 index a5b5ff70a55..00000000000 --- a/packages/client/utils/draftjs/convertToTaskContent.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {ContentState, convertToRaw} from 'draft-js' -import entitizeText from './entitizeText' - -export const removeSpaces = (str: string) => - str - .split(/\s/) - .filter((s) => s.length) - .join(' ') - -export function convertStateToRaw(contentState: ContentState) { - const selectionState = contentState.getSelectionAfter().merge({ - anchorKey: contentState.getFirstBlock().getKey(), - focusKey: contentState.getLastBlock().getKey(), - anchorOffset: 0, - focusOffset: contentState.getLastBlock().getLength() - }) - - const nextContentState = entitizeText(contentState, selectionState) || contentState - const raw = convertToRaw(nextContentState) - return JSON.stringify(raw) -} - -const convertToTaskContent = (spacedText: string) => { - const text = removeSpaces(spacedText) - const contentState = ContentState.createFromText(text) - return convertStateToRaw(contentState) -} - -export default convertToTaskContent diff --git a/packages/client/utils/draftjs/dontTellDraft.ts b/packages/client/utils/draftjs/dontTellDraft.ts deleted file mode 100644 index bdd3456610a..00000000000 --- a/packages/client/utils/draftjs/dontTellDraft.ts +++ /dev/null @@ -1,6 +0,0 @@ -const dontTellDraft = (e: React.MouseEvent) => { - e.preventDefault() - // e.stopPropagation(); -} - -export default dontTellDraft diff --git a/packages/client/utils/draftjs/entitizeText.ts b/packages/client/utils/draftjs/entitizeText.ts deleted file mode 100644 index e8094b4f8eb..00000000000 --- a/packages/client/utils/draftjs/entitizeText.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {ContentState, Modifier, SelectionState} from 'draft-js' -import {textTags} from '../constants' - -const entitizeText = (contentState: ContentState, selectionState: SelectionState) => { - const anchorOffset = selectionState.getAnchorOffset() - const anchorKey = selectionState.getAnchorKey() - const focusOffset = selectionState.getFocusOffset() - const focusKey = selectionState.getFocusKey() - let currentKey = anchorKey - let cs = contentState - while (currentKey) { - const currentBlock = contentState.getBlockForKey(currentKey) - const currentStart = anchorKey === currentKey ? anchorOffset : 0 - const currentEnd = focusKey === currentKey ? focusOffset : currentBlock.getLength() - const blockText = currentBlock.getText().slice(currentStart, currentEnd) - for (let i = 0; i < textTags.length; i++) { - const tag = textTags[i]! - const startIdx = blockText.indexOf(tag) - if (startIdx !== -1) { - const contentStateWithEntity = cs.createEntity('TAG', 'IMMUTABLE', { - value: tag.slice(1) - }) - const entityKey = contentStateWithEntity.getLastCreatedEntityKey() - cs = Modifier.applyEntity( - cs, - selectionState.merge({ - anchorKey: currentKey, - focusKey: currentKey, - anchorOffset: startIdx, - focusOffset: startIdx + tag.length - }), - entityKey - ) - } - } - if (focusKey === currentKey) { - return contentState === cs - ? null - : (cs.merge({ - selectionAfter: contentState.getSelectionAfter() - }) as ContentState) - } - currentKey = contentState.getKeyAfter(currentKey) - } - return undefined -} - -export default entitizeText diff --git a/packages/client/utils/draftjs/extractTextFromDraftString.ts b/packages/client/utils/draftjs/extractTextFromDraftString.ts deleted file mode 100644 index 95e03014184..00000000000 --- a/packages/client/utils/draftjs/extractTextFromDraftString.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {RawDraftContentState} from 'draft-js' - -const extractTextFromDraftString = (content: string) => { - const parsedContent = JSON.parse(content) as RawDraftContentState - // toWellFormed replaces lone surrogates with replacement char (e.g. emoji that only has its first code point) - const textBlocks = parsedContent.blocks.map(({text}) => (text as any).toWellFormed()) - return textBlocks.join('\n') -} - -export default extractTextFromDraftString diff --git a/packages/client/utils/draftjs/getFullLinkSelection.ts b/packages/client/utils/draftjs/getFullLinkSelection.ts deleted file mode 100644 index fadc8ff3beb..00000000000 --- a/packages/client/utils/draftjs/getFullLinkSelection.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {EditorState} from 'draft-js' - -const getFullLinkSelection = (editorState: EditorState) => { - const selectionState = editorState.getSelection() - const contentState = editorState.getCurrentContent() - const anchorOffset = selectionState.getAnchorOffset() - const blockKey = selectionState.getAnchorKey() - const block = contentState.getBlockForKey(blockKey) - const entityKey = block.getEntityAt(anchorOffset - 1) - const anchor = { - offset: anchorOffset - 1, - block - } - const curLeft = {...anchor} - for (let i = 0; i < 1e5; i++) { - if (curLeft.offset === 0) { - curLeft.block = contentState.getBlockBefore(curLeft.block.getKey())! - if (!curLeft.block) break - curLeft.offset = curLeft.block.getLength() - } - const currentEntity = curLeft.block.getEntityAt(--curLeft.offset) - if (currentEntity !== entityKey) break - anchor.offset = curLeft.offset - anchor.block = curLeft.block - } - - const focus = { - offset: anchorOffset, - block - } - const curRight = {...focus} - - for (let i = 0; i < 1e5; i++) { - if (curRight.offset === curRight.block.getLength()) { - curRight.block = contentState.getBlockAfter(curRight.block.getKey())! - if (!curRight.block) break - curRight.offset = 0 - } - // ++ suffix here because the focus goes up to but not including - const currentEntity = curRight.block.getEntityAt(curRight.offset++) - if (currentEntity !== entityKey) break - focus.offset = curRight.offset - focus.block = curRight.block - } - - return selectionState.merge({ - anchorOffset: anchor.offset, - anchorKey: anchor.block.getKey(), - focusOffset: focus.offset, - focusKey: focus.block.getKey() - }) -} - -export default getFullLinkSelection diff --git a/packages/client/utils/draftjs/isAndroid.ts b/packages/client/utils/draftjs/isAndroid.ts deleted file mode 100644 index bb577a01e6a..00000000000 --- a/packages/client/utils/draftjs/isAndroid.ts +++ /dev/null @@ -1,5 +0,0 @@ -const isAndroid = () => { - return navigator.userAgent.toLowerCase().indexOf('android') > -1 -} - -export default isAndroid() diff --git a/packages/client/utils/draftjs/isRichDraft.ts b/packages/client/utils/draftjs/isRichDraft.ts deleted file mode 100644 index d720c5b1ae3..00000000000 --- a/packages/client/utils/draftjs/isRichDraft.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {EditorState} from 'draft-js' - -const isRichDraft = (editorState: EditorState) => { - const content = editorState.getCurrentContent() - return !content.getBlockMap().every((block) => { - if (!block) return false - if ((block as any).get('type') !== 'unstyled') return false - const charList = block.getCharacterList() - return charList.every((char) => { - if (!char) return false - return char.getStyle().size === 0 && !char.getEntity() - }) - }) -} - -export default isRichDraft diff --git a/packages/client/utils/draftjs/makeAddLink.ts b/packages/client/utils/draftjs/makeAddLink.ts deleted file mode 100644 index 9f06b6d4051..00000000000 --- a/packages/client/utils/draftjs/makeAddLink.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {ContentState, EditorState, Modifier} from 'draft-js' - -const makeAddLink = - (blockKey: string, anchorOffset: number, focusOffset: number, url: string) => - (editorState: EditorState) => { - const contentState = editorState.getCurrentContent() - const contentStateWithEntity = contentState.createEntity('LINK', 'MUTABLE', { - href: url - }) - const entityKey = contentStateWithEntity.getLastCreatedEntityKey() - const selectionState = editorState.getSelection() - const linkSelectionState = selectionState.merge({ - anchorKey: blockKey, - focusKey: blockKey, - anchorOffset, - focusOffset - }) - const contentWithUrl = Modifier.applyEntity(contentState, linkSelectionState, entityKey).merge({ - selectionAfter: selectionState, - selectionBefore: selectionState - }) as ContentState - return EditorState.push(editorState, contentWithUrl, 'apply-entity') - } - -export default makeAddLink diff --git a/packages/client/utils/draftjs/makeEditorState.ts b/packages/client/utils/draftjs/makeEditorState.ts deleted file mode 100644 index 22fe9c84aec..00000000000 --- a/packages/client/utils/draftjs/makeEditorState.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {ContentState, convertFromRaw, EditorState} from 'draft-js' -import editorDecorators from '../../components/TaskEditor/decorators' - -const makeEditorState = (content: string | undefined | null, getEditorState: () => EditorState) => { - const contentState = content - ? convertFromRaw(JSON.parse(content)) - : ContentState.createFromText('') - return EditorState.createWithContent(contentState, editorDecorators(getEditorState)) -} - -export default makeEditorState diff --git a/packages/client/utils/draftjs/makeEmptyStr.ts b/packages/client/utils/draftjs/makeEmptyStr.ts deleted file mode 100644 index f32030a3b18..00000000000 --- a/packages/client/utils/draftjs/makeEmptyStr.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {ContentState, convertToRaw} from 'draft-js' - -const makeEmptyStr = () => JSON.stringify(convertToRaw(ContentState.createFromText(''))) - -export default makeEmptyStr diff --git a/packages/client/utils/draftjs/remountDecorators.ts b/packages/client/utils/draftjs/remountDecorators.ts deleted file mode 100644 index 7ce25cdbed9..00000000000 --- a/packages/client/utils/draftjs/remountDecorators.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {EditorState} from 'draft-js' -import editorDecorators from '../../components/TaskEditor/decorators' - -const remountDecorators = ( - getEditorState: () => EditorState, - searchQuery: string | undefined | null -) => { - return EditorState.set(getEditorState(), { - decorator: editorDecorators(getEditorState, undefined, searchQuery) - }) -} - -export default remountDecorators diff --git a/packages/client/utils/draftjs/removeLink.ts b/packages/client/utils/draftjs/removeLink.ts deleted file mode 100644 index 420f5bde770..00000000000 --- a/packages/client/utils/draftjs/removeLink.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {ContentState, EditorState, Modifier} from 'draft-js' -import getFullLinkSelection from './getFullLinkSelection' - -const removeLink = (editorState: EditorState) => { - const selectionState = editorState.getSelection() - const linkSelection = selectionState.isCollapsed() - ? getFullLinkSelection(editorState) - : selectionState - const contentWithoutLink = Modifier.applyEntity( - editorState.getCurrentContent(), - linkSelection, - null - ).merge({ - selectionAfter: selectionState, - selectionBefore: selectionState - }) as ContentState - return EditorState.push(editorState, contentWithoutLink, 'apply-entity') -} - -export default removeLink diff --git a/packages/client/utils/draftjs/splitBlock.ts b/packages/client/utils/draftjs/splitBlock.ts deleted file mode 100644 index a3036f96506..00000000000 --- a/packages/client/utils/draftjs/splitBlock.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {EditorState, Modifier} from 'draft-js' - -const splitBlock = (editorState: EditorState) => { - const contentState = Modifier.splitBlock( - editorState.getCurrentContent(), - editorState.getSelection() - ) - return EditorState.push(editorState, contentState, 'split-block') -} - -export default splitBlock diff --git a/packages/client/utils/draftjs/splitDraftContent.ts b/packages/client/utils/draftjs/splitDraftContent.ts deleted file mode 100644 index 1026c718a5b..00000000000 --- a/packages/client/utils/draftjs/splitDraftContent.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {ContentState, convertFromRaw, RawDraftContentState} from 'draft-js' - -const splitDraftContent = (content: string) => { - const rawContent = JSON.parse(content) as RawDraftContentState - const {blocks} = rawContent - let title = blocks[0]?.text ?? '' - // if the title exceeds 256, repeat it in the body because it probably has entities in it - if (title.length <= 256) { - blocks.shift() - } else { - title = title.slice(0, 256) - } - const contentState = - blocks.length === 0 ? ContentState.createFromText('') : convertFromRaw(rawContent) - return {title, contentState} -} - -export default splitDraftContent diff --git a/packages/client/utils/getDraftCoords.ts b/packages/client/utils/getDraftCoords.ts deleted file mode 100644 index f304f9bbc0c..00000000000 --- a/packages/client/utils/getDraftCoords.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {getVisibleSelectionRect} from 'draft-js' - -const getDraftCoords = () => { - const selection = window.getSelection() - if (!selection || !selection.rangeCount) { - return null - } - let target = selection.anchorNode - while (target && target !== document) { - // make sure the selection is inside draft, this isn't always guaranteed - if ((target as any).className === 'notranslate public-DraftEditor-content') { - return getVisibleSelectionRect(window) as ClientRect - } - target = target.parentNode - } - return null -} - -export default getDraftCoords diff --git a/packages/client/utils/mergeServerContent.ts b/packages/client/utils/mergeServerContent.ts deleted file mode 100644 index 2e63dbc885c..00000000000 --- a/packages/client/utils/mergeServerContent.ts +++ /dev/null @@ -1,51 +0,0 @@ -import {ContentState, EditorState} from 'draft-js' - -const getBestFitSelection = (newContentState: ContentState, oldKey: string, oldOffset: number) => { - const contentBlock = newContentState.getBlockForKey(oldKey) || newContentState.getFirstBlock() - const key = contentBlock.getKey() - const blockLength = contentBlock.getText().length - const offset = key === oldKey ? Math.min(blockLength, oldOffset) : blockLength - return {key, offset} -} - -const getMergedSelection = (oldEditorState: EditorState, newContentState: ContentState) => { - const oldSelection = oldEditorState.getSelection() - const oldStartKey = oldSelection.getStartKey() - const oldStartOffset = oldSelection.getStartOffset() - const {offset: startOffset, key: startKey} = getBestFitSelection( - newContentState, - oldStartKey, - oldStartOffset - ) - if (oldSelection.isCollapsed()) { - return oldSelection.merge({ - anchorOffset: startOffset, - focusOffset: startOffset, - anchorKey: startKey, - focusKey: startKey - }) - } - const {offset: endOffset, key: endKey} = getBestFitSelection( - newContentState, - oldSelection.getEndKey(), - oldSelection.getEndOffset() - ) - return oldSelection.merge({ - anchorOffset: startOffset, - focusOffset: endOffset, - anchorKey: startKey, - focusKey: endKey - }) -} - -const mergeServerContent = (oldEditorState: EditorState, newContentState: ContentState) => { - // unless it's being simultaneously edited, don't bother setting selection - if (!oldEditorState.getSelection().getHasFocus()) { - return newContentState - } - return newContentState.merge({ - selectionAfter: getMergedSelection(oldEditorState, newContentState) - }) as ContentState -} - -export default mergeServerContent diff --git a/packages/client/utils/smartGroup/getGroupSmartTitle.ts b/packages/client/utils/smartGroup/getGroupSmartTitle.ts index 2ab15d4a549..19cafcdb1e2 100644 --- a/packages/client/utils/smartGroup/getGroupSmartTitle.ts +++ b/packages/client/utils/smartGroup/getGroupSmartTitle.ts @@ -6,7 +6,7 @@ import computeDistanceMatrix from './computeDistanceMatrix' import getAllLemmasFromReflections from './getAllLemmasFromReflections' import getTitleFromComputedGroup from './getTitleFromComputedGroup' -const getGroupSmartTitle = (reflections: {entities: any[]}[]) => { +const getGroupSmartTitle = (reflections: {plaintextContent: string; entities: any[]}[]) => { const allReflectionEntities = reflections.map(({entities}) => entities).filter(Boolean) const uniqueLemmaArr = getAllLemmasFromReflections(allReflectionEntities) diff --git a/packages/client/utils/smartGroup/getTitleFromComputedGroup.ts b/packages/client/utils/smartGroup/getTitleFromComputedGroup.ts index cffe6cbe07d..57862a25939 100644 --- a/packages/client/utils/smartGroup/getTitleFromComputedGroup.ts +++ b/packages/client/utils/smartGroup/getTitleFromComputedGroup.ts @@ -3,8 +3,6 @@ * Uses the most salient entities to create a 40-character theme to summarize the content of the reflections */ -import extractTextFromDraftString from '../draftjs/extractTextFromDraftString' - const SALIENT_THRESHOLD = 0.6 const MIN_ENTITIES = 2 const MAX_CHARS = 30 @@ -32,7 +30,7 @@ const getTitleFromComputedGroup = ( uniqueLemmaArr: string[], group: DistanceArray[], reflectionEntities: {lemma?: string; name: string; salience: number}[][], - reflections: any[] + reflections: {plaintextContent: string}[] ) => { const sumArr = new Array(uniqueLemmaArr.length).fill(0) group.forEach((reflectionDistanceArr) => { @@ -67,7 +65,7 @@ const getTitleFromComputedGroup = ( if (titleArr.length === 0) { const [firstReflection] = reflections if (!firstReflection) return 'Unknown Topic' - const text = extractTextFromDraftString(firstReflection.content) + const text = firstReflection.plaintextContent const maxStr = text.trim().slice(0, MAX_CHARS) const lastSpace = maxStr.lastIndexOf(' ') const wordsOrMax = lastSpace === -1 ? maxStr : maxStr.slice(0, lastSpace).trim() diff --git a/packages/client/utils/smartGroup/groupReflections.ts b/packages/client/utils/smartGroup/groupReflections.ts index dc2976225df..8734f0157de 100644 --- a/packages/client/utils/smartGroup/groupReflections.ts +++ b/packages/client/utils/smartGroup/groupReflections.ts @@ -27,7 +27,9 @@ export type GroupingOptions = { maxReductionPercent?: number } -const groupReflections = ( +const groupReflections = < + T extends {entities: any[]; reflectionGroupId: string; id: string; plaintextContent: string} +>( reflections: T[], groupingOptions: GroupingOptions ) => { diff --git a/packages/client/validation/normalizeRawDraftJS.ts b/packages/client/validation/normalizeRawDraftJS.ts deleted file mode 100644 index c6affbf12ca..00000000000 --- a/packages/client/validation/normalizeRawDraftJS.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {RawDraftContentState} from 'draft-js' -import makeEmptyStr from '../utils/draftjs/makeEmptyStr' - -const normalizeRawDraftJS = (str: string | undefined | null) => { - if (!str) return makeEmptyStr() - let parsedContent: RawDraftContentState - try { - parsedContent = JSON.parse(str) - } catch (e) { - return makeEmptyStr() - } - const keys = Object.keys(parsedContent) - if ( - keys.length !== 2 || - typeof parsedContent.entityMap !== 'object' || - !Array.isArray(parsedContent.blocks) || - parsedContent.blocks.length === 0 - ) { - return makeEmptyStr() - } - // remove empty first block - const {blocks} = parsedContent - const firstBlockIdx = blocks.findIndex((block) => Boolean(block.text.replace(/\s/g, ''))) - if (firstBlockIdx === -1) { - return makeEmptyStr() - } - if (firstBlockIdx > 0) { - return JSON.stringify({ - ...parsedContent, - blocks: blocks.slice(firstBlockIdx) - }) - } - return str -} - -export default normalizeRawDraftJS diff --git a/packages/mattermost-plugin/components/PushReflection/PushReflectionModal.tsx b/packages/mattermost-plugin/components/PushReflection/PushReflectionModal.tsx index 312352b4e98..90a855c75a7 100644 --- a/packages/mattermost-plugin/components/PushReflection/PushReflectionModal.tsx +++ b/packages/mattermost-plugin/components/PushReflection/PushReflectionModal.tsx @@ -1,13 +1,13 @@ +import {generateJSON, mergeAttributes} from '@tiptap/core' +import BaseLink from '@tiptap/extension-link' +import StarterKit from '@tiptap/starter-kit' import graphql from 'babel-plugin-relay/macro' -import {markdownToDraft} from 'markdown-draft-js' +import {marked} from 'marked' +import {getPost} from 'mattermost-redux/selectors/entities/posts' +import {GlobalState} from 'mattermost-redux/types/store' import React, {useEffect, useMemo} from 'react' import {Modal} from 'react-bootstrap' import {useDispatch, useSelector} from 'react-redux' - -import {getPost} from 'mattermost-redux/selectors/entities/posts' - -import {GlobalState} from 'mattermost-redux/types/store' - import {useLazyLoadQuery, useMutation} from 'react-relay' import {PushReflectionModalMutation} from '../../__generated__/PushReflectionModalMutation.graphql' import {PushReflectionModalQuery} from '../../__generated__/PushReflectionModalQuery.graphql' @@ -122,7 +122,23 @@ const PushReflectionModal = () => { } const markdown = `${comment}\n\n${formattedPost}` - const rawObject = markdownToDraft(markdown) + const html = await marked.parse(markdown) + const rawObject = generateJSON(html, [ + StarterKit, + BaseLink.extend({ + parseHTML() { + return [{tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])'}] + }, + + renderHTML({HTMLAttributes}) { + return [ + 'a', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {class: 'link'}), + 0 + ] + } + }) + ]) const content = JSON.stringify(rawObject) createReflection({ diff --git a/packages/mattermost-plugin/package.json b/packages/mattermost-plugin/package.json index 6f050ec81cc..34ed0755f9a 100644 --- a/packages/mattermost-plugin/package.json +++ b/packages/mattermost-plugin/package.json @@ -27,7 +27,6 @@ "@babel/cli": "7.18.6", "@babel/core": "7.18.6", "@babel/preset-react": "^7.25.9", - "@types/markdown-draft-js": "^2.2.7", "@mattermost/types": "6.7.0-0", "@types/franc": "^5.0.3", "@types/node": "^16.11.62", @@ -51,10 +50,13 @@ "dependencies": { "@mattermost/compass-icons": "0.1.47", "@reduxjs/toolkit": "1.9.7", + "@tiptap/core": "^2.9.1", + "@tiptap/extension-link": "^2.9.1", + "@tiptap/starter-kit": "^2.9.1", "classnames": "^2.5.1", "dd-trace": "^5.0.0", "franc-min": "^5.0.0", - "markdown-draft-js": "^2.4.0", + "marked": "^15.0.4", "mattermost-redux": "5.33.1", "react-relay": "^18.2.0", "react-select": "5.8.2", diff --git a/packages/server/graphql/mutations/createReflection.ts b/packages/server/graphql/mutations/createReflection.ts index ca41613f6bb..6190d76b86a 100644 --- a/packages/server/graphql/mutations/createReflection.ts +++ b/packages/server/graphql/mutations/createReflection.ts @@ -1,16 +1,17 @@ +import {generateText} from '@tiptap/core' import {GraphQLNonNull} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' import isPhaseComplete from 'parabol-client/utils/meetings/isPhaseComplete' import getGroupSmartTitle from 'parabol-client/utils/smartGroup/getGroupSmartTitle' import unlockAllStagesForPhase from 'parabol-client/utils/unlockAllStagesForPhase' -import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' +import {serverTipTapExtensions} from '../../../client/shared/tiptap/serverTipTapExtensions' import ReflectionGroup from '../../database/types/ReflectionGroup' import generateUID from '../../generateUID' import getKysely from '../../postgres/getKysely' import {toGoogleAnalyzedEntity} from '../../postgres/helpers/toGoogleAnalyzedEntity' import {analytics} from '../../utils/analytics/analytics' import {getUserId} from '../../utils/authorization' +import {convertToTipTap} from '../../utils/convertToTipTap' import publish from '../../utils/publish' import standardError from '../../utils/standardError' import {GQLContext} from '../graphql' @@ -59,13 +60,13 @@ export default { } // VALIDATION - const normalizedContent = normalizeRawDraftJS(content) + const normalizedContent = convertToTipTap(content) if (normalizedContent.length > 2000) { return {error: {message: 'Reflection content is too long'}} } // RESOLUTION - const plaintextContent = extractTextFromDraftString(normalizedContent) + const plaintextContent = generateText(normalizedContent, serverTipTapExtensions) const [entities, sentimentScore] = await Promise.all([ getReflectionEntities(plaintextContent), @@ -78,7 +79,7 @@ export default { const reflection = { id: generateUID(), creatorId: viewerId, - content: normalizedContent, + content: JSON.stringify(normalizedContent), plaintextContent, entities, sentimentScore, diff --git a/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts b/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts index cdccfb40f76..7c358d0161a 100644 --- a/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts +++ b/packages/server/graphql/mutations/helpers/removeEmptyReflections.ts @@ -1,4 +1,3 @@ -import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' import {DataLoaderInstance} from '../../../dataloader/RootDataLoader' import getKysely from '../../../postgres/getKysely' import {AnyMeeting} from '../../../postgres/types/Meeting' @@ -12,7 +11,7 @@ const removeEmptyReflections = async (meeting: AnyMeeting, dataLoader: DataLoade const emptyReflectionGroupIds = [] as string[] const emptyReflectionIds = [] as string[] reflections.forEach((reflection) => { - const text = extractTextFromDraftString(reflection.content) + const text = reflection.plaintextContent if (text.length === 0) { emptyReflectionGroupIds.push(reflection.reflectionGroupId) emptyReflectionIds.push(reflection.id) diff --git a/packages/server/graphql/mutations/helpers/updateReflectionLocation/updateSmartGroupTitle.ts b/packages/server/graphql/mutations/helpers/updateReflectionLocation/updateSmartGroupTitle.ts index d21c7dd713d..e68ca57745b 100644 --- a/packages/server/graphql/mutations/helpers/updateReflectionLocation/updateSmartGroupTitle.ts +++ b/packages/server/graphql/mutations/helpers/updateReflectionLocation/updateSmartGroupTitle.ts @@ -1,8 +1,9 @@ import {sql} from 'kysely' import getKysely from '../../../../postgres/getKysely' -const updateSmartGroupTitle = async (reflectionGroupId: string, smartTitle: string) => { +const updateSmartGroupTitle = async (reflectionGroupId: string, longSmartTitle: string) => { const pg = getKysely() + const smartTitle = longSmartTitle.slice(0, 255) await pg .updateTable('RetroReflectionGroup') .set({ diff --git a/packages/server/graphql/mutations/updateReflectionContent.ts b/packages/server/graphql/mutations/updateReflectionContent.ts index 3cdcea748d7..69173b89204 100644 --- a/packages/server/graphql/mutations/updateReflectionContent.ts +++ b/packages/server/graphql/mutations/updateReflectionContent.ts @@ -1,12 +1,13 @@ +import {generateText} from '@tiptap/core' import {GraphQLID, GraphQLNonNull, GraphQLString} from 'graphql' import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' import isPhaseComplete from 'parabol-client/utils/meetings/isPhaseComplete' -import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' import stringSimilarity from 'string-similarity' +import {serverTipTapExtensions} from '../../../client/shared/tiptap/serverTipTapExtensions' import getKysely from '../../postgres/getKysely' import {toGoogleAnalyzedEntity} from '../../postgres/helpers/toGoogleAnalyzedEntity' import {getUserId, isTeamMember} from '../../utils/authorization' +import {convertToTipTap} from '../../utils/convertToTipTap' import publish from '../../utils/publish' import standardError from '../../utils/standardError' import {GQLContext} from '../graphql' @@ -25,7 +26,7 @@ export default { }, content: { type: new GraphQLNonNull(GraphQLString), - description: 'A stringified draft-js document containing thoughts' + description: 'A stringified TipTap JSONContent document containing thoughts' } }, async resolve( @@ -65,13 +66,13 @@ export default { } // VALIDATION - const normalizedContent = normalizeRawDraftJS(content) + const normalizedContent = convertToTipTap(content) if (normalizedContent.length > 2000) { return {error: {message: 'Reflection content is too long'}} } // RESOLUTION - const plaintextContent = extractTextFromDraftString(normalizedContent) + const plaintextContent = generateText(normalizedContent, serverTipTapExtensions) const isVeryDifferent = stringSimilarity.compareTwoStrings(plaintextContent, reflection.plaintextContent) < 0.9 const entities = isVeryDifferent @@ -86,7 +87,7 @@ export default { await pg .updateTable('RetroReflection') .set({ - content: normalizedContent, + content: JSON.stringify(normalizedContent), entities: toGoogleAnalyzedEntity(entities), sentimentScore, plaintextContent diff --git a/packages/server/graphql/public/typeDefs/AddCommentInput.graphql b/packages/server/graphql/public/typeDefs/AddCommentInput.graphql index 9fb75071f5e..e4c487c151a 100644 --- a/packages/server/graphql/public/typeDefs/AddCommentInput.graphql +++ b/packages/server/graphql/public/typeDefs/AddCommentInput.graphql @@ -1,6 +1,6 @@ input AddCommentInput { """ - A stringified draft-js document containing thoughts + A stringified TipTap JSONContent document containing thoughts """ content: String! diff --git a/packages/server/graphql/public/typeDefs/CreateReflectionInput.graphql b/packages/server/graphql/public/typeDefs/CreateReflectionInput.graphql index 127f1a09fd5..769ea326402 100644 --- a/packages/server/graphql/public/typeDefs/CreateReflectionInput.graphql +++ b/packages/server/graphql/public/typeDefs/CreateReflectionInput.graphql @@ -1,6 +1,6 @@ input CreateReflectionInput { """ - A stringified draft-js document containing thoughts + A stringified TipTap JSONContent document containing thoughts """ content: String meetingId: ID! diff --git a/packages/server/graphql/public/typeDefs/Mutation.graphql b/packages/server/graphql/public/typeDefs/Mutation.graphql index 48a23598d20..a38790ed86f 100644 --- a/packages/server/graphql/public/typeDefs/Mutation.graphql +++ b/packages/server/graphql/public/typeDefs/Mutation.graphql @@ -844,7 +844,7 @@ type Mutation { commentId: ID! """ - A stringified draft-js document containing thoughts + A stringified TipTap JSONContent document containing thoughts """ content: String! meetingId: ID! @@ -924,7 +924,7 @@ type Mutation { reflectionId: ID! """ - A stringified draft-js document containing thoughts + A stringified TipTap JSONContent document containing thoughts """ content: String! ): UpdateReflectionContentPayload diff --git a/packages/server/graphql/public/typeDefs/RetroReflection.graphql b/packages/server/graphql/public/typeDefs/RetroReflection.graphql index 1e0cda202f1..00de64caf96 100644 --- a/packages/server/graphql/public/typeDefs/RetroReflection.graphql +++ b/packages/server/graphql/public/typeDefs/RetroReflection.graphql @@ -43,7 +43,7 @@ type RetroReflection implements Reactable { isViewerCreator: Boolean! """ - The stringified draft-js content + The stringified TipTap JSONContent content """ content: String! diff --git a/packages/server/graphql/public/types/RetroReflection.ts b/packages/server/graphql/public/types/RetroReflection.ts index a1697b86259..cbf8eabd966 100644 --- a/packages/server/graphql/public/types/RetroReflection.ts +++ b/packages/server/graphql/public/types/RetroReflection.ts @@ -1,8 +1,17 @@ +import {isDraftJSContent} from '../../../../client/shared/tiptap/isDraftJSContent' import {getUserId, isSuperUser} from '../../../utils/authorization' +import {convertKnownDraftToTipTap} from '../../../utils/convertToTipTap' import getGroupedReactjis from '../../../utils/getGroupedReactjis' import {RetroReflectionResolvers} from '../resolverTypes' const RetroReflection: RetroReflectionResolvers = { + content: ({content}) => { + const contentJSON = JSON.parse(content) + const validContent = isDraftJSContent(contentJSON) + ? convertKnownDraftToTipTap(contentJSON) + : contentJSON + return JSON.stringify(validContent) + }, creatorId: async ({creatorId, meetingId}, _args, {authToken, dataLoader}) => { const meeting = await dataLoader.get('newMeetings').loadNonNull(meetingId) const {meetingType} = meeting diff --git a/packages/server/graphql/types/CreateReflectionInput.ts b/packages/server/graphql/types/CreateReflectionInput.ts index 9afdee15332..0929dfc72ba 100644 --- a/packages/server/graphql/types/CreateReflectionInput.ts +++ b/packages/server/graphql/types/CreateReflectionInput.ts @@ -11,7 +11,7 @@ const CreateReflectionInput = new GraphQLInputObjectType({ fields: () => ({ content: { type: GraphQLString, - description: 'A stringified draft-js document containing thoughts' + description: 'A stringified TipTap JSONContent document containing thoughts' }, meetingId: { type: new GraphQLNonNull(GraphQLID) diff --git a/packages/server/types/modules.d.ts b/packages/server/types/modules.d.ts index c7f7fb31c96..8a44fc48be1 100644 --- a/packages/server/types/modules.d.ts +++ b/packages/server/types/modules.d.ts @@ -13,8 +13,6 @@ declare module '*.graphql' { export = value } -declare module 'draft-js-utils' -declare module 'draft-js-export-markdown' declare module 'babel-plugin-relay/macro' declare module '@authenio/samlify-node-xmllint' declare module 'node-env-flag' diff --git a/scripts/webpack/dev.clientdll.config.js b/scripts/webpack/dev.clientdll.config.js index b63ba59b9bb..57836bd2092 100644 --- a/scripts/webpack/dev.clientdll.config.js +++ b/scripts/webpack/dev.clientdll.config.js @@ -16,7 +16,6 @@ module.exports = { '@mattkrick/trebuchet-client', '@sentry/browser', 'debug', - 'draft-js', 'email-addresses', 'emoji-mart', 'eventemitter3', diff --git a/yarn.lock b/yarn.lock index ca908644cba..7de4ae775c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6378,6 +6378,11 @@ dependencies: "@sentry/types" "7.120.0" +"@sereneinserenade/tiptap-search-and-replace@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@sereneinserenade/tiptap-search-and-replace/-/tiptap-search-and-replace-0.1.1.tgz#7f73ce1b80f2bd067829858dfb08aa8baa5254ae" + integrity sha512-lVYuCYj8ORUCpv9WD7mmcKGQez/QaGUyCoJReH8Kn8hQo8dJEETo3sC3l7QrsYokoV8VNS42rBh0vj7qbgDg1Q== + "@sigstore/bundle@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-1.1.0.tgz#17f8d813b09348b16eeed66a8cf1c3d6bd3d04f1" @@ -7871,14 +7876,6 @@ dependencies: "@types/node" "*" -"@types/draft-js@*": - version "0.11.18" - resolved "https://registry.yarnpkg.com/@types/draft-js/-/draft-js-0.11.18.tgz#f2cad2178987fdd444827a2f7809a653f82a70ad" - integrity sha512-lP6yJ+EKv5tcG1dflWgDKeezdwBa8wJ7KkiNrrHqXuXhl/VGes1SKjEfKHDZqOz19KQbrAhFvNhDPWwnQXYZGQ== - dependencies: - "@types/react" "*" - immutable "~3.7.4" - "@types/draft-js@^0.10.24": version "0.10.45" resolved "https://registry.yarnpkg.com/@types/draft-js/-/draft-js-0.10.45.tgz#4ab5b0785fcacdea7d5a836d0024158fa8108e70" @@ -8176,13 +8173,6 @@ resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7" integrity sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA== -"@types/markdown-draft-js@^2.2.7": - version "2.2.7" - resolved "https://registry.yarnpkg.com/@types/markdown-draft-js/-/markdown-draft-js-2.2.7.tgz#5ac700f307cc80e8b5e58f5f33366f317b8858a0" - integrity sha512-EXnEi36xa9m7HH4O1yH6Sp8zd29u4nDzKvtNPt0QSRDjGSC33UcyFVQ86D4GGEVnUlbB0ySm6eXZTAjekVKpNA== - dependencies: - "@types/draft-js" "*" - "@types/markdown-it@^14.0.0": version "14.1.2" resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61" @@ -9264,7 +9254,7 @@ arg@^5.0.2: resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== -argparse@^1.0.10, argparse@^1.0.7: +argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== @@ -9493,13 +9483,6 @@ auto-bind@~4.0.0: resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-4.0.0.tgz#e3589fc6c2da8f7ca43ba9f84fa52a744fc997fb" integrity sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ== -autolinker@^3.11.0: - version "3.16.2" - resolved "https://registry.yarnpkg.com/autolinker/-/autolinker-3.16.2.tgz#6bb4f32432fc111b65659336863e653973bfbcc9" - integrity sha512-JiYl7j2Z19F9NdTmirENSUUIIL/9MytEWtmzhfmsKPCp9E+G35Y0UNCMoM9tFigxT59qSc8Ml2dlZXOCVTYwuA== - dependencies: - tslib "^2.3.0" - autoprefixer@^10.4.13: version "10.4.20" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.20.tgz#5caec14d43976ef42e32dcb4bd62878e96be5b3b" @@ -10210,9 +10193,9 @@ camelize@^1.0.0: integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ== caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001669, caniuse-lite@~1.0.0: - version "1.0.30001680" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz#5380ede637a33b9f9f1fc6045ea99bd142f3da5e" - integrity sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA== + version "1.0.30001689" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001689.tgz#67ca960dd5f443903e19949aeacc9d28f6e10910" + integrity sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g== capital-case@^1.0.4: version "1.0.4" @@ -16970,13 +16953,6 @@ map-obj@^4.0.0: resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a" integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ== -markdown-draft-js@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/markdown-draft-js/-/markdown-draft-js-2.4.0.tgz#164c53be05a96b3a7864cdd0fc3e51166726d01e" - integrity sha512-MalOqajYYaELKLPHLnUcaU7kwhIHveVdKd15SKBkDkWj1NBLHuM5uZjXQ5TDfM4rrk4tcAzIvwWhCJzdGNcmkg== - dependencies: - remarkable "^2.0.1" - markdown-it@^14.0.0, markdown-it@^14.1.0: version "14.1.0" resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45" @@ -16999,6 +16975,11 @@ marked@^13.0.3: resolved "https://registry.yarnpkg.com/marked/-/marked-13.0.3.tgz#5c5b4a5d0198060c7c9bc6ef9420a7fed30f822d" integrity sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA== +marked@^15.0.4: + version "15.0.4" + resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.4.tgz#864dbf50227b6507646c771c2ef5f0de2924833e" + integrity sha512-TCHvDqmb3ZJ4PWG7VEGVgtefA5/euFmsIhxtD0XsBxI39gUSKL81mIRFdt0AiNQozUahd4ke98ZdirExd/vSEw== + marked@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" @@ -20614,14 +20595,6 @@ relay-runtime@^14.1.0: fbjs "^3.0.2" invariant "^2.2.4" -remarkable@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/remarkable/-/remarkable-2.0.1.tgz#280ae6627384dfb13d98ee3995627ca550a12f31" - integrity sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA== - dependencies: - argparse "^1.0.10" - autolinker "^3.11.0" - remedial@^1.0.7: version "1.0.8" resolved "https://registry.yarnpkg.com/remedial/-/remedial-1.0.8.tgz#a5e4fd52a0e4956adbaf62da63a5a46a78c578a0" @@ -21940,7 +21913,7 @@ string-similarity@^3.0.0: resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-3.0.0.tgz#07b0bc69fae200ad88ceef4983878d03793847c7" integrity sha512-7kS7LyTp56OqOI2BDWQNVnLX/rCxIQn+/5M0op1WV6P8Xx6TZNdajpuqQdiJ7Xx+p1C5CsWMvdiBp9ApMhxzEQ== -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -21958,15 +21931,6 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -22053,7 +22017,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -22067,13 +22031,6 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1: dependencies: ansi-regex "^2.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -24106,7 +24063,7 @@ workbox-window@6.6.1: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -24124,15 +24081,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"