mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-08 20:30:20 +08:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f19dc919e1 | ||
|
|
8d92474f73 | ||
|
|
cc6f09615d | ||
|
|
be20c09c89 | ||
|
|
76487fb7ea | ||
|
|
c5de1a16ad | ||
|
|
cb0c0fbcda | ||
|
|
e7c29fb410 | ||
|
|
f0dd199cd1 | ||
|
|
7656b64018 | ||
|
|
c3f88e54fe | ||
|
|
a55ef7adf9 | ||
|
|
984eaae04d | ||
|
|
0ed06360aa | ||
|
|
b0313eb2dc | ||
|
|
dc37ce7fb1 |
@@ -34,6 +34,15 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Download draw.io static files for offline use
|
||||||
|
run: |
|
||||||
|
rm -rf public/drawio
|
||||||
|
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||||
|
mkdir -p public/drawio
|
||||||
|
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||||
|
rm -rf public/drawio/WEB-INF
|
||||||
|
rm -rf public/drawio/META-INF
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -57,6 +66,16 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Download draw.io static files for offline use
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
rm -rf public/drawio
|
||||||
|
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||||
|
mkdir -p public/drawio
|
||||||
|
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||||
|
rm -rf public/drawio/WEB-INF
|
||||||
|
rm -rf public/drawio/META-INF
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -80,7 +99,7 @@ jobs:
|
|||||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||||
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
||||||
project-slug: 'next-ai-draw-io'
|
project-slug: 'next-ai-draw-io'
|
||||||
signing-policy-slug: 'test-signing'
|
signing-policy-slug: 'release-signing'
|
||||||
artifact-configuration-slug: 'windows-exe'
|
artifact-configuration-slug: 'windows-exe'
|
||||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||||
wait-for-completion: true
|
wait-for-completion: true
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ push-via-ec2.sh
|
|||||||
/dist-electron/
|
/dist-electron/
|
||||||
/release/
|
/release/
|
||||||
/electron-standalone/
|
/electron-standalone/
|
||||||
|
# Draw.io static files (downloaded during CI build)
|
||||||
|
public/drawio/
|
||||||
*.dmg
|
*.dmg
|
||||||
*.exe
|
*.exe
|
||||||
*.AppImage
|
*.AppImage
|
||||||
|
|||||||
+59
-7
@@ -11,13 +11,16 @@ import {
|
|||||||
} from "@/components/ui/resizable"
|
} from "@/components/ui/resizable"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
|
import { isIndexedDBUsable } from "@/lib/session-storage"
|
||||||
const drawioBaseUrl =
|
|
||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
const {
|
||||||
useDiagram()
|
drawioRef,
|
||||||
|
handleDiagramExport,
|
||||||
|
handleDiagramAutoSave,
|
||||||
|
onDrawioLoad,
|
||||||
|
resetDrawioReady,
|
||||||
|
} = useDiagram()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
||||||
@@ -28,6 +31,12 @@ export default function Home() {
|
|||||||
const [darkMode, setDarkMode] = useState(false)
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
|
const [isElectron, setIsElectron] = useState(false)
|
||||||
|
const [canPersist, setCanPersist] = useState(false)
|
||||||
|
const [canPersistChecked, setCanPersistChecked] = useState(false)
|
||||||
|
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
||||||
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
||||||
|
)
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
const isMobileRef = useRef(false)
|
const isMobileRef = useRef(false)
|
||||||
@@ -64,6 +73,22 @@ export default function Home() {
|
|||||||
document.documentElement.classList.toggle("dark", prefersDark)
|
document.documentElement.classList.toggle("dark", prefersDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect Electron and use bundled draw.io files for offline use
|
||||||
|
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
|
||||||
|
// Include /index.html because Next.js doesn't auto-serve index.html for directories
|
||||||
|
const electronDetected =
|
||||||
|
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
|
||||||
|
!!(window as unknown as { electronAPI?: unknown }).electronAPI
|
||||||
|
if (electronDetected) {
|
||||||
|
setIsElectron(true)
|
||||||
|
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const usable = await isIndexedDBUsable()
|
||||||
|
setCanPersist(usable)
|
||||||
|
setCanPersistChecked(true)
|
||||||
|
})()
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [pathname, router])
|
||||||
|
|
||||||
@@ -72,6 +97,17 @@ export default function Home() {
|
|||||||
onDrawioLoad()
|
onDrawioLoad()
|
||||||
}, [onDrawioLoad])
|
}, [onDrawioLoad])
|
||||||
|
|
||||||
|
const handleDrawioAutoSave = useCallback(
|
||||||
|
(data: { xml?: string }) => {
|
||||||
|
handleDiagramAutoSave(data)
|
||||||
|
// Only suppress modified state when persistence is available
|
||||||
|
if (canPersist) {
|
||||||
|
drawioRef.current?.status({ message: "", modified: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[canPersist, drawioRef, handleDiagramAutoSave],
|
||||||
|
)
|
||||||
|
|
||||||
const handleDarkModeChange = () => {
|
const handleDarkModeChange = () => {
|
||||||
const newValue = !darkMode
|
const newValue = !darkMode
|
||||||
setDarkMode(newValue)
|
setDarkMode(newValue)
|
||||||
@@ -155,25 +191,41 @@ export default function Home() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||||
{isLoaded && (
|
{isLoaded && canPersistChecked && (
|
||||||
<div
|
<div
|
||||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||||
>
|
>
|
||||||
<DrawIoEmbed
|
<DrawIoEmbed
|
||||||
key={`${drawioUi}-${darkMode}-${currentLang}`}
|
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
|
autosave
|
||||||
|
onAutoSave={handleDrawioAutoSave}
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
onLoad={handleDrawioLoad}
|
onLoad={handleDrawioLoad}
|
||||||
baseUrl={drawioBaseUrl}
|
baseUrl={drawioBaseUrl}
|
||||||
|
configuration={
|
||||||
|
canPersist
|
||||||
|
? { confirmExit: false }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
urlParameters={{
|
urlParameters={{
|
||||||
ui: drawioUi,
|
ui: drawioUi,
|
||||||
spin: false,
|
spin: false,
|
||||||
libraries: false,
|
libraries: false,
|
||||||
|
// Disable modified tracking only when persistence is available
|
||||||
|
...(canPersist && {
|
||||||
|
modified: false,
|
||||||
|
keepmodified: false,
|
||||||
|
}),
|
||||||
saveAndExit: false,
|
saveAndExit: false,
|
||||||
noSaveBtn: true,
|
noSaveBtn: true,
|
||||||
noExitBtn: true,
|
noExitBtn: true,
|
||||||
dark: darkMode,
|
dark: darkMode,
|
||||||
lang: currentLang,
|
lang: currentLang,
|
||||||
|
// Enable offline mode in Electron to disable external service calls
|
||||||
|
...(isElectron && {
|
||||||
|
offline: true,
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
FileCode,
|
FileCode,
|
||||||
FileText,
|
FileText,
|
||||||
|
Link,
|
||||||
Pencil,
|
Pencil,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
@@ -59,20 +60,20 @@ function getCompleteOperations(
|
|||||||
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
|
||||||
// Helper to split text content into regular text and file sections (PDF or text files)
|
// Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs)
|
||||||
interface TextSection {
|
interface TextSection {
|
||||||
type: "text" | "file"
|
type: "text" | "file" | "url"
|
||||||
content: string
|
content: string
|
||||||
filename?: string
|
filename?: string
|
||||||
charCount?: number
|
charCount?: number
|
||||||
fileType?: "pdf" | "text"
|
fileType?: "pdf" | "text" | "url"
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitTextIntoFileSections(text: string): TextSection[] {
|
function splitTextIntoFileSections(text: string): TextSection[] {
|
||||||
const sections: TextSection[] = []
|
const sections: TextSection[] = []
|
||||||
// Match [PDF: filename] or [File: filename] patterns
|
// Match [PDF: filename], [File: filename], or [URL: url] patterns
|
||||||
const filePattern =
|
const filePattern =
|
||||||
/\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g
|
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
let match
|
let match
|
||||||
|
|
||||||
@@ -83,28 +84,34 @@ function splitTextIntoFileSections(text: string): TextSection[] {
|
|||||||
sections.push({ type: "text", content: beforeText })
|
sections.push({ type: "text", content: beforeText })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add file section
|
// Add file/url section
|
||||||
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
|
const sectionType = match[1].toLowerCase()
|
||||||
|
const fileType =
|
||||||
|
sectionType === "pdf"
|
||||||
|
? "pdf"
|
||||||
|
: sectionType === "url"
|
||||||
|
? "url"
|
||||||
|
: "text"
|
||||||
const filename = match[2].trim()
|
const filename = match[2].trim()
|
||||||
const fileContent = match[3].trim()
|
const content = match[3].trim()
|
||||||
sections.push({
|
sections.push({
|
||||||
type: "file",
|
type: sectionType === "url" ? "url" : "file",
|
||||||
content: fileContent,
|
content: content,
|
||||||
filename,
|
filename,
|
||||||
charCount: fileContent.length,
|
charCount: content.length,
|
||||||
fileType,
|
fileType,
|
||||||
})
|
})
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length
|
lastIndex = match.index + match[0].length
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining text after last file section
|
// Add remaining text after last section
|
||||||
const remainingText = text.slice(lastIndex).trim()
|
const remainingText = text.slice(lastIndex).trim()
|
||||||
if (remainingText) {
|
if (remainingText) {
|
||||||
sections.push({ type: "text", content: remainingText })
|
sections.push({ type: "text", content: remainingText })
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no file sections found, return original text
|
// If no file/url sections found, return original text
|
||||||
if (sections.length === 0) {
|
if (sections.length === 0) {
|
||||||
sections.push({ type: "text", content: text })
|
sections.push({ type: "text", content: text })
|
||||||
}
|
}
|
||||||
@@ -123,8 +130,8 @@ const getMessageTextContent = (message: UIMessage): string => {
|
|||||||
// Get only the user's original text, excluding appended file content
|
// Get only the user's original text, excluding appended file content
|
||||||
const getUserOriginalText = (message: UIMessage): string => {
|
const getUserOriginalText = (message: UIMessage): string => {
|
||||||
const fullText = getMessageTextContent(message)
|
const fullText = getMessageTextContent(message)
|
||||||
// Strip out [PDF: ...] and [File: ...] sections that were appended
|
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
|
||||||
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
|
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
|
||||||
return fullText.replace(filePattern, "").trim()
|
return fullText.replace(filePattern, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,12 +1091,14 @@ export function ChatMessageDisplay({
|
|||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
section.type ===
|
section.type ===
|
||||||
"file"
|
"file" ||
|
||||||
|
section.type ===
|
||||||
|
"url"
|
||||||
) {
|
) {
|
||||||
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
|
const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}`
|
||||||
const isExpanded =
|
const isExpanded =
|
||||||
expandedPdfSections[
|
expandedPdfSections[
|
||||||
pdfKey
|
sectionKey
|
||||||
] ??
|
] ??
|
||||||
false
|
false
|
||||||
const charDisplay =
|
const charDisplay =
|
||||||
@@ -1098,10 +1107,27 @@ export function ChatMessageDisplay({
|
|||||||
1000
|
1000
|
||||||
? `${(section.charCount / 1000).toFixed(1)}k`
|
? `${(section.charCount / 1000).toFixed(1)}k`
|
||||||
: section.charCount
|
: section.charCount
|
||||||
|
|
||||||
|
// Icon selector
|
||||||
|
const Icon =
|
||||||
|
section.fileType ===
|
||||||
|
"pdf"
|
||||||
|
? FileText
|
||||||
|
: section.fileType ===
|
||||||
|
"url"
|
||||||
|
? Link
|
||||||
|
: FileCode
|
||||||
|
|
||||||
|
const iconColor =
|
||||||
|
section.fileType ===
|
||||||
|
"pdf"
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-blue-700"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={
|
key={
|
||||||
pdfKey
|
sectionKey
|
||||||
}
|
}
|
||||||
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
||||||
>
|
>
|
||||||
@@ -1116,7 +1142,7 @@ export function ChatMessageDisplay({
|
|||||||
prev,
|
prev,
|
||||||
) => ({
|
) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[pdfKey]:
|
[sectionKey]:
|
||||||
!isExpanded,
|
!isExpanded,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1124,13 +1150,10 @@ export function ChatMessageDisplay({
|
|||||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{section.fileType ===
|
<Icon
|
||||||
"pdf" ? (
|
className={`h-4 w-4 ${iconColor}`}
|
||||||
<FileText className="h-4 w-4 text-red-500" />
|
/>
|
||||||
) : (
|
<span className="text-xs font-medium truncate max-w-[200px]">
|
||||||
<FileCode className="h-4 w-4 text-blue-500" />
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-medium">
|
|
||||||
{
|
{
|
||||||
section.filename
|
section.filename
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-18
@@ -137,26 +137,22 @@ export default function ChatPanel({
|
|||||||
const onFetchChart = (saveToHistory = true) => {
|
const onFetchChart = (saveToHistory = true) => {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
new Promise<string>((resolve) => {
|
new Promise<string>((resolve) => {
|
||||||
if (resolverRef && "current" in resolverRef) {
|
resolverRef.current = resolve
|
||||||
resolverRef.current = resolve
|
|
||||||
}
|
|
||||||
if (saveToHistory) {
|
if (saveToHistory) {
|
||||||
onExport()
|
onExport()
|
||||||
} else {
|
} else {
|
||||||
handleExportWithoutHistory()
|
handleExportWithoutHistory()
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
new Promise<string>((_, reject) =>
|
new Promise<string>((_, reject) => {
|
||||||
setTimeout(
|
const currentResolver = resolverRef.current
|
||||||
() =>
|
setTimeout(() => {
|
||||||
reject(
|
if (resolverRef.current === currentResolver) {
|
||||||
new Error(
|
resolverRef.current = null
|
||||||
"Chart export timed out after 10 seconds",
|
}
|
||||||
),
|
reject(new Error("Chart export timed out after 10 seconds"))
|
||||||
),
|
}, 10000)
|
||||||
10000,
|
}),
|
||||||
),
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +581,7 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const currentSession = sessionManager.currentSession
|
const currentSession = sessionManager.currentSession
|
||||||
if (currentSession && currentSession.messages.length > 0) {
|
if (currentSession) {
|
||||||
// Restore from session manager (IndexedDB)
|
// Restore from session manager (IndexedDB)
|
||||||
justLoadedSessionRef.current = true
|
justLoadedSessionRef.current = true
|
||||||
syncUIWithSession(currentSession)
|
syncUIWithSession(currentSession)
|
||||||
@@ -622,7 +618,7 @@ export default function ChatPanel({
|
|||||||
lastSyncedSessionIdRef.current = newSessionId
|
lastSyncedSessionIdRef.current = newSessionId
|
||||||
|
|
||||||
// Sync UI with new session
|
// Sync UI with new session
|
||||||
if (newSession && newSession.messages.length > 0) {
|
if (newSession) {
|
||||||
justLoadedSessionRef.current = true
|
justLoadedSessionRef.current = true
|
||||||
syncUIWithSession(newSession)
|
syncUIWithSession(newSession)
|
||||||
} else if (!newSession) {
|
} else if (!newSession) {
|
||||||
@@ -677,7 +673,7 @@ export default function ChatPanel({
|
|||||||
// Debounce: save after 1 second of no changes
|
// Debounce: save after 1 second of no changes
|
||||||
localStorageDebounceRef.current = setTimeout(async () => {
|
localStorageDebounceRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0 || hasDiagramNow) {
|
||||||
const sessionData = await buildSessionData({
|
const sessionData = await buildSessionData({
|
||||||
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
||||||
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
||||||
@@ -699,6 +695,7 @@ export default function ChatPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
chartXML,
|
||||||
messages,
|
messages,
|
||||||
status,
|
status,
|
||||||
sessionIsAvailable,
|
sessionIsAvailable,
|
||||||
@@ -729,7 +726,8 @@ export default function ChatPanel({
|
|||||||
const handleVisibilityChange = async () => {
|
const handleVisibilityChange = async () => {
|
||||||
if (
|
if (
|
||||||
document.visibilityState === "hidden" &&
|
document.visibilityState === "hidden" &&
|
||||||
messagesRef.current.length > 0
|
(messagesRef.current.length > 0 ||
|
||||||
|
isRealDiagram(chartXMLRef.current))
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Attempt to save session - browser may not wait for completion
|
// Attempt to save session - browser may not wait for completion
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ interface DiagramContextType {
|
|||||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||||
handleExport: () => void
|
handleExport: () => void
|
||||||
handleExportWithoutHistory: () => void
|
handleExportWithoutHistory: () => void
|
||||||
resolverRef: React.Ref<((value: string) => void) | null>
|
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||||
handleDiagramExport: (data: any) => void
|
handleDiagramExport: (data: any) => void
|
||||||
|
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||||
clearDiagram: () => void
|
clearDiagram: () => void
|
||||||
saveDiagramToFile: (
|
saveDiagramToFile: (
|
||||||
filename: string,
|
filename: string,
|
||||||
@@ -56,8 +57,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
const pngResolverRef = 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 diagram has been restored after DrawIO remount (e.g., theme change)
|
|
||||||
const hasDiagramRestoredRef = useRef<boolean>(false)
|
|
||||||
// Track latest chartXML for restoration after remount
|
// Track latest chartXML for restoration after remount
|
||||||
const chartXMLRef = useRef<string>("")
|
const chartXMLRef = useRef<string>("")
|
||||||
|
|
||||||
@@ -79,22 +78,22 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, [chartXML])
|
}, [chartXML])
|
||||||
|
|
||||||
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
|
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
|
||||||
|
// Also restore when chartXML changes while DrawIO is ready (e.g., session loaded after iframe ready)
|
||||||
|
const lastRestoredXmlRef = useRef<string>("")
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
|
if (!isDrawioReady || !drawioRef.current) return
|
||||||
if (!isDrawioReady) {
|
// Only load if we have a real diagram and it's different from what we already loaded
|
||||||
hasDiagramRestoredRef.current = false
|
if (
|
||||||
return
|
isRealDiagram(chartXML) &&
|
||||||
|
chartXML !== lastRestoredXmlRef.current
|
||||||
|
) {
|
||||||
|
lastRestoredXmlRef.current = chartXML
|
||||||
|
drawioRef.current.load({ xml: chartXML })
|
||||||
|
} else if (!isRealDiagram(chartXML)) {
|
||||||
|
// Reset when diagram is cleared so a future restore can re-load the same XML.
|
||||||
|
lastRestoredXmlRef.current = ""
|
||||||
}
|
}
|
||||||
// Only restore once per ready cycle
|
}, [isDrawioReady, chartXML])
|
||||||
if (hasDiagramRestoredRef.current) return
|
|
||||||
hasDiagramRestoredRef.current = true
|
|
||||||
|
|
||||||
// Restore diagram from ref if we have one
|
|
||||||
const xmlToRestore = chartXMLRef.current
|
|
||||||
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
|
|
||||||
drawioRef.current.load({ xml: xmlToRestore })
|
|
||||||
}
|
|
||||||
}, [isDrawioReady])
|
|
||||||
|
|
||||||
// Track if we're expecting an export for file save (stores raw export data)
|
// Track if we're expecting an export for file save (stores raw export data)
|
||||||
const saveResolverRef = useRef<{
|
const saveResolverRef = useRef<{
|
||||||
@@ -267,6 +266,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||||
|
if (!data?.xml) return
|
||||||
|
// Don't overwrite a pending restore - if we have a real diagram in state
|
||||||
|
// but DrawIO isn't ready yet, it means we're waiting to restore
|
||||||
|
if (!isDrawioReady && isRealDiagram(chartXML)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChartXML(data.xml)
|
||||||
|
}
|
||||||
|
|
||||||
const clearDiagram = () => {
|
const clearDiagram = () => {
|
||||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
@@ -391,6 +400,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
resolverRef,
|
resolverRef,
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
|
handleDiagramAutoSave,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
getThumbnailSvg,
|
getThumbnailSvg,
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ mac:
|
|||||||
arch:
|
arch:
|
||||||
- x64
|
- x64
|
||||||
- arm64
|
- arm64
|
||||||
hardenedRuntime: true
|
# Disable electron-builder's signing - we use custom ad-hoc signing in afterPack
|
||||||
|
# to properly sign nested bundles with --deep flag for bundled draw.io files
|
||||||
|
identity: null
|
||||||
|
hardenedRuntime: false
|
||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
entitlements: resources/entitlements.mac.plist
|
|
||||||
entitlementsInherit: resources/entitlements.mac.plist
|
|
||||||
|
|
||||||
dmg:
|
dmg:
|
||||||
contents:
|
contents:
|
||||||
|
|||||||
+102
-38
@@ -47,6 +47,33 @@ interface ChatSessionDB extends DBSchema {
|
|||||||
|
|
||||||
// Database singleton
|
// Database singleton
|
||||||
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
||||||
|
const resetDBPromise = () => {
|
||||||
|
dbPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClosingError = (error: unknown): boolean => {
|
||||||
|
return (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === "InvalidStateError" &&
|
||||||
|
/closing/i.test(error.message)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const withDB = async <T>(
|
||||||
|
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
|
||||||
|
): Promise<T> => {
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
return await action(db)
|
||||||
|
} catch (error) {
|
||||||
|
if (isClosingError(error)) {
|
||||||
|
resetDBPromise()
|
||||||
|
const db = await getDB()
|
||||||
|
return await action(db)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
||||||
if (!dbPromise) {
|
if (!dbPromise) {
|
||||||
@@ -60,7 +87,23 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
|||||||
}
|
}
|
||||||
// Future migrations: if (oldVersion < 2) { ... }
|
// Future migrations: if (oldVersion < 2) { ... }
|
||||||
},
|
},
|
||||||
|
terminated() {
|
||||||
|
resetDBPromise()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
dbPromise
|
||||||
|
.then((db) => {
|
||||||
|
db.onversionchange = () => {
|
||||||
|
db.close()
|
||||||
|
resetDBPromise()
|
||||||
|
}
|
||||||
|
db.onclose = () => {
|
||||||
|
resetDBPromise()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
resetDBPromise()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return dbPromise
|
return dbPromise
|
||||||
}
|
}
|
||||||
@@ -75,31 +118,46 @@ export function isIndexedDBAvailable(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if IndexedDB is actually usable (not just present).
|
||||||
|
// Note: Do NOT close the db here - getDB() returns a shared singleton connection
|
||||||
|
// that other code depends on.
|
||||||
|
export async function isIndexedDBUsable(): Promise<boolean> {
|
||||||
|
if (!isIndexedDBAvailable()) return false
|
||||||
|
try {
|
||||||
|
await getDB()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CRUD Operations
|
// CRUD Operations
|
||||||
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
||||||
if (!isIndexedDBAvailable()) return []
|
if (!isIndexedDBAvailable()) return []
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
return await withDB(async (db) => {
|
||||||
const tx = db.transaction(STORE_NAME, "readonly")
|
const tx = db.transaction(STORE_NAME, "readonly")
|
||||||
const index = tx.store.index("by-updated")
|
const index = tx.store.index("by-updated")
|
||||||
const metadata: SessionMetadata[] = []
|
const metadata: SessionMetadata[] = []
|
||||||
|
|
||||||
// Use cursor to read only metadata fields (avoids loading full messages/XML)
|
// Use cursor to read only metadata fields (avoids loading full messages/XML)
|
||||||
let cursor = await index.openCursor(null, "prev") // newest first
|
let cursor = await index.openCursor(null, "prev") // newest first
|
||||||
while (cursor) {
|
while (cursor) {
|
||||||
const s = cursor.value
|
const s = cursor.value
|
||||||
metadata.push({
|
metadata.push({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
updatedAt: s.updatedAt,
|
updatedAt: s.updatedAt,
|
||||||
messageCount: s.messages.length,
|
messageCount: s.messages.length,
|
||||||
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
|
hasDiagram:
|
||||||
thumbnailDataUrl: s.thumbnailDataUrl,
|
!!s.diagramXml && s.diagramXml.trim().length > 0,
|
||||||
})
|
thumbnailDataUrl: s.thumbnailDataUrl,
|
||||||
cursor = await cursor.continue()
|
})
|
||||||
}
|
cursor = await cursor.continue()
|
||||||
return metadata
|
}
|
||||||
|
return metadata
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get session metadata:", error)
|
console.error("Failed to get session metadata:", error)
|
||||||
return []
|
return []
|
||||||
@@ -109,8 +167,9 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
|||||||
export async function getSession(id: string): Promise<ChatSession | null> {
|
export async function getSession(id: string): Promise<ChatSession | null> {
|
||||||
if (!isIndexedDBAvailable()) return null
|
if (!isIndexedDBAvailable()) return null
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
return await withDB(async (db) => {
|
||||||
return (await db.get(STORE_NAME, id)) || null
|
return (await db.get(STORE_NAME, id)) || null
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get session:", error)
|
console.error("Failed to get session:", error)
|
||||||
return null
|
return null
|
||||||
@@ -120,8 +179,9 @@ export async function getSession(id: string): Promise<ChatSession | null> {
|
|||||||
export async function saveSession(session: ChatSession): Promise<boolean> {
|
export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||||
if (!isIndexedDBAvailable()) return false
|
if (!isIndexedDBAvailable()) return false
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
await db.put(STORE_NAME, session)
|
await db.put(STORE_NAME, session)
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle quota exceeded
|
// Handle quota exceeded
|
||||||
@@ -133,8 +193,9 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
|||||||
await deleteOldestSession()
|
await deleteOldestSession()
|
||||||
// Retry once
|
// Retry once
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
await db.put(STORE_NAME, session)
|
await db.put(STORE_NAME, session)
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -153,8 +214,9 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
|||||||
export async function deleteSession(id: string): Promise<void> {
|
export async function deleteSession(id: string): Promise<void> {
|
||||||
if (!isIndexedDBAvailable()) return
|
if (!isIndexedDBAvailable()) return
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
await db.delete(STORE_NAME, id)
|
await db.delete(STORE_NAME, id)
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete session:", error)
|
console.error("Failed to delete session:", error)
|
||||||
}
|
}
|
||||||
@@ -163,8 +225,9 @@ export async function deleteSession(id: string): Promise<void> {
|
|||||||
export async function getSessionCount(): Promise<number> {
|
export async function getSessionCount(): Promise<number> {
|
||||||
if (!isIndexedDBAvailable()) return 0
|
if (!isIndexedDBAvailable()) return 0
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
return await withDB(async (db) => {
|
||||||
return await db.count(STORE_NAME)
|
return await db.count(STORE_NAME)
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get session count:", error)
|
console.error("Failed to get session count:", error)
|
||||||
return 0
|
return 0
|
||||||
@@ -174,14 +237,15 @@ export async function getSessionCount(): Promise<number> {
|
|||||||
export async function deleteOldestSession(): Promise<void> {
|
export async function deleteOldestSession(): Promise<void> {
|
||||||
if (!isIndexedDBAvailable()) return
|
if (!isIndexedDBAvailable()) return
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
const tx = db.transaction(STORE_NAME, "readwrite")
|
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||||
const index = tx.store.index("by-updated")
|
const index = tx.store.index("by-updated")
|
||||||
const cursor = await index.openCursor()
|
const cursor = await index.openCursor()
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
await cursor.delete()
|
await cursor.delete()
|
||||||
}
|
}
|
||||||
await tx.done
|
await tx.done
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete oldest session:", error)
|
console.error("Failed to delete oldest session:", error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const DEFAULT_SYSTEM_PROMPT = `
|
|||||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
||||||
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
||||||
|
ALWAYS respond in the same language as the user's last message.
|
||||||
|
|
||||||
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
|
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
|
||||||
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.11",
|
"version": "0.4.12-beta.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.11",
|
"version": "0.4.12-beta.5",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.11",
|
"version": "0.4.12",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.13",
|
"version": "0.1.15",
|
||||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ function isLikelyMcpSessionId(sessionId: string): boolean {
|
|||||||
return sessionId.startsWith("mcp-") && sessionId.length <= 128
|
return sessionId.startsWith("mcp-") && sessionId.length <= 128
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find the most recent active session (for auto-redirect when no sessionId provided)
|
||||||
|
function getMostRecentSessionId(): string | null {
|
||||||
|
let mostRecent: { id: string; lastUpdated: Date } | null = null
|
||||||
|
for (const [sessionId, state] of stateStore) {
|
||||||
|
if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) {
|
||||||
|
mostRecent = { id: sessionId, lastUpdated: state.lastUpdated }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mostRecent?.id || null
|
||||||
|
}
|
||||||
|
|
||||||
function ensureSessionStateInitialized(sessionId: string): void {
|
function ensureSessionStateInitialized(sessionId: string): void {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
if (!isLikelyMcpSessionId(sessionId)) return
|
if (!isLikelyMcpSessionId(sessionId)) return
|
||||||
@@ -195,6 +206,17 @@ function handleRequest(
|
|||||||
|
|
||||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
const sessionId = url.searchParams.get("mcp") || ""
|
const sessionId = url.searchParams.get("mcp") || ""
|
||||||
|
|
||||||
|
// Auto-redirect to most recent session if no sessionId provided
|
||||||
|
if (!sessionId) {
|
||||||
|
const recentSessionId = getMostRecentSessionId()
|
||||||
|
if (recentSessionId) {
|
||||||
|
res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ensureSessionStateInitialized(sessionId)
|
ensureSessionStateInitialized(sessionId)
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
@@ -375,85 +397,202 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Draw.io MCP</title>
|
<title>Next AI Draw.io</title>
|
||||||
<style>
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap');
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||||
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||||||
#header {
|
#header {
|
||||||
padding: 8px 16px; background: #1a1a2e; color: #eee;
|
padding: 0 20px; height: 52px;
|
||||||
font-family: system-ui, sans-serif; font-size: 14px;
|
background: linear-gradient(to bottom, #ffffff, #fafbfc);
|
||||||
|
border-bottom: 1px solid #e8ecf0;
|
||||||
|
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||||
|
position: relative; z-index: 10;
|
||||||
|
}
|
||||||
|
#header .brand {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
#header .logo {
|
||||||
|
width: 28px; height: 28px; border-radius: 6px;
|
||||||
|
background: #18181b;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
|
||||||
|
#header .title {
|
||||||
|
font-size: 15px; font-weight: 600; color: #1a1a2e;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
#header .session {
|
||||||
|
font-size: 11px; color: #8b95a5; font-weight: 400;
|
||||||
|
background: #f1f3f9; padding: 3px 8px; border-radius: 4px;
|
||||||
|
margin-left: 12px; font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
}
|
||||||
|
#header .right { display: flex; align-items: center; gap: 12px; }
|
||||||
|
#save-btn {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
||||||
|
background: linear-gradient(to bottom, #18181b, #27272a);
|
||||||
|
color: white; border: none; cursor: pointer;
|
||||||
|
font-weight: 500; font-family: inherit;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
#save-btn svg { width: 14px; height: 14px; }
|
||||||
|
#save-btn:hover {
|
||||||
|
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
#save-btn:active { transform: translateY(0); }
|
||||||
|
#save-btn:disabled, #history-btn:disabled {
|
||||||
|
background: #e5e7eb; color: #9ca3af;
|
||||||
|
cursor: not-allowed; transform: none; box-shadow: none;
|
||||||
}
|
}
|
||||||
#header .session { color: #888; font-size: 12px; }
|
|
||||||
#header .status { font-size: 12px; }
|
|
||||||
#header .status.connected { color: #4ade80; }
|
|
||||||
#header .status.disconnected { color: #f87171; }
|
|
||||||
#drawio { flex: 1; border: none; }
|
|
||||||
#history-btn {
|
#history-btn {
|
||||||
position: fixed; bottom: 24px; right: 24px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
width: 48px; height: 48px; border-radius: 50%;
|
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
||||||
background: #3b82f6; color: white; border: none; cursor: pointer;
|
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
cursor: pointer; font-weight: 500; font-family: inherit;
|
||||||
display: flex; align-items: center; justify-content: center;
|
transition: all 0.15s ease;
|
||||||
z-index: 1000;
|
|
||||||
}
|
}
|
||||||
#history-btn:hover { background: #2563eb; }
|
#history-btn svg { width: 14px; height: 14px; }
|
||||||
#history-btn:disabled { background: #6b7280; cursor: not-allowed; }
|
#history-btn:hover {
|
||||||
#history-btn svg { width: 24px; height: 24px; }
|
background: #e4e4e7; border-color: #d4d4d8;
|
||||||
#history-modal {
|
}
|
||||||
|
#drawio { flex: 1; border: none; }
|
||||||
|
#history-modal, #save-modal {
|
||||||
display: none; position: fixed; inset: 0;
|
display: none; position: fixed; inset: 0;
|
||||||
background: rgba(0,0,0,0.5); z-index: 2000;
|
background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
|
||||||
align-items: center; justify-content: center;
|
z-index: 2000; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
#history-modal.open { display: flex; }
|
#history-modal.open, #save-modal.open { display: flex; }
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: white; border-radius: 12px;
|
background: white; border-radius: 16px;
|
||||||
width: 90%; max-width: 500px; max-height: 70vh;
|
width: 90%; max-width: 480px; max-height: 70vh;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
|
||||||
|
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
animation: modalIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
.modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
|
@keyframes modalIn {
|
||||||
.modal-header h2 { font-size: 18px; margin: 0; }
|
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||||||
.modal-body { flex: 1; overflow-y: auto; padding: 16px; }
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
.modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
|
}
|
||||||
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
.modal-header {
|
||||||
|
padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5;
|
||||||
|
}
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 17px; font-weight: 600; margin: 0; color: #18181b;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 24px; border-top: 1px solid #f1f3f5;
|
||||||
|
display: flex; gap: 10px; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||||
.history-item {
|
.history-item {
|
||||||
border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px;
|
border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px;
|
||||||
cursor: pointer; text-align: center;
|
cursor: pointer; text-align: center; transition: all 0.15s ease;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.history-item:hover { border-color: #a1a1aa; background: white; }
|
||||||
|
.history-item.selected {
|
||||||
|
border-color: #18181b; background: white;
|
||||||
|
box-shadow: 0 0 0 3px rgba(24,24,27,0.1);
|
||||||
}
|
}
|
||||||
.history-item:hover { border-color: #3b82f6; }
|
|
||||||
.history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
|
|
||||||
.history-item .thumb {
|
.history-item .thumb {
|
||||||
aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px;
|
aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
margin-bottom: 4px; overflow: hidden;
|
margin-bottom: 6px; overflow: hidden;
|
||||||
}
|
}
|
||||||
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
.history-item .label { font-size: 12px; color: #666; }
|
.history-item .label { font-size: 11px; color: #71717a; font-weight: 500; }
|
||||||
.btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
|
.btn {
|
||||||
.btn-primary { background: #3b82f6; color: white; }
|
padding: 9px 18px; border-radius: 8px; font-size: 13px;
|
||||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
cursor: pointer; border: none; font-weight: 500;
|
||||||
.btn-secondary { background: #f3f4f6; color: #374151; }
|
font-family: inherit; transition: all 0.15s ease;
|
||||||
.empty { text-align: center; padding: 40px; color: #666; }
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(to bottom, #18181b, #27272a);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: #e4e4e7; color: #a1a1aa;
|
||||||
|
cursor: not-allowed; transform: none; box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: #e4e4e7; }
|
||||||
|
.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; }
|
||||||
|
.form-group { margin-bottom: 18px; }
|
||||||
|
.form-group label {
|
||||||
|
display: block; font-size: 13px; font-weight: 500;
|
||||||
|
margin-bottom: 8px; color: #3f3f46;
|
||||||
|
}
|
||||||
|
.form-group select, .form-group input {
|
||||||
|
width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7;
|
||||||
|
border-radius: 8px; font-size: 14px; outline: none;
|
||||||
|
font-family: inherit; background: white;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.form-group select:focus, .form-group input:focus {
|
||||||
|
border-color: #18181b;
|
||||||
|
box-shadow: 0 0 0 3px rgba(24,24,27,0.08);
|
||||||
|
}
|
||||||
|
.filename-group { display: flex; }
|
||||||
|
.filename-group input { border-radius: 8px 0 0 8px; border-right: none; }
|
||||||
|
.filename-group .ext {
|
||||||
|
padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7;
|
||||||
|
border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="container">
|
<div id="container">
|
||||||
<div id="header">
|
<div id="header">
|
||||||
<div>
|
<div class="brand">
|
||||||
<strong>Draw.io MCP</strong>
|
<div class="logo">
|
||||||
<span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span>
|
<svg viewBox="0 0 1536 1536" fill="#ffffff">
|
||||||
|
<g transform="translate(0,1536) scale(0.1,-0.1)">
|
||||||
|
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431 -560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356 319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85 -17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529 -2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128 -82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303 -177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276 286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242 241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337 -25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22 -5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204 -383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72 -167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300 6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884 -15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534 c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154 11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448 -20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10 -47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293 -369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6 39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52 -149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415 20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50 118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0 535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539 376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70 290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="title">Next AI Draw.io</span>
|
||||||
|
${sessionId ? `<span class="session">${sessionId.slice(-8)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
|
</svg>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
<button id="save-btn" ${sessionId ? "" : "disabled"}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="status" class="status disconnected">Connecting...</div>
|
|
||||||
</div>
|
</div>
|
||||||
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0"></iframe>
|
||||||
</div>
|
</div>
|
||||||
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
|
||||||
<polyline points="12 6 12 12 16 14"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div id="history-modal">
|
<div id="history-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header"><h2>History</h2></div>
|
<div class="modal-header"><h2>History</h2></div>
|
||||||
@@ -467,10 +606,35 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="save-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header"><h2>Download Diagram</h2></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Format</label>
|
||||||
|
<select id="save-format">
|
||||||
|
<option value="drawio">Draw.io (.drawio)</option>
|
||||||
|
<option value="png">PNG Image (.png)</option>
|
||||||
|
<option value="svg">SVG Vector (.svg)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Filename</label>
|
||||||
|
<div class="filename-group">
|
||||||
|
<input type="text" id="save-filename" value="diagram" placeholder="Enter filename">
|
||||||
|
<span class="ext" id="save-ext">.drawio</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="save-cancel-btn">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="save-confirm-btn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const sessionId = "${sessionId}";
|
const sessionId = "${sessionId}";
|
||||||
const iframe = document.getElementById('drawio');
|
const iframe = document.getElementById('drawio');
|
||||||
const statusEl = document.getElementById('status');
|
|
||||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||||
let pendingSvgExport = null;
|
let pendingSvgExport = null;
|
||||||
let pendingAiSvg = false;
|
let pendingAiSvg = false;
|
||||||
@@ -481,8 +645,6 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.event === 'init') {
|
if (msg.event === 'init') {
|
||||||
isReady = true;
|
isReady = true;
|
||||||
statusEl.textContent = 'Ready';
|
|
||||||
statusEl.className = 'status connected';
|
|
||||||
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||||
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
||||||
// Request SVG export, then push state with SVG
|
// Request SVG export, then push state with SVG
|
||||||
@@ -491,6 +653,23 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
// Fallback if export doesn't respond
|
// Fallback if export doesn't respond
|
||||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
||||||
} else if (msg.event === 'export' && msg.data) {
|
} else if (msg.event === 'export' && msg.data) {
|
||||||
|
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
||||||
|
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
||||||
|
const dl = pendingDownload;
|
||||||
|
pendingDownload = null;
|
||||||
|
let dataUrl = msg.data;
|
||||||
|
if (!dataUrl.startsWith('data:')) {
|
||||||
|
const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml';
|
||||||
|
dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data)));
|
||||||
|
}
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = dataUrl; a.download = dl.filename;
|
||||||
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
|
saveModal.classList.remove('open');
|
||||||
|
saveConfirmBtn.disabled = false;
|
||||||
|
saveConfirmBtn.textContent = 'Save';
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Handle sync export (XML format) - server requested fresh state
|
// Handle sync export (XML format) - server requested fresh state
|
||||||
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
|
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
|
||||||
pendingSyncExport = false;
|
pendingSyncExport = false;
|
||||||
@@ -563,6 +742,64 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
|
|
||||||
if (sessionId) { poll(); setInterval(poll, 2000); }
|
if (sessionId) { poll(); setInterval(poll, 2000); }
|
||||||
|
|
||||||
|
// Save modal
|
||||||
|
const saveBtn = document.getElementById('save-btn');
|
||||||
|
const saveModal = document.getElementById('save-modal');
|
||||||
|
const saveFormat = document.getElementById('save-format');
|
||||||
|
const saveFilename = document.getElementById('save-filename');
|
||||||
|
const saveExt = document.getElementById('save-ext');
|
||||||
|
const saveCancelBtn = document.getElementById('save-cancel-btn');
|
||||||
|
const saveConfirmBtn = document.getElementById('save-confirm-btn');
|
||||||
|
let pendingDownload = null;
|
||||||
|
|
||||||
|
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg' };
|
||||||
|
|
||||||
|
saveBtn.onclick = () => {
|
||||||
|
if (!sessionId || !isReady) return;
|
||||||
|
saveModal.classList.add('open');
|
||||||
|
saveFilename.focus();
|
||||||
|
saveFilename.select();
|
||||||
|
};
|
||||||
|
|
||||||
|
saveFormat.onchange = () => {
|
||||||
|
saveExt.textContent = extMap[saveFormat.value] || '.drawio';
|
||||||
|
};
|
||||||
|
|
||||||
|
saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); };
|
||||||
|
saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); };
|
||||||
|
|
||||||
|
saveConfirmBtn.onclick = () => {
|
||||||
|
const format = saveFormat.value;
|
||||||
|
const filename = (saveFilename.value.trim() || 'diagram') + extMap[format];
|
||||||
|
saveConfirmBtn.disabled = true;
|
||||||
|
saveConfirmBtn.textContent = 'Exporting...';
|
||||||
|
|
||||||
|
if (format === 'drawio') {
|
||||||
|
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
|
||||||
|
let xmlData = lastXml || '';
|
||||||
|
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||||
|
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||||
|
}
|
||||||
|
const blob = new Blob([xmlData], { type: 'application/xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = filename;
|
||||||
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
saveModal.classList.remove('open');
|
||||||
|
saveConfirmBtn.disabled = false;
|
||||||
|
saveConfirmBtn.textContent = 'Save';
|
||||||
|
} else if (format === 'png') {
|
||||||
|
pendingDownload = { format: 'png', filename };
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2 }), '*');
|
||||||
|
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
||||||
|
} else if (format === 'svg') {
|
||||||
|
pendingDownload = { format: 'svg', filename };
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||||
|
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// History UI
|
// History UI
|
||||||
const historyBtn = document.getElementById('history-btn');
|
const historyBtn = document.getElementById('history-btn');
|
||||||
const historyModal = document.getElementById('history-modal');
|
const historyModal = document.getElementById('history-modal');
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function proxy(request: NextRequest) {
|
|||||||
if (
|
if (
|
||||||
pathname.startsWith("/api/") ||
|
pathname.startsWith("/api/") ||
|
||||||
pathname.startsWith("/_next/") ||
|
pathname.startsWith("/_next/") ||
|
||||||
|
pathname.startsWith("/drawio") ||
|
||||||
pathname.includes("/favicon") ||
|
pathname.includes("/favicon") ||
|
||||||
/\.(.*)$/.test(pathname)
|
/\.(.*)$/.test(pathname)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* electron-builder afterPack hook
|
* electron-builder afterPack hook
|
||||||
* Copies node_modules to the standalone directory in the packaged app
|
* Copies node_modules to the standalone directory in the packaged app
|
||||||
|
* and ad-hoc signs macOS apps for offline draw.io bundle compatibility
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -12,6 +13,7 @@ const {
|
|||||||
statSync,
|
statSync,
|
||||||
} = require("fs")
|
} = require("fs")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
|
const { execSync } = require("child_process")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy directory recursively, converting symlinks to regular files/directories.
|
* Copy directory recursively, converting symlinks to regular files/directories.
|
||||||
@@ -80,4 +82,22 @@ module.exports = async (context) => {
|
|||||||
"Ensure 'npm run electron:prepare' was run before building.",
|
"Ensure 'npm run electron:prepare' was run before building.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ad-hoc sign macOS apps to fix signature issues with bundled draw.io files
|
||||||
|
if (context.packager.platform.name === "mac") {
|
||||||
|
const appPath = path.join(
|
||||||
|
appOutDir,
|
||||||
|
`${context.packager.appInfo.productFilename}.app`,
|
||||||
|
)
|
||||||
|
console.log(`[afterPack] Ad-hoc signing macOS app: ${appPath}`)
|
||||||
|
try {
|
||||||
|
execSync(`codesign --force --deep --sign - "${appPath}"`, {
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
console.log("[afterPack] Ad-hoc signing completed successfully")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[afterPack] Ad-hoc signing failed:", error.message)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user