feat: replace rich text editor with custom Tiptap implementation and add HTML formatting utilities

This commit is contained in:
2026-06-05 19:25:51 +07:00 Unverified
parent 658e683c96
commit cb288588d8
7 changed files with 69 additions and 46 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ import {
} from "@/app/actions/questions";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X } from "lucide-react";
import Image from 'next/image';
import { RichTextEditor } from "@/components/ui/rich-text-editor";
import { RichTextEditor } from "@/components/rich-text-editor";
interface Question {
id: string;
+6 -4
View File
@@ -16,6 +16,8 @@ import {
import { Copy, Check, Users, Calendar, Clock } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import Image from 'next/image';
import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
interface Question {
id: string;
@@ -112,9 +114,9 @@ export default function SubmissionDetailClient({
>
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0 gap-4">
<div className="space-y-1">
<Label className="text-base font-semibold leading-normal">
{q.label}
</Label>
<div className="text-base font-semibold leading-normal">
<HtmlDisplay html={q.label} />
</div>
{wasEdited && (
<p className="text-xs text-muted-foreground italic flex items-center gap-1">
edited
@@ -173,7 +175,7 @@ export default function SubmissionDetailClient({
History
</ContextMenuLabel>
<ContextMenuLabel className="text-sm font-medium text-foreground truncate pb-2">
{q.label}
{stripHtml(q.label)}
</ContextMenuLabel>
<ContextMenuSeparator />
+4 -41
View File
@@ -9,6 +9,9 @@ import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { cn, stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
import { FormattedText } from "@/components/formatted-text";
import { Dialog, DialogContent, DialogTrigger, DialogTitle } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { toast } from "sonner";
@@ -28,40 +31,7 @@ import {
FormMessage,
} from "@/components/ui/form";
function FormattedText({ text }: { text: string }) {
const lines = text.split('\n');
const urlRegex = /(https?:\/\/[^\s]+)/g;
return (
<>
{lines.map((line, i) => {
const parts = line.split(urlRegex);
return (
<React.Fragment key={i}>
{i > 0 && <br />}
{parts.map((part, j) => {
if (part.match(urlRegex)) {
return (
<a
key={j}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline underline-offset-4"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
);
}
return part;
})}
</React.Fragment>
);
})}
</>
);
}
export function FormLayoutClient({
userName,
@@ -119,10 +89,6 @@ interface FormFillerClientProps {
const LS_KEY = (formId: string) => `form-draft-${formId}`;
const QUESTIONS_PER_STEP = 3;
const stripHtml = (html: string) => {
return (html || "").replace(/<[^>]*>?/gm, '').trim();
};
const buildSchema = (questions: Question[]) => {
const shape: Record<string, z.ZodTypeAny> = {};
questions.forEach(q => {
@@ -315,10 +281,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="text-base leading-normal mt-1 flex-1">
<div
className="prose prose-sm dark:prose-invert max-w-none [&>p]:m-0"
dangerouslySetInnerHTML={{ __html: q.label || "" }}
/>
<HtmlDisplay html={q.label} />
</div>
<div className="flex items-start gap-4 shrink-0">
{q.imageUrl && (
+36
View File
@@ -0,0 +1,36 @@
import React from "react";
export function FormattedText({ text }: { text: string }) {
const lines = text.split('\n');
const urlRegex = /(https?:\/\/[^\s]+)/g;
return (
<>
{lines.map((line, i) => {
const parts = line.split(urlRegex);
return (
<React.Fragment key={i}>
{i > 0 && <br />}
{parts.map((part, j) => {
if (part.match(urlRegex)) {
return (
<a
key={j}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline underline-offset-4"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
);
}
return part;
})}
</React.Fragment>
);
})}
</>
);
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface HtmlDisplayProps extends React.HTMLAttributes<HTMLDivElement> {
html?: string | null;
}
export function HtmlDisplay({ html, className, ...props }: HtmlDisplayProps) {
if (!html) return null;
return (
<div
className={cn("prose prose-sm dark:prose-invert max-w-none [&>p]:m-0", className)}
dangerouslySetInnerHTML={{ __html: html }}
{...props}
/>
)
}
+4
View File
@@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export const stripHtml = (html: string) => {
return (html || "").replace(/<[^>]*>?/gm, '').trim();
};