From 2cedb44df575db7185abab39e7f7ab3cb7e22149 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Tue, 21 Oct 2025 23:59:01 +0300 Subject: [PATCH] Simplify canvas widgets placement at one or multiple specific layers: * Add `CanvasPlaceAt` component to render a widget at specified non-viewport canvas layer; - Now it is possible to place widgets at `underlay` and `overLinkGeometry` layers; * Add `changeTransform` event to `CanvasApi.events` which triggers on `CanvasApi.metrics.getTransform()` changes; * Add `--reactodia-z-index-base` CSS property to override base z-index level for workspace components with a `z-index` set; * Fix `HaloLink` and visual authoring path highlight being rendered on top on elements by placing it onto `overLinkGeometry` layer; * Deprecate `canvasWidgets` prop on `DefaultWorkspace` and `ClassicWorkspace` in favor of passing widgets directly as children; * **[Breaking]** Remove `defineCanvasWidget()` and `SharedCanvasState.setCanvasWidget()`: - Canvas children are always assumed to be viewport widgets; - Use child `CanvasPlaceAt` components to render different parts at other layers instead. * **[Breaking]** Canvas widgets are not re-rendered when parent canvas is rendered and require explicit subscriptions: - Subscribe to canvas `changeTransform` event when using `CanvasApi.metrics` to convert between coordinates; - Subscribe to canvas `resize` event to track viewport size; - Subscribe to `changeCells` event from `DiagramModel` to track graph content changes. --- CHANGELOG.md | 17 ++ examples/sparql.tsx | 19 +- examples/styleCustomization.tsx | 18 +- src/diagram/canvasApi.ts | 29 +-- .../{canvasWidget.tsx => canvasHotkey.tsx} | 47 +--- src/diagram/paperArea.tsx | 144 +++++------ src/diagram/placeLayer.tsx | 91 +++++++ src/diagram/sharedCanvasState.ts | 43 +--- src/editor/overlayController.tsx | 230 ++++++++++-------- src/widgets/canvas.tsx | 4 +- src/widgets/connectionsMenu.tsx | 3 - src/widgets/dialog.tsx | 13 +- src/widgets/dropOnCanvas.tsx | 3 - src/widgets/halo.tsx | 19 +- src/widgets/haloLink.tsx | 64 +++-- src/widgets/navigator.tsx | 5 +- src/widgets/selection.tsx | 26 +- src/widgets/selectionAction.tsx | 2 +- src/widgets/toolbar.tsx | 4 - src/widgets/toolbarAction.tsx | 2 +- src/widgets/unifiedSearch/unifiedSearch.tsx | 2 +- .../authoredRelationOverlay.tsx | 32 ++- src/widgets/visualAuthoring/dragEditLayer.tsx | 14 +- .../visualAuthoring/visualAuthoring.tsx | 52 ++-- src/widgets/zoomControl.tsx | 3 - src/workspace.ts | 3 +- src/workspace/classicWorkspace.tsx | 7 +- src/workspace/defaultWorkspace.tsx | 9 +- styles/mixin/_zIndex.scss | 16 +- styles/theme/_common.scss | 1 + styles/theme/_theme.scss | 3 +- 31 files changed, 492 insertions(+), 433 deletions(-) rename src/diagram/{canvasWidget.tsx => canvasHotkey.tsx} (54%) create mode 100644 src/diagram/placeLayer.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 481cc85e..137c245a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,28 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] #### 🚀 New Features +- Simplify canvas widgets placement at one or multiple layers: + * Canvas children are always assumed to be viewport widgets; + * Add `CanvasPlaceAt` component to render its children at specified non-viewport canvas layer instead; + * Support new placement layers: `underlay` layer to place components under all canvas content, `overLinkGeometry` layer to place components above link geometry (connections) but under link labels; + * **[💥Breaking]** Remove `defineCanvasWidget()` and `SharedCanvasState.setCanvasWidget()` (use `CanvasPlaceAt` to display components at canvas layers instead). - Add `EditorController.applyAuthoringChanges()` method to apply current authoring changes to the diagram (i.e. change entity data, delete relations, etc) and reset the change state to be empty. +#### ⏱ Performance +- **[💥Breaking]** Canvas widgets are not automatically updated when parent canvas is rendered to reduce unnecessary re-renders, and now require explicit subscriptions: + * Subscribe to canvas `changeTransform` event when using `CanvasApi.metrics` to convert between coordinates; + * Subscribe to canvas `resize` event to track viewport size; + * Subscribe to `changeCells` event from `DiagramModel` to track graph content changes. + #### 💅 Polish - Make dialogs fill the available viewport when the viewport width is small: * This is controlled by new CSS property `--reactodia-dialog-viewport-breakpoint-s` with default value `600px` which makes dialog fill the viewport if the available width is less or equal to that value. +- Allow to override base z-index level for workspace components with a set z-index value via `--reactodia-z-index-base` CSS property; +- Add `changeTransform` event to `CanvasApi.events` which triggers on `CanvasApi.metrics.getTransform()` changes, i.e. when coordinate mapping changes due to scale or canvas size is re-adjusted. +- Deprecate `canvasWidgets` prop on `DefaultWorkspace` and `ClassicWorkspace` in favor of passing widgets directly as children. + +#### 🐛 Fixed +- Fix `HaloLink` and visual authoring link path highlight being rendered on top on elements by placing it onto `overLinkGeometry` widget layer instead. ## [0.30.1] - 2025-06-27 #### 🐛 Fixed diff --git a/examples/sparql.tsx b/examples/sparql.tsx index 26752ba4..228eaefe 100644 --- a/examples/sparql.tsx +++ b/examples/sparql.tsx @@ -62,15 +62,6 @@ function SparqlExample() { defaultLayout={defaultLayout}> } - canvasWidgets={[ - - - - ]} languages={[ {code: 'de', label: 'Deutsch'}, {code: 'en', label: 'English'}, @@ -82,8 +73,14 @@ function SparqlExample() { {code: 'pt', label: 'português'}, {code: 'ru', label: 'Русский'}, {code: 'zh', label: '汉语'}, - ]} - /> + ]}> + + + + ); } diff --git a/examples/styleCustomization.tsx b/examples/styleCustomization.tsx index 82e49ae1..25afbca8 100644 --- a/examples/styleCustomization.tsx +++ b/examples/styleCustomization.tsx @@ -61,24 +61,28 @@ function StyleCustomizationExample() { }, linkTemplateResolver: type => DoubleArrowLinkTemplate, }} - canvasWidgets={[ - - ]} - menu={} - /> + menu={}> + + ); } function BookDecorations() { const {model} = Reactodia.useCanvas(); + + const [, forceUpdate] = React.useState({}); + React.useEffect(() => { + const listener = new Reactodia.EventObserver(); + listener.listen(model.events, 'changeCells', () => forceUpdate({})); + return () => listener.stopListening(); + }); + return model.elements .filter(element => element instanceof Reactodia.EntityElement) .map(element => ); } -Reactodia.defineCanvasWidget(BookDecorations, element => ({element, attachment: 'viewport'})); - function BookDecoration(props: { target: Reactodia.EntityElement }) { const {target} = props; diff --git a/src/diagram/canvasApi.ts b/src/diagram/canvasApi.ts index e738b4f2..736bf0fd 100644 --- a/src/diagram/canvasApi.ts +++ b/src/diagram/canvasApi.ts @@ -212,6 +212,10 @@ export interface CanvasEvents { * Triggered on {@link CanvasApi.getScale} property change. */ changeScale: PropertyChange; + /** + * Triggered on {@link CanvasApi.metrics.getTransform()} property change. + */ + changeTransform: PropertyChange; } /** @@ -551,31 +555,6 @@ export interface ExportSvgOptions { */ export interface ExportRasterOptions extends ExportSvgOptions, ToDataURLOptions {} -/** - * Canvas widget layer to render widget: - * - `viewport` - topmost layer, uses client (viewport) coordinates and - * does not scale or scroll with the diagram; - * - `overElements` - displayed over both elements and links, uses paper coordinates, - * scales and scrolls with the diagram; - * - `overLinks` - displayed under elements but over links, uses paper coordinates, - * scales and scrolls with the diagram. - */ -export type CanvasWidgetAttachment = 'viewport' | 'overElements' | 'overLinks'; - -/** - * Describes canvas widget element to render on the specific widget layer. - */ -export interface CanvasWidgetDescription { - /** - * Canvas widget element to render. - */ - element: React.ReactElement; - /** - * Canvas widget layer to render widget on. - */ - attachment: CanvasWidgetAttachment; -} - /** * Represents a context for everything rendered inside the canvas, * including diagram content and widgets. diff --git a/src/diagram/canvasWidget.tsx b/src/diagram/canvasHotkey.tsx similarity index 54% rename from src/diagram/canvasWidget.tsx rename to src/diagram/canvasHotkey.tsx index 999c4c5c..1b33b39e 100644 --- a/src/diagram/canvasWidget.tsx +++ b/src/diagram/canvasHotkey.tsx @@ -2,54 +2,9 @@ import * as React from 'react'; import { HotkeyAst, type HotkeyString, parseHotkey, formatHotkey } from '../coreUtils/hotkey'; -import { type CanvasWidgetDescription, useCanvas } from './canvasApi'; +import { useCanvas } from './canvasApi'; import { type MutableRenderingState } from './renderingState'; -const GET_WIDGET_METADATA: unique symbol = Symbol('getWidgetMetadata'); - -interface WithMetadata { - [GET_WIDGET_METADATA]?: (element: React.ReactElement) => CanvasWidgetDescription; -} - -/** - * Defines the React component to be a canvas widget. - * - * A component cannot be rendered by canvas as widget unless explicitly - * defined as such using this function. - * - * **Example**: - * ```jsx - * function MyWidget(props) { - * ... - * } - * - * defineCanvasWidget(MyWidget, element => ({ - * element, - * attachment: 'viewport' - * })); - * ``` - * - * @category Core - */ -export function defineCanvasWidget

