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}
+
+ );
+ }
+}