;
}
/**
@@ -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
);
}
-
-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);