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:
Alex Grozav 2024-08-16 17:01:10 +03:00 committed by GitHub
parent fc6d4138d5
commit 77ebd93bd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 183 additions and 47 deletions

View file

@ -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();

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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>"
`;

View file

@ -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;
}
}