mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 21:07:28 -08:00
feat(editor): Update canvas control buttons design and behaviour in new canvas (no-changelog) (#10435)
Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
parent
fc6d4138d5
commit
77ebd93bd3
|
@ -8,16 +8,16 @@ import { createCanvasConnection, createCanvasNodeElement } from '@/__tests__/dat
|
|||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import type { useDeviceSupport } from 'n8n-design-system';
|
||||
|
||||
const matchMedia = global.window.matchMedia;
|
||||
// @ts-expect-error Initialize window object
|
||||
global.window = jsdom.window as unknown as Window & typeof globalThis;
|
||||
global.window.matchMedia = matchMedia;
|
||||
|
||||
vi.mock('n8n-design-system', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof useDeviceSupport>();
|
||||
return { ...actual, useDeviceSupport: vi.fn(() => ({ isCtrlKeyPressed: vi.fn() })) };
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useDeviceSupport');
|
||||
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
beforeEach(() => {
|
||||
const pinia = createPinia();
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CanvasConnection, CanvasNode, CanvasNodeMoveEvent, ConnectStartEvent } from '@/types';
|
||||
import type { EdgeMouseEvent, NodeDragEvent, Connection, XYPosition } from '@vue-flow/core';
|
||||
import type {
|
||||
EdgeMouseEvent,
|
||||
NodeDragEvent,
|
||||
Connection,
|
||||
XYPosition,
|
||||
ViewportTransform,
|
||||
} from '@vue-flow/core';
|
||||
import { useVueFlow, VueFlow, PanelPosition } from '@vue-flow/core';
|
||||
import { Background } from '@vue-flow/background';
|
||||
import { Controls } from '@vue-flow/controls';
|
||||
import { MiniMap } from '@vue-flow/minimap';
|
||||
import Node from './elements/nodes/CanvasNode.vue';
|
||||
import Edge from './elements/edges/CanvasEdge.vue';
|
||||
|
@ -77,6 +82,9 @@ const {
|
|||
removeSelectedNodes,
|
||||
viewportRef,
|
||||
fitView,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomTo,
|
||||
setInteractive,
|
||||
elementsSelectable,
|
||||
project,
|
||||
|
@ -100,6 +108,10 @@ useKeybindings({
|
|||
ctrl_enter: () => emit('run:workflow'),
|
||||
ctrl_s: () => emit('save:workflow'),
|
||||
ctrl_a: () => addSelectedNodes(graphNodes.value),
|
||||
'+|=': async () => await onZoomIn(),
|
||||
'-|_': async () => await onZoomOut(),
|
||||
0: async () => await onResetZoom(),
|
||||
1: async () => await onFitView(),
|
||||
// @TODO implement arrow key shortcuts to modify selection
|
||||
});
|
||||
|
||||
|
@ -257,6 +269,8 @@ function emitWithLastSelectedNode(emitFn: (id: string) => void) {
|
|||
* View
|
||||
*/
|
||||
|
||||
const zoom = ref(1);
|
||||
|
||||
function onClickPane(event: MouseEvent) {
|
||||
const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 };
|
||||
const position = project({
|
||||
|
@ -268,7 +282,27 @@ function onClickPane(event: MouseEvent) {
|
|||
}
|
||||
|
||||
async function onFitView() {
|
||||
await fitView({ maxZoom: 1.2, padding: 0.1 });
|
||||
await fitView({ maxZoom: 1.2, 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(1);
|
||||
}
|
||||
|
||||
function onViewportChange(viewport: ViewportTransform) {
|
||||
zoom.value = viewport.zoom;
|
||||
}
|
||||
|
||||
function setReadonly(value: boolean) {
|
||||
|
@ -382,6 +416,7 @@ watch(() => props.readOnly, setReadonly, {
|
|||
@connect-end="onConnectEnd"
|
||||
@pane-click="onClickPane"
|
||||
@contextmenu="onOpenContextMenu"
|
||||
@viewport-change="onViewportChange"
|
||||
>
|
||||
<template #node-canvas-node="canvasNodeProps">
|
||||
<Node
|
||||
|
@ -424,15 +459,21 @@ watch(() => props.readOnly, setReadonly, {
|
|||
pannable
|
||||
zoomable
|
||||
:node-class-name="minimapNodeClassnameFn"
|
||||
mask-color="var(--color-background-base)"
|
||||
:node-border-radius="16"
|
||||
/>
|
||||
|
||||
<Controls
|
||||
<CanvasControlButtons
|
||||
data-test-id="canvas-controls"
|
||||
:class="$style.canvasControls"
|
||||
:position="controlsPosition"
|
||||
:show-interactive="!readOnly"
|
||||
:show-interactive="false"
|
||||
:zoom="zoom"
|
||||
@fit-view="onFitView"
|
||||
></Controls>
|
||||
@zoom-in="onZoomIn"
|
||||
@zoom-out="onZoomOut"
|
||||
@reset-zoom="onResetZoom"
|
||||
/>
|
||||
|
||||
<Suspense>
|
||||
<ContextMenu @action="onContextMenuAction" />
|
||||
|
@ -449,40 +490,3 @@ watch(() => props.readOnly, setReadonly, {
|
|||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.vue-flow__controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.vue-flow__controls-button {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: var(--border-base);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: 0;
|
||||
transition-property: transform, background, border, color;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-button-secondary-hover-active-border);
|
||||
background-color: var(--color-button-secondary-active-background);
|
||||
transform: scale(1.1);
|
||||
|
||||
svg {
|
||||
fill: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
max-height: 16px;
|
||||
max-width: 16px;
|
||||
transition-property: fill;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import CanvasControlButtons from './CanvasControlButtons.vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(CanvasControlButtons);
|
||||
|
||||
describe('CanvasChatButton', () => {
|
||||
it('should render correctly', () => {
|
||||
const wrapper = renderComponent();
|
||||
|
||||
expect(wrapper.getByTestId('zoom-in-button')).toBeVisible();
|
||||
expect(wrapper.getByTestId('zoom-out-button')).toBeVisible();
|
||||
expect(wrapper.getByTestId('zoom-to-fit')).toBeVisible();
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should show reset zoom button when zoom is not equal to 1', () => {
|
||||
const wrapper = renderComponent({
|
||||
props: {
|
||||
zoom: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.getByTestId('reset-zoom-button')).toBeVisible();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,76 @@
|
|||
<script setup lang="ts">
|
||||
import { Controls } from '@vue-flow/controls';
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
zoom?: number;
|
||||
}>(),
|
||||
{
|
||||
zoom: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'reset-zoom': [];
|
||||
}>();
|
||||
|
||||
const isResetZoomVisible = computed(() => props.zoom !== 1);
|
||||
|
||||
function onResetZoom() {
|
||||
emit('reset-zoom');
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Controls :show-zoom="false" :show-fit-view="false">
|
||||
<KeyboardShortcutTooltip
|
||||
:label="$locale.baseText('nodeView.zoomIn')"
|
||||
:shortcut="{ keys: ['+'] }"
|
||||
>
|
||||
<N8nIconButton
|
||||
type="tertiary"
|
||||
size="large"
|
||||
icon="search-plus"
|
||||
data-test-id="zoom-in-button"
|
||||
/>
|
||||
</KeyboardShortcutTooltip>
|
||||
<KeyboardShortcutTooltip
|
||||
:label="$locale.baseText('nodeView.zoomOut')"
|
||||
:shortcut="{ keys: ['-'] }"
|
||||
>
|
||||
<N8nIconButton
|
||||
type="tertiary"
|
||||
size="large"
|
||||
icon="search-minus"
|
||||
data-test-id="zoom-out-button"
|
||||
/>
|
||||
</KeyboardShortcutTooltip>
|
||||
<KeyboardShortcutTooltip
|
||||
:label="$locale.baseText('nodeView.zoomToFit')"
|
||||
:shortcut="{ keys: ['1'] }"
|
||||
>
|
||||
<N8nIconButton type="tertiary" size="large" icon="expand" data-test-id="zoom-to-fit" />
|
||||
</KeyboardShortcutTooltip>
|
||||
<KeyboardShortcutTooltip
|
||||
v-if="isResetZoomVisible"
|
||||
:label="$locale.baseText('nodeView.resetZoom')"
|
||||
:shortcut="{ keys: ['0'] }"
|
||||
>
|
||||
<N8nIconButton
|
||||
type="tertiary"
|
||||
size="large"
|
||||
icon="undo"
|
||||
data-test-id="reset-zoom-button"
|
||||
@click="onResetZoom"
|
||||
/>
|
||||
</KeyboardShortcutTooltip>
|
||||
</Controls>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.vue-flow__controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,25 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CanvasChatButton > should render correctly 1`] = `
|
||||
"<div class="vue-flow__panel bottom left vue-flow__controls" style="pointer-events: all;">
|
||||
<!---->
|
||||
<!----><button class="vue-flow__controls-button vue-flow__controls-interactive"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 32">
|
||||
<path d="M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0c-4.114 1.828-1.37 2.133.305 2.438 1.676.305 4.42 2.59 4.42 5.181v3.048H3.047A3.056 3.056 0 0 0 0 13.714v15.238A3.056 3.056 0 0 0 3.048 32h18.285a3.056 3.056 0 0 0 3.048-3.048V13.714a3.056 3.056 0 0 0-3.048-3.047zM12.19 24.533a3.056 3.056 0 0 1-3.047-3.047 3.056 3.056 0 0 1 3.047-3.048 3.056 3.056 0 0 1 3.048 3.048 3.056 3.056 0 0 1-3.048 3.047z"></path>
|
||||
</svg>
|
||||
<!---->
|
||||
</button><button class="button button tertiary large withIcon square el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="zoom-in-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-search-plus fa-w-16 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search-plus" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M304 192v32c0 6.6-5.4 12-12 12h-56v56c0 6.6-5.4 12-12 12h-32c-6.6 0-12-5.4-12-12v-56h-56c-6.6 0-12-5.4-12-12v-32c0-6.6 5.4-12 12-12h56v-56c0-6.6 5.4-12 12-12h32c6.6 0 12 5.4 12 12v56h56c6.6 0 12 5.4 12 12zm201 284.7L476.7 505c-9.4 9.4-24.6 9.4-33.9 0L343 405.3c-4.5-4.5-7-10.6-7-17V372c-35.3 27.6-79.7 44-128 44C93.1 416 0 322.9 0 208S93.1 0 208 0s208 93.1 208 208c0 48.3-16.4 92.7-44 128h16.3c6.4 0 12.5 2.5 17 7l99.7 99.7c9.3 9.4 9.3 24.6 0 34zM344 208c0-75.2-60.8-136-136-136S72 132.8 72 208s60.8 136 136 136 136-60.8 136-136z"></path></svg></span></span>
|
||||
<!--v-if-->
|
||||
</button>
|
||||
<!--teleport start-->
|
||||
<!--teleport end--><button class="button button tertiary large withIcon square el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="zoom-out-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-search-minus fa-w-16 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search-minus" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M304 192v32c0 6.6-5.4 12-12 12H124c-6.6 0-12-5.4-12-12v-32c0-6.6 5.4-12 12-12h168c6.6 0 12 5.4 12 12zm201 284.7L476.7 505c-9.4 9.4-24.6 9.4-33.9 0L343 405.3c-4.5-4.5-7-10.6-7-17V372c-35.3 27.6-79.7 44-128 44C93.1 416 0 322.9 0 208S93.1 0 208 0s208 93.1 208 208c0 48.3-16.4 92.7-44 128h16.3c6.4 0 12.5 2.5 17 7l99.7 99.7c9.3 9.4 9.3 24.6 0 34zM344 208c0-75.2-60.8-136-136-136S72 132.8 72 208s60.8 136 136 136 136-60.8 136-136z"></path></svg></span></span>
|
||||
<!--v-if-->
|
||||
</button>
|
||||
<!--teleport start-->
|
||||
<!--teleport end--><button class="button button tertiary large withIcon square el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="zoom-to-fit"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-expand fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="expand" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M0 180V56c0-13.3 10.7-24 24-24h124c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H64v84c0 6.6-5.4 12-12 12H12c-6.6 0-12-5.4-12-12zM288 44v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12V56c0-13.3-10.7-24-24-24H300c-6.6 0-12 5.4-12 12zm148 276h-40c-6.6 0-12 5.4-12 12v84h-84c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24V332c0-6.6-5.4-12-12-12zM160 468v-40c0-6.6-5.4-12-12-12H64v-84c0-6.6-5.4-12-12-12H12c-6.6 0-12 5.4-12 12v124c0 13.3 10.7 24 24 24h124c6.6 0 12-5.4 12-12z"></path></svg></span></span>
|
||||
<!--v-if-->
|
||||
</button>
|
||||
<!--teleport start-->
|
||||
<!--teleport end-->
|
||||
<!--v-if-->
|
||||
</div>"
|
||||
`;
|
|
@ -7,14 +7,19 @@
|
|||
}
|
||||
|
||||
.vue-flow__minimap {
|
||||
height: 120px;
|
||||
overflow: hidden;
|
||||
margin-bottom: calc(48px + 2 * var(--spacing-xs));
|
||||
border: var(--border-base);
|
||||
border-radius: var(--border-radius-base);
|
||||
background: var(--color-background-light);
|
||||
|
||||
.minimap-node-default {
|
||||
fill: var(--color-foreground-dark);
|
||||
}
|
||||
|
||||
.minimap-node-n8n-nodes-base-stickyNote {
|
||||
opacity: 0.05;
|
||||
fill: var(--color-background-dark);
|
||||
fill: var(--color-foreground-dark);
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue