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

WIP: Add slate editor with read only support #193

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions packages/orca-frontend/components/TextEditor/BlockButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { FC } from 'react';
import { Editor, Element as SlateElement, Transforms } from 'slate';
import { useSlate } from 'slate-react';
import { BulletedList, NumberedList, TextBlockQuote, TextHeader1, TextHeader2, TextHeader3 } from './menuItems';
import { StyledEditorButton } from './style';

interface BlockButtonProps {
format: string;
}

const LIST_TYPES = ['numbered-list', 'bulleted-list'];

const isBlockActive = (editor: Editor, format: string) => {
const { selection } = editor;
if (!selection) return false;

const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === format,
})
);

return !!match;
};

const toggleBlock = (editor, format) => {
const isActive = isBlockActive(editor, format);
const isList = LIST_TYPES.includes(format);

Transforms.unwrapNodes(editor, {
match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && LIST_TYPES.includes(n.type),
split: true,
});
const newProperties: Partial<SlateElement> = {
type: isActive ? 'paragraph' : isList ? 'list-item' : format,
};
Transforms.setNodes<SlateElement>(editor, newProperties);

if (!isActive && isList) {
const block = { type: format, children: [] };
Transforms.wrapNodes(editor, block);
}
};

const BlockButton: FC<BlockButtonProps> = ({ format }) => {
const editor = useSlate();

let MenuItemComponent;
switch (format) {
case 'heading-one':
MenuItemComponent = TextHeader1;
break;
case 'heading-two':
MenuItemComponent = TextHeader2;
break;
case 'heading-three':
MenuItemComponent = TextHeader3;
break;
case 'block-quote':
MenuItemComponent = TextBlockQuote;
break;
case 'bulleted-list':
MenuItemComponent = BulletedList;
break;
case 'numbered-list':
MenuItemComponent = NumberedList;
break;
}

return (
<StyledEditorButton
onMouseDown={(event) => {
event.preventDefault();
toggleBlock(editor, format);
}}
>
<MenuItemComponent width={20} active={isBlockActive(editor, format)}></MenuItemComponent>
</StyledEditorButton>
);
};

export default BlockButton;
29 changes: 29 additions & 0 deletions packages/orca-frontend/components/TextEditor/ContainerElement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { FC } from 'react';
import { RenderElementProps } from 'slate-react';
import Linkify from '../Linkify';
import { StyledBlockQuote } from './style';

export const ContainerElement: FC<RenderElementProps> = ({ attributes, children, element }) => {
switch (element.type) {
case 'block-quote':
return (
<StyledBlockQuote {...attributes}>
<Linkify>{children}</Linkify>
</StyledBlockQuote>
);
case 'bulleted-list':
return <ul {...attributes}>{children}</ul>;
case 'heading-one':
return <h1 {...attributes}>{children}</h1>;
case 'heading-two':
return <h2 {...attributes}>{children}</h2>;
case 'heading-three':
return <h3 {...attributes}>{children}</h3>;
case 'list-item':
return <li {...attributes}>{children}</li>;
case 'numbered-list':
return <ol {...attributes}>{children}</ol>;
default:
return <p {...attributes}>{children}</p>;
}
};
16 changes: 16 additions & 0 deletions packages/orca-frontend/components/TextEditor/EditorMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React, { ForwardRefRenderFunction, forwardRef } from 'react';
import { StyledEditorMenu } from './style';

interface EditorMenuProps {
children: React.ReactNode;
}

const EditorMenu: ForwardRefRenderFunction<HTMLDivElement, EditorMenuProps> = ({ children, ...props }, ref) => {
return (
<StyledEditorMenu {...props} ref={ref}>
{children}
</StyledEditorMenu>
);
};

export default forwardRef(EditorMenu);
16 changes: 16 additions & 0 deletions packages/orca-frontend/components/TextEditor/EditorToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React, { ForwardRefRenderFunction, forwardRef } from 'react';
import EditorMenu from './EditorMenu';

interface EditorToolbarProps {
children: React.ReactNode;
}

const EditorToolbar: ForwardRefRenderFunction<HTMLDivElement, EditorToolbarProps> = ({ children, ...props }, ref) => {
return (
<EditorMenu {...props} ref={ref}>
{children}
</EditorMenu>
);
};

export default forwardRef(EditorToolbar);
25 changes: 25 additions & 0 deletions packages/orca-frontend/components/TextEditor/LeafElement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { FC } from 'react';
import { StyledCodeBlock } from './style';
import { CustomRenderLeafProps } from './TextEditor';

const LeafElement: FC<CustomRenderLeafProps> = ({ attributes, children, leaf }) => {
if (leaf.bold) {
children = <strong>{children}</strong>;
}

if (leaf.code) {
children = <StyledCodeBlock>{children}</StyledCodeBlock>;
}

if (leaf.italic) {
children = <em>{children}</em>;
}

if (leaf.underline) {
children = <u>{children}</u>;
}

return <span {...attributes}>{children}</span>;
};

export default LeafElement;
57 changes: 57 additions & 0 deletions packages/orca-frontend/components/TextEditor/MarkButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { FC } from 'react';
import { Editor } from 'slate';
import { useSlate } from 'slate-react';
import { TextBold, TextCode, TextItalic, TextUnderline } from './menuItems';
import { StyledEditorButton } from './style';

interface MarkButtonProps {
format: string;
}

const isMarkActive = (editor: Editor, format: string) => {
const marks = Editor.marks(editor);
return marks ? marks[format] === true : false;
};

