mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 14:22:28 +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 { Textarea } from "@/components/ui/textarea";
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal";
|
||||
import { SaveDialog } from "@/components/save-dialog";
|
||||
import {
|
||||
Loader2,
|
||||
Send,
|
||||
RotateCcw,
|
||||
Image as ImageIcon,
|
||||
History,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||
import { FilePreviewList } from "./file-preview-list";
|
||||
@@ -39,11 +41,12 @@ export function ChatInput({
|
||||
showHistory = false,
|
||||
onToggleHistory = () => {},
|
||||
}: ChatInputProps) {
|
||||
const { diagramHistory } = useDiagram();
|
||||
const { diagramHistory, saveDiagramToFile } = useDiagram();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// Debug: Log status changes
|
||||
const isDisabled = status === "streaming" || status === "submitted";
|
||||
@@ -166,6 +169,7 @@ export function ChatInput({
|
||||
setShowClearDialog(false);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
@@ -235,6 +239,25 @@ export function ChatInput({
|
||||
<History className="h-4 w-4" />
|
||||
</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
|
||||
type="button"
|
||||
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>;
|
||||
handleDiagramExport: (data: any) => void;
|
||||
clearDiagram: () => void;
|
||||
saveDiagramToFile: (filename: string) => void;
|
||||
}
|
||||
|
||||
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);
|
||||
// Track if we're expecting an export for history (user-initiated)
|
||||
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 = () => {
|
||||
if (drawioRef.current) {
|
||||
@@ -68,6 +71,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
resolverRef.current(extractedXML);
|
||||
resolverRef.current = null;
|
||||
}
|
||||
|
||||
// Handle save to file if requested
|
||||
if (saveResolverRef.current) {
|
||||
saveResolverRef.current(extractedXML);
|
||||
saveResolverRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearDiagram = () => {
|
||||
@@ -78,6 +87,35 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
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 (
|
||||
<DiagramContext.Provider
|
||||
value={{
|
||||
@@ -90,6 +128,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
clearDiagram,
|
||||
saveDiagramToFile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user