diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c21c93..7c096054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,15 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Add `changeTransform` event to `CanvasApi.events` which triggers on `CanvasApi.metrics.getTransform()` changes, i.e. when coordinate mapping changes due to scale or canvas size is re-adjusted. - Add `DiagramModel.cellsVersion` property which updates on every element or link addition/removal/reordering to be able to subscribe to `changeCells` event with `useSyncStore()` hook. - Deprecate `canvasWidgets` prop on `DefaultWorkspace` and `ClassicWorkspace` in favor of passing widgets directly as children. +- Move expanded element state from distinct property on `Element` to be stored in `Element.elementState` with `TemplateProperties.Expanded` property: + * All existing properties, methods and commands works as before but use element template state as storage for expanded state; + * `changeExpanded` event is removed from element events, use `changeElementState` event instead; + * When exporting the diagram the expanded state is serialized only with `elementState` while using `isExpanded` property when importing the diagram for backward compatibility. +- Move "expand/collapse on double click" global element behavior to `StandardEntity` and `ClassicEntity` implementation only. #### 🐛 Fixed - Fix `HaloLink` and visual authoring link path highlight being rendered on top on elements by placing it onto `overLinkGeometry` widget layer instead. +- Fix element template state not being restored when ungrouping entities. ## [0.30.1] - 2025-06-27 #### 🐛 Fixed diff --git a/src/data/schema.ts b/src/data/schema.ts index 260eccce..ddf0d143 100644 --- a/src/data/schema.ts +++ b/src/data/schema.ts @@ -27,6 +27,13 @@ export const PlaceholderRelationType: LinkTypeIri = 'urn:reactodia:newLink'; * @category Constants */ export enum TemplateProperties { + /** + * Element state property to display the element as expanded + * (if supported by its element template). + * + * @see {@link Element.isExpanded} + */ + Expanded = 'urn:reactodia:expanded', /** * Element state property to mark some element data properties as "pinned", * i.e. displayed even if element is collapsed. diff --git a/src/diagram/commands.ts b/src/diagram/commands.ts index 82f9e745..e77e58ba 100644 --- a/src/diagram/commands.ts +++ b/src/diagram/commands.ts @@ -1,6 +1,7 @@ import { TranslatedText } from '../coreUtils/i18n'; import type { LinkTypeIri } from '../data/model'; +import { TemplateProperties } from '../data/schema'; import type { CanvasApi } from './canvasApi'; import type { @@ -140,6 +141,9 @@ export function setElementState(element: Element, state: ElementTemplateState | /** * Command to toggle element expanded or collapsed. + * + * Expanded state is stored in the {@link Element.elementState element state} + * with {@link TemplateProperties.Expanded} property. * * @category Commands */ diff --git a/src/diagram/customization.ts b/src/diagram/customization.ts index 02f8bb97..28d5773a 100644 --- a/src/diagram/customization.ts +++ b/src/diagram/customization.ts @@ -91,6 +91,9 @@ export interface TemplateProps { * Specifies whether element is in the expanded state. * * Same as {@link Element.isExpanded}. + * + * Expanded state is stored in the {@link elementState element state} + * with {@link TemplateProperties.Expanded} property. */ readonly isExpanded: boolean; /** diff --git a/src/diagram/elementLayer.tsx b/src/diagram/elementLayer.tsx index 046f30fe..e58e3488 100644 --- a/src/diagram/elementLayer.tsx +++ b/src/diagram/elementLayer.tsx @@ -8,7 +8,6 @@ import { Debouncer } from '../coreUtils/scheduler'; import { ElementTemplate, TemplateProps } from './customization'; import { useCanvas } from './canvasApi'; -import { setElementExpanded } from './commands'; import { Element, VoidElement } from './elements'; import type { Size } from './geometry'; import { DiagramModel } from './model'; @@ -133,7 +132,7 @@ export class ElementLayer extends React.Component { this.requestRedrawAll(RedrawFlags.None); }); this.listener.listen(model.events, 'elementEvent', ({data}) => { - const invalidatesTemplate = data.changeExpanded || data.changeElementState; + const invalidatesTemplate = data.changeElementState; if (invalidatesTemplate) { this.requestRedraw(invalidatesTemplate.source, RedrawFlags.RecomputeTemplate); } @@ -312,9 +311,6 @@ class OverlaidElement extends React.Component { // const angle = model.get('angle') || 0; // if (angle) { transform += `rotate(${angle}deg)`; } - const className = ( - `reactodia-overlaid-element ${blurred ? 'reactodia-overlaid-element--blurred' : ''}` - ); const style: React.CSSProperties = {position: 'absolute', transform}; return ( <> @@ -336,8 +332,7 @@ class OverlaidElement extends React.Component { // eslint-disable-next-line react/no-unknown-property onLoad={this.onLoadOrErrorEvent} // eslint-disable-next-line react/no-unknown-property - onError={this.onLoadOrErrorEvent} - onDoubleClick={this.onDoubleClick}> + onError={this.onLoadOrErrorEvent}>
{ } }; - private onDoubleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const {model, state: {element}} = this.props; - model.history.execute( - setElementExpanded(element, !element.isExpanded) - ); - }; - componentDidMount() { const {state, onResize, renderingState} = this.props; diff --git a/src/diagram/elements.ts b/src/diagram/elements.ts index 7854bb41..d2651e9b 100644 --- a/src/diagram/elements.ts +++ b/src/diagram/elements.ts @@ -1,6 +1,7 @@ import { EventSource, Events, PropertyChange } from '../coreUtils/events'; import { LinkTypeIri } from '../data/model'; +import { TemplateProperties } from '../data/schema'; import { generate128BitID } from '../data/utils'; import { Vector, isPolylineEqual } from './geometry'; @@ -20,10 +21,6 @@ export interface ElementEvents { * Triggered on {@link Element.position} property change. */ changePosition: PropertyChange; - /** - * Triggered on {@link Element.isExpanded} property change. - */ - changeExpanded: PropertyChange; /** * Triggered on {@link Element.elementState} property change. */ @@ -72,9 +69,26 @@ export type ElementRedrawLevel = 'render' | 'template'; * Properties for {@link Element}. */ export interface ElementProps { + /** + * Unique and immutable {@link Element.id element ID}. + * + * If not specified, {@link Element.generateId()} is used to create one. + */ id?: string; + /** + * Initial value for the {@link Element.position element position}. + */ position?: Vector; + /** + * Initial value for the {@link Element.isExpanded} state. + * + * If specified as `true`, the value is added to the {@link Element.elementState} + * with {@link TemplateProperties.Expanded} property. + */ expanded?: boolean; + /** + * Initial value for the {@link Element.elementState element template state}. + */ elementState?: ElementTemplateState; } @@ -84,13 +98,21 @@ export interface ElementProps { * @category Core */ export abstract class Element { + /** + * Event source to trigger events from derived element types. + */ protected readonly source = new EventSource(); + /** + * Events for the graph element. + */ readonly events: Events = this.source; + /** + * Unique and immutable element ID on the diagram. + */ readonly id: string; private _position: Vector; - private _expanded: boolean; private _elementState: ElementTemplateState | undefined; constructor(props: ElementProps) { @@ -103,8 +125,9 @@ export abstract class Element { this.id = id; this._position = position; - this._expanded = expanded; - this._elementState = elementState; + this._elementState = expanded + ? {...elementState, [TemplateProperties.Expanded]: expanded} + : elementState; } /** @@ -114,8 +137,22 @@ export abstract class Element { return `urn:reactodia:e:${generate128BitID()}`; } - get position(): Vector { return this._position; } - setPosition(value: Vector) { + /** + * Gets the element position on the canvas in paper coordinates. + */ + get position(): Vector { + return this._position; + } + + /** + * Sets a new value for {@link position} property. + * + * Triggers {@link ElementEvents.changePosition} event if new value does + * not equal to the previous one. + * + * @see {@link RestoreGeometry} + */ + setPosition(value: Vector): void { const previous = this._position; const same = ( previous.x === value.x && @@ -126,27 +163,68 @@ export abstract class Element { this.source.trigger('changePosition', {source: this, previous}); } - get isExpanded(): boolean { return this._expanded; } - setExpanded(value: boolean) { - const previous = this._expanded; - if (previous === value) { return; } - this._expanded = value; - this.source.trigger('changeExpanded', {source: this, previous}); + /** + * Whether the element should be displayed as expanded + * (as defined by the element template). + * + * Expanded state is stored in the {@link Element.elementState element state} + * with {@link TemplateProperties.Expanded} property. + */ + get isExpanded(): boolean { + return Boolean(this._elementState?.[TemplateProperties.Expanded]); + } + + /** + * Sets a new value for {@link isExpanded} property. + * + * Expanded state is stored in the {@link Element.elementState element state} + * with {@link TemplateProperties.Expanded} property. + * + * Triggers {@link ElementEvents.changeElementState} event if new value does + * not equal to the previous one. + */ + setExpanded(value: boolean): void { + if (value && !this._elementState?.[TemplateProperties.Expanded]) { + this.setElementState({...this._elementState, [TemplateProperties.Expanded]: true}); + } else if (!value && this._elementState?.[TemplateProperties.Expanded]) { + const {[TemplateProperties.Expanded]: _, ...withoutExpanded} = this._elementState; + this.setElementState(withoutExpanded); + } } - get elementState(): ElementTemplateState | undefined { return this._elementState; } - setElementState(value: ElementTemplateState | undefined) { + /** + * Gets a serializable template-specific state for the element. + */ + get elementState(): ElementTemplateState | undefined { + return this._elementState; + } + + /** + * Sets a new value for {@link elementState} property. + * + * Triggers {@link ElementEvents.changeElementState} event if new value does + * not equal to the previous one. + */ + setElementState(value: ElementTemplateState | undefined): void { const previous = this._elementState; if (previous === value) { return; } this._elementState = value; this.source.trigger('changeElementState', {source: this, previous}); } - focus() { + /** + * Focuses on the element template on a canvas (if possible). + */ + focus(): void { this.source.trigger('requestedFocus', {source: this}); } - redraw(level?: ElementRedrawLevel) { + /** + * Forces a re-render of the element displayed by a template on a canvas. + * + * @param level specifies which cached state should be invalidated on re-render + */ + redraw(level?: ElementRedrawLevel): void { this.source.trigger('requestedRedraw', {source: this, level}); } } @@ -201,10 +279,27 @@ export interface LinkEvents { * Properties for {@link Link}. */ export interface LinkProps { + /** + * Unique and immutable {@link Link.id link ID}. + * + * If not specified, {@link Link.generateId()} is used to create one. + */ id?: string; + /** + * An immutable link {@link Link.sourceId source} ({@link Element.id element ID}). + */ sourceId: string; + /** + * An immutable link {@link Link.targetId target} ({@link Element.id element ID}). + */ targetId: string; + /** + * Initial value for the {@link Link.vertices link vertices (geometry)}. + */ vertices?: ReadonlyArray; + /** + * Initial value for the {@link Link.linkState link template state}. + */ linkState?: LinkTemplateState; } @@ -214,9 +309,18 @@ export interface LinkProps { * @category Core */ export abstract class Link { + /** + * Event source to trigger events from derived link types. + */ protected readonly source = new EventSource(); + /** + * Events for the graph link. + */ readonly events: Events = this.source; + /** + * Unique and immutable link ID on the diagram. + */ readonly id: string; private _sourceId: string; @@ -248,31 +352,83 @@ export abstract class Link { return `urn:reactodia:l:${generate128BitID()}`; } - get sourceId(): string { return this._sourceId; } - get targetId(): string { return this._targetId; } + /** + * Gets an immutable link source {@link Element.id element ID}. + */ + get sourceId(): string { + return this._sourceId; + } + + /** + * Gets an immutable link target {@link Element.id element ID}. + */ + get targetId(): string { + return this._targetId; + } + + /** + * Gets the link type IRI. + */ get typeId(): LinkTypeIri { return this.getTypeId(); } + /** + * Should return the link type IRI. + * + * For derived link types without natural type IRIs the synthetic IRIs can be + * used, e.g. `my:custom:link`. + */ protected abstract getTypeId(): LinkTypeIri; - get vertices(): ReadonlyArray { return this._vertices; } - setVertices(value: ReadonlyArray) { + /** + * Gets the link geometry (intermediate points in paper coordinates in order + * from the link source to the target). + */ + get vertices(): ReadonlyArray { + return this._vertices; + } + + /** + * Sets a new value for {@link vertices} property. + * + * Triggers {@link LinkEvents.changeVertices} event if new geometry + * does not equal to the previous one. + * + * @see {@link RestoreGeometry} + * @see {@link restoreCapturedLinkGeometry()} + */ + setVertices(value: ReadonlyArray): void { const previous = this._vertices; if (isPolylineEqual(this._vertices, value)) { return; } this._vertices = value; this.source.trigger('changeVertices', {source: this, previous}); } - get linkState(): LinkTemplateState | undefined { return this._linkState; } - setLinkState(value: LinkTemplateState | undefined) { + /** + * Gets a serializable template-specific state for the link. + */ + get linkState(): LinkTemplateState | undefined { + return this._linkState; + } + + /** + * Sets a new value for {@link linkState} property. + * + * Triggers {@link LinkEvents.changeLinkState} event if new value does + * not equal to the previous one. + */ + setLinkState(value: LinkTemplateState | undefined): void { const previous = this._linkState; if (previous === value) { return; } this._linkState = value; this.source.trigger('changeLinkState', {source: this, previous}); } - redraw() { + /** + * Forces a re-render of the link displayed by a template on a canvas. + */ + redraw(): void { this.source.trigger('requestedRedraw', {source: this}); } } diff --git a/src/editor/dataDiagramModel.ts b/src/editor/dataDiagramModel.ts index d0809520..930fe9c9 100644 --- a/src/editor/dataDiagramModel.ts +++ b/src/editor/dataDiagramModel.ts @@ -869,6 +869,7 @@ export class DataDiagramModel extends DiagramModel implements DataGraphStructure const entity = new EntityElement({ data: item.data, position: group.position, + elementState: item.elementState, }); this.addElement(entity); ungrouped.push(entity); @@ -932,6 +933,7 @@ export class DataDiagramModel extends DiagramModel implements DataGraphStructure const entity = new EntityElement({ data: item.data, position: group.position, + elementState: item.elementState, }); this.addElement(entity); ungroupedElements.push(entity); diff --git a/src/editor/editorController.tsx b/src/editor/editorController.tsx index 2fd214fd..41319ae5 100644 --- a/src/editor/editorController.tsx +++ b/src/editor/editorController.tsx @@ -4,6 +4,7 @@ import { TranslatedText } from '../coreUtils/i18n'; import { MetadataProvider } from '../data/metadataProvider'; import { ValidationProvider } from '../data/validationProvider'; import { ElementModel, LinkModel, ElementIri, equalLinks } from '../data/model'; +import { TemplateProperties } from '../data/schema'; import { Element, Link } from '../diagram/elements'; import { Command } from '../diagram/history'; @@ -305,7 +306,11 @@ export class EditorController { ); const element = model.createElement(data); - element.setExpanded(true); + // TODO: customize initial state via MetadataProvider + element.setElementState({ + ...element.elementState, + [TemplateProperties.Expanded]: true, + }); if (options.temporary) { this.setTemporaryState( diff --git a/src/editor/serializedDiagram.ts b/src/editor/serializedDiagram.ts index 85765a0c..6fee9db0 100644 --- a/src/editor/serializedDiagram.ts +++ b/src/editor/serializedDiagram.ts @@ -48,6 +48,10 @@ export interface SerializedLayoutElement { '@id': string; iri?: ElementIri; position: Vector; + /** + * @deprecated only deserialized to {@link TemplateProperties.Expanded} + * in {@link elementState} for compatibility + */ isExpanded?: boolean; elementState?: ElementTemplateState; } @@ -192,7 +196,6 @@ function serializeLayout( '@id': element.id, iri: element instanceof EntityElement ? element.iri : undefined, position: element.position, - isExpanded: element.isExpanded, elementState: element.elementState, }); } diff --git a/src/forms/findOrCreateEntityForm.tsx b/src/forms/findOrCreateEntityForm.tsx index a3b4917e..57005108 100644 --- a/src/forms/findOrCreateEntityForm.tsx +++ b/src/forms/findOrCreateEntityForm.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { TranslatedText } from '../coreUtils/i18n'; +import { TemplateProperties } from '../data/schema'; + import { HtmlSpinner } from '../diagram/spinner'; import { AuthoringState, TemporaryState } from '../editor/authoringState'; @@ -233,7 +235,11 @@ export class FindOrCreateEntityForm extends React.Component ) : null; + const onDoubleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + model.history.execute( + setElementExpanded(element, !element.isExpanded) + ); + }; + return (
+ data-expanded={isExpanded} + onDoubleClick={onDoubleClick}>