chore: fix all biome lint errors across monorepo

- Fix import ordering (organizeImports) across multiple packages
- Replace forEach with for...of loops (noForEach)
- Replace non-null assertions with fallback values (noNonNullAssertion)
- Add biome-ignore comments for justified noExplicitAny usages
- Remove parameter properties, use explicit class properties (noParameterProperties)
- Fix string concatenation to template literals (useTemplate)
- Fix format issues (CSS, TypeScript)
- Add tailwindDirectives CSS parser config in biome.json
- Replace var with const (noVar)

Result: 0 errors, 12 warnings (all cognitive complexity, acceptable)
This commit is contained in:
2026-05-23 18:39:02 +08:00
parent 330db43b5f
commit 1abc3b4cf4
61 changed files with 687 additions and 639 deletions
+9
View File
@@ -17,6 +17,15 @@
"indentWidth": 2, "indentWidth": 2,
"lineWidth": 100 "lineWidth": 100
}, },
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
},
"linter": {
"enabled": false
}
},
"javascript": { "javascript": {
"formatter": { "formatter": {
"quoteStyle": "double", "quoteStyle": "double",
@@ -62,9 +62,9 @@ const olderEntry = JSON.stringify({
async function writeLogFiles(): Promise<void> { async function writeLogFiles(): Promise<void> {
const logsDir = join(storageRoot, "logs"); const logsDir = join(storageRoot, "logs");
await writeFile(join(logsDir, "2026-05-20.jsonl"), [entry1, entry2, entry3].join("\n") + "\n"); await writeFile(join(logsDir, "2026-05-20.jsonl"), `${[entry1, entry2, entry3].join("\n")}\n`);
await writeFile(join(logsDir, "2026-05-19.jsonl"), oldEntry + "\n"); await writeFile(join(logsDir, "2026-05-19.jsonl"), `${oldEntry}\n`);
await writeFile(join(logsDir, "2026-05-18.jsonl"), olderEntry + "\n"); await writeFile(join(logsDir, "2026-05-18.jsonl"), `${olderEntry}\n`);
} }
describe("cmdLogList", () => { describe("cmdLogList", () => {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { resolvePath } from "../src/tools/path.js";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { resolvePath } from "../src/tools/path.js";
describe("resolvePath", () => { describe("resolvePath", () => {
test("resolves relative paths against cwd", () => { test("resolves relative paths against cwd", () => {
@@ -56,8 +56,7 @@ function runShell(
export const runCommandTool: BuiltinTool = { export const runCommandTool: BuiltinTool = {
name: "run_command", name: "run_command",
description: description: "Run a shell command. Output is truncated to 32KB.",
"Run a shell command. Output is truncated to 32KB.",
parameters: { parameters: {
type: "object", type: "object",
required: ["command"], required: ["command"],
@@ -73,9 +73,7 @@ describe("parseClaudeCodeStreamOutput", () => {
type: "user", type: "user",
message: { message: {
role: "user", role: "user",
content: [ content: [{ type: "tool_result", tool_use_id: "tool_1", content: "file1.ts\nfile2.ts" }],
{ type: "tool_result", tool_use_id: "tool_1", content: "file1.ts\nfile2.ts" },
],
}, },
session_id: "sess-123", session_id: "sess-123",
}), }),
@@ -167,7 +165,12 @@ describe("storeClaudeCodeDetail", () => {
durationMs: 15000, durationMs: 15000,
model: "claude-sonnet-4.5", model: "claude-sonnet-4.5",
stopReason: "end_turn", stopReason: "end_turn",
usage: { inputTokens: 100, outputTokens: 50, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }, usage: {
inputTokens: 100,
outputTokens: 50,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
turns: [ turns: [
{ index: 0, role: "assistant", content: "hello", toolCalls: null }, { index: 0, role: "assistant", content: "hello", toolCalls: null },
{ index: 1, role: "tool_result", content: "world", toolCalls: null }, { index: 1, role: "tool_result", content: "world", toolCalls: null },
@@ -1,8 +1,5 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import type { Store } from "@uncaged/json-cas"; import type { Store } from "@uncaged/json-cas";
import { createLogger } from "@uncaged/workflow-util";
import { import {
type AgentContext, type AgentContext,
type AgentRunResult, type AgentRunResult,
@@ -11,6 +8,7 @@ import {
getCachedSessionId, getCachedSessionId,
setCachedSessionId, setCachedSessionId,
} from "@uncaged/workflow-agent-kit"; } from "@uncaged/workflow-agent-kit";
import { createLogger } from "@uncaged/workflow-util";
import { parseClaudeCodeStreamOutput, storeClaudeCodeDetail } from "./session-detail.js"; import { parseClaudeCodeStreamOutput, storeClaudeCodeDetail } from "./session-detail.js";
@@ -146,7 +144,12 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
} }
return result; return result;
} catch (err) { } catch (err) {
log("5VKR8N3Q", "resume failed for session %s, falling back to fresh run: %s", cachedSessionId, err); log(
"5VKR8N3Q",
"resume failed for session %s, falling back to fresh run: %s",
cachedSessionId,
err,
);
} }
} }
} }
@@ -267,8 +267,7 @@ export class HermesAcpClient {
case "tool_call": { case "tool_call": {
const title = (update.title as string) ?? ""; const title = (update.title as string) ?? "";
const rawInput = update.rawInput; const rawInput = update.rawInput;
const args = const args = rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
const toolCallId = update.toolCallId as string; const toolCallId = update.toolCallId as string;
this.pendingTools.set(toolCallId, { name: title, args }); this.pendingTools.set(toolCallId, { name: title, args });
+1 -1
View File
@@ -12,8 +12,8 @@ export {
export type { FrontmatterFastPathResult } from "./frontmatter.js"; export type { FrontmatterFastPathResult } from "./frontmatter.js";
export { tryFrontmatterFastPath } from "./frontmatter.js"; export { tryFrontmatterFastPath } from "./frontmatter.js";
export { createAgent } from "./run.js"; export { createAgent } from "./run.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
export { getCachedSessionId, setCachedSessionId } from "./session-cache.js"; export { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
export type { export type {
AgentContext, AgentContext,
AgentContinueFn, AgentContinueFn,
@@ -1,5 +1,5 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import type { ThreadId } from "@uncaged/workflow-protocol"; import type { ThreadId } from "@uncaged/workflow-protocol";
+2 -2
View File
@@ -6,8 +6,8 @@
<title>Workflow UI</title> <title>Workflow UI</title>
<link rel="stylesheet" href="./src/index.css" /> <link rel="stylesheet" href="./src/index.css" />
<script> <script>
(function () { (() => {
var t = localStorage.getItem("theme"); const t = localStorage.getItem("theme");
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) { if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
} }
-3
View File
@@ -7,6 +7,3 @@ const server = await createServer({
}); });
await server.listen(); await server.listen();
// biome-ignore lint/nursery/noConsole: CLI user-facing output
console.log(`Workflow UI running at http://localhost:${PORT}`);
+3 -3
View File
@@ -1,11 +1,11 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import type { WorkFlowSteps } from "../shared/types.ts"; import type { WorkFlowSteps } from "../shared/types.ts";
import { import {
listWorkflows,
getWorkflow,
createWorkflow, createWorkflow,
saveWorkflow,
deleteWorkflow, deleteWorkflow,
getWorkflow,
listWorkflows,
saveWorkflow,
} from "./workflow.ts"; } from "./workflow.ts";
export function createApi() { export function createApi() {
@@ -1,11 +1,7 @@
import { readdir, readFile, writeFile, unlink, mkdir } from "node:fs/promises"; import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
import YAML from "yaml"; import YAML from "yaml";
import type {
WorkflowPayload,
RoleDefinition,
Transition,
} from "@uncaged/workflow-protocol";
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts"; import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
const WORKFLOW_DIR = join(import.meta.dirname, "..", "tmp", "workflow"); const WORKFLOW_DIR = join(import.meta.dirname, "..", "tmp", "workflow");
@@ -67,7 +63,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
let condName: string | null = null; let condName: string | null = null;
if (t.condition) { if (t.condition) {
if (expressionToName.has(t.condition)) { if (expressionToName.has(t.condition)) {
condName = expressionToName.get(t.condition)!; condName = expressionToName.get(t.condition) ?? null;
} else { } else {
condName = `cond${condIdx++}`; condName = `cond${condIdx++}`;
expressionToName.set(t.condition, condName); expressionToName.set(t.condition, condName);
@@ -90,7 +86,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
if (steps.length > 0) { if (steps.length > 0) {
const firstRole = steps[0].role.name; const firstRole = steps[0].role.name;
graph["$START"] = [ graph.$START = [
{ {
role: firstRole, role: firstRole,
condition: null, condition: null,
@@ -1,7 +1,7 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button" import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@@ -37,8 +37,8 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function Button({ function Button({
className, className,
@@ -52,7 +52,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };
@@ -1,6 +1,6 @@
import * as React from "react" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Card({ function Card({
className, className,
@@ -13,11 +13,11 @@ function Card({
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -26,11 +26,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3", "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -39,11 +39,11 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-title" data-slot="card-title"
className={cn( className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", "font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -53,20 +53,17 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -76,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-4 group-data-[size=sm]/card:px-3", className)} className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -85,19 +82,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-footer" data-slot="card-footer"
className={cn( className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3", "flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -1,40 +1,36 @@
import * as React from "react" import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { XIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils";
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) { function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ ...props }: DialogPrimitive.Close.Props) { function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return ( return (
<DialogPrimitive.Backdrop <DialogPrimitive.Backdrop
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -43,7 +39,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: DialogPrimitive.Popup.Props & { }: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal> <DialogPortal>
@@ -52,7 +48,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className className,
)} )}
{...props} {...props}
> >
@@ -60,32 +56,21 @@ function DialogContent({
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-close" data-slot="dialog-close"
render={ render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
> >
<XIcon <XIcon />
/>
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Popup> </DialogPrimitive.Popup>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
data-slot="dialog-header" );
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
} }
function DialogFooter({ function DialogFooter({
@@ -94,54 +79,46 @@ function DialogFooter({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end", "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}> <DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
Close
</DialogPrimitive.Close>
)} )}
</div> </div>
) );
} }
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn( className={cn("font-heading text-base leading-none font-medium", className)}
"font-heading text-base leading-none font-medium",
className
)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
className,
...props
}: DialogPrimitive.Description.Props) {
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn( className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -155,4 +132,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };
@@ -1,7 +1,7 @@
import * as React from "react" import { Input as InputPrimitive } from "@base-ui/react/input";
import { Input as InputPrimitive } from "@base-ui/react/input" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };
@@ -1,18 +1,19 @@
import * as React from "react" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) { function Label({ className, ...props }: React.ComponentProps<"label">) {
return ( return (
// biome-ignore lint/a11y/noLabelWithoutControl: generic Label component; control association handled by consumer
<label <label
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };
@@ -1,25 +1,21 @@
"use client" "use client";
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Separator({ function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return ( return (
<SeparatorPrimitive <SeparatorPrimitive
data-slot="separator" data-slot="separator"
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };
@@ -1,6 +1,6 @@
import * as React from "react" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return ( return (
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Textarea } export { Textarea };
@@ -1,12 +1,12 @@
import { createContext, useMemo, useSyncExternalStore, useContext, useLayoutEffect } from 'react'; import { type ReactFlowInstance, useReactFlow } from "@xyflow/react";
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from "react";
import { useReactFlow, ReactFlowInstance } from '@xyflow/react'; import { createContext, useContext, useLayoutEffect, useMemo, useSyncExternalStore } from "react";
import type { AnyWorkNode } from './type'; import type { AnyWorkNode } from "./type";
type Reduce<T> = (data: T) => T; type Reduce<T> = (data: T) => T;
type Setter<T> = (ch: Reduce<T> | T) => void; type Setter<T> = (ch: Reduce<T> | T) => void;
interface State<T, A> { interface State<T, A> {
readonly get: () => T; readonly get: () => T;
readonly set: Setter<T>; readonly set: Setter<T>;
readonly use: () => T; readonly use: () => T;
@@ -15,6 +15,7 @@ interface State<T, A> {
readonly onlyView: boolean; readonly onlyView: boolean;
} }
type Use = <T, A>(sub: SubModel<T, A>) => [T, A]; type Use = <T, A>(sub: SubModel<T, A>) => [T, A];
// biome-ignore lint/suspicious/noExplicitAny: UseV intentionally erases the action type
type UseV = <T>(sub: SubModel<T, any>) => T; type UseV = <T>(sub: SubModel<T, any>) => T;
type Create<T, A> = (set: Setter<T>, get: () => T, model: Model) => A; type Create<T, A> = (set: Setter<T>, get: () => T, model: Model) => A;
@@ -24,10 +25,12 @@ export function generate<T>(val: T) {
const listener = new Set<VoidFunction>(); const listener = new Set<VoidFunction>();
const get = () => val; const get = () => val;
function set(ch: T | ((prev: T) => T)) { function set(ch: T | ((prev: T) => T)) {
const next = (typeof ch === 'function') ? (ch as (prev: T) => T)(val) : ch; const next = typeof ch === "function" ? (ch as (prev: T) => T)(val) : ch;
if (Object.is(val, next)) return; if (Object.is(val, next)) return;
val = next; val = next;
listener.forEach(call => call()); for (const call of listener) {
call();
}
} }
const listen = (call: VoidFunction) => { const listen = (call: VoidFunction) => {
listener.add(call); listener.add(call);
@@ -38,21 +41,26 @@ export function generate<T>(val: T) {
} }
class SubModel<T, A> { class SubModel<T, A> {
constructor( public readonly name: string;
public readonly name: string, private readonly make: () => T;
private make: () => T, private readonly create: Create<T, A>;
private create: Create<T, A>, private readonly onlyView: boolean;
private onlyView = false,
) {} constructor(name: string, _make: () => T, _create: Create<T, A>, _onlyView = false) {
this.name = name;
this.make = _make;
this.create = _create;
this.onlyView = _onlyView;
}
public gen(model: Model): State<T, A> { public gen(model: Model): State<T, A> {
const { make, create, onlyView } = this; const { get, set, use, listen } = generate(this.make());
const { get, set, use, listen } = generate(make()); const actions = this.create(set, get, model);
const actions = create(set, get, model); return { get, set, use, listen, actions, onlyView: this.onlyView };
return { get, set, use, listen, actions, onlyView };
} }
use(): [T, A] { use(): [T, A] {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { query } = useContext(Context); const { query } = useContext(Context);
const { use, actions } = query(this); const { use, actions } = query(this);
return [use(), actions]; return [use(), actions];
@@ -67,20 +75,27 @@ class SubModel<T, A> {
} }
} }
// biome-ignore lint/suspicious/noExplicitAny: snapshot data is heterogeneous
type Snapshot = [name: string, data: any]; type Snapshot = [name: string, data: any];
class Model { class Model {
private ustack: Snapshot[][] = []; private ustack: Snapshot[][] = [];
private rstack: Snapshot[][] = []; private rstack: Snapshot[][] = [];
private transaction = 0; private transaction = 0;
// biome-ignore lint/suspicious/noExplicitAny: backup stores heterogeneous state values
private backup = new Map<string, any>(); private backup = new Map<string, any>();
public flow = {} as ReactFlowInstance<AnyWorkNode>; public flow = {} as ReactFlowInstance<AnyWorkNode>;
private stackListeners = new Set<() => void>(); private stackListeners = new Set<() => void>();
public readonly stackState: readonly [boolean, boolean] = [false, false]; public readonly stackState: readonly [boolean, boolean] = [false, false];
constructor( // biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
private readonly store: Map<string, State<any, any>>, private readonly store: Map<string, State<any, any>>;
public readonly use: Use, public readonly use: Use;
) {}
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
constructor(store: Map<string, State<any, any>>, use: Use) {
this.store = store;
this.use = use;
}
public reset() { public reset() {
this.ustack = []; this.ustack = [];
@@ -93,12 +108,14 @@ class Model {
public readonly listenStackState = (cb: () => void) => { public readonly listenStackState = (cb: () => void) => {
this.stackListeners.add(cb); this.stackListeners.add(cb);
return () => this.stackListeners.delete(cb); return () => this.stackListeners.delete(cb);
} };
private triggerStackState() { private triggerStackState() {
// @ts-expect-error // @ts-expect-error
this.stackState = [this.canUndo(), this.canRedo()]; this.stackState = [this.canUndo(), this.canRedo()];
this.stackListeners.forEach(call => call()); for (const call of this.stackListeners) {
call();
}
} }
private getStackState = () => this.stackState; private getStackState = () => this.stackState;
@@ -108,13 +125,11 @@ class Model {
} }
public log() { public log() {
console.log('undo stack:', this.ustack); // biome-ignore lint/suspicious/noExplicitAny: debug log accumulates heterogeneous values
console.log('redo stack:', this.rstack);
const snapshots: Record<string, any> = {}; const snapshots: Record<string, any> = {};
this.store.forEach((state, name) => { for (const [name, state] of this.store) {
snapshots[name] = state.get(); snapshots[name] = state.get();
}); }
console.log('current state:', snapshots);
} }
public undo() { public undo() {
@@ -122,11 +137,13 @@ class Model {
const item = ustack.pop(); const item = ustack.pop();
if (!item) return; if (!item) return;
const step: Snapshot[] = []; const step: Snapshot[] = [];
item.forEach(([name, data]) => { for (const [name, data] of item) {
const { get, set } = store.get(name)!; const entry = store.get(name);
if (!entry) continue;
const { get, set } = entry;
step.push([name, get()]); step.push([name, get()]);
set(data); set(data);
}); }
rstack.push(step); rstack.push(step);
this.triggerStackState(); this.triggerStackState();
} }
@@ -136,11 +153,13 @@ class Model {
const item = rstack.pop(); const item = rstack.pop();
if (!item) return; if (!item) return;
const step: Snapshot[] = []; const step: Snapshot[] = [];
item.forEach(([name, data]) => { for (const [name, data] of item) {
const { get, set } = store.get(name)!; const entry = store.get(name);
if (!entry) continue;
const { get, set } = entry;
step.push([name, get()]); step.push([name, get()]);
set(data); set(data);
}); }
ustack.push(step); ustack.push(step);
this.triggerStackState(); this.triggerStackState();
} }
@@ -156,10 +175,10 @@ class Model {
public startTransaction() { public startTransaction() {
if (this.transaction === 0) { if (this.transaction === 0) {
this.backup.clear(); this.backup.clear();
this.store.forEach((state, name) => { for (const [name, state] of this.store) {
if (state.onlyView) return; if (state.onlyView) continue;
this.backup.set(name, state.get()); this.backup.set(name, state.get());
}); }
} }
this.transaction += 1; this.transaction += 1;
return this.endTransaction; return this.endTransaction;
@@ -170,24 +189,26 @@ class Model {
this.transaction -= 1; this.transaction -= 1;
if (this.transaction === 0) { if (this.transaction === 0) {
const changes: Snapshot[] = []; const changes: Snapshot[] = [];
this.store.forEach((state, name) => { for (const [name, state] of this.store) {
if (state.onlyView) return; if (state.onlyView) continue;
const before = this.backup.get(name); const before = this.backup.get(name);
if (Object.is(before, state.get())) return; if (Object.is(before, state.get())) continue;
changes.push([name, before]); changes.push([name, before]);
}); }
this.backup.clear(); this.backup.clear();
if (changes.length === 0) return; if (changes.length === 0) return;
this.ustack.push(changes); this.ustack.push(changes);
this.rstack.length = 0; this.rstack.length = 0;
this.triggerStackState(); this.triggerStackState();
} }
} };
} }
function build() { function build() {
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
const store = new Map<string, State<any, any>>(); const store = new Map<string, State<any, any>>();
// biome-ignore lint/suspicious/noExplicitAny: memo cache stores heterogeneous values
const mem: Record<string, any> = {}; const mem: Record<string, any> = {};
function use<T, A>(m: SubModel<T, A>): [T, A] { function use<T, A>(m: SubModel<T, A>): [T, A] {
const state = query(m); const state = query(m);
@@ -195,8 +216,8 @@ function build() {
} }
const model = new Model(store, use); const model = new Model(store, use);
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
// @ts-ignore // @ts-expect-error
window.__md__ = model; window.__md__ = model;
} }
@@ -206,9 +227,9 @@ function build() {
const created = m.gen(model); const created = m.gen(model);
store.set(m.name, created); store.set(m.name, created);
return created; return created;
}; }
return { query, model, mem, use } return { query, model, mem, use };
} }
const Context = createContext(build()); const Context = createContext(build());
@@ -222,24 +243,28 @@ export function RegisterFlowToContext() {
const instance = useReactFlow<AnyWorkNode>(); const instance = useReactFlow<AnyWorkNode>();
useLayoutEffect(() => { useLayoutEffect(() => {
model.flow = instance; model.flow = instance;
}, [instance]); }, [instance, model]);
return null; return null;
} }
export const ModelProvider: FC<PropsWithChildren> = (p) => ( export const ModelProvider: FC<PropsWithChildren> = (p) => (
<Context.Provider value={useMemo(build, [])}> <Context.Provider value={useMemo(build, [])}>{p.children}</Context.Provider>
{p.children}
</Context.Provider>
); );
function defineModel<T, A>(name: string, make: () => T, create: Create<T, A>) { function defineModel<T, A>(name: string, make: () => T, create: Create<T, A>) {
return new SubModel<T, A>(name, make, create); return new SubModel<T, A>(name, make, create);
} }
// biome-ignore lint/suspicious/noExplicitAny: default create returns setter directly
const defaultCreate: Create<any, Setter<any>> = (set) => set; const defaultCreate: Create<any, Setter<any>> = (set) => set;
function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A> function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A>;
function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>> function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>>;
function defineView<T>(name: string, make: () => T, create?: any): any { function defineView<T>(
name: string,
make: () => T,
create?: Create<T, unknown>,
): SubModel<T, unknown> {
// biome-ignore lint/suspicious/noExplicitAny: wraps into SubModel with erased action type
return new SubModel<T, any>(name, make, create ?? defaultCreate, true); return new SubModel<T, any>(name, make, create ?? defaultCreate, true);
} }
@@ -247,9 +272,12 @@ function memoize<T>(init: (use: Use, model: Model) => T) {
const id = uuid(); const id = uuid();
return { return {
use(): T { use(): T {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { mem, model, use } = useContext(Context); const { mem, model, use } = useContext(Context);
const fn = mem[id] || (mem[id] = init(use, model)); if (!mem[id]) {
return fn as T; mem[id] = init(use, model);
}
return mem[id] as T;
}, },
}; };
} }
@@ -258,21 +286,29 @@ function compute<T>(calc: (use: UseV) => T) {
const id = uuid(); const id = uuid();
return { return {
use(): T { use(): T {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { mem, query } = useContext(Context); const { mem, query } = useContext(Context);
let state: ReturnType<typeof generate<T>> = mem[id]; let state: ReturnType<typeof generate<T>> = mem[id];
if (state) return state.use(); if (state) return state.use();
// biome-ignore lint/suspicious/noExplicitAny: deps collect heterogeneous SubModels
const deps = new Set<SubModel<any, any>>(); const deps = new Set<SubModel<any, any>>();
let usev = (m: SubModel<any, any>) => (deps.add(m), query(m).get()); // biome-ignore lint/suspicious/noExplicitAny: useV erases action type
let usev = (m: SubModel<any, any>) => {
deps.add(m);
return query(m).get();
};
mem[id] = state = generate<T>(calc(usev)); mem[id] = state = generate<T>(calc(usev));
if (deps.size) { if (deps.size) {
usev = m => query(m).get(); usev = (m) => query(m).get();
const update = () => state.set(calc(usev)); const update = () => state.set(calc(usev));
deps.forEach(m => query(m).listen(update)); for (const m of deps) {
query(m).listen(update);
}
} }
return state.use(); return state.use();
}, },
} };
} }
export const define = { export const define = {
@@ -1,15 +1,15 @@
import { import {
getSmoothStepPath,
EdgeLabelRenderer,
useReactFlow,
type EdgeProps,
type Edge, type Edge,
EdgeLabelRenderer,
type EdgeProps,
getSmoothStepPath,
useReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import { useState, useRef, useEffect, useMemo, type ReactNode } from "react";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useModel } from "../context.tsx";
import { cn } from "../../lib/utils.ts"; import { cn } from "../../lib/utils.ts";
import { useModel } from "../context.tsx";
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
const SOURCE_COLOR = "#10b981"; const SOURCE_COLOR = "#10b981";
const TARGET_COLOR = "#3b82f6"; const TARGET_COLOR = "#3b82f6";
@@ -38,7 +38,7 @@ function GradientPath({
const gradientId = `gradient-${id}`; const gradientId = `gradient-${id}`;
const showLack = hasCondition === false; const showLack = hasCondition === false;
const strokeStyle = selected const strokeStyle = selected
? { stroke: '#f59e0b', strokeWidth: 2 } ? { stroke: "#f59e0b", strokeWidth: 2 }
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 }; : { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
return ( return (
@@ -63,13 +63,7 @@ function GradientPath({
strokeWidth={20} strokeWidth={20}
className="react-flow__edge-interaction" className="react-flow__edge-interaction"
/> />
<path <path id={id} d={path} fill="none" className="react-flow__edge-path" style={strokeStyle} />
id={id}
d={path}
fill="none"
className="react-flow__edge-path"
style={strokeStyle}
/>
</> </>
); );
} }
@@ -143,13 +137,12 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
}} }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
> >
{/* biome-ignore lint/a11y/noStaticElementInteractions: click handler on badge label */}
<div onClick={handleBadgeClick} onKeyDown={undefined} className="cursor-pointer"> <div onClick={handleBadgeClick} onKeyDown={undefined} className="cursor-pointer">
<span <span
className={cn( className={cn(
"inline-block px-1 bg-white rounded text-[10px]", "inline-block px-1 bg-white rounded text-[10px]",
condition condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
? "border border-gray-300 text-black"
: "border border-dashed text-red-500",
)} )}
style={condition ? undefined : { borderColor: LACK_COLOR }} style={condition ? undefined : { borderColor: LACK_COLOR }}
> >
@@ -166,7 +159,6 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoFocus
/> />
<button <button
type="button" type="button"
@@ -183,7 +175,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
} }
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean { export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
const siblings = allEdges.filter(e => e.source === source && e.type === 'conditional'); const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
return siblings.length >= 2 && siblings[0].id === edgeId; return siblings.length >= 2 && siblings[0].id === edgeId;
} }
@@ -200,7 +192,13 @@ export function ConditionalEdge({
data, data,
}: EdgeProps<ConditionalEdgeType>): ReactNode { }: EdgeProps<ConditionalEdgeType>): ReactNode {
const [edgePath, labelX, labelY] = getSmoothStepPath({ const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS, sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: RADIUS,
}); });
const flow = useReactFlow(); const flow = useReactFlow();
const model = useModel(); const model = useModel();
@@ -224,14 +222,20 @@ export function ConditionalEdge({
sourceY={sourceY} sourceY={sourceY}
targetX={targetX} targetX={targetX}
targetY={targetY} targetY={targetY}
hasCondition={isElse ? null : (condition ? true : false)} hasCondition={isElse ? null : !!condition}
selected={!!selected} selected={!!selected}
/> />
<EdgeLabelRenderer> <EdgeLabelRenderer>
{isElse {isElse ? (
? <ElseBadge labelX={labelX} labelY={labelY} /> <ElseBadge labelX={labelX} labelY={labelY} />
: <ConditionLabel condition={condition} labelX={labelX} labelY={labelY} onSave={handleSave} /> ) : (
} <ConditionLabel
condition={condition}
labelX={labelX}
labelY={labelY}
onSave={handleSave}
/>
)}
</EdgeLabelRenderer> </EdgeLabelRenderer>
</> </>
); );
@@ -248,7 +252,13 @@ export function GradientEdge({
selected, selected,
}: EdgeProps<Edge>): ReactNode { }: EdgeProps<Edge>): ReactNode {
const [edgePath] = getSmoothStepPath({ const [edgePath] = getSmoothStepPath({
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS, sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: RADIUS,
}); });
return ( return (
@@ -1,4 +1,4 @@
import { ConditionalEdge, GradientEdge } from './conditional'; import { ConditionalEdge, GradientEdge } from "./conditional";
export const edgeTypes = { export const edgeTypes = {
conditional: ConditionalEdge, conditional: ConditionalEdge,
+20 -20
View File
@@ -1,16 +1,16 @@
import { memo, createElement, useLayoutEffect, useEffect, createContext, useContext } from 'react'; import { Background, Controls, type Edge, ReactFlow, ReactFlowProvider } from "@xyflow/react";
import { ReactFlow, ReactFlowProvider, Controls, Background, type Edge } from '@xyflow/react'; import { createContext, createElement, memo, useContext, useEffect, useLayoutEffect } from "react";
// @ts-ignore // @ts-expect-error
import '@xyflow/react/dist/style.css'; import "@xyflow/react/dist/style.css";
import { nodesModel, edgesModel, handlers, injection } from './model'; import { ModelProvider, RegisterFlowToContext } from "./context";
import { ModelProvider, RegisterFlowToContext } from './context'; import { edgeTypes } from "./edges";
import { nodeTypes } from './nodes'; import { FlowModel, InternalField } from "./injection";
import { edgeTypes } from './edges'; import { edgesModel, handlers, injection, nodesModel } from "./model";
import { Dialogs, TopCenterPanel } from './panel'; import { nodeTypes } from "./nodes";
import type { AnyWorkNode } from './type'; import { Dialogs, TopCenterPanel } from "./panel";
import { FlowModel, InternalField } from './injection'; import type { AnyWorkNode } from "./type";
export * from './trans/type'; export * from "./trans/type";
const proOptions = { hideAttribution: true }; const proOptions = { hideAttribution: true };
@@ -20,11 +20,13 @@ export const useReadonly = () => useContext(ReadonlyContext);
function Flow() { function Flow() {
const [nodes, { onNodesChange }] = nodesModel.use(); const [nodes, { onNodesChange }] = nodesModel.use();
const [edges, { onEdgesChange, onConnect }] = edgesModel.use(); const [edges, { onEdgesChange, onConnect }] = edgesModel.use();
const { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } = handlers.use(); const { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } =
handlers.use();
const readonly = useReadonly(); const readonly = useReadonly();
return ( return (
<div style={{ height: '100%' }} tabIndex={0} onKeyDown={readonly ? undefined : handleKeyDown}> // biome-ignore lint/a11y/noStaticElementInteractions: keyboard handler for flow shortcuts
<div style={{ height: "100%" }} onKeyDown={readonly ? undefined : handleKeyDown}>
<ReactFlowProvider> <ReactFlowProvider>
<ReactFlow<AnyWorkNode, Edge> <ReactFlow<AnyWorkNode, Edge>
nodes={nodes} nodes={nodes}
@@ -70,11 +72,11 @@ function Connect({ model }: { model: FlowModel }) {
useLayoutEffect(() => { useLayoutEffect(() => {
return inject(instance); return inject(instance);
}, [instance]); }, [instance, inject]);
useEffect(() => { useEffect(() => {
return instance.on('load', loadSteps); return instance.on("load", loadSteps);
}, [instance]); }, [instance, loadSteps]);
return <MemoFlow />; return <MemoFlow />;
} }
@@ -83,8 +85,6 @@ export { FlowModel };
// biome-ignore lint/style/noDefaultExport: FlowEditor is the main public component // biome-ignore lint/style/noDefaultExport: FlowEditor is the main public component
export default ({ model, readonly = false }: Props) => ( export default ({ model, readonly = false }: Props) => (
<ReadonlyContext.Provider value={readonly}> <ReadonlyContext.Provider value={readonly}>
<ModelProvider> <ModelProvider>{createElement(Connect, { model })}</ModelProvider>
{createElement(Connect, { model })}
</ModelProvider>
</ReadonlyContext.Provider> </ReadonlyContext.Provider>
); );
@@ -1,5 +1,5 @@
import { WorkFlowSteps } from "./trans"; import type { WorkFlowSteps } from "./trans";
import { Eventer } from './utils/eventer'; import { Eventer } from "./utils/eventer";
interface PublicEvents { interface PublicEvents {
save: WorkFlowSteps; save: WorkFlowSteps;
@@ -9,19 +9,21 @@ interface PrivateEvents {
load: WorkFlowSteps; load: WorkFlowSteps;
} }
export const InternalField = Symbol('InternalField'); export const InternalField = Symbol("InternalField");
export class Injection extends Eventer<PrivateEvents> { export class Injection extends Eventer<PrivateEvents> {
constructor( public readonly emitPublic: Eventer<PublicEvents>["emit"];
public readonly emitPublic: Eventer<PublicEvents>['emit'], private inital_steps: WorkFlowSteps | undefined;
private inital_steps?: WorkFlowSteps,
) { constructor(emitPublic: Eventer<PublicEvents>["emit"], inital_steps?: WorkFlowSteps) {
super(); super();
this.emitPublic = emitPublic;
this.inital_steps = inital_steps;
} }
public on: Eventer<PrivateEvents>['on'] = (type, lisenter) => { public on: Eventer<PrivateEvents>["on"] = (type, lisenter) => {
const off = super.on(type, lisenter); const off = super.on(type, lisenter);
if (type === 'load' && this.inital_steps) { if (type === "load" && this.inital_steps) {
lisenter(this.inital_steps); lisenter(this.inital_steps);
this.inital_steps = undefined; this.inital_steps = undefined;
} }
@@ -37,13 +39,10 @@ export class FlowModel {
public readonly [InternalField]: Injection; public readonly [InternalField]: Injection;
constructor(inital_steps?: WorkFlowSteps) { constructor(inital_steps?: WorkFlowSteps) {
this[InternalField] = new Injection( this[InternalField] = new Injection(this.eventer.emit.bind(this.eventer), inital_steps);
this.eventer.emit.bind(this.eventer),
inital_steps,
);
} }
public load(steps: WorkFlowSteps) { public load(steps: WorkFlowSteps) {
this[InternalField].emit('load', steps); this[InternalField].emit("load", steps);
} }
} }
@@ -1,4 +1,4 @@
import { Node, Edge } from '@xyflow/react'; import type { Edge, Node } from "@xyflow/react";
const DEFAULT_NODE_WIDTH = 120; const DEFAULT_NODE_WIDTH = 120;
const DEFAULT_NODE_HEIGHT = 50; const DEFAULT_NODE_HEIGHT = 50;
@@ -34,8 +34,8 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
// 构建图 // 构建图
for (const edge of edges) { for (const edge of edges) {
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) { if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
outgoing.get(edge.source)!.push(edge.target); outgoing.get(edge.source)?.push(edge.target);
incoming.get(edge.target)!.push(edge.source); incoming.get(edge.target)?.push(edge.source);
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1); inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
} }
} }
@@ -55,17 +55,17 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
const queue: string[] = []; const queue: string[] = [];
// 1. start 节点固定在第 0 层 // 1. start 节点固定在第 0 层
layers.set('start', 0); layers.set("start", 0);
queue.push('start'); queue.push("start");
// 2. BFS 分层(排除 end 节点,稍后单独处理) // 2. BFS 分层(排除 end 节点,稍后单独处理)
while (queue.length > 0) { while (queue.length > 0) {
const current = queue.shift()!; const current = queue.shift() ?? "";
const currentLayer = layers.get(current)!; const currentLayer = layers.get(current) ?? 0;
for (const target of outgoing.get(current) ?? []) { for (const target of outgoing.get(current) ?? []) {
// 跳过 end 节点,稍后处理 // 跳过 end 节点,稍后处理
if (target === 'end') continue; if (target === "end") continue;
const newLayer = currentLayer + 1; const newLayer = currentLayer + 1;
const existingLayer = layers.get(target); const existingLayer = layers.get(target);
@@ -93,7 +93,7 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
// 把它们放在中间层 // 把它们放在中间层
const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2)); const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2));
for (const node of nodes) { for (const node of nodes) {
if (node.id !== 'start' && node.id !== 'end' && !layers.has(node.id)) { if (node.id !== "start" && node.id !== "end" && !layers.has(node.id)) {
layers.set(node.id, middleLayer); layers.set(node.id, middleLayer);
} }
} }
@@ -101,13 +101,13 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
// 5. 重新计算最大层级(可能因为孤立节点而变化) // 5. 重新计算最大层级(可能因为孤立节点而变化)
maxLayer = 0; maxLayer = 0;
for (const [id, layer] of layers) { for (const [id, layer] of layers) {
if (id !== 'end') { if (id !== "end") {
maxLayer = Math.max(maxLayer, layer); maxLayer = Math.max(maxLayer, layer);
} }
} }
// 6. end 节点固定在最后一层 // 6. end 节点固定在最后一层
layers.set('end', maxLayer + 1); layers.set("end", maxLayer + 1);
return layers; return layers;
} }
@@ -123,7 +123,7 @@ function groupByLayer<N extends Node>(nodes: N[], layers: Map<string, number>):
if (!groups.has(layer)) { if (!groups.has(layer)) {
groups.set(layer, []); groups.set(layer, []);
} }
groups.get(layer)!.push(node); groups.get(layer)?.push(node);
} }
return groups; return groups;
@@ -152,7 +152,7 @@ function calculateLayerWidths(layerGroups: Map<number, Node[]>): Map<number, num
*/ */
function calculateLayerXPositions( function calculateLayerXPositions(
layerWidths: Map<number, number>, layerWidths: Map<number, number>,
maxLayer: number maxLayer: number,
): Map<number, number> { ): Map<number, number> {
const xPositions = new Map<number, number>(); const xPositions = new Map<number, number>();
let currentX = 0; let currentX = 0;
@@ -1,13 +1,13 @@
import type { Edge } from '@xyflow/react'; import type { Edge } from "@xyflow/react";
import { define } from '../context'; import { define } from "../context";
import { nodesModel } from './nodes'; import type { AnyWorkNode, RoleNodeData } from "../type";
import { edgesModel } from './edges'; import { edgesModel } from "./edges";
import type { RoleNodeData, AnyWorkNode } from '../type'; import { nodesModel } from "./nodes";
type ConnectHandle = { type ConnectHandle = {
id?: string | null; id?: string | null;
nodeId: string; nodeId: string;
type: 'source' | 'target'; type: "source" | "target";
}; };
export type AddNodeState = { export type AddNodeState = {
@@ -21,10 +21,10 @@ type CommitParams = {
}; };
function addNodeView() { function addNodeView() {
return null as (AddNodeState | null); return null as AddNodeState | null;
} }
export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, get, model) => { export const addNodeViewModel = define.view("addNodeView", addNodeView, (set, get, model) => {
function start(state: AddNodeState) { function start(state: AddNodeState) {
set(state); set(state);
} }
@@ -42,12 +42,19 @@ export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, ge
const { data } = params; const { data } = params;
const id = `n${Date.now()}`; const id = `n${Date.now()}`;
const node = { id, data, position, type: 'role' as const, origin: [0.0, 0.5] as [number, number] }; const node = {
id,
data,
position,
type: "role" as const,
origin: [0.0, 0.5] as [number, number],
};
const [fnid, fhid] = [fromNode.id, fromHandle.id]; const [fnid, fhid] = [fromNode.id, fromHandle.id];
const newEdge: Edge = fromHandle.type === 'source' const newEdge: Edge =
? { id: `e${fnid}-${id}`, source: fnid, target: id, sourceHandle: fhid, animated: true } fromHandle.type === "source"
: { id: `e${id}-${fnid}`, source: id, target: fnid, targetHandle: fhid, animated: true }; ? { id: `e${fnid}-${id}`, source: fnid, target: id, sourceHandle: fhid, animated: true }
: { id: `e${id}-${fnid}`, source: id, target: fnid, targetHandle: fhid, animated: true };
model.startTransaction(); model.startTransaction();
model.use(nodesModel)[1].set((nds) => nds.concat(node)); model.use(nodesModel)[1].set((nds) => nds.concat(node));
@@ -1,21 +1,16 @@
import { import { applyEdgeChanges, type Connection, type Edge, type EdgeChange } from "@xyflow/react";
applyEdgeChanges, import { define } from "../context";
type Edge,
type EdgeChange,
type Connection,
} from '@xyflow/react';
import { define } from '../context';
function makeEdges(): Edge[] { function makeEdges(): Edge[] {
return []; return [];
} }
function isInputHandle(handle: string | null | undefined): boolean { function isInputHandle(handle: string | null | undefined): boolean {
return handle === 'input' || handle === 'input-top' || handle === 'input-bottom'; return handle === "input" || handle === "input-top" || handle === "input-bottom";
} }
function isOutputHandle(handle: string | null | undefined): boolean { function isOutputHandle(handle: string | null | undefined): boolean {
return handle === 'output' || handle === 'output-top' || handle === 'output-bottom'; return handle === "output" || handle === "output-top" || handle === "output-bottom";
} }
function normalizeConnection(params: Edge | Connection): Edge | Connection { function normalizeConnection(params: Edge | Connection): Edge | Connection {
@@ -33,10 +28,10 @@ function normalizeConnection(params: Edge | Connection): Edge | Connection {
let edgeCounter = 0; let edgeCounter = 0;
export const edgesModel = define.model('edges', makeEdges, (set, get, model) => { export const edgesModel = define.model("edges", makeEdges, (set, get, model) => {
function onEdgesChange(changes: EdgeChange[]) { function onEdgesChange(changes: EdgeChange[]) {
const whites = new Set(['add', 'replace']); const whites = new Set(["add", "replace"]);
if (changes.some(c => whites.has(c.type))) { if (changes.some((c) => whites.has(c.type))) {
model.startTransaction(); model.startTransaction();
set((eds) => applyEdgeChanges(changes, eds)); set((eds) => applyEdgeChanges(changes, eds));
requestAnimationFrame(model.endTransaction); requestAnimationFrame(model.endTransaction);
@@ -54,7 +49,7 @@ export const edgesModel = define.model('edges', makeEdges, (set, get, model) =>
const currentEdges = get(); const currentEdges = get();
const duplicate = currentEdges.some( const duplicate = currentEdges.some(
e => e.source === normalized.source && e.target === normalized.target, (e) => e.source === normalized.source && e.target === normalized.target,
); );
if (duplicate) return; if (duplicate) return;
@@ -67,15 +62,15 @@ export const edgesModel = define.model('edges', makeEdges, (set, get, model) =>
animated: true, animated: true,
} as Edge; } as Edge;
const existingFromSource = currentEdges.filter(e => e.source === normalized.source); const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
if (existingFromSource.length > 0) { if (existingFromSource.length > 0) {
edge.type = 'conditional'; edge.type = "conditional";
edge.data = { condition: '' }; edge.data = { condition: "" };
const promoted = currentEdges.map(e => { const promoted = currentEdges.map((e) => {
if (e.source === normalized.source && e.type !== 'conditional') { if (e.source === normalized.source && e.type !== "conditional") {
return { ...e, type: 'conditional' as const, data: { condition: '' } }; return { ...e, type: "conditional" as const, data: { condition: "" } };
} }
return e; return e;
}); });
@@ -1,21 +1,21 @@
import { define } from '../context'; import { define } from "../context";
import { nodesModel } from './nodes'; import type { RoleNodeData, WorkNode } from "../type";
import type { RoleNodeData, WorkNode } from '../type'; import { nodesModel } from "./nodes";
export type EditNodeState = { export type EditNodeState = {
node: WorkNode<'role'>; node: WorkNode<"role">;
}; };
function editNodeView() { function editNodeView() {
return null as (EditNodeState | null); return null as EditNodeState | null;
} }
export const editNodeViewModel = define.view('editNodeView', editNodeView, (set, get, model) => { export const editNodeViewModel = define.view("editNodeView", editNodeView, (set, get, model) => {
function start(nodeId: string) { function start(nodeId: string) {
const [nodes] = model.use(nodesModel); const [nodes] = model.use(nodesModel);
const node = nodes.find(n => n.id === nodeId); const node = nodes.find((n) => n.id === nodeId);
if (!node || node.type !== 'role') return; if (!node || node.type !== "role") return;
set({ node: node as WorkNode<'role'> }); set({ node: node as WorkNode<"role"> });
} }
function cancel() { function cancel() {
@@ -31,6 +31,7 @@ export const editNodeViewModel = define.view('editNodeView', editNodeView, (set,
model.startTransaction(); model.startTransaction();
editNode(state.node.id, (node) => { editNode(state.node.id, (node) => {
// biome-ignore lint/suspicious/noExplicitAny: node data type varies by node kind
node.data = data as any; node.data = data as any;
}); });
requestAnimationFrame(model.endTransaction); requestAnimationFrame(model.endTransaction);
@@ -1,14 +1,14 @@
import type { OnNodeDrag, OnConnectEnd, OnBeforeDelete, OnDelete } from '@xyflow/react'; import type { OnBeforeDelete, OnConnectEnd, OnDelete, OnNodeDrag } from "@xyflow/react";
import { define } from '../context'; import { define } from "../context";
import { addNodeViewModel } from './add-node-view'; import { LayoutLR } from "../layout";
import type { AnyWorkNode } from '../type'; import type { WorkFlowSteps } from "../trans";
import { LayoutLR } from '../layout'; import { transIn, transOut, validate } from "../trans";
import { nodesModel } from './nodes'; import type { AnyWorkNode } from "../type";
import { edgesModel } from './edges'; import { addNodeViewModel } from "./add-node-view";
import { injection } from './inject'; import { edgesModel } from "./edges";
import { transIn, transOut, validate } from '../trans'; import { editNodeViewModel } from "./edit-node-view";
import type { WorkFlowSteps } from '../trans'; import { injection } from "./inject";
import { editNodeViewModel } from './edit-node-view'; import { nodesModel } from "./nodes";
export const handlers = define.memoize((use, model) => { export const handlers = define.memoize((use, model) => {
const onNodeDragStart: OnNodeDrag<AnyWorkNode> = () => { const onNodeDragStart: OnNodeDrag<AnyWorkNode> = () => {
@@ -23,6 +23,7 @@ export const handlers = define.memoize((use, model) => {
if (!to || !fromHandle || !fromNode) return; if (!to || !fromHandle || !fromNode) return;
const { clientX, clientY } = event as MouseEvent; const { clientX, clientY } = event as MouseEvent;
use(addNodeViewModel)[1].start({ use(addNodeViewModel)[1].start({
// biome-ignore lint/suspicious/noExplicitAny: ReactFlow node type mismatch
fromNode: fromNode as any as AnyWorkNode, fromNode: fromNode as any as AnyWorkNode,
fromHandle: fromHandle, fromHandle: fromHandle,
position: model.flow.screenToFlowPosition({ x: clientX, y: clientY }), position: model.flow.screenToFlowPosition({ x: clientX, y: clientY }),
@@ -31,15 +32,17 @@ export const handlers = define.memoize((use, model) => {
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => { const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
for (const node of nodes) { for (const node of nodes) {
if (node.type === 'start' || node.type === 'end') { if (node.type === "start" || node.type === "end") {
return false; return false;
} }
} }
if (edges.length > 0) { if (edges.length > 0) {
const allEdges = use(edgesModel)[0]; const allEdges = use(edgesModel)[0];
for (const edge of edges) { for (const edge of edges) {
if (edge.type !== 'conditional') continue; if (edge.type !== "conditional") continue;
const siblings = allEdges.filter(e => e.source === edge.source && e.type === 'conditional'); const siblings = allEdges.filter(
(e) => e.source === edge.source && e.type === "conditional",
);
if (siblings.length >= 2 && siblings[0].id === edge.id) { if (siblings.length >= 2 && siblings[0].id === edge.id) {
return false; return false;
} }
@@ -52,20 +55,20 @@ export const handlers = define.memoize((use, model) => {
if (deletedEdges.length > 0) { if (deletedEdges.length > 0) {
const currentEdges = use(edgesModel)[0]; const currentEdges = use(edgesModel)[0];
const sourcesToCheck = new Set( const sourcesToCheck = new Set(
deletedEdges deletedEdges.filter((e) => e.type === "conditional").map((e) => e.source),
.filter(e => e.type === 'conditional')
.map(e => e.source),
); );
if (sourcesToCheck.size > 0) { if (sourcesToCheck.size > 0) {
let needsDowngrade = false; let needsDowngrade = false;
const updatedEdges = currentEdges.map(e => { const updatedEdges = currentEdges.map((e) => {
if (!sourcesToCheck.has(e.source) || e.type !== 'conditional') return e; if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
const siblings = currentEdges.filter(s => s.source === e.source && s.type === 'conditional'); const siblings = currentEdges.filter(
(s) => s.source === e.source && s.type === "conditional",
);
if (siblings.length === 1) { if (siblings.length === 1) {
needsDowngrade = true; needsDowngrade = true;
const { data: _, ...rest } = e; const { data: _, ...rest } = e;
return { ...rest, type: 'default' as const }; return { ...rest, type: "default" as const };
} }
return e; return e;
}); });
@@ -94,7 +97,7 @@ export const handlers = define.memoize((use, model) => {
} }
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === 'Escape') { if (event.code === "Escape") {
const [addView, addViewActions] = use(addNodeViewModel); const [addView, addViewActions] = use(addNodeViewModel);
const [editView, editViewActions] = use(editNodeViewModel); const [editView, editViewActions] = use(editNodeViewModel);
if (addView) addViewActions.cancel(); if (addView) addViewActions.cancel();
@@ -102,12 +105,12 @@ export const handlers = define.memoize((use, model) => {
return; return;
} }
if (event.code === 'KeyZ') { if (event.code === "KeyZ") {
if (event.ctrlKey || event.metaKey) { if (event.ctrlKey || event.metaKey) {
if (event.shiftKey) model.redo(); if (event.shiftKey) model.redo();
else model.undo(); else model.undo();
} }
} else if (event.code === 'KeyY') { } else if (event.code === "KeyY") {
if (event.ctrlKey || event.metaKey) { if (event.ctrlKey || event.metaKey) {
model.redo(); model.redo();
} }
@@ -130,7 +133,7 @@ export const handlers = define.memoize((use, model) => {
if (result.valid) { if (result.valid) {
const steps = transOut(nodes, edges); const steps = transOut(nodes, edges);
const instance = use(injection)[0]; const instance = use(injection)[0];
instance.emitPublic('save', steps); instance.emitPublic("save", steps);
} }
return result; return result;
} }
@@ -1,6 +1,6 @@
export { nodesModel } from './nodes'; export { type AddNodeState, addNodeViewModel } from "./add-node-view";
export { edgesModel } from './edges'; export { edgesModel } from "./edges";
export { addNodeViewModel, type AddNodeState } from './add-node-view'; export { type EditNodeState, editNodeViewModel } from "./edit-node-view";
export { editNodeViewModel, type EditNodeState } from './edit-node-view'; export { handlers } from "./handlers";
export { handlers } from './handlers'; export { injection } from "./inject";
export { injection } from './inject'; export { nodesModel } from "./nodes";
@@ -3,8 +3,7 @@
*/ */
import { define } from "../context.tsx"; import { define } from "../context.tsx";
import { Injection } from '../injection.ts'; import { Injection } from "../injection.ts";
const NOOP = () => {}; const NOOP = () => {};
const placeholder = new Injection(NOOP); const placeholder = new Injection(NOOP);
@@ -13,7 +12,7 @@ function make(): Injection {
return placeholder; return placeholder;
} }
export const injection = define.view('injection', make, (set) => { export const injection = define.view("injection", make, (set) => {
function reset() { function reset() {
set(make()); set(make());
} }
@@ -1,48 +1,49 @@
import { produce, type Draft } from 'immer'; import { applyNodeChanges, type NodeChange } from "@xyflow/react";
import { applyNodeChanges, NodeChange } from '@xyflow/react'; import { type Draft, produce } from "immer";
import { define } from '../context'; import { define } from "../context";
import type { AnyWorkNode } from '../type'; import type { AnyWorkNode } from "../type";
function makeNodes(): AnyWorkNode[] { function makeNodes(): AnyWorkNode[] {
return [ return [
{ {
id: 'start', id: "start",
type: 'start', type: "start",
data: { label: 'Start' }, data: { label: "Start" },
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
}, },
{ {
id: 'end', id: "end",
data: { label: 'End' }, data: { label: "End" },
position: { x: 1000, y: 0 }, position: { x: 1000, y: 0 },
type: 'end', type: "end",
}, },
]; ];
} }
export const nodesModel = define.model('nodes', makeNodes, (set, _get, model) => { export const nodesModel = define.model("nodes", makeNodes, (set, _get, model) => {
const whites = new Set<NodeChange['type']>(['add', 'replace']); const whites = new Set<NodeChange["type"]>(["add", "replace"]);
function onNodesChange(changes: NodeChange<AnyWorkNode>[]) { function onNodesChange(changes: NodeChange<AnyWorkNode>[]) {
if (changes.some(c => whites.has(c.type))) { if (changes.some((c) => whites.has(c.type))) {
model.startTransaction(); model.startTransaction();
set((nds) => applyNodeChanges(changes, nds)); set((nds) => applyNodeChanges(changes, nds));
requestAnimationFrame(model.endTransaction); requestAnimationFrame(model.endTransaction);
return; return;
} }
set((nds) => applyNodeChanges(changes, nds)); set((nds) => applyNodeChanges(changes, nds));
}; }
function editNode(id: string, updater: (node: Draft<AnyWorkNode>) => void) { function editNode(id: string, updater: (node: Draft<AnyWorkNode>) => void) {
set(produce((draft) => { set(
const node = draft.find(n => n.id === id); produce((draft) => {
if (node) updater(node); const node = draft.find((n) => n.id === id);
})); if (node) updater(node);
}),
);
} }
function deleteNode(id: string) { function deleteNode(id: string) {
model.startTransaction(); model.startTransaction();
set((nds) => nds.filter(n => n.id !== id)); set((nds) => nds.filter((n) => n.id !== id));
requestAnimationFrame(model.endTransaction); requestAnimationFrame(model.endTransaction);
} }
@@ -1,23 +1,19 @@
import { Handle, Position, Node, NodeProps } from '@xyflow/react'; import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
import { EndNode } from './nodes.style'; import { EndNode } from "./nodes.style";
interface NodeData { interface NodeData {
label: string; label: string;
[key: string]: unknown; [key: string]: unknown;
} }
type NodeType = Node<NodeData, 'end'>; type NodeType = Node<NodeData, "end">;
type Props = NodeProps<NodeType>; type Props = NodeProps<NodeType>;
export function NodeEnd({ data }: Props) { export function NodeEnd({ data }: Props) {
return ( return (
<EndNode> <EndNode>
<Handle <Handle type="target" position={Position.Left} id="input" />
type="target" {data?.label || "End"}
position={Position.Left}
id="input"
/>
{data?.label || 'End'}
</EndNode> </EndNode>
); );
} }
@@ -1,6 +1,6 @@
import { NodeStart } from './start'; import { NodeEnd } from "./end";
import { NodeEnd } from './end'; import { NodeRole } from "./role";
import { NodeRole } from './role'; import { NodeStart } from "./start";
export const nodeTypes = { export const nodeTypes = {
start: NodeStart, start: NodeStart,
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Pencil, Trash2 } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "../../components/ui/button.tsx"; import { Button } from "../../components/ui/button.tsx";
type Props = { type Props = {
@@ -13,7 +13,13 @@ export function NodeToolbarActions({ onEdit, onDelete }: Props): ReactNode {
<Button variant="ghost" size="icon-xs" onClick={onEdit} title="编辑"> <Button variant="ghost" size="icon-xs" onClick={onEdit} title="编辑">
<Pencil /> <Pencil />
</Button> </Button>
<Button variant="ghost" size="icon-xs" className="hover:bg-destructive/10 hover:text-destructive" onClick={onDelete} title="删除"> <Button
variant="ghost"
size="icon-xs"
className="hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="删除"
>
<Trash2 /> <Trash2 />
</Button> </Button>
</div> </div>
@@ -45,12 +45,7 @@ export function NodeContent({ children }: { children: ReactNode }): ReactNode {
export function NodeIcon({ className, children }: Props): ReactNode { export function NodeIcon({ className, children }: Props): ReactNode {
return ( return (
<div <div className={cn("flex items-center justify-center w-8 h-8 rounded-lg shrink-0", className)}>
className={cn(
"flex items-center justify-center w-8 h-8 rounded-lg shrink-0",
className,
)}
>
{children} {children}
</div> </div>
); );
@@ -62,23 +57,14 @@ export function NodeBody({ children }: { children: ReactNode }): ReactNode {
export function NodeKindLabel({ className, children }: Props): ReactNode { export function NodeKindLabel({ className, children }: Props): ReactNode {
return ( return (
<div <div className={cn("text-[10px] font-semibold uppercase tracking-wide mb-1", className)}>
className={cn(
"text-[10px] font-semibold uppercase tracking-wide mb-1",
className,
)}
>
{children} {children}
</div> </div>
); );
} }
export function NodeHint({ children }: { children: ReactNode }): ReactNode { export function NodeHint({ children }: { children: ReactNode }): ReactNode {
return ( return <div className="text-[13px] text-gray-800 leading-snug break-words">{children}</div>;
<div className="text-[13px] text-gray-800 leading-snug break-words">
{children}
</div>
);
} }
export function NodeSubHint({ children }: { children: ReactNode }): ReactNode { export function NodeSubHint({ children }: { children: ReactNode }): ReactNode {
@@ -93,8 +79,6 @@ export function RoleIcon({ children }: { children: ReactNode }): ReactNode {
); );
} }
export function RoleKindLabel({ export function RoleKindLabel({ children }: { children: ReactNode }): ReactNode {
children,
}: { children: ReactNode }): ReactNode {
return <NodeKindLabel className="text-teal-700">{children}</NodeKindLabel>; return <NodeKindLabel className="text-teal-700">{children}</NodeKindLabel>;
} }
@@ -1,24 +1,20 @@
import { Handle, Position, NodeToolbar, useNodeConnections, type NodeProps } from '@xyflow/react'; import { Handle, type NodeProps, NodeToolbar, Position, useNodeConnections } from "@xyflow/react";
import { Users } from 'lucide-react'; import { Users } from "lucide-react";
import { import { useMemo } from "react";
NodeContent, import { useReadonly } from "../flow";
NodeBody, import { nodesModel } from "../model";
RoleIcon, import { editNodeViewModel } from "../model/edit-node-view";
RoleKindLabel, import type { WorkNode } from "../type";
NodeHint, import { NodeToolbarActions } from "./node-toolbar";
} from './nodes.style'; import { NodeBody, NodeContent, NodeHint, RoleIcon, RoleKindLabel } from "./nodes.style";
import { NodeToolbarActions } from './node-toolbar';
import { editNodeViewModel } from '../model/edit-node-view';
import { nodesModel } from '../model';
import type { WorkNode } from '../type';
import { useMemo, type ReactNode } from 'react';
import { useReadonly } from '../flow';
type Props = NodeProps<WorkNode<'role'>>; type Props = NodeProps<WorkNode<"role">>;
const containerClass = "bg-white border border-gray-200 rounded-[10px] shadow-sm transition-all duration-200 hover:shadow-md hover:border-gray-400 [&_.react-flow\\_\\_handle]:w-3 [&_.react-flow\\_\\_handle]:h-3 [&_.react-flow\\_\\_handle]:border-2 [&_.react-flow\\_\\_handle]:transition-all [&_.react-flow\\_\\_handle]:duration-150"; const containerClass =
"bg-white border border-gray-200 rounded-[10px] shadow-sm transition-all duration-200 hover:shadow-md hover:border-gray-400 [&_.react-flow\\_\\_handle]:w-3 [&_.react-flow\\_\\_handle]:h-3 [&_.react-flow\\_\\_handle]:border-2 [&_.react-flow\\_\\_handle]:transition-all [&_.react-flow\\_\\_handle]:duration-150";
const targetClass = "!bg-blue-100 !border-blue-500 hover:!bg-blue-500 hover:!border-blue-600"; const targetClass = "!bg-blue-100 !border-blue-500 hover:!bg-blue-500 hover:!border-blue-600";
const sourceClass = "!bg-emerald-100 !border-emerald-500 hover:!bg-emerald-500 hover:!border-emerald-600"; const sourceClass =
"!bg-emerald-100 !border-emerald-500 hover:!bg-emerald-500 hover:!border-emerald-600";
export function NodeRole({ data, id, selected }: Props) { export function NodeRole({ data, id, selected }: Props) {
const startEdit = editNodeViewModel.useCreation().start; const startEdit = editNodeViewModel.useCreation().start;
@@ -35,8 +31,14 @@ export function NodeRole({ data, id, selected }: Props) {
return set; return set;
}, [connections, id]); }, [connections, id]);
const hasInputConnection = connectedHandles.has('input') || connectedHandles.has('input-top') || connectedHandles.has('input-bottom'); const hasInputConnection =
const hasOutputConnection = connectedHandles.has('output') || connectedHandles.has('output-top') || connectedHandles.has('output-bottom'); connectedHandles.has("input") ||
connectedHandles.has("input-top") ||
connectedHandles.has("input-bottom");
const hasOutputConnection =
connectedHandles.has("output") ||
connectedHandles.has("output-top") ||
connectedHandles.has("output-bottom");
const showHandle = (handleId: string, alwaysShow: boolean) => { const showHandle = (handleId: string, alwaysShow: boolean) => {
if (readonly) return connectedHandles.has(handleId); if (readonly) return connectedHandles.has(handleId);
@@ -45,9 +47,35 @@ export function NodeRole({ data, id, selected }: Props) {
return ( return (
<div className={containerClass}> <div className={containerClass}>
{showHandle('input', true) && <Handle type="target" position={Position.Left} id="input" className={targetClass} isConnectableStart />} {showHandle("input", true) && (
{showHandle('input-top', hasInputConnection) && <Handle type="target" position={Position.Top} id="input-top" style={{ left: '30%' }} className={targetClass} isConnectableStart />} <Handle
{showHandle('input-bottom', hasInputConnection) && <Handle type="target" position={Position.Bottom} id="input-bottom" style={{ left: '30%' }} className={targetClass} isConnectableStart />} type="target"
position={Position.Left}
id="input"
className={targetClass}
isConnectableStart
/>
)}
{showHandle("input-top", hasInputConnection) && (
<Handle
type="target"
position={Position.Top}
id="input-top"
style={{ left: "30%" }}
className={targetClass}
isConnectableStart
/>
)}
{showHandle("input-bottom", hasInputConnection) && (
<Handle
type="target"
position={Position.Bottom}
id="input-bottom"
style={{ left: "30%" }}
className={targetClass}
isConnectableStart
/>
)}
<NodeContent> <NodeContent>
<RoleIcon> <RoleIcon>
<Users size={16} /> <Users size={16} />
@@ -58,14 +86,37 @@ export function NodeRole({ data, id, selected }: Props) {
</NodeBody> </NodeBody>
</NodeContent> </NodeContent>
<NodeToolbar isVisible={selected && !readonly} position={Position.Bottom}> <NodeToolbar isVisible={selected && !readonly} position={Position.Bottom}>
<NodeToolbarActions <NodeToolbarActions onEdit={() => startEdit(id)} onDelete={() => deleteNode(id)} />
onEdit={() => startEdit(id)}
onDelete={() => deleteNode(id)}
/>
</NodeToolbar> </NodeToolbar>
{showHandle('output', true) && <Handle type="source" position={Position.Right} id="output" className={sourceClass} isConnectableEnd />} {showHandle("output", true) && (
{showHandle('output-top', hasOutputConnection) && <Handle type="source" position={Position.Top} id="output-top" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />} <Handle
{showHandle('output-bottom', hasOutputConnection) && <Handle type="source" position={Position.Bottom} id="output-bottom" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />} type="source"
position={Position.Right}
id="output"
className={sourceClass}
isConnectableEnd
/>
)}
{showHandle("output-top", hasOutputConnection) && (
<Handle
type="source"
position={Position.Top}
id="output-top"
style={{ left: "70%" }}
className={sourceClass}
isConnectableEnd
/>
)}
{showHandle("output-bottom", hasOutputConnection) && (
<Handle
type="source"
position={Position.Bottom}
id="output-bottom"
style={{ left: "70%" }}
className={sourceClass}
isConnectableEnd
/>
)}
</div> </div>
); );
} }
@@ -1,13 +1,13 @@
import { Handle, Position, Node, NodeProps, useNodeConnections } from '@xyflow/react'; import { Handle, type Node, type NodeProps, Position, useNodeConnections } from "@xyflow/react";
import { StartNode } from './nodes.style'; import { useMemo } from "react";
import { useMemo } from 'react'; import { StartNode } from "./nodes.style";
interface NodeData { interface NodeData {
label: string; label: string;
[key: string]: unknown; [key: string]: unknown;
} }
type NodeType = Node<NodeData, 'start'>; type NodeType = Node<NodeData, "start">;
type Props = NodeProps<NodeType>; type Props = NodeProps<NodeType>;
export function NodeStart({ data, id }: Props) { export function NodeStart({ data, id }: Props) {
@@ -19,7 +19,7 @@ export function NodeStart({ data, id }: Props) {
return ( return (
<StartNode> <StartNode>
{data?.label || 'Start'} {data?.label || "Start"}
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
@@ -1,16 +1,16 @@
import { useState, useEffect, type ReactNode } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { addNodeViewModel, type AddNodeState } from "../model/index.ts"; import { Button } from "../../components/ui/button.tsx";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter,
} from "../../components/ui/dialog.tsx"; } from "../../components/ui/dialog.tsx";
import { Input } from "../../components/ui/input.tsx"; import { Input } from "../../components/ui/input.tsx";
import { Textarea } from "../../components/ui/textarea.tsx";
import { Button } from "../../components/ui/button.tsx";
import { Label } from "../../components/ui/label.tsx"; import { Label } from "../../components/ui/label.tsx";
import { Textarea } from "../../components/ui/textarea.tsx";
import { type AddNodeState, addNodeViewModel } from "../model/index.ts";
import type { RoleNodeData } from "../type.ts"; import type { RoleNodeData } from "../type.ts";
type FormProps = { type FormProps = {
@@ -34,7 +34,7 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
setPrepare(""); setPrepare("");
setExecute(""); setExecute("");
setReport(""); setReport("");
}, [state]); }, []);
function handleConfirm() { function handleConfirm() {
if (!name.trim()) return; if (!name.trim()) return;
@@ -59,11 +59,7 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1"> <div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> *</Label> <Label className="text-xs text-muted-foreground"> *</Label>
<Input <Input value={name} onChange={(e) => setName(e.target.value)} placeholder="角色名称" />
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="角色名称"
/>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"></Label> <Label className="text-xs text-muted-foreground"></Label>
@@ -136,7 +132,9 @@ export function AddNodeDialog(): ReactNode {
return ( return (
<Dialog <Dialog
open={state !== null} open={state !== null}
onOpenChange={(open) => { if (!open) cancel(); }} onOpenChange={(open) => {
if (!open) cancel();
}}
> >
<DialogContent showCloseButton={false} className="sm:max-w-md"> <DialogContent showCloseButton={false} className="sm:max-w-md">
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />} {state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
@@ -1,19 +1,16 @@
import { useState, useEffect, type ReactNode } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { import { Button } from "../../components/ui/button.tsx";
editNodeViewModel,
type EditNodeState,
} from "../model/edit-node-view.ts";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter,
} from "../../components/ui/dialog.tsx"; } from "../../components/ui/dialog.tsx";
import { Input } from "../../components/ui/input.tsx"; import { Input } from "../../components/ui/input.tsx";
import { Textarea } from "../../components/ui/textarea.tsx";
import { Button } from "../../components/ui/button.tsx";
import { Label } from "../../components/ui/label.tsx"; import { Label } from "../../components/ui/label.tsx";
import { Textarea } from "../../components/ui/textarea.tsx";
import { type EditNodeState, editNodeViewModel } from "../model/edit-node-view.ts";
import type { RoleNodeData } from "../type.ts"; import type { RoleNodeData } from "../type.ts";
type FormProps = { type FormProps = {
@@ -61,11 +58,7 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1"> <div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> *</Label> <Label className="text-xs text-muted-foreground"> *</Label>
<Input <Input value={name} onChange={(e) => setName(e.target.value)} placeholder="角色名称" />
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="角色名称"
/>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"></Label> <Label className="text-xs text-muted-foreground"></Label>
@@ -138,7 +131,9 @@ export function EditNodeDialog(): ReactNode {
return ( return (
<Dialog <Dialog
open={state !== null} open={state !== null}
onOpenChange={(open) => { if (!open) cancel(); }} onOpenChange={(open) => {
if (!open) cancel();
}}
> >
<DialogContent showCloseButton={false} className="sm:max-w-md"> <DialogContent showCloseButton={false} className="sm:max-w-md">
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />} {state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
@@ -1,8 +1,7 @@
import { Panel } from '@xyflow/react'; import { Panel } from "@xyflow/react";
import { AddNodeDialog } from './add-node'; import { AddNodeDialog } from "./add-node";
import { EditNodeDialog } from './edit-node'; import { EditNodeDialog } from "./edit-node";
import { Toolbar } from './toolbar'; import { Toolbar } from "./toolbar";
export function Dialogs() { export function Dialogs() {
return ( return (
@@ -13,7 +12,6 @@ export function Dialogs() {
); );
} }
export function TopCenterPanel() { export function TopCenterPanel() {
return ( return (
<Panel position="top-center"> <Panel position="top-center">
@@ -1,28 +1,21 @@
import { type ReactNode } from "react";
import {
Undo2,
Redo2,
Users,
LayoutList,
Save,
} from "lucide-react";
import { useReactFlow, useStoreApi } from "@xyflow/react"; import { useReactFlow, useStoreApi } from "@xyflow/react";
import { LayoutList, Redo2, Save, Undo2, Users } from "lucide-react";
import { type ReactNode, useState } from "react";
import { Button } from "../../components/ui/button.tsx";
import { Separator } from "../../components/ui/separator.tsx";
import { cn } from "../../lib/utils.ts";
import { useModel } from "../context.tsx"; import { useModel } from "../context.tsx";
import { handlers, nodesModel } from "../model/index.ts"; import { handlers, nodesModel } from "../model/index.ts";
import { Separator } from "../../components/ui/separator.tsx";
import { Button } from "../../components/ui/button.tsx";
import type { RoleNodeData, WorkNode } from "../type.ts"; import type { RoleNodeData, WorkNode } from "../type.ts";
import { uuid } from "../utils/index.ts"; import { uuid } from "../utils/index.ts";
import { useState } from "react";
import { cn } from "../../lib/utils.ts";
const DEFAULT_ROLE_DATA: RoleNodeData = { const DEFAULT_ROLE_DATA: RoleNodeData = {
name: '新角色', name: "新角色",
description: '', description: "",
identity: '', identity: "",
prepare: '', prepare: "",
execute: '', execute: "",
report: '', report: "",
}; };
export function Toolbar(): ReactNode { export function Toolbar(): ReactNode {
@@ -48,9 +41,9 @@ export function Toolbar(): ReactNode {
const centerY = (height / 2 - y) / zoom; const centerY = (height / 2 - y) / zoom;
const id = `n${uuid()}`; const id = `n${uuid()}`;
const node: WorkNode<'role'> = { const node: WorkNode<"role"> = {
id, id,
type: 'role', type: "role",
position: { x: centerX - 80, y: centerY - 40 }, position: { x: centerX - 80, y: centerY - 40 },
data: { ...DEFAULT_ROLE_DATA }, data: { ...DEFAULT_ROLE_DATA },
}; };
@@ -63,10 +56,22 @@ export function Toolbar(): ReactNode {
return ( return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-[10px] shadow-md"> <div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-[10px] shadow-md">
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon-sm" title="撤销 (Undo)" onClick={handleUndo} disabled={!canUndo}> <Button
variant="ghost"
size="icon-sm"
title="撤销 (Undo)"
onClick={handleUndo}
disabled={!canUndo}
>
<Undo2 /> <Undo2 />
</Button> </Button>
<Button variant="ghost" size="icon-sm" title="重做 (Redo)" onClick={handleRedo} disabled={!canRedo}> <Button
variant="ghost"
size="icon-sm"
title="重做 (Redo)"
onClick={handleRedo}
disabled={!canRedo}
>
<Redo2 /> <Redo2 />
</Button> </Button>
</div> </div>
@@ -101,14 +106,12 @@ function SaveButton(): ReactNode {
if (valid) { if (valid) {
setToast({ open: true, severity: "success", message: "流程保存成功" }); setToast({ open: true, severity: "success", message: "流程保存成功" });
} else { } else {
const errorMessages = errors.map( const errorMessages = errors.map(({ message, nodeId }) => (
({ message, nodeId }) => ( <div key={nodeId ?? message}>
<div key={nodeId ?? message}> {nodeId ? `节点 ${nodeId}` : ""}
{nodeId ? `节点 ${nodeId}` : ""} {message}
{message} </div>
</div> ));
),
);
setToast({ setToast({
open: true, open: true,
severity: "error", severity: "error",
@@ -1,4 +1,4 @@
export * from './type'; export * from "./trans-in";
export * from './trans-in'; export * from "./trans-out";
export * from './trans-out'; export * from "./type";
export * from './validate'; export * from "./validate";
@@ -1,20 +1,20 @@
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type'; import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
import type { WorkFlowStep } from './type'; import { uuid } from "../utils";
import { uuid } from '../utils'; import type { WorkFlowStep } from "./type";
type Result = { type Result = {
nodes: AnyWorkNode[]; nodes: AnyWorkNode[];
edges: AnyWorkEdge[]; edges: AnyWorkEdge[];
}; };
const OUT_HANDLES = ['output-top', 'output', 'output-bottom'] as const; const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
const IN_HANDLES = ['input-top', 'input', 'input-bottom'] as const; const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
function assignHandles( function assignHandles(
indices: number[], indices: number[],
edges: AnyWorkEdge[], edges: AnyWorkEdge[],
handles: readonly string[], handles: readonly string[],
key: 'sourceHandle' | 'targetHandle', key: "sourceHandle" | "targetHandle",
): void { ): void {
if (indices.length === 1) { if (indices.length === 1) {
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] }; edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
@@ -29,8 +29,18 @@ function assignHandles(
} }
export function transIn(steps: WorkFlowStep[]): Result { export function transIn(steps: WorkFlowStep[]): Result {
const startNode: AnyWorkNode = { id: 'start', type: 'start', data: { label: 'Start' }, position: { x: 0, y: 0 } }; const startNode: AnyWorkNode = {
const endNode: AnyWorkNode = { id: 'end', type: 'end', data: { label: 'End' }, position: { x: 250, y: 0 } }; id: "start",
type: "start",
data: { label: "Start" },
position: { x: 0, y: 0 },
};
const endNode: AnyWorkNode = {
id: "end",
type: "end",
data: { label: "End" },
position: { x: 250, y: 0 },
};
if (steps.length === 0) { if (steps.length === 0) {
return { nodes: [startNode, endNode], edges: [] }; return { nodes: [startNode, endNode], edges: [] };
@@ -40,9 +50,9 @@ export function transIn(steps: WorkFlowStep[]): Result {
const edges: AnyWorkEdge[] = []; const edges: AnyWorkEdge[] = [];
const nameToId = new Map<string, string>(); const nameToId = new Map<string, string>();
const idToOrder = new Map<string, number>(); const idToOrder = new Map<string, number>();
nameToId.set('END', 'end'); nameToId.set("END", "end");
idToOrder.set('start', -1); idToOrder.set("start", -1);
idToOrder.set('end', steps.length); idToOrder.set("end", steps.length);
for (let si = 0; si < steps.length; si++) { for (let si = 0; si < steps.length; si++) {
const step = steps[si]; const step = steps[si];
@@ -51,25 +61,25 @@ export function transIn(steps: WorkFlowStep[]): Result {
idToOrder.set(nodeId, si); idToOrder.set(nodeId, si);
nodes.push({ nodes.push({
id: nodeId, id: nodeId,
type: 'role', type: "role",
data: { ...step.role }, data: { ...step.role },
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
}); });
} }
const firstStepId = nameToId.get(steps[0].role.name)!; const firstStepId = nameToId.get(steps[0].role.name) ?? "";
edges.push({ edges.push({
id: `e-start-${firstStepId}`, id: `e-start-${firstStepId}`,
source: 'start', source: "start",
sourceHandle: 'output', sourceHandle: "output",
target: firstStepId, target: firstStepId,
targetHandle: 'input', targetHandle: "input",
animated: true, animated: true,
}); });
for (const step of steps) { for (const step of steps) {
const sourceId = nameToId.get(step.role.name)!; const sourceId = nameToId.get(step.role.name) ?? "";
const sourceOrder = idToOrder.get(sourceId)!; const _sourceOrder = idToOrder.get(sourceId) ?? 0;
const hasMultipleTransitions = step.transitions.length > 1; const hasMultipleTransitions = step.transitions.length > 1;
const sorted = hasMultipleTransitions const sorted = hasMultipleTransitions
@@ -95,10 +105,10 @@ export function transIn(steps: WorkFlowStep[]): Result {
id: edgeId, id: edgeId,
source: sourceId, source: sourceId,
target: targetId, target: targetId,
sourceHandle: 'output', sourceHandle: "output",
targetHandle: 'input', targetHandle: "input",
type: 'conditional', type: "conditional",
data: { condition: t.condition ?? '' }, data: { condition: t.condition ?? "" },
animated: true, animated: true,
}; };
if (hasMultipleTransitions && i === 0) { if (hasMultipleTransitions && i === 0) {
@@ -111,8 +121,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
id: edgeId, id: edgeId,
source: sourceId, source: sourceId,
target: targetId, target: targetId,
sourceHandle: 'output', sourceHandle: "output",
targetHandle: 'input', targetHandle: "input",
animated: true, animated: true,
}); });
} }
@@ -120,7 +130,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
// out: else → output (right); if → sort by target order desc (rightmost first), then top/bottom // out: else → output (right); if → sort by target order desc (rightmost first), then top/bottom
for (const e of elseEdges) { for (const e of elseEdges) {
edges.push({ ...e, sourceHandle: 'output' }); edges.push({ ...e, sourceHandle: "output" });
} }
if (ifEdges.length > 0) { if (ifEdges.length > 0) {
const sortedIf = [...ifEdges].sort((a, b) => { const sortedIf = [...ifEdges].sort((a, b) => {
@@ -128,7 +138,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
const ob = idToOrder.get(b.target) ?? 0; const ob = idToOrder.get(b.target) ?? 0;
return ob - oa; return ob - oa;
}); });
const ifHandles = ['output-top', 'output-bottom'] as const; const ifHandles = ["output-top", "output-bottom"] as const;
for (let i = 0; i < sortedIf.length; i++) { for (let i = 0; i < sortedIf.length; i++) {
edges.push({ ...sortedIf[i], sourceHandle: ifHandles[i % ifHandles.length] }); edges.push({ ...sortedIf[i], sourceHandle: ifHandles[i % ifHandles.length] });
} }
@@ -140,7 +150,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
for (let i = 0; i < edges.length; i++) { for (let i = 0; i < edges.length; i++) {
const target = edges[i].target; const target = edges[i].target;
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []); if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
incomingByTarget.get(target)!.push(i); incomingByTarget.get(target)?.push(i);
} }
for (const indices of incomingByTarget.values()) { for (const indices of incomingByTarget.values()) {
@@ -149,7 +159,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
const ob = idToOrder.get(edges[b].source) ?? 0; const ob = idToOrder.get(edges[b].source) ?? 0;
return oa - ob; return oa - ob;
}); });
assignHandles(indices, edges, IN_HANDLES, 'targetHandle'); assignHandles(indices, edges, IN_HANDLES, "targetHandle");
} }
return { nodes, edges }; return { nodes, edges };
@@ -1,5 +1,5 @@
import type { AnyWorkNode, AnyWorkEdge, WorkNode, ConditionalEdge } from '../type'; import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge, WorkNode } from "../type";
import type { WorkFlowStep, WorkFlowTransition } from './type'; import type { WorkFlowStep, WorkFlowTransition } from "./type";
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] { export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
const nodeMap = new Map<string, AnyWorkNode>(); const nodeMap = new Map<string, AnyWorkNode>();
@@ -12,10 +12,10 @@ export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowSt
if (!outgoingEdges.has(edge.source)) { if (!outgoingEdges.has(edge.source)) {
outgoingEdges.set(edge.source, []); outgoingEdges.set(edge.source, []);
} }
outgoingEdges.get(edge.source)!.push(edge); outgoingEdges.get(edge.source)?.push(edge);
} }
const startOutEdges = outgoingEdges.get('start') ?? []; const startOutEdges = outgoingEdges.get("start") ?? [];
if (startOutEdges.length === 0) return []; if (startOutEdges.length === 0) return [];
const firstNodeId = startOutEdges[0].target; const firstNodeId = startOutEdges[0].target;
@@ -34,23 +34,26 @@ function traverse(
visited: Set<string>, visited: Set<string>,
steps: WorkFlowStep[], steps: WorkFlowStep[],
): void { ): void {
if (visited.has(nodeId) || nodeId === 'start' || nodeId === 'end') return; if (visited.has(nodeId) || nodeId === "start" || nodeId === "end") return;
visited.add(nodeId); visited.add(nodeId);
const node = nodeMap.get(nodeId); const node = nodeMap.get(nodeId);
if (!node || node.type !== 'role') return; if (!node || node.type !== "role") return;
const roleNode = node as WorkNode<'role'>; const roleNode = node as WorkNode<"role">;
const outEdges = outgoingEdges.get(nodeId) ?? []; const outEdges = outgoingEdges.get(nodeId) ?? [];
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => { const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
const targetNode = nodeMap.get(edge.target); const targetNode = nodeMap.get(edge.target);
const target = edge.target === 'end' const target =
? 'END' edge.target === "end"
: (targetNode?.type === 'role' ? (targetNode as WorkNode<'role'>).data.name : edge.target); ? "END"
: targetNode?.type === "role"
? (targetNode as WorkNode<"role">).data.name
: edge.target;
let condition: string | null = null; let condition: string | null = null;
if (edge.type === 'conditional') { if (edge.type === "conditional") {
const isElse = outEdges.length >= 2 && index === 0; const isElse = outEdges.length >= 2 && index === 0;
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null); condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
} }
@@ -1,6 +1,6 @@
export type { export type {
WorkFlowRole, WorkFlowRole,
WorkFlowTransition,
WorkFlowStep, WorkFlowStep,
WorkFlowSteps, WorkFlowSteps,
WorkFlowTransition,
} from "../../../shared/types.ts"; } from "../../../shared/types.ts";
@@ -1,4 +1,4 @@
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type'; import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
export type ValidationError = { export type ValidationError = {
nodeId: string | null; nodeId: string | null;
@@ -13,12 +13,12 @@ export type ValidationResult = {
export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): ValidationResult { export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): ValidationResult {
const errors: ValidationError[] = []; const errors: ValidationError[] = [];
const outgoing = buildEdgeMap(edges, 'source'); const outgoing = buildEdgeMap(edges, "source");
const incoming = buildEdgeMap(edges, 'target'); const incoming = buildEdgeMap(edges, "target");
const startNodes = nodes.filter(n => n.type === 'start'); const startNodes = nodes.filter((n) => n.type === "start");
const endNodes = nodes.filter(n => n.type === 'end'); const endNodes = nodes.filter((n) => n.type === "end");
const roleNodes = nodes.filter(n => n.type === 'role'); const roleNodes = nodes.filter((n) => n.type === "role");
validateStartNode(startNodes, outgoing, errors); validateStartNode(startNodes, outgoing, errors);
validateEndNode(endNodes, incoming, outgoing, errors); validateEndNode(endNodes, incoming, outgoing, errors);
@@ -29,17 +29,14 @@ export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): Validation
return { valid: errors.length === 0, errors }; return { valid: errors.length === 0, errors };
} }
function buildEdgeMap( function buildEdgeMap(edges: AnyWorkEdge[], key: "source" | "target"): Map<string, AnyWorkEdge[]> {
edges: AnyWorkEdge[],
key: 'source' | 'target',
): Map<string, AnyWorkEdge[]> {
const map = new Map<string, AnyWorkEdge[]>(); const map = new Map<string, AnyWorkEdge[]>();
for (const edge of edges) { for (const edge of edges) {
const id = edge[key]; const id = edge[key];
if (!map.has(id)) { if (!map.has(id)) {
map.set(id, []); map.set(id, []);
} }
map.get(id)!.push(edge); map.get(id)?.push(edge);
} }
return map; return map;
} }
@@ -50,20 +47,20 @@ function validateStartNode(
errors: ValidationError[], errors: ValidationError[],
): void { ): void {
if (startNodes.length === 0) { if (startNodes.length === 0) {
errors.push({ nodeId: null, message: '缺少 Start 节点' }); errors.push({ nodeId: null, message: "缺少 Start 节点" });
return; return;
} }
if (startNodes.length > 1) { if (startNodes.length > 1) {
errors.push({ nodeId: null, message: 'Start 节点只能有一个' }); errors.push({ nodeId: null, message: "Start 节点只能有一个" });
return; return;
} }
const startId = startNodes[0].id; const startId = startNodes[0].id;
const outEdges = outgoing.get(startId) ?? []; const outEdges = outgoing.get(startId) ?? [];
if (outEdges.length === 0) { if (outEdges.length === 0) {
errors.push({ nodeId: startId, message: 'Start 节点必须有一个输出连接' }); errors.push({ nodeId: startId, message: "Start 节点必须有一个输出连接" });
} else if (outEdges.length > 1) { } else if (outEdges.length > 1) {
errors.push({ nodeId: startId, message: 'Start 节点只能有一个输出连接' }); errors.push({ nodeId: startId, message: "Start 节点只能有一个输出连接" });
} }
} }
@@ -74,23 +71,23 @@ function validateEndNode(
errors: ValidationError[], errors: ValidationError[],
): void { ): void {
if (endNodes.length === 0) { if (endNodes.length === 0) {
errors.push({ nodeId: null, message: '缺少 End 节点' }); errors.push({ nodeId: null, message: "缺少 End 节点" });
return; return;
} }
if (endNodes.length > 1) { if (endNodes.length > 1) {
errors.push({ nodeId: null, message: 'End 节点只能有一个' }); errors.push({ nodeId: null, message: "End 节点只能有一个" });
return; return;
} }
const endId = endNodes[0].id; const endId = endNodes[0].id;
const inEdges = incoming.get(endId) ?? []; const inEdges = incoming.get(endId) ?? [];
if (inEdges.length === 0) { if (inEdges.length === 0) {
errors.push({ nodeId: endId, message: 'End 节点必须有至少一个输入连接' }); errors.push({ nodeId: endId, message: "End 节点必须有至少一个输入连接" });
} }
const outEdges = outgoing.get(endId) ?? []; const outEdges = outgoing.get(endId) ?? [];
if (outEdges.length > 0) { if (outEdges.length > 0) {
errors.push({ nodeId: endId, message: 'End 节点不能有输出连接' }); errors.push({ nodeId: endId, message: "End 节点不能有输出连接" });
} }
} }
@@ -105,22 +102,22 @@ function validateRoleNodes(
const outEdges = outgoing.get(node.id) ?? []; const outEdges = outgoing.get(node.id) ?? [];
if (inEdges.length === 0) { if (inEdges.length === 0) {
errors.push({ nodeId: node.id, message: '角色节点缺少输入连接' }); errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" });
} }
if (outEdges.length === 0) { if (outEdges.length === 0) {
errors.push({ nodeId: node.id, message: '角色节点缺少输出连接' }); errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" });
} }
if (outEdges.length > 1) { if (outEdges.length > 1) {
const conditionalEdges = outEdges.filter(e => e.type === 'conditional'); const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
if (conditionalEdges.length !== outEdges.length) { if (conditionalEdges.length !== outEdges.length) {
errors.push({ nodeId: node.id, message: '多输出节点的所有出边必须附带条件' }); errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
} else { } else {
const ifEdges = conditionalEdges.slice(1); const ifEdges = conditionalEdges.slice(1);
for (const edge of ifEdges) { for (const edge of ifEdges) {
const condEdge = edge as ConditionalEdge; const condEdge = edge as ConditionalEdge;
if (!condEdge.data?.condition?.trim()) { if (!condEdge.data?.condition?.trim()) {
errors.push({ nodeId: node.id, message: '条件边的条件表达式不能为空' }); errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
break; break;
} }
} }
@@ -129,12 +126,9 @@ function validateRoleNodes(
} }
} }
function validateRoleCount( function validateRoleCount(roleNodes: AnyWorkNode[], errors: ValidationError[]): void {
roleNodes: AnyWorkNode[],
errors: ValidationError[],
): void {
if (roleNodes.length < 2) { if (roleNodes.length < 2) {
errors.push({ nodeId: null, message: '工作流至少需要 2 个角色节点' }); errors.push({ nodeId: null, message: "工作流至少需要 2 个角色节点" });
} }
} }
@@ -151,21 +145,21 @@ function validateReachability(
const backwardAdj = new Map<string, string[]>(); const backwardAdj = new Map<string, string[]>();
for (const edge of edges) { for (const edge of edges) {
if (!forwardAdj.has(edge.source)) forwardAdj.set(edge.source, []); if (!forwardAdj.has(edge.source)) forwardAdj.set(edge.source, []);
forwardAdj.get(edge.source)!.push(edge.target); forwardAdj.get(edge.source)?.push(edge.target);
if (!backwardAdj.has(edge.target)) backwardAdj.set(edge.target, []); if (!backwardAdj.has(edge.target)) backwardAdj.set(edge.target, []);
backwardAdj.get(edge.target)!.push(edge.source); backwardAdj.get(edge.target)?.push(edge.source);
} }
const reachableFromStart = bfs(startNodes[0].id, forwardAdj); const reachableFromStart = bfs(startNodes[0].id, forwardAdj);
const reachableFromEnd = bfs(endNodes[0].id, backwardAdj); const reachableFromEnd = bfs(endNodes[0].id, backwardAdj);
for (const node of nodes) { for (const node of nodes) {
if (node.type === 'start' || node.type === 'end') continue; if (node.type === "start" || node.type === "end") continue;
if (!reachableFromStart.has(node.id)) { if (!reachableFromStart.has(node.id)) {
errors.push({ nodeId: node.id, message: '节点不可从 Start 到达(孤立节点)' }); errors.push({ nodeId: node.id, message: "节点不可从 Start 到达(孤立节点)" });
} }
if (!reachableFromEnd.has(node.id)) { if (!reachableFromEnd.has(node.id)) {
errors.push({ nodeId: node.id, message: '节点无法到达 End(死端节点)' }); errors.push({ nodeId: node.id, message: "节点无法到达 End(死端节点)" });
} }
} }
} }
@@ -175,7 +169,7 @@ function bfs(startId: string, adj: Map<string, string[]>): Set<string> {
const queue = [startId]; const queue = [startId];
visited.add(startId); visited.add(startId);
while (queue.length > 0) { while (queue.length > 0) {
const current = queue.shift()!; const current = queue.shift() ?? "";
for (const next of adj.get(current) ?? []) { for (const next of adj.get(current) ?? []) {
if (!visited.has(next)) { if (!visited.has(next)) {
visited.add(next); visited.add(next);
@@ -1,4 +1,4 @@
import type { Node, Edge } from '@xyflow/react'; import type { Edge, Node } from "@xyflow/react";
type AnyKeyBase = { [key: string]: unknown | undefined }; type AnyKeyBase = { [key: string]: unknown | undefined };
@@ -19,11 +19,11 @@ export type NodeMap = {
export type WorkNodeType = keyof NodeMap; export type WorkNodeType = keyof NodeMap;
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>; export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
export type AnyWorkNode = WorkNode<'start'> | WorkNode<'end'> | WorkNode<'role'>; export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
export type ConditionalEdgeData = AnyKeyBase & { export type ConditionalEdgeData = AnyKeyBase & {
condition: string; condition: string;
}; };
export type ConditionalEdge = Edge<ConditionalEdgeData, 'conditional'>; export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
export type AnyWorkEdge = ConditionalEdge | Edge; export type AnyWorkEdge = ConditionalEdge | Edge;
@@ -1,12 +1,16 @@
interface Maper<T> { [key: string]: T } type Maper<T> = {
[key: string]: T;
};
type Listen<T> = (data: T) => void; type Listen<T> = (data: T) => void;
// biome-ignore lint/suspicious/noExplicitAny: generic event map requires any
export class Eventer<M extends Maper<any>> { export class Eventer<M extends Maper<any>> {
// biome-ignore lint/complexity/noBannedTypes: Set<Function> needed for heterogeneous listener types
private lisenters = {} as { [K in keyof M]: Set<Function> }; private lisenters = {} as { [K in keyof M]: Set<Function> };
public on<K extends keyof M>(key: K, lisenter: Listen<M[K]>) { public on<K extends keyof M>(key: K, lisenter: Listen<M[K]>) {
let set = this.lisenters[key]; let set = this.lisenters[key];
if (set == undefined) { if (set === undefined) {
set = new Set(); set = new Set();
this.lisenters[key] = set; this.lisenters[key] = set;
} }
@@ -26,6 +30,8 @@ export class Eventer<M extends Maper<any>> {
const set = this.lisenters[key]; const set = this.lisenters[key];
if (set === undefined) return; if (set === undefined) return;
// Todo: maybe implement stoping bubble // Todo: maybe implement stoping bubble
set.forEach(call => call(data)); for (const call of set) {
call(data);
}
} }
} }
@@ -1,5 +1,3 @@
export function uuid() { export function uuid() {
const now = Date.now(); const now = Date.now();
const randon = 1 + Math.random(); const randon = 1 + Math.random();
@@ -1,5 +1,4 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
function judge(container: HTMLElement, target: HTMLElement): boolean { function judge(container: HTMLElement, target: HTMLElement): boolean {
if (container === target) { if (container === target) {
@@ -8,14 +7,11 @@ function judge(container: HTMLElement, target: HTMLElement): boolean {
if (target === document.body) { if (target === document.body) {
return false; return false;
} }
let parent = target.parentElement; const parent = target.parentElement;
return parent ? judge(container, parent) : false; return parent ? judge(container, parent) : false;
} }
export function useClickOutRef<T extends HTMLElement>( export function useClickOutRef<T extends HTMLElement>(callback: () => void, delay = 0) {
callback: () => void,
delay = 0,
) {
const ref = useRef<T>(null); const ref = useRef<T>(null);
const flag = useRef<boolean>(delay === 0); const flag = useRef<boolean>(delay === 0);
@@ -37,8 +33,8 @@ export function useClickOutRef<T extends HTMLElement>(
callback(); callback();
} }
} }
document.addEventListener('click', handle); document.addEventListener("click", handle);
return () => document.removeEventListener('click', handle); return () => document.removeEventListener("click", handle);
}, [callback]); }, [callback]);
return ref; return ref;
+1 -1
View File
@@ -33,7 +33,7 @@
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif; --font-sans: "Geist Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
@@ -1,8 +1,8 @@
import { useState, useEffect, useRef, type ReactNode } from "react"; import { ArrowLeft, Eye, Pencil } from "lucide-react";
import { useParams, useNavigate, useLocation } from "react-router"; import { type ReactNode, useEffect, useRef, useState } from "react";
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx"; import { useLocation, useNavigate, useParams } from "react-router";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft, Pencil, Eye } from "lucide-react"; import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
export function DetailPage(): ReactNode { export function DetailPage(): ReactNode {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
@@ -44,18 +44,18 @@ export function DetailPage(): ReactNode {
if (!cancelled) navigate("/"); if (!cancelled) navigate("/");
}); });
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, [name, navigate]); }, [name, navigate]);
if (loading) { if (loading) {
return ( return (
<div className="flex h-full items-center justify-center text-muted-foreground"> <div className="flex h-full items-center justify-center text-muted-foreground">...</div>
...
</div>
); );
} }
const basePath = `/workflow/${encodeURIComponent(name!)}`; const basePath = `/workflow/${encodeURIComponent(name ?? "")}`;
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -66,29 +66,19 @@ export function DetailPage(): ReactNode {
<h1 className="text-base font-medium">{name}</h1> <h1 className="text-base font-medium">{name}</h1>
<div className="flex-1" /> <div className="flex-1" />
{editing ? ( {editing ? (
<Button <Button variant="outline" size="sm" onClick={() => navigate(basePath)}>
variant="outline"
size="sm"
onClick={() => navigate(basePath)}
>
<Eye className="size-3.5" data-icon="inline-start" /> <Eye className="size-3.5" data-icon="inline-start" />
</Button> </Button>
) : ( ) : (
<Button <Button variant="outline" size="sm" onClick={() => navigate(`${basePath}/edit`)}>
variant="outline"
size="sm"
onClick={() => navigate(`${basePath}/edit`)}
>
<Pencil className="size-3.5" data-icon="inline-start" /> <Pencil className="size-3.5" data-icon="inline-start" />
</Button> </Button>
)} )}
{saving && <span className="text-xs text-muted-foreground">...</span>} {saving && <span className="text-xs text-muted-foreground">...</span>}
</div> </div>
<div className="flex-1"> <div className="flex-1">{model && <FlowEditor model={model} readonly={!editing} />}</div>
{model && <FlowEditor model={model} readonly={!editing} />}
</div>
</div> </div>
); );
} }
@@ -1,4 +1,4 @@
import { useState, useEffect, type ReactNode } from "react"; import { type ReactNode, useState } from "react";
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx"; import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
const DEFAULT_STEPS: WorkFlowSteps = [ const DEFAULT_STEPS: WorkFlowSteps = [
@@ -1,19 +1,19 @@
import { useState, useEffect, useCallback, type ReactNode, type FormEvent } from "react"; import { Plus, Trash2, Workflow } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardDescription, CardAction } from "@/components/ui/card"; import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader,
DialogTitle,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2, Workflow } from "lucide-react";
import type { WorkflowSummary } from "../../shared/types.ts"; import type { WorkflowSummary } from "../../shared/types.ts";
export function HomePage(): ReactNode { export function HomePage(): ReactNode {
+1 -1
View File
@@ -1,7 +1,7 @@
import { createBrowserRouter } from "react-router"; import { createBrowserRouter } from "react-router";
import { Layout } from "./app.tsx"; import { Layout } from "./app.tsx";
import { HomePage } from "./pages/home.tsx";
import { DetailPage } from "./pages/detail.tsx"; import { DetailPage } from "./pages/detail.tsx";
import { HomePage } from "./pages/home.tsx";
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Plugin } from "vite";
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import type { Plugin } from "vite";
import { createApi } from "./server/api.ts"; import { createApi } from "./server/api.ts";
function buildRequest(req: IncomingMessage, body: string | null): Request { function buildRequest(req: IncomingMessage, body: string | null): Request {
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import { createProcessLogger } from "../src/process-logger/index.js"; import { createProcessLogger } from "../src/process-logger/index.js";
+1 -1
View File
@@ -13,13 +13,13 @@ export {
validateFrontmatter, validateFrontmatter,
} from "./frontmatter-markdown/index.js"; } from "./frontmatter-markdown/index.js";
export { createLogger } from "./logger.js"; export { createLogger } from "./logger.js";
export { createProcessLogger } from "./process-logger/index.js";
export type { export type {
CreateProcessLoggerOptions, CreateProcessLoggerOptions,
ProcessLogFn, ProcessLogFn,
ProcessLogger, ProcessLogger,
ProcessLoggerContext, ProcessLoggerContext,
} from "./process-logger/index.js"; } from "./process-logger/index.js";
export { createProcessLogger } from "./process-logger/index.js";
export { normalizeRefsField } from "./refs-field.js"; export { normalizeRefsField } from "./refs-field.js";
export { err, ok } from "./result.js"; export { err, ok } from "./result.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js"; export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";