feat(demo): workflow running style support

This commit is contained in:
xiamidaxia 2025-05-12 15:36:09 +08:00
parent e2e80efb7a
commit e8c60a8443
20 changed files with 327 additions and 51 deletions

View File

@ -8,6 +8,7 @@ import { ZoomSelect } from './zoom-select';
import { SwitchVertical } from './switch-vertical';
import { ToolContainer, ToolSection } from './styles';
import { Save } from './save';
import { Run } from './run';
import { Readonly } from './readonly';
import { MinimapSwitch } from './minimap-switch';
import { Minimap } from './minimap';
@ -50,6 +51,7 @@ export const DemoTools = () => {
/>
</Tooltip>
<Save disabled={playground.config.readonly} />
<Run />
</ToolSection>
</ToolContainer>
);

View File

@ -0,0 +1,108 @@
import { useState } from 'react';
import {
usePlayground,
FlowNodeEntity,
FixedLayoutPluginContext,
useClientContext,
delay,
} from '@flowgram.ai/fixed-layout-editor';
import { Button } from '@douyinfe/semi-ui';
const styleElement = document.createElement('style');
const RUNNING_COLOR = 'rgb(78, 64, 229)';
const RUNNING_INTERVAL = 1000;
function getRunningNodes(targetNode?: FlowNodeEntity | undefined, addChildren?: boolean): string[] {
const result: string[] = [];
if (targetNode) {
result.push(targetNode.id);
if (addChildren) {
result.push(...targetNode.allChildren.map((n) => n.id));
}
if (targetNode.parent) {
result.push(targetNode.parent.id);
}
if (targetNode.pre) {
result.push(...getRunningNodes(targetNode.pre, true));
}
if (targetNode.parent) {
if (targetNode.parent.pre) {
result.push(...getRunningNodes(targetNode.parent.pre, true));
}
}
}
return result;
}
function clear() {
styleElement.innerText = '';
}
function runningNode(ctx: FixedLayoutPluginContext, nodeId: string) {
const nodes = getRunningNodes(ctx.document.getNode(nodeId), true);
if (nodes.length === 0) {
styleElement.innerText = '';
} else {
const content = nodes
.map(
(n) => `
path[data-line-id$="${n}"] {
animation: flowingDash 0.5s linear infinite;
stroke-dasharray: 8, 5;
stroke: ${RUNNING_COLOR} !important;
}
marker[data-line-id$="${n}"] path {
fill: ${RUNNING_COLOR} !important;
}
[data-node-id$="${n}"] {
border: 1px dashed ${RUNNING_COLOR} !important;
border-radius: 8px;
}
[data-label-id$="${n}"] {
color: ${RUNNING_COLOR} !important;
}
`
)
.join('\n');
styleElement.innerText = `
@keyframes flowingDash {
to {
stroke-dashoffset: -13;
}
}
${content}
`;
}
if (!styleElement.parentNode) {
document.body.appendChild(styleElement);
}
}
/**
* Run the simulation and highlight the lines
*/
export function Run() {
const [isRunning, setRunning] = useState(false);
const ctx = useClientContext();
const playground = usePlayground();
const onRun = async () => {
setRunning(true);
playground.config.readonly = true;
const nodes = ctx.document.root.blocks.slice();
while (nodes.length > 0) {
const currentNode = nodes.shift();
runningNode(ctx, currentNode!.id);
await delay(RUNNING_INTERVAL);
}
playground.config.readonly = false;
clear();
setRunning(false);
};
return (
<Button onClick={onRun} loading={isRunning}>
Run
</Button>
);
}

View File

@ -10,6 +10,7 @@ import { ZoomSelect } from './zoom-select';
import { SwitchLine } from './switch-line';
import { ToolContainer, ToolSection } from './styles';
import { Save } from './save';
import { Run } from './run';
import { Readonly } from './readonly';
import { MinimapSwitch } from './minimap-switch';
import { Minimap } from './minimap';
@ -71,6 +72,7 @@ export const DemoTools = () => {
<AddNode disabled={playground.config.readonly} />
<Divider layout="vertical" style={{ height: '16px' }} margin={3} />
<Save disabled={playground.config.readonly} />
<Run />
</ToolSection>
</ToolContainer>
);

View File

