From 60b6fbe22646230759f23764917c1ca9f44dd771 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Sat, 25 Oct 2025 03:53:27 +0300 Subject: [PATCH] Support to import and export diagram layout with custom JSON-serializable types derived from `Element` or `Link` : * Introduce an optional contract for `Element` or `Link` derived cell types to be serializable: `SerializableElementCell` and `SerializableLinkCell`; * When implemented, the corresponding cell types can be exported and later imported with the diagram; * `DataDiagramModel.importLayout()` will accept known cell types via `elementCellTypes` and `linkCellTypes` to import. --- CHANGELOG.md | 4 + examples/resources/common.tsx | 16 +- src/editor/dataDiagramModel.ts | 54 +++++- src/editor/dataElements.ts | 235 ++++++++++++++++++++++ src/editor/serializedDiagram.ts | 332 ++++++++++++++------------------ src/workspace.ts | 6 +- vitest.config.mts | 4 +- 7 files changed, 458 insertions(+), 193 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 945785a2..b3adb41a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p * Add `CanvasPlaceAt` component to render its children at specified non-viewport canvas layer instead; * Support new placement layers: `underlay` layer to place components under all canvas content, `overLinkGeometry` layer to place components above link geometry (connections) but under link labels; * **[šŸ’„Breaking]** Remove `defineCanvasWidget()` and `SharedCanvasState.setCanvasWidget()` (use `CanvasPlaceAt` to display components at canvas layers instead). +- Support to import and export diagram layout with custom element and link cell types (derived from `Element` or `Link`): + * Introduce an optional contract for `Element` or `Link`-derived cell types to be serializable: `SerializableElementCell` and `SerializableLinkCell`; + * When implemented, the corresponding cell types can be exported and later imported with the diagram; + * `DataDiagramModel.importLayout()` will accept known cell types via `elementCellTypes` and `linkCellTypes` to import. - Add `EditorController.applyAuthoringChanges()` method to apply current authoring changes to the diagram (i.e. change entity data, delete relations, etc) and reset the change state to be empty. #### ā± Performance diff --git a/examples/resources/common.tsx b/examples/resources/common.tsx index 6cc5b93f..73593c40 100644 --- a/examples/resources/common.tsx +++ b/examples/resources/common.tsx @@ -1,3 +1,4 @@ +import { HashMap } from '@reactodia/hashmap'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { saveAs } from 'file-saver'; @@ -35,8 +36,18 @@ export function ExampleToolbarMenu() { onSelect={async file => { const preloadedElements = new Map(); for (const element of model.elements) { - if (element instanceof Reactodia.EntityElement) { - preloadedElements.set(element.iri, element.data); + for (const entity of Reactodia.iterateEntitiesOf(element)) { + preloadedElements.set(entity.id, entity); + } + } + + const preloadedLinks = new HashMap( + Reactodia.hashLink, + Reactodia.equalLinks + ); + for (const link of model.links) { + for (const relation of Reactodia.iterateRelationsOf(link)) { + preloadedLinks.set(relation, relation); } } @@ -48,6 +59,7 @@ export function ExampleToolbarMenu() { dataProvider: model.dataProvider, diagram: diagramLayout, preloadedElements, + preloadedLinks, validateLinks: true, }); } catch (err) { diff --git a/src/editor/dataDiagramModel.ts b/src/editor/dataDiagramModel.ts index 930fe9c9..af47bb47 100644 --- a/src/editor/dataDiagramModel.ts +++ b/src/editor/dataDiagramModel.ts @@ -1,9 +1,11 @@ +import type { ReadonlyHashMap } from '@reactodia/hashmap'; + import { AbortScope } from '../coreUtils/async'; import { AnyEvent, EventSource, Events } from '../coreUtils/events'; import { Translation, TranslatedText } from '../coreUtils/i18n'; import { - ElementIri, ElementModel, ElementTypeIri, LinkModel, LinkTypeModel, + ElementIri, ElementModel, ElementTypeIri, LinkKey, LinkModel, LinkTypeModel, LinkTypeIri, PropertyTypeIri, equalLinks, } from '../data/model'; import { EmptyDataProvider } from '../data/decorated/emptyDataProvider'; @@ -34,6 +36,7 @@ import { type DataLocaleProvider, DefaultDataLocaleProvider } from './dataLocale import { SerializedDiagram, SerializedLinkOptions, emptyDiagram, serializeDiagram, deserializeDiagram, markLayoutOnly, + SerializableElementCell, SerializableLinkCell, } from './serializedDiagram'; import { DataGraph } from './dataGraph'; @@ -316,10 +319,35 @@ export class DataDiagramModel extends DiagramModel implements DataGraphStructure */ diagram?: SerializedDiagram; /** - * Pre-cached data for the elements which should be used instead of + * Element cell types to deserialize from the imported diagram state. + * + * Any element cell type not from this list will be silently ignored. + * + * **Unstable**: this feature is likely to be changed in the future. + * + * @default [EntityElement, EntityGroup] + */ + elementCellTypes?: readonly SerializableElementCell[]; + /** + * Link cell types to deserialize from the imported diagram state. + * + * Any link cell type not from this list will be silently ignored. + * + * **Unstable**: this feature is likely to be changed in the future. + * + * @default [RelationLink, RelationGroup] + */ + linkCellTypes?: readonly SerializableLinkCell[]; + /** + * Pre-cached data for the entities which should be used instead of * being requested from the data provider on import. */ preloadedElements?: ReadonlyMap; + /** + * Pre-cached data for the relations which should be used instead of + * being requested from the data provider on import. + */ + preloadedLinks?: ReadonlyHashMap; /** * Whether links for the between imported elements should be requested * from the data provider on import. @@ -343,7 +371,10 @@ export class DataDiagramModel extends DiagramModel implements DataGraphStructure dataProvider, locale, diagram = emptyDiagram(), + elementCellTypes = [EntityElement, EntityGroup], + linkCellTypes = [RelationLink, RelationGroup], preloadedElements, + preloadedLinks, validateLinks = false, hideUnusedLinkTypes = false, signal: parentSignal, @@ -368,7 +399,10 @@ export class DataDiagramModel extends DiagramModel implements DataGraphStructure this.createGraphElements({ diagram, + elementCellTypes, + linkCellTypes, preloadedElements, + preloadedLinks, markLinksAsLayoutOnly: validateLinks, }); @@ -477,16 +511,28 @@ export class DataDiagramModel extends DiagramModel implements DataGraphStructure private createGraphElements(params: { diagram: SerializedDiagram; + elementCellTypes: readonly SerializableElementCell[]; + linkCellTypes: readonly SerializableLinkCell[]; preloadedElements?: ReadonlyMap; + preloadedLinks?: ReadonlyHashMap; markLinksAsLayoutOnly: boolean; }): void { - const {diagram, preloadedElements, markLinksAsLayoutOnly} = params; + const { + diagram, elementCellTypes, linkCellTypes, + preloadedElements, preloadedLinks, markLinksAsLayoutOnly, + } = params; const { elements, links, linkTypeVisibility, - } = deserializeDiagram(diagram, {preloadedElements, markLinksAsLayoutOnly}); + } = deserializeDiagram(diagram, { + elementCellTypes, + linkCellTypes, + preloadedElements, + preloadedLinks, + markLinksAsLayoutOnly, + }); const batch = this.history.startBatch( TranslatedText.text('data_diagram_model.import_layout.command') diff --git a/src/editor/dataElements.ts b/src/editor/dataElements.ts index 620bea8d..f739611b 100644 --- a/src/editor/dataElements.ts +++ b/src/editor/dataElements.ts @@ -18,6 +18,11 @@ import { import { Command } from '../diagram/history'; import { DiagramModel } from '../diagram/model'; +import type { + SerializedElement, SerializableElementCell, ElementFromJsonOptions, + SerializedLink, SerializableLinkCell, LinkFromJsonOptions, +} from './serializedDiagram'; + /** * Event data for {@link EntityElement} events. * @@ -84,6 +89,51 @@ export class EntityElement extends Element { this.entitySource.trigger('changeData', {source: this, previous}); this.entitySource.trigger('requestedRedraw', {source: this, level: 'template'}); } + + static readonly fromJSONType = 'Element'; + + static fromJSON( + state: SerializedEntityElement, + options: ElementFromJsonOptions + ): EntityElement | undefined { + const {'@id': id, iri, position, isExpanded, elementState} = state; + if (iri) { + const initialData = options.getInitialData(iri); + return new EntityElement({ + id, + data: initialData ?? EntityElement.placeholderData(iri), + position, + expanded: isExpanded, + elementState: options.mapTemplateState(elementState), + }); + } + return undefined; + } + + toJSON(): SerializedEntityElement { + return { + '@type': 'Element', + '@id': this.id, + iri: this.iri, + position: this.position, + elementState: this.elementState, + }; + } +} + +EntityElement satisfies SerializableElementCell; + +/** + * Serialized entity element state. + */ +export interface SerializedEntityElement extends SerializedElement { + '@type': 'Element'; + iri?: ElementIri; + /** + * @deprecated only deserialized to {@link TemplateProperties.Expanded} + * in {@link elementState} for compatibility + */ + isExpanded?: boolean; } /** @@ -167,8 +217,42 @@ export class EntityGroup extends Element { this._itemIris.add(item.data.id); } } + + static readonly fromJSONType = 'ElementGroup'; + + static fromJSON( + state: SerializedEntityGroup, + options: ElementFromJsonOptions + ): EntityGroup | undefined { + const {'@id': id, items, position, elementState} = state; + const groupItems: EntityGroupItem[] = []; + for (const item of items) { + const initialData = options.getInitialData(item.iri); + groupItems.push({ + data: initialData ?? EntityElement.placeholderData(item.iri), + elementState: options.mapTemplateState(item.elementState), + }); + } + return new EntityGroup({id, items: groupItems, position, elementState}); + } + + toJSON(): SerializedEntityGroup { + return { + '@type': 'ElementGroup', + '@id': this.id, + items: this.items.map((item): SerializedEntityGroupItem => ({ + '@type': 'ElementItem', + iri: item.data.id, + elementState: item.elementState, + })), + position: this.position, + elementState: this.elementState, + }; + } } +EntityGroup satisfies SerializableElementCell; + /** * Represents a single entity contained in the entity group. * @@ -179,6 +263,23 @@ export interface EntityGroupItem { readonly elementState?: ElementTemplateState | undefined; } +/** + * Serialized entity group state. + */ +export interface SerializedEntityGroup extends SerializedElement { + '@type': 'ElementGroup'; + items: ReadonlyArray; +} + +/** + * Serialized entity group item state. + */ +export interface SerializedEntityGroupItem { + '@type': 'ElementItem'; + iri: ElementIri; + elementState?: ElementTemplateState; +} + /** * Command to set {@link EntityGroup.items entity group items}. * @@ -276,6 +377,68 @@ export class RelationLink extends Link { ? this.targetId : this.sourceId; return new RelationLink({sourceId, targetId, data}); } + + static readonly fromJSONType = 'Link'; + + static fromJSON( + state: SerializedRelationLink, + options: LinkFromJsonOptions + ): RelationLink | undefined { + const {'@id': id, property, vertices, linkState} = state; + const {source, target} = options; + + const sourceIri = state.sourceIri ?? ( + source instanceof EntityElement ? source.data.id : undefined + ); + const targetIri = state.targetIri ?? ( + target instanceof EntityElement ? target.data.id : undefined + ); + if (sourceIri && targetIri) { + const key: LinkModel = { + linkTypeId: property, + sourceId: sourceIri, + targetId: targetIri, + properties: {}, + }; + const initialData = options.getInitialData(key); + return new RelationLink({ + id, + sourceId: source.id, + targetId: target.id, + data: initialData ?? key, + vertices, + linkState: options.mapTemplateState(linkState), + }); + } + + return undefined; + } + + toJSON(): SerializedRelationLink { + return { + '@type': 'Link', + '@id': this.id, + property: this.typeId, + source: {'@id': this.sourceId}, + target: {'@id': this.targetId}, + sourceIri: this.data.sourceId, + targetIri: this.data.targetId, + vertices: [...this.vertices], + linkState: this.linkState, + }; + } +} + +RelationLink satisfies SerializableLinkCell; + +/** + * Serialized relation link state. + */ +export interface SerializedRelationLink extends SerializedLink { + '@type': 'Link'; + property: LinkTypeIri; + targetIri?: ElementIri; + sourceIri?: ElementIri; } /** @@ -386,8 +549,61 @@ export class RelationGroup extends Link { this._targets.add(item.data.targetId); } } + + static readonly fromJSONType = 'LinkGroup'; + + static fromJSON( + state: SerializedRelationGroup, + options: LinkFromJsonOptions + ): RelationGroup | undefined { + const {'@id': id, property, vertices, linkState} = state; + const {source, target} = options; + const groupItems: RelationGroupItem[] = []; + for (const item of state.items) { + const key: LinkModel = { + linkTypeId: state.property, + sourceId: item.sourceIri, + targetId: item.targetIri, + properties: {}, + }; + const initialData = options.getInitialData(key); + groupItems.push({ + data: initialData ?? key, + linkState: options.mapTemplateState(item.linkState), + }); + } + return new RelationGroup({ + id, + typeId: property, + sourceId: source.id, + targetId: target.id, + items: groupItems, + vertices, + linkState: options.mapTemplateState(linkState), + }); + } + + toJSON(): SerializedRelationGroup { + return { + '@type': 'LinkGroup', + '@id': this.id, + property: this.typeId, + source: {'@id': this.sourceId}, + target: {'@id': this.targetId}, + items: this.items.map((item): SerializedRelationGroupItem => ({ + '@type': 'LinkItem', + sourceIri: item.data.sourceId, + targetIri: item.data.targetId, + linkState: item.linkState, + })), + vertices: [...this.vertices], + linkState: this.linkState, + }; + } } +RelationGroup satisfies SerializableLinkCell; + /** * Represents a single relation contained in the relation group. * @@ -398,6 +614,25 @@ export interface RelationGroupItem { readonly linkState?: LinkTemplateState | undefined; } +/** + * Serialized relation group state. + */ +export interface SerializedRelationGroup extends SerializedLink { + '@type': 'LinkGroup'; + property: LinkTypeIri; + items: ReadonlyArray; +} + +/** + * Serialized relation group item state. + */ +export interface SerializedRelationGroupItem { + '@type': 'LinkItem'; + targetIri: ElementIri; + sourceIri: ElementIri; + linkState?: LinkTemplateState; +} + /** * Command to set {@link RelationGroup.items relation group items}. * diff --git a/src/editor/serializedDiagram.ts b/src/editor/serializedDiagram.ts index 6fee9db0..62f605b1 100644 --- a/src/editor/serializedDiagram.ts +++ b/src/editor/serializedDiagram.ts @@ -1,14 +1,11 @@ -import { ElementIri, ElementModel, LinkTypeIri } from '../data/model'; +import type { ReadonlyHashMap } from '@reactodia/hashmap'; + +import { ElementIri, ElementModel, LinkKey, LinkModel, LinkTypeIri } from '../data/model'; import { DiagramContextV1, PlaceholderRelationType, TemplateProperties } from '../data/schema'; import { Element, ElementTemplateState, Link, LinkTemplateState, LinkTypeVisibility } from '../diagram/elements'; import { Vector } from '../diagram/geometry'; -import { - EntityElement, EntityGroup, EntityGroupItem, - RelationLink, RelationGroup, RelationGroupItem, -} from './dataElements'; - /** * Serialized diagram state in [JSON-LD](https://json-ld.org/) compatible format. * @@ -36,85 +33,120 @@ export interface SerializedLinkOptions { */ export interface SerializedLayout { '@type': 'Layout'; - elements: ReadonlyArray; - links: ReadonlyArray; + elements: ReadonlyArray; + links: ReadonlyArray; } +type SerializedState = T extends { toJSON(): infer S } ? Exclude : never; + +type JsonableElement = Element & { toJSON(): SerializedElement }; + /** - * Serialized entity element state. + * Static interface (contract) for serializable graph element + * classes derived from {@link Element}. + * + * **Example**: + * ```ts + * class MyElement extends Reactodia.Element { + * ... + * static readonly fromJSONType = 'MyElement'; + * static fromJSON(state: SerializedMyElement): MyElement | undefined { + * ... + * } + * toJSON(): SerializedMyElement { + * ... + * } + * } + * + * interface SerializedMyElement extends Reactodia.SerializedElement { + * '@type': 'MyElement'; + * ... + * } + * + * MyElement satisfies SerializableElementCell; + * ``` */ -export interface SerializedLayoutElement { - '@type': 'Element'; - '@id': string; - iri?: ElementIri; - position: Vector; - /** - * @deprecated only deserialized to {@link TemplateProperties.Expanded} - * in {@link elementState} for compatibility - */ - isExpanded?: boolean; - elementState?: ElementTemplateState; +export interface SerializableElementCell { + new (...args: any[]): T; + readonly fromJSONType: SerializedState['@type']; + fromJSON(state: SerializedState, options: ElementFromJsonOptions): T | undefined; +} + +/** + * Options for {@link SerializableElementCell.fromJSON} method. + */ +export interface ElementFromJsonOptions { + readonly getInitialData: (iri: ElementIri) => ElementModel | undefined; + readonly mapTemplateState: + (from: ElementTemplateState | undefined) => ElementTemplateState | undefined; } /** - * Serialized entity group state. + * Serialized graph element state. */ -export interface SerializedLayoutElementGroup { - '@type': 'ElementGroup'; +export interface SerializedElement { + '@type': string; '@id': string; - items: ReadonlyArray; position: Vector; elementState?: ElementTemplateState; } +type JsonableLink = Link & { toJSON(): SerializedLink }; + /** - * Serialized entity group item state. + * Static interface (contract) for serializable graph link + * classes derived from {@link Link}. + * + * **Example**: + * ```ts + * class MyLink extends Reactodia.Link { + * ... + * static readonly fromJSONType = 'MyLink'; + * static fromJSON(state: SerializedMyLink): MyLink | undefined { + * ... + * } + * toJSON(): SerializedMyLink { + * ... + * } + * } + * + * interface SerializedMyLink extends Reactodia.SerializedLink { + * '@type': 'MyLink'; + * ... + * } + * + * MyLink satisfies SerializableLinkCell; + * ``` */ -export interface SerializedLayoutElementItem { - '@type': 'ElementItem'; - iri: ElementIri; - elementState?: ElementTemplateState; +export interface SerializableLinkCell { + new (...args: any[]): T; + readonly fromJSONType: SerializedState['@type']; + fromJSON(state: SerializedState, options: LinkFromJsonOptions): T | undefined; } /** - * Serialized relation link state. + * Options for {@link SerializableLinkCell.fromJSON} method. */ -export interface SerializedLayoutLink { - '@type': 'Link'; - '@id': string; - property: LinkTypeIri; - source: { '@id': string }; - target: { '@id': string }; - targetIri?: ElementIri; - sourceIri?: ElementIri; - vertices?: ReadonlyArray; - linkState?: LinkTemplateState; +export interface LinkFromJsonOptions { + readonly source: Element; + readonly target: Element; + readonly getInitialData: (key: LinkKey) => LinkModel | undefined; + readonly mapTemplateState: + (from: LinkTemplateState | undefined) => LinkTemplateState | undefined; } /** - * Serialized relation group state. + * Serialized graph link state. */ -export interface SerializedLayoutLinkGroup { - '@type': 'LinkGroup'; +export interface SerializedLink { + '@type': string; '@id': string; - property: LinkTypeIri; source: { '@id': string }; target: { '@id': string }; - items: ReadonlyArray; vertices?: ReadonlyArray; linkState?: LinkTemplateState; } -/** - * Serialized relation group item state. - */ -export interface SerializedLayoutLinkItem { - '@type': 'LinkItem'; - targetIri: ElementIri; - sourceIri: ElementIri; - linkState?: LinkTemplateState; -} - /** * Makes an empty serialized diagram state. */ @@ -176,62 +208,26 @@ function serializeLayout( modelElements: ReadonlyArray, modelLinks: ReadonlyArray, ): SerializedLayout { - const elements: Array = []; + const elements: Array = []; for (const element of modelElements) { - if (element instanceof EntityGroup) { - elements.push({ - '@type': 'ElementGroup', - '@id': element.id, - items: element.items.map((item): SerializedLayoutElementItem => ({ - '@type': 'ElementItem', - iri: item.data.id, - elementState: item.elementState, - })), - position: element.position, - elementState: element.elementState, - }); - } else { - elements.push({ - '@type': 'Element', - '@id': element.id, - iri: element instanceof EntityElement ? element.iri : undefined, - position: element.position, - elementState: element.elementState, - }); + if (hasToJSON(element)) { + const state = element.toJSON(); + if (isValidSerializedState(state)) { + elements.push(state as SerializedElement); + } } } - const links: Array = []; + + const links: Array = []; for (const link of modelLinks) { - if (link instanceof RelationGroup) { - links.push({ - '@type': 'LinkGroup', - '@id': link.id, - property: link.typeId, - source: {'@id': link.sourceId}, - target: {'@id': link.targetId}, - items: link.items.map((item): SerializedLayoutLinkItem => ({ - '@type': 'LinkItem', - sourceIri: item.data.sourceId, - targetIri: item.data.targetId, - linkState: item.linkState, - })), - vertices: [...link.vertices], - linkState: link.linkState, - }); - } else { - links.push({ - '@type': 'Link', - '@id': link.id, - property: link.typeId, - source: {'@id': link.sourceId}, - target: {'@id': link.targetId}, - sourceIri: link instanceof RelationLink ? link.data.sourceId : undefined, - targetIri: link instanceof RelationLink ? link.data.targetId : undefined, - vertices: [...link.vertices], - linkState: link.linkState, - }); + if (hasToJSON(link)) { + const state = link.toJSON(); + if (isValidSerializedState(state)) { + links.push(state as SerializedLink); + } } } + return {'@type': 'Layout', elements, links}; } @@ -239,7 +235,10 @@ function serializeLayout( * Options for diagram deserialization. */ export interface DeserializeDiagramOptions { + readonly elementCellTypes: readonly SerializableElementCell[]; + readonly linkCellTypes: readonly SerializableLinkCell[]; readonly preloadedElements?: ReadonlyMap; + readonly preloadedLinks?: ReadonlyHashMap; readonly markLinksAsLayoutOnly?: boolean; } @@ -250,7 +249,7 @@ export interface DeserializeDiagramOptions { */ export function deserializeDiagram( diagram: SerializedDiagram, - options: DeserializeDiagramOptions = {} + options: DeserializeDiagramOptions ): DeserializedDiagram { const {layoutData, linkTypeOptions} = diagram; const linkTypeVisibility = new Map(); @@ -279,42 +278,40 @@ function deserializeLayout( layout: SerializedLayout, options: DeserializeDiagramOptions ): DeserializedLayout { - const {preloadedElements, markLinksAsLayoutOnly = false} = options; + const {preloadedElements, preloadedLinks, markLinksAsLayoutOnly = false} = options; + + const typeToElement = new Map(); + for (const elementCellType of options.elementCellTypes) { + typeToElement.set(elementCellType.fromJSONType, elementCellType); + } + const elementOptions: ElementFromJsonOptions = { + getInitialData: iri => preloadedElements?.get(iri), + mapTemplateState: state => state, + }; + + const typeToLink = new Map(); + for (const linkCellType of options.linkCellTypes) { + typeToLink.set(linkCellType.fromJSONType, linkCellType); + } + const getInitialLinkData = (key: LinkKey) => preloadedLinks?.get(key); + const mapLinkTemplateState = (state: LinkTemplateState | undefined) => + markLayoutOnly(state, markLinksAsLayoutOnly); + const elements = new Map(); const links: Link[] = []; for (const layoutElement of layout.elements) { - switch (layoutElement['@type']) { - case 'Element': { - const {'@id': id, iri, position, isExpanded, elementState} = layoutElement; - if (iri) { - const preloadedData = preloadedElements?.get(iri); - const data = preloadedData ?? EntityElement.placeholderData(iri); - const element = new EntityElement({id, data, position, expanded: isExpanded, elementState}); - elements.set(element.id, element); - } - break; - } - case 'ElementGroup': { - const {'@id': id, items, position, elementState} = layoutElement; - const groupItems: EntityGroupItem[] = []; - for (const item of items) { - const preloadedData = preloadedElements?.get(item.iri); - groupItems.push({ - data: preloadedData ?? EntityElement.placeholderData(item.iri), - elementState: item.elementState, - }); - } - const group = new EntityGroup({id, items: groupItems, position, elementState}); - elements.set(group.id, group); - break; + const elementClass = typeToElement.get(layoutElement['@type']); + if (elementClass) { + const element = elementClass.fromJSON(layoutElement, elementOptions); + if (element) { + elements.set(element.id, element); } } - } for (const layoutLink of layout.links) { - const {'@id': id, property, source, target, vertices, linkState} = layoutLink; + const {source, target} = layoutLink; const sourceElement = elements.get(source['@id']); const targetElement = elements.get(target['@id']); @@ -322,56 +319,16 @@ function deserializeLayout( continue; } - switch (layoutLink['@type']) { - case 'Link': { - const sourceIri = layoutLink.sourceIri ?? ( - sourceElement instanceof EntityElement ? sourceElement.data.id : undefined - ); - const targetIri = layoutLink.targetIri ?? ( - targetElement instanceof EntityElement ? targetElement.data.id : undefined - ); - if (sourceElement && targetElement && sourceIri && targetIri) { - const link = new RelationLink({ - id, - sourceId: sourceElement.id, - targetId: targetElement.id, - data: { - linkTypeId: property, - sourceId: sourceIri, - targetId: targetIri, - properties: {}, - }, - vertices, - linkState: markLayoutOnly(linkState, markLinksAsLayoutOnly), - }); - links.push(link); - } - break; - } - case 'LinkGroup': { - const groupItems: RelationGroupItem[] = []; - for (const item of layoutLink.items) { - groupItems.push({ - data: { - linkTypeId: property, - sourceId: item.sourceIri, - targetId: item.targetIri, - properties: {}, - }, - linkState: markLayoutOnly(item.linkState, markLinksAsLayoutOnly), - }); - } - const group = new RelationGroup({ - id, - typeId: property, - sourceId: sourceElement.id, - targetId: targetElement.id, - items: groupItems, - vertices, - linkState: linkState, - }); - links.push(group); - break; + const linkClass = typeToLink.get(layoutLink['@type']); + if (linkClass) { + const link = linkClass.fromJSON(layoutLink, { + source: sourceElement, + target: targetElement, + getInitialData: getInitialLinkData, + mapTemplateState: mapLinkTemplateState, + }); + if (link) { + links.push(link); } } } @@ -405,3 +362,12 @@ export function markLayoutOnly( } return linkState; } + +function hasToJSON(instance: object): instance is { toJSON(): { ['@type']?: unknown } } { + const withToJson = instance as { toJSON?(): { ['@type']?: unknown } }; + return Boolean(typeof withToJson.toJSON === 'function'); +} + +function isValidSerializedState(state: { ['@type']?: unknown }): boolean { + return typeof state === 'object' && state && typeof state['@type'] === 'string'; +} diff --git a/src/workspace.ts b/src/workspace.ts index c24b3c6c..f50f5124 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -111,8 +111,10 @@ export * from './editor/dataDiagramModel'; export { EntityElement, EntityElementEvents, EntityElementProps, EntityGroup, EntityGroupEvents, EntityGroupProps, EntityGroupItem, + SerializedEntityElement, SerializedEntityGroup, SerializedEntityGroupItem, RelationLink, RelationLinkEvents, RelationLinkProps, RelationGroup, RelationGroupEvents, RelationGroupProps, RelationGroupItem, + SerializedRelationLink, SerializedRelationGroup, SerializedRelationGroupItem, ElementType, ElementTypeEvents, LinkType, LinkTypeEvents, PropertyType, PropertyTypeEvents, @@ -147,8 +149,8 @@ export { FormInputText, type FormInputTextProps } from './forms/input/formInputT export { SerializedDiagram, SerializedLayout, SerializedLinkOptions, - SerializedLayoutElement, SerializedLayoutElementGroup, SerializedLayoutElementItem, - SerializedLayoutLink, SerializedLayoutLinkGroup, SerializedLayoutLinkItem, + SerializedElement, SerializableElementCell, ElementFromJsonOptions, + SerializedLink, SerializableLinkCell, LinkFromJsonOptions, } from './editor/serializedDiagram'; export { ClassicTemplate, ClassicEntity, ClassicEntityProps } from './templates/classicTemplate'; diff --git a/vitest.config.mts b/vitest.config.mts index a708ec0f..7d283a05 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -8,7 +8,7 @@ const rootDirectory = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ css: { modules: { - generateScopedName: "[name]__[local]", + generateScopedName: '[name]__[local]', } }, build: { @@ -38,7 +38,7 @@ export default defineConfig({ // "new dependencies optimized: react/jsx-dev-runtime": optimizeDeps: { include: [ - "react/jsx-dev-runtime", + 'react/jsx-dev-runtime', ] }, });