Browse Source

feat: 添加便签节点,markdown组件

jiaxing.liao 3 weeks ago
parent
commit
55630dd936

+ 24 - 4
apps/web/src/views/Editor.vue

@@ -34,8 +34,6 @@ const workflow = ref<IWorkflow>({
 			id: 'node-1',
 			type: 'canvas-node',
 			position: { x: 100, y: 100 },
-			width: 96,
-			height: 96,
 			data: {
 				version: ['1.0.0'],
 				displayName: '用户输入',
@@ -55,8 +53,6 @@ const workflow = ref<IWorkflow>({
 		{
 			id: 'node-2',
 			type: 'canvas-node',
-			width: 96,
-			height: 96,
 			position: { x: 400, y: 100 },
 			data: {
 				version: ['1.0.0'],
@@ -119,6 +115,30 @@ const workflow = ref<IWorkflow>({
 				],
 				outputNames: ['true', 'false']
 			}
+		},
+		{
+			id: 'node-note',
+			type: 'canvas-node',
+			width: 96,
+			height: 96,
+			position: { x: 600, y: 300 },
+			data: {
+				version: ['1.0.0'],
+				displayName: '条件判断',
+				name: 'if',
+				description: '通过条件判断拆分多个流程分支',
+				icon: 'roentgen:guidepost',
+				iconColor: '#108e49',
+				inputs: [],
+				outputs: [],
+				// 便签数据
+				renderType: 'stickyNote',
+				content:
+					'# 标题\n\n这是一些便签内容,可以使用 **Markdown** 语法进行格式化。\n\n- 列表项 1\n- 列表项 2\n\n[链接](https://example.com)',
+				width: 400,
+				height: 200,
+				color: '#d6f5e3'
+			}
 		}
 	],
 	edges: [

+ 410 - 0
packages/ui/components/markdown/Markdown.vue

@@ -0,0 +1,410 @@
+<script lang="ts" setup>
+import type { Options as MarkdownOptions } from 'markdown-it'
+import Markdown from 'markdown-it'
+import { full as emoji } from 'markdown-it-emoji'
+import markdownLink from 'markdown-it-link-attributes'
+import markdownTaskLists from 'markdown-it-task-lists'
+import { computed, ref } from 'vue'
+import xss, { whiteList } from 'xss'
+
+import { toggleCheckbox, serializeAttr } from './utils'
+
+interface IImage {
+	id: string | number
+	url: string
+}
+
+interface Options {
+	markdown: MarkdownOptions
+	linkAttributes: markdownLink.Config
+	tasklists: markdownTaskLists.Config
+}
+
+interface MarkdownProps {
+	content?: string | null
+	withMultiBreaks?: boolean
+	images?: IImage[]
+	loading?: boolean
+	loadingBlocks?: number
+	loadingRows?: number
+	theme?: string
+	options?: Options
+}
+
+const props = withDefaults(defineProps<MarkdownProps>(), {
+	content: '',
+	withMultiBreaks: false,
+	images: () => [],
+	loading: false,
+	loadingBlocks: 2,
+	loadingRows: 3,
+	theme: 'markdown',
+	options: () => ({
+		markdown: {
+			html: false,
+			linkify: true,
+			typographer: true,
+			breaks: true
+		},
+		linkAttributes: {
+			attrs: {
+				target: '_blank',
+				rel: 'noopener'
+			}
+		},
+		tasklists: {
+			enabled: true,
+			label: true,
+			labelAfter: false
+		}
+	})
+})
+
+const editor = ref<HTMLDivElement | undefined>(undefined)
+
+const { options } = props
+const md = new Markdown(options.markdown)
+	.use(markdownLink, options.linkAttributes)
+	.use(emoji)
+	.use(markdownTaskLists, options.tasklists)
+
+const xssWhiteList = {
+	...whiteList,
+	label: ['class', 'for'],
+	iframe: ['width', 'height', 'src', 'title', 'frameborder', 'allow', 'referrerpolicy']
+}
+
+const htmlContent = computed(() => {
+	if (!props.content) {
+		return ''
+	}
+
+	const imageUrls: { [key: string]: string } = {}
+	if (props.images) {
+		props.images.forEach((image: IImage) => {
+			if (!image) {
+				// Happens if an image got deleted but the workflow
+				// still has a reference to it
+				return
+			}
+			imageUrls[image.id] = image.url
+		})
+	}
+
+	const fileIdRegex = new RegExp('fileId:([0-9]+)')
+	let contentToRender = props.content
+	if (props.withMultiBreaks) {
+		contentToRender = contentToRender.replaceAll('\n\n', '\n&nbsp;\n')
+	}
+	const html = md.render(contentToRender)
+
+	const safeHtml = xss(html, {
+		onTagAttr(tag, name, value) {
+			if (tag === 'img' && name === 'src') {
+				if (value.match(fileIdRegex)) {
+					const id = value.split('fileId:')[1]
+					const imageUrl = imageUrls[id]
+					if (!imageUrl) {
+						return ''
+					}
+					return serializeAttr(tag, name, imageUrl)
+				}
+				// Only allow http requests to supported image files from the `static` directory
+				const isImageFile = value.split('#')[0].match(/\.(jpeg|jpg|gif|png|webp)$/) !== null
+				const isStaticImageFile = isImageFile && value.startsWith('/static/')
+				if (!value.startsWith('https://') && !isStaticImageFile) {
+					return ''
+				}
+			}
+			// Return nothing, means keep the default handling measure
+			return
+		},
+		onTag(tag, code) {
+			if (tag === 'img' && code.includes('alt="workflow-screenshot"')) {
+				return ''
+			}
+			// return nothing, keep tag
+			return
+		},
+		onIgnoreTag(tag, tagHTML) {
+			// Allow checkboxes
+			if (tag === 'input' && tagHTML.includes('type="checkbox"')) {
+				return tagHTML
+			}
+			return
+		},
+		whiteList: xssWhiteList
+	})
+
+	return safeHtml
+})
+
+const emit = defineEmits<{
+	'markdown-click': [link: HTMLAnchorElement, e: MouseEvent]
+	'update-content': [content: string]
+}>()
+
+const onClick = (event: MouseEvent) => {
+	let clickedLink: HTMLAnchorElement | null = null
+
+	if (event.target instanceof HTMLAnchorElement) {
+		clickedLink = event.target
+	}
+
+	if (event.target instanceof HTMLElement && event.target.matches('a *')) {
+		const parentLink = event.target.closest('a')
+		if (parentLink) {
+			clickedLink = parentLink
+		}
+	}
+	if (clickedLink) {
+		emit('markdown-click', clickedLink, event)
+	}
+}
+
+// Handle checkbox changes
+const onChange = async (event: Event) => {
+	if (event.target instanceof HTMLInputElement && event.target.type === 'checkbox') {
+		const checkboxes = editor.value?.querySelectorAll('input[type="checkbox"]')
+		if (checkboxes) {
+			// Get the index of the checkbox that was clicked
+			const index = Array.from(checkboxes).indexOf(event.target)
+			if (index !== -1) {
+				onCheckboxChange(index)
+			}
+		}
+	}
+}
+
+const onMouseDown = (event: MouseEvent) => {
+	// Mouse down on input fields is caught by node view handlers
+	// which prevents checking them, this will prevent that
+	if (event.target instanceof HTMLInputElement) {
+		event.stopPropagation()
+	}
+}
+
+// Update markdown when checkbox state changes
+const onCheckboxChange = (index: number) => {
+	const currentContent = props.content
+	if (!currentContent) {
+		return
+	}
+
+	// We are using index to connect the checkbox with the corresponding line in the markdown
+	const newContent = toggleCheckbox(currentContent, index)
+	emit('update-content', newContent)
+}
+</script>
+
+<template>
+	<div class="markdown">
+		<!-- eslint-disable vue/no-v-html -->
+		<div
+			v-if="!loading"
+			ref="editor"
+			:class="$style[theme]"
+			@click="onClick"
+			@mousedown="onMouseDown"
+			@change="onChange"
+			v-html="htmlContent"
+		/>
+		<!-- eslint-enable vue/no-v-html -->
+		<div v-else :class="$style.markdown">
+			<div v-for="(_, index) in loadingBlocks" :key="index" v-loading="loading">
+				<div :class="$style.spacer" />
+			</div>
+		</div>
+	</div>
+</template>
+
+<style lang="less" module>
+.markdown {
+	color: var(--color--text);
+
+	* {
+		font-size: var(--font-size--md);
+		line-height: var(--line-height--xl);
+	}
+
+	h1,
+	h2,
+	h3,
+	h4 {
+		margin-bottom: var(--spacing--sm);
+		font-size: var(--font-size--md);
+		font-weight: var(--font-weight--bold);
+	}
+
+	h3,
+	h4 {
+		font-weight: var(--font-weight--bold);
+	}
+
+	p,
+	span {
+		margin-bottom: var(--spacing--sm);
+	}
+
+	ul,
+	ol {
+		margin-bottom: var(--spacing--sm);
+		padding-left: var(--spacing--md);
+
+		li {
+			margin-top: 0.25em;
+		}
+	}
+
+	pre > code {
+		background-color: var(--color--background);
+		color: var(--color--text--shade-1);
+	}
+
+	li > code,
+	p > code {
+		padding: 0 var(--spacing--4xs);
+		color: var(--color--text--shade-1);
+		background-color: var(--color--background);
+	}
+
+	.label {
+		color: var(--color--text);
+	}
+
+	img {
+		max-width: 100%;
+		border-radius: var(--radius--lg);
+	}
+
+	blockquote {
+		padding-left: 10px;
+		font-style: italic;
+		border-left: var(--border-color) 2px solid;
+	}
+}
+
+input[type='checkbox'] {
+	accent-color: var(--color--primary);
+}
+
+input[type='checkbox'] + label {
+	cursor: pointer;
+}
+
+.sticky {
+	color: var(--sticky--color--text);
+	overflow-wrap: break-word;
+
+	h1,
+	h2,
+	h3,
+	h4,
+	h5,
+	h6 {
+		color: var(--sticky--color--text);
+	}
+
+	h1,
+	h2,
+	h3,
+	h4 {
+		margin-bottom: var(--spacing--2xs);
+		font-weight: var(--font-weight--bold);
+		line-height: var(--line-height--lg);
+	}
+
+	h1 {
+		font-size: 36px;
+	}
+
+	h2 {
+		font-size: 24px;
+	}
+
+	h3,
+	h4,
+	h5,
+	h6 {
+		font-size: var(--font-size--md);
+	}
+
+	p {
+		margin-bottom: var(--spacing--2xs);
+		font-size: var(--font-size--sm);
+		font-weight: var(--font-weight--regular);
+		line-height: var(--line-height--lg);
+	}
+
+	ul,
+	ol {
+		margin-bottom: var(--spacing--2xs);
+		padding-left: var(--spacing--md);
+
+		li {
+			margin-top: 0.25em;
+			font-size: var(--font-size--sm);
+			font-weight: var(--font-weight--regular);
+			line-height: var(--line-height--md);
+		}
+
+		&:has(input[type='checkbox']) {
+			list-style-type: none;
+			padding-left: var(--spacing--5xs);
+		}
+	}
+
+	pre > code {
+		background-color: var(--sticky--code--color--background);
+		color: var(--sticky--code--color--text);
+	}
+
+	pre > code,
+	li > code,
+	p > code {
+		color: var(--sticky--code--color--text);
+	}
+
+	a {
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
+	img {
+		object-fit: contain;
+		margin-top: var(--spacing--xs);
+		margin-bottom: var(--spacing--2xs);
+
+		&[src*='#full-width'] {
+			width: 100%;
+		}
+	}
+}
+
+.sticky,
+.markdown {
+	pre {
+		margin-bottom: var(--spacing--sm);
+		display: grid;
+	}
+
+	pre > code {
+		display: block;
+		padding: var(--spacing--sm);
+		overflow-x: auto;
+	}
+
+	iframe {
+		aspect-ratio: 16/9 auto;
+	}
+
+	summary {
+		cursor: pointer;
+	}
+}
+
+.spacer {
+	margin: var(--spacing--2xl);
+}
+</style>

+ 39 - 0
packages/ui/components/markdown/utils.ts

@@ -0,0 +1,39 @@
+import { safeAttrValue } from 'xss';
+
+const checkedRegEx = /(\*|-) \[x\]/;
+const uncheckedRegEx = /(\*|-) \[\s\]/;
+
+/**
+ * Toggles the checkbox at the specified index in the given markdown string.
+ *
+ * @param markdown - The markdown string containing checkboxes.
+ * @param index - The index of the checkbox to toggle.
+ * @returns The updated markdown string with the checkbox toggled.
+ */
+export const toggleCheckbox = (markdown: string, index: number) => {
+	let cursor = 0;
+	const lines = markdown.split('\n');
+
+	for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
+		const line = lines[lineNumber];
+		const checked = checkedRegEx.test(line);
+		const unchecked = uncheckedRegEx.test(line);
+
+		if (checked || unchecked) {
+			if (cursor === index) {
+				const regExp = checked ? checkedRegEx : uncheckedRegEx;
+				const replacement = checked ? '[ ]' : '[x]';
+				lines[lineNumber] = line.replace(regExp, `$1 ${replacement}`);
+				break;
+			}
+			cursor++;
+		}
+	}
+
+	return lines.join('\n');
+};
+
+export function serializeAttr(tag: string, name: string, value: string) {
+	const safe = safeAttrValue(tag, name, value, { process: (v) => v });
+	return safe ? `${name}="${safe}"` : '';
+}

+ 161 - 0
packages/ui/components/sticky-note/StickyNote.vue

@@ -0,0 +1,161 @@
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue'
+
+import { defaultStickyProps } from './constants'
+import type { StickyProps } from './types'
+import { ElInput } from 'element-plus'
+import Markdown from '../markdown/Markdown.vue'
+
+const props = withDefaults(defineProps<StickyProps>(), defaultStickyProps)
+
+const emit = defineEmits<{
+	edit: [editing: boolean]
+	'update:modelValue': [value: string]
+	'markdown-click': [link: HTMLAnchorElement, e: MouseEvent]
+}>()
+
+const isResizing = ref(false)
+const input = ref<HTMLTextAreaElement | undefined>(undefined)
+
+const resHeight = computed((): number => {
+	return props.height < props.minHeight ? props.minHeight : props.height
+})
+
+const resWidth = computed((): number => {
+	return props.width < props.minWidth ? props.minWidth : props.width
+})
+
+const inputName = computed(() => (props.id ? `${props.id}-input` : undefined))
+
+const styles = computed((): { height: string; width: string; backgroundColor: string } => ({
+	height: `${resHeight.value}px`,
+	width: `${resWidth.value}px`,
+	backgroundColor: props.backgroundColor
+}))
+
+const shouldShowFooter = computed((): boolean => resHeight.value > 100 && resWidth.value > 155)
+
+watch(
+	() => props.editMode,
+	(newMode, prevMode) => {
+		setTimeout(() => {
+			if (newMode && !prevMode && input.value) {
+				if (props.defaultText === props.modelValue) {
+					input.value.select()
+				}
+				input.value.focus()
+			}
+		}, 100)
+	}
+)
+
+const onDoubleClick = () => {
+	if (!props.readOnly) emit('edit', true)
+}
+
+const onInputBlur = () => {
+	if (!isResizing.value) emit('edit', false)
+}
+
+const onUpdateModelValue = (value: string) => {
+	emit('update:modelValue', value)
+}
+
+const onMarkdownClick = (link: HTMLAnchorElement, event: MouseEvent) => {
+	emit('markdown-click', link, event)
+}
+
+const onInputScroll = (event: WheelEvent) => {
+	// Pass through zoom events but hold regular scrolling
+	if (!event.ctrlKey && !event.metaKey) {
+		event.stopPropagation()
+	}
+}
+</script>
+
+<template>
+	<div
+		:class="{
+			'sticky-note': true,
+			[$style.sticky]: true,
+			[$style.clickable]: !isResizing
+		}"
+		:style="styles"
+		@keydown.prevent
+	>
+		<div v-show="!editMode" :class="$style.wrapper" @dblclick.stop="onDoubleClick">
+			<Markdown
+				theme="sticky"
+				:content="modelValue"
+				:with-multi-breaks="true"
+				@markdown-click="onMarkdownClick"
+				@update-content="onUpdateModelValue"
+			/>
+		</div>
+		<div
+			v-show="editMode"
+			:class="{ 'full-height': !shouldShowFooter, 'sticky-textarea': true }"
+			@click.stop
+			@mousedown.stop
+			@mouseup.stop
+			@keydown.esc="onInputBlur"
+			@keydown.stop
+		>
+			<ElInput
+				ref="input"
+				:model-value="modelValue"
+				:name="inputName"
+				type="textarea"
+				:rows="5"
+				@blur="onInputBlur"
+				@update:model-value="onUpdateModelValue"
+				@wheel="onInputScroll"
+			/>
+		</div>
+		<div v-if="editMode && shouldShowFooter" :class="$style.footer">
+			<span>{{ footerText }}</span>
+		</div>
+	</div>
+</template>
+
+<style lang="less" module>
+.sticky {
+	position: relative;
+	border-radius: 4px;
+	overflow: hidden;
+	border: 1px solid #eee;
+}
+
+.clickable {
+	cursor: pointer;
+}
+
+.wrapper {
+	width: 100%;
+	height: 100%;
+	position: absolute;
+	padding: 0.5rem 0.75rem 0;
+	overflow: hidden;
+}
+</style>
+
+<style lang="less">
+.sticky-textarea {
+	height: calc(100% - var(--spacing--lg));
+	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>

+ 10 - 0
packages/ui/components/sticky-note/constants.ts

@@ -0,0 +1,10 @@
+export const defaultStickyProps = {
+	height: 180,
+	width: 240,
+	minHeight: 80,
+	minWidth: 150,
+	id: '0',
+	editMode: false,
+	readOnly: false,
+	backgroundColor: '#d6f5e3'
+}

+ 13 - 0
packages/ui/components/sticky-note/types.ts

@@ -0,0 +1,13 @@
+export interface StickyProps {
+	modelValue?: string
+	height?: number
+	width?: number
+	minHeight?: number
+	minWidth?: number
+	id?: string
+	defaultText?: string
+	editMode?: boolean
+	readOnly?: boolean
+	backgroundColor?: string
+	footerText?: string
+}

+ 3 - 1
packages/ui/index.ts

@@ -1,4 +1,6 @@
 import Icon from './components/icon/Icon.vue'
 import IconButton from './components/icon-button/IconButton.vue'
+import StickyNote from './components/sticky-note/StickyNote.vue'
+import Markdown from './components/markdown/Markdown.vue'
 
-export { Icon, IconButton }
+export { Icon, IconButton, StickyNote, Markdown }

+ 8 - 0
packages/ui/package.json

@@ -5,5 +5,13 @@
   "private": true,
   "exports": {
     ".": "./index.ts"
+  },
+  "dependencies": {
+    "markdown-it": "^14.1.0",
+    "markdown-it-emoji": "^3.0.0",
+    "markdown-it-link-attributes": "^4.0.1",
+    "markdown-it-task-lists": "^2.1.1",
+    "vue": "^3.5.24",
+    "xss": "^1.0.15"
   }
 }

+ 1 - 0
packages/workflow/src/Interface.ts

@@ -24,6 +24,7 @@ export interface IWorkflowNode extends Node {
 	data: {
 		inputs: CanvasConnectionPort[]
 		outputs: CanvasConnectionPort[]
+		renderType?: 'default' | 'stickyNote' | 'custom'
 		// 定义节点数据
 		[key: string]: any
 	}

+ 5 - 1
packages/workflow/src/components/Canvas.vue

@@ -194,6 +194,8 @@ console.log(props.nodes)
 		:edges="edges"
 		:connection-line-options="{ markerEnd: MarkerType.ArrowClosed }"
 		:connection-radius="60"
+		snap-to-grid
+		:snap-grid="[16, 16]"
 		@node-click="onNodeClick"
 		@drop="onDrop"
 		@connect="onConnect"
@@ -261,7 +263,9 @@ console.log(props.nodes)
 			@run="handleRun"
 		/>
 
-		<CanvasBackground :viewport="viewport" :striped="readOnly" />
+		<slot name="canvas-background" v-bind="{ viewport }">
+			<CanvasBackground :viewport="viewport" :striped="readOnly" />
+		</slot>
 
 		<CanvasArrowHeadMarker id="custom-arrow-head-marker" />
 	</VueFlow>

+ 36 - 36
packages/workflow/src/components/elements/CanvasControlBar.vue

@@ -2,68 +2,68 @@
 import { Controls } from '@vue-flow/controls'
 import { Icon } from '@iconify/vue'
 import { ElButton } from 'element-plus'
-import AddNode from './handles/AddNode.vue';
+import AddNode from './handles/AddNode.vue'
 import type { SourceType } from '@repo/nodes'
 const emit = defineEmits<{
-    'reset-zoom': []
-    'zoom-in': []
-    'zoom-out': []
-    'zoom-to-fit': []
-    'tidy-up': []
-    'toggle-zoom-mode': []
-    'add-node': [value: SourceType]
-    'run': []
+	'reset-zoom': []
+	'zoom-in': []
+	'zoom-out': []
+	'zoom-to-fit': []
+	'tidy-up': []
+	'toggle-zoom-mode': []
+	'add-node': [value: SourceType]
+	run: []
 }>()
 
 function onResetZoom() {
-    emit('reset-zoom')
+	emit('reset-zoom')
 }
 
 function onZoomIn() {
-    emit('zoom-in')
+	emit('zoom-in')
 }
 
 function onZoomOut() {
-    emit('zoom-out')
+	emit('zoom-out')
 }
 
 function onZoomToFit() {
-    emit('zoom-to-fit')
+	emit('zoom-to-fit')
 }
 
 function onAddNode(value: SourceType) {
-    emit('add-node', value)
+	emit('add-node', value)
 }
 function onRun() {
-    emit('run')
+	emit('run')
 }
 </script>
 
 <template>
-    <Controls :show-fit-view="false" :show-zoom="false" :show-interactive="false">
-        <div class="flex gap-0px">
-            <ElButton @click="onZoomToFit" square>
-                <Icon icon="lucide:maximize" height="16" width="16" />
-            </ElButton>
-            <ElButton @click="onZoomIn">
-                <Icon icon="lucide:zoom-in" height="16" width="16" />
-            </ElButton>
-            <ElButton @click="onZoomOut">
-                <Icon icon="lucide:zoom-out" height="16" width="16" />
-            </ElButton>
-            <ElButton @click="onResetZoom">
-                <Icon icon="lucide:undo-2" height="16" width="16" />
-            </ElButton>
-            <AddNode @add-node="onAddNode" />
-            <ElButton @click="onRun" type="success">
-                <Icon icon="lucide:play" height="16" width="16" class="mr-1" /> 执行
-            </ElButton>
-        </div>
-    </Controls>
+	<Controls :show-fit-view="false" :show-zoom="false" :show-interactive="false">
+		<div class="flex gap-0px">
+			<ElButton @click="onZoomToFit" square>
+				<Icon icon="lucide:maximize" height="16" width="16" />
+			</ElButton>
+			<ElButton @click="onZoomIn">
+				<Icon icon="lucide:zoom-in" height="16" width="16" />
+			</ElButton>
+			<ElButton @click="onZoomOut">
+				<Icon icon="lucide:zoom-out" height="16" width="16" />
+			</ElButton>
+			<ElButton @click="onResetZoom">
+				<Icon icon="bx:reset" height="16" width="16" />
+			</ElButton>
+			<AddNode @add-node="onAddNode" />
+			<ElButton @click="onRun" type="success">
+				<Icon icon="lucide:play" height="16" width="16" class="mr-1" /> 执行
+			</ElButton>
+		</div>
+	</Controls>
 </template>
 
 <style lang="less">
 .el-button {
-    padding: 8px;
+	padding: 8px;
 }
 </style>

+ 8 - 26
packages/workflow/src/components/elements/nodes/CanvasNode.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed } from 'vue'
+import { computed, provide } from 'vue'
 import { Position } from '@vue-flow/core'
 import type { NodeProps } from '@vue-flow/core'
 import type {
@@ -8,8 +8,8 @@ import type {
 	CanvasElementPortWithRenderData
 } from '../../../Interface'
 
-import { Icon } from '@repo/ui'
 import CanvasHandle from '../handles/CanvasHandle.vue'
+import NodeRenderer from './render-types/NodeRenderer.vue'
 
 type Props = NodeProps<IWorkflowNode['data']> & {
 	readOnly?: boolean
@@ -72,34 +72,16 @@ const outputs = computed(() =>
 	)
 )
 
-const nodeClass = computed(() => {
-	let classes: string[] = []
-	if (props.selected) {
-		classes.push('ring-6px', 'ring-#e0e2e7')
-	}
-	if (inputs.value.length === 0) {
-		classes.push('rounded-l-36px')
-	}
-
-	return classes
+provide('canvas-node-data', {
+	props,
+	inputs,
+	outputs
 })
 </script>
 
 <template>
-	<div
-		class="w-full h-full bg-#fff box-border border-2 border-solid border-#dcdcdc rounded-8px relative"
-		:class="nodeClass"
-	>
-		<div className="w-full h-full relative flex items-center justify-center">
-			<Icon :icon="data?.icon" height="40" width="40" :color="data?.iconColor" />
-		</div>
-
-		<div className="absolute w-full bottom--24px text-12px text-center text-#222">
-			{{ data?.displayName }}
-		</div>
-		<div className="absolute w-full bottom--40px text-12px text-center text-#999 truncate">
-			{{ data.subtitle }}
-		</div>
+	<div class="relative">
+		<NodeRenderer />
 
 		<template v-for="target in inputs" :key="'handle-inputs-port' + target.index">
 			<CanvasHandle v-bind="target" type="target" />

+ 45 - 0
packages/workflow/src/components/elements/nodes/render-types/NodeDefault.vue

@@ -0,0 +1,45 @@
+<template>
+	<div
+		:class="nodeClass"
+		class="default-node w-96px h-96px bg-#fff box-border border-2 border-solid border-#dcdcdc rounded-8px relative"
+	>
+		<div className="w-full h-full relative flex items-center justify-center">
+			<Icon :icon="data?.icon" height="40" width="40" :color="data?.iconColor" />
+		</div>
+
+		<div className="absolute w-full bottom--24px text-12px text-center text-#333">
+			<div>{{ data?.displayName }}</div>
+			<div className="absolute w-full bottom--40px text-12px text-center text-#999 truncate">
+				{{ data?.subtitle }}
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { inject, computed, type Ref } from 'vue'
+import { Icon } from '@repo/ui'
+
+import type { NodeProps } from '@vue-flow/core'
+import type { IWorkflowNode, CanvasConnectionPort } from '../../../../Interface'
+
+const node = inject<{
+	props?: NodeProps<IWorkflowNode['data']>
+	inputs?: Ref<CanvasConnectionPort[]>
+	outputs?: Ref<CanvasConnectionPort[]>
+}>('canvas-node-data')
+
+const nodeClass = computed(() => {
+	let classes: string[] = []
+	if (node?.props?.selected) {
+		classes.push('ring-6px', 'ring-#e0e2e7')
+	}
+	if (node?.inputs?.value?.length === 0) {
+		classes.push('rounded-l-36px')
+	}
+
+	return classes
+})
+
+const data = computed<IWorkflowNode['data'] | undefined>(() => node?.props?.data)
+</script>

+ 21 - 0
packages/workflow/src/components/elements/nodes/render-types/NodeRenderer.vue

@@ -0,0 +1,21 @@
+<template>
+	<NodeStickyNote v-if="renderType === 'stickyNote'" v-bind="$attrs" />
+	<NodeDefault v-else v-bind="$attrs"> </NodeDefault>
+</template>
+
+<script setup lang="ts">
+import { inject, computed, type Ref } from 'vue'
+import NodeDefault from './NodeDefault.vue'
+import NodeStickyNote from './NodeStickyNote.vue'
+
+import type { NodeProps } from '@vue-flow/core'
+import type { IWorkflowNode, CanvasConnectionPort } from '../../../../Interface'
+
+const node = inject<{
+	props?: NodeProps<IWorkflowNode['data']>
+	inputs?: Ref<CanvasConnectionPort[]>
+	outputs?: Ref<CanvasConnectionPort[]>
+}>('canvas-node-data')
+
+const renderType = computed(() => node?.props?.data?.renderType)
+</script>

+ 59 - 0
packages/workflow/src/components/elements/nodes/render-types/NodeStickyNote.vue

@@ -0,0 +1,59 @@
+<template>
+	<div class="sticky-note__node relative">
+		<StickyNote
+			v-model="modelValue"
+			:minHeight="100"
+			:minWidth="120"
+			:width="data?.width"
+			:height="data?.height"
+			:backgroundColor="data?.color"
+			:readOnly="node?.props?.readOnly"
+			:editMode="editMode"
+			@markdown-click="handleSetEditMode"
+		/>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref, inject, computed } from 'vue'
+import { StickyNote } from '@repo/ui'
+
+import type { NodeProps } from '@vue-flow/core'
+import type { IWorkflowNode } from '../../../../Interface'
+
+const node = inject<{
+	props?: NodeProps<
+		IWorkflowNode['data'] & {
+			content?: string
+			width?: number
+			height?: number
+			color?: string
+		}
+	> & {
+		readOnly?: boolean
+		hovered?: boolean
+	}
+}>('canvas-node-data')
+
+const data = computed(() => node?.props?.data)
+
+const modelValue = computed({
+	get() {
+		return data.value?.content
+	},
+	set(value) {
+		if (data.value?.content) {
+			data.value.content = value
+		}
+	}
+})
+
+const editMode = ref(false)
+
+const handleSetEditMode = (_link: HTMLElement, e: MouseEvent) => {
+	e.stopPropagation()
+	if (!node?.props?.readOnly) {
+		editMode.value = true
+	}
+}
+</script>

+ 55 - 1
pnpm-lock.yaml

@@ -265,7 +265,26 @@ importers:
 
   packages/typescript-config: {}
 
-  packages/ui: {}
+  packages/ui:
+    dependencies:
+      markdown-it:
+        specifier: ^14.1.0
+        version: 14.1.0
+      markdown-it-emoji:
+        specifier: ^3.0.0
+        version: 3.0.0
+      markdown-it-link-attributes:
+        specifier: ^4.0.1
+        version: 4.0.1
+      markdown-it-task-lists:
+        specifier: ^2.1.1
+        version: 2.1.1
+      vue:
+        specifier: ^3.5.24
+        version: 3.5.27(typescript@5.9.3)
+      xss:
+        specifier: ^1.0.15
+        version: 1.0.15
 
   packages/workflow:
     dependencies:
@@ -2928,6 +2947,9 @@ packages:
     resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==}
     engines: {node: '>=20'}
 
+  commander@2.20.3:
+    resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
+
   commander@5.1.0:
     resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
     engines: {node: '>= 6'}
@@ -2999,6 +3021,9 @@ packages:
     resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
     engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
 
+  cssfilter@0.0.10:
+    resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==}
+
   csstype@3.2.3:
     resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
 
@@ -4185,6 +4210,15 @@ packages:
     resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
     engines: {node: '>=16'}
 
+  markdown-it-emoji@3.0.0:
+    resolution: {integrity: sha512-+rUD93bXHubA4arpEZO3q80so0qgoFJEKRkRbjKX8RTdca89v2kfyF+xR3i2sQTwql9tpPZPOQN5B+PunspXRg==}
+
+  markdown-it-link-attributes@4.0.1:
+    resolution: {integrity: sha512-pg5OK0jPLg62H4k7M9mRJLT61gUp9nvG0XveKYHMOOluASo9OEF13WlXrpAp2aj35LbedAy3QOCgQCw0tkLKAQ==}
+
+  markdown-it-task-lists@2.1.1:
+    resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
+
   markdown-it@14.1.0:
     resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
     hasBin: true
@@ -5570,6 +5604,11 @@ packages:
   wrappy@1.0.2:
     resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
 
+  xss@1.0.15:
+    resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==}
+    engines: {node: '>= 0.10.0'}
+    hasBin: true
+
   y18n@5.0.8:
     resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
     engines: {node: '>=10'}
@@ -9326,6 +9365,8 @@ snapshots:
 
   commander@14.0.2: {}
 
+  commander@2.20.3: {}
+
   commander@5.1.0: {}
 
   compute-scroll-into-view@1.0.20: {}
@@ -9390,6 +9431,8 @@ snapshots:
       mdn-data: 2.12.2
       source-map-js: 1.2.1
 
+  cssfilter@0.0.10: {}
+
   csstype@3.2.3: {}
 
   d3-color@3.1.0: {}
@@ -10745,6 +10788,12 @@ snapshots:
 
   markdown-extensions@2.0.0: {}
 
+  markdown-it-emoji@3.0.0: {}
+
+  markdown-it-link-attributes@4.0.1: {}
+
+  markdown-it-task-lists@2.1.1: {}
+
   markdown-it@14.1.0:
     dependencies:
       argparse: 2.0.1
@@ -12638,6 +12687,11 @@ snapshots:
 
   wrappy@1.0.2: {}
 
+  xss@1.0.15:
+    dependencies:
+      commander: 2.20.3
+      cssfilter: 0.0.10
+
   y18n@5.0.8: {}
 
   yallist@3.1.1: {}