Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/markdown editor #292

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"classnames": "^2.3.1",
"echarts": "^5.4.0",
"echarts-for-react": "^3.0.2",
"emoji-picker-react": "^4.11.1",
"env-cmd": "^10.1.0",
"eslint": "^7.11.0",
"framer-motion": "^10.12.18",
Expand Down
251 changes: 251 additions & 0 deletions src/components/atoms/markdown/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import React, {
useState,
TextareaHTMLAttributes,
useEffect,
useRef,
} from 'react';
import ReactMarkdown from 'react-markdown';
import styles from './markdown.module.scss';
import {
Button,
Stack,
Menu,
MenuButton,
MenuList,
MenuItem,
} from '@chakra-ui/react';
import { MdInsertLink } from 'react-icons/md';
import EmojiPicker, { EmojiClickData, Theme } from 'emoji-picker-react';

interface MarkdownEditorProps
extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
maxWidth?: number;
minWidth?: number;
error?: string[] | undefined;
resize?: boolean;
value?: string;
}

const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
error,
maxWidth,
minWidth,
label,
value,
resize,
onChange,
...rest
}) => {
const [input, setInput] = useState(value ? value : '');
const [isFocused, setIsFocused] = useState(false);
// 2 is textarea default value for rows
const [rows, setRows] = useState(2);
const [view, setShowPreview] = useState(false); // Add state variable for toggle
const [showEmoji, setShowEmoji] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);

const handleButtonClick = (callback: () => void) => {
callback();
if (textareaRef.current) {
textareaRef.current.focus();
}
};

const getLabelStyle = () => {
return !!input || isFocused
? `${styles.label} ${styles.styledLabel}`
: styles.label;
};

useEffect(() => {
if (!resize) {
return;
}
const rowlen = input ? input.toString().split('\n').length : 2;
const max = 14;
setRows(rowlen < max ? rowlen : max);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more readable with the reverse if statement.

if (resize) {
...
}

}, [input, resize]);

const insertText = (text: string, offset: number) => {
const textarea = textareaRef.current;
if (textarea === null) {
return;
}
const position = textarea.selectionStart;
const before = textarea.value.substring(0, position);
const after = textarea.value.substring(position, textarea.value.length);

// Insert the new text at the cursor position
textarea.value = before + text + after;
setInput(textarea.value);
textarea.selectionStart = textarea.selectionEnd =
position + text.length - offset;
};

const insertLink = () => {
const text = '[Display text](https://www.example.com)';
insertText(text, 1);
};

const insertBold = () => {
const text = '****';
insertText(text, 2);
};

const insertItalic = () => {
const text = '**';
insertText(text, 1);
};

const insertHeader = (level: number) => {
const text = '#'.repeat(level) + ' ';
insertText(text, 0);
};

const onClickEmoji = (EmojiObject: EmojiClickData) => {
insertText(EmojiObject.emoji, 0);
if (textareaRef.current) {
textareaRef.current.focus();
}
};

return (
<div className={styles.markdownContainer}>
<div className={styles.buttonContainer}>
<Stack direction="row" spacing={2} justify={'space-between'}>
<Button
size="xs"
colorScheme="purple"
variant={view ? 'outline' : 'solid'}
onClick={() => setShowPreview(false)}>
Write
</Button>
<Button
size="xs"
colorScheme="purple"
variant={view ? 'solid' : 'outline'}
onClick={() => setShowPreview(true)}>
Preview
</Button>
<Button
size="xs"
rightIcon={<MdInsertLink />}
colorScheme="purple"
variant="outline"
onClick={() => handleButtonClick(insertLink)}>
Link
</Button>
<Button
size="xs"
colorScheme="purple"
variant="outline"
onClick={() => handleButtonClick(insertBold)}>
B
</Button>
<Button
size="xs"
colorScheme="purple"
variant="outline"
onClick={() => handleButtonClick(insertItalic)}>
Italic
</Button>
<Menu>
<MenuButton
as={Button}
colorScheme="purple"
size="xs"
variant="outline">
H
</MenuButton>
<MenuList>
<MenuItem
onClick={() => handleButtonClick(() => insertHeader(1))}>
H1
</MenuItem>
<MenuItem
onClick={() => handleButtonClick(() => insertHeader(2))}>
H2
</MenuItem>
<MenuItem
onClick={() => handleButtonClick(() => insertHeader(3))}>
H3
</MenuItem>
<MenuItem
onClick={() => handleButtonClick(() => insertHeader(4))}>
H4
</MenuItem>
<MenuItem
onClick={() => handleButtonClick(() => insertHeader(5))}>
H5
</MenuItem>
<MenuItem
onClick={() => handleButtonClick(() => insertHeader(6))}>
H6
</MenuItem>
</MenuList>
</Menu>
<Button
size="xs"
colorScheme="purple"
variant={showEmoji ? 'solid' : 'outline'}
onClick={() => setShowEmoji(!showEmoji)}>
Emoji
</Button>
</Stack>
</div>
<div className={styles.container}>
{label && <label className={getLabelStyle()}>{label}</label>}

{view ? ( // Conditionally render markdown or preview based on toggle state
<ReactMarkdown className={styles.preview}>{input}</ReactMarkdown>
) : (
<div className={styles.inputContainer}>
<textarea
ref={textareaRef}
style={{
maxWidth: maxWidth ? maxWidth + 'ch' : '',
minWidth: minWidth ? minWidth + 'ch' : '',
}}
rows={rows}
className={styles.text}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
value={input}
onChange={(e) => {
setInput(e.target.value);
onChange && onChange(e);
}}
cols={50}
{...rest}
/>
</div>
)}

{showEmoji ? (
<div style={{ display: 'flex' }}>
<EmojiPicker
theme={Theme.AUTO}
lazyLoadEmojis={true}
onEmojiClick={onClickEmoji}
/>
</div>
) : (
<div style={{ display: 'none' }}>
<EmojiPicker onEmojiClick={onClickEmoji} />
</div>
)}

{error && (
<div className={styles.errors}>
{error.map((err, index: number) =>
error.length > 1 ? <li key={index}>{err}</li> : err
)}
</div>
)}
</div>
</div>
);
};

