diff --git a/package-lock.json b/package-lock.json
index 43b48108..8bddc4df 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,10 +25,11 @@
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
- "folds": "1.0.4",
+ "folds": "1.0.5",
"formik": "2.2.9",
"html-react-parser": "3.0.4",
"immer": "9.0.16",
+ "is-hotkey": "0.2.0",
"jotai": "1.12.0",
"katex": "0.16.4",
"linkify-html": "4.0.2",
@@ -44,6 +45,8 @@
"react-google-recaptcha": "2.1.0",
"react-modal": "3.16.1",
"sanitize-html": "2.8.0",
+ "slate": "0.90.0",
+ "slate-react": "0.90.0",
"tippy.js": "6.3.7",
"twemoji": "14.0.2"
},
@@ -922,6 +925,11 @@
"@jridgewell/sourcemap-codec": "1.4.14"
}
},
+ "node_modules/@juggle/resize-observer": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
+ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
+ },
"node_modules/@khanacademy/simple-markdown": {
"version": "0.8.6",
"resolved": "https://registry.npmjs.org/@khanacademy/simple-markdown/-/simple-markdown-0.8.6.tgz",
@@ -1032,6 +1040,11 @@
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
"integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g=="
},
+ "node_modules/@types/is-hotkey": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.7.tgz",
+ "integrity": "sha512-yB5C7zcOM7idwYZZ1wKQ3pTfjA9BbvFqRWvKB46GFddxnJtHwi/b9y84ykQtxQPg5qhdpg4Q/kWU3EGoCTmLzQ=="
+ },
"node_modules/@types/json-schema": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
@@ -1044,6 +1057,11 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
+ "node_modules/@types/lodash": {
+ "version": "4.14.191",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
+ "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ=="
+ },
"node_modules/@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@@ -1904,6 +1922,11 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
+ "node_modules/compute-scroll-into-view": {
+ "version": "1.0.20",
+ "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+ "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
+ },
"node_modules/computed-style": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/computed-style/-/computed-style-0.1.4.tgz",
@@ -2071,6 +2094,18 @@
"node": ">=8"
}
},
+ "node_modules/direction": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
+ "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==",
+ "bin": {
+ "direction": "cli.js"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/dnd-core": {
"version": "15.1.2",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-15.1.2.tgz",
@@ -3022,9 +3057,9 @@
}
},
"node_modules/folds": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/folds/-/folds-1.0.4.tgz",
- "integrity": "sha512-oFtFutLaTW3CBvNlWOK8GDOWAw9CARZ/XVxGSHp6gucZRs0tNhD9bBy/oH0qbwzYfNIm4CmlBUdV5nMEaOnZsw==",
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/folds/-/folds-1.0.5.tgz",
+ "integrity": "sha512-j5QTgWL0+fpQ6v2SymealBgsMmt0BmXUbMXzXY2NO9SWgbvBbekRyEzNZfRUpX63vnLgQ1kK7s9Np/s/UrsDRw==",
"peerDependencies": {
"@vanilla-extract/css": "^1.9.2",
"@vanilla-extract/recipes": "^0.3.0",
@@ -3548,6 +3583,11 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-hotkey": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
+ "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
+ },
"node_modules/is-negative-zero": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
@@ -4882,6 +4922,14 @@
"object-assign": "^4.1.1"
}
},
+ "node_modules/scroll-into-view-if-needed": {
+ "version": "2.2.31",
+ "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+ "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
+ "dependencies": {
+ "compute-scroll-into-view": "^1.0.20"
+ }
+ },
"node_modules/sdp-transform": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.1.tgz",
@@ -4946,6 +4994,42 @@
"node": ">=8"
}
},
+ "node_modules/slate": {
+ "version": "0.90.0",
+ "resolved": "https://registry.npmjs.org/slate/-/slate-0.90.0.tgz",
+ "integrity": "sha512-dv8idv0JjYyHiAJcVKf5yWKPDMTDi+PSZyfjsnquEI8VB5nmTVGjeJab06lc3o69O7aN05ROwO9/OY8mU1IUPA==",
+ "dependencies": {
+ "immer": "^9.0.6",
+ "is-plain-object": "^5.0.0",
+ "tiny-warning": "^1.0.3"
+ }
+ },
+ "node_modules/slate-react": {
+ "version": "0.90.0",
+ "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.90.0.tgz",
+ "integrity": "sha512-z6pGd6jjU5VazLxlDi6zL3a6yaPBPJ+A2VyIlE/h/rvDywaLYGvk0xcrA9NrK71Dr47HK5ZN2zFEZNleh6wlPA==",
+ "dependencies": {
+ "@juggle/resize-observer": "^3.4.0",
+ "@types/is-hotkey": "^0.1.1",
+ "@types/lodash": "^4.14.149",
+ "direction": "^1.0.3",
+ "is-hotkey": "^0.1.6",
+ "is-plain-object": "^5.0.0",
+ "lodash": "^4.17.4",
+ "scroll-into-view-if-needed": "^2.2.20",
+ "tiny-invariant": "1.0.6"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0",
+ "slate": ">=0.65.3"
+ }
+ },
+ "node_modules/slate-react/node_modules/is-hotkey": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.8.tgz",
+ "integrity": "sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ=="
+ },
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
@@ -5084,6 +5168,11 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
+ "node_modules/tiny-invariant": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.6.tgz",
+ "integrity": "sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA=="
+ },
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
diff --git a/package.json b/package.json
index 26ed0add..c6869614 100644
--- a/package.json
+++ b/package.json
@@ -35,10 +35,11 @@
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
- "folds": "1.0.4",
+ "folds": "1.0.5",
"formik": "2.2.9",
"html-react-parser": "3.0.4",
"immer": "9.0.16",
+ "is-hotkey": "0.2.0",
"jotai": "1.12.0",
"katex": "0.16.4",
"linkify-html": "4.0.2",
@@ -54,6 +55,8 @@
"react-google-recaptcha": "2.1.0",
"react-modal": "3.16.1",
"sanitize-html": "2.8.0",
+ "slate": "0.90.0",
+ "slate-react": "0.90.0",
"tippy.js": "6.3.7",
"twemoji": "14.0.2"
},
diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts
new file mode 100644
index 00000000..ece59ff2
--- /dev/null
+++ b/src/app/components/editor/Editor.css.ts
@@ -0,0 +1,44 @@
+import { style } from '@vanilla-extract/css';
+import { color, config, DefaultReset, toRem } from 'folds';
+
+export const Editor = style([
+ DefaultReset,
+ {
+ backgroundColor: color.SurfaceVariant.Container,
+ border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
+ borderRadius: config.radii.R400,
+ },
+]);
+
+export const EditorOptions = style([
+ DefaultReset,
+ {
+ padding: config.space.S200,
+ },
+]);
+
+export const EditorTextareaScroll = style({});
+
+export const EditorTextarea = style([
+ DefaultReset,
+ {
+ flexGrow: 1,
+ height: '100%',
+ padding: `${toRem(13)} 0`,
+ selectors: {
+ [`${EditorTextareaScroll}:first-child &`]: {
+ paddingLeft: toRem(13),
+ },
+ [`${EditorTextareaScroll}:last-child &`]: {
+ paddingRight: toRem(13),
+ },
+ },
+ },
+]);
+
+export const EditorToolbar = style([
+ DefaultReset,
+ {
+ padding: config.space.S100,
+ },
+]);
diff --git a/src/app/components/editor/Editor.preview.tsx b/src/app/components/editor/Editor.preview.tsx
new file mode 100644
index 00000000..48202523
--- /dev/null
+++ b/src/app/components/editor/Editor.preview.tsx
@@ -0,0 +1,80 @@
+import React, { useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ config,
+ Icon,
+ IconButton,
+ Icons,
+ Line,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+} from 'folds';
+
+import { CustomEditor } from './Editor';
+import { Toolbar } from './Toolbar';
+
+export function EditorPreview() {
+ const [open, setOpen] = useState(false);
+ const [toolbar, setToolbar] = useState(false);
+
+ return (
+ <>
+ setOpen(!open)}>
+
+
+ }>
+
+ setOpen(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+
+
+
+
+
+ }
+ after={
+ <>
+ setToolbar(!toolbar)}
+ aria-pressed={toolbar}
+ >
+
+
+
+
+
+
+
+
+ >
+ }
+ bottom={
+ toolbar && (
+
+
+
+
+ )
+ }
+ />
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx
new file mode 100644
index 00000000..0ac3baa5
--- /dev/null
+++ b/src/app/components/editor/Editor.tsx
@@ -0,0 +1,85 @@
+import React, { KeyboardEventHandler, ReactNode, useCallback, useState } from 'react';
+import { Box, Scroll } from 'folds';
+import { createEditor } from 'slate';
+import { Slate, Editable, withReact, RenderLeafProps, RenderElementProps } from 'slate-react';
+import { BlockType, RenderElement, RenderLeaf } from './Elements';
+import { CustomElement } from './slate';
+import * as css from './Editor.css';
+import { toggleKeyboardShortcut } from './keyboard';
+
+const initialValue: CustomElement[] = [
+ {
+ type: BlockType.Paragraph,
+ children: [{ text: 'A line of text in paragraph' }],
+ },
+];
+
+type CustomEditorProps = {
+ top?: ReactNode;
+ bottom?: ReactNode;
+ before?: ReactNode;
+ after?: ReactNode;
+ maxHeight?: string;
+ placeholder?: string;
+};
+export function CustomEditor({
+ top,
+ bottom,
+ before,
+ after,
+ maxHeight = '50vh',
+ placeholder,
+}: CustomEditorProps) {
+ const [editor] = useState(() => withReact(createEditor()));
+
+ const renderElement = useCallback(
+ (props: RenderElementProps) => ,
+ []
+ );
+
+ const renderLeaf = useCallback((props: RenderLeafProps) => , []);
+
+ const handleKeydown: KeyboardEventHandler = useCallback(
+ (evt) => {
+ toggleKeyboardShortcut(editor, evt);
+ },
+ [editor]
+ );
+
+ return (
+
+
+ {top}
+
+ {before && (
+
+ {before}
+
+ )}
+
+
+
+ {after && (
+
+ {after}
+
+ )}
+
+ {bottom}
+
+
+ );
+}
diff --git a/src/app/components/editor/Elements.css.ts b/src/app/components/editor/Elements.css.ts
new file mode 100644
index 00000000..696ea3ba
--- /dev/null
+++ b/src/app/components/editor/Elements.css.ts
@@ -0,0 +1,55 @@
+import { style } from '@vanilla-extract/css';
+import { color, config, DefaultReset } from 'folds';
+
+const MarginBottom = style({
+ marginBottom: config.space.S200,
+ selectors: {
+ '&:last-child': {
+ marginBottom: 0,
+ },
+ },
+});
+
+export const Paragraph = style([MarginBottom]);
+
+export const Heading = style([MarginBottom]);
+
+export const BlockQuote = style([
+ DefaultReset,
+ MarginBottom,
+ {
+ paddingLeft: config.space.S200,
+ borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
+ fontStyle: 'italic',
+ },
+]);
+
+const BaseCode = style({
+ fontFamily: 'monospace',
+ color: color.Warning.OnContainer,
+ background: color.Warning.Container,
+ border: `${config.borderWidth.B300} solid ${color.Warning.ContainerLine}`,
+ borderRadius: config.radii.R300,
+});
+
+export const Code = style([
+ DefaultReset,
+ BaseCode,
+ {
+ padding: `0 ${config.space.S100}`,
+ },
+]);
+
+export const CodeBlock = style([DefaultReset, BaseCode, MarginBottom]);
+export const CodeBlockInternal = style({
+ padding: `${config.space.S200} ${config.space.S200} 0`,
+});
+
+export const List = style([
+ DefaultReset,
+ MarginBottom,
+ {
+ padding: `0 ${config.space.S100}`,
+ paddingLeft: config.space.S600,
+ },
+]);
diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx
new file mode 100644
index 00000000..197142d1
--- /dev/null
+++ b/src/app/components/editor/Elements.tsx
@@ -0,0 +1,108 @@
+import { Scroll, Text } from 'folds';
+import React from 'react';
+import { RenderElementProps, RenderLeafProps } from 'slate-react';
+
+import * as css from './Elements.css';
+
+export enum MarkType {
+ Bold = 'bold',
+ Italic = 'italic',
+ Underline = 'underline',
+ StrikeThrough = 'strikeThrough',
+ Code = 'code',
+}
+
+export enum BlockType {
+ Paragraph = 'paragraph',
+ Heading = 'heading',
+ CodeLine = 'code-line',
+ CodeBlock = 'code-block',
+ QuoteLine = 'quote-line',
+ BlockQuote = 'block-quote',
+ ListItem = 'list-item',
+ OrderedList = 'ordered-list',
+ UnorderedList = 'unordered-list',
+}
+
+export function RenderElement({ attributes, element, children }: RenderElementProps) {
+ switch (element.type) {
+ case BlockType.Paragraph:
+ return (
+
+ {children}
+
+ );
+ case BlockType.Heading:
+ if (element.level === 1)
+ return (
+
+ {children}
+
+ );
+ if (element.level === 2)
+ return (
+
+ {children}
+
+ );
+ if (element.level === 3)
+ return (
+
+ {children}
+
+ );
+ return (
+
+ {children}
+
+ );
+ case BlockType.CodeLine:
+ return {children}
;
+ case BlockType.CodeBlock:
+ return (
+
+
+ {children}
+
+
+ );
+ case BlockType.QuoteLine:
+ return {children}
;
+ case BlockType.BlockQuote:
+ return (
+
+ {children}
+
+ );
+ case BlockType.ListItem:
+ return {children};
+ case BlockType.OrderedList:
+ return {children}
;
+ case BlockType.UnorderedList:
+ return ;
+ default:
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
+ let child = children;
+ if (leaf.bold) child = {child};
+ if (leaf.italic) child = {child};
+ if (leaf.underline) child = {child};
+ if (leaf.strikeThrough) child = {child};
+ if (leaf.code)
+ child = (
+
+ {child}
+
+ );
+
+ if (child !== children) return child;
+
+ return {child};
+}
diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx
new file mode 100644
index 00000000..16ddb371
--- /dev/null
+++ b/src/app/components/editor/Toolbar.tsx
@@ -0,0 +1,132 @@
+import FocusTrap from 'focus-trap-react';
+import { Box, config, Icon, IconButton, Icons, IconSrc, Line, Menu, PopOut, toRem } from 'folds';
+import React, { useState } from 'react';
+import { useSlate } from 'slate-react';
+import { isBlockActive, isMarkActive, toggleBlock, toggleMark } from './common';
+import * as css from './Editor.css';
+import { BlockType, MarkType } from './Elements';
+import { HeadingLevel } from './slate';
+
+type MarkButtonProps = { format: MarkType; icon: IconSrc };
+export function MarkButton({ format, icon }: MarkButtonProps) {
+ const editor = useSlate();
+
+ return (
+ toggleMark(editor, format)}
+ aria-pressed={isMarkActive(editor, format)}
+ size="300"
+ radii="300"
+ >
+
+
+ );
+}
+
+type BlockButtonProps = { format: BlockType; icon: IconSrc };
+export function BlockButton({ format, icon }: BlockButtonProps) {
+ const editor = useSlate();
+ return (
+ toggleBlock(editor, format, { level: 1 })}
+ aria-pressed={isBlockActive(editor, format)}
+ size="300"
+ radii="300"
+ >
+
+
+ );
+}
+
+export function HeadingBlockButton() {
+ const editor = useSlate();
+ const [level, setLevel] = useState(1);
+ const [open, setOpen] = useState(false);
+ const isActive = isBlockActive(editor, BlockType.Heading);
+
+ const handleMenuSelect = (selectedLevel: HeadingLevel) => {
+ setOpen(false);
+ setLevel(selectedLevel);
+ toggleBlock(editor, BlockType.Heading, { level: selectedLevel });
+ };
+
+ return (
+ setOpen(false),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+ }}
+ >
+
+
+ }
+ >
+ {(ref) => (
+ (isActive ? toggleBlock(editor, BlockType.Heading) : setOpen(!open))}
+ aria-pressed={isActive}
+ size="300"
+ radii="300"
+ >
+
+
+
+ )}
+
+ );
+}
+
+export function Toolbar() {
+ const editor = useSlate();
+ const allowInline = !isBlockActive(editor, BlockType.CodeBlock);
+
+ return (
+
+
+
+
+
+
+
+
+ {allowInline && (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/app/components/editor/common.ts b/src/app/components/editor/common.ts
new file mode 100644
index 00000000..6432451d
--- /dev/null
+++ b/src/app/components/editor/common.ts
@@ -0,0 +1,96 @@
+import { Editor, Element, Transforms } from 'slate';
+import { BlockType, MarkType } from './Elements';
+import { HeadingLevel } from './slate';
+
+export const isMarkActive = (editor: Editor, format: MarkType) => {
+ const marks = Editor.marks(editor);
+ return marks ? marks[format] === true : false;
+};
+
+export const toggleMark = (editor: Editor, format: MarkType) => {
+ const isActive = isMarkActive(editor, format);
+
+ if (isActive) {
+ Editor.removeMark(editor, format);
+ } else {
+ Editor.addMark(editor, format, true);
+ }
+};
+
+export const isBlockActive = (editor: Editor, format: BlockType) => {
+ const [match] = Editor.nodes(editor, {
+ match: (node) => Element.isElement(node) && node.type === format,
+ });
+
+ return !!match;
+};
+
+type BlockOption = { level: HeadingLevel };
+const NESTED_BLOCK = [
+ BlockType.OrderedList,
+ BlockType.UnorderedList,
+ BlockType.BlockQuote,
+ BlockType.CodeBlock,
+];
+
+export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => {
+ const isActive = isBlockActive(editor, format);
+
+ Transforms.unwrapNodes(editor, {
+ match: (node) => Element.isElement(node) && NESTED_BLOCK.includes(node.type),
+ split: true,
+ });
+
+ if (isActive) {
+ Transforms.setNodes(editor, {
+ type: BlockType.Paragraph,
+ });
+ return;
+ }
+
+ if (format === BlockType.OrderedList || format === BlockType.UnorderedList) {
+ Transforms.setNodes(editor, {
+ type: BlockType.ListItem,
+ });
+ const block = {
+ type: format,
+ children: [],
+ };
+ Transforms.wrapNodes(editor, block);
+ return;
+ }
+ if (format === BlockType.CodeBlock) {
+ Transforms.setNodes(editor, {
+ type: BlockType.CodeLine,
+ });
+ const block = {
+ type: format,
+ children: [],
+ };
+ Transforms.wrapNodes(editor, block);
+ return;
+ }
+
+ if (format === BlockType.BlockQuote) {
+ Transforms.setNodes(editor, {
+ type: BlockType.QuoteLine,
+ });
+ const block = {
+ type: format,
+ children: [],
+ };
+ Transforms.wrapNodes(editor, block);
+ return;
+ }
+
+ if (format === BlockType.Heading) {
+ Transforms.setNodes(editor, {
+ type: format,
+ level: option?.level ?? 1,
+ });
+ }
+
+ Transforms.setNodes(editor, {
+ type: format,
+ });
+};
diff --git a/src/app/components/editor/keyboard.ts b/src/app/components/editor/keyboard.ts
new file mode 100644
index 00000000..b8b57db3
--- /dev/null
+++ b/src/app/components/editor/keyboard.ts
@@ -0,0 +1,48 @@
+import { isHotkey } from 'is-hotkey';
+import { KeyboardEvent } from 'react';
+import { Editor } from 'slate';
+import { isBlockActive, toggleBlock, toggleMark } from './common';
+import { BlockType, MarkType } from './Elements';
+
+export const INLINE_HOTKEYS: Record = {
+ 'ctrl+b': MarkType.Bold,
+ 'mod+b': MarkType.Bold,
+ 'ctrl+i': MarkType.Italic,
+ 'mod+i': MarkType.Italic,
+ 'ctrl+u': MarkType.Underline,
+ 'mod+u': MarkType.Underline,
+ 'ctrl+shift+u': MarkType.StrikeThrough,
+ 'mod+shift+u': MarkType.StrikeThrough,
+ 'ctrl+[': MarkType.Code,
+ 'mod+[': MarkType.Code,
+};
+const INLINE_KEYS = Object.keys(INLINE_HOTKEYS);
+
+export const BLOCK_HOTKEYS: Record = {
+ 'ctrl+shift+0': BlockType.OrderedList,
+ 'mod+shift+0': BlockType.OrderedList,
+ 'ctrl+shift+8': BlockType.UnorderedList,
+ 'mod+shift+8': BlockType.UnorderedList,
+ 'ctrl+shift+.': BlockType.BlockQuote,
+ 'mod+shift+.': BlockType.BlockQuote,
+ 'ctrl+shift+m': BlockType.CodeBlock,
+ 'mod+shift+m': BlockType.CodeBlock,
+};
+const BLOCK_KEYS = Object.keys(BLOCK_HOTKEYS);
+
+export const toggleKeyboardShortcut = (editor: Editor, event: KeyboardEvent) => {
+ BLOCK_KEYS.forEach((hotkey) => {
+ if (isHotkey(hotkey, event)) {
+ event.preventDefault();
+ toggleBlock(editor, BLOCK_HOTKEYS[hotkey]);
+ }
+ });
+
+ if (!isBlockActive(editor, BlockType.CodeBlock))
+ INLINE_KEYS.forEach((hotkey) => {
+ if (isHotkey(hotkey, event)) {
+ event.preventDefault();
+ toggleMark(editor, INLINE_HOTKEYS[hotkey]);
+ }
+ });
+};
diff --git a/src/app/components/editor/slate.d.ts b/src/app/components/editor/slate.d.ts
new file mode 100644
index 00000000..d2e144f4
--- /dev/null
+++ b/src/app/components/editor/slate.d.ts
@@ -0,0 +1,106 @@
+import { BaseEditor } from 'slate';
+import { ReactEditor } from 'slate-react';
+import { BlockType } from './Elements';
+
+export type HeadingLevel = 1 | 2 | 3;
+
+export type Text = {
+ text: string;
+};
+
+export type FormattedText = Text & {
+ bold?: boolean;
+ italic?: boolean;
+ underline?: boolean;
+ strikeThrough?: boolean;
+ code?: boolean;
+};
+
+export type LinkElement = {
+ type: 'link';
+ href: string;
+ children: FormattedText[];
+};
+export type SpoilerElement = {
+ type: 'spoiler';
+ children: FormattedText[];
+};
+export type UserPillElement = {
+ type: 'user-pill';
+ userId: string;
+ children: Text[];
+};
+export type RoomPillElement = {
+ type: 'room-pill';
+ roomId: string;
+ children: Text[];
+};
+export type EmoticonElement = {
+ type: 'emoticon';
+ mxc: string;
+ children: Text[];
+};
+
+export type ParagraphElement = {
+ type: BlockType.Paragraph;
+ children: FormattedText[];
+};
+export type HeadingElement = {
+ type: BlockType.Heading;
+ level: HeadingLevel;
+ children: FormattedText[];
+};
+export type CodeLineElement = {
+ type: BlockType.CodeLine;
+ children: Text[];
+};
+export type CodeBlockElement = {
+ type: BlockType.CodeBlock;
+ children: CodeLineElement[];
+};
+export type QuoteLineElement = {
+ type: BlockType.QuoteLine;
+ children: FormattedText[];
+};
+export type BlockQuoteElement = {
+ type: BlockType.BlockQuote;
+ children: QuoteLineElement[];
+};
+export type ListItemElement = {
+ type: BlockType.ListItem;
+ children: FormattedText[];
+};
+export type OrderedListElement = {
+ type: BlockType.OrderedList;
+ children: ListItemElement[];
+};
+export type UnorderedListElement = {
+ type: BlockType.UnorderedList;
+ children: ListItemElement[];
+};
+
+export type CustomElement =
+ // | LinkElement
+ // | SpoilerElement
+ // | UserPillElement
+ // | RoomPillElement
+ // | EmoticonElement
+ | ParagraphElement
+ | HeadingElement
+ | CodeLineElement
+ | CodeBlockElement
+ | QuoteLineElement
+ | BlockQuoteElement
+ | ListItemElement
+ | OrderedListElement
+ | UnorderedListElement;
+
+export type CustomEditor = BaseEditor & ReactEditor;
+
+declare module 'slate' {
+ interface CustomTypes {
+ Editor: BaseEditor & ReactEditor;
+ Element: CustomElement;
+ Text: FormattedText & Text;
+ }
+}
diff --git a/src/app/organisms/navigation/Navigation.jsx b/src/app/organisms/navigation/Navigation.jsx
index d457321f..6c368095 100644
--- a/src/app/organisms/navigation/Navigation.jsx
+++ b/src/app/organisms/navigation/Navigation.jsx
@@ -1,14 +1,14 @@
import React from 'react';
import './Navigation.scss';
-import { Sidebar1 } from './Sidebar1';
import SideBar from './SideBar';
import Drawer from './Drawer';
+import { EditorPreview } from '../../components/editor/Editor.preview';
function Navigation() {
return (
-
+
diff --git a/tsconfig.json b/tsconfig.json
index c6afcfab..02eb1843 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,7 +2,7 @@
"compilerOptions": {
"sourceMap": true,
"jsx": "react",
- "target": "ES6",
+ "target": "ES2016",
"allowJs": true,
"strict": true,
"esModuleInterop": true,