Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
* Allow to select multiple elements with `Selection` with touch when `CanvasApi.pointerMode` is `selection`;
* Allow to establish new links with `SelectionActionEstablishLink` on touchscreen;
* Enable `showPointerModeToggle` on `ZoomControl` by default (can be disabled by passing `false`).
- Allow to customize how resource anchors and asset URLs (e.g. images or downloadable files) are resolved via `DataLocaleProvider.{prepareAnchor, resolveAssetUrl}`:
* Resolve anchors and image thumbnail URLs in `StandardEntity`, `ClassicEntity` and `SelectionActionAnchor`;
* Add `useResolvedAssetUrl()` helper hook to simplify resolving asset URLs from components;
- Support conditionally rendering `WorkspaceLayout*` child components by passing `null` instead of a child to remove it:
* Allow to hide left and right panels in `ClassicWorkspace` by passing `null` to the corresponding props.

#### 💅 Polish
- Expose `useAsync()` utility hook to simplify data loading from via a single Promise-returning task.
- Provide `onlySelected` property to link templates the same way as for element templates.
- Allow to configure whether `ClassTree` and `SearchSectionElementTypes` tree items should be draggable.

#### 🔧 Maintenance
- Preparations to extract generic scrollable paper component `Paper` from diagram-specific state and logic:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@reactodia/workspace",
"version": "0.33.0",
"version": "0.33.0-next",
"description": "Reactodia Workspace -- library for visual interaction with graphs in a form of a diagram.",
"repository": {
"type": "git",
Expand Down
66 changes: 66 additions & 0 deletions src/coreUtils/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,69 @@ export function useSyncStoreWithComparator<R>(
}
);
}

/**
* Result from {@link useAsync} hook.
*/
export interface UseAsyncResult<T> {
/**
* The last successfully loaded value.
*
* (Does not reset on error or when reloading is in progress.)
*/
readonly data: T | undefined;
/**
* Load operation status.
*/
readonly status: 'loading' | 'error' | 'completed';
/**
* Load operation error.
*/
readonly error?: unknown;
}

/**
* Asynchronously loads a value by specified `load` function for an `input` dependencies.
*
* Reloads the result when an `input` dependency array changes (shallow equality).
*
* @category Hooks
*/
export function useAsync<const I extends React.DependencyList, T>(params: {
input: I;
load: (input: I, options: { signal: AbortSignal }) => Promise<T> | undefined;
}): UseAsyncResult<T> {
const {input, load} = params;
const latestLoad = React.useRef(load);
const [result, setResult] = React.useState<UseAsyncResult<T>>({
data: undefined,
status: 'loading',
});
React.useEffect(() => {
latestLoad.current = load;
});
React.useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const task = latestLoad.current(input, {signal});
if (task) {
setResult(previous => ({...previous, status: 'loading', error: undefined}));
task.then(
data => {
if (!signal.aborted) {
setResult({data, status: 'completed', error: undefined});
}
},
error => {
if (!signal.aborted) {
setResult(previous => ({...previous, status: 'error', error}));
}
}
);
} else {
setResult({data: undefined, status: 'completed', error: undefined});
}
return () => controller.abort();
}, input);
return result;
}
2 changes: 1 addition & 1 deletion src/diagram/canvasApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ export const CanvasContext = React.createContext<CanvasContext | null>(null);
export function useCanvas(): CanvasContext {
const context = React.useContext(CanvasContext);
if (!context) {
throw new Error('Missing Reactodia canvas context');
throw new Error('Reactodia: missing canvas context');
}
return context;
}
60 changes: 60 additions & 0 deletions src/editor/dataLocaleProvider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Translation } from '../coreUtils/i18n';
import { type UseAsyncResult, useAsync } from '../coreUtils/hooks';

import { ElementModel, PropertyTypeIri, isEncodedBlank } from '../data/model';
import * as Rdf from '../data/rdf/rdfModel';
Expand Down Expand Up @@ -38,6 +39,17 @@ export interface DataLocaleProvider {
* @param language target language code
*/
formatEntityTypeList(entity: ElementModel, language: string): string;
/**
* Provides props for an anchor (`<a>` link) to a resource IRI.
*/
prepareAnchor(
targetIri: string
): Pick<React.ComponentProps<'a'>, 'draggable' | 'href' | 'target' | 'rel' | 'onClick'>;
/**
* Asynchronously resolves an IRI/URL to referenced data asset for display or download,
* e.g. an image (thumbnail) or a downloadable file.
*/
resolveAssetUrl(assetIri: string, options: { signal?: AbortSignal }): Promise<string>;
}

