-
Notifications
You must be signed in to change notification settings - Fork 43
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
28 changed files
with
1,857 additions
and
255 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
220 changes: 220 additions & 0 deletions
220
browser/data-browser/src/chunks/MarkdownEditor/AsyncMarkdownEditor.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,220 @@ | ||
import { EditorContent, FloatingMenu, useEditor } from '@tiptap/react'; | ||
import { StarterKit } from '@tiptap/starter-kit'; | ||
import { Link } from '@tiptap/extension-link'; | ||
import { Placeholder } from '@tiptap/extension-placeholder'; | ||
import { Typography } from '@tiptap/extension-typography'; | ||
import { styled } from 'styled-components'; | ||
import { Markdown } from 'tiptap-markdown'; | ||
import { EditorEvents } from './EditorEvents'; | ||
import { FaCode } from 'react-icons/fa6'; | ||
import { useCallback, useState } from 'react'; | ||
import { BubbleMenu } from './BubbleMenu'; | ||
import { TiptapContextProvider } from './TiptapContext'; | ||
import { ToggleButton } from './ToggleButton'; | ||
import { SlashCommands, suggestion } from './SlashMenu/CommandsExtension'; | ||
import { ExtendedImage } from './ImagePicker'; | ||
import { transition } from '../../helpers/transition'; | ||
|
||
export type AsyncMarkdownEditorProps = { | ||
placeholder?: string; | ||
initialContent?: string; | ||
onChange?: (content: string) => void; | ||
id?: string; | ||
labelId?: string; | ||
}; | ||
|
||
const MIN_EDITOR_HEIGHT = '10rem'; | ||
// The lineheight of a textarea. | ||
const LINE_HEIGHT = 1.15; | ||
|
||
export default function AsyncMarkdownEditor({ | ||
placeholder, | ||
initialContent, | ||
id, | ||
labelId, | ||
onChange, | ||
}: AsyncMarkdownEditorProps): React.JSX.Element { | ||
const [extensions] = useState(() => [ | ||
StarterKit, | ||
Markdown, | ||
Typography, | ||
Link.configure({ | ||
protocols: [ | ||
'http', | ||
'https', | ||
'mailto', | ||
{ | ||
scheme: 'tel', | ||
optionalSlashes: true, | ||
}, | ||
], | ||
HTMLAttributes: { | ||
class: 'tiptap-link', | ||
rel: 'noopener noreferrer', | ||
target: '_blank', | ||
}, | ||
}), | ||
ExtendedImage.configure({ | ||
HTMLAttributes: { | ||
class: 'tiptap-image', | ||
}, | ||
}), | ||
Placeholder.configure({ | ||
placeholder: placeholder ?? 'Start typing...', | ||
}), | ||
SlashCommands.configure({ | ||
suggestion, | ||
}), | ||
]); | ||
|
||
const [markdown, setMarkdown] = useState(initialContent ?? ''); | ||
const [codeMode, setCodeMode] = useState(false); | ||
|
||
const editor = useEditor({ | ||
extensions, | ||
content: markdown, | ||
|
||
editorProps: { | ||
attributes: { | ||
...(id && { id }), | ||
...(labelId && { 'aria-labelledby': labelId }), | ||
'data-testid': 'markdown-editor', | ||
}, | ||
}, | ||
}); | ||
|
||
const handleChange = useCallback( | ||
(value: string) => { | ||
setMarkdown(value); | ||
onChange?.(value); | ||
}, | ||
[onChange], | ||
); | ||
|
||
const handleCodeModeChange = (enable: boolean) => { | ||
setCodeMode(enable); | ||
|
||
if (!enable) { | ||
editor?.commands.setContent(markdown); | ||
} | ||
}; | ||
|
||
return ( | ||
<TiptapContextProvider editor={editor}> | ||
<EditorWrapper hideEditor={codeMode}> | ||
{codeMode && ( | ||
<RawEditor | ||
placeholder={placeholder ?? 'Start typing...'} | ||
onChange={e => handleChange(e.target.value)} | ||
value={markdown} | ||
/> | ||
)} | ||
<EditorContent key='rich-editor' editor={editor}> | ||
<FloatingMenu editor={editor ?? undefined}> | ||
<FloatingMenuText>Type '/' for options</FloatingMenuText> | ||
</FloatingMenu> | ||
<BubbleMenu /> | ||
<EditorEvents onChange={handleChange} /> | ||
</EditorContent> | ||
<FloatingCodeButton | ||
type='button' | ||
active={codeMode} | ||
title='Edit raw markdown' | ||
onClick={() => handleCodeModeChange(!codeMode)} | ||
> | ||
<FaCode /> | ||
</FloatingCodeButton> | ||
</EditorWrapper> | ||
</TiptapContextProvider> | ||
); | ||
} | ||
|
||
// Textareas do not automatically grow when the content exceeds the height of the textarea. | ||
// This function calculates the height of the textarea based on the number of lines in the content. | ||
const calcHeight = (value: string) => { | ||
const lines = value.split('\n').length; | ||
|
||
return `calc(${lines * LINE_HEIGHT}em + 5px)`; | ||
}; | ||
|
||
const EditorWrapper = styled.div<{ hideEditor: boolean }>` | ||
position: relative; | ||
background-color: ${p => p.theme.colors.bg}; | ||
padding: ${p => p.theme.margin}rem; | ||
border-radius: ${p => p.theme.radius}; | ||
box-shadow: 0 0 0 1px ${p => p.theme.colors.bg2}; | ||
min-height: ${MIN_EDITOR_HEIGHT}; | ||
${transition('box-shadow')} | ||
&:focus-within { | ||
box-shadow: 0 0 0 2px ${p => p.theme.colors.main}; | ||
} | ||
&:not(:focus-within) { | ||
& .tiptap p.is-editor-empty:first-child::before { | ||
color: ${p => p.theme.colors.textLight}; | ||
content: attr(data-placeholder); | ||
float: left; | ||
height: 0; | ||
pointer-events: none; | ||
} | ||
} | ||
& .tiptap { | ||
display: ${p => (p.hideEditor ? 'none' : 'block')}; | ||
outline: none; | ||
width: min(100%, 75ch); | ||
min-height: ${MIN_EDITOR_HEIGHT}; | ||
.tiptap-image { | ||
max-width: 100%; | ||
height: auto; | ||
} | ||
pre { | ||
padding: 0.75rem 1rem; | ||
background-color: ${p => p.theme.colors.bg1}; | ||
border-radius: ${p => p.theme.radius}; | ||
font-family: monospace; | ||
code { | ||
white-space: pre; | ||
color: inherit; | ||
padding: 0; | ||
background: none; | ||
font-size: 0.8rem; | ||
} | ||
} | ||
blockquote { | ||
margin-inline-start: 0; | ||
border-inline-start: 3px solid ${p => p.theme.colors.textLight2}; | ||
color: ${p => p.theme.colors.textLight}; | ||
padding-inline-start: 1rem; | ||
} | ||
} | ||
`; | ||
|
||
const RawEditor = styled.textarea.attrs(p => ({ | ||
style: { height: calcHeight((p.value as string) ?? '') }, | ||
}))` | ||
border: none; | ||
width: 100%; | ||
min-height: ${MIN_EDITOR_HEIGHT}; | ||
outline: none; | ||
overflow: visible; | ||
height: fit-content; | ||
background-color: transparent; | ||
color: ${p => p.theme.colors.text}; | ||
resize: none; | ||
`; | ||
|
||
const FloatingMenuText = styled.span` | ||
color: ${p => p.theme.colors.textLight}; | ||
`; | ||
|
||
const FloatingCodeButton = styled(ToggleButton)` | ||
position: absolute; | ||
top: 0.5rem; | ||
right: 0.5rem; | ||
`; |
116 changes: 116 additions & 0 deletions
116
browser/data-browser/src/chunks/MarkdownEditor/BubbleMenu.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
import { BubbleMenu as TipTapBubbleMenu } from '@tiptap/react'; | ||
import { | ||
FaBold, | ||
FaCode, | ||
FaItalic, | ||
FaLink, | ||
FaQuoteLeft, | ||
FaStrikethrough, | ||
} from 'react-icons/fa6'; | ||
import { styled } from 'styled-components'; | ||
import * as RadixPopover from '@radix-ui/react-popover'; | ||
import { Row } from '../../components/Row'; | ||
|
||
import { Popover } from '../../components/Popover'; | ||
import { useState } from 'react'; | ||
import { transparentize } from 'polished'; | ||
import { EditLinkForm } from './EditLinkForm'; | ||
import { useTipTapEditor } from './TiptapContext'; | ||
import { ToggleButton } from './ToggleButton'; | ||
import { NodeSelectMenu } from './NodeSelectMenu'; | ||
|
||
export function BubbleMenu(): React.JSX.Element { | ||
const editor = useTipTapEditor(); | ||
const [linkMenuOpen, setLinkMenuOpen] = useState(false); | ||
|
||
if (!editor) { | ||
return <></>; | ||
} | ||
|
||
return ( | ||
<TipTapBubbleMenu editor={editor}> | ||
<BubbleMenuInner gap='0.5ch'> | ||
<NodeSelectMenu /> | ||
<ToggleButton | ||
title='Toggle bold' | ||
active={!!editor.isActive('bold')} | ||
onClick={() => editor.chain().focus().toggleBold().run()} | ||
disabled={!editor.can().chain().focus().toggleBold().run()} | ||
> | ||
<FaBold /> | ||
</ToggleButton> | ||
<ToggleButton | ||
title='Toggle italic' | ||
active={!!editor.isActive('italic')} | ||
onClick={() => editor.chain().focus().toggleItalic().run()} | ||
disabled={!editor.can().chain().focus().toggleItalic().run()} | ||
> | ||
<FaItalic /> | ||
</ToggleButton> | ||
<ToggleButton | ||
title='Toggle strikethrough' | ||
active={!!editor.isActive('strike')} | ||
onClick={() => editor.chain().focus().toggleStrike().run()} | ||
disabled={!editor.can().chain().focus().toggleStrike().run()} | ||
> | ||
<FaStrikethrough /> | ||
</ToggleButton> | ||
<ToggleButton | ||
title='Toggle blockquote' | ||
active={!!editor.isActive('blockquote')} | ||
onClick={() => editor.chain().focus().toggleBlockquote().run()} | ||
disabled={!editor.can().chain().focus().toggleBlockquote().run()} | ||
> | ||
<FaQuoteLeft /> | ||
</ToggleButton> | ||
<ToggleButton | ||
title='Toggle inline code' | ||
active={!!editor.isActive('code')} | ||
onClick={() => editor.chain().focus().toggleCode().run()} | ||
disabled={!editor.can().chain().focus().toggleCode().run()} | ||
> | ||
<FaCode /> | ||
</ToggleButton> | ||
<StyledPopover | ||
open={linkMenuOpen} | ||
onOpenChange={setLinkMenuOpen} | ||
Trigger={ | ||
<ToggleButton | ||
as={RadixPopover.Trigger} | ||
active={!!editor.isActive('link')} | ||
disabled={!editor.can().chain().focus().toggleCode().run()} | ||
> | ||
<FaLink /> | ||
</ToggleButton> | ||
} | ||
> | ||
<EditLinkForm onDone={() => setLinkMenuOpen(false)} /> | ||
</StyledPopover> | ||
</BubbleMenuInner> | ||
</TipTapBubbleMenu> | ||
); | ||
} | ||
|
||
const BubbleMenuInner = styled(Row)` | ||
background-color: ${p => p.theme.colors.bg}; | ||
border-radius: ${p => p.theme.radius}; | ||
padding: ${p => p.theme.margin / 2}rem; | ||
box-shadow: ${p => p.theme.boxShadowSoft}; | ||
@supports (backdrop-filter: blur(5px)) { | ||
background-color: ${p => transparentize(0.15, p.theme.colors.bg)}; | ||
backdrop-filter: blur(5px); | ||
} | ||
`; | ||
|
||
const StyledPopover = styled(Popover)` | ||
background-color: ${p => p.theme.colors.bg}; | ||
backdrop-filter: blur(5px); | ||
padding: ${p => p.theme.margin}rem; | ||
border-radius: ${p => p.theme.radius}; | ||
@supports (backdrop-filter: blur(5px)) { | ||
background-color: ${p => transparentize(0.15, p.theme.colors.bg)}; | ||
backdrop-filter: blur(5px); | ||
} | ||
`; |
Oops, something went wrong.