Text Editor
Text Editor components
Text Editor UI
Rich text editor with ProseMirror.
@types
Description
Editor
EditorEmojiMenu
EditorLinkModal
EditorMentionMenu
emoji
hooks
Icon
PopoverEditorLink
PopoverEmoji
PopoverProfile
position
prosemirror
store
utils
App.tsx
createProvider.tsx
import { Flex } from '@chakra-ui/react'
import { Description } from './Description'
// Disabling border for non-keyboard interactions
// @see https://github.com/chakra-ui/chakra-ui/blob/develop/packages/css-reset/README.md
import 'focus-visible/dist/focus-visible'
export function App() {
return (
<Flex
flex={1}
h="full"
flexDirection="column"
bg="transparent"
minH={{ base: 'auto' }}
maxW={{ base: 'auto', md: '600px' }}
mx={{ base: 'auto' }}
>
<Description />
</Flex>
)
}
import { forwardRef } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import React, { createContext, memo } from 'react'
/**
* Creates a provider component for a given context.
*
* @param {function} useValue - A function that takes props and returns the context value.
*
* @return An object containing the provider component, the context object, and a hook to consume the context.
*/
export function createProvider<
ContextProps extends object,
Props extends object,
>(useValue: (props: Props) => ContextProps) {
const Context = createContext<ContextProps>({} as ContextProps)
const useContext = () => {
const context = React.useContext(Context)
if (!Object.keys(context).length) {
throw new Error(
`【${
(useValue as any).__PROVIDER__
}】Context needs to be consumed in Provider`,
)
}
return context
}
const Provider: React.FC<PropsWithChildren<Props>> = memo<
PropsWithChildren<Props>
>(
forwardRef((props, ref) => (
<Component {...props} ref={ref} {...useValue(props)} />
)) as React.FC<PropsWithChildren<Props>>,
)
Provider.displayName = 'Provider'
const Component: React.FC<PropsWithChildren<Props> & ContextProps> = memo<
PropsWithChildren<Props> & ContextProps
>(
forwardRef(({ children, ...rest }, ref) => {
return (
<Context.Provider value={{ ...(rest as unknown as ContextProps), ref }}>
{children}
</Context.Provider>
)
}) as React.FC<PropsWithChildren<Props> & ContextProps>,
)
return {
Provider,
Context,
useContext,
}
}
declare type Override<T1, T2> = Omit<T1, keyof T2> & T2
declare type Writeable<T> = { -readonly [P in keyof T]-?: T[P] }
declare type ValueOf<T> = T[keyof T]
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React, { memo } from 'react'
import { useDescriptionContext } from './Provider'
type Props = FlexProps
export const Container = memo<Props>(function Container(props) {
const { ref, focused, onFocus } = useDescriptionContext()
return (
<Flex
ref={ref}
border="1px"
borderRadius="md"
borderColor={focused ? 'gray.400' : 'transparent'}
_hover={{
borderColor: 'gray.400',
}}
py={2}
px={3}
flexDirection="column"
flex={1}
position="relative"
onFocus={onFocus}
{...props}
/>
)
})
import isEqual from 'lodash-es/isEqual'
import React, { memo, useCallback, useMemo } from 'react'
import { Editor, EditorContent, EditorProvider } from '../Editor'
import {
parseDescription,
stringifyDescription,
} from '../prosemirror/convertDescription'
import { Container } from './Container'
import { Placeholder } from './Placeholder'
import { Provider } from './Provider'
import { ToolBar } from './ToolBar'
export const Description = memo(function Description(props) {
return (
<Provider>
<DescriptionHandler {...props} />
</Provider>
)
})
const DescriptionHandler = memo(function DescriptionHandler() {
const initialValue = useMemo(() => stringifyDescription(null), [])
const handleChange = useCallback(
async (val: string) => {
const description = parseDescription(val)
if (isEqual(description, initialValue)) return
console.log(description)
},
[initialValue],
)
return <Component onChange={handleChange} initialValue={initialValue} />
})
type ComponentProps = {
onChange: (val: string) => void
initialValue: string
}
const Component = memo<ComponentProps>(function Component(props) {
const { onChange, initialValue } = props
const handleChange = useCallback(
(val: string) => {
onChange(val)
},
[onChange],
)
return (
<Container>
<EditorProvider>
<Editor onChange={handleChange} initialValue={initialValue}>
<EditorContent style={{ minHeight: '300px' }} />
<Placeholder />
<ToolBar />
</Editor>
</EditorProvider>
</Container>
)
})
import React, { memo } from 'react'
import { EditorPlaceholder } from '../Editor/Editors/EditorPlaceholder'
import { useDescriptionContext } from './Provider'
export const Placeholder = memo(function Placeholder() {
const { focused } = useDescriptionContext()
if (focused) return null
return (
<EditorPlaceholder alignItems="flex-start">
Write something here...
</EditorPlaceholder>
)
})
import { useOutsideClick } from '@chakra-ui/react'
import type { Dispatch, SetStateAction, MutableRefObject } from 'react'
import { useRef } from 'react'
import { useCallback, useState } from 'react'
import { createProvider } from '../createProvider'
type ContextProps = {
focused: boolean
toolbarFocused: boolean
onFocus: () => void
setToolbarFocused: Dispatch<SetStateAction<boolean>>
ref: MutableRefObject<HTMLElement | null>
}
const useValue = (): ContextProps => {
const [focused, setFocused] = useState<boolean>(false)
const [toolbarFocused, setToolbarFocused] = useState<boolean>(false)
const ref = useRef<HTMLElement | null>(null)
useOutsideClick({
ref,
handler: () => {
if (toolbarFocused) return
setFocused(false)
},
})
const onFocus = useCallback(() => {
setFocused(true)
}, [])
return {
focused,
onFocus,
ref,
toolbarFocused,
setToolbarFocused,
}
}
useValue.__PROVIDER__ = 'Description/Provider.tsx'
export const { Provider, useContext: useDescriptionContext } =
createProvider(useValue)
import { Divider, Stack } from '@chakra-ui/react'
import React, { memo, useCallback } from 'react'
import {
Bold,
BulletList,
DecreaseListIndent,
IncreaseListIndent,
Italic,
Link,
OrderedList,
Strikethrough,
Underline,
Emoji,
AtMention,
} from '../Editor/ToolBar'
import { useDescriptionContext } from './Provider'
export const ToolBar = memo(function ToolBar() {
const { focused, setToolbarFocused, toolbarFocused } = useDescriptionContext()
const handleOpened = useCallback(() => {
setToolbarFocused(true)
}, [setToolbarFocused])
const handleClosed = useCallback(() => {
setToolbarFocused(false)
}, [setToolbarFocused])
return (
<Stack
flex={1}
direction="row"
spacing={0}
minH={8}
alignItems="center"
bg="transparent"
flexWrap="wrap"
>
{focused && (
<>
<Bold />
<Italic />
<Underline />
<Strikethrough />
<BulletList />
<OrderedList />
<IncreaseListIndent />
<DecreaseListIndent />
<Link />
<Divider orientation="vertical" borderColor="gray.400" h={5} />
<AtMention />
<Emoji onOpened={handleOpened} onClosed={handleClosed} />
</>
)}
</Stack>
)
})
import type { EditorProps } from 'prosemirror-view'
import type { PropsWithChildren } from 'react'
import React, { memo, useMemo } from 'react'
import { schema, plugins } from '../prosemirror/config'
import { EditorContainer } from './Editors'
type Props = PropsWithChildren<
{
initialValue: string
forceUpdate?: number
onChange?: (val: string) => void
resetView?: number
} & EditorProps
>
export const Editor = memo<Props>(function Editor(props) {
const pluginsProp = useMemo(() => plugins(), [])
return (
<EditorContainer
onChange={props.onChange}
{...props}
debounce={500}
schema={schema}
plugins={pluginsProp}
initialValue={props.initialValue}
>
{props.children}
</EditorContainer>
)
})
import type { PropsWithChildren } from 'react'
import { EditorEmojiMenuProvider, EditorEmojiMenu } from '../EditorEmojiMenu'
import { EditorLinkModalProvider, EditorLinkModal } from '../EditorLinkModal'
import {
EditorMentionMenuProvider,
EditorMentionMenu,
} from '../EditorMentionMenu'
export function EditorProvider({ children }: PropsWithChildren) {
return (
<EditorLinkModalProvider>
<EditorEmojiMenuProvider>
<EditorMentionMenuProvider>
{children}
<EditorLinkModal />
<EditorEmojiMenu />
<EditorMentionMenu />
</EditorMentionMenuProvider>
</EditorEmojiMenuProvider>
</EditorLinkModalProvider>
)
}
import { Modal } from '@chakra-ui/react'
import React from 'react'
import { MenuList } from './MenuList'
import { useEditorEmojiMenuContext } from './Provider'
export function EditorEmojiMenu() {
const { isOpen, onClose } = useEditorEmojiMenuContext()
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="xs"
autoFocus={false}
trapFocus={false}
motionPreset="none"
>
{isOpen && <MenuList />}
</Modal>
)
}
import type { ModalBodyProps } from '@chakra-ui/react'
import { ModalBody, ModalContent } from '@chakra-ui/react'
import React, { useCallback } from 'react'
import type { BaseEmoji } from '../emoji'
import { useMenuStyle } from '../hooks/useMenuStyle'
import { EmojiItem } from './EmojiItem'
import { useEditorEmojiMenuContext } from './Provider'
export function MenuList() {
const { emojis, state, setValue, containerRef } = useEditorEmojiMenuContext()
const menuStyles = useMenuStyle()
const handleClick = useCallback(
(val: BaseEmoji) => {
setValue(val)
},
[setValue],
)
return (
<ModalContent
position="fixed"
top={state.y}
left={state.x}
mb={0}
mt={0}
maxW="450px"
maxH={56}
overflowY="scroll"
ref={containerRef}
>
<ModalBody w="full" px={0} {...(menuStyles.list as ModalBodyProps)}>
{emojis.map((e, i) => (
<EmojiItem onClick={handleClick} emoji={e} key={e.id} index={i} />
))}
</ModalBody>
</ModalContent>
)
}
import {
Input,
Stack,
Modal,
ModalBody,
ModalContent,
Button,
} from '@chakra-ui/react'
import React, { useCallback } from 'react'
import { useEditorLinkModalContext } from './Provider'
const MARGIN = 30
export function EditorLinkModal() {
const { isOpen, x, y, onClose, setInput, input } = useEditorLinkModalContext()
const handleInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, type: keyof typeof input) => {
setInput({
...input,
[type]: e.target.value,
})
},
[input, setInput],
)
return (
<Modal isOpen={isOpen} onClose={onClose} size="xs">
<ModalContent position="fixed" top={x + MARGIN} left={y} mb={0} mt={0}>
<ModalBody>
<Stack direction="row" alignItems="center" spacing={2}>
<Input
value={input.url}
onChange={(e) => handleInput(e, 'url')}
focusBorderColor="none"
_focusVisible={{ boxShadow: 'none', outline: 'none' }}
placeholder="Add URL"
size="sm"
/>
<Button size="sm" onClick={onClose}>
Save
</Button>
</Stack>
</ModalBody>
</ModalContent>
</Modal>
)
}
import { useCallback, useState } from 'react'
import { createProvider } from '../createProvider'
type ContextProps = {
isOpen: boolean
x: number
y: number
input: {
url: string
}
callback: (input: State['input']) => void
setInput: (input: State['input']) => void
onOpen: (options: { x: State['x']; y: State['y'] }) => Promise<State['input']>
onClose: () => void
}
type State = {
isOpen: boolean
x: number
y: number
input: {
url: string
}
callback: (input: State['input']) => void
}
const initializeState = (): State => ({
isOpen: false,
x: 0,
y: 0,
input: {
url: '',
},
callback: () => {},
})
const useValue = (): ContextProps => {
const [state, setState] = useState<State>(initializeState())
const resetState = useCallback(() => {
setState(initializeState())
}, [])
const onClose = useCallback(() => {
setState((s) => ({ ...s, isOpen: false }))
state.callback(state.input)
resetState()
}, [resetState, setState, state])
const onOpen = useCallback(
({ x, y }: { x: State['x']; y: State['y'] }) => {
return new Promise<State['input']>((resolve) => {
setState((s) => ({
...s,
isOpen: true,
x,
y,
callback: resolve,
}))
})
},
[setState],
)
const setInput = useCallback(
(input: State['input']) => {
setState((s) => ({ ...s, input }))
},
[setState],
)
return {
...state,
setInput,
onOpen,
onClose,
}
}
useValue.__PROVIDER__ = 'EditorLinkModal/Provider.tsx'
export const {
Provider: EditorLinkModalProvider,
useContext: useEditorLinkModalContext,
} = createProvider(useValue)
import { Modal } from '@chakra-ui/react'
import React, { memo } from 'react'
import { MenuContent } from './MenuContent'
import { useEditorMentionMenuContext } from './Provider'
export const EditorMentionMenu = memo(function EditorMentionMenu() {
const { isOpen, onClose } = useEditorMentionMenuContext()
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="xs"
autoFocus={false}
trapFocus={false}
motionPreset="none"
>
{isOpen && <MenuContent />}
</Modal>
)
})
import type { ModalBodyProps } from '@chakra-ui/react'
import { ModalBody, ModalContent } from '@chakra-ui/react'
import React, { memo } from 'react'
import { useMenuStyle } from '../hooks/useMenuStyle'
import { MenuList } from './MenuList'
import { useEditorMentionMenuContext } from './Provider'
export const MenuContent = memo(function MenuContent() {
const { state, containerRef } = useEditorMentionMenuContext()
const menuStyles = useMenuStyle().list
return (
<ModalContent
position="fixed"
top={state.y}
left={state.x}
mb={0}
mt={0}
maxW="450px"
maxH={56}
overflowY="scroll"
ref={containerRef}
>
<ModalBody w="full" px={0} {...(menuStyles as ModalBodyProps)}>
<MenuList />
</ModalBody>
</ModalContent>
)
})
import React, { memo, useCallback, useEffect, useState } from 'react'
import { useDebounce } from '../hooks/useDebounce'
import { MentionItem } from './MentionItem'
import { Empty } from './MentionItem/Empty'
import { MenuLoading } from './MenuLoading'
import type { SetValueParam } from './Provider'
import { useEditorMentionMenuContext } from './Provider'
export const MenuList = memo(function MenuList() {
const { mentions, setValue, refetch, state } = useEditorMentionMenuContext()
const [hasChangedQuery, setHasChangedQuery] = useState<number>(0)
const [searching, setSearching] = useState<boolean>(true)
useEffect(() => {
if (!state.query) return
setSearching(true)
setHasChangedQuery((prev) => prev + 1)
}, [state.query])
const handleDebounce = useCallback(async () => {
await refetch({ queryText: state.query })
// TODO: avoid duplicated rendering.
setTimeout(() => {
setSearching(false)
}, 100)
}, [state.query, refetch])
useDebounce(hasChangedQuery, handleDebounce, 500)
const handleClick = useCallback(
(val: SetValueParam) => {
setValue(val)
},
[setValue],
)
if (searching) return <MenuLoading />
if (!searching && mentions.length === 0)
return (
<Empty>Mention a teammate or link to a task, project, or message.</Empty>
)
return (
<>
{mentions.map((m, i) => (
<MentionItem
onClick={handleClick}
mention={m}
key={`${m.type}_${m.id}`}
index={i}
/>
))}
</>
)
})
import type { IconProps as ChakraIconProps } from '@chakra-ui/react'
import { Icon as ChakraIcon } from '@chakra-ui/react'
import { forwardRef } from '@chakra-ui/react'
import React from 'react'
import type { IconType } from './icons'
import { icons } from './icons'
type Props = ChakraIconProps & {
icon: IconType
size?: Sizes
ref?: React.ForwardedRef<any>
}
const sizes = {
'3xl': {
w: 10,
h: 10,
},
'2xl': {
w: 8,
h: 8,
},
xl: {
w: 6,
h: 6,
},
lg: {
w: '1.5em',
h: '1.5em',
},
md: {
w: '1.25em',
h: '1.25em',
},
sm: {
w: '1.15em',
h: '1.15em',
},
xs: {
w: '1em',
h: '1em',
},
} as const
type Sizes = keyof typeof sizes
export const Icon = forwardRef<Props, 'svg'>((props, ref) => {
const { size, icon, ...iconProps } = props
const iconComponent = icons[icon]
const sizeStyle = sizes[size ?? 'md']
return (
<ChakraIcon
ref={ref}
as={iconComponent}
color="whiteAlpha"
{...sizeStyle}
{...(iconProps as any)}
/>
)
})
Icon.id = 'Icon'
import {
AiFillCheckCircle,
AiFillPlayCircle,
AiOutlineProject,
AiOutlineFileText,
AiOutlineFilePdf,
AiFillLike,
AiOutlineLike,
} from 'react-icons/ai'
import {
BiInfoCircle,
BiPencil,
BiLeftArrowAlt,
BiImageAlt,
BiTrash,
BiHome,
BiSun,
BiSort,
BiMoveVertical,
BiMenu,
BiCompass,
BiCodeAlt,
BiCheckCircle,
BiBell,
BiNotification,
BiBarChart,
BiRocket,
BiIdCard,
BiHelpCircle,
BiUserPlus,
BiTrashAlt,
BiGridAlt,
BiTask,
BiBookOpen,
BiLayerPlus,
BiMobile,
BiGridHorizontal,
BiChevronRight,
BiCopyAlt,
BiX,
BiChevronDown,
BiChevronLeft,
BiPlay,
BiPause,
BiPlayCircle,
BiMovie,
BiShapePolygon,
BiPlus,
BiSpreadsheet,
BiFileBlank,
BiLayout,
BiDotsHorizontalRounded,
BiMessageRoundedDots,
BiMessageRounded,
BiCheck,
BiSearch,
BiListPlus,
BiTime,
BiTable,
BiLockAlt,
BiFilterAlt,
BiFilter,
BiCustomize,
BiArrowToRight,
BiGridVertical,
BiSliderAlt,
BiSubdirectoryRight,
BiBookAdd,
BiSquareRounded,
BiGitPullRequest,
BiExpand,
BiBeenHere,
BiCalendar,
BiStrikethrough,
BiListUl,
BiListOl,
BiRightIndent,
BiLeftIndent,
BiLink,
BiUnlink,
BiAt,
BiLinkExternal,
BiGroup,
BiCalendarAlt,
BiUser,
BiDownload,
BiCommentDots,
BiDownArrowAlt,
BiEditAlt,
BiPhotoAlbum,
BiDetail,
BiSave,
} from 'react-icons/bi'
import { BsTagFill } from 'react-icons/bs'
import { FaTwitter, FaGithub, FaMoon, FaRegStar, FaStar } from 'react-icons/fa'
import { FiBold, FiItalic, FiUnderline } from 'react-icons/fi'
import { HiOutlineMail, HiOutlineEmojiHappy } from 'react-icons/hi'
import { IoMdAttach } from 'react-icons/io'
import { MdSort, MdTextFormat } from 'react-icons/md'
import { TiFlowChildren } from 'react-icons/ti'
export const icons = {
arrowDownAlt: BiDownArrowAlt,
arrowLeftAlt: BiLeftArrowAlt,
arrowToRight: BiArrowToRight,
at: BiAt,
attach: IoMdAttach,
barChart: BiBarChart,
beenHere: BiBeenHere,
bell: BiBell,
bold: FiBold,
bookAdd: BiBookAdd,
bookOpen: BiBookOpen,
calendar: BiCalendar,
calendarAlt: BiCalendarAlt,
check: BiCheck,
checkCircle: BiCheckCircle,
checkCircleFilled: AiFillCheckCircle,
chevronDown: BiChevronDown,
chevronLeft: BiChevronLeft,
chevronRight: BiChevronRight,
codeAlt: BiCodeAlt,
commentDots: BiCommentDots,
compass: BiCompass,
copyAlt: BiCopyAlt,
customize: BiCustomize,
detail: BiDetail,
dotsHorizontalRounded: BiDotsHorizontalRounded,
download: BiDownload,
editAlt: BiEditAlt,
emojiHappy: HiOutlineEmojiHappy,
fileBlank: BiFileBlank,
fillLike: AiFillLike,
filter: BiFilter,
filterAlt: BiFilterAlt,
flowChildren: TiFlowChildren,
fullscreenOutline: BiExpand,
gitPullRequest: BiGitPullRequest,
github: FaGithub,
gridAlt: BiGridAlt,
gridHorizontal: BiGridHorizontal,
gridVertical: BiGridVertical,
group: BiGroup,
help: BiHelpCircle,
home: BiHome,
idCard: BiIdCard,
imageAlt: BiImageAlt,
infoCircle: BiInfoCircle,
italic: FiItalic,
layerPlus: BiLayerPlus,
layout: BiLayout,
leftIndent: BiLeftIndent,
link: BiLink,
linkExternal: BiLinkExternal,
listOl: BiListOl,
listPlus: BiListPlus,
listUl: BiListUl,
lockAlt: BiLockAlt,
mailOutline: HiOutlineMail,
menu: BiMenu,
messageRounded: BiMessageRounded,
messageRoundedDots: BiMessageRoundedDots,
mobile: BiMobile,
moon: FaMoon,
moveVertical: BiMoveVertical,
movie: BiMovie,
notification: BiNotification,
outlineFilePdf: AiOutlineFilePdf,
outlineFileText: AiOutlineFileText,
outlineLike: AiOutlineLike,
outlineProject: AiOutlineProject,
pause: BiPause,
pencil: BiPencil,
photoAlbum: BiPhotoAlbum,
play: BiPlay,
playCircle: AiFillPlayCircle,
playCircleOutline: BiPlayCircle,
plus: BiPlus,
tag: BsTagFill,
rightIndent: BiRightIndent,
rocket: BiRocket,
save: BiSave,
search: BiSearch,
shapePolygon: BiShapePolygon,
sliderAlt: BiSliderAlt,
sort2: BiSort,
sort: MdSort,
spreadsheet: BiSpreadsheet,
squareRounded: BiSquareRounded,
starFilled: FaStar,
starOutline: FaRegStar,
strikethrough: BiStrikethrough,
subdirectoryRight: BiSubdirectoryRight,
sun: BiSun,
table: BiTable,
task: BiTask,
textFormat: MdTextFormat,
time: BiTime,
trash: BiTrash,
trashAlt: BiTrashAlt,
twitter: FaTwitter,
underline: FiUnderline,
unlink: BiUnlink,
user: BiUser,
userPlus: BiUserPlus,
x: BiX,
} as const
export type IconType = keyof typeof icons
import { PortalManager, Popover } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import React from 'react'
export function PopoverEditorLink(props: PropsWithChildren) {
return (
<PortalManager zIndex={1500}>
<Popover trigger="hover" isLazy placement="bottom-start" openDelay={500}>
{props.children}
</Popover>
</PortalManager>
)
}
import { Flex, Portal, PopoverBody, PopoverContent } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
export function PopoverEditorLinkContent(props: PropsWithChildren) {
return (
<Portal>
<PopoverContent contentEditable={false}>
<PopoverBody boxShadow="md" borderRadius="md">
<Flex fontSize="sm" alignItems="center" userSelect="none">
{props.children}
</Flex>
</PopoverBody>
</PopoverContent>
</Portal>
)
}
import { Flex, Spinner } from '@chakra-ui/react'
import React, { memo } from 'react'
export const PopoverEditorLinkLoading = memo(
function PopoverEditorLinkLoading() {
return (
<Flex alignItems="center" justifyContent="center">
<Spinner size="sm" color="gray.400" emptyColor="gray.200" />
</Flex>
)
},
)
import { Text, type TextProps } from '@chakra-ui/react'
import React from 'react'
import { useLinkStyle } from '../hooks/useLinkStyle'
type Props = TextProps
export function PopoverEditorLinkText(props: Props) {
const { style } = useLinkStyle()
return (
<Text
as="span"
{...(style as TextProps)}
fontSize="sm"
ml={3}
flex={1}
{...props}
/>
)
}
import type { LinkProps } from '@chakra-ui/react'
import { Link, PopoverTrigger } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import { useLinkStyle } from '../hooks/useLinkStyle'
export function PopoverEditorLinkTrigger(props: PropsWithChildren) {
const { style } = useLinkStyle()
return (
<PopoverTrigger>
<Link {...(style as LinkProps)}>{props.children}</Link>
</PopoverTrigger>
)
}
import { Portal, Box, PopoverContent, useOutsideClick } from '@chakra-ui/react'
import React, { memo, useCallback, useRef } from 'react'
import type { BaseEmoji } from '../emoji'
import { EmojiPicker } from '../emoji'
import { usePopoverEmojiContext } from './Provider'
import 'emoji-mart/css/emoji-mart.css'
export const Content: React.FC = memo(function Content() {
const { onClose } = usePopoverEmojiContext()
const ref = useRef<HTMLDivElement | null>(null)
useOutsideClick({
ref,
handler: () => onClose(),
})
const handleSelect = useCallback(
(emoji: BaseEmoji) => {
onClose(emoji)
},
[onClose],
)
return (
<Portal>
<Box zIndex="popover" w="full" h="full" ref={ref}>
<PopoverContent boxShadow="none" border="none" w="auto">
<EmojiPicker onSelect={handleSelect} title="Interactive UI" />
</PopoverContent>
</Box>
</Portal>
)
})
import { Flex, PortalManager, PopoverTrigger, Popover } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import React from 'react'
import { Content } from './Content'
import { usePopoverEmojiContext } from './Provider'
import { Provider } from './Provider'
type Props = {
onOpened?: () => void
onClosed?: () => void
}
export function PopoverEmoji(props: PropsWithChildren<Props>) {
return (
<Provider onOpened={props.onOpened} onClosed={props.onClosed}>
<Component {...props} />
</Provider>
)
}
function Component(props: PropsWithChildren) {
const { isOpen } = usePopoverEmojiContext()
return (
<PortalManager zIndex={1500}>
<Popover isOpen={isOpen} placement="right-end" closeOnBlur={false}>
<PopoverTrigger>
<Flex>{props.children}</Flex>
</PopoverTrigger>
{isOpen && <Content />}
</Popover>
</PortalManager>
)
}
import { useCallback, useState } from 'react'
import { createProvider } from '../createProvider'
import type { BaseEmoji } from '../emoji'
type ContextProps = {
isOpen: boolean
emoji: BaseEmoji | null
onClose: (data?: BaseEmoji) => void
onOpen: () => Promise<BaseEmoji>
}
type Props = {
onChange?: (emoji?: BaseEmoji) => void
onOpened?: () => void
onClosed?: () => void
}
const useValue = (props: Props): ContextProps => {
const [isOpen, setIsOpen] = useState<boolean>(false)
const [emoji, setEmoji] = useState<BaseEmoji | null>(null)
const [callback, setCallback] = useState<(val?: BaseEmoji) => void>()
const onClose = useCallback(
(data?: BaseEmoji) => {
setIsOpen(false)
callback?.(data)
props.onChange?.(data)
props.onClosed?.()
setEmoji(data ?? null)
},
[callback, props],
)
const onOpen = useCallback((): Promise<BaseEmoji> => {
return new Promise((resolve) => {
setIsOpen(true)
props.onOpened?.()
setCallback(() => resolve)
})
}, [props])
return {
isOpen,
emoji,
onClose,
onOpen,
}
}
useValue.__PROVIDER__ = 'PopoverEmoji/Provider.tsx'
export const { Provider, useContext: usePopoverEmojiContext } =
createProvider(useValue)
import {
AspectRatio,
Button,
Divider,
Portal,
Text,
Image,
PortalManager,
Box,
Flex,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import { BiMessageRoundedDots } from 'react-icons/bi'
import { Icon } from '../Icon'
type Props = {
profile: {
name: string
image: string
email: string
}
}
export function PopoverProfile(props: PropsWithChildren<Props>) {
return (
<PortalManager zIndex={1600}>
<Popover trigger="hover" isLazy>
<PopoverTrigger>
<Box as="span" maxW="max-content">
{props.children}
</Box>
</PopoverTrigger>
<Portal>
<PopoverContent w={56} border="none">
<PopoverBody p={0} boxShadow="md" borderRadius="md">
<AspectRatio ratio={4 / 3}>
<Flex
bg="teal.400"
w="full"
justifyContent="flex-start"
alignItems="flex-end !important"
borderTopRadius="md"
>
<Flex
w="full"
justifyContent="center"
position="absolute"
bg="teal.400"
>
<Image
src={props.profile.image}
w="100%"
objectFit="cover"
/>
</Flex>
<Text
w="full"
fontSize="sm"
fontWeight="bold"
color="white"
px={3}
py={1}
zIndex="docked"
bgGradient="linear(to-b, transparent, gray.700)"
>
{props.profile.name}
</Text>
</Flex>
</AspectRatio>
<Flex px={4} py={3} alignItems="center">
<Icon icon="mailOutline" w={4} h={4} />
<Text fontSize="xs" ml={3} noOfLines={1}>
{props.profile.email}
</Text>
</Flex>
<Divider />
<Flex px={4} py={3} alignItems="center">
<Button
leftIcon={<Icon icon="messageRoundedDots" w={4} h={4} />}
variant="outline"
size="xs"
>
Send message
</Button>
</Flex>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
</PortalManager>
)
}
import { Picker } from 'emoji-mart'
import type { PickerProps } from 'emoji-mart'
import type { PropsWithChildren, FC } from 'react'
export type { BaseEmoji, EmojiData, EmojiSkin } from 'emoji-mart'
export { emojiIndex as emojiData, frequently } from 'emoji-mart'
export const EmojiPicker = Picker as unknown as FC<
PropsWithChildren<PickerProps>
>
import { useEffect, useRef } from 'react'
import { useMountedRef } from './useMountedRef'
export const useDebounce = <T>(
value: T,
callback: (value: T) => void,
delay: number,
options?: {
skip?: boolean
},
) => {
const timer = useRef<number | null>(null)
const { mountedRef } = useMountedRef()
useEffect(() => {
if (timer.current) window.clearInterval(timer.current)
if (options?.skip) return
timer.current = window.setTimeout(() => {
if (!mountedRef.current) return
callback(value)
}, delay)
}, [callback, delay, mountedRef, options?.skip, timer, value])
}
import useHoverReactHook from '@react-hook/hover'
import { useRef } from 'react'
export const useHover = <T extends HTMLElement>() => {
const ref = useRef<T | null>(null)
const isHovering = useHoverReactHook(ref)
return {
ref,
isHovering,
}
}
import type { ChakraProps } from '@chakra-ui/react'
import { useMemo } from 'react'
type Props = ChakraProps
export const useLinkStyle = (props?: Props) => {
const style = useMemo<ChakraProps>(
() => ({
color: 'cyan.400',
cursor: 'pointer',
fontSize: 'sm',
_hover: {
textDecoration: 'underline !important',
},
...props,
}),
[props],
)
const styleHover = useMemo<ChakraProps>(
() => ({
cursor: 'pointer',
_hover: {
color: 'cyan.400',
},
...props,
}),
[props],
)
return {
style,
styleHover,
}
}
import { useCallback, useState } from 'react'
import type { Mention, Teammate } from '../store/mention'
/**
* Example data of mentions query
*/
const data = {
mentions: [
{
id: '0AC01HQNH90PTBBNQ18NBNBWS2QZD',
type: 1,
text: 'manato.kuroda@example.com',
title: 'Manato Kuroda',
subtitle: 'Manato Kuroda',
teammate: {
id: '0AC01HQTP2M024C3DFPR8YE6XCE5K',
name: 'Manato Kuroda',
image: '/images/cat_img.png',
email: 'manato.kuroda@example.com',
createdAt: '2024-03-01T00:00:12+09:00',
updatedAt: '2024-03-01T00:00:12+09:00',
},
},
{
id: '0AC01HQNH90PTBBNQ18NBNF7MR734',
type: 1,
text: 'dan.abrahmov@example.com',
title: 'Dan Abrahmov',
subtitle: 'Dan Abrahmov',
teammate: {
id: '0AC01HQTP2M024C3DFPR8YGBKPDYS',
name: 'Dan Abrahmov',
image: '/images/dan.jpg',
email: 'dan.abrahmov@example.com',
createdAt: '2024-03-01T00:00:12+09:00',
updatedAt: '2024-03-01T00:00:12+09:00',
},
},
{
id: '0AC01HQNH90PTBBNQ18NBNFYE62WV',
type: 1,
text: 'kent.dodds@example.com',
title: 'Kent Dodds',
subtitle: 'Kent Dodds',
teammate: {
id: '0AC01HQTP2M024C3DFPR8YJF0RZKR',
name: 'Kent Dodds',
image: '/images/kent.jpg',
email: 'kent.dodds@example.com',
createdAt: '2024-03-01T00:00:12+09:00',
updatedAt: '2024-03-01T00:00:12+09:00',
},
},
],
} as const
/**
* Contains a function and states related to mentions query.
*
* You can apply your api endpoints here.
*
*/
export const useMentionsQuery = () => {
const [loading, setLoading] = useState<boolean>(false)
const [mentions, setMentions] = useState<Mention[]>(
data.mentions as unknown as Mention[],
)
const refetch = useCallback(
async ({ queryText }: { queryText: string }): Promise<Mention[]> => {
setLoading(true)
return new Promise((resolve) => {
setTimeout(() => {
const mentions = data.mentions.filter((m) =>
m.text.includes(queryText),
)
resolve(mentions as Mention[])
setMentions(mentions as Mention[])
setLoading(false)
}, 200)
})
},
[],
)
return {
loading,
mentions,
refetch,
}
}
/**
* Get a teammate that is mentioned on the editor.
*
*/
export const useTeammate = (id: string): { teammate: Teammate } => {
const mention = data.mentions.find((m) => m.id === id)!
return {
teammate: mention.teammate,
}
}
import type { ChakraProps } from '@chakra-ui/react'
import { useStyleConfig } from '@chakra-ui/react'
import { useMemo } from 'react'
type MenuStyle = {
list: ChakraProps
item: Override<
ChakraProps,
{
_focus: {
bg: ChakraProps['bg']
}
}
>
}
export const useMenuStyle = () => {
const menuStyles = useStyleConfig('Menu') as MenuStyle
return useMemo((): MenuStyle => {
return {
list: {
__css: {
...menuStyles.list,
},
},
item: {
__css: {
...menuStyles.item,
},
display: 'flex',
flex: 1,
cursor: 'pointer',
_hover: {
bg: 'gray.100',
},
_focus: {
bg: 'gray.100',
},
_dark: {
_focus: {
bg: 'gray.600',
},
_hover: {
bg: 'gray.600',
},
},
},
}
}, [menuStyles])
}
import { useEffect, useRef } from 'react'
export const useMountedRef = () => {
const mountedRef = useRef<boolean | null>(null)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
})
return {
mountedRef,
}
}
import { useEffect, useRef } from 'react'
export const usePrevious = <T>(value: T) => {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
import useLatest from '@react-hook/latest'
import useLayoutEffect from '@react-hook/passive-layout-effect'
import rafSchd from 'raf-schd'
import type { RefObject } from 'react'
import ResizeObserver from 'resize-observer-polyfill'
export const useResizeObserver = <T extends HTMLElement>(
target: RefObject<T> | T | null,
callback: UseResizeObserverCallback,
options?: { skip?: boolean },
): ResizeObserver => {
const resizeObserver = getResizeObserver()
const storedCallback = useLatest(callback)
useLayoutEffect(() => {
let didUnsubscribe = false
const targetEl = target && 'current' in target ? target.current : target
if (!targetEl) return
if (options?.skip) return
resizeObserver.subscribe(
targetEl,
(entry: ResizeObserverEntry, observer: ResizeObserver) => {
if (didUnsubscribe) return
storedCallback.current(entry, observer)
},
)
return () => {
didUnsubscribe = true
resizeObserver.unsubscribe(targetEl)
}
}, [target, resizeObserver, storedCallback, options?.skip])
return resizeObserver.observer
}
const createResizeObserver = () => {
const callbacks: Map<any, UseResizeObserverCallback> = new Map()
const observer = new ResizeObserver(
rafSchd((entries, observer) => {
if (entries.length === 1) {
callbacks.get(entries[0]!.target)?.(entries[0]!, observer)
} else {
for (let i = 0; i < entries.length; i++) {
callbacks.get(entries[i]!.target)?.(entries[i]!, observer)
}
}
}),
)
return {
observer,
subscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
observer.observe(target)
callbacks.set(target, callback)
},
unsubscribe(target: HTMLElement) {
observer.unobserve(target)
callbacks.delete(target)
},
}
}
let resizeObserver: ReturnType<typeof createResizeObserver>
const getResizeObserver = () =>
!resizeObserver ? (resizeObserver = createResizeObserver()) : resizeObserver
export type UseResizeObserverCallback = (
entry: ResizeObserverEntry,
observer: ResizeObserver,
) => any
export const calculateModalPosition = (
node: HTMLElement,
basePosition?: { y: number },
): { y: number } | null => {
const rect = node.getBoundingClientRect()
const position = basePosition || { y: rect.y }
const margin = window.innerHeight - (rect.height + position.y)
if (margin < 30 && margin < -10) {
position.y -= rect.height + 24
}
return position
}
export const getCaretPosition = (): { x: number; y: number } | null => {
const selection = window.getSelection()
const node = selection?.focusNode
let rect: DOMRect
if (isHTMLElement(node)) {
rect = node.getBoundingClientRect()
} else {
const range = selection?.getRangeAt(0).cloneRange()
if (!range) return { x: 0, y: 0 }
range.collapse(true)
rect = range.getClientRects()[0] as DOMRect
}
return {
y: rect.top,
x: rect.left,
}
}
const isHTMLElement = (obj: any): obj is HTMLElement =>
!!obj?.getBoundingClientRect
import { getDefaultDescription } from './getDefaultDescription'
export const parseDescription = <T extends object>(val: string): T => {
try {
return JSON.parse(val) as T
} catch (e) {
if (e instanceof Error) {
console.log('parseDescription error: ', e)
}
throw e
}
}
export const stringifyDescription = <T extends object>(
val: T | null,
): string => {
try {
return val ? JSON.stringify(val) : JSON.stringify(getDefaultDescription())
} catch (e) {
if (e instanceof Error) {
console.log('stringifyDescription error: ', e)
}
throw e
}
}
export const getDefaultDescription = () => {
return {
type: 'doc',
content: [{ type: 'paragraph', content: null }],
}
}
export const uniqBy = <Data>(arr: Data[], by: keyof Data): Data[] => {
const tmp = new Set()
return (arr?.filter((a: any) => !tmp.has(a[by]) && tmp.add(a[by])) ||
[]) as Data[]
}
import type { Node as ProsemirrorNode } from 'prosemirror-model'
import type { Plugin } from 'prosemirror-state'
import { EditorState } from 'prosemirror-state'
import type { EditorProps } from 'prosemirror-view'
import { EditorView } from 'prosemirror-view'
import type { Dispatch, FC, PropsWithChildren, SetStateAction } from 'react'
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
import { createReactNodeView } from './ReactNodeView'
import type { PortalHandlers } from './ReactNodeViewPortals'
import {
ReactNodeViewPortalsProvider,
useReactNodeViewCreatePortal,
} from './ReactNodeViewPortals'
import { Link, Mention, Emoji } from './nodeViews'
const EditorStateContext = createContext<EditorState | null>(null)
const EditorViewContext = createContext<EditorView | null>(null)
export const useEditorStateContext = (): EditorState => {
const context = useContext(EditorStateContext)
if (!context)
throw new Error('useEditorState is only available inside EditorProvider')
return context
}
export const useEditorViewContext = () => useContext(EditorViewContext)
type Props = {
doc?: ProsemirrorNode
plugins?: Plugin[]
forceUpdate?: number
resetView?: number
} & EditorProps
export function EditorProvider(props: PropsWithChildren<Props>) {
return (
<ReactNodeViewPortalsProvider>
<Provider {...props} />
</ReactNodeViewPortalsProvider>
)
}
const generateState = (props: Parameters<typeof EditorState.create>[0]) => {
return EditorState.create({
doc: props.doc,
plugins: props.plugins,
})
}
const generateView = (
props: Props & {
state: EditorState
createPortal: PortalHandlers['createPortal']
removePortal: PortalHandlers['removePortal']
setState: Dispatch<SetStateAction<EditorState>>
},
) => {
const view = new EditorView(null, {
state: props.state,
editable: props.editable,
nodeViews: {
link(node, view, getPos, decorations) {
return createReactNodeView({
node,
view,
getPos,
decorations,
component: Link,
onCreatePortal: props.createPortal,
onRemovePortal: props.removePortal,
})
},
mention(node, view, getPos, decorations) {
return createReactNodeView({
node,
view,
getPos,
decorations,
component: Mention as FC<any>,
onCreatePortal: props.createPortal,
onRemovePortal: props.removePortal,
})
},
emoji(node, view, getPos, decorations) {
return createReactNodeView({
node,
view,
getPos,
decorations,
component: Emoji,
onCreatePortal: props.createPortal,
onRemovePortal: props.removePortal,
})
},
},
dispatchTransaction(tr) {
const state = view.state.apply(tr)
view.updateState(state)
props.setState(state)
},
})
return view
}
function Provider(props: PropsWithChildren<Props>) {
const { createPortal, removePortal } = useReactNodeViewCreatePortal()
const [state, setState] = useState(generateState(props))
const [view, setView] = useState<EditorView | null>(null)
//
// useEffect(() => {
// if (!view) return
// if (!props.forceUpdate) return
// if (props.forceUpdate === 2) return
//
// // const newState = generateState({
// // doc: props.doc,
// // plugins: view.state.plugins,
// // selection: view.state.selection,
// // storedMarks: view.state.storedMarks,
// // })
// // view.state.tr.replace(
// // 0,
// // view.state.doc.content.size,
// // new Slice<any>(props.doc?.content!, 0, 0),
// // )
// // setState(newState)
// // view.updateState(newState)
//
// if (!view.state.doc.content.size) return
// console.log('forceUpdate!')
//
// const tr = view.state.tr.replaceWith(
// 0,
// view.state.doc.content.size,
// props.doc?.content!,
// )
// view.dispatch(
// tr.setSelection(
// TextSelection.create(
// tr.doc,
// view.state.selection.anchor,
// view.state.selection.head,
// ),
// ),
// )
// }, [props.forceUpdate])
const resetView = useCallback(() => {
setView(
generateView({
...props,
state: generateState({
doc: props.doc,
plugins: props.plugins,
}),
setState,
createPortal,
removePortal,
}),
)
}, [props])
useEffect(() => {
resetView()
/* eslint react-hooks/exhaustive-deps: off */
}, [props.editable])
useEffect(() => {
resetView()
/* eslint react-hooks/exhaustive-deps: off */
}, [props.resetView])
return (
<EditorStateContext.Provider value={state}>
<EditorViewContext.Provider value={view}>
{props.children}
</EditorViewContext.Provider>
</EditorStateContext.Provider>
)
}
import type { Node as ProsemirrorNode, Schema } from 'prosemirror-model'
import type { Plugin } from 'prosemirror-state'
import type { EditorProps } from 'prosemirror-view'
import type { PropsWithChildren } from 'react'
import React, { useMemo } from 'react'
import { useDebounce } from '../../hooks/useDebounce'
import { usePrevious } from '../../hooks/usePrevious'
import type { ProsemirrorTransformer } from '../../prosemirror/transformers'
import { createJSONTransformer } from '../../prosemirror/transformers'
import { EditorProvider, useEditorStateContext } from './EdiorProvider'
import { Portals } from './Portals'
type Props = PropsWithChildren<{
schema: Schema
plugins: Plugin[]
initialValue: string
onChange?: (value: string) => void
debounce: number
forceUpdate?: number
resetView?: number
}> &
EditorProps
export function EditorContainer(props: Props) {
const transformer = useMemo<ProsemirrorTransformer>(
() => createJSONTransformer(props.schema),
[props.schema],
)
const initialDoc = useMemo<ProsemirrorNode>(
() => transformer.parse(props.initialValue),
[props.initialValue, transformer],
)
return (
<>
<EditorProvider
plugins={props.plugins}
doc={initialDoc}
editable={props.editable}
forceUpdate={props.forceUpdate}
resetView={props.resetView}
>
<Container
transformer={transformer}
debounce={props.debounce}
onChange={props.onChange}
initialValue={props.initialValue}
>
{props.children}
</Container>
<Portals />
</EditorProvider>
</>
)
}
type ContainerProps<P> = {
onChange?: (value: P) => void
transformer: ProsemirrorTransformer<P>
debounce: number
initialValue: string
}
export function Container<P>(props: PropsWithChildren<ContainerProps<P>>) {
const state = useEditorStateContext()
const prevStateDoc = usePrevious<ProsemirrorNode>(state.doc)
useDebounce(
state.doc,
(val) => {
const serializedValue = props.transformer.serialize(val)
if (
prevStateDoc &&
serializedValue === props.transformer.serialize(prevStateDoc)
)
return
props.onChange?.(serializedValue)
},
props.debounce,
)
return <>{props.children}</>
}
import { Box } from '@chakra-ui/react'
import type { CSSProperties } from 'react'
import React, { useEffect, useRef, memo, useLayoutEffect } from 'react'
import { useEditorViewContext } from './EdiorProvider'
import 'prosemirror-view/style/prosemirror.css'
import '../../prosemirror/style/style.css'
type Props = {
style?: CSSProperties
onRendered?: () => void
}
export const EditorContent = memo<Props>(function EditorContent(props) {
const { style, onRendered } = props
const view = useEditorViewContext()
const ref = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
const current = ref.current
if (current && view) {
if (style) {
Object.keys(style).forEach((k: any) => {
;(view.dom as HTMLElement).style[k] = (style as any)[k]
})
}
current.appendChild(view.dom)
onRendered?.()
}
return () => {
if (current && view) {
current.removeChild(view.dom)
}
}
/* eslint react-hooks/exhaustive-deps: off */
}, [view])
useEffect(() => {
setTimeout(() => {
if (!view?.dom) return
// Explicitly enable `focus ring` style
// @see https://github.com/WICG/focus-visible#2-update-your-css
view.dom.classList.add('focus-visible')
}, 300)
}, [view])
return <Box ref={ref} />
})
import type { FlexProps } from '@chakra-ui/react'
import { Flex, Text } from '@chakra-ui/react'
import { useMemo, memo } from 'react'
import { isContentEmpty } from '../../prosemirror/utils'
import { useEditorViewContext } from './EdiorProvider'
type Props = FlexProps
export const EditorPlaceholder = memo<Props>(function EditorPlaceholder(props) {
const { children, ...rest } = props
const view = useEditorViewContext()
const show = useMemo(() => {
if (!view) return true
return isContentEmpty(view)
}, [view])
if (!show) return null
return (
<Flex
position="absolute"
top={2}
left={2}
w="full"
h="full"
pointerEvents="none"
alignItems="center"
{...rest}
>
<Text fontSize="sm" color="gray.500">
{children}
</Text>
</Flex>
)
})
import React, { memo } from 'react'
import ReactDOM from 'react-dom'
import { useReactNodeViewPortals } from './ReactNodeViewPortals'
export const Portals = memo(function Portals() {
const portals = useReactNodeViewPortals()
return (
<>
{portals.map((p) =>
ReactDOM.createPortal(<p.Component />, p.container, p.key),
)}
</>
)
})
import type { Node } from 'prosemirror-model'
import { DOMSerializer } from 'prosemirror-model'
import type { Decoration, EditorView, NodeView } from 'prosemirror-view'
import React, { useContext, useEffect, useRef } from 'react'
import {
entries,
isDomNodeOutputSpec,
isElementDomNode,
isNodeOfType,
isPlainObject,
isString,
} from '../../prosemirror/utils'
import type { PortalHandlers } from './ReactNodeViewPortals'
type ReactNodeViewContextProps = {
node: Node
view: EditorView
getPos: TGetPos
decorations: readonly Decoration[]
}
const ReactNodeViewContext = React.createContext<
Partial<ReactNodeViewContextProps>
>({
node: undefined,
view: undefined,
getPos: undefined,
decorations: undefined,
})
type TGetPos = () => number | undefined
class ReactNodeView implements NodeView {
componentRef: React.RefObject<HTMLDivElement>
dom: any
contentDOM: NodeView['contentDOM']
contentDOMWrapper?: HTMLElement | undefined
component: React.FC<any>
node: Node
view: EditorView
getPos: TGetPos
decorations: readonly Decoration[]
onCreatePortal: (portal: { Component: any; container: any }) => void
onRemovePortal: (container: HTMLElement) => void
constructor(
node: Node,
view: EditorView,
getPos: TGetPos,
decorations: readonly Decoration[],
component: React.FC<any>,
onCreatePortal: PortalHandlers['createPortal'],
onRemovePortal: PortalHandlers['removePortal'],
) {
this.node = node
this.view = view
this.getPos = getPos
this.decorations = decorations
this.component = component
this.componentRef = React.createRef()
this.onCreatePortal = onCreatePortal
this.onRemovePortal = onRemovePortal
}
init() {
this.dom = this.node.type.spec.inline
? document.createElement('span')
: document.createElement('div')
const { contentDOM, wrapper } = this.createContentDom() ?? {}
this.contentDOM = contentDOM
this.contentDOMWrapper = wrapper
if (this.contentDOMWrapper) {
this.dom.append(this.contentDOMWrapper)
}
this.setDomAttributes(this.node, this.dom)
this.renderPortal()
return this
}
createContentDom() {
if (this.node.isLeaf) return
const domSpec = this.node.type.spec.toDOM?.(this.node)
if (!domSpec) return
const { contentDOM, dom } = DOMSerializer.renderSpec(document, domSpec)
if (!isElementDomNode(dom)) return
const wrapper = dom
return { wrapper, contentDOM }
}
renderPortal() {
const Component: React.FC = (props) => {
const componentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const componentDOM = componentRef.current
if (!!componentDOM && !!this.contentDOM) {
if (!this.node.isLeaf) {
componentDOM.firstChild?.appendChild(this.contentDOM)
}
}
}, [componentRef])
const NodeView = this.component
return (
<span ref={componentRef} className="ProseMirror__reactComponent">
<ReactNodeViewContext.Provider
value={{
node: this.node,
view: this.view,
getPos: this.getPos,
decorations: this.decorations,
}}
>
<NodeView {...props} />
</ReactNodeViewContext.Provider>
</span>
)
}
return this.onCreatePortal({ Component, container: this.dom })
}
update(node: Node) {
if (!isNodeOfType({ types: this.node.type, node })) return false
if (this.node === node) return true
if (!this.node.sameMarkup(node)) {
this.setDomAttributes(node, this.dom!)
}
this.node = node
this.renderPortal()
return true
}
setDomAttributes(node: any, element: HTMLElement): void {
const { toDOM } = this.node.type.spec
let attributes = node.attrs
if (toDOM) {
const domSpec = toDOM(node)
if (isString(domSpec) || isDomNodeOutputSpec(domSpec)) {
return
}
if (isPlainObject(domSpec[1])) {
attributes = domSpec[1]
}
}
for (const [attribute, value] of entries(attributes)) {
element.setAttribute(attribute, value)
}
}
destroy() {
const dom = this.dom as HTMLElement
this.onRemovePortal(dom)
this.dom = undefined
this.contentDOM = undefined
}
ignoreMutation() {
return true
}
}
type CreateReactNodeViewProps = {
component: React.FC<any>
onCreatePortal: PortalHandlers['createPortal']
onRemovePortal: PortalHandlers['removePortal']
} & ReactNodeViewContextProps
export const createReactNodeView = (props: CreateReactNodeViewProps) => {
const reactNodeView = new ReactNodeView(
props.node,
props.view,
props.getPos,
props.decorations,
props.component,
props.onCreatePortal,
props.onRemovePortal,
)
return reactNodeView.init()
}
export const useReactNodeView = () => useContext(ReactNodeViewContext)
import type { PropsWithChildren } from 'react'
import React, { useCallback, useContext, useState } from 'react'
import shortid from 'shortid'
import { uniqBy } from '../../utils/uniqBy'
type Portal = {
Component: React.FC
container: HTMLElement
key: string
}
const ReactNodeViewPortalsContext = React.createContext<Portal[]>([])
export type PortalHandlers = {
createPortal: (portal: { Component: any; container: any }) => void
removePortal: (container: HTMLElement) => void
}
const ReactNodeViewCreatePortalContext = React.createContext<PortalHandlers>({
createPortal: () => {},
removePortal: () => {},
})
export function ReactNodeViewPortalsProvider(props: PropsWithChildren) {
const [portals, setPortals] = useState<Portal[]>([])
const findPortal = useCallback(
(container: HTMLElement) => portals.find((p) => p.container === container),
[portals],
)
const createPortal = useCallback(
({ container, Component }: { Component: any; container: any }) => {
const newVal: Portal = {
container,
Component,
key: findPortal(container)?.key ?? shortid(),
}
setPortals((prev) => {
return uniqBy([...prev, newVal], 'container').map((p) => {
if (p.container === newVal.container) {
return {
...p,
...newVal,
}
}
return p
})
})
},
[findPortal],
)
const removePortal = useCallback((container: HTMLElement) => {
setPortals((prev) => {
return prev.filter((p) => p.container !== container)
})
}, [])
return (
<ReactNodeViewPortalsContext.Provider value={portals}>
<ReactNodeViewCreatePortalContext.Provider
value={{
createPortal,
removePortal,
}}
>
{props.children}
</ReactNodeViewCreatePortalContext.Provider>
</ReactNodeViewPortalsContext.Provider>
)
}
export const useReactNodeViewPortals = () =>
useContext(ReactNodeViewPortalsContext)
export const useReactNodeViewCreatePortal = () =>
useContext(ReactNodeViewCreatePortalContext)
import type { IconButtonProps } from '@chakra-ui/react'
import type { TooltipProps } from '@chakra-ui/react'
import { IconButton } from '@chakra-ui/react'
import { Tooltip, Flex } from '@chakra-ui/react'
import React, { useCallback } from 'react'
import type { ToolbarItem } from '../../prosemirror/hooks'
import {
useEditorStateContext,
useEditorViewContext,
} from '../Editors/EdiorProvider'
type Props = {
isActive?: ToolbarItem['isActive']
isEnable?: ToolbarItem['isEnable']
action: ToolbarItem['action']
tooltip: Omit<TooltipProps, 'children'>
} & Omit<IconButtonProps, 'isActive'>
export function BaseButton(props: Props) {
const state = useEditorStateContext()
const view = useEditorViewContext()
const { onClick, tooltip, action, isEnable, isActive, ...rest } = props
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!view) return
e.preventDefault()
action(state, view.dispatch, view)
},
[action, state, view],
)
return (
<Tooltip hasArrow {...tooltip} size="sm" openDelay={500}>
<Flex as="span" alignItems="center">
<IconButton
variant="ghost"
size="sm"
colorScheme="teal"
onMouseDown={handleMouseDown}
{...rest}
isActive={isActive?.(state) ?? false}
isDisabled={isEnable?.(state) === false}
_disabled={{
cursor: 'pointer',
opacity: 0.4,
boxShadow: 'none',
}}
/>
</Flex>
</Tooltip>
)
}
import type { FlexProps } from '@chakra-ui/react'
import { Flex, Text } from '@chakra-ui/react'
import React, { memo, useCallback, useEffect, useMemo } from 'react'
import type { BaseEmoji } from '../../emoji'
import { useHover } from '../../hooks/useHover'
import { useMenuStyle } from '../../hooks/useMenuStyle'
import { useEditorEmojiMenuContext } from '../Provider'
type Props = Override<
FlexProps,
{
onClick: (val: BaseEmoji) => void
}
> & {
emoji: BaseEmoji
index: number
}
export const EmojiItem = memo<Props>(function EmojiItem(props) {
const { onClick, ...rest } = props
const styles = useMenuStyle().item
const { ref, isHovering } = useHover()
const { state, setSelectedIndex } = useEditorEmojiMenuContext()
delete styles._hover
const handleClick = useCallback(() => {
onClick(props.emoji)
}, [onClick, props.emoji])
useEffect(() => {
if (isHovering) setSelectedIndex(props.index)
}, [isHovering, props.index, setSelectedIndex])
const selected = useMemo(
() => props.index === state.selectedIndex,
[props.index, state.selectedIndex],
)
return (
<Flex
ref={ref}
{...(styles as FlexProps)}
bg={selected ? 'gray.100' : 'transparent'}
_dark={{
...styles._dark,
bg: selected ? 'gray.600' : 'transparent',
}}
fontSize="sm"
alignItems="center"
onClick={handleClick}
{...rest}
>
<Text fontSize="sm">{props.emoji.native}</Text>
<Text
ml={2}
fontSize="sm"
color="gray.500"
_dark={{
color: 'white',
}}
>
{props.emoji.colons}
</Text>
</Flex>
)
})
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import { useEffect } from 'react'
import { useRef } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { createProvider } from '../../createProvider'
import type { BaseEmoji } from '../../emoji'
import { emojiData } from '../../emoji'
import { useResizeObserver } from '../../hooks/useResizeObserver'
import { calculateModalPosition } from '../../position/calculateModalPosition'
import { getCaretPosition } from '../../position/getCaretPosition'
import { defaultEmojis } from './defaultEmojis'
import { setEmojiRef } from './emojiRef'
import type { State } from './state'
import { initializeState } from './state'
// NOTE: Export functions in order to execute inside prosemirror's plugins
// @see src/shared/prosemirror/config/plugins.ts
let onOpen: () => Promise<void> | void
let onClose: () => void
let setQuery: (query: string) => void
let getQuery: () => string
let onArrowDown: () => void
let onArrowUp: () => void
let onEnter: () => void
let isOpen: boolean
let getCurrentCaretPosition: () => { x: number; y: number } | null
type ContextProps = {
state: State
setState: Dispatch<SetStateAction<State>>
setValue: (val: BaseEmoji) => void
emojis: BaseEmoji[]
setSelectedIndex: (val: number) => void
containerRef: MutableRefObject<HTMLDivElement | null>
isOpen: boolean
onClose: () => void
}
const useValue = (): ContextProps => {
const { setState, setValue, setSelectedIndex, reset, state, emojis } =
useCore()
const { containerRef } = useContainer({ state, setState })
useQuery({ state, setState })
useDisclosure({ state, setState, reset })
useOnKeyBindings({ state, setState, setValue, emojis })
return {
state,
setState,
setValue,
emojis,
setSelectedIndex,
containerRef,
isOpen,
onClose,
}
}
function useCore() {
const [state, setState] = useState<State>(initializeState())
const resetState = useCallback(() => {
setState(initializeState())
}, [])
const setValue = useCallback((val: BaseEmoji) => {
setEmojiRef(val)
onClose()
}, [])
const emojis = useMemo<BaseEmoji[]>(() => {
if (!state.query) return defaultEmojis()
return (
(emojiData.search(state.query.toLowerCase()) as BaseEmoji[])?.map(
(o) => o,
) || []
).slice(0, 10)
}, [state.query])
const setSelectedIndex = useCallback(
(val: number) => {
setState((s) => ({ ...s, selectedIndex: val }))
},
[setState],
)
const reset = useCallback(() => {
resetState()
setEmojiRef(null)
}, [resetState])
return {
state,
setState,
setValue,
emojis,
setSelectedIndex,
reset,
}
}
function useQuery({
state,
setState,
}: {
state: State
setState: Dispatch<SetStateAction<State>>
}) {
setQuery = useCallback(
(query) => {
setState((s) => ({ ...s, query }))
},
[setState],
)
getQuery = useCallback(() => state.query, [state.query])
}
function useContainer({
state,
setState,
}: {
state: State
setState: Dispatch<SetStateAction<State>>
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (containerRef.current) {
setState((s) => ({
...s,
containerRef: containerRef.current,
}))
}
return () => {
setState((s) => ({ ...s, containerRef: null }))
}
}, [setState])
// TODO: Make text input faster and more smoothly.
useResizeObserver(containerRef, () => {
if (!containerRef.current) return
const caretPosition = getCurrentCaretPosition()
if (!caretPosition) return null
const position = calculateModalPosition(containerRef.current, {
y: caretPosition.y,
})
if (!position) return
if (position.y === state.y) return
setState((s) => ({ ...s, ...position }))
})
return {
containerRef,
}
}
function useDisclosure({
state,
setState,
reset,
}: {
reset: () => void
state: State
setState: Dispatch<SetStateAction<State>>
}) {
getCurrentCaretPosition = useCallback(() => {
const position = getCaretPosition()
if (!position) return null
position.y += 24
return position
}, [])
onOpen = useCallback(() => {
// Avoid recalculate the position while the modal is opening
const position = isOpen ? {} : getCurrentCaretPosition()
if (!position) return
isOpen = true
return new Promise<void>((resolve) => {
setState((s) => ({
...s,
isOpen: true,
callback: resolve as () => Promise<void>,
...position,
}))
})
}, [setState])
onClose = useCallback(async () => {
isOpen = false
setState((s) => ({ ...s, isOpen: false }))
await state.callback()
// Use setTimeout to prevent moving back to the initial position ({ top: 0, left: 0 }) before closing
setTimeout(() => {
console.log('reset!')
reset()
}, 200)
}, [reset, setState, state])
}
function useOnKeyBindings({
emojis,
setState,
setValue,
state,
}: {
emojis: BaseEmoji[]
setValue: (emoji: BaseEmoji) => void
state: State
setState: Dispatch<SetStateAction<State>>
}) {
const scrollTo = useCallback(
(index: number) => {
const dom = state.containerRef
if (!dom) return
if (index === 0) dom.scrollTop = 0
if (index < 5) return
dom.scrollTop += 50 * index
},
[state.containerRef],
)
onArrowDown = useCallback(() => {
const selectedIndex = state.selectedIndex + 1
if (selectedIndex > emojis.length) {
setState((s) => ({ ...s, selectedIndex: 0 }))
scrollTo(0)
return
}
setState((s) => ({ ...s, selectedIndex }))
scrollTo(selectedIndex)
}, [emojis, scrollTo, setState, state.selectedIndex])
onArrowUp = useCallback(() => {
const selectedIndex = state.selectedIndex - 1
if (selectedIndex < 0) {
setState((s) => ({ ...s, selectedIndex: emojis.length }))
scrollTo(emojis.length)
return
}
setState((s) => ({ ...s, selectedIndex }))
scrollTo(-selectedIndex)
}, [emojis.length, scrollTo, setState, state.selectedIndex])
onEnter = useCallback(() => {
const emoji = emojis.find((_, i) => i === state.selectedIndex)
if (!emoji) return
setValue(emoji)
}, [emojis, setValue, state.selectedIndex])
}
useValue.__PROVIDER__ = 'EditorEmojiMenu/Provider.tsx'
export const {
Provider: EditorEmojiMenuProvider,
useContext: useEditorEmojiMenuContext,
} = createProvider(useValue)
export {
onOpen as onEmojiOpen,
onClose as onEmojiClose,
setQuery as setEmojiQuery,
getQuery as getEmojiQuery,
onArrowDown as onEmojiArrowDown,
onArrowUp as onEmojiArrowUp,
onEnter as onEmojiEnter,
isOpen as isEmojiOpen,
getCurrentCaretPosition,
}
import type { BaseEmoji, EmojiData, EmojiSkin } from '../../emoji'
import { emojiData, frequently } from '../../emoji'
const DEFAULT_EMOJIS = [
'grinning',
'laughing',
'sweat_smile',
'joy',
'scream',
'sob',
'sunglasses',
]
type EmojiWithSkin = { [variant in EmojiSkin]: EmojiData }
const isEmojiWithSkin = (data: any): data is EmojiWithSkin => !!data[1]
export const defaultEmojis = (): BaseEmoji[] => {
const frequentlyEmojis = frequently.get(2)
const data = frequentlyEmojis.length
? frequentlyEmojis.slice(0, 7)
: DEFAULT_EMOJIS
return data.map((e) => {
const matched = emojiData.emojis[e]
if (isEmojiWithSkin(matched)) return matched[1]
return matched
}) as BaseEmoji[]
}
import type { BaseEmoji } from '../../emoji'
type EmojiRef = Readonly<{ current: BaseEmoji | null }>
const emojiRef: EmojiRef = {
current: null,
}
export const getEmoji = () => emojiRef.current
export const setEmojiRef = (val: BaseEmoji | null) =>
void ((emojiRef as Writeable<EmojiRef>).current = val)
export type State = {
isOpen: boolean
x: number
y: number
query: string
callback: () => Promise<void>
selectedIndex: number
containerRef: HTMLDivElement | null
}
export const initializeState = (): State => ({
isOpen: false,
x: 0,
y: 0,
query: '',
callback: () => Promise.resolve(),
selectedIndex: 0,
containerRef: null,
})
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React, { memo } from 'react'
import { useMenuStyle } from '../../hooks/useMenuStyle'
type Props = FlexProps
export const Empty = memo<Props>(function Empty(props) {
const styles = useMenuStyle().item
return (
<Flex
fontSize="sm"
{...(styles as FlexProps)}
color="gray.500"
pointerEvents="none"
{...props}
>
{props.children}
</Flex>
)
})
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React, { memo } from 'react'
type Props = FlexProps
export const LeftContainer = memo<Props>(function LeftContainer(props) {
return <Flex alignItems="center" justifyContent="center" w={8} {...props} />
})
import type { FlexProps } from '@chakra-ui/react'
import React, { memo } from 'react'
import { MentionType } from '../../store/mention'
import type { Mention } from '../../store/mention'
import type { SetValueParam } from '../Provider'
import { MentionItemBase } from './MentionItemBase'
import { Teammate } from './Teammate'
type Props = Override<
FlexProps,
{
onClick: (val: SetValueParam) => void
}
> & {
mention: Mention
index: number
}
export const MentionItem = memo<Props>(function MentionItem(props) {
const { onClick, mention, ...rest } = props
switch (mention.type) {
case MentionType.TEAMMATE:
return (
<MentionItemBase {...props}>
<Teammate {...rest} mention={props.mention} />
</MentionItemBase>
)
}
})
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React, { memo, useCallback, useEffect, useMemo } from 'react'
import { useHover } from '../../hooks/useHover'
import { useMenuStyle } from '../../hooks/useMenuStyle'
import type { Mention } from '../../store/mention'
import { useEditorMentionMenuContext } from '../Provider'
import type { SetValueParam } from '../Provider'
type Props = Override<FlexProps, { onClick: (val: SetValueParam) => void }> & {
mention: Mention
index: number
}
export const MentionItemBase = memo<Props>(function MentionItemBase(props) {
const { onClick, ...rest } = props
const styles = useMenuStyle().item
const { ref, isHovering } = useHover()
const { state, setSelectedIndex } = useEditorMentionMenuContext()
delete styles._hover
const handleClick = useCallback(() => {
onClick({ id: props.mention.id, type: props.mention.type })
}, [onClick, props.mention])
useEffect(() => {
if (isHovering) setSelectedIndex(props.index)
}, [isHovering, props.index, setSelectedIndex])
const selected = useMemo(
() => props.index === state.selectedIndex,
[props.index, state.selectedIndex],
)
return (
<Flex
ref={ref}
{...(styles as FlexProps)}
bg={selected ? 'gray.100' : 'transparent'}
_dark={{
...styles._dark,
bg: selected ? 'gray.600' : 'transparent',
}}
fontSize="sm"
onClick={handleClick}
{...rest}
>
{props.children}
</Flex>
)
})
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React, { memo } from 'react'
type Props = FlexProps
export const RightContainer = memo<Props>(function RightContainer(props) {
return <Flex alignItems="center" flex={1} ml={2} {...props} />
})
import type { FlexProps } from '@chakra-ui/react'
import { Avatar, Flex, Text } from '@chakra-ui/react'
import React, { memo } from 'react'
import { useTeammate } from '../../hooks/useMentionsQuery'
import type { Mention } from '../../store/mention'
import { LeftContainer } from './LeftContainer'
import { RightContainer } from './RightContainer'
type Props = FlexProps & {
mention: Mention
}
export const Teammate = memo<Props>(function Teammate(props) {
const { teammate } = useTeammate(props.mention.id)
return (
<Flex alignItems="center" flex={1}>
<LeftContainer>
<Avatar
name={teammate.name}
src={teammate.image}
size="xs"
cursor="pointer"
bg="teal.200"
/>
</LeftContainer>
<RightContainer>
<Text fontSize="sm">{teammate.name}</Text>
<Text ml={5} fontSize="xs" color="gray.500">
{teammate.email}
</Text>
</RightContainer>
</Flex>
)
})
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import { memo, useEffect, useMemo } from 'react'
import { useHover } from '../../hooks/useHover'
import { useMenuStyle } from '../../hooks/useMenuStyle'
import { useSearchMenuIndex } from './useSearchMenuIndex'
type Props = FlexProps & {
index: number
}
export const MenuListItem = memo<Props>(function MenuListItem(props) {
const { _hover: _, ...style } = useMenuStyle().item
const { ref, isHovering } = useHover()
const { selectedIndex, setSelectedIndex } = useSearchMenuIndex()
useEffect(() => {
if (isHovering) setSelectedIndex(props.index)
}, [isHovering, props.index, setSelectedIndex])
const selected = useMemo(
() => props.index === selectedIndex,
[props.index, selectedIndex],
)
return (
<Flex
ref={ref}
{...(style as FlexProps)}
bg={selected ? style._focus.bg : 'transparent'}
fontSize="sm"
{...props}
/>
)
})
import { Spinner } from '@chakra-ui/react'
import React, { memo } from 'react'
import { MenuListItem } from './MenuListItem'
export const MenuLoading = memo(function MenuLoading() {
return (
<MenuListItem index={-1} alignItems="center" justifyContent="center">
<Spinner size="sm" color="gray.400" emptyColor="gray.200" />
</MenuListItem>
)
})
import { useState } from 'react'
export const useSearchMenuIndex = () => {
const [selectedIndex, setSelectedIndex] = useState<number>(0)
return {
selectedIndex,
setSelectedIndex,
}
}
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { createProvider } from '../../createProvider'
import { useMentionsQuery } from '../../hooks/useMentionsQuery'
import { useResizeObserver } from '../../hooks/useResizeObserver'
import { calculateModalPosition } from '../../position/calculateModalPosition'
import { getCaretPosition } from '../../position/getCaretPosition'
import type { Mention } from '../../store/mention'
import type { SetValueParam } from './mention'
import { setMentionIdRef, setMentionTypeRef } from './mention'
import type { State } from './state'
import { initializeState } from './state'
// NOTE: Export functions in order to execute inside prosemirror's plugins
// @see src/shared/prosemirror/config/plugins.ts
let onOpen: () => Promise<void> | void
let onClose: () => void
let setQuery: (query: string) => void
let getQuery: () => string
let onArrowDown: () => void
let onArrowUp: () => void
let onEnter: () => void
let isOpen: boolean
let getCurrentCaretPosition: () => { x: number; y: number } | null
type ContextProps = {
state: State
setState: Dispatch<SetStateAction<State>>
setSelectedIndex: (val: number) => void
containerRef: MutableRefObject<HTMLDivElement | null>
isOpen: boolean
onClose: () => void
onOpen: () => void
refetch: (params: { queryText: string }) => Promise<Mention[]>
loading: boolean
setValue: (val: SetValueParam) => void
mentions: Mention[]
}
const useValue = (): ContextProps => {
const { setState, state, setValue, setSelectedIndex, reset } = useCore()
const { refetch, mentions, loading } = useMentionsQuery()
const { containerRef } = useContainer({ setState, state })
useOnKeyBindings({ mentions, setValue, state, setState })
useQuery({ setState, state })
useDisclosure({ reset, setState, state })
return {
state,
setState,
refetch,
loading,
setValue,
onOpen,
onClose,
mentions,
setSelectedIndex,
containerRef,
isOpen,
}
}
function useCore() {
const [state, setState] = useState<State>(initializeState())
const resetState = useCallback(() => {
setState(initializeState())
}, [])
const reset = useCallback(() => {
setMentionIdRef(null)
setMentionTypeRef(null)
resetState()
}, [resetState])
const setValue = useCallback((params: SetValueParam) => {
setMentionIdRef(params.id)
setMentionTypeRef(params.type)
onClose()
}, [])
const setSelectedIndex = useCallback(
(val: number) => {
setState((s) => ({ ...s, selectedIndex: val }))
},
[setState],
)
return {
state,
setState,
resetState,
reset,
setValue,
setSelectedIndex,
}
}
function useOnKeyBindings(props: {
mentions: Mention[]
setValue: (val: SetValueParam) => void
state: State
setState: Dispatch<SetStateAction<State>>
}) {
const scrollTo = useCallback(
(index: number) => {
const dom = props.state.containerRef
if (!dom) return
if (index === 0) dom.scrollTop = 0
if (index < 5) return
dom.scrollTop += 50 * index
},
[props.state.containerRef],
)
onArrowDown = useCallback(() => {
const selectedIndex = props.state.selectedIndex + 1
if (selectedIndex > props.mentions.length) {
props.setState((s) => ({ ...s, selectedIndex: 0 }))
scrollTo(0)
return
}
props.setState((s) => ({ ...s, selectedIndex }))
scrollTo(selectedIndex)
}, [props, scrollTo])
onArrowUp = useCallback(() => {
const selectedIndex = props.state.selectedIndex - 1
if (selectedIndex < 0) {
props.setState((s) => ({ ...s, selectedIndex: props.mentions.length }))
scrollTo(props.mentions.length)
return
}
props.setState((s) => ({ ...s, selectedIndex }))
scrollTo(-selectedIndex)
}, [props, scrollTo])
onEnter = useCallback(() => {
const mention = props.mentions.find(
(_, i) => i === props.state.selectedIndex,
)
// Do nothing when it is entered without selecting an item
if (!mention) return
props.setValue({ id: mention.id, type: mention.type })
}, [props])
}
function useDisclosure(props: {
reset: () => void
state: State
setState: Dispatch<SetStateAction<State>>
}) {
getCurrentCaretPosition = useCallback(() => {
const position = getCaretPosition()
if (!position) return null
position.y += 24
return position
}, [])
onOpen = useCallback(() => {
// Avoid recalculate the position while the modal is opening
const position = isOpen ? {} : getCurrentCaretPosition()
if (!position) return
isOpen = true
return new Promise<void>((resolve) => {
props.setState((s) => ({
...s,
isOpen: true,
callback: resolve as () => Promise<void>,
...position,
}))
})
}, [props])
onClose = useCallback(async () => {
isOpen = false
props.setState((s) => ({ ...s, isOpen: false }))
await props.state.callback()
// Use setTimeout to prevent moving back to the initial position ({ top: 0, left: 0 }) before closing
setTimeout(() => {
props.reset()
}, 200)
}, [props])
}
function useQuery({
setState,
state,
}: {
state: State
setState: Dispatch<SetStateAction<State>>
}) {
setQuery = useCallback(
(query) => {
setState((s) => ({ ...s, query }))
},
[setState],
)
getQuery = useCallback(() => state.query, [state.query])
}
function useContainer({
setState,
state,
}: {
state: State
setState: Dispatch<SetStateAction<State>>
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (containerRef.current) {
setState((s) => ({
...s,
containerRef: containerRef.current,
}))
}
return () => {
setState((s) => ({ ...s, containerRef: null }))
}
}, [setState])
// TODO: Make text input faster and more smoothly.
useResizeObserver(containerRef, () => {
if (!containerRef.current) return
const caretPosition = getCurrentCaretPosition()
if (!caretPosition) return null
const position = calculateModalPosition(containerRef.current, {
y: caretPosition.y,
})
if (!position) return
if (position.y === state.y) return
setState((s) => ({ ...s, ...position }))
})
return {
containerRef,
}
}
useValue.__PROVIDER__ = 'EditorMentionMenu/Provider.tsx'
export const {
Provider: EditorMentionMenuProvider,
useContext: useEditorMentionMenuContext,
} = createProvider(useValue)
export {
onOpen as onMentionOpen,
onClose as onMentionClose,
setQuery as setMentionQuery,
getQuery as getMentionQuery,
onArrowDown as onMentionArrowDown,
onArrowUp as onMentionArrowUp,
onEnter as onMentionEnter,
isOpen as isMentionOpen,
}
import type { MentionTypeCode } from '../../store/mention'
export type Id = string | null
type IdRef = Readonly<{ current: Id }>
const idRef: IdRef = {
current: '',
}
export const getMentionId = () => idRef.current
export const setMentionIdRef = (val: Id) =>
void ((idRef as Writeable<IdRef>).current = val)
type TypeRef = Readonly<{ current: MentionTypeCode | null }>
const typeRef: IdRef = {
current: null,
}
export const getMentionType = () => typeRef.current
export const setMentionTypeRef = (val: MentionTypeCode | null) =>
void ((typeRef as Writeable<TypeRef>).current = val)
export type SetValueParam = {
id: Id
type: MentionTypeCode
}
export type State = {
isOpen: boolean
x: number
y: number
query: string
callback: () => Promise<void>
selectedIndex: number
containerRef: HTMLDivElement | null
}
export const initializeState = (): State => ({
isOpen: false,
x: 0,
y: 0,
query: '',
callback: () => Promise.resolve(),
selectedIndex: 0,
containerRef: null,
})
import type {
MarkType,
Node as ProsemirrorNode,
NodeType,
ResolvedPos,
} from 'prosemirror-model'
import type { Command } from 'prosemirror-state'
import type { EditorState, NodeSelection, Selection } from 'prosemirror-state'
import { findWrapping, liftTarget } from 'prosemirror-transform'
export const insertNodeOfType =
(nodeType: NodeType): Command =>
(state, dispatch) => {
const node = nodeType.create()
if (dispatch) {
dispatch(state.tr.replaceSelectionWith(node).scrollIntoView())
}
return true
}
export const isMarkActive =
(markType: MarkType) =>
(state: EditorState): boolean => {
const { from, $from, to, empty } = state.selection
if (empty) {
return Boolean(markType.isInSet(state.storedMarks || $from.marks()))
}
return state.doc.rangeHasMark(from, to, markType)
}
const isNodeSelection = (selection: Selection): selection is NodeSelection =>
'node' in selection
export const isBlockActive =
(type: NodeType, attrs: Record<string, unknown> = {}) =>
(state: EditorState): boolean => {
if (isNodeSelection(state.selection)) {
return state.selection.node.hasMarkup(type, attrs)
}
const { $from, to } = state.selection
return to <= $from.end() && $from.parent.hasMarkup(type, attrs)
}
const parentWithNodeType = (
$pos: ResolvedPos,
nodeType: NodeType,
): ProsemirrorNode | undefined => {
for (let depth = $pos.depth; depth >= 0; depth--) {
const parent = $pos.node(depth)
if (parent.type === nodeType) {
return parent
}
}
}
const parentWithNodeTypePos = (
$pos: ResolvedPos,
nodeType: NodeType,
): number | undefined => {
for (let depth = $pos.depth; depth >= 0; depth--) {
const parent = $pos.node(depth)
if (parent.type === nodeType) {
return $pos.before(depth)
}
}
}
export const parentInGroupPos = (
$pos: ResolvedPos,
nodeTypeGroup: string,
): number | undefined => {
for (let depth = $pos.depth; depth >= 0; depth--) {
const parent = $pos.node(depth)
const { group } = parent.type.spec
if (group && group.split(/\s+/).includes(nodeTypeGroup)) {
return $pos.before(depth)
}
}
}
export const isWrapped =
(nodeType: NodeType) =>
(state: EditorState): boolean => {
const { $from, $to } = state.selection
const range = $from.blockRange($to)
if (!range) {
return false
}
return parentWithNodeType(range.$from, nodeType) !== undefined
}
export const toggleWrap =
(nodeType: NodeType, attrs?: Record<string, unknown>): Command =>
(state, dispatch): boolean => {
const { $from, $to } = state.selection
const range = $from.blockRange($to)
if (!range) {
return false
}
const parentPos = parentWithNodeTypePos(range.$from, nodeType)
if (typeof parentPos === 'number') {
// unwrap
const target = liftTarget(range)
if (typeof target !== 'number') {
return false
}
if (dispatch) {
dispatch(state.tr.lift(range, target).scrollIntoView())
}
return true
} else {
// wrap
const wrapping = findWrapping(range, nodeType, attrs)
if (!wrapping) {
return false
}
if (dispatch) {
dispatch(state.tr.wrap(range, wrapping).scrollIntoView())
}
return true
}
}
export const setListTypeOrWrapInList =
(listType: NodeType, attrs: { type: string }): Command =>
(state, dispatch) => {
const { $from, $to } = state.selection
const range = $from.blockRange($to)
if (!range) {
return false
}
const parentPos = parentInGroupPos(range.$from, 'list')
if (typeof parentPos === 'number') {
// already in list
const $pos = state.doc.resolve(parentPos)
const node = $pos.nodeAfter
if (node && node.type === listType && node.attrs.type === attrs.type) {
// return false if the list type already matches
return false
}
if (dispatch) {
dispatch(
state.tr.setNodeMarkup(
parentPos,
listType,
node ? { ...node.attrs, ...attrs } : attrs,
),
)
}
return true
} else {
const wrapping = findWrapping(range, listType, attrs)
if (!wrapping) {
return false
}
if (dispatch) {
dispatch(state.tr.wrap(range, wrapping).scrollIntoView())
}
return true
}
}
export const promptForURL = (): string | null => {
let url = window && window.prompt('Enter the URL', 'https://')
if (url && !/^https?:\/\//i.test(url)) {
url = 'https://' + url
}
return url
}
import { setBlockType, toggleMark, wrapIn } from 'prosemirror-commands'
import {
liftListItem,
sinkListItem,
splitListItem,
} from 'prosemirror-schema-list'
import type { Command } from 'prosemirror-state'
import {
insertNodeOfType,
isMarkActive,
promptForURL,
setListTypeOrWrapInList,
toggleWrap,
} from '../commands'
import { schema } from './schema'
export const toggleMarkBold = toggleMark(schema.marks.bold!)
export const toggleMarkItalic = toggleMark(schema.marks.italic!)
export const toggleMarkCode = toggleMark(schema.marks.code!)
export const toggleMarkSubscript = toggleMark(schema.marks.subscript!)
export const toggleMarkSuperscript = toggleMark(schema.marks.superscript!)
export const toggleMarkUnderline = toggleMark(schema.marks.underline!)
export const toggleMarkStrikethrough = toggleMark(schema.marks.strikethrough!)
export const toggleLink: Command = (state, dispatch) => {
if (isMarkActive(schema.marks.link!)(state)) {
toggleMark(schema.marks.link!)(state, dispatch)
return true
}
const href = promptForURL()
if (!href) {
return false
}
toggleMark(schema.marks.link!, { href })(state, dispatch)
// view.focus()
return true
}
export const setBlockTypeParagraph = setBlockType(schema.nodes.paragraph!)
export const setBlockTypeCodeBlock = setBlockType(schema.nodes.codeBlock!)
export const setBlockTypeHeading = (level: number): Command =>
setBlockType(schema.nodes.heading!, { level })
export const toggleWrapBlockquote = toggleWrap(schema.nodes.blockquote!)
export const wrapInBlockquote = wrapIn(schema.nodes.blockquote!)
export const setListTypeBullet = setListTypeOrWrapInList(schema.nodes.list!, {
type: 'bullet',
})
export const setListTypeOrdered = setListTypeOrWrapInList(schema.nodes.list!, {
type: 'ordered',
})
export const liftListItemCommand = liftListItem(schema.nodes.listItem!)
export const sinkListItemCommand = sinkListItem(schema.nodes.listItem!)
export const splitListItemCommand = splitListItem(schema.nodes.listItem!)
export const insertNodeLineBreak = insertNodeOfType(schema.nodes.lineBreak!)
export const insertNodeHorizontalRule = insertNodeOfType(
schema.nodes.horizontalRule!,
)
import {
suggestionPlugin,
rules,
history,
editorKeys,
baseKeys,
listKeys,
} from '../plugins'
export const plugins = () => [
suggestionPlugin(),
history(),
listKeys(),
editorKeys(),
baseKeys(),
rules(),
]
import { Schema } from 'prosemirror-model'
import {
blockquote,
bold,
code,
codeBlock,
doc,
heading,
horizontalRule,
italic,
lineBreak,
link,
list,
listItem,
paragraph,
strikethrough,
subscript,
superscript,
table,
tableDataCell,
tableHeaderCell,
tableRow,
text,
underline,
mention,
emoji,
} from '../schema'
export const schema = new Schema({
marks: {
bold,
code,
italic,
link,
strikethrough,
subscript,
superscript,
underline,
},
nodes: {
text,
doc,
paragraph,
lineBreak,
heading,
blockquote,
codeBlock,
horizontalRule,
list,
listItem,
table,
tableRow,
tableDataCell,
tableHeaderCell,
mention,
emoji,
},
})
import type { EditorState, Transaction } from 'prosemirror-state'
import type { EditorView } from 'prosemirror-view'
export type ToolbarItem = {
action: (
state: EditorState,
dispatch: (tr: Transaction) => void,
view: EditorView,
) => boolean | Promise<boolean>
isActive?: (state: EditorState) => boolean
isEnable?: (state: EditorState) => boolean
}
import type { Command } from 'prosemirror-state'
import { useCallback, useMemo } from 'react'
import { MENTION_CHAR } from '../plugins/suggestions/suggestMention'
import type { ToolbarItem } from './types'
export const useAtMention = (): ToolbarItem => {
const action = useCallback<
(
state: Parameters<Command>[0],
dispatch: Parameters<Command>[1],
view: Parameters<Command>[2],
) => boolean
>((state, dispatch) => {
const { tr } = state
dispatch?.(tr.insertText(MENTION_CHAR))
return true
}, [])
return useMemo(
() => ({
action,
}),
[action],
)
}
import { useMemo } from 'react'
import { isMarkActive } from '../commands'
import { schema } from '../config'
import { toggleMarkBold } from '../config/commands'
import type { ToolbarItem } from './types'
export const useBold = (): ToolbarItem => {
return useMemo(
() => ({
action: toggleMarkBold,
isActive: isMarkActive(schema.marks.bold!),
}),
[],
)
}
import { useMemo } from 'react'
import { isBlockActive } from '../../prosemirror/commands'
import { schema } from '../../prosemirror/config'
import { setListTypeBullet } from '../../prosemirror/config/commands'
import type { ToolbarItem } from './types'
export const useBulletList = (): ToolbarItem => {
return useMemo(
() => ({
action: setListTypeBullet,
isActive: isBlockActive(schema.nodes.list!, { type: 'bullet' }),
}),
[],
)
}
import { useMemo } from 'react'
import { liftListItemCommand } from '../config/commands'
import type { ToolbarItem } from './types'
export const useDecreaseListIndent = (): ToolbarItem => {
return useMemo(
() => ({
action: liftListItemCommand,
isEnable: liftListItemCommand,
}),
[],
)
}
import type { BaseEmoji } from 'emoji-mart'
import type { Command } from 'prosemirror-state'
import { useCallback, useMemo } from 'react'
import type { ToolbarItem } from './types'
type Props = {
onOpen: () => Promise<BaseEmoji>
}
export const useEmoji = ({ onOpen }: Props): ToolbarItem => {
const action = useCallback<
(
state: Parameters<Command>[0],
dispatch: Parameters<Command>[1],
view: Parameters<Command>[2],
) => Promise<boolean>
>(
async (state, dispatch) => {
const emoji = await onOpen()
if (!emoji) return false
const { tr } = state
tr.insertText(emoji.native)
dispatch?.(tr)
return true
},
[onOpen],
)
return useMemo(
() => ({
action,
}),
[action],
)
}
import { useMemo } from 'react'
import { sinkListItemCommand } from '../config/commands'
import type { ToolbarItem } from './types'
export const useIncreaseListIndent = (): ToolbarItem => {
return useMemo(
() => ({
action: sinkListItemCommand,
isEnable: sinkListItemCommand,
}),
[],
)
}
import { useMemo } from 'react'
import { isMarkActive } from '../commands'
import { schema } from '../config'
import { toggleMarkItalic } from '../config/commands'
import type { ToolbarItem } from './types'
export const useItalic = (): ToolbarItem => {
return useMemo(
() => ({
action: toggleMarkItalic,
isActive: isMarkActive(schema.marks.italic!),
}),
[],
)
}
import { toggleMark } from 'prosemirror-commands'
import type { Command } from 'prosemirror-state'
import { useCallback, useMemo } from 'react'
import { useEditorLinkModalContext } from '../../EditorLinkModal'
import { isMarkActive } from '../commands'
import { schema } from '../config'
import type { ToolbarItem } from './types'
export const useLink = (): ToolbarItem => {
const { onOpen } = useEditorLinkModalContext()
const action = useCallback<
(
state: Parameters<Command>[0],
dispatch: Parameters<Command>[1],
view: Parameters<Command>[2],
) => Promise<boolean>
>(
async (state, dispatch) => {
if (isMarkActive(schema.marks.link!)(state)) {
toggleMark(schema.marks.link!)(state, dispatch)
return true
}
const selectedText = window.getSelection()
const position = selectedText?.getRangeAt(0).getBoundingClientRect()
if (!selectedText?.anchorNode) return false
const input = await onOpen({
x: Number(position?.top),
y: Number(position?.left),
})
if (!input.url) return false
toggleMark(schema.marks.link!, { href: input.url })(state, dispatch)
return true
},
[onOpen],
)
return useMemo(
() => ({
action,
isActive: isMarkActive(schema.marks.link!),
isEnable: (state) => !state.selection.empty,
}),
[action],
)
}
import { useMemo } from 'react'
import { isBlockActive } from '../commands'
import { schema } from '../config'
import { setListTypeOrdered } from '../config/commands'
import type { ToolbarItem } from './types'
export const useOrderedList = (): ToolbarItem => {
return useMemo(
() => ({
action: setListTypeOrdered,
isActive: isBlockActive(schema.nodes.list!, { type: 'ordered' }),
}),
[],
)
}
import { useMemo } from 'react'
import { isMarkActive } from '../commands'
import { schema } from '../config'
import { toggleMarkStrikethrough } from '../config/commands'
import type { ToolbarItem } from './types'
export const useStrikethrough = (): ToolbarItem => {
return useMemo(
() => ({
action: toggleMarkStrikethrough,
isActive: isMarkActive(schema.marks.strikethrough!),
}),
[],
)
}
import { useMemo } from 'react'
import { isMarkActive } from '../commands'
import { schema } from '../config'
import { toggleMarkUnderline } from '../config/commands'
import type { ToolbarItem } from './types'
export const useUnderline = (): ToolbarItem => {
return useMemo(
() => ({
action: toggleMarkUnderline,
isActive: isMarkActive(schema.marks.underline!),
}),
[],
)
}
export { history } from 'prosemirror-history'
import {
baseKeymap,
chainCommands,
exitCode,
joinDown,
joinUp,
lift,
} from 'prosemirror-commands'
import { redo, undo } from 'prosemirror-history'
import { undoInputRule } from 'prosemirror-inputrules'
import { keymap } from 'prosemirror-keymap'
import type { Plugin } from 'prosemirror-state'
import {
insertNodeHorizontalRule,
insertNodeLineBreak,
setListTypeBullet,
setListTypeOrdered,
liftListItemCommand,
sinkListItemCommand,
splitListItemCommand,
toggleMarkBold,
toggleMarkCode,
toggleMarkItalic,
toggleMarkUnderline,
wrapInBlockquote,
toggleMarkStrikethrough,
} from '../config/commands'
import { Escape, Enter, ArrowUp, ArrowDown } from '../plugins/suggestions/keys'
export const listKeys = (): Plugin =>
keymap({
'Mod-]': sinkListItemCommand,
'Mod-[': liftListItemCommand,
Tab: sinkListItemCommand,
'Shift-Tab': liftListItemCommand,
Enter: splitListItemCommand,
})
export const editorKeys = (): Plugin =>
keymap({
'Mod-z': undo,
'Shift-Mod-z': redo,
Backspace: undoInputRule,
'Mod-y': redo,
'Alt-ArrowUp': joinUp,
'Alt-ArrowDown': joinDown,
'Mod-BracketLeft': lift,
Escape,
Enter,
ArrowUp,
ArrowDown,
'Shift-Mod-8': setListTypeBullet,
'Shift-Mod-7': setListTypeOrdered,
'Mod-b': toggleMarkBold,
'Mod-i': toggleMarkItalic,
'Ctrl-`': toggleMarkCode,
'Mod-u': toggleMarkUnderline,
'Shift-Mod-s': toggleMarkStrikethrough,
'Ctrl->': wrapInBlockquote,
'Mod-Enter': chainCommands(exitCode, insertNodeLineBreak),
'Shift-Enter': chainCommands(exitCode, insertNodeLineBreak),
'Ctrl-Enter': chainCommands(exitCode, insertNodeLineBreak), // mac-only?
'Mod-_': insertNodeHorizontalRule,
})
export const baseKeys = (): Plugin => keymap(baseKeymap)
import {
ellipsis,
emDash,
inputRules,
smartQuotes,
textblockTypeInputRule,
wrappingInputRule,
} from 'prosemirror-inputrules'
import type { Plugin } from 'prosemirror-state'
import { schema } from '../config/schema'
export const rules = (): Plugin =>
inputRules({
rules: [
...smartQuotes,
ellipsis,
emDash,
// > blockquote
wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote!),
// 1. ordered list
wrappingInputRule(
/^(\d+)\.\s$/,
schema.nodes.list!,
(match) => {
return { type: 'ordered', start: +(match[1] as string) }
},
(match, node) => {
return (
node.childCount + Number(node.attrs.start) === +(match[1] as string)
)
},
),
// * bullet list
wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.list!, () => {
return { type: 'bullet' }
}),
// ``` code block
textblockTypeInputRule(/^```$/, schema.nodes.codeBlock!),
// # heading
textblockTypeInputRule(
new RegExp('^(#{1,6})\\s$'),
schema.nodes.heading!,
(match) => {
return { level: (match[1] as string).length }
},
),
],
})
import {
ellipsis,
emDash,
inputRules,
smartQuotes,
textblockTypeInputRule,
wrappingInputRule,
} from 'prosemirror-inputrules'
import type { Plugin } from 'prosemirror-state'
import { schema } from '../config'
export const rules = (): Plugin =>
inputRules({
rules: [
...smartQuotes,
ellipsis,
emDash,
// > blockquote
wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote!),
// 1. ordered list
wrappingInputRule(
/^(\d+)\.\s$/,
schema.nodes.list!,
(match) => {
return { type: 'ordered', start: +(match[1] as string) }
},
(match, node) => {
return (
node.childCount + Number(node.attrs.start) === +(match[1] as string)
)
},
),
// * bullet list
wrappingInputRule(/^\s*\*\s$/, schema.nodes.list!, () => {
return { type: 'bullet' }
}),
// ``` code block
textblockTypeInputRule(/^```$/, schema.nodes.codeBlock!),
// # heading
textblockTypeInputRule(
new RegExp('^(#{1,6})\\s$'),
schema.nodes.heading!,
(match) => {
return { level: match[1]?.length }
},
),
],
})
import type { Schema } from 'prosemirror-model'
import { DOMParser, DOMSerializer } from 'prosemirror-model'
import type { ProsemirrorTransformer } from './types'
export const createHTMLTransformer = <S extends Schema>(
schema: S,
): ProsemirrorTransformer<string> => {
const parser = DOMParser.fromSchema(schema)
const serializer = DOMSerializer.fromSchema(schema)
return {
parse: (html) => {
const template = document.createElement('template')
template.innerHTML = html.trim()
if (!template.content?.firstChild) {
throw new Error('Error parsing HTML input')
}
return parser.parse(template.content)
},
serialize: (doc) => {
const template = document.createElement('template')
template.content.appendChild(serializer.serializeFragment(doc.content))
return template.innerHTML
},
}
}
import type { Schema } from 'prosemirror-model'
import { Node as ProsemirrorNode } from 'prosemirror-model'
import type { ProsemirrorTransformer } from './types'
export const createJSONTransformer = (
schema: Schema,
): ProsemirrorTransformer<string> => {
return {
parse: (json) => {
return ProsemirrorNode.fromJSON(schema, JSON.parse(json))
},
serialize: (doc) => {
return JSON.stringify(doc.toJSON(), null, 2)
},
}
}
import type { Node as ProsemirrorNode } from 'prosemirror-model'
import type { ProsemirrorTransformer } from './types'
export const createNullTransformer =
(): ProsemirrorTransformer<ProsemirrorNode> => {
return {
parse: (doc) => doc,
serialize: (doc) => doc,
}
}
import type { Node as ProsemirrorNode } from 'prosemirror-model'
export interface ProsemirrorTransformer<T = any> {
parse: (input: T) => ProsemirrorNode
serialize: (doc: ProsemirrorNode) => T
}
import type { NodeType } from 'prosemirror-model'
import type { EditorView } from 'prosemirror-view'
const isOfType = <Type>(type: string, predicate?: (value: Type) => boolean) => {
return (value: unknown): value is Type => {
if (typeof value !== type) return false
return predicate ? predicate(value as Type) : true
}
}
export const isString = isOfType<string>('string')
export const isNull = (value: unknown): value is null => value === null
export const isUndefined = isOfType<undefined>('undefined')
export const isFunction = isOfType<() => void>('function')
export const isNumber = isOfType<number>(
'number',
(value) => !Number.isNaN(value),
)
export const Cast = <Type = any>(value: unknown): Type => value as Type
export const toString = (value: unknown): string =>
Object.prototype.toString.call(value)
export const isNullOrUndefined = (
value: unknown,
): value is null | undefined => {
return isNull(value) || isUndefined(value)
}
export const isObject = <Type>(value: unknown): value is Type => {
return (
!isNullOrUndefined(value) &&
(isFunction(value) || isOfType('object')(value))
)
}
export const isNodeOfType = (props: any): boolean => {
const { types, node } = props
if (!node) return false
const matches = (type: NodeType | string) =>
type === node.type || type === node.type.name
if (Array.isArray(types)) return types.some(matches)
return matches(types)
}
export const isDomNode = (domNode: unknown): domNode is Node =>
isObject(Node)
? domNode instanceof Node
: isObject(domNode) &&
isNumber(Cast(domNode).nodeType) &&
isString(Cast(domNode).nodeName)
const getObjectType = (value: unknown) => toString(value).slice(8, -1)
export const isPlainObject = (value: unknown) => {
if (getObjectType(value) !== 'Object') return false
const prototype = Object.getPrototypeOf(value)
return prototype === null || prototype === Object.getPrototypeOf({})
}
export const isDomNodeOutputSpec = (
value: unknown,
): value is Node | { dom: Node; contentDOM?: Node } =>
isDomNode(value) || (isPlainObject(value) && isDomNode((value as any).dom))
export const isElementDomNode = (domNode: unknown): domNode is HTMLElement =>
isDomNode(domNode) && domNode.nodeType === Node.ELEMENT_NODE
export const entries = <
Type extends object,
Key extends Extract<keyof Type, string>,
Value extends Type[Key],
Entry extends [Key, Value],
>(
value: Type,
): Entry[] => Object.entries(value) as Entry[]
export const isContentEmpty = (view: EditorView): boolean => {
const { state } = view
return (
state.doc.content.size === 0 ||
(state.doc.content.size <= 2 && state.doc.textContent === '')
)
}
export type Teammate = {
id: string
name: string
image: string
email: string
createdAt: string
updatedAt: string
}
type MentionResponse = {
id: string
type: number
text: string
title: string
subtitle: string
teammate: Teammate
}
export const MentionType = {
TEAMMATE: 1,
} as const
export type MentionTypeCode = ValueOf<typeof MentionType>
export type Mention = Override<
MentionResponse,
{
type: MentionTypeCode
}
>
import React, { memo } from 'react'
import type { EmojiAttrs } from '../../../prosemirror/schema'
import { useReactNodeView } from '../ReactNodeView'
export const Emoji: React.FC = memo(function Emoji() {
const context = useReactNodeView()
const attrs = context.node?.attrs as EmojiAttrs
return <>{attrs.emoji}</>
})
import { Link as AtomsLink } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import React from 'react'
import { Icon } from '../../../Icon'
import {
PopoverEditorLink,
PopoverEditorLinkContent,
PopoverEditorLinkText,
PopoverEditorLinkTrigger,
} from '../../../PopoverEditorLink'
import { useReactNodeView } from '../ReactNodeView'
export function Link(props: PropsWithChildren) {
const context = useReactNodeView()
return (
<PopoverEditorLink>
<PopoverEditorLinkTrigger>{props.children}</PopoverEditorLinkTrigger>
<PopoverEditorLinkContent>
<Icon icon="linkExternal" color="gray.500" size="sm" />
<PopoverEditorLinkText>
<AtomsLink href={context.node?.attrs.href} isExternal>
{context.node?.attrs.href}
</AtomsLink>
</PopoverEditorLinkText>
</PopoverEditorLinkContent>
</PopoverEditorLink>
)
}
import React, { memo } from 'react'
import { Icon } from '../../../Icon'
import { useAtMention } from '../../../prosemirror/hooks'
import { BaseButton } from '../BaseButton'
export const AtMention = memo(function AtMention() {
const { action } = useAtMention()
return (
<BaseButton
aria-label="At mention"
icon={
<Icon
icon="at"
color="gray.500"
_dark={{
color: 'gray.200',
}}
/>
}
action={action}
tooltip={{
label: 'At-Mention (Enter `@`)',
'aria-label': 'At-Mention',
}}
/>
)
})
import type { IconButtonProps, TooltipProps } from '@chakra-ui/react'
import React, { memo } from 'react'
import { Icon } from '../../../Icon'
import { useBold } from '../../../prosemirror/hooks'
import { BaseButton } from '../BaseButton'
type Props = Omit<IconButtonProps, 'aria-label'> & {
tooltip?: Omit<TooltipProps, 'children'>
}
export const Bold = memo<Props>(function Bold(props) {
const { action, isActive } = useBold()
return (
<BaseButton
aria-label="bold"
icon={
<Icon
icon="bold"
color="gray.500"
_dark={{
color: 'gray.200',
}}
/>
}
action={action}
{...props}
tooltip={{
label: 'Bold\n(⌘+b)',
'aria-label': 'Bold',
...props.tooltip,
}}
isActive={isActive}
/>
)
})
import type { IconButtonProps } from '@chakra-ui/react'
import type { TooltipProps } from '@chakra-ui/react'
import React, { memo } from 'react'
import { Icon } from '../../../Icon'
import { useBulletList } from '../../../prosemirror/hooks'
import { BaseButton } from '../BaseButton'
type Props = Omit<IconButtonProps, 'aria-label'> & {
tooltip?: Omit<TooltipProps, 'children'>
}
export const BulletList = memo<Props>(function BulletList(props) {
const { action, isActive } = useBulletList()
return (
<BaseButton
aria-label="underline"
icon={
<Icon
icon="listUl"
color="gray.500"
_dark={{
color: 'gray.200',
}}
/>
}
action={action}
{...props}
tooltip={{
label: 'Bullet List\n(⌘+⇧+8)',
'aria-label': 'Bullet List',
...props.tooltip,
}}
isActive={isActive}
/>
)
})
import type { IconButtonProps } from '@chakra-ui/react'
import type { TooltipProps } from '@chakra-ui/react'
import React, { memo } from 'react'
import { Icon } from '../../../Icon'
import { useDecreaseListIndent } from '../../../prosemirror/hooks'
import { BaseButton } from '../BaseButton'
type Props = Omit<IconButtonProps, 'aria-label' | 'isActive'> & {
tooltip?: Omit<TooltipProps, 'children'>
}
export const DecreaseListIndent = memo<Props>(
function DecreaseListIndent(props) {
const { action, isEnable } = useDecreaseListIndent()
return (
<BaseButton
aria-label="Decrease list indent"
icon={
<Icon
icon="leftIndent"
color="gray.500"
_dark={{
color: 'gray.200',
}}
/>
}
isEnable={isEnable}
action={action}
{...props}
tooltip={{
label: 'Decrease list indent\n(⌘+])',
'aria-label': 'Decrease list indent',
...props.tooltip,
}}
/>
)
},
)
import React, { memo } from 'react'
import { Icon } from '../../../Icon'
import { PopoverEmoji, usePopoverEmojiContext } from '../../../PopoverEmoji'
import { useEmoji } from '../../../prosemirror/hooks'
import { BaseButton } from '../BaseButton'
type Props = {
onOpened?: () => void
onClosed?: () => void
}
export function Emoji({ onClosed, onOpened }: Props) {
return (
<PopoverEmoji onOpened={onOpened} onClosed={onClosed}>
<Component />
</PopoverEmoji>
)
}
const Component = memo<Props>(function Component() {
const { onOpen } = usePopoverEmojiContext()
const { action } = useEmoji({ onOpen })
return (
<BaseButton
aria-label="emoji"
icon={
<Icon
icon="emojiHappy"
color="gray.500"
_dark={{
color: 'gray.200',
}}
/>
}
action={action}
tooltip={{
label: 'Emoji (Enter `:`)',
'aria-label': 'Emoji',
}}
/>
)
})
import type { IconButtonProps, TooltipProps } from '@chakra-ui/react'
import React, { memo } from 'react'
import { Icon } from '../../../Icon'
import { useIncreaseListIndent } from '../../../prosemirror/hooks'
import { BaseButton } from '../BaseButton'
type Props = Omit<IconButtonProps, 'aria-label' | 'isActive'> & {
tooltip?: Omit<TooltipProps, 'children'>
}
export const IncreaseListIndent = memo<Props>(
function IncreaseListIndent(props) {
const { action, isEnable } = useIncreaseListIndent()
return (
<BaseButton
aria-label="Increase list indent"
icon={
<Icon
icon="rightIndent"
color="gray.500"
_dark={{
color: 'gray.200',
}}
/>
}
isEnable={isEnable}
action={action}
{...props}
tooltip={{
label: 'Increase list indent\n(⌘+[)',
'aria-label': 'Increase list indent',
...props.tooltip,
}}
/>
)
},
)
import type { IconButtonProps } from '@chakra-ui/react'
import type { TooltipProps } from '@chakra-ui/react'
import React, { memo } from 'react'
import { Icon } from '../../../Icon'
import { useItalic } from '../../../prosemirror/hooks'
import { BaseButton } from '../BaseButton'
type Props = Omit<IconButtonProps, 'aria-label'> & {
tooltip?: Omit<TooltipProps, 'children'>
}
export const Italic = memo<Props>(function Italic(props) {
const { action, isActive } = useItalic()
return (
<BaseButton
aria-label="italic"
icon={
<Icon
icon="italic"
color="gray.500"
_dark={{
color: 'gray.200',
}}
/>
}
{...props}
action={action}
tooltip={{
label: 'Italic\n(⌘+i)',
'aria-label': 'Italic',
...props.tooltip,
}}
isActive={isActive}
/>
)
})
import type { IconButtonProps } from '@chakra-ui/react'
import type { TooltipProps } from '@chakra-ui/react'
import React, { memo } from 'react'
import { Icon } from '../../../Icon'
import { useLink } from '../../../prosemirror/hooks'
import { BaseButton } from '../BaseButton'
type Props = Omit<IconButtonProps, 'aria-label'> & {
tooltip?: Omit<TooltipProps, 'children'>
}
export const Link = memo<Props>(function Link(props) {
const { action, isActive, isEnable } = useLink()
return (
<BaseButton
aria-label="link"
icon={
<Icon
icon="link"
color="gray.500"
_dark={{
color: 'gray.200',
}}
/>
}
isEnable={isEnable}
action={action}
{...props}
tooltip={{
label: 'Link\n(⌘+b)',
'aria-label': 'Link',
...props.tooltip,
}}
isActive={isActive}
/>
)
})
import type { IconButtonProps } from '@chakra-ui/react'
import type { TooltipProps } from '@chakra-ui/react'
import React, { memo } from 'react'
import { Icon } from '../../../Icon'
import { useOrderedList } from '../../../prosemirror/hooks'
import { BaseButton } from '../BaseButton'
type Props = Omit<IconButtonProps, 'aria-label'> & {
tooltip?: Omit<TooltipProps, 'children'>
}
export const OrderedList = memo<Props>(function OrderedList(props) {
const { action, isActive } = useOrderedList()
return (
<BaseButton
aria-label="ordered list"
icon={
<Icon
icon="listOl"
color="gray.500"
_dark={{
color: 'gray.200',
}}
/>
}
action={action}
{...props}
tooltip={{
label: 'Ordered List\n(⌘+⇧+7)',
'aria-label': 'Ordered List',
...props.tooltip,
}}
isActive={isActive}
/>
)
})
import type { IconButtonProps } from '@chakra-ui/react'
import type { TooltipProps } from '@chakra-ui/react'
import React, { memo } from 'react'
import { Icon } from '../../../Icon'
import { useStrikethrough } from '../../../prosemirror/hooks'
import { BaseButton } from '../BaseButton'
type Props = Omit<IconButtonProps, 'aria-label'> & {
tooltip?: Omit<TooltipProps, 'children'>
}
export const Strikethrough = memo<Props>(function Strikethrough(props) {
const { action, isActive } = useStrikethrough()
return (
<BaseButton
aria-label="strikethrough"
icon={
<Icon
icon="strikethrough"
color="gray.500"
_dark={{
color: 'gray.200',
}}
/>
}
action={action}
{...props}
tooltip={{
label: 'Strikethrough\n(⌘+⇧+S)',
'aria-label': 'Strikethrough',
...props.tooltip,
}}
isActive={isActive}
/>
)
})
import type { IconButtonProps } from '@chakra-ui/react'
import type { TooltipProps } from '@chakra-ui/react'
import React, { memo } from 'react'
import { Icon } from '../../../Icon'
import { useUnderline } from '../../../prosemirror/hooks'
import { BaseButton } from '../BaseButton'
type Props = Omit<IconButtonProps, 'aria-label'> & {
tooltip?: Omit<TooltipProps, 'children'>
}
export const Underline = memo<Props>(function Underline(props) {
const { action, isActive } = useUnderline()
return (
<BaseButton
aria-label="underline"
icon={
<Icon
icon="underline"
color="gray.500"
_dark={{
color: 'gray.200',
}}
/>
}
action={action}
{...props}
tooltip={{
label: 'Underline\n(⌘+u)',
'aria-label': 'Underline',
...props.tooltip,
}}
isActive={isActive}
/>
)
})
import {
isEmojiOpen,
onEmojiArrowUp,
onEmojiArrowDown,
onEmojiClose,
onEmojiEnter,
} from '../../../EditorEmojiMenu'
import {
onMentionArrowDown,
onMentionArrowUp,
onMentionEnter,
} from '../../../EditorMentionMenu'
import { isMentionOpen, onMentionClose } from '../../../EditorMentionMenu'
export const Escape = () => {
if (isEmojiOpen) {
onEmojiClose()
return true
}
if (isMentionOpen) {
onMentionClose()
return true
}
return false
}
export const ArrowUp = (): boolean => {
if (isEmojiOpen) {
onEmojiArrowUp()
return true
}
if (isMentionOpen) {
onMentionArrowUp()
return true
}
return false
}
export const ArrowDown = (): boolean => {
if (isEmojiOpen) {
onEmojiArrowDown()
return true
}
if (isMentionOpen) {
onMentionArrowDown()
return true
}
return false
}
export const Enter = (): boolean => {
if (isEmojiOpen) {
onEmojiEnter()
return true
}
if (isMentionOpen) {
onMentionEnter()
return true
}
return false
}
import type { Suggester } from 'prosemirror-suggest'
import {
getEmoji,
isEmojiOpen,
onEmojiClose as onClose,
onEmojiOpen as onOpen,
setEmojiQuery as setQuery,
} from '../../../EditorEmojiMenu'
export const suggestEmoji: Suggester = {
disableDecorations: true,
char: ':',
name: 'emoji-suggestion',
onChange: async (params) => {
// Close the modal when the suggestion character(`:`) is deleted.
if (params.exitReason && isEmojiOpen) {
onClose()
return
}
setQuery(params.query.full)
await onOpen()
if (!getEmoji()) return
const emoji = getEmoji()?.native + ' '
const state = params.view.state
const { from, to } = params.range
const { tr } = state
params.view.dispatch(tr.insertText(emoji, from, to))
onClose()
},
}
import type { Suggester } from 'prosemirror-suggest'
import {
isMentionOpen,
onMentionOpen as onOpen,
onMentionClose as onClose,
setMentionQuery as setQuery,
getMentionId,
getMentionType,
} from '../../../EditorMentionMenu'
import type { MentionAttrs } from '../../schema'
export const MENTION_CHAR = '@'
export const suggestMention: Suggester = {
disableDecorations: true,
char: MENTION_CHAR,
name: 'mention-suggestion',
onChange: async (params) => {
// Close the modal when the suggestion character(`@`) is deleted.
if (params.exitReason && isMentionOpen) {
onClose()
return
}
setQuery(params.query.full)
await onOpen()
if (!getMentionId()) return
const state = params.view.state
const node = state.schema.nodes.mention?.create({
mentionId: String(getMentionId()),
mentionType: String(getMentionType()),
} as MentionAttrs)
const { from, to } = params.range
const tr = state.tr.replaceWith(from, to, node!)
params.view.dispatch(tr)
onClose()
},
}
import type { MarkSpec } from 'prosemirror-model'
export const bold: MarkSpec = {
parseDOM: [
{
tag: 'b',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
getAttrs: (element: HTMLElement) =>
element.style.fontWeight !== 'normal' && null,
},
{ tag: 'strong' },
{
style: 'font-weight',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
getAttrs: (style: string) =>
/^(bold(er)?|[5-9]\d{2,})$/.test(style) && null,
},
],
toDOM: () => ['b', 0],
}
import type { MarkSpec } from 'prosemirror-model'
export const code: MarkSpec = {
parseDOM: [{ tag: 'code' }, { style: 'font-family=monospace' }],
toDOM: () => ['code', 0],
}
import type { MarkSpec } from 'prosemirror-model'
export const italic: MarkSpec = {
parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }],
toDOM: () => [
'em',
{
style: 'font-style: italic !important',
},
0,
],
}
import type { MarkSpec } from 'prosemirror-model'
interface Attrs {
href: string
title: string | null
}
export const link: MarkSpec = {
attrs: {
href: {},
title: { default: null },
},
group: 'inline',
inline: true,
inclusive: false,
parseDOM: [
{
tag: 'a[href]',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
getAttrs: (element: HTMLAnchorElement): Attrs => {
return {
href: element.getAttribute('href') as string,
title: element.getAttribute('title'),
}
},
},
],
toDOM(node) {
const { href, title } = node.attrs as Attrs
return ['a', { href, title }, 0]
},
}
import type { MarkSpec } from 'prosemirror-model'
export const strikethrough: MarkSpec = {
parseDOM: [
{ tag: 'strike' },
{ style: 'text-decoration=line-through' },
{ style: 'text-decoration-line=line-through' },
],
toDOM: () => ['span', { style: 'text-decoration-line:line-through' }],
}
import type { MarkSpec } from 'prosemirror-model'
export const subscript: MarkSpec = {
parseDOM: [{ tag: 'sub' }, { style: 'vertical-align=sub' }],
toDOM: () => ['sub', 0],
excludes: 'superscript',
}
import type { MarkSpec } from 'prosemirror-model'
export const superscript: MarkSpec = {
parseDOM: [{ tag: 'sup' }, { style: 'vertical-align=super' }],
toDOM: () => ['sup', 0],
excludes: 'subscript',
}
import type { MarkSpec } from 'prosemirror-model'
export const underline: MarkSpec = {
parseDOM: [{ tag: 'u' }, { style: 'text-decoration=underline' }],
toDOM: () => ['span', { style: 'text-decoration:underline' }, 0],
}
import type { NodeSpec } from 'prosemirror-model'
// adapted from prosemirror-schema-basic
interface Attrs {
cite?: string
}
export const blockquote: NodeSpec = {
attrs: {
cite: { default: null },
},
content: 'block+',
group: 'block',
defining: true,
parseDOM: [
{
tag: 'blockquote',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
getAttrs: (element: HTMLElement) => ({
cite: element.getAttribute('cite'),
}),
},
],
toDOM: (node) => {
const { cite } = node.attrs as Attrs
return ['blockquote', { cite }, 0]
},
}
import type { NodeSpec } from 'prosemirror-model'
// from prosemirror-schema-basic
export const codeBlock: NodeSpec = {
content: 'text*',
marks: '',
group: 'block',
code: true,
defining: true,
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }],
toDOM: () => ['pre', ['code', 0]],
}
import type { NodeSpec } from 'prosemirror-model'
export const doc: NodeSpec = {
content: 'block+',
}
import type { NodeSpec } from 'prosemirror-model'
type Attrs = {
emoji: string
}
export type EmojiAttrs = Attrs
export const emoji: Override<
NodeSpec,
{ attrs: Record<keyof Attrs, { default: string }> }
> = {
group: 'inline',
inline: true,
atom: true,
attrs: {
emoji: { default: '' },
},
selectable: false,
draggable: false,
toDOM: (node: NodeSpec) => {
const attrs = node.attrs as Attrs
return [
'span',
{
'data-mention-emoji': attrs.emoji,
},
]
},
parseDOM: [
{
tag: 'span[data-mention-emoji]',
getAttrs: (element: HTMLSpanElement): Attrs => {
const emoji = element.getAttribute('data-mention-emoji') ?? ''
return {
emoji,
}
},
},
],
}
import type { NodeSpec } from 'prosemirror-model'
export const heading: NodeSpec = {
attrs: {
level: { default: 1 },
},
group: 'block heading',
content: 'inline*',
marks: 'italic superscript subscript',
defining: true,
parseDOM: [
{ tag: 'h1', attrs: { level: 1 } },
{ tag: 'h2', attrs: { level: 2 } },
{ tag: 'h3', attrs: { level: 3 } },
{ tag: 'h4', attrs: { level: 4 } },
{ tag: 'h5', attrs: { level: 5 } },
{ tag: 'h6', attrs: { level: 6 } },
],
toDOM: (node) => [`h${String(node.attrs.level)}`, 0],
}
import type { NodeSpec } from 'prosemirror-model'
export const horizontalRule: NodeSpec = {
group: 'block',
parseDOM: [{ tag: 'hr' }],
toDOM: () => ['hr'],
}
import type { NodeSpec } from 'prosemirror-model'
interface Attrs {
src: string
alt: string | null
title: string | null
}
export const image: NodeSpec = {
inline: true,
attrs: {
src: {},
alt: { default: null },
title: { default: null },
},
group: 'inline',
draggable: true,
parseDOM: [
{
tag: 'img[src]',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
getAttrs: (element: HTMLImageElement): Attrs => {
return {
src: element.getAttribute('src') as string,
title: element.getAttribute('title'),
alt: element.getAttribute('alt'),
}
},
},
],
toDOM(node) {
const { src, alt, title } = node.attrs as Attrs
return ['img', { src, alt, title }]
},
}
import type { NodeSpec } from 'prosemirror-model'
export const lineBreak: NodeSpec = {
inline: true,
group: 'inline',
selectable: false,
parseDOM: [{ tag: 'br' }],
toDOM: () => ['br'],
}
import type { NodeSpec } from 'prosemirror-model'
interface Attrs {
type: string
start?: number
}
export const list: NodeSpec = {
attrs: {
type: { default: 'ordered' }, // 'ordered', 'bullet', 'simple'
start: { default: 1 }, // for ordered lists
},
group: 'block list',
content: 'listItem+',
parseDOM: [
{
tag: 'ol',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
getAttrs: (element: HTMLOListElement): Attrs => {
const start = element.getAttribute('start')
return {
type: 'ordered',
start: start === null ? 1 : Number(start),
}
},
},
{ tag: 'ul.simple', getAttrs: () => ({ type: 'simple' }) },
{ tag: 'ul', getAttrs: () => ({ type: 'bullet' }) },
],
toDOM: (node) => {
const { type, start } = node.attrs as Attrs
switch (type) {
case 'ordered':
return ['ol', { start: start === 1 ? undefined : String(start) }, 0]
case 'simple':
return ['ul', { class: 'simple' }, 0]
case 'bullet':
default:
return ['ul', {}, 0]
}
},
}
export const listItem: NodeSpec = {
content: 'paragraph block*', // 'block+',
defining: true,
parseDOM: [{ tag: 'li' }],
toDOM: () => ['li', 0],
}
import type { NodeSpec } from 'prosemirror-model'
type Attrs = {
mentionId: string
mentionType: string
}
export type MentionAttrs = Attrs
export const mention: Override<
NodeSpec,
{ attrs: Record<keyof Attrs, { default: string }> }
> = {
group: 'inline',
inline: true,
atom: true,
attrs: {
mentionId: { default: '' },
mentionType: { default: '' },
},
selectable: false,
draggable: false,
toDOM: (node: NodeSpec) => {
const attrs = node.attrs as Attrs
return [
'span',
{
'data-mention-mentionId': attrs.mentionId,
'data-mention-mentionType': attrs.mentionType,
},
]
},
parseDOM: [
{
tag: 'span[data-mention-mentionId]',
getAttrs: (element: HTMLSpanElement): Attrs => {
const mentionId = element.getAttribute('data-mention-mentionId') ?? ''
const mentionType =
element.getAttribute('data-mention-mentionType') ?? ''
return {
mentionId,
mentionType,
}
},
},
],
}
import type { NodeSpec } from 'prosemirror-model'
export const paragraph: NodeSpec = {
group: 'block',
content: 'inline*',
parseDOM: [{ tag: 'p' }],
toDOM: () => ['p', 0],
}
import type { NodeSpec } from 'prosemirror-model'
export const table: NodeSpec = {
group: 'block',
content: 'tableRow+',
tableRole: 'table',
parseDOM: [{ tag: 'table' }],
toDOM: () => ['div', { class: 'table-wrap' }, ['table', 0]],
}
export const tableRow: NodeSpec = {
content: 'tableCell+',
tableRole: 'row',
parseDOM: [{ tag: 'tr' }],
toDOM: () => ['tr', 0],
}
export const tableDataCell: NodeSpec = {
attrs: {
colspan: { default: 1 },
rowspan: { default: 1 },
colwidth: { default: null },
},
group: 'tableCell',
content: 'block+',
tableRole: 'cell',
parseDOM: [{ tag: 'td' }],
toDOM: () => ['td', 0],
}
export const tableHeaderCell: NodeSpec = {
attrs: {
colspan: { default: 1 },
rowspan: { default: 1 },
colwidth: { default: null },
},
group: 'tableCell',
content: 'block+',
tableRole: 'header_cell',
parseDOM: [{ tag: 'th' }],
toDOM: () => ['th', 0],
}
import type { NodeSpec } from 'prosemirror-model'
export const text: NodeSpec = {
group: 'inline',
}
import React from 'react'
import type { MentionAttrs } from '../../../../prosemirror/schema'
import type { MentionTypeCode } from '../../../../store/mention'
import { MentionType } from '../../../../store/mention'
import { useReactNodeView } from '../../ReactNodeView'
import { Teammate } from './Teammate'
export function Mention() {
const context = useReactNodeView()
const attrs = context.node?.attrs as MentionAttrs
const type = Number(attrs.mentionType) as MentionTypeCode
switch (type) {
case MentionType.TEAMMATE:
return <Teammate />
}
}
import type { TextProps } from '@chakra-ui/react'
import { Text } from '@chakra-ui/react'
import React from 'react'
import { useLinkStyle } from '../../../../hooks/useLinkStyle'
type Props = TextProps
export function MentionText(props: Props) {
const { style } = useLinkStyle()
return <Text as="span" {...(style as TextProps)} {...props} />
}
import React, { memo } from 'react'
import { PopoverProfile } from '../../../../PopoverProfile'
import { useTeammate } from '../../../../hooks/useMentionsQuery'
import type { MentionAttrs } from '../../../../prosemirror/schema'
import { useReactNodeView } from '../../ReactNodeView'
import { MentionText } from './MentionText'
export const Teammate = memo(function Teammate() {
const context = useReactNodeView()
const attrs = context.node?.attrs as MentionAttrs
const { teammate } = useTeammate(attrs.mentionId)
return (
<PopoverProfile
profile={{
name: teammate.name,
email: teammate.email,
image: teammate.image,
}}
>
<MentionText>{teammate.email + ' '}</MentionText>
</PopoverProfile>
)
})