/**
Expand Down Expand Up @@ -170,6 +182,54 @@ export class DefaultDataLocaleProvider implements DataLocaleProvider {
labelList.sort();
return labelList.join(', ');
}

/**
* Provides props for an anchor (`<a>` link) to a resource IRI.
*
* **By default**: returns
* ```
* {
* href: targetIri,
* target: '_blank',
* rel: 'noreferrer',
* }
* ```
*/
prepareAnchor(targetIri: string): Pick<React.ComponentProps<'a'>, 'href' | 'target' | 'rel' | 'onClick'> {
return {
href: targetIri,
target: '_blank',
rel: 'noreferrer',
};
}

/**
* Asynchronously resolves an IRI/URL to referenced data asset for display or download,
* e.g. an image (thumbnail) or a downloadable file.
*
* **By default**: returns the asset IRI/URL as-is.
*/
resolveAssetUrl(assetIri: string, options: { signal?: AbortSignal }): Promise<string> {
return Promise.resolve(assetIri);
}
}

/**
* Helper hook to resolve IRI/URL to referenced data asset with
* {@link DataLocaleProvider.resolveAssetUrl}.
*
* @category Hooks
*/
export function useResolvedAssetUrl(
locale: DataLocaleProvider,
assetIri: string | undefined
): UseAsyncResult<string | undefined> {
return useAsync({
input: [locale, assetIri],
load: ([locale, assetIri], {signal}) => {
return assetIri === undefined ? undefined : locale.resolveAssetUrl(assetIri, {signal});
},
});
}

