feat(notes): rich-text editor with tiptap (task 85)
Replace plain textarea with shared tiptap editor and sanitized html rendering. Adds interactive checklists on read surfaces and mobile overflow fixes.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { normalizeNoteBody } from "./normalize-body";
|
||||
import { sanitizeRichTextHtml } from "./sanitize";
|
||||
|
||||
export function richTextToPlainText(body: string, maxLength?: number): string {
|
||||
const normalized = normalizeNoteBody(body);
|
||||
const safe = sanitizeRichTextHtml(normalized);
|
||||
const text = safe
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<\/li>/gi, "\n")
|
||||
.replace(/<\/h[1-6]>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
if (maxLength === undefined) return text;
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength).trimEnd()}…`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { RichTextContent } from "./rich-text-content";
|
||||
export { RichTextEditor } from "./rich-text-editor";
|
||||
export { richTextToPlainText } from "./body-text";
|
||||
export { normalizeNoteBody } from "./normalize-body";
|
||||
export { sanitizeRichTextHtml } from "./sanitize";
|
||||
export { countTaskItems, toggleTaskItemInHtml } from "./toggle-checklist";
|
||||
@@ -0,0 +1,36 @@
|
||||
const HTML_LIKE = /^\s*</;
|
||||
|
||||
export function isLikelyHtml(body: string): boolean {
|
||||
return HTML_LIKE.test(body);
|
||||
}
|
||||
|
||||
export function normalizeNoteBody(body: string): string {
|
||||
if (!body.trim()) return "";
|
||||
if (isLikelyHtml(body)) return body;
|
||||
|
||||
const paragraphs = body
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (paragraphs.length === 0) return "";
|
||||
|
||||
return paragraphs
|
||||
.map((block) => {
|
||||
const lines = block
|
||||
.split("\n")
|
||||
.map((line) => escapeHtml(line.trim()))
|
||||
.filter(Boolean);
|
||||
return `<p>${lines.join("<br>")}</p>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useTransition } from "react";
|
||||
import { normalizeNoteBody } from "./normalize-body";
|
||||
import { sanitizeRichTextHtml } from "./sanitize";
|
||||
import "./rich-text.css";
|
||||
|
||||
type ToggleHandler = (taskIndex: number, checked: boolean) => Promise<void>;
|
||||
|
||||
type Props = {
|
||||
html: string;
|
||||
interactiveChecklists?: boolean;
|
||||
onToggleChecklist?: ToggleHandler;
|
||||
className?: string;
|
||||
clampLines?: number;
|
||||
};
|
||||
|
||||
export function RichTextContent({
|
||||
html,
|
||||
interactiveChecklists = false,
|
||||
onToggleChecklist,
|
||||
className,
|
||||
clampLines,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const safeHtml = useMemo(() => {
|
||||
const normalized = normalizeNoteBody(html);
|
||||
return sanitizeRichTextHtml(normalized);
|
||||
}, [html]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root || !interactiveChecklists || !onToggleChecklist) return;
|
||||
|
||||
const items = [...root.querySelectorAll<HTMLLIElement>('li[data-type="taskItem"]')];
|
||||
|
||||
for (const [index, item] of items.entries()) {
|
||||
const checked = item.getAttribute("data-checked") === "true";
|
||||
let label = item.querySelector("label");
|
||||
if (!label) {
|
||||
label = document.createElement("label");
|
||||
item.insertBefore(label, item.firstChild);
|
||||
}
|
||||
|
||||
let input = label.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
if (!input) {
|
||||
input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
label.prepend(input);
|
||||
}
|
||||
|
||||
input.checked = checked;
|
||||
input.disabled = isPending;
|
||||
input.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextChecked = !input!.checked;
|
||||
startTransition(async () => {
|
||||
await onToggleChecklist(index, nextChecked);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [safeHtml, interactiveChecklists, onToggleChecklist, isPending, router]);
|
||||
|
||||
if (!safeHtml) {
|
||||
return <p className="text-sm text-[var(--ink-mute)]">No body text.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={["rich-text-content", className].filter(Boolean).join(" ")}
|
||||
style={
|
||||
clampLines
|
||||
? {
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: clampLines,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import TableRow from "@tiptap/extension-table-row";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { normalizeNoteBody } from "./normalize-body";
|
||||
import { RichTextToolbar } from "./rich-text-toolbar";
|
||||
import "./rich-text.css";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
"aria-label"?: string;
|
||||
};
|
||||
|
||||
async function uploadImage(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetch("/api/uploads?scope=notes", { method: "POST", body: formData });
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(payload?.error ?? "Image upload failed");
|
||||
}
|
||||
const payload = (await response.json()) as { url: string };
|
||||
return payload.url;
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Start writing…",
|
||||
disabled = false,
|
||||
id,
|
||||
"aria-label": ariaLabel,
|
||||
}: Props) {
|
||||
const onChangeRef = useRef(onChange);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
editable: !disabled,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
||||
}),
|
||||
Image.configure({ inline: false, allowBase64: false }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
content: normalizeNoteBody(value),
|
||||
onUpdate: ({ editor: current }) => {
|
||||
onChangeRef.current(current.getHTML());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
...(id ? { id } : {}),
|
||||
...(ariaLabel ? { "aria-label": ariaLabel } : {}),
|
||||
class: "rich-text-content",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const normalized = normalizeNoteBody(value);
|
||||
if (editor.getHTML() === normalized) return;
|
||||
editor.commands.setContent(normalized, { emitUpdate: false });
|
||||
}, [editor, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.setEditable(!disabled);
|
||||
}, [editor, disabled]);
|
||||
|
||||
async function handleImageUpload(file: File) {
|
||||
if (!editor) return;
|
||||
const url = await uploadImage(file);
|
||||
editor.chain().focus().setImage({ src: url, alt: file.name }).run();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rich-text-editor grid gap-2">
|
||||
<RichTextToolbar editor={editor} onImageUpload={handleImageUpload} disabled={disabled} />
|
||||
<div
|
||||
className="rounded-[var(--r-md)] border-[0.5px] bg-transparent px-3 py-2"
|
||||
style={{ borderColor: "var(--hair-2)" }}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import {
|
||||
Bold,
|
||||
Code,
|
||||
Heading2,
|
||||
ImagePlus,
|
||||
Italic,
|
||||
Link2,
|
||||
List,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Redo2,
|
||||
Smile,
|
||||
Strikethrough,
|
||||
Table2,
|
||||
Underline,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
|
||||
const EMOJIS = ["😀", "😊", "❤️", "👍", "🎉", "✅", "⭐", "🔥", "🍕", "🏠", "📅", "🛒"];
|
||||
|
||||
type Props = {
|
||||
editor: Editor | null;
|
||||
onImageUpload: (file: File) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function RichTextToolbar({ editor, onImageUpload, disabled }: Props) {
|
||||
if (!editor) return null;
|
||||
|
||||
const currentEditor = editor;
|
||||
|
||||
function setLink() {
|
||||
const previous = currentEditor.getAttributes("link").href as string | undefined;
|
||||
const url = window.prompt("Link URL", previous ?? "https://");
|
||||
if (url === null) return;
|
||||
if (url === "") {
|
||||
currentEditor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
currentEditor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||
}
|
||||
|
||||
function insertTable() {
|
||||
currentEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
}
|
||||
|
||||
function pickImage() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) void onImageUpload(file);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rich-text-toolbar">
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("bold")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleBold().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Bold"
|
||||
>
|
||||
<Bold className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("italic")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleItalic().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Italic"
|
||||
>
|
||||
<Italic className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("underline")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleUnderline().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Underline"
|
||||
>
|
||||
<Underline className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("strike")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleStrike().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Strikethrough"
|
||||
>
|
||||
<Strikethrough className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
||||
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("heading", { level: 2 })}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
disabled={disabled}
|
||||
aria-label="Heading"
|
||||
>
|
||||
<Heading2 className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("bulletList")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleBulletList().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Bullet list"
|
||||
>
|
||||
<List className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("orderedList")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleOrderedList().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Numbered list"
|
||||
>
|
||||
<ListOrdered className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("taskList")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleTaskList().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Checklist"
|
||||
>
|
||||
<ListChecks className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("blockquote")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleBlockquote().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Blockquote"
|
||||
>
|
||||
<Quote className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("codeBlock")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleCodeBlock().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Code block"
|
||||
>
|
||||
<Code className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
||||
|
||||
<Button type="button" variant="ghost" size="sm" onClick={setLink} disabled={disabled}>
|
||||
<Link2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={pickImage} disabled={disabled}>
|
||||
<ImagePlus className="size-3.5" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={insertTable} disabled={disabled}>
|
||||
<Table2 className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex h-8 items-center justify-center rounded-md px-2 text-sm hover:bg-[var(--shade)]"
|
||||
disabled={disabled}
|
||||
aria-label="Insert emoji"
|
||||
>
|
||||
<Smile className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="grid grid-cols-6 gap-1 p-2">
|
||||
{EMOJIS.map((emoji) => (
|
||||
<DropdownMenuItem
|
||||
key={emoji}
|
||||
className="justify-center px-2 text-lg"
|
||||
onClick={() => currentEditor.chain().focus().insertContent(emoji).run()}
|
||||
>
|
||||
{emoji}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => currentEditor.chain().focus().undo().run()}
|
||||
disabled={disabled || !currentEditor.can().undo()}
|
||||
>
|
||||
<Undo2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => currentEditor.chain().focus().redo().run()}
|
||||
disabled={disabled || !currentEditor.can().redo()}
|
||||
>
|
||||
<Redo2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
.rich-text-content {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
text-wrap: pretty;
|
||||
color: var(--ink-2);
|
||||
font-family: var(--serif);
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.rich-text-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rich-text-content > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rich-text-content h1,
|
||||
.rich-text-content h2,
|
||||
.rich-text-content h3,
|
||||
.rich-text-content h4 {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin: 1.1em 0 0.45em;
|
||||
}
|
||||
|
||||
.rich-text-content h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.rich-text-content h2 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.rich-text-content h3 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.rich-text-content p {
|
||||
margin: 0.65em 0;
|
||||
}
|
||||
|
||||
.rich-text-content ul,
|
||||
.rich-text-content ol {
|
||||
margin: 0.65em 0;
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
|
||||
.rich-text-content blockquote {
|
||||
border-left: 3px solid var(--hair-2);
|
||||
color: var(--ink-mute);
|
||||
margin: 0.8em 0;
|
||||
padding-left: 0.9rem;
|
||||
}
|
||||
|
||||
.rich-text-content pre {
|
||||
background: var(--shade);
|
||||
border-radius: var(--r-md);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
margin: 0.8em 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.rich-text-content code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.rich-text-content a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.rich-text-content img {
|
||||
border-radius: var(--r-md);
|
||||
display: block;
|
||||
height: auto;
|
||||
margin: 0.8em 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.rich-text-content table {
|
||||
border-collapse: collapse;
|
||||
display: block;
|
||||
margin: 0.8em 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.rich-text-content th,
|
||||
.rich-text-content td {
|
||||
border: 0.5px solid var(--hair);
|
||||
min-width: 4rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rich-text-content ul[data-type="taskList"] {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.rich-text-content li[data-type="taskItem"] {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rich-text-content li[data-type="taskItem"] > label {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.rich-text-content li[data-type="taskItem"] > label input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.rich-text-content li[data-type="taskItem"] > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor .ProseMirror {
|
||||
min-height: 18rem;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.rich-text-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: var(--ink-faint);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rich-text-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"b",
|
||||
"em",
|
||||
"i",
|
||||
"u",
|
||||
"s",
|
||||
"strike",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"pre",
|
||||
"code",
|
||||
"a",
|
||||
"img",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"span",
|
||||
"div",
|
||||
"label",
|
||||
"input",
|
||||
"hr",
|
||||
];
|
||||
|
||||
const ALLOWED_ATTR = [
|
||||
"href",
|
||||
"target",
|
||||
"rel",
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
"class",
|
||||
"data-type",
|
||||
"data-checked",
|
||||
"type",
|
||||
"checked",
|
||||
"disabled",
|
||||
"colspan",
|
||||
"rowspan",
|
||||
];
|
||||
|
||||
export function sanitizeRichTextHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
ALLOW_DATA_ATTR: true,
|
||||
ADD_ATTR: ["target"],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const TASK_ITEM_RE = /<li([^>]*data-type="taskItem"[^>]*)>/gi;
|
||||
|
||||
export function toggleTaskItemInHtml(html: string, taskIndex: number, checked: boolean): string {
|
||||
let index = 0;
|
||||
return html.replace(TASK_ITEM_RE, (match, attrs: string) => {
|
||||
const current = index;
|
||||
index += 1;
|
||||
if (current !== taskIndex) return match;
|
||||
|
||||
const checkedValue = checked ? "true" : "false";
|
||||
let nextAttrs = attrs;
|
||||
|
||||
if (/data-checked="(?:true|false)"/i.test(nextAttrs)) {
|
||||
nextAttrs = nextAttrs.replace(
|
||||
/data-checked="(?:true|false)"/i,
|
||||
`data-checked="${checkedValue}"`,
|
||||
);
|
||||
} else {
|
||||
nextAttrs = `${nextAttrs} data-checked="${checkedValue}"`;
|
||||
}
|
||||
|
||||
return `<li${nextAttrs}>`;
|
||||
});
|
||||
}
|
||||
|
||||
export function countTaskItems(html: string): number {
|
||||
return [...html.matchAll(TASK_ITEM_RE)].length;
|
||||
}
|
||||
Reference in New Issue
Block a user