kanban-app/frontend/src/components/slate-editor-components/index.tsx

631 lines
16 KiB
TypeScript
Raw Normal View History

2026-03-22 11:52:33 +00:00
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;
};