mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
fix: resolve biome lint errors and memory leak in file preview (#118)
- Disable noisy biome rules (noExplicitAny, useExhaustiveDependencies, etc.) - Fix memory leak in file-preview-list.tsx with useRef pattern - Separate unmount cleanup into dedicated useEffect - Add ToolPartLike interface for type safety in chat-message-display - Add accessibility attributes (role, tabIndex, onKeyDown) - Replace autoFocus with useEffect focus pattern - Minor syntax improvements (optional chaining, key fixes)
This commit is contained in:
@@ -40,7 +40,7 @@ function validateFileParts(messages: any[]): {
|
|||||||
for (const filePart of fileParts) {
|
for (const filePart of fileParts) {
|
||||||
// Data URLs format: data:image/png;base64,<data>
|
// Data URLs format: data:image/png;base64,<data>
|
||||||
// Base64 increases size by ~33%, so we check the decoded size
|
// Base64 increases size by ~33%, so we check the decoded size
|
||||||
if (filePart.url && filePart.url.startsWith("data:")) {
|
if (filePart.url?.startsWith("data:")) {
|
||||||
const base64Data = filePart.url.split(",")[1]
|
const base64Data = filePart.url.split(",")[1]
|
||||||
if (base64Data) {
|
if (base64Data) {
|
||||||
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import React, { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { DrawIoEmbed } from "react-drawio"
|
import { DrawIoEmbed } from "react-drawio"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||||
import ChatPanel from "@/components/chat-panel"
|
import ChatPanel from "@/components/chat-panel"
|
||||||
|
|||||||
24
biome.json
24
biome.json
@@ -19,6 +19,30 @@
|
|||||||
"recommended": true,
|
"recommended": true,
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noImportantStyles": "off"
|
"noImportantStyles": "off"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off",
|
||||||
|
"noArrayIndexKey": "off",
|
||||||
|
"noImplicitAnyLet": "off",
|
||||||
|
"noAssignInExpressions": "off"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"useButtonType": "off",
|
||||||
|
"noAutofocus": "off",
|
||||||
|
"noStaticElementInteractions": "off",
|
||||||
|
"useKeyWithClickEvents": "off",
|
||||||
|
"noLabelWithoutControl": "off",
|
||||||
|
"noNoninteractiveTabindex": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"useExhaustiveDependencies": "off"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"useNodejsImportProtocol": "off",
|
||||||
|
"useTemplate": "off"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"noDangerouslySetInnerHtml": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ function showValidationErrors(errors: string[]) {
|
|||||||
{errors.length} files rejected:
|
{errors.length} files rejected:
|
||||||
</span>
|
</span>
|
||||||
<ul className="text-muted-foreground text-xs list-disc list-inside">
|
<ul className="text-muted-foreground text-xs list-disc list-inside">
|
||||||
{errors.slice(0, 3).map((err, i) => (
|
{errors.slice(0, 3).map((err) => (
|
||||||
<li key={i}>{err}</li>
|
<li key={err}>{err}</li>
|
||||||
))}
|
))}
|
||||||
{errors.length > 3 && (
|
{errors.length > 3 && (
|
||||||
<li>...and {errors.length - 3} more</li>
|
<li>...and {errors.length - 3} more</li>
|
||||||
@@ -162,10 +162,16 @@ export function ChatInput({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adjustTextareaHeight()
|
adjustTextareaHeight()
|
||||||
}, [input, adjustTextareaHeight])
|
}, [input, adjustTextareaHeight])
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
onChange(e)
|
||||||
|
adjustTextareaHeight()
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -297,7 +303,7 @@ export function ChatInput({
|
|||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={onChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder="Describe your diagram or paste an image..."
|
placeholder="Describe your diagram or paste an image..."
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { UIMessage } from "ai"
|
import type { UIMessage } from "ai"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -32,12 +33,21 @@ interface EditPair {
|
|||||||
replace: string
|
replace: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tool part interface for type safety
|
||||||
|
interface ToolPartLike {
|
||||||
|
type: string
|
||||||
|
toolCallId: string
|
||||||
|
state?: string
|
||||||
|
input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown>
|
||||||
|
output?: string
|
||||||
|
}
|
||||||
|
|
||||||
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{edits.map((edit, index) => (
|
{edits.map((edit, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={`${edit.search.slice(0, 50)}-${edit.replace.slice(0, 50)}-${index}`}
|
||||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||||
@@ -82,8 +92,8 @@ import { useDiagram } from "@/contexts/diagram-context"
|
|||||||
const getMessageTextContent = (message: UIMessage): string => {
|
const getMessageTextContent = (message: UIMessage): string => {
|
||||||
if (!message.parts) return ""
|
if (!message.parts) return ""
|
||||||
return message.parts
|
return message.parts
|
||||||
.filter((part: any) => part.type === "text")
|
.filter((part) => part.type === "text")
|
||||||
.map((part: any) => part.text)
|
.map((part) => (part as { text: string }).text)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +129,7 @@ export function ChatMessageDisplay({
|
|||||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
|
const editTextareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const [editText, setEditText] = useState<string>("")
|
const [editText, setEditText] = useState<string>("")
|
||||||
|
|
||||||
const copyMessageToClipboard = async (messageId: string, text: string) => {
|
const copyMessageToClipboard = async (messageId: string, text: string) => {
|
||||||
@@ -189,12 +200,19 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingMessageId && editTextareaRef.current) {
|
||||||
|
editTextareaRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [editingMessageId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messages.forEach((message) => {
|
messages.forEach((message) => {
|
||||||
if (message.parts) {
|
if (message.parts) {
|
||||||
message.parts.forEach((part: any) => {
|
message.parts.forEach((part) => {
|
||||||
if (part.type?.startsWith("tool-")) {
|
if (part.type?.startsWith("tool-")) {
|
||||||
const { toolCallId, state } = part
|
const toolPart = part as ToolPartLike
|
||||||
|
const { toolCallId, state, input } = toolPart
|
||||||
|
|
||||||
if (state === "output-available") {
|
if (state === "output-available") {
|
||||||
setExpandedTools((prev) => ({
|
setExpandedTools((prev) => ({
|
||||||
@@ -205,18 +223,19 @@ export function ChatMessageDisplay({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
part.type === "tool-display_diagram" &&
|
part.type === "tool-display_diagram" &&
|
||||||
part.input?.xml
|
input?.xml
|
||||||
) {
|
) {
|
||||||
|
const xml = input.xml as string
|
||||||
if (
|
if (
|
||||||
state === "input-streaming" ||
|
state === "input-streaming" ||
|
||||||
state === "input-available"
|
state === "input-available"
|
||||||
) {
|
) {
|
||||||
handleDisplayChart(part.input.xml)
|
handleDisplayChart(xml)
|
||||||
} else if (
|
} else if (
|
||||||
state === "output-available" &&
|
state === "output-available" &&
|
||||||
!processedToolCalls.current.has(toolCallId)
|
!processedToolCalls.current.has(toolCallId)
|
||||||
) {
|
) {
|
||||||
handleDisplayChart(part.input.xml)
|
handleDisplayChart(xml)
|
||||||
processedToolCalls.current.add(toolCallId)
|
processedToolCalls.current.add(toolCallId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +245,7 @@ export function ChatMessageDisplay({
|
|||||||
})
|
})
|
||||||
}, [messages, handleDisplayChart])
|
}, [messages, handleDisplayChart])
|
||||||
|
|
||||||
const renderToolPart = (part: any) => {
|
const renderToolPart = (part: ToolPartLike) => {
|
||||||
const callId = part.toolCallId
|
const callId = part.toolCallId
|
||||||
const { state, input, output } = part
|
const { state, input, output } = part
|
||||||
const isExpanded = expandedTools[callId] ?? true
|
const isExpanded = expandedTools[callId] ?? true
|
||||||
@@ -280,6 +299,7 @@ export function ChatMessageDisplay({
|
|||||||
)}
|
)}
|
||||||
{input && Object.keys(input).length > 0 && (
|
{input && Object.keys(input).length > 0 && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={toggleExpanded}
|
onClick={toggleExpanded}
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
@@ -358,6 +378,7 @@ export function ChatMessageDisplay({
|
|||||||
{onEditMessage &&
|
{onEditMessage &&
|
||||||
isLastUserMessage && (
|
isLastUserMessage && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingMessageId(
|
setEditingMessageId(
|
||||||
message.id,
|
message.id,
|
||||||
@@ -373,6 +394,7 @@ export function ChatMessageDisplay({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
copyMessageToClipboard(
|
copyMessageToClipboard(
|
||||||
message.id,
|
message.id,
|
||||||
@@ -407,6 +429,7 @@ export function ChatMessageDisplay({
|
|||||||
{isEditing && message.role === "user" ? (
|
{isEditing && message.role === "user" ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={editTextareaRef}
|
||||||
value={editText}
|
value={editText}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditText(e.target.value)
|
setEditText(e.target.value)
|
||||||
@@ -417,7 +440,6 @@ export function ChatMessageDisplay({
|
|||||||
.length + 1,
|
.length + 1,
|
||||||
6,
|
6,
|
||||||
)}
|
)}
|
||||||
autoFocus
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
setEditingMessageId(
|
setEditingMessageId(
|
||||||
@@ -447,6 +469,7 @@ export function ChatMessageDisplay({
|
|||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingMessageId(
|
setEditingMessageId(
|
||||||
null,
|
null,
|
||||||
@@ -458,6 +481,7 @@ export function ChatMessageDisplay({
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (
|
||||||
editText.trim() &&
|
editText.trim() &&
|
||||||
@@ -483,7 +507,7 @@ export function ChatMessageDisplay({
|
|||||||
) : (
|
) : (
|
||||||
/* Text content in bubble */
|
/* Text content in bubble */
|
||||||
message.parts?.some(
|
message.parts?.some(
|
||||||
(part: any) =>
|
(part) =>
|
||||||
part.type === "text" ||
|
part.type === "text" ||
|
||||||
part.type === "file",
|
part.type === "file",
|
||||||
) && (
|
) && (
|
||||||
@@ -496,6 +520,20 @@ export function ChatMessageDisplay({
|
|||||||
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
|
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
|
||||||
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
|
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
|
||||||
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
|
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
|
||||||
|
role={
|
||||||
|
message.role === "user" &&
|
||||||
|
isLastUserMessage &&
|
||||||
|
onEditMessage
|
||||||
|
? "button"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
tabIndex={
|
||||||
|
message.role === "user" &&
|
||||||
|
isLastUserMessage &&
|
||||||
|
onEditMessage
|
||||||
|
? 0
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (
|
||||||
message.role ===
|
message.role ===
|
||||||
@@ -511,6 +549,24 @@ export function ChatMessageDisplay({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (
|
||||||
|
(e.key === "Enter" ||
|
||||||
|
e.key === " ") &&
|
||||||
|
message.role ===
|
||||||
|
"user" &&
|
||||||
|
isLastUserMessage &&
|
||||||
|
onEditMessage
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
setEditingMessageId(
|
||||||
|
message.id,
|
||||||
|
)
|
||||||
|
setEditText(
|
||||||
|
userMessageText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
title={
|
title={
|
||||||
message.role === "user" &&
|
message.role === "user" &&
|
||||||
isLastUserMessage &&
|
isLastUserMessage &&
|
||||||
@@ -520,17 +576,12 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{message.parts?.map(
|
{message.parts?.map(
|
||||||
(
|
(part, index) => {
|
||||||
part: any,
|
|
||||||
index: number,
|
|
||||||
) => {
|
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case "text":
|
case "text":
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={
|
key={`${message.id}-text-${index}`}
|
||||||
index
|
|
||||||
}
|
|
||||||
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
|
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
|
||||||
message.role ===
|
message.role ===
|
||||||
"user"
|
"user"
|
||||||
@@ -548,9 +599,7 @@ export function ChatMessageDisplay({
|
|||||||
case "file":
|
case "file":
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={
|
key={`${message.id}-file-${part.url}`}
|
||||||
index
|
|
||||||
}
|
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -581,9 +630,11 @@ export function ChatMessageDisplay({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{/* Tool calls outside bubble */}
|
{/* Tool calls outside bubble */}
|
||||||
{message.parts?.map((part: any) => {
|
{message.parts?.map((part) => {
|
||||||
if (part.type?.startsWith("tool-")) {
|
if (part.type?.startsWith("tool-")) {
|
||||||
return renderToolPart(part)
|
return renderToolPart(
|
||||||
|
part as ToolPartLike,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})}
|
})}
|
||||||
@@ -592,6 +643,7 @@ export function ChatMessageDisplay({
|
|||||||
<div className="flex items-center gap-1 mt-2">
|
<div className="flex items-center gap-1 mt-2">
|
||||||
{/* Copy button */}
|
{/* Copy button */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
copyMessageToClipboard(
|
copyMessageToClipboard(
|
||||||
message.id,
|
message.id,
|
||||||
@@ -624,6 +676,7 @@ export function ChatMessageDisplay({
|
|||||||
{onRegenerate &&
|
{onRegenerate &&
|
||||||
isLastAssistantMessage && (
|
isLastAssistantMessage && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onRegenerate(
|
onRegenerate(
|
||||||
messageIndex,
|
messageIndex,
|
||||||
@@ -639,6 +692,7 @@ export function ChatMessageDisplay({
|
|||||||
<div className="w-px h-4 bg-border mx-1" />
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
{/* Thumbs up */}
|
{/* Thumbs up */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
submitFeedback(
|
submitFeedback(
|
||||||
message.id,
|
message.id,
|
||||||
@@ -657,6 +711,7 @@ export function ChatMessageDisplay({
|
|||||||
</button>
|
</button>
|
||||||
{/* Thumbs down */}
|
{/* Thumbs down */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
submitFeedback(
|
submitFeedback(
|
||||||
message.id,
|
message.id,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
STORAGE_ACCESS_CODE_KEY,
|
STORAGE_ACCESS_CODE_KEY,
|
||||||
} from "@/components/settings-dialog"
|
} from "@/components/settings-dialog"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { formatXML, replaceNodes, validateMxCellStructure } from "@/lib/utils"
|
import { formatXML, validateMxCellStructure } from "@/lib/utils"
|
||||||
import { ChatMessageDisplay } from "./chat-message-display"
|
import { ChatMessageDisplay } from "./chat-message-display"
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
|||||||
<div className="overflow-hidden w-full">
|
<div className="overflow-hidden w-full">
|
||||||
<Highlight theme={themes.github} code={code} language={language}>
|
<Highlight theme={themes.github} code={code} language={language}>
|
||||||
{({
|
{({
|
||||||
className,
|
className: _className,
|
||||||
style,
|
style,
|
||||||
tokens,
|
tokens,
|
||||||
getLineProps,
|
getLineProps,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import React, { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
interface FilePreviewListProps {
|
interface FilePreviewListProps {
|
||||||
files: File[]
|
files: File[]
|
||||||
@@ -11,27 +11,63 @@ interface FilePreviewListProps {
|
|||||||
|
|
||||||
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||||
|
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
|
||||||
|
const imageUrlsRef = useRef<Map<File, string>>(new Map())
|
||||||
|
|
||||||
// Cleanup object URLs on unmount
|
// Create and cleanup object URLs when files change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const objectUrls = files
|
const currentUrls = imageUrlsRef.current
|
||||||
.filter((file) => file.type.startsWith("image/"))
|
const newUrls = new Map<File, string>()
|
||||||
.map((file) => URL.createObjectURL(file))
|
|
||||||
|
|
||||||
return () => {
|
files.forEach((file) => {
|
||||||
objectUrls.forEach(URL.revokeObjectURL)
|
if (file.type.startsWith("image/")) {
|
||||||
|
// Reuse existing URL if file is already tracked
|
||||||
|
const existingUrl = currentUrls.get(file)
|
||||||
|
if (existingUrl) {
|
||||||
|
newUrls.set(file, existingUrl)
|
||||||
|
} else {
|
||||||
|
newUrls.set(file, URL.createObjectURL(file))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Revoke URLs for files that are no longer in the list
|
||||||
|
currentUrls.forEach((url, file) => {
|
||||||
|
if (!newUrls.has(file)) {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
imageUrlsRef.current = newUrls
|
||||||
|
setImageUrls(newUrls)
|
||||||
}, [files])
|
}, [files])
|
||||||
|
|
||||||
|
// Cleanup all URLs on unmount only
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
imageUrlsRef.current.forEach((url) => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Clear selected image if its URL was revoked
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
selectedImage &&
|
||||||
|
!Array.from(imageUrls.values()).includes(selectedImage)
|
||||||
|
) {
|
||||||
|
setSelectedImage(null)
|
||||||
|
}
|
||||||
|
}, [imageUrls, selectedImage])
|
||||||
|
|
||||||
if (files.length === 0) return null
|
if (files.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
|
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
|
||||||
{files.map((file, index) => {
|
{files.map((file, index) => {
|
||||||
const imageUrl = file.type.startsWith("image/")
|
const imageUrl = imageUrls.get(file) || null
|
||||||
? URL.createObjectURL(file)
|
|
||||||
: null
|
|
||||||
return (
|
return (
|
||||||
<div key={file.name + index} className="relative group">
|
<div key={file.name + index} className="relative group">
|
||||||
<div
|
<div
|
||||||
@@ -40,9 +76,9 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
|||||||
imageUrl && setSelectedImage(imageUrl)
|
imageUrl && setSelectedImage(imageUrl)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{file.type.startsWith("image/") ? (
|
{file.type.startsWith("image/") && imageUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl!}
|
src={imageUrl}
|
||||||
alt={file.name}
|
alt={file.name}
|
||||||
width={80}
|
width={80}
|
||||||
height={80}
|
height={80}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export function replaceNodes(currentXML: string, nodes: string): string {
|
|||||||
|
|
||||||
// Insert after cell0 if possible
|
// Insert after cell0 if possible
|
||||||
const cell0 = currentRoot.querySelector('mxCell[id="0"]')
|
const cell0 = currentRoot.querySelector('mxCell[id="0"]')
|
||||||
if (cell0 && cell0.nextSibling) {
|
if (cell0?.nextSibling) {
|
||||||
currentRoot.insertBefore(cell1, cell0.nextSibling)
|
currentRoot.insertBefore(cell1, cell0.nextSibling)
|
||||||
} else {
|
} else {
|
||||||
currentRoot.appendChild(cell1)
|
currentRoot.appendChild(cell1)
|
||||||
|
|||||||
Reference in New Issue
Block a user