2
0

⬆️ Upgrade and improve plate editor

Closes #606
This commit is contained in:
Baptiste Arnaud
2024-02-27 10:03:55 +01:00
parent ce17ce5061
commit b9e54686d5
18 changed files with 704 additions and 1517 deletions

View File

@ -9,6 +9,7 @@
"lint": "dotenv -e ./.env -e ../../.env -- next lint",
"test": "dotenv -e ./.env -e ../../.env -- pnpm playwright test",
"test:show-report": "pnpm playwright show-report src/test/reporters",
"test:ui": "dotenv -e ./.env -e ../../.env -- pnpm playwright test --ui",
"format:check": "prettier --check ./src"
},
"dependencies": {
@ -42,12 +43,12 @@
"@typebot.io/env": "workspace:*",
"@typebot.io/js": "workspace:*",
"@typebot.io/nextjs": "workspace:*",
"@udecode/plate-basic-marks": "21.1.5",
"@udecode/plate-common": "21.1.5",
"@udecode/plate-core": "21.1.5",
"@udecode/plate-link": "21.2.0",
"@udecode/plate-ui-link": "21.2.0",
"@udecode/plate-ui-toolbar": "21.1.5",
"@udecode/cn": "29.0.1",
"@udecode/plate-basic-marks": "30.5.3",
"@udecode/plate-common": "30.4.5",
"@udecode/plate-core": "30.4.5",
"@udecode/plate-floating": "30.5.3",
"@udecode/plate-link": "30.5.3",
"@uiw/codemirror-extensions-langs": "4.21.7",
"@uiw/codemirror-theme-github": "4.21.7",
"@uiw/codemirror-theme-tokyo-night": "4.21.7",
@ -83,9 +84,6 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-markdown": "^9.0.1",
"slate": "0.94.1",
"slate-history": "0.93.0",
"slate-react": "0.94.2",
"sonner": "1.3.1",
"stripe": "12.13.0",
"svg-round-corners": "0.4.1",
@ -97,7 +95,7 @@
},
"devDependencies": {
"@chakra-ui/styled-system": "2.9.1",
"@playwright/test": "1.36.0",
"@playwright/test": "1.41.2",
"@typebot.io/forge": "workspace:*",
"@typebot.io/forge-repository": "workspace:*",
"@typebot.io/forge-schemas": "workspace:*",

View File

@ -651,3 +651,14 @@ export const LightBulbIcon = (props: IconProps) => (
<path d="M10 22h4" />
</Icon>
)
export const UnlinkIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="m18.84 12.25 1.72-1.71h-.02a5.004 5.004 0 0 0-.12-7.07 5.006 5.006 0 0 0-6.95 0l-1.72 1.71" />
<path d="m5.17 11.75-1.71 1.71a5.004 5.004 0 0 0 .12 7.07 5.006 5.006 0 0 0 6.95 0l1.71-1.71" />
<line x1="8" x2="8" y1="2" y2="5" />
<line x1="2" x2="5" y1="8" y2="8" />
<line x1="16" x2="16" y1="19" y2="22" />
<line x1="19" x2="22" y1="16" y2="16" />
</Icon>
)

View File

