From 688261878676219e68dc5089162b69ac3ff270ae Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Sat, 1 Nov 2025 01:45:51 +0300 Subject: [PATCH] Allow to customize link template separately for each link instead of only based on its link type IRI: * `linkTemplateResolver` prop in `Canvas` now receives a second `Link` argument to be able to return a different link template for a specific link; * Link templates and markers are now longer cached based on link type IRI, so it is recommended to always return a stable template and marker object for the same link to avoid unnecessary marker updates; * `LinkTemplate.markerTarget` will no longer fallback to default marker target if only some styles are overriden, and instead require an explicit `{...Reactodia.DefaultLinkTemplate.markerTarget}` in that case; --- CHANGELOG.md | 1 + examples/classicWorkspace.tsx | 6 +- examples/styleCustomization.tsx | 4 +- src/diagram/customization.ts | 8 +- src/diagram/linkLayer.tsx | 101 ++++++++++-------- src/diagram/paperArea.tsx | 4 +- src/diagram/renderingState.ts | 49 ++++----- src/diagram/sharedCanvasState.ts | 5 +- src/legacy-styles.tsx | 19 +++- src/widgets/canvas.tsx | 46 +++++--- src/widgets/haloLink.tsx | 2 +- src/widgets/utility/dragLinkMover.tsx | 2 +- .../authoredRelationOverlay.tsx | 2 +- src/workspace.ts | 4 +- src/workspace/workspace.tsx | 4 +- 15 files changed, 151 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 297c14a6..03ed3d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Support user-resizable element templates with `ElementSize` template state property: * Resizable elements display "box with handles" in the `Halo` to change the size; * Changed element sizes are captured and restored by `RestoreGeometry` command. +- Allow to customize link template separately for each link instead of only based on its link type IRI in `linkTemplateResolver` for `Canvas`. - 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/classicWorkspace.tsx b/examples/classicWorkspace.tsx index 4baaf314..684b945c 100644 --- a/examples/classicWorkspace.tsx +++ b/examples/classicWorkspace.tsx @@ -55,11 +55,11 @@ function ClassicWorkspaceExample() { } return undefined; }, - linkTemplateResolver: type => { - if (type === 'http://www.w3.org/2000/01/rdf-schema#subClassOf') { + linkTemplateResolver: (linkType, link) => { + if (linkType === 'http://www.w3.org/2000/01/rdf-schema#subClassOf') { return Reactodia.DefaultLinkTemplate; } - return OntologyLinkTemplates(type); + return OntologyLinkTemplates(linkType, link); }, }} toolbar={{ diff --git a/examples/styleCustomization.tsx b/examples/styleCustomization.tsx index b66e548c..590a6d16 100644 --- a/examples/styleCustomization.tsx +++ b/examples/styleCustomization.tsx @@ -59,8 +59,8 @@ function StyleCustomizationExample() { } return undefined; }, - linkTemplateResolver: type => { - if (!type.startsWith('urn:reactodia:')) { + linkTemplateResolver: (linkType, link) => { + if (linkType) { return DoubleArrowLinkTemplate; } }, diff --git a/src/diagram/customization.ts b/src/diagram/customization.ts index 2a366239..89dd653f 100644 --- a/src/diagram/customization.ts +++ b/src/diagram/customization.ts @@ -1,7 +1,5 @@ import type * as React from 'react'; -import type { LinkTypeIri } from '../data/model'; - import type { Element, ElementTemplateState, Link } from './elements'; import type { SizeProvider, Vector } from './geometry'; import type { GraphStructure } from './model'; @@ -15,14 +13,14 @@ import type { GraphStructure } from './model'; */ export type TypeStyleResolver = (types: ReadonlyArray) => TypeStyle | undefined; /** - * Provides a custom template to render an element on the canvas. + * Provides a custom template to render an element (graph node) on the canvas. */ export type ElementTemplateResolver = (element: Element) => ElementTemplate | ElementTemplateComponent | undefined; /** - * Provides a custom rendering on a diagram for links of specific type. + * Provides a custom template to render a link (graph edge) on the canvas. */ -export type LinkTemplateResolver = (linkTypeId: LinkTypeIri) => LinkTemplate | undefined; +export type LinkTemplateResolver = (link: Link) => LinkTemplate | undefined; /** * Common style for a type or set of types to display in various parts of the UI. diff --git a/src/diagram/linkLayer.tsx b/src/diagram/linkLayer.tsx index d5dd4fbf..06987a4f 100644 --- a/src/diagram/linkLayer.tsx +++ b/src/diagram/linkLayer.tsx @@ -279,10 +279,8 @@ const LINK_CLASS = 'reactodia-link'; function LinkView(props: LinkViewProps) { const {link, model, renderingState} = props; - const template = React.useMemo( - () => renderingState.createLinkTemplate(link.typeId), - [link.typeId] - ); + const template = renderingState.getLinkTemplate(link); + const defaultTemplate = renderingState.shared.defaultLinkResolver(link); const source = model.getElement(link.sourceId); const target = model.getElement(link.targetId); @@ -310,15 +308,20 @@ function LinkView(props: LinkViewProps) { return getPointAlongPolyline(polyline, polylineLength * offset); }; - const typeIndex = renderingState.ensureLinkTypeIndex(link.typeId); + const markerSource = template.markerSource ?? defaultTemplate.markerSource; + const markerTarget = template.markerTarget ?? defaultTemplate.markerTarget; + const markerSourceIndex = markerSource + ? renderingState.ensureLinkMarkerIndex(markerSource) : undefined; + const markerTargetIndex = markerTarget + ? renderingState.ensureLinkMarkerIndex(markerTarget) : undefined; const {highlighter} = renderingState.shared; const isBlurred = highlighter && !highlighter(link); const renderedLink = template.renderLink({ link, - markerSource: `url(#${linkMarkerKey(typeIndex, true)})`, - markerTarget: `url(#${linkMarkerKey(typeIndex, false)})`, + markerSource: markerSourceIndex ? `url(#${linkMarkerKey(markerSourceIndex, true)})` : '', + markerTarget: markerTargetIndex ? `url(#${linkMarkerKey(markerTargetIndex, false)})` : '', path: spline.toPath(), getPathPosition, route, @@ -674,6 +677,7 @@ class VertexTools extends React.Component<{ } export interface LinkMarkersProps { + model: DiagramModel; renderingState: MutableRenderingState; } @@ -682,38 +686,50 @@ export class LinkMarkers extends React.Component { private readonly delayedUpdate = new Debouncer(); render() { - const {renderingState} = this.props; + const {model, renderingState} = this.props; + + const sourceMarkers = new Set(); + const targetMarkers = new Set(); + + for (const link of model.links) { + const template = renderingState.getLinkTemplate(link); + const defaultTemplate = renderingState.shared.defaultLinkResolver(link); - const markers: Array> = []; - - for (const [linkTypeId, template] of renderingState.getLinkTemplates()) { - const defaultTemplate = renderingState.shared.defaultLinkResolver(linkTypeId); - const typeIndex = renderingState.ensureLinkTypeIndex(linkTypeId); - - if (template.markerSource) { - markers.push( - - ); + const markerSource = template.markerSource ?? defaultTemplate.markerSource; + if (markerSource) { + sourceMarkers.add(markerSource); } - if (template.markerTarget) { - markers.push( - - ); + const markerTarget = template.markerTarget ?? defaultTemplate.markerTarget; + if (markerTarget) { + targetMarkers.add(markerTarget); } } - - return {markers}; + + return ( + + {Array.from(sourceMarkers, marker => { + const index = renderingState.ensureLinkMarkerIndex(marker); + return ( + + ); + })} + {Array.from(targetMarkers, marker => { + const index = renderingState.ensureLinkMarkerIndex(marker); + return ( + + ); + })} + + ); } componentDidMount() { @@ -738,10 +754,9 @@ export class LinkMarkers extends React.Component { } interface LinkMarkerProps { - linkTypeIndex: number; + markerIndex: number; isStartMarker: boolean; style: LinkMarkerStyle; - defaultStyle: LinkMarkerStyle | undefined; } const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; @@ -760,19 +775,15 @@ class LinkMarker extends React.Component { return; } - const {linkTypeIndex, isStartMarker, style, defaultStyle} = this.props; - const { - d = defaultStyle?.d, - width = defaultStyle?.width, - height = defaultStyle?.height, - } = style; + const {markerIndex, isStartMarker, style} = this.props; + const {d, width, height} = style; if (!(d !== undefined && width !== undefined && height !== undefined)) { return; } const className = 'reactodia-link-marker'; marker.setAttribute('class', className); - marker.setAttribute('id', linkMarkerKey(linkTypeIndex, isStartMarker)); + marker.setAttribute('id', linkMarkerKey(markerIndex, isStartMarker)); marker.setAttribute('markerWidth', String(width)); marker.setAttribute('markerHeight', String(height)); marker.setAttribute('orient', 'auto'); @@ -795,6 +806,6 @@ class LinkMarker extends React.Component { }; } -function linkMarkerKey(linkTypeIndex: number, startMarker: boolean) { - return `reactodia-marker-${startMarker ? 'start' : 'end'}-${linkTypeIndex}`; +function linkMarkerKey(markerIndex: number, startMarker: boolean) { + return `reactodia-marker-${startMarker ? 'start' : 'end'}-${markerIndex}`; } diff --git a/src/diagram/paperArea.tsx b/src/diagram/paperArea.tsx index 1c25c912..e58139d5 100644 --- a/src/diagram/paperArea.tsx +++ b/src/diagram/paperArea.tsx @@ -227,7 +227,9 @@ export class PaperArea extends React.Component implements className={`${CLASS_NAME}__canvas`} style={{overflow: 'visible'}} paperTransform={paperTransform}> - + diff --git a/src/diagram/renderingState.ts b/src/diagram/renderingState.ts index 599923a9..cb12e1a2 100644 --- a/src/diagram/renderingState.ts +++ b/src/diagram/renderingState.ts @@ -10,11 +10,9 @@ import { Debouncer } from '../coreUtils/scheduler'; import { ElementTemplate, ElementTemplateComponent, ElementTemplateResolver, LinkTemplateResolver, - LinkTemplate, LinkRouter, RoutedLink, RoutedLinks, + LinkTemplate, LinkMarkerStyle, LinkRouter, RoutedLink, RoutedLinks, } from './customization'; -import { LinkTypeIri } from '../data/model'; - import { Element, Link } from './elements'; import { Rect, ShapeGeometry, Size, SizeProvider, boundsOf, isPolylineEqual } from './geometry'; import { DefaultLinkRouter } from './linkRouter'; @@ -159,13 +157,13 @@ export interface RenderingState extends SizeProvider { */ getLinkLabelBounds(link: Link): Rect | undefined; /** - * Resolve template component for the element. + * Resolve template for the graph element. */ getElementTemplate(element: Element): ElementTemplate; /** - * Returns link templates for all types of rendered links. + * Resolve template for the graph link. */ - getLinkTemplates(): ReadonlyMap; + getLinkTemplate(link: Link): LinkTemplate; /** * Returns route data for all links in the graph. */ @@ -193,10 +191,10 @@ export class MutableRenderingState implements RenderingState { private readonly linkLabelContainer = document.createElement('div'); private readonly linkLabelBounds = new WeakMap(); - private readonly linkTypeIndex = new Map(); - private static nextLinkTypeIndex = 0; - - private readonly linkTemplates = new Map(); + private cachedLinkTemplates = new WeakMap(); + private readonly linkMarkerIndex = new WeakMap(); + private static nextLinkMarkerIndex = 1; + private readonly delayedUpdateRoutings = new Debouncer(); private routings: RoutedLinks = new Map(); @@ -231,7 +229,7 @@ export class MutableRenderingState implements RenderingState { this.scheduleUpdateRoutings(); }); this.listener.listen(this.model.events, 'discardGraph', () => { - this.linkTemplates.clear(); + this.cachedLinkTemplates = new WeakMap(); const routings = this.routings; this.routings = new Map(); @@ -353,28 +351,27 @@ export class MutableRenderingState implements RenderingState { }; } - ensureLinkTypeIndex(linkTypeId: LinkTypeIri): number { - let typeIndex = this.linkTypeIndex.get(linkTypeId); - if (typeIndex === undefined) { - typeIndex = MutableRenderingState.nextLinkTypeIndex++; - this.linkTypeIndex.set(linkTypeId, typeIndex); + ensureLinkMarkerIndex(linkMarker: LinkMarkerStyle): number { + let index = this.linkMarkerIndex.get(linkMarker); + if (index === undefined) { + index = MutableRenderingState.nextLinkMarkerIndex++; + if (MutableRenderingState.nextLinkMarkerIndex >= Number.MAX_SAFE_INTEGER) { + MutableRenderingState.nextLinkMarkerIndex = 1; + } + this.linkMarkerIndex.set(linkMarker, index); } - return typeIndex; - } - - getLinkTemplates(): ReadonlyMap { - return this.linkTemplates; + return index; } - createLinkTemplate(linkTypeId: LinkTypeIri): LinkTemplate { - const existingTemplate = this.linkTemplates.get(linkTypeId); + getLinkTemplate(link: Link): LinkTemplate { + const existingTemplate = this.cachedLinkTemplates.get(link); if (existingTemplate) { return existingTemplate; } - const template = this.resolveLinkTemplate(linkTypeId) - ?? this.shared.defaultLinkResolver(linkTypeId); - this.linkTemplates.set(linkTypeId, template); + const template = this.resolveLinkTemplate(link) + ?? this.shared.defaultLinkResolver(link); + this.cachedLinkTemplates.set(link, template); this.source.trigger('changeLinkTemplates', {source: this}); return template; } diff --git a/src/diagram/sharedCanvasState.ts b/src/diagram/sharedCanvasState.ts index 01c61f1f..7689a35e 100644 --- a/src/diagram/sharedCanvasState.ts +++ b/src/diagram/sharedCanvasState.ts @@ -2,7 +2,6 @@ import * as React from 'react'; import { Events, EventSource, EventObserver, PropertyChange } from '../coreUtils/events'; -import type { LinkTypeIri } from '../data/model'; import { TemplateProperties, setTemplateProperty } from '../data/schema'; import type { CanvasApi, CanvasDropEvent } from './canvasApi'; @@ -58,7 +57,7 @@ export type CellHighlighter = (item: Element | Link) => boolean; /** @hidden */ export interface SharedCanvasStateOptions { defaultElementResolver: (element: Element) => ElementTemplate; - defaultLinkResolver: (linkType: LinkTypeIri) => LinkTemplate; + defaultLinkResolver: (link: Link) => LinkTemplate; defaultLayout: LayoutFunction; renameLinkProvider?: RenameLinkProvider; } @@ -90,7 +89,7 @@ export class SharedCanvasState { * Default link template resolver to use as a fallback * (returns a default template for any link). */ - readonly defaultLinkResolver: (linkTypeId: LinkTypeIri) => LinkTemplate; + readonly defaultLinkResolver: (link: Link) => LinkTemplate; /** * Default layout algorithm function to use if it's not specified explicitly. */ diff --git a/src/legacy-styles.tsx b/src/legacy-styles.tsx index 8c707122..898ce47d 100644 --- a/src/legacy-styles.tsx +++ b/src/legacy-styles.tsx @@ -1,4 +1,5 @@ -import type { TypeStyleResolver, LinkTemplate, LinkTemplateResolver } from './diagram/customization'; +import type { TypeStyleResolver, LinkTemplate } from './diagram/customization'; +import type { TypedLinkResolver } from './widgets/canvas'; const classIcon = require('@images/semantic/class.svg') as string; const objectPropertyIcon = require('@images/semantic/objectProperty.svg') as string; @@ -97,10 +98,11 @@ export function makeLinkStyleShowIri(Reactodia: typeof import('./workspace')): L * * @deprecated These link templates will be removed in later versions */ -export function makeOntologyLinkTemplates(Reactodia: typeof import('./workspace')): LinkTemplateResolver { +export function makeOntologyLinkTemplates(Reactodia: typeof import('./workspace')): TypedLinkResolver { const LINK_SUB_CLASS_OF: LinkTemplate = { ...Reactodia.DefaultLinkTemplate, markerTarget: { + ...Reactodia.DefaultLinkTemplate.markerTarget, fill: '#f8a485', stroke: '#cf8e76', }, @@ -116,6 +118,7 @@ export function makeOntologyLinkTemplates(Reactodia: typeof import('./workspace' const LINK_DOMAIN: LinkTemplate = { ...Reactodia.DefaultLinkTemplate, markerTarget: { + ...Reactodia.DefaultLinkTemplate.markerTarget, fill: '#34c7f3', stroke: '#38b5db', }, @@ -131,6 +134,7 @@ export function makeOntologyLinkTemplates(Reactodia: typeof import('./workspace' const LINK_RANGE: LinkTemplate = { ...Reactodia.DefaultLinkTemplate, markerTarget: { + ...Reactodia.DefaultLinkTemplate.markerTarget, fill: '#34c7f3', stroke: '#38b5db', }, @@ -146,6 +150,7 @@ export function makeOntologyLinkTemplates(Reactodia: typeof import('./workspace' const LINK_TYPE_OF: LinkTemplate = { ...Reactodia.DefaultLinkTemplate, markerTarget: { + ...Reactodia.DefaultLinkTemplate.markerTarget, fill: '#8cd965', stroke: '#5b9a3b', }, @@ -158,6 +163,14 @@ export function makeOntologyLinkTemplates(Reactodia: typeof import('./workspace' ), }; + const LINK_PLACEHOLDER: LinkTemplate = { + ...Reactodia.DefaultLinkTemplate, + markerTarget: { + ...Reactodia.DefaultLinkTemplate.markerTarget, + fill: 'none', + }, + }; + return type => { if (type === 'http://www.w3.org/2000/01/rdf-schema#subClassOf') { return LINK_SUB_CLASS_OF; @@ -168,7 +181,7 @@ export function makeOntologyLinkTemplates(Reactodia: typeof import('./workspace' } else if (type === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type') { return LINK_TYPE_OF; } else if (type === Reactodia.PlaceholderRelationType) { - return {...Reactodia.DefaultLinkTemplate, markerTarget: {fill: 'none'}}; + return LINK_PLACEHOLDER; } else { return undefined; } diff --git a/src/widgets/canvas.tsx b/src/widgets/canvas.tsx index ac0e6d15..d5fcf331 100644 --- a/src/widgets/canvas.tsx +++ b/src/widgets/canvas.tsx @@ -2,15 +2,17 @@ import * as React from 'react'; import { ColorSchemeApi } from '../coreUtils/colorScheme'; +import type { LinkTypeIri } from '../data/model'; + import type { ZoomOptions } from '../diagram/canvasApi'; import type { - LinkRouter, LinkTemplateResolver, ElementTemplate, ElementTemplateComponent, + ElementTemplate, ElementTemplateComponent, LinkTemplate, LinkRouter, } from '../diagram/customization'; -import { Element } from '../diagram/elements'; +import { Element, Link } from '../diagram/elements'; import { PaperArea } from '../diagram/paperArea'; import { MutableRenderingState } from '../diagram/renderingState'; -import { EntityElement } from '../editor/dataElements'; +import { EntityElement, RelationGroup, RelationLink } from '../editor/dataElements'; import { OverlaySupport } from '../editor/overlayController'; import { useWorkspace } from '../workspace/workspaceContext'; @@ -24,18 +26,23 @@ import { AnnotationSupport } from './annotation'; */ export interface CanvasProps { /** - * Custom provider to render diagram elements. + * Custom provider to render diagram elements (graph nodes). * - * **Default** is to render elements with {@link StandardTemplate}. + * **Default** is to render: + * - {@link AnnotationElement} with {@link NoteTemplate}; + * - other elements with {@link StandardTemplate} which + * uses {@link StandardEntity} and {@link StandardEntityGroup}. */ elementTemplateResolver?: TypedElementResolver; /** - * Custom provider to render diagram links of a specific type. + * Custom provider to render diagram links (graph edges). * - * **Default** is to render links with {@link DefaultLinkTemplate} which uses - * {@link DefaultLinkPathTemplate} for the link itself. + * **Default** is to render: + * - {@link AnnotationLink} with {@link NoteLinkTemplate}; + * - other links with {@link StandardLinkTemplate} which + * uses {@link StandardRelation}. */ - linkTemplateResolver?: LinkTemplateResolver; + linkTemplateResolver?: TypedLinkResolver; /** * Custom provider to route (layout) diagram links on the diagram. * @@ -76,12 +83,21 @@ export interface CanvasProps { } /** - * Provides a custom component to render element on a diagram - * based on the element itself and its type IRIs if the element is an entity. + * Provides a custom component to render an element on the diagram + * based on the element itself and its type IRIs if the element is + * an {@link EntityElement entity}. */ export type TypedElementResolver = (types: readonly string[], element: Element) => ElementTemplate | ElementTemplateComponent | undefined; +/** + * Provides a custom component to render a link on the diagram + * based on the link itself and its type IRI if the link is + * a {@link RelationLink relation}. + */ +export type TypedLinkResolver = (linkType: LinkTypeIri | undefined, link: Link) => + LinkTemplate | undefined; + const CLASS_NAME = 'reactodia-canvas'; /** @@ -105,7 +121,13 @@ export function Canvas(props: CanvasProps) { return elementTemplateResolver(data?.types ?? [], element); } ) : undefined, - linkTemplateResolver, + linkTemplateResolver: linkTemplateResolver ? ( + link => { + const linkType = (link instanceof RelationLink || link instanceof RelationGroup) + ? link.typeId : undefined; + return linkTemplateResolver(linkType, link); + } + ) : undefined, linkRouter, })); diff --git a/src/widgets/haloLink.tsx b/src/widgets/haloLink.tsx index f366c840..16dbf7d1 100644 --- a/src/widgets/haloLink.tsx +++ b/src/widgets/haloLink.tsx @@ -271,7 +271,7 @@ function computeLinkSpline( return undefined; } - const template = renderingState.getLinkTemplates().get(link.typeId); + const template = renderingState.getLinkTemplate(link); const route = renderingState.getRouting(link.id); const verticesDefinedByUser = link.vertices || []; diff --git a/src/widgets/utility/dragLinkMover.tsx b/src/widgets/utility/dragLinkMover.tsx index 6763867a..53d36b15 100644 --- a/src/widgets/utility/dragLinkMover.tsx +++ b/src/widgets/utility/dragLinkMover.tsx @@ -417,7 +417,7 @@ class DragLinkMoverInner extends React.Component return ( - + {this.renderHighlight()} {this.renderCanDropIndicator()} {waitingForMetadata ? null : ( diff --git a/src/widgets/visualAuthoring/authoredRelationOverlay.tsx b/src/widgets/visualAuthoring/authoredRelationOverlay.tsx index 29af0c33..66e408ef 100644 --- a/src/widgets/visualAuthoring/authoredRelationOverlay.tsx +++ b/src/widgets/visualAuthoring/authoredRelationOverlay.tsx @@ -118,7 +118,7 @@ class LinkStateWidgetInner extends React.Component { } return StandardTemplate; }, - defaultLinkResolver: linkTypeId => { - if (linkTypeId === AnnotationLink.typeId) { + defaultLinkResolver: link => { + if (link instanceof AnnotationLink) { return NoteLinkTemplate; } return DefaultLinkTemplate;