( - type: React.ComponentType

, - metadataOf: (element: React.ReactElement

) => CanvasWidgetDescription -): void { - const typeWithMetadata = type as WithMetadata; - typeWithMetadata[GET_WIDGET_METADATA] = metadataOf as WithMetadata[typeof GET_WIDGET_METADATA]; -} - -export function extractCanvasWidget( - element: React.ReactElement -): CanvasWidgetDescription | undefined { - const typeWithMetadata = element.type as WithMetadata; - const metadataOf = typeWithMetadata[GET_WIDGET_METADATA]; - if (metadataOf) { - return metadataOf(element); - } - return undefined; -} - /** * Represents a registered canvas hotkey. * diff --git a/src/diagram/paperArea.tsx b/src/diagram/paperArea.tsx index e3005f4a..6b29dbe0 100644 --- a/src/diagram/paperArea.tsx +++ b/src/diagram/paperArea.tsx @@ -8,20 +8,22 @@ import { Debouncer, animateInterval, easeInOutBezier } from '../coreUtils/schedu import { CanvasContext, CanvasApi, CanvasEvents, CanvasMetrics, CanvasAreaMetrics, - CanvasDropEvent, CenterToOptions, ScaleOptions, ViewportOptions, CanvasWidgetDescription, + CanvasDropEvent, CenterToOptions, ScaleOptions, ViewportOptions, CanvasPointerMode, ZoomOptions, ExportSvgOptions, ExportRasterOptions, } from './canvasApi'; -import { extractCanvasWidget } from './canvasWidget'; import { RestoreGeometry } from './commands'; import { Element, Link, Cell, LinkVertex } from './elements'; import { ElementLayer } from './elementLayer'; import { Vector, Rect, computePolyline, findNearestSegmentIndex, getContentFittingBox, } from './geometry'; +import { CommandBatch } from './history'; import { LinkLabelLayer, LinkLayer, LinkMarkers } from './linkLayer'; import { DiagramModel } from './model'; -import { CommandBatch } from './history'; import { Paper, PaperTransform, SvgPaperLayer } from './paper'; +import { + CanvasPlaceLayerContext, CanvasPlaceLayer, createPlaceLayerContext, +} from './placeLayer'; import { MutableRenderingState, RenderingLayer } from './renderingState'; import { ToSVGOptions, toSVG, toDataURL, fitRectKeepingAspectRatio, @@ -39,14 +41,7 @@ export interface PaperAreaProps { } interface State { - readonly width: number; - readonly height: number; - readonly originX: number; - readonly originY: number; - readonly scale: number; - readonly paddingX: number; - readonly paddingY: number; - + readonly transform: PaperTransform; readonly cssAnimations: number; readonly cssAnimationDuration: number | undefined; } @@ -104,6 +99,7 @@ export class PaperArea extends React.Component implements private readonly linkLayerRef = React.createRef(); private readonly labelLayerRef = React.createRef(); private readonly elementLayerRef = React.createRef(); + private readonly placeLayerContext: CanvasPlaceLayerContext; private readonly pageSize = {x: 1500, y: 800}; private readonly canvasContext: CanvasContext; @@ -129,16 +125,19 @@ export class PaperArea extends React.Component implements super(props); const {zoomOptions = {}} = this.props; this.state = { - width: this.pageSize.x, - height: this.pageSize.y, - originX: 0, - originY: 0, - scale: 1, - paddingX: 0, - paddingY: 0, + transform: { + width: this.pageSize.x, + height: this.pageSize.y, + originX: 0, + originY: 0, + scale: 1, + paddingX: 0, + paddingY: 0, + }, cssAnimations: 0, cssAnimationDuration: undefined, }; + this.placeLayerContext = createPlaceLayerContext(); this.resizeObserver = new ResizeObserver(this.onResize); this.metrics = new (class extends BasePaperMetrics { constructor(private readonly paperArea: PaperArea) { @@ -148,8 +147,7 @@ export class PaperArea extends React.Component implements return this.paperArea.area; } get transform(): PaperTransform { - const {width, height, originX, originY, scale, paddingX, paddingY} = this.paperArea.state; - return {width, height, originX, originY, scale, paddingX, paddingY}; + return this.paperArea.state.transform; } protected getClientRect(): AreaClientRect { return this.paperArea.area.getBoundingClientRect(); @@ -191,7 +189,7 @@ export class PaperArea extends React.Component implements } render() { - const {model, renderingState, hideScrollBars, watermarkSvg, watermarkUrl} = this.props; + const {model, renderingState, hideScrollBars, watermarkSvg, watermarkUrl, children} = this.props; const {cssAnimationDuration} = this.state; const paperTransform = this.metrics.getTransform(); @@ -205,7 +203,6 @@ export class PaperArea extends React.Component implements ? undefined : `${cssAnimationDuration}ms`, } as React.CSSProperties; - const renderedWidgets = Array.from(this.getAllWidgets()); return (

implements onPointerDown={this.onPaperPointerDown} onContextMenu={this.onContextMenu} onScrollCapture={this.onPaperScrollCapture}> + implements renderingState={renderingState} /> + -
- {renderedWidgets - .filter(w => w.attachment === 'overLinks') - .map(widget => ensureWidgetGetRendered(widget.element)) - } -
+ -
- {renderedWidgets - .filter(w => w.attachment === 'overElements') - .map(widget => ensureWidgetGetRendered(widget.element)) - } -
+ {watermarkSvg ? ( @@ -264,10 +263,9 @@ export class PaperArea extends React.Component implements ) : null}
- {renderedWidgets - .filter(w => w.attachment === 'viewport') - .map(widget => ensureWidgetGetRendered(widget.element)) - } + + {children} + ); @@ -298,9 +296,6 @@ export class PaperArea extends React.Component implements if (layer !== RenderingLayer.PaperArea) { return; } this.delayedPaperAdjust.runSynchronously(); }); - this.listener.listen(renderingState.shared.events, 'changeWidgets', () => { - this.forceUpdate(); - }); this.listener.listen(renderingState.shared.events, 'findCanvas', e => { e.canvases.push(this); }); @@ -315,9 +310,10 @@ export class PaperArea extends React.Component implements componentDidUpdate(prevProps: PaperAreaProps, prevState: State) { if (this.scrollBeforeUpdate) { - const {scale, originX, originY, paddingX, paddingY} = this.state; - const scrollX = (originX - prevState.originX) * scale + (paddingX - prevState.paddingX); - const scrollY = (originY - prevState.originY) * scale + (paddingY - prevState.paddingY); + const {scale, originX, originY, paddingX, paddingY} = this.state.transform; + const prevTransform = prevState.transform; + const scrollX = (originX - prevTransform.originX) * scale + (paddingX - prevTransform.paddingX); + const scrollY = (originY - prevTransform.originY) * scale + (paddingY - prevTransform.paddingY); const scrollLeft = this.scrollBeforeUpdate.left + scrollX; const scrollTop = this.scrollBeforeUpdate.top + scrollY; @@ -340,26 +336,6 @@ export class PaperArea extends React.Component implements this.resizeObserver.disconnect(); } - private *getAllWidgets(): IterableIterator { - const {renderingState, children} = this.props; - for (const element of React.Children.toArray(children)) { - if (React.isValidElement(element)) { - const widget = extractCanvasWidget(element); - if (widget) { - yield widget; - } else { - console.warn('Unexpected non-widget canvas child: ', element); - } - } - } - yield* renderingState.shared.widgets.values(); - } - - private onWidgetsPointerDown = (e: React.PointerEvent) => { - // prevent PaperArea from generating click on a blank area - e.stopPropagation(); - }; - /** Returns bounding box of paper content in paper coordinates. */ private getContentFittingBox() { const {model, renderingState} = this.props; @@ -367,7 +343,7 @@ export class PaperArea extends React.Component implements return getContentFittingBox(elements, links, renderingState); } - private computeAdjustedBox(): Pick { + private computeAdjustedBox(): Pick { // bbox in paper coordinates const bbox = this.getContentFittingBox(); const bboxLeft = bbox.x; @@ -397,12 +373,13 @@ export class PaperArea extends React.Component implements private adjustPaper = (callback?: () => void) => { const {clientWidth, clientHeight} = this.area; - const adjusted = { + const adjusted: PaperTransform = { ...this.computeAdjustedBox(), paddingX: Math.ceil(clientWidth), paddingY: Math.ceil(clientHeight), - } satisfies Partial; - const previous = this.state; + scale: this.state.transform.scale, + }; + const previous = this.state.transform; const samePaperProps = ( adjusted.width === previous.width && adjusted.height === previous.height && @@ -416,7 +393,9 @@ export class PaperArea extends React.Component implements left: this.area.scrollLeft, top: this.area.scrollTop, }; - this.setState(adjusted, callback); + this.setState({transform: adjusted}, () => { + this.source.trigger('changeTransform', {previous, source: this}); + }); } else if (callback) { callback(); } @@ -706,7 +685,7 @@ export class PaperArea extends React.Component implements } centerTo(paperPosition?: Vector, options: CenterToOptions = {}): Promise { - const {width, height} = this.state; + const {width, height} = this.state.transform; const paperCenter = paperPosition || {x: width / 2, y: height / 2}; if (typeof options.scale === 'number') { const {min, max} = this.zoomOptions; @@ -735,7 +714,7 @@ export class PaperArea extends React.Component implements } getScale() { - return this.state.scale; + return this.state.transform.scale; } setScale(value: number, options?: ScaleOptions): Promise { @@ -752,7 +731,7 @@ export class PaperArea extends React.Component implements this.area.clientWidth / 2, this.area.clientHeight / 2 ); - const previousScale = this.state.scale; + const previousScale = this.state.transform.scale; const scaledBy = scale / previousScale; viewportState = { center: { @@ -973,7 +952,7 @@ export class PaperArea extends React.Component implements private get viewportState(): ViewportState { const {clientWidth, clientHeight} = this.area; - const {originX, originY, paddingX, paddingY, scale} = this.state; + const {originX, originY, paddingX, paddingY, scale} = this.state.transform; const scrollCenterX = this.area.scrollLeft + clientWidth / 2 - paddingX; const scrollCenterY = this.area.scrollTop + clientHeight / 2 - paddingY; @@ -1031,12 +1010,12 @@ export class PaperArea extends React.Component implements } private applyViewportState(targetState: ViewportState) { - const previousScale = this.state.scale; + const previous = this.state.transform; const scale = targetState.scale.x; const paperCenter = targetState.center; - this.setState({scale}, () => { - const {originX, originY, paddingX, paddingY} = this.state; + this.setState({transform: {...previous, scale}}, () => { + const {originX, originY, paddingX, paddingY} = this.state.transform; const scrollCenterX = (paperCenter.x + originX) * scale; const scrollCenterY = (paperCenter.y + originY) * scale; const {clientWidth, clientHeight} = this.area; @@ -1044,8 +1023,9 @@ export class PaperArea extends React.Component implements this.area.scrollLeft = scrollCenterX - clientWidth / 2 + paddingX; this.area.scrollTop = scrollCenterY - clientHeight / 2 + paddingY; - if (scale !== previousScale) { - this.source.trigger('changeScale', {source: this, previous: previousScale}); + if (scale !== previous.scale) { + this.source.trigger('changeScale', {source: this, previous: previous.scale}); + this.source.trigger('changeTransform', {previous, source: this}); } }); } @@ -1177,10 +1157,6 @@ function clientCoordsFor(container: HTMLElement, e: MouseEvent) { }; } -function ensureWidgetGetRendered(element: React.ReactElement) { - return React.cloneElement(element); -} - /** Clears accidental text selection in the diagram area. */ function clearTextSelectionInArea() { if (document.getSelection) { diff --git a/src/diagram/placeLayer.tsx b/src/diagram/placeLayer.tsx new file mode 100644 index 00000000..86c77ba6 --- /dev/null +++ b/src/diagram/placeLayer.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; + +export interface CanvasPlaceLayerContext { + readonly containers: Record; +} + +type NestedPlaceLayerContext = 'nested'; + +export const CanvasPlaceLayerContext = + React.createContext(null); + +export function createPlaceLayerContext(): CanvasPlaceLayerContext { + const containers: Record = { + underlay: document.createElement('div'), + overLinkGeometry: document.createElement('div'), + overLinks: document.createElement('div'), + overElements: document.createElement('div'), + }; + + for (const container of Object.values(containers)) { + container.style.display = 'contents'; + } + + return {containers}; +} + +export interface CanvasPlaceLayerProps extends React.HTMLProps { + context: CanvasPlaceLayerContext; + layer: CanvasPlaceAtLayer; +} + +export function CanvasPlaceLayer(props: CanvasPlaceLayerProps) { + const {context, layer, ...otherProps} = props; + const outerRef = React.useRef(null); + + const container = context.containers[layer]; + React.useLayoutEffect(() => { + const outer = outerRef.current; + if (outer) { + outer.appendChild(container); + return () => { + outer.removeChild(container); + }; + } + }, [outerRef, container]); + + return
; +} + +/** + * Canvas layer to render widget components at, from the bottom to the top: + * - `underlay` - placed under any diagram content; + * - `overLinkGeometry` - placed over graph link geometry (paths) but under its labels; + * - `overLinks` - placed over graph links (including its geometry and labels); + * - `overElements` - placed over both graph elements and links. + * + * All layers stated above use paper coordinates, scales and scrolls with the diagram. + */ +export type CanvasPlaceAtLayer = 'underlay' | 'overLinkGeometry' | 'overLinks' | 'overElements'; + +/** + * Places child components on a specified canvas layer as canvas widgets. + * + * @category Components + */ +export function CanvasPlaceAt(props: { + layer: CanvasPlaceAtLayer; + children: React.ReactNode; +}) { + const {layer, children} = props; + + const placeContext = React.useContext(CanvasPlaceLayerContext); + if (!placeContext) { + throw new Error('Reactodia: should be rendered only inside '); + } else if (placeContext === 'nested') { + throw new Error('Reactodia: cannot be nested into another '); + } + + const withContext = React.useMemo( + () => ( + + {children} + + ), + [children] + ); + + const container = placeContext.containers[layer]; + return createPortal(withContext, container); +} diff --git a/src/diagram/sharedCanvasState.ts b/src/diagram/sharedCanvasState.ts index ab22bf51..a3c7105d 100644 --- a/src/diagram/sharedCanvasState.ts +++ b/src/diagram/sharedCanvasState.ts @@ -4,9 +4,7 @@ import { Events, EventSource, EventObserver, PropertyChange } from '../coreUtils import { TemplateProperties } from '../data/schema'; -import type { - CanvasApi, CanvasDropEvent, CanvasWidgetDescription, -} from './canvasApi'; +import type { CanvasApi, CanvasDropEvent } from './canvasApi'; import type { ElementTemplate, LinkTemplate, RenameLinkProvider } from './customization'; import { Element, Link } from './elements'; import type { LayoutFunction } from './layout'; @@ -24,13 +22,6 @@ export interface SharedCanvasStateEvents { SharedCanvasState, CellHighlighter | undefined >; - /** - * Triggered on {@link SharedCanvasState.widgets} property change. - */ - changeWidgets: PropertyChange< - SharedCanvasState, - ReadonlyMap - >; /** * Triggered on a request to find all canvases using this state. */ @@ -86,7 +77,6 @@ export class SharedCanvasState { private disposed = false; - private _canvasWidgets: ReadonlyMap; private dropOnPaperHandler: ((e: CanvasDropEvent) => void) | undefined; private _highlighter: CellHighlighter | undefined; @@ -112,7 +102,6 @@ export class SharedCanvasState { const { defaultElementTemplate, defaultLinkTemplate, defaultLayout, renameLinkProvider, } = options; - this._canvasWidgets = new Map(); this.defaultElementTemplate = defaultElementTemplate; this.defaultLinkTemplate = defaultLinkTemplate; this.defaultLayout = defaultLayout; @@ -144,36 +133,6 @@ export class SharedCanvasState { return canvases.length > 0 ? canvases[0] : undefined; } - /** - * Live collection of canvas widgets rendered on each canvas. - */ - get widgets(): ReadonlyMap { - return this._canvasWidgets; - } - - /** - * Adds, changes or removes a canvas widget from being rendered on the canvases. - * - * @param key unique key for a widget - * @param widget widget description with a target widget layer to render on - * or `null` to remove the widget - */ - setCanvasWidget(key: string, widget: CanvasWidgetDescription | null): void { - const previous = this._canvasWidgets; - const nextWidgets = new Map(previous); - if (widget) { - const description: CanvasWidgetDescription = { - element: React.cloneElement(widget.element, {key}), - attachment: widget.attachment, - }; - nextWidgets.set(key, description); - } else { - nextWidgets.delete(key); - } - this._canvasWidgets = nextWidgets; - this.source.trigger('changeWidgets', {source: this, previous}); - } - /** * Sets the handler for the next drop event from drag-and-drop operation on a canvas. * diff --git a/src/editor/overlayController.tsx b/src/editor/overlayController.tsx index f4539ab1..d47b1a8c 100644 --- a/src/editor/overlayController.tsx +++ b/src/editor/overlayController.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { delay } from '../coreUtils/async'; import { Events, EventObserver, EventSource, PropertyChange } from '../coreUtils/events'; import { - useEventStore, useFrameDebouncedStore, useSyncStoreWithComparator, + useEventStore, useFrameDebouncedStore, useObservedProperty, useSyncStoreWithComparator, } from '../coreUtils/hooks'; import type { Translation } from '../coreUtils/i18n'; @@ -11,6 +11,7 @@ import { CanvasPointerUpEvent, CanvasKeyboardEvent, useCanvas } from '../diagram import { Element, Link, LinkVertex } from '../diagram/elements'; import { Size, Vector } from '../diagram/geometry'; import { DiagramModel } from '../diagram/model'; +import { CanvasPlaceAt } from '../diagram/placeLayer'; import type { MutableRenderingState } from '../diagram/renderingState'; import { SharedCanvasState } from '../diagram/sharedCanvasState'; import { Spinner, SpinnerProps } from '../diagram/spinner'; @@ -77,12 +78,12 @@ export class OverlayController { private readonly source = new EventSource(); readonly events: Events = this.source; + private readonly interalSource = new EventSource(); + private readonly model: DiagramModel; private readonly view: SharedCanvasState; private readonly translation: Translation; - private readonly overlays = new Set(); - private _openedDialog: OpenedDialog | undefined; private _tasks = new Set(); private _taskError: { error: unknown } | undefined; @@ -94,6 +95,15 @@ export class OverlayController { this.view = view; this.translation = translation; + withInternalApi(this)[OverlayInternalApi] = { + events: this.interalSource, + overlays: new Set(), + dialog: null, + spinner: null, + onCanvasPointerUp: this.onAnyCanvasPointerUp, + onCanvasKeydown: this.onAnyCanvasKeydown, + }; + this.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) { @@ -105,17 +115,6 @@ export class OverlayController { this.hideDialog(); } }); - - view.setCanvasWidget('selectionHandler', { - element: ( - - ), - attachment: 'viewport', - }); } /** @@ -274,10 +273,11 @@ export class OverlayController { } private setSpinner(props: SpinnerProps | undefined) { - this.view.setCanvasWidget('loadingWidget', props ? { - element: , - attachment: 'viewport', - } : null); + const internalApi = withInternalApi(this)[OverlayInternalApi]; + const spinner = props ? : null; + const previous = internalApi.spinner; + internalApi.spinner = spinner; + this.interalSource.trigger('changeSpinner', {previous, source: this}); } /** @@ -328,15 +328,18 @@ export class OverlayController { }; const canvas = this.view.findAnyCanvas(); - const breakpoint = readOverlayProperty(this.overlays, reader => reader.getDialogViewportBreakpoint()); + const breakpoint = readOverlayProperty( + withInternalApi(this)[OverlayInternalApi].overlays, + reader => reader.getDialogViewportBreakpoint() + ); const isSmallViewport = Boolean( canvas && breakpoint !== undefined && canvas.metrics.area.clientWidth <= breakpoint ); const onHide = () => this.hideDialog(); if (target && !isSmallViewport) { - this.view.setCanvasWidget('dialog', { - element: ( + this.setDialog( + {content} - ), - attachment: 'overElements', - }); + + ); } else { - this.view.setCanvasWidget('dialog', { - element: ( - - {content} - - ), - attachment: 'viewport', - }); + this.setDialog( + + {content} + + ); } this.source.trigger('changeOpenedDialog', { @@ -378,10 +377,17 @@ export class OverlayController { const previous = this._openedDialog; this._openedDialog = undefined; previous.onClose?.(); - this.view.setCanvasWidget('dialog', null); + this.setDialog(null); this.source.trigger('changeOpenedDialog', {source: this, previous}); } } + + private setDialog(dialog: React.ReactElement | null): void { + const internalApi = withInternalApi(this)[OverlayInternalApi]; + const previous = internalApi.dialog; + internalApi.dialog = dialog; + this.interalSource.trigger('changeDialog', {previous, source: this}); + } } /** @@ -417,6 +423,99 @@ interface ExtendedOverlayTask extends OverlayTask { [OverlayTaskActive]: boolean; } +const OverlayInternalApi: unique symbol = Symbol('OverlayController.internalApi'); + +interface OverlayWithInternalApi { + [OverlayInternalApi]: OverlayControllerInternalApi; +} + +interface OverlayControllerInternalApi { + readonly events: Events; + readonly overlays: Set; + onCanvasPointerUp(event: CanvasPointerUpEvent): void; + onCanvasKeydown(event: CanvasKeyboardEvent): void; + dialog: React.ReactElement | null; + spinner: React.ReactElement | null; +} + +interface OverlayControllerInternalEvents { + changeDialog: PropertyChange; + changeSpinner: PropertyChange; +} + +interface OverlayReader { + getDialogViewportBreakpoint(): number | undefined; +} + +function withInternalApi(controller: OverlayController): OverlayWithInternalApi { + return controller as Partial as OverlayWithInternalApi; +} + +function readOverlayProperty( + readers: Iterable, + getProperty: (reader: OverlayReader) => T | undefined +): T | undefined { + for (const reader of readers) { + const value = getProperty(reader); + if (value !== undefined) { + return value; + } + } + return undefined; +} + +export function OverlaySupport(props: { + overlay: OverlayController; +}) { + const { overlay } = props; + const {canvas} = useCanvas(); + + const internalApi = withInternalApi(overlay)[OverlayInternalApi]; + const {overlays, onCanvasPointerUp, onCanvasKeydown} = internalApi; + + const ref = React.useRef(null); + React.useLayoutEffect(() => { + const overlay = ref.current; + if (overlay) { + const reader: OverlayReader = { + getDialogViewportBreakpoint: () => { + const style = getComputedStyle(overlay); + // Use parseFloat to ignore trailing "px" unit at the end + return style.maxWidth ? parseFloat(style.maxWidth) : undefined; + }, + }; + overlays.add(reader); + return () => { + overlays.delete(reader); + }; + } + }, [overlays]); + + React.useEffect(() => { + const listener = new EventObserver(); + listener.listen(canvas.events, 'pointerUp', onCanvasPointerUp); + listener.listen(canvas.events, 'keydown', onCanvasKeydown); + return () => listener.stopListening(); + }, [onCanvasPointerUp, onCanvasKeydown]); + + const dialog = useObservedProperty(internalApi.events, 'changeDialog', () => internalApi.dialog); + const spinner = useObservedProperty(internalApi.events, 'changeSpinner', () => internalApi.spinner); + + return ( + <> +
+ {dialog} + {spinner} + + ); +} + function LoadingWidget(props: { spinnerProps: SpinnerProps }) { const {spinnerProps} = props; const size = useViewportSize(); @@ -478,67 +577,6 @@ function useViewportSize() { return size; } -interface OverlayReader { - getDialogViewportBreakpoint(): number | undefined; -} - -function CanvasOverlayHandler(props: { - overlays: Set; - onCanvasPointerUp: (event: CanvasPointerUpEvent) => void; - onCanvasKeydown: (event: CanvasKeyboardEvent) => void; -}) { - const {overlays, onCanvasPointerUp, onCanvasKeydown} = props; - const {canvas} = useCanvas(); - - const ref = React.useRef(null); - React.useLayoutEffect(() => { - const overlay = ref.current; - if (overlay) { - const reader: OverlayReader = { - getDialogViewportBreakpoint: () => { - const style = getComputedStyle(overlay); - // Use parseFloat to ignore trailing "px" unit at the end - return style.maxWidth ? parseFloat(style.maxWidth) : undefined; - }, - }; - overlays.add(reader); - return () => { - overlays.delete(reader); - }; - } - }, [overlays]); - - React.useEffect(() => { - const listener = new EventObserver(); - listener.listen(canvas.events, 'pointerUp', onCanvasPointerUp); - listener.listen(canvas.events, 'keydown', onCanvasKeydown); - return () => listener.stopListening(); - }, [onCanvasPointerUp, onCanvasKeydown]); - - return ( -
- ); -} - -function readOverlayProperty( - readers: Iterable, - getProperty: (reader: OverlayReader) => T | undefined -): T | undefined { - for (const reader of readers) { - const value = getProperty(reader); - if (value !== undefined) { - return value; - } - } - return undefined; -} - function getErrorMessage(error: unknown): string | undefined { if (error && typeof error === 'object' && 'message' in error) { const message = error.message; diff --git a/src/widgets/canvas.tsx b/src/widgets/canvas.tsx index feeaa7dd..94c03d15 100644 --- a/src/widgets/canvas.tsx +++ b/src/widgets/canvas.tsx @@ -11,6 +11,7 @@ import { PaperArea } from '../diagram/paperArea'; import { MutableRenderingState } from '../diagram/renderingState'; import { EntityElement } from '../editor/dataElements'; +import { OverlaySupport } from '../editor/overlayController'; import { useWorkspace } from '../workspace/workspaceContext'; @@ -87,7 +88,7 @@ const CLASS_NAME = 'reactodia-canvas'; * @category Components */ export function Canvas(props: CanvasProps) { - const {model, view} = useWorkspace(); + const {model, view, overlay} = useWorkspace(); const { elementTemplateResolver, linkTemplateResolver, linkRouter, showScrollbars, zoomOptions, watermarkSvg, watermarkUrl, children, @@ -118,6 +119,7 @@ export function Canvas(props: CanvasProps) { watermarkSvg={watermarkSvg} watermarkUrl={watermarkUrl}> {children} +
); diff --git a/src/widgets/connectionsMenu.tsx b/src/widgets/connectionsMenu.tsx index e327ef18..bf237ca0 100644 --- a/src/widgets/connectionsMenu.tsx +++ b/src/widgets/connectionsMenu.tsx @@ -9,7 +9,6 @@ import { ElementModel, ElementIri, LinkTypeIri, LinkTypeModel } from '../data/mo import { generate128BitID, makeCaseInsensitiveFilter } from '../data/utils'; import { CanvasApi, useCanvas } from '../diagram/canvasApi'; -import { defineCanvasWidget } from '../diagram/canvasWidget'; import { changeLinkTypeVisibility, placeElementsAroundTarget } from '../diagram/commands'; import { Element, VoidElement } from '../diagram/elements'; import { getContentFittingBox } from '../diagram/geometry'; @@ -198,8 +197,6 @@ export function ConnectionsMenu(props: ConnectionsMenuProps) { return null; } -defineCanvasWidget(ConnectionsMenu, element => ({element, attachment: 'viewport'})); - class VirtualTarget extends VoidElement { private readonly listener = new EventObserver(); diff --git a/src/widgets/dialog.tsx b/src/widgets/dialog.tsx index 48bad7e8..2163e4f6 100644 --- a/src/widgets/dialog.tsx +++ b/src/widgets/dialog.tsx @@ -101,7 +101,7 @@ export class Dialog extends React.Component { declare readonly context: CanvasContext; private unsubscribeFromTarget: Unsubscribe | undefined = undefined; - private readonly handler = new EventObserver(); + private readonly listener = new EventObserver(); private updateAll = () => this.forceUpdate(); @@ -113,6 +113,9 @@ export class Dialog extends React.Component { } componentDidMount() { + const {canvas, model} = this.context; + this.listener.listen(model.events, 'changeLanguage', this.updateAll); + this.listener.listen(canvas.events, 'changeTransform', this.updateAll); this.listenToTarget(this.props.target); if (this.props.target) { this.focusOn(); @@ -126,6 +129,7 @@ export class Dialog extends React.Component { } componentWillUnmount() { + this.listener.stopListening(); this.listenToTarget(undefined); } @@ -136,14 +140,9 @@ export class Dialog extends React.Component { } if (target) { - const {model} = this.context; - const unsubscribeFromStore = target.subscribe(this.updateAll, this.context); - this.handler.listen(model.events, 'changeLanguage', this.updateAll); - this.unsubscribeFromTarget = () => { unsubscribeFromStore(); - this.handler.stopListening(); }; } } @@ -287,6 +286,7 @@ export class Dialog extends React.Component { render() { const { + target, mode, dock = DEFAULT_DOCK, caption, @@ -308,6 +308,7 @@ export class Dialog extends React.Component {
({element, attachment: 'viewport'})); - function tryParseDefaultDragAndDropData(e: DragEvent): ElementIri[] { const tryGetIri = (type: string, decode: boolean = false) => { try { diff --git a/src/widgets/halo.tsx b/src/widgets/halo.tsx index 22cc3989..44307d05 100644 --- a/src/widgets/halo.tsx +++ b/src/widgets/halo.tsx @@ -4,9 +4,9 @@ import { AnyListener, EventObserver } from '../coreUtils/events'; import { useObservedProperty } from '../coreUtils/hooks'; import { CanvasApi, useCanvas } from '../diagram/canvasApi'; -import { defineCanvasWidget } from '../diagram/canvasWidget'; import { Element, ElementEvents } from '../diagram/elements'; import { boundsOf } from '../diagram/geometry'; +import { CanvasPlaceAt } from '../diagram/placeLayer'; import { SelectionActionRemove, SelectionActionExpand, SelectionActionAnchor, @@ -64,17 +64,17 @@ export function Halo(props: HaloProps) { if (singleTarget) { return ( - + + + ); } return null; } -defineCanvasWidget(Halo, element => ({element, attachment: 'overElements'})); - interface HaloInnerProps extends HaloProps { readonly target: Element; readonly canvas: CanvasApi; @@ -83,6 +83,7 @@ interface HaloInnerProps extends HaloProps { const CLASS_NAME = 'reactodia-halo'; class HaloInner extends React.Component { + private readonly listener = new EventObserver(); private targetListener = new EventObserver(); constructor(props: HaloInnerProps) { @@ -91,7 +92,8 @@ class HaloInner extends React.Component { } componentDidMount() { - const {target} = this.props; + const {canvas, target} = this.props; + this.listener.listen(canvas.events, 'changeTransform', () => this.forceUpdate()); this.listenToElement(target); } @@ -102,6 +104,7 @@ class HaloInner extends React.Component { } componentWillUnmount() { + this.listener.stopListening(); this.listenToElement(undefined); } diff --git a/src/widgets/haloLink.tsx b/src/widgets/haloLink.tsx index b8d766c3..f366c840 100644 --- a/src/widgets/haloLink.tsx +++ b/src/widgets/haloLink.tsx @@ -4,13 +4,13 @@ import { EventObserver } from '../coreUtils/events'; import { useEventStore, useSyncStore } from '../coreUtils/hooks'; import { CanvasApi, useCanvas } from '../diagram/canvasApi'; -import { defineCanvasWidget } from '../diagram/canvasWidget'; import { Link } from '../diagram/elements'; import { Rect, Spline, Vector, computePolyline, computePolylineLength, getPointAlongPolyline, } from '../diagram/geometry'; import type { DiagramModel, GraphStructure } from '../diagram/model'; import { SvgPaperLayer } from '../diagram/paper'; +import { CanvasPlaceAt } from '../diagram/placeLayer'; import type { RenderingState } from '../diagram/renderingState'; import { @@ -89,8 +89,6 @@ export function HaloLink(props: HaloLinkProps) { return null; } -defineCanvasWidget(HaloLink, element => ({element, attachment: 'overElements'})); - interface HaloLinkInnerProps extends HaloLinkProps { target: Link; model: DiagramModel; @@ -111,6 +109,7 @@ const DEFAULT_BUTTON_SIZE = 20; const DEFAULT_BUTTON_MARGIN = 5; class HaloLinkInner extends React.Component { + private readonly listener = new EventObserver(); private targetListener = new EventObserver(); constructor(props: HaloLinkInnerProps) { @@ -167,7 +166,8 @@ class HaloLinkInner extends React.Component { } componentDidMount() { - const {target} = this.props; + const {canvas, target} = this.props; + this.listener.listen(canvas.events, 'changeTransform', () => this.forceUpdate()); this.listenToTarget(target); } @@ -186,6 +186,7 @@ class HaloLinkInner extends React.Component { } componentWillUnmount() { + this.listener.stopListening(); this.listenToTarget(undefined); } @@ -234,21 +235,26 @@ class HaloLinkInner extends React.Component { } as React.CSSProperties; return ( -
+ <> - - {children ?? <> - - - - - - } - -
+ +
+ + {children ?? <> + + + + + + } + +
+
+ ); } } @@ -285,12 +291,13 @@ function computeLinkSpline( interface LinkHighlightProps { actionContext: HaloLinkActionContext; + style: React.CSSProperties; margin: number; canvas: CanvasApi; } function LinkHighlight(props: LinkHighlightProps) { - const {actionContext: {link, spline}, margin, canvas} = props; + const {actionContext: {link, spline}, style, margin, canvas} = props; const labelBoundsStore = useEventStore(canvas.renderingState.events, 'changeLinkLabelBounds'); const labelBounds = useSyncStore( @@ -318,14 +325,21 @@ function LinkHighlight(props: LinkHighlightProps) { }; return <> -
- - - + + + + + + +
+
+
+ ; } diff --git a/src/widgets/navigator.tsx b/src/widgets/navigator.tsx index 18b72cdf..6ec1ba50 100644 --- a/src/widgets/navigator.tsx +++ b/src/widgets/navigator.tsx @@ -5,9 +5,8 @@ import { EventObserver } from '../coreUtils/events'; import { Debouncer } from '../coreUtils/scheduler'; import { CanvasApi, useCanvas } from '../diagram/canvasApi'; -import { defineCanvasWidget } from '../diagram/canvasWidget'; import { Element } from '../diagram/elements'; -import { Rect, Vector, boundsOf, getContentFittingBox } from '../diagram/geometry'; +import { Rect, Vector, getContentFittingBox } from '../diagram/geometry'; import { PaperTransform, totalPaneSize, paneTopLeft, paneFromPaperCoords, paperFromPaneCoords } from '../diagram/paper'; @@ -563,8 +562,6 @@ class NavigatorInner extends React.Component { }; } -defineCanvasWidget(Navigator, element => ({element, attachment: 'viewport'})); - function computeDrawStyle(props: NavigatorProps, styleSource: HTMLElement): DrawStyle { const { backgroundFill, diff --git a/src/widgets/selection.tsx b/src/widgets/selection.tsx index b0269dfa..878f9754 100644 --- a/src/widgets/selection.tsx +++ b/src/widgets/selection.tsx @@ -3,18 +3,19 @@ import * as React from 'react'; import { shallowArrayEqual } from '../coreUtils/collections'; import { EventObserver } from '../coreUtils/events'; import { - SyncStore, useEventStore, useFrameDebouncedStore, useSyncStoreWithComparator, + SyncStore, useEventStore, useFrameDebouncedStore, useSyncStore, useSyncStoreWithComparator, } from '../coreUtils/hooks'; import type { HotkeyString } from '../coreUtils/hotkey'; import { CanvasApi, CanvasMetrics, useCanvas } from '../diagram/canvasApi'; -import { defineCanvasWidget, useCanvasHotkey } from '../diagram/canvasWidget'; +import { useCanvasHotkey } from '../diagram/canvasHotkey'; import { RestoreGeometry } from '../diagram/commands'; import { Element, Link } from '../diagram/elements'; import { Rect, SizeProvider, Vector, boundsOf, findElementAtPoint, getContentFittingBox, } from '../diagram/geometry'; import { DiagramModel } from '../diagram/model'; +import { CanvasPlaceAt } from '../diagram/placeLayer'; import { SelectionActionRemove, SelectionActionZoomToFit, SelectionActionLayout, @@ -143,20 +144,20 @@ export function Selection(props: SelectionProps) { if (highlightedBox || selectedElements.length > 1) { return ( - + + + ); } else { return null; } } -defineCanvasWidget(Selection, element => ({element, attachment: 'overElements'})); - interface PageOrigin { readonly pageX: number; readonly pageY: number; @@ -227,6 +228,11 @@ function SelectionBox(props: SelectionBoxProps) { children, } = props; + useSyncStore( + useEventStore(canvas.events, 'changeTransform'), + () => canvas.metrics.getTransform() + ); + const elementBoundsStore = useElementBoundsStore(model, canvas, selectedElements); const elementBoundsDebouncedStore = useFrameDebouncedStore(elementBoundsStore); const fittingBox = useSyncStoreWithComparator( diff --git a/src/widgets/selectionAction.tsx b/src/widgets/selectionAction.tsx index 593906bf..50f2e246 100644 --- a/src/widgets/selectionAction.tsx +++ b/src/widgets/selectionAction.tsx @@ -12,7 +12,7 @@ import { TranslatedText, useTranslation } from '../coreUtils/i18n'; import { LinkTypeIri } from '../data/model'; import { useCanvas } from '../diagram/canvasApi'; -import { useCanvasHotkey } from '../diagram/canvasWidget'; +import { useCanvasHotkey } from '../diagram/canvasHotkey'; import { setElementExpanded } from '../diagram/commands'; import { Element, Link } from '../diagram/elements'; import { getContentFittingBox } from '../diagram/geometry'; diff --git a/src/widgets/toolbar.tsx b/src/widgets/toolbar.tsx index 0746c341..725e6b60 100644 --- a/src/widgets/toolbar.tsx +++ b/src/widgets/toolbar.tsx @@ -3,8 +3,6 @@ import * as React from 'react'; import { useTranslation } from '../coreUtils/i18n'; -import { defineCanvasWidget } from '../diagram/canvasWidget'; - import { DropdownMenu } from './utility/dropdown'; import { DockDirection, ViewportDock } from './utility/viewportDock'; @@ -78,5 +76,3 @@ export function Toolbar(props: ToolbarProps) { ); } - -defineCanvasWidget(Toolbar, element => ({element, attachment: 'viewport'})); diff --git a/src/widgets/toolbarAction.tsx b/src/widgets/toolbarAction.tsx index 12ed0d4e..36d410d7 100644 --- a/src/widgets/toolbarAction.tsx +++ b/src/widgets/toolbarAction.tsx @@ -7,7 +7,7 @@ import type { HotkeyString } from '../coreUtils/hotkey'; import { Translation, TranslatedText, useTranslation } from '../coreUtils/i18n'; import { ExportRasterOptions, useCanvas } from '../diagram/canvasApi'; -import { useCanvasHotkey } from '../diagram/canvasWidget'; +import { useCanvasHotkey } from '../diagram/canvasHotkey'; import type { Command } from '../diagram/history'; import { dataURLToBlob } from '../diagram/toSvg'; diff --git a/src/widgets/unifiedSearch/unifiedSearch.tsx b/src/widgets/unifiedSearch/unifiedSearch.tsx index 7d6c8583..199b7b94 100644 --- a/src/widgets/unifiedSearch/unifiedSearch.tsx +++ b/src/widgets/unifiedSearch/unifiedSearch.tsx @@ -7,7 +7,7 @@ import { useTranslation } from '../../coreUtils/i18n'; import { Debouncer } from '../../coreUtils/scheduler'; import { useCanvas } from '../../diagram/canvasApi'; -import { useCanvasHotkey } from '../../diagram/canvasWidget'; +import { useCanvasHotkey } from '../../diagram/canvasHotkey'; import type { Rect, Size, Vector } from '../../diagram/geometry'; import { DraggableHandle } from '../utility/draggableHandle'; diff --git a/src/widgets/visualAuthoring/authoredRelationOverlay.tsx b/src/widgets/visualAuthoring/authoredRelationOverlay.tsx index a5f60d8e..29af0c33 100644 --- a/src/widgets/visualAuthoring/authoredRelationOverlay.tsx +++ b/src/widgets/visualAuthoring/authoredRelationOverlay.tsx @@ -9,12 +9,13 @@ import { LinkKey } from '../../data/model'; import type { ValidationSeverity } from '../../data/validationProvider'; import { CanvasApi, useCanvas } from '../../diagram/canvasApi'; +import { Link } from '../../diagram/elements'; import { Rect, Spline, Vector, computePolyline, getPointAlongPolyline, computePolylineLength, } from '../../diagram/geometry'; import { SvgPaperLayer } from '../../diagram/paper'; +import { CanvasPlaceAt } from '../../diagram/placeLayer'; import { RenderingLayer } from '../../diagram/renderingState'; -import { Link } from '../../diagram/elements'; import { HtmlSpinner } from '../../diagram/spinner'; import { AuthoredRelation, AuthoringState } from '../../editor/authoringState'; @@ -84,6 +85,7 @@ class LinkStateWidgetInner extends React.Component - - {this.renderLinkStateHighlighting()} - -
- {this.renderLinkStateLabels()} -
-
; + return ( + <> + +
+ + {this.renderLinkStateHighlighting()} + +
+
+ +
+
+ {this.renderLinkStateLabels()} +
+
+
+ + ); } } diff --git a/src/widgets/visualAuthoring/dragEditLayer.tsx b/src/widgets/visualAuthoring/dragEditLayer.tsx index ae0be4d4..2e975512 100644 --- a/src/widgets/visualAuthoring/dragEditLayer.tsx +++ b/src/widgets/visualAuthoring/dragEditLayer.tsx @@ -12,6 +12,7 @@ import { Element, VoidElement } from '../../diagram/elements'; import { SizeProvider, Vector, boundsOf, findElementAtPoint } from '../../diagram/geometry'; import { LinkLayer, LinkMarkers } from '../../diagram/linkLayer'; import { SvgPaperLayer } from '../../diagram/paper'; +import { CanvasPlaceAt } from '../../diagram/placeLayer'; import type { MutableRenderingState } from '../../diagram/renderingState'; import { Spinner } from '../../diagram/spinner'; @@ -75,10 +76,12 @@ export function DragEditLayer(props: DragEditLayerProps) { const workspace = useWorkspace(); const {canvas} = useCanvas(); return ( - + + + ); } @@ -111,7 +114,7 @@ class DragEditLayerInner extends React.Component } componentDidMount() { - const {operation} = this.props; + const {canvas, operation} = this.props; this.cancellation = new AbortController(); @@ -133,6 +136,7 @@ class DragEditLayerInner extends React.Component this.forceUpdate(); this.queryCanConnectToAny(); + this.listener.listen(canvas.events, 'changeTransform', () => this.forceUpdate()); document.addEventListener('mousemove', this.onMouseMove); document.addEventListener('mouseup', this.onMouseUp); } diff --git a/src/widgets/visualAuthoring/visualAuthoring.tsx b/src/widgets/visualAuthoring/visualAuthoring.tsx index 76f45ab2..55ca194b 100644 --- a/src/widgets/visualAuthoring/visualAuthoring.tsx +++ b/src/widgets/visualAuthoring/visualAuthoring.tsx @@ -5,7 +5,6 @@ import { useObservedProperty } from '../../coreUtils/hooks'; import { Debouncer } from '../../coreUtils/scheduler'; import type { ElementModel, LinkModel } from '../../data/model'; -import { defineCanvasWidget } from '../../diagram/canvasWidget'; import { Link } from '../../diagram/elements'; import { Size } from '../../diagram/geometry'; @@ -144,16 +143,11 @@ export interface VisualAuthoringCommands { */ export function VisualAuthoring(props: VisualAuthoringProps) { const {propertyEditor, inputResolver, inlineEntityActions = true} = props; - const {model, view, editor, overlay, translation: t, getCommandBus} = useWorkspace(); + const {model, editor, overlay, translation: t, getCommandBus} = useWorkspace(); React.useLayoutEffect(() => { const listener = new EventObserver(); - view.setCanvasWidget('states', { - element: , - attachment: 'overLinks', - }); - listener.listen(overlay.events, 'changeOpenedDialog', ({previous}) => { if (previous && previous.target) { editor.removeTemporaryCells([previous.target]); @@ -162,7 +156,6 @@ export function VisualAuthoring(props: VisualAuthoringProps) { return () => { listener.stopListening(); - view.setCanvasWidget('states', null); }; }, []); @@ -170,18 +163,6 @@ export function VisualAuthoring(props: VisualAuthoringProps) { const commands = getCommandBus(VisualAuthoringTopic); const listener = new EventObserver(); - listener.listen(commands, 'startDragEdit', ({operation}) => { - const onFinishEditing = () => { - view.setCanvasWidget('dragEditLayer', null); - }; - const dragEditLayer = ( - - ); - view.setCanvasWidget('dragEditLayer', {element: dragEditLayer, attachment: 'overElements'}); - }); - listener.listen(commands, 'editEntity', ({target}) => { const onSubmit = (newData: ElementModel) => { overlay.hideDialog(); @@ -316,7 +297,13 @@ export function VisualAuthoring(props: VisualAuthoringProps) { return () => listener.stopListening(); }, []); - return ; + return ( + <> + + + + + ); } function EntityDecoratorsInner(props: { @@ -370,4 +357,25 @@ function EntityDecoratorsInner(props: { const EntityDecorators = React.memo(EntityDecoratorsInner); -defineCanvasWidget(VisualAuthoring, element => ({element, attachment: 'viewport'})); +function DragEditState() { + const [layer, setLayer] = React.useState(null); + + const {getCommandBus} = useWorkspace(); + + React.useLayoutEffect(() => { + const commands = getCommandBus(VisualAuthoringTopic); + const listener = new EventObserver(); + + listener.listen(commands, 'startDragEdit', ({operation}) => { + setLayer( + setLayer(null)} + /> + ); + }); + + return () => listener.stopListening(); + }, []); + + return layer; +} diff --git a/src/widgets/zoomControl.tsx b/src/widgets/zoomControl.tsx index 596821b8..f6dd3a87 100644 --- a/src/widgets/zoomControl.tsx +++ b/src/widgets/zoomControl.tsx @@ -5,7 +5,6 @@ import { useObservedProperty } from '../coreUtils/hooks'; import { useTranslation } from '../coreUtils/i18n'; import { useCanvas } from '../diagram/canvasApi'; -import { defineCanvasWidget } from '../diagram/canvasWidget'; import { DockDirection, ViewportDock } from './utility/viewportDock'; @@ -105,5 +104,3 @@ export function ZoomControl(props: ZoomControlProps) { ); } - -defineCanvasWidget(ZoomControl, element => ({element, attachment: 'viewport'})); diff --git a/src/workspace.ts b/src/workspace.ts index cede34fc..c24b3c6c 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -54,7 +54,7 @@ export * from './data/sparql/sparqlDataProvider'; export * from './data/sparql/sparqlDataProviderSettings'; export * from './diagram/canvasApi'; -export { defineCanvasWidget, useCanvasHotkey } from './diagram/canvasWidget'; +export { type CanvasHotkey, useCanvasHotkey } from './diagram/canvasHotkey'; export { RestoreGeometry, setElementState, setElementExpanded, setLinkState, changeLinkTypeVisibility, restoreCapturedLinkGeometry, restoreViewport, @@ -92,6 +92,7 @@ export { HtmlPaperLayer, type HtmlPaperLayerProps, SvgPaperLayer, type SvgPaperLayerProps, } from './diagram/paper'; +export { CanvasPlaceAt, type CanvasPlaceAtLayer } from './diagram/placeLayer'; export { RenderingState, RenderingStateEvents, RenderingLayer } from './diagram/renderingState'; export { type SharedCanvasState, SharedCanvasStateEvents, CellHighlighter, diff --git a/src/workspace/classicWorkspace.tsx b/src/workspace/classicWorkspace.tsx index 8d71dd3a..fc83b2ff 100644 --- a/src/workspace/classicWorkspace.tsx +++ b/src/workspace/classicWorkspace.tsx @@ -2,8 +2,6 @@ import * as React from 'react'; import { useTranslation } from '../coreUtils/i18n'; -import { defineCanvasWidget } from '../diagram/canvasWidget'; - import { Canvas } from '../widgets/canvas'; import { ClassTree, ClassTreeProps } from '../widgets/classTree'; import { ConnectionsMenu } from '../widgets/connectionsMenu'; @@ -69,7 +67,7 @@ export interface ClassicWorkspaceProps extends BaseDefaultWorkspaceProps { */ export function ClassicWorkspace(props: ClassicWorkspaceProps) { const { - colorScheme, leftColumn, rightColumn, + colorScheme, leftColumn, rightColumn, children, canvas, canvasWidgets, connectionsMenu, dropOnCanvas, halo, haloLink, selection, navigator, zoomControl, visualAuthoring, toolbar, classTree, instancesSearch, linkToolbox, @@ -109,6 +107,7 @@ export function ClassicWorkspace(props: ClassicWorkspaceProps) { {toolbar === null ? null : } {zoomControl === null ? null : } {canvasWidgets} + {children} ); } - -defineCanvasWidget(ClassicToolbar, element => ({element, attachment: 'viewport'})); diff --git a/src/workspace/defaultWorkspace.tsx b/src/workspace/defaultWorkspace.tsx index a30b6558..eaf20f02 100644 --- a/src/workspace/defaultWorkspace.tsx +++ b/src/workspace/defaultWorkspace.tsx @@ -43,6 +43,8 @@ export interface BaseDefaultWorkspaceProps { canvas?: CanvasProps; /** * Additional widgets to pass as children to the {@link Canvas} component. + * + * @deprecated Place additional widgets as direct children instead. */ canvasWidgets?: ReadonlyArray; /** @@ -91,6 +93,10 @@ export interface BaseDefaultWorkspaceProps { * If specified as `null`, the component will not be rendered. */ zoomControl?: Partial | null; + /** + * Children to the {@link Canvas} component (e.g. additional widgets). + */ + children?: React.ReactNode; } /** @@ -163,7 +169,7 @@ export interface DefaultWorkspaceProps extends BaseDefaultWorkspaceProps { */ export function DefaultWorkspace(props: DefaultWorkspaceProps) { const { - colorScheme, + colorScheme, children, canvas, canvasWidgets, connectionsMenu, dropOnCanvas, halo, haloLink, selection, navigator, visualAuthoring, zoomControl, menu, search, actions, mainToolbar, actionsToolbar, @@ -251,6 +257,7 @@ export function DefaultWorkspace(props: DefaultWorkspaceProps) { )} {canvasWidgets} + {children} ); diff --git a/styles/mixin/_zIndex.scss b/styles/mixin/_zIndex.scss index c94bb888..e5ab08f4 100644 --- a/styles/mixin/_zIndex.scss +++ b/styles/mixin/_zIndex.scss @@ -1,7 +1,9 @@ -$accordion-handle: 10; -$accordion-handle-button: 20; -$loading-widget: 30; -$dropdown: 40; -$toolbar-menu: 50; -$viewport-dialog: 60; -$panning-overlay: 70; +@use "../theme/theme"; + +$accordion-handle: calc(theme.$z-index-base + 10); +$accordion-handle-button: calc(theme.$z-index-base + 20); +$loading-widget: calc(theme.$z-index-base + 30); +$dropdown: calc(theme.$z-index-base + 40); +$toolbar-menu: calc(theme.$z-index-base + 50); +$viewport-dialog: calc(theme.$z-index-base + 60); +$panning-overlay: calc(theme.$z-index-base + 70); diff --git a/styles/theme/_common.scss b/styles/theme/_common.scss index 9e2904cf..3d8e8a28 100644 --- a/styles/theme/_common.scss +++ b/styles/theme/_common.scss @@ -74,6 +74,7 @@ --reactodia-spacing-base: 5px; --reactodia-spacing-vertical: var(--reactodia-spacing-base); --reactodia-spacing-horizontal: var(--reactodia-spacing-base); + --reactodia-z-index-base: 0; --reactodia-border-radius-base: 4px; --reactodia-border-radius-s: 2px; diff --git a/styles/theme/_theme.scss b/styles/theme/_theme.scss index 29228729..478edc57 100644 --- a/styles/theme/_theme.scss +++ b/styles/theme/_theme.scss @@ -101,10 +101,11 @@ $line-height-base: var(--reactodia-line-height-base); $font-color-base: var(--reactodia-font-color-base); $font-color-base-inverse: var(--reactodia-font-color-base-inverse); -/* Spacing */ +/* Layout */ $spacing-base: var(--reactodia-spacing-base); $spacing-vertical: var(--reactodia-spacing-vertical); $spacing-horizontal: var(--reactodia-spacing-horizontal); +$z-index-base: var(--reactodia-z-index-base); /* Borders */ $border-width-base: var(--reactodia-border-width-base);