├── .github ├── CONTRIBUTING.md ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── components.json ├── docker-compose.yml ├── eslint.config.mjs ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── favicon.png ├── locales │ ├── cn │ │ └── translation.json │ ├── jp │ │ └── translation.json │ └── ru │ │ └── translation.json ├── logo.svg ├── manifest.json └── robots.txt ├── src ├── App.tsx ├── atom │ ├── __tests__ │ │ ├── content.test.tsx │ │ ├── flags.test.tsx │ │ ├── group.test.tsx │ │ ├── insert.test.tsx │ │ ├── look-around.test.tsx │ │ ├── quantifier.test.tsx │ │ ├── remove.test.tsx │ │ ├── select.test.tsx │ │ └── undo.test.tsx │ ├── atoms.ts │ ├── content.ts │ ├── flags.ts │ ├── group.ts │ ├── index.ts │ ├── insert.ts │ ├── look-around.tsx │ ├── quantifier.ts │ ├── remove.ts │ ├── select.ts │ ├── undo.ts │ └── utils.ts ├── components │ ├── button-dropdown │ │ └── index.tsx │ ├── button-group │ │ └── index.tsx │ ├── cell │ │ └── index.tsx │ ├── header │ │ └── index.tsx │ ├── language-select │ │ └── index.tsx │ ├── legend-item │ │ └── index.tsx │ ├── logo │ │ └── index.tsx │ ├── mode-toggle │ │ └── index.tsx │ ├── range-input │ │ └── index.tsx │ ├── show-more │ │ └── index.tsx │ ├── test-item │ │ └── index.tsx │ ├── theme-provider │ │ └── index.tsx │ ├── ui │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── checkbox-group.tsx │ │ ├── checkbox.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── toggle.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts │ └── validation │ │ └── index.tsx ├── constants │ └── index.ts ├── global.css ├── i18n.ts ├── index.tsx ├── logo.svg ├── modules │ ├── editor │ │ ├── edit-tab.tsx │ │ ├── features │ │ │ ├── content │ │ │ │ ├── back-ref.tsx │ │ │ │ ├── class-character.tsx │ │ │ │ ├── helper.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── ranges.tsx │ │ │ │ ├── simple-string.tsx │ │ │ │ └── word-boundary.tsx │ │ │ ├── expression │ │ │ │ └── index.tsx │ │ │ ├── group │ │ │ │ └── index.tsx │ │ │ ├── insert │ │ │ │ └── index.tsx │ │ │ ├── look-around │ │ │ │ └── index.tsx │ │ │ └── quantifier │ │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── legend-tab.tsx │ │ ├── legends.tsx │ │ ├── test-tab.tsx │ │ └── utils.ts │ ├── graph │ │ ├── ast-graph.tsx │ │ ├── choice.tsx │ │ ├── content.tsx │ │ ├── end-connect.tsx │ │ ├── group-like.tsx │ │ ├── index.tsx │ │ ├── measure.ts │ │ ├── mid-connect.tsx │ │ ├── name-quantifier.tsx │ │ ├── nodes.tsx │ │ ├── quantifier.tsx │ │ ├── root-nodes.tsx │ │ ├── simple-graph.tsx │ │ ├── simple-node.tsx │ │ ├── start-connect.tsx │ │ ├── text.tsx │ │ └── utils.ts │ ├── home │ │ ├── index.tsx │ │ └── regex-input.tsx │ ├── playground │ │ └── index.tsx │ └── samples │ │ └── index.tsx ├── parser │ ├── __tests__ │ │ ├── backslash.test.ts │ │ ├── flag.test.ts │ │ ├── invalid-2015.test.ts │ │ ├── literal-gen.test.ts │ │ ├── lookbehind.test.ts │ │ ├── non-literal-parse.test.ts │ │ ├── valid-2015.test.ts │ │ └── visit.test.ts │ ├── ast.ts │ ├── backslash.ts │ ├── character-class.ts │ ├── dict.ts │ ├── gen-with-selected.ts │ ├── gen.ts │ ├── index.ts │ ├── lexer.ts │ ├── modifiers │ │ ├── choice.ts │ │ ├── content.ts │ │ ├── flags.ts │ │ ├── group.ts │ │ ├── index.ts │ │ ├── insert.ts │ │ ├── lookaround.ts │ │ ├── quantifier.ts │ │ ├── remove.ts │ │ └── replace.ts │ ├── parse.ts │ ├── parser.ts │ ├── patterns.ts │ ├── token.ts │ ├── utils.ts │ └── visit.ts ├── routes.tsx ├── utils.ts ├── utils │ ├── helpers │ │ └── index.ts │ ├── hooks │ │ ├── index.ts │ │ ├── use-current-state.ts │ │ ├── use-debounce-change.ts │ │ ├── use-drag-select.tsx │ │ ├── use-focus.ts │ │ ├── use-hover.ts │ │ └── use-latest.ts │ └── links │ │ └── index.tsx └── vite-envd.ts ├── tailwind.config.ts ├── tests ├── __mocks__ │ ├── analytics.js │ ├── css.js │ ├── react-i18next.js │ └── svgrMock.js ├── index.test.tsx └── setup.ts ├── tsconfig.json ├── vercel.json └── vite.config.ts /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Regex Vis Contribution Guide 2 | 3 | ## Ask questions 4 | 5 | You can open a new [discussion](https://github.com/Bowen7/regex-vis/discussions) to ask questions about this repository or get help. 6 | 7 | ## Report a bug 8 | 9 | Please first search the repository's [Issues](https://github.com/Bowen7/regex-vis/issues) page and make sure that no one has already reported it. 10 | 11 | If it hasn't been reported, please feel free to submit an issue. 12 | 13 | ## Open a PR 14 | 15 | Preparation: 16 | 17 | 1. `pnpm`. `regex-vis` use `pnpm` as the package manager, so make sure to install it globally. 18 | 2. Node.js v16.x You can choose a Node.js version manager to easily switch between different Node.js versions easily. 19 | 20 | Steps: 21 | 22 | 1. Fork this project to your own account. 23 | 2. Checkout to a new branch. 24 | 3. Run `pnpm install` to install dependencies. 25 | 4. Run `pnpm start` to start a development server on the 3000 port. 26 | 5. Edit code. 27 | 6. Run `pnpm test`. Make sure to pass all test cases. 28 | 7. Submit your code. 29 | 8. [Open a PR on Github](https://github.com/Bowen7/regex-vis/compare). 30 | 31 | ## FAQ 32 | 33 | Q: Can I use Chinese as the language of communication? 34 | 35 | A: Yes, but using English is a better choice to make it possible for more people to understand and participate in the conversation. -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Bowen7] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: bowen7 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: 4 | - main 5 | - dev 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - uses: pnpm/action-setup@v4 16 | name: Install pnpm 17 | with: 18 | version: 9 19 | run_install: false 20 | 21 | - name: Install Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 18 25 | cache: 'pnpm' 26 | 27 | - name: Install dependencies 28 | run: pnpm install 29 | 30 | - run: pnpm test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 106 | 107 | # dependencies 108 | /node_modules 109 | /.pnp 110 | .pnp.js 111 | 112 | # testing 113 | /coverage 114 | 115 | # production 116 | /build 117 | 118 | # misc 119 | .DS_Store 120 | .env.local 121 | .env.development.local 122 | .env.test.local 123 | .env.production.local 124 | 125 | npm-debug.log* 126 | yarn-debug.log* 127 | yarn-error.log* 128 | 129 | .vscode 130 | 131 | /export 132 | 133 | .swc -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false 4 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | # Install pnpm 4 | RUN npm install -g pnpm 5 | 6 | # Set the working directory 7 | WORKDIR /app 8 | 9 | # Copy package.json and package-lock.json to the working directory 10 | COPY . ./ 11 | 12 | # Install dependencies 13 | RUN pnpm install 14 | 15 | # Copy the rest of the application code 16 | COPY . . 17 | 18 | # Expose port 3000 19 | EXPOSE 3000 20 | 21 | # Start the application 22 | CMD [ "pnpm", "start"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bowen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Regex-Vis](https://regex-vis.com) 2 | 🎨 Regex visualizer & editor 3 | 4 | ## Preview 5 | ![regex-vis](https://user-images.githubusercontent.com/27432981/180222745-da4671c6-8e0e-44f2-818f-25d5fa237956.gif) 6 | 7 | 8 | ## Features 9 | - Visualizing regular expressions 10 | - Visual editing of regular expressions 11 | - Testing of regular expressions. 12 | 13 | ## Blog 14 | - [English](https://www.bowencodes.com/post/regex-vis_en) 15 | - [Chinese](https://www.bowencodes.com/post/regex-vis) 16 | 17 | ## Contribution Guide 18 | 19 | [Contribution Guide](https://github.com/Bowen7/regex-vis/blob/master/.github/CONTRIBUTING.md) 20 | 21 | ## Feedback 22 | 23 | - [Report a bug](https://github.com/Bowen7/regex-vis/issues) 24 | - You can also [open a new discussion](https://github.com/Bowen7/regex-vis/discussions) to ask questions about this repository or get help. 25 | 26 | ## License 27 | 28 | [MIT](https://choosealicense.com/licenses/mit/) 29 | 30 | ## Thanks 31 | 32 | This project is tested with BrowserStack. 33 | 34 | ## Star History 35 | 36 | [![Star History Chart](https://api.star-history.com/svg?repos=bowen7/regex-vis&type=Date)](https://star-history.com/#bowen7/regex-vis&Date) 37 | 38 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "global.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | regex-vis: 3 | build: . 4 | container_name: regex-vis 5 | ports: 6 | - "3000:3000" 7 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ react: true, rules: { 4 | 'react-refresh/only-export-components': 'off', 5 | 'style/brace-style': ['error', '1tbs'], 6 | 'antfu/top-level-function': 'off', 7 | '@typescript-eslint/consistent-type-definitions': ['warn', 'type'], 8 | } }) 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | Regex Vis 14 | 15 | 16 | 17 |
18 |
19 | <% if (!isDev) { %> 20 | 21 | 22 | 23 | <% } %> 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "regex-vis", 3 | "type": "module", 4 | "version": "0.1.0", 5 | "private": true, 6 | "scripts": { 7 | "start": "vite", 8 | "build": "tsc && vite build", 9 | "serve": "vite preview", 10 | "test": "vitest", 11 | "test:ui": "vitest --ui" 12 | }, 13 | "dependencies": { 14 | "@phosphor-icons/react": "^2.1.7", 15 | "@radix-ui/react-checkbox": "^1.1.1", 16 | "@radix-ui/react-dropdown-menu": "^2.1.1", 17 | "@radix-ui/react-icons": "^1.3.0", 18 | "@radix-ui/react-label": "^2.1.0", 19 | "@radix-ui/react-scroll-area": "^1.1.0", 20 | "@radix-ui/react-select": "^2.1.1", 21 | "@radix-ui/react-slot": "^1.1.0", 22 | "@radix-ui/react-tabs": "^1.1.0", 23 | "@radix-ui/react-toast": "^1.2.1", 24 | "@radix-ui/react-toggle": "^1.1.0", 25 | "@radix-ui/react-tooltip": "^1.1.2", 26 | "@sentry/react": "^8.17.0", 27 | "@vercel/analytics": "^1.0.0", 28 | "class-variance-authority": "^0.7.0", 29 | "clsx": "^2.1.1", 30 | "i18next": "^21.6.16", 31 | "i18next-browser-languagedetector": "^6.1.4", 32 | "i18next-http-backend": "^1.4.0", 33 | "immer": "^9.0.6", 34 | "jotai": "^2.9.0", 35 | "jotai-immer": "^0.4.1", 36 | "nanoid": "^3.1.16", 37 | "react": "^18.1.0", 38 | "react-dom": "^18.1.0", 39 | "react-i18next": "^11.16.7", 40 | "react-router-dom": "^6.3.0", 41 | "react-use": "^17.4.0", 42 | "tailwind-merge": "^2.4.0", 43 | "tailwindcss": "^3.4.4", 44 | "tailwindcss-animate": "^1.0.7", 45 | "typescript": "^5.5.0", 46 | "usehooks-ts": "^3.1.0", 47 | "vite": "^5.3.3", 48 | "vite-tsconfig-paths": "^4.3.2", 49 | "web-vitals": "^3.3.0", 50 | "zod": "^3.23.8" 51 | }, 52 | "browserslist": { 53 | "production": [ 54 | ">0.2%", 55 | "not dead", 56 | "not op_mini all" 57 | ], 58 | "development": [ 59 | "last 1 chrome version", 60 | "last 1 firefox version", 61 | "last 1 safari version" 62 | ] 63 | }, 64 | "devDependencies": { 65 | "@antfu/eslint-config": "^2.22.2", 66 | "@testing-library/dom": "^10.4.0", 67 | "@testing-library/jest-dom": "^6.4.8", 68 | "@testing-library/react": "^16.0.0", 69 | "@types/node": "^18.0.0", 70 | "@types/react": "^18.0.11", 71 | "@types/react-dom": "^18.0.5", 72 | "@types/styled-jsx": "^2.2.8", 73 | "@vitejs/plugin-react-swc": "^3.7.0", 74 | "@vitest/ui": "^2.0.5", 75 | "autoprefixer": "^10.4.19", 76 | "eslint": "^9.7.0", 77 | "eslint-plugin-react-hooks": "5.1.0-rc-df5f2736-20240712", 78 | "eslint-plugin-react-refresh": "^0.4.8", 79 | "jsdom": "^24.1.1", 80 | "postcss": "^8.4.39", 81 | "vite-plugin-ejs": "^1.7.0", 82 | "vitest": "^2.0.5", 83 | "vitest-canvas-mock": "^0.3.3" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bowen7/regex-vis/296c0c72660346e415fce8b5afc8d2473a86d738/public/favicon.png -------------------------------------------------------------------------------- /public/locales/cn/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "Home": "首页", 3 | "Samples": "样例", 4 | "Legends": "图例", 5 | "Edit": "编辑", 6 | "Test": "测试", 7 | "You can select nodes by dragging or clicking on the graph": "可以通过点击或拖拽选中节点", 8 | "Input a regular expression": "输入一条正则表达式", 9 | "Characters": "字符", 10 | "Direct match characters": "直接匹配字符串", 11 | "Character classes": "字符类", 12 | "Distinguish different types of characters": "区分不同类型的字符", 13 | "Ranges": "范围", 14 | "One of": "其一", 15 | "Matches any one of the enclosed characters": "匹配任何一个包含的字符", 16 | "None of": "没有其一", 17 | "Matches anything that is not enclosed in the brackets": "匹配任何没有包含在括号中的字符", 18 | "Choice": "或", 19 | "Group": "组", 20 | "Matches either \"x\" or \"y\"": "匹配 “x” 或者 “y”", 21 | "Quantifier": "量词", 22 | "Indicate numbers of characters or expressions to match": "表示要匹配的字符或表达式的数量", 23 | "Matches x and remembers the match": "匹配x并记住匹配项", 24 | "Matches \"x\" but does not remember the match": "匹配 “x”,但不记得匹配", 25 | "Matches \"x\" and stores it on the groups property of the returned matches under the name specified by ": "匹配 “x” 并将其存储在返回的匹配项的groups属性中,该属性位于 指定的名称下", 26 | "Back reference": "反向引用", 27 | "A back reference to match group #1": "匹配组 #1 的反向引用", 28 | "A back reference to match group #Name": "匹配组 #Name 的反向引用", 29 | "Assertion": "断言", 30 | "Begins with": "以...开始", 31 | "Ends with": "以...结束", 32 | "Matches the beginning of input": "匹配输入的开头", 33 | "Followed by:": "接着:", 34 | "Not followed by:": "不接着:", 35 | "Preceded by:": "前面是:", 36 | "Not preceded by:": "前面不是:", 37 | "WordBoundary": "单词边界", 38 | "NonWordBoundary": "非单词边界", 39 | "Matches \"x\" only if \"x\" is followed by \"y\"": "x 被 y 跟随时匹配 x", 40 | "Global search": "全局搜索", 41 | "Case-insensitive": "区分大小写", 42 | "Multi-line": "多行", 43 | "Add A Case": "添加一个用例", 44 | "Insert around": "插入节点", 45 | "Group selection": "分组", 46 | "Lookaround assertion": "向前/向后断言", 47 | "Before": "向前插入", 48 | "Parallel": "插入或", 49 | "After": "向后插入", 50 | "show more": "显示更多", 51 | "show less": "显示常用", 52 | "Expression": "表达式", 53 | "Content": "内容", 54 | "Capturing group": "捕获组", 55 | "Non-capturing group": "非捕获组", 56 | "Named capturing group": "具名捕获组", 57 | "Capturing": "捕获组", 58 | "Non-cap": "非捕获组", 59 | "Named cap": "具名捕获组", 60 | "Lookahead assertion": "向前断言", 61 | "Lookbehind assertion": "向后断言", 62 | "Lookahead": "向前断言", 63 | "Lookbehind": "向后断言", 64 | "Type": "类型", 65 | "Value": " 值", 66 | "Simple string": "简单字符串", 67 | "Character class": "字符类", 68 | "Character range": "字符范围", 69 | "Beginning Assertion": "开始断言", 70 | "End Assertion": "结束断言", 71 | "Word Boundary Assertion": "单词边界断言", 72 | "The input will be escaped automatically.": "输入将会被自动转义", 73 | "Negate": "否定", 74 | "negate": "否定", 75 | "Any character": "任意字符", 76 | "Any digit": "任意数字", 77 | "Non-digit": "任意非数字", 78 | "Any alphanumeric": "任意基本拉丁字母数字", 79 | "Non-alphanumeric": "任意非基本拉丁字母数字", 80 | "White space": "任意空白字符", 81 | "Non-white space": "任意非空白字符", 82 | "Horizontal tab": "制表符", 83 | "Carriage return": "回车符", 84 | "Linefeed": "换行符", 85 | "Vertical tab": "垂直制表符", 86 | "Form-feed": "Form-feed", 87 | "Backspace": "退格", 88 | "NUL": "NUL", 89 | "\\b Backspace": "\\b 退格", 90 | "\\t Horizontal Tab": "\\t 制表符", 91 | "\\n Line Feed": "\\n 换行符", 92 | "\\v Vertical Tab": "\\v 垂直制表符", 93 | "\\f Form Feed": "\\f Form Feed", 94 | "\\r Carriage Return": "\\r 回车符", 95 | "Class": "类", 96 | "Back Reference": "反向引用", 97 | "Choose one": "选择一项", 98 | "UnGroup": "取消组", 99 | "Cancel assertion": "取消断言", 100 | "times": "次数", 101 | "custom": "自定义", 102 | "1 (default)": "1 (默认)", 103 | "greedy": "贪婪", 104 | "1. Whole Numbers": "1. 整数", 105 | "2. Decimal Numbers": "2. 小数", 106 | "3. Whole + Decimal Numbers": "3. 整数 + 小数", 107 | "4. Negative, Positive Whole + Decimal Numbers": "4. 正负 整数 + 小数", 108 | "6. Date Format YYYY-MM-dd": "6. 日期格式 YYYY-MM-dd", 109 | "Flags: ": "标志: ", 110 | "Allows . to match newline": "允许 . 匹配换行符", 111 | "Settings: ": "设置: ", 112 | "include escape ": "包括转义 ", 113 | "Copy permalink": "复制链接", 114 | "Permalink copied.": "链接已复制", 115 | "Empty": "空", 116 | "Group's name": "组名" 117 | } 118 | -------------------------------------------------------------------------------- /public/locales/jp/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "Home": "ホーム", 3 | "Samples": "サンプル", 4 | "Legends": "凡例", 5 | "Edit": "編集", 6 | "You have to select nodes first": "最初にノードを選択してください", 7 | "Test": "テスト", 8 | "You can select nodes by dragging or clicking on the graph": "グラフ上でドラッグまたはクリックしてノードを選択できます", 9 | "Input a regular expression": "正規表現を入力してください", 10 | "Characters": "文字", 11 | "Direct match characters": "文字列に直接マッチさせる", 12 | "Character classes": "文字クラス", 13 | "Distinguish different types of characters": "異なる種類の文字を区別する", 14 | "Ranges": "範囲", 15 | "One of": "いずれか", 16 | "Matches any one of the enclosed characters": "いずれかの文字に一致する", 17 | "None of": "これ以外", 18 | "Matches anything that is not enclosed in the brackets": "指定した以外の任意の文字に一致する", 19 | "Choice": "または", 20 | "Group": "グループ", 21 | "Matches either \"x\" or \"y\"": "「x」または「y」に一致する", 22 | "Quantifier": "量指定子", 23 | "Indicate numbers of characters or expressions to match": "一致させる文字または式の数を指定する", 24 | "Matches x and remembers the match": "x に一致し、その一致を記憶する", 25 | "Matches \"x\" but does not remember the match": "x に一致するが、その一致は記憶しない", 26 | "Matches \"x\" and stores it on the groups property of the returned matches under the name specified by ": "「x」に一致し、その一致を で指定された名前で groups プロパティに保存する", 27 | "Back reference": "後方参照", 28 | "A back reference to match group #1": "グループ #1 への後方参照", 29 | "A back reference to match group #Name": "グループ #Name への後方参照", 30 | "Assertion": "アサーション", 31 | "Begins with": "先頭", 32 | "Ends with": "末尾", 33 | "Matches the beginning of input": "入力の先頭に一致する", 34 | "Followed by:": "次に続く:", 35 | "Not followed by:": "次に続かない:", 36 | "Preceded by:": "前にある:", 37 | "Not preceded by:": "前にない:", 38 | "WordBoundary": "単語境界", 39 | "NonWordBoundary": "非単語境界", 40 | "Matches \"x\" only if \"x\" is followed by \"y\"": "「x」の次に「y」に続く場合にのみ「x」に一致する", 41 | "Global search": "グローバル検索", 42 | "Case-insensitive": "大文字と小文字を区別", 43 | "Multi-line": "複数行", 44 | "Add A Case": "テストケースを追加する", 45 | "Insert around": "ノードを挿入", 46 | "Group selection": "グループ化", 47 | "Lookaround assertion": "先読み/後読みアサーション", 48 | "Before": "前に挿入", 49 | "Parallel": "「または」", 50 | "After": "後に挿入", 51 | "show more": "もっと表示する", 52 | "show less": "よく使うものだけ表示する", 53 | "Expression": "式", 54 | "Content": "内容", 55 | "An Empty Range": "レンジを追加", 56 | "Capturing group": "キャプチャする", 57 | "Non-capturing group": "キャプチャしない", 58 | "Named capturing group": "名前付きキャプチャ", 59 | "Capturing": "キャプチャする", 60 | "Non-cap": "キャプチャしない", 61 | "Named cap": "名前付きキャプチャ", 62 | "Lookahead assertion": "先読みアサーション", 63 | "Lookbehind assertion": "後読みアサーション", 64 | "Lookahead": "先読み", 65 | "Lookbehind": "後読み", 66 | "Type": "タイプ", 67 | "Value": "値", 68 | "Simple string": "単純な文字列", 69 | "Character class": "文字クラス", 70 | "Character range": "文字範囲", 71 | "Beginning Assertion": "先頭", 72 | "End Assertion": "末尾", 73 | "Word Boundary Assertion": "単語境界", 74 | "The input will be escaped automatically.": "入力は自動的にエスケープされます。", 75 | "Negate": "否定する", 76 | "negate": "否定する", 77 | "Any character": "任意の文字", 78 | "Any digit": "任意の数字", 79 | "Non-digit": "数字以外の文字", 80 | "Any alphanumeric": "英数字", 81 | "Non-alphanumeric": "英数字以外の文字", 82 | "White space": "空白文字", 83 | "Non-white space": "空白以外の文字", 84 | "Horizontal tab": "水平タブ", 85 | "Carriage return": "キャリッジリターン", 86 | "Linefeed": "改行文字", 87 | "Vertical tab": "垂直タブ", 88 | "Form-feed": "フォームフィード", 89 | "Backspace": "バックスペース", 90 | "NUL": "NUL", 91 | "\\b Backspace": "\\b バックスペース", 92 | "\\t Horizontal Tab": "\\t 水平タブ", 93 | "\\n Line Feed": "\\n 改行", 94 | "\\v Vertical Tab": "\\v 垂直タブ", 95 | "\\f Form Feed": "\\f フォームフィード", 96 | "\\r Carriage Return": "\\r キャリッジリターン", 97 | "ASCII symbol": "ASCII文字", 98 | "Unicode symbol": "Unicode文字", 99 | "Class": "クラス", 100 | "Back Reference": "逆参照", 101 | "Choose one": "一つ選ぶ", 102 | "UnGroup": "グループ解除する", 103 | "Cancel assertion": "断言を解除する", 104 | "times": "回数", 105 | "custom": "カスタム", 106 | "1 (default)": "1(デフォルト)", 107 | "0 or 1": "あるかないか", 108 | "0 or more": "0回以上", 109 | "1 or more": "1回以上", 110 | "greedy": "貪欲に", 111 | "Greedy": "貪欲にマッチ", 112 | "1. Whole Numbers": "1. 整数", 113 | "2. Decimal Numbers": "2. 小数", 114 | "3. Whole + Decimal Numbers": "3. 整数と小数", 115 | "4. Negative, Positive Whole + Decimal Numbers": "4. 正負の整数と小数", 116 | "6. Date Format YYYY-MM-dd": "6. 日付形式 YYYY-MM-dd", 117 | "Flags: ": "フラグ: ", 118 | "Flag: ": "フラグ: ", 119 | "Allows . to match newline": "ドットが改行に一致することを許可", 120 | "Settings: ": "設定: ", 121 | "include escape ": "エスケープ文字を含む", 122 | "Copy permalink": "パーマリンクをコピー", 123 | "Permalink copied.": "パーマリンクがコピーされました", 124 | "Empty": "空", 125 | "Group's name": "グループ名" 126 | } 127 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Regex Vis", 3 | "name": "Regex Vis", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router } from 'react-router-dom' 2 | import Routes from './routes' 3 | import Header from '@/components/header' 4 | import { ThemeProvider } from '@/components/theme-provider' 5 | import { Toaster } from '@/components/ui/toaster' 6 | 7 | export default function App() { 8 | return ( 9 | 10 | 11 |
12 |
13 | 14 |
15 | 16 |
17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/atom/__tests__/content.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, it, vi } from 'vitest' 2 | import { act } from 'react' 3 | import { renderHook } from '@testing-library/react' 4 | import { useAtom, useSetAtom } from 'jotai' 5 | import { nanoid } from 'nanoid' 6 | import { astAtom, selectedIdsAtom } from '../atoms' 7 | import { updateContentAtom } from '../content' 8 | import type { AST } from '@/parser' 9 | 10 | vi.mock('nanoid') 11 | 12 | it('update content', async () => { 13 | const { result: astAtomRef } = renderHook(() => useAtom(astAtom)) 14 | const { result: setUpdateContentAtom } = renderHook(() => 15 | useSetAtom(updateContentAtom), 16 | ) 17 | const { result: setSelectedIdsRef } = renderHook(() => 18 | useSetAtom(selectedIdsAtom), 19 | ) 20 | 21 | act(() => { 22 | astAtomRef.current[1]({ 23 | id: '1', 24 | type: 'regex', 25 | body: [ 26 | { 27 | id: '2', 28 | type: 'character', 29 | kind: 'string', 30 | value: 'foo', 31 | quantifier: null, 32 | }, 33 | ], 34 | flags: [], 35 | literal: true, 36 | escapeBackslash: false, 37 | }) 38 | setSelectedIdsRef.current(['2']) 39 | }) 40 | 41 | act(() => { 42 | setUpdateContentAtom.current({ 43 | kind: 'ranges', 44 | ranges: [{ from: 'a', to: 'z', id: '1' }], 45 | negate: false, 46 | }) 47 | }) 48 | 49 | const expected: AST.Regex = { 50 | id: '1', 51 | type: 'regex', 52 | body: [ 53 | { 54 | id: '2', 55 | type: 'character', 56 | kind: 'ranges', 57 | ranges: [{ from: 'a', to: 'z', id: '1' }], 58 | negate: false, 59 | quantifier: null, 60 | }, 61 | ], 62 | flags: [], 63 | literal: true, 64 | escapeBackslash: false, 65 | } 66 | 67 | expect(astAtomRef.current[0]).toEqual(expected) 68 | }) 69 | 70 | it('update a string node which has quantifier', async () => { 71 | const { result: astAtomRef } = renderHook(() => useAtom(astAtom)) 72 | const { result: setUpdateContentAtom } = renderHook(() => 73 | useSetAtom(updateContentAtom), 74 | ) 75 | const { result: setSelectedIdsRef } = renderHook(() => 76 | useSetAtom(selectedIdsAtom), 77 | ) 78 | 79 | act(() => { 80 | astAtomRef.current[1]({ 81 | id: '1', 82 | type: 'regex', 83 | body: [ 84 | { 85 | id: '2', 86 | type: 'character', 87 | kind: 'string', 88 | value: 'f', 89 | quantifier: { 90 | kind: 'custom', 91 | min: 2, 92 | max: 5, 93 | greedy: false, 94 | }, 95 | }, 96 | ], 97 | flags: [], 98 | literal: true, 99 | escapeBackslash: false, 100 | }) 101 | setSelectedIdsRef.current(['2']) 102 | }) 103 | 104 | vi.mocked(nanoid).mockReturnValue('3') 105 | 106 | act(() => { 107 | setUpdateContentAtom.current({ 108 | kind: 'string', 109 | value: 'fff', 110 | }) 111 | }) 112 | 113 | const expected: AST.Regex = { 114 | id: '1', 115 | type: 'regex', 116 | body: [ 117 | { 118 | id: '3', 119 | type: 'group', 120 | kind: 'nonCapturing', 121 | children: [ 122 | { 123 | id: '2', 124 | type: 'character', 125 | kind: 'string', 126 | value: 'fff', 127 | quantifier: null, 128 | }, 129 | ], 130 | quantifier: { 131 | kind: 'custom', 132 | min: 2, 133 | max: 5, 134 | greedy: false, 135 | }, 136 | }, 137 | ], 138 | flags: [], 139 | literal: true, 140 | escapeBackslash: false, 141 | } 142 | 143 | expect(astAtomRef.current[0]).toEqual(expected) 144 | }) 145 | -------------------------------------------------------------------------------- /src/atom/__tests__/flags.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { act } from 'react' 3 | import { renderHook } from '@testing-library/react' 4 | import { useAtom, useSetAtom } from 'jotai' 5 | import { updateFlagsAtom } from '../flags' 6 | import { astAtom } from '../atoms' 7 | import type { AST } from '@/parser' 8 | 9 | it('update flags', async () => { 10 | const { result: astAtomRef } = renderHook(() => useAtom(astAtom)) 11 | const { result: setUpdateFlagsAtom } = renderHook(() => 12 | useSetAtom(updateFlagsAtom), 13 | ) 14 | 15 | act(() => { 16 | astAtomRef.current[1]({ 17 | id: '1', 18 | type: 'regex', 19 | body: [ 20 | { 21 | id: '2', 22 | type: 'character', 23 | kind: 'string', 24 | value: 'foo', 25 | quantifier: null, 26 | }, 27 | ], 28 | flags: [], 29 | literal: true, 30 | escapeBackslash: false, 31 | }) 32 | }) 33 | 34 | act(() => { 35 | setUpdateFlagsAtom.current(['g', 'i']) 36 | }) 37 | 38 | const expected: AST.Regex = { 39 | id: '1', 40 | type: 'regex', 41 | body: [ 42 | { 43 | id: '2', 44 | type: 'character', 45 | kind: 'string', 46 | value: 'foo', 47 | quantifier: null, 48 | }, 49 | ], 50 | flags: ['g', 'i'], 51 | literal: true, 52 | escapeBackslash: false, 53 | } 54 | expect(astAtomRef.current[0]).toEqual(expected) 55 | }) 56 | -------------------------------------------------------------------------------- /src/atom/__tests__/remove.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { act } from 'react' 3 | import { renderHook } from '@testing-library/react' 4 | import { useAtom, useSetAtom } from 'jotai' 5 | import { removeAtom } from '../remove' 6 | import { astAtom, selectedIdsAtom } from '../atoms' 7 | import type { AST } from '@/parser' 8 | 9 | it('remove selected', async () => { 10 | const { result: astAtomRef } = renderHook(() => useAtom(astAtom)) 11 | const { result: selectedIdsAtomRef } = renderHook(() => 12 | useAtom(selectedIdsAtom), 13 | ) 14 | const { result: setRemoveAtom } = renderHook(() => useSetAtom(removeAtom)) 15 | 16 | act(() => { 17 | astAtomRef.current[1]({ 18 | id: '1', 19 | type: 'regex', 20 | body: [ 21 | { 22 | id: '2', 23 | type: 'character', 24 | kind: 'string', 25 | value: 'foo', 26 | quantifier: null, 27 | }, 28 | ], 29 | flags: [], 30 | literal: true, 31 | escapeBackslash: false, 32 | }) 33 | 34 | selectedIdsAtomRef.current[1](['2']) 35 | }) 36 | 37 | act(() => { 38 | setRemoveAtom.current() 39 | }) 40 | 41 | const expected: AST.Regex = { 42 | id: '1', 43 | type: 'regex', 44 | body: [], 45 | flags: [], 46 | literal: true, 47 | escapeBackslash: false, 48 | } 49 | expect(astAtomRef.current[0]).toEqual(expected) 50 | expect(selectedIdsAtomRef.current[0]).toEqual([]) 51 | }) 52 | 53 | it('remove selected in a choice node', async () => { 54 | const { result: astAtomRef } = renderHook(() => useAtom(astAtom)) 55 | const { result: selectedIdsAtomRef } = renderHook(() => 56 | useAtom(selectedIdsAtom), 57 | ) 58 | const { result: setRemoveAtom } = renderHook(() => useSetAtom(removeAtom)) 59 | 60 | act(() => { 61 | astAtomRef.current[1]({ 62 | id: '1', 63 | type: 'regex', 64 | body: [ 65 | { 66 | id: '2', 67 | type: 'choice', 68 | branches: [ 69 | [ 70 | { 71 | id: '3', 72 | type: 'character', 73 | kind: 'string', 74 | value: 'foo', 75 | quantifier: null, 76 | }, 77 | ], 78 | [ 79 | { 80 | id: '4', 81 | type: 'character', 82 | kind: 'string', 83 | value: 'foo', 84 | quantifier: null, 85 | }, 86 | ], 87 | ], 88 | }, 89 | ], 90 | flags: [], 91 | literal: true, 92 | escapeBackslash: false, 93 | }) 94 | 95 | selectedIdsAtomRef.current[1](['3']) 96 | }) 97 | 98 | act(() => { 99 | setRemoveAtom.current() 100 | }) 101 | 102 | const expected: AST.Regex = { 103 | id: '1', 104 | type: 'regex', 105 | body: [ 106 | { 107 | id: '4', 108 | type: 'character', 109 | kind: 'string', 110 | value: 'foo', 111 | quantifier: null, 112 | }, 113 | ], 114 | flags: [], 115 | literal: true, 116 | escapeBackslash: false, 117 | } 118 | expect(astAtomRef.current[0]).toEqual(expected) 119 | expect(selectedIdsAtomRef.current[0]).toEqual([]) 120 | }) 121 | -------------------------------------------------------------------------------- /src/atom/__tests__/select.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, it, vi } from 'vitest' 2 | import { act } from 'react' 3 | import { renderHook } from '@testing-library/react' 4 | import { useAtom, useSetAtom } from 'jotai' 5 | import { selectedIdsAtom } from '../atoms' 6 | import { clearSelectedAtom } from '../select' 7 | 8 | vi.mock('nanoid') 9 | 10 | it('clear selected', async () => { 11 | const { result: selectedIdsAtomRef } = renderHook(() => 12 | useAtom(selectedIdsAtom), 13 | ) 14 | const { result: setClearSelectedRef } = renderHook(() => 15 | useSetAtom(clearSelectedAtom), 16 | ) 17 | 18 | act(() => { 19 | selectedIdsAtomRef.current[1](['1']) 20 | }) 21 | 22 | act(() => { 23 | setClearSelectedRef.current() 24 | }) 25 | 26 | expect(selectedIdsAtomRef.current[0]).toEqual([]) 27 | }) 28 | -------------------------------------------------------------------------------- /src/atom/__tests__/undo.test.tsx: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { act } from 'react' 3 | import { renderHook } from '@testing-library/react' 4 | import { useAtom, useSetAtom } from 'jotai' 5 | import { astAtom, redoStack, selectedIdsAtom, undoStack } from '../atoms' 6 | import { redoAtom, undoAtom } from '../undo' 7 | import { updateContentAtom } from '../content' 8 | import type { AST } from '@/parser' 9 | 10 | it('undo and redo', async () => { 11 | const { result: astAtomRef } = renderHook(() => useAtom(astAtom)) 12 | const { result: setUpdateContentAtom } = renderHook(() => 13 | useSetAtom(updateContentAtom), 14 | ) 15 | const { result: setSelectedIdsRef } = renderHook(() => 16 | useSetAtom(selectedIdsAtom), 17 | ) 18 | const { result: setUndoAtom } = renderHook(() => useSetAtom(undoAtom)) 19 | const { result: setRedoAtom } = renderHook(() => useSetAtom(redoAtom)) 20 | 21 | // reset redo stack and undo stack 22 | redoStack.length = 0 23 | undoStack.length = 0 24 | 25 | const ast1: AST.Regex = { 26 | id: '1', 27 | type: 'regex', 28 | body: [ 29 | { 30 | id: '2', 31 | type: 'character', 32 | kind: 'string', 33 | value: 'foo', 34 | quantifier: null, 35 | }, 36 | ], 37 | flags: [], 38 | literal: true, 39 | escapeBackslash: false, 40 | } 41 | 42 | const ast2: AST.Regex = { 43 | id: '1', 44 | type: 'regex', 45 | body: [ 46 | { 47 | id: '2', 48 | type: 'character', 49 | kind: 'string', 50 | value: '123', 51 | quantifier: null, 52 | }, 53 | ], 54 | flags: [], 55 | literal: true, 56 | escapeBackslash: false, 57 | } 58 | 59 | act(() => { 60 | astAtomRef.current[1](ast1) 61 | setSelectedIdsRef.current(['2']) 62 | }) 63 | 64 | act(() => { 65 | setUpdateContentAtom.current({ kind: 'string', value: '123' }) 66 | }) 67 | 68 | expect(undoStack).toEqual([ast1]) 69 | 70 | act(() => { 71 | setUndoAtom.current() 72 | }) 73 | 74 | expect(astAtomRef.current[0]).toEqual(ast1) 75 | expect(undoStack).toEqual([]) 76 | expect(redoStack).toEqual([ast2]) 77 | 78 | act(() => { 79 | setRedoAtom.current() 80 | }) 81 | 82 | expect(astAtomRef.current[0]).toEqual(ast2) 83 | expect(undoStack).toEqual([ast1]) 84 | expect(redoStack).toEqual([]) 85 | }) 86 | -------------------------------------------------------------------------------- /src/atom/atoms.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { atomWithImmer } from 'jotai-immer' 3 | import type { AST } from '@/parser' 4 | import type { NodeSize } from '@/modules/graph/measure' 5 | 6 | export const undoStack: AST.Regex[] = [] 7 | export const redoStack: AST.Regex[] = [] 8 | export const nodesBoxMap: Map< 9 | string, 10 | { x1: number, y1: number, x2: number, y2: number }[] 11 | > = new Map() 12 | 13 | export const astAtom = atomWithImmer({ 14 | id: '', 15 | type: 'regex', 16 | body: [], 17 | flags: [], 18 | literal: false, 19 | escapeBackslash: false, 20 | }) 21 | 22 | export const selectedIdsAtom = atom([]) 23 | export const groupNamesAtom = atom([]) 24 | 25 | export const sizeMapAtom = atom>(new Map()) 26 | export const isPrimaryGraphAtom = atom(true) 27 | -------------------------------------------------------------------------------- /src/atom/content.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { astAtom, selectedIdsAtom } from './atoms' 3 | import { refreshValidUndoAtom } from './utils' 4 | import type { AST } from '@/parser' 5 | import { updateContent } from '@/parser' 6 | 7 | export const updateContentAtom = atom( 8 | null, 9 | (get, set, content: AST.Content) => { 10 | set(astAtom, (draft) => { 11 | const selectedIds = get(selectedIdsAtom) 12 | if (selectedIds.length !== 1) { 13 | return 14 | } 15 | const nextSelectedId = updateContent(draft, selectedIds[0], content) 16 | set(selectedIdsAtom, [nextSelectedId]) 17 | set(refreshValidUndoAtom, draft) 18 | }) 19 | }, 20 | ) 21 | -------------------------------------------------------------------------------- /src/atom/flags.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { astAtom } from './atoms' 3 | import { pushUndoAtom } from './utils' 4 | import { updateFlags } from '@/parser' 5 | 6 | export const updateFlagsAtom = atom(null, (get, set, flags: string[]) => { 7 | set(astAtom, (draft) => { 8 | updateFlags(draft, flags) 9 | set(pushUndoAtom) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/atom/group.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { astAtom, selectedIdsAtom } from './atoms' 3 | import { refreshValidUndoAtom } from './utils' 4 | import type { AST } from '@/parser' 5 | import { groupSelected, updateGroup } from '@/parser' 6 | 7 | export const updateGroupAtom = atom( 8 | null, 9 | (get, set, group: AST.Group | null) => { 10 | set(astAtom, (draft) => { 11 | const selectedIds = updateGroup(draft, get(selectedIdsAtom), group) 12 | set(selectedIdsAtom, selectedIds) 13 | set(refreshValidUndoAtom, draft) 14 | }) 15 | }, 16 | ) 17 | 18 | export const groupSelectedAtom = atom(null, (get, set, group: AST.Group) => { 19 | set(astAtom, (draft) => { 20 | const selectedIds = groupSelected(draft, get(selectedIdsAtom), group) 21 | set(selectedIdsAtom, selectedIds) 22 | set(refreshValidUndoAtom, draft) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/atom/index.ts: -------------------------------------------------------------------------------- 1 | export * from './atoms' 2 | export * from './select' 3 | export * from './content' 4 | export * from './quantifier' 5 | export * from './flags' 6 | export * from './insert' 7 | export * from './look-around' 8 | export * from './remove' 9 | export * from './undo' 10 | export * from './group' 11 | -------------------------------------------------------------------------------- /src/atom/insert.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { astAtom, selectedIdsAtom } from './atoms' 3 | import { validUndoAtom } from './utils' 4 | import { insertAroundSelected } from '@/parser' 5 | 6 | export const insertAtom = atom( 7 | null, 8 | (get, set, direction: 'prev' | 'next' | 'branch') => { 9 | set(astAtom, (draft) => { 10 | const selectedIds = get(selectedIdsAtom) 11 | insertAroundSelected(draft, selectedIds, direction) 12 | set(validUndoAtom, draft) 13 | }) 14 | }, 15 | ) 16 | -------------------------------------------------------------------------------- /src/atom/look-around.tsx: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { astAtom, selectedIdsAtom } from './atoms' 3 | import { validUndoAtom } from './utils' 4 | import { 5 | lookAroundAssertionSelected, 6 | unLookAroundAssertion, 7 | updateLookAroundAssertion, 8 | } from '@/parser' 9 | 10 | export const updateLookAroundAtom = atom( 11 | null, 12 | ( 13 | get, 14 | set, 15 | lookAround: { 16 | kind: 'lookahead' | 'lookbehind' 17 | negate: boolean 18 | } | null, 19 | ) => { 20 | set(astAtom, (draft) => { 21 | const selectedIds = get(selectedIdsAtom) 22 | if (!lookAround) { 23 | const nextSelectedIds = unLookAroundAssertion(draft, selectedIds) 24 | set(selectedIdsAtom, nextSelectedIds) 25 | } else { 26 | updateLookAroundAssertion(draft, selectedIds, lookAround) 27 | } 28 | set(validUndoAtom, draft) 29 | }) 30 | }, 31 | ) 32 | 33 | export const lookAroundSelectedAtom = atom( 34 | null, 35 | (get, set, kind: 'lookahead' | 'lookbehind') => { 36 | set(astAtom, (draft) => { 37 | const selectedIds = get(selectedIdsAtom) 38 | const nextSelectedIds = lookAroundAssertionSelected( 39 | draft, 40 | selectedIds, 41 | kind, 42 | ) 43 | set(selectedIdsAtom, nextSelectedIds) 44 | set(validUndoAtom, draft) 45 | }) 46 | }, 47 | ) 48 | -------------------------------------------------------------------------------- /src/atom/quantifier.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { astAtom, selectedIdsAtom } from './atoms' 3 | import { refreshValidUndoAtom } from './utils' 4 | import type { AST } from '@/parser' 5 | import { updateQuantifier } from '@/parser' 6 | import { toast } from '@/components/ui/use-toast' 7 | 8 | export const updateQuantifierAtom = atom( 9 | null, 10 | (get, set, quantifier: AST.Quantifier | null) => { 11 | set(astAtom, (draft) => { 12 | const selectedIds = get(selectedIdsAtom) 13 | if (selectedIds.length === 1) { 14 | const nextSelectedId = updateQuantifier( 15 | draft, 16 | selectedIds[0], 17 | quantifier, 18 | ) 19 | if (nextSelectedId !== selectedIds[0]) { 20 | set(selectedIdsAtom, [nextSelectedId]) 21 | toast({ 22 | description: 'Group selection automatically', 23 | }) 24 | } 25 | } 26 | set(refreshValidUndoAtom, draft) 27 | }) 28 | }, 29 | ) 30 | -------------------------------------------------------------------------------- /src/atom/remove.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { astAtom, selectedIdsAtom } from './atoms' 3 | import { clearSelectedAtom } from './select' 4 | import { refreshValidUndoAtom } from './utils' 5 | import { removeSelected } from '@/parser' 6 | 7 | export const removeAtom = atom(null, (get, set) => { 8 | set(astAtom, (draft) => { 9 | const selectedIds = get(selectedIdsAtom) 10 | removeSelected(draft, selectedIds) 11 | set(clearSelectedAtom) 12 | set(refreshValidUndoAtom, draft) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/atom/select.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { astAtom, nodesBoxMap, selectedIdsAtom } from './atoms' 3 | import type { AST } from '@/parser' 4 | import { visitNodes } from '@/parser' 5 | 6 | export const clearSelectedAtom = atom(null, (get, set) => { 7 | set(selectedIdsAtom, []) 8 | }) 9 | 10 | export const selectNodeAtom = atom(null, (get, set, id: string) => { 11 | const selectedIds = get(selectedIdsAtom) 12 | if (selectedIds.length === 1 && selectedIds[0] === id) { 13 | set(selectedIdsAtom, []) 14 | } else { 15 | set(selectedIdsAtom, [id]) 16 | } 17 | }) 18 | 19 | export const selectNodesAtom = atom(null, (get, set, ids: string[]) => { 20 | set(selectedIdsAtom, ids) 21 | }) 22 | 23 | export const selectNodesByBoxAtom = atom( 24 | null, 25 | (get, set, box: { x1: number, y1: number, x2: number, y2: number }) => { 26 | const ast = get(astAtom) 27 | const ids: string[] = [] 28 | visitNodes(ast, (id: string, index: number, nodes: AST.Node[]) => { 29 | const boxes = nodesBoxMap.get(`${id}-${index}`)! 30 | for (let i = 0; i < boxes.length; i++) { 31 | const nodeBox = boxes[i] 32 | if ( 33 | box.x1 <= nodeBox.x1 34 | && box.x2 >= nodeBox.x2 35 | && box.y1 <= nodeBox.y1 36 | && box.y2 >= nodeBox.y2 37 | ) { 38 | ids.push(nodes[i].id) 39 | } else if (ids.length > 0) { 40 | break 41 | } 42 | } 43 | if (ids.length > 0) { 44 | return true 45 | } 46 | return false 47 | }) 48 | set(selectNodesAtom, ids) 49 | }, 50 | ) 51 | -------------------------------------------------------------------------------- /src/atom/undo.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { astAtom, redoStack, undoStack } from './atoms' 3 | import { clearSelectedAtom } from './select' 4 | 5 | export const undoAtom = atom(null, (get, set) => { 6 | if (undoStack.length > 0) { 7 | const ast = undoStack.pop()! 8 | redoStack.push(get(astAtom)) 9 | set(clearSelectedAtom) 10 | set(astAtom, ast) 11 | } 12 | }) 13 | 14 | export const redoAtom = atom(null, (get, set) => { 15 | if (redoStack.length > 0) { 16 | const ast = redoStack.pop()! 17 | undoStack.push(get(astAtom)) 18 | set(clearSelectedAtom) 19 | set(astAtom, ast) 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /src/atom/utils.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { astAtom, groupNamesAtom, undoStack } from './atoms' 3 | import type { AST } from '@/parser' 4 | import { makeChoiceValid, visit } from '@/parser' 5 | import { toast } from '@/components/ui/use-toast' 6 | 7 | export const refreshGroupAtom = atom(null, (get, set, ast: AST.Regex) => { 8 | let groupIndex = 0 9 | const groupNames: string[] = [] 10 | visit(ast, (node: AST.Node) => { 11 | if ( 12 | node.type === 'group' 13 | && (node.kind === 'capturing' || node.kind === 'namedCapturing') 14 | ) { 15 | const index = ++groupIndex 16 | node.index = index 17 | if (node.kind === 'capturing') { 18 | node.name = index.toString() 19 | groupNames.push(index.toString()) 20 | } else { 21 | groupNames.push(node.name) 22 | } 23 | } 24 | }) 25 | set(groupNamesAtom, groupNames) 26 | }) 27 | 28 | export const pushUndoAtom = atom(null, (get) => { 29 | undoStack.push(get(astAtom)) 30 | }) 31 | 32 | export const makeChoiceValidAtom = atom(null, (get, set, ast: AST.Regex) => { 33 | if (!makeChoiceValid(ast)) { 34 | toast({ 35 | description: 'Group automatically', 36 | }) 37 | } 38 | }) 39 | 40 | export const refreshValidUndoAtom = atom(null, (get, set, ast: AST.Regex) => { 41 | set(refreshGroupAtom, ast) 42 | set(makeChoiceValidAtom, ast) 43 | set(pushUndoAtom) 44 | }) 45 | 46 | export const validUndoAtom = atom(null, (get, set, ast: AST.Regex) => { 47 | set(makeChoiceValidAtom, ast) 48 | set(pushUndoAtom) 49 | }) 50 | -------------------------------------------------------------------------------- /src/components/button-dropdown/index.tsx: -------------------------------------------------------------------------------- 1 | import { CaretDownIcon } from '@radix-ui/react-icons' 2 | import { Button } from '@/components/ui/button' 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuGroup, 7 | DropdownMenuItem, 8 | DropdownMenuTrigger, 9 | } from '@/components/ui/dropdown-menu' 10 | 11 | type Props = { 12 | children: React.ReactNode 13 | buttonProps?: React.ComponentProps 14 | } 15 | 16 | export const ButtonDropdownItem = DropdownMenuItem 17 | 18 | export function ButtonDropdown(props: Props) { 19 | const { buttonProps, children } = props 20 | return ( 21 | 22 | 23 | 26 | 27 | 28 | 29 | {children} 30 | 31 | 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/components/button-group/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from 'react' 2 | import clsx from 'clsx' 3 | 4 | type Props = ComponentProps<'div'> & { 5 | variant: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' 6 | } 7 | export function ButtonGroup(props: Props) { 8 | const { className, variant, ...rest } = props 9 | return ( 10 |
button]:rounded-none [&>button]:font-normal [&>button]:px-3 [&>*:first-child]:rounded-l-md [&>*:last-child]:rounded-r-md', { 13 | '[&>*:not(:last-child)]:border-r-0': variant === 'outline', 14 | }, className)} 15 | /> 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/cell/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Question as QuestionIcon } from '@phosphor-icons/react' 3 | import clsx from 'clsx' 4 | import type { MdnLinkKey } from '@/utils/links' 5 | import mdnLinks from '@/utils/links' 6 | import { Button } from '@/components/ui/button' 7 | import { 8 | Tooltip, 9 | TooltipContent, 10 | TooltipProvider, 11 | TooltipTrigger, 12 | } from '@/components/ui/tooltip' 13 | 14 | type ItemProps = { 15 | label: string 16 | children: React.ReactNode 17 | } 18 | function CellItem({ label, children }: ItemProps) { 19 | return ( 20 |
21 |
{label}
22 | {children} 23 |
24 | ) 25 | } 26 | 27 | type Props = { 28 | className?: string 29 | label: string 30 | mdnLinkKey?: MdnLinkKey 31 | rightIcon?: React.ReactNode 32 | rightTooltip?: string 33 | onRightIconClick?: () => void 34 | children: React.ReactNode 35 | } 36 | function Cell({ 37 | className, 38 | label, 39 | mdnLinkKey, 40 | children, 41 | rightIcon, 42 | rightTooltip, 43 | onRightIconClick, 44 | }: Props) { 45 | return ( 46 |
47 |
48 |
{label}
49 | {mdnLinkKey && ( 50 | 51 | 52 | 53 | )} 54 | {rightIcon && ( 55 | 56 | 57 | 58 | 61 | 62 | 63 |

{rightTooltip}

64 |
65 |
66 |
67 | )} 68 |
69 |
{children}
70 |
71 | ) 72 | } 73 | 74 | Cell.Item = CellItem 75 | export default Cell 76 | -------------------------------------------------------------------------------- /src/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | import { Link, NavLink } from 'react-router-dom' 3 | import clsx from 'clsx' 4 | import { useTranslation } from 'react-i18next' 5 | import { GitHubLogoIcon } from '@radix-ui/react-icons' 6 | import { LanguageSelect } from '@/components/language-select' 7 | import { ModeToggle } from '@/components/mode-toggle' 8 | import { Logo } from '@/components/logo' 9 | 10 | function navLinkClassName({ isActive }: { isActive: boolean }) { 11 | return clsx('transition-colors hover:text-foreground/80 text-sm', isActive ? 'text-foreground' : 'text-foreground/60') 12 | } 13 | 14 | const Header = memo(() => { 15 | const { t } = useTranslation() 16 | return ( 17 |
18 |
19 | 20 |
21 | 22 | Regex Vis 23 |
24 | 25 | 29 | {t('Home')} 30 | 31 | 35 | {t('Samples')} 36 | 37 |
38 |
39 | 40 | 45 |
46 | 47 |
48 |
49 | 50 |
51 |
52 | ) 53 | }) 54 | 55 | export default Header 56 | -------------------------------------------------------------------------------- /src/components/language-select/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next' 2 | import { 3 | Select, 4 | SelectContent, 5 | SelectGroup, 6 | SelectItem, 7 | SelectTrigger, 8 | SelectValue, 9 | } from '@/components/ui/select' 10 | 11 | export function LanguageSelect() { 12 | const { i18n } = useTranslation() 13 | const language = i18n.language 14 | 15 | return ( 16 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/components/legend-item/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | 4 | type Props = { 5 | name: string 6 | infos: { 7 | desc: string 8 | Icon: React.ReactNode 9 | }[] 10 | } 11 | const LegendItem: React.FC = ({ name, infos }) => { 12 | const { t } = useTranslation() 13 | return ( 14 |
15 |
16 | {t(name)} 17 | : 18 |
19 | {infos.map(({ Icon, desc }) => ( 20 | 21 | {Icon} 22 | {t(desc)} 23 | 24 | ))} 25 |
26 | ) 27 | } 28 | 29 | export default LegendItem 30 | -------------------------------------------------------------------------------- /src/components/logo/index.tsx: -------------------------------------------------------------------------------- 1 | type Props = React.ComponentProps<'svg'> 2 | 3 | export function Logo(props: Props) { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/mode-toggle/index.tsx: -------------------------------------------------------------------------------- 1 | import { MoonIcon, SunIcon } from '@radix-ui/react-icons' 2 | import { useTheme } from '@/components/theme-provider' 3 | 4 | export function ModeToggle() { 5 | const { theme, setTheme } = useTheme() 6 | 7 | const onClick = () => { 8 | setTheme(theme === 'dark' ? 'light' : 'dark') 9 | } 10 | 11 | return ( 12 |
13 | {theme === 'dark' ? : } 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/range-input/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Trash as TrashIcon } from '@phosphor-icons/react' 3 | import clsx from 'clsx' 4 | import { Input } from '@/components/ui/input' 5 | import { useFocus } from '@/utils/hooks/use-focus' 6 | import { useHover } from '@/utils/hooks/use-hover' 7 | 8 | export type Range = { 9 | start: string 10 | end: string 11 | } 12 | 13 | type Prop = { 14 | className?: string 15 | value: Range 16 | startPlaceholder?: string 17 | endPlaceholder?: string 18 | removable?: boolean 19 | onChange: (value: Range) => void 20 | onRemove?: () => void 21 | } 22 | export const RangeInput: React.FC = ({ 23 | className, 24 | value, 25 | startPlaceholder = '', 26 | endPlaceholder = '', 27 | removable = true, 28 | onChange, 29 | onRemove, 30 | }) => { 31 | const { hovered, hoverProps } = useHover() 32 | const { focused, focusProps } = useFocus() 33 | const removeBtnVisible = hovered || focused 34 | 35 | const onStartChange = (start: string) => { 36 | onChange({ start, end: value.end }) 37 | } 38 | const onEndChange = (end: string) => { 39 | onChange({ start: value.start, end }) 40 | } 41 | 42 | return ( 43 |
44 |
45 | 52 | {' - '} 53 | 60 |
61 | {removable && ( 62 | 66 | 67 | 68 | )} 69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /src/components/show-more/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useLocalStorage } from 'react-use' 3 | import { useTranslation } from 'react-i18next' 4 | import { CaretDown as CaretDownIcon } from '@phosphor-icons/react' 5 | import clsx from 'clsx' 6 | 7 | type Props = { 8 | id: string 9 | children: React.ReactNode 10 | } 11 | function ShowMore({ id, children }: Props) { 12 | const { t } = useTranslation() 13 | const [expanded, setExpanded] = useLocalStorage(id, false) 14 | const handleClick = () => setExpanded(!expanded) 15 | return ( 16 | <> 17 | {expanded && children} 18 |
19 |
20 | {expanded ? t('show less') : t('show more')} 21 | 22 |
23 |
24 | 25 | ) 26 | } 27 | 28 | export default ShowMore 29 | -------------------------------------------------------------------------------- /src/components/test-item/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { Check as CheckIcon, Trash as TrashIcon, X as XIcon } from '@phosphor-icons/react' 3 | import { Textarea } from '@/components/ui/textarea' 4 | 5 | type Props = { 6 | value: string 7 | regExp: RegExp 8 | onChange: (value: string) => void 9 | onRemove: () => void 10 | } 11 | 12 | function TestItem({ value, regExp, onChange, onRemove }: Props) { 13 | const isPass = useMemo(() => regExp.test(value), [value, regExp]) 14 | 15 | const onKeyDown = (e: React.KeyboardEvent) => { 16 | e.stopPropagation() 17 | } 18 | 19 | return ( 20 |
21 |