Files
dashboards/components/RichTextArea.tsx
T

265 lines
7.0 KiB
TypeScript

"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import aiStyles from "@/styles/ai.module.css";
import styles from "./RichTextArea.module.css";
const FONT_SIZES = [
{ value: "2", label: "S" },
{ value: "3", label: "M" },
{ value: "4", label: "L" },
{ value: "5", label: "XL" },
] as const;
function escapeAttr(value: string) {
return value
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "&lt;");
}
export default function RichTextArea({
label,
value,
onChange,
disabled,
minHeight = 200,
toolbarEnd,
aiWorking = false,
onUploadImage,
}: {
label: string;
value: string;
onChange: (html: string) => void;
disabled?: boolean;
minHeight?: number;
toolbarEnd?: ReactNode;
aiWorking?: boolean;
onUploadImage?: (file: File) => Promise<string>;
}) {
const ref = useRef<HTMLDivElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [direction, setDirection] = useState<"ltr" | "rtl">("ltr");
const [fontSize, setFontSize] = useState("3");
const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = useState("");
useEffect(() => {
const node = ref.current;
if (!node) return;
if (node.innerHTML !== value) {
node.innerHTML = value || "";
}
}, [value]);
function emit() {
onChange(ref.current?.innerHTML ?? "");
}
function exec(command: string, commandValue?: string) {
if (disabled) return;
ref.current?.focus();
document.execCommand(command, false, commandValue);
emit();
}
function applyFontSize(size: string) {
setFontSize(size);
exec("fontSize", size);
}
function applyDirection(next: "ltr" | "rtl") {
setDirection(next);
const node = ref.current;
if (!node || disabled) return;
node.setAttribute("dir", next);
node.focus();
emit();
}
async function insertImage(file: File) {
if (!onUploadImage || disabled) return;
setUploadingImage(true);
setImageError("");
try {
const url = await onUploadImage(file);
if (
!url.startsWith("/") &&
!url.startsWith("http://") &&
!url.startsWith("https://")
) {
throw new Error("Could not add image.");
}
ref.current?.focus();
document.execCommand(
"insertHTML",
false,
`<img src="${escapeAttr(url)}" alt="" />`,
);
emit();
} catch (err) {
setImageError(
err instanceof Error ? err.message : "Could not add image.",
);
} finally {
setUploadingImage(false);
}
}
return (
<div className={styles.wrap}>
<div className={styles.head}>
{label ? <span className={styles.label}>{label}</span> : null}
<div className={styles.tools}>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Bold"
onClick={() => exec("bold")}
>
B
</button>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Italic"
onClick={() => exec("italic")}
>
I
</button>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Bullet list"
onClick={() => exec("insertUnorderedList")}
>
• List
</button>
{onUploadImage ? (
<button
type="button"
className={styles.tool}
disabled={disabled || uploadingImage}
title="Insert image"
onClick={() => imageInputRef.current?.click()}
>
{uploadingImage ? "…" : "Image"}
</button>
) : null}
<span className={styles.toolSep} aria-hidden />
<label className={styles.toolSelectWrap}>
<span className={styles.srOnly}>Font size</span>
<select
className={styles.toolSelect}
value={fontSize}
disabled={disabled}
title="Font size"
onChange={(event) => applyFontSize(event.target.value)}
>
{FONT_SIZES.map((size) => (
<option key={size.value} value={size.value}>
Size {size.label}
</option>
))}
</select>
</label>
<span className={styles.toolSep} aria-hidden />
<button
type="button"
className={`${styles.tool} ${direction === "ltr" ? styles.toolActive : ""}`}
disabled={disabled}
title="Left to right"
onClick={() => applyDirection("ltr")}
>
LTR
</button>
<button
type="button"
className={`${styles.tool} ${direction === "rtl" ? styles.toolActive : ""}`}
disabled={disabled}
title="Right to left"
onClick={() => applyDirection("rtl")}
>
RTL
</button>
<span className={styles.toolSep} aria-hidden />
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Align left"
onClick={() => exec("justifyLeft")}
>
Left
</button>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Align center"
onClick={() => exec("justifyCenter")}
>
Center
</button>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Align right"
onClick={() => exec("justifyRight")}
>
Right
</button>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Justify"
onClick={() => exec("justifyFull")}
>
Justify
</button>
{toolbarEnd ? (
<div className={styles.toolEnd}>{toolbarEnd}</div>
) : null}
</div>
</div>
{imageError ? <p className={styles.imageError}>{imageError}</p> : null}
<div
ref={ref}
className={`${styles.editor}${aiWorking ? ` ${aiStyles.aiWorkingBorder}` : ""}`}
style={{ minHeight }}
contentEditable={!disabled}
suppressContentEditableWarning
dir={direction}
onInput={emit}
onBlur={emit}
/>
{onUploadImage ? (
<input
ref={imageInputRef}
type="file"
accept="image/*"
className={styles.fileInput}
disabled={disabled || uploadingImage}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void insertImage(file);
}}
/>
) : null}
</div>
);
}