Skip to content

Commit

Permalink
#884 Better markdown editor
Browse files Browse the repository at this point in the history
  • Loading branch information
Polleps committed May 27, 2024
1 parent 997a748 commit 2360632
Show file tree
Hide file tree
Showing 28 changed files with 1,857 additions and 255 deletions.
1 change: 1 addition & 0 deletions browser/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This changelog covers all five packages, as they are (for now) updated as a whol
- [#850](https://github.com/atomicdata-dev/atomic-server/issues/850) Add drag & drop sorting to ResourceArray inputs.
- [#757](https://github.com/atomicdata-dev/atomic-server/issues/757) Add drag & drop sorting to sidebar.
- [#873](https://github.com/atomicdata-dev/atomic-server/issues/873) Add option to allow multiple resources in relation columns (Tables).
- [#884](https://github.com/atomicdata-dev/atomic-server/issues/884) Add new markdown editor.

### @tomic/lib

Expand Down
16 changes: 13 additions & 3 deletions browser/data-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-scroll-area": "^1.0.1",
"@radix-ui/react-tabs": "^1.0.4",
"@tiptap/extension-image": "^2.4.0",
"@tiptap/extension-link": "^2.3.2",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/extension-typography": "^2.4.0",
"@tiptap/pm": "^2.3.0",
"@tiptap/react": "^2.3.0",
"@tiptap/starter-kit": "^2.3.0",
"@tiptap/suggestion": "^2.4.0",
"@tomic/react": "workspace:*",
"emoji-mart": "^5.5.2",
"polished": "^4.1.0",
Expand All @@ -42,10 +50,11 @@
"remark-gfm": "^3.0.1",
"styled-components": "^6.0.7",
"stylis": "4.3.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"yamde": "^1.7.1"
},
"devDependencies": {
"vite": "^5.2.10",
"@swc/plugin-styled-components": "^1.5.110",
"@types/react-pdf": "^6.2.0",
"@types/react-window": "^1.8.7",
Expand All @@ -54,10 +63,11 @@
"gh-pages": "^5.0.0",
"lint-staged": "^10.5.4",
"types-wm": "^1.1.0",
"typescript": "^5.4.5",
"vite": "^5.2.10",
"vite-plugin-pwa": "^0.17.0",
"vite-plugin-webfont-dl": "^3.9.1",
"workbox-cli": "^6.4.1",
"typescript": "^5.4.5"
"workbox-cli": "^6.4.1"
},
"type": "module",
"homepage": "https://atomicdata.dev/",
Expand Down
220 changes: 220 additions & 0 deletions browser/data-browser/src/chunks/MarkdownEditor/AsyncMarkdownEditor.tsx
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 &apos;/&apos; 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 browser/data-browser/src/chunks/MarkdownEditor/BubbleMenu.tsx
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);
}
`;
Loading

0 comments on commit 2360632

Please sign in to comment.