feat(editor): Add Workflow Stickies (Notes) (#3154)

* N8N-3029 Add Node Type for Wokrflow Stickies/Notes

* N8N-3029 Update Content, Update Aliasses

* N8N-3030 Created N8N Sticky Component in Design System

* N8N-3030 Fixed Code spaccing Sticky Component

* N8N-3030 Fixed Code spaccing StickyStories Component

* N8N-3030 Fixed Code spaccing Markdown Component

* N8N-3030 Added Sticky Colors Pallete into Storybook, Update Color Variables for Sticky Component

* N8N-3030 Added Unfocus Event

* N8N-3030 Update Default Placeholder, Markdown Styles, Fixed Edit State, Added Text to EditState, Fixed Height of Area, Turned off Resize of textarea

* N8N-3030 Update Sticky Overflow, Update Hover States, Updated Markdown Overflow

* N8N-3030, N8N-3031 - Add Resize to Sticky, Created N8n-Resize component

* N8N-3031 Fixed Importing Components in Editor-ui

* N8N-3031 Fixed Resize Component, Fixed Gradient

* N8N-3030, N8N-3031 Update Note Description

* N8N-3032 Hotfix Building Storybook

* N8N-3032 - Select Behaviour, Changes in Resize Component, Emit on Width/Height/Top/Left Change

* N8N-3032 Update Resize Component to emmit left/top, Update Dynamic Resize on Selected Background

* N8N-3032 Updated / Dragging vs Resizing, prevent open Modal for stickies

* N8N-3032 Added ID props to n8n-sticky // dynamic id for multi resizing in NodeView

* N8N-3033 Add dynamic size Tooltip on Sticky

* N8N-3033 Updated Z-index for Sticky Component

* N8N-3033 Updated N8N-Resize Component, Fixed SelectedBackround for Sticky Component

* N8N-3033 Refactor

* N8N-3033 Focus/Defocus on TextArea

* N8N-3033 Fixed Resizing on NW Point

* N8N-3030 Save content in vuex on input change

* N8N-3033 Fixed Resizer, Save Width and Height in Vue

* N8N-3033 Hide Sticky Footer on small height/width

* N8N-3033 Fixed Resizer

* N8N-3033 Dynamic Z-index for Stickies

* N8N-3033 Dynamic Z-index for Stickies

* N8N-3033 Removed static z-index for select sticky class

* N8N-3034 Added Telemetry

* N8N-3030 Formatter

* N8N-3030 Format code

* N8N-3030 Fixed Selecting Stickies

* N8N-3033 Fixed Notifications

* N8N-3030 Added new paddings for Default Stickies

* N8N-3033 Prevent Scrolling NodeView when Sticky is in Edit mode and Mouse is Over the TextArea

* N8N-3030 Prevent double clicking to switch state of Sticky component in Edit Mode

* N8N-3033 Fixed Z-index of Stickies

* N8N-3033 Prevent delete node when in EditMode

* N8N-3030 Prevent Delete Button to delete the Sticky while in Edit Mode

* N8N-3030 Change EditMode (emit) on keyboard shortucts, update Markdown Links & Images, Added new props

* N8N-3030 Sticky Component - No padding when hiding footer text

* N8N-3033 Fix Resizing enter into Edit Mode

* N8N-3033 Selecting different nodes - exit the edit mode

* N8N-3033 Auto Select Text in text-area by default - Sticky Component

* N8N-3033 Prevent Default behaviour for CTRL + X, CTRL + A when Sticky is Active && inEditMode

* N8N-3033 Refactor Resizer, Refactor Sticky, Update zIndex inEditMode

* N8N-3033 Updated Default Text // Node-base, Storybook

* N8N-3033 Add Resizing in EditMode - Components update

* N8N-3033 Fixed Footer - Show/Hide on Resize in EditMode

* N8N-3033 Fix ActiveSticky on Init

* N8N-3033 Refactor Sticky in Vuex, Fixed Init Sticky Tweaks, Prevent Modal Openning, Save on Keyboard shortcuts

* Stickies - Update Note node with new props

* N8N-3030 Updated Default Note text, Update the Markdown Link

* N8N-3030 CMD-C does not copy the text fix

* N8N-3030 Fix Max Zoom / Zoom out shortcuts disabled in editState

* N8N-3030 Z-index fixed during Edit Mode typing

* N8N-3030 Prevent Autoselect Text in Stickies if the text is not default

* N8N-3030 Fixed ReadOnly Bugs / Prevent showing Tooltip, Resizing

* N8N-3030 Added Sticky Creator Button

* N8N-3030 Update Icon / Sticky Creator Button

* N8N-3033 Update Sticky Icon / StickyCreator Button

* update package lock

* 🔩 update note props

* 🚿 clean props

* 🔧 linting

* 🔧 fix spacing

* remove resize component

* remove resize component

* ✂ clean up sticky

* revert back to height width

* revert back to height/width

* replace zindex property

* replace default text property

* use i18n to translate

* update package lock

* move resize

* clean up how height/width are set

* fix resize for sticky to support left/top

* clean up resize

* fix lasso/highlight bug

* remove unused props

* fix zoom to fit

* fix padding for demo view

* fix readonly

* remove iseditable, use active state

* clean up keyboard events

* chang button size, no edit on insert

* scale resizing correctly

* make active on resize

* fix select on resize/move

* use outline icon

* allow for multiple line breaks

* fix multi line bug

* fix edit mode outline

* keep edit open as one resizes

* respect multiple spaces

* fix scrolling bug

* clean up hover impl

* clean up references to note

* disable for rename

* fix drifting while drag

* fix mouse cursor on resize

* fix sticky min height

* refactor resize into component

* fix pulling too far bug

* fix delete/cut all bug

* fix padding bottom

* fix active change on resize

* add transition to button

* Fix sticky markdown click

* add solid fa icon

* update node graph, telemetry event

* add snapping

* change alt text

* update package lock

* fix bug in button hover

* add back transition

* clean up resize

* add grid size as param

* remove breaks

* clean up markdown

* lint fixes

* fix spacing

* clean up markdown colors

* clean up classes in resize

* clean up resize

* update sticky story

* fix spacing

* clean up classes

* revert change

* revert change

* revert change

* clean up sticky component

* remove unused component

* remove unnessary data

* remove unnessary data

* clean up actions

* clean up sticky size

* clean up unnessary border style

* fix bug

* replace sticky note name

* update description

* remove support for multi spaces

* update tracking name

* update telemetry reqs

* fix enter bug

* update alt text

* update sticky notes doc url

* fix readonly bug

* update class name

* update quote marks

Co-authored-by: SchnapsterDog <olivertrajceski@yahoo.com>
This commit is contained in:
Mutasem Aldmour 2022-04-25 12:38:37 +02:00 committed by GitHub
parent d446f9e281
commit 31dd01f9cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 9501 additions and 67038 deletions

75203
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -84,11 +84,18 @@ export class InternalHooksClass implements IInternalHooksClass {
async onWorkflowSaved(userId: string, workflow: IWorkflowDb): Promise<void> { async onWorkflowSaved(userId: string, workflow: IWorkflowDb): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
const notesCount = Object.keys(nodeGraph.notes).length;
const overlappingCount = Object.values(nodeGraph.notes).filter(
(note) => note.overlapping,
).length;
return this.telemetry.track('User saved workflow', { return this.telemetry.track('User saved workflow', {
user_id: userId, user_id: userId,
workflow_id: workflow.id, workflow_id: workflow.id,
node_graph: nodeGraph, node_graph: nodeGraph,
node_graph_string: JSON.stringify(nodeGraph), node_graph_string: JSON.stringify(nodeGraph),
notes_count_overlapping: overlappingCount,
notes_count_non_overlapping: notesCount - overlappingCount,
version_cli: this.versionCli, version_cli: this.versionCli,
num_tags: workflow.tags?.length ?? 0, num_tags: workflow.tags?.length ?? 0,
}); });

View file

@ -79,6 +79,7 @@
"vue-loader": "^15.9.7", "vue-loader": "^15.9.7",
"vue-property-decorator": "^9.1.2", "vue-property-decorator": "^9.1.2",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.11",
"vue-typed-mixins": "^0.2.0",
"vue2-boring-avatars": "0.3.4", "vue2-boring-avatars": "0.3.4",
"xss": "^1.0.10" "xss": "^1.0.10"
} }

View file

@ -69,7 +69,6 @@ export default {
default: false, default: false,
}, },
icon: { icon: {
type: String,
}, },
round: { round: {
type: Boolean, type: Boolean,

View file

@ -26,7 +26,6 @@ export default {
}, },
props: { props: {
icon: { icon: {
type: String,
required: true, required: true,
}, },
size: { size: {

View file

@ -40,7 +40,6 @@ export default {
default: false, default: false,
}, },
icon: { icon: {
type: String,
required: true, required: true,
}, },
theme: { theme: {

View file

@ -1,6 +1,10 @@
<template> <template>
<div> <div>
<div v-if="!loading" ref="editor" :class="$style.markdown" v-html="htmlContent" /> <div
v-if="!loading"
ref="editor"
:class="$style[theme]" v-html="htmlContent"
/>
<div v-else :class="$style.markdown"> <div v-else :class="$style.markdown">
<div v-for="(block, index) in loadingBlocks" <div v-for="(block, index) in loadingBlocks"
:key="index"> :key="index">
@ -59,6 +63,9 @@ export default {
content: { content: {
type: String, type: String,
}, },
withMultiBreaks: {
type: Boolean,
},
images: { images: {
type: Array, type: Array,
}, },
@ -75,6 +82,10 @@ export default {
return 3; return 3;
}, },
}, },
theme: {
type: String,
default: 'markdown',
},
options: { options: {
type: Object, type: Object,
default() { default() {
@ -106,7 +117,11 @@ export default {
} }
const fileIdRegex = new RegExp('fileId:([0-9]+)'); const fileIdRegex = new RegExp('fileId:([0-9]+)');
const html = this.md.render(escapeMarkdown(this.content)); let contentToRender = this.content;
if (this.withMultiBreaks) {
contentToRender = contentToRender.replaceAll('\n\n', '\n&nbsp;\n');
}
const html = this.md.render(escapeMarkdown(contentToRender));
const safeHtml = xss(html, { const safeHtml = xss(html, {
onTagAttr: (tag, name, value, isWhiteAttr) => { onTagAttr: (tag, name, value, isWhiteAttr) => {
if (tag === 'img' && name === 'src') { if (tag === 'img' && name === 'src') {
@ -214,6 +229,67 @@ export default {
} }
} }
.sticky {
color: var(--color-text-dark);
h1, h2, h3, h4 {
margin-bottom: var(--spacing-2xs);
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-loose);
}
h1 {
font-size: 36px;
}
h2 {
font-size: 24px;
}
h3, h4, h5, h6 {
font-size: var(--font-size-m);
}
p {
margin-bottom: var(--spacing-2xs);
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
line-height: var(--font-line-height-loose);
}
ul, ol {
margin-bottom: var(--spacing-2xs);
padding-left: var(--spacing-m);
li {
margin-top: 0.25em;
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
line-height: var(--font-line-height-regular);
}
}
code {
background-color: var(--color-background-base);
padding: 0 var(--spacing-4xs);
color: var(--color-secondary);
}
pre > code,li > code, p > code {
color: var(--color-secondary);
}
a {
&:hover {
text-decoration: underline;
}
}
img {
object-fit: contain;
}
}
.spacer { .spacer {
margin: var(--spacing-2xl); margin: var(--spacing-2xl);
} }

View file

@ -0,0 +1,238 @@
<template>
<div :class="$style.resize">
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="right" :class="[$style.resizer, $style.right]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="left" :class="[$style.resizer, $style.left]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="top" :class="[$style.resizer, $style.top]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="bottom" :class="[$style.resizer, $style.bottom]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="top-left" :class="[$style.resizer, $style.topLeft]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="top-right" :class="[$style.resizer, $style.topRight]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="bottom-left" :class="[$style.resizer, $style.bottomLeft]" />
<div v-if="isResizingEnabled" @mousedown="resizerMove" data-dir="bottom-right" :class="[$style.resizer, $style.bottomRight]" />
<slot></slot>
</div>
</template>
<script lang="ts">
const cursorMap = {
right: 'ew-resize',
top: 'ns-resize',
bottom: 'ns-resize',
left: 'ew-resize',
'top-left': 'nw-resize',
'top-right' : 'ne-resize',
'bottom-left': 'sw-resize',
'bottom-right': 'se-resize',
};
function closestNumber(value: number, divisor: number): number {
let q = parseInt(value / divisor);
let n1 = divisor * q;
let n2 = (value * divisor) > 0 ?
(divisor * (q + 1)) : (divisor * (q - 1));
if (Math.abs(value - n1) < Math.abs(value - n2))
return n1;
return n2;
}
function getSize(delta, min, virtual, gridSize): number {
const target = closestNumber(virtual, gridSize);
if (target >= min && virtual > 0) {
return target;
}
return min;
};
export default {
name: 'n8n-resize',
props: {
isResizingEnabled: {
type: Boolean,
default: true,
},
height: {
type: Number,
},
width: {
type: Number,
},
minHeight: {
type: Number,
},
minWidth: {
type: Number,
},
scale: {
type: Number,
default: 1,
},
gridSize: {
type: Number,
},
},
data() {
return {
dir: '',
dHeight: 0,
dWidth: 0,
vHeight: 0,
vWidth: 0,
x: 0,
y: 0,
};
},
methods: {
resizerMove(e) {
e.preventDefault();
e.stopPropagation();
const targetResizer = e.target;
this.dir = targetResizer.dataset.dir;
document.body.style.cursor = cursorMap[this.dir];
this.x = e.pageX;
this.y = e.pageY;
this.dWidth = 0;
this.dHeight = 0;
this.vHeight = this.height;
this.vWidth = this.width;
window.addEventListener('mousemove', this.mouseMove);
window.addEventListener('mouseup', this.mouseUp);
this.$emit('resizestart');
},
mouseMove(e) {
e.preventDefault();
e.stopPropagation();
let dWidth = 0;
let dHeight = 0;
let top = false;
let left = false;
if (this.dir.includes('right')) {
dWidth = e.pageX - this.x;
}
if (this.dir.includes('left')) {
dWidth = this.x - e.pageX;
left = true;
}
if (this.dir.includes('top')) {
dHeight = this.y - e.pageY;
top = true;
}
if (this.dir.includes('bottom')) {
dHeight = e.pageY - this.y;
}
const deltaWidth = (dWidth - this.dWidth) / this.scale;
const deltaHeight = (dHeight - this.dHeight) / this.scale;
this.vHeight = this.vHeight + deltaHeight;
this.vWidth = this.vWidth + deltaWidth;
const height = getSize(deltaHeight, this.minHeight, this.vHeight, this.gridSize);
const width = getSize(deltaWidth, this.minWidth, this.vWidth, this.gridSize);
const dX = left && width !== this.width ? -1 * (width - this.width) : 0;
const dY = top && height !== this.height ? -1 * (height - this.height): 0;
this.$emit('resize', { height, width, dX, dY });
this.dHeight = dHeight;
this.dWidth = dWidth;
},
mouseUp(e) {
e.preventDefault();
e.stopPropagation();
this.$emit('resizeend');
window.removeEventListener('mousemove', this.mouseMove);
window.removeEventListener('mouseup', this.mouseUp);
document.body.style.cursor = 'unset';
this.dir = '';
},
},
};
</script>
<style lang="scss" module>
.resize {
position: absolute;
width: 100%;
height: 100%;
z-index: 2;
}
.resizer {
position: absolute;
z-index: 2;
}
.right {
width: 12px;
height: 100%;
top: -2px;
right: -2px;
cursor: ew-resize;
}
.top {
width: 100%;
height: 12px;
top: -2px;
left: -2px;
cursor: ns-resize;
}
.bottom {
width: 100%;
height: 12px;
bottom: -2px;
left: -2px;
cursor: ns-resize;
}
.left {
width: 12px;
height: 100%;
top: -2px;
left: -2px;
cursor: ew-resize;
}
.topLeft {
width: 12px;
height: 12px;
top: -3px;
left: -3px;
cursor: nw-resize;
z-index: 3;
}
.topRight {
width: 12px;
height: 12px;
top: -3px;
right: -3px;
cursor: ne-resize;
z-index: 3;
}
.bottomLeft {
width: 12px;
height: 12px;
bottom: -3px;
left: -3px;
cursor: sw-resize;
z-index: 3;
}
.bottomRight {
width: 12px;
height: 12px;
bottom: -3px;
right: -3px;
cursor: se-resize;
z-index: 3;
}
</style>

View file

@ -0,0 +1,67 @@
import { action } from '@storybook/addon-actions';
import N8nSticky from './Sticky.vue';
export default {
title: 'Atoms/Sticky',
component: N8nSticky,
argTypes: {
content: {
control: {
control: 'text',
},
},
height: {
control: {
control: 'number',
},
},
minHeight: {
control: {
control: 'number',
},
},
minWidth: {
control: {
control: 'number',
},
},
readOnly: {
control: {
control: 'Boolean',
},
},
width: {
control: {
control: 'number',
},
},
},
};
const methods = {
onInput: action('input'),
onResize: action('resize'),
onResizeEnd: action('resizeend'),
onResizeStart: action('resizestart'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nSticky,
},
template:
'<n8n-sticky v-bind="$props" @resize="onResize" @resizeend="onResizeEnd" @resizeStart="onResizeStart" @input="onInput"></n8n-sticky>',
methods,
});
export const Sticky = Template.bind({});
Sticky.args = {
height: 160,
width: 150,
content: `## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)`,
defaultText: `## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)`,
minHeight: 80,
minWidth: 150,
readOnly: false,
};

View file

@ -0,0 +1,253 @@
<template>
<div
:class="{[$style.sticky]: true, [$style.clickable]: !isResizing}"
:style="styles"
@keydown.prevent
>
<resize
:isResizingEnabled="!readOnly"
:height="height"
:width="width"
:minHeight="minHeight"
:minWidth="minWidth"
:scale="scale"
:gridSize="gridSize"
@resizeend="onResizeEnd"
@resize="onResize"
@resizestart="onResizeStart"
>
<template>
<div
v-show="!editMode"
:class="$style.wrapper"
@dblclick.stop="onDoubleClick"
>
<n8n-markdown
theme="sticky"
:content="content"
:withMultiBreaks="true"
/>
</div>
<div
v-show="editMode"
@click.stop
@mousedown.stop
@mouseup.stop
@keydown.esc="onInputBlur"
@keydown.stop
@wheel.stop
class="sticky-textarea"
:class="{'full-height': !shouldShowFooter}"
>
<n8n-input
:value="content"
type="textarea"
:rows="5"
@blur="onInputBlur"
@input="onInput"
ref="input"
/>
</div>
<div v-if="editMode && shouldShowFooter" :class="$style.footer">
<n8n-text
size="xsmall"
aligh="right"
>
<span v-html="t('sticky.markdownHint')"></span>
</n8n-text>
</div>
</template>
</resize>
</div>
</template>
<script lang="ts">
import N8nInput from '../N8nInput';
import N8nMarkdown from '../N8nMarkdown';
import Resize from './Resize';
import N8nText from '../N8nText';
import Locale from '../../mixins/locale';
import mixins from 'vue-typed-mixins';
export default mixins(Locale).extend({
name: 'n8n-sticky',
props: {
content: {
type: String,
},
height: {
type: Number,
default: 180,
},
width: {
type: Number,
default: 240,
},
minHeight: {
type: Number,
default: 80,
},
minWidth: {
type: Number,
default: 150,
},
scale: {
type: Number,
default: 1,
},
gridSize: {
type: Number,
default: 20,
},
id: {
type: String,
default: '0',
},
defaultText: {
type: String,
},
editMode: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
},
components: {
N8nInput,
N8nMarkdown,
Resize,
N8nText,
},
data() {
return {
isResizing: false,
};
},
computed: {
resHeight(): number {
if (this.height < this.minHeight) {
return this.minHeight;
}
return this.height;
},
resWidth(): number {
if (this.width < this.minWidth) {
return this.minWidth;
}
return this.width;
},
styles() {
return {
height: this.resHeight + 'px',
width: this.resWidth + 'px',
};
},
shouldShowFooter() {
return this.resHeight > 100 && this.resWidth > 155;
},
},
methods: {
onDoubleClick() {
if (!this.readOnly) {
this.$emit('edit', true);
}
},
onInputBlur(value) {
if (!this.isResizing) {
this.$emit('edit', false);
}
},
onInput(value: string) {
this.$emit('input', value);
},
onResize(values) {
this.$emit('resize', values);
},
onResizeEnd(resizeEnd) {
this.isResizing = false;
this.$emit('resizeend', resizeEnd);
},
onResizeStart() {
this.isResizing = true;
this.$emit('resizestart');
},
},
watch: {
editMode(newMode, prevMode) {
setTimeout(() => {
if (newMode && !prevMode && this.$refs.input && this.$refs.input.$refs && this.$refs.input.$refs.textarea) {
const textarea = this.$refs.input.$refs.textarea;
if (this.defaultText === this.content) {
textarea.select();
}
textarea.focus();
}
}, 100);
},
},
});
</script>
<style lang="scss" module>
.sticky {
position: absolute;
background-color: var(--color-sticky-default-background);
border: 1px solid var(--color-sticky-default-border);
border-radius: var(--border-radius-base);
}
.clickable {
cursor: pointer;
}
.wrapper {
width: 100%;
height: 100%;
position: absolute;
padding: var(--spacing-2xs) var(--spacing-xs) 0;
overflow: hidden;
&::after {
content: '';
width: 100%;
height: 24px;
left: 0;
bottom: 0;
position: absolute;
background: linear-gradient(180deg, var(--color-sticky-default-background), #fff5d600 0.01%, var(--color-sticky-default-background));
border-radius: var(--border-radius-base);
}
}
.footer {
padding: var(--spacing-5xs) var(--spacing-2xs) 0 var(--spacing-2xs);
display: flex;
justify-content: flex-end;
}
</style>
<style lang="scss">
.sticky-textarea {
height: calc(100% - var(--spacing-l));
padding: var(--spacing-2xs) var(--spacing-2xs) 0 var(--spacing-2xs);
cursor: default;
.el-textarea {
height: 100%;
.el-textarea__inner {
height: 100%;
resize: unset;
}
}
}
.full-height {
height: calc(100% - var(--spacing-2xs));
}
</style>

View file

@ -0,0 +1,3 @@
import Sticky from './Sticky.vue';
export default Sticky;

View file

@ -57,6 +57,7 @@ import N8nOption from './N8nOption';
import N8nRadioButtons from './N8nRadioButtons'; import N8nRadioButtons from './N8nRadioButtons';
import N8nSelect from './N8nSelect'; import N8nSelect from './N8nSelect';
import N8nSpinner from './N8nSpinner'; import N8nSpinner from './N8nSpinner';
import N8nSticky from './N8nSticky';
import N8nSquareButton from './N8nSquareButton'; import N8nSquareButton from './N8nSquareButton';
import N8nTags from './N8nTags'; import N8nTags from './N8nTags';
import N8nTabs from './N8nTabs'; import N8nTabs from './N8nTabs';
@ -93,6 +94,7 @@ export {
N8nRadioButtons, N8nRadioButtons,
N8nSelect, N8nSelect,
N8nSpinner, N8nSpinner,
N8nSticky,
N8nSquareButton, N8nSquareButton,
N8nTabs, N8nTabs,
N8nTags, N8nTags,

View file

@ -16,4 +16,5 @@ export default {
config.minimum > 1 ? 's' : '' config.minimum > 1 ? 's' : ''
}`), }`),
"formInput.validator.defaultPasswordRequirements": "8+ characters, at least 1 number and 1 capital letter", "formInput.validator.defaultPasswordRequirements": "8+ characters, at least 1 number and 1 capital letter",
"sticky.markdownHint": `You can style with <a href="https://docs.n8n.io/workflows/sticky-notes/" target="_blank">Markdown</a>`,
}; };

View file

@ -166,3 +166,17 @@ import ColorCircles from './ColorCircles.vue';
}} }}
</Story> </Story>
</Canvas> </Canvas>
## Sticky
<Canvas>
<Story name="sticky">
{{
template: `<color-circles :colors="['--color-sticky-default-background', '--color-sticky-default-border']" />`,
components: {
ColorCircles,
},
}}
</Story>
</Canvas>

View file

@ -3,6 +3,7 @@ export const escapeMarkdown = (html: string | undefined): string => {
return ''; return '';
} }
const escaped = html.replace(/</g, "&lt;").replace(/>/g, "&gt;"); const escaped = html.replace(/</g, "&lt;").replace(/>/g, "&gt;");
// unescape greater than quotes at start of line // unescape greater than quotes at start of line
const withQuotes = escaped.replace(/^((\s)*(&gt;)+)+\s*/gm, (matches) => { const withQuotes = escaped.replace(/^((\s)*(&gt;)+)+\s*/gm, (matches) => {
return matches.replace(/&gt;/g, '>'); return matches.replace(/&gt;/g, '>');

View file

@ -70,13 +70,6 @@
var(--color-secondary-l) var(--color-secondary-l)
); );
--color-secondary-tint-1-l: 92%;
--color-secondary-tint-1: hsl(
var(--color-secondary-h),
var(--color-secondary-s),
var(--color-secondary-tint-1-l)
);
--color-success-h: 150.4; --color-success-h: 150.4;
--color-success-s: 60%; --color-success-s: 60%;
--color-success-l: 40.4%; --color-success-l: 40.4%;
@ -340,6 +333,24 @@
--color-json-line: #bfcbd9; --color-json-line: #bfcbd9;
--color-json-highlight: #E2E5EE; --color-json-highlight: #E2E5EE;
--color-sticky-default-background-h: 46;
--color-sticky-default-background-s: 100%;
--color-sticky-default-background-l: 92%;
--color-sticky-default-background: hsl(
var(--color-sticky-default-background-h),
var(--color-sticky-default-background-s),
var(--color-sticky-default-background-l)
);
--color-sticky-default-border-h: 43;
--color-sticky-default-border-s: 75%;
--color-sticky-default-border-l: 80%;
--color-sticky-default-border: hsl(
var(--color-sticky-default-border-h),
var(--color-sticky-default-border-s),
var(--color-sticky-default-border-l)
);
--border-radius-xlarge: 12px; --border-radius-xlarge: 12px;
--border-radius-large: 8px; --border-radius-large: 8px;
--border-radius-base: 4px; --border-radius-base: 4px;

View file

@ -25,14 +25,15 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/open-sans": "^4.5.0", "@fontsource/open-sans": "^4.5.0",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"luxon": "^2.3.0", "luxon": "^2.3.0",
"n8n-design-system": "~0.17.0",
"monaco-editor": "^0.29.1", "monaco-editor": "^0.29.1",
"n8n-design-system": "~0.17.0",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"v-click-outside": "^3.1.2", "v-click-outside": "^3.1.2",
"vue-fragment": "^1.5.2", "vue-fragment": "^1.5.2",
"vue2-boring-avatars": "0.3.4",
"vue-i18n": "^8.26.7", "vue-i18n": "^8.26.7",
"vue2-boring-avatars": "0.3.4",
"xss": "^1.0.10" "xss": "^1.0.10"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,6 +1,6 @@
<template> <template>
<el-dialog <el-dialog
:visible="!!node || renaming" :visible="(!!node || renaming) && !isActiveStickyNode"
:before-close="close" :before-close="close"
:show-close="false" :show-close="false"
custom-class="data-display-wrapper" custom-class="data-display-wrapper"
@ -41,6 +41,7 @@ import RunData from '@/components/RunData.vue';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import Vue from 'vue'; import Vue from 'vue';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { STICKY_NODE_TYPE } from '@/constants';
export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
name: 'DataDisplay', name: 'DataDisplay',
@ -76,15 +77,18 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
} }
return null; return null;
}, },
isActiveStickyNode(): boolean {
return !!this.$store.getters.activeNode && this.$store.getters.activeNode.type === STICKY_NODE_TYPE;
},
}, },
watch: { watch: {
node (node, oldNode) { node (node, oldNode) {
if(node && !oldNode) { if(node && !oldNode && !this.isActiveStickyNode) {
this.triggerWaitingWarningEnabled = false; this.triggerWaitingWarningEnabled = false;
this.$externalHooks().run('dataDisplay.nodeTypeChanged', { nodeSubtitle: this.getNodeSubtitle(node, this.nodeType, this.getWorkflow()) }); this.$externalHooks().run('dataDisplay.nodeTypeChanged', { nodeSubtitle: this.getNodeSubtitle(node, this.nodeType, this.getWorkflow()) });
this.$telemetry.track('User opened node modal', { node_type: this.nodeType ? this.nodeType.name : '', workflow_id: this.$store.getters.workflowId }); this.$telemetry.track('User opened node modal', { node_type: this.nodeType ? this.nodeType.name : '', workflow_id: this.$store.getters.workflowId });
} }
if (window.top) { if (window.top && !this.isActiveStickyNode) {
window.top.postMessage(JSON.stringify({command: (node? 'openNDV': 'closeNDV')}), '*'); window.top.postMessage(JSON.stringify({command: (node? 'openNDV': 'closeNDV')}), '*');
} }
}, },

View file

@ -0,0 +1,288 @@
<template>
<div class="sticky-wrapper" :style="stickyPosition">
<div
:class="{'sticky-default': true, 'touch-active': isTouchActive, 'is-touch-device': isTouchDevice}"
:style="stickySize"
>
<div class="select-sticky-background" v-show="isSelected" />
<div
class="sticky-box"
:data-name="data.name"
:ref="data.name"
@click.left="mouseLeftClick"
v-touch:start="touchStart"
v-touch:end="touchEnd"
>
<n8n-sticky
:content.sync="node.parameters.content"
:height="node.parameters.height"
:width="node.parameters.width"
:scale="nodeViewScale"
:id="nodeIndex"
:readOnly="isReadOnly"
:defaultText="defaultText"
:editMode="isActive && !isReadOnly"
:gridSize="gridSize"
@input="onInputChange"
@edit="onEdit"
@resizestart="onResizeStart"
@resize="onResize"
@resizeend="onResizeEnd"
/>
</div>
<div v-show="showActions" class="sticky-options no-select-on-click">
<div v-touch:tap="deleteNode" class="option" :title="$locale.baseText('node.deleteNode')" >
<font-awesome-icon icon="trash" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeBase } from '@/components/mixins/nodeBase';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { getStyleTokenValue, isNumber, isString } from './helpers';
import { INodeUi, XYPosition } from '@/Interface';
import {
INodeTypeDescription,
} from 'n8n-workflow';
export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).extend({
name: 'Sticky',
props: {
nodeViewScale: {
type: Number,
},
gridSize: {
type: Number,
},
},
computed: {
defaultText (): string {
if (!this.nodeType) {
return '';
}
const properties = this.nodeType.properties;
const content = properties.find((property) => property.name === 'content');
return content && isString(content.default) ? content.default : '';
},
isSelected (): boolean {
return this.$store.getters.getSelectedNodes.find((node: INodeUi) => node.name === this.data.name);
},
nodeType (): INodeTypeDescription | null {
return this.data && this.$store.getters.nodeType(this.data.type, this.data.typeVersion);
},
node (): INodeUi | undefined { // same as this.data but reactive..
return this.$store.getters.nodesByName[this.name] as INodeUi | undefined;
},
position (): XYPosition {
if (this.node) {
return this.node.position;
} else {
return [0, 0];
}
},
height(): number {
return this.node && isNumber(this.node.parameters.height)? this.node.parameters.height : 0;
},
width(): number {
return this.node && isNumber(this.node.parameters.width)? this.node.parameters.width : 0;
},
stickySize(): object {
const returnStyles: {
[key: string]: string | number;
} = {
height: this.height + 'px',
width: this.width + 'px',
};
return returnStyles;
},
stickyPosition (): object {
const returnStyles: {
[key: string]: string | number;
} = {
left: this.position[0] + 'px',
top: this.position[1] + 'px',
zIndex: this.isActive ? 9999999 : -1 * Math.floor((this.height * this.width) / 1000),
};
return returnStyles;
},
showActions(): boolean {
return !(this.hideActions || this.isReadOnly || this.workflowRunning || this.isResizing);
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
},
data () {
return {
isResizing: false,
isTouchActive: false,
};
},
methods: {
deleteNode () {
Vue.nextTick(() => {
// Wait a tick else vue causes problems because the data is gone
this.$emit('removeNode', this.data.name);
});
},
onEdit(edit: boolean) {
if (edit && !this.isActive && this.node) {
this.$store.commit('setActiveNode', this.node.name);
}
else if (this.isActive && !edit) {
this.$store.commit('setActiveNode', null);
}
},
onInputChange(content: string) {
this.setParameters({content});
},
onResizeStart() {
this.isResizing = true;
if (!this.isSelected && this.node) {
this.$emit('nodeSelected', this.node.name, false, true);
}
const nodeIndex = this.$store.getters.getNodeIndex(this.data.name);
const nodeIdName = `node-${nodeIndex}`;
this.instance.destroyDraggable(nodeIdName); // todo
},
onResize({height, width, dX, dY}: { width: number, height: number, dX: number, dY: number }) {
if (!this.node) {
return;
}
if (dX !== 0 || dY !== 0) {
this.setPosition([this.node.position[0] + (dX || 0), this.node.position[1] + (dY || 0)]);
}
this.setParameters({ height, width });
},
onResizeEnd() {
this.isResizing = false;
this.__makeInstanceDraggable(this.data);
},
setParameters(params: {content?: string, height?: number, width?: number}) {
if (this.node) {
const nodeParameters = {
content: isString(params.content) ? params.content : this.node.parameters.content,
height: isNumber(params.height) ? params.height : this.node.parameters.height,
width: isNumber(params.width) ? params.width : this.node.parameters.width,
};
const updateInformation = {
name: this.node.name,
value: nodeParameters,
};
this.$store.commit('setNodeParameters', updateInformation);
}
},
setPosition(position: XYPosition) {
if (!this.node) {
return;
}
const updateInformation = {
name: this.node.name,
properties: {
position,
},
};
this.$store.commit('updateNodeProperties', updateInformation);
},
touchStart () {
if (this.isTouchDevice === true && this.isMacOs === false && this.isTouchActive === false) {
this.isTouchActive = true;
setTimeout(() => {
this.isTouchActive = false;
}, 2000);
}
},
},
});
</script>
<style lang="scss" scoped>
.sticky-wrapper {
position: absolute;
.sticky-default {
position: absolute;
.sticky-box {
width: 100%;
height: 100%;
}
&.touch-active,
&:hover {
.sticky-options {
display: flex;
cursor: pointer;
}
}
.sticky-options {
display: none;
justify-content: flex-start;
position: absolute;
top: -25px;
left: -8px;
height: 26px;
font-size: 0.9em;
text-align: left;
z-index: 10;
color: #aaa;
text-align: center;
.option {
width: 28px;
display: inline-block;
&.touch {
display: none;
}
&:hover {
color: $--color-primary;
}
}
}
&.is-touch-device .sticky-options {
left: -25px;
width: 150px;
.option.touch {
display: initial;
}
}
}
}
.select-sticky-background {
display: block;
position: absolute;
background-color: hsla(var(--color-foreground-base-h), var(--color-foreground-base-s), var(--color-foreground-base-l), 60%);
border-radius: var(--border-radius-xlarge);
overflow: hidden;
height: calc(100% + 16px);
width: calc(100% + 16px);
left: -8px;
top: -8px;
z-index: 0;
}
</style>

View file

@ -59,3 +59,11 @@ export function filterTemplateNodes(nodes: ITemplatesNode[]) {
export function setPageTitle(title: string) { export function setPageTitle(title: string) {
window.document.title = title; window.document.title = title;
} }
export function isString(value: unknown): value is string {
return typeof value === 'string';
}
export function isNumber(value: unknown): value is number {
return typeof value === 'number';
}

View file

@ -190,7 +190,6 @@ export const mouseSelect = mixins(
this.$store.commit('resetSelectedNodes'); this.$store.commit('resetSelectedNodes');
this.$store.commit('setLastSelectedNode', null); this.$store.commit('setLastSelectedNode', null);
this.$store.commit('setLastSelectedNodeOutputIndex', null); this.$store.commit('setLastSelectedNodeOutputIndex', null);
this.$store.commit('setActiveNode', null);
// @ts-ignore // @ts-ignore
this.lastSelectedConnection = null; this.lastSelectedConnection = null;
// @ts-ignore // @ts-ignore

View file

@ -4,7 +4,7 @@ import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers'; import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { nodeIndex } from '@/components/mixins/nodeIndex'; import { nodeIndex } from '@/components/mixins/nodeIndex';
import { NODE_NAME_PREFIX, NO_OP_NODE_TYPE } from '@/constants'; import { NODE_NAME_PREFIX, NO_OP_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import * as CanvasHelpers from '@/views/canvasHelpers'; import * as CanvasHelpers from '@/views/canvasHelpers';
import { Endpoint } from 'jsplumb'; import { Endpoint } from 'jsplumb';
@ -221,7 +221,15 @@ export const nodeBase = mixins(
// @ts-ignore // @ts-ignore
this.dragging = true; this.dragging = true;
if (params.e && !this.$store.getters.isNodeSelected(this.data.name)) { const isSelected = this.$store.getters.isNodeSelected(this.data.name);
const nodeName = this.data.name;
if (this.data.type === STICKY_NODE_TYPE && !isSelected) {
setTimeout(() => {
this.$emit('nodeSelected', nodeName, false, true);
}, 0);
}
if (params.e && !isSelected) {
// Only the node which gets dragged directly gets an event, for all others it is // Only the node which gets dragged directly gets an event, for all others it is
// undefined. So check if the currently dragged node is selected and if not clear // undefined. So check if the currently dragged node is selected and if not clear
// the drag-selection. // the drag-selection.

View file

@ -65,6 +65,7 @@ export const JIRA_TRIGGER_NODE_TYPE = 'n8n-nodes-base.jiraTrigger';
export const MICROSOFT_EXCEL_NODE_TYPE = 'n8n-nodes-base.microsoftExcel'; export const MICROSOFT_EXCEL_NODE_TYPE = 'n8n-nodes-base.microsoftExcel';
export const MICROSOFT_TEAMS_NODE_TYPE = 'n8n-nodes-base.microsoftTeams'; export const MICROSOFT_TEAMS_NODE_TYPE = 'n8n-nodes-base.microsoftTeams';
export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp'; export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp';
export const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote';
export const NOTION_TRIGGER_NODE_TYPE = 'n8n-nodes-base.notionTrigger'; export const NOTION_TRIGGER_NODE_TYPE = 'n8n-nodes-base.notionTrigger';
export const PAGERDUTY_NODE_TYPE = 'n8n-nodes-base.pagerDuty'; export const PAGERDUTY_NODE_TYPE = 'n8n-nodes-base.pagerDuty';
export const SALESFORCE_NODE_TYPE = 'n8n-nodes-base.salesforce'; export const SALESFORCE_NODE_TYPE = 'n8n-nodes-base.salesforce';

View file

@ -65,6 +65,7 @@ import {
N8nRadioButtons, N8nRadioButtons,
N8nSelect, N8nSelect,
N8nSpinner, N8nSpinner,
N8nSticky,
N8nTabs, N8nTabs,
N8nFormInputs, N8nFormInputs,
N8nFormBox, N8nFormBox,
@ -100,6 +101,7 @@ Vue.use(N8nMenuItem);
Vue.use(N8nOption); Vue.use(N8nOption);
Vue.use(N8nSelect); Vue.use(N8nSelect);
Vue.use(N8nSpinner); Vue.use(N8nSpinner);
Vue.component('n8n-sticky', N8nSticky);
Vue.use(N8nRadioButtons); Vue.use(N8nRadioButtons);
Vue.component('n8n-square-button', N8nSquareButton); Vue.component('n8n-square-button', N8nSquareButton);
Vue.use(N8nTags); Vue.use(N8nTags);

View file

@ -411,6 +411,7 @@
"nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)", "nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)",
"nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)", "nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)",
"nodeView.addNode": "Add node", "nodeView.addNode": "Add node",
"nodeView.addSticky": "Click to add sticky note",
"nodeView.confirmMessage.beforeRouteLeave.cancelButtonText": "Leave without saving", "nodeView.confirmMessage.beforeRouteLeave.cancelButtonText": "Leave without saving",
"nodeView.confirmMessage.beforeRouteLeave.confirmButtonText": "Save", "nodeView.confirmMessage.beforeRouteLeave.confirmButtonText": "Save",
"nodeView.confirmMessage.beforeRouteLeave.headline": "Save changes before leaving?", "nodeView.confirmMessage.beforeRouteLeave.headline": "Save changes before leaving?",

View file

@ -93,7 +93,11 @@ import {
faUserCircle, faUserCircle,
faUserFriends, faUserFriends,
faUsers, faUsers,
faStickyNote as faSolidStickyNote,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import {
faStickyNote,
} from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
function addIcon(icon: any) { // tslint:disable-line:no-any function addIcon(icon: any) { // tslint:disable-line:no-any
@ -177,6 +181,8 @@ addIcon(faServer);
addIcon(faSignInAlt); addIcon(faSignInAlt);
addIcon(faSlidersH); addIcon(faSlidersH);
addIcon(faSpinner); addIcon(faSpinner);
addIcon(faSolidStickyNote);
addIcon(faStickyNote);
addIcon(faStop); addIcon(faStop);
addIcon(faSun); addIcon(faSun);
addIcon(faSync); addIcon(faSync);

View file

@ -169,6 +169,9 @@ class Telemetry {
case 'nodeView.addNodeButton': case 'nodeView.addNodeButton':
this.telemetry.track('User added node to workflow canvas', properties); this.telemetry.track('User added node to workflow canvas', properties);
break; break;
case 'nodeView.addSticky':
this.telemetry.track('User inserted workflow note', properties);
break;
default: default:
break; break;
} }

View file

@ -21,29 +21,54 @@
class="node-view" class="node-view"
:style="workflowStyle" :style="workflowStyle"
> >
<node <div v-for="nodeData in nodes" :key="getNodeIndex(nodeData.name)">
v-for="nodeData in nodes" <node
@duplicateNode="duplicateNode" v-if="nodeData.type !== STICKY_NODE_TYPE"
@deselectAllNodes="deselectAllNodes" @duplicateNode="duplicateNode"
@deselectNode="nodeDeselectedByName" @deselectAllNodes="deselectAllNodes"
@nodeSelected="nodeSelectedByName" @deselectNode="nodeDeselectedByName"
@removeNode="removeNode" @nodeSelected="nodeSelectedByName"
@runWorkflow="runWorkflow" @removeNode="removeNode"
@moved="onNodeMoved" @runWorkflow="runWorkflow"
@run="onNodeRun" @moved="onNodeMoved"
:id="'node-' + getNodeIndex(nodeData.name)" @run="onNodeRun"
:key="getNodeIndex(nodeData.name)" :id="'node-' + getNodeIndex(nodeData.name)"
:name="nodeData.name" :key="getNodeIndex(nodeData.name)"
:isReadOnly="isReadOnly" :name="nodeData.name"
:instance="instance" :isReadOnly="isReadOnly"
:isActive="!!activeNode && activeNode.name === nodeData.name" :instance="instance"
:hideActions="pullConnActive" :isActive="!!activeNode && activeNode.name === nodeData.name"
/> :hideActions="pullConnActive"
/>
<Sticky
v-else
@deselectAllNodes="deselectAllNodes"
@deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName"
@removeNode="removeNode"
:id="'node-' + getNodeIndex(nodeData.name)"
:name="nodeData.name"
:isReadOnly="isReadOnly"
:instance="instance"
:isActive="!!activeNode && activeNode.name === nodeData.name"
:nodeViewScale="nodeViewScale"
:gridSize="GRID_SIZE"
:hideActions="pullConnActive"
/>
</div>
</div> </div>
</div> </div>
<DataDisplay :renaming="renamingActive" @valueChanged="valueChanged"/> <DataDisplay :renaming="renamingActive" @valueChanged="valueChanged"/>
<div v-if="!createNodeActive && !isReadOnly" class="node-creator-button" :title="$locale.baseText('nodeView.addNode')" @click="() => openNodeCreator('add_node_button')"> <div
<n8n-icon-button size="xlarge" icon="plus" /> class="node-buttons-wrapper"
v-if="!createNodeActive && !isReadOnly"
>
<div class="node-creator-button">
<n8n-icon-button size="xlarge" icon="plus" @click="() => openNodeCreator('add_node_button')" :title="$locale.baseText('nodeView.addNode')"/>
<div class="add-sticky-button" @click="nodeTypeSelected(STICKY_NODE_TYPE)">
<n8n-icon-button size="large" :icon="['far', 'note-sticky']" type="outline" :title="$locale.baseText('nodeView.addSticky')"/>
</div>
</div>
</div> </div>
<node-creator <node-creator
:active="createNodeActive" :active="createNodeActive"
@ -122,7 +147,7 @@ import {
} from 'jsplumb'; } from 'jsplumb';
import { MessageBoxInputData } from 'element-ui/types/message-box'; import { MessageBoxInputData } from 'element-ui/types/message-box';
import { jsPlumb, OnConnectionBindInfo } from 'jsplumb'; import { jsPlumb, OnConnectionBindInfo } from 'jsplumb';
import { MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, NODE_NAME_PREFIX, NODE_OUTPUT_DEFAULT_KEY, PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE, VIEWS, WEBHOOK_NODE_TYPE, WORKFLOW_OPEN_MODAL_KEY } from '@/constants'; import { MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, NODE_NAME_PREFIX, NODE_OUTPUT_DEFAULT_KEY, PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE, STICKY_NODE_TYPE, VIEWS, WEBHOOK_NODE_TYPE, WORKFLOW_OPEN_MODAL_KEY } from '@/constants';
import { copyPaste } from '@/components/mixins/copyPaste'; import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
import { genericHelpers } from '@/components/mixins/genericHelpers'; import { genericHelpers } from '@/components/mixins/genericHelpers';
@ -141,6 +166,7 @@ import Node from '@/components/Node.vue';
import NodeCreator from '@/components/NodeCreator/NodeCreator.vue'; import NodeCreator from '@/components/NodeCreator/NodeCreator.vue';
import NodeSettings from '@/components/NodeSettings.vue'; import NodeSettings from '@/components/NodeSettings.vue';
import RunData from '@/components/RunData.vue'; import RunData from '@/components/RunData.vue';
import Sticky from '@/components/Sticky.vue';
import * as CanvasHelpers from './canvasHelpers'; import * as CanvasHelpers from './canvasHelpers';
@ -211,6 +237,7 @@ export default mixins(
NodeCreator, NodeCreator,
NodeSettings, NodeSettings,
RunData, RunData,
Sticky,
}, },
errorCaptured: (err, vm, info) => { errorCaptured: (err, vm, info) => {
console.error('errorCaptured'); // eslint-disable-line no-console console.error('errorCaptured'); // eslint-disable-line no-console
@ -344,6 +371,8 @@ export default mixins(
}, },
data () { data () {
return { return {
GRID_SIZE: CanvasHelpers.GRID_SIZE,
STICKY_NODE_TYPE,
createNodeActive: false, createNodeActive: false,
instance: jsPlumb.getInstance(), instance: jsPlumb.getInstance(),
lastSelectedConnection: null as null | Connection, lastSelectedConnection: null as null | Connection,
@ -710,7 +739,7 @@ export default mixins(
this.ctrlKeyPressed = true; this.ctrlKeyPressed = true;
} else if (e.key === 'F2' && !this.isReadOnly) { } else if (e.key === 'F2' && !this.isReadOnly) {
const lastSelectedNode = this.lastSelectedNode; const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode !== null) { if (lastSelectedNode !== null && lastSelectedNode.type !== STICKY_NODE_TYPE) {
this.callDebounced('renameNodePrompt', { debounceTime: 1500 }, lastSelectedNode.name); this.callDebounced('renameNodePrompt', { debounceTime: 1500 }, lastSelectedNode.name);
} }
} else if ((e.key === '=' || e.key === '+') && !this.isCtrlKeyPressed(e)) { } else if ((e.key === '=' || e.key === '+') && !this.isCtrlKeyPressed(e)) {
@ -777,6 +806,9 @@ export default mixins(
const lastSelectedNode = this.lastSelectedNode; const lastSelectedNode = this.lastSelectedNode;
if (lastSelectedNode !== null) { if (lastSelectedNode !== null) {
if (lastSelectedNode.type === STICKY_NODE_TYPE && this.isReadOnly) {
return;
}
this.$store.commit('setActiveNode', lastSelectedNode.name); this.$store.commit('setActiveNode', lastSelectedNode.name);
} }
} else if (e.key === 'ArrowRight' && e.shiftKey === true) { } else if (e.key === 'ArrowRight' && e.shiftKey === true) {
@ -1391,18 +1423,22 @@ export default mixins(
this.$store.commit('setStateDirty', true); this.$store.commit('setStateDirty', true);
this.$externalHooks().run('nodeView.addNodeButton', { nodeTypeName }); if (nodeTypeName === STICKY_NODE_TYPE) {
this.$telemetry.trackNodesPanel('nodeView.addNodeButton', { this.$telemetry.trackNodesPanel('nodeView.addSticky', { workflow_id: this.$store.getters.workflowId });
node_type: nodeTypeName, } else {
workflow_id: this.$store.getters.workflowId, this.$externalHooks().run('nodeView.addNodeButton', { nodeTypeName });
drag_and_drop: options.dragAndDrop, this.$telemetry.trackNodesPanel('nodeView.addNodeButton', {
} as IDataObject); node_type: nodeTypeName,
workflow_id: this.$store.getters.workflowId,
drag_and_drop: options.dragAndDrop,
} as IDataObject);
}
// Automatically deselect all nodes and select the current one and also active // Automatically deselect all nodes and select the current one and also active
// current node // current node
this.deselectAllNodes(); this.deselectAllNodes();
setTimeout(() => { setTimeout(() => {
this.nodeSelectedByName(newNodeData.name, true); this.nodeSelectedByName(newNodeData.name, nodeTypeName !== STICKY_NODE_TYPE);
}); });
return newNodeData; return newNodeData;
@ -2174,8 +2210,12 @@ export default mixins(
} }
} }
this.$externalHooks().run('node.deleteNode', { node }); if(node.type === STICKY_NODE_TYPE) {
this.$telemetry.track('User deleted node', { node_type: node.type, workflow_id: this.$store.getters.workflowId }); this.$telemetry.track('User deleted workflow note', { workflow_id: this.$store.getters.workflowId });
} else {
this.$externalHooks().run('node.deleteNode', { node });
this.$telemetry.track('User deleted node', { node_type: node.type, workflow_id: this.$store.getters.workflowId });
}
let waitForNewConnection = false; let waitForNewConnection = false;
// connect nodes before/after deleted node // connect nodes before/after deleted node
@ -2869,6 +2909,28 @@ export default mixins(
bottom: 10px; bottom: 10px;
} }
.node-buttons-wrapper {
position: fixed;
width: 150px;
height: 200px;
top: 0;
right: 0;
display: flex;
.add-sticky-button {
margin-top: var(--spacing-2xs);
opacity: 0;
transition: .1s;
transition-timing-function: linear;
}
&:hover {
.add-sticky-button {
opacity: 1;
}
}
}
.node-creator-button { .node-creator-button {
position: fixed; position: fixed;
text-align: center; text-align: center;

View file

@ -1,5 +1,5 @@
import { getStyleTokenValue } from "@/components/helpers"; import { getStyleTokenValue, isNumber } from "@/components/helpers";
import { NODE_OUTPUT_DEFAULT_KEY, START_NODE_TYPE } from "@/constants"; import { NODE_OUTPUT_DEFAULT_KEY, START_NODE_TYPE, STICKY_NODE_TYPE } from "@/constants";
import { IBounds, INodeUi, IZoomConfig, XYPosition } from "@/Interface"; import { IBounds, INodeUi, IZoomConfig, XYPosition } from "@/Interface";
import { Connection, Endpoint, Overlay, OverlaySpec, PaintStyle } from "jsplumb"; import { Connection, Endpoint, Overlay, OverlaySpec, PaintStyle } from "jsplumb";
import { import {
@ -217,17 +217,22 @@ export const getLeftmostTopNode = (nodes: INodeUi[]): INodeUi => {
export const getWorkflowCorners = (nodes: INodeUi[]): IBounds => { export const getWorkflowCorners = (nodes: INodeUi[]): IBounds => {
return nodes.reduce((accu: IBounds, node: INodeUi) => { return nodes.reduce((accu: IBounds, node: INodeUi) => {
if (node.position[0] < accu.minX) { const xOffset = node.type === STICKY_NODE_TYPE && isNumber(node.parameters.width) ? node.parameters.width : NODE_SIZE;
accu.minX = node.position[0]; const yOffset = node.type === STICKY_NODE_TYPE && isNumber(node.parameters.height) ? node.parameters.height : NODE_SIZE;
const x = node.position[0];
const y = node.position[1];
if (x < accu.minX) {
accu.minX = x;
} }
if (node.position[1] < accu.minY) { if (y < accu.minY) {
accu.minY = node.position[1]; accu.minY = y;
} }
if (node.position[0] > accu.maxX) { if ((x + xOffset) > accu.maxX) {
accu.maxX = node.position[0]; accu.maxX = x + xOffset;
} }
if (node.position[1] > accu.maxY) { if ((y + yOffset) > accu.maxY) {
accu.maxY = node.position[1]; accu.maxY = y + yOffset;
} }
return accu; return accu;
@ -592,6 +597,7 @@ export const getZoomToFit = (nodes: INodeUi[], addComponentPadding = true): {off
const {minX, minY, maxX, maxY} = getWorkflowCorners(nodes); const {minX, minY, maxX, maxY} = getWorkflowCorners(nodes);
const sidebarWidth = addComponentPadding? SIDEBAR_WIDTH: 0; const sidebarWidth = addComponentPadding? SIDEBAR_WIDTH: 0;
const headerHeight = addComponentPadding? HEADER_HEIGHT: 0; const headerHeight = addComponentPadding? HEADER_HEIGHT: 0;
const footerHeight = addComponentPadding? 200: 100;
const PADDING = NODE_SIZE * 4; const PADDING = NODE_SIZE * 4;
@ -605,10 +611,10 @@ export const getZoomToFit = (nodes: INodeUi[], addComponentPadding = true): {off
const zoomLevel = Math.min(scaleX, scaleY, 1); const zoomLevel = Math.min(scaleX, scaleY, 1);
let xOffset = (minX * -1) * zoomLevel + sidebarWidth; // find top right corner let xOffset = (minX * -1) * zoomLevel + sidebarWidth; // find top right corner
xOffset += (editorWidth - sidebarWidth - (maxX - minX + NODE_SIZE) * zoomLevel) / 2; // add padding to center workflow xOffset += (editorWidth - sidebarWidth - (maxX - minX) * zoomLevel) / 2; // add padding to center workflow
let yOffset = (minY * -1) * zoomLevel + headerHeight; // find top right corner let yOffset = (minY * -1) * zoomLevel + headerHeight; // find top right corner
yOffset += (editorHeight - headerHeight - (maxY - minY + NODE_SIZE * 2) * zoomLevel) / 2; // add padding to center workflow yOffset += (editorHeight - headerHeight - (maxY - minY + footerHeight) * zoomLevel) / 2; // add padding to center workflow
return { return {
zoomLevel, zoomLevel,

View file

@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.stickyNote",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": [
"Core Nodes"
],
"alias": [
"Comments",
"Notes",
"Sticky"
],
"subcategories": {
"Core Nodes": [
"Helpers"
]
}
}

View file

@ -0,0 +1,52 @@
import { IExecuteFunctions } from 'n8n-core';
import {
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
export class StickyNote implements INodeType {
description: INodeTypeDescription = {
displayName: 'Sticky Note',
name: 'stickyNote',
icon: 'fa:sticky-note',
group: ['input'],
version: 1,
description: 'Make your workflow easier to understand',
defaults: {
name: 'Note',
color: '#FFD233',
},
inputs: [],
outputs: [],
properties: [
{
displayName: 'Content',
name: 'content',
type: 'string',
required: true,
default: `## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)`,
},
{
displayName: 'height',
name: 'height',
type: 'number',
required: true,
default: 160,
},
{
displayName: 'width',
name: 'width',
type: 'number',
required: true,
default: 240,
},
],
};
execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
return this.prepareOutputData(items);
}
}

View file

@ -551,6 +551,7 @@
"dist/nodes/Netlify/NetlifyTrigger.node.js", "dist/nodes/Netlify/NetlifyTrigger.node.js",
"dist/nodes/NextCloud/NextCloud.node.js", "dist/nodes/NextCloud/NextCloud.node.js",
"dist/nodes/NocoDB/NocoDB.node.js", "dist/nodes/NocoDB/NocoDB.node.js",
"dist/nodes/StickyNote/StickyNote.node.js",
"dist/nodes/NoOp/NoOp.node.js", "dist/nodes/NoOp/NoOp.node.js",
"dist/nodes/Onfleet/Onfleet.node.js", "dist/nodes/Onfleet/Onfleet.node.js",
"dist/nodes/Onfleet/OnfleetTrigger.node.js", "dist/nodes/Onfleet/OnfleetTrigger.node.js",

View file

@ -1410,12 +1410,24 @@ export interface INodesGraph {
node_types: string[]; node_types: string[];
node_connections: IDataObject[]; node_connections: IDataObject[];
nodes: INodesGraphNode; nodes: INodesGraphNode;
notes: INotesGraphNode;
} }
export interface INodesGraphNode { export interface INodesGraphNode {
[key: string]: INodeGraphItem; [key: string]: INodeGraphItem;
} }
export interface INotesGraphNode {
[key: string]: INoteGraphItem;
}
export interface INoteGraphItem {
overlapping: boolean;
position: [number, number];
height: number;
width: number;
}
export interface INodeGraphItem { export interface INodeGraphItem {
type: string; type: string;
resource?: string; resource?: string;

View file

@ -9,13 +9,57 @@ import {
IWorkflowBase, IWorkflowBase,
INodeTypes, INodeTypes,
} from '.'; } from '.';
import { INodeType } from './Interfaces';
import { getInstance as getLoggerInstance } from './LoggerProxy'; import { getInstance as getLoggerInstance } from './LoggerProxy';
const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote';
export function getNodeTypeForName(workflow: IWorkflowBase, nodeName: string): INode | undefined { export function getNodeTypeForName(workflow: IWorkflowBase, nodeName: string): INode | undefined {
return workflow.nodes.find((node) => node.name === nodeName); return workflow.nodes.find((node) => node.name === nodeName);
} }
export function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
function getStickyDimensions(note: INode, stickyType: INodeType | undefined) {
const heightProperty = stickyType?.description.properties.find(
(property) => property.name === 'height',
);
const widthProperty = stickyType?.description.properties.find(
(property) => property.name === 'width',
);
const defaultHeight =
heightProperty && isNumber(heightProperty?.default) ? heightProperty.default : 0;
const defaultWidth =
widthProperty && isNumber(widthProperty?.default) ? widthProperty.default : 0;
const height: number = isNumber(note.parameters.height) ? note.parameters.height : defaultHeight;
const width: number = isNumber(note.parameters.width) ? note.parameters.width : defaultWidth;
return {
height,
width,
};
}
type XYPosition = [number, number];
function areOverlapping(
topLeft: XYPosition,
bottomRight: XYPosition,
targetPos: XYPosition,
): boolean {
return (
targetPos[0] > topLeft[0] &&
targetPos[1] > topLeft[1] &&
targetPos[0] < bottomRight[0] &&
targetPos[1] < bottomRight[1]
);
}
export function generateNodesGraph( export function generateNodesGraph(
workflow: IWorkflowBase, workflow: IWorkflowBase,
nodeTypes: INodeTypes, nodeTypes: INodeTypes,
@ -24,11 +68,32 @@ export function generateNodesGraph(
node_types: [], node_types: [],
node_connections: [], node_connections: [],
nodes: {}, nodes: {},
notes: {},
}; };
const nodeNameAndIndex: INodeNameIndex = {}; const nodeNameAndIndex: INodeNameIndex = {};
try { try {
workflow.nodes.forEach((node: INode, index: number) => { const notes = workflow.nodes.filter((node) => node.type === STICKY_NODE_TYPE);
const otherNodes = workflow.nodes.filter((node) => node.type !== STICKY_NODE_TYPE);
notes.forEach((stickyNote: INode, index: number) => {
const stickyType = nodeTypes.getByNameAndVersion(STICKY_NODE_TYPE, stickyNote.typeVersion);
const { height, width } = getStickyDimensions(stickyNote, stickyType);
const topLeft = stickyNote.position;
const bottomRight: [number, number] = [topLeft[0] + width, topLeft[1] + height];
const overlapping = Boolean(
otherNodes.find((node) => areOverlapping(topLeft, bottomRight, node.position)),
);
nodesGraph.notes[index] = {
overlapping,
position: topLeft,
height,
width,
};
});
otherNodes.forEach((node: INode, index: number) => {
nodesGraph.node_types.push(node.type); nodesGraph.node_types.push(node.type);
const nodeItem: INodeGraphItem = { const nodeItem: INodeGraphItem = {
type: node.type, type: node.type,