From a0e3bbf81a535235df3066d208338526e13586c7 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Thu, 28 May 2026 00:56:27 +0300 Subject: [PATCH] Support creating standalone workspace state (context): * Workspace state can be created with `createWorkspace()` and provided to the UI components via `WorkspaceProvider`. * Deprecate `Workspace` in favor of `WorkspaceProvider`. --- CHANGELOG.md | 9 +- examples/basic.tsx | 9 +- examples/classicWorkspace.tsx | 21 +- examples/graphAuthoring.tsx | 19 +- examples/i18n.tsx | 70 ++--- examples/rdfExplorer.tsx | 9 +- examples/sparql.tsx | 9 +- examples/stressTest.tsx | 9 +- examples/styleCustomization.tsx | 35 ++- examples/wikidata.tsx | 9 +- package.json | 2 +- src/diagram/locale.tsx | 4 +- src/diagram/sharedCanvasState.ts | 8 +- src/editor/editorController.tsx | 25 +- src/editor/overlayController.tsx | 11 +- src/workspace.ts | 6 +- .../{workspace.tsx => workspaceProvider.tsx} | 286 +++++++++--------- src/workspace/workspaceWrapper.tsx | 188 ++++++++++++ 18 files changed, 467 insertions(+), 262 deletions(-) rename src/workspace/{workspace.tsx => workspaceProvider.tsx} (75%) create mode 100644 src/workspace/workspaceWrapper.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c53a986a..957a141a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Make tree- and list-like components more accessible: * Change `ClassTree`, `InstancesSearch`, `ConnectionsMenu` and `SearchResults` to be "focus group" components with support for keyboard interaction (arrow keys to move focus or toggle tree items, space to select); * Add proper `aria-*` attributes for "focus group" containers and children e.g. `tree` and `treeitem` roles; +- Support creating standalone workspace state (context) separated from UI components: + * Workspace state can be created with `createWorkspace()` and provided to the UI components via `WorkspaceProvider`; + * Allow nested definitions of `WorkspaceRoot` to use built-in styling outside the workspace component. #### 🐛 Fixed - Fix partially or fully hidden outlines for `WorkspaceLayoutItem` headers and `Navigator` toggle button. @@ -16,14 +19,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Fix canvas panning optimization not being applied due to incorrect `z-index` value. #### 💅 Polish -- Allow to define `WorkspaceRoot` and `TranslationProvider` as parent to the `Workspace` component so they can be shared between multiple workspaces or used outside the workspace itself: - * Deprecate `translations`, `useDefaultTranslation` and `selectLabelLanguage` props on `Workspace` component in favor of wrapping the workspace in `TranslationProvider` with (newly) exported `DefaultTranslation`; +- Export `TranslationProvider` and `DefaultTranslation` to be able to use `useTranslation()` outside the workspace component: * Remove deprecated `Translation.formatIri()` method (use `DataLocaleProvider.formatIri()` instead). +- Always display ungroup buttons on `StandardGroup` when the element is single-selected. - Allow to configure `SearchResults` utility component with `isItemDisabled` and `multiSelection` props: * Remove `singleSelectOnClick` mode from `SearchResults` as it mostly superseded by `multiSelection`. - Extend `ListElementView` utility component to accept any other additional HTML props. -- Export `AccessibleList` component as base for [accessible](https://www.w3.org/TR/wai-aria/) list-like container. -- Always display ungroup buttons on `StandardGroup` when the element is single-selected. ## [0.34.1] - 2026-03-30 #### 🐛 Fixed diff --git a/examples/basic.tsx b/examples/basic.tsx index c246bedb..08f4de45 100644 --- a/examples/basic.tsx +++ b/examples/basic.tsx @@ -16,6 +16,9 @@ const Layouts = Reactodia.defineLayoutWorker(() => new Worker( function BasicExample() { const {defaultLayout} = Reactodia.useWorker(Layouts); + const [workspace] = React.useState(() => Reactodia.createWorkspace({ + defaultLayout, + })); const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { const {model, performLayout} = context; @@ -38,10 +41,10 @@ function BasicExample() { }, []); return ( - + - + ); } diff --git a/examples/classicWorkspace.tsx b/examples/classicWorkspace.tsx index 7095cb3a..b4170589 100644 --- a/examples/classicWorkspace.tsx +++ b/examples/classicWorkspace.tsx @@ -17,6 +17,13 @@ const Layouts = Reactodia.defineLayoutWorker(() => new Worker( function ClassicWorkspaceExample() { const {defaultLayout} = Reactodia.useWorker(Layouts); + const [workspace] = React.useState(() => Reactodia.createWorkspace({ + defaultLayout, + metadataProvider: new ExampleMetadataProvider(), + validationProvider: new ExampleValidationProvider(), + renameLinkProvider: new RenameSubclassOfProvider(), + typeStyleResolver: SemanticTypeStyles, + })); const [turtleData, setTurtleData] = React.useState(TURTLE_DATA); const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { @@ -39,17 +46,9 @@ function ClassicWorkspaceExample() { }); }, [turtleData]); - const [metadataProvider] = React.useState(() => new ExampleMetadataProvider()); - const [validationProvider] = React.useState(() => new ExampleValidationProvider()); - const [renameLinkProvider] = React.useState(() => new RenameSubclassOfProvider()); - return ( - + { @@ -74,7 +73,7 @@ function ClassicWorkspaceExample() { ), }} /> - + ); } diff --git a/examples/graphAuthoring.tsx b/examples/graphAuthoring.tsx index 7dd36e16..50f5d035 100644 --- a/examples/graphAuthoring.tsx +++ b/examples/graphAuthoring.tsx @@ -20,6 +20,12 @@ const Layouts = Reactodia.defineLayoutWorker(() => new Worker( function GraphAuthoringExample() { const {defaultLayout} = Reactodia.useWorker(Layouts); + const [workspace] = React.useState(() => Reactodia.createWorkspace({ + defaultLayout, + metadataProvider: new ExampleMetadataProvider(), + validationProvider: new ExampleValidationProvider(), + renameLinkProvider: new RenameSubclassOfProvider(), + })); const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { const {model, editor, translation: t, performLayout} = context; @@ -62,16 +68,9 @@ function GraphAuthoringExample() { } }, []); - const [metadataProvider] = React.useState(() => new ExampleMetadataProvider()); - const [validationProvider] = React.useState(() => new ExampleValidationProvider()); - const [renameLinkProvider] = React.useState(() => new RenameSubclassOfProvider()); - return ( - + } visualAuthoring={{ @@ -110,7 +109,7 @@ function GraphAuthoringExample() { {code: 'ja', label: '日本語'}, ]} /> - + ); } diff --git a/examples/i18n.tsx b/examples/i18n.tsx index d5ab62e4..ac56494d 100644 --- a/examples/i18n.tsx +++ b/examples/i18n.tsx @@ -14,33 +14,35 @@ const Layouts = Reactodia.defineLayoutWorker(() => new Worker( function I18nExample() { const {defaultLayout} = Reactodia.useWorker(Layouts); - - const translation = React.useMemo(() => new Reactodia.DefaultTranslation({ - bundles: [ - { - 'default_workspace': { - 'search_section_entities.label': 'Nodes', - 'search_section_entities.title': 'Graph Nodes Lookup', - 'search_section_entity_types.label': 'Node Types', - 'search_section_entity_types.title': 'Graph Node Type Hierarchy', - 'search_section_link_types.label': 'Edge Types', - 'search_section_link_types.title': 'Graph Edge Types on the diagram' - }, - 'search_defaults': { - 'input_term_too_short': 'Minimum search term length is {{termLength}}', - }, - 'search_entities': { - 'criteria_connected_to_source': - '{{sourceIcon}}\u00A0{{entity}} (source) via {{relationType}}', - 'criteria_connected_to_target': - '{{targetIcon}}\u00A0{{entity}} (target) via {{relationType}}', - }, - 'toolbar_action': { - 'layout.label': 'Layout the graph', - }, - } - ] - }), []); + const [workspace] = React.useState(() => Reactodia.createWorkspace({ + translation: new Reactodia.DefaultTranslation({ + bundles: [ + { + 'default_workspace': { + 'search_section_entities.label': 'Nodes', + 'search_section_entities.title': 'Graph Nodes Lookup', + 'search_section_entity_types.label': 'Node Types', + 'search_section_entity_types.title': 'Graph Node Type Hierarchy', + 'search_section_link_types.label': 'Edge Types', + 'search_section_link_types.title': 'Graph Edge Types on the diagram' + }, + 'search_defaults': { + 'input_term_too_short': 'Minimum search term length is {{termLength}}', + }, + 'search_entities': { + 'criteria_connected_to_source': + '{{sourceIcon}}\u00A0{{entity}} (source) via {{relationType}}', + 'criteria_connected_to_target': + '{{targetIcon}}\u00A0{{entity}} (target) via {{relationType}}', + }, + 'toolbar_action': { + 'layout.label': 'Layout the graph', + }, + } + ], + }), + defaultLayout, + })); const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { const {model} = context; @@ -58,14 +60,12 @@ function I18nExample() { }, []); return ( - - - } - /> - - + + } + /> + ); } diff --git a/examples/rdfExplorer.tsx b/examples/rdfExplorer.tsx index f4589ca9..0b7150d4 100644 --- a/examples/rdfExplorer.tsx +++ b/examples/rdfExplorer.tsx @@ -14,6 +14,9 @@ const Layouts = Reactodia.defineLayoutWorker(() => new Worker( function RdfExample() { const {defaultLayout} = Reactodia.useWorker(Layouts); + const [workspace] = React.useState(() => Reactodia.createWorkspace({ + defaultLayout, + })); const [turtleData, setTurtleData] = React.useState(TURTLE_DATA); const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { @@ -41,8 +44,8 @@ function RdfExample() { }, [turtleData]); return ( - + @@ -63,7 +66,7 @@ function RdfExample() { {code: 'zh', label: '汉语'}, ]} /> - + ); } diff --git a/examples/sparql.tsx b/examples/sparql.tsx index ed893246..d7572637 100644 --- a/examples/sparql.tsx +++ b/examples/sparql.tsx @@ -19,6 +19,9 @@ const Layouts = Reactodia.defineLayoutWorker(() => new Worker( function SparqlExample() { const {defaultLayout} = Reactodia.useWorker(Layouts); + const [workspace] = React.useState(() => Reactodia.createWorkspace({ + defaultLayout, + })); const [connectionSettings, setConnectionSettings] = React.useState( (): SparqlConnectionSettings | undefined => { @@ -61,8 +64,8 @@ function SparqlExample() { }, [connectionSettings]); return ( - + } languages={[ @@ -84,7 +87,7 @@ function SparqlExample() { /> - + ); } diff --git a/examples/stressTest.tsx b/examples/stressTest.tsx index ff5bf4fd..e6161e90 100644 --- a/examples/stressTest.tsx +++ b/examples/stressTest.tsx @@ -11,6 +11,9 @@ const Layouts = Reactodia.defineLayoutWorker(() => new Worker( function StressTestExample() { const {defaultLayout} = Reactodia.useWorker(Layouts); + const [workspace] = React.useState(() => Reactodia.createWorkspace({ + defaultLayout, + })); const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { const {model, view} = context; @@ -58,15 +61,15 @@ function StressTestExample() { }, []); return ( - + } navigator={{ expanded: false, }} /> - + ); } diff --git a/examples/styleCustomization.tsx b/examples/styleCustomization.tsx index 767cf8ea..0a8cf249 100644 --- a/examples/styleCustomization.tsx +++ b/examples/styleCustomization.tsx @@ -19,6 +19,22 @@ const Layouts = Reactodia.defineLayoutWorker(() => new Worker( function StyleCustomizationExample() { const {defaultLayout} = Reactodia.useWorker(Layouts); + const [workspace] = React.useState(() => Reactodia.createWorkspace({ + defaultLayout, + typeStyleResolver: types => { + if (types.includes('http://www.w3.org/2000/01/rdf-schema#Class')) { + return {icon: CERTIFICATE_ICON, iconMonochrome: true}; + } else if (types.includes('http://www.w3.org/2002/07/owl#Class')) { + return {icon: CERTIFICATE_ICON, iconMonochrome: true}; + } else if (types.includes('http://www.w3.org/2002/07/owl#ObjectProperty')) { + return {icon: COG_ICON, iconMonochrome: true}; + } else if (types.includes('http://www.w3.org/2002/07/owl#DatatypeProperty')) { + return {color: '#00b9f2'}; + } else { + return undefined; + } + }, + })); const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { const {model} = context; @@ -36,21 +52,8 @@ function StyleCustomizationExample() { }, []); return ( - { - if (types.includes('http://www.w3.org/2000/01/rdf-schema#Class')) { - return {icon: CERTIFICATE_ICON, iconMonochrome: true}; - } else if (types.includes('http://www.w3.org/2002/07/owl#Class')) { - return {icon: CERTIFICATE_ICON, iconMonochrome: true}; - } else if (types.includes('http://www.w3.org/2002/07/owl#ObjectProperty')) { - return {icon: COG_ICON, iconMonochrome: true}; - } else if (types.includes('http://www.w3.org/2002/07/owl#DatatypeProperty')) { - return {color: '#00b9f2'}; - } else { - return undefined; - } - }}> + { @@ -71,7 +74,7 @@ function StyleCustomizationExample() { menu={}> - + ); } diff --git a/examples/wikidata.tsx b/examples/wikidata.tsx index 7ef9be27..15cf0ac0 100644 --- a/examples/wikidata.tsx +++ b/examples/wikidata.tsx @@ -12,6 +12,9 @@ const Layouts = Reactodia.defineLayoutWorker(() => new Worker( function WikidataExample() { const {defaultLayout} = Reactodia.useWorker(Layouts); + const [workspace] = React.useState(() => Reactodia.createWorkspace({ + defaultLayout, + })); const {onMount, getContext} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { const {model, getCommandBus} = context; @@ -65,8 +68,8 @@ function WikidataExample() { }, []); return ( - + @@ -92,7 +95,7 @@ function WikidataExample() { {code: 'zh', label: '汉语'}, ]} /> - + ); } diff --git a/package.json b/package.json index f9159081..39acd230 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@reactodia/workspace", - "version": "0.34.1", + "version": "0.34.1-next", "description": "Reactodia Workspace -- library for visual interaction with graphs in a form of a diagram.", "repository": { "type": "git", diff --git a/src/diagram/locale.tsx b/src/diagram/locale.tsx index 4ec6a825..145e0429 100644 --- a/src/diagram/locale.tsx +++ b/src/diagram/locale.tsx @@ -19,7 +19,7 @@ export class DefaultTranslation implements Translation { private readonly _selectLabel: LabelLanguageSelector; - constructor(options: { + constructor(options?: { /** * Additional translation bundles for UI text strings in the workspace * in order from higher to lower priority. @@ -44,7 +44,7 @@ export class DefaultTranslation implements Translation { bundles = [], useDefaultBundle = true, selectLabel = defaultSelectLabel, - } = options; + } = options ?? {}; const translationBundles: Partial[] = [...bundles]; if (useDefaultBundle) { translationBundles.push(DefaultTranslationBundle); diff --git a/src/diagram/sharedCanvasState.ts b/src/diagram/sharedCanvasState.ts index 8b8e439b..2901b3c8 100644 --- a/src/diagram/sharedCanvasState.ts +++ b/src/diagram/sharedCanvasState.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -import { Events, EventSource, EventObserver, PropertyChange } from '../coreUtils/events'; +import { Events, EventSource, PropertyChange } from '../coreUtils/events'; import { TemplateProperties } from '../data/schema'; @@ -68,15 +68,12 @@ export interface SharedCanvasStateOptions { * @category Core */ export class SharedCanvasState { - private readonly listener = new EventObserver(); private readonly source = new EventSource(); /** * Event for the shared canvas state. */ readonly events: Events = this.source; - private disposed = false; - private dropOnPaperHandler: ((e: CanvasDropEvent) => void) | undefined; private _highlighter: CellHighlighter | undefined; @@ -112,10 +109,7 @@ export class SharedCanvasState { /** @hidden */ dispose(): void { - if (this.disposed) { return; } this.source.trigger('dispose', {source: this}); - this.listener.stopListening(); - this.disposed = true; } /** diff --git a/src/editor/editorController.tsx b/src/editor/editorController.tsx index 8e3ed511..c500288f 100644 --- a/src/editor/editorController.tsx +++ b/src/editor/editorController.tsx @@ -24,6 +24,7 @@ import { ValidationState, changedElementsToValidate, validateElements } from './ export interface EditorProps { readonly model: DataDiagramModel; readonly translation: Translation; + readonly getDisposeSignal: () => AbortSignal; readonly metadataProvider?: MetadataProvider; readonly validationProvider?: ValidationProvider; } @@ -59,7 +60,6 @@ export interface EditorEvents { * @category Core */ export class EditorController { - private readonly listener = new EventObserver(); private readonly source = new EventSource(); /** * Events for the editor controller. @@ -68,6 +68,7 @@ export class EditorController { private readonly model: DataDiagramModel; private readonly translation: Translation; + private readonly getDisposeSignal: () => AbortSignal; private _inAuthoringMode = false; private _metadataProvider: MetadataProvider | undefined; @@ -76,17 +77,19 @@ export class EditorController { private _validationState = ValidationState.empty; private _temporaryState = TemporaryState.empty; - private readonly cancellation = new AbortController(); - /** @hidden */ constructor(props: EditorProps) { - const {model, translation, metadataProvider, validationProvider} = props; + const { + model, translation, metadataProvider, validationProvider, getDisposeSignal, + } = props; this.model = model; this.translation = translation; + this.getDisposeSignal = getDisposeSignal; this._metadataProvider = metadataProvider; this._validationProvider = validationProvider; - this.listener.listen(this.events, 'changeValidationState', e => { + const listener = new EventObserver(); + listener.listen(this.events, 'changeValidationState', e => { for (const element of this.model.elements) { if (!(element instanceof EntityElement)) { continue; @@ -98,20 +101,14 @@ export class EditorController { } } }); - this.listener.listen(this.events, 'changeAuthoringState', e => { + listener.listen(this.events, 'changeAuthoringState', e => { this.validateChangedFrom(e.previous); }); - this.listener.listen(model.events, 'changeCells', e => { + listener.listen(model.events, 'changeCells', e => { this.validateChangedCells(e); }); } - /** @hidden */ - dispose(): void { - this.listener.stopListening(); - this.cancellation.abort(); - } - /** * Returns `true` if the editor is in the graph authoring mode; * otherwise `false`. @@ -272,7 +269,7 @@ export class EditorController { this, this.translation, this.model.language, - this.cancellation.signal + this.getDisposeSignal() ); } diff --git a/src/editor/overlayController.tsx b/src/editor/overlayController.tsx index 5ebc5e65..53a22dfe 100644 --- a/src/editor/overlayController.tsx +++ b/src/editor/overlayController.tsx @@ -99,7 +99,6 @@ export interface DialogSettingsProvider { * @category Core */ export class OverlayController { - private readonly listener = new EventObserver(); private readonly source = new EventSource(); readonly events: Events = this.source; @@ -131,13 +130,14 @@ export class OverlayController { onCanvasKeydown: this.onAnyCanvasKeydown, }; - this.listener.listen(this.model.events, 'changeSelection', () => { + const listener = new EventObserver(); + listener.listen(this.model.events, 'changeSelection', () => { const target = this.model.selection.length === 1 ? this.model.selection[0] : undefined; if (this.openedDialog && this.openedDialog.target !== target) { this.hideDialog(); } }); - this.listener.listen(this.model.events, 'discardGraph', () => { + listener.listen(this.model.events, 'discardGraph', () => { if (this.openedDialog && this.openedDialog.target) { this.hideDialog(); } @@ -153,11 +153,6 @@ export class OverlayController { return this._openedDialog; } - /** @hidden */ - dispose() { - this.listener.stopListening(); - } - private onAnyCanvasPointerUp = (event: CanvasPointerUpEvent): void => { const {source: canvas, sourceEvent, target, triggerAsClick} = event; diff --git a/src/workspace.ts b/src/workspace.ts index 1b6a9021..0cddf167 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -312,13 +312,15 @@ export { } from './workspace/classicWorkspace'; export { DefaultWorkspace, type DefaultWorkspaceProps } from './workspace/defaultWorkspace'; export { - Workspace, type WorkspaceProps, DefaultRenameLinkProvider, + WorkspaceProvider, DefaultRenameLinkProvider, + type CreateWorkspaceParams, type TrackedWorkspaceContext, createWorkspace, type LoadedWorkspace, type LoadedWorkspaceParams, useLoadedWorkspace, -} from './workspace/workspace'; +} from './workspace/workspaceProvider'; export { WorkspaceContext, WorkspaceEventKey, type WorkspacePerformLayoutParams, type ProcessedTypeStyle, useWorkspace, } from './workspace/workspaceContext'; +export { Workspace, type WorkspaceProps } from './workspace/workspaceWrapper'; export { CommandBusTopic, AnnotationTopic, ConnectionsMenuTopic, InstancesSearchTopic, UnifiedSearchTopic, diff --git a/src/workspace/workspace.tsx b/src/workspace/workspaceProvider.tsx similarity index 75% rename from src/workspace/workspace.tsx rename to src/workspace/workspaceProvider.tsx index 7c6ff421..e5dc0982 100644 --- a/src/workspace/workspace.tsx +++ b/src/workspace/workspaceProvider.tsx @@ -4,10 +4,7 @@ import { hcl } from 'd3-color'; import { shallowArrayEqual } from '../coreUtils/collections'; import { EventObserver, EventSource } from '../coreUtils/events'; -import { - LabelLanguageSelector, type Translation, type TranslationBundle, TranslatedText, - TranslationContext, TranslationProvider, -} from '../coreUtils/i18n'; +import { type Translation, TranslatedText, TranslationProvider } from '../coreUtils/i18n'; import { ElementTypeIri } from '../data/model'; import { MetadataProvider } from '../data/metadataProvider'; @@ -44,12 +41,20 @@ import { WorkspaceContext, WorkspaceEventKey, ProcessedTypeStyle, } from './workspaceContext'; +const DEFAULT_LANGUAGE = 'en'; +const DEFAULT_TYPE_STYLE_RESOLVER: TypeStyleResolver = types => undefined; +const TYPE_STYLE_COLOR_SEED = 0x0BADBEEF; + /** - * Props for {@link Workspace} component. - * - * @see {@link Workspace} + * Params for {@link createWorkspace} function. */ -export interface WorkspaceProps { +export interface CreateWorkspaceParams { + /** + * Overrides default i18n (translation) implementation. + * + * By default, {@link DefaultTranslation} instance is used. + */ + translation?: Translation; /** * Overrides default command history implementation. * @@ -92,29 +97,6 @@ export interface WorkspaceProps { * If specified as `null`, the default provider would not be used. */ renameLinkProvider?: RenameLinkProvider | null; - /** - * Additional translation bundles for UI text strings in the workspace - * in order from higher to lower priority. - * - * @default [] - * @see {@link useDefaultTranslation} - * @deprecated Use {@link TranslationProvider} with {@link DefaultTranslation} instead. - */ - translations?: ReadonlyArray>; - /** - * If set, disables translation fallback which (with default `en` language). - * - * @default true - * @see {@link translations} - * @deprecated Use {@link TranslationProvider} with {@link DefaultTranslation} instead. - */ - useDefaultTranslation?: boolean; - /** - * Overrides how a single label gets selected from multiple of them based on target language. - * - * @deprecated Use {@link TranslationProvider} with {@link DefaultTranslation} instead. - */ - selectLabelLanguage?: LabelLanguageSelector; /** * Initial language to display the graph data with. */ @@ -133,26 +115,88 @@ export interface WorkspaceProps { * Handler for a well-known workspace event. */ onWorkspaceEvent?: (key: WorkspaceEventKey) => void; +} + +/** + * Represents a context for the whole workspace, its stores and services. + * + * The context tracks ongoing async operations while it's actively mounted + * with {@link mount()} once or many times at the same time, + * and cancels all operations with {@link WorkspaceContext.disposeSignal} + * when fully unmounted. + * + * @category Core + */ +export interface TrackedWorkspaceContext extends WorkspaceContext { /** - * Component children. + * Mounts the workspace to allow tracking async operations within. + * + * @returns a function to unmount the workspace, cancelling all active + * async operations. */ - children: React.ReactNode; + mount(): () => void; } -const DEFAULT_LANGUAGE = 'en'; -const DEFAULT_TYPE_STYLE_RESOLVER: TypeStyleResolver = types => undefined; -const TYPE_STYLE_COLOR_SEED = 0x0BADBEEF; +/** + * Creates standalone workspace context which stores graph data and provides + * means to display and interact with the diagram. + * + * @see {@link WorkspaceProvider} + */ +export function createWorkspace(params: CreateWorkspaceParams): TrackedWorkspaceContext { + return new RefCountedWorkspaceContext(params); +} /** - * Top-level component which establishes workspace context, which stores - * graph data and provides means to display and interact with the diagram. + * Top-level component to mount and provide specified workspace context + * to the child UI components. * * @category Components + * @see {@link createWorkspace} */ -export class Workspace extends React.Component { - private readonly listener = new EventObserver(); - private readonly cancellation = new AbortController(); +export function WorkspaceProvider(props: { + /** + * Workspace context to provide to the child UI components. + */ + workspace: TrackedWorkspaceContext; + /** + * Handler to run when the context is mounted and UI components are ready. + * + * @see {@link useLoadedWorkspace} + */ + onMount?: (instance: { getContext(): WorkspaceContext } | null) => void; + /** + * Component children. + */ + children: React.ReactNode; +}) { + const {workspace, onMount, children} = props; + + React.useEffect(() => { + const unmount = workspace.mount(); + return unmount; + }, [workspace]); + React.useEffect(() => { + if (onMount) { + const instance = { getContext: () => workspace }; + onMount(instance); + return () => onMount(null); + } + }, [onMount]); + + return ( + + + {children} + + + ); +} + +class RefCountedWorkspaceContext implements WorkspaceContext { + private refCount = 0; + private cancellation = new AbortController(); private readonly extensionCommands = new WeakMap, EventSource>(); private readonly resolveTypeStyle: TypeStyleResolver; @@ -170,47 +214,38 @@ export class Workspace extends React.Component { private readonly layoutTypeProvider: LayoutTypeProvider; - private readonly workspaceContext: WorkspaceContext; - private translation: Translation; - - /** @hidden */ - static contextType = TranslationContext; - /** @hidden */ - declare context: React.ContextType; + readonly model: DataDiagramModel; + readonly view: SharedCanvasState; + readonly editor: EditorController; + readonly overlay: OverlayController; + readonly translation: Translation; - /** @hidden */ - constructor(props: WorkspaceProps, context: unknown) { - super(props, context); + readonly triggerWorkspaceEvent: (key: WorkspaceEventKey) => void; + constructor(params: CreateWorkspaceParams) { const { + translation = new DefaultTranslation(), history = new InMemoryHistory(), dialogSettingsProvider = new DefaultDialogSettingsProvider(), metadataProvider, validationProvider, renameLinkProvider, typeStyleResolver, - selectLabelLanguage, - translations = [], - useDefaultTranslation = true, defaultLanguage = DEFAULT_LANGUAGE, defaultLayout, onWorkspaceEvent = () => {}, - } = this.props; + } = params; - this.translation = this.context ?? new DefaultTranslation({ - bundles: translations, - useDefaultBundle: useDefaultTranslation, - selectLabel: selectLabelLanguage, - }); + this.translation = translation; this.resolveTypeStyle = typeStyleResolver ?? DEFAULT_TYPE_STYLE_RESOLVER; this.cachedTypeStyles = new WeakMap(); this.cachedGroupStyles = new WeakMap(); - const model = new DataDiagramModel({history, translation: this.translation}); - model.setLanguage(defaultLanguage); + this.model = new DataDiagramModel({history, translation: this.translation}); + this.model.setLanguage(defaultLanguage); - const view = new SharedCanvasState({ + this.view = new SharedCanvasState({ defaultElementResolver: element => { if (element instanceof AnnotationElement) { return NoteTemplate; @@ -229,20 +264,23 @@ export class Workspace extends React.Component { ), }); - const editor = new EditorController({ - model, + this.editor = new EditorController({ + model: this.model, translation: this.translation, + getDisposeSignal: () => this.disposeSignal, metadataProvider, validationProvider, }); - const overlay = new OverlayController({ - model, - view, + this.overlay = new OverlayController({ + model: this.model, + view: this.view, translation: this.translation, dialogSettingsProvider, }); + this.triggerWorkspaceEvent = onWorkspaceEvent; + this.layoutTypeProvider = { getElementTypes: element => { if (element instanceof EntityElement) { @@ -252,75 +290,45 @@ export class Workspace extends React.Component { }, }; - this.workspaceContext = { - model, - view, - editor, - overlay, - translation: this.translation, - disposeSignal: this.cancellation.signal, - getCommandBus: this.getCommandBus, - getElementStyle: this.getElementStyle, - getElementTypeStyle: this.getElementTypeStyle, - performLayout: this.onPerformLayout, - group: this.onGroup, - ungroupAll: this.onUngroupAll, - ungroupSome: this.onUngroupSome, - triggerWorkspaceEvent: onWorkspaceEvent, - }; - } - - /** - * Returns top-level workspace context. - */ - getContext(): WorkspaceContext { - return this.workspaceContext; - } - - /** @hidden */ - render() { - const {children} = this.props; - return ( - - - {children} - - - ); - } - - /** @hidden */ - componentDidMount() { - const {onWorkspaceEvent} = this.props; - const {model, view, overlay} = this.workspaceContext; - - this.listener.listen(model.events, 'loadingSuccess', () => { - for (const canvas of view.findAllCanvases()) { + const listener = new EventObserver(); + listener.listen(this.model.events, 'loadingSuccess', () => { + for (const canvas of this.view.findAllCanvases()) { canvas.renderingState.syncUpdate(); void canvas.zoomToFit(); } }); if (onWorkspaceEvent) { - this.listener.listen(model.events, 'changeSelection', () => + listener.listen(this.model.events, 'changeSelection', () => onWorkspaceEvent(WorkspaceEventKey.editorChangeSelection) ); - this.listener.listen(overlay.events, 'changeOpenedDialog', () => + listener.listen(this.overlay.events, 'changeOpenedDialog', () => onWorkspaceEvent(WorkspaceEventKey.editorToggleDialog) ); } } - /** @hidden */ - componentWillUnmount() { - this.listener.stopListening(); - const {view, editor, overlay} = this.workspaceContext; - view.dispose(); - editor.dispose(); - overlay.dispose(); + mount(): () => void { + this.refCount++; + let mounted = true; + return () => { + if (mounted) { + mounted = false; + this.refCount--; + if (this.refCount === 0) { + this.view.dispose(); + this.cancellation.abort(); + this.cancellation = new AbortController(); + } + } + }; + } + + get disposeSignal(): AbortSignal { + return this.cancellation.signal; } - private getCommandBus: WorkspaceContext['getCommandBus'] = (extension) => { + readonly getCommandBus: WorkspaceContext['getCommandBus'] = (extension) => { let commands = this.extensionCommands.get(extension); if (!commands) { commands = new EventSource(); @@ -329,7 +337,7 @@ export class Workspace extends React.Component { return commands; }; - private getElementStyle: WorkspaceContext['getElementStyle'] = element => { + readonly getElementStyle: WorkspaceContext['getElementStyle'] = element => { if (element instanceof EntityElement) { return this.getElementTypeStyle(element.data.types); } else if (element instanceof EntityGroup) { @@ -340,7 +348,7 @@ export class Workspace extends React.Component { } }; - private getElementTypeStyle: WorkspaceContext['getElementTypeStyle'] = types => { + readonly getElementTypeStyle: WorkspaceContext['getElementTypeStyle'] = types => { let processedStyle = this.cachedTypeStyles.get(types); if (!processedStyle) { const customStyle = this.resolveTypeStyle(types); @@ -394,12 +402,12 @@ export class Workspace extends React.Component { return undefined; } - private onPerformLayout: WorkspaceContext['performLayout'] = async params => { + readonly performLayout: WorkspaceContext['performLayout'] = async params => { const { canvas: targetCanvas, layoutFunction, selectedElements, fixedElements, animate, signal, zoomToFit = true, } = params; - const {model, view, overlay, disposeSignal} = this.workspaceContext; + const {model, view, overlay, disposeSignal} = this; const t = this.translation; const canvas = targetCanvas ?? view.findAnyCanvas(); @@ -459,16 +467,16 @@ export class Workspace extends React.Component { } }; - private onGroup: WorkspaceContext['group'] = params => { - return groupEntities(this.workspaceContext, params); + readonly group: WorkspaceContext['group'] = params => { + return groupEntities(this, params); }; - private onUngroupAll: WorkspaceContext['ungroupAll'] = params => { - return ungroupAllEntities(this.workspaceContext, params); + readonly ungroupAll: WorkspaceContext['ungroupAll'] = params => { + return ungroupAllEntities(this, params); }; - private onUngroupSome: WorkspaceContext['ungroupSome'] = params => { - return ungroupSomeEntities(this.workspaceContext, params); + readonly ungroupSome: WorkspaceContext['ungroupSome'] = params => { + return ungroupSomeEntities(this, params); }; } @@ -518,7 +526,7 @@ export interface LoadedWorkspace { * Callback to pass as `ref` to the top-level workspace component * to perform the initialization specified in the hook. */ - readonly onMount: (workspace: Workspace | null) => void; + readonly onMount: (instance: { getContext(): WorkspaceContext } | null) => void; } /** @@ -531,10 +539,14 @@ export interface LoadedWorkspace { * * **Example**: * ```ts - * const {getContext, onMount} = useLoadedWorkspace(); - * + * const workspace = React.useState(() => Reactodia.createWorkspace({...})); + * const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { + * // ... + * }); * return ( - * + * * ... * * ); @@ -618,7 +630,7 @@ export function useLoadedWorkspace( /** * Default {@link RenameLinkProvider} implementation for the {@link Workspace workspace}. * - * Unless overriden, it allows to rename {@link AnnotationLink} graph links + * Unless overridden, it allows to rename {@link AnnotationLink} graph links * and stores the changed label in the {@link Link.linkState link template state}. * * @see {@link WorkspaceProps.renameLinkProvider} diff --git a/src/workspace/workspaceWrapper.tsx b/src/workspace/workspaceWrapper.tsx new file mode 100644 index 00000000..175a9a6c --- /dev/null +++ b/src/workspace/workspaceWrapper.tsx @@ -0,0 +1,188 @@ +import * as React from 'react'; + +import { + LabelLanguageSelector, type TranslationBundle, TranslationContext, +} from '../coreUtils/i18n'; + +import { MetadataProvider } from '../data/metadataProvider'; +import { TemplateProperties } from '../data/schema'; +import { ValidationProvider } from '../data/validationProvider'; + +import { TypeStyleResolver, RenameLinkProvider } from '../diagram/customization'; +import { Element } from '../diagram/elements'; +import { CommandHistory, InMemoryHistory } from '../diagram/history'; +import { LayoutFunction } from '../diagram/layout'; +import { DefaultTranslation } from '../diagram/locale'; + +import { EntityElement } from '../editor/dataElements'; +import { + type DialogSettingsProvider, DefaultDialogSettingsProvider, +} from '../editor/overlayController'; + +import { WorkspaceContext, WorkspaceEventKey } from './workspaceContext'; +import { + TrackedWorkspaceContext, WorkspaceProvider, createWorkspace, +} from './workspaceProvider'; + +/** + * Props for {@link Workspace} component. + * + * @see {@link Workspace} + */ +export interface WorkspaceProps { + /** + * Overrides default command history implementation. + * + * By default, {@link InMemoryHistory} instance is used. + */ + history?: CommandHistory; + /** + * Allows to customize how colors and icons are assigned to elements based + * on its types. + * + * By default, the colors are assigned deterministically based on total + * hash of type strings. + * + * For non-{@link EntityElement entity} elements, the {@link Element.elementState} + * is checked for {@link TemplateProperties.ColorVariant} template state property + * instead. + */ + typeStyleResolver?: TypeStyleResolver; + /** + * Provides defaults and persists changes to {@link OverlayDialog overlay dialog} properties. + * + * By default, {@link DefaultDialogSettingsProvider} instance is used. + */ + dialogSettingsProvider?: DialogSettingsProvider; + /** + * Provides an strategy to visually edit graph data. + * + * If provided, switches editor into the graph authoring mode. + */ + metadataProvider?: MetadataProvider; + /** + * Provides a strategy to validate changes to the data in the graph authoring mode. + */ + validationProvider?: ValidationProvider; + /** + * Provides a strategy to rename diagram links (change labels). + * + * By default, {@link DefaultRenameLinkProvider} instance is used. + * + * If specified as `null`, the default provider would not be used. + */ + renameLinkProvider?: RenameLinkProvider | null; + /** + * Additional translation bundles for UI text strings in the workspace + * in order from higher to lower priority. + * + * @default [] + * @see {@link useDefaultTranslation} + */ + translations?: ReadonlyArray>; + /** + * If set, disables translation fallback which (with default `en` language). + * + * @default true + * @see {@link translations} + */ + useDefaultTranslation?: boolean; + /** + * Overrides how a single label gets selected from multiple of them based on target language. + */ + selectLabelLanguage?: LabelLanguageSelector; + /** + * Initial language to display the graph data with. + */ + defaultLanguage?: string; + /** + * Default function to compute diagram layout. + * + * It is recommended to get layout function from a background worker, + * e.g. with {@link defineDefaultLayouts} and {@link useWorker}. + * + * In cases when a worker is not available, it is possible to import and + * use {@link blockingDefaultLayout} as a synchronous fallback. + */ + defaultLayout: LayoutFunction; + /** + * Handler for a well-known workspace event. + */ + onWorkspaceEvent?: (key: WorkspaceEventKey) => void; + /** + * Component children. + */ + children: React.ReactNode; +} + +/** + * Top-level component which establishes workspace context, which stores + * graph data and provides means to display and interact with the diagram. + * + * For more control over workspace lifecycle {@link WorkspaceProvider} + * with {@link createWorkspace} can be used instead. + * + * @category Components + */ +export class Workspace extends React.Component { + private readonly _workspace: TrackedWorkspaceContext; + + /** @hidden */ + static contextType = TranslationContext; + /** @hidden */ + declare context: React.ContextType; + + /** @hidden */ + constructor(props: WorkspaceProps, context: unknown) { + super(props, context); + + const { + history, + dialogSettingsProvider, + metadataProvider, + validationProvider, + renameLinkProvider, + typeStyleResolver, + selectLabelLanguage, + translations = [], + useDefaultTranslation = true, + defaultLanguage, + defaultLayout, + onWorkspaceEvent, + } = this.props; + + this._workspace = createWorkspace({ + translation: this.context ?? new DefaultTranslation({ + bundles: translations, + useDefaultBundle: useDefaultTranslation, + selectLabel: selectLabelLanguage, + }), + history, + dialogSettingsProvider, + metadataProvider, + validationProvider, + renameLinkProvider, + typeStyleResolver, + defaultLanguage, + defaultLayout, + onWorkspaceEvent, + }); + } + + /** + * Returns top-level workspace context. + */ + getContext(): WorkspaceContext { + return this._workspace; + } + + /** @hidden */ + render() { + const {children} = this.props; + return ( + + {children} + + ); + } +}