function filterInLiterals(terms: ReadonlyArray<Rdf.NamedNode | Rdf.Literal>): readonly Rdf.Literal[] {
Expand Down
53 changes: 19 additions & 34 deletions src/forms/editEntityForm.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import cx from 'clsx';
import * as React from 'react';

import { mapAbortedToNull } from '../coreUtils/async';
import { useTranslation } from '../coreUtils/i18n';
import { useAsync } from '../coreUtils/hooks';

import { ElementModel, ElementIri, PropertyTypeIri } from '../data/model';
import type {
Expand Down Expand Up @@ -141,39 +141,24 @@ function useEntityMetadata(
metadataProvider: MetadataProvider | undefined,
entity: ElementModel
): readonly [shape: EntityMetadata | undefined, error?: unknown] {
const [metadata, setMetadata] = React.useState<EntityMetadata>();
const [metadataError, setMetadataError] = React.useState<unknown>();

React.useEffect(() => {
if (metadataProvider) {
setMetadata(undefined);
setMetadataError(undefined);
const cancellation = new AbortController();
const signal = cancellation.signal;
mapAbortedToNull(
Promise.all([
metadataProvider.canModifyEntity(entity, {signal}),
metadataProvider.getEntityShape(entity.types, {signal}),
]),
signal
).then(
result => {
if (result === null) {
return;
}
const [editable, shape] = result;
setMetadata({editable, shape});
},
error => {
console.error('Failed to load entity shape:', error);
setMetadataError(error);
}
);
return () => cancellation.abort();
} else {
setMetadata({editable: {}, shape: DEFAULT_ENTITY_SHAPE});
const {data, status, error} = useAsync({
input: [metadataProvider, entity],
load: async ([provider, target], {signal}): Promise<EntityMetadata> => {
if (provider) {
const [editable, shape] = await Promise.all([
provider.canModifyEntity(target, {signal}),
provider.getEntityShape(target.types, {signal}),
]);
return {editable, shape};
} else {
return {editable: {}, shape: DEFAULT_ENTITY_SHAPE};
}
}
}, [entity]);
});

return [metadata, metadataError];
if (data && status === 'completed') {
return [data];
} else {
return [undefined, error];
}
}
53 changes: 19 additions & 34 deletions src/forms/editRelationForm.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import cx from 'clsx';
import * as React from 'react';

import { mapAbortedToNull } from '../coreUtils/async';
import { useTranslation } from '../coreUtils/i18n';
import { useAsync } from '../coreUtils/hooks';

import { ElementModel, LinkModel, PropertyTypeIri, equalLinks, equalProperties } from '../data/model';
import type { MetadataProvider, MetadataRelationShape } from '../data/metadataProvider';
Expand Down Expand Up @@ -316,44 +316,29 @@ function useRelationMetadata(
linkSource: ElementModel,
linkTarget: ElementModel
): readonly [shape: RelationMetadata | undefined, error?: unknown] {
const [shape, setShape] = React.useState<RelationMetadata>();
const [shapeError, setShapeError] = React.useState<unknown>();

React.useEffect(() => {
if (metadataProvider) {
setShape(undefined);
setShapeError(undefined);
const cancellation = new AbortController();
const signal = cancellation.signal;
mapAbortedToNull(
Promise.all([
metadataProvider.canModifyRelation(link, linkSource, linkTarget, {signal}),
metadataProvider.getRelationShape(
const {data, status, error} = useAsync({
input: [metadataProvider, link.linkTypeId, linkSource, linkTarget],
load: ([provider, _typeId, source, target], {signal}) => {
if (provider) {
return Promise.all([
provider.canModifyRelation(link, source, target, {signal}),
provider.getRelationShape(
link.linkTypeId,
linkSource,
linkTarget,
{signal}
),
]),
signal
).then(
result => {
if (result === null) {
return;
}
const [status, shape] = result;
setShape({shape: status.canEdit ? shape : null});
},
error => {
console.error('Failed to load relation metadata:', error);
setShapeError(error);
}
);
return () => cancellation.abort();
} else {
setShape({shape: null});
]);
} else {
return undefined;
}
}
}, [link.linkTypeId, linkSource, linkTarget]);
});

return [shape, shapeError];
if (data && status === 'completed') {
const [loadedStatus, loadedShape] = data;
return [{shape: loadedStatus.canEdit ? loadedShape : null}];
} else {
return [undefined, error];
}
}
37 changes: 9 additions & 28 deletions src/forms/linkTypeSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';

import { mapAbortedToNull } from '../coreUtils/async';
import { useObservedProperty } from '../coreUtils/hooks';
import { useObservedProperty, useAsync } from '../coreUtils/hooks';
import { useTranslation, type Translation } from '../coreUtils/i18n';
import { useKeyedSyncStore } from '../coreUtils/keyedObserver';

Expand Down Expand Up @@ -42,32 +42,13 @@ export function LinkTypeSelector(props: {
const {editor} = useWorkspace();
const t = useTranslation();

const [linkTypes, setLinkTypes] = React.useState<readonly DirectedLinkType[]>();
const [fetchState, setFetchState] = React.useState<{ type: 'loading' | 'error'; error?: unknown }>();
const fetchedTypes = useAsync({
input: [editor.metadataProvider, link.source.id, link.target.id],
load: ([provider], {signal}) =>
fetchPossibleLinkTypes({link, metadataProvider: provider, signal}),
});

React.useEffect(() => {
const controller = new AbortController();
setFetchState({type: 'loading'});
fetchPossibleLinkTypes({
link,
metadataProvider: editor.metadataProvider,
signal: controller.signal,
}).then(
linkTypes => {
if (!controller.signal.aborted) {
setLinkTypes(linkTypes);
setFetchState(undefined);
}
},
error => {
if (!controller.signal.aborted) {
setLinkTypes([]);
setFetchState({type: 'error', error});
}
},
);
return () => controller.abort();
}, [link.source.id, link.target.id]);
const linkTypes = fetchedTypes.status === 'completed' ? fetchedTypes.data : undefined;

const selectedType = (linkTypes ?? []).find(({iri, direction}) =>
iri === link.base.linkTypeId && direction === link.direction
Expand All @@ -91,7 +72,7 @@ export function LinkTypeSelector(props: {
return (
<div className={`${FORM_CLASS}__control-row`}>
<label>{t.text('visual_authoring.select_relation.type.label')}</label>
{linkTypes && !fetchState ? (
{linkTypes && fetchedTypes.status === 'completed' ? (
<select className='reactodia-form-control'
name='reactodia-link-type-selector-select'
value={selectedType?.key ?? -1}
Expand All @@ -104,7 +85,7 @@ export function LinkTypeSelector(props: {
</select>
) : (
<div>
<HtmlSpinner width={20} height={20} errorOccurred={fetchState?.type === 'error'} />
<HtmlSpinner width={20} height={20} errorOccurred={fetchedTypes.status === 'error'} />
</div>
)}
{error ? <span className={`${FORM_CLASS}__control-error`}>{error}</span> : null}
Expand Down
Loading
Loading