added slate editor, fixes

This commit is contained in:
lnn0q 2025-05-19 09:42:15 +03:00
parent 1d40d7e15f
commit bc250e73bb
12 changed files with 828 additions and 16 deletions

View file

@ -36,7 +36,7 @@ export function BookSearchList() {
<WorkingList workingsList={[...data.bookMany.items]} />
{data.bookMany.items.length != 0 && (
{data.bookMany.items.length !== 0 && (
<Pagination
total={data.bookMany.total}
page={workingsListPage}

View file

@ -0,0 +1,362 @@
import { Button, Card, Stack, Text, Wrap } from '@chakra-ui/react';
import isHotkey from 'is-hotkey';
import React, { KeyboardEvent, MouseEvent, useCallback, useMemo, useState } 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() {
return (
<>
<Card.Root
variant={'subtle'}
width="100%"
maxWidth="840px"
minHeight="100%"
flex={1}
justifyContent="center"
alignItems="center"
>
{/* <Card.Header>
<Heading textAlign="center">Editor</Heading>
</Card.Header> */}
<Card.Body
width="100%"
css={{
'& *': {
fontSize: 'revert',
fontWeight: 'revert'
}
}}
>
<RichTextExample />
</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;
const RichTextExample = () => {
const renderElement = useCallback((props: RenderElementProps) => <Element {...props} />, []);
const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
const initialValue: Descendant[] = [
{
type: 'paragraph',
children: [
{ text: 'This is editable ' },
{ text: 'rich', bold: true },
{ text: ' text, ' },
{ text: 'much', italic: true },
{ text: ' better than a ' },
{ text: '<textarea>', code: true },
{ text: '!' }
]
},
{
type: 'paragraph',
children: [
{
text: "Since it's rich text, you can do things like turn a selection of text "
},
{ text: 'bold', bold: true },
{
text: ', or add a semantically rendered block quote in the middle of the page, like this:'
}
]
},
{
type: 'block-quote',
children: [{ text: 'A wise quote.' }]
},
{
type: 'paragraph',
align: 'center',
children: [{ text: 'Try it out for yourself!' }]
}
];
return (
<Slate editor={editor} initialValue={initialValue}>
<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%" display="flex" justifyContent="center" alignItems="center">
<Prose
width="100%"
height="100%"
maxWidth="unset"
maxHeight="unset"
display="flex"
justifyContent="center"
alignItems="center"
>
<Editable
className="slate-editor"
style={{
outline: 'none',
minWidth: '100%'
}}
renderElement={renderElement}
renderLeaf={renderLeaf}
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>
);
};
const 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);
}
};
const toggleMark = (editor: CustomEditor, format: CustomTextKey) => {
const isActive = isMarkActive(editor, format);
if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
};
const 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 !!match;
};
const isMarkActive = (editor: CustomEditor, format: CustomTextKey) => {
const marks = Editor.marks(editor);
return marks ? marks[format] === true : false;
};
const 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>
);
}
};
const 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;
}
const 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;
}
const 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>
);
};
const isAlignType = (format: CustomElementFormat): format is AlignType => {
return TEXT_ALIGN_TYPES.includes(format as AlignType);
};
const isListType = (format: CustomElementFormat): format is ListType => {
return LIST_TYPES.includes(format as ListType);
};
const isAlignElement = (element: CustomElement): element is CustomElementWithAlign => {
return 'align' in element;
};

View file

@ -0,0 +1,56 @@
import { Card, Heading } from '@chakra-ui/react';
import { useState } from 'react';
import { BaseEditor, createEditor, Descendant } from 'slate';
import { Editable, ReactEditor, Slate, withReact } from 'slate-react';
type CustomElement = { type: 'paragraph'; children: CustomText[] };
type CustomText = { text: string };
declare module 'slate' {
interface CustomTypes {
Editor: BaseEditor & ReactEditor;
Element: CustomElement;
Text: CustomText;
}
}
export function EditorSlate() {
const initialValue: Descendant[] = [
{
type: 'paragraph',
children: [{ text: 'A line of text in a paragraph.' }]
}
];
const [editor] = useState(() => withReact(createEditor()));
return (
// Add the editable component inside the context.
<>
<Card.Root
variant={'subtle'}
width="100%"
maxWidth="840px"
minHeight="sm"
justifyContent="center"
alignItems="center"
>
<Card.Header>
<Heading textAlign="center">Editor</Heading>
</Card.Header>
<Card.Body
width="90%"
css={{
'& *': {
fontSize: 'revert',
fontWeight: 'revert'
}
}}
>
<Slate editor={editor} initialValue={initialValue}>
<Editable />
</Slate>
</Card.Body>
</Card.Root>
</>
);
}

