Repository: plait-board/drawnix Branch: develop Commit: f7b818bee1c2 Files: 216 Total size: 470.8 KB Directory structure: gitextract_iabkvbp0/ ├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github/ │ └── workflows/ │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CFPAGE-DEPLOY.md ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── README_en.md ├── SECURITY.md ├── apps/ │ ├── web/ │ │ ├── .eslintrc.json │ │ ├── index.html │ │ ├── jest.config.ts │ │ ├── project.json │ │ ├── public/ │ │ │ ├── _headers │ │ │ ├── _redirects │ │ │ ├── robots.txt │ │ │ └── sitemap.xml │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── app.module.scss │ │ │ │ ├── app.spec.tsx │ │ │ │ └── app.tsx │ │ │ ├── assets/ │ │ │ │ └── .gitkeep │ │ │ ├── main.tsx │ │ │ └── styles.scss │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.spec.json │ │ └── vite.config.ts │ └── web-e2e/ │ ├── .eslintrc.json │ ├── playwright.config.ts │ ├── project.json │ ├── src/ │ │ └── example.spec.ts │ └── tsconfig.json ├── jest.config.ts ├── jest.preset.js ├── nx.json ├── package.json ├── packages/ │ ├── drawnix/ │ │ ├── .babelrc │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── arrow-mark-picker.tsx │ │ │ │ ├── arrow-picker.tsx │ │ │ │ ├── clean-confirm/ │ │ │ │ │ ├── clean-confirm.scss │ │ │ │ │ └── clean-confirm.tsx │ │ │ │ ├── color-picker.scss │ │ │ │ ├── color-picker.tsx │ │ │ │ ├── dialog/ │ │ │ │ │ ├── dialog.scss │ │ │ │ │ └── dialog.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── island.scss │ │ │ │ ├── island.tsx │ │ │ │ ├── menu/ │ │ │ │ │ ├── common.ts │ │ │ │ │ ├── menu-group.tsx │ │ │ │ │ ├── menu-item-content-radio.tsx │ │ │ │ │ ├── menu-item-content.tsx │ │ │ │ │ ├── menu-item-custom.tsx │ │ │ │ │ ├── menu-item-link.tsx │ │ │ │ │ ├── menu-item.tsx │ │ │ │ │ ├── menu-separator.tsx │ │ │ │ │ ├── menu.scss │ │ │ │ │ └── menu.tsx │ │ │ │ ├── popover/ │ │ │ │ │ └── popover.tsx │ │ │ │ ├── popup/ │ │ │ │ │ └── link-popup/ │ │ │ │ │ ├── link-popup.scss │ │ │ │ │ └── link-popup.tsx │ │ │ │ ├── radio-group.scss │ │ │ │ ├── radio-group.tsx │ │ │ │ ├── select/ │ │ │ │ │ ├── select.scss │ │ │ │ │ └── select.tsx │ │ │ │ ├── shape-picker.tsx │ │ │ │ ├── size-slider.scss │ │ │ │ ├── size-slider.tsx │ │ │ │ ├── stack.scss │ │ │ │ ├── stack.tsx │ │ │ │ ├── tool-button.tsx │ │ │ │ ├── tool-icon.scss │ │ │ │ ├── toolbar/ │ │ │ │ │ ├── app-toolbar/ │ │ │ │ │ │ ├── app-menu-items.tsx │ │ │ │ │ │ ├── app-toolbar.tsx │ │ │ │ │ │ └── language-switcher-menu.tsx │ │ │ │ │ ├── creation-toolbar.tsx │ │ │ │ │ ├── extra-tools/ │ │ │ │ │ │ ├── extra-tools-button.tsx │ │ │ │ │ │ └── menu-items.tsx │ │ │ │ │ ├── freehand-panel/ │ │ │ │ │ │ └── freehand-panel.tsx │ │ │ │ │ ├── pencil-mode-toolbar.tsx │ │ │ │ │ ├── popup-toolbar/ │ │ │ │ │ │ ├── arrow-mark-button.tsx │ │ │ │ │ │ ├── fill-button.tsx │ │ │ │ │ │ ├── font-color-button.tsx │ │ │ │ │ │ ├── font-size-control.tsx │ │ │ │ │ │ ├── link-button.tsx │ │ │ │ │ │ ├── popup-toolbar.scss │ │ │ │ │ │ ├── popup-toolbar.tsx │ │ │ │ │ │ └── stroke-button.tsx │ │ │ │ │ ├── theme-toolbar.tsx │ │ │ │ │ └── zoom-toolbar.tsx │ │ │ │ ├── ttd-dialog/ │ │ │ │ │ ├── markdown-to-drawnix.tsx │ │ │ │ │ ├── mermaid-to-drawnix.scss │ │ │ │ │ ├── mermaid-to-drawnix.tsx │ │ │ │ │ ├── ttd-dialog-input.tsx │ │ │ │ │ ├── ttd-dialog-output.tsx │ │ │ │ │ ├── ttd-dialog-panel.tsx │ │ │ │ │ ├── ttd-dialog-panels.tsx │ │ │ │ │ ├── ttd-dialog-submit-shortcut.tsx │ │ │ │ │ ├── ttd-dialog.scss │ │ │ │ │ └── ttd-dialog.tsx │ │ │ │ ├── tutorial.scss │ │ │ │ └── tutorial.tsx │ │ │ ├── constants/ │ │ │ │ └── color.ts │ │ │ ├── constants.ts │ │ │ ├── css.d.ts │ │ │ ├── data/ │ │ │ │ ├── blob.ts │ │ │ │ ├── filesystem.ts │ │ │ │ ├── image.ts │ │ │ │ ├── json.ts │ │ │ │ └── types.ts │ │ │ ├── drawnix.spec.tsx │ │ │ ├── drawnix.tsx │ │ │ ├── errors.ts │ │ │ ├── hooks/ │ │ │ │ └── use-drawnix.tsx │ │ │ ├── i18n/ │ │ │ │ ├── index.tsx │ │ │ │ ├── translations/ │ │ │ │ │ ├── ar.ts │ │ │ │ │ ├── en.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ru.ts │ │ │ │ │ ├── vi.ts │ │ │ │ │ └── zh.ts │ │ │ │ └── types.ts │ │ │ ├── i18n.tsx │ │ │ ├── index.ts │ │ │ ├── keys.ts │ │ │ ├── libs/ │ │ │ │ └── image-viewer.ts │ │ │ ├── plugins/ │ │ │ │ ├── components/ │ │ │ │ │ ├── emoji.tsx │ │ │ │ │ └── image.tsx │ │ │ │ ├── freehand/ │ │ │ │ │ ├── freehand.component.ts │ │ │ │ │ ├── freehand.generator.ts │ │ │ │ │ ├── smoother.ts │ │ │ │ │ ├── type.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ ├── with-freehand-create.ts │ │ │ │ │ ├── with-freehand-erase.ts │ │ │ │ │ ├── with-freehand-fragment.ts │ │ │ │ │ └── with-freehand.ts │ │ │ │ ├── with-common.tsx │ │ │ │ ├── with-hotkey.ts │ │ │ │ ├── with-image.tsx │ │ │ │ ├── with-mind-extend.tsx │ │ │ │ ├── with-pencil.ts │ │ │ │ └── with-text-link.tsx │ │ │ ├── styles/ │ │ │ │ ├── index.scss │ │ │ │ ├── theme.scss │ │ │ │ └── variables.module.scss │ │ │ ├── transforms/ │ │ │ │ └── property.ts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── color.ts │ │ │ ├── common.ts │ │ │ ├── image.ts │ │ │ ├── index.ts │ │ │ ├── laser-pointer.ts │ │ │ ├── property.ts │ │ │ └── utility-types.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ ├── tsconfig.spec.json │ │ └── vite.config.ts │ ├── react-board/ │ │ ├── .babelrc │ │ ├── .eslintrc.json │ │ ├── README.md │ │ ├── jest.config.ts │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── board.spec.tsx │ │ │ ├── board.tsx │ │ │ ├── hooks/ │ │ │ │ ├── use-board-event.ts │ │ │ │ ├── use-board.tsx │ │ │ │ └── use-plugin-event.tsx │ │ │ ├── index.ts │ │ │ ├── plugins/ │ │ │ │ ├── board.ts │ │ │ │ ├── with-pinch-zoom-plugin.ts │ │ │ │ └── with-react.tsx │ │ │ ├── styles/ │ │ │ │ ├── index.scss │ │ │ │ └── mixins.scss │ │ │ └── wrapper.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ ├── tsconfig.spec.json │ │ └── vite.config.ts │ └── react-text/ │ ├── .babelrc │ ├── .eslintrc.json │ ├── README.md │ ├── jest.config.ts │ ├── package.json │ ├── project.json │ ├── src/ │ │ ├── custom-types.ts │ │ ├── index.ts │ │ ├── plugins/ │ │ │ ├── index.ts │ │ │ ├── with-link.tsx │ │ │ └── with-text.ts │ │ ├── styles/ │ │ │ └── index.scss │ │ ├── text.spec.tsx │ │ └── text.tsx │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ └── vite.config.ts ├── scripts/ │ ├── publish.js │ └── release-version.js └── tsconfig.base.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .nx .dist .node_modules ================================================ FILE: .editorconfig ================================================ # Editor configuration, see http://editorconfig.org root = true [*] charset = utf-8 indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true [*.md] max_line_length = off trim_trailing_whitespace = false ================================================ FILE: .eslintignore ================================================ node_modules ================================================ FILE: .eslintrc.json ================================================ { "root": true, "ignorePatterns": ["**/*"], "plugins": ["@nx"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": { "@nx/enforce-module-boundaries": [ "error", { "enforceBuildableLibDependency": true, "allow": [], "depConstraints": [ { "sourceTag": "*", "onlyDependOnLibsWithTags": ["*"] } ] } ] } }, { "files": ["*.ts", "*.tsx"], "extends": ["plugin:@nx/typescript"], "rules": {} }, { "files": ["*.js", "*.jsx"], "extends": ["plugin:@nx/javascript"], "rules": {} }, { "files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"], "env": { "jest": true }, "rules": {} } ] } ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: - main pull_request: permissions: actions: read contents: read jobs: main: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # Connect your workspace on nx.app and uncomment this to enable task distribution. # The "--stop-agents-after" is optional, but allows idle agents to shut down once the "e2e-ci" targets have been requested # - run: npx nx-cloud start-ci-run --distribute-on="5 linux-medium-js" --stop-agents-after="e2e-ci" # Cache node_modules - uses: actions/setup-node@v3 with: node-version: 20 cache: 'npm' - run: npm ci - uses: nrwl/nx-set-shas@v4 # 安装 Playwright 浏览器和依赖 - name: Install Playwright run: npx playwright install --with-deps # Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud # - run: npx nx-cloud record -- echo Hello World - run: npx nx affected -t lint test build --base=origin/develop --verbose - run: npx nx affected --parallel 1 -t e2e-ci --base=origin/develop --verbose ================================================ FILE: .github/workflows/publish.yml ================================================ name: publish on: push: tags: - "*" jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Registry uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set TAG run: echo TAG=${GITHUB_REF#refs/tags/} >> $GITHUB_ENV - name: Publish ${{ matrix.svc }} uses: docker/build-push-action@v3 with: file: Dockerfile outputs: "type=registry,push=true" platforms: linux/amd64, linux/arm64 tags: | ghcr.io/${{ github.repository_owner }}/drawnix:latest ghcr.io/${{ github.repository_owner }}/drawnix:${{ env.TAG }} pubuzhixing/drawnix:${{ env.TAG }} pubuzhixing/drawnix:latest ================================================ FILE: .gitignore ================================================ # See http://help.github.com/ignore-files/ for more about ignoring files. # compiled output dist tmp /out-tsc # dependencies node_modules # IDEs and editors /.idea .project .classpath .c9/ *.launch .settings/ *.sublime-workspace # IDE - VSCode .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json # misc /.sass-cache /connect.lock /coverage /libpeerconnection.log npm-debug.log yarn-error.log testem.log /typings # System Files .DS_Store Thumbs.db .nx/cache .nx/workspace-data ================================================ FILE: .prettierignore ================================================ # Add files here to ignore them from prettier formatting /dist /coverage /.nx/cache /.nx/workspace-data ================================================ FILE: .prettierrc ================================================ { "singleQuote": true } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "nrwl.angular-console", "esbenp.prettier-vscode", "ms-playwright.playwright", "firsttris.vscode-jest-runner" ] } ================================================ FILE: .vscode/settings.json ================================================ { "cSpell.words": [ "drawnix" ] } ================================================ FILE: CFPAGE-DEPLOY.md ================================================ ## ### 1. 打开 Cloudflare Pages 访问:https://dash.cloudflare.com/pages ### 2. 创建项目 - 点击 **"Create a project"** - 选择 **"Connect to Git"** - 选择您的 GitHub 仓库 ### 3. 配置构建设置 在可视化界面中填写: ``` Framework preset: None Build command: npm run build:web Build output directory: dist/apps/web Root directory: (留空) ``` 在 **Environment variables** 部分添加: ``` NODE_VERSION = 20 ``` ### 4. 点击 "Save and Deploy" 就这么简单! ## 项目已包含的配置文件 - `apps/web/public/_redirects` - SPA 路由支持 - `apps/web/public/_headers` - 基本缓存配置 - `package.json` 中的 `build:web` 脚本 ## 部署后检查 1. 访问分配的 `.pages.dev` 域名 2. 确认网站正常运行 3. 测试页面刷新是否正常(SPA 路由) ## 自定义域名(可选) 部署成功后,在 Cloudflare Pages 项目中: 1. 点击 **"Custom domains"** 2. 添加您的域名 3. 按提示配置 DNS 就是这么简单!无需复杂的配置文件。 ================================================ FILE: CHANGELOG.md ================================================ ## 0.4.0-2 (2025-12-05) This was a version bump only, there were no code changes. ## 0.4.0-1 (2025-12-05) ### 🚀 Features - improve i18n ([4e44c7b](https://github.com/plait-board/drawnix/commit/4e44c7b)) - **export:** support export as svg #278 This pr will support export-as-svg feature, it depend on the `toSvgData` method from `plait/core`. ([#278](https://github.com/plait-board/drawnix/issues/278)) - **i18n:** add Vietnamese translations for UI elements ([892b627](https://github.com/plait-board/drawnix/commit/892b627)) ### 🩹 Fixes - add "babel-plugin-macros": "^3.1.0" to resolve `npm ci` error in ([2940a09](https://github.com/plait-board/drawnix/commit/2940a09)) - add tooltip, fix get wrong percentage, change cursor when not allowed ([#318](https://github.com/plait-board/drawnix/pull/318)) ### ❤️ Thank You - Phạm Viết Nghĩa @vigstudio - pubuzhixing8 @pubuzhixing8 - Six ## 0.4.0-0 (2025-11-10) ### 🩹 Fixes - **mind:** fix image scaling issue ([44dd360](https://github.com/plait-board/drawnix/commit/44dd360)) ### ❤️ Thank You - seepine @seepine ## 0.3.3 (2025-10-26) ### 🚀 Features - handle text editing on touch device ([e4a42d0](https://github.com/plait-board/drawnix/commit/e4a42d0)) ### 🩹 Fixes - make menu click trigger show/hide submenu for mobile users to be able to choose the submenu ([93c9254](https://github.com/plait-board/drawnix/commit/93c9254)) - improve withPinchZoom Maintain pointerRecords accurately, as previous schemes would cause pointerRecords to be confused(using touch events) ([f39b1c8](https://github.com/plait-board/drawnix/commit/f39b1c8)) - fix moving and selection error since leave out pointerMove, some plugins don't work ([193e193](https://github.com/plait-board/drawnix/commit/193e193)) - improve arrow ([1d111fb](https://github.com/plait-board/drawnix/commit/1d111fb)) - **freehand:** disable freehand and erase functionalities when user is using two fingers This pr will fix the issue mentioned in #331. 1. Maintain the status of two fingers pressing 2. Prevent freehand drawing and erasing when `isTwoFingerMode` is `true`. ([#331](https://github.com/plait-board/drawnix/issues/331)) - **toolbar:** make arrow and shape picker button automatically select the first arrow/shape if it's the first time ([451b36e](https://github.com/plait-board/drawnix/commit/451b36e)) ### ❤️ Thank You - Andy Lu (Lu, Yu-An) - pubuzhixing8 @pubuzhixing8 ## 0.3.2 (2025-10-19) This was a version bump only, there were no code changes. ## 0.3.1 (2025-10-16) ### 🩹 Fixes - correct @plait-board/markdown-to-drawnix version ([9ff924e](https://github.com/plait-board/drawnix/commit/9ff924e)) - **hotkey:** move preventDefault() into specific conditional branching ([#303](https://github.com/plait-board/drawnix/pull/303)) ### ❤️ Thank You - pubuzhixing8 @pubuzhixing8 ## 0.3.0 (2025-09-13) ### 🚀 Features - **arrow:** support set arrow mark ([#258](https://github.com/plait-board/drawnix/pull/258)) - **eraser:** implement eraser feature ([#221](https://github.com/plait-board/drawnix/pull/221)) - **eraser:** adding i18n for eraser ([427a730](https://github.com/plait-board/drawnix/commit/427a730)) - **eraser:** Improving all the eraser feature mentioned in #247 ([#249](https://github.com/plait-board/drawnix/pull/249), [#247](https://github.com/plait-board/drawnix/issues/247)) - **eraser:** drawing erasing trail animation effect ([#295](https://github.com/plait-board/drawnix/pull/295)) - **i18n:** added i18n tool for multi-Language support ([#232](https://github.com/plait-board/drawnix/pull/232)) - **i18n:** adding i18n for clean confirm ([7bdf543](https://github.com/plait-board/drawnix/commit/7bdf543)) - **i18n:** refactor the structure of i18n, adding with-common getI18n for plait objects, complete the translation of zh,en,ru ([#276](https://github.com/plait-board/drawnix/pull/276)) - **i18n:** add Arabic language ([#280](https://github.com/plait-board/drawnix/pull/280)) - **popup-toolbar:** add stroke select state, add stroke type text ([#272](https://github.com/plait-board/drawnix/pull/272)) ### 🩹 Fixes - fix dockerfile build logic ([#201](https://github.com/plait-board/drawnix/pull/201)) - **cursor:** set mind element css to always be inherit ([#260](https://github.com/plait-board/drawnix/pull/260)) - **freehand&i18n:** fix i18n of freehand toolbar and make secondary toolbar always exist while using freehand element ([#255](https://github.com/plait-board/drawnix/pull/255)) - **frontend:** comment addDebugLog to prevent potential XSS security issue ([#269](https://github.com/plait-board/drawnix/pull/269)) - **hotkey:** prevent switch arrow creation mode when mod+a #195 ([#200](https://github.com/plait-board/drawnix/pull/200), [#195](https://github.com/plait-board/drawnix/issues/195)) - **hotkey:** prevent enter arrow creation mode when press a and there are some selected elements ([#205](https://github.com/plait-board/drawnix/pull/205)) - **hotkey:** Prevent Arc browser undo on Cmd+Z in Drawnix ([#254](https://github.com/plait-board/drawnix/pull/254)) - **hotkey:** skip creation hotkey when use press special key and the among of alt, meta and ctrl ([#262](https://github.com/plait-board/drawnix/pull/262)) - **menu:** Adding margin for the menu components ([c9ecd09](https://github.com/plait-board/drawnix/commit/c9ecd09)) - **menu:** fix hotkey instruction for every OS ([#274](https://github.com/plait-board/drawnix/pull/274)) - **mind:** bump plait into 0.84.0 to fix text can not show completely mentioned in #208 ([#261](https://github.com/plait-board/drawnix/pull/261), [#208](https://github.com/plait-board/drawnix/issues/208)) - **toolbar:** fix issue mentioned in #290 ([#291](https://github.com/plait-board/drawnix/pull/291), [#290](https://github.com/plait-board/drawnix/issues/290)) - **tutorial:** fix tutorial instruction issues and update styles ([#289](https://github.com/plait-board/drawnix/pull/289)) ### ❤️ Thank You - Andy Lu (Lu, Yu-An) @NaoCoding - coderwei @coderwei99 - MalikAli @MalikAliQassem - Peter Chen - pubuzhixing8 @pubuzhixing8 - Six - vishwak @PATTASWAMY-VISHWAK-YASASHREE ## 0.2.1 (2025-08-06) ### 🩹 Fixes - **hotkey:** assign t as hotkey to create text element ([#192](https://github.com/plait-board/drawnix/pull/192)) ### ❤️ Thank You - pubuzhixing8 @pubuzhixing8 ## 0.2.0 (2025-08-06) This was a version bump only, there were no code changes. ## 0.1.4 (2025-08-06) This was a version bump only, there were no code changes. ## 0.1.3 (2025-08-06) ### 🩹 Fixes - **hotkey:** prevent hotkey when type normally ([#189](https://github.com/plait-board/drawnix/pull/189)) ### ❤️ Thank You - pubuzhixing8 @pubuzhixing8 ## 0.1.2 (2025-08-06) ### 🚀 Features - **creation:** support creation mode hotkey #183 ([#185](https://github.com/plait-board/drawnix/pull/185), [#183](https://github.com/plait-board/drawnix/issues/183)) - **mind:** bump plait into 0.82.0 to improve the experience of mind ([f904594](https://github.com/plait-board/drawnix/commit/f904594)) - **viewer:** support image which in mind node view #125 ([#125](https://github.com/plait-board/drawnix/issues/125)) ### ❤️ Thank You - pubuzhixing8 @pubuzhixing8 ## 0.1.1 (2025-07-10) ### 🩹 Fixes - **text:** resolve text can not auto break line #173 #169 ([#173](https://github.com/plait-board/drawnix/issues/173), [#169](https://github.com/plait-board/drawnix/issues/169)) ### ❤️ Thank You - pubuzhixing8 @pubuzhixing8 ## 0.1.0 (2025-07-02) ### 🚀 Features - import styles ([ecfe3cd](https://github.com/plait-board/drawnix/commit/ecfe3cd)) - add script and update ci ([147c028](https://github.com/plait-board/drawnix/commit/147c028)) - bump plait into 0.62.0-next.7 ([7ab4003](https://github.com/plait-board/drawnix/commit/7ab4003)) - add main menu ([#14](https://github.com/plait-board/drawnix/pull/14)) - improve active-toolbar ([fd19725](https://github.com/plait-board/drawnix/commit/fd19725)) - rename active-toolbar to popup-toolbar and modify tool-button ([aa06c7e](https://github.com/plait-board/drawnix/commit/aa06c7e)) - support opacity for color property ([#16](https://github.com/plait-board/drawnix/pull/16)) - support local storage ([9c0e652](https://github.com/plait-board/drawnix/commit/9c0e652)) - add product_showcase bump plait into 0.69.0 ([61fe571](https://github.com/plait-board/drawnix/commit/61fe571)) - add sitemap ([3b9d9a3](https://github.com/plait-board/drawnix/commit/3b9d9a3)) - improve pinch zoom ([#77](https://github.com/plait-board/drawnix/pull/77)) - bump plait into 0.76.0 and handle break changes ([#90](https://github.com/plait-board/drawnix/pull/90)) - improve README ([9e0190d](https://github.com/plait-board/drawnix/commit/9e0190d)) - add dependencies for packages ([6d89b32](https://github.com/plait-board/drawnix/commit/6d89b32)) - init dialog and mermaid-to-dialog ([6ff70b9](https://github.com/plait-board/drawnix/commit/6ff70b9)) - support save as json from hotkey ([120dffa](https://github.com/plait-board/drawnix/commit/120dffa)) - support sub menu and export jpg ([#132](https://github.com/plait-board/drawnix/pull/132)) - improve link popup state ([#147](https://github.com/plait-board/drawnix/pull/147)) - improve seo ([#148](https://github.com/plait-board/drawnix/pull/148)) - **active-toolbar:** add active toolbar ([7e737a2](https://github.com/plait-board/drawnix/commit/7e737a2)) - **active-toolbar:** support font color property ([4b2d964](https://github.com/plait-board/drawnix/commit/4b2d964)) - **app:** use localforage to storage main board content #122 ([#122](https://github.com/plait-board/drawnix/issues/122)) - **app-toolbar:** support undo/redo operation ([50f8831](https://github.com/plait-board/drawnix/commit/50f8831)) - **app-toolbar:** add trash and duplicate in app-toolbar ([#28](https://github.com/plait-board/drawnix/pull/28)) - **clean-board:** complete clean board ([#124](https://github.com/plait-board/drawnix/pull/124)) - **clean-confirm:** autoFocus ok button ([582172a](https://github.com/plait-board/drawnix/commit/582172a)) - **color-picker:** support merge operations for update opacity #4 ([#45](https://github.com/plait-board/drawnix/pull/45), [#4](https://github.com/plait-board/drawnix/issues/4)) - **component:** improve the onXXXChange feature for drawnix component #79 ([#79](https://github.com/plait-board/drawnix/issues/79)) - **component:** add afterInit to expose board instance ([23d91dc](https://github.com/plait-board/drawnix/commit/23d91dc)) - **component:** support update value from drawnix component outside ([#103](https://github.com/plait-board/drawnix/pull/103)) - **component:** fit viewport after children updated ([#104](https://github.com/plait-board/drawnix/pull/104)) - **creation-toolbar:** support long-press triggers drag selection an… ([#78](https://github.com/plait-board/drawnix/pull/78)) - **creation-toolbar:** remove default action when click shape and arrow icon in creation toolbar improve tool-button ([a46c2df](https://github.com/plait-board/drawnix/commit/a46c2df)) - **draw:** bump plait into 0.75.0-next.0 and support fine-grained selection ([#69](https://github.com/plait-board/drawnix/pull/69)) - **draw-toolbar:** add draw toolbar ([#9](https://github.com/plait-board/drawnix/pull/9)) - **draw-toolbar:** add shape and arrow panel for draw-toolbar #10 ([#12](https://github.com/plait-board/drawnix/pull/12), [#10](https://github.com/plait-board/drawnix/issues/10)) - **drawnix:** init drawnix package ([397d865](https://github.com/plait-board/drawnix/commit/397d865)) - **drawnix:** export utils ([#105](https://github.com/plait-board/drawnix/pull/105)) - **drawnix-board:** initialize drawnix board ([117e5a8](https://github.com/plait-board/drawnix/commit/117e5a8)) - **fill:** split fill color and fill opacity setting ([#53](https://github.com/plait-board/drawnix/pull/53)) - **flowchart:** add terminal shape element ([#80](https://github.com/plait-board/drawnix/pull/80)) - **freehand:** initialize freehand #2 ([#2](https://github.com/plait-board/drawnix/issues/2)) - **freehand:** apply gaussianSmooth to freehand curve ([#47](https://github.com/plait-board/drawnix/pull/47)) - **freehand:** update stroke width to 2 and optimize freehand end points ([#50](https://github.com/plait-board/drawnix/pull/50)) - **freehand:** improve freehand experience ([#51](https://github.com/plait-board/drawnix/pull/51)) - **freehand:** add FreehandSmoother to optimize freehand curve ([#62](https://github.com/plait-board/drawnix/pull/62)) - **freehand:** optimize freehand curve by stylus features ([#63](https://github.com/plait-board/drawnix/pull/63)) - **freehand:** freehand support theme ([b7c7965](https://github.com/plait-board/drawnix/commit/b7c7965)) - **freehand:** support closed freehand and add popup for freehand ([#68](https://github.com/plait-board/drawnix/pull/68)) - **freehand:** bump plait into 0.75.0-next.9 and resolve freehand unexpected resize-handle after moving freehand elements ([#84](https://github.com/plait-board/drawnix/pull/84)) - **hotkey:** support export png hotkey ([#30](https://github.com/plait-board/drawnix/pull/30)) - **image:** support free image element and support insert image at m… ([#95](https://github.com/plait-board/drawnix/pull/95)) - **image:** should hide popup toolbar when selected element include image ([#96](https://github.com/plait-board/drawnix/pull/96)) - **image:** support drag image to board to add image as draw element or mind node image ([#144](https://github.com/plait-board/drawnix/pull/144)) - **link:** improve link popup ([eba06e2](https://github.com/plait-board/drawnix/commit/eba06e2)) - **markdown-to-drawnix:** support markdown to drawnix mind map #134 ([#135](https://github.com/plait-board/drawnix/pull/135), [#134](https://github.com/plait-board/drawnix/issues/134)) - **menu:** support export to json file ([d0d6ca5](https://github.com/plait-board/drawnix/commit/d0d6ca5)) - **menu:** support load file action ([758aa6d](https://github.com/plait-board/drawnix/commit/758aa6d)) - **mermaid:** improve mermaid-to-drawnix ([a928ba1](https://github.com/plait-board/drawnix/commit/a928ba1)) - **mobile:** adapt mobile device ([7c0742f](https://github.com/plait-board/drawnix/commit/7c0742f)) - **pencil-mode:** add pencil mode and add drawnix context ([#76](https://github.com/plait-board/drawnix/pull/76)) - **pinch-zoom:** support pinch zoom for touch device ([#60](https://github.com/plait-board/drawnix/pull/60)) - **pinch-zoom:** improve pinch zoom functionality and support hand moving ([#75](https://github.com/plait-board/drawnix/pull/75)) - **popover:** add reusable popover and replace radix popover ([d30388a](https://github.com/plait-board/drawnix/commit/d30388a)) - **popup:** display icon when color is complete opacity ([#42](https://github.com/plait-board/drawnix/pull/42)) - **popup-toolbar:** support set branch color remove color property when select transparent #17 ([#17](https://github.com/plait-board/drawnix/issues/17)) - **popup-toolbar:** bump plait into 0.71.0 and mind node link stroke and node stroke support dashed/dotted style #22 ([#22](https://github.com/plait-board/drawnix/issues/22)) - **property:** support stroke style setting ([463c92a](https://github.com/plait-board/drawnix/commit/463c92a)) - **size-slider:** improve size-slider component ([780be9d](https://github.com/plait-board/drawnix/commit/780be9d)) - **text:** support soft break ([#39](https://github.com/plait-board/drawnix/pull/39)) - **text:** support update text from outside ([#58](https://github.com/plait-board/drawnix/pull/58)) - **text:** support insertSoftBreak for text #136 ([#136](https://github.com/plait-board/drawnix/issues/136)) - **theme-toolbar:** add theme selection toolbar for customizable themes ([dca0e33](https://github.com/plait-board/drawnix/commit/dca0e33)) - **toolbar:** support zoom toolbar ([76ef5d9](https://github.com/plait-board/drawnix/commit/76ef5d9)) - **web:** seo ([84cde4b](https://github.com/plait-board/drawnix/commit/84cde4b)) - **web:** add cloud.umami.is to track views ([#64](https://github.com/plait-board/drawnix/pull/64)) - **web:** modify initialize-data for adding freehand data ([#65](https://github.com/plait-board/drawnix/pull/65)) - **web:** add debug console ([#83](https://github.com/plait-board/drawnix/pull/83)) - **wrapper:** add wrapper component and context hook ([#6](https://github.com/plait-board/drawnix/pull/6)) - **zoom-toolbar:** support zoom menu ([cc6a6b8](https://github.com/plait-board/drawnix/commit/cc6a6b8)) ### 🩹 Fixes - remove theme-toolbar font-weight style ([#67](https://github.com/plait-board/drawnix/pull/67)) - revert package lock ([1aa9d42](https://github.com/plait-board/drawnix/commit/1aa9d42)) - fix pub issue ([156abcb](https://github.com/plait-board/drawnix/commit/156abcb)) - improve libs build ([9ddb6d9](https://github.com/plait-board/drawnix/commit/9ddb6d9)) - **app-toolbar:** correct app-toolbar style ([#106](https://github.com/plait-board/drawnix/pull/106)) - **arrow-line:** optimize the popup toolbar position when selected element is arrow line ([#70](https://github.com/plait-board/drawnix/pull/70)) - **board:** resolve mobile scrolling issue when resize or moving ([8fdca8e](https://github.com/plait-board/drawnix/commit/8fdca8e)) - **board:** bump plait into 0.69.1 deselect when text editing end refactor popup toolbar placement ([aef6d23](https://github.com/plait-board/drawnix/commit/aef6d23)) - **board:** use updateViewBox to fix board wobbles when dragging or resizing ([#94](https://github.com/plait-board/drawnix/pull/94)) - **color-picker:** support display 0 opacity ([#48](https://github.com/plait-board/drawnix/pull/48)) - **core:** bump plait into 0.79.1 to fix with-hand issue when press space key #141 ([#149](https://github.com/plait-board/drawnix/pull/149), [#141](https://github.com/plait-board/drawnix/issues/141)) - **creation-toolbar:** use pointerUp set basic pointer cause onChange do not fire on mobile bind pointermove/pointerup to viewportContainerRef to implement dnd on mobile #20 ([#20](https://github.com/plait-board/drawnix/issues/20)) - **creation-toolbar:** move out toolbar from board to avoid fired pointer down event when operating ([ddb6092](https://github.com/plait-board/drawnix/commit/ddb6092)) - **font-color:** fix color can not be assigned when current color is empty ([#55](https://github.com/plait-board/drawnix/pull/55)) - **freehand:** fix freehand creation issue(caused by throttleRAF) ([#40](https://github.com/plait-board/drawnix/pull/40)) - **mermaid:** bump mermaid-to-drawnix to 0.0.2 to fix text display issue ([33878d0](https://github.com/plait-board/drawnix/commit/33878d0)) - **mermaid-to-drawnix:** support group for insertToBoard ([e2f5056](https://github.com/plait-board/drawnix/commit/e2f5056)) - **mind:** remove branchColor property setting ([#46](https://github.com/plait-board/drawnix/pull/46)) - **property:** prevent set fill color opacity when color is none ([#56](https://github.com/plait-board/drawnix/pull/56)) - **react-board:** resolve text should not display in safari ([19fc20f](https://github.com/plait-board/drawnix/commit/19fc20f)) - **react-board:** support fit viewport after browser window resized ([96f4a0e](https://github.com/plait-board/drawnix/commit/96f4a0e)) - **size-slider:** correct size slider click handle can not fire ([#57](https://github.com/plait-board/drawnix/pull/57)) - **text:** fix composition input and abc input trembly issue ([#15](https://github.com/plait-board/drawnix/pull/15)) - **text:** resolve with-text build error ([#41](https://github.com/plait-board/drawnix/pull/41)) - **text:** fix text can not editing ([#52](https://github.com/plait-board/drawnix/pull/52)) - **text:** fix text can not display correctly on windows 10 chrome env #99 ([#100](https://github.com/plait-board/drawnix/pull/100), [#99](https://github.com/plait-board/drawnix/issues/99)) - **text:** allow scroll to show all text ([#156](https://github.com/plait-board/drawnix/pull/156)) - **text:** set whiteSpace: pre to avoid \n is ineffectual ([#165](https://github.com/plait-board/drawnix/pull/165)) - **use-board-event:** fix board event timing ([0d4a8f1](https://github.com/plait-board/drawnix/commit/0d4a8f1)) ### ❤️ Thank You - pubuzhixing8 @pubuzhixing8 ## 0.0.4 (2025-04-15) ### 🚀 Features - ci: a tiny docker image (#127) ([#122](https://github.com/plait-board/drawnix/pull/127)) - support save as json from hotkey ([120dffa](https://github.com/plait-board/drawnix/commit/120dffa)) - **app:** use localforage to storage main board content #122 ([#122](https://github.com/plait-board/drawnix/issues/122)) - **clean-board:** complete clean board ([#124](https://github.com/plait-board/drawnix/pull/124)) ### 🩹 Fixes - **react-board:** support fit viewport after browser window resized ([96f4a0e](https://github.com/plait-board/drawnix/commit/96f4a0e)) ### ❤️ Thank You - lurenyang418 @lurenyang418 - whyour @whyour - pubuzhixing8 @pubuzhixing8 ## 0.0.4-3 (2025-03-25) ### 🩹 Fixes - improve libs build ([9ddb6d9](https://github.com/plait-board/drawnix/commit/9ddb6d9)) - **mermaid:** bump mermaid-to-drawnix to 0.0.2 to fix text display issue ([33878d0](https://github.com/plait-board/drawnix/commit/33878d0)) ### ❤️ Thank You - pubuzhixing8 ## 0.0.4-2 (2025-03-19) ### 🚀 Features - init dialog and mermaid-to-dialog ([6ff70b9](https://github.com/plait-board/drawnix/commit/6ff70b9)) - **mermaid:** improve mermaid-to-drawnix ([a928ba1](https://github.com/plait-board/drawnix/commit/a928ba1)) ### ❤️ Thank You - pubuzhixing8 @pubuzhixing8 ## 0.0.4-1 (2025-03-16) This was a version bump only, there were no code changes. ## 0.0.4-0 (2025-03-16) ### 🚀 Features - add dependencies for packages ([6d89b32](https://github.com/plait-board/drawnix/commit/6d89b32)) - **component:** support update value from drawnix component outside ([#103](https://github.com/plait-board/drawnix/pull/103)) - **component:** fit viewport after children updated ([#104](https://github.com/plait-board/drawnix/pull/104)) - **drawnix:** export utils ([#105](https://github.com/plait-board/drawnix/pull/105)) ### 🩹 Fixes - **app-toolbar:** correct app-toolbar style ([#106](https://github.com/plait-board/drawnix/pull/106)) ### ❤️ Thank You - pubuzhixing8 @pubuzhixing8 ## 0.0.3 (2025-03-14) ### 🩹 Fixes - revert package lock ([1aa9d42](https://github.com/plait-board/drawnix/commit/1aa9d42)) - fix pub issue ([156abcb](https://github.com/plait-board/drawnix/commit/156abcb)) - **text:** fix text can not display correctly on windows 10 chrome env #99 ([#100](https://github.com/plait-board/drawnix/pull/100), [#99](https://github.com/plait-board/drawnix/issues/99)) ### ❤️ Thank You - pubuzhixing8 @pubuzhixing8 ## 0.0.2 (2025-03-10) ### 🚀 Features - improve README ([9e0190d](https://github.com/plait-board/drawnix/commit/9e0190d)) ### ❤️ Thank You - pubuzhixing8 @pubuzhixing8 ## 0.0.1 (2025-03-10) ### 🚀 Features - import styles ([ecfe3cd](https://github.com/plait-board/drawnix/commit/ecfe3cd)) - add script and update ci ([147c028](https://github.com/plait-board/drawnix/commit/147c028)) - bump plait into 0.62.0-next.7 ([7ab4003](https://github.com/plait-board/drawnix/commit/7ab4003)) - add main menu ([#14](https://github.com/plait-board/drawnix/pull/14)) - improve active-toolbar ([fd19725](https://github.com/plait-board/drawnix/commit/fd19725)) - rename active-toolbar to popup-toolbar and modify tool-button ([aa06c7e](https://github.com/plait-board/drawnix/commit/aa06c7e)) - support opacity for color property ([#16](https://github.com/plait-board/drawnix/pull/16)) - support local storage ([9c0e652](https://github.com/plait-board/drawnix/commit/9c0e652)) - add product_showcase bump plait into 0.69.0 ([61fe571](https://github.com/plait-board/drawnix/commit/61fe571)) - add sitemap ([3b9d9a3](https://github.com/plait-board/drawnix/commit/3b9d9a3)) - improve pinch zoom ([#77](https://github.com/plait-board/drawnix/pull/77)) - bump plait into 0.76.0 and handle break changes ([#90](https://github.com/plait-board/drawnix/pull/90)) - **active-toolbar:** add active toolbar ([7e737a2](https://github.com/plait-board/drawnix/commit/7e737a2)) - **active-toolbar:** support font color property ([4b2d964](https://github.com/plait-board/drawnix/commit/4b2d964)) - **app-toolbar:** support undo/redo operation ([50f8831](https://github.com/plait-board/drawnix/commit/50f8831)) - **app-toolbar:** add trash and duplicate in app-toolbar ([#28](https://github.com/plait-board/drawnix/pull/28)) - **color-picker:** support merge operations for update opacity #4 ([#45](https://github.com/plait-board/drawnix/pull/45), [#4](https://github.com/plait-board/drawnix/issues/4)) - **component:** improve the onXXXChange feature for drawnix component #79 ([#79](https://github.com/plait-board/drawnix/issues/79)) - **component:** add afterInit to expose board instance ([23d91dc](https://github.com/plait-board/drawnix/commit/23d91dc)) - **creation-toolbar:** support long-press triggers drag selection an… ([#78](https://github.com/plait-board/drawnix/pull/78)) - **draw:** bump plait into 0.75.0-next.0 and support fine-grained selection ([#69](https://github.com/plait-board/drawnix/pull/69)) - **draw-toolbar:** add draw toolbar ([#9](https://github.com/plait-board/drawnix/pull/9)) - **draw-toolbar:** add shape and arrow panel for draw-toolbar #10 ([#12](https://github.com/plait-board/drawnix/pull/12), [#10](https://github.com/plait-board/drawnix/issues/10)) - **drawnix:** init drawnix package ([397d865](https://github.com/plait-board/drawnix/commit/397d865)) - **drawnix-board:** initialize drawnix board ([117e5a8](https://github.com/plait-board/drawnix/commit/117e5a8)) - **fill:** split fill color and fill opacity setting ([#53](https://github.com/plait-board/drawnix/pull/53)) - **flowchart:** add terminal shape element ([#80](https://github.com/plait-board/drawnix/pull/80)) - **freehand:** initialize freehand #2 ([#2](https://github.com/plait-board/drawnix/issues/2)) - **freehand:** apply gaussianSmooth to freehand curve ([#47](https://github.com/plait-board/drawnix/pull/47)) - **freehand:** update stroke width to 2 and optimize freehand end points ([#50](https://github.com/plait-board/drawnix/pull/50)) - **freehand:** improve freehand experience ([#51](https://github.com/plait-board/drawnix/pull/51)) - **freehand:** add FreehandSmoother to optimize freehand curve ([#62](https://github.com/plait-board/drawnix/pull/62)) - **freehand:** optimize freehand curve by stylus features ([#63](https://github.com/plait-board/drawnix/pull/63)) - **freehand:** freehand support theme ([b7c7965](https://github.com/plait-board/drawnix/commit/b7c7965)) - **freehand:** support closed freehand and add popup for freehand ([#68](https://github.com/plait-board/drawnix/pull/68)) - **freehand:** bump plait into 0.75.0-next.9 and resolve freehand unexpected resize-handle after moving freehand elements ([#84](https://github.com/plait-board/drawnix/pull/84)) - **hotkey:** support export png hotkey ([#30](https://github.com/plait-board/drawnix/pull/30)) - **image:** support free image element and support insert image at m… ([#95](https://github.com/plait-board/drawnix/pull/95)) - **image:** should hide popup toolbar when selected element include image ([#96](https://github.com/plait-board/drawnix/pull/96)) - **menu:** support export to json file ([d0d6ca5](https://github.com/plait-board/drawnix/commit/d0d6ca5)) - **menu:** support load file action ([758aa6d](https://github.com/plait-board/drawnix/commit/758aa6d)) - **mobile:** adapt mobile device ([7c0742f](https://github.com/plait-board/drawnix/commit/7c0742f)) - **pencil-mode:** add pencil mode and add drawnix context ([#76](https://github.com/plait-board/drawnix/pull/76)) - **pinch-zoom:** support pinch zoom for touch device ([#60](https://github.com/plait-board/drawnix/pull/60)) - **pinch-zoom:** improve pinch zoom functionality and support hand moving ([#75](https://github.com/plait-board/drawnix/pull/75)) - **popover:** add reusable popover and replace radix popover ([d30388a](https://github.com/plait-board/drawnix/commit/d30388a)) - **popup:** display icon when color is complete opacity ([#42](https://github.com/plait-board/drawnix/pull/42)) - **popup-toolbar:** support set branch color remove color property when select transparent #17 ([#17](https://github.com/plait-board/drawnix/issues/17)) - **popup-toolbar:** bump plait into 0.71.0 and mind node link stroke and node stroke support dashed/dotted style #22 ([#22](https://github.com/plait-board/drawnix/issues/22)) - **property:** support stroke style setting ([463c92a](https://github.com/plait-board/drawnix/commit/463c92a)) - **size-slider:** improve size-slider component ([780be9d](https://github.com/plait-board/drawnix/commit/780be9d)) - **text:** support soft break ([#39](https://github.com/plait-board/drawnix/pull/39)) - **text:** support update text from outside ([#58](https://github.com/plait-board/drawnix/pull/58)) - **theme-toolbar:** add theme selection toolbar for customizable themes ([dca0e33](https://github.com/plait-board/drawnix/commit/dca0e33)) - **toolbar:** support zoom toolbar ([76ef5d9](https://github.com/plait-board/drawnix/commit/76ef5d9)) - **web:** seo ([84cde4b](https://github.com/plait-board/drawnix/commit/84cde4b)) - **web:** add cloud.umami.is to track views ([#64](https://github.com/plait-board/drawnix/pull/64)) - **web:** modify initialize-data for adding freehand data ([#65](https://github.com/plait-board/drawnix/pull/65)) - **web:** add debug console ([#83](https://github.com/plait-board/drawnix/pull/83)) - **wrapper:** add wrapper component and context hook ([#6](https://github.com/plait-board/drawnix/pull/6)) - **zoom-toolbar:** support zoom menu ([cc6a6b8](https://github.com/plait-board/drawnix/commit/cc6a6b8)) ### 🩹 Fixes - remove theme-toolbar font-weight style ([#67](https://github.com/plait-board/drawnix/pull/67)) - **arrow-line:** optimize the popup toolbar position when selected element is arrow line ([#70](https://github.com/plait-board/drawnix/pull/70)) - **board:** resolve mobile scrolling issue when resize or moving ([8fdca8e](https://github.com/plait-board/drawnix/commit/8fdca8e)) - **board:** bump plait into 0.69.1 deselect when text editing end refactor popup toolbar placement ([aef6d23](https://github.com/plait-board/drawnix/commit/aef6d23)) - **board:** use updateViewBox to fix board wobbles when dragging or resizing ([#94](https://github.com/plait-board/drawnix/pull/94)) - **color-picker:** support display 0 opacity ([#48](https://github.com/plait-board/drawnix/pull/48)) - **creation-toolbar:** use pointerUp set basic pointer cause onChange do not fire on mobile bind pointermove/pointerup to viewportContainerRef to implement dnd on mobile #20 ([#20](https://github.com/plait-board/drawnix/issues/20)) - **creation-toolbar:** move out toolbar from board to avoid fired pointer down event when operating ([ddb6092](https://github.com/plait-board/drawnix/commit/ddb6092)) - **font-color:** fix color can not be assigned when current color is empty ([#55](https://github.com/plait-board/drawnix/pull/55)) - **freehand:** fix freehand creation issue(caused by throttleRAF) ([#40](https://github.com/plait-board/drawnix/pull/40)) - **mind:** remove branchColor property setting ([#46](https://github.com/plait-board/drawnix/pull/46)) - **property:** prevent set fill color opacity when color is none ([#56](https://github.com/plait-board/drawnix/pull/56)) - **react-board:** resolve text should not display in safari ([19fc20f](https://github.com/plait-board/drawnix/commit/19fc20f)) - **size-slider:** correct size slider click handle can not fire ([#57](https://github.com/plait-board/drawnix/pull/57)) - **text:** fix composition input and abc input trembly issue ([#15](https://github.com/plait-board/drawnix/pull/15)) - **text:** resolve with-text build error ([#41](https://github.com/plait-board/drawnix/pull/41)) - **text:** fix text can not editing ([#52](https://github.com/plait-board/drawnix/pull/52)) - **use-board-event:** fix board event timing ([0d4a8f1](https://github.com/plait-board/drawnix/commit/0d4a8f1)) ### ❤️ Thank You - pubuzhixing8 @pubuzhixing8 ================================================ FILE: Dockerfile ================================================ FROM node:20 AS builder WORKDIR /builder COPY . /builder RUN npm install \ && npm run build FROM lipanski/docker-static-website:2.4.0 WORKDIR /home/static COPY --from=builder /builder/dist/apps/web/ /home/static EXPOSE 80 CMD ["/busybox-httpd", "-f", "-v", "-p", "80", "-c", "httpd.conf"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Drawnix Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Drawnix logo and name

