├── src ├── reactflow.css ├── vite-env.d.ts ├── App.css ├── components │ ├── Renderer │ │ ├── index.ts │ │ ├── CustomHandle.tsx │ │ ├── RendererWrapper.tsx │ │ ├── CustomEdge.tsx │ │ ├── ModelNode.tsx │ │ └── Renderer.tsx │ ├── Panels.tsx │ ├── Sidebar.tsx │ ├── Editor.tsx │ ├── Header.tsx │ ├── Share.tsx │ └── Preferences.tsx ├── themes │ ├── vs-light.json │ ├── vs-dark.json │ ├── index.ts │ ├── solarized-light.json │ └── solarized-dark.json ├── hooks │ ├── useIsMobile.ts │ ├── useDebounced.ts │ ├── useMediaQuery.ts │ └── useFullscreen.ts ├── types.ts ├── monaco-vim.d.ts ├── main.tsx ├── edge-segment-cache.ts ├── examples.ts ├── index.css ├── stores │ ├── graph.ts │ ├── user-options.ts │ └── documents.ts ├── App.tsx ├── utils │ └── svg-export.ts └── lib │ └── parser │ ├── Parser.test.ts │ ├── Parser.ts │ └── ModelParser.test.ts ├── .husky └── pre-commit ├── postcss.config.js ├── .editorconfig ├── tsconfig.node.json ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── public └── favicon.svg ├── tailwind.config.js ├── vite.config.ts ├── index.html ├── package.json └── README.md /src/reactflow.css: -------------------------------------------------------------------------------- 1 | @import "reactflow/dist/style.css"; 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/Renderer/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./RendererWrapper"; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx vitest run 5 | npx lint-staged 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/themes/vs-light.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "VS Light", 3 | "base": "vs", 4 | "inherit": true, 5 | "rules": [], 6 | "colors": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/themes/vs-dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "VS Dark", 3 | "base": "vs-dark", 4 | "inherit": true, 5 | "rules": [], 6 | "colors": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/hooks/useIsMobile.ts: -------------------------------------------------------------------------------- 1 | import { useMediaQuery } from "./useMediaQuery"; 2 | 3 | export const useIsMobile = () => useMediaQuery("(max-width: 768px)"); 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { XYPosition } from "reactflow"; 2 | 3 | export enum Direction { 4 | Bottom = 3, 5 | Left = 4, 6 | None = 0, 7 | Right = 2, 8 | Top = 1, 9 | } 10 | 11 | export type PathSegment = { start: XYPosition; end: XYPosition; direction: Direction }; 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false, 6 | "printWidth": 110, 7 | "quoteProps": "as-needed", 8 | "bracketSpacing": true, 9 | "bracketSameLine": false, 10 | "arrowParens": "always", 11 | "useTabs": false 12 | } 13 | -------------------------------------------------------------------------------- /src/themes/index.ts: -------------------------------------------------------------------------------- 1 | import solarizedDark from "./solarized-dark.json"; 2 | import solarizedLight from "./solarized-light.json"; 3 | import vsDark from "./vs-dark.json"; 4 | import vsLight from "./vs-light.json"; 5 | 6 | export const themes = { 7 | vsLight, 8 | vsDark, 9 | solarizedLight, 10 | solarizedDark, 11 | }; 12 | -------------------------------------------------------------------------------- /src/monaco-vim.d.ts: -------------------------------------------------------------------------------- 1 | declare module "monaco-vim" { 2 | export type InitVimModeResult = { 3 | dispose: () => void; 4 | on: (event: "vim-mode-change", callback: (args: { mode: string }) => void) => void; 5 | }; 6 | export function initVimMode( 7 | editor: monaco.editor.IStandaloneCodeEditor, 8 | container: HTMLElement 9 | ): InitVimModeResult; 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | stats.html 27 | coverage 28 | -------------------------------------------------------------------------------- /src/hooks/useDebounced.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useDebounced(value: T, delay?: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => setDebouncedValue(value), delay || 500); 8 | 9 | return () => { 10 | clearTimeout(timer); 11 | }; 12 | }, [delay, value]); 13 | 14 | return debouncedValue; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Renderer/CustomHandle.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { Handle, HandleProps, useUpdateNodeInternals } from "reactflow"; 3 | 4 | export const CustomHandle = (props: HandleProps & Omit, "id">) => { 5 | const updateNodeInternals = useUpdateNodeInternals(); 6 | 7 | useEffect(() => { 8 | updateNodeInternals(props.id!); 9 | }, [props.id, updateNodeInternals]); 10 | 11 | return ; 12 | }; 13 | -------------------------------------------------------------------------------- /src/hooks/useMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useMediaQuery = (query: string) => { 4 | const [matches, setMatches] = useState(false); 5 | 6 | useEffect(() => { 7 | const mediaQueryList = window.matchMedia(query); 8 | setMatches(mediaQueryList.matches); 9 | 10 | const listener = (event: MediaQueryListEvent) => setMatches(event.matches); 11 | mediaQueryList.addEventListener("change", listener); 12 | 13 | return () => mediaQueryList.removeEventListener("change", listener); 14 | }, [query]); 15 | 16 | return matches; 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true 18 | }, 19 | "include": ["src"], 20 | "references": [ 21 | { 22 | "path": "./tsconfig.node.json" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./index.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | ); 11 | 12 | if (process.env.NODE_ENV !== "development") { 13 | document.addEventListener("DOMContentLoaded", () => { 14 | const umamiScript = document.createElement("script"); 15 | umamiScript.src = "https://umami.dev.pet/script.js"; 16 | umamiScript.dataset.websiteId = "4c0a2abe-8e94-4a06-89b4-0915ffd69018"; 17 | umamiScript.defer = true; 18 | document.head.append(umamiScript); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useFullscreen.ts: -------------------------------------------------------------------------------- 1 | import { useMediaQuery } from "./useMediaQuery"; 2 | 3 | export const useFullscreen = (ref: React.RefObject) => { 4 | const isFullscreen = useMediaQuery("(display-mode: fullscreen)"); 5 | 6 | const enterFullscreen = () => { 7 | if (!ref.current) return; 8 | ref.current.requestFullscreen(); 9 | }; 10 | 11 | const exitFullscreen = () => { 12 | if (!ref.current) return; 13 | document.exitFullscreen(); 14 | }; 15 | 16 | const toggleFullscreen = () => { 17 | if (isFullscreen) { 18 | exitFullscreen(); 19 | } else { 20 | enterFullscreen(); 21 | } 22 | }; 23 | 24 | return { isFullscreen, enterFullscreen, exitFullscreen, toggleFullscreen }; 25 | }; 26 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/edge-segment-cache.ts: -------------------------------------------------------------------------------- 1 | import { EdgeProps } from "reactflow"; 2 | import { PathSegment } from "./types"; 3 | 4 | type CacheEntry = { 5 | edge: EdgeProps; 6 | path: [number, number][]; 7 | segments: PathSegment[]; 8 | }; 9 | 10 | const cache = new Map(); 11 | // const pointSet = new Set(); 12 | 13 | const edgeSegmentCache = { 14 | get: (id: string) => cache.get(id), 15 | getBySourcePosition: (sourceX: number, sourceY: number) => { 16 | for (const entry of cache.values()) { 17 | if (entry.edge.sourceX === sourceX && entry.edge.sourceY === sourceY) { 18 | return entry; 19 | } 20 | } 21 | return null; 22 | }, 23 | set: (id: string, entry: CacheEntry) => { 24 | return cache.set(id, entry); 25 | }, 26 | clear: () => { 27 | cache.clear(); 28 | }, 29 | }; 30 | 31 | export { edgeSegmentCache }; 32 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{ts,tsx}"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | gray: { 8 | 50: "#f7f7f7", 9 | 100: "#f0f0f0", 10 | 200: "#e3e3e3", 11 | 300: "#d1d1d1", 12 | 400: "#c2c2c2", 13 | 500: "#aaaaaa", 14 | 600: "#969696", 15 | 700: "#818181", 16 | 800: "#6a6a6a", 17 | 900: "#585858", 18 | 950: "#333333", 19 | }, 20 | primary: { 21 | 50: "#f2f7fd", 22 | 100: "#e5edf9", 23 | 200: "#c5d9f2", 24 | 300: "#91b9e8", 25 | 400: "#5795d9", 26 | 500: "#3178c6", 27 | 600: "#215da8", 28 | 700: "#1c4b88", 29 | 800: "#1b4171", 30 | 900: "#1c385e", 31 | 950: "#12233f", 32 | }, 33 | }, 34 | }, 35 | }, 36 | plugins: [], 37 | }; 38 | -------------------------------------------------------------------------------- /src/examples.ts: -------------------------------------------------------------------------------- 1 | export const taskManagement = ` 2 | interface Node { 3 | id: string; 4 | path: string; 5 | source: string; 6 | get meta(): Record; 7 | get title(): string; 8 | get links(): Node[]; 9 | get backlinks(): Node[]; 10 | get tasks(): Task[]; 11 | }; 12 | 13 | interface Task { 14 | title: string; 15 | children: Task[]; 16 | status: "default" | "active" | "done" | "cancelled"; 17 | schedule: TaskSchedule; 18 | sessions: TaskSession[]; 19 | get isInProgress(): boolean; 20 | } 21 | 22 | interface TaskSchedule { 23 | start: Date; 24 | end: Date; 25 | get duration(): number; 26 | get isCurrent(): boolean; 27 | } 28 | 29 | interface TaskSession { 30 | start: Date; 31 | end?: Date; 32 | get duration(): number; 33 | get isCurrent(): boolean; 34 | } 35 | 36 | class Wiki { 37 | rootPath: string; 38 | 39 | constructor(rootPath: string) { 40 | this.rootPath = rootPath; 41 | } 42 | 43 | getNodes(): Node[] { 44 | return []; 45 | } 46 | } 47 | `.trim(); 48 | -------------------------------------------------------------------------------- /src/components/Renderer/RendererWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useStore } from "statelift"; 3 | import { useDebounced } from "../../hooks/useDebounced"; 4 | import { useIsMobile } from "../../hooks/useIsMobile"; 5 | import { ModelParser } from "../../lib/parser/ModelParser"; 6 | import { documentsStore } from "../../stores/documents"; 7 | import { Renderer } from "./Renderer"; 8 | 9 | export const RendererWrapper = () => { 10 | const documentSource = useStore(documentsStore, (state) => state.currentDocument.source); 11 | const isMobile = useIsMobile(); 12 | 13 | const debouncedSource = useDebounced(documentSource, 16); 14 | 15 | // eslint-disable-next-line react-hooks/exhaustive-deps 16 | const parser = useMemo(() => new ModelParser(debouncedSource), []); 17 | 18 | const models = useMemo(() => { 19 | parser.setSource(debouncedSource); 20 | return parser.getModels(); 21 | }, [parser, debouncedSource]); 22 | 23 | return ; 24 | }; 25 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | :root { 8 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 9 | line-height: 1.5; 10 | font-weight: 400; 11 | 12 | font-synthesis: none; 13 | text-rendering: optimizeLegibility; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | } 17 | 18 | html { 19 | width: 100%; 20 | height: 100%; 21 | } 22 | 23 | body { 24 | margin: 0; 25 | width: 100%; 26 | height: 100%; 27 | overflow: hidden; 28 | } 29 | 30 | .checkered { 31 | background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee 100%), 32 | linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee 100%); 33 | background-size: 20px 20px; 34 | background-position: 35 | 0 0, 36 | 10px 10px; 37 | } 38 | 39 | .vim-status input { 40 | @apply bg-gray-100; 41 | } 42 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // https://vitejs.dev/config/ 3 | import { defineConfig } from "vite"; 4 | import path from "node:path"; 5 | import react from "@vitejs/plugin-react"; 6 | import { visualizer } from "rollup-plugin-visualizer"; 7 | // import million from "million/compiler"; 8 | 9 | export default defineConfig({ 10 | plugins: [ 11 | // million.vite({ auto: true }), 12 | react(), 13 | visualizer(), 14 | ], 15 | test: {}, 16 | build: { 17 | sourcemap: true, 18 | rollupOptions: { 19 | external: ["perf_hooks"], 20 | output: { 21 | manualChunks: { 22 | "ts-morph": ["ts-morph"], 23 | elkjs: ["elkjs"], 24 | "dom-to-svg": ["dom-to-svg"], 25 | }, 26 | }, 27 | }, 28 | }, 29 | resolve: { 30 | alias: { 31 | process: "process/browser", 32 | path: path.resolve("./node_modules/@jspm/core/nodelibs/browser/path.js"), 33 | url: path.resolve("./node_modules/@jspm/core/nodelibs/browser/url.js"), 34 | fs: path.resolve("./node_modules/@jspm/core/nodelibs/browser/fs.js"), 35 | "source-map-js": path.resolve("./node_modules/source-map-js/source-map.js"), 36 | }, 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /src/stores/graph.ts: -------------------------------------------------------------------------------- 1 | import { EdgeProps, Node } from "reactflow"; 2 | import { createStore, useStore } from "statelift"; 3 | import { Model } from "../lib/parser/ModelParser"; 4 | 5 | type GraphState = { 6 | // nodes: Node<{ model: Model }>[]; 7 | // edges: Edge[]; 8 | hoveredNode: Node<{ model: Model }> | null; 9 | }; 10 | export const graphStore = createStore({ 11 | hoveredNode: null, 12 | }); 13 | 14 | export const useGraphStore = () => useStore(graphStore); 15 | 16 | export const useIsNodeHighlighted = (model: Model) => 17 | useStore(graphStore, (state) => { 18 | const { hoveredNode } = state; 19 | if (!hoveredNode) return false; 20 | if (hoveredNode.id === model.id) return true; 21 | if (model.dependencies.some((dependency) => dependency.id === hoveredNode.id)) return true; 22 | if (model.dependants.some((dependant) => dependant.id === hoveredNode.id)) return true; 23 | return false; 24 | }); 25 | 26 | export const useIsEdgeDecorated = (edge: EdgeProps) => 27 | useStore(graphStore, (state) => { 28 | const { hoveredNode } = state; 29 | if (!hoveredNode) return { highlighted: false, faded: false }; 30 | if (hoveredNode.id === edge.source || hoveredNode.id === edge.target) { 31 | return { highlighted: true, faded: false }; 32 | } 33 | return { highlighted: false, faded: true }; 34 | }); 35 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | TSDiagram - Diagrams as code with TypeScript 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import { ReactFlowProvider } from "reactflow"; 3 | import { Editor } from "./components/Editor"; 4 | import { Header } from "./components/Header"; 5 | import { Panels } from "./components/Panels"; 6 | import { Preferences } from "./components/Preferences"; 7 | import { RendererWrapper } from "./components/Renderer"; 8 | import { Share } from "./components/Share"; 9 | import { Sidebar } from "./components/Sidebar"; 10 | import { useUserOptions } from "./stores/user-options"; 11 | import "./App.css"; 12 | 13 | function App() { 14 | const [showPreferences, setShowPreferences] = useState(false); 15 | const [showShare, setShowShare] = useState(false); 16 | const options = useUserOptions(); 17 | 18 | const handlePreferencesClick = useCallback(() => { 19 | setShowPreferences((value) => !value); 20 | }, []); 21 | 22 | const handleShareClick = useCallback(() => { 23 | setShowShare((value) => !value); 24 | }, []); 25 | 26 | return ( 27 | 28 |
29 |
30 |
31 | {options.general.sidebarOpen && } 32 | } rendererChildren={} /> 33 |
34 | 35 | 36 |
37 |
38 | ); 39 | } 40 | 41 | export default App; 42 | -------------------------------------------------------------------------------- /src/components/Panels.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import classNames from "classnames"; 3 | import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; 4 | import { useIsMobile } from "../hooks/useIsMobile"; 5 | import { useUserOptions } from "../stores/user-options"; 6 | 7 | const defaultCodePanelSizePercentage = 50; 8 | const mobileCodePanelSizePercentage = 60; 9 | 10 | type PanelsProps = { 11 | editorChildren: React.ReactNode; 12 | rendererChildren: React.ReactNode; 13 | }; 14 | 15 | export const Panels = ({ editorChildren, rendererChildren }: PanelsProps) => { 16 | const options = useUserOptions(); 17 | const isMobile = useIsMobile(); 18 | 19 | const direction = isMobile ? "vertical" : options.panels.splitDirection; 20 | const isVertical = direction === "vertical"; 21 | 22 | const panelGroupMembers = useMemo(() => { 23 | const members = [ 24 | 30 | {editorChildren} 31 | , 32 | , 40 | 41 | {rendererChildren} 42 | , 43 | ]; 44 | if (isVertical) members.reverse(); 45 | return members; 46 | }, [isMobile, isVertical, editorChildren, options.renderer.theme, rendererChildren]); 47 | 48 | return ( 49 | 50 | {panelGroupMembers} 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/stores/user-options.ts: -------------------------------------------------------------------------------- 1 | import { createStore, useStore } from "statelift"; 2 | import { z } from "zod"; 3 | import { themes } from "../themes"; 4 | 5 | const userOptionsSchema = z.object({ 6 | general: z.object({ 7 | sidebarOpen: z.boolean().default(true), 8 | }), 9 | panels: z.object({ 10 | splitDirection: z.enum(["horizontal", "vertical"]).default("horizontal"), 11 | }), 12 | editor: z.object({ 13 | theme: z.enum(Object.keys(themes) as [string, ...string[]]).default("vsLight"), 14 | editingMode: z.enum(["default", "vim"]).default("default"), 15 | }), 16 | renderer: z.object({ 17 | direction: z.enum(["horizontal", "vertical"]).default("horizontal"), 18 | autoFitView: z.boolean().default(true), 19 | theme: z.enum(["light", "dark"]).default("light"), 20 | enableMinimap: z.boolean().default(true), 21 | }), 22 | }); 23 | 24 | export type UserOptions = z.infer & { 25 | load: () => void; 26 | save: () => void; 27 | }; 28 | export const optionsStore = createStore({ 29 | general: { 30 | sidebarOpen: false, 31 | }, 32 | panels: { 33 | splitDirection: "horizontal", 34 | }, 35 | editor: { 36 | theme: "vsLight", 37 | editingMode: "default", 38 | }, 39 | renderer: { 40 | direction: "horizontal", 41 | autoFitView: true, 42 | theme: "light", 43 | enableMinimap: true, 44 | }, 45 | load() { 46 | try { 47 | const data = JSON.parse(localStorage.getItem("options") ?? ""); 48 | const parsedData = userOptionsSchema.parse(data); 49 | parsedData.renderer.autoFitView = true; 50 | Object.assign(this, parsedData); 51 | } catch {} 52 | }, 53 | save() { 54 | localStorage.setItem( 55 | "options", 56 | JSON.stringify({ 57 | general: this.general, 58 | panels: this.panels, 59 | editor: this.editor, 60 | renderer: this.renderer, 61 | }) 62 | ); 63 | }, 64 | }); 65 | optionsStore.state.load(); 66 | export const useUserOptions = () => useStore(optionsStore); 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsdiagram", 3 | "version": "0.0.3", 4 | "repository": "https://github.com/3rd/tsdiagram", 5 | "description": "Create diagrams and plan your code with TypeScript.", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite --open", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview", 11 | "test": "vitest", 12 | "coverage": "vitest run --coverage", 13 | "format": "prettier --write .", 14 | "prepare": "husky" 15 | }, 16 | "lint-staged": { 17 | "*.{json,js,ts,jsx,tsx,html}": [ 18 | "prettier --write --ignore-unknown" 19 | ] 20 | }, 21 | "dependencies": { 22 | "@headlessui/react": "^1.7.19", 23 | "@jspm/core": "^2.0.1", 24 | "@monaco-editor/react": "^4.6.0", 25 | "@radix-ui/react-icons": "^1.3.0", 26 | "@tisoap/react-flow-smart-edge": "^3.0.0", 27 | "classnames": "^2.5.1", 28 | "dom-to-svg": "^0.12.2", 29 | "elkjs": "^0.9.3", 30 | "lodash": "^4.17.21", 31 | "lz-string": "^1.5.0", 32 | "million": "^3.0.6", 33 | "monaco-vim": "^0.4.1", 34 | "nanoid": "^5.0.7", 35 | "path-browserify": "^1.0.1", 36 | "pathfinding": "^0.4.18", 37 | "process": "^0.11.10", 38 | "react": "^18.2.0", 39 | "react-dom": "^18.2.0", 40 | "react-resizable-panels": "^2.0.17", 41 | "reactflow": "^11.11.1", 42 | "source-map-js": "^1.2.0", 43 | "statelift": "^1.0.15", 44 | "ts-morph": "^22.0.0", 45 | "zod": "^3.22.4" 46 | }, 47 | "devDependencies": { 48 | "@types/lodash": "^4.17.0", 49 | "@types/node": "^20.12.7", 50 | "@types/pathfinding": "^0.0.9", 51 | "@types/react": "^18.2.79", 52 | "@types/react-dom": "^18.2.25", 53 | "@vitejs/plugin-react": "^4.3.4", 54 | "@vitest/coverage-v8": "^1.5.0", 55 | "autoprefixer": "^10.4.19", 56 | "husky": "^9.0.11", 57 | "lint-staged": "^15.2.2", 58 | "postcss": "^8.4.38", 59 | "prettier": "^3.2.5", 60 | "rollup-plugin-visualizer": "^5.12.0", 61 | "tailwindcss": "^3.4.3", 62 | "typescript": "^5.4.5", 63 | "vite": "^5.2.9", 64 | "vitest": "^1.5.0", 65 | "web-worker": "^1.3.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/svg-export.ts: -------------------------------------------------------------------------------- 1 | import { elementToSVG } from "dom-to-svg"; 2 | import mainStyle from "../index.css?inline"; 3 | import reactFlowStyle from "../reactflow.css?inline"; 4 | 5 | const CONTAINER_QUERY = ".react-flow__viewport"; 6 | const PADDING = 18; 7 | 8 | const isFirefox = navigator.userAgent.toLowerCase().includes("firefox"); 9 | 10 | export const exportReactFlowToSVG = async (width: number, height: number, transform = "none") => { 11 | return new Promise((resolve) => { 12 | const container = document.querySelector(CONTAINER_QUERY); 13 | if (!container) throw new Error(`Could not find container with query: ${CONTAINER_QUERY}`); 14 | 15 | const iframe = document.createElement("iframe"); 16 | iframe.style.width = `${width + PADDING * 2}px`; 17 | iframe.style.height = `${height + PADDING * 2}px`; 18 | iframe.style.position = "absolute"; 19 | // iframe.style.top = "0"; 20 | // iframe.style.left = "0"; 21 | iframe.style.top = "150%"; 22 | iframe.style.left = "150%"; 23 | 24 | iframe.addEventListener("load", () => { 25 | const iframeDocument = iframe.contentDocument; 26 | if (!iframeDocument) throw new Error("Could not get iframe document"); 27 | 28 | const iframeStyle = iframeDocument.createElement("style"); 29 | iframeStyle.innerHTML = ` 30 | ${mainStyle + reactFlowStyle} 31 | * { 32 | box-sizing: border-box; 33 | } 34 | svg { 35 | overflow: visible; 36 | } 37 | svg > g { 38 | transform: translate(${PADDING}px, ${PADDING}px); 39 | } 40 | .react-flow__nodes { 41 | transform: translate(${PADDING}px, ${PADDING}px); 42 | } 43 | `; 44 | iframeDocument.body.append(iframeStyle); 45 | 46 | const clone = container.cloneNode(true) as HTMLElement; 47 | Object.assign(clone.style, { 48 | transform, 49 | width: `${width}px`, 50 | height: `${height}px`, 51 | }); 52 | iframeDocument.body.append(clone); 53 | 54 | setTimeout( 55 | () => { 56 | const svgDocument = elementToSVG(iframeDocument.documentElement); 57 | const result = new XMLSerializer().serializeToString(svgDocument); 58 | resolve(result); 59 | iframe.remove(); 60 | }, 61 | isFirefox ? 500 : 0 62 | ); 63 | }); 64 | document.body.append(iframe); 65 | }); 66 | }; 67 | 68 | export const downloadSVG = async (svgString: string, filename: string) => { 69 | const svgBlob = new Blob([svgString], { type: "image/svg+xml" }); 70 | const svgUrl = URL.createObjectURL(svgBlob); 71 | 72 | const a = document.createElement("a"); 73 | a.href = svgUrl; 74 | a.download = filename; 75 | a.click(); 76 | }; 77 | 78 | export const copySVG = async (svgString: string) => { 79 | await navigator.clipboard.writeText(svgString); 80 | }; 81 | -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback, useState } from "react"; 2 | import { Cross2Icon } from "@radix-ui/react-icons"; 3 | import classNames from "classnames"; 4 | import { documentsStore, useDocuments } from "../stores/documents"; 5 | import { useUserOptions } from "../stores/user-options"; 6 | 7 | type SidebarItemProps = { 8 | id: string; 9 | title: string; 10 | isActive: boolean; 11 | onClick: (id: string) => void; 12 | onDelete: (id: string) => void; 13 | }; 14 | const SidebarItem = memo(({ id, title, onClick, onDelete, isActive }: SidebarItemProps) => { 15 | const [deleteConfirmationState, setDeleteConfirmationState] = useState<"confirm" | "default">("default"); 16 | 17 | const handleClick = () => onClick(id); 18 | 19 | const handleDeleteClick = (e: React.MouseEvent) => { 20 | e.stopPropagation(); 21 | if (deleteConfirmationState === "default") { 22 | setDeleteConfirmationState("confirm"); 23 | } else { 24 | onDelete(id); 25 | } 26 | }; 27 | 28 | const handleMouseLeave = () => { 29 | setDeleteConfirmationState("default"); 30 | }; 31 | 32 | return ( 33 |
  • 42 | {title || "Untitled"} 43 | 51 |
  • 52 | ); 53 | }); 54 | 55 | export const Sidebar = memo(() => { 56 | const options = useUserOptions(); 57 | const documents = useDocuments(); 58 | 59 | const handleItemClick = useCallback((id: string) => { 60 | documentsStore.state.setCurrentDocumentId(id); 61 | }, []); 62 | 63 | const handleItemDelete = useCallback((id: string) => { 64 | documentsStore.state.delete(id); 65 | }, []); 66 | 67 | const sidebarItems = documents.documents.map((doc) => { 68 | const isCurrentDocument = documents.currentDocumentId === doc.id; 69 | 70 | return ( 71 | 79 | ); 80 | }); 81 | 82 | return ( 83 |
    89 |
    90 |
      {sidebarItems}
    91 |
    92 |
    93 | ); 94 | }); 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TSDiagram 2 | 3 | **TSDiagram** is an online tool that helps you draft diagrams quickly by using TypeScript. 4 | \ 5 | :point_right: https://tsdiagram.com 6 | 7 | ### **Features** 8 | 9 | - Lets you define your data models through **top-level type aliases**, **interfaces**, and **classes**. 10 | - Automatically layouts the nodes in an efficient way. 11 | - ...but if you move one of the nodes manually, it will only auto-layout the other ones. 12 | - Persists the document state in the URL and localStorage. 13 | - Export your diagrams as SVG. 14 | 15 | ### **Roadmap** 16 | 17 | - Function call representation 18 | - Customizable TypeScript context (lib, etc.) 19 | - Bring your own storage (different vendors) 20 | 21 | --- 22 | 23 | > This project is not just a diagramming tool, but also the foundation for a greater code visualization project. 24 | > Imagine flagging types and functions in your code editor and see how they are connected, and how data flows through them. 25 | > That's the end goal, so we'll swap the TS compiler with Tree-sitter in the process. 26 | 27 | --- 28 | 29 | ![TSDiagram Screenshot](https://root.b-cdn.net/tsdiagram/media.png) 30 | 31 | ### Test links 32 | 33 | - [Default example](https://tsdiagram.com/#/N4IgJg9gxgrgtgUwHYBcDOIBcBtUBLMLEACwBsBrAIQEFi4AFAKwEMBGAJwGEEAzU2AO6UAqiAA0IFHhSkERAOoJ+EROJBoIMdlDmYQeVAnY9mOgAQA5CGARngAHSRmzBTGbQp2BgOYBuR84ADswoxG4eXkh+Ae6a2gjhnj7+TmbeCChmiCjMABQAlG4ASghQEOxgADwRPmJmMEjkSBACSAB8Kc7pmVIyCAWJkdGp3WakBuRoA5bWCNgAup1pGWYARqbk441ThTM2C0ujOWiT0wAqzCcHjgC+KY4GKEYm5hcndjG9soPJMVDEeFIYHYyDcb3I11SHhCMDQbns4F4zBgpBQCLMAB8zAjTFIAG4IdFYhGQJCEkCY7EgKDMJA6UiyMAIpZof4IMAohJmcEAZTZHNkLIQaDQeAgSDh3Mu5B5wtF4shXRWeDQAEkkPR2BBvCCRdNVhAILJaSkbo4HoZjKZbLz+ZyPlCcuwUG4ACIhBBLZBgN0ew4rDnsEJipDTJDwVZGf2ZFWcLQg1D6w3GpCm81IR7Pa1Sk6ykUhh3OaHO31PL1IMAAflLnpio0DwfFYYjUbryrQcfYCZQSaNCBNt3TUFIlzQZnkeHIeELZi1EBQ9BCYXcSSi91SZQlnhgUBQ5Vyc4XS5+UXyM+coRVADpD4vQmYALyzw1H0JLM1INsoKw2HZuH9zPM56zhkWhOIqZgfjcagjh4ACy1h4DweDslgrAAOwAAwAEwAGwACwAJwAMzYdhACsrC4Tc8wSLAXbICgrrQPAjGqoQehkFQtAMCwHDcHwggiCANxAA) 34 | - [Interfaces](https://tsdiagram.com/#/N4IgJg9gxgrgtgUwHYBcDOIBcBtUBLMLEAIUgDMBhANwGkBaGgcwE4APATQHcAvAKwCkAqgA0YAQxAAaECjwoANgiIBJVAgBOZMVAQZpaCDHU6ieNZu0IABAEErwADpIrLq2QgRMVtCnVnGANxOAL5OZigaWjq29k6uVojqjAhgXj5+SIEhYeZR1sSxzq4ARmLqXjZBSKFIOREW0RSF8WJeAEoIUBDqYAA86f6StgB8VfHFXgCyYgAOvcRDA5nD2UjhkZZWACLNrq222AC6VTV1G9EAolYIrBFIYGgxjkUupeXevv4nZw3WAGLXW7IB62IYFZ7jMTcNKfTLfWprXKbADivQAKkNBMNdi50l40WNXEh4F5BPD1r8rAAJdGAu4gpaMTFWAC8VmJcGKGmxN3pj1RGKsWJxVgiPhhGSy1SkIHkYh8kwgYDwZDwKSwAEYAOwABgATAA2AAszA1Ro1AA4DXrgodpLB1OpkCgttB4M7lIRMCRyNR6Ew2Fw+EJRBJgkA) 35 | - [Type aliases](https://tsdiagram.com/#/N4IgJg9gxgrgtgUwHYBcDOIBcBtUBLMLEACwCYB2AJQC0BPAMQGYJTSBpALwHEEB1AQ14BRAI4BOEABoQKPCgA2CIgBVaABwQACAILy8-NAgzS0EGACcoSzDPVbtmgLyah58xHOaAZJuAAdJE0gzUMANwRzOVpMTT8QAHd+cyQ8JABzOM0AH1iQCPdzTJy4gDN+FH55OIBuAIBfAJQ7TQAhJx1apEbmgGF2-0Dg-hjtTuDNACMYlrHg8wQoDzAYygWlgB40FEj0yU0AEQA+eoDujQP2rZ206qkQeQMUAFkIMDwSvARCTABGcgAGUgANgALGJSGJyGJGD86gBdaSwNzIFD7aDwFEASW+JAoNAYzFYnB4AmE4hAdSAA) 36 | - [Interfaces and classes extends & implements](https://tsdiagram.com/#/N4IgJg9gxgrgtgUwHYBcDOIBcBtUBLMLEAKQFYBJAIwBEAOAOQC8ANAfSloFkAVAZgCsAwlABijAIwgANCBR4UAGwREAogA8UyMGgAEAMh144AByWJUGGWggwATlGWYQUBQEM0ugII7gOgGYQEJg6aCi2eEgA5gDcOgC+ADpILu66AEI+OpSutgAUAJTBoeFRmSgAFrYQAO46SAi1KrZVefmxcfFJKR46gjoIGlpehiZmyOg6Gb7ZrZm2CCh2SDoJINmMq+2dSEkRmrZ+rg46zD5JOhf+gUVhETFJiUh7CAdHCDoAmmfLlzM3JfckI9nq9jgAtfqDJDaE5ST7fS5ZVyMf53aIPaQgNyhTgQMB4Px4BCETDiADsAAYAEwANgALABOXhUqmkcQ0uIAXRksGa42o0Hg43IJJIFBoDBY7C4fCEogkIDiQA) 37 | 38 | ### Special thanks <3 39 | 40 | - [TypeScript](https://www.typescriptlang.org/) 41 | - [React Flow](https://reactflow.dev) 42 | - [Monaco](https://github.com/microsoft/monaco-editor) 43 | - [elkjs](https://github.com/kieler/elkjs) 44 | - [dom-to-svg](https://github.com/felixfbecker/dom-to-svg) 45 | -------------------------------------------------------------------------------- /src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, memo, useCallback, useEffect, useRef } from "react"; 2 | import MonacoEditor, { useMonaco } from "@monaco-editor/react"; 3 | import { initVimMode, InitVimModeResult } from "monaco-vim"; 4 | import { useStore } from "statelift"; 5 | import { documentsStore } from "../stores/documents"; 6 | import { useUserOptions } from "../stores/user-options"; 7 | import { themes } from "../themes"; 8 | 9 | type MonacoMountHandler = ComponentProps["onMount"]; 10 | type IStandaloneCodeEditor = Parameters>[0]; 11 | 12 | const editorOptions: ComponentProps["options"] = { 13 | minimap: { enabled: false }, 14 | renderLineHighlight: "none", 15 | fontSize: 15, 16 | scrollbar: { 17 | vertical: "auto", 18 | horizontal: "auto", 19 | }, 20 | }; 21 | 22 | export const Editor = memo(() => { 23 | const options = useUserOptions(); 24 | const currentDocumentSource = useStore(documentsStore, (state) => state.currentDocument.source); 25 | 26 | const monaco = useMonaco(); 27 | const editorRef = useRef(null); 28 | const vimModeRef = useRef(null); 29 | const vimStatusLineRef = useRef(null); 30 | 31 | const isVimMode = options.editor.editingMode === "vim"; 32 | 33 | const handleSourceChange = useCallback((value: string | undefined) => { 34 | documentsStore.state.setCurrentDocumentSource(value ?? ""); 35 | documentsStore.state.save(); 36 | }, []); 37 | 38 | useEffect(() => { 39 | if (!monaco) return; 40 | const themeConfig = themes[(options.editor.theme as keyof typeof themes) ?? "vsLight"] as Parameters< 41 | typeof monaco.editor.defineTheme 42 | >[1]; 43 | monaco.editor.defineTheme("theme", themeConfig); 44 | monaco.editor.setTheme("theme"); 45 | }, [monaco, options.editor.theme]); 46 | 47 | const handleMount: MonacoMountHandler = (mountedEditor, mountedMonaco) => { 48 | editorRef.current = mountedEditor; 49 | 50 | const compilerOptions = mountedMonaco.languages.typescript.typescriptDefaults.getCompilerOptions(); 51 | compilerOptions.target = mountedMonaco.languages.typescript.ScriptTarget.Latest; 52 | compilerOptions.lib = ["esnext"]; 53 | mountedMonaco.languages.typescript.typescriptDefaults.setCompilerOptions(compilerOptions); 54 | mountedEditor.updateOptions({ cursorStyle: isVimMode ? "block" : "line" }); 55 | mountedEditor.getModel()?.updateOptions({ tabSize: 2, indentSize: 2 }); 56 | mountedEditor.focus(); 57 | 58 | if (isVimMode) { 59 | if (!vimStatusLineRef.current) throw new Error("vimStatusLineRef.current is null"); 60 | vimModeRef.current = initVimMode(mountedEditor, vimStatusLineRef.current); 61 | vimModeRef.current.on("vim-mode-change", ({ mode }) => { 62 | if (!editorRef.current) return; 63 | mountedEditor.updateOptions({ cursorStyle: mode === "insert" ? "line" : "block" }); 64 | }); 65 | } 66 | }; 67 | 68 | useEffect(() => { 69 | if (isVimMode) { 70 | if (vimModeRef.current) return; 71 | if (!editorRef.current) return; 72 | if (!vimStatusLineRef.current) return; 73 | vimModeRef.current = initVimMode(editorRef.current, vimStatusLineRef.current); 74 | editorRef.current.updateOptions({ cursorStyle: "block" }); 75 | } else { 76 | vimModeRef.current?.dispose(); 77 | vimModeRef.current = null; 78 | editorRef.current?.updateOptions({ cursorStyle: "line" }); 79 | } 80 | }, [isVimMode]); 81 | 82 | return ( 83 |
    84 | 91 | {isVimMode &&
    } 92 |
    93 | ); 94 | }); 95 | -------------------------------------------------------------------------------- /src/lib/parser/Parser.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Parser } from "./Parser"; 3 | 4 | it("parses code into AST and updates it on code change", () => { 5 | const parser = new Parser("interface A { }"); 6 | expect(parser.interfaces.length).toBe(1); 7 | 8 | parser.setSource("interface A { }; interface B { }"); 9 | expect(parser.interfaces.length).toBe(2); 10 | }); 11 | 12 | it("parses interfaces", () => { 13 | const parser = new Parser(` 14 | interface A { foo: string; bar(): string; } 15 | interface B { baz: A; } 16 | interface C extends A, B { qux: string; } 17 | `); 18 | 19 | const interfaces = parser.interfaces; 20 | expect(interfaces.length).toBe(3); 21 | 22 | const [A, B, C] = interfaces; 23 | 24 | expect(A.name).toBe("A"); 25 | expect(A.declaration.getText()).toBe("interface A { foo: string; bar(): string; }"); 26 | expect(A.extends.length).toBe(0); 27 | expect(A.properties.length).toBe(1); 28 | expect(A.properties[0].getName()).toBe("foo"); 29 | expect(A.methods.length).toBe(1); 30 | expect(A.methods[0].getName()).toBe("bar"); 31 | 32 | expect(B.name).toBe("B"); 33 | expect(B.declaration.getText()).toBe("interface B { baz: A; }"); 34 | expect(B.extends.length).toBe(0); 35 | expect(B.properties.length).toBe(1); 36 | expect(B.properties[0].getName()).toBe("baz"); 37 | expect(B.methods.length).toBe(0); 38 | 39 | expect(C.name).toBe("C"); 40 | expect(C.declaration.getText()).toBe("interface C extends A, B { qux: string; }"); 41 | expect(C.extends.length).toBe(2); 42 | expect(C.extends[0].getText()).toBe("A"); 43 | expect(C.extends[1].getText()).toBe("B"); 44 | expect(C.properties.length).toBe(3); 45 | expect(C.properties[0].getName()).toBe("qux"); 46 | expect(C.properties[1].getName()).toBe("foo"); 47 | expect(C.properties[2].getName()).toBe("baz"); 48 | expect(C.methods.length).toBe(1); 49 | expect(C.methods[0].getName()).toBe("bar"); 50 | }); 51 | 52 | it("parses type aliases", () => { 53 | const parser = new Parser(` 54 | type A = { foo: string; bar(): string; }; 55 | type B = { baz: A; }; 56 | type C = A & B; 57 | type D = string; 58 | `); 59 | 60 | const typeAliases = parser.typeAliases; 61 | expect(typeAliases.length).toBe(4); 62 | const [A, B, C, D] = typeAliases; 63 | 64 | expect(A.name).toBe("A"); 65 | expect(A.declaration.getText()).toBe("type A = { foo: string; bar(): string; };"); 66 | expect(A.type.getText()).toBe("A"); 67 | 68 | expect(B.name).toBe("B"); 69 | expect(B.declaration.getText()).toBe("type B = { baz: A; };"); 70 | expect(B.type.getText()).toBe("B"); 71 | 72 | expect(C.name).toBe("C"); 73 | expect(C.declaration.getText()).toBe("type C = A & B;"); 74 | expect(C.type.getText()).toBe("C"); 75 | 76 | expect(D.name).toBe("D"); 77 | expect(D.declaration.getText()).toBe("type D = string;"); 78 | expect(D.type.getText()).toBe("string"); 79 | }); 80 | 81 | it("parses classes", () => { 82 | const parser = new Parser(` 83 | class A { foo: string; } 84 | class B { bar(): string { throw new Error(); } } 85 | class C extends A implements B { bar() { return "baz"; } } 86 | `); 87 | 88 | const classes = parser.classes; 89 | expect(classes.length).toBe(3); 90 | 91 | const [A, B, C] = classes; 92 | 93 | expect(A.name).toBe("A"); 94 | expect(A.declaration.getText()).toBe("class A { foo: string; }"); 95 | expect(A.extends).toBeUndefined(); 96 | expect(A.properties.length).toBe(1); 97 | expect(A.properties[0].getName()).toBe("foo"); 98 | expect(A.methods.length).toBe(0); 99 | 100 | expect(B.name).toBe("B"); 101 | expect(B.declaration.getText()).toBe("class B { bar(): string { throw new Error(); } }"); 102 | expect(B.extends).toBeUndefined(); 103 | expect(B.properties.length).toBe(0); 104 | expect(B.methods.length).toBe(1); 105 | expect(B.methods[0].getName()).toBe("bar"); 106 | 107 | expect(C.name).toBe("C"); 108 | expect(C.declaration.getText()).toBe('class C extends A implements B { bar() { return "baz"; } }'); 109 | expect(C.extends).toBeDefined(); 110 | expect(C.extends?.getText()).toBe("A"); 111 | expect(C.properties.length).toBe(1); 112 | expect(C.methods.length).toBe(1); 113 | }); 114 | -------------------------------------------------------------------------------- /src/components/Renderer/CustomEdge.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { getSmartEdge, PathFindingFunction, SVGDrawFunction } from "@tisoap/react-flow-smart-edge"; 3 | import { DiagonalMovement, JumpPointFinder } from "pathfinding"; 4 | import { BezierEdge, EdgeProps, useNodes, XYPosition } from "reactflow"; 5 | import { useIsEdgeDecorated } from "../../stores/graph"; 6 | import { Direction } from "../../types"; 7 | // import { edgeSegmentCache } from "../../edge-segment-cache"; 8 | 9 | const toPoint = ([x, y]: number[]): XYPosition => ({ x, y }); 10 | // const toXYPosition = ({ x, y }: XYPosition): [number, number] => [x, y]; 11 | 12 | const getDirection = (a: XYPosition, b: XYPosition) => { 13 | if (a.x === b.x) { 14 | if (a.y > b.y) return Direction.Top; 15 | return Direction.Bottom; 16 | } else if (a.y === b.y) { 17 | if (a.x > b.x) return Direction.Left; 18 | return Direction.Right; 19 | } 20 | return Direction.None; 21 | }; 22 | 23 | const processSegments = (points: XYPosition[]) => { 24 | const segments: { start: XYPosition; end: XYPosition; direction: Direction }[] = []; 25 | const directions = points 26 | .map((_, index) => { 27 | if (index === 0) return Direction.None; 28 | return getDirection(points[index - 1], points[index]); 29 | }) 30 | .slice(1); 31 | 32 | let [prev, curr] = points; 33 | let prevDirection = directions[0]; 34 | 35 | // align first segment 36 | if (prevDirection === Direction.None) { 37 | const nextDirection = directions[1]; 38 | if (nextDirection !== Direction.None) { 39 | prevDirection = nextDirection; 40 | if (nextDirection === Direction.Right || nextDirection === Direction.Left) { 41 | curr.x = prev.x; 42 | } else { 43 | curr.y = prev.y; 44 | } 45 | } 46 | } 47 | 48 | for (const next of points.slice(2)) { 49 | const nextDirection = getDirection(curr, next); 50 | if (nextDirection !== prevDirection) { 51 | segments.push({ start: prev, end: curr, direction: prevDirection }); 52 | prev = curr; 53 | prevDirection = nextDirection; 54 | } 55 | curr = next; 56 | } 57 | 58 | const lastSegment = { start: prev, end: curr, direction: prevDirection }; 59 | 60 | // align last segment (works, but the arrow goes crazy) 61 | // if (lastSegment.direction === Direction.None) { 62 | // const prevSegment = segments[segments.length - 1]; 63 | // if (prevSegment.direction === Direction.Right || prevSegment.direction === Direction.Left) { 64 | // lastSegment.start.x = lastSegment.end.x; 65 | // } else { 66 | // lastSegment.start.y = lastSegment.end.y; 67 | // } 68 | // } 69 | 70 | segments.push(lastSegment); 71 | return segments; 72 | }; 73 | 74 | const drawEdge: SVGDrawFunction = (source, target, path) => { 75 | const points = [ 76 | [Math.floor(source.x), Math.floor(source.y)], 77 | ...path, 78 | [Math.floor(target.x), Math.floor(target.y)], 79 | ].map(toPoint); 80 | 81 | try { 82 | processSegments(points); 83 | // edgeSegmentCache.set(edge.id, {}); 84 | } catch (error) { 85 | console.error(error); 86 | } 87 | 88 | const first = points[0]; 89 | let svgPath = `M${first.x},${first.y}M`; 90 | 91 | let prev = first; 92 | 93 | for (const next of points) { 94 | const midPoint = { x: (prev.x - next.x) / 2 + next.x, y: (prev.y - next.y) / 2 + next.y }; 95 | svgPath += ` ${midPoint.x},${midPoint.y}`; 96 | svgPath += `Q${next.x},${next.y}`; 97 | prev = next; 98 | } 99 | 100 | const last = points[points.length - 1]; 101 | svgPath += ` ${last.x},${last.y}`; 102 | 103 | return svgPath; 104 | }; 105 | 106 | const generatePath: PathFindingFunction = (grid, start, end) => { 107 | try { 108 | // @ts-ignore 109 | const finder = new JumpPointFinder({ diagonalMovement: DiagonalMovement.Never }); 110 | const fullPath = finder.findPath(start.x, start.y, end.x, end.y, grid); 111 | if (fullPath.length === 0) return null; 112 | return { fullPath, smoothedPath: fullPath }; 113 | } catch { 114 | return null; 115 | } 116 | }; 117 | 118 | const styles = { 119 | selfLoop: { 120 | strokeDasharray: "5, 5", 121 | stroke: "#a9b2bc", 122 | }, 123 | highlighted: { 124 | strokeWidth: 1, 125 | }, 126 | faded: { 127 | stroke: "#cad0d6", 128 | strokeOpacity: 0.5, 129 | }, 130 | }; 131 | 132 | export const CustomEdge = (edge: EdgeProps) => { 133 | const nodes = useNodes(); 134 | const { highlighted, faded } = useIsEdgeDecorated(edge); 135 | 136 | const edgeStyle = useMemo(() => { 137 | return { 138 | ...edge.style, 139 | ...(edge.source === edge.target ? styles.selfLoop : {}), 140 | ...(highlighted ? styles.highlighted : {}), 141 | ...(faded ? styles.faded : {}), 142 | }; 143 | }, [edge.source, edge.style, edge.target, faded, highlighted]); 144 | 145 | const getSmartEdgeResponse = useMemo( 146 | () => 147 | getSmartEdge({ 148 | sourcePosition: edge.sourcePosition, 149 | targetPosition: edge.targetPosition, 150 | sourceX: edge.sourceX, 151 | sourceY: edge.sourceY, 152 | targetX: edge.targetX, 153 | targetY: edge.targetY, 154 | nodes, 155 | options: { 156 | drawEdge, 157 | generatePath, 158 | nodePadding: 6, 159 | gridRatio: 10, 160 | }, 161 | }), 162 | [edge.sourcePosition, edge.targetPosition, edge.sourceX, edge.sourceY, edge.targetX, edge.targetY, nodes] 163 | ); 164 | 165 | if (getSmartEdgeResponse === null) { 166 | return ; 167 | } 168 | 169 | return ( 170 | <> 171 | 178 | 179 | ); 180 | }; 181 | -------------------------------------------------------------------------------- /src/stores/documents.ts: -------------------------------------------------------------------------------- 1 | import isEqual from "lodash/isEqual"; 2 | import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string"; 3 | import { nanoid } from "nanoid"; 4 | import { createStore, useStore } from "statelift"; 5 | import { z } from "zod"; 6 | import * as examples from "../examples"; 7 | 8 | const documentSchema = z.object({ 9 | id: z.string(), 10 | title: z.string(), 11 | source: z.string(), 12 | lastModified: z.number().default(Date.now()), 13 | }); 14 | export type Document = z.infer; 15 | 16 | const documentStateSchema = z.object({ 17 | documents: z.array(documentSchema), 18 | currentDocumentId: z.string(), 19 | }); 20 | export type DocumentsState = z.infer; 21 | export type DocumentsStore = DocumentsState & { 22 | readonly currentDocument: Document; 23 | save: () => void; 24 | create: () => void; 25 | delete: (id: string) => void; 26 | setCurrentDocumentId: (id: string) => void; 27 | setCurrentDocumentTitle: (title: string) => void; 28 | setCurrentDocumentSource: (source: string) => void; 29 | sortByLastModified: () => void; 30 | }; 31 | 32 | const defaultState: DocumentsState = { 33 | documents: [ 34 | { 35 | id: "default", 36 | title: "Welcome", 37 | source: examples.taskManagement, 38 | lastModified: Date.now(), 39 | }, 40 | ], 41 | currentDocumentId: "default", 42 | }; 43 | 44 | const serializeState = (state: DocumentsState) => { 45 | return JSON.stringify({ 46 | documents: state.documents, 47 | currentDocumentId: state.currentDocumentId, 48 | }); 49 | }; 50 | 51 | const saveLocalStorageState = (state: DocumentsState) => { 52 | localStorage.setItem("documents", serializeState(state)); 53 | }; 54 | 55 | const saveURLState = (state: DocumentsState) => { 56 | const monoState = { 57 | documents: state.documents.filter((d) => d.id === state.currentDocumentId), 58 | currentDocumentId: state.currentDocumentId, 59 | }; 60 | const compressed = compressToEncodedURIComponent(serializeState(monoState)); 61 | location.hash = `#/${compressed}`; 62 | }; 63 | 64 | const localStorageState = (() => { 65 | try { 66 | const data = JSON.parse(localStorage.getItem("documents") ?? ""); 67 | return documentStateSchema.parse(data); 68 | } catch {} 69 | return null; 70 | })(); 71 | 72 | const urlState = (() => { 73 | try { 74 | if (location.hash.startsWith("#/")) { 75 | const encoded = location.hash.slice(2); 76 | const decompressed = decompressFromEncodedURIComponent(encoded); 77 | const parsed = JSON.parse(decompressed); 78 | return { string: decompressed, state: documentStateSchema.parse(parsed) }; 79 | } 80 | } catch {} 81 | return null; 82 | })(); 83 | 84 | if (urlState && urlState.state.currentDocumentId === "default") { 85 | urlState.state.currentDocumentId = nanoid(); 86 | urlState.state.documents[0].id = urlState.state.currentDocumentId; 87 | } 88 | 89 | const combinedState = localStorageState ?? urlState?.state ?? defaultState; 90 | 91 | if (localStorageState && !urlState) { 92 | saveURLState(localStorageState); 93 | } 94 | 95 | // merge url state into local state 96 | let hasIngestedForeignState = false; 97 | if (localStorageState && urlState) { 98 | const urlDocumentId = urlState.state.currentDocumentId; 99 | const urlDocument = urlState.state.documents.find((d) => d.id === urlDocumentId); 100 | const localStorageDocument = localStorageState?.documents.find((d) => d.id === urlDocumentId); 101 | 102 | // if we've seen this document before, update it 103 | if (urlDocument && localStorageDocument) { 104 | if (!isEqual(urlDocument, localStorageDocument)) { 105 | localStorageDocument.title = urlDocument.title; 106 | localStorageDocument.source = urlDocument.source; 107 | hasIngestedForeignState = true; 108 | } 109 | combinedState.currentDocumentId = urlDocumentId; 110 | } 111 | 112 | // if it's a new document, ingest it 113 | if (urlDocument && !localStorageDocument) { 114 | combinedState.documents.unshift(urlDocument); 115 | combinedState.currentDocumentId = urlDocumentId; 116 | hasIngestedForeignState = true; 117 | } 118 | } 119 | 120 | export const documentsStore = createStore({ 121 | ...combinedState, 122 | get currentDocument() { 123 | const document = this.documents.find((doc: Document) => doc.id === this.currentDocumentId); 124 | if (!document) throw new Error("Document not found"); 125 | return document; 126 | }, 127 | save() { 128 | saveLocalStorageState(this); 129 | saveURLState(this); 130 | }, 131 | create() { 132 | const id = nanoid(); 133 | this.documents.unshift({ 134 | id, 135 | title: "Untitled", 136 | source: "", 137 | lastModified: Date.now(), 138 | }); 139 | this.currentDocumentId = id; 140 | this.sortByLastModified(); 141 | this.save(); 142 | }, 143 | delete(id: string) { 144 | if (this.documents.length === 1) { 145 | this.documents.push({ 146 | id: nanoid(), 147 | title: "Untitled", 148 | source: "", 149 | lastModified: Date.now(), 150 | }); 151 | } 152 | const isCurrentDocument = this.currentDocumentId === id; 153 | if (isCurrentDocument) { 154 | for (const document of this.documents) { 155 | if (document.id !== id) { 156 | this.currentDocumentId = document.id; 157 | break; 158 | } 159 | } 160 | } 161 | this.documents = this.documents.filter((d) => d.id !== id); 162 | this.save(); 163 | }, 164 | setCurrentDocumentId(id: string) { 165 | this.currentDocumentId = id; 166 | this.save(); 167 | }, 168 | setCurrentDocumentTitle(title: string) { 169 | this.currentDocument.title = title; 170 | this.currentDocument.lastModified = Date.now(); 171 | this.sortByLastModified(); 172 | this.save(); 173 | }, 174 | setCurrentDocumentSource(source: string) { 175 | this.currentDocument.source = source; 176 | this.currentDocument.lastModified = Date.now(); 177 | this.sortByLastModified(); 178 | this.save(); 179 | }, 180 | sortByLastModified() { 181 | this.documents.sort((a, b) => b.lastModified - a.lastModified); 182 | }, 183 | }); 184 | 185 | if (hasIngestedForeignState) { 186 | documentsStore.state.sortByLastModified(); 187 | documentsStore.state.save(); 188 | } 189 | 190 | export const useDocuments = () => useStore(documentsStore); 191 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { 3 | ArrowLeftIcon, 4 | ArrowRightIcon, 5 | ChevronDownIcon, 6 | ExternalLinkIcon, 7 | FilePlusIcon, 8 | GearIcon, 9 | Share1Icon, 10 | } from "@radix-ui/react-icons"; 11 | import classNames from "classnames"; 12 | import { useStore } from "statelift"; 13 | import { documentsStore } from "../stores/documents"; 14 | import { optionsStore, useUserOptions } from "../stores/user-options"; 15 | 16 | const RELATED_SITES = [ 17 | { 18 | name: "BenchJS", 19 | description: "Benchmark JavaScript online", 20 | href: "https://benchjs.com", 21 | }, 22 | { 23 | name: "SneakyDomains", 24 | description: "Find amazing domain names", 25 | href: "https://sneakydomains.com", 26 | }, 27 | ] as const; 28 | 29 | type HeaderProps = { 30 | onPreferencesClick?: () => void; 31 | onShareClick?: () => void; 32 | }; 33 | 34 | export const Header = memo(({ onPreferencesClick, onShareClick }: HeaderProps) => { 35 | const options = useUserOptions(); 36 | const documentTitle = useStore(documentsStore, (state) => state.currentDocument.title); 37 | 38 | const handleTitleChange = (e: React.ChangeEvent) => { 39 | documentsStore.state.setCurrentDocumentTitle(e.target.value); 40 | documentsStore.state.save(); 41 | }; 42 | 43 | const handleSidebarButtonClick = () => { 44 | optionsStore.state.general.sidebarOpen = !optionsStore.state.general.sidebarOpen; 45 | optionsStore.state.save(); 46 | }; 47 | 48 | const handleNewDocumentClick = () => { 49 | documentsStore.state.create(); 50 | }; 51 | 52 | return ( 53 |
    54 | {/* sidebar header */} 55 | {options.general.sidebarOpen && ( 56 |
    62 |
    63 | Documents 64 | 70 |
    71 |
    72 | )} 73 | 74 | {/* main wrapper */} 75 |
    76 | {/* left */} 77 |
    78 | {/* sidebar button */} 79 | 85 | 86 | {/* logo with site switcher */} 87 |
    88 |
    89 |
    90 | 91 | TS 92 | 93 | Diagram 94 | 95 |
    96 | 97 | {/* dropdown menu - on hover */} 98 |
    99 |
    100 | {RELATED_SITES.map((site) => ( 101 | 108 |
    109 |
    110 | {site.name} 111 | 112 |
    113 |

    {site.description}

    114 |
    115 |
    116 | ))} 117 |
    118 |
    119 |
    120 | 121 | {/* github link */} 122 |