@ -0,0 +1,28 @@
import { useState } from 'react';
import { useService } from '@flowgram.ai/free-layout-editor';
import { Button } from '@douyinfe/semi-ui';
import { RunningService } from '../../services';
/**
* Run the simulation and highlight the lines
*/
export function Run() {
const [isRunning, setRunning] = useState(false);
const runningService = useService(RunningService);
const onRun = async () => {
setRunning(true);
await runningService.startRun();
setRunning(false);
};
return (
<Button
onClick={onRun}
loading={isRunning}
style={{ backgroundColor: 'rgba(171,181,255,0.3)', borderRadius: '8px' }}
>
Run
</Button>
);
}

View File

@ -13,7 +13,7 @@ import { createContainerNodePlugin } from '@flowgram.ai/free-container-plugin';
import { onDragLineEnd } from '../utils';
import { FlowNodeRegistry, FlowDocumentJSON } from '../typings';
import { shortcuts } from '../shortcuts';
import { CustomService } from '../services';
import { CustomService, RunningService } from '../services';
import { createSyncVariablePlugin } from '../plugins';
import { defaultFormMeta } from '../nodes/default-form-meta';
import { WorkflowNodeType } from '../nodes';
@ -135,6 +135,10 @@ export function useEditorProps(
onContentChange: debounce((ctx, event) => {
console.log('Auto Save: ', event, ctx.document.toJSON());
}, 1000),
/**
* Running line
*/
isFlowingLine: (ctx, line) => ctx.get(RunningService).isFlowingLine(line),
/**
* Shortcuts
*/
@ -144,6 +148,7 @@ export function useEditorProps(
*/
onBind: ({ bind }) => {
bind(CustomService).toSelf().inSingletonScope();
bind(RunningService).toSelf().inSingletonScope();
},
/**
* Playground init

View File

@ -1 +1,2 @@
export { CustomService } from './custom-service';
export { RunningService } from './running-service';

View File

@ -0,0 +1,47 @@
import {
injectable,
inject,
WorkflowDocument,
Playground,
delay,
WorkflowLineEntity,
WorkflowNodeEntity,
WorkflowNodeLinesData,
} from '@flowgram.ai/free-layout-editor';
const RUNNING_INTERVAL = 1000;
@injectable()
export class RunningService {
@inject(Playground) playground: Playground;
@inject(WorkflowDocument) document: WorkflowDocument;
private _runningNodes: WorkflowNodeEntity[] = [];
async addRunningNode(node: WorkflowNodeEntity): Promise<void> {
this._runningNodes.push(node);
node.renderData.node.classList.add('node-running');
this.document.linesManager.forceUpdate(); // Refresh line renderer
await delay(RUNNING_INTERVAL);
// Child Nodes
await Promise.all(node.blocks.map((nextNode) => this.addRunningNode(nextNode)));
// Sibling Nodes
const nextNodes = node.getData(WorkflowNodeLinesData).outputNodes;
await Promise.all(nextNodes.map((nextNode) => this.addRunningNode(nextNode)));
}
async startRun(): Promise<void> {
await this.addRunningNode(this.document.getNode('start_0')!);
this._runningNodes.forEach((node) => {
node.renderData.node.classList.remove('node-running');
});
this._runningNodes = [];
this.document.linesManager.forceUpdate();
}
isFlowingLine(line: WorkflowLineEntity) {
return this._runningNodes.some((node) =>
node.getData(WorkflowNodeLinesData).outputLines.includes(line)
);
}
}

View File

@ -15,6 +15,16 @@
background-color: var(--g-playground-selectBox-background);
}
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 1; }
}
.node-running {
border: 1px dashed rgb(78, 64, 229) !important;
border-radius: 8px;
}
.demo-editor {
flex-grow: 1;
position: relative;

View File

@ -8,5 +8,5 @@ import { usePlaygroundContainer } from './use-playground-container';
*/
export function useService<T>(identifier: interfaces.ServiceIdentifier<T>): T {
const container = usePlaygroundContainer();
return container.get(identifier) as T;
return container.get?.(identifier) as T;
}

View File

@ -32,6 +32,7 @@ export interface FlowTransitionLine {
activated?: boolean; // 是否激活态
side?: LABEL_SIDE_TYPE; // 区分是否分支前缀线条
style?: React.CSSProperties;
lineId?: string;
}
// 内置几种标签
@ -52,6 +53,7 @@ export interface FlowTransitionLabel {
width?: number; // 宽度
rotate?: string; // 循环, 如 '60deg'
props?: Record<string, any>;
labelId?: string;
}
export interface AdderProps {

View File

@ -3,6 +3,7 @@
exports[`flow-label-layer > test render 1`] = `
<DocumentFragment>
<div
data-label-id="start"
style="position: absolute; left: 0px; top: 0px; transform: translate(-50%, -50%);"
>
<div
@ -14,6 +15,7 @@ exports[`flow-label-layer > test render 1`] = `
/>
</div>
<div
data-label-id="mock"
style="position: absolute; left: 0px; top: 0px; transform: translate(-50%, -50%);"
>
<div
@ -21,9 +23,11 @@ exports[`flow-label-layer > test render 1`] = `
/>
</div>
<div
data-label-id="mock"
style="position: absolute; left: 0px; top: 0px; transform: translate(-50%, -50%);"
/>
<div
data-label-id="mock"
style="position: absolute; left: 0px; top: 0px; transform: translate(-50%, -50%);"
>
<div
@ -32,6 +36,7 @@ exports[`flow-label-layer > test render 1`] = `
/>
</div>
<div
data-label-id="mock"
style="position: absolute; left: 0px; top: 0px; transform: translate(-50%, -50%);"
>
<div
@ -48,6 +53,7 @@ exports[`flow-label-layer > test render 1`] = `
</div>
</div>
<div
data-label-id="mock"
style="position: absolute; left: 0px; top: 0px; transform: translate(-50%, -50%);"
>
<div
@ -67,6 +73,7 @@ exports[`flow-label-layer > test render 1`] = `
</div>
</div>
<div
data-label-id="mock"
style="position: absolute; left: 0px; top: 0px; transform: translate(-50%, -50%);"
>
<div
@ -86,27 +93,33 @@ exports[`flow-label-layer > test render 1`] = `
</div>
</div>
<div
data-label-id="mock"
style="position: absolute; left: 0px; top: 0px; transform: translate(-50%, -50%);"
>
<div
data-label-id="mock"
style="font-size: 12px; color: rgb(187, 191, 196); text-align: center; white-space: nowrap; line-height: 20px;"
>
123
</div>
</div>
<div
data-label-id="mock"
style="position: absolute; left: 0px; top: 0px; transform: translate(-50%, -50%);"
>
<div
data-label-id="mock"
style="font-size: 12px; color: rgb(187, 191, 196); text-align: center; white-space: nowrap; line-height: 20px; width: 100px; transform: rotate(90deg);"
>
catch-text
</div>
</div>
<div
data-label-id="mock"
style="position: absolute; left: 0px; top: 0px; transform: translate(-50%, -50%);"
/>
<div
data-label-id="mock"
style="position: absolute; left: 0px; top: 0px; transform: translate(-50%, -50%);"
/>
</DocumentFragment>

View File

@ -23,7 +23,7 @@ function CustomLine(props: PropsType): JSX.Element {
const Component = renderer.renderer as (props: FlowTransitionLine) => JSX.Element;
return <Component {...line} />;
return <Component lineId={props.lineId} {...line} />;
}
export default CustomLine;

View File

@ -59,19 +59,45 @@ export function createLabels(labelProps: LabelOpts): void {
switch (type) {
case FlowTransitionLabelEnum.BRANCH_DRAGGING_LABEL:
child = (
<BranchDraggableRenderer rendererRegistry={rendererRegistry} data={data} {...props} />
<BranchDraggableRenderer
labelId={label.labelId || labelProps.data.entity.id}
rendererRegistry={rendererRegistry}
data={data}
{...props}
/>
);
break;
case FlowTransitionLabelEnum.ADDER_LABEL:
child = <Adder rendererRegistry={rendererRegistry} data={data} {...props} />;
child = (
<Adder
labelId={label.labelId || labelProps.data.entity.id}
rendererRegistry={rendererRegistry}
data={data}
{...props}
/>
);
break;
case FlowTransitionLabelEnum.COLLAPSE_LABEL:
child = <Collapse rendererRegistry={rendererRegistry} data={data} {...props} />;
child = (
<Collapse
labelId={label.labelId || labelProps.data.entity.id}
rendererRegistry={rendererRegistry}
data={data}
{...props}
/>
);
break;
case FlowTransitionLabelEnum.COLLAPSE_ADDER_LABEL:
child = <CollapseAdder rendererRegistry={rendererRegistry} data={data} {...props} />;
child = (
<CollapseAdder
labelId={label.labelId || labelProps.data.entity.id}
rendererRegistry={rendererRegistry}
data={data}
{...props}
/>
);
break;
case FlowTransitionLabelEnum.TEXT_LABEL:
@ -81,6 +107,7 @@ export function createLabels(labelProps: LabelOpts): void {
const text = rendererRegistry.getText(renderKey) || renderKey;
child = (
<div
data-label-id={label.labelId || labelProps.data.entity.id}
style={{
...TEXT_LABEL_STYLE,
...props?.style,
@ -103,6 +130,7 @@ export function createLabels(labelProps: LabelOpts): void {
renderer.renderer as (props: any) => JSX.Element,
{
node: data.entity,
labelId: label.labelId || labelProps.data.entity.id,
...props,
} as CustomLabelProps
);
@ -118,6 +146,7 @@ export function createLabels(labelProps: LabelOpts): void {
return (
<div
key={`${data.entity.id}${index}`}
data-label-id={label.labelId || labelProps.data.entity.id}
style={{
position: 'absolute',
left: offsetX,

View File

@ -48,7 +48,12 @@ export function createLines(props: PropsType): void {
switch (line.type) {
case FlowTransitionLineEnum.STRAIGHT_LINE:
return (
<StraightLine key={`${data.entity.id}${index}`} activated={lineActivated} {...line} />
<StraightLine
key={`${data.entity.id}_${index}`}
lineId={data.entity.id}
activated={lineActivated}
{...line}
/>
);
case FlowTransitionLineEnum.DIVERGE_LINE:
@ -57,7 +62,8 @@ export function createLines(props: PropsType): void {
case FlowTransitionLineEnum.ROUNDED_LINE:
return (
<RoundedTurningLine
key={`${data.entity.id}${index}`}
key={`${data.entity.id}_${index}`}
lineId={data.entity.id}
isHorizontal={!isVertical}
activated={lineActivated || draggingLineActivated}
{...line}
@ -70,7 +76,8 @@ export function createLines(props: PropsType): void {
case FlowTransitionLineEnum.CUSTOM_LINE:
return (
<CustomLine
key={`${data.entity.id}${index}`}
key={`${data.entity.id}_${index}`}
lineId={data.entity.id}
{...line}
rendererRegistry={rendererRegistry}
/>

View File

@ -2,14 +2,15 @@ import React from 'react';
import { useBaseColor } from '../hooks/use-base-color';
export const MARK_ACTIVATED_ARROW_ID = 'line-marker-arrow-activated';
export const MARK_ACTIVATED_ARROW_URL = `url(#${MARK_ACTIVATED_ARROW_ID})`;
export const MARK_ACTIVATED_ARROW_ID = '$marker_arrow_activated$';
// export const MARK_ACTIVATED_ARROW_URL = `url(#${MARK_ACTIVATED_ARROW_ID})`;
function MarkerActivatedArrow(): JSX.Element {
function MarkerActivatedArrow(props: { id?: string }): JSX.Element {
const { baseActivatedColor } = useBaseColor();
return (
<marker
id={MARK_ACTIVATED_ARROW_ID}
data-line-id={props.id}
id={props.id || MARK_ACTIVATED_ARROW_ID}
markerWidth="11"
markerHeight="14"
refX="10"

View File

@ -2,13 +2,21 @@ import React from 'react';
import { useBaseColor } from '../hooks/use-base-color';
export const MARK_ARROW_ID = 'line-marker-arrow';
export const MARK_ARROW_URL = `url(#${MARK_ARROW_ID})`;
export const MARK_ARROW_ID = '$marker_arrow$';
// export const MARK_ARROW_URL = `url(#${MARK_ARROW_ID})`;
function MarkerArrow(): JSX.Element {
function MarkerArrow(props: { id: string }): JSX.Element {
const { baseColor } = useBaseColor();
return (
<marker id={MARK_ARROW_ID} markerWidth="11" markerHeight="14" refX="10" refY="7" orient="auto">
<marker
data-line-id={props.id}
id={props.id || MARK_ARROW_ID}
markerWidth="11"
markerHeight="14"
refX="10"
refY="7"
orient="auto"
>
<path
d="M9.6 5.2C10.8 6.1 10.8 7.9 9.6 8.8L3.6 13.3C2.11672 14.4125 0 13.3541 0 11.5L0 2.5C0 0.645898 2.11672 -0.412461 3.6 0.7L9.6 5.2Z"
fill={baseColor}

View File

@ -3,11 +3,13 @@ import React, { useMemo } from 'react';
import { isNil } from 'lodash';
import { Point } from '@flowgram.ai/utils';
import { type FlowTransitionLine } from '@flowgram.ai/document';
import { useService } from '@flowgram.ai/core';
import { useBaseColor } from '../hooks/use-base-color';
import { DEFAULT_LINE_ATTRS, DEFAULT_RADIUS, getHorizontalVertices, getVertices } from './utils';
import { MARK_ARROW_URL } from './MarkerArrow';
import { MARK_ACTIVATED_ARROW_URL } from './MarkerActivatedArrow';
import MarkerArrow, { MARK_ARROW_ID } from './MarkerArrow';
import MarkerActivatedArrow, { MARK_ACTIVATED_ARROW_ID } from './MarkerActivatedArrow';
import { FlowRendererKey, FlowRendererRegistry } from '../flow-renderer-registry';
interface PropsType extends FlowTransitionLine {
radius?: number;
@ -16,6 +18,27 @@ interface PropsType extends FlowTransitionLine {
yRadius?: number;
}
function MarkerDefs(props: { id: string; activated?: boolean }): JSX.Element {
const renderRegistry = useService(FlowRendererRegistry);
const ArrowRenderer = renderRegistry?.tryToGetRendererComponent(
props.activated ? FlowRendererKey.MARKER_ACTIVATE_ARROW : FlowRendererKey.MARKER_ARROW
);
if (ArrowRenderer) {
return <ArrowRenderer.renderer {...props} />;
}
if (props.activated) {
return (
<defs>
<MarkerActivatedArrow id={props.id} />
</defs>
);
}
return (
<defs>
<MarkerArrow id={props.id} />
</defs>
);
}
/**
* 线
*/
@ -100,19 +123,26 @@ function RoundedTurningLine(props: PropsType): JSX.Element | null {
}
const pathStr = `M ${from.x} ${from.y} ${middleStr} L ${to.x} ${to.y}`;
const markerId = activated
? `${MARK_ACTIVATED_ARROW_ID}${props.lineId}`
: `${MARK_ARROW_ID}${props.lineId}`;
return (
<path
d={pathStr}
{...DEFAULT_LINE_ATTRS}
stroke={activated ? baseActivatedColor : baseColor}
{...(arrow
? {
markerEnd: activated ? MARK_ACTIVATED_ARROW_URL : MARK_ARROW_URL,
}
: {})}
style={style}
/>
<>
{arrow ? <MarkerDefs id={markerId} activated={activated} /> : null}
<path
data-line-id={props.lineId}
d={pathStr}
{...DEFAULT_LINE_ATTRS}
stroke={activated ? baseActivatedColor : baseColor}
{...(arrow
? {
markerEnd: `url(#${markerId})`,
}
: {})}
style={style}
></path>
</>
);
}

View File

@ -11,6 +11,7 @@ function StraightLine(props: FlowTransitionLine): JSX.Element {
return (
<path
data-line-id={props.lineId}
d={`M ${from.x} ${from.y} L ${to.x} ${to.y}`}
{...DEFAULT_LINE_ATTRS}
stroke={activated ? baseActivatedColor : baseColor}

View File

@ -13,9 +13,7 @@ import {
} from '@flowgram.ai/document';
import { Layer, observeEntity, observeEntityDatas } from '@flowgram.ai/core';
import { FlowRendererKey, FlowRendererRegistry } from '../flow-renderer-registry';
import MarkerArrow from '../components/MarkerArrow';
import MarkerActivatedArrow from '../components/MarkerActivatedArrow';
import { FlowRendererRegistry } from '../flow-renderer-registry';
import { createLines } from '../components/LinesRenderer';
@injectable()
@ -90,19 +88,6 @@ export class FlowLinesLayer extends Layer {
);
const resultLines = [...normalLines, ...activateLines];
const arrowRenderer = this.rendererRegistry.tryToGetRendererComponent(
FlowRendererKey.MARKER_ARROW
);
const activateArrowRenderer = this.rendererRegistry.tryToGetRendererComponent(
FlowRendererKey.MARKER_ACTIVATE_ARROW
);
const arrow = arrowRenderer
? React.createElement(arrowRenderer.renderer as () => JSX.Element)
: null;
const activateArrow = activateArrowRenderer
? React.createElement(activateArrowRenderer.renderer as () => JSX.Element)
: null;
return (
<svg
className="flow-lines-container"
@ -112,10 +97,6 @@ export class FlowLinesLayer extends Layer {
viewBox={this.viewBox}
xmlns="http://www.w3.org/2000/svg"
>
<defs>
{arrowRenderer ? arrow : <MarkerArrow />}
{activateArrow ? activateArrow : <MarkerActivatedArrow />}
</defs>
{resultLines}
</svg>
);

View File

@ -129,6 +129,7 @@ function TryCatchCollapse(props: CustomLabelProps): JSX.Element {
}}
>
<div
data-label-id={props.labelId}
style={{
fontSize: 12,
color: hoverActivated ? baseActivatedColor : baseColor,