export default MarkdownEditor;
22 changes: 22 additions & 0 deletions src/components/atoms/markdown/markdown.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
@import 'styles/text.scss';

.markdownContainer {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
justify-content: flex-start;
}

.buttonContainer {
width: 100%;
justify-content: space-between;
padding-bottom: 3%;
}

.preview {
padding: 5%;
border: 2px solid plum;
border-radius: 5px;
text-align: start;
}
4 changes: 2 additions & 2 deletions src/components/molecules/event/eventBody/EventBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import styles from './eventBody.module.scss';
import { transformDate } from 'utils/timeConverter';
import EventButton from '../eventButton/EventButton';
import TextField from 'components/atoms/textfield/Textfield';
import Textarea from 'components/atoms/textarea/Textarea';
import { Button } from '@chakra-ui/react';
import ToggleButton from 'components/atoms/toggleButton/ToggleButton';
import { Text } from '@chakra-ui/react';
Expand All @@ -27,6 +26,7 @@ import { AuthenticateContext, RoleOptions, Roles } from 'contexts/authProvider';
import ReactMarkdown from 'react-markdown';
import FileSelector from 'components/atoms/fileSelector/FileSelector';
import { useToast } from 'hooks/useToast';
import MarkdownEditor from 'components/atoms/markdown/MarkdownEditor';

// TODO extend the admin features
export const EditEvent: React.FC<{ event: Event; setEdit: () => void }> = ({
Expand Down Expand Up @@ -202,7 +202,7 @@ export const EditEvent: React.FC<{ event: Event; setEdit: () => void }> = ({
style={{ boxSizing: 'border-box', width: '100%' }}
/>
<br />
<Textarea
<MarkdownEditor
name={'description'}
onChange={onFieldChange}
label={'Beskrivelse'}
Expand Down
5 changes: 3 additions & 2 deletions src/components/molecules/forms/eventForm/EventForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import styles from './eventForm.module.scss';
import { Button } from '@chakra-ui/react';
import { createEvent, uploadEventPicture } from 'api';
import { useHistory } from 'react-router-dom';
import Textarea from 'components/atoms/textarea/Textarea';
import MarkdownEditor from 'components/atoms/markdown/MarkdownEditor';
import ToggleButton from 'components/atoms/toggleButton/ToggleButton';
import DropdownHeader from 'components/atoms/dropdown/dropdownHeader/DropdownHeader';
import { useToast } from 'hooks/useToast';
Expand Down Expand Up @@ -179,6 +179,7 @@ const EventForm = () => {
onChange={onFieldChange}
error={fields['price'].error}
/>

<TextField
name={'maxParticipants'}
label={'Maks antall deltagere (valgfritt)'}
Expand All @@ -188,7 +189,7 @@ const EventForm = () => {
error={fields['maxParticipants'].error}
/>

<Textarea
<MarkdownEditor
name={'description'}
label={'Beskrivelse'}
minWidth={25}
Expand Down
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5091,6 +5091,13 @@ emittery@^0.8.1:
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.8.1.tgz#bb23cc86d03b30aa75a7f734819dee2e1ba70860"
integrity sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==

emoji-picker-react@^4.11.1:
version "4.11.1"
resolved "https://registry.yarnpkg.com/emoji-picker-react/-/emoji-picker-react-4.11.1.tgz#5be830d50b9ea8a606c8a4d46dfe5e0021cc2e93"
integrity sha512-e3vhGcZyyNu7GqJaXzgoVxtASXs97duAP/vh7aL88dHJcW72DjuwYMjipzNBCjPFxXwUiQas483SKCAxPwwaUQ==
dependencies:
flairup "1.0.0"

emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
Expand Down Expand Up @@ -5818,6 +5825,11 @@ find-up@^5.0.0:
locate-path "^6.0.0"
path-exists "^4.0.0"

[email protected]:
version "1.0.0"
resolved "https://registry.yarnpkg.com/flairup/-/flairup-1.0.0.tgz#d3af0052ad02734c12d2446608a869498adb351b"
integrity sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==

flat-cache@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11"
Expand Down
Loading