export const toggleMark = (editor: Editor, format: string) => {
const isActive = isMarkActive(editor, format);

if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
};

const MarkButton: FC<MarkButtonProps> = ({ format }) => {
const editor = useSlate();

let MenuItemComponent;
switch (format) {
case 'bold':
MenuItemComponent = TextBold;
break;
case 'italic':
MenuItemComponent = TextItalic;
break;
case 'underline':
MenuItemComponent = TextUnderline;
break;
case 'code':
MenuItemComponent = TextCode;
break;
}

return (
<StyledEditorButton
onMouseDown={(event) => {
event.preventDefault();
toggleMark(editor, format);
}}
>
<MenuItemComponent width="20" active={isMarkActive(editor, format)}></MenuItemComponent>
</StyledEditorButton>
);
};

export default MarkButton;
131 changes: 131 additions & 0 deletions packages/orca-frontend/components/TextEditor/TextEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React, { useCallback, useMemo, useState, FC } from 'react';
import { Editable, withReact, Slate, ReactEditor } from 'slate-react';
import isHotkey from 'is-hotkey';
import { createEditor, Descendant, BaseEditor, Editor } from 'slate';
import EditorToolbar from './EditorToolbar';
import { StyledDivider } from './style';
import BlockButton from './BlockButton';
import MarkButton, { toggleMark } from './MarkButton';
import { ContainerElement } from './ContainerElement';
import LeafElement from './LeafElement';

declare module 'slate' {
interface CustomTypes {
Editor: BaseEditor & ReactEditor;
Element: CustomElement;
Text: CustomText;
}
}

const HOTKEYS = {
'mod+b': 'bold',
'mod+i': 'italic',
'mod+u': 'underline',
'mod+`': 'code',
};

type CustomElementTypes =
| 'paragraph'
| 'block-quote'
| 'bulleted-list'
| 'heading-one'
| 'heading-two'
| 'heading-three'
| 'list-item'
| 'numbered-list';
type CustomText = {
text?: string;
bold?: boolean;
code?: boolean;
italic?: boolean;
underline?: boolean;
type?: CustomElementTypes;
children?: CustomText[];
};

export type CustomElement = { type: CustomElementTypes; children: CustomText[] };

export interface CustomRenderLeafProps {
children: any;
leaf: CustomText;
text: Text;
attributes: {
'data-slate-leaf': true;
};
}

interface TextEditorProps {
isReadOnly?: boolean;
placeholderText?: string;
name?: string;
post?: CustomElement[];
[x: string]: any;
}

const initialValue: CustomElement[] = [
{
type: 'paragraph',
children: [{ text: '' }],
},
];

const TextEditor: FC<TextEditorProps> = ({ isReadOnly, placeholderText, post, name, onChange }) => {
const [value, setValue] = useState<Descendant[]>(post);
const renderElement = useCallback((props) => <ContainerElement {...props} />, []);
const renderLeaf = useCallback((props) => <LeafElement {...props} />, []);
const editor = useMemo(() => withReact(createEditor()), []);

const handleChange = (value) => {
console.log(Editor.hasTexts(editor, initialValue[0]));
setValue(value);
if (onChange) {
onChange(name, value);
}
};

return (
<Slate editor={editor} value={value} onChange={(value) => !isReadOnly && handleChange(value)}>
{!isReadOnly && (
<EditorToolbar>
<BlockButton format="heading-one" />
<BlockButton format="heading-two" />
<BlockButton format="heading-three" />
<StyledDivider />
<MarkButton format="bold" />
<MarkButton format="italic" />
<MarkButton format="underline" />
<MarkButton format="code" />
<StyledDivider />
<BlockButton format="block-quote" />
<BlockButton format="bulleted-list" />
<BlockButton format="numbered-list" />
</EditorToolbar>
)}
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={placeholderText}
spellCheck
autoFocus
readOnly={isReadOnly}
onKeyDown={(event) => {
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey, event as any)) {
event.preventDefault();
const mark = HOTKEYS[hotkey];
toggleMark(editor, mark);
}
}
}}
/>
</Slate>
);
};

TextEditor.defaultProps = {
isReadOnly: false,
post: initialValue,
name: 'post',
};

export default TextEditor;
2 changes: 2 additions & 0 deletions packages/orca-frontend/components/TextEditor/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './TextEditor';
export type { CustomElement } from './TextEditor';
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React, { FC } from 'react';
import { StyledMenuItem } from './style';

interface BulletedListProps {
width?: string;
active?: boolean;
}

const BulletedList: FC<BulletedListProps> = ({ width, active }) => {
const DEFAULT_WIDTH = '20';

return (
<StyledMenuItem active={active} width={width || DEFAULT_WIDTH}>
<path d="M7 5h14v2H7V5m0 8v-2h14v2H7M4 4.5A1.5 1.5 0 0 1 5.5 6A1.5 1.5 0 0 1 4 7.5A1.5 1.5 0 0 1 2.5 6A1.5 1.5 0 0 1 4 4.5m0 6A1.5 1.5 0 0 1 5.5 12A1.5 1.5 0 0 1 4 13.5A1.5 1.5 0 0 1 2.5 12A1.5 1.5 0 0 1 4 10.5M7 19v-2h14v2H7m-3-2.5A1.5 1.5 0 0 1 5.5 18A1.5 1.5 0 0 1 4 19.5A1.5 1.5 0 0 1 2.5 18A1.5 1.5 0 0 1 4 16.5Z" />
</StyledMenuItem>
);
};

export default BulletedList;
Loading