diff --git a/src/components/EditorCanvas/Canvas.jsx b/src/components/EditorCanvas/Canvas.jsx index aed3af69a..648ecef00 100644 --- a/src/components/EditorCanvas/Canvas.jsx +++ b/src/components/EditorCanvas/Canvas.jsx @@ -9,7 +9,7 @@ import { gridCircleRadius, minAreaSize, } from "../../data/constants"; -import { Toast } from "@douyinfe/semi-ui"; +import { Toast, Modal, Button } from "@douyinfe/semi-ui"; import Table from "./Table"; import Area from "./Area"; import Relationship from "./Relationship"; @@ -25,6 +25,7 @@ import { useNotes, useLayout, useSaveState, + useFileDrop, } from "../../hooks"; import { useTranslation } from "react-i18next"; import { useEventListener } from "usehooks-ts"; @@ -58,6 +59,16 @@ export default function Canvas() { bulkSelectedElements, setBulkSelectedElements, } = useSelect(); + const { + isDragOver, + showImportModal, + handleDragOver, + handleDragLeave, + handleDrop, + handleImportOverwrite, + handleImportAppend, + handleImportCancel, + } = useFileDrop(); const notDragging = { id: -1, type: ObjectType.NONE, @@ -685,12 +696,22 @@ export default function Canvas() { return (
+ {isDragOver && ( +
+
+ {t("drop_to_import") || "Drop file to import"} +
+
+ )}
)} + + + + +
+ } + > +

+ {t("overwrite_or_append_description") || + "The current diagram is not empty. Would you like to overwrite it with the imported data or add to the existing diagram?"} +

