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', + }} + > + + + handleMenuSelect(1)} size="300" radii="300"> + + + handleMenuSelect(2)} size="300" radii="300"> + + + handleMenuSelect(3)} size="300" radii="300"> + + + + + + } + > + {(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,