diff --git a/libs/@hashintel/petrinaut/src/views/Editor/lib/export-sdcpn.ts b/libs/@hashintel/petrinaut/src/file-format/export-sdcpn.ts similarity index 73% rename from libs/@hashintel/petrinaut/src/views/Editor/lib/export-sdcpn.ts rename to libs/@hashintel/petrinaut/src/file-format/export-sdcpn.ts index 77ace5ba4d9..b964bbec3bf 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/lib/export-sdcpn.ts +++ b/libs/@hashintel/petrinaut/src/file-format/export-sdcpn.ts @@ -1,8 +1,11 @@ -import type { SDCPN } from "../../../core/types/sdcpn"; +import type { SDCPN } from "../core/types/sdcpn"; import { removeVisualInformation } from "./remove-visual-info"; +import { SDCPN_FILE_FORMAT_VERSION } from "./types"; /** * Saves the SDCPN to a JSON file by triggering a browser download. + * The file includes format metadata (version, meta.generator). + * * @param petriNetDefinition - The SDCPN to save * @param title - The title of the SDCPN * @param removeVisualInfo - If true, removes visual positioning information (x, y) from places and transitions @@ -16,28 +19,31 @@ export function exportSDCPN({ title: string; removeVisualInfo?: boolean; }): void { - // Optionally remove visual information const sdcpnToExport = removeVisualInfo ? removeVisualInformation(petriNetDefinition) : petriNetDefinition; - // Convert SDCPN to JSON string - const jsonString = JSON.stringify({ title, ...sdcpnToExport }, null, 2); + const payload = { + version: SDCPN_FILE_FORMAT_VERSION, + meta: { + generator: "Petrinaut", + }, + title, + ...sdcpnToExport, + }; + + const jsonString = JSON.stringify(payload, null, 2); - // Create a blob from the JSON string const blob = new Blob([jsonString], { type: "application/json" }); - // Create a download link const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${title.replace(/[^a-z0-9]/gi, "_").toLowerCase()}_${new Date().toISOString().replace(/:/g, "-")}.json`; - // Trigger download document.body.appendChild(link); link.click(); - // Cleanup document.body.removeChild(link); URL.revokeObjectURL(url); } diff --git a/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts new file mode 100644 index 00000000000..fad136952c6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/file-format/import-sdcpn.ts @@ -0,0 +1,160 @@ +import type { SDCPN } from "../core/types/sdcpn"; +import { convertPre20251128ToSDCPN } from "./old-formats/pre-2025-11-28/convert"; +import { oldFormatFileSchema } from "./old-formats/pre-2025-11-28/schema"; +import { legacySdcpnFileSchema, sdcpnFileSchema } from "./types"; + +type SDCPNWithTitle = SDCPN & { title: string }; + +/** + * Result of attempting to import an SDCPN file. + */ +export type ImportResult = + | { ok: true; sdcpn: SDCPNWithTitle; hadMissingVisualInfo: boolean } + | { ok: false; error: string }; + +/** + * Checks whether any visual information is missing (positions, color display info). + */ +const hasMissingVisualInfo = (sdcpn: { + places: { x?: number; y?: number }[]; + transitions: { x?: number; y?: number }[]; + types: { iconSlug?: string; displayColor?: string }[]; +}): boolean => { + for (const node of [...sdcpn.places, ...sdcpn.transitions]) { + if (node.x === undefined || node.y === undefined) { + return true; + } + } + for (const type of sdcpn.types) { + if (type.iconSlug === undefined || type.displayColor === undefined) { + return true; + } + } + return false; +}; + +/** + * Fills missing visual information so the SDCPN satisfies the runtime type. + * - Places/transitions at (0, 0) will be laid out by ELK after import. + * - Colors get default iconSlug and displayColor when missing (e.g. exported without visual info). + */ +const fillMissingVisualInfo = (sdcpn: { + title: string; + places: Array<{ x?: number; y?: number }>; + transitions: Array<{ x?: number; y?: number }>; + types: Array<{ iconSlug?: string; displayColor?: string }>; +}): SDCPNWithTitle => + ({ + ...sdcpn, + places: sdcpn.places.map((place) => ({ + ...place, + x: place.x ?? 0, + y: place.y ?? 0, + })), + transitions: sdcpn.transitions.map((transition) => ({ + ...transition, + x: transition.x ?? 0, + y: transition.y ?? 0, + })), + types: sdcpn.types.map((type) => ({ + ...type, + iconSlug: type.iconSlug ?? "circle", + displayColor: type.displayColor ?? "#808080", + })), + }) as SDCPNWithTitle; + +/** + * Parses raw JSON data into an SDCPN, handling versioned, legacy, and old pre-2025-11-28 formats. + */ +export const parseSDCPNFile = (data: unknown): ImportResult => { + // Try the versioned format first + const versioned = sdcpnFileSchema.safeParse(data); + if (versioned.success) { + const { version: _version, meta: _meta, ...sdcpnData } = versioned.data; + const hadMissing = hasMissingVisualInfo(sdcpnData); + return { + ok: true, + sdcpn: fillMissingVisualInfo(sdcpnData), + hadMissingVisualInfo: hadMissing, + }; + } + + // If the data has a `version` field but failed the versioned schema, reject it + // rather than falling through to the legacy path (which would silently accept + // future-versioned files by stripping the unknown `version` key). + if (typeof data === "object" && data !== null && "version" in data) { + return { + ok: false, + error: "Unsupported SDCPN file format version", + }; + } + + // Fall back to legacy format (current schema without version/meta) + const legacy = legacySdcpnFileSchema.safeParse(data); + if (legacy.success) { + const hadMissing = hasMissingVisualInfo(legacy.data); + return { + ok: true, + sdcpn: fillMissingVisualInfo(legacy.data), + hadMissingVisualInfo: hadMissing, + }; + } + + // Try the pre-2025-11-28 old format (different field names like `type`, `iconId`, etc.) + const oldFormat = oldFormatFileSchema.safeParse(data); + if (oldFormat.success) { + const converted = convertPre20251128ToSDCPN(oldFormat.data); + return { + ok: true, + sdcpn: { ...converted, title: oldFormat.data.title }, + hadMissingVisualInfo: false, // old format has positions + }; + } + + return { + ok: false, + error: `Invalid SDCPN file: ${legacy.error.issues.map((i) => i.message).join(", ")}`, + }; +}; + +/** + * Opens a file picker dialog and reads an SDCPN JSON file. + * Returns a promise that resolves with the import result, or null if the user cancelled. + */ +export function importSDCPN(): Promise { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + + input.onchange = (event) => { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) { + resolve(null); + return; + } + + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const content = ev.target?.result as string; + const loadedData: unknown = JSON.parse(content); + resolve(parseSDCPNFile(loadedData)); + } catch (error) { + resolve({ + ok: false, + error: `Error reading file: ${error instanceof Error ? error.message : String(error)}`, + }); + } + }; + + reader.onerror = () => { + resolve({ ok: false, error: "Failed to read file" }); + }; + + reader.readAsText(file); + }; + + input.click(); + }); +} diff --git a/libs/@hashintel/petrinaut/src/old-formats/convert-old-format.ts b/libs/@hashintel/petrinaut/src/file-format/old-formats/convert-old-format.ts similarity index 90% rename from libs/@hashintel/petrinaut/src/old-formats/convert-old-format.ts rename to libs/@hashintel/petrinaut/src/file-format/old-formats/convert-old-format.ts index 2671bbc62b1..ac856ffa7b1 100644 --- a/libs/@hashintel/petrinaut/src/old-formats/convert-old-format.ts +++ b/libs/@hashintel/petrinaut/src/file-format/old-formats/convert-old-format.ts @@ -1,4 +1,4 @@ -import type { SDCPN } from "../core/types/sdcpn"; +import type { SDCPN } from "../../core/types/sdcpn"; import { convertPre20251128ToSDCPN, isPre20251128SDCPN, diff --git a/libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/convert.ts b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/convert.ts similarity index 95% rename from libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/convert.ts rename to libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/convert.ts index 183517d1fdf..999e8bdf1a2 100644 --- a/libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/convert.ts +++ b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/convert.ts @@ -1,4 +1,4 @@ -import type { SDCPN } from "../../core/types/sdcpn"; +import type { SDCPN } from "../../../core/types/sdcpn"; import type { Pre20251128SDCPN } from "./type"; export const isPre20251128SDCPN = ( diff --git a/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/schema.ts b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/schema.ts new file mode 100644 index 00000000000..2952a4c77d4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/schema.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +const arcSchema = z.object({ + placeId: z.string(), + weight: z.number(), +}); + +const oldPlaceSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string().nullable(), + dynamicsEnabled: z.boolean(), + differentialEquationCode: z.object({ refId: z.string() }).nullable(), + visualizerCode: z.string().optional(), + x: z.number(), + y: z.number(), + width: z.number().optional(), + height: z.number().optional(), +}); + +const oldTransitionSchema = z.object({ + id: z.string(), + name: z.string(), + inputArcs: z.array(arcSchema), + outputArcs: z.array(arcSchema), + lambdaType: z.enum(["predicate", "stochastic"]), + lambdaCode: z.string(), + transitionKernelCode: z.string(), + x: z.number(), + y: z.number(), + width: z.number().optional(), + height: z.number().optional(), +}); + +const oldColorElementSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(["real", "integer", "boolean"]), +}); + +const oldColorSchema = z.object({ + id: z.string(), + name: z.string(), + iconId: z.string(), + colorCode: z.string(), + elements: z.array(oldColorElementSchema), +}); + +const oldDifferentialEquationSchema = z.object({ + id: z.string(), + name: z.string(), + typeId: z.string(), + code: z.string(), +}); + +const parameterSchema = z.object({ + id: z.string(), + name: z.string(), + variableName: z.string(), + type: z.enum(["real", "integer", "boolean"]), + defaultValue: z.string(), +}); + +/** + * Schema for the pre-2025-11-28 old format. + * Uses different field names: `type` instead of `colorId`, `differentialEquationCode` + * instead of `differentialEquationId`, `iconId` instead of `iconSlug`, etc. + */ +export const oldFormatFileSchema = z.object({ + id: z.string(), + title: z.string(), + places: z.array(oldPlaceSchema), + transitions: z.array(oldTransitionSchema), + types: z.array(oldColorSchema).default([]), + differentialEquations: z.array(oldDifferentialEquationSchema).default([]), + parameters: z.array(parameterSchema).default([]), +}); diff --git a/libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/type.ts b/libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/type.ts similarity index 100% rename from libs/@hashintel/petrinaut/src/old-formats/pre-2025-11-28/type.ts rename to libs/@hashintel/petrinaut/src/file-format/old-formats/pre-2025-11-28/type.ts diff --git a/libs/@hashintel/petrinaut/src/views/Editor/lib/remove-visual-info.ts b/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts similarity index 89% rename from libs/@hashintel/petrinaut/src/views/Editor/lib/remove-visual-info.ts rename to libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts index 35791d2a647..61744ed3078 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/lib/remove-visual-info.ts +++ b/libs/@hashintel/petrinaut/src/file-format/remove-visual-info.ts @@ -1,9 +1,4 @@ -import type { - Color, - Place, - SDCPN, - Transition, -} from "../../../core/types/sdcpn"; +import type { Color, Place, SDCPN, Transition } from "../core/types/sdcpn"; type SDCPNWithoutVisualInfo = Omit< SDCPN, diff --git a/libs/@hashintel/petrinaut/src/file-format/types.ts b/libs/@hashintel/petrinaut/src/file-format/types.ts new file mode 100644 index 00000000000..a18229bd9f7 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/file-format/types.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +export const SDCPN_FILE_FORMAT_VERSION = 1; + +const arcSchema = z.object({ + placeId: z.string(), + weight: z.number(), +}); + +const placeSchema = z.object({ + id: z.string(), + name: z.string(), + colorId: z.string().nullable(), + dynamicsEnabled: z.boolean(), + differentialEquationId: z.string().nullable(), + visualizerCode: z.string().optional(), + x: z.number().optional(), + y: z.number().optional(), +}); + +const transitionSchema = z.object({ + id: z.string(), + name: z.string(), + inputArcs: z.array(arcSchema), + outputArcs: z.array(arcSchema), + lambdaType: z.enum(["predicate", "stochastic"]), + lambdaCode: z.string(), + transitionKernelCode: z.string(), + x: z.number().optional(), + y: z.number().optional(), +}); + +const colorElementSchema = z.object({ + elementId: z.string(), + name: z.string(), + type: z.enum(["real", "integer", "boolean"]), +}); + +const colorSchema = z.object({ + id: z.string(), + name: z.string(), + iconSlug: z.string().optional(), + displayColor: z.string().optional(), + elements: z.array(colorElementSchema), +}); + +const differentialEquationSchema = z.object({ + id: z.string(), + name: z.string(), + colorId: z.string(), + code: z.string(), +}); + +const parameterSchema = z.object({ + id: z.string(), + name: z.string(), + variableName: z.string(), + type: z.enum(["real", "integer", "boolean"]), + defaultValue: z.string(), +}); + +const sdcpnSchema = z.object({ + places: z.array(placeSchema), + transitions: z.array(transitionSchema), + types: z.array(colorSchema).default([]), + differentialEquations: z.array(differentialEquationSchema).default([]), + parameters: z.array(parameterSchema).default([]), +}); + +const fileMetaSchema = z.object({ + generator: z.string(), + generatorVersion: z.string().optional(), +}); + +/** + * Schema for the versioned SDCPN file format (v1+). + * Includes format metadata (version, meta.generator) alongside the SDCPN data. + */ +export const sdcpnFileSchema = sdcpnSchema.extend({ + version: z.number().int().min(1).max(SDCPN_FILE_FORMAT_VERSION), + meta: fileMetaSchema, + title: z.string(), +}); + +/** + * Schema for the legacy file format (no version/meta, just title + SDCPN data). + */ +export const legacySdcpnFileSchema = sdcpnSchema.extend({ + title: z.string(), +}); diff --git a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts index 94605dca1c8..e3284db576b 100644 --- a/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts +++ b/libs/@hashintel/petrinaut/src/lib/calculate-graph-layout.ts @@ -33,7 +33,11 @@ export type NodePosition = { * It does not mutate any state or trigger side effects. * * @param sdcpn - The SDCPN to layout - * @returns A promise that resolves to an array of node positions + * @param dims - Node dimensions for places and transitions + * @param options.onlyMissingPositions - When true, only nodes with x=0 and y=0 will receive new positions. + * Nodes that already have non-zero positions are included in the layout graph (so ELK can route around them) + * but their returned positions are excluded from the result. + * @returns A promise that resolves to a map of node IDs to their calculated positions */ export const calculateGraphLayout = async ( sdcpn: SDCPN, @@ -41,11 +45,31 @@ export const calculateGraphLayout = async ( place: { width: number; height: number }; transition: { width: number; height: number }; }, + options?: { onlyMissingPositions?: boolean }, ): Promise> => { if (sdcpn.places.length === 0) { return {}; } + // Track which nodes need positions (have x=0 and y=0) + const needsPosition = new Set(); + if (options?.onlyMissingPositions) { + for (const place of sdcpn.places) { + if (place.x === 0 && place.y === 0) { + needsPosition.add(place.id); + } + } + for (const transition of sdcpn.transitions) { + if (transition.x === 0 && transition.y === 0) { + needsPosition.add(transition.id); + } + } + + if (needsPosition.size === 0) { + return {}; + } + } + // Build ELK nodes from places and transitions const elkNodes: ElkNode["children"] = [ ...sdcpn.places.map((place) => ({ @@ -99,9 +123,15 @@ export const calculateGraphLayout = async ( const positionsByNodeId: Record = {}; for (const child of updatedElements.children ?? []) { if (child.x !== undefined && child.y !== undefined) { + // When onlyMissingPositions is set, skip nodes that already have positions + if (options?.onlyMissingPositions && !needsPosition.has(child.id)) { + continue; + } + const nodeDimensions = placeIds.has(child.id) ? dimensions.place : dimensions.transition; + positionsByNodeId[child.id] = { x: child.x + nodeDimensions.width / 2, y: child.y + nodeDimensions.height / 2, diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index e23a3da809e..b332a673c05 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -1,8 +1,8 @@ export type { ErrorTracker } from "./error-tracker/error-tracker.context"; export { ErrorTrackerContext } from "./error-tracker/error-tracker.context"; -export type { OldFormat } from "./old-formats/convert-old-format"; +export type { OldFormat } from "./file-format/old-formats/convert-old-format"; export { convertOldFormatToSDCPN, isOldFormat, -} from "./old-formats/convert-old-format"; +} from "./file-format/old-formats/convert-old-format"; export * from "./petrinaut"; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/import-error-dialog.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/import-error-dialog.tsx new file mode 100644 index 00000000000..ebd6967be50 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/import-error-dialog.tsx @@ -0,0 +1,53 @@ +import { css } from "@hashintel/ds-helpers/css"; + +import { Button } from "../../../components/button"; +import { Dialog, type DialogRootProps } from "../../../components/dialog"; + +const errorTextStyle = css({ + fontSize: "sm", + color: "neutral.s90", + lineHeight: "[1.5]", + whiteSpace: "pre-wrap", + wordBreak: "break-word", +}); + +export const ImportErrorDialog = ({ + open, + onOpenChange, + errorMessage, + onCreateEmpty, +}: { + open: boolean; + onOpenChange: DialogRootProps["onOpenChange"]; + errorMessage: string; + onCreateEmpty: () => void; +}) => ( + + + + + Import Error + + +

{errorMessage}

+
+
+ + + + + + + + +
+
+); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 487dc69981e..14f1694ead5 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -1,5 +1,5 @@ import { css, cx } from "@hashintel/ds-helpers/css"; -import { use, useRef } from "react"; +import { use, useRef, useState } from "react"; import { Box } from "../../components/box"; import { Stack } from "../../components/stack"; @@ -9,18 +9,24 @@ import { probabilisticSatellitesSDCPN } from "../../examples/satellites-launcher import { sirModel } from "../../examples/sir-model"; import { supplyChainSDCPN } from "../../examples/supply-chain"; import { supplyChainStochasticSDCPN } from "../../examples/supply-chain-stochastic"; -import { convertOldFormatToSDCPN } from "../../old-formats/convert-old-format"; +import { exportSDCPN } from "../../file-format/export-sdcpn"; +import { importSDCPN } from "../../file-format/import-sdcpn"; +import { calculateGraphLayout } from "../../lib/calculate-graph-layout"; import { EditorContext } from "../../state/editor-context"; import { PortalContainerContext } from "../../state/portal-container-context"; import { SDCPNContext } from "../../state/sdcpn-context"; import { useSelectionCleanup } from "../../state/use-selection-cleanup"; import type { ViewportAction } from "../../types/viewport-action"; +import { UserSettingsContext } from "../../state/user-settings-context"; import { SDCPNView } from "../SDCPN/sdcpn-view"; +import { + classicNodeDimensions, + compactNodeDimensions, +} from "../SDCPN/styles/styling"; import { BottomBar } from "./components/BottomBar/bottom-bar"; +import { ImportErrorDialog } from "./components/import-error-dialog"; import { TopBar } from "./components/TopBar/top-bar"; -import { exportSDCPN } from "./lib/export-sdcpn"; import { exportTikZ } from "./lib/export-tikz"; -import { importSDCPN } from "./lib/import-sdcpn"; import { BottomPanel } from "./panels/BottomPanel/panel"; import { LeftSideBar } from "./panels/LeftSideBar/panel"; import { PropertiesPanel } from "./panels/PropertiesPanel/panel"; @@ -85,10 +91,15 @@ export const EditorView = ({ clearSelection, } = use(EditorContext); + const { compactNodes } = use(UserSettingsContext); + const dims = compactNodes ? compactNodeDimensions : classicNodeDimensions; + + const [importError, setImportError] = useState(null); + // Clean up stale selections when items are deleted useSelectionCleanup(); - function handleNew() { + function handleCreateEmpty() { createNewNet({ title: "Untitled", petriNetDefinition: { @@ -102,6 +113,10 @@ export const EditorView = ({ clearSelection(); } + function handleNew() { + handleCreateEmpty(); + } + function handleExport() { exportSDCPN({ petriNetDefinition, title }); } @@ -114,16 +129,52 @@ export const EditorView = ({ exportTikZ({ petriNetDefinition, title }); } - function handleImport() { - importSDCPN((loadedSDCPN) => { - const convertedSdcpn = convertOldFormatToSDCPN(loadedSDCPN); + async function handleImport() { + const result = await importSDCPN(); + if (!result) { + return; // User cancelled file picker + } + + if (!result.ok) { + setImportError(result.error); + return; + } + + const { sdcpn: loadedSDCPN, hadMissingVisualInfo } = result; + let sdcpnToLoad = loadedSDCPN; - createNewNet({ - title: loadedSDCPN.title, - petriNetDefinition: convertedSdcpn ?? loadedSDCPN, + // If any nodes were missing positions, run ELK layout BEFORE creating the net. + // We must do this before createNewNet because after createNewNet triggers a + // re-render, the mutatePetriNetDefinition closure would be stale. + if (hadMissingVisualInfo) { + const positions = await calculateGraphLayout(sdcpnToLoad, dims, { + onlyMissingPositions: true, }); - clearSelection(); + + if (Object.keys(positions).length > 0) { + sdcpnToLoad = { + ...sdcpnToLoad, + places: sdcpnToLoad.places.map((place) => { + const position = positions[place.id]; + return position + ? { ...place, x: position.x, y: position.y } + : place; + }), + transitions: sdcpnToLoad.transitions.map((transition) => { + const position = positions[transition.id]; + return position + ? { ...transition, x: position.x, y: position.y } + : transition; + }), + }; + } + } + + createNewNet({ + title: loadedSDCPN.title, + petriNetDefinition: sdcpnToLoad, }); + clearSelection(); } const menuItems = [ @@ -242,6 +293,17 @@ export const EditorView = ({
+ { + if (!open) { + setImportError(null); + } + }} + errorMessage={importError ?? ""} + onCreateEmpty={handleCreateEmpty} + /> + {/* Top Bar - always visible */} { - return ( - typeof data === "object" && - data !== null && - "title" in data && - "places" in data && - "transitions" in data && - Array.isArray(data.places) && - Array.isArray(data.transitions) - ); -}; - -/** - * Opens a file picker dialog and loads an SDCPN from a JSON file. - * @param onLoad - Callback function called with the loaded SDCPN - * @param onError - Callback function called if there's an error - */ -export function importSDCPN( - onLoad: (sdcpn: SDCPNWithTitle) => void, - onError?: (error: string) => void, -): void { - // Create a file input element - const input = document.createElement("input"); - input.type = "file"; - input.accept = ".json"; - - input.onchange = (event) => { - const file = (event.target as HTMLInputElement).files?.[0]; - if (!file) { - return; - } - - // Read the file - const reader = new FileReader(); - reader.onload = (ev) => { - try { - const content = ev.target?.result as string; - const loadedData: unknown = JSON.parse(content); - - // Type guard to validate SDCPN structure - if (isValidJson(loadedData)) { - onLoad(loadedData); - } else { - const errorMessage = "Invalid SDCPN file format"; - if (onError) { - onError(errorMessage); - } else { - // eslint-disable-next-line no-alert - alert(errorMessage); - } - } - } catch (error) { - const errorMessage = `Error loading file: ${error instanceof Error ? error.message : String(error)}`; - if (onError) { - onError(errorMessage); - } else { - // eslint-disable-next-line no-alert - alert(errorMessage); - } - } - }; - - reader.readAsText(file); - }; - - // Trigger file selection - input.click(); -}