360 lines
9.5 KiB
TypeScript
360 lines
9.5 KiB
TypeScript
import { Button, Card, Stack, Text, Wrap } from '@chakra-ui/react';
|
|
import isHotkey from 'is-hotkey';
|
|
import React, { KeyboardEvent, MouseEvent, useCallback, useMemo } from 'react';
|
|
import { createEditor, Descendant, Editor, Element as SlateElement, Transforms } from 'slate';
|
|
import { withHistory } from 'slate-history';
|
|
import {
|
|
Editable,
|
|
RenderElementProps,
|
|
RenderLeafProps,
|
|
Slate,
|
|
useSlate,
|
|
withReact
|
|
} from 'slate-react';
|
|
import {
|
|
CustomEditor,
|
|
CustomElement,
|
|
CustomElementType,
|
|
CustomElementWithAlign,
|
|
CustomTextKey
|
|
} from '../types';
|
|
import { Prose } from './ui/prose';
|
|
|
|
export function EditorSlate({
|
|
initialValue,
|
|
onChange,
|
|
readOnly
|
|
}: {
|
|
readonly initialValue?: Descendant[];
|
|
readonly onChange?: (values: Descendant[]) => void;
|
|
readonly readOnly?: boolean;
|
|
}) {
|
|
return (
|
|
<>
|
|
<Card.Root
|
|
variant={'subtle'}
|
|
width="100%"
|
|
flex={1}
|
|
justifyContent="center"
|
|
alignItems="center"
|
|
background={readOnly ? 'none' : undefined}
|
|
>
|
|
<Card.Body
|
|
width="100%"
|
|
css={{
|
|
'& *': {
|
|
fontSize: 'revert',
|
|
fontWeight: 'revert'
|
|
}
|
|
}}
|
|
>
|
|
<EditorViewport
|
|
initialValue={
|
|
initialValue ?? [
|
|
{
|
|
type: 'paragraph',
|
|
children: [{ text: '' }]
|
|
}
|
|
]
|
|
}
|
|
onChange={values => {
|
|
if (onChange != null) onChange(values);
|
|
}}
|
|
readOnly={readOnly}
|
|
/>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const HOTKEYS: Record<string, CustomTextKey> = {
|
|
'mod+b': 'bold',
|
|
'mod+i': 'italic',
|
|
'mod+u': 'underline',
|
|
'mod+`': 'code'
|
|
};
|
|
|
|
const LIST_TYPES = ['numbered-list', 'bulleted-list'] as const;
|
|
const TEXT_ALIGN_TYPES = ['left', 'center', 'right', 'justify'] as const;
|
|
|
|
type AlignType = (typeof TEXT_ALIGN_TYPES)[number];
|
|
type ListType = (typeof LIST_TYPES)[number];
|
|
type CustomElementFormat = CustomElementType | AlignType | ListType;
|
|
|
|
function EditorViewport({
|
|
initialValue,
|
|
onChange,
|
|
readOnly
|
|
}: {
|
|
readonly initialValue: Descendant[];
|
|
readonly onChange: (values: Descendant[]) => void;
|
|
readonly readOnly?: boolean;
|
|
}) {
|
|
const renderElement = useCallback((props: RenderElementProps) => <Element {...props} />, []);
|
|
const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
|
|
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
|
|
|
|
return (
|
|
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
|
{!readOnly && (
|
|
<Wrap justifyContent="center" alignItems="center" gap={1}>
|
|
<MarkButton format="bold" icon="nf nf-oct-bold" />
|
|
<MarkButton format="italic" icon="nf nf-oct-italic" />
|
|
<MarkButton format="underline" icon="nf nf-md-format_underline" />
|
|
<MarkButton format="code" icon="nf nf-oct-code" />
|
|
<BlockButton format="heading-one" icon="nf nf-fa-heading" text="1" />
|
|
<BlockButton format="heading-two" icon="nf nf-fa-heading" text="2" />
|
|
<BlockButton format="block-quote" icon="nf nf-oct-quote" />
|
|
<BlockButton format="numbered-list" icon="nf nf-fa-list_ol" />
|
|
<BlockButton format="bulleted-list" icon="nf nf-fa-list_ul" />
|
|
<BlockButton format="left" icon="nf nf-fa-align_left" />
|
|
<BlockButton format="center" icon="nf nf-fa-align_center" />
|
|
<BlockButton format="right" icon="nf nf-fa-align_right" />
|
|
<BlockButton format="justify" icon="nf nf-fa-align_justify" />
|
|
</Wrap>
|
|
)}
|
|
<Stack
|
|
width="100%"
|
|
maxWidth="100%"
|
|
height="100%"
|
|
paddingX={3}
|
|
display="flex"
|
|
justifyContent="center"
|
|
alignItems="center"
|
|
flex={1}
|
|
>
|
|
<Prose width="100%" minWidth="100%" height="100%" display="flex" flex={1}>
|
|
<Editable
|
|
className="slate-editor"
|
|
style={{
|
|
outline: 'none',
|
|
minWidth: '100%',
|
|
maxWidth: 'unset',
|
|
maxHeight: readOnly ? undefined : '60vh',
|
|
height: '100%',
|
|
padding: 0,
|
|
overflowY: 'auto'
|
|
}}
|
|
renderElement={renderElement}
|
|
renderLeaf={renderLeaf}
|
|
readOnly={readOnly}
|
|
placeholder=""
|
|
spellCheck
|
|
autoFocus
|
|
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
|
for (const hotkey in HOTKEYS) {
|
|
if (isHotkey(hotkey, event as any)) {
|
|
event.preventDefault();
|
|
const mark = HOTKEYS[hotkey];
|
|
toggleMark(editor, mark);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
</Prose>
|
|
</Stack>
|
|
</Slate>
|
|
);
|
|
}
|
|
|
|
function toggleBlock(editor: CustomEditor, format: CustomElementFormat) {
|
|
const isActive = isBlockActive(editor, format, isAlignType(format) ? 'align' : 'type');
|
|
const isList = isListType(format);
|
|
|
|
Transforms.unwrapNodes(editor, {
|
|
match: n =>
|
|
!Editor.isEditor(n) &&
|
|
SlateElement.isElement(n) &&
|
|
isListType(n.type) &&
|
|
!isAlignType(format),
|
|
split: true
|
|
});
|
|
let newProperties: Partial<SlateElement>;
|
|
if (isAlignType(format)) {
|
|
newProperties = {
|
|
align: isActive ? undefined : format
|
|
};
|
|
} else {
|
|
newProperties = {
|
|
type: isActive ? 'paragraph' : isList ? 'list-item' : format
|
|
};
|
|
}
|
|
Transforms.setNodes<SlateElement>(editor, newProperties);
|
|
|
|
if (!isActive && isList) {
|
|
const block = { type: format, children: [] };
|
|
Transforms.wrapNodes(editor, block);
|
|
}
|
|
}
|
|
|
|
function toggleMark(editor: CustomEditor, format: CustomTextKey) {
|
|
const isActive = isMarkActive(editor, format);
|
|
|
|
if (isActive) {
|
|
Editor.removeMark(editor, format);
|
|
} else {
|
|
Editor.addMark(editor, format, true);
|
|
}
|
|
}
|
|
|
|
function isBlockActive(
|
|
editor: CustomEditor,
|
|
format: CustomElementFormat,
|
|
blockType: 'type' | 'align' = 'type'
|
|
) {
|
|
const { selection } = editor;
|
|
if (!selection) return false;
|
|
|
|
const [match] = Array.from(
|
|
Editor.nodes(editor, {
|
|
at: Editor.unhangRange(editor, selection),
|
|
match: n => {
|
|
if (!Editor.isEditor(n) && SlateElement.isElement(n)) {
|
|
if (blockType === 'align' && isAlignElement(n)) {
|
|
return n.align === format;
|
|
}
|
|
return n.type === format;
|
|
}
|
|
return false;
|
|
}
|
|
})
|
|
);
|
|
|
|
return Boolean(match);
|
|
}
|
|
|
|
function isMarkActive(editor: CustomEditor, format: CustomTextKey) {
|
|
const marks = Editor.marks(editor);
|
|
return marks ? marks[format] === true : false;
|
|
}
|
|
|
|
function Element({ attributes, children, element }: RenderElementProps) {
|
|
const style: React.CSSProperties = {};
|
|
if (isAlignElement(element)) {
|
|
style.textAlign = element.align as AlignType;
|
|
}
|
|
switch (element.type) {
|
|
case 'block-quote':
|
|
return (
|
|
<blockquote style={style} {...attributes}>
|
|
{children}
|
|
</blockquote>
|
|
);
|
|
case 'bulleted-list':
|
|
return (
|
|
<ul style={style} {...attributes}>
|
|
{children}
|
|
</ul>
|
|
);
|
|
case 'heading-one':
|
|
return (
|
|
<h1 style={style} {...attributes}>
|
|
{children}
|
|
</h1>
|
|
);
|
|
case 'heading-two':
|
|
return (
|
|
<h2 style={style} {...attributes}>
|
|
{children}
|
|
</h2>
|
|
);
|
|
case 'list-item':
|
|
return (
|
|
<li style={style} {...attributes}>
|
|
{children}
|
|
</li>
|
|
);
|
|
case 'numbered-list':
|
|
return (
|
|
<ol style={style} {...attributes}>
|
|
{children}
|
|
</ol>
|
|
);
|
|
default:
|
|
return (
|
|
<p style={style} {...attributes}>
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|
|
}
|
|
|
|
function Leaf({ attributes, children, leaf }: RenderLeafProps) {
|
|
if (leaf.bold) {
|
|
children = <strong>{children}</strong>;
|
|
}
|
|
|
|
if (leaf.code) {
|
|
children = <code>{children}</code>;
|
|
}
|
|
|
|
if (leaf.italic) {
|
|
children = <em style={{ fontStyle: 'italic' }}>{children}</em>;
|
|
}
|
|
|
|
if (leaf.underline) {
|
|
children = <u>{children}</u>;
|
|
}
|
|
|
|
return <span {...attributes}>{children}</span>;
|
|
}
|
|
|
|
interface BlockButtonProps {
|
|
format: CustomElementFormat;
|
|
icon: string;
|
|
text?: string;
|
|
}
|
|
|
|
function BlockButton({ format, icon, text }: BlockButtonProps) {
|
|
const editor = useSlate();
|
|
return (
|
|
<Button
|
|
variant={isBlockActive(editor, format) ? 'solid' : 'ghost'}
|
|
onMouseDown={(event: MouseEvent<HTMLSpanElement>) => {
|
|
event.preventDefault();
|
|
toggleBlock(editor, format);
|
|
}}
|
|
gap={0.5}
|
|
>
|
|
<i className={icon} />
|
|
<Stack alignItems="end">
|
|
<Text fontSize="0.8rem">{text}</Text>
|
|
</Stack>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
interface MarkButtonProps {
|
|
format: CustomTextKey;
|
|
icon: string;
|
|
text?: string;
|
|
}
|
|
|
|
function MarkButton({ format, icon, text }: MarkButtonProps) {
|
|
const editor = useSlate();
|
|
return (
|
|
<Button
|
|
variant={isMarkActive(editor, format) ? 'solid' : 'ghost'}
|
|
onMouseDown={(event: MouseEvent<HTMLSpanElement>) => {
|
|
event.preventDefault();
|
|
toggleMark(editor, format);
|
|
}}
|
|
>
|
|
<i className={icon} />
|
|
{text}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function isAlignType(format: CustomElementFormat): format is AlignType {
|
|
return TEXT_ALIGN_TYPES.includes(format as AlignType);
|
|
}
|
|
|
|
function isListType(format: CustomElementFormat): format is ListType {
|
|
return LIST_TYPES.includes(format as ListType);
|
|
}
|
|
|
|
function isAlignElement(element: CustomElement): element is CustomElementWithAlign {
|
|
return 'align' in element;
|
|
}
|