feat: Implement MiniMap, Background and Controls components
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Connection, Edge, Elements, FlowElement, Node, OnLoadParams } from './types';
|
||||
import { addEdge, isNode, removeElements } from './utils/graph';
|
||||
import RevueFlow from './container/RevueFlow';
|
||||
import { MiniMap, Controls, Background } from './additional-components';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
|
||||
const onNodeDragStop = (_: MouseEvent, node: Node) => console.log('drag stop', node);
|
||||
@@ -16,6 +17,7 @@ const initialElements: Elements = [
|
||||
];
|
||||
|
||||
const BasicFlow = defineComponent({
|
||||
components: { RevueFlow, MiniMap, Controls, Background },
|
||||
setup() {
|
||||
const elements = ref<Elements>(initialElements);
|
||||
const rfInstance = ref<OnLoadParams | null>(null);
|
||||
@@ -62,6 +64,9 @@ const BasicFlow = defineComponent({
|
||||
minZoom={0.2}
|
||||
maxZoom={4}
|
||||
>
|
||||
<MiniMap />
|
||||
<Controls />
|
||||
<Background color="#aaa" gap={16} />
|
||||
<div style={{ position: 'absolute', right: '10px', top: '10px', zIndex: 4 }}>
|
||||
<button onClick={resetTransform} style={{ marginRight: '5px' }}>
|
||||
reset transform
|
||||
|
||||
76
src/additional-components/Background/index.tsx
Normal file
76
src/additional-components/Background/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BackgroundVariant } from '../../types';
|
||||
import { createGridDotsPath, createGridLinesPath } from './utils';
|
||||
import { computed, defineComponent, HTMLAttributes, PropType } from 'vue';
|
||||
import store from '../../store';
|
||||
|
||||
export interface BackgroundProps extends HTMLAttributes {
|
||||
variant?: BackgroundVariant;
|
||||
gap?: number;
|
||||
color?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const defaultColors = {
|
||||
[BackgroundVariant.Dots]: '#81818a',
|
||||
[BackgroundVariant.Lines]: '#eee'
|
||||
};
|
||||
|
||||
const Background = defineComponent({
|
||||
name: 'Background',
|
||||
props: {
|
||||
variant: {
|
||||
type: String as PropType<BackgroundProps['variant']>,
|
||||
required: false,
|
||||
default: BackgroundVariant.Dots
|
||||
},
|
||||
gap: {
|
||||
type: Number as PropType<BackgroundProps['gap']>,
|
||||
required: false,
|
||||
default: 15
|
||||
},
|
||||
color: {
|
||||
type: String as PropType<BackgroundProps['color']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
size: {
|
||||
type: Number as PropType<BackgroundProps['size']>,
|
||||
required: false,
|
||||
default: 0.4
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const pinia = store();
|
||||
const [x, y, scale] = pinia.transform;
|
||||
// when there are multiple flows on a page we need to make sure that every background gets its own pattern.
|
||||
const patternId = computed(() => `pattern-${Math.floor(Math.random() * 100000)}`);
|
||||
|
||||
const bgClasses = ['react-flow__background'];
|
||||
const scaledGap = props.gap || 15 * scale;
|
||||
const xOffset = x % scaledGap;
|
||||
const yOffset = y % scaledGap;
|
||||
|
||||
const isLines = props.variant === BackgroundVariant.Lines;
|
||||
const bgColor = props.color ? props.color : defaultColors[props.variant || BackgroundVariant.Dots];
|
||||
const path = isLines
|
||||
? createGridLinesPath(scaledGap, props.size || 0.4, bgColor)
|
||||
: createGridDotsPath(props.size || 0.4 * scale, bgColor);
|
||||
|
||||
return () => (
|
||||
<svg
|
||||
class={bgClasses}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<pattern id={patternId.value} x={xOffset} y={yOffset} width={scaledGap} height={scaledGap} patternUnits="userSpaceOnUse">
|
||||
{path}
|
||||
</pattern>
|
||||
<rect x="0" y="0" width="100%" height="100%" fill={`url(#${patternId.value})`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default Background;
|
||||
7
src/additional-components/Background/utils.tsx
Normal file
7
src/additional-components/Background/utils.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const createGridLinesPath = (size: number, strokeWidth: number, stroke: string) => {
|
||||
return <path stroke={stroke} stroke-width={strokeWidth} d={`M${size / 2} 0 V${size} M0 ${size / 2} H${size}`} />;
|
||||
};
|
||||
|
||||
export const createGridDotsPath = (size: number, fill: string) => {
|
||||
return <circle cx={size} cy={size} r={size} fill={fill} />;
|
||||
};
|
||||
149
src/additional-components/Controls/index.tsx
Normal file
149
src/additional-components/Controls/index.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import useZoomPanHelper from '../../hooks/useZoomPanHelper';
|
||||
import { FitViewParams } from '../../types';
|
||||
import { defineComponent, HTMLAttributes, onMounted, PropType, ref } from 'vue';
|
||||
import store from '../../store';
|
||||
|
||||
export interface ControlProps extends HTMLAttributes {
|
||||
showZoom?: boolean;
|
||||
showFitView?: boolean;
|
||||
showInteractive?: boolean;
|
||||
fitViewParams?: FitViewParams;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
onFitView?: () => void;
|
||||
onInteractiveChange?: (interactiveStatus: boolean) => void;
|
||||
}
|
||||
|
||||
export type ControlButtonProps = HTMLButtonElement;
|
||||
|
||||
export const ControlButton = defineComponent({
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean as PropType<ControlButtonProps['disabled']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
onClick: {
|
||||
type: Function as PropType<() => any>,
|
||||
required: false,
|
||||
default: undefined
|
||||
}
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => (
|
||||
<button class={['react-flow__controls-button']} onClick={props.onClick} disabled={props.disabled}>
|
||||
{slots.default ? slots.default() : ''}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const Controls = defineComponent({
|
||||
name: 'Controls',
|
||||
props: {
|
||||
showZoom: {
|
||||
type: Boolean as PropType<ControlProps['showZoom']>,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
showFitView: {
|
||||
type: Boolean as PropType<ControlProps['showFitView']>,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
showInteractive: {
|
||||
type: Boolean as PropType<ControlProps['showInteractive']>,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
fitViewParams: {
|
||||
type: Object as PropType<ControlProps['fitViewParams']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
onZoomIn: {
|
||||
type: Function() as PropType<ControlProps['onZoomIn']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
onZoomOut: {
|
||||
type: Function() as PropType<ControlProps['onZoomOut']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
onFitView: {
|
||||
type: Function() as PropType<ControlProps['onFitView']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
onInteractiveChange: {
|
||||
type: Function() as PropType<ControlProps['onInteractiveChange']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
}
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const pinia = store();
|
||||
const isVisible = ref<boolean>(false);
|
||||
const { zoomIn, zoomOut, fitView } = useZoomPanHelper();
|
||||
|
||||
const isInteractive = pinia.nodesDraggable && pinia.nodesConnectable && pinia.elementsSelectable;
|
||||
const mapClasses = ['react-flow__controls'];
|
||||
|
||||
const onZoomInHandler = () => {
|
||||
zoomIn?.();
|
||||
props.onZoomIn?.();
|
||||
};
|
||||
|
||||
const onZoomOutHandler = () => {
|
||||
zoomOut?.();
|
||||
props.onZoomOut?.();
|
||||
};
|
||||
|
||||
const onFitViewHandler = () => {
|
||||
fitView?.(props.fitViewParams);
|
||||
props.onFitView?.();
|
||||
};
|
||||
|
||||
const onInteractiveChangeHandler = () => {
|
||||
pinia.setInteractive?.(!isInteractive);
|
||||
props.onInteractiveChange?.(!isInteractive);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
isVisible.value = true;
|
||||
});
|
||||
|
||||
return () => (
|
||||
<div class={mapClasses}>
|
||||
{props.showZoom && (
|
||||
<>
|
||||
<ControlButton onClick={onZoomInHandler} class="react-flow__controls-zoomin">
|
||||
<img src={'../../../assets/icons/plus.svg'} alt="Plus" />
|
||||
</ControlButton>
|
||||
<ControlButton onClick={onZoomOutHandler} class="react-flow__controls-zoomout">
|
||||
<img src={'../../../assets/icons/minus.svg'} alt="Minus" />
|
||||
</ControlButton>
|
||||
</>
|
||||
)}
|
||||
{props.showFitView && (
|
||||
<ControlButton class="react-flow__controls-fitview" onClick={onFitViewHandler}>
|
||||
<img src={'../../../assets/icons/fitview.svg'} alt="FitView" />
|
||||
</ControlButton>
|
||||
)}
|
||||
{props.showInteractive && (
|
||||
<ControlButton class="react-flow__controls-interactive" onClick={onInteractiveChangeHandler}>
|
||||
{isInteractive ? (
|
||||
<img src={'../../../assets/icons/unlock.svg'} alt="Unlock" />
|
||||
) : (
|
||||
<img src={'../../../assets/icons/lock.svg'} alt="Lock" />
|
||||
)}
|
||||
</ControlButton>
|
||||
)}
|
||||
{slots.default ? slots.default() : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default Controls;
|
||||
86
src/additional-components/MiniMap/MiniMapNode.tsx
Normal file
86
src/additional-components/MiniMap/MiniMapNode.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
|
||||
interface MiniMapNodeProps {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
borderRadius: number;
|
||||
color: string;
|
||||
shapeRendering: string;
|
||||
strokeColor: string;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
const MiniMapNode = defineComponent({
|
||||
name: 'MiniMapNode',
|
||||
props: {
|
||||
x: {
|
||||
type: Number as PropType<MiniMapNodeProps['x']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
y: {
|
||||
type: Number as PropType<MiniMapNodeProps['y']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
width: {
|
||||
type: Number as PropType<MiniMapNodeProps['width']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
height: {
|
||||
type: Number as PropType<MiniMapNodeProps['height']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
borderRadius: {
|
||||
type: Number as PropType<MiniMapNodeProps['borderRadius']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
color: {
|
||||
type: String as PropType<MiniMapNodeProps['color']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
shapeRendering: {
|
||||
type: String as PropType<MiniMapNodeProps['shapeRendering']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
strokeColor: {
|
||||
type: String as PropType<MiniMapNodeProps['strokeColor']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
},
|
||||
strokeWidth: {
|
||||
type: Number as PropType<MiniMapNodeProps['strokeWidth']>,
|
||||
required: false,
|
||||
default: undefined
|
||||
}
|
||||
},
|
||||
setup(props, { attrs }: { attrs: Record<string, any> }) {
|
||||
const { background, backgroundColor } = attrs.style || {};
|
||||
const fill = (props.color || background || backgroundColor) as string;
|
||||
|
||||
return () => (
|
||||
<rect
|
||||
class={['react-flow__minimap-node']}
|
||||
x={props.x}
|
||||
y={props.y}
|
||||
rx={props.borderRadius}
|
||||
ry={props.borderRadius}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
fill={fill}
|
||||
stroke={props.strokeColor}
|
||||
stroke-width={props.strokeWidth}
|
||||
shape-rendering={props.shapeRendering}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default MiniMapNode;
|
||||
131
src/additional-components/MiniMap/index.tsx
Normal file
131
src/additional-components/MiniMap/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { getRectOfNodes, getBoundsofRects } from '../../utils/graph';
|
||||
import { Node, Rect } from '../../types';
|
||||
import MiniMapNode from './MiniMapNode';
|
||||
import { computed, defineComponent, HTMLAttributes, PropType } from 'vue';
|
||||
import store from '../../store';
|
||||
|
||||
type StringFunc = (node: Node) => string;
|
||||
|
||||
export interface MiniMapProps extends HTMLAttributes {
|
||||
nodeColor?: string | StringFunc;
|
||||
nodeStrokeColor?: string | StringFunc;
|
||||
nodeClassName?: string | StringFunc;
|
||||
nodeBorderRadius?: number;
|
||||
nodeStrokeWidth?: number;
|
||||
maskColor?: string;
|
||||
}
|
||||
|
||||
declare const window: any;
|
||||
|
||||
const defaultWidth = 200;
|
||||
const defaultHeight = 150;
|
||||
|
||||
const MiniMap = defineComponent({
|
||||
name: 'MiniMap',
|
||||
props: {
|
||||
nodeStrokeColor: {
|
||||
type: (String || Function) as PropType<MiniMapProps['nodeStrokeColor']>,
|
||||
required: false,
|
||||
default: '#555'
|
||||
},
|
||||
nodeColor: {
|
||||
type: (String || Function) as PropType<MiniMapProps['nodeColor']>,
|
||||
required: false,
|
||||
default: '#fff'
|
||||
},
|
||||
nodeClassName: {
|
||||
type: (String || Function) as PropType<MiniMapProps['nodeClassName']>,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
nodeBorderRadius: {
|
||||
type: Number as PropType<MiniMapProps['nodeBorderRadius']>,
|
||||
required: false,
|
||||
default: 5
|
||||
},
|
||||
nodeStrokeWidth: {
|
||||
type: Number as PropType<MiniMapProps['nodeStrokeWidth']>,
|
||||
required: false,
|
||||
default: 2
|
||||
},
|
||||
maskColor: {
|
||||
type: String as PropType<MiniMapProps['maskColor']>,
|
||||
required: false,
|
||||
default: 'rgb(240, 242, 243, 0.7)'
|
||||
}
|
||||
},
|
||||
setup(props, { attrs }: { attrs: Record<string, any> }) {
|
||||
const pinia = store();
|
||||
const transform = computed(() => pinia.transform);
|
||||
|
||||
const mapClasses = ['react-flow__minimap'];
|
||||
const elementWidth = computed(() => (attrs.style?.width || defaultWidth)! as number);
|
||||
const elementHeight = computed(() => (attrs.style?.height || defaultHeight)! as number);
|
||||
const nodeColorFunc = (props.nodeColor instanceof Function ? props.nodeColor : () => props.nodeColor) as StringFunc;
|
||||
const nodeStrokeColorFunc = (
|
||||
props.nodeStrokeColor instanceof Function ? props.nodeStrokeColor : () => props.nodeStrokeColor
|
||||
) as StringFunc;
|
||||
const nodeClassNameFunc = (
|
||||
props.nodeClassName instanceof Function ? props.nodeClassName : () => props.nodeClassName
|
||||
) as StringFunc;
|
||||
const hasNodes = computed(() => pinia.nodes && pinia.nodes.length);
|
||||
const bb = computed(() => getRectOfNodes(pinia.nodes));
|
||||
const viewBB = computed<Rect>(() => ({
|
||||
x: -transform.value[0] / transform.value[2],
|
||||
y: -transform.value[1] / transform.value[2],
|
||||
width: pinia.width / transform.value[2],
|
||||
height: pinia.height / transform.value[2]
|
||||
}));
|
||||
const boundingRect = computed(() => (hasNodes.value ? getBoundsofRects(bb.value, viewBB.value) : viewBB.value));
|
||||
const scaledWidth = computed(() => boundingRect.value.width / elementWidth.value);
|
||||
const scaledHeight = computed(() => boundingRect.value.height / elementHeight.value);
|
||||
const viewScale = computed(() => Math.max(scaledWidth.value, scaledHeight.value));
|
||||
const viewWidth = computed(() => viewScale.value * elementWidth.value);
|
||||
const viewHeight = computed(() => viewScale.value * elementHeight.value);
|
||||
const offset = computed(() => 5 * viewScale.value);
|
||||
const x = computed(() => boundingRect.value.x - (viewWidth.value - boundingRect.value.width) / 2 - offset.value);
|
||||
const y = computed(() => boundingRect.value.y - (viewHeight.value - boundingRect.value.height) / 2 - offset.value);
|
||||
const width = computed(() => viewWidth.value + offset.value * 2);
|
||||
const height = computed(() => viewHeight.value + offset.value * 2);
|
||||
const shapeRendering = typeof window === 'undefined' || !!window.chrome ? 'crispEdges' : 'geometricPrecision';
|
||||
|
||||
return () => (
|
||||
<svg
|
||||
width={elementWidth.value}
|
||||
height={elementHeight.value}
|
||||
viewBox={`${x.value} ${y.value} ${width.value} ${height.value}`}
|
||||
class={mapClasses}
|
||||
>
|
||||
{pinia.nodes
|
||||
.filter((node) => !node.isHidden)
|
||||
.map((node) => (
|
||||
<MiniMapNode
|
||||
key={node.id}
|
||||
x={node.__rf.position.x}
|
||||
y={node.__rf.position.y}
|
||||
width={node.__rf.width}
|
||||
height={node.__rf.height}
|
||||
style={node.style}
|
||||
class={nodeClassNameFunc(node)}
|
||||
color={nodeColorFunc(node)}
|
||||
borderRadius={props.nodeBorderRadius}
|
||||
strokeColor={nodeStrokeColorFunc(node)}
|
||||
strokeWidth={props.nodeStrokeWidth}
|
||||
shapeRendering={shapeRendering}
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
class="react-flow__minimap-mask"
|
||||
d={`M${x.value - offset.value},${y.value - offset.value}h${width.value + offset.value * 2}v${
|
||||
height.value + offset.value * 2
|
||||
}h${-width.value - offset.value * 2}z
|
||||
M${viewBB.value.x},${viewBB.value.y}h${viewBB.value.width}v${viewBB.value.height}h${-viewBB.value.width}z`}
|
||||
fill={props.maskColor}
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default MiniMap;
|
||||
6
src/additional-components/index.ts
Normal file
6
src/additional-components/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// These components are not used by React Flow directly
|
||||
// but the user can add them as children of a React Flow component
|
||||
|
||||
export { default as MiniMap } from './MiniMap';
|
||||
export { default as Controls, ControlButton } from './Controls';
|
||||
export { default as Background } from './Background';
|
||||
@@ -31,7 +31,6 @@ export default function configureStore(
|
||||
this.onConnectStop = onConnectStop;
|
||||
},
|
||||
setElements(elements) {
|
||||
console.log('setting elements');
|
||||
const propElements = elements;
|
||||
const nextElements: NextElements = {
|
||||
nextNodes: [],
|
||||
|
||||
@@ -88,8 +88,6 @@ export const addEdge = (edgeParams: Edge | Connection, elements: Elements): Elem
|
||||
return elements;
|
||||
}
|
||||
|
||||
console.log(edge);
|
||||
|
||||
return elements.concat(edge);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user