perf: 优化并重构工作流幕布UI表现
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import {getBezierPath, useConnection} from '@xyflow/svelte';
|
||||
import FlowLinePath from './FlowLinePath.svelte';
|
||||
import FlowMarkerDefs from './FlowMarkerDefs.svelte';
|
||||
|
||||
const connection = useConnection();
|
||||
const markerId = 'tf-flow-connection-arrow-closed';
|
||||
|
||||
const path = $derived.by(() => {
|
||||
const current = connection.current;
|
||||
if (!current.inProgress) {
|
||||
return '';
|
||||
}
|
||||
const [linePath] = getBezierPath({
|
||||
sourceX: current.from.x,
|
||||
sourceY: current.from.y,
|
||||
sourcePosition: current.fromPosition,
|
||||
targetX: current.to.x,
|
||||
targetY: current.to.y,
|
||||
targetPosition: current.toPosition
|
||||
});
|
||||
return linePath;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if connection.current.inProgress && path}
|
||||
<FlowMarkerDefs id={markerId} />
|
||||
<FlowLinePath
|
||||
class="svelte-flow__connection-path"
|
||||
{path}
|
||||
markerEnd={`url(#${markerId})`}
|
||||
animated={false}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import {EdgeLabel, type EdgeProps, getBezierPath} from '@xyflow/svelte';
|
||||
import FlowLinePath from './FlowLinePath.svelte';
|
||||
|
||||
let {
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
markerStart,
|
||||
markerEnd,
|
||||
interactionWidth = 20,
|
||||
label,
|
||||
labelStyle
|
||||
}: EdgeProps = $props();
|
||||
|
||||
const bezierPathResult = $derived.by(() =>
|
||||
getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition
|
||||
})
|
||||
);
|
||||
|
||||
const path = $derived(bezierPathResult[0]);
|
||||
const labelX = $derived(bezierPathResult[1]);
|
||||
const labelY = $derived(bezierPathResult[2]);
|
||||
</script>
|
||||
|
||||
<FlowLinePath path={path} {markerStart} {markerEnd} animated={true} />
|
||||
|
||||
{#if interactionWidth > 0}
|
||||
<path
|
||||
d={path}
|
||||
stroke-opacity={0}
|
||||
stroke-width={interactionWidth}
|
||||
fill="none"
|
||||
class="svelte-flow__edge-interaction"
|
||||
></path>
|
||||
{/if}
|
||||
|
||||
{#if label}
|
||||
<EdgeLabel x={labelX} y={labelY} style={labelStyle} selectEdgeOnClick>
|
||||
{label}
|
||||
</EdgeLabel>
|
||||
{/if}
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type {ClassValue} from 'svelte/elements';
|
||||
|
||||
let {
|
||||
path = '',
|
||||
markerStart,
|
||||
markerEnd,
|
||||
animated = true,
|
||||
class: className = ''
|
||||
}: {
|
||||
path?: string;
|
||||
markerStart?: string;
|
||||
markerEnd?: string;
|
||||
animated?: boolean;
|
||||
class?: ClassValue;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
marker-start={markerStart}
|
||||
marker-end={markerEnd}
|
||||
class={[
|
||||
'svelte-flow__edge-path',
|
||||
'tf-flow-line-path',
|
||||
animated && 'tf-flow-line-path--animated',
|
||||
className
|
||||
]}
|
||||
></path>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
id,
|
||||
markerWidth = 20,
|
||||
markerHeight = 20,
|
||||
markerUnits = 'strokeWidth'
|
||||
}: {
|
||||
id: string;
|
||||
markerWidth?: number;
|
||||
markerHeight?: number;
|
||||
markerUnits?: 'strokeWidth' | 'userSpaceOnUse';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<defs>
|
||||
<marker
|
||||
class="svelte-flow__arrowhead"
|
||||
{id}
|
||||
markerWidth={`${markerWidth}`}
|
||||
markerHeight={`${markerHeight}`}
|
||||
viewBox="-10 -10 20 20"
|
||||
orient="auto-start-reverse"
|
||||
{markerUnits}
|
||||
refX="0"
|
||||
refY="0"
|
||||
>
|
||||
<polyline
|
||||
class="arrowclosed tf-flow-line-arrow-closed"
|
||||
points="-5,-4 0,0 -5,4 -5,-4"
|
||||
></polyline>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<style>
|
||||
.tf-flow-line-arrow-closed {
|
||||
stroke: var(--xy-edge-stroke);
|
||||
fill: var(--xy-edge-stroke);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,345 @@
|
||||
<script lang="ts">
|
||||
import type {NodePaletteItem} from '../utils/nodePalette';
|
||||
|
||||
const { nodes = [], onSelect, placeholder = '搜索节点、插件、工作流' }: {
|
||||
nodes: NodePaletteItem[];
|
||||
onSelect?: (node: NodePaletteItem) => void;
|
||||
placeholder?: string;
|
||||
} = $props();
|
||||
|
||||
let keyword = $state('');
|
||||
type HighlightPart = { text: string; hit: boolean };
|
||||
|
||||
const PINYIN_CHAR_MAP: Record<string, string> = {
|
||||
大: 'da', 模: 'mo', 型: 'xing',
|
||||
开: 'kai', 始: 'shi', 节: 'jie', 点: 'dian',
|
||||
循: 'xun', 环: 'huan',
|
||||
条: 'tiao', 件: 'jian', 判: 'pan', 断: 'duan',
|
||||
知: 'zhi', 识: 'shi', 库: 'ku',
|
||||
搜: 'sou', 索: 'suo', 引: 'yin', 擎: 'qing',
|
||||
请: 'qing', 求: 'qiu',
|
||||
动: 'dong', 态: 'tai', 代: 'dai', 码: 'ma',
|
||||
内: 'nei', 容: 'rong', 板: 'ban',
|
||||
用: 'yong', 户: 'hu', 确: 'que', 认: 'ren',
|
||||
结: 'jie', 束: 'shu',
|
||||
文: 'wen', 档: 'dang', 提: 'ti', 取: 'qu',
|
||||
生: 'sheng', 成: 'cheng',
|
||||
查: 'cha', 询: 'xun', 数: 'shu', 据: 'ju',
|
||||
插: 'cha', 子: 'zi',
|
||||
流: 'liu', 程: 'cheng',
|
||||
素: 'su', 材: 'cai', 同: 'tong', 步: 'bu',
|
||||
保: 'bao', 存: 'cun',
|
||||
基: 'ji', 础: 'chu',
|
||||
扩: 'kuo', 展: 'zhan', 工: 'gong', 具: 'ju',
|
||||
与: 'yu', 输: 'shu', 入: 'ru',
|
||||
其: 'qi', 他: 'ta'
|
||||
};
|
||||
|
||||
const categoryOrder = ['模型与流程', '逻辑', '输入输出', '数据与集成', '基础节点', '扩展工具', '其他'];
|
||||
|
||||
const normalizedKeyword = $derived(keyword.trim().toLowerCase());
|
||||
const compactKeyword = $derived(normalizeForMatch(keyword));
|
||||
|
||||
function normalizeForMatch(text: string) {
|
||||
return text.toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function toPinyinToken(text: string) {
|
||||
let full = '';
|
||||
let initials = '';
|
||||
for (const char of text) {
|
||||
const py = PINYIN_CHAR_MAP[char];
|
||||
if (py) {
|
||||
full += py;
|
||||
initials += py[0];
|
||||
continue;
|
||||
}
|
||||
const code = char.codePointAt(0) || 0;
|
||||
const isAsciiDigit = code >= 48 && code <= 57;
|
||||
const isAsciiUpper = code >= 65 && code <= 90;
|
||||
const isAsciiLower = code >= 97 && code <= 122;
|
||||
if (isAsciiDigit || isAsciiUpper || isAsciiLower) {
|
||||
const lowerChar = char.toLowerCase();
|
||||
full += lowerChar;
|
||||
initials += lowerChar;
|
||||
}
|
||||
}
|
||||
return { full, initials };
|
||||
}
|
||||
|
||||
function isNodeMatched(item: NodePaletteItem) {
|
||||
if (!compactKeyword) {
|
||||
return true;
|
||||
}
|
||||
const directHaystack = normalizeForMatch(`${item.title} ${item.type} ${item.description || ''}`);
|
||||
if (directHaystack.includes(compactKeyword)) {
|
||||
return true;
|
||||
}
|
||||
const pinyinToken = toPinyinToken(item.title);
|
||||
return pinyinToken.full.includes(compactKeyword) || pinyinToken.initials.includes(compactKeyword);
|
||||
}
|
||||
|
||||
function getHighlightParts(title: string): HighlightPart[] {
|
||||
if (!normalizedKeyword) {
|
||||
return [{ text: title, hit: false }];
|
||||
}
|
||||
const source = title.toLowerCase();
|
||||
const index = source.indexOf(normalizedKeyword);
|
||||
if (index < 0) {
|
||||
return [{ text: title, hit: false }];
|
||||
}
|
||||
const head = title.slice(0, index);
|
||||
const middle = title.slice(index, index + normalizedKeyword.length);
|
||||
const tail = title.slice(index + normalizedKeyword.length);
|
||||
const parts: HighlightPart[] = [];
|
||||
if (head) {
|
||||
parts.push({ text: head, hit: false });
|
||||
}
|
||||
if (middle) {
|
||||
parts.push({ text: middle, hit: true });
|
||||
}
|
||||
if (tail) {
|
||||
parts.push({ text: tail, hit: false });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
const filteredNodes = $derived.by(() => {
|
||||
if (!compactKeyword) {
|
||||
return nodes;
|
||||
}
|
||||
return nodes.filter((item) => isNodeMatched(item));
|
||||
});
|
||||
|
||||
const groupedNodes = $derived.by(() => {
|
||||
const map = new Map<string, NodePaletteItem[]>();
|
||||
for (const item of filteredNodes) {
|
||||
const category = item.category || '其他';
|
||||
if (!map.has(category)) {
|
||||
map.set(category, []);
|
||||
}
|
||||
map.get(category)?.push(item);
|
||||
}
|
||||
return [...map.entries()].sort((a, b) => {
|
||||
const ai = categoryOrder.indexOf(a[0]);
|
||||
const bi = categoryOrder.indexOf(b[0]);
|
||||
if (ai === -1 && bi === -1) {
|
||||
return a[0].localeCompare(b[0]);
|
||||
}
|
||||
if (ai === -1) {
|
||||
return 1;
|
||||
}
|
||||
if (bi === -1) {
|
||||
return -1;
|
||||
}
|
||||
return ai - bi;
|
||||
});
|
||||
});
|
||||
|
||||
function onDragStart(event: DragEvent, node: NodePaletteItem) {
|
||||
if (!event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
type: node.type,
|
||||
data: {
|
||||
title: node.title,
|
||||
description: node.description,
|
||||
...(node.extra || {})
|
||||
}
|
||||
};
|
||||
event.dataTransfer.setData('application/tinyflow', JSON.stringify(payload));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function handleSelect(node: NodePaletteItem) {
|
||||
onSelect?.(node);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tf-node-picker">
|
||||
<div class="tf-node-picker-search">
|
||||
<span class="tf-node-picker-search-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="tf-node-picker-search-input"
|
||||
bind:value={keyword}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tf-node-picker-content">
|
||||
{#if groupedNodes.length === 0}
|
||||
<div class="tf-node-picker-empty">暂无可用节点</div>
|
||||
{:else}
|
||||
{#each groupedNodes as [category, categoryNodes]}
|
||||
<div class="tf-node-picker-group">
|
||||
<div class="tf-node-picker-group-title">{category}</div>
|
||||
<div class="tf-node-picker-grid">
|
||||
{#each categoryNodes as node}
|
||||
<button
|
||||
type="button"
|
||||
class="tf-node-picker-item nopan nodrag"
|
||||
draggable={true}
|
||||
ondragstart={(event) => onDragStart(event, node)}
|
||||
onclick={() => handleSelect(node)}
|
||||
>
|
||||
<span class="tf-node-picker-item-icon">
|
||||
{#if node.icon}
|
||||
{@html node.icon}
|
||||
{:else}
|
||||
<span class="tf-node-picker-item-icon-fallback">N</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="tf-node-picker-item-title">
|
||||
{#each getHighlightParts(node.title) as part}
|
||||
<span class:tf-node-picker-item-title-hit={part.hit}>{part.text}</span>
|
||||
{/each}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="less">
|
||||
.tf-node-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tf-node-picker-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--tf-border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--tf-bg-surface);
|
||||
padding: 0 10px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.tf-node-picker-search-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--tf-text-muted);
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.tf-node-picker-search-input {
|
||||
flex: 1;
|
||||
margin-left: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
color: var(--tf-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tf-node-picker-content {
|
||||
max-height: min(300px, 38vh);
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.tf-node-picker-empty {
|
||||
padding: 18px 4px;
|
||||
font-size: 14px;
|
||||
color: var(--tf-text-muted);
|
||||
}
|
||||
|
||||
.tf-node-picker-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tf-node-picker-group-title {
|
||||
font-size: 13px;
|
||||
color: var(--tf-text-secondary);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tf-node-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.tf-node-picker-item {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--tf-text-primary);
|
||||
}
|
||||
|
||||
.tf-node-picker-item:hover {
|
||||
background: var(--tf-bg-hover);
|
||||
}
|
||||
|
||||
.tf-node-picker-item:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.tf-node-picker-item-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--tf-primary-color);
|
||||
}
|
||||
|
||||
.tf-node-picker-item-icon :global(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.tf-node-picker-item-icon-fallback {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--tf-bg-hover);
|
||||
color: var(--tf-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tf-node-picker-item-title {
|
||||
font-size: 13px;
|
||||
color: var(--tf-text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tf-node-picker-item-title-hit {
|
||||
color: var(--tf-primary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user