@ -1,175 +1,10 @@
import {
Flex,
Popover,
PopoverAnchor,
PopoverContent,
Portal,
Stack,
useColorModeValue,
} from '@chakra-ui/react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Plate, PlateProvider, usePlateEditorRef } from '@udecode/plate-core'
import { editorStyle, platePlugins } from '@/lib/plate'
import { BaseEditor, BaseSelection, Transforms } from 'slate'
import { Variable } from '@typebot.io/schemas'
import { ReactEditor } from 'slate-react'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { colors } from '@/lib/theme'
import { useOutsideClick } from '@/hooks/useOutsideClick'
import { selectEditor, TElement } from '@udecode/plate-common'
import { TextEditorToolBar } from './TextEditorToolBar'
import { useTranslate } from '@tolgee/react'
import React, { useState } from 'react'
import { Plate } from '@udecode/plate-core'
import { platePlugins } from '@/lib/plate'
import { TElement } from '@udecode/plate-common'
import { TextEditorEditorContent } from './TextEditorEditorContent'
type TextBubbleEditorContentProps = {
id: string
textEditorValue: TElement[]
onClose: (newContent: TElement[]) => void
}
const TextBubbleEditorContent = ({
id,
textEditorValue,
onClose,
}: TextBubbleEditorContentProps) => {
const { t } = useTranslate()
const editor = usePlateEditorRef()
const varDropdownRef = useRef<HTMLDivElement | null>(null)
const rememberedSelection = useRef<BaseSelection | null>(null)
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
const [isFirstFocus, setIsFirstFocus] = useState(true)
const textEditorRef = useRef<HTMLDivElement>(null)
const closeEditor = () => onClose(textEditorValue)
useOutsideClick({
ref: textEditorRef,
handler: closeEditor,
})
const computeTargetCoord = useCallback(() => {
if (rememberedSelection.current) return { top: 0, left: 0 }
const selection = window.getSelection()
const relativeParent = textEditorRef.current
if (!selection || !relativeParent) return { top: 0, left: 0 }
const range = selection.getRangeAt(0)
const selectionBoundingRect = range.getBoundingClientRect()
const relativeRect = relativeParent.getBoundingClientRect()
return {
top: selectionBoundingRect.bottom - relativeRect.top,
left: selectionBoundingRect.left - relativeRect.left,
}
}, [])
useEffect(() => {
if (!isVariableDropdownOpen) return
const el = varDropdownRef.current
if (!el) return
const { top, left } = computeTargetCoord()
if (top === 0 && left === 0) return
el.style.top = `${top}px`
el.style.left = `${left}px`
}, [computeTargetCoord, isVariableDropdownOpen])
const handleVariableSelected = (variable?: Variable) => {
setIsVariableDropdownOpen(false)
if (!rememberedSelection.current || !variable) return
ReactEditor.focus(editor as unknown as ReactEditor)
Transforms.select(
editor as unknown as BaseEditor,
rememberedSelection.current
)
Transforms.insertText(
editor as unknown as BaseEditor,
'{{' + variable.name + '}}'
)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.shiftKey) return
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) closeEditor()
}
return (
<Stack
flex="1"
ref={textEditorRef}
borderWidth="2px"
borderColor="blue.400"
rounded="md"
pos="relative"
spacing={0}
cursor="text"
className="prevent-group-drag"
onContextMenuCapture={(e) => e.stopPropagation()}
sx={{
'.slate-ToolbarButton-active': {
color: useColorModeValue('blue.500', 'blue.300') + ' !important',
},
'[class^="PlateFloatingLink___Styled"]': {
'--tw-bg-opacity': useColorModeValue('1', '.1') + '!important',
backgroundColor: useColorModeValue('white', 'gray.800'),
borderRadius: 'md',
transitionProperty: 'background-color',
transitionDuration: 'normal',
},
'[class^="FloatingVerticalDivider___"]': {
'--tw-bg-opacity': useColorModeValue('1', '.4') + '!important',
},
'.slate-a': {
color: useColorModeValue('blue.500', 'blue.300'),
},
}}
>
<TextEditorToolBar
onVariablesButtonClick={() => setIsVariableDropdownOpen(true)}
/>
<Plate
id={id}
editableProps={{
style: editorStyle(useColorModeValue('white', colors.gray[850])),
autoFocus: true,
onFocus: () => {
rememberedSelection.current = null
if (!isFirstFocus) return
if (editor.children.length === 0) return
selectEditor(editor, {
edge: 'end',
})
setIsFirstFocus(false)
},
'aria-label': `${t('editor.blocks.bubbles.textEditor.plate.label')}`,
onBlur: () => {
rememberedSelection.current = editor?.selection
},
onKeyDown: handleKeyDown,
onClick: () => {
setIsVariableDropdownOpen(false)
},
}}
/>
<Popover isOpen={isVariableDropdownOpen} isLazy>
<PopoverAnchor>
<Flex pos="absolute" ref={varDropdownRef} />
</PopoverAnchor>
<Portal>
<PopoverContent>
<VariableSearchInput
initialVariableId={undefined}
onSelectVariable={handleVariableSelected}
placeholder={t(
'editor.blocks.bubbles.textEditor.searchVariable.placeholder'
)}
autoFocus
/>
</PopoverContent>
</Portal>
</Popover>
</Stack>
)
}
type TextBubbleEditorProps = {
id: string
initialValue: TElement[]
onClose: (newContent: TElement[]) => void
@ -179,11 +14,14 @@ export const TextBubbleEditor = ({
id,
initialValue,
onClose,
}: TextBubbleEditorProps) => {
const [textEditorValue, setTextEditorValue] = useState(initialValue)
}: TextBubbleEditorContentProps) => {
const [textEditorValue, setTextEditorValue] =
useState<TElement[]>(initialValue)
const closeEditor = () => onClose(textEditorValue)
return (
<PlateProvider
<Plate
id={id}
plugins={platePlugins}
initialValue={
@ -193,11 +31,7 @@ export const TextBubbleEditor = ({
}
onChange={setTextEditorValue}
>
<TextBubbleEditorContent
id={id}
textEditorValue={textEditorValue}
onClose={onClose}
/>
</PlateProvider>
<TextEditorEditorContent closeEditor={closeEditor} />
</Plate>
)
}

View File

@ -0,0 +1,152 @@
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { editorStyle } from '@/lib/plate'
import { colors } from '@/lib/theme'
import {
useColorModeValue,
Popover,
PopoverAnchor,
Flex,
Portal,
PopoverContent,
Stack,
} from '@chakra-ui/react'
import { Variable } from '@typebot.io/schemas'
import { useCallback, useEffect, useRef, useState } from 'react'
import { TextEditorToolBar } from './TextEditorToolBar'
import { useTranslate } from '@tolgee/react'
import { PlateContent, useEditorRef } from '@udecode/plate-core'
import { focusEditor, insertText, selectEditor } from '@udecode/plate-common'
import { useOutsideClick } from '@/hooks/useOutsideClick'
type Props = {
closeEditor: () => void
}
export const TextEditorEditorContent = ({ closeEditor }: Props) => {
const { t } = useTranslate()
const editor = useEditorRef()
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
const [isFirstFocus, setIsFirstFocus] = useState(true)
const varDropdownRef = useRef<HTMLDivElement | null>(null)
const rememberedSelection = useRef<typeof editor.selection | null>(null)
const textEditorRef = useRef<HTMLDivElement>(null)
const plateContentRef = useRef<HTMLDivElement>(null)
const handleVariableSelected = (variable?: Variable) => {
setIsVariableDropdownOpen(false)
if (!variable) return
focusEditor(editor)
insertText(editor, '{{' + variable.name + '}}')
}
useOutsideClick({
ref: textEditorRef,
handler: closeEditor,
})
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.shiftKey) return
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) closeEditor()
}
const computeTargetCoord = useCallback(() => {
if (rememberedSelection.current) return { top: 0, left: 0 }
const selection = window.getSelection()
const relativeParent = textEditorRef.current
if (!selection || !relativeParent) return { top: 0, left: 0 }
const range = selection.getRangeAt(0)
const selectionBoundingRect = range.getBoundingClientRect()
const relativeRect = relativeParent.getBoundingClientRect()
return {
top: selectionBoundingRect.bottom - relativeRect.top,
left: selectionBoundingRect.left - relativeRect.left,
}
}, [])
useEffect(() => {
if (!isVariableDropdownOpen) return
const el = varDropdownRef.current
if (!el) return
const { top, left } = computeTargetCoord()
if (top === 0 && left === 0) return
el.style.top = `${top}px`
el.style.left = `${left}px`
}, [computeTargetCoord, isVariableDropdownOpen])
return (
<Stack
flex="1"
ref={textEditorRef}
borderWidth="2px"
borderColor="blue.400"
rounded="md"
pos="relative"
spacing={0}
cursor="text"
className="prevent-group-drag"
onContextMenuCapture={(e) => e.stopPropagation()}
sx={{
'.slate-ToolbarButton-active': {
color: useColorModeValue('blue.500', 'blue.300') + ' !important',
},
'[class^="PlateFloatingLink___Styled"]': {
'--tw-bg-opacity': useColorModeValue('1', '.1') + '!important',
backgroundColor: useColorModeValue('white', 'gray.800'),
borderRadius: 'md',
transitionProperty: 'background-color',
transitionDuration: 'normal',
},
'[class^="FloatingVerticalDivider___"]': {
'--tw-bg-opacity': useColorModeValue('1', '.4') + '!important',
},
'.slate-a': {
color: useColorModeValue('blue.500', 'blue.300'),
},
}}
>
<TextEditorToolBar
onVariablesButtonClick={() => setIsVariableDropdownOpen(true)}
/>
<PlateContent
ref={plateContentRef}
onKeyDown={handleKeyDown}
style={editorStyle(useColorModeValue('white', colors.gray[850]))}
autoFocus
onClick={() => {
setIsVariableDropdownOpen(false)
}}
onFocus={() => {
rememberedSelection.current = null
if (!isFirstFocus || !editor) return
if (editor.children.length === 0) return
selectEditor(editor, {
edge: 'end',
})
setIsFirstFocus(false)
}}
onBlur={() => {
if (!editor) return
rememberedSelection.current = editor.selection
}}
aria-label="Text editor"
/>
<Popover isOpen={isVariableDropdownOpen} isLazy>
<PopoverAnchor>
<Flex pos="absolute" ref={varDropdownRef} />
</PopoverAnchor>
<Portal>
<PopoverContent>
<VariableSearchInput
initialVariableId={undefined}
onSelectVariable={handleVariableSelected}
placeholder={t(
'editor.blocks.bubbles.textEditor.searchVariable.placeholder'
)}
autoFocus
/>
</PopoverContent>
</Portal>
</Popover>
</Stack>
)
}

