From ae5b141e4702c82068ffa30f08ec5a169a198334 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Wed, 11 Jun 2025 20:07:36 +0300 Subject: [PATCH 1/3] Finalize library before v0.30: * Change `EmptyMetadataProvider` into `BaseMetadataProvider` which auto-delegates to passed partial provider methods; * Update examples; --- CHANGELOG.md | 2 +- examples/graphAuthoring.tsx | 1 - examples/resources/exampleMetadata.ts | 302 ++++++++++++-------------- examples/stressTest.tsx | 4 +- src/data/metadataProvider.ts | 40 +++- src/workspace.ts | 2 +- 6 files changed, 172 insertions(+), 179 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a01909f..3de26a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Optimize diagram loading time by avoiding unnecessary updates and separating a measuring element sizes step from applying the sizes to the rendering state. #### 💅 Polish -- Export `EmptyMetadataProvider` as a stable base class to extend from when implementing custom metadata providers. +- Export `BaseMetadataProvider` as a stable base to instantiate or extend when implementing custom metadata providers. - Re-use and un-deprecate `model.locale` formatting object with `DataLocaleProvider` interface type: * **[💥Breaking]**: Remove `Translation.formatIri()` in favor of `locale.formatIri()`; * Replace other deprecated methods of `locale` with: `selectEntityLabel()`, `selectEntityImageUrl()`, `formatEntityLabel()`, `formatEntityTypeList()`; diff --git a/examples/graphAuthoring.tsx b/examples/graphAuthoring.tsx index bfbb5415..6234748b 100644 --- a/examples/graphAuthoring.tsx +++ b/examples/graphAuthoring.tsx @@ -41,7 +41,6 @@ function GraphAuthoringExample() { model.createElement('http://www.w3.org/ns/org#subOrganizationOf'), model.createElement('http://www.w3.org/ns/org#unitOf'), ]; - model.history.execute(Reactodia.setElementExpanded(elements[0], true)); await Promise.all([ model.requestElementData(elements.map(el => el.iri)), model.requestLinks(), diff --git a/examples/resources/exampleMetadata.ts b/examples/resources/exampleMetadata.ts index 8620c9c5..9261c2ba 100644 --- a/examples/resources/exampleMetadata.ts +++ b/examples/resources/exampleMetadata.ts @@ -18,189 +18,153 @@ const rdfs = vocabulary('http://www.w3.org/2000/01/rdf-schema#', [ const SIMULATED_DELAY: number = 200; /* ms */ -export class ExampleMetadataProvider extends Reactodia.EmptyMetadataProvider { +export class ExampleMetadataProvider extends Reactodia.BaseMetadataProvider { private readonly propertyTypes = [owl.AnnotationProperty, owl.DatatypeProperty, owl.ObjectProperty]; private readonly editableTypes = new Set([owl.Class, ...this.propertyTypes]); private readonly editableRelations = new Set([rdfs.domain, rdfs.range]); private readonly literalLanguages: ReadonlyArray = ['de', 'en', 'es', 'ru', 'zh']; - getLiteralLanguages(): ReadonlyArray { - return this.literalLanguages; - } - - async createEntity( - type: Reactodia.ElementTypeIri, - options: { readonly signal?: AbortSignal } - ): Promise { - await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal}); - const random32BitDigits = Math.floor((1 + Math.random()) * 0x100000000) - .toString(16).substring(1); - const typeLabel = Reactodia.Rdf.getLocalName(type) ?? 'Entity'; - return { - id: `${type}_${random32BitDigits}` as Reactodia.ElementIri, - types: [type], - properties: { - [Reactodia.rdfs.label]: [ - Reactodia.Rdf.DefaultDataFactory.literal(`New ${typeLabel}`) - ] + constructor() { + super({ + getLiteralLanguages: () => this.literalLanguages, + createEntity: async (type, {signal}) => { + await Reactodia.delay(SIMULATED_DELAY, {signal}); + const random32BitDigits = Math.floor((1 + Math.random()) * 0x100000000) + .toString(16).substring(1); + const typeLabel = Reactodia.Rdf.getLocalName(type) ?? 'Entity'; + return { + id: `${type}_${random32BitDigits}`, + types: [type], + properties: { + [Reactodia.rdfs.label]: [ + Reactodia.Rdf.DefaultDataFactory.literal(`New ${typeLabel}`) + ] + }, + }; }, - }; - } - - async createRelation( - source: Reactodia.ElementModel, - target: Reactodia.ElementModel, - linkType: Reactodia.LinkTypeIri, - options: { readonly signal?: AbortSignal } - ): Promise { - await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal}); - return { - sourceId: source.id, - targetId: target.id, - linkTypeId: linkType, - properties: {}, - }; - } - - async canConnect( - source: Reactodia.ElementModel, - target: Reactodia.ElementModel | undefined, - linkType: Reactodia.LinkTypeIri | undefined, - options: { readonly signal?: AbortSignal } - ): Promise { - await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal}); - - const connections: Reactodia.MetadataCanConnect[] = []; - const addConnections = ( - types: readonly Reactodia.ElementTypeIri[], - allOutLinks: readonly Reactodia.LinkTypeIri[], - allInLinks: readonly Reactodia.LinkTypeIri[] - ) => { - const outLinks = linkType - ? allOutLinks.filter(type => type === linkType) - : allOutLinks; - const inLinks = linkType - ? allInLinks.filter(type => type === linkType) - : allInLinks; - if (types.length > 0 && (outLinks.length > 0 || inLinks.length > 0)) { - connections.push({targetTypes: new Set(types), outLinks, inLinks}); - } - }; - - if (hasType(source, owl.Class)) { - if (hasType(target, owl.Class)) { - addConnections([owl.Class], [rdfs.subClassOf], [rdfs.subClassOf]); - } + createRelation: async (source, target, linkType, {signal}) => { + await Reactodia.delay(SIMULATED_DELAY, {signal: signal}); + return { + sourceId: source.id, + targetId: target.id, + linkTypeId: linkType, + properties: {}, + }; + }, + canConnect: async (source, target, linkType, {signal}) => { + await Reactodia.delay(SIMULATED_DELAY, {signal}); + + const connections: Reactodia.MetadataCanConnect[] = []; + const addConnections = ( + types: readonly Reactodia.ElementTypeIri[], + allOutLinks: readonly Reactodia.LinkTypeIri[], + allInLinks: readonly Reactodia.LinkTypeIri[] + ) => { + const outLinks = linkType + ? allOutLinks.filter(type => type === linkType) + : allOutLinks; + const inLinks = linkType + ? allInLinks.filter(type => type === linkType) + : allInLinks; + if (types.length > 0 && (outLinks.length > 0 || inLinks.length > 0)) { + connections.push({targetTypes: new Set(types), outLinks, inLinks}); + } + }; - const targetPropertyTypes = this.propertyTypes.filter(type => hasType(target, type)); - if (targetPropertyTypes.length > 0) { - addConnections(targetPropertyTypes, [], [rdfs.domain, rdfs.range]); - } - } + if (hasType(source, owl.Class)) { + if (hasType(target, owl.Class)) { + addConnections([owl.Class], [rdfs.subClassOf], [rdfs.subClassOf]); + } - const sourcePropertyTypes = this.propertyTypes.filter(type => hasType(source, type)); - if (sourcePropertyTypes.length > 0) { - for (const type of sourcePropertyTypes) { - if (hasType(target, type)) { - addConnections([type], [rdfs.subPropertyOf], [rdfs.subPropertyOf]); + const targetPropertyTypes = this.propertyTypes.filter(type => hasType(target, type)); + if (targetPropertyTypes.length > 0) { + addConnections(targetPropertyTypes, [], [rdfs.domain, rdfs.range]); + } } - } - - if (hasType(target, owl.Class)) { - addConnections([owl.Class], [rdfs.domain, rdfs.range], []); - } - } - return connections; - } - - async canModifyEntity( - entity: Reactodia.ElementModel, - options: { readonly signal?: AbortSignal; } - ): Promise { - await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal}); - const editable = entity.types.some(type => this.editableTypes.has(type)); - return { - canChangeIri: entity.types.includes(owl.Class), - canEdit: editable, - canDelete: editable, - }; - } + const sourcePropertyTypes = this.propertyTypes.filter(type => hasType(source, type)); + if (sourcePropertyTypes.length > 0) { + for (const type of sourcePropertyTypes) { + if (hasType(target, type)) { + addConnections([type], [rdfs.subPropertyOf], [rdfs.subPropertyOf]); + } + } + + if (hasType(target, owl.Class)) { + addConnections([owl.Class], [rdfs.domain, rdfs.range], []); + } + } - async canModifyRelation( - link: Reactodia.LinkModel, - source: Reactodia.ElementModel, - target: Reactodia.ElementModel, - options: { readonly signal?: AbortSignal; } - ): Promise { - await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal}); - switch (link.linkTypeId) { - case rdfs.domain: - case rdfs.range: - case rdfs.subClassOf: - case rdfs.subPropertyOf: { + return connections; + }, + canModifyEntity: async (entity, {signal}) => { + await Reactodia.delay(SIMULATED_DELAY, {signal}); + const editable = entity.types.some(type => this.editableTypes.has(type)); return { - canChangeType: true, - canEdit: this.editableRelations.has(link.linkTypeId), - canDelete: true, + canChangeIri: entity.types.includes(owl.Class), + canEdit: editable, + canDelete: editable, }; - } - default: { - return {}; - } - } - } - - async getEntityShape( - types: ReadonlyArray, - options: { readonly signal?: AbortSignal; } - ): Promise { - await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal}); - const properties = new Map(); - if (types.some(type => this.editableTypes.has(type))) { - properties.set(rdfs.comment, { - valueShape: {termType: 'Literal'}, - }); - properties.set(Reactodia.rdfs.label, { - valueShape: {termType: 'Literal'}, - }); - properties.set(Reactodia.schema.thumbnailUrl, { - valueShape: {termType: 'NamedNode'}, - maxCount: 1, - }); - properties.set(rdfs.seeAlso, { - valueShape: {termType: 'NamedNode'}, - maxCount: 1, - }); - } - return { - extraProperty: { - valueShape: {termType: 'Literal'}, }, - properties, - }; - } - - async getRelationShape( - linkType: Reactodia.LinkTypeIri, - options: { readonly signal?: AbortSignal; } - ): Promise { - await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal}); - const properties = new Map(); - if (this.editableRelations.has(linkType)) { - properties.set(rdfs.comment, { - valueShape: {termType: 'Literal'}, - }); - } - return {properties}; - } - - async filterConstructibleTypes( - types: ReadonlySet, - options: { readonly signal?: AbortSignal } - ): Promise> { - await Reactodia.delay(SIMULATED_DELAY, {signal: options.signal}); - return new Set(Array.from(types).filter(type => this.editableTypes.has(type))); + canModifyRelation: async (link, source, target, {signal}) => { + await Reactodia.delay(SIMULATED_DELAY, {signal}); + switch (link.linkTypeId) { + case rdfs.domain: + case rdfs.range: + case rdfs.subClassOf: + case rdfs.subPropertyOf: { + return { + canChangeType: true, + canEdit: this.editableRelations.has(link.linkTypeId), + canDelete: true, + }; + } + default: { + return {}; + } + } + }, + getEntityShape: async (types, {signal}) => { + await Reactodia.delay(SIMULATED_DELAY, {signal}); + const properties = new Map(); + if (types.some(type => this.editableTypes.has(type))) { + properties.set(rdfs.comment, { + valueShape: {termType: 'Literal'}, + }); + properties.set(Reactodia.rdfs.label, { + valueShape: {termType: 'Literal'}, + }); + properties.set(Reactodia.schema.thumbnailUrl, { + valueShape: {termType: 'NamedNode'}, + maxCount: 1, + }); + properties.set(rdfs.seeAlso, { + valueShape: {termType: 'NamedNode'}, + maxCount: 1, + }); + } + return { + extraProperty: { + valueShape: {termType: 'Literal'}, + }, + properties, + }; + }, + getRelationShape: async (linkType, {signal}) => { + await Reactodia.delay(SIMULATED_DELAY, {signal: signal}); + const properties = new Map(); + if (this.editableRelations.has(linkType)) { + properties.set(rdfs.comment, { + valueShape: {termType: 'Literal'}, + }); + } + return {properties}; + }, + filterConstructibleTypes: async (types, {signal}) => { + await Reactodia.delay(SIMULATED_DELAY, {signal: signal}); + return new Set(Array.from(types).filter(type => this.editableTypes.has(type))); + } + }); } } diff --git a/examples/stressTest.tsx b/examples/stressTest.tsx index 4385d4f9..a35fafb6 100644 --- a/examples/stressTest.tsx +++ b/examples/stressTest.tsx @@ -82,9 +82,7 @@ function createLayout(factory: Reactodia.Rdf.DataFactory, options: { const nodeType = factory.namedNode('urn:test:Node'); const linkType = factory.namedNode('urn:test:link'); - const makeNodeIri = (n: number) => factory.namedNode( - `urn:test:n:${n}` as Reactodia.ElementIri - ); + const makeNodeIri = (n: number) => factory.namedNode(`urn:test:n:${n}`); const elementIris: Reactodia.ElementIri[] = []; const quads: Reactodia.Rdf.Quad[] = []; diff --git a/src/data/metadataProvider.ts b/src/data/metadataProvider.ts index a901d0d9..63e7b9e6 100644 --- a/src/data/metadataProvider.ts +++ b/src/data/metadataProvider.ts @@ -9,7 +9,7 @@ import type { * * **Unstable**: this interface will likely change in the future. * - * It is recommended to extend {@link EmptyMetadataProvider} instead of + * It is recommended to extend {@link BaseMetadataProvider} instead of * implementing this interface directly to stay compatible with future versions. * * @category Core @@ -119,15 +119,23 @@ export type MetadataValueShape = }; /** - * Metadata provider which does not allow to change anything in the graph - * and returns nothing or empty metadata when requested. + * Metadata provider to use as a stable base to implement {@link MetadataProvider} + * interface. * * @category Core */ -export class EmptyMetadataProvider implements MetadataProvider { +export class BaseMetadataProvider implements MetadataProvider { + private readonly methods: Partial; private readonly emptyProperties = new Map(); + constructor(methods: Partial = {}) { + this.methods = methods; + } + getLiteralLanguages(): ReadonlyArray { + if (this.methods.getLiteralLanguages) { + return this.methods.getLiteralLanguages(); + } return []; } @@ -135,6 +143,9 @@ export class EmptyMetadataProvider implements MetadataProvider { type: ElementTypeIri, options: { readonly signal?: AbortSignal; } ): Promise { + if (this.methods.createEntity) { + return this.methods.createEntity(type, options); + } return { id: '', types: [], @@ -148,6 +159,9 @@ export class EmptyMetadataProvider implements MetadataProvider { linkType: LinkTypeIri, options: { readonly signal?: AbortSignal; } ): Promise { + if (this.methods.createRelation) { + return this.methods.createRelation(source, target, linkType, options); + } return { linkTypeId: linkType, sourceId: source.id, @@ -162,6 +176,9 @@ export class EmptyMetadataProvider implements MetadataProvider { linkType: LinkTypeIri | undefined, options: { readonly signal?: AbortSignal; } ): Promise { + if (this.methods.canConnect) { + return this.methods.canConnect(source, target, linkType, options); + } return []; } @@ -169,6 +186,9 @@ export class EmptyMetadataProvider implements MetadataProvider { entity: ElementModel, options: { readonly signal?: AbortSignal; } ): Promise { + if (this.methods.canModifyEntity) { + return this.methods.canModifyEntity(entity, options); + } return {}; } @@ -178,6 +198,9 @@ export class EmptyMetadataProvider implements MetadataProvider { target: ElementModel, options: { readonly signal?: AbortSignal; } ): Promise { + if (this.methods.canModifyRelation) { + this.methods.canModifyRelation(link, source, target, options); + } return {}; } @@ -185,6 +208,9 @@ export class EmptyMetadataProvider implements MetadataProvider { types: ReadonlyArray, options: { readonly signal?: AbortSignal; } ): Promise { + if (this.methods.getEntityShape) { + return this.methods.getEntityShape(types, options); + } return { properties: this.emptyProperties, }; @@ -194,6 +220,9 @@ export class EmptyMetadataProvider implements MetadataProvider { linkType: LinkTypeIri, options: { readonly signal?: AbortSignal; } ): Promise { + if (this.methods.getRelationShape) { + return this.methods.getRelationShape(linkType, options); + } return { properties: this.emptyProperties, }; @@ -203,6 +232,9 @@ export class EmptyMetadataProvider implements MetadataProvider { types: ReadonlySet, options: { readonly signal?: AbortSignal; } ): Promise> { + if (this.methods.filterConstructibleTypes) { + return this.methods.filterConstructibleTypes(types, options); + } return new Set(); } } diff --git a/src/workspace.ts b/src/workspace.ts index 42fb4b0c..164c00a6 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -23,7 +23,7 @@ export * from './data/dataProvider'; export * from './data/model'; export { MetadataProvider, MetadataCanConnect, MetadataCanModifyEntity, MetadataCanModifyRelation, - MetadataEntityShape, MetadataRelationShape, MetadataPropertyShape, EmptyMetadataProvider, + MetadataEntityShape, MetadataRelationShape, MetadataPropertyShape, BaseMetadataProvider, } from './data/metadataProvider'; export { ValidationProvider, ValidationEvent, ValidationResult, ValidatedElement, ValidatedLink, From 245b104d4e164a42fe505663ffbdd857787ab903 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Fri, 13 Jun 2025 02:32:09 +0300 Subject: [PATCH 2/3] Follow-up finalizations: * Warn on multiple registered hotkeys with the same keys and only run the first one; * Allow to full override property editor for relations the same way as for entities via `propertyEditor`; * Move `useLoadedWorkspace()` callback execution into a microtask to avoid React warnings when calling `syncUpdate()`; * Export `DefaultLinkRouter`, `TypedElementResolver`; * Fix item borders for up-expanded dropdown menus; --- examples/styleCustomization.tsx | 9 ++- src/diagram/renderingState.ts | 10 ++- src/widgets/toolbarAction.tsx | 1 - .../visualAuthoring/visualAuthoring.tsx | 65 +++++++++++++++++-- src/workspace.ts | 3 +- src/workspace/workspace.tsx | 4 ++ styles/utility/_dropdown.scss | 12 +++- 7 files changed, 86 insertions(+), 18 deletions(-) diff --git a/examples/styleCustomization.tsx b/examples/styleCustomization.tsx index fb52b1d2..4b9361fd 100644 --- a/examples/styleCustomization.tsx +++ b/examples/styleCustomization.tsx @@ -36,13 +36,13 @@ function StyleCustomizationExample() { { - if (types.indexOf('http://www.w3.org/2000/01/rdf-schema#Class') !== -1) { + if (types.includes('http://www.w3.org/2000/01/rdf-schema#Class')) { return {icon: CERTIFICATE_ICON, iconMonochrome: true}; - } else if (types.indexOf('http://www.w3.org/2002/07/owl#Class') !== -1) { + } else if (types.includes('http://www.w3.org/2002/07/owl#Class')) { return {icon: CERTIFICATE_ICON, iconMonochrome: true}; - } else if (types.indexOf('http://www.w3.org/2002/07/owl#ObjectProperty') !== -1) { + } else if (types.includes('http://www.w3.org/2002/07/owl#ObjectProperty')) { return {icon: COG_ICON, iconMonochrome: true}; - } else if (types.indexOf('http://www.w3.org/2002/07/owl#DatatypeProperty') !== -1) { + } else if (types.includes('http://www.w3.org/2002/07/owl#DatatypeProperty')) { return {color: '#00b9f2'}; } else { return undefined; @@ -155,7 +155,6 @@ const DoubleArrowLinkTemplate: Reactodia.LinkTemplate = { width: 20, height: 12, }, - spline: 'smooth', renderLink: props => ( void): () => void { multimapAdd(this.hotkeyHandlers, ast, handler); + if (this.hotkeyHandlers.get(ast)!.size === 2) { + console.warn( + 'Reactodia: registered multiple handlers for the same hotkey ' + + `"${formatHotkey(ast)}" but only the first one will run if triggered.` + ); + } return () => { multimapDelete(this.hotkeyHandlers, ast, handler); }; @@ -423,6 +429,8 @@ export class MutableRenderingState implements RenderingState { for (const handler of handlers) { e.preventDefault(); handler(); + // Use only the first handler and skip the rest + break; } } } diff --git a/src/widgets/toolbarAction.tsx b/src/widgets/toolbarAction.tsx index fc90c328..6248ae89 100644 --- a/src/widgets/toolbarAction.tsx +++ b/src/widgets/toolbarAction.tsx @@ -16,7 +16,6 @@ import { AuthoringState } from '../editor/authoringState'; import { DropdownMenuItem, useInsideDropdown } from './utility/dropdown'; import { useWorkspace } from '../workspace/workspaceContext'; -import { EventObserver } from '../workspace'; const CLASS_NAME = 'reactodia-toolbar-action'; diff --git a/src/widgets/visualAuthoring/visualAuthoring.tsx b/src/widgets/visualAuthoring/visualAuthoring.tsx index 9afe7a35..76f45ab2 100644 --- a/src/widgets/visualAuthoring/visualAuthoring.tsx +++ b/src/widgets/visualAuthoring/visualAuthoring.tsx @@ -4,7 +4,7 @@ import { EventObserver } from '../../coreUtils/events'; import { useObservedProperty } from '../../coreUtils/hooks'; import { Debouncer } from '../../coreUtils/scheduler'; -import type { ElementModel } from '../../data/model'; +import type { ElementModel, LinkModel } from '../../data/model'; import { defineCanvasWidget } from '../../diagram/canvasWidget'; import { Link } from '../../diagram/elements'; import { Size } from '../../diagram/geometry'; @@ -56,25 +56,59 @@ export interface VisualAuthoringProps { * Provides custom editor for the entity data. */ export type PropertyEditor = (options: PropertyEditorOptions) => React.ReactElement; + /** * Parameters for {@link PropertyEditor}. */ -export interface PropertyEditorOptions { +export type PropertyEditorOptions = + | PropertyEditorOptionsEntity + | PropertyEditorOptionsRelation; + +/** + * Parameters for {@link PropertyEditor} for an entity target. + */ +export interface PropertyEditorOptionsEntity { + /** + * Type for the target to edit. + */ + readonly type: 'entity'; /** * Target entity data to edit. */ - elementData: ElementModel; + readonly elementData: ElementModel; /** * Handler to submit changed entity data. * * Changed data may have a different entity IRI ({@link ElementModel.id}) * in case when the entity identity needs to be changed. */ - onSubmit: (newData: ElementModel) => void; + readonly onSubmit: (newData: ElementModel) => void; /** * Handler to abort changing the entity, discarding the operation. */ - onCancel?: () => void; + readonly onCancel?: () => void; +} + +/** + * Parameters for {@link PropertyEditor} for a relation target. + */ +export interface PropertyEditorOptionsRelation { + /** + * Type for the target to edit. + */ + readonly type: 'relation'; + /** + * Target relation data to edit. + */ + readonly linkData: LinkModel; + /** + * Handler to submit changed relation data. + */ + readonly onSubmit: (newData: LinkModel) => void; + /** + * Handler to abort changing the relation, discarding the operation. + */ + readonly onCancel?: () => void; } /** @@ -159,7 +193,14 @@ export function VisualAuthoring(props: VisualAuthoringProps) { modelToEdit = {...target.data, id: event.newIri}; } const onCancel = () => overlay.hideDialog(); - const content = propertyEditor ? propertyEditor({elementData: target.data, onSubmit, onCancel}) : ( + const content = propertyEditor ? ( + propertyEditor({ + type: 'entity', + elementData: target.data, + onSubmit, + onCancel, + }) + ) : ( { - const content = ( + const content = propertyEditor ? ( + propertyEditor({ + type: 'relation', + linkData: link.data, + onSubmit: newData => { + editor.changeRelation(link.data, newData); + overlay.hideDialog(); + }, + onCancel: () => overlay.hideDialog(), + }) + ) : ( { const task = context.overlay.startTask(); try { + // Move execution into a microtask to avoid React warnings + // when calling RenderingState.syncUpdate() + await Promise.resolve(); + await latestOnLoad({context, signal: controller.signal}); } catch (err) { if (!controller.signal.aborted) { diff --git a/styles/utility/_dropdown.scss b/styles/utility/_dropdown.scss index 9ad132bc..41476e31 100644 --- a/styles/utility/_dropdown.scss +++ b/styles/utility/_dropdown.scss @@ -81,6 +81,11 @@ border-bottom-right-radius: theme.$button-border-radius; } + .reactodia-dropdown--down & ~ & { + margin-top: calc(-1 * theme.$button-border-width); + border-top-color: theme.$button-default-border-color; + } + .reactodia-dropdown--up &:first-child { border-bottom-right-radius: theme.$button-border-radius; } @@ -90,10 +95,11 @@ border-top-right-radius: theme.$button-border-radius; } - & ~ & { - margin-top: calc(-1 * theme.$button-border-width); - border-top-color: theme.$button-default-border-color; + .reactodia-dropdown--up & ~ & { + margin-bottom: calc(-1 * theme.$button-border-width); + border-bottom-color: theme.$button-default-border-color; } + &--disabled, &--disabled:hover { color: theme.$button-default-color; background-color: theme.$input-background-color-disabled; From 905a58ae0c0f838da29a475165ce9259c68855ee Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Sat, 14 Jun 2025 00:08:55 +0300 Subject: [PATCH 3/3] Follow-up finalizations: * Deprecate `Translation.formatIri()` instead of full removal; * Clarify JSDoc for some components; * Move `webcola` from `dependencies` to `devDependencies`; --- CHANGELOG.md | 2 +- examples/rdfExplorer.tsx | 11 ++++++++--- examples/sparql.tsx | 11 ++++++----- package-lock.json | 9 +++++++-- package.json | 4 ++-- src/coreUtils/hotkey.ts | 2 +- src/coreUtils/i18n.tsx | 9 +++++++++ src/diagram/canvasApi.ts | 3 ++- src/diagram/elementLayer.tsx | 2 -- src/diagram/linkLayer.tsx | 1 - src/diagram/locale.tsx | 8 ++++++++ src/diagram/paper.tsx | 9 ++++++--- 12 files changed, 50 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3de26a6f..f134ddb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p #### 💅 Polish - Export `BaseMetadataProvider` as a stable base to instantiate or extend when implementing custom metadata providers. - Re-use and un-deprecate `model.locale` formatting object with `DataLocaleProvider` interface type: - * **[💥Breaking]**: Remove `Translation.formatIri()` in favor of `locale.formatIri()`; + * Deprecate `Translation.formatIri()` in favor of `locale.formatIri()`; * Replace other deprecated methods of `locale` with: `selectEntityLabel()`, `selectEntityImageUrl()`, `formatEntityLabel()`, `formatEntityTypeList()`; - Provide gradual customization options for the built-in entity and relation property editor: * Expose ability to customize property input in authoring forms with `inputResolver` option for `VisualAuthoring` component; diff --git a/examples/rdfExplorer.tsx b/examples/rdfExplorer.tsx index 95e366c0..80ef1bc8 100644 --- a/examples/rdfExplorer.tsx +++ b/examples/rdfExplorer.tsx @@ -49,9 +49,14 @@ function RdfExample() { } languages={[ {code: 'de', label: 'Deutsch'}, - {code: 'en', label: 'english'}, - {code: 'es', label: 'español'}, - {code: 'ru', label: 'русский'}, + {code: 'en', label: 'English'}, + {code: 'es', label: 'Español'}, + {code: 'fr', label: 'Français'}, + {code: 'hi', label: 'हिन्दी'}, + {code: 'it', label: 'Italiano'}, + {code: 'ja', label: '日本語'}, + {code: 'pt', label: 'português'}, + {code: 'ru', label: 'Русский'}, {code: 'zh', label: '汉语'}, ]} /> diff --git a/examples/sparql.tsx b/examples/sparql.tsx index 08d4284c..f95be9c1 100644 --- a/examples/sparql.tsx +++ b/examples/sparql.tsx @@ -73,13 +73,14 @@ function SparqlExample() { ]} languages={[ {code: 'de', label: 'Deutsch'}, - {code: 'en', label: 'english'}, - {code: 'es', label: 'español'}, - {code: 'fr', label: 'français'}, - {code: 'ja', label: '日本語'}, + {code: 'en', label: 'English'}, + {code: 'es', label: 'Español'}, + {code: 'fr', label: 'Français'}, {code: 'hi', label: 'हिन्दी'}, + {code: 'it', label: 'Italiano'}, + {code: 'ja', label: '日本語'}, {code: 'pt', label: 'português'}, - {code: 'ru', label: 'русский'}, + {code: 'ru', label: 'Русский'}, {code: 'zh', label: '汉语'}, ]} /> diff --git a/package-lock.json b/package-lock.json index cdeea854..3f9a8abd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,7 @@ "clsx": "^2.1.1", "d3-color": "^3.1.0", "file-saver": "^2.0.5", - "n3": "^1.23.1", - "webcola": "~3.3.8" + "n3": "^1.23.1" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -51,6 +50,7 @@ "typescript-eslint": "^8.25.0", "use-sync-external-store": "^1.4.0", "vitest": "^3.0.9", + "webcola": "~3.3.8", "webpack": "^5.98.0", "webpack-cli": "^6.0.1", "webpack-dev-middleware": "^7.4.2" @@ -3643,12 +3643,14 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/d3-drag": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz", "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "d3-dispatch": "1", @@ -3659,12 +3661,14 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/d3-timer": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/data-view-buffer": { @@ -9424,6 +9428,7 @@ "version": "3.3.9", "resolved": "https://registry.npmjs.org/webcola/-/webcola-3.3.9.tgz", "integrity": "sha512-hjE23yiRU+7AGajxuDdUDW8txyMVgXHCW71erA0UVKYx0lruqs1o4QFQ0OSpSdNS6wlAwgk0IPxhuEiQq1MXfQ==", + "dev": true, "license": "MIT", "dependencies": { "d3-dispatch": "^1.0.3", diff --git a/package.json b/package.json index adb7d9c8..2d7d96d1 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,7 @@ "clsx": "^2.1.1", "d3-color": "^3.1.0", "file-saver": "^2.0.5", - "n3": "^1.23.1", - "webcola": "~3.3.8" + "n3": "^1.23.1" }, "peerDependencies": { "react": "^17.0.2 || ^18 || ^19", @@ -84,6 +83,7 @@ "typescript-eslint": "^8.25.0", "use-sync-external-store": "^1.4.0", "vitest": "^3.0.9", + "webcola": "~3.3.8", "webpack": "^5.98.0", "webpack-cli": "^6.0.1", "webpack-dev-middleware": "^7.4.2" diff --git a/src/coreUtils/hotkey.ts b/src/coreUtils/hotkey.ts index b15f35ff..726051e4 100644 --- a/src/coreUtils/hotkey.ts +++ b/src/coreUtils/hotkey.ts @@ -92,7 +92,7 @@ export function formatHotkey(ast: HotkeyAst): string { result += 'Ctrl+'; } if (modifiers & HotkeyModifier.Meta) { - result += '⌘+'; + result += IsMac ? '⌘+' : 'Meta+'; } if (modifiers & HotkeyModifier.Alt) { result += 'Alt+'; diff --git a/src/coreUtils/i18n.tsx b/src/coreUtils/i18n.tsx index c2e992ac..c51272df 100644 --- a/src/coreUtils/i18n.tsx +++ b/src/coreUtils/i18n.tsx @@ -124,6 +124,15 @@ export interface Translation { fallbackIri: string, language: string ): string; + + /** + * Formats IRI to display in the UI: + * - usual IRIs are enclosed in ``; + * - anonymous element IRIs displayed as `(blank node)`. + * + * @deprecated Use {@link DataLocaleProvider.formatIri} instead. + */ + formatIri(iri: string): string; } /** diff --git a/src/diagram/canvasApi.ts b/src/diagram/canvasApi.ts index 26e33ce1..8d492032 100644 --- a/src/diagram/canvasApi.ts +++ b/src/diagram/canvasApi.ts @@ -110,7 +110,8 @@ export interface CanvasApi { */ zoomToFitRect(paperRect: Rect, options?: ViewportOptions): Promise; /** - * Exports the diagram as a serialized SVG document (XML text content). + * Exports the diagram as a serialized into text SVG document + * with `` HTML layers inside. * * Exported SVG document would include all diagram content as well as every CSS rule * which applies to any DOM element from the diagram content. diff --git a/src/diagram/elementLayer.tsx b/src/diagram/elementLayer.tsx index 9e626815..d1ec4c9a 100644 --- a/src/diagram/elementLayer.tsx +++ b/src/diagram/elementLayer.tsx @@ -441,8 +441,6 @@ function computeIsBlurred(element: Element, view: SharedCanvasState): boolean { * set to the same values as the target element to be able to layout decorations * via CSS. * - * **Unstable**: this feature may change in the future. - * * @category Components */ export function ElementDecoration(props: { diff --git a/src/diagram/linkLayer.tsx b/src/diagram/linkLayer.tsx index 21e42e7f..6129fd0b 100644 --- a/src/diagram/linkLayer.tsx +++ b/src/diagram/linkLayer.tsx @@ -30,7 +30,6 @@ enum UpdateRequest { All, } -/** @hidden */ interface MeasurableLabel { readonly owner: Link; measureBounds(): Rect | undefined; diff --git a/src/diagram/locale.tsx b/src/diagram/locale.tsx index 866020a1..8581e74a 100644 --- a/src/diagram/locale.tsx +++ b/src/diagram/locale.tsx @@ -6,6 +6,7 @@ import { LabelLanguageSelector, Translation, TranslationKey, TranslationBundle, TranslationContext, } from '../coreUtils/i18n'; +import { isEncodedBlank } from '../data/model'; import * as Rdf from '../data/rdf/rdfModel'; export const DefaultTranslationBundle: TranslationBundle = DefaultBundle; @@ -69,6 +70,13 @@ export class DefaultTranslation implements Translation { const label = labels ? this.selectLabel(labels, language) : undefined; return resolveLabel(label, fallbackIri); } + + formatIri(iri: string): string { + if (isEncodedBlank(iri)) { + return '(blank node)'; + } + return `<${iri}>`; + } } function getString( diff --git a/src/diagram/paper.tsx b/src/diagram/paper.tsx index cdcb6a36..42af4f3a 100644 --- a/src/diagram/paper.tsx +++ b/src/diagram/paper.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { Component, CSSProperties } from 'react'; import { Cell, LinkVertex } from './elements'; import { Vector } from './geometry'; @@ -16,7 +15,7 @@ export interface PaperProps { const CLASS_NAME = 'reactodia-paper'; -export class Paper extends Component { +export class Paper extends React.Component { render() { const {paperTransform, children} = this.props; const {width, height, scale, paddingX, paddingY} = paperTransform; @@ -26,7 +25,7 @@ export class Paper extends Component { // using padding instead of margin in combination with setting width and height // on .paper element to avoid "over-constrained" margins, see an explanation here: // https://stackoverflow.com/questions/11695354 - const style: CSSProperties = { + const style: React.CSSProperties = { width: scaledWidth + paddingX, height: scaledHeight + paddingY, marginLeft: paddingX, @@ -120,6 +119,7 @@ export interface PaperTransform { * Props for {@link HtmlPaperLayer} component. * * @see {@link HtmlPaperLayer} + * @hidden */ export interface HtmlPaperLayerProps extends React.HTMLProps { paperTransform: PaperTransform; @@ -132,6 +132,7 @@ export interface HtmlPaperLayerProps extends React.HTMLProps { * **Unstable**: this component will likely change in the future. * * @category Components + * @hidden */ export function HtmlPaperLayer(props: HtmlPaperLayerProps) { const {paperTransform, layerRef, style, children, ...otherProps} = props; @@ -160,6 +161,7 @@ export function HtmlPaperLayer(props: HtmlPaperLayerProps) { * Props for {@link SvgPaperLayer} component. * * @see {@link SvgPaperLayer} + * @hidden */ export interface SvgPaperLayerProps extends React.HTMLProps { paperTransform: PaperTransform; @@ -172,6 +174,7 @@ export interface SvgPaperLayerProps extends React.HTMLProps { * **Unstable**: this component will likely change in the future. * * @category Components + * @hidden */ export function SvgPaperLayer(props: SvgPaperLayerProps) { const {layerRef, paperTransform, style, children, ...otherProps} = props;