开源白板工具(SaaS),一体化白板,包含思维导图、流程图、自由画等

Product showcase

All in one 白板,思维导图、流程图、自由画等

Featured|HelloGitHub
plait-board%2Fdrawnix | Trendshift
[*English README*](https://github.com/plait-board/drawnix/blob/develop/README_en.md) ## 特性 - 💯 免费 + 开源 - ⚒️ 思维导图、流程图 - 🖌 画笔 - 😀 插入图片 - 🚀 基于插件机制 - 🖼️ 📃 导出为 PNG, JSON(`.drawnix`) - 💾 自动保存(浏览器缓存) - ⚡ 编辑特性:撤销、重做、复制、粘贴等 - 🌌 无限画布:缩放、滚动 - 🎨 主题模式 - 📱 移动设备适配 - 📈 支持 mermaid 语法转流程图 - ✨ 支持 markdown 文本转思维导图(新支持 🔥🔥🔥) ## 关于名称 ***Drawnix*** ,源于绘画( ***Draw*** )与凤凰( ***Phoenix*** )的灵感交织。 凤凰象征着生生不息的创造力,而 *Draw* 代表着人类最原始的表达方式。在这里,每一次创作都是一次艺术的涅槃,每一笔绘画都是灵感的重生。 创意如同凤凰,浴火方能重生,而 ***Drawnix*** 要做技术与创意之火的守护者。 *Draw Beyond, Rise Above.* ## 与 Plait 画图框架 *Drawnix* 的定位是一个开箱即用、开源、免费的工具产品,它的底层是 *Plait* 框架,*Plait* 是我司开源的一款画图框架,代表着公司在知识库产品([PingCode Wiki](https://pingcode.com/product/wiki?utm_source=drawnix))上的重要技术沉淀。 Drawnix 是插件架构,与前面说到开源工具比技术架构更复杂一些,但是插件架构也有优势,比如能够支持多种 UI 框架(*Angular、React*),能够集成不同富文本框架(当前仅支持 *Slate* 框架),在开发上可以很好的实现业务的分层,开发各种细粒度的可复用插件,可以扩展更多的画板的应用场景。 ## 仓储结构 ``` drawnix/ ├── apps/ │ ├── web # drawnix.com │ │ └── index.html # HTML ├── dist/ # 构建产物 ├── packages/ │ └── drawnix/ # 白板应用 │ └── react-board/ # 白板 React 视图层 │ └── react-text/ # 文本渲染模块 ├── package.json ├── ... └── README.md └── README_en.md ``` ## 应用 [*https://drawnix.com*](https://drawnix.com) 是 *drawnix* 的最小化应用。 近期会高频迭代 drawnix.com,直到发布 *Dawn(破晓)* 版本。 ## 开发 ``` npm install npm run start ``` ## Docker ``` docker pull pubuzhixing/drawnix:latest ``` ## 依赖 - [plait](https://github.com/worktile/plait) - 开源画图框架 - [slate](https://github.com/ianstormtaylor/slate) - 富文本编辑器框架 - [floating-ui](https://github.com/floating-ui/floating-ui) - 一个超级好用的创建弹出层基础库 ## 贡献 欢迎任何形式的贡献: - 提 Bug - 贡献代码 ## 感谢支持 特别感谢公司对开源项目的大力支持,也感谢为本项目贡献代码、提供建议的朋友。

PingCode

## License [MIT License](https://github.com/plait-board/drawnix/blob/master/LICENSE) ================================================ FILE: README_en.md ================================================

Drawnix logo and name

Open-source whiteboard tool (SaaS), an all-in-one collaborative canvas that includes mind mapping, flowcharts, freehand and more.

Product showcase

Whiteboard with mind mapping, flowcharts, freehand drawing and more

Featured|HelloGitHub
plait-board%2Fdrawnix | Trendshift
[*中文*](https://github.com/plait-board/drawnix/blob/develop/README.md) ## Features - 💯 Free and Open Source - ⚒️ Mind Maps and Flowcharts - 🖌 Freehand - 😀 Image Support - 🚀 Plugin-based Architecture - Extensible - 🖼️ 📃 Export to PNG, JPG, JSON(.drawnix) - 💾 Auto-save (Browser Storage) - ⚡ Edit Features: Undo, Redo, Copy, Paste, etc. - 🌌 Infinite Canvas: Zoom, Pan - 🎨 Theme Support - 📱 Mobile-friendly - 📈 Support mermaid syntax conversion to flowchart - ✨ Support markdown text conversion to mind map(New 🔥🔥🔥) ## About the Name ***Drawnix*** is born from the interweaving of ***Draw*** and ***Phoenix***, a fusion of artistic inspiration. The *Phoenix* symbolizes endless creativity, while *Draw* represents humanity's most fundamental form of expression. Here, each creation is an artistic rebirth, every stroke a renaissance of inspiration. Like a Phoenix, creativity must rise from the flames to be reborn, and ***Drawnix*** stands as the guardian of both technical and creative fire. *Draw Beyond, Rise Above.* ## About Plait Drawing Framework *Drawnix* is positioned as an out-of-the-box, *open-source*, and free tool product. It is built on top of the *Plait* framework, which is our company's *open-source* drawing framework representing significant technical accumulation in knowledge base products([PingCode Wiki](https://pingcode.com/product/wiki?utm_source=drawnix)). *Drawnix* uses a *plugin architecture*, which is technically more complex than the previously mentioned *open-source* tools. However, this *plugin architecture* has its advantages: it supports multiple *UI frameworks* (*Angular*, *React*), integrates with different *rich text frameworks* (currently only supporting *Slate* framework), enables better business layer separation in development, allows development of various fine-grained reusable plugins, and can expand to more whiteboard application scenarios. ## Repository Structure ``` drawnix/ ├── apps/ │ ├── web # drawnix.com │ │ └── index.html # HTML ├── dist/ # Build artifacts ├── packages/ │ └── drawnix/ # Whiteboard application core │ └── react-board/ # Whiteboard react view layer │ └── react-text/ # Text rendering module ├── package.json ├── ... └── README.md └── README_en.md ``` ## Try It Out *https://drawnix.com* is the minimal application of *drawnix*. I will be iterating frequently on *drawnix.com* until the release of the *Dawn* version. ## Development ``` npm install npm run start ``` ## Docker ``` docker pull pubuzhixing/drawnix:latest ``` ## Dependencies - [plait](https://github.com/worktile/plait) - Open source drawing framework - [slate](https://github.com/ianstormtaylor/slate) - Rich text editor framework - [floating-ui](https://github.com/floating-ui/floating-ui) - An awesome library for creating floating UI elements ## Contributing Any form of contribution is welcome: - Report bugs - Contribute code ## Thank you for supporting Special thanks to the company for its strong support for open source projects, and also to the friends who contributed code and provided suggestions to this project.

PingCode

## License [MIT License](https://github.com/plait-board/drawnix/blob/master/LICENSE) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability We have an official discord server for discussing and reporting about Drawnix. Please contact pubuzhixing in the server if the valnerability is confidential and critical. [Discord Server Link](https://discord.gg/5d9undgnsP) ================================================ FILE: apps/web/.eslintrc.json ================================================ { "extends": ["plugin:@nx/react", "../../.eslintrc.json"], "ignorePatterns": ["!**/*"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": {} }, { "files": ["*.ts", "*.tsx"], "rules": {} }, { "files": ["*.js", "*.jsx"], "rules": {} } ] } ================================================ FILE: apps/web/index.html ================================================ Drawnix - 开源白板工具
================================================ FILE: apps/web/jest.config.ts ================================================ /* eslint-disable */ export default { displayName: 'web', preset: '../../jest.preset.js', transform: { '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '../../coverage/apps/web', }; ================================================ FILE: apps/web/project.json ================================================ { "name": "web", "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/web/src", "projectType": "application", "tags": [], "// targets": "to see all targets run: nx show project web --web", "targets": {} } ================================================ FILE: apps/web/public/_headers ================================================ # 基本缓存配置 /* Cache-Control: public, max-age=31536000, immutable /*.html Cache-Control: public, max-age=0, must-revalidate / Cache-Control: public, max-age=0, must-revalidate ================================================ FILE: apps/web/public/_redirects ================================================ # SPA 路由支持 /* /index.html 200 ================================================ FILE: apps/web/public/robots.txt ================================================ id: robots-txt name: Robots.txt type: code.txt content: |- User-agent: * Allow: / # 禁止访问管理后台 Disallow: /admin/ Disallow: /private/ # 站点地图 Sitemap: https://drawnix.com/sitemap.xml ================================================ FILE: apps/web/public/sitemap.xml ================================================ https://drawnix.com/ 2024-11-15 weekly 1.0 https://drawnix.com/en 2024-11-15 weekly 0.9 https://drawnix.com/docs 2024-11-15 weekly 0.8 https://drawnix.com/docs/getting-started 2024-11-15 monthly 0.7 ================================================ FILE: apps/web/src/app/app.module.scss ================================================ /* Your styles goes here. */ ================================================ FILE: apps/web/src/app/app.spec.tsx ================================================ import { render } from '@testing-library/react'; import App from './app'; describe('App', () => { it('should render successfully', () => { // const { baseElement } = render(); // expect(baseElement).toBeTruthy(); }); }); ================================================ FILE: apps/web/src/app/app.tsx ================================================ import { useState, useEffect } from 'react'; import { Drawnix } from '@drawnix/drawnix'; import { PlaitBoard, PlaitElement, PlaitTheme, Viewport } from '@plait/core'; import localforage from 'localforage'; type AppValue = { children: PlaitElement[]; viewport?: Viewport; theme?: PlaitTheme; }; const MAIN_BOARD_CONTENT_KEY = 'main_board_content'; localforage.config({ name: 'Drawnix', storeName: 'drawnix_store', driver: [localforage.INDEXEDDB, localforage.LOCALSTORAGE], }); export function App() { const [value, setValue] = useState({ children: [] }); const [tutorial, setTutorial] = useState(false); useEffect(() => { const loadData = async () => { const storedData = (await localforage.getItem( MAIN_BOARD_CONTENT_KEY )) as AppValue; if (storedData) { setValue(storedData); if (storedData.children && storedData.children.length === 0) { setTutorial(true); } return; } setTutorial(true); }; loadData(); }, []); return ( { const newValue = value as AppValue; localforage.setItem(MAIN_BOARD_CONTENT_KEY, newValue); setValue(newValue); if (newValue.children && newValue.children.length > 0) { setTutorial(false); } }} tutorial={tutorial} afterInit={(board) => { console.log('board initialized'); // console.log( // `add __drawnix__web__debug_log to window, so you can call add log anywhere, like: window.__drawnix__web__console('some thing')` // ); // (window as any)['__drawnix__web__console'] = (value: string) => { // addDebugLog(board, value); // }; }} > ); } const addDebugLog = (board: PlaitBoard, value: string) => { const container = PlaitBoard.getBoardContainer(board).closest( '.drawnix' ) as HTMLElement; let consoleContainer = container.querySelector('.drawnix-console'); if (!consoleContainer) { consoleContainer = document.createElement('div'); consoleContainer.classList.add('drawnix-console'); container.append(consoleContainer); } const div = document.createElement('div'); div.innerHTML = value; consoleContainer.append(div); }; export default App; ================================================ FILE: apps/web/src/assets/.gitkeep ================================================ ================================================ FILE: apps/web/src/main.tsx ================================================ import { StrictMode } from 'react'; import * as ReactDOM from 'react-dom/client'; import App from './app/app'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); root.render( ); ================================================ FILE: apps/web/src/styles.scss ================================================ /* You can add global styles to this file, and also import other style files */ body { margin: 0; padding: 0; } html, body { height: 100%; width: 100%; overflow: hidden; } #root { height: 100%; width: 100%; overflow: hidden; } .drawnix-console { position: absolute; top: 50%; transform: translateY(-50%); left: 0; height: 200px; width: 100px; overflow: auto; background-color: black; color: white; padding: 8px; opacity: 0.5; } ================================================ FILE: apps/web/tsconfig.app.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", "types": [ "node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts", "vite/client" ] }, "exclude": [ "src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.spec.tsx", "src/**/*.test.tsx", "src/**/*.spec.js", "src/**/*.test.js", "src/**/*.spec.jsx", "src/**/*.test.jsx" ], "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] } ================================================ FILE: apps/web/tsconfig.json ================================================ { "compilerOptions": { "jsx": "react-jsx", "allowJs": false, "esModuleInterop": false, "allowSyntheticDefaultImports": true, "strict": true, "types": ["vite/client"] }, "files": [], "include": [], "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.spec.json" } ], "extends": "../../tsconfig.base.json" } ================================================ FILE: apps/web/tsconfig.spec.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "commonjs", "types": [ "jest", "node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts" ] }, "include": [ "jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.test.tsx", "src/**/*.spec.tsx", "src/**/*.test.js", "src/**/*.spec.js", "src/**/*.test.jsx", "src/**/*.spec.jsx", "src/**/*.d.ts" ] } ================================================ FILE: apps/web/vite.config.ts ================================================ /// import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; export default defineConfig({ root: __dirname, cacheDir: '../../node_modules/.vite/apps/web', server: { port: 7200, host: 'localhost', }, preview: { port: 4300, host: 'localhost', }, plugins: [react(), nxViteTsPaths()], // Uncomment this if you are using workers. // worker: { // plugins: [ nxViteTsPaths() ], // }, build: { outDir: '../../dist/apps/web', emptyOutDir: true, reportCompressedSize: true, commonjsOptions: { transformMixedEsModules: true, }, }, }); ================================================ FILE: apps/web-e2e/.eslintrc.json ================================================ { "extends": ["plugin:playwright/recommended", "../../.eslintrc.json"], "ignorePatterns": ["!**/*"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": {} }, { "files": ["*.ts", "*.tsx"], "rules": {} }, { "files": ["*.js", "*.jsx"], "rules": {} }, { "files": ["src/**/*.{ts,js,tsx,jsx}"], "rules": {} } ] } ================================================ FILE: apps/web-e2e/playwright.config.ts ================================================ import { defineConfig, devices } from '@playwright/test'; import { nxE2EPreset } from '@nx/playwright/preset'; import { workspaceRoot } from '@nx/devkit'; // For CI, you may want to set BASE_URL to the deployed application. const baseURL = process.env['BASE_URL'] || 'http://localhost:7200'; /** * Read environment variables from file. * https://github.com/motdotla/dotenv */ // require('dotenv').config(); /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ ...nxE2EPreset(__filename, { testDir: './src' }), /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', }, /* Run your local dev server before starting the tests */ webServer: { command: 'npx nx serve web', url: 'http://localhost:7200', reuseExistingServer: !process.env.CI, cwd: workspaceRoot, }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, // Uncomment for mobile browsers support /* { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] }, }, { name: 'Mobile Safari', use: { ...devices['iPhone 12'] }, }, */ // Uncomment for branded browsers /* { name: 'Microsoft Edge', use: { ...devices['Desktop Edge'], channel: 'msedge' }, }, { name: 'Google Chrome', use: { ...devices['Desktop Chrome'], channel: 'chrome' }, }, */ ], }); ================================================ FILE: apps/web-e2e/project.json ================================================ { "name": "web-e2e", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", "sourceRoot": "apps/web-e2e/src", "implicitDependencies": ["web"], "// targets": "to see all targets run: nx show project web-e2e --web", "targets": {} } ================================================ FILE: apps/web-e2e/src/example.spec.ts ================================================ import { test, expect } from '@playwright/test'; test('has title', async ({ page }) => { await page.goto('/'); // Expect h1 to contain a substring. expect(await page.title()).toContain('Drawnix - 开源白板工具'); expect(page.locator('drawnix')).toBeTruthy(); }); ================================================ FILE: apps/web-e2e/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "allowJs": true, "outDir": "../../dist/out-tsc", "module": "commonjs", "sourceMap": false }, "include": [ "**/*.ts", "**/*.js", "playwright.config.ts", "src/**/*.spec.ts", "src/**/*.spec.js", "src/**/*.test.ts", "src/**/*.test.js", "src/**/*.d.ts" ] } ================================================ FILE: jest.config.ts ================================================ import { getJestProjectsAsync } from '@nx/jest'; export default async () => ({ projects: await getJestProjectsAsync(), }); ================================================ FILE: jest.preset.js ================================================ const nxPreset = require('@nx/jest/preset').default; module.exports = { ...nxPreset }; ================================================ FILE: nx.json ================================================ { "$schema": "./node_modules/nx/schemas/nx-schema.json", "namedInputs": { "default": [ "{projectRoot}/**/*", "sharedGlobals" ], "production": [ "default", "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", "!{projectRoot}/tsconfig.spec.json", "!{projectRoot}/.eslintrc.json", "!{projectRoot}/eslint.config.js", "!{projectRoot}/jest.config.[jt]s", "!{projectRoot}/src/test-setup.[jt]s", "!{projectRoot}/test-setup.[jt]s" ], "sharedGlobals": [] }, "plugins": [ { "plugin": "@nx/vite/plugin", "options": { "buildTargetName": "build", "testTargetName": "test", "serveTargetName": "serve", "previewTargetName": "preview", "serveStaticTargetName": "serve-static" } }, { "plugin": "@nx/eslint/plugin", "options": { "targetName": "lint" } }, { "plugin": "@nx/playwright/plugin", "options": { "targetName": "e2e" } }, { "plugin": "@nx/jest/plugin", "options": { "targetName": "test" } } ], "generators": { "@nx/react": { "application": { "babel": true, "style": "scss", "linter": "eslint", "bundler": "vite" }, "component": { "style": "scss" }, "library": { "style": "scss", "linter": "eslint", "unitTestRunner": "jest" } } }, "release": { "changelog": { "workspaceChangelog": true, "file": "CHANGELOG.md", "git": { "commit": false, "tag": false } }, "version": { "git": { "commit": false, "tag": false } } } } ================================================ FILE: package.json ================================================ { "name": "@drawnix/source", "version": "0.0.2", "license": "MIT", "scripts": { "start": "nx serve web --host=0.0.0.0", "build": "nx run-many -t=build", "lint": "nx run-many --target=lint --all --fix", "build:web": "nx build web", "test": "nx run-many -t=test", "release": "node scripts/release-version.js", "pub": "npm run build && node scripts/publish.js" }, "private": true, "dependencies": { "@floating-ui/react": "^0.26.24", "@plait-board/markdown-to-drawnix": "^0.0.8", "@plait-board/mermaid-to-drawnix": "^0.0.7", "@plait/common": "^0.92.1", "@plait/core": "^0.92.1", "@plait/draw": "^0.92.1", "@plait/layouts": "^0.92.1", "@plait/mind": "^0.92.1", "@plait/text-plugins": "^0.92.1", "@types/lodash": "^4.17.21", "ahooks": "^3.9.6", "browser-fs-access": "^0.35.0", "classnames": "^2.5.1", "is-hotkey": "^0.2.0", "laser-pen": "^1.0.1", "localforage": "^1.10.0", "lodash": "^4.17.21", "mobile-detect": "^1.4.5", "open-color": "^1.9.1", "react": "19.2.0", "react-dom": "19.2.0", "roughjs": "^4.6.6", "slate": "^0.116.0", "slate-dom": "^0.116.0", "slate-history": "^0.115.0", "slate-react": "^0.116.0", "tslib": "^2.3.0" }, "devDependencies": { "@babel/core": "^7.14.5", "@babel/preset-react": "^7.14.5", "@nx/cypress": "19.3.0", "@nx/devkit": "19.3.0", "@nx/eslint": "19.3.0", "@nx/eslint-plugin": "19.3.0", "@nx/jest": "19.3.0", "@nx/js": "19.3.0", "@nx/playwright": "19.3.0", "@nx/react": "19.3.0", "@nx/vite": "^20.6.0", "@nx/web": "19.3.0", "@nx/workspace": "19.3.0", "@playwright/test": "^1.36.0", "@swc-node/register": "~1.9.1", "@swc/cli": "^0.6.0", "@swc/core": "~1.5.7", "@swc/helpers": "~0.5.11", "@testing-library/react": "16.3.0", "@types/is-hotkey": "^0.1.10", "@types/jest": "^29.4.0", "@types/node": "18.16.9", "@types/react": "18.3.1", "@types/react-dom": "18.3.0", "@typescript-eslint/eslint-plugin": "^7.3.0", "@typescript-eslint/parser": "^7.3.0", "@vitejs/plugin-react": "^4.2.0", "@vitest/ui": "^3.0.8", "babel-jest": "^29.4.1", "babel-plugin-macros": "^3.1.0", "eslint": "~8.57.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-import": "2.27.5", "eslint-plugin-jsx-a11y": "6.7.1", "eslint-plugin-playwright": "^0.15.3", "eslint-plugin-react": "7.32.2", "eslint-plugin-react-hooks": "4.6.0", "jest": "^29.4.1", "jest-environment-jsdom": "^29.4.1", "jsdom": "~22.1.0", "nx": "19.3.0", "prettier": "^2.6.2", "sass": "^1.55.0", "ts-jest": "^29.1.0", "ts-node": "10.9.1", "typescript": "~5.4.2", "vite": "^6.2.2", "vite-plugin-dts": "^4.5.3", "vitest": "^3.0.8" } } ================================================ FILE: packages/drawnix/.babelrc ================================================ { "presets": [ [ "@nx/react/babel", { "runtime": "automatic", "useBuiltIns": "usage" } ] ], "plugins": [] } ================================================ FILE: packages/drawnix/.eslintrc.json ================================================ { "extends": ["plugin:@nx/react", "../../.eslintrc.json"], "ignorePatterns": ["!**/*"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": {} }, { "files": ["*.ts", "*.tsx"], "rules": {} }, { "files": ["*.js", "*.jsx"], "rules": {} } ] } ================================================ FILE: packages/drawnix/README.md ================================================ # drawnix This library was generated with [Nx](https://nx.dev). ## Running unit tests Run `nx test drawnix` to execute the unit tests via [Vitest](https://vitest.dev/). ================================================ FILE: packages/drawnix/jest.config.ts ================================================ /* eslint-disable */ export default { displayName: 'drawnix', preset: '../../jest.preset.js', transform: { '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '../../coverage/packages/drawnix', }; ================================================ FILE: packages/drawnix/package.json ================================================ { "name": "@drawnix/drawnix", "version": "0.4.0-2", "main": "./index.js", "types": "./index.d.ts", "private": false, "dependencies": { "@floating-ui/react": "^0.26.24", "mobile-detect": "^1.4.5", "open-color": "^1.9.1", "@plait-board/mermaid-to-drawnix": "^0.0.7", "@plait-board/markdown-to-drawnix": "^0.0.8", "laser-pen": "^1.0.1" }, "exports": { ".": { "import": "./index.mjs", "require": "./index.js", "types": "./index.d.ts" }, "./index.css": "./index.css" } } ================================================ FILE: packages/drawnix/project.json ================================================ { "name": "drawnix", "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "packages/drawnix/src", "projectType": "library", "tags": [], "// targets": "to see all targets run: nx show project drawnix --web", "targets": {} } ================================================ FILE: packages/drawnix/src/components/arrow-mark-picker.tsx ================================================ import classNames from 'classnames'; import { Island } from './island'; import Stack from './stack'; import { ToolButton } from './tool-button'; import { LineIcon, ArrowIcon } from './icons'; import { useBoard } from '@plait-board/react-board'; import { ATTACHED_ELEMENT_CLASS_NAME } from '@plait/core'; import React from 'react'; import { PropertyTransforms } from '@plait/common'; import { ArrowLineHandle } from '@plait/draw'; import { useI18n } from '../i18n'; export type ArrowMarkerPickerProps = { end: 'source' | 'target'; property: ArrowLineHandle; }; export const ArrowMarkerPicker: React.FC = ({ end, property, }) => { const board = useBoard(); const { marker: currentMarker } = property; const { t } = useI18n(); const setMarker = (marker: string) => { PropertyTransforms.setProperty(board, { [end]: { ...property, marker, }, }); }; return ( { setMarker('none'); }} > { setMarker('arrow'); }} > ); }; ================================================ FILE: packages/drawnix/src/components/arrow-picker.tsx ================================================ import classNames from 'classnames'; import { Island } from './island'; import Stack from './stack'; import { ToolButton } from './tool-button'; import { StraightArrowIcon, ElbowArrowIcon, CurveArrowIcon } from './icons'; import { useBoard } from '@plait-board/react-board'; import { Translations, useI18n } from '../i18n'; import { BoardTransforms , PlaitBoard } from '@plait/core'; import React from 'react'; import { BoardCreationMode, setCreationMode } from '@plait/common'; import { ArrowLineShape, DrawPointerType } from '@plait/draw'; export interface ArrowProps { icon: React.ReactNode; title: string; pointer: ArrowLineShape; } export const ARROWS: ArrowProps[] = [ { icon: StraightArrowIcon, title: 'toolbar.arrow.straight', pointer: ArrowLineShape.straight, }, { icon: ElbowArrowIcon, title: 'toolbar.arrow.elbow', pointer: ArrowLineShape.elbow, }, { icon: CurveArrowIcon, title: 'toolbar.arrow.curve', pointer: ArrowLineShape.curve, }, ]; export type ArrowPickerProps = { onPointerUp: (pointer: DrawPointerType) => void; }; export const ArrowPicker: React.FC = ({ onPointerUp }) => { const board = useBoard(); const { t } = useI18n(); return ( {ARROWS.map((arrow, index) => { return ( { setCreationMode(board, BoardCreationMode.drawing); BoardTransforms.updatePointerType(board, arrow.pointer); }} onPointerUp={() => { onPointerUp(arrow.pointer); }} /> ); })} ); }; ================================================ FILE: packages/drawnix/src/components/clean-confirm/clean-confirm.scss ================================================ .clean-confirm { background: white; border-radius: 8px; padding: 20px; width: 300px; &__title { font-size: 18px; font-weight: 500; margin: 0 0 8px; } &__description { color: #666; font-size: 14px; margin: 0 0 20px; } &__actions { display: flex; justify-content: flex-end; gap: 8px; } &__button { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; &--cancel { background: #f5f5f5; color: #000; &:hover { background: #e8e8e8; } } &--ok { background: white; color: #ff4d4f; border: 1px solid #ff4d4f; &:hover { color: white; background: #ff4d4f; } } } } ================================================ FILE: packages/drawnix/src/components/clean-confirm/clean-confirm.tsx ================================================ import { Dialog, DialogContent } from '../dialog/dialog'; import { useDrawnix } from '../../hooks/use-drawnix'; import './clean-confirm.scss'; import { useBoard } from '@plait-board/react-board'; import { useI18n } from '../../i18n'; export const CleanConfirm = ({ container, }: { container: HTMLElement | null; }) => { const { appState, setAppState } = useDrawnix(); const { t } = useI18n(); const board = useBoard(); return ( { setAppState({ ...appState, openCleanConfirm: open }); }} >

{t('cleanConfirm.title')}

{t('cleanConfirm.description')}

); }; ================================================ FILE: packages/drawnix/src/components/color-picker.scss ================================================ @import "open-color/open-color.scss"; .color-select-item { width: var(--default-button-size); height: var(--default-button-size); border-radius: 50%; display: inline-flex; justify-content: center; align-items: center; border: 1px solid var(--color-gray-30); cursor: pointer; padding: 0; &.active { border-color: var(--color-primary); &.no-color { .selected-icon { background-color: $oc-white; } } } .selected-icon { stroke: currentColor; outline: none; position: absolute; width: var(--default-icon-size); height: var(--default-icon-size); } &.no-color { border: none; .no-color-icon { display: block; width: var(-default-button-size); height: var(-default-button-size); color: rgba($oc-black, 0.4); } } } ================================================ FILE: packages/drawnix/src/components/color-picker.tsx ================================================ import { useState } from 'react'; import { Check, NoColorIcon } from './icons'; import Stack from '../components/stack'; import './color-picker.scss'; import { splitRows } from '../utils/common'; import { hexAlphaToOpacity, isDefaultStroke, isNoColor, removeHexAlpha, } from '../utils/color'; import React from 'react'; import { SizeSlider } from './size-slider'; import { DEFAULT_COLOR, isNullOrUndefined, MERGING, PlaitHistoryBoard, } from '@plait/core'; import { CLASSIC_COLORS, NO_COLOR, TRANSPARENT, WHITE, } from '../constants/color'; import { useBoard } from '@plait-board/react-board'; import { Translations, useI18n } from '../i18n'; const ROWS_CLASSIC_COLORS = splitRows(CLASSIC_COLORS, 4); export type ColorPickerProps = { onColorChange: (color: string) => void; onOpacityChange: (opacity: number) => void; currentColor?: string; }; export const ColorPicker = React.forwardRef((props: ColorPickerProps, ref) => { const board = useBoard(); const { t } = useI18n(); const { currentColor, onColorChange, onOpacityChange } = props; const [selectedColor, setSelectedColor] = useState( (currentColor && removeHexAlpha(currentColor)) || ROWS_CLASSIC_COLORS[0][0].value ); const [opacity, setOpacity] = useState(() => { const _opacity = currentColor && hexAlphaToOpacity(currentColor); return (!isNullOrUndefined(_opacity) ? _opacity : 100) as number; }); return ( { setOpacity(value); onOpacityChange(value); }} beforeStart={() => { MERGING.set(board, true); PlaitHistoryBoard.setSplittingOnce(board, true); }} afterEnd={() => { MERGING.set(board, false); }} disabled={selectedColor === CLASSIC_COLORS[0]['value']} > {ROWS_CLASSIC_COLORS.map((colors, index) => ( {colors.map((color) => { return ( ); })} ))} ); }); ================================================ FILE: packages/drawnix/src/components/dialog/dialog.scss ================================================ .Dialog-overlay { background: rgba(#121212, 0.2); display: grid; place-items: center; } .Dialog { margin: 15px; background-color: white; padding: 15px; border-radius: 4px; } ================================================ FILE: packages/drawnix/src/components/dialog/dialog.tsx ================================================ import * as React from 'react'; import { useFloating, useClick, useDismiss, useRole, useInteractions, useMergeRefs, FloatingPortal, FloatingFocusManager, FloatingOverlay, useId, } from '@floating-ui/react'; import './dialog.scss'; interface DialogOptions { initialOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; } export function useDialog({ initialOpen = false, open: controlledOpen, onOpenChange: setControlledOpen, }: DialogOptions = {}) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen); const [labelId, setLabelId] = React.useState(); const [descriptionId, setDescriptionId] = React.useState< string | undefined >(); const open = controlledOpen ?? uncontrolledOpen; const setOpen = setControlledOpen ?? setUncontrolledOpen; const data = useFloating({ open, onOpenChange: setOpen, }); const context = data.context; const click = useClick(context, { enabled: controlledOpen == null, }); const dismiss = useDismiss(context, { outsidePressEvent: 'mousedown' }); const role = useRole(context); const interactions = useInteractions([click, dismiss, role]); return React.useMemo( () => ({ open, setOpen, ...interactions, ...data, labelId, descriptionId, setLabelId, setDescriptionId, }), [open, setOpen, interactions, data, labelId, descriptionId] ); } type ContextType = | (ReturnType & { setLabelId: React.Dispatch>; setDescriptionId: React.Dispatch< React.SetStateAction >; }) | null; const DialogContext = React.createContext(null); export const useDialogContext = () => { const context = React.useContext(DialogContext); if (context == null) { throw new Error('Dialog components must be wrapped in '); } return context; }; export function Dialog({ children, ...options }: { children: React.ReactNode; } & DialogOptions) { const dialog = useDialog(options); return ( {children} ); } interface DialogTriggerProps { children: React.ReactNode; asChild?: boolean; } export const DialogTrigger = React.forwardRef< HTMLElement, React.HTMLProps & DialogTriggerProps >(function DialogTrigger({ children, asChild = false, ...props }, propRef) { const context = useDialogContext(); const childrenRef = (children as any).ref; const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]); // `asChild` allows the user to pass any element as the anchor if (asChild && React.isValidElement(children)) { return React.cloneElement( children, context.getReferenceProps({ ref, ...props, ...children.props, 'data-state': context.open ? 'open' : 'closed', }) ); } return ( ); }); export const DialogContent = React.forwardRef< HTMLDivElement, React.HTMLProps & { container?: HTMLElement | null } >(function DialogContent(props, propRef) { const { context: floatingContext, ...context } = useDialogContext(); const ref = useMergeRefs([context.refs.setFloating, propRef]); if (!floatingContext.open) return null; return (
{props.children}
); }); export const DialogHeading = React.forwardRef< HTMLHeadingElement, React.HTMLProps >(function DialogHeading({ children, ...props }, ref) { const { setLabelId } = useDialogContext(); const id = useId(); // Only sets `aria-labelledby` on the Dialog root element // if this component is mounted inside it. React.useLayoutEffect(() => { setLabelId(id); return () => setLabelId(undefined); }, [id, setLabelId]); return (

{children}

); }); export const DialogDescription = React.forwardRef< HTMLParagraphElement, React.HTMLProps >(function DialogDescription({ children, ...props }, ref) { const { setDescriptionId } = useDialogContext(); const id = useId(); // Only sets `aria-describedby` on the Dialog root element // if this component is mounted inside it. React.useLayoutEffect(() => { setDescriptionId(id); return () => setDescriptionId(undefined); }, [id, setDescriptionId]); return (

{children}

); }); export const DialogClose = React.forwardRef< HTMLButtonElement, React.ButtonHTMLAttributes >(function DialogClose(props, ref) { const { setOpen } = useDialogContext(); return ( {submenu} ); } return ( ); }; MenuItem.displayName = 'MenuItem'; export const DropDownMenuItemBadge = ({ children, }: { children: React.ReactNode; }) => { return (
{children}
); }; DropDownMenuItemBadge.displayName = 'MenuItemBadge'; MenuItem.Badge = DropDownMenuItemBadge; export default MenuItem; ================================================ FILE: packages/drawnix/src/components/menu/menu-separator.tsx ================================================ const MenuSeparator = () => (
); export default MenuSeparator; MenuSeparator.displayName = 'MenuSeparator'; ================================================ FILE: packages/drawnix/src/components/menu/menu.scss ================================================ @import "../../styles/variables.module.scss"; .drawnix { .menu { &--mobile { left: 0; width: 100%; row-gap: 0.75rem; .menu-container { padding: 8px 8px; box-sizing: border-box; box-shadow: var(--shadow-island); border-radius: var(--border-radius-lg); position: relative; transition: box-shadow 0.5s ease-in-out; &.zen-mode { box-shadow: none; } } } .menu-container { background-color: var(--island-bg-color); max-height: calc(100vh - 150px); overflow-y: auto; --gap: 2; } .menu-item-base { display: flex; padding: 0 0.625rem; column-gap: 0.625rem; font-size: 0.875rem; color: var(--color-gray-90); width: 100%; box-sizing: border-box; font-weight: normal; font-family: inherit; } .menu-item { background-color: transparent; border: 1px solid transparent; align-items: center; height: 2rem; margin-top: 4px; cursor: pointer; border-radius: var(--border-radius-md); @media screen and (min-width: 1921px) { height: 2.25rem; } &--active { background-color: var(--color-surface-primary-container); text-decoration: none; } &__text { display: flex; align-items: center; width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; gap: 0.75rem; } &__shortcut { margin-inline-start: auto; opacity: 0.5; &--orphaned { text-align: right; font-size: 0.875rem; padding: 0 0.625rem; } } &:hover { background-color: var(--color-surface-primary-container); text-decoration: none; } &:active { background-color: var(--color-surface-primary-container); border-color: var(--color-brand-active); } svg { width: 1rem; height: 1rem; display: block; } } .menu-item-bare { align-items: center; height: 2rem; justify-content: space-between; @media screen and (min-width: 1921px) { height: 2.25rem; } svg { width: 1rem; height: 1rem; display: block; } } .menu-item-custom { margin-top: 0.5rem; } .menu-group-title { font-size: 14px; text-align: left; margin: 10px 0; font-weight: 500; } } .menu-button { @include outlineButtonStyles; width: var(--lg-button-size); height: var(--lg-button-size); @at-root .drawnix.theme--dark#{&} { --background: var(--color-surface-high); &:hover { --background: #363541; } } svg { width: var(--lg-icon-size); height: var(--lg-icon-size); } &--mobile { border: none; margin: 0; padding: 0; width: var(--default-button-size); height: var(--default-button-size); } } } ================================================ FILE: packages/drawnix/src/components/menu/menu.tsx ================================================ import { Island } from '../island'; import React from 'react'; import { MenuContentPropsContext } from './common'; import classNames from 'classnames'; import './menu.scss'; const Menu = ({ children, className = '', onSelect, style, }: { children?: React.ReactNode; className?: string; /** * Called when any menu item is selected (clicked on). */ onSelect?: (event: Event) => void; style?: React.CSSProperties; }) => { const newClassName = classNames(`menu ${className}`).trim(); return (
{ {children} }
); }; Menu.displayName = 'Menu'; export default Menu; ================================================ FILE: packages/drawnix/src/components/popover/popover.tsx ================================================ import * as React from 'react'; import { useFloating, autoUpdate, offset, flip, shift, useClick, useDismiss, useRole, useInteractions, useMergeRefs, Placement, FloatingPortal, FloatingFocusManager, } from '@floating-ui/react'; interface PopoverOptions { initialOpen?: boolean; placement?: Placement; modal?: boolean; open?: boolean; sideOffset?: number; onOpenChange?: (open: boolean) => void; } export function usePopover({ initialOpen = false, placement = 'bottom', modal, sideOffset, open: controlledOpen, onOpenChange: setControlledOpen, }: PopoverOptions = {}) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen); const [labelId, setLabelId] = React.useState(); const [descriptionId, setDescriptionId] = React.useState< string | undefined >(); const open = controlledOpen ?? uncontrolledOpen; const setOpen = setControlledOpen ?? setUncontrolledOpen; const data = useFloating({ placement, open, onOpenChange: setOpen, whileElementsMounted: autoUpdate, middleware: [ offset(sideOffset || 4), flip({ crossAxis: placement.includes('-'), fallbackAxisSideDirection: 'end', padding: 5, }), shift({ padding: 5 }), ], }); const context = data.context; const click = useClick(context, { enabled: controlledOpen == null, }); const dismiss = useDismiss(context); const role = useRole(context); const interactions = useInteractions([click, dismiss, role]); return React.useMemo( () => ({ open, setOpen, ...interactions, ...data, modal, labelId, descriptionId, setLabelId, setDescriptionId, }), [open, setOpen, interactions, data, modal, labelId, descriptionId] ); } type ContextType = | (ReturnType & { setLabelId: React.Dispatch>; setDescriptionId: React.Dispatch< React.SetStateAction >; }) | null; const PopoverContext = React.createContext(null); export const usePopoverContext = () => { const context = React.useContext(PopoverContext); if (context == null) { throw new Error('Popover components must be wrapped in '); } return context; }; export function Popover({ children, modal = false, ...restOptions }: { children: React.ReactNode; } & PopoverOptions) { // This can accept any props as options, e.g. `placement`, // or other positioning options. const popover = usePopover({ modal, ...restOptions }); return ( {children} ); } interface PopoverTriggerProps { children: React.ReactNode; asChild?: boolean; } export const PopoverTrigger = React.forwardRef< HTMLElement, React.HTMLProps & PopoverTriggerProps >(function PopoverTrigger({ children, asChild = false, ...props }, propRef) { const context = usePopoverContext(); const childrenRef = (children as any).ref; const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]); // `asChild` allows the user to pass any element as the anchor if (asChild && React.isValidElement(children)) { return React.cloneElement( children, context.getReferenceProps({ ref, ...props, ...children.props, 'data-state': context.open ? 'open' : 'closed', }) ); } return ( ); }); export const PopoverContent = React.forwardRef< HTMLDivElement, React.HTMLProps & { container?: HTMLElement | null } >(function PopoverContent({ container, style, ...props }, propRef) { const { context: floatingContext, ...context } = usePopoverContext(); const ref = useMergeRefs([context.refs.setFloating, propRef]); if (!floatingContext.open) return null; return (
{props.children}
); }); ================================================ FILE: packages/drawnix/src/components/popup/link-popup/link-popup.scss ================================================ .drawnix { .link-popup { padding-left: 8px; &__link { font-size: 14px; } .link-popup__link { display: inline-block; width: 18rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } &__input { padding: 10px 0px; width: 328px; border: none; border-radius: 4px; font-size: 14px; outline: none; } } } ================================================ FILE: packages/drawnix/src/components/popup/link-popup/link-popup.tsx ================================================ import { useEffect, useState, useRef } from 'react'; import { Island } from '../../island'; import Stack from '../../stack'; import { ToolButton } from '../../tool-button'; import classNames from 'classnames'; import './link-popup.scss'; import { flip, offset, useFloating } from '@floating-ui/react'; import { useDrawnix } from '../../../hooks/use-drawnix'; import { FeltTipPenIcon, TrashIcon } from '../../icons'; import { Transforms } from 'slate'; import { ReactEditor } from 'slate-react'; import { LinkEditor } from '@plait/text-plugins'; import { LinkElement } from '@plait/common'; import { useBoard } from '@plait-board/react-board'; import { useI18n } from '../../../i18n'; export const LinkPopup = () => { const { t } = useI18n(); const [url, setUrl] = useState(''); const { appState, setAppState } = useDrawnix(); const board = useBoard(); const { refs, floatingStyles } = useFloating({ placement: 'top', middleware: [offset(20), flip()], }); const linkState = appState.linkState; const target = appState.linkState?.targetDom || null; const isEditing = appState.linkState?.isEditing || false; const isHoveringOrigin = appState.linkState?.isHoveringOrigin || false; const isHovering = appState.linkState?.isHovering || false; const isOpening = isEditing || isHoveringOrigin || isHovering; const linkStateRef = useRef(appState.linkState); useEffect(() => { linkStateRef.current = appState.linkState; if (appState.linkState) { setUrl(appState.linkState.targetElement.url); } else { setUrl(''); } }, [appState.linkState]); useEffect(() => { if (target) { const rect = target.getBoundingClientRect(); refs.setPositionReference({ getBoundingClientRect() { return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, top: rect.y, left: rect.x, right: rect.x + rect.width, bottom: rect.y + rect.height, }; }, }); } }, [board.viewport, target]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( refs.floating.current && !refs.floating.current.contains(event.target as Node) ) { if (linkStateRef.current) { const linkElement = LinkEditor.getLinkElement( linkStateRef.current.editor ); if (linkElement && !(linkElement[0] as LinkElement).url.trim()) { LinkEditor.unwrapLink(linkStateRef.current.editor); } } setAppState({ ...appState, linkState: null, }); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, []); const saveUrlAndExitEditing = () => { if (url !== linkState!.targetElement.url) { const editor = linkState!.editor; const node = linkState!.targetElement; const path = ReactEditor.findPath(editor, node); Transforms.setNodes(editor, { url: url }, { at: path }); } const linkElement = LinkEditor.getLinkElement(linkState!.editor); setAppState({ ...appState, linkState: { ...appState.linkState!, targetElement: linkElement[0] as LinkElement, isEditing: false, isHoveringOrigin: true, }, }); }; return ( isOpening && ( { if (!isHovering) { setAppState({ ...appState, linkState: { ...appState.linkState!, isHovering: true, }, }); } }} onPointerLeave={() => { if (!isEditing) { setAppState({ ...appState, linkState: { ...appState.linkState!, isHovering: false, }, }); } }} > {isEditing ? ( <> { setUrl(e.target.value); }} onKeyDown={(e) => { if (e.key === 'Enter') { saveUrlAndExitEditing(); } }} className="link-popup__input" autoFocus /> { const editor = linkState!.editor; const targetElement = linkState!.targetElement; const path = ReactEditor.findPath(editor, targetElement); Transforms.unwrapNodes(editor, { at: path, }); setAppState({ ...appState, linkState: null, }); }} > ) : ( <> {url} { event.preventDefault(); setAppState({ ...appState, linkState: { ...appState.linkState!, isEditing: true, }, }); }} > { const editor = linkState!.editor; const targetElement = linkState!.targetElement; const path = ReactEditor.findPath(editor, targetElement); Transforms.unwrapNodes(editor, { at: path, }); setAppState({ ...appState, linkState: null, }); }} > )} ) ); }; ================================================ FILE: packages/drawnix/src/components/radio-group.scss ================================================ @import '../styles/variables.module.scss'; .drawnix { --RadioGroup-background: var(--island-bg-color); --RadioGroup-border: var(--color-surface-high); --RadioGroup-choice-color-off: var(--color-primary); --RadioGroup-choice-color-off-hover: var(--color-brand-hover); --RadioGroup-choice-background-off: var(--island-bg-color); --RadioGroup-choice-background-off-active: var(--color-surface-high); --RadioGroup-choice-color-on: var(--color-surface-lowest); --RadioGroup-choice-background-on: var(--color-primary); --RadioGroup-choice-background-on-hover: var(--color-brand-hover); --RadioGroup-choice-background-on-active: var(--color-brand-active); .RadioGroup { box-sizing: border-box; display: flex; flex-direction: row; align-items: flex-start; padding: 3px; border-radius: 10px; background: var(--RadioGroup-background); border: 1px solid var(--RadioGroup-border); &__choice { position: relative; display: flex; align-items: center; justify-content: center; width: 32px; height: 24px; color: var(--RadioGroup-choice-color-off); background: var(--RadioGroup-choice-background-off); border-radius: 8px; font-family: "Assistant"; font-style: normal; font-weight: 600; font-size: 0.75rem; line-height: 100%; user-select: none; letter-spacing: 0.4px; transition: all 75ms ease-out; &:hover { color: var(--RadioGroup-choice-color-off-hover); } &:active { background: var(--RadioGroup-choice-background-off-active); } &.active { color: var(--RadioGroup-choice-color-on); background: var(--RadioGroup-choice-background-on); &:hover { background: var(--RadioGroup-choice-background-on-hover); } &:active { background: var(--RadioGroup-choice-background-on-active); } } & input { z-index: 1; position: absolute; width: 100%; height: 100%; margin: 0; padding: 0; border-radius: 8px; -webkit-appearance: none; -moz-appearance: none; appearance: none; cursor: pointer; } } } } ================================================ FILE: packages/drawnix/src/components/radio-group.tsx ================================================ import classNames from 'classnames'; import './radio-group.scss'; export type RadioGroupChoice = { value: T; label: React.ReactNode; ariaLabel?: string; }; export type RadioGroupProps = { choices: RadioGroupChoice[]; value: T; onChange: (value: T) => void; name: string; }; export const RadioGroup = function ({ onChange, value, choices, name, }: RadioGroupProps) { return (
{choices.map((choice) => (
onChange(choice.value)} aria-label={choice.ariaLabel} /> {choice.label}
))}
); }; ================================================ FILE: packages/drawnix/src/components/select/select.scss ================================================ .drawnix { /* Define local variables mapped to project theme or defaults */ --dx-select-trigger-height: 2rem; --dx-select-content-padding: 4px; --dx-select-item-height: 2rem; --dx-select-item-indicator-width: 20px; --dx-select-separator-margin-right: 8px; /* Trigger Styles */ .dx-SelectTrigger { display: inline-flex; align-items: center; justify-content: space-between; flex-shrink: 0; user-select: none; vertical-align: top; height: var(--dx-select-trigger-height); box-sizing: border-box; font-family: inherit; font-size: 14px; line-height: 1; text-align: left; cursor: default; background-color: var(--island-bg-color); color: var(--color-text-primary); border: 1px solid var(--island-border-color); border-radius: var(--border-radius-md); padding: 0 var(--dx-space-2); gap: var(--dx-space-2); outline: none; &:hover { background-color: var(--color-gray-10); border-color: var(--color-gray-40); } &[data-state='open'] { border-color: var(--color-primary); box-shadow: 0 0 0 1px var(--color-primary); } &:focus-visible { border-color: var(--color-primary); box-shadow: 0 0 0 1px var(--color-primary); } &[data-disabled] { color: var(--color-text-disabled); background-color: var(--color-bg-disabled); cursor: not-allowed; } &.dx-variant-ghost { background-color: transparent; border-color: transparent; &:hover { background-color: var(--color-gray-10); } } } .dx-SelectTriggerInner { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .dx-SelectIcon { flex-shrink: 0; color: var(--color-text-secondary); display: flex; align-items: center; svg { display: block; } } /* Content Styles */ .dx-SelectContent { box-sizing: border-box; background-color: var(--island-bg-color); border: 1px solid var(--island-border-color); box-shadow: var(--shadow-island); border-radius: var(--border-radius-lg); padding: var(--dx-select-content-padding); min-width: var(--radix-select-trigger-width); /* Note: Floating UI might handle width */ max-height: 300px; overflow: hidden; z-index: 1000; display: flex; flex-direction: column; &:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } } .dx-SelectContent[data-hide-selected-indicator] { .dx-SelectItem { padding-left: var(--dx-space-2); } .dx-SelectItemIndicator { display: none; } } .dx-SelectViewport { overflow-y: auto; padding: 0; flex: 1; } /* Item Styles */ .dx-SelectItem { display: flex; align-items: center; width: 100%; height: var(--dx-select-item-height); padding: 0 var(--dx-space-2) 0 var(--dx-select-item-indicator-width); position: relative; box-sizing: border-box; outline: none; user-select: none; cursor: pointer; border: none; background: transparent; color: var(--color-text-primary); font-size: 14px; border-radius: var(--dx-radius-2); text-align: center; &[data-highlighted] { background-color: var(--color-primary); color: var(--color-icon-white); .dx-SelectItemIndicator { color: var(--color-icon-white); } } &[data-disabled] { color: var(--color-text-disabled); cursor: not-allowed; } } .dx-SelectItemIndicator { position: absolute; left: 0; width: var(--dx-select-item-indicator-width); display: inline-flex; align-items: center; justify-content: center; color: var(--color-primary); } .dx-SelectItemText { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: inherit; } /* Label, Group, Separator */ .dx-SelectLabel { display: flex; align-items: center; height: var(--dx-select-item-height); padding-left: var(--dx-select-item-indicator-width); padding-right: var(--dx-space-2); color: var(--color-text-secondary); font-size: 12px; font-weight: 500; user-select: none; cursor: default; } .dx-SelectSeparator { height: 1px; background-color: var(--island-border-color); margin: var(--dx-space-1) 0; } /* Size Variants */ .dx-r-size-1 { --dx-select-trigger-height: 24px; --dx-select-item-height: 24px; font-size: 12px; &.dx-SelectItem { font-size: 12px; } } .dx-r-size-2 { --dx-select-trigger-height: 32px; --dx-select-item-height: 28px; /* Radix often uses slightly smaller items */ font-size: 14px; } .dx-r-size-3 { --dx-select-trigger-height: 40px; --dx-select-item-height: 32px; font-size: 16px; &.dx-SelectItem { font-size: 16px; } } } ================================================ FILE: packages/drawnix/src/components/select/select.tsx ================================================ import * as React from 'react'; import classNames from 'classnames'; import { autoUpdate, flip, FloatingFocusManager, FloatingList, FloatingPortal, offset, Placement, shift, useClick, useDismiss, useFloating, useInteractions, useListItem, useListNavigation, useMergeRefs, useRole, useTypeahead, } from '@floating-ui/react'; import { ChevronDownIcon, ThickCheckIcon } from '../icons'; import './select.scss'; type SelectValueType = string; interface SelectContextType { open: boolean; setOpen: (open: boolean) => void; value: SelectValueType | undefined; setValue: (value: SelectValueType) => void; activeIndex: number | null; setActiveIndex: (index: number | null) => void; selectedIndex: number | null; elementsRef: React.MutableRefObject>; labelsRef: React.MutableRefObject>; valuesRef: React.MutableRefObject>; getReferenceProps: ReturnType['getReferenceProps']; getFloatingProps: ReturnType['getFloatingProps']; getItemProps: ReturnType['getItemProps']; refs: ReturnType['refs']; floatingStyles: React.CSSProperties; floatingContext: ReturnType['context']; size: '1' | '2' | '3'; hideSelectedIndicator: boolean; disableItemHoverHighlight: boolean; } const SelectContext = React.createContext(null); const useSelectContext = () => { const context = React.useContext(SelectContext); if (!context) { throw new Error('Select components must be wrapped in '); } return context; }; interface SelectRootProps { children: React.ReactNode; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; size?: '1' | '2' | '3'; disabled?: boolean; placement?: Placement; sideOffset?: number; hideSelectedIndicator?: boolean; disableItemHoverHighlight?: boolean; disableInitialHighlight?: boolean; disableTypeahead?: boolean; } const SelectRoot: React.FC = ({ children, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen = false, onOpenChange: setControlledOpen, size = '2', disabled = false, placement = 'bottom-start', sideOffset = 4, hideSelectedIndicator = false, disableItemHoverHighlight = false, disableInitialHighlight = false, disableTypeahead = false, }) => { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen); const open = controlledOpen ?? uncontrolledOpen; const setOpen = setControlledOpen ?? setUncontrolledOpen; const [uncontrolledValue, setUncontrolledValue] = React.useState< string | undefined >(defaultValue); const value = controlledValue ?? uncontrolledValue; const setValue = React.useCallback( (nextValue: string) => { if (controlledValue === undefined) { setUncontrolledValue(nextValue); } onValueChange?.(nextValue); }, [controlledValue, onValueChange] ); const elementsRef = React.useRef>([]); const labelsRef = React.useRef>([]); const valuesRef = React.useRef>([]); const [activeIndex, setActiveIndex] = React.useState(null); const selectedIndex = React.useMemo(() => { if (value == null) return null; const index = valuesRef.current.findIndex((v) => v === value); return index >= 0 ? index : null; }, [value]); const navigationSelectedIndex = disableInitialHighlight ? null : selectedIndex; React.useEffect(() => { if (!open) return; if (disableInitialHighlight) { setActiveIndex(null); return; } setActiveIndex(selectedIndex ?? 0); }, [open, selectedIndex, disableInitialHighlight]); const { refs, floatingStyles, context } = useFloating({ placement, open, onOpenChange: setOpen, whileElementsMounted: autoUpdate, middleware: [ offset(sideOffset), flip({ padding: 5 }), shift({ padding: 5 }), ], }); const click = useClick(context, { enabled: !disabled && controlledOpen === undefined }); const dismiss = useDismiss(context); const role = useRole(context, { role: 'listbox' }); const listNavigation = useListNavigation(context, { listRef: elementsRef, activeIndex, selectedIndex: navigationSelectedIndex, onNavigate: setActiveIndex, loop: true, focusItemOnHover: !disableItemHoverHighlight, }); const typeahead = useTypeahead(context, { listRef: labelsRef, activeIndex, selectedIndex: navigationSelectedIndex, onMatch: setActiveIndex, }); const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ click, dismiss, role, listNavigation, disableTypeahead ? ({} as any) : typeahead, ]); const contextValue = React.useMemo( () => ({ open, setOpen, value, setValue, activeIndex, setActiveIndex, selectedIndex, elementsRef, labelsRef, valuesRef, getReferenceProps, getFloatingProps, getItemProps, refs, floatingStyles, floatingContext: context, size, hideSelectedIndicator, disableItemHoverHighlight, }), [ open, setOpen, value, setValue, activeIndex, selectedIndex, getReferenceProps, getFloatingProps, getItemProps, refs, floatingStyles, context, size, hideSelectedIndicator, disableItemHoverHighlight, ] ); return ( {children} ); }; SelectRoot.displayName = 'Select.Root'; interface SelectTriggerProps extends React.ButtonHTMLAttributes { variant?: 'classic' | 'surface' | 'soft' | 'ghost'; color?: string; radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; placeholder?: string; asChild?: boolean; } const SelectTrigger = React.forwardRef( ( { children, className, variant = 'surface', color, radius, placeholder, asChild, ...props }, forwardedRef ) => { const context = useSelectContext(); const mergedRef = useMergeRefs([context.refs.setReference, forwardedRef]); if (asChild && React.isValidElement(children)) { return React.cloneElement(children, { ref: mergedRef, ...context.getReferenceProps(props), 'data-state': context.open ? 'open' : 'closed', } as any); } // Value rendering logic let content = children; if (!content && context.value) { // Find label for value const index = context.valuesRef.current.indexOf(context.value); if (index !== -1) { content = context.labelsRef.current[index]; } else { content = context.value; } } const shouldShowPlaceholder = !content && placeholder; const displayContent = shouldShowPlaceholder ? placeholder : content; return ( ); } ); SelectTrigger.displayName = 'Select.Trigger'; interface SelectContentProps extends React.HTMLAttributes { variant?: 'solid' | 'soft'; color?: string; highContrast?: boolean; container?: HTMLElement | null; } const SelectContent = React.forwardRef( ( { children, className, variant = 'solid', color, highContrast, container, style, ...props }, forwardedRef ) => { const context = useSelectContext(); const mergedRef = useMergeRefs([context.refs.setFloating, forwardedRef]); if (!context.open) return null; return (
{children}
); } ); SelectContent.displayName = 'Select.Content'; interface SelectItemProps extends React.ButtonHTMLAttributes { value: string; textValue?: string; } const SelectItem = React.forwardRef( ({ children, className, value, textValue, disabled, ...props }, forwardedRef) => { const context = useSelectContext(); const { ref: itemRef, index } = useListItem({ label: textValue ?? (typeof children === 'string' ? children : value), }); const mergedRef = useMergeRefs([itemRef, forwardedRef]); const isActive = context.activeIndex === index; const isSelected = context.value === value; React.useEffect(() => { if (index !== null) { context.valuesRef.current[index] = value; // Best effort to get label context.labelsRef.current[index] = textValue ?? (typeof children === 'string' ? children : value); } }, [index, value, textValue, children, context.valuesRef, context.labelsRef]); const handleSelect = () => { context.setValue(value); context.setOpen(false); }; const mergedItemProps = context.getItemProps({ ...props, onClick: (e: React.MouseEvent) => { props.onClick?.(e as React.MouseEvent); handleSelect(); }, onKeyDown: (e: React.KeyboardEvent) => { props.onKeyDown?.(e as React.KeyboardEvent); if (e.key === 'Enter') { e.preventDefault(); handleSelect(); } }, }); const { onPointerMove, onMouseMove, onMouseEnter, onMouseLeave, ...restMergedItemProps } = mergedItemProps as React.ButtonHTMLAttributes; return ( ); } ); SelectItem.displayName = 'Select.Item'; type SelectGroupProps = React.HTMLAttributes const SelectGroup = React.forwardRef( ({ className, ...props }, ref) => (
) ); SelectGroup.displayName = 'Select.Group'; type SelectLabelProps = React.HTMLAttributes const SelectLabel = React.forwardRef( ({ className, ...props }, ref) => (
) ); SelectLabel.displayName = 'Select.Label'; type SelectSeparatorProps = React.HTMLAttributes const SelectSeparator = React.forwardRef( ({ className, ...props }, ref) => (
) ); SelectSeparator.displayName = 'Select.Separator'; export const Select = { Root: SelectRoot, Trigger: SelectTrigger, Content: SelectContent, Item: SelectItem, Group: SelectGroup, Label: SelectLabel, Separator: SelectSeparator, }; ================================================ FILE: packages/drawnix/src/components/shape-picker.tsx ================================================ import classNames from 'classnames'; import { Island } from './island'; import Stack from './stack'; import { ToolButton } from './tool-button'; import { RectangleIcon, EllipseIcon, TriangleIcon, DiamondIcon, ParallelogramIcon, RoundRectangleIcon, TerminalIcon, NoteCurlyLeftIcon, NoteCurlyRightIcon, } from './icons'; import { BoardTransforms, PlaitBoard } from '@plait/core'; import React from 'react'; import { BoardCreationMode, setCreationMode } from '@plait/common'; import { BasicShapes, DrawPointerType, FlowchartSymbols } from '@plait/draw'; import { useBoard } from '@plait-board/react-board'; import { Translations, useI18n } from '../i18n'; import { splitRows } from '../utils/common'; export interface ShapeProps { icon: React.ReactNode; title: string; pointer: DrawPointerType; } export const SHAPES: ShapeProps[] = [ { icon: RectangleIcon, title: 'toolbar.shape.rectangle', pointer: BasicShapes.rectangle, }, { icon: EllipseIcon, title: 'toolbar.shape.ellipse', pointer: BasicShapes.ellipse, }, { icon: TriangleIcon, title: 'toolbar.shape.triangle', pointer: BasicShapes.triangle, }, { icon: TerminalIcon, title: 'toolbar.shape.terminal', pointer: FlowchartSymbols.terminal, }, { icon: NoteCurlyRightIcon, title: 'toolbar.shape.noteCurlyRight', pointer: FlowchartSymbols.noteCurlyRight, }, { icon: NoteCurlyLeftIcon, title: 'toolbar.shape.noteCurlyLeft', pointer: FlowchartSymbols.noteCurlyLeft, }, { icon: DiamondIcon, title: 'toolbar.shape.diamond', pointer: BasicShapes.diamond, }, { icon: ParallelogramIcon, title: 'toolbar.shape.parallelogram', pointer: BasicShapes.parallelogram, }, { icon: RoundRectangleIcon, title: 'toolbar.shape.roundRectangle', pointer: BasicShapes.roundRectangle, }, ]; const ROW_SHAPES = splitRows(SHAPES, 5); export type ShapePickerProps = { onPointerUp: (pointer: DrawPointerType) => void; }; export const ShapePicker: React.FC = ({ onPointerUp }) => { const board = useBoard(); const { t } = useI18n(); return ( {ROW_SHAPES.map((rowShapes, rowIndex) => { return ( {rowShapes.map((shape, index) => { return ( { setCreationMode(board, BoardCreationMode.dnd); BoardTransforms.updatePointerType(board, shape.pointer); }} onPointerUp={() => { setCreationMode(board, BoardCreationMode.drawing); onPointerUp(shape.pointer); }} /> ); })} ); })} ); }; ================================================ FILE: packages/drawnix/src/components/size-slider.scss ================================================ @import "open-color/open-color.scss"; .slider-container { padding: 10px 0px; &.disabled { opacity: 50%; cursor: not-allowed; .slider-track,.slider-thumb { cursor: not-allowed; } } .slider-track { position: relative; height: 4px; background-color: var(--color-gray-20); border-radius: 2px; cursor: pointer; } .slider-range { position: absolute; height: 100%; background-color: var(--color-primary); border-radius: 3px; } .slider-thumb { position: absolute; width: 12px; height: 12px; background-color: $oc-white; border: 2px solid var(--color-primary); border-radius: 50%; top: 50%; transform: translate(-50%, -50%); cursor: grab; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), } } ================================================ FILE: packages/drawnix/src/components/size-slider.tsx ================================================ import React, { useState, useRef, useCallback, useEffect, useMemo, } from 'react'; import { toFixed } from '@plait/core'; import './size-slider.scss'; import classNames from 'classnames'; import { throttle } from 'lodash'; interface SliderProps { min?: number; max?: number; step?: number; defaultValue?: number; disabled?: boolean; title?: string; onChange?: (value: number) => void; beforeStart?: () => void; afterEnd?: () => void; } export const SizeSlider: React.FC = ({ min = 0, max = 100, step = 1, defaultValue = 100, disabled = false, onChange, beforeStart, afterEnd, title, }) => { const [isDragging, setIsDragging] = useState(false); const [value, setValue] = useState(defaultValue); const sliderRef = useRef(null); const thumbRef = useRef(null); const percentage = ((value - min) / (max - min)) * 100; const handleSliderChange = useCallback( throttle( (event: React.MouseEvent | MouseEvent) => { if (sliderRef.current && thumbRef.current) { const sliderRect = sliderRef.current.getBoundingClientRect(); const thumbRect = thumbRef.current.getBoundingClientRect(); const x = event.clientX - sliderRect.left; const thumbPercentage = toFixed( (thumbRect.width / 2 / sliderRect.width) * 100 ); let percentage = Math.min(Math.max(x / sliderRect.width, 0), 1); if (percentage >= (100 - thumbPercentage) / 100) { percentage = 1; } else if (percentage <= thumbPercentage / 100) { percentage = 0; } const newValue = Math.round((percentage * (max - min)) / step) * step + min; setValue(newValue); onChange && onChange(newValue); } }, 50, { leading: true, trailing: true } ), [min, max, step, onChange] ); const handlePointerDown = useCallback(() => { const handleMouseMove = (e: MouseEvent) => { setIsDragging(true); handleSliderChange(e); }; const handleMouseUp = () => { document.removeEventListener('pointermove', handleMouseMove); document.removeEventListener('pointerup', handleMouseUp); afterEnd && afterEnd(); setTimeout(() => { setIsDragging(false); }, 0); }; document.addEventListener('pointermove', handleMouseMove); document.addEventListener('pointerup', handleMouseUp); }, [handleSliderChange]); useEffect(()=>{ setValue(defaultValue) },[defaultValue]) return (
{ if (disabled || isDragging) { return; } handleSliderChange(event); }} onPointerDown={(event) => { event.preventDefault(); if (disabled) { return; } beforeStart && beforeStart(); handlePointerDown(); }} >
); }; ================================================ FILE: packages/drawnix/src/components/stack.scss ================================================ .drawnix { .stack { --gap: 0; display: grid; gap: calc(var(--space-factor) * var(--gap)); } .stack_vertical { grid-template-columns: auto; grid-auto-flow: row; grid-auto-rows: min-content; } .stack_horizontal { grid-template-rows: auto; grid-auto-flow: column; grid-auto-columns: min-content; } } ================================================ FILE: packages/drawnix/src/components/stack.tsx ================================================ // Credits to excalidraw import "./stack.scss"; import React, { forwardRef } from "react"; import clsx from "classnames"; type StackProps = { children: React.ReactNode; gap?: number; align?: "start" | "center" | "end" | "baseline"; justifyContent?: "center" | "space-around" | "space-between"; className?: string | boolean; style?: React.CSSProperties; ref: React.RefObject; }; const RowStack = forwardRef( ( { children, gap, align, justifyContent, className, style }: StackProps, ref: React.ForwardedRef, ) => { return (
{children}
); }, ); const ColStack = forwardRef( ( { children, gap, align, justifyContent, className, style }: StackProps, ref: React.ForwardedRef, ) => { return (
{children}
); }, ); export default { Row: RowStack, Col: ColStack, }; ================================================ FILE: packages/drawnix/src/components/tool-button.tsx ================================================ // Credits to excalidraw import './tool-icon.scss'; import type { CSSProperties } from 'react'; import React, { useEffect, useRef, useState } from 'react'; import { AbortError } from '../errors'; import { isPromiseLike } from '../utils/common'; import classNames from 'classnames'; import { EventPointerType } from '../types'; export type ToolButtonSize = 'small' | 'medium'; type ToolButtonBaseProps = { icon?: React.ReactNode; 'aria-label': string; 'aria-keyshortcuts'?: string; 'data-testid'?: string; label?: string; title?: string; name?: string; id?: string; size?: ToolButtonSize; keyBindingLabel?: string | null; showAriaLabel?: boolean; hidden?: boolean; visible?: boolean; selected?: boolean; disabled?: boolean; className?: string; style?: CSSProperties; onPointerDown?(data: { pointerType: EventPointerType; event: React.PointerEvent; }): void; onPointerUp?(data: { pointerType: EventPointerType }): void; }; type ToolButtonProps = | (ToolButtonBaseProps & { type: 'button'; children?: React.ReactNode; onClick?(event: React.MouseEvent): void; }) | (ToolButtonBaseProps & { type: 'submit'; children?: React.ReactNode; onClick?(event: React.MouseEvent): void; }) | (ToolButtonBaseProps & { type: 'icon'; children?: React.ReactNode; onClick?(): void; }) | (ToolButtonBaseProps & { type: 'radio'; checked: boolean; onChange?(data: { pointerType: EventPointerType | null }): void; }); export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { const { id: drawnixId } = { id: 'drawnix' }; const innerRef = React.useRef(null); React.useImperativeHandle(ref, () => innerRef.current); const sizeCn = `tool-icon_size_${props.size || 'medium'}`; const [isLoading, setIsLoading] = useState(false); const isMountedRef = useRef(true); const onClick = async (event: React.MouseEvent) => { const ret = 'onClick' in props && props.onClick?.(event); if (isPromiseLike(ret)) { try { setIsLoading(true); await ret; } catch (error: any) { if (!(error instanceof AbortError)) { throw error; } else { console.warn(error); } } finally { if (isMountedRef.current) { setIsLoading(false); } } } }; useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); const lastPointerTypeRef = useRef(null); if ( props.type === 'button' || props.type === 'icon' || props.type === 'submit' ) { const type = (props.type === 'icon' ? 'button' : props.type) as | 'button' | 'submit'; return ( ); } return ( ); }); ToolButton.displayName = 'ToolButton'; ================================================ FILE: packages/drawnix/src/components/tool-icon.scss ================================================ @import "open-color/open-color.scss"; @import "../styles/variables.module.scss"; .drawnix { .tool-icon { border-radius: var(--border-radius-md); display: inline-flex; align-items: center; justify-content: center; position: relative; cursor: pointer; -webkit-tap-highlight-color: transparent; user-select: none; &__hidden { display: none !important; } @include toolbarButtonColorStates; } .tool-icon--plain { background-color: transparent; .tool-icon__icon { width: 2rem; height: 2rem; } } .tool-icon_type_radio, .tool-icon_type_checkbox { position: absolute; opacity: 0; pointer-events: none; } .tool-icon__icon { box-sizing: border-box; width: var(--lg-button-size); height: var(--lg-button-size); color: var(--icon-fill-color); display: flex; justify-content: center; align-items: center; border-radius: var(--border-radius-md); & + .tool-icon__label { margin-inline-start: 0; } svg { stroke: currentColor; position: relative; width: var(--lg-icon-size); height: var(--lg-icon-size); outline: none; } } .tool-icon_type_button { padding: 0; border: none; margin: 0; font-size: inherit; background-color: initial; &:focus-visible { box-shadow: 0 0 0 2px var(--color-primary); outline: none; } &.tool-icon--selected { background: var(--color-surface-primary-container); svg { color: var(--color-on-primary-container); } } &:active { background-color: var(--button-gray-3); } &:disabled { cursor: default; &:active, &:focus-visible, &:hover { background-color: initial; border: none; box-shadow: none; } svg { color: var(--color-disabled); } } &--show { visibility: visible; } &--hide { display: none !important; } } .tool-icon__label { display: flex; align-items: center; color: var(--icon-fill-color); font-family: var(--ui-font); margin: 0 0.8em; text-overflow: ellipsis; } .tool-icon_size_small .tool-icon__icon { width: 2rem; height: 2rem; font-size: 0.8em; svg { width: var(--default-icon-size); height: var(--default-icon-size); } } .tool-icon__keybinding { position: absolute; bottom: 2px; right: 3px; font-size: 0.625rem; color: var(--keybinding-color); font-family: var(--ui-font); user-select: none; } } ================================================ FILE: packages/drawnix/src/components/toolbar/app-toolbar/app-menu-items.tsx ================================================ import { ExportImageIcon, GithubIcon, OpenFileIcon, SaveFileIcon, TrashIcon, } from '../../icons'; import { useBoard, useListRender } from '@plait-board/react-board'; import { BoardTransforms, PlaitBoard, PlaitElement, PlaitTheme, ThemeColorMode, Viewport, } from '@plait/core'; import { loadFromJSON, saveAsJSON } from '../../../data/json'; import MenuItem from '../../menu/menu-item'; import MenuItemLink from '../../menu/menu-item-link'; import { saveAsImage, saveAsSvg } from '../../../utils/image'; import { useDrawnix } from '../../../hooks/use-drawnix'; import { useI18n } from '../../../i18n'; import Menu from '../../menu/menu'; import { useContext } from 'react'; import { MenuContentPropsContext } from '../../menu/common'; import { EVENT } from '../../../constants'; import { getShortcutKey } from '../../../utils/common'; export const SaveToFile = () => { const board = useBoard(); const { t } = useI18n(); return ( { saveAsJSON(board); }} icon={SaveFileIcon} aria-label={t('menu.saveFile')} shortcut={getShortcutKey('CtrlOrCmd+S')} >{t('menu.saveFile')} ); }; SaveToFile.displayName = 'SaveToFile'; export const OpenFile = () => { const board = useBoard(); const listRender = useListRender(); const { t } = useI18n(); const clearAndLoad = ( value: PlaitElement[], viewport?: Viewport, theme?: PlaitTheme ) => { board.children = value; board.viewport = viewport || { zoom: 1 }; if (theme) { board.theme = theme; } listRender.update(board.children, { board: board, parent: board, parentG: PlaitBoard.getElementHost(board), }); BoardTransforms.fitViewport(board); }; return ( { loadFromJSON(board).then((data) => { clearAndLoad(data.elements, data.viewport, data.theme); }); }} icon={OpenFileIcon} aria-label={t('menu.open')} >{t('menu.open')} ); }; OpenFile.displayName = 'OpenFile'; export const SaveAsImage = () => { const board = useBoard(); const menuContentProps = useContext(MenuContentPropsContext); const { t } = useI18n(); return ( { saveAsImage(board, true); }} submenu={ { const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, { bubbles: true, cancelable: true, }); menuContentProps.onSelect?.(itemSelectEvent); }}> { saveAsSvg(board); }} aria-label={t('menu.exportImage.svg')} > {t('menu.exportImage.svg')} { saveAsImage(board, true); }} aria-label={t('menu.exportImage.png')} > {t('menu.exportImage.png')} { saveAsImage(board, false); }} aria-label={t('menu.exportImage.jpg')} > {t('menu.exportImage.jpg')} } shortcut={getShortcutKey('CtrlOrCmd+Shift+E')} aria-label={t('menu.exportImage')} > {t('menu.exportImage')} ); }; SaveAsImage.displayName = 'SaveAsImage'; export const CleanBoard = () => { const { appState, setAppState } = useDrawnix(); const { t } = useI18n(); return ( { setAppState({ ...appState, openCleanConfirm: true, }); }} shortcut={getShortcutKey('CtrlOrCmd+Backspace')} aria-label={t('menu.cleanBoard')} > {t('menu.cleanBoard')} ); }; CleanBoard.displayName = 'CleanBoard'; export const Socials = () => { return ( GitHub ); }; Socials.displayName = 'Socials'; ================================================ FILE: packages/drawnix/src/components/toolbar/app-toolbar/app-toolbar.tsx ================================================ import { useBoard } from '@plait-board/react-board'; import Stack from '../../stack'; import { ToolButton } from '../../tool-button'; import { DuplicateIcon, MenuIcon, RedoIcon, TrashIcon, UndoIcon, } from '../../icons'; import classNames from 'classnames'; import { ATTACHED_ELEMENT_CLASS_NAME, deleteFragment, duplicateElements, getSelectedElements, PlaitBoard, } from '@plait/core'; import { Island } from '../../island'; import { Popover, PopoverContent, PopoverTrigger } from '../../popover/popover'; import { useState } from 'react'; import { CleanBoard, OpenFile, SaveAsImage, SaveToFile, Socials } from './app-menu-items'; import { LanguageSwitcherMenu } from './language-switcher-menu'; import Menu from '../../menu/menu'; import MenuSeparator from '../../menu/menu-separator'; import { useI18n } from '../../../i18n'; export const AppToolbar = () => { const board = useBoard(); const { t } = useI18n(); const container = PlaitBoard.getBoardContainer(board); const selectedElements = getSelectedElements(board); const [appMenuOpen, setAppMenuOpen] = useState(false); const isUndoDisabled = board.history.undos.length <= 0; const isRedoDisabled = board.history.redos.length <= 0; return ( { setAppMenuOpen(open); }} placement="bottom-start" > { setAppMenuOpen(!appMenuOpen); }} /> { setAppMenuOpen(false); }} > { board.undo(); }} disabled={isUndoDisabled} /> { board.redo(); }} disabled={isRedoDisabled} /> {selectedElements.length > 0 && ( { duplicateElements(board); }} /> )} {selectedElements.length > 0 && ( { deleteFragment(board); }} /> )} ); }; ================================================ FILE: packages/drawnix/src/components/toolbar/app-toolbar/language-switcher-menu.tsx ================================================ import { useContext } from 'react'; import { MenuIcon } from '../../icons'; import { useI18n } from '../../../i18n'; import Menu from '../../menu/menu'; import MenuItem from '../../menu/menu-item'; import { MenuContentPropsContext } from '../../menu/common'; import { EVENT } from '../../../constants'; export const LanguageSwitcherMenu = () => { const { language, setLanguage, t } = useI18n(); const menuContentProps = useContext(MenuContentPropsContext); return ( { // This will be handled by the submenu }} submenu={ { const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, { bubbles: true, cancelable: true, }); menuContentProps.onSelect?.(itemSelectEvent); }}> { setLanguage('zh'); }} aria-label={t('language.chinese')} selected={language === 'zh'} > {t('language.chinese')} { setLanguage('en'); }} aria-label={t('language.english')} selected={language === 'en'} > {t('language.english')} { setLanguage('ru'); }} aria-label={t('language.russian')} selected={language === 'ru'} > {t('language.russian')} { setLanguage('ar'); }} aria-label={t('language.arabic')} selected={language === 'ar'} >{t('language.arabic')} { setLanguage('vi'); }} aria-label={t('language.vietnamese')} selected={language === 'vi'} >{t('language.vietnamese')} } aria-label={t('language.switcher')} > {t('language.switcher')} ); }; LanguageSwitcherMenu.displayName = 'LanguageSwitcherMenu'; ================================================ FILE: packages/drawnix/src/components/toolbar/creation-toolbar.tsx ================================================ import classNames from 'classnames'; import { Island } from '../island'; import Stack from '../stack'; import { ToolButton } from '../tool-button'; import { HandIcon, MindIcon, SelectionIcon, ShapeIcon, TextIcon, EraseIcon, StraightArrowLineIcon, FeltTipPenIcon, ImageIcon, ExtraToolsIcon, } from '../icons'; import { useBoard } from '@plait-board/react-board'; import { ATTACHED_ELEMENT_CLASS_NAME, BoardTransforms, PlaitBoard, PlaitPointerType, } from '@plait/core'; import { MindPointerType } from '@plait/mind'; import { BoardCreationMode, setCreationMode } from '@plait/common'; import { ArrowLineShape, BasicShapes, DrawPointerType, FlowchartSymbols, } from '@plait/draw'; import { FreehandPanel , FREEHANDS } from './freehand-panel/freehand-panel'; import { ShapePicker } from '../shape-picker'; import { ArrowPicker } from '../arrow-picker'; import { useState } from 'react'; import { Popover, PopoverContent, PopoverTrigger } from '../popover/popover'; import { FreehandShape } from '../../plugins/freehand/type'; import { DrawnixPointerType, useDrawnix, useSetPointer, } from '../../hooks/use-drawnix'; import { ExtraToolsButton } from './extra-tools/extra-tools-button'; import { addImage } from '../../utils/image'; import { useI18n } from '../../i18n'; import { SHAPES } from '../shape-picker'; import { ARROWS } from '../arrow-picker'; export enum PopupKey { 'shape' = 'shape', 'arrow' = 'arrow', 'freehand' = 'freehand', } type AppToolButtonProps = { titleKey?: keyof typeof import('../../i18n').Translations; name?: string; icon: React.ReactNode; pointer?: DrawnixPointerType; key?: PopupKey | 'image' | 'extra-tools'; }; const isBasicPointer = (pointer: string) => { return ( pointer === PlaitPointerType.hand || pointer === PlaitPointerType.selection ); }; export const BUTTONS: AppToolButtonProps[] = [ { icon: HandIcon, pointer: PlaitPointerType.hand, titleKey: 'toolbar.hand', }, { icon: SelectionIcon, pointer: PlaitPointerType.selection, titleKey: 'toolbar.selection', }, { icon: MindIcon, pointer: MindPointerType.mind, titleKey: 'toolbar.mind', }, { icon: TextIcon, pointer: BasicShapes.text, titleKey: 'toolbar.text', }, { icon: FeltTipPenIcon, pointer: FreehandShape.feltTipPen, titleKey: 'toolbar.pen', key: PopupKey.freehand, }, { icon: StraightArrowLineIcon, titleKey: 'toolbar.arrow', key: PopupKey.arrow, pointer: ArrowLineShape.straight, }, { icon: ShapeIcon, titleKey: 'toolbar.shape', key: PopupKey.shape, pointer: BasicShapes.rectangle, }, { icon: ImageIcon, titleKey: 'toolbar.image', key: 'image', }, { icon: ExtraToolsIcon, titleKey: 'toolbar.extraTools', key: 'extra-tools', }, ]; // TODO provider by plait/draw export const isArrowLinePointer = (board: PlaitBoard) => { return Object.keys(ArrowLineShape).includes(board.pointer); }; export const isShapePointer = (board: PlaitBoard) => { return ( Object.keys(BasicShapes).includes(board.pointer) || Object.keys(FlowchartSymbols).includes(board.pointer) ); }; export const CreationToolbar = () => { const board = useBoard(); const { appState } = useDrawnix(); const { t } = useI18n(); const setPointer = useSetPointer(); const container = PlaitBoard.getBoardContainer(board); const [freehandOpen, setFreehandOpen] = useState(false); const [arrowOpen, setArrowOpen] = useState(false); const [shapeOpen, setShapeOpen] = useState(false); const [lastFreehandButton, setLastFreehandButton] = useState( BUTTONS.find((button) => button.key === PopupKey.freehand)! ); const [lastShapePointer, setLastShapePointer] = useState(SHAPES[0].pointer); const [lastArrowPointer, setLastArrowPointer] = useState(ARROWS[0].pointer); const onPointerDown = (pointer: DrawnixPointerType) => { setCreationMode(board, BoardCreationMode.dnd); BoardTransforms.updatePointerType(board, pointer); setPointer(pointer); }; const onPointerUp = () => { setCreationMode(board, BoardCreationMode.drawing); }; const isChecked = (button: AppToolButtonProps) => { return ( PlaitBoard.isPointer(board, button.pointer) && !arrowOpen && !shapeOpen && !freehandOpen ); }; const checkCurrentPointerIsFreehand = (board: PlaitBoard) => { return PlaitBoard.isInPointer(board, [ FreehandShape.feltTipPen, FreehandShape.eraser, ]); }; return ( {BUTTONS.map((button, index) => { if (appState.isMobile && button.pointer === PlaitPointerType.hand) { return <>; } if (button.key === PopupKey.freehand) { return ( { setFreehandOpen(open); }} > { setFreehandOpen(!freehandOpen); onPointerDown(lastFreehandButton.pointer!); }} onPointerUp={() => { onPointerUp(); }} /> { setPointer(pointer); setLastFreehandButton( FREEHANDS.find((button) => button.pointer === pointer)! ); }} > ); } if (button.key === PopupKey.shape) { return ( { setShapeOpen(open); }} > { setShapeOpen(!shapeOpen); if (isShapePointer(board)) { BoardTransforms.updatePointerType(board, board.pointer); } else { setPointer(lastShapePointer || SHAPES[0].pointer) setCreationMode(board, BoardCreationMode.drawing); BoardTransforms.updatePointerType(board, lastShapePointer || SHAPES[0].pointer); } }} /> { setShapeOpen(false); setPointer(pointer); setLastShapePointer(pointer); }} > ); } if (button.key === PopupKey.arrow) { return ( { setArrowOpen(open); }} > { setArrowOpen(!arrowOpen); if (isArrowLinePointer(board)) { BoardTransforms.updatePointerType(board, board.pointer); } else { setCreationMode(board, BoardCreationMode.drawing); BoardTransforms.updatePointerType(board, lastArrowPointer || ARROWS[0].pointer); setPointer(lastArrowPointer || ARROWS[0].pointer); } }} /> { setArrowOpen(false); setPointer(pointer); setLastArrowPointer(pointer); }} > ); } if (button.key === 'extra-tools') { return ; } return ( { if (button.pointer && !isBasicPointer(button.pointer)) { onPointerDown(button.pointer); } }} onPointerUp={() => { if (button.pointer && !isBasicPointer(button.pointer)) { onPointerUp(); } else if (button.pointer && isBasicPointer(button.pointer)) { BoardTransforms.updatePointerType(board, button.pointer); setPointer(button.pointer); } if (button.key === 'image') { addImage(board); } }} /> ); })} ); }; ================================================ FILE: packages/drawnix/src/components/toolbar/extra-tools/extra-tools-button.tsx ================================================ import { useBoard } from "@plait-board/react-board"; import { Popover, PopoverContent, PopoverTrigger } from "../../popover/popover"; import { PlaitBoard } from "@plait/core"; import { useState } from "react"; import { ToolButton } from "../../tool-button"; import { ExtraToolsIcon } from "../../icons"; import Menu from "../../menu/menu"; import { MarkdownToDrawnixItem, MermaidToDrawnixItem } from "./menu-items"; import { useI18n } from "../../../i18n"; export const ExtraToolsButton = () => { const board = useBoard(); const { t } = useI18n(); const container = PlaitBoard.getBoardContainer(board); const [appMenuOpen, setAppMenuOpen] = useState(false); return ( { setAppMenuOpen(open); }} placement="bottom-start" > { setAppMenuOpen(!appMenuOpen); }} /> { setAppMenuOpen(false); }} > ); }; ================================================ FILE: packages/drawnix/src/components/toolbar/extra-tools/menu-items.tsx ================================================ import MenuItem from '../../menu/menu-item'; import { MarkdownLogoIcon, MermaidLogoIcon } from '../../icons'; import { DialogType, useDrawnix } from '../../../hooks/use-drawnix'; import { useI18n } from '../../../i18n'; export const MermaidToDrawnixItem = () => { const { appState, setAppState } = useDrawnix(); const { t } = useI18n(); return ( { setAppState({ ...appState, openDialogType: DialogType.mermaidToDrawnix, }); }} icon={MermaidLogoIcon} aria-label={t('extraTools.mermaidToDrawnix')} > {t('extraTools.mermaidToDrawnix')} ); }; MermaidToDrawnixItem.displayName = 'MermaidToDrawnix'; export const MarkdownToDrawnixItem = () => { const { appState, setAppState } = useDrawnix(); const { t } = useI18n(); return ( { setAppState({ ...appState, openDialogType: DialogType.markdownToDrawnix, }); }} icon={MarkdownLogoIcon} aria-label={t('extraTools.markdownToDrawnix')} > {t('extraTools.markdownToDrawnix')} ); }; MarkdownToDrawnixItem.displayName = 'MarkdownToDrawnix'; ================================================ FILE: packages/drawnix/src/components/toolbar/freehand-panel/freehand-panel.tsx ================================================ import classNames from 'classnames'; import { Island } from '../../island'; import Stack from '../../stack'; import { ToolButton } from '../../tool-button'; import { EraseIcon, FeltTipPenIcon, } from '../../icons'; import { BoardTransforms } from '@plait/core'; import React from 'react'; import { BoardCreationMode, setCreationMode } from '@plait/common'; import { FreehandShape } from '../../../plugins/freehand/type'; import { useBoard } from '@plait-board/react-board'; import { splitRows } from '../../../utils/common'; import { DrawnixPointerType, } from '../../../hooks/use-drawnix'; import { useI18n } from '../../../i18n'; export interface FreehandProps { titleKey: string; icon: React.ReactNode; pointer: DrawnixPointerType; } export const FREEHANDS: FreehandProps[] = [ { icon: FeltTipPenIcon, pointer: FreehandShape.feltTipPen, titleKey: 'toolbar.pen', }, { icon: EraseIcon, pointer: FreehandShape.eraser, titleKey: 'toolbar.eraser', }, ]; const ROW_FREEHANDS = splitRows(FREEHANDS, 5); export type FreehandPickerProps = { onPointerUp: (pointer: DrawnixPointerType) => void; }; export const FreehandPanel: React.FC = ({ onPointerUp, }) => { const { t } = useI18n(); const board = useBoard(); return ( {ROW_FREEHANDS.map((rowFreehands, rowIndex) => { return ( {rowFreehands.map((freehand, index) => { return ( { setCreationMode(board, BoardCreationMode.dnd); BoardTransforms.updatePointerType(board, freehand.pointer); }} onPointerUp={() => { setCreationMode(board, BoardCreationMode.drawing); onPointerUp(freehand.pointer); }} /> ); })} ); })} ); }; ================================================ FILE: packages/drawnix/src/components/toolbar/pencil-mode-toolbar.tsx ================================================ import { ToolButton } from '../tool-button'; import { useBoard } from '@plait-board/react-board'; import { useDrawnix } from '../../hooks/use-drawnix'; import { setIsPencilMode } from '../../plugins/with-pencil'; export const ClosePencilToolbar = () => { const board = useBoard(); const { appState, setAppState } = useDrawnix(); return ( <> {appState.isPencilMode && (
{ setAppState({ ...appState, isPencilMode: false }); setIsPencilMode(board, false); }} />
)} ); }; ================================================ FILE: packages/drawnix/src/components/toolbar/popup-toolbar/arrow-mark-button.tsx ================================================ import React, { useState } from 'react'; import { ToolButton } from '../../tool-button'; import classNames from 'classnames'; import { PlaitBoard } from '@plait/core'; import { ArrowIcon, LineIcon } from '../../icons'; import { Popover, PopoverContent, PopoverTrigger } from '../../popover/popover'; import { ArrowLineHandle } from '@plait/draw'; import { ArrowMarkerPicker } from '../../arrow-mark-picker'; import { useI18n } from '../../../i18n'; import type { Translations } from '../../../i18n'; export type ArrowMarkButtonProps = { board: PlaitBoard; endProperty?: ArrowLineHandle; children?: React.ReactNode; end: 'source' | 'target'; }; export const ArrowMarkButton: React.FC = ({ board, end, endProperty, }) => { const [isPopoverOpen, setIsPopoverrOpen] = useState(false); const container = PlaitBoard.getBoardContainer(board); const { t } = useI18n(); if (!endProperty) { return null; } const marker = endProperty.marker ?? 'none'; const endLabelKey: keyof Translations = end === 'source' ? 'line.source' : 'line.target'; const markerLabelKey: keyof Translations = marker === 'none' ? 'line.none' : 'line.arrow'; const title = `${t(endLabelKey)} — ${t(markerLabelKey)}`; return ( { setIsPopoverrOpen(open); }} placement={'top'} > { setIsPopoverrOpen(!isPopoverOpen); }} > ); }; ================================================ FILE: packages/drawnix/src/components/toolbar/popup-toolbar/fill-button.tsx ================================================ import React, { useState } from 'react'; import { ToolButton } from '../../tool-button'; import classNames from 'classnames'; import { ATTACHED_ELEMENT_CLASS_NAME, PlaitBoard } from '@plait/core'; import { Island } from '../../island'; import { ColorPicker } from '../../color-picker'; import { hexAlphaToOpacity, isFullyTransparent, removeHexAlpha, } from '../../../utils/color'; import { BackgroundColorIcon } from '../../icons'; import { Popover, PopoverContent, PopoverTrigger } from '../../popover/popover'; import { setFillColor, setFillColorOpacity, } from '../../../transforms/property'; export type PopupFillButtonProps = { board: PlaitBoard; currentColor: string | undefined; title: string; children?: React.ReactNode; }; export const PopupFillButton: React.FC = ({ board, currentColor, title, children, }) => { const [isFillPropertyOpen, setIsFillPropertyOpen] = useState(false); const hexColor = currentColor && removeHexAlpha(currentColor); const opacity = currentColor ? hexAlphaToOpacity(currentColor) : 100; const container = PlaitBoard.getBoardContainer(board); const icon = !hexColor || isFullyTransparent(opacity) ? BackgroundColorIcon : undefined; return ( { setIsFillPropertyOpen(open); }} placement={'top'} > { setIsFillPropertyOpen(!isFillPropertyOpen); }} > {!icon && children} { setFillColor(board, selectedColor); }} onOpacityChange={(opacity: number) => { setFillColorOpacity(board, opacity); }} currentColor={currentColor} > ); }; ================================================ FILE: packages/drawnix/src/components/toolbar/popup-toolbar/font-color-button.tsx ================================================ import React, { ReactNode, useState } from 'react'; import { ColorPicker } from '../../color-picker'; import { ToolButton } from '../../tool-button'; import classNames from 'classnames'; import { Island } from '../../island'; import { ATTACHED_ELEMENT_CLASS_NAME, PlaitBoard } from '@plait/core'; import { Popover, PopoverContent, PopoverTrigger } from '../../popover/popover'; import { setTextColor, setTextColorOpacity, } from '../../../transforms/property'; export type PopupFontColorButtonProps = { board: PlaitBoard; currentColor: string | undefined; fontColorIcon: ReactNode; title: string; }; export const PopupFontColorButton: React.FC = ({ board, currentColor, fontColorIcon, title, }) => { const [isFontColorPropertyOpen, setIsFontColorPropertyOpen] = useState(false); const container = PlaitBoard.getBoardContainer(board); return ( { setIsFontColorPropertyOpen(open); }} placement={'top'} > { setIsFontColorPropertyOpen(!isFontColorPropertyOpen); }} > { setTextColor( board, currentColor ? currentColor : selectedColor, selectedColor ); }} onOpacityChange={(opacity: number) => { if (currentColor) { setTextColorOpacity(board, currentColor, opacity); } }} currentColor={currentColor} > ); }; ================================================ FILE: packages/drawnix/src/components/toolbar/popup-toolbar/font-size-control.tsx ================================================ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { PlaitBoard } from '@plait/core'; import { setTextFontSize } from '../../../transforms/property'; import { FontSizeStepperDownIcon, FontSizeStepperUpIcon } from '../../icons'; import { Select } from '../../select/select'; import { DEFAULT_FONT_SIZE } from '@plait/text-plugins'; export type PopupFontSizeControlProps = { board: PlaitBoard; currentFontSize?: number; title: string; options?: number[]; }; const DEFAULT_OPTIONS = [10, 12, 14, 18, 24, 36, 48]; const MIN_FONT_SIZE = 8; const MAX_FONT_SIZE = 78; export const PopupFontSizeControl: React.FC = ({ board, currentFontSize, title, options = DEFAULT_OPTIONS, }) => { const [open, setOpen] = useState(false); const inputRef = useRef(null); const normalizedCurrent = useMemo(() => { return Number.isFinite(currentFontSize as number) && (currentFontSize as number) > 0 ? (currentFontSize as number) : undefined; }, [currentFontSize]); const [draft, setDraft] = useState( normalizedCurrent ? String(normalizedCurrent) : String(DEFAULT_FONT_SIZE) ); useEffect(() => { setDraft(normalizedCurrent ? String(normalizedCurrent) : String(DEFAULT_FONT_SIZE)); }, [normalizedCurrent]); const apply = (value: string) => { if (!value) { setDraft(''); return; } const next = Number(value); if (!Number.isFinite(next)) { return; } const clamped = Math.min( MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(next)) ); const nextValue = String(clamped); setDraft(nextValue); setTextFontSize(board, clamped); }; const getBaseValue = () => { const parsed = Number(draft); if (Number.isFinite(parsed)) { return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(parsed))); } if (typeof normalizedCurrent === 'number' && normalizedCurrent > 0) { return Math.min( MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(normalizedCurrent)) ); } return DEFAULT_FONT_SIZE; }; const stepBy = (delta: number) => { const base = getBaseValue(); const next = Math.min( MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(base + delta)) ); const value = String(next); setDraft(value); apply(value); }; const container = PlaitBoard.getBoardContainer(board); return (
{ event.stopPropagation(); }} onPointerUp={(event) => { event.stopPropagation(); }} > setDraft(event.target.value)} onBlur={(event) => apply(event.target.value)} onPointerUp={(event) => { event.stopPropagation(); setOpen(true); }} onKeyDown={(event) => { if (event.key === 'Enter') { apply(draft); } }} />
{ event.preventDefault(); event.stopPropagation(); }} onPointerUp={(event) => { event.stopPropagation(); }} > {options.map((size) => { const value = String(size); return ( { setDraft(value); apply(value); }} > {size} ); })}
); }; ================================================ FILE: packages/drawnix/src/components/toolbar/popup-toolbar/link-button.tsx ================================================ import React from 'react'; import { ToolButton } from '../../tool-button'; import classNames from 'classnames'; import { useI18n } from '../../../i18n'; import { getSelectedElements, PlaitBoard } from '@plait/core'; import { LinkIcon } from '../../icons'; import { useDrawnix } from '../../../hooks/use-drawnix'; import { getFirstTextEditor, LinkElement } from '@plait/common'; import { ReactEditor } from 'slate-react'; import { LinkEditor } from '@plait/text-plugins'; export type PopupLinkButtonProps = { board: PlaitBoard; title: string; }; export const PopupLinkButton: React.FC = ({ board, title, }) => { const { t } = useI18n(); const { appState, setAppState } = useDrawnix(); return ( { const pbElement = getSelectedElements(board)[0]; const editor = getFirstTextEditor(pbElement); const linkElementEntry = LinkEditor.getLinkElement(editor); if (!linkElementEntry) { LinkEditor.wrapLink(editor, t('textPlaceholders.link'), ''); } setTimeout(() => { const linkElementEntry = LinkEditor.getLinkElement(editor); const linkElement = linkElementEntry[0] as LinkElement; const targetDom = ReactEditor.toDOMNode(editor, linkElement); setAppState({ ...appState, linkState: { editor, targetDom: targetDom, targetElement: linkElement, isEditing: true, isHovering: false, isHoveringOrigin: false, }, }); }, 0); }} > ); }; ================================================ FILE: packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.scss ================================================ .popup-toolbar { .popup-font-size { height: var(--lg-button-size); display: flex; align-items: center; gap: 2px; padding: 0 4px 0 4px; border-radius: var(--border-radius-sm); background-color: var(--color-surface-secondary-container); color: var(--color-on-surface); &:not([data-disable-hover]):hover { background-color: var(--color-surface-primary-container); } .popup-font-size__input { width: 36px; height: 100%; border: none; outline: none; padding: 0; background: transparent; color: inherit; font-size: 14px; text-align: center; appearance: textfield; -moz-appearance: textfield; &::-webkit-outer-spin-button, &::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } } .popup-font-size__stepper { height: 100%; width: 16px; display: flex; flex-direction: column; align-items: stretch; justify-content: stretch; } .popup-font-size__stepper-button { flex: 1; border: 0; outline: none; padding: 0; background: transparent; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; } .popup-font-size__stepper-icon { width: 12px; height: 12px; display: block; } .popup-font-size__stepper-button:first-child .popup-font-size__stepper-icon { transform: translateY(0.5px); } .popup-font-size__stepper-button:last-child .popup-font-size__stepper-icon { transform: translateY(-0.5px); } } .property-button { height: var(--lg-button-size); width: var(--lg-button-size); .color-label { cursor: pointer; } .fill-label { display: inline-block; width: var(--popup-label-size); height: var(--popup-label-size); border-radius: 50%; &.color-white { border: 1px solid var(--color-gray-30); } } .stroke-label { border-radius: 50%; width: calc(var(--popup-label-size) - var(--border-radius-lg)); height: calc(var(--popup-label-size) - var(--border-radius-lg)); border-width: var(--border-radius-sm); border-style: solid; } .tool-icon__icon { svg { width: var(--xlg-icon-size); height: var(--xlg-icon-size); } } } } .stroke-setting { &.has-stroke-style { padding-top: 8px !important; } .stroke-style-picker { justify-content: space-between; padding: 0 8px; } } .source-arrow-island .property-button ,.source-arrow-button{ transform: rotateY(180deg); } ================================================ FILE: packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx ================================================ import Stack from '../../stack'; import { FontColorIcon } from '../../icons'; import { ATTACHED_ELEMENT_CLASS_NAME, getRectangleByElements, getSelectedElements, isDragging, isMovingElements, isSelectionMoving, PlaitBoard, PlaitElement, RectangleClient, toHostPointFromViewBoxPoint, toScreenPointFromHostPoint, } from '@plait/core'; import { useEffect, useRef, useState } from 'react'; import { useBoard } from '@plait-board/react-board'; import { flip, offset, useFloating } from '@floating-ui/react'; import { Island } from '../../island'; import classNames from 'classnames'; import { useI18n } from '../../../i18n'; import { getStrokeColorByElement as getStrokeColorByMindElement, MindElement, } from '@plait/mind'; import './popup-toolbar.scss'; import { ArrowLineHandle, getStrokeColorByElement as getStrokeColorByDrawElement, getStrokeStyleByElement, isClosedCustomGeometry, isClosedDrawElement, isDrawElementsIncludeText, PlaitDrawElement, } from '@plait/draw'; import { CustomText, StrokeStyle } from '@plait/common'; import { getTextMarksByElement } from '@plait/text-plugins'; import { PopupFontColorButton } from './font-color-button'; import { PopupFontSizeControl } from './font-size-control'; import { PopupStrokeButton } from './stroke-button'; import { PopupFillButton } from './fill-button'; import { isWhite, removeHexAlpha } from '../../../utils/color'; import { NO_COLOR } from '../../../constants/color'; import { Freehand } from '../../../plugins/freehand/type'; import { PopupLinkButton } from './link-button'; import { ArrowMarkButton } from './arrow-mark-button'; export const PopupToolbar = () => { const board = useBoard(); const { t } = useI18n(); const selectedElements = getSelectedElements(board); const [movingOrDragging, setMovingOrDragging] = useState(false); const movingOrDraggingRef = useRef(movingOrDragging); const open = selectedElements.length > 0 && !isSelectionMoving(board) && !selectedElements.some(PlaitDrawElement.isImage); const { viewport, selection, children } = board; const { refs, floatingStyles } = useFloating({ placement: 'right-start', middleware: [offset(32), flip()], }); let state: { fill: string | undefined; strokeColor?: string; strokeStyle?: StrokeStyle; hasFill?: boolean; hasText?: boolean; fontColor?: string; hasFontColor?: boolean; hasStroke?: boolean; hasStrokeStyle?: boolean; marks?: Omit; // Line state isLine?: boolean; source?: ArrowLineHandle; target?: ArrowLineHandle; } = { fill: 'red', }; if (open && !movingOrDragging) { const hasFill = selectedElements.some((value) => hasFillProperty(board, value)) && !PlaitBoard.hasBeenTextEditing(board); const hasText = selectedElements.some((value) => hasTextProperty(board, value) ); const hasStroke = selectedElements.some((value) => hasStrokeProperty(board, value)) && !PlaitBoard.hasBeenTextEditing(board); const hasStrokeStyle = selectedElements.some((value) => hasStrokeStyleProperty(board, value)) && !PlaitBoard.hasBeenTextEditing(board); const isLine = selectedElements.every((value) => PlaitDrawElement.isArrowLine(value) ); state = { ...getElementState(board), hasFill, hasFontColor: hasText, hasStroke, hasStrokeStyle, hasText, isLine, }; } useEffect(() => { if (open) { const hasSelected = selectedElements.length > 0; if (!movingOrDragging && hasSelected) { const elements = getSelectedElements(board); const rectangle = getRectangleByElements(board, elements, false); const [start, end] = RectangleClient.getPoints(rectangle); const screenStart = toScreenPointFromHostPoint( board, toHostPointFromViewBoxPoint(board, start) ); const screenEnd = toScreenPointFromHostPoint( board, toHostPointFromViewBoxPoint(board, end) ); const width = screenEnd[0] - screenStart[0]; const height = screenEnd[1] - screenStart[1]; refs.setPositionReference({ getBoundingClientRect() { return { width, height, x: screenStart[0], y: screenStart[1], top: screenStart[1], left: screenStart[0], right: screenStart[0] + width, bottom: screenStart[1] + height, }; }, }); } } }, [viewport, selection, children, movingOrDragging]); useEffect(() => { movingOrDraggingRef.current = movingOrDragging; }, [movingOrDragging]); useEffect(() => { const { pointerUp, pointerMove } = board; board.pointerMove = (event: PointerEvent) => { if ( (isMovingElements(board) || isDragging(board)) && !movingOrDraggingRef.current ) { setMovingOrDragging(true); } pointerMove(event); }; board.pointerUp = (event: PointerEvent) => { if ( movingOrDraggingRef.current && (isMovingElements(board) || isDragging(board)) ) { setMovingOrDragging(false); } pointerUp(event); }; return () => { board.pointerUp = pointerUp; board.pointerMove = pointerMove; }; }, [board]); return ( <> {open && !movingOrDragging && ( {state.hasText && ( )} {state.hasFontColor && ( } > )} {state.hasStroke && ( )} {state.hasFill && ( )} {state.hasText && ( )} {state.isLine && ( <> )} )} ); }; export const getMindElementState = ( board: PlaitBoard, element: MindElement ) => { const marks = getTextMarksByElement(element); return { fill: element.fill, strokeColor: getStrokeColorByMindElement(board, element), strokeStyle: getStrokeStyleByElement(board, element), marks, }; }; export const getDrawElementState = ( board: PlaitBoard, element: PlaitDrawElement ) => { const marks: Omit = getTextMarksByElement(element); return { fill: element.fill, strokeColor: getStrokeColorByDrawElement(board, element), strokeStyle: getStrokeStyleByElement(board, element), marks, source: element?.source || {}, target: element?.target || {}, }; }; export const getElementState = (board: PlaitBoard) => { const selectedElement = getSelectedElements(board)[0]; if (MindElement.isMindElement(board, selectedElement)) { return getMindElementState(board, selectedElement); } return getDrawElementState(board, selectedElement as PlaitDrawElement); }; export const hasFillProperty = (board: PlaitBoard, element: PlaitElement) => { if (MindElement.isMindElement(board, element)) { return true; } if (isClosedCustomGeometry(board, element)) { return true; } if (PlaitDrawElement.isDrawElement(element)) { return ( PlaitDrawElement.isShapeElement(element) && !PlaitDrawElement.isImage(element) && !PlaitDrawElement.isText(element) && isClosedDrawElement(element) ); } return false; }; export const hasStrokeProperty = (board: PlaitBoard, element: PlaitElement) => { if (MindElement.isMindElement(board, element)) { return true; } if (Freehand.isFreehand(element)) { return true; } if (PlaitDrawElement.isDrawElement(element)) { return ( (PlaitDrawElement.isShapeElement(element) && !PlaitDrawElement.isImage(element) && !PlaitDrawElement.isText(element)) || PlaitDrawElement.isArrowLine(element) || PlaitDrawElement.isVectorLine(element) || PlaitDrawElement.isTable(element) ); } return false; }; export const hasStrokeStyleProperty = ( board: PlaitBoard, element: PlaitElement ) => { return hasStrokeProperty(board, element); }; export const hasTextProperty = (board: PlaitBoard, element: PlaitElement) => { if (MindElement.isMindElement(board, element)) { return true; } if (PlaitDrawElement.isDrawElement(element)) { return isDrawElementsIncludeText([element]); } return false; }; export const getColorPropertyValue = (color: string) => { if (color === NO_COLOR) { return null; } else { return color; } }; const getFontSizeFromMarks = (marks?: Omit) => { const value = (marks as any)?.['font-size']; const size = typeof value === 'number' ? value : Number(value); return Number.isFinite(size) && size > 0 ? size : undefined; }; ================================================ FILE: packages/drawnix/src/components/toolbar/popup-toolbar/stroke-button.tsx ================================================ import React, { useState } from 'react'; import { ToolButton } from '../../tool-button'; import classNames from 'classnames'; import { ATTACHED_ELEMENT_CLASS_NAME, PlaitBoard } from '@plait/core'; import { Island } from '../../island'; import { ColorPicker } from '../../color-picker'; import { hexAlphaToOpacity, isFullyTransparent, isWhite, removeHexAlpha, } from '../../../utils/color'; import { StrokeIcon, StrokeStyleDashedIcon, StrokeStyleDotedIcon, StrokeStyleNormalIcon, StrokeWhiteIcon, } from '../../icons'; import { Popover, PopoverContent, PopoverTrigger } from '../../popover/popover'; import Stack from '../../stack'; import { PropertyTransforms, StrokeStyle } from '@plait/common'; import { getMemorizeKey } from '@plait/draw'; import { setStrokeColor, setStrokeColorOpacity, } from '../../../transforms/property'; import { useI18n } from '../../../i18n'; export type PopupStrokeButtonProps = { board: PlaitBoard; currentColor: string | undefined; currentStyle?: StrokeStyle; title: string; hasStrokeStyle: boolean; children?: React.ReactNode; }; export const PopupStrokeButton: React.FC = ({ board, currentColor, currentStyle, title, hasStrokeStyle, children, }) => { const [isStrokePropertyOpen, setIsStrokePropertyOpen] = useState(false); const hexColor = currentColor && removeHexAlpha(currentColor); const opacity = currentColor ? hexAlphaToOpacity(currentColor) : 100; const container = PlaitBoard.getBoardContainer(board); const { t } = useI18n(); const icon = isFullyTransparent(opacity) ? StrokeIcon : isWhite(hexColor) ? StrokeWhiteIcon : undefined; const setStrokeStyle = (style: StrokeStyle) => { PropertyTransforms.setStrokeStyle(board, style, { getMemorizeKey }); }; return ( { setIsStrokePropertyOpen(open); }} placement={'top'} > { setIsStrokePropertyOpen(!isStrokePropertyOpen); }} > {!icon && children} {hasStrokeStyle && ( { setStrokeStyle(StrokeStyle.solid); }} > { setStrokeStyle(StrokeStyle.dashed); }} > { setStrokeStyle(StrokeStyle.dotted); }} > )} { setStrokeColor(board, selectedColor); }} onOpacityChange={(opacity: number) => { setStrokeColorOpacity(board, opacity); }} currentColor={currentColor} > ); }; ================================================ FILE: packages/drawnix/src/components/toolbar/theme-toolbar.tsx ================================================ import { useBoard } from '@plait-board/react-board'; import classNames from 'classnames'; import { ATTACHED_ELEMENT_CLASS_NAME, BoardTransforms, ThemeColorMode, } from '@plait/core'; import { Island } from '../island'; import { useI18n } from '../../i18n'; export const ThemeToolbar = () => { const board = useBoard(); const { t } = useI18n(); const theme = board.theme; return ( ); }; ================================================ FILE: packages/drawnix/src/components/toolbar/zoom-toolbar.tsx ================================================ import { useBoard } from '@plait-board/react-board'; import Stack from '../stack'; import { ToolButton } from '../tool-button'; import { ZoomInIcon, ZoomOutIcon } from '../icons'; import classNames from 'classnames'; import { ATTACHED_ELEMENT_CLASS_NAME, BoardTransforms, PlaitBoard, } from '@plait/core'; import { Island } from '../island'; import { Popover, PopoverContent, PopoverTrigger } from '../popover/popover'; import { useState } from 'react'; import Menu from '../menu/menu'; import MenuItem from '../menu/menu-item'; import { useI18n } from '../../i18n'; export const ZoomToolbar = () => { const board = useBoard(); const { t } = useI18n(); const container = PlaitBoard.getBoardContainer(board); const [zoomMenuOpen, setZoomMenuOpen] = useState(false); return ( { BoardTransforms.updateZoom(board, board.viewport.zoom - 0.1); }} className="zoom-out-button" /> { setZoomMenuOpen(open); }} placement="bottom-end" >
{ setZoomMenuOpen(!zoomMenuOpen); }} > {Number(((board?.viewport?.zoom || 1) * 100).toFixed(0))}%
{ setZoomMenuOpen(false); }} > { BoardTransforms.fitViewport(board); }} aria-label={t('zoom.fit')} shortcut={`Cmd+Shift+=`} >{t('zoom.fit')} { BoardTransforms.updateZoom(board, 1); }} aria-label={t('zoom.100')} shortcut={`Cmd+0`} >{t('zoom.100')}
{ BoardTransforms.updateZoom(board, board.viewport.zoom + 0.1); }} className="zoom-in-button" />
); }; ================================================ FILE: packages/drawnix/src/components/ttd-dialog/markdown-to-drawnix.tsx ================================================ import { useState, useEffect, useDeferredValue } from 'react'; import './mermaid-to-drawnix.scss'; import './ttd-dialog.scss'; import { TTDDialogPanels } from './ttd-dialog-panels'; import { TTDDialogPanel } from './ttd-dialog-panel'; import { TTDDialogInput } from './ttd-dialog-input'; import { TTDDialogOutput } from './ttd-dialog-output'; import { TTDDialogSubmitShortcut } from './ttd-dialog-submit-shortcut'; import { useDrawnix } from '../../hooks/use-drawnix'; import { useI18n } from '../../i18n'; import { useBoard } from '@plait-board/react-board'; import { getViewportOrigination, PlaitBoard, PlaitElement, WritableClipboardOperationType, } from '@plait/core'; import { MindElement } from '@plait/mind'; export interface MarkdownToDrawnixLibProps { loaded: boolean; api: Promise<{ parseMarkdownToDrawnix: ( definition: string, mainTopic?: string ) => MindElement; }>; } const MarkdownToDrawnix = () => { const { appState, setAppState } = useDrawnix(); const { t, language } = useI18n(); const [markdownToDrawnixLib, setMarkdownToDrawnixLib] = useState({ loaded: false, api: Promise.resolve({ parseMarkdownToDrawnix: (definition: string, mainTopic?: string) => null as any as MindElement, }), }); useEffect(() => { const loadLib = async () => { try { const module = await import('@plait-board/markdown-to-drawnix'); setMarkdownToDrawnixLib({ loaded: true, api: Promise.resolve(module), }); } catch (err) { console.error('Failed to load mermaid library:', err); setError(new Error(t('dialog.error.loadMermaid'))); } }; loadLib(); }, []); const [text, setText] = useState(() => t('markdown.example')); const [value, setValue] = useState(() => []); const deferredText = useDeferredValue(text.trim()); const [error, setError] = useState(null); const board = useBoard(); // Update markdown example when language changes useEffect(() => { setText(t('markdown.example')); }, [language]); useEffect(() => { const convertMarkdown = async () => { try { const api = await markdownToDrawnixLib.api; let ret; try { ret = await api.parseMarkdownToDrawnix(deferredText); } catch (err: any) { ret = await api.parseMarkdownToDrawnix( deferredText.replace(/"/g, "'") ); } const mind = ret; mind.points = [[0, 0]]; if (mind) { setValue([mind]); setError(null); } } catch (err: any) { setError(err); } }; convertMarkdown(); }, [deferredText, markdownToDrawnixLib]); const insertToBoard = () => { if (!value.length) { return; } const boardContainerRect = PlaitBoard.getBoardContainer(board).getBoundingClientRect(); const focusPoint = [ boardContainerRect.width / 4, boardContainerRect.height / 2 - 20, ]; const zoom = board.viewport.zoom; const origination = getViewportOrigination(board); const focusX = origination![0] + focusPoint[0] / zoom; const focusY = origination![1] + focusPoint[1] / zoom; const elements = value; board.insertFragment( { elements: JSON.parse(JSON.stringify(elements)), }, [focusX, focusY], WritableClipboardOperationType.paste ); setAppState({ ...appState, openDialogType: null }); }; return ( <>
{t('dialog.markdown.description')}
setText(event.target.value)} onKeyboardSubmit={() => { insertToBoard(); }} /> { insertToBoard(); }, label: t('dialog.markdown.insert'), }} renderSubmitShortcut={() => } > ); }; export default MarkdownToDrawnix; ================================================ FILE: packages/drawnix/src/components/ttd-dialog/mermaid-to-drawnix.scss ================================================ .drawnix { .dialog-mermaid { &-title { margin-block: 0.25rem; font-size: 1.25rem; font-weight: 700; padding-inline: 2.5rem; } } } ================================================ FILE: packages/drawnix/src/components/ttd-dialog/mermaid-to-drawnix.tsx ================================================ import { useState, useEffect, useDeferredValue } from 'react'; import './mermaid-to-drawnix.scss'; import './ttd-dialog.scss'; import { TTDDialogPanels } from './ttd-dialog-panels'; import { TTDDialogPanel } from './ttd-dialog-panel'; import { TTDDialogInput } from './ttd-dialog-input'; import { TTDDialogOutput } from './ttd-dialog-output'; import { TTDDialogSubmitShortcut } from './ttd-dialog-submit-shortcut'; import { useDrawnix } from '../../hooks/use-drawnix'; import { useI18n } from '../../i18n'; import { useBoard } from '@plait-board/react-board'; import { getViewportOrigination, PlaitBoard, PlaitElement, PlaitGroupElement, Point, RectangleClient, WritableClipboardOperationType, } from '@plait/core'; import type { MermaidConfig } from '@plait-board/mermaid-to-drawnix/dist'; import type { MermaidToDrawnixResult } from '@plait-board/mermaid-to-drawnix/dist/interfaces'; export interface MermaidToDrawnixLibProps { loaded: boolean; api: Promise<{ parseMermaidToDrawnix: ( definition: string, config?: MermaidConfig ) => Promise; }>; } const MERMAID_EXAMPLE = 'flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]'; const MermaidToDrawnix = () => { const { appState, setAppState } = useDrawnix(); const { t, language } = useI18n(); const [mermaidToDrawnixLib, setMermaidToDrawnixLib] = useState({ loaded: false, api: Promise.resolve({ parseMermaidToDrawnix: async () => ({ elements: [] }), }), }); useEffect(() => { const loadLib = async () => { try { const module = await import('@plait-board/mermaid-to-drawnix'); setMermaidToDrawnixLib({ loaded: true, api: Promise.resolve(module), }); } catch (err) { console.error('Failed to load mermaid library:', err); setError(new Error(t('dialog.error.loadMermaid'))); } }; loadLib(); }, []); const [text, setText] = useState(() => MERMAID_EXAMPLE); const [value, setValue] = useState(() => []); const deferredText = useDeferredValue(text.trim()); const [error, setError] = useState(null); const board = useBoard(); useEffect(() => { const convertMermaid = async () => { try { const api = await mermaidToDrawnixLib.api; let ret; try { ret = await api.parseMermaidToDrawnix(deferredText); } catch (err: any) { ret = await api.parseMermaidToDrawnix( deferredText.replace(/"/g, "'") ); } const { elements } = ret; setValue(elements); setError(null); } catch (err: any) { setError(err); } }; convertMermaid(); }, [deferredText, mermaidToDrawnixLib]); const insertToBoard = () => { if (!value.length) { return; } const boardContainerRect = PlaitBoard.getBoardContainer(board).getBoundingClientRect(); const focusPoint = [ boardContainerRect.width / 2, boardContainerRect.height / 2, ]; const zoom = board.viewport.zoom; const origination = getViewportOrigination(board); const centerX = origination![0] + focusPoint[0] / zoom; const centerY = origination![1] + focusPoint[1] / zoom; const elements = value; const elementRectangle = RectangleClient.getBoundingRectangle( elements .filter((ele) => !PlaitGroupElement.isGroup(ele)) .map((ele) => RectangleClient.getRectangleByPoints(ele.points as Point[]) ) ); const startPoint = [ centerX - elementRectangle.width / 2, centerY - elementRectangle.height / 2, ] as Point; board.insertFragment( { elements: JSON.parse(JSON.stringify(elements)), }, startPoint, WritableClipboardOperationType.paste ); setAppState({ ...appState, openDialogType: null }); }; return ( <>
{language === 'zh' ? ( <> {t('dialog.mermaid.description')} {' '} {t('dialog.mermaid.flowchart')} {t('dialog.mermaid.sequence')} {' '} 和 {' '} {t('dialog.mermaid.class')} {t('dialog.mermaid.otherTypes')} ) : ( <> {t('dialog.mermaid.description')} {' '} {t('dialog.mermaid.flowchart')} ,{' '} {t('dialog.mermaid.sequence')} ,{' '} {t('dialog.mermaid.class')} {t('dialog.mermaid.otherTypes')} )}
setText(event.target.value)} onKeyboardSubmit={() => { insertToBoard(); }} /> { insertToBoard(); }, label: t('dialog.mermaid.insert'), }} renderSubmitShortcut={() => } > ); }; export default MermaidToDrawnix; ================================================ FILE: packages/drawnix/src/components/ttd-dialog/ttd-dialog-input.tsx ================================================ import type { ChangeEventHandler } from "react"; import { useEffect, useRef } from "react"; import { EVENT } from "../../constants"; import { KEYS } from "../../keys"; interface TTDDialogInputProps { input: string; placeholder: string; onChange: ChangeEventHandler; onKeyboardSubmit?: () => void; } export const TTDDialogInput = ({ input, placeholder, onChange, onKeyboardSubmit, }: TTDDialogInputProps) => { const ref = useRef(null); const callbackRef = useRef(onKeyboardSubmit); callbackRef.current = onKeyboardSubmit; useEffect(() => { if (!callbackRef.current) { return; } const textarea = ref.current; if (textarea) { const handleKeyDown = (event: KeyboardEvent) => { if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.ENTER) { event.preventDefault(); callbackRef.current?.(); } }; textarea.addEventListener(EVENT.KEYDOWN, handleKeyDown); return () => { textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown); }; } }, []); return (