View file

@ -4,7 +4,7 @@ import { WorkingCardProps, WorkingListCard } from './WorkingListCard';
export function WorkingList({ workingsList }: { workingsList: WorkingCardProps[] }) {
return (
<List.Root variant="plain" gap={4}>
{workingsList.length != 0 ? (
{workingsList.length !== 0 ? (
workingsList.map(workingData => (
<List.Item key={workingData.id}>
<WorkingListCard {...workingData} />

View file

@ -1,6 +1,6 @@
import { Stack } from '@chakra-ui/react';
import { createFileRoute } from '@tanstack/react-router';
import { Editor } from '../components/Editor';
import { EditorSlate } from '../components/EditorSlate';
export const Route = createFileRoute('/editor')({
component: RouteComponent
@ -8,8 +8,8 @@ export const Route = createFileRoute('/editor')({
function RouteComponent() {
return (
<Stack justifyContent="center" alignItems="center">
<Editor />
<Stack justifyContent="center" alignItems="center" minHeight="88vh">
<EditorSlate />
</Stack>
);
}

View file

@ -1,4 +1,4 @@
import { Heading, Input, Stack } from '@chakra-ui/react';
import { Heading, Input, Stack, Textarea } from '@chakra-ui/react';
import { Button } from '@client/components/ui/button';
import { Field } from '@client/components/ui/field';
import { valibotResolver } from '@hookform/resolvers/valibot';
@ -15,13 +15,51 @@ export const Route = createFileRoute('/new/book')({
function RouteComponent() {
return (
<Stack flexDirection={'column'} justifyContent={'center'} alignItems={'center'}>
<Stack width="100%" flexDirection={'column'} justifyContent={'center'} alignItems={'center'}>
<Heading>New book</Heading>
<form></form>
<NewBookForm />
</Stack>
);
}
interface FormValues {
title: string;
description: string;
}
function NewBookForm() {
const schema = v.pipe(
v.object({
title: v.pipe(v.string(), v.nonEmpty('Title is required')),
description: v.pipe(v.string(), v.nonEmpty('Password is required'))
})
);
const {
register,
handleSubmit,
formState: { errors }
} = useForm<FormValues>({ resolver: valibotResolver(schema) });
return (
<form style={{ width: '80%' }} onSubmit={handleSubmit(async values => {})}>
<Stack>
<Field label="Title" invalid={errors.title != null} errorText={errors.title?.message}>
<Input {...register('title')} />
</Field>
<Field
label="Description"
invalid={errors.description != null}
errorText={errors.description?.message}
>
<Textarea width="100%" lineHeight={4} {...register('description')} />
</Field>
<Button type="submit">Create new book</Button>
</Stack>
</form>
);
}
// function LoginForm() {
// const schema = v.pipe(
// v.object({
@ -40,13 +78,6 @@ function RouteComponent() {
//
// return (
// <form
// onSubmit={handleSubmit(async values => {
// const session = await login({
// email: values.email,
// password: values.password
// });
// if (session != null) window.history.back();
// })}
// >
// <Stack>
// <Field label="Email" invalid={errors.email != null} errorText={errors.email?.message}>

196
app/client/types.d.ts vendored Normal file
View file

@ -0,0 +1,196 @@
import { Descendant, BaseEditor, BaseRange, Range, Element } from 'slate';
import { ReactEditor } from 'slate-react';
import { HistoryEditor } from 'slate-history';
export type BlockQuoteElement = {
type: 'block-quote';
align?: string;
children: Descendant[];
};
export type BulletedListElement = {
type: 'bulleted-list';
align?: string;
children: Descendant[];
};
export type CheckListItemElement = {
type: 'check-list-item';
checked: boolean;
children: Descendant[];
};
export type EditableVoidElement = {
type: 'editable-void';
children: EmptyText[];
};
export type HeadingElement = {
type: 'heading-one';
align?: string;
children: Descendant[];
};
export type HeadingTwoElement = {
type: 'heading-two';
align?: string;
children: Descendant[];
};
export type HeadingThreeElement = {
type: 'heading-three';
align?: string;
children: Descendant[];
};
export type HeadingFourElement = {
type: 'heading-four';
align?: string;
children: Descendant[];
};
export type HeadingFiveElement = {
type: 'heading-five';
align?: string;
children: Descendant[];
};
export type HeadingSixElement = {
type: 'heading-six';
align?: string;
children: Descendant[];
};
export type ImageElement = {
type: 'image';
url: string;
children: EmptyText[];
};
export type LinkElement = { type: 'link'; url: string; children: Descendant[] };
export type ButtonElement = { type: 'button'; children: Descendant[] };
export type BadgeElement = { type: 'badge'; children: Descendant[] };
export type ListItemElement = { type: 'list-item'; children: Descendant[] };
export type NumberedListItemElement = {
type: 'numbered-list';
children: Descendant[];
};
export type MentionElement = {
type: 'mention';
character: string;
children: CustomText[];
};
export type ParagraphElement = {
type: 'paragraph';
align?: string;
children: Descendant[];
};
export type TableElement = { type: 'table'; children: TableRow[] };
export type TableCellElement = { type: 'table-cell'; children: CustomText[] };
export type TableRowElement = { type: 'table-row'; children: TableCell[] };
export type TitleElement = { type: 'title'; children: Descendant[] };
export type VideoElement = { type: 'video'; url: string; children: EmptyText[] };
export type CodeBlockElement = {
type: 'code-block';
language: string;
children: Descendant[];
};
export type CodeLineElement = {
type: 'code-line';
children: Descendant[];
};
export type CustomElementWithAlign =
| ParagraphElement
| HeadingElement
| HeadingTwoElement
| HeadingThreeElement
| HeadingFourElement
| HeadingFiveElement
| HeadingSixElement
| BlockQuoteElement
| BulletedListElement;
type CustomElement =
| BlockQuoteElement
| BulletedListElement
| CheckListItemElement
| EditableVoidElement
| HeadingElement
| HeadingTwoElement
| HeadingThreeElement
| HeadingFourElement
| HeadingFiveElement
| HeadingSixElement
| ImageElement
| LinkElement
| ButtonElement
| BadgeElement
| ListItemElement
| NumberedListItemElement
| MentionElement
| ParagraphElement
| TableElement
| TableRowElement
| TableCellElement
| TitleElement
| VideoElement
| CodeBlockElement
| CodeLineElement;
export type CustomElementType = CustomElement['type'];
export type CustomText = {
bold?: boolean;
italic?: boolean;
code?: boolean;
underline?: boolean;
strikethrough?: boolean;
// MARKDOWN PREVIEW SPECIFIC LEAF
underlined?: boolean;
title?: boolean;
list?: boolean;
hr?: boolean;
blockquote?: boolean;
text: string;
};
export type CustomTextKey = keyof Omit<CustomText, 'text'>;
export type EmptyText = {
text: string;
};
export type RenderElementPropsFor<T> = RenderElementProps & {
element: T;
};
export type CustomEditor = BaseEditor &
ReactEditor &
HistoryEditor & {
nodeToDecorations?: Map<Element, Range[]>;
};
declare module 'slate' {
interface CustomTypes {
Editor: CustomEditor;
Element: CustomElement;
Text: CustomText;
Range: BaseRange & {
[key: string]: unknown;
};
}
}

View file

@ -11,6 +11,7 @@ export async function toBookChapterSchema(model: BookChapter): Promise<BookChapt
updatedAt: model.createdAt.toISOString(),
foreword: model.foreword,
footnote: model.footnote,
sequenceNumber: model.sequenceNumber,
reviewsTotal: await call(async () => {
const reviewsCount = await prisma.review.count({
where: {

View file

@ -52,6 +52,18 @@ input PaginationFilterInput {
size: Int
}
input BookCreateInput {
title: String!
description: String!
}
input BookChapterCreateInput {
title: String!
content: String!
foreword: String
footnote: String
}
input BookManyInput {
filter: PaginationFilterInput!
}
@ -88,6 +100,7 @@ type BookChapter {
updatedAt: String!
foreword: String!
footnote: String!
sequenceNumber: Int!
reviewsTotal: Int!
}

View file

@ -59,6 +59,8 @@ model BookChapter {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sequenceNumber Int @default(1)
title String @db.VarChar(255)
foreword String @db.MediumText
footnote String @db.MediumText