+ ); } diff --git a/src/data/schemas.js b/src/data/schemas.js index 23a50844d..8f656fc9f 100644 --- a/src/data/schemas.js +++ b/src/data/schemas.js @@ -83,8 +83,8 @@ export const noteSchema = { type: "object", properties: { id: { type: "integer" }, - x: { type: "number" }, - y: { type: "number" }, + x: { type: ["number", "null"] }, + y: { type: ["number", "null"] }, title: { type: "string" }, content: { type: "string" }, color: { type: "string", pattern: "^#[0-9a-fA-F]{6}$" }, @@ -92,7 +92,7 @@ export const noteSchema = { width: { type: "number" }, locked: { type: "boolean" }, }, - required: ["id", "x", "y", "title", "content", "color", "height"], + required: ["id"], }; export const typeSchema = { diff --git a/src/hooks/index.js b/src/hooks/index.js index bc279d888..e079fe2e5 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -11,4 +11,5 @@ export { default as useTransform } from "./useTransform"; export { default as useTypes } from "./useTypes"; export { default as useUndoRedo } from "./useUndoRedo"; export { default as useEnums } from "./useEnums"; +export { default as useFileDrop } from "./useFileDrop"; export { default as useThemedPage } from "./useThemedPage"; diff --git a/src/hooks/useFileDrop.js b/src/hooks/useFileDrop.js new file mode 100644 index 000000000..9f3674747 --- /dev/null +++ b/src/hooks/useFileDrop.js @@ -0,0 +1,203 @@ +import { useState, useCallback, useRef } from "react"; +import { Toast } from "@douyinfe/semi-ui"; +import { useTranslation } from "react-i18next"; +import { importFromFile, isFileSupported } from "../utils/importFrom/file"; +import { databases } from "../data/databases"; +import { mergeCustomTypes } from "../utils/customTypes"; +import useDiagram from "./useDiagram"; +import useNotes from "./useNotes"; +import useAreas from "./useAreas"; +import useTypes from "./useTypes"; +import useEnums from "./useEnums"; +import useTransform from "./useTransform"; +import useUndoRedo from "./useUndoRedo"; + +/** + * Hook that provides drag-and-drop file import functionality for the canvas. + * Handles dragover/dragleave/drop events and processes supported file types. + * When the canvas is not empty, prompts the user to overwrite or append. + */ +export default function useFileDrop() { + const { t } = useTranslation(); + const [isDragOver, setIsDragOver] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); + const pendingData = useRef(null); + + const { tables, relationships, setTables, setRelationships, database } = + useDiagram(); + const { notes, setNotes } = useNotes(); + const { areas, setAreas } = useAreas(); + const { types, setTypes } = useTypes(); + const { enums, setEnums } = useEnums(); + const { setTransform } = useTransform(); + const { setUndoStack, setRedoStack } = useUndoRedo(); + + const isDiagramEmpty = useCallback(() => { + return ( + tables.length === 0 && + relationships.length === 0 && + notes.length === 0 && + areas.length === 0 && + types.length === 0 && + enums.length === 0 + ); + }, [tables, relationships, notes, areas, types, enums]); + + const overwriteDiagram = useCallback( + (data) => { + if (data.tables) setTables(data.tables); + if (data.relationships) setRelationships(data.relationships); + setAreas(data.subjectAreas ?? data.areas ?? []); + setNotes(data.notes ?? []); + if (databases[database].hasEnums && data.enums) { + setEnums(data.enums); + } + if (databases[database].hasTypes && data.types) { + setTypes(data.types); + } + if (data.customTypes) { + mergeCustomTypes(data.customTypes); + } + setTransform((prev) => ({ ...prev, pan: { x: 0, y: 0 } })); + setUndoStack([]); + setRedoStack([]); + }, + [ + database, + setTables, + setRelationships, + setAreas, + setNotes, + setEnums, + setTypes, + setTransform, + setUndoStack, + setRedoStack, + ], + ); + + const appendToDiagram = useCallback( + (data) => { + if (data.tables) { + setTables((prev) => [...prev, ...data.tables]); + } + if (data.relationships) { + setRelationships((prev) => + [...prev, ...data.relationships].map((r, i) => ({ ...r, id: i })), + ); + } + if (databases[database].hasEnums && data.enums?.length) { + setEnums((prev) => [...prev, ...data.enums]); + } + if (databases[database].hasTypes && data.types?.length) { + setTypes((prev) => [...prev, ...data.types]); + } + if (data.customTypes) { + mergeCustomTypes(data.customTypes); + } + setUndoStack([]); + setRedoStack([]); + }, + [ + database, + setTables, + setRelationships, + setEnums, + setTypes, + setUndoStack, + setRedoStack, + ], + ); + + const handleImportOverwrite = useCallback(() => { + if (pendingData.current) { + overwriteDiagram(pendingData.current); + Toast.success( + t("file_imported_successfully") || "File imported successfully", + ); + } + pendingData.current = null; + setShowImportModal(false); + }, [overwriteDiagram, t]); + + const handleImportAppend = useCallback(() => { + if (pendingData.current) { + appendToDiagram(pendingData.current); + Toast.success( + t("file_imported_successfully") || "File imported successfully", + ); + } + pendingData.current = null; + setShowImportModal(false); + }, [appendToDiagram, t]); + + const handleImportCancel = useCallback(() => { + pendingData.current = null; + setShowImportModal(false); + }, []); + + const handleDragOver = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const handleDrop = useCallback( + async (e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + + const file = files[0]; + + if (!isFileSupported(file)) { + Toast.error( + t("file_type_not_supported") || + "This file type is not supported. Supported types: JSON, DDB, DBML, SQL", + ); + return; + } + + const { data, error } = await importFromFile(file, database); + + if (error) { + Toast.error(error); + return; + } + + if (!data) return; + + if (isDiagramEmpty()) { + overwriteDiagram(data); + Toast.success( + t("file_imported_successfully") || "File imported successfully", + ); + } else { + pendingData.current = data; + setShowImportModal(true); + } + }, + [database, isDiagramEmpty, overwriteDiagram, t], + ); + + return { + isDragOver, + showImportModal, + handleDragOver, + handleDragLeave, + handleDrop, + handleImportOverwrite, + handleImportAppend, + handleImportCancel, + }; +} diff --git a/src/utils/importFrom/file.js b/src/utils/importFrom/file.js new file mode 100644 index 000000000..7c41d4dd6 --- /dev/null +++ b/src/utils/importFrom/file.js @@ -0,0 +1,201 @@ +import { Parser } from "node-sql-parser"; +import { Parser as OracleParser } from "oracle-sql-parser"; +import { DB } from "../../data/constants"; +import { importSQL } from "../importSQL"; +import { fromDBML } from "./dbml"; + +/** + * Supported file extensions for diagram import. + */ +export const SUPPORTED_FILE_EXTENSIONS = ["json", "ddb", "dbml", "sql"]; + +/** + * Returns the file extension from a filename (lowercase, without dot). + * @param {string} filename + * @returns {string} + */ +function getFileExtension(filename) { + return (filename.split(".").pop() || "").toLowerCase(); +} + +/** + * Checks whether a file is supported for import based on its extension. + * @param {File} file + * @returns {boolean} + */ +export function isFileSupported(file) { + const ext = getFileExtension(file.name); + return SUPPORTED_FILE_EXTENSIONS.includes(ext); +} + +/** + * Parses a JSON/DDB diagram file and validates its structure. + * Uses a lenient check that accepts any file with the minimum required + * diagram structure (tables + relationships arrays), since drawdb's own + * exports can sometimes fail the strict jsonschema validation. + * @param {string} content - Raw file content + * @param {string} extension - File extension ("json" or "ddb") + * @param {string} database - Current diagram database type + * @returns {{ data: object|null, error: string|null }} + */ +function parseJsonDiagram(content, extension, database) { + let jsonObject; + try { + jsonObject = JSON.parse(content); + } catch { + return { data: null, error: "The file contains invalid JSON." }; + } + + // Check minimal diagram structure: must have tables and relationships arrays + if ( + !jsonObject.tables || + !Array.isArray(jsonObject.tables) || + !jsonObject.relationships || + !Array.isArray(jsonObject.relationships) + ) { + return { + data: null, + error: "The file is missing necessary properties for a diagram.", + }; + } + + if (!jsonObject.database) { + jsonObject.database = DB.GENERIC; + } + + if (jsonObject.database !== database) { + return { + data: null, + error: + "The imported diagram and the open diagram don't use matching databases.", + }; + } + + // Validate relationship references + for (const rel of jsonObject.relationships) { + const startTable = jsonObject.tables.find( + (t) => t.id === rel.startTableId, + ); + const endTable = jsonObject.tables.find((t) => t.id === rel.endTableId); + + if (!startTable || !endTable) { + return { + data: null, + error: `Relationship ${rel.name} references a table that does not exist.`, + }; + } + + if ( + !startTable.fields.find((f) => f.id === rel.startFieldId) || + !endTable.fields.find((f) => f.id === rel.endFieldId) + ) { + return { + data: null, + error: `Relationship ${rel.name} references a field that does not exist.`, + }; + } + } + + return { data: jsonObject, error: null }; +} + +/** + * Parses a DBML file into diagram data. + * @param {string} content - Raw DBML content + * @returns {{ data: object|null, error: string|null }} + */ +function parseDbmlDiagram(content) { + try { + const data = fromDBML(content); + return { data, error: null }; + } catch (err) { + const message = + err.diags && err.diags[0] + ? `${err.diags[0].name} [Ln ${err.diags[0].location.start.line}, Col ${err.diags[0].location.start.column}]: ${err.diags[0].message}` + : err.message || "Failed to parse DBML file."; + return { data: null, error: message }; + } +} + +/** + * Parses a SQL file into diagram data. + * @param {string} content - Raw SQL content + * @param {string} database - Current diagram database type + * @returns {{ data: object|null, error: string|null }} + */ +function parseSqlDiagram(content, database) { + const targetDatabase = database === DB.GENERIC ? DB.MYSQL : database; + + let ast; + try { + if (targetDatabase === DB.ORACLESQL) { + const oracleParser = new OracleParser(); + ast = oracleParser.parse(content); + } else { + const parser = new Parser(); + ast = parser.astify(content, { database: targetDatabase }); + } + } catch (err) { + const message = err.location + ? `${err.name} [Ln ${err.location.start.line}, Col ${err.location.start.column}]: ${err.message}` + : err.message || "Failed to parse SQL file."; + return { data: null, error: message }; + } + + try { + const diagramData = importSQL(ast, targetDatabase, database); + return { data: diagramData, error: null }; + } catch { + return { + data: null, + error: "Failed to convert SQL to diagram. Please check for syntax errors.", + }; + } +} + +/** + * Reads a file and parses it into diagram data based on its extension. + * @param {File} file - The file to import + * @param {string} database - Current diagram database type + * @returns {Promise<{ data: object|null, error: string|null }>} + */ +export function importFromFile(file, database) { + return new Promise((resolve) => { + const extension = getFileExtension(file.name); + + if (!SUPPORTED_FILE_EXTENSIONS.includes(extension)) { + resolve({ + data: null, + error: `Unsupported file type ".${extension}". Supported types: ${SUPPORTED_FILE_EXTENSIONS.join(", ")}`, + }); + return; + } + + const reader = new FileReader(); + + reader.onload = (e) => { + const content = e.target.result; + + switch (extension) { + case "json": + case "ddb": + resolve(parseJsonDiagram(content, extension, database)); + break; + case "dbml": + resolve(parseDbmlDiagram(content)); + break; + case "sql": + resolve(parseSqlDiagram(content, database)); + break; + default: + resolve({ data: null, error: "Unsupported file type." }); + } + }; + + reader.onerror = () => { + resolve({ data: null, error: "Failed to read the file." }); + }; + + reader.readAsText(file); + }); +}