mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
feat: add save diagram to local file button (#60)
- Add save button in chat input area with download icon - Create SaveDialog component for filename input - Export current diagram as .drawio file format - Support custom filename with default timestamp-based name Closes #53
This commit is contained in:
@@ -4,12 +4,14 @@ import React, { useCallback, useRef, useEffect, useState } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal";
|
import { ResetWarningModal } from "@/components/reset-warning-modal";
|
||||||
|
import { SaveDialog } from "@/components/save-dialog";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Send,
|
Send,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
History,
|
History,
|
||||||
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||||
import { FilePreviewList } from "./file-preview-list";
|
import { FilePreviewList } from "./file-preview-list";
|
||||||
@@ -39,11 +41,12 @@ export function ChatInput({
|
|||||||
showHistory = false,
|
showHistory = false,
|
||||||
onToggleHistory = () => {},
|
onToggleHistory = () => {},
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const { diagramHistory } = useDiagram();
|
const { diagramHistory, saveDiagramToFile } = useDiagram();
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||||
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||||
|
|
||||||
// Debug: Log status changes
|
// Debug: Log status changes
|
||||||
const isDisabled = status === "streaming" || status === "submitted";
|
const isDisabled = status === "streaming" || status === "submitted";
|
||||||
@@ -166,6 +169,7 @@ export function ChatInput({
|
|||||||
setShowClearDialog(false);
|
setShowClearDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
@@ -235,6 +239,25 @@ export function ChatInput({
|
|||||||
<History className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
|
{/* Save Diagram Button */}
|
||||||
|
<ButtonWithTooltip
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowSaveDialog(true)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
tooltipContent="Save diagram to local file"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
|
<SaveDialog
|
||||||
|
open={showSaveDialog}
|
||||||
|
onOpenChange={setShowSaveDialog}
|
||||||
|
onSave={saveDiagramToFile}
|
||||||
|
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
80
components/save-dialog.tsx
Normal file
80
components/save-dialog.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface SaveDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSave: (filename: string) => void;
|
||||||
|
defaultFilename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSave,
|
||||||
|
defaultFilename,
|
||||||
|
}: SaveDialogProps) {
|
||||||
|
const [filename, setFilename] = useState(defaultFilename);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFilename(defaultFilename);
|
||||||
|
}
|
||||||
|
}, [open, defaultFilename]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const finalFilename = filename.trim() || defaultFilename;
|
||||||
|
onSave(finalFilename);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save Diagram</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Filename</label>
|
||||||
|
<div className="flex items-stretch">
|
||||||
|
<Input
|
||||||
|
value={filename}
|
||||||
|
onChange={(e) => setFilename(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Enter filename"
|
||||||
|
autoFocus
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
className="rounded-r-none border-r-0 focus-visible:z-10"
|
||||||
|
/>
|
||||||
|
<span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono">
|
||||||
|
.drawio
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>Save</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ interface DiagramContextType {
|
|||||||
drawioRef: React.Ref<DrawIoEmbedRef | null>;
|
drawioRef: React.Ref<DrawIoEmbedRef | null>;
|
||||||
handleDiagramExport: (data: any) => void;
|
handleDiagramExport: (data: any) => void;
|
||||||
clearDiagram: () => void;
|
clearDiagram: () => void;
|
||||||
|
saveDiagramToFile: (filename: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined);
|
const DiagramContext = createContext<DiagramContextType | undefined>(undefined);
|
||||||
@@ -28,6 +29,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const resolverRef = useRef<((value: string) => void) | null>(null);
|
const resolverRef = useRef<((value: string) => void) | null>(null);
|
||||||
// Track if we're expecting an export for history (user-initiated)
|
// Track if we're expecting an export for history (user-initiated)
|
||||||
const expectHistoryExportRef = useRef<boolean>(false);
|
const expectHistoryExportRef = useRef<boolean>(false);
|
||||||
|
// Track if we're expecting an export for file save
|
||||||
|
const saveResolverRef = useRef<((xml: string) => void) | null>(null);
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
if (drawioRef.current) {
|
if (drawioRef.current) {
|
||||||
@@ -68,6 +71,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
resolverRef.current(extractedXML);
|
resolverRef.current(extractedXML);
|
||||||
resolverRef.current = null;
|
resolverRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle save to file if requested
|
||||||
|
if (saveResolverRef.current) {
|
||||||
|
saveResolverRef.current(extractedXML);
|
||||||
|
saveResolverRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearDiagram = () => {
|
const clearDiagram = () => {
|
||||||
@@ -78,6 +87,35 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setDiagramHistory([]);
|
setDiagramHistory([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveDiagramToFile = (filename: string) => {
|
||||||
|
if (!drawioRef.current) {
|
||||||
|
console.warn("Draw.io editor not ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export diagram and save when export completes
|
||||||
|
drawioRef.current.exportDiagram({ format: "xmlsvg" });
|
||||||
|
saveResolverRef.current = (xml: string) => {
|
||||||
|
// Wrap in proper .drawio format
|
||||||
|
let fileContent = xml;
|
||||||
|
if (!xml.includes("<mxfile")) {
|
||||||
|
fileContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([fileContent], { type: "application/xml" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
// Add .drawio extension if not present
|
||||||
|
a.download = filename.endsWith(".drawio") ? filename : `${filename}.drawio`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
// Delay URL revocation to ensure download completes
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DiagramContext.Provider
|
<DiagramContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -90,6 +128,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
|
saveDiagramToFile,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user