265 lines
7.0 KiB
TypeScript
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, "<");
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|