630 lines
16 KiB
TypeScript
630 lines
16 KiB
TypeScript
import React, { ReactNode } from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import { RenderElementProps, RenderLeafProps, useSlate } from 'slate-react';
|
|
import {
|
|
CustomEditor,
|
|
CustomElement,
|
|
CustomElementType,
|
|
CustomElementWithAlign,
|
|
} from '../custom-types';
|
|
import { Editor, Node, Transforms, Element as SlateElement } from 'slate';
|
|
import { ELEMENT_TAGS, TEXT_TAGS } from '../ELEMENT_TAGS';
|
|
|
|
// --- Icon Types ---
|
|
export type SlateIconType =
|
|
| 'format_bold'
|
|
| 'format_italic'
|
|
| 'format_underlined'
|
|
| 'code'
|
|
| 'looks_one'
|
|
| 'looks_two'
|
|
| 'format_quote'
|
|
| 'format_list_numbered'
|
|
| 'format_list_bulleted'
|
|
| 'format_align_left'
|
|
| 'format_align_center'
|
|
| 'format_align_right'
|
|
| 'format_align_justify';
|
|
|
|
// --- SVG Icons ---
|
|
const FormatBoldIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
|
|
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
|
|
</svg>
|
|
);
|
|
|
|
const FormatItalicIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="19" y1="4" x2="10" y2="4"></line>
|
|
<line x1="14" y1="20" x2="5" y2="20"></line>
|
|
<line x1="15" y1="4" x2="9" y2="20"></line>
|
|
</svg>
|
|
);
|
|
|
|
const FormatUnderlinedIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"></path>
|
|
<line x1="4" y1="21" x2="20" y2="21"></line>
|
|
</svg>
|
|
);
|
|
|
|
const CodeIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<polyline points="16 18 22 12 16 6"></polyline>
|
|
<polyline points="8 6 2 12 8 18"></polyline>
|
|
</svg>
|
|
);
|
|
|
|
const LooksOneIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M4 12h8"></path>
|
|
<path d="M4 18V6"></path>
|
|
<path d="M12 18V6"></path>
|
|
</svg>
|
|
);
|
|
|
|
const LooksTwoIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M10 10a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v2"></path>
|
|
<path d="M17 10a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2v2"></path>
|
|
<path d="M8 16v-6"></path>
|
|
<path d="M15 16v-6"></path>
|
|
</svg>
|
|
);
|
|
|
|
const FormatQuoteIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path>
|
|
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path>
|
|
</svg>
|
|
);
|
|
|
|
const FormatListNumberedIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="10" y1="6" x2="21" y2="6"></line>
|
|
<line x1="10" y1="12" x2="21" y2="12"></line>
|
|
<line x1="10" y1="18" x2="21" y2="18"></line>
|
|
<path d="M4 6h1v4"></path>
|
|
<path d="M4 10h2"></path>
|
|
<path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path>
|
|
</svg>
|
|
);
|
|
|
|
const FormatListBulletedIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<circle cx="9" cy="12" r="1"></circle>
|
|
<circle cx="9" cy="5" r="1"></circle>
|
|
<circle cx="9" cy="19" r="1"></circle>
|
|
<line x1="3" y1="6" x2="3" y2="6"></line>
|
|
<line x1="3" y1="12" x2="3" y2="12"></line>
|
|
<line x1="3" y1="18" x2="3" y2="18"></line>
|
|
<line x1="21" y1="6" x2="21" y2="6"></line>
|
|
<line x1="21" y1="12" x2="21" y2="12"></line>
|
|
<line x1="21" y1="18" x2="21" y2="18"></line>
|
|
<line x1="15" y1="6" x2="15" y2="6"></line>
|
|
<line x1="15" y1="12" x2="15" y2="12"></line>
|
|
<line x1="15" y1="18" x2="15" y2="18"></line>
|
|
</svg>
|
|
);
|
|
|
|
const FormatAlignLeftIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="17" y1="10" x2="3" y2="10"></line>
|
|
<line x1="21" y1="6" x2="3" y2="6"></line>
|
|
<line x1="21" y1="14" x2="3" y2="14"></line>
|
|
<line x1="17" y1="18" x2="3" y2="18"></line>
|
|
</svg>
|
|
);
|
|
|
|
const FormatAlignCenterIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="18" y1="10" x2="6" y2="10"></line>
|
|
<line x1="21" y1="6" x2="3" y2="6"></line>
|
|
<line x1="21" y1="14" x2="3" y2="14"></line>
|
|
<line x1="18" y1="18" x2="6" y2="18"></line>
|
|
</svg>
|
|
);
|
|
|
|
const FormatAlignRightIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="21" y1="10" x2="7" y2="10"></line>
|
|
<line x1="21" y1="6" x2="3" y2="6"></line>
|
|
<line x1="21" y1="14" x2="3" y2="14"></line>
|
|
<line x1="21" y1="18" x2="7" y2="18"></line>
|
|
</svg>
|
|
);
|
|
|
|
const FormatAlignJustifyIcon = () => (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="21" y1="10" x2="3" y2="10"></line>
|
|
<line x1="21" y1="6" x2="3" y2="6"></line>
|
|
<line x1="21" y1="14" x2="3" y2="14"></line>
|
|
<line x1="21" y1="18" x2="3" y2="18"></line>
|
|
</svg>
|
|
);
|
|
|
|
// --- Icon Mapping ---
|
|
const iconMap: Record<SlateIconType, React.FC> = {
|
|
format_bold: FormatBoldIcon,
|
|
format_italic: FormatItalicIcon,
|
|
format_underlined: FormatUnderlinedIcon,
|
|
code: CodeIcon,
|
|
looks_one: LooksOneIcon,
|
|
looks_two: LooksTwoIcon,
|
|
format_quote: FormatQuoteIcon,
|
|
format_list_numbered: FormatListNumberedIcon,
|
|
format_list_bulleted: FormatListBulletedIcon,
|
|
format_align_left: FormatAlignLeftIcon,
|
|
format_align_center: FormatAlignCenterIcon,
|
|
format_align_right: FormatAlignRightIcon,
|
|
format_align_justify: FormatAlignJustifyIcon,
|
|
};
|
|
|
|
export const deserialize = (node: any): any[] => {
|
|
if (node.nodeType === 3) {
|
|
return [{ text: node.textContent }];
|
|
} else if (node.nodeType !== 1) {
|
|
return [];
|
|
}
|
|
|
|
const el = node as any;
|
|
const children = Array.from(node.childNodes).map(deserialize).flat();
|
|
|
|
if (el.nodeName === 'BODY') {
|
|
return children;
|
|
}
|
|
|
|
if (ELEMENT_TAGS[el.nodeName]) {
|
|
const attrs = ELEMENT_TAGS[el.nodeName](el);
|
|
return [{ ...attrs, children }];
|
|
}
|
|
|
|
if (TEXT_TAGS[el.nodeName]) {
|
|
const attrs = TEXT_TAGS[el.nodeName]();
|
|
return children.map((child) => ({ ...child, ...attrs }));
|
|
}
|
|
|
|
return children;
|
|
};
|
|
|
|
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];
|
|
|
|
// --- Interfaces ---
|
|
|
|
// Extend standard HTML attributes to include className, id, etc. automatically
|
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
active: boolean;
|
|
reversed?: boolean;
|
|
}
|
|
|
|
type ListType = (typeof LIST_TYPES)[number];
|
|
type CustomElementFormat = CustomElementType | AlignType | ListType;
|
|
|
|
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);
|
|
};
|
|
|
|
// --- Components ---
|
|
|
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
({ className, active, ...props }, ref) => {
|
|
// Determine color based on active state for dark theme
|
|
const colorClass = active
|
|
? 'text-blue-400 bg-gray-700/50'
|
|
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/30';
|
|
|
|
return (
|
|
<button
|
|
{...props}
|
|
type="button"
|
|
ref={ref}
|
|
className={`border-none bg-transparent cursor-pointer p-2 rounded transition-colors duration-150 ${colorClass} ${className ?? ''}`}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
Button.displayName = 'Button';
|
|
|
|
// --- SlateIcon Component ---
|
|
interface SlateIconProps {
|
|
type: SlateIconType;
|
|
className?: string;
|
|
}
|
|
|
|
export const SlateIcon = React.forwardRef<HTMLSpanElement, SlateIconProps>(
|
|
({ type, className, ...props }, ref) => {
|
|
const IconComponent = iconMap[type];
|
|
return (
|
|
<span
|
|
{...props}
|
|
ref={ref}
|
|
className={`inline-flex items-center justify-center ${className ?? ''}`}
|
|
>
|
|
<IconComponent />
|
|
</span>
|
|
);
|
|
}
|
|
);
|
|
SlateIcon.displayName = 'SlateIcon';
|
|
|
|
// Keep old Icon for backward compatibility, but mark as deprecated
|
|
export const Icon = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<span
|
|
{...props}
|
|
ref={ref}
|
|
// 'material-icons' is kept as it likely comes from a global CSS link
|
|
className={`material-icons text-[18px] align-text-bottom ${className ?? ''}`}
|
|
/>
|
|
)
|
|
);
|
|
Icon.displayName = 'Icon';
|
|
|
|
export const Instruction = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<div
|
|
{...props}
|
|
ref={ref}
|
|
className={`whitespace-pre-wrap my-0 mx-[-20px] mb-[10px] p-[10px] px-[20px] text-[14px] bg-[#f8f8e8] ${className ?? ''}`}
|
|
/>
|
|
)
|
|
);
|
|
Instruction.displayName = 'Instruction';
|
|
|
|
export const Menu = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<div
|
|
{...props}
|
|
data-test-id="menu"
|
|
ref={ref}
|
|
// Replaced inline-block + margin-left logic with flex + gap
|
|
className={`flex flex-wrap items-center gap-1 ${className ?? ''}`}
|
|
/>
|
|
)
|
|
);
|
|
Menu.displayName = 'Menu';
|
|
|
|
export const Separator = () => <div className="w-px h-6 bg-gray-600 mx-1" />;
|
|
|
|
export const Portal = ({ children }: { children?: ReactNode }) => {
|
|
return typeof document === 'object' ? ReactDOM.createPortal(children, document.body) : null;
|
|
};
|
|
|
|
export const Toolbar = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<Menu
|
|
{...props}
|
|
ref={ref}
|
|
className={`relative px-3 py-2 mx-0 mb-3 border-b border-gray-700 bg-gray-800/50 ${className ?? ''}`}
|
|
/>
|
|
)
|
|
);
|
|
Toolbar.displayName = 'Toolbar';
|
|
|
|
export const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
|
|
if (leaf.bold) {
|
|
children = <strong className="font-bold">{children}</strong>;
|
|
}
|
|
|
|
if (leaf.code) {
|
|
children = (
|
|
<code className="bg-gray-900 text-blue-300 px-1 py-0.5 rounded font-mono text-sm border border-gray-700">
|
|
{children}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
if (leaf.italic) {
|
|
children = <em className="italic">{children}</em>;
|
|
}
|
|
|
|
if (leaf.underline) {
|
|
children = <u className="underline">{children}</u>;
|
|
}
|
|
|
|
return <span {...attributes}>{children}</span>;
|
|
};
|
|
|
|
const isAlignElement = (element: CustomElement): element is CustomElementWithAlign => {
|
|
return 'align' in element;
|
|
};
|
|
export const SlateRenderElement = ({ attributes, children, element }: RenderElementProps) => {
|
|
switch (element.type) {
|
|
case 'block-quote':
|
|
return (
|
|
<blockquote
|
|
className="border-l-4 border-gray-500 pl-4 italic my-4 text-gray-300"
|
|
{...attributes}
|
|
>
|
|
{children}
|
|
</blockquote>
|
|
);
|
|
case 'code-block':
|
|
return (
|
|
<pre
|
|
className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto my-4 font-mono text-sm border border-gray-700"
|
|
{...attributes}
|
|
>
|
|
<code>{children}</code>
|
|
</pre>
|
|
);
|
|
case 'bulleted-list':
|
|
return (
|
|
<ul className="list-disc pl-5 my-4 text-gray-200" {...attributes}>
|
|
{children}
|
|
</ul>
|
|
);
|
|
case 'heading-one':
|
|
return (
|
|
<h1 className="text-3xl font-bold my-4 text-gray-100" {...attributes}>
|
|
{children}
|
|
</h1>
|
|
);
|
|
case 'heading-two':
|
|
return (
|
|
<h2 className="text-2xl font-bold my-3 text-gray-100" {...attributes}>
|
|
{children}
|
|
</h2>
|
|
);
|
|
case 'heading-three':
|
|
return (
|
|
<h3 className="text-xl font-bold my-2 text-gray-100" {...attributes}>
|
|
{children}
|
|
</h3>
|
|
);
|
|
case 'list-item':
|
|
return (
|
|
<li className="my-1 text-gray-200" {...attributes}>
|
|
{children}
|
|
</li>
|
|
);
|
|
case 'numbered-list':
|
|
return (
|
|
<ol className="list-decimal pl-5 my-4 text-gray-200" {...attributes}>
|
|
{children}
|
|
</ol>
|
|
);
|
|
default:
|
|
return (
|
|
<div className="inline my-2 mr-2 leading-relaxed text-gray-200" {...attributes}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
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 (Node.isElement(n)) {
|
|
if (blockType === 'align' && isAlignElement(n)) {
|
|
return n.align === format;
|
|
}
|
|
return n.type === format;
|
|
}
|
|
return false;
|
|
},
|
|
})
|
|
);
|
|
|
|
return !!match;
|
|
};
|
|
|
|
const toggleBlock = (editor: CustomEditor, format: CustomElementFormat) => {
|
|
const isActive = isBlockActive(editor, format, isAlignType(format) ? 'align' : 'type');
|
|
const isList = isListType(format);
|
|
|
|
Transforms.unwrapNodes(editor, {
|
|
match: (n) => Node.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: [] } as any;
|
|
Transforms.wrapNodes(editor, block);
|
|
}
|
|
};
|
|
|
|
interface BlockButtonProps {
|
|
format: CustomElementFormat;
|
|
icon: SlateIconType;
|
|
}
|
|
|
|
export const BlockButton = ({ format, icon }: BlockButtonProps) => {
|
|
const editor = useSlate();
|
|
return (
|
|
<Button
|
|
active={isBlockActive(editor, format, isAlignType(format) ? 'align' : 'type')}
|
|
onPointerDown={(event: any) => event.preventDefault()}
|
|
onClick={() => toggleBlock(editor, format)}
|
|
data-test-id={`block-button-${format}`}
|
|
>
|
|
<SlateIcon type={icon} />
|
|
</Button>
|
|
);
|
|
};
|
|
|
|
export const withHtml = (editor: CustomEditor) => {
|
|
const { insertData, isInline, isVoid } = editor;
|
|
|
|
editor.isInline = (element: any) => {
|
|
return element.type === 'link' ? true : isInline(element);
|
|
};
|
|
|
|
editor.isVoid = (element: any) => {
|
|
return element.type === 'image' ? true : isVoid(element);
|
|
};
|
|
|
|
editor.insertData = (data) => {
|
|
const html = data.getData('text/html');
|
|
|
|
if (html) {
|
|
const parsed = new DOMParser().parseFromString(html, 'text/html');
|
|
const fragment = deserialize(parsed.body);
|
|
Transforms.insertFragment(editor, fragment);
|
|
return;
|
|
}
|
|
|
|
insertData(data);
|
|
};
|
|
|
|
return editor;
|
|
};
|