View File

@ -9,16 +9,17 @@ import {
MARK_ITALIC,
MARK_UNDERLINE,
} from '@udecode/plate-basic-marks'
import { getPluginType, usePlateEditorRef } from '@udecode/plate-core'
import { LinkToolbarButton } from '@udecode/plate-ui-link'
import { MarkToolbarButton } from '@udecode/plate-ui-toolbar'
import { getPluginType, useEditorRef } from '@udecode/plate-core'
import {
BoldIcon,
ItalicIcon,
UnderlineIcon,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
LinkIcon,
UserIcon,
} from '@/components/icons'
import { MarkToolbarButton } from './plate/MarkToolbarButton'
import { LinkToolbarButton } from './plate/LinkToolbarButton'
type Props = {
onVariablesButtonClick: () => void
@ -28,7 +29,8 @@ export const TextEditorToolBar = ({
onVariablesButtonClick,
...props
}: Props) => {
const editor = usePlateEditorRef()
const editor = useEditorRef()
const handleVariablesButtonMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
@ -52,24 +54,27 @@ export const TextEditorToolBar = ({
/>
<span data-testid="bold-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_BOLD)}
nodeType={getPluginType(editor, MARK_BOLD)}
icon={<BoldIcon />}
aria-label="Toggle bold"
/>
</span>
<span data-testid="italic-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_ITALIC)}
nodeType={getPluginType(editor, MARK_ITALIC)}
icon={<ItalicIcon />}
aria-label="Toggle italic"
/>
</span>
<span data-testid="underline-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_UNDERLINE)}
nodeType={getPluginType(editor, MARK_UNDERLINE)}
icon={<UnderlineIcon />}
aria-label="Toggle underline"
/>
</span>
<span data-testid="link-button">
<LinkToolbarButton icon={<LinkIcon />} />
<LinkToolbarButton icon={<LinkIcon />} aria-label="Add link" />
</span>
</HStack>
)

View File

@ -0,0 +1,149 @@
import React, { useRef } from 'react'
import {
flip,
offset,
UseVirtualFloatingOptions,
} from '@udecode/plate-floating'
import {
LinkFloatingToolbarState,
useFloatingLinkEdit,
useFloatingLinkEditState,
useFloatingLinkInsert,
useFloatingLinkInsertState,
useFloatingLinkUrlInput,
} from '@udecode/plate-link'
import { LinkIcon, UnlinkIcon } from '@/components/icons'
import {
Button,
Divider,
HStack,
IconButton,
Input,
InputGroup,
InputLeftElement,
Stack,
} from '@chakra-ui/react'
import { TextInputIcon } from '@/features/blocks/inputs/textInput/components/TextInputIcon'
const floatingOptions: UseVirtualFloatingOptions = {
placement: 'bottom-start',
middleware: [
offset(12),
flip({
padding: 12,
fallbackPlacements: ['bottom-end', 'top-start', 'top-end'],
}),
],
}
export interface LinkFloatingToolbarProps {
state?: LinkFloatingToolbarState
}
export function LinkFloatingToolbar({ state }: LinkFloatingToolbarProps) {
const urlInputRef = useRef<HTMLInputElement>(null)
const insertState = useFloatingLinkInsertState({
...state,
floatingOptions: {
...floatingOptions,
...state?.floatingOptions,
},
})
const {
props: insertProps,
ref: insertRef,
hidden,
textInputProps,
} = useFloatingLinkInsert(insertState)
const { props } = useFloatingLinkUrlInput({
ref: urlInputRef,
})
const editState = useFloatingLinkEditState({
...state,
floatingOptions: {
...floatingOptions,
...state?.floatingOptions,
},
})
const {
props: editProps,
ref: editRef,
editButtonProps,
unlinkButtonProps,
} = useFloatingLinkEdit(editState)
if (hidden) return null
const input = (
<Stack
w="330px"
bgColor="white"
px="4"
py="2"
rounded="md"
borderWidth={1}
shadow="md"
>
<InputGroup>
<InputLeftElement pointerEvents="none">
<LinkIcon color="gray.300" />
</InputLeftElement>
<Input
ref={urlInputRef}
placeholder="Paste link"
defaultValue={props.defaultValue}
onChange={props.onChange}
/>
</InputGroup>
<Divider />
<InputGroup>
<InputLeftElement pointerEvents="none">
<TextInputIcon color="gray.300" />
</InputLeftElement>
<Input placeholder="Text to display" {...textInputProps} />
</InputGroup>
</Stack>
)
const editContent = editState.isEditing ? (
input
) : (
<HStack
bgColor="white"
p="2"
rounded="md"
borderWidth={1}
shadow="md"
align="center"
>
<Button {...editButtonProps} size="sm">
Edit link
</Button>
<Divider orientation="vertical" h="20px" />
<IconButton
icon={<UnlinkIcon />}
aria-label="Unlink"
size="sm"
{...unlinkButtonProps}
/>
</HStack>
)
return (
<>
<div ref={insertRef} {...insertProps}>
{input}
</div>
<div ref={editRef} {...editProps}>
{editContent}
</div>
</>
)
}

View File

@ -0,0 +1,23 @@
import React from 'react'
import { IconButton, IconButtonProps } from '@chakra-ui/react'
import {
useLinkToolbarButton,
useLinkToolbarButtonState,
} from '@udecode/plate-link'
type Props = IconButtonProps
export const LinkToolbarButton = ({ ...rest }: Props) => {
const state = useLinkToolbarButtonState()
const { props } = useLinkToolbarButton(state)
return (
<IconButton
size="sm"
variant={props.pressed ? 'outline' : 'ghost'}
colorScheme={props.pressed ? 'blue' : undefined}
{...props}
{...rest}
/>
)
}

View File

@ -0,0 +1,26 @@
import React from 'react'
import {
useMarkToolbarButton,
useMarkToolbarButtonState,
} from '@udecode/plate-common'
import { IconButton, IconButtonProps } from '@chakra-ui/react'
type Props = {
nodeType: string
clear?: string | string[]
} & IconButtonProps
export const MarkToolbarButton = ({ clear, nodeType, ...rest }: Props) => {
const state = useMarkToolbarButtonState({ clear, nodeType })
const { props } = useMarkToolbarButton(state)
return (
<IconButton
size="sm"
variant={props.pressed ? 'outline' : 'ghost'}
colorScheme={props.pressed ? 'blue' : undefined}
{...props}
{...rest}
/>
)
}

View File

@ -1,3 +1,4 @@
import { LinkFloatingToolbar } from '@/features/blocks/bubbles/textBubble/components/plate/LinkFloatingInput'
import {
createBoldPlugin,
createItalicPlugin,
@ -5,13 +6,13 @@ import {
} from '@udecode/plate-basic-marks'
import { createPlugins } from '@udecode/plate-core'
import { createLinkPlugin, ELEMENT_LINK } from '@udecode/plate-link'
import { PlateFloatingLink } from '@udecode/plate-ui-link'
export const editorStyle = (backgroundColor: string): React.CSSProperties => ({
flex: 1,
padding: '1rem',
backgroundColor,
borderRadius: '0.25rem',
outline: 'none',
})
export const platePlugins = createPlugins(
@ -20,7 +21,8 @@ export const platePlugins = createPlugins(
createItalicPlugin(),
createUnderlinePlugin(),
createLinkPlugin({
renderAfterEditable: PlateFloatingLink,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderAfterEditable: LinkFloatingToolbar as any,
options: {
isUrl: (url: string) =>
url.startsWith('http:') ||

View File

@ -19460,6 +19460,37 @@
"type": "string"
}
}
},
"progressBar": {
"type": "object",
"properties": {
"isEnabled": {
"type": "boolean"
},
"color": {
"type": "string"
},
"backgroundColor": {
"type": "string"
},
"placement": {
"type": "string",
"enum": [
"Top",
"Bottom"
]
},
"thickness": {
"type": "number"
},
"position": {
"type": "string",
"enum": [
"fixed",
"absolute"
]
}
}
}
}
},

View File

@ -1380,6 +1380,10 @@
}
},
"description": "If the typebot contains dynamic avatars, dynamicTheme returns the new avatar URLs whenever their variables are updated."
},
"progress": {
"type": "number",
"description": "If progress bar is enabled, this field will return a number between 0 and 100 indicating the current progress based on the longest remaining path of the flow."
}
},
"required": [
@ -1709,6 +1713,10 @@
}
},
"description": "If the typebot contains dynamic avatars, dynamicTheme returns the new avatar URLs whenever their variables are updated."
},
"progress": {
"type": "number",
"description": "If progress bar is enabled, this field will return a number between 0 and 100 indicating the current progress based on the longest remaining path of the flow."
}
},
"required": [
@ -2127,6 +2135,10 @@
}
},
"description": "If the typebot contains dynamic avatars, dynamicTheme returns the new avatar URLs whenever their variables are updated."
},
"progress": {
"type": "number",
"description": "If progress bar is enabled, this field will return a number between 0 and 100 indicating the current progress based on the longest remaining path of the flow."
}
},
"required": [
@ -6759,6 +6771,37 @@
"type": "string"
}
}
},
"progressBar": {
"type": "object",
"properties": {
"isEnabled": {
"type": "boolean"
},
"color": {
"type": "string"
},
"backgroundColor": {
"type": "string"
},
"placement": {
"type": "string",
"enum": [
"Top",
"Bottom"
]
},
"thickness": {
"type": "number"
},
"position": {
"type": "string",
"enum": [
"fixed",
"absolute"
]
}
}
}
}
},