refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)

This commit is contained in:
Alex Grozav
2025-02-28 14:28:30 +02:00
committed by GitHub
parent 684353436d
commit f5743176e5
1635 changed files with 805 additions and 1079 deletions

View File

@@ -0,0 +1,886 @@
<script lang="ts" setup>
import {
type CanvasConnection,
type CanvasNode,
type CanvasNodeMoveEvent,
type CanvasEventBusEvents,
type ConnectStartEvent,
CanvasNodeRenderType,
} from '@/types';
import type {
Connection,
XYPosition,
NodeDragEvent,
NodeMouseEvent,
GraphNode,
} from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition, MarkerType } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import Node from './elements/nodes/CanvasNode.vue';
import Edge from './elements/edges/CanvasEdge.vue';
import { computed, onMounted, onUnmounted, provide, ref, toRef, useCssModule, watch } from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
import { useContextMenu, type ContextMenuAction } from '@/composables/useContextMenu';
import { useKeybindings } from '@/composables/useKeybindings';
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
import type { NodeCreatorOpenSource } from '@/Interface';
import type { PinDataSource } from '@/composables/usePinnedData';
import { isPresent } from '@/utils/typesUtils';
import { GRID_SIZE } from '@/utils/nodeViewUtils';
import { CanvasKey } from '@/constants';
import { onKeyDown, onKeyUp, useThrottleFn } from '@vueuse/core';
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
import CanvasBackground from './elements/background/CanvasBackground.vue';
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
import { NodeConnectionType } from 'n8n-workflow';
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
const $style = useCssModule();
const emit = defineEmits<{
'update:modelValue': [elements: CanvasNode[]];
'update:node:position': [id: string, position: XYPosition];
'update:nodes:position': [events: CanvasNodeMoveEvent[]];
'update:node:activated': [id: string];
'update:node:deactivated': [id: string];
'update:node:enabled': [id: string];
'update:node:selected': [id?: string];
'update:node:name': [id: string];
'update:node:parameters': [id: string, parameters: Record<string, unknown>];
'update:node:inputs': [id: string];
'update:node:outputs': [id: string];
'click:node': [id: string];
'click:node:add': [id: string, handle: string];
'run:node': [id: string];
'delete:node': [id: string];
'create:node': [source: NodeCreatorOpenSource];
'create:sticky': [];
'delete:nodes': [ids: string[]];
'update:nodes:enabled': [ids: string[]];
'copy:nodes': [ids: string[]];
'duplicate:nodes': [ids: string[]];
'update:nodes:pin': [ids: string[], source: PinDataSource];
'cut:nodes': [ids: string[]];
'delete:connection': [connection: Connection];
'create:connection:start': [handle: ConnectStartEvent];
'create:connection': [connection: Connection];
'create:connection:end': [connection: Connection, event?: MouseEvent];
'create:connection:cancelled': [
handle: ConnectStartEvent,
position: XYPosition,
event?: MouseEvent,
];
'click:connection:add': [connection: Connection];
'click:pane': [position: XYPosition];
'run:workflow': [];
'save:workflow': [];
'create:workflow': [];
'drag-and-drop': [position: XYPosition, event: DragEvent];
}>();
const props = withDefaults(
defineProps<{
id?: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
controlsPosition?: PanelPosition;
eventBus?: EventBus<CanvasEventBusEvents>;
readOnly?: boolean;
executing?: boolean;
keyBindings?: boolean;
loading?: boolean;
}>(),
{
id: 'canvas',
nodes: () => [],
connections: () => [],
controlsPosition: PanelPosition.BottomLeft,
eventBus: () => createEventBus(),
readOnly: false,
executing: false,
keyBindings: true,
loading: false,
},
);
const { isMobileDevice, controlKeyCode } = useDeviceSupport();
const vueFlow = useVueFlow({ id: props.id, deleteKeyCode: null });
const {
getSelectedNodes: selectedNodes,
addSelectedNodes,
removeSelectedNodes,
viewportRef,
fitView,
zoomIn,
zoomOut,
zoomTo,
setInteractive,
elementsSelectable,
project,
nodes: graphNodes,
onPaneReady,
onNodesInitialized,
findNode,
viewport,
onEdgeMouseLeave,
onEdgeMouseEnter,
onEdgeMouseMove,
onNodeMouseEnter,
onNodeMouseLeave,
} = vueFlow;
const {
getIncomingNodes,
getOutgoingNodes,
getSiblingNodes,
getDownstreamNodes,
getUpstreamNodes,
} = useCanvasTraversal(vueFlow);
const isPaneReady = ref(false);
const classes = computed(() => ({
[$style.canvas]: true,
[$style.ready]: !props.loading && isPaneReady.value,
}));
/**
* Panning and Selection key bindings
*/
// @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#whitespace_keys
const panningKeyCode = ref<string[] | true>(isMobileDevice ? true : [' ', controlKeyCode]);
const panningMouseButton = ref<number[] | true>(isMobileDevice ? true : [1]);
const selectionKeyCode = ref<string | true | null>(isMobileDevice ? 'Shift' : true);
function switchToPanningMode() {
selectionKeyCode.value = null;
panningMouseButton.value = [0, 1];
}
function switchToSelectionMode() {
selectionKeyCode.value = true;
panningMouseButton.value = [1];
}
onKeyDown(panningKeyCode.value, switchToPanningMode, {
dedupe: true,
});
onKeyUp(panningKeyCode.value, switchToSelectionMode);
/**
* Rename node key bindings
* We differentiate between short and long press because the space key is also used for activating panning
*/
const renameKeyCode = ' ';
useShortKeyPress(
renameKeyCode,
() => {
if (lastSelectedNode.value) {
emit('update:node:name', lastSelectedNode.value.id);
}
},
{
disabled: toRef(props, 'readOnly'),
},
);
/**
* Key bindings
*/
const disableKeyBindings = computed(() => !props.keyBindings);
function selectLeftNode(id: string) {
const incomingNodes = getIncomingNodes(id);
const previousNode = incomingNodes[0];
if (previousNode) {
onSelectNodes({ ids: [previousNode.id] });
}
}
function selectRightNode(id: string) {
const outgoingNodes = getOutgoingNodes(id);
const nextNode = outgoingNodes[0];
if (nextNode) {
onSelectNodes({ ids: [nextNode.id] });
}
}
function selectLowerSiblingNode(id: string) {
const siblingNodes = getSiblingNodes(id);
const index = siblingNodes.findIndex((n) => n.id === id);
const nextNode = siblingNodes[index + 1] ?? siblingNodes[0];
if (nextNode) {
onSelectNodes({
ids: [nextNode.id],
});
}
}
function selectUpperSiblingNode(id: string) {
const siblingNodes = getSiblingNodes(id);
const index = siblingNodes.findIndex((n) => n.id === id);
const previousNode = siblingNodes[index - 1] ?? siblingNodes[siblingNodes.length - 1];
if (previousNode) {
onSelectNodes({
ids: [previousNode.id],
});
}
}
function selectDownstreamNodes(id: string) {
const downstreamNodes = getDownstreamNodes(id);
onSelectNodes({ ids: [...downstreamNodes.map((node) => node.id), id] });
}
function selectUpstreamNodes(id: string) {
const upstreamNodes = getUpstreamNodes(id);
onSelectNodes({ ids: [...upstreamNodes.map((node) => node.id), id] });
}
const keyMap = computed(() => ({
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
enter: emitWithLastSelectedNode((id) => onSetNodeActivated(id)),
ctrl_a: () => addSelectedNodes(graphNodes.value),
// Support both key and code for zooming in and out
'shift_+|+|=|shift_Equal|Equal': async () => await onZoomIn(),
'shift+_|-|_|shift_Minus|Minus': async () => await onZoomOut(),
0: async () => await onResetZoom(),
1: async () => await onFitView(),
ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode),
ArrowDown: emitWithLastSelectedNode(selectLowerSiblingNode),
ArrowLeft: emitWithLastSelectedNode(selectLeftNode),
ArrowRight: emitWithLastSelectedNode(selectRightNode),
shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes),
shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes),
...(props.readOnly
? {}
: {
ctrl_x: emitWithSelectedNodes((ids) => emit('cut:nodes', ids)),
'delete|backspace': emitWithSelectedNodes((ids) => emit('delete:nodes', ids)),
ctrl_d: emitWithSelectedNodes((ids) => emit('duplicate:nodes', ids)),
d: emitWithSelectedNodes((ids) => emit('update:nodes:enabled', ids)),
p: emitWithSelectedNodes((ids) => emit('update:nodes:pin', ids, 'keyboard-shortcut')),
f2: emitWithLastSelectedNode((id) => emit('update:node:name', id)),
tab: () => emit('create:node', 'tab'),
shift_s: () => emit('create:sticky'),
ctrl_alt_n: () => emit('create:workflow'),
ctrl_enter: () => emit('run:workflow'),
ctrl_s: () => emit('save:workflow'),
}),
}));
useKeybindings(keyMap, { disabled: disableKeyBindings });
/**
* Nodes
*/
const hasSelection = computed(() => selectedNodes.value.length > 0);
const selectedNodeIds = computed(() => selectedNodes.value.map((node) => node.id));
const lastSelectedNode = ref<GraphNode>();
const triggerNodes = computed(() =>
props.nodes.filter(
(node) =>
node.data?.render.type === CanvasNodeRenderType.Default && node.data.render.options.trigger,
),
);
const hoveredTriggerNode = useCanvasNodeHover(triggerNodes, vueFlow, (nodeRect) => ({
x: nodeRect.x - nodeRect.width * 2, // should cover the width of trigger button
y: nodeRect.y - nodeRect.height,
width: nodeRect.width * 4,
height: nodeRect.height * 3,
}));
watch(selectedNodes, (nodes) => {
if (!lastSelectedNode.value || !nodes.find((node) => node.id === lastSelectedNode.value?.id)) {
lastSelectedNode.value = nodes[nodes.length - 1];
}
});
function onClickNodeAdd(id: string, handle: string) {
emit('click:node:add', id, handle);
}
function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) {
emit('update:nodes:position', events);
}
function onUpdateNodePosition(id: string, position: XYPosition) {
emit('update:node:position', id, position);
}
function onNodeDragStop(event: NodeDragEvent) {
onUpdateNodesPosition(event.nodes.map(({ id, position }) => ({ id, position })));
}
function onNodeClick({ event, node }: NodeMouseEvent) {
emit('click:node', node.id);
if (event.ctrlKey || event.metaKey || selectedNodes.value.length < 2) {
return;
}
onSelectNodes({ ids: [node.id] });
}
function onSelectionDragStop(event: NodeDragEvent) {
onUpdateNodesPosition(event.nodes.map(({ id, position }) => ({ id, position })));
}
function onSetNodeActivated(id: string) {
props.eventBus.emit('nodes:action', { ids: [id], action: 'update:node:activated' });
emit('update:node:activated', id);
}
function onSetNodeDeactivated(id: string) {
emit('update:node:deactivated', id);
}
function clearSelectedNodes() {
removeSelectedNodes(selectedNodes.value);
}
function onSelectNode() {
emit('update:node:selected', lastSelectedNode.value?.id);
}
function onSelectNodes({ ids }: CanvasEventBusEvents['nodes:select']) {
clearSelectedNodes();
addSelectedNodes(ids.map(findNode).filter(isPresent));
}
function onToggleNodeEnabled(id: string) {
emit('update:node:enabled', id);
}
function onDeleteNode(id: string) {
emit('delete:node', id);
}
function onUpdateNodeParameters(id: string, parameters: Record<string, unknown>) {
emit('update:node:parameters', id, parameters);
}
function onUpdateNodeInputs(id: string) {
emit('update:node:inputs', id);
}
function onUpdateNodeOutputs(id: string) {
emit('update:node:outputs', id);
}
/**
* Connections / Edges
*/
const connectionCreated = ref(false);
const connectingHandle = ref<ConnectStartEvent>();
const connectedHandle = ref<Connection>();
function onConnectStart(handle: ConnectStartEvent) {
emit('create:connection:start', handle);
connectingHandle.value = handle;
connectionCreated.value = false;
}
function onConnect(connection: Connection) {
emit('create:connection', connection);
connectedHandle.value = connection;
connectionCreated.value = true;
}
function onConnectEnd(event?: MouseEvent) {
if (connectedHandle.value) {
emit('create:connection:end', connectedHandle.value, event);
} else if (connectingHandle.value) {
emit('create:connection:cancelled', connectingHandle.value, getProjectedPosition(event), event);
}
connectedHandle.value = undefined;
connectingHandle.value = undefined;
}
function onDeleteConnection(connection: Connection) {
emit('delete:connection', connection);
}
function onClickConnectionAdd(connection: Connection) {
emit('click:connection:add', connection);
}
const arrowHeadMarkerId = ref('custom-arrow-head');
/**
* Edge and Nodes Hovering
*/
const edgesHoveredById = ref<Record<string, boolean>>({});
const edgesBringToFrontById = ref<Record<string, boolean>>({});
onEdgeMouseEnter(({ edge }) => {
edgesBringToFrontById.value = { [edge.id]: true };
edgesHoveredById.value = { [edge.id]: true };
});
onEdgeMouseMove(
useThrottleFn(({ edge, event }) => {
const type = edge.data.source.type;
if (type !== NodeConnectionType.AiTool) {
return;
}
if (!edge.data.maxConnections || edge.data.maxConnections > 1) {
const projectedPosition = getProjectedPosition(event);
const yDiff = projectedPosition.y - edge.targetY;
if (yDiff < 4 * GRID_SIZE) {
edgesBringToFrontById.value = { [edge.id]: false };
} else {
edgesBringToFrontById.value = { [edge.id]: true };
}
}
}, 100),
);
onEdgeMouseLeave(({ edge }) => {
edgesBringToFrontById.value = { [edge.id]: false };
edgesHoveredById.value = { [edge.id]: false };
});
function onUpdateEdgeLabelHovered(id: string, hovered: boolean) {
edgesBringToFrontById.value = { [id]: true };
edgesHoveredById.value[id] = hovered;
}
const nodesHoveredById = ref<Record<string, boolean>>({});
onNodeMouseEnter(({ node }) => {
nodesHoveredById.value = { [node.id]: true };
});
onNodeMouseLeave(({ node }) => {
nodesHoveredById.value = { [node.id]: false };
});
/**
* Executions
*/
function onRunNode(id: string) {
emit('run:node', id);
}
/**
* Emit helpers
*/
function emitWithSelectedNodes(emitFn: (ids: string[]) => void) {
return () => {
if (hasSelection.value) {
emitFn(selectedNodeIds.value);
}
};
}
function emitWithLastSelectedNode(emitFn: (id: string) => void) {
return () => {
if (lastSelectedNode.value) {
emitFn(lastSelectedNode.value.id);
}
};
}
/**
* View
*/
const defaultZoom = 1;
const isPaneMoving = ref(false);
function getProjectedPosition(event?: Pick<MouseEvent, 'clientX' | 'clientY'>) {
const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 };
const offsetX = event?.clientX ?? 0;
const offsetY = event?.clientY ?? 0;
return project({
x: offsetX - bounds.left,
y: offsetY - bounds.top,
});
}
function onClickPane(event: MouseEvent) {
emit('click:pane', getProjectedPosition(event));
}
async function onFitView() {
await fitView({ maxZoom: defaultZoom, padding: 0.2 });
}
async function onZoomTo(zoomLevel: number) {
await zoomTo(zoomLevel);
}
async function onZoomIn() {
await zoomIn();
}
async function onZoomOut() {
await zoomOut();
}
async function onResetZoom() {
await onZoomTo(defaultZoom);
}
function setReadonly(value: boolean) {
setInteractive(!value);
elementsSelectable.value = true;
}
function onPaneMoveStart() {
isPaneMoving.value = true;
}
function onPaneMoveEnd() {
isPaneMoving.value = false;
}
/**
* Context menu
*/
const contextMenu = useContextMenu();
function onOpenContextMenu(event: MouseEvent) {
contextMenu.open(event, {
source: 'canvas',
nodeIds: selectedNodeIds.value,
});
}
function onOpenSelectionContextMenu({ event }: { event: MouseEvent }) {
contextMenu.open(event, {
source: 'canvas',
nodeIds: selectedNodeIds.value,
});
}
function onOpenNodeContextMenu(
id: string,
event: MouseEvent,
source: 'node-button' | 'node-right-click',
) {
if (selectedNodeIds.value.includes(id)) {
onOpenContextMenu(event);
}
contextMenu.open(event, { source, nodeId: id });
}
function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
switch (action) {
case 'add_node':
return emit('create:node', 'context_menu');
case 'add_sticky':
return emit('create:sticky');
case 'copy':
return emit('copy:nodes', nodeIds);
case 'delete':
return emit('delete:nodes', nodeIds);
case 'select_all':
return addSelectedNodes(graphNodes.value);
case 'deselect_all':
return clearSelectedNodes();
case 'duplicate':
return emit('duplicate:nodes', nodeIds);
case 'toggle_pin':
return emit('update:nodes:pin', nodeIds, 'context-menu');
case 'execute':
return emit('run:node', nodeIds[0]);
case 'toggle_activation':
return emit('update:nodes:enabled', nodeIds);
case 'open':
return onSetNodeActivated(nodeIds[0]);
case 'rename':
return emit('update:node:name', nodeIds[0]);
case 'change_color':
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
}
}
/**
* Drag and drop
*/
function onDragOver(event: DragEvent) {
event.preventDefault();
}
function onDrop(event: DragEvent) {
const position = getProjectedPosition(event);
emit('drag-and-drop', position, event);
}
/**
* Minimap
*/
const minimapVisibilityDelay = 1000;
const minimapHideTimeout = ref<NodeJS.Timeout | null>(null);
const isMinimapVisible = ref(false);
function minimapNodeClassnameFn(node: CanvasNode) {
return `minimap-node-${node.data?.render.type.replace(/\./g, '-') ?? 'default'}`;
}
watch(isPaneMoving, (value) => {
if (value) {
showMinimap();
} else {
hideMinimap();
}
});
function showMinimap() {
if (minimapHideTimeout.value) {
clearTimeout(minimapHideTimeout.value);
minimapHideTimeout.value = null;
}
isMinimapVisible.value = true;
}
function hideMinimap() {
minimapHideTimeout.value = setTimeout(() => {
isMinimapVisible.value = false;
}, minimapVisibilityDelay);
}
function onMinimapMouseEnter() {
showMinimap();
}
function onMinimapMouseLeave() {
hideMinimap();
}
/**
* Window Events
*/
function onWindowBlur() {
switchToSelectionMode();
}
/**
* Lifecycle
*/
const initialized = ref(false);
onMounted(() => {
props.eventBus.on('fitView', onFitView);
props.eventBus.on('nodes:select', onSelectNodes);
window.addEventListener('blur', onWindowBlur);
});
onUnmounted(() => {
props.eventBus.off('fitView', onFitView);
props.eventBus.off('nodes:select', onSelectNodes);
window.removeEventListener('blur', onWindowBlur);
});
onPaneReady(async () => {
await onFitView();
isPaneReady.value = true;
});
onNodesInitialized(() => {
initialized.value = true;
});
watch(() => props.readOnly, setReadonly, {
immediate: true,
});
/**
* Provide
*/
const isExecuting = toRef(props, 'executing');
provide(CanvasKey, {
connectingHandle,
isExecuting,
initialized,
viewport,
});
</script>
<template>
<VueFlow
:id="id"
:nodes="nodes"
:edges="connections"
:class="classes"
:apply-changes="false"
:connection-line-options="{ markerEnd: MarkerType.ArrowClosed }"
:connection-radius="60"
:pan-on-drag="panningMouseButton"
pan-on-scroll
snap-to-grid
:snap-grid="[GRID_SIZE, GRID_SIZE]"
:min-zoom="0"
:max-zoom="4"
:selection-key-code="selectionKeyCode"
:zoom-activation-key-code="panningKeyCode"
:pan-activation-key-code="panningKeyCode"
:disable-keyboard-a11y="true"
data-test-id="canvas"
@connect-start="onConnectStart"
@connect="onConnect"
@connect-end="onConnectEnd"
@pane-click="onClickPane"
@pane-context-menu="onOpenContextMenu"
@move-start="onPaneMoveStart"
@move-end="onPaneMoveEnd"
@node-drag-stop="onNodeDragStop"
@node-click="onNodeClick"
@selection-drag-stop="onSelectionDragStop"
@selection-context-menu="onOpenSelectionContextMenu"
@dragover="onDragOver"
@drop="onDrop"
>
<template #node-canvas-node="nodeProps">
<Node
v-bind="nodeProps"
:read-only="readOnly"
:event-bus="eventBus"
:hovered="nodesHoveredById[nodeProps.id]"
:nearby-hovered="nodeProps.id === hoveredTriggerNode.id.value"
@delete="onDeleteNode"
@run="onRunNode"
@select="onSelectNode"
@toggle="onToggleNodeEnabled"
@activate="onSetNodeActivated"
@deactivate="onSetNodeDeactivated"
@open:contextmenu="onOpenNodeContextMenu"
@update="onUpdateNodeParameters"
@update:inputs="onUpdateNodeInputs"
@update:outputs="onUpdateNodeOutputs"
@move="onUpdateNodePosition"
@add="onClickNodeAdd"
>
<template v-if="$slots.nodeToolbar" #toolbar="toolbarProps">
<slot name="nodeToolbar" v-bind="toolbarProps" />
</template>
</Node>
</template>
<template #edge-canvas-edge="edgeProps">
<Edge
v-bind="edgeProps"
:marker-end="`url(#${arrowHeadMarkerId})`"
:read-only="readOnly"
:hovered="edgesHoveredById[edgeProps.id]"
:bring-to-front="edgesBringToFrontById[edgeProps.id]"
@add="onClickConnectionAdd"
@delete="onDeleteConnection"
@update:label:hovered="onUpdateEdgeLabelHovered(edgeProps.id, $event)"
/>
</template>
<template #connection-line="connectionLineProps">
<CanvasConnectionLine v-bind="connectionLineProps" />
</template>
<CanvasArrowHeadMarker :id="arrowHeadMarkerId" />
<CanvasBackground :viewport="viewport" :striped="readOnly" />
<Transition name="minimap">
<MiniMap
v-show="isMinimapVisible"
data-test-id="canvas-minimap"
aria-label="n8n Minimap"
:height="120"
:width="200"
:position="PanelPosition.BottomLeft"
pannable
zoomable
:node-class-name="minimapNodeClassnameFn"
:node-border-radius="16"
@mouseenter="onMinimapMouseEnter"
@mouseleave="onMinimapMouseLeave"
/>
</Transition>
<CanvasControlButtons
data-test-id="canvas-controls"
:class="$style.canvasControls"
:position="controlsPosition"
:show-interactive="false"
:zoom="viewport.zoom"
@zoom-to-fit="onFitView"
@zoom-in="onZoomIn"
@zoom-out="onZoomOut"
@reset-zoom="onResetZoom"
/>
<Suspense>
<ContextMenu @action="onContextMenuAction" />
</Suspense>
</VueFlow>
</template>
<style lang="scss" module>
.canvas {
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 300ms ease;
&.ready {
opacity: 1;
}
:global(.vue-flow__pane) {
cursor: grab;
&:global(.selection) {
cursor: default;
}
&:global(.dragging) {
cursor: grabbing;
}
}
}
</style>
<style lang="scss" scoped>
.minimap-enter-active,
.minimap-leave-active {
transition: opacity 0.3s ease;
}
.minimap-enter-from,
.minimap-leave-to {
opacity: 0;
}
</style>