feat: enable ticket comment editing
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
533d9ca856
commit
07ff117a67
5 changed files with 340 additions and 56 deletions
|
|
@ -405,6 +405,45 @@ export const addComment = mutation({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateComment = mutation({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
commentId: v.id("ticketComments"),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
body: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { ticketId, commentId, actorId, body }) => {
|
||||||
|
const comment = await ctx.db.get(commentId);
|
||||||
|
if (!comment || comment.ticketId !== ticketId) {
|
||||||
|
throw new ConvexError("Comentário não encontrado");
|
||||||
|
}
|
||||||
|
if (comment.authorId !== actorId) {
|
||||||
|
throw new ConvexError("Você não tem permissão para editar este comentário");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await ctx.db.patch(commentId, {
|
||||||
|
body,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null;
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId,
|
||||||
|
type: "COMMENT_EDITED",
|
||||||
|
payload: {
|
||||||
|
commentId,
|
||||||
|
actorId,
|
||||||
|
actorName: actor?.name,
|
||||||
|
actorAvatar: actor?.avatarUrl,
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(ticketId, { updatedAt: now });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const removeCommentAttachment = mutation({
|
export const removeCommentAttachment = mutation({
|
||||||
args: {
|
args: {
|
||||||
ticketId: v.id("tickets"),
|
ticketId: v.id("tickets"),
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@
|
||||||
@apply text-foreground;
|
@apply text-foreground;
|
||||||
}
|
}
|
||||||
.rich-text p { @apply my-2; }
|
.rich-text p { @apply my-2; }
|
||||||
.rich-text a { @apply text-[#00e8ff] underline; }
|
.rich-text a { @apply text-neutral-900 underline; }
|
||||||
.rich-text ul { @apply my-2 list-disc ps-5; }
|
.rich-text ul { @apply my-2 list-disc ps-5; }
|
||||||
.rich-text ol { @apply my-2 list-decimal ps-5; }
|
.rich-text ol { @apply my-2 list-decimal ps-5; }
|
||||||
.rich-text li { @apply my-1; }
|
.rich-text li { @apply my-1; }
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { IconLock, IconMessage } from "@tabler/icons-react"
|
import { IconLock, IconMessage } from "@tabler/icons-react"
|
||||||
import { FileIcon, Trash2, X } from "lucide-react"
|
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
|
||||||
import { useAction, useMutation } from "convex/react"
|
import { useAction, useMutation } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
@ -21,6 +21,7 @@ import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/component
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
|
||||||
interface TicketCommentsProps {
|
interface TicketCommentsProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
|
@ -35,6 +36,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
const { userId } = useAuth()
|
const { userId } = useAuth()
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
||||||
|
const updateComment = useMutation(api.tickets.updateComment)
|
||||||
const [body, setBody] = useState("")
|
const [body, setBody] = useState("")
|
||||||
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
||||||
const [preview, setPreview] = useState<string | null>(null)
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
|
|
@ -42,6 +44,52 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
|
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
|
||||||
const [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null)
|
const [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null)
|
||||||
const [removingAttachment, setRemovingAttachment] = useState(false)
|
const [removingAttachment, setRemovingAttachment] = useState(false)
|
||||||
|
const [editingComment, setEditingComment] = useState<{ id: string; value: string } | null>(null)
|
||||||
|
const [savingCommentId, setSavingCommentId] = useState<string | null>(null)
|
||||||
|
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const startEditingComment = useCallback((commentId: string, currentBody: string) => {
|
||||||
|
setEditingComment({ id: commentId, value: currentBody || "" })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cancelEditingComment = useCallback(() => {
|
||||||
|
setEditingComment(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const saveEditedComment = useCallback(
|
||||||
|
async (commentId: string, originalBody: string) => {
|
||||||
|
if (!editingComment || editingComment.id !== commentId) return
|
||||||
|
if (!userId) return
|
||||||
|
if (commentId.startsWith("temp-")) return
|
||||||
|
|
||||||
|
const sanitized = sanitizeEditorHtml(editingComment.value)
|
||||||
|
if (sanitized === originalBody) {
|
||||||
|
setEditingComment(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastId = `edit-comment-${commentId}`
|
||||||
|
setSavingCommentId(commentId)
|
||||||
|
toast.loading("Salvando comentário...", { id: toastId })
|
||||||
|
try {
|
||||||
|
await updateComment({
|
||||||
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
|
commentId: commentId as unknown as Id<"ticketComments">,
|
||||||
|
actorId: userId as Id<"users">,
|
||||||
|
body: sanitized,
|
||||||
|
})
|
||||||
|
setLocalBodies((prev) => ({ ...prev, [commentId]: sanitized }))
|
||||||
|
setEditingComment(null)
|
||||||
|
toast.success("Comentário atualizado!", { id: toastId })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível atualizar o comentário.", { id: toastId })
|
||||||
|
} finally {
|
||||||
|
setSavingCommentId(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editingComment, ticket.id, updateComment, userId]
|
||||||
|
)
|
||||||
|
|
||||||
const commentsAll = useMemo(() => {
|
const commentsAll = useMemo(() => {
|
||||||
return [...pending, ...ticket.comments]
|
return [...pending, ...ticket.comments]
|
||||||
|
|
@ -150,12 +198,16 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.map((part) => part[0]?.toUpperCase())
|
.map((part) => part[0]?.toUpperCase())
|
||||||
.join("")
|
.join("")
|
||||||
const bodyHtml = comment.body ?? ""
|
const commentId = String(comment.id)
|
||||||
const bodyPlain = bodyHtml.replace(/<[^>]*>/g, "").trim()
|
const storedBody = localBodies[commentId] ?? comment.body ?? ""
|
||||||
const hasBody = bodyPlain.length > 0
|
const bodyPlain = storedBody.replace(/<[^>]*>/g, "").trim()
|
||||||
|
const isEditing = editingComment?.id === commentId
|
||||||
|
const isPending = commentId.startsWith("temp-")
|
||||||
|
const canEdit = Boolean(userId && String(comment.author.id) === userId && !isPending)
|
||||||
|
const hasBody = bodyPlain.length > 0 || isEditing
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={comment.id} className="flex gap-3">
|
<div key={comment.id} className="group/comment flex gap-3">
|
||||||
<Avatar className="size-9 border border-slate-200">
|
<Avatar className="size-9 border border-slate-200">
|
||||||
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
|
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
|
||||||
<AvatarFallback>{initials}</AvatarFallback>
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
|
@ -172,9 +224,60 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{hasBody ? (
|
{isEditing ? (
|
||||||
<div className="break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
|
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2">
|
||||||
<RichTextContent html={bodyHtml} />
|
<RichTextEditor
|
||||||
|
value={editingComment?.value ?? ""}
|
||||||
|
onChange={(next) =>
|
||||||
|
setEditingComment((prev) => (prev && prev.id === commentId ? { ...prev, value: next } : prev))
|
||||||
|
}
|
||||||
|
disabled={savingCommentId === commentId}
|
||||||
|
placeholder="Edite o comentário..."
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
|
||||||
|
onClick={cancelEditingComment}
|
||||||
|
disabled={savingCommentId === commentId}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className={submitButtonClass}
|
||||||
|
onClick={() => saveEditedComment(commentId, storedBody)}
|
||||||
|
disabled={savingCommentId === commentId}
|
||||||
|
>
|
||||||
|
{savingCommentId === commentId ? <Spinner className="size-4 text-white" /> : "Salvar"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : hasBody ? (
|
||||||
|
<div className="relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
|
||||||
|
{canEdit ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startEditingComment(commentId, storedBody)}
|
||||||
|
className="absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
|
||||||
|
aria-label="Editar comentário"
|
||||||
|
>
|
||||||
|
<PencilLine className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<RichTextContent html={storedBody} />
|
||||||
|
</div>
|
||||||
|
) : canEdit ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-slate-300 bg-white/60 px-3 py-2 text-sm text-neutral-500">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startEditingComment(commentId, storedBody)}
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
<PencilLine className="size-4" />
|
||||||
|
Adicionar conteúdo ao comentário
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{comment.attachments?.length ? (
|
{comment.attachments?.length ? (
|
||||||
|
|
@ -331,6 +434,11 @@ function CommentAttachmentCard({
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [errored, setErrored] = useState(false)
|
const [errored, setErrored] = useState(false)
|
||||||
const hasRefreshedRef = useRef(false)
|
const hasRefreshedRef = useRef(false)
|
||||||
|
const isImageType = useMemo(() => {
|
||||||
|
const name = attachment.name ?? ""
|
||||||
|
const type = attachment.type ?? ""
|
||||||
|
return (!!type && type.startsWith("image/")) || /\.(png|jpe?g|gif|webp|svg)$/i.test(name)
|
||||||
|
}, [attachment.name, attachment.type])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUrl(attachment.url ?? null)
|
setUrl(attachment.url ?? null)
|
||||||
|
|
@ -355,6 +463,19 @@ function CommentAttachmentCard({
|
||||||
return null
|
return null
|
||||||
}, [attachment.id, getFileUrl])
|
}, [attachment.id, getFileUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
const fresh = await ensureUrl()
|
||||||
|
if (!cancelled && fresh) {
|
||||||
|
setUrl(fresh)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [ensureUrl])
|
||||||
|
|
||||||
const handleImageError = useCallback(async () => {
|
const handleImageError = useCallback(async () => {
|
||||||
if (hasRefreshedRef.current) {
|
if (hasRefreshedRef.current) {
|
||||||
setErrored(true)
|
setErrored(true)
|
||||||
|
|
@ -382,31 +503,43 @@ function CommentAttachmentCard({
|
||||||
}, [ensureUrl, url])
|
}, [ensureUrl, url])
|
||||||
|
|
||||||
const name = attachment.name ?? ""
|
const name = attachment.name ?? ""
|
||||||
const type = attachment.type ?? ""
|
const urlLooksImage = url ? /\.(png|jpe?g|gif|webp|svg)$/i.test(url) : false
|
||||||
const isImage =
|
const showImage = isImageType && url && !errored
|
||||||
(!!type && type.startsWith("image/")) ||
|
|
||||||
/\.(png|jpe?g|gif|webp|svg)$/i.test(name) ||
|
|
||||||
/\.(png|jpe?g|gif|webp|svg)$/i.test(url ?? "")
|
|
||||||
|
|
||||||
const showImage = isImage && url && !errored
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
|
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
|
||||||
{showImage ? (
|
{showImage ? (
|
||||||
<button type="button" onClick={handlePreview} className="block w-full overflow-hidden rounded-md">
|
<button type="button" onClick={handlePreview} className="relative block w-full overflow-hidden rounded-md">
|
||||||
|
{refreshing ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/70">
|
||||||
|
<Spinner className="size-5 text-neutral-600" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={url ?? undefined} alt={name} className="h-24 w-full rounded-md object-cover" onError={handleImageError} />
|
<img
|
||||||
|
key={url ?? "no-url"}
|
||||||
|
src={url ?? undefined}
|
||||||
|
alt={name}
|
||||||
|
className="h-24 w-full rounded-md object-cover"
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={isImage ? handlePreview : handleDownload}
|
onClick={isImageType || urlLooksImage ? handlePreview : handleDownload}
|
||||||
className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-700 transition hover:bg-slate-100"
|
className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-700 transition hover:bg-slate-100"
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
>
|
>
|
||||||
|
{refreshing ? (
|
||||||
|
<Spinner className="size-5 text-neutral-600" />
|
||||||
|
) : isImageType || urlLooksImage ? (
|
||||||
|
<ImageIcon className="size-5 text-neutral-600" />
|
||||||
|
) : (
|
||||||
<FileIcon className="size-5 text-neutral-600" />
|
<FileIcon className="size-5 text-neutral-600" />
|
||||||
|
)}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{refreshing ? "Gerando link..." : isImage ? "Visualizar" : url ? "Baixar" : "Gerar link"}
|
{errored ? "Não foi possível carregar" : refreshing ? "Gerando link..." : url ? "Abrir" : "Gerar link"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
||||||
STATUS_CHANGED: IconSquareCheck,
|
STATUS_CHANGED: IconSquareCheck,
|
||||||
ASSIGNEE_CHANGED: IconUserCircle,
|
ASSIGNEE_CHANGED: IconUserCircle,
|
||||||
COMMENT_ADDED: IconNote,
|
COMMENT_ADDED: IconNote,
|
||||||
|
COMMENT_EDITED: IconNote,
|
||||||
WORK_STARTED: IconClockHour4,
|
WORK_STARTED: IconClockHour4,
|
||||||
WORK_PAUSED: IconClockHour4,
|
WORK_PAUSED: IconClockHour4,
|
||||||
SUBJECT_CHANGED: IconNote,
|
SUBJECT_CHANGED: IconNote,
|
||||||
|
|
@ -35,6 +36,7 @@ const timelineLabels: Record<string, string> = {
|
||||||
STATUS_CHANGED: "Status alterado",
|
STATUS_CHANGED: "Status alterado",
|
||||||
ASSIGNEE_CHANGED: "Responsável alterado",
|
ASSIGNEE_CHANGED: "Responsável alterado",
|
||||||
COMMENT_ADDED: "Comentário adicionado",
|
COMMENT_ADDED: "Comentário adicionado",
|
||||||
|
COMMENT_EDITED: "Comentário editado",
|
||||||
WORK_STARTED: "Atendimento iniciado",
|
WORK_STARTED: "Atendimento iniciado",
|
||||||
WORK_PAUSED: "Atendimento pausado",
|
WORK_PAUSED: "Atendimento pausado",
|
||||||
SUBJECT_CHANGED: "Assunto atualizado",
|
SUBJECT_CHANGED: "Assunto atualizado",
|
||||||
|
|
@ -110,6 +112,8 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
requesterName?: string
|
requesterName?: string
|
||||||
authorName?: string
|
authorName?: string
|
||||||
authorId?: string
|
authorId?: string
|
||||||
|
actorName?: string
|
||||||
|
actorId?: string
|
||||||
from?: string
|
from?: string
|
||||||
attachmentName?: string
|
attachmentName?: string
|
||||||
sessionDurationMs?: number
|
sessionDurationMs?: number
|
||||||
|
|
@ -136,6 +140,10 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
if (entry.type === "COMMENT_ADDED" && (payload.authorName || payload.authorId)) {
|
if (entry.type === "COMMENT_ADDED" && (payload.authorName || payload.authorId)) {
|
||||||
message = "Comentário adicionado" + (payload.authorName ? " por " + payload.authorName : "")
|
message = "Comentário adicionado" + (payload.authorName ? " por " + payload.authorName : "")
|
||||||
}
|
}
|
||||||
|
if (entry.type === "COMMENT_EDITED" && (payload.actorName || payload.actorId || payload.authorName)) {
|
||||||
|
const name = payload.actorName ?? payload.authorName
|
||||||
|
message = "Comentário editado" + (name ? " por " + name : "")
|
||||||
|
}
|
||||||
if (entry.type === "SUBJECT_CHANGED" && (payload.to || payload.toLabel)) {
|
if (entry.type === "SUBJECT_CHANGED" && (payload.to || payload.toLabel)) {
|
||||||
message = "Assunto alterado" + (payload.to ? " para \"" + payload.to + "\"" : "")
|
message = "Assunto alterado" + (payload.to ? " para \"" + payload.to + "\"" : "")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect } from "react"
|
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
import { useEditor, EditorContent } from "@tiptap/react"
|
import { useEditor, EditorContent } from "@tiptap/react"
|
||||||
import StarterKit from "@tiptap/starter-kit"
|
import StarterKit from "@tiptap/starter-kit"
|
||||||
import Link from "@tiptap/extension-link"
|
import Link from "@tiptap/extension-link"
|
||||||
|
|
@ -9,6 +10,8 @@ import { cn } from "@/lib/utils"
|
||||||
import sanitize from "sanitize-html"
|
import sanitize from "sanitize-html"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
Italic,
|
Italic,
|
||||||
|
|
@ -19,6 +22,8 @@ import {
|
||||||
Undo,
|
Undo,
|
||||||
Redo,
|
Redo,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
|
Check,
|
||||||
|
Link2Off,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
type RichTextEditorProps = {
|
type RichTextEditorProps = {
|
||||||
|
|
@ -67,6 +72,62 @@ export function RichTextEditor({
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false)
|
||||||
|
const [linkUrl, setLinkUrl] = useState("")
|
||||||
|
const linkInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const closeLinkPopover = useCallback(() => {
|
||||||
|
setLinkPopoverOpen(false)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editor?.commands.focus()
|
||||||
|
})
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const openLinkPopover = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
editor.chain().focus()
|
||||||
|
const prev = (editor.getAttributes("link").href as string | undefined) ?? ""
|
||||||
|
setLinkUrl(prev)
|
||||||
|
setLinkPopoverOpen(true)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
linkInputRef.current?.focus()
|
||||||
|
if (prev) {
|
||||||
|
linkInputRef.current?.select()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const applyLink = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
const trimmed = linkUrl.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
editor.chain().focus().extendMarkRange("link").unsetLink().run()
|
||||||
|
closeLinkPopover()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalized = /^(https?:\/\/|mailto:)/i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||||
|
editor.chain().focus().extendMarkRange("link").setLink({ href: normalized }).run()
|
||||||
|
closeLinkPopover()
|
||||||
|
}, [closeLinkPopover, editor, linkUrl])
|
||||||
|
|
||||||
|
const removeLink = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
editor.chain().focus().extendMarkRange("link").unsetLink().run()
|
||||||
|
closeLinkPopover()
|
||||||
|
}, [closeLinkPopover, editor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !linkPopoverOpen) return
|
||||||
|
const handler = () => {
|
||||||
|
const prev = (editor.getAttributes("link").href as string | undefined) ?? ""
|
||||||
|
setLinkUrl(prev)
|
||||||
|
}
|
||||||
|
editor.on("selectionUpdate", handler)
|
||||||
|
return () => {
|
||||||
|
editor.off("selectionUpdate", handler)
|
||||||
|
}
|
||||||
|
}, [editor, linkPopoverOpen])
|
||||||
|
|
||||||
// Keep external value in sync when it changes
|
// Keep external value in sync when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
@ -124,22 +185,62 @@ export function RichTextEditor({
|
||||||
>
|
>
|
||||||
<Quote className="size-4" />
|
<Quote className="size-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton
|
<Popover
|
||||||
onClick={() => {
|
open={linkPopoverOpen}
|
||||||
const prev = editor.getAttributes("link").href as string | undefined
|
onOpenChange={(open) => {
|
||||||
const url = window.prompt("URL do link:", prev || "https://")
|
if (open) {
|
||||||
if (url === null) return
|
openLinkPopover()
|
||||||
if (url === "") {
|
} else {
|
||||||
editor.chain().focus().extendMarkRange("link").unsetLink().run()
|
closeLinkPopover()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run()
|
|
||||||
}}
|
}}
|
||||||
active={editor.isActive("link")}
|
|
||||||
ariaLabel="Inserir link"
|
|
||||||
>
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<ToolbarButton active={editor.isActive("link")} ariaLabel="Inserir link">
|
||||||
<LinkIcon className="size-4" />
|
<LinkIcon className="size-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72 space-y-3" align="start">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label htmlFor="rich-text-editor-link" className="text-xs font-semibold text-neutral-600">
|
||||||
|
URL do link
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="rich-text-editor-link"
|
||||||
|
ref={linkInputRef}
|
||||||
|
placeholder="https://"
|
||||||
|
value={linkUrl}
|
||||||
|
onChange={(event) => setLinkUrl(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
applyLink()
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
closeLinkPopover()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="inline-flex items-center gap-2"
|
||||||
|
onClick={removeLink}
|
||||||
|
disabled={!editor.isActive("link")}
|
||||||
|
>
|
||||||
|
<Link2Off className="size-4" />
|
||||||
|
Remover
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" className="inline-flex items-center gap-2" onClick={applyLink}>
|
||||||
|
<Check className="size-4" />
|
||||||
|
Aplicar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<div className="ms-auto flex items-center gap-1">
|
<div className="ms-auto flex items-center gap-1">
|
||||||
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} ariaLabel="Desfazer">
|
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} ariaLabel="Desfazer">
|
||||||
<Undo className="size-4" />
|
<Undo className="size-4" />
|
||||||
|
|
@ -156,30 +257,33 @@ export function RichTextEditor({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolbarButton({
|
type ToolbarButtonProps = {
|
||||||
onClick,
|
onClick?: () => void
|
||||||
active,
|
|
||||||
ariaLabel,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
onClick: () => void
|
|
||||||
active?: boolean
|
active?: boolean
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
children: React.ReactNode
|
children: ReactNode
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
const ToolbarButton = forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
||||||
|
({ onClick, active, ariaLabel, children }, ref) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={active ? "default" : "ghost"}
|
variant={active ? "default" : "ghost"}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ToolbarButton.displayName = "ToolbarButton"
|
||||||
|
|
||||||
// Utilitário simples para renderização segura do HTML do editor.
|
// Utilitário simples para renderização segura do HTML do editor.
|
||||||
// Remove tags <script>/<style> e atributos on*.
|
// Remove tags <script>/<style> e atributos on*.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue