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
================================================
开源白板工具(SaaS),一体化白板,包含思维导图、流程图、自由画等
[*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
- 贡献代码
## 感谢支持
特别感谢公司对开源项目的大力支持,也感谢为本项目贡献代码、提供建议的朋友。
## License
[MIT License](https://github.com/plait-board/drawnix/blob/master/LICENSE)
================================================
FILE: README_en.md
================================================
Open-source whiteboard tool (SaaS), an all-in-one collaborative canvas that includes mind mapping, flowcharts, freehand and more.
[*中文*](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.
## 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')}
{
setAppState({ ...appState, openCleanConfirm: false });
}}
>
{t('cleanConfirm.cancel')}
{
board.deleteFragment(board.children);
setAppState({ ...appState, openCleanConfirm: false });
}}
>
{t('cleanConfirm.ok')}
);
};
================================================
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 (
{
setSelectedColor(color.value);
if (color.value === NO_COLOR) {
setOpacity(100);
}
onColorChange(color.value);
}}
title={t((color.name || 'color.unknown') as keyof Translations)}
aria-label={t((color.name || 'color.unknown') as keyof Translations)}
>
{isNoColor(color.value) && NoColorIcon}
{selectedColor === color.value && Check}
);
})}
))}
);
});
================================================
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 (
{children}
);
});
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 (
setOpen(false)} />
);
});
================================================
FILE: packages/drawnix/src/components/icons.tsx
================================================
import React from 'react';
export const createIcon = (svg: React.ReactNode) => {
return svg;
};
export const HandIcon = createIcon(
);
export const SelectionIcon = createIcon(
);
export const MindIcon = createIcon(
);
export const ShapeIcon = createIcon(
);
export const TextIcon = createIcon(
);
export const EraseIcon = createIcon(
);
export const StraightArrowLineIcon = createIcon(
);
export const RectangleIcon = createIcon(
);
export const TerminalIcon = createIcon(
);
export const EllipseIcon = createIcon(
);
export const TriangleIcon = createIcon(
);
export const DiamondIcon = createIcon(
);
export const ParallelogramIcon = createIcon(
);
export const RoundRectangleIcon = createIcon(
);
export const StraightArrowIcon = createIcon(
);
export const ElbowArrowIcon = createIcon(
);
export const CurveArrowIcon = createIcon(
);
export const MenuIcon = createIcon(
);
export const GithubIcon = createIcon(
);
export const ExportImageIcon = createIcon(
);
export const ZoomOutIcon = createIcon(
);
export const ZoomInIcon = createIcon(
);
export const SaveFileIcon = createIcon(
);
export const OpenFileIcon = createIcon(
);
export const BackgroundColorIcon = createIcon(
);
export const NoColorIcon = createIcon(
);
export const Check = createIcon(
);
export const StrokeIcon = createIcon(
);
export const StrokeWhiteIcon = createIcon(
);
export const StrokeStyleNormalIcon = createIcon(
);
export const StrokeStyleDashedIcon = createIcon(
);
export const StrokeStyleDotedIcon = createIcon(
);
export const FontColorIcon: React.FC<{ currentColor?: string }> = ({
currentColor,
}) => {
return (
);
};
export const UndoIcon = createIcon(
);
export const RedoIcon = createIcon(
);
export const TrashIcon = createIcon(
);
export const DuplicateIcon = createIcon(
);
export const FeltTipPenIcon = createIcon(
);
export const ImageIcon = createIcon(
);
export const ExtraToolsIcon = createIcon(
);
export const MermaidLogoIcon = createIcon(
);
export const MarkdownLogoIcon = createIcon(
);
export const LinkIcon = createIcon(
);
export const ArrowIcon = createIcon(
);
export const LineIcon = createIcon(
);
export const StraightLineIcon = createIcon(
);
export const NoteCurlyRightIcon = createIcon(
);
export const NoteCurlyLeftIcon = createIcon(
);
export const ChevronDownIcon = createIcon(
);
export const ThickCheckIcon = createIcon(
);
export const FontSizeStepperUpIcon: React.FC<
React.SVGProps
> = (props) => {
return (
);
};
export const FontSizeStepperDownIcon: React.FC<
React.SVGProps
> = (props) => {
return (
);
};
================================================
FILE: packages/drawnix/src/components/island.scss
================================================
.drawnix {
.island {
--padding: 0;
box-sizing: border-box;
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-md);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
border: 1px solid var(--island-border-color);
&.zen-mode {
box-shadow: none;
}
}
}
================================================
FILE: packages/drawnix/src/components/island.tsx
================================================
// Credits to excalidraw
import classNames from 'classnames';
import './island.scss';
import React from 'react';
type IslandProps = {
children: React.ReactNode;
padding?: number;
className?: string | boolean;
style?: object;
} & React.HTMLAttributes;
export const Island = React.forwardRef(
({ children, padding, className, style, ...restProps }, ref) => (
{children}
)
);
================================================
FILE: packages/drawnix/src/components/menu/common.ts
================================================
import React, { useContext } from 'react';
import { EVENT } from '../../constants';
import { composeEventHandlers } from '../../utils/common';
export const MenuContentPropsContext = React.createContext<{
onSelect?: (event: Event) => void;
}>({});
export const getMenuItemClassName = (
className = '',
active = false,
) => {
return `menu-item menu-item-base ${className} ${
active ? 'menu-item--active' : ''
}`.trim();
};
export const useHandleMenuItemClick = (
origOnClick:
| React.MouseEventHandler
| undefined,
onSelect: ((event: Event) => void) | undefined
) => {
const menuContentProps = useContext(MenuContentPropsContext);
return composeEventHandlers(origOnClick, (event) => {
const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, {
bubbles: true,
cancelable: true,
});
onSelect?.(itemSelectEvent);
if (!itemSelectEvent.defaultPrevented) {
menuContentProps.onSelect?.(itemSelectEvent);
}
});
};
================================================
FILE: packages/drawnix/src/components/menu/menu-group.tsx
================================================
import React from 'react';
const MenuGroup = ({
children,
className = '',
style,
title,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
title?: string;
}) => {
return (
{title &&
{title}
}
{children}
);
};
export default MenuGroup;
MenuGroup.displayName = 'MenuGroup';
================================================
FILE: packages/drawnix/src/components/menu/menu-item-content-radio.tsx
================================================
import { RadioGroup } from '../radio-group';
type Props = {
value: T;
shortcut?: string;
choices: {
value: T;
label: React.ReactNode;
ariaLabel?: string;
}[];
onChange: (value: T) => void;
children: React.ReactNode;
name: string;
};
const MenuItemContentRadio = ({
value,
shortcut,
onChange,
choices,
children,
name,
}: Props) => {
return (
<>
{children}
{shortcut && (
{shortcut}
)}
>
);
};
MenuItemContentRadio.displayName = 'MenuItemContentRadio';
export default MenuItemContentRadio;
================================================
FILE: packages/drawnix/src/components/menu/menu-item-content.tsx
================================================
import React from 'react';
const MenuItemContent = ({
icon,
shortcut,
children,
}: {
icon?: React.ReactNode;
shortcut?: string;
children: React.ReactNode;
}) => {
return (
<>
{icon && {icon}
}
{children}
{shortcut && (
{shortcut}
)}
>
);
};
export default MenuItemContent;
================================================
FILE: packages/drawnix/src/components/menu/menu-item-custom.tsx
================================================
import React from 'react';
const MenuItemCustom = ({
children,
className = '',
selected,
...rest
}: {
children: React.ReactNode;
className?: string;
selected?: boolean;
} & React.HTMLAttributes) => {
return (
{children}
);
};
export default MenuItemCustom;
================================================
FILE: packages/drawnix/src/components/menu/menu-item-link.tsx
================================================
import React from 'react';
import { getMenuItemClassName, useHandleMenuItemClick } from './common';
import MenuItemContent from './menu-item-content';
const MenuItemLink = ({
icon,
shortcut,
href,
children,
onSelect,
className = '',
selected,
...rest
}: {
href: string;
icon?: React.ReactNode;
children: React.ReactNode;
shortcut?: string;
className?: string;
selected?: boolean;
onSelect?: (event: Event) => void;
} & React.AnchorHTMLAttributes) => {
const handleClick = useHandleMenuItemClick(rest.onClick, onSelect);
return (
{children}
);
};
export default MenuItemLink;
MenuItemLink.displayName = 'MenuItemLink';
================================================
FILE: packages/drawnix/src/components/menu/menu-item.tsx
================================================
import React, { useState, useRef } from 'react';
import {
getMenuItemClassName,
useHandleMenuItemClick,
} from './common';
import MenuItemContent from './menu-item-content';
import { Popover, PopoverContent, PopoverTrigger } from '../popover/popover';
const MenuItem = ({
icon,
onSelect,
children,
shortcut,
className,
selected,
submenu,
...rest
}: {
icon?: React.ReactNode;
onSelect: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
selected?: boolean;
className?: string;
submenu?: React.ReactNode;
} & Omit, 'onSelect'>) => {
const [isOpen, setIsOpen] = useState(false);
const closeTimeoutRef = useRef();
const handleClick = useHandleMenuItemClick(rest.onClick, onSelect);
const menuItemContent = (
{children}
);
const handleMouseEnter = () => {
if (closeTimeoutRef.current) {
window.clearTimeout(closeTimeoutRef.current);
}
setIsOpen(true);
};
const handleMouseLeave = () => {
closeTimeoutRef.current = window.setTimeout(() => {
setIsOpen(false);
}, 100);
};
const handleMenuItemClick = (event: React.MouseEvent) => {
if (submenu) {
setIsOpen(!isOpen);
rest.onClick?.(event as any);
} else {
handleClick(event as any);
}
};
if (submenu) {
return (
{menuItemContent}
{submenu}
);
}
return (
{menuItemContent}
);
};
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 (
{children}
);
});
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 (
{displayContent}
{ChevronDownIcon}
);
}
);
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 (
);
}
);
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 (
{!context.hideSelectedIndicator && (
{isSelected && ThickCheckIcon}
)}
{children}
);
}
);
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 (
{
props.onPointerDown?.({
pointerType: event.pointerType || null,
event,
});
}}
onPointerUp={(event) => {
props.onPointerUp?.({ pointerType: event.pointerType || null });
}}
ref={innerRef}
disabled={isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
{props.icon || props.label}
{props.keyBindingLabel && (
{props.keyBindingLabel}
)}
)}
{props.showAriaLabel && (
{props['aria-label']}
)}
{props.children && (
{props.children}
)}
);
}
return (
{
lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.({
pointerType: event.pointerType || null,
event,
});
}}
onPointerUp={(event) => {
props.onPointerUp?.({ pointerType: event.pointerType || null });
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
>
{
props.onChange?.({ pointerType: lastPointerTypeRef.current });
}}
checked={props.checked}
ref={innerRef}
/>
{props.icon}
{props.keyBindingLabel && (
{props.keyBindingLabel}
)}
);
});
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();
stepBy(1);
inputRef.current?.focus();
}}
>
{
event.preventDefault();
event.stopPropagation();
}}
onPointerUp={(event) => {
event.stopPropagation();
stepBy(-1);
inputRef.current?.focus();
}}
>
{
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 (
{
const value = (e.target as HTMLSelectElement).value;
BoardTransforms.updateThemeColor(board, value as ThemeColorMode);
}}
value={theme.themeColorMode}
>
{t('theme.default')}
{t('theme.colorful')}
{t('theme.soft')}
{t('theme.retro')}
{t('theme.dark')}
{t('theme.starry')}
);
};
================================================
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 (
<>
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 (
);
};
================================================
FILE: packages/drawnix/src/components/ttd-dialog/ttd-dialog-output.tsx
================================================
import { withGroup } from '@plait/common';
import { PlaitElement, PlaitPlugin } from '@plait/core';
import { withDraw } from '@plait/draw';
import { withCommonPlugin } from '../../plugins/with-common';
import { Board, Wrapper } from '@plait-board/react-board';
import { MindThemeColors, withMind } from '@plait/mind';
const ErrorComp = ({ error }: { error: string }) => {
return (
);
};
interface TTDDialogOutputProps {
error: Error | null;
value: PlaitElement[];
loaded: boolean;
}
export const TTDDialogOutput = ({
error,
value,
loaded,
}: TTDDialogOutputProps) => {
const plugins: PlaitPlugin[] = [withDraw, withMind, withGroup, withCommonPlugin];
const options = {
readonly: true,
hideScrollbar: false,
disabledScrollOnNonFocus: true,
themeColors: MindThemeColors,
};
return (
);
};
================================================
FILE: packages/drawnix/src/components/ttd-dialog/ttd-dialog-panel.tsx
================================================
import type { ReactNode } from 'react';
import classNames from 'classnames';
interface TTDDialogPanelProps {
label: string;
children: ReactNode;
panelAction?: {
label: string;
action: () => void;
icon?: ReactNode;
};
panelActionDisabled?: boolean;
onTextSubmitInProgress?: boolean;
renderTopRight?: () => ReactNode;
renderSubmitShortcut?: () => ReactNode;
renderBottomRight?: () => ReactNode;
}
export const TTDDialogPanel = ({
label,
children,
panelAction,
panelActionDisabled = false,
onTextSubmitInProgress,
renderTopRight,
renderSubmitShortcut,
renderBottomRight,
}: TTDDialogPanelProps) => {
return (
{label}
{renderTopRight?.()}
{children}
{panelAction?.label}
{panelAction?.icon && {panelAction.icon} }
{!panelActionDisabled &&
!onTextSubmitInProgress &&
renderSubmitShortcut?.()}
{renderBottomRight?.()}
);
};
================================================
FILE: packages/drawnix/src/components/ttd-dialog/ttd-dialog-panels.tsx
================================================
import type { ReactNode } from "react";
export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
return {children}
;
};
================================================
FILE: packages/drawnix/src/components/ttd-dialog/ttd-dialog-submit-shortcut.tsx
================================================
import { getShortcutKey } from "../../utils/common";
export const TTDDialogSubmitShortcut = () => {
return (
{getShortcutKey("CtrlOrCmd")}
{getShortcutKey("Enter")}
);
};
================================================
FILE: packages/drawnix/src/components/ttd-dialog/ttd-dialog.scss
================================================
@import "../../styles/variables.module.scss";
$verticalBreakpoint: 861px;
.drawnix {
.Dialog.ttd-dialog {
padding: 1.25rem;
display: flex;
flex-direction: column;
width: 100%;
max-width: 1024px;
height: 100%;
max-height: 540px;
&.Dialog--fullscreen {
margin-top: 0;
}
.Island {
padding-inline: 0 !important;
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
box-shadow: none;
}
.Modal__content {
height: auto;
max-height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
max-height: 750px;
height: 100%;
}
}
.Dialog__content {
flex: 1 1 auto;
}
}
.ttd-dialog-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
margin-bottom: 1.5rem;
}
.ttd-dialog-tabs-root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.ttd-dialog-tab-trigger {
color: var(--color-on-surface);
font-size: 0.875rem;
margin: 0;
padding: 0 1rem;
background-color: transparent;
border: 0;
height: 2.875rem;
font-weight: 600;
font-family: inherit;
letter-spacing: 0.4px;
&[data-state="active"] {
border-bottom: 2px solid var(--color-primary);
}
}
.ttd-dialog-triggers {
border-bottom: 1px solid var(--color-surface-high);
margin-bottom: 1.5rem;
padding-inline: 2.5rem;
}
.ttd-dialog-content {
padding-inline: 2.5rem;
height: 100%;
display: flex;
flex-direction: column;
&[hidden] {
display: none;
}
}
.ttd-dialog-input {
width: auto;
height: 10rem;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
font-family: monospace;
@media screen and (min-width: $verticalBreakpoint) {
width: 100%;
height: 100%;
}
}
.ttd-dialog-output-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
flex-grow: 1;
position: relative;
// background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
// left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
height: 400px;
width: auto;
@media screen and (min-width: $verticalBreakpoint) {
width: 100%;
// acts as min-height
height: 200px;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
.ttd-dialog-output-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
overflow: hidden;
}
.ttd-dialog-output-error {
color: red;
font-weight: 700;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute;
z-index: 10;
p {
font-weight: 500;
font-family: Cascadia;
text-align: left;
white-space: pre-wrap;
font-size: 0.875rem;
padding: 0 10px;
}
}
.ttd-dialog-panels {
height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
}
}
.ttd-dialog-panel {
display: flex;
flex-direction: column;
width: 100%;
&__header {
display: flex;
margin: 0px 4px 4px 4px;
align-items: center;
gap: 1rem;
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
}
}
&:first-child {
.ttd-dialog-panel-button-container:not(.invisible) {
margin-bottom: 4rem;
}
}
@media screen and (min-width: $verticalBreakpoint) {
.ttd-dialog-panel-button-container:not(.invisible) {
margin-bottom: 0.5rem !important;
}
}
textarea {
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
height: 10rem;
}
}
}
.ttd-dialog-panel-button-container {
margin-top: 1rem;
margin-bottom: 0.5rem;
&.invisible {
.ttd-dialog-panel-button {
display: none;
@media screen and (min-width: $verticalBreakpoint) {
display: block;
visibility: hidden;
}
}
}
}
.ttd-dialog-panel-button {
&.drawnix-button {
font-family: inherit;
font-weight: 600;
height: 2.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
width: 100%;
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background-color: var(--color-primary);
}
}
@media screen and (min-width: $verticalBreakpoint) {
width: auto;
min-width: 7.5rem;
}
@at-root .drawnix.theme--dark#{&} {
color: var(--color-gray-100);
}
}
position: relative;
div {
display: contents;
&.invisible {
visibility: hidden;
}
&.Spinner {
display: flex !important;
position: absolute;
inset: 0;
--spinner-color: white;
@at-root .drawnix.theme--dark#{&} {
--spinner-color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
.ttd-dialog-submit-shortcut {
margin-inline-start: 0.5rem;
font-size: 0.625rem;
opacity: 0.6;
display: flex;
gap: 0.125rem;
&__key {
border: 1px solid gray;
padding: 2px 3px;
border-radius: 4px;
}
}
}
================================================
FILE: packages/drawnix/src/components/ttd-dialog/ttd-dialog.tsx
================================================
import { Dialog, DialogContent } from '../dialog/dialog';
import MermaidToDrawnix from './mermaid-to-drawnix';
import { DialogType, useDrawnix } from '../../hooks/use-drawnix';
import MarkdownToDrawnix from './markdown-to-drawnix';
export const TTDDialog = ({ container }: { container: HTMLElement | null }) => {
const { appState, setAppState } = useDrawnix();
return (
<>
{
setAppState({
...appState,
openDialogType: open ? DialogType.mermaidToDrawnix : null,
});
}}
>
{
setAppState({
...appState,
openDialogType: open ? DialogType.markdownToDrawnix : null,
});
}}
>
>
);
};
================================================
FILE: packages/drawnix/src/components/tutorial.scss
================================================
.drawnix-tutorial {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Noto Sans', 'Noto Sans CJK SC', 'Microsoft Yahei', 'Hiragino Sans GB', Arial, sans-serif;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
background-color: transparent;
p {
margin: 0;
font-size: 14px;
color: #888;
line-height: 1.5;
}
.tutorial-overlay {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
}
.tutorial-content {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.brand-title {
font-size: 72px;
font-weight: 400;
color: #333;
letter-spacing: 2px;
margin: 0;
margin-bottom: 25px;
}
.brand-description {
font-size: 18px;
color: #333;
text-align: center;
max-width: 600px;
line-height: 1.6;
font-style: italic;
margin-bottom: 25px;
}
.brand-tooltip {
color: #888;
text-align: center;
max-width: 600px;
line-height: 1.6;
margin-bottom: 40px;
}
.feature-pointer {
position: absolute;
}
.top-left {
position: absolute;
top: 100px;
left: 60px;
.pointer-content {
position: absolute;
top: 100px;
width: 100%;
text-align: center;
left: 20px;
}
}
.top-center {
top: 100px;
left: 50%;
width: 200px;
transform: translateX(-50%);
.pointer-content {
position: absolute;
width: 100%;
top: 50px;
left: 60px;
}
}
.bottom-right {
bottom: 70px;
right: 40px;
.pointer-content {
position: absolute;
top: -30px;
right: 80px;
width: 100%;
}
}
}
@media screen and (max-width: 768px){
.drawnix-tutorial {
.tutorial-content {
width: 95%;
height: 95%;
}
.feature-pointer {
display: none;
}
}
}
================================================
FILE: packages/drawnix/src/components/tutorial.tsx
================================================
import React, { useState, useEffect } from "react";
import { useI18n } from "../i18n";
import "./tutorial.scss";
export const Tutorial: React.FC = () => {
const { t } = useI18n();
return (
{t('tutorial.title')}
{t('tutorial.description')}
{t('tutorial.dataDescription')}
{t('tutorial.appToolbar')}
{t('tutorial.creationToolbar')}
{t('tutorial.themeDescription')}
);
};
================================================
FILE: packages/drawnix/src/constants/color.ts
================================================
import { DEFAULT_COLOR } from '@plait/core';
export const TRANSPARENT = 'TRANSPARENT';
export const NO_COLOR = 'NO_COLOR';
export const WHITE = '#FFFFFF';
export const CLASSIC_COLORS = [
{ name: 'color.none', value: NO_COLOR },
{ name: 'color.default', value: DEFAULT_COLOR },
{ name: 'color.white', value: WHITE },
{ name: 'color.gray', value: '#808080' },
{ name: 'color.deepBlue', value: '#1E90FF' },
{ name: 'color.red', value: '#FF4500' },
{ name: 'color.green', value: '#2ECC71' },
{ name: 'color.yellow', value: '#FFD700' },
{ name: 'color.purple', value: '#8A2BE2' },
{ name: 'color.orange', value: '#FFA500' },
{ name: 'color.pastelPink', value: '#FFB3BA' },
{ name: 'color.cyan', value: '#00CED1' },
{ name: 'color.brown', value: '#8B4513' },
{ name: 'color.forestGreen', value: '#228B22' },
{ name: 'color.lightGray', value: '#D3D3D3' },
];
================================================
FILE: packages/drawnix/src/constants.ts
================================================
export enum EVENT {
COPY = 'copy',
PASTE = 'paste',
CUT = 'cut',
KEYDOWN = 'keydown',
KEYUP = 'keyup',
MOUSE_MOVE = 'mousemove',
RESIZE = 'resize',
UNLOAD = 'unload',
FOCUS = 'focus',
BLUR = 'blur',
DRAG_OVER = 'dragover',
DROP = 'drop',
GESTURE_END = 'gestureend',
BEFORE_UNLOAD = 'beforeunload',
GESTURE_START = 'gesturestart',
GESTURE_CHANGE = 'gesturechange',
POINTER_MOVE = 'pointermove',
POINTER_DOWN = 'pointerdown',
POINTER_UP = 'pointerup',
STATE_CHANGE = 'statechange',
WHEEL = 'wheel',
TOUCH_START = 'touchstart',
TOUCH_END = 'touchend',
HASHCHANGE = 'hashchange',
VISIBILITY_CHANGE = 'visibilitychange',
SCROLL = 'scroll',
MENU_ITEM_SELECT = 'menu.itemSelect',
MESSAGE = 'message',
FULLSCREENCHANGE = 'fullscreenchange',
}
export const IMAGE_MIME_TYPES = {
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
avif: "image/avif",
jfif: "image/jfif",
} as const;
export const MIME_TYPES = {
json: 'application/json',
drawnix: 'application/vnd.drawnix+json',
// image
...IMAGE_MIME_TYPES,
} as const;
export const VERSIONS = {
drawnix: 1,
} as const;
================================================
FILE: packages/drawnix/src/css.d.ts
================================================
import "csstype";
declare module "csstype" {
interface Properties {
"--max-width"?: number | string;
"--swatch-color"?: string;
"--gap"?: number | string;
"--padding"?: number | string;
}
}
================================================
FILE: packages/drawnix/src/data/blob.ts
================================================
import { PlaitBoard } from '@plait/core';
import { isValidDrawnixData } from './json';
import { IMAGE_MIME_TYPES, MIME_TYPES } from '../constants';
import { ValueOf } from '../utils/utility-types';
import { DataURL } from '../types';
export const loadFromBlob = async (board: PlaitBoard, blob: Blob | File) => {
const contents = await parseFileContents(blob);
let data;
try {
data = JSON.parse(contents);
if (isValidDrawnixData(data)) {
return data;
}
throw new Error('Error: invalid file');
} catch (error: any) {
throw new Error('Error: invalid file');
}
};
export const createFile = (
blob: File | Blob | ArrayBuffer,
mimeType: ValueOf,
name: string | undefined
) => {
return new File([blob], name || '', {
type: mimeType,
});
};
export const blobToArrayBuffer = (blob: Blob): Promise => {
if ('arrayBuffer' in blob) {
return blob.arrayBuffer();
}
// Safari
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target?.result) {
return reject(new Error("Couldn't convert blob to ArrayBuffer"));
}
resolve(event.target.result as ArrayBuffer);
};
reader.readAsArrayBuffer(blob);
});
};
export const normalizeFile = async (file: File) => {
if (!file.type) {
if (file?.name?.endsWith('.drawnix')) {
file = createFile(
await blobToArrayBuffer(file),
MIME_TYPES.drawnix,
file.name
);
}
}
return file;
};
export const parseFileContents = async (blob: Blob | File) => {
let contents: string;
if ('text' in Blob) {
contents = await blob.text();
} else {
contents = await new Promise((resolve) => {
const reader = new FileReader();
reader.readAsText(blob, 'utf8');
reader.onloadend = () => {
if (reader.readyState === FileReader.DONE) {
resolve(reader.result as string);
}
};
});
}
return contents;
};
export const getDataURL = async (file: Blob | File): Promise => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataURL = reader.result as DataURL;
resolve(dataURL);
};
reader.onerror = (error) => reject(error);
reader.readAsDataURL(file);
});
};
export const isSupportedImageFileType = (type: string | null | undefined) => {
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
};
export const isSupportedImageFile = (
blob: Blob | null | undefined
): blob is Blob & { type: ValueOf } => {
const { type } = blob || {};
return isSupportedImageFileType(type);
};
================================================
FILE: packages/drawnix/src/data/filesystem.ts
================================================
import type { FileSystemHandle } from 'browser-fs-access';
import {
fileOpen as _fileOpen,
fileSave as _fileSave,
supported as nativeFileSystemSupported,
} from 'browser-fs-access';
import { MIME_TYPES } from '../constants';
type FILE_EXTENSION = Exclude;
export const fileOpen = (opts: {
extensions?: FILE_EXTENSION[];
description: string;
multiple?: M;
}): Promise => {
// an unsafe TS hack, alas not much we can do AFAIK
type RetType = M extends false | undefined ? File : File[];
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
mimeTypes.push(MIME_TYPES[type]);
return mimeTypes;
}, [] as string[]);
const extensions = opts.extensions?.reduce((acc, ext) => {
if (ext === 'jpg') {
return acc.concat('.jpg', '.jpeg');
}
return acc.concat(`.${ext}`);
}, [] as string[]);
return _fileOpen({
description: opts.description,
extensions,
mimeTypes,
multiple: opts.multiple ?? false,
}) as Promise;
};
export const fileSave = (
blob: Blob | Promise,
opts: {
/** supply without the extension */
name: string;
/** file extension */
extension: FILE_EXTENSION;
description: string;
/** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null;
}
) => {
return _fileSave(
blob,
{
fileName: `${opts.name}.${opts.extension}`,
description: opts.description,
extensions: [`.${opts.extension}`],
},
opts.fileHandle as any
);
};
export type { FileSystemHandle };
export { nativeFileSystemSupported };
================================================
FILE: packages/drawnix/src/data/image.ts
================================================
import {
getHitElementByPoint,
getSelectedElements,
PlaitBoard,
Point,
} from '@plait/core';
import { DataURL } from '../types';
import { getDataURL } from './blob';
import { MindElement, MindTransforms } from '@plait/mind';
import { DrawTransforms } from '@plait/draw';
import { getElementOfFocusedImage } from '@plait/common';
export const loadHTMLImageElement = (dataURL: DataURL) => {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
resolve(image);
};
image.onerror = (error) => {
reject(error);
};
image.src = dataURL;
});
};
export const buildImage = (
image: HTMLImageElement,
dataURL: DataURL,
maxWidth: number
) => {
const width = image.width > maxWidth ? maxWidth : image.width;
const height = (width / image.width) * image.height;
return {
url: dataURL,
width,
height,
};
};
export const insertImage = async (
board: PlaitBoard,
imageFile: File,
startPoint?: Point,
isDrop?: boolean
) => {
const selectedElement =
getSelectedElements(board)[0] || getElementOfFocusedImage(board);
const defaultImageWidth = selectedElement ? 240 : 400;
const dataURL = await getDataURL(imageFile);
const image = await loadHTMLImageElement(dataURL);
const imageItem = buildImage(image, dataURL, defaultImageWidth);
const element = startPoint && getHitElementByPoint(board, startPoint);
if (isDrop && element && MindElement.isMindElement(board, element)) {
MindTransforms.setImage(board, element as MindElement, imageItem);
return;
}
if (
selectedElement &&
MindElement.isMindElement(board, selectedElement) &&
!isDrop
) {
MindTransforms.setImage(board, selectedElement as MindElement, imageItem);
} else {
DrawTransforms.insertImage(board, imageItem, startPoint);
}
};
================================================
FILE: packages/drawnix/src/data/json.ts
================================================
import { PlaitBoard, PlaitElement } from '@plait/core';
import { MIME_TYPES, VERSIONS } from '../constants';
import { fileOpen, fileSave } from './filesystem';
import { DrawnixExportedData, DrawnixExportedType } from './types';
import { loadFromBlob, normalizeFile } from './blob';
export const getDefaultName = () => {
const time = new Date().getTime();
return time.toString();
};
export const saveAsJSON = async (
board: PlaitBoard,
name: string = getDefaultName()
) => {
const serialized = serializeAsJSON(board);
const blob = new Blob([serialized], {
type: MIME_TYPES.drawnix,
});
const fileHandle = await fileSave(blob, {
name,
extension: 'drawnix',
description: 'Drawnix file',
});
return { fileHandle };
};
export const loadFromJSON = async (board: PlaitBoard) => {
const file = await fileOpen({
description: 'Drawnix files',
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
// gets resolved. Else, iOS users cannot open `.drawnix` files.
// extensions: ["json", "drawnix", "png", "svg"],
});
return loadFromBlob(board, await normalizeFile(file));
};
export const isValidDrawnixData = (data?: any): data is DrawnixExportedData => {
return (
data &&
data.type === DrawnixExportedType.drawnix &&
Array.isArray(data.elements) &&
typeof data.viewport === 'object'
);
};
export const serializeAsJSON = (board: PlaitBoard): string => {
const data = {
type: DrawnixExportedType.drawnix,
version: VERSIONS.drawnix,
source: 'web',
elements: board.children,
viewport: board.viewport,
theme: board.theme,
};
return JSON.stringify(data, null, 2);
};
================================================
FILE: packages/drawnix/src/data/types.ts
================================================
import { PlaitElement, PlaitTheme, Viewport } from '@plait/core';
export interface DrawnixExportedData {
type: DrawnixExportedType.drawnix;
version: number;
source: 'web';
elements: PlaitElement[];
viewport: Viewport;
theme?: PlaitTheme;
}
export enum DrawnixExportedType {
drawnix = 'drawnix'
}
================================================
FILE: packages/drawnix/src/drawnix.spec.tsx
================================================
describe('Drawnix', () => {
it('should render successfully', () => {
// const { baseElement } = render( );
// expect(baseElement).toBeTruthy();
});
});
================================================
FILE: packages/drawnix/src/drawnix.tsx
================================================
import { Board, BoardChangeData, Wrapper } from '@plait-board/react-board';
import {
PlaitBoard,
PlaitBoardOptions,
PlaitElement,
PlaitPlugin,
PlaitPointerType,
PlaitTheme,
Selection,
ThemeColorMode,
Viewport,
} from '@plait/core';
import React, { useState, useRef, useEffect } from 'react';
import { withGroup } from '@plait/common';
import { withDraw } from '@plait/draw';
import { MindThemeColors, withMind } from '@plait/mind';
import MobileDetect from 'mobile-detect';
import { withMindExtend } from './plugins/with-mind-extend';
import { withCommonPlugin } from './plugins/with-common';
import { CreationToolbar } from './components/toolbar/creation-toolbar';
import { ZoomToolbar } from './components/toolbar/zoom-toolbar';
import { PopupToolbar } from './components/toolbar/popup-toolbar/popup-toolbar';
import { AppToolbar } from './components/toolbar/app-toolbar/app-toolbar';
import classNames from 'classnames';
import './styles/index.scss';
import { buildDrawnixHotkeyPlugin } from './plugins/with-hotkey';
import { withFreehand } from './plugins/freehand/with-freehand';
import { ThemeToolbar } from './components/toolbar/theme-toolbar';
import { buildPencilPlugin } from './plugins/with-pencil';
import {
DrawnixBoard,
DrawnixContext,
DrawnixState,
} from './hooks/use-drawnix';
import { ClosePencilToolbar } from './components/toolbar/pencil-mode-toolbar';
import { TTDDialog } from './components/ttd-dialog/ttd-dialog';
import { CleanConfirm } from './components/clean-confirm/clean-confirm';
import { buildTextLinkPlugin } from './plugins/with-text-link';
import { LinkPopup } from './components/popup/link-popup/link-popup';
import { I18nProvider } from './i18n';
import { Tutorial } from './components/tutorial';
import { LASER_POINTER_CLASS_NAME } from './utils/laser-pointer';
export type DrawnixProps = {
value: PlaitElement[];
viewport?: Viewport;
theme?: PlaitTheme;
onChange?: (value: BoardChangeData) => void;
onSelectionChange?: (selection: Selection | null) => void;
onValueChange?: (value: PlaitElement[]) => void;
onViewportChange?: (value: Viewport) => void;
onThemeChange?: (value: ThemeColorMode) => void;
afterInit?: (board: PlaitBoard) => void;
tutorial?: boolean;
} & React.HTMLAttributes;
export const Drawnix: React.FC = ({
value,
viewport,
theme,
onChange,
onSelectionChange,
onViewportChange,
onThemeChange,
onValueChange,
afterInit,
tutorial = false,
}) => {
const options: PlaitBoardOptions = {
readonly: false,
hideScrollbar: false,
disabledScrollOnNonFocus: false,
themeColors: MindThemeColors,
};
const [appState, setAppState] = useState(() => {
// TODO: need to consider how to maintenance the pointer state in future
const md = new MobileDetect(window.navigator.userAgent);
return {
pointer: PlaitPointerType.hand,
isMobile: md.mobile() !== null,
isPencilMode: false,
openDialogType: null,
openCleanConfirm: false,
};
});
const [board, setBoard] = useState(null);
if (board) {
board.appState = appState;
}
const updateAppState = (newAppState: Partial) => {
setAppState({
...appState,
...newAppState,
});
};
const plugins: PlaitPlugin[] = [
withDraw,
withGroup,
withMind,
withMindExtend,
withCommonPlugin,
buildDrawnixHotkeyPlugin(updateAppState),
withFreehand,
buildPencilPlugin(updateAppState),
buildTextLinkPlugin(updateAppState),
];
const containerRef = useRef(null);
return (
{
onChange && onChange(data);
}}
onSelectionChange={onSelectionChange}
onViewportChange={onViewportChange}
onThemeChange={onThemeChange}
onValueChange={onValueChange}
>
{
setBoard(board as DrawnixBoard);
afterInit && afterInit(board);
}}
>
{tutorial &&
board &&
PlaitBoard.isPointer(board, PlaitPointerType.selection) && (
)}
);
};
================================================
FILE: packages/drawnix/src/errors.ts
================================================
export class AbortError extends DOMException {
constructor(message = 'Request Aborted') {
super(message, 'AbortError');
}
}
================================================
FILE: packages/drawnix/src/hooks/use-drawnix.tsx
================================================
/**
* A React context for sharing the board object, in a way that re-renders the
* context whenever changes occur.
*/
import { PlaitBoard, PlaitPointerType } from '@plait/core';
import { createContext, useContext } from 'react';
import { MindPointerType } from '@plait/mind';
import { DrawPointerType } from '@plait/draw';
import { FreehandShape } from '../plugins/freehand/type';
import { Editor } from 'slate';
import { LinkElement } from '@plait/common';
export enum DialogType {
mermaidToDrawnix = 'mermaidToDrawnix',
markdownToDrawnix = 'markdownToDrawnix',
}
export type DrawnixPointerType =
| PlaitPointerType
| MindPointerType
| DrawPointerType
| FreehandShape;
export interface DrawnixBoard extends PlaitBoard {
appState: DrawnixState;
}
export type LinkState = {
targetDom: HTMLElement;
editor: Editor;
targetElement: LinkElement;
isEditing: boolean;
isHovering: boolean;
isHoveringOrigin: boolean;
};
export type DrawnixState = {
pointer: DrawnixPointerType;
isMobile: boolean;
isPencilMode: boolean;
openDialogType: DialogType | null;
openCleanConfirm: boolean;
linkState?: LinkState | null;
};
export const DrawnixContext = createContext<{
appState: DrawnixState;
setAppState: (appState: DrawnixState) => void;
} | null>(null);
export const useDrawnix = (): {
appState: DrawnixState;
setAppState: (appState: DrawnixState) => void;
} => {
const context = useContext(DrawnixContext);
if (!context) {
throw new Error(
`The \`useDrawnix\` hook must be used inside the component's context.`
);
}
return context;
};
export const useSetPointer = () => {
const { appState, setAppState } = useDrawnix();
return (pointer: DrawnixPointerType) => {
setAppState({ ...appState, pointer });
};
};
================================================
FILE: packages/drawnix/src/i18n/index.tsx
================================================
import React, { createContext, useContext, useState, useMemo } from 'react';
import { zhTranslations, enTranslations, ruTranslations, arTranslations, viTranslations } from './translations';
import { Language, Translations, I18nContextType, I18nProviderProps } from './types';
// Translation data
const translations: Record = {
zh: zhTranslations,
en: enTranslations,
ru: ruTranslations,
ar: arTranslations,
vi: viTranslations
};
// Create the context
const I18nContext = createContext(undefined);
export const I18nProvider: React.FC = ({
children,
defaultLanguage = 'zh',
}) => {
const [language, setLanguageState] = useState(() => {
const storedLanguage = localStorage.getItem('language') as Language;
return storedLanguage || defaultLanguage;
});
const setLanguage = (newLanguage: Language) => {
localStorage.setItem('language', newLanguage);
setLanguageState(newLanguage);
};
const t = (key: keyof Translations): string => {
return translations[language][key] || key;
};
const value: I18nContextType = useMemo(
() => ({
language,
setLanguage,
t,
}),
[language]
);
return {children} ;
};
export const useI18n = (): I18nContextType => {
const context = useContext(I18nContext);
if (!context) {
throw new Error('useI18n must be used within I18nProvider');
}
return context;
};
export const i18nInsidePlaitHook = () => {
const i18n = {
t: (key: keyof Translations): string => {
const currentLang = localStorage.getItem('language') as Language || 'zh';
return translations[currentLang][key] || key;
},
language: localStorage.getItem('language') as Language || 'zh',
};
return i18n;
}
export type { Language, Translations, I18nContextType };
================================================
FILE: packages/drawnix/src/i18n/translations/ar.ts
================================================
import { Translations } from '../types';
const arTranslations: Translations = {
// Toolbar items
"toolbar.hand": "اليد — H",
"toolbar.selection": "التحديد — V",
"toolbar.mind": "خريطة ذهنية — M",
'toolbar.eraser': 'ممحاة — E',
"toolbar.text": "نص — T",
"toolbar.pen": "قلم — P",
"toolbar.arrow": "سهم — A",
"toolbar.shape": "أشكال",
"toolbar.image": "صورة — Cmd+U",
"toolbar.extraTools": "أدوات إضافية",
"toolbar.arrow.straight": "سهم مستقيم",
"toolbar.arrow.elbow": "سهم بزوايا",
"toolbar.arrow.curve": "سهم منحني",
"toolbar.shape.rectangle": "مستطيل — R",
"toolbar.shape.ellipse": "بيضاوي — O",
"toolbar.shape.triangle": "مثلث",
"toolbar.shape.terminal": "نهائي",
"toolbar.shape.noteCurlyLeft": "ملاحظة معقوفة — يسار",
"toolbar.shape.noteCurlyRight": "ملاحظة معقوفة — يمين",
"toolbar.shape.diamond": "معين",
"toolbar.shape.parallelogram": "متوازي أضلاع",
"toolbar.shape.roundRectangle": "مستطيل دائري الحواف",
// Zoom controls
"zoom.in": "تكبير — Cmd++",
"zoom.out": "تصغير — Cmd+-",
"zoom.fit": "ملاءمة الشاشة",
"zoom.100": "تكبير إلى 100%",
// Themes
"theme.default": "افتراضي",
"theme.colorful": "ملون",
"theme.soft": "ناعم",
"theme.retro": "كلاسيكي",
"theme.dark": "داكن",
"theme.starry": "ليلي",
// Colors
"color.none": "لون الموضوع",
"color.unknown": "لون آخر",
"color.default": "أسود أساسي",
"color.white": "أبيض",
"color.gray": "رمادي",
"color.deepBlue": "أزرق غامق",
"color.red": "أحمر",
"color.green": "أخضر",
"color.yellow": "أصفر",
"color.purple": "بنفسجي",
"color.orange": "برتقالي",
"color.pastelPink": "وردي فاتح",
"color.cyan": "سماوي",
"color.brown": "بني",
"color.forestGreen": "أخضر غامق (غابة)",
"color.lightGray": "رمادي فاتح",
// General
"general.undo": "تراجع",
"general.redo": "إعادة",
"general.menu": "قائمة التطبيق",
"general.duplicate": "تكرار",
"general.delete": "حذف",
// Language
"language.switcher": "اللغة",
"language.chinese": "中文",
"language.english": "English",
"language.russian": "Русский",
"language.arabic": "عربي",
'language.vietnamese': 'Tiếng Việt',
// Menu items
"menu.open": "فتح",
"menu.saveFile": "حفظ الملف",
"menu.exportImage": "تصدير صورة",
"menu.exportImage.svg": "SVG",
"menu.exportImage.png": "PNG",
"menu.exportImage.jpg": "JPG",
"menu.cleanBoard": "مسح اللوحة",
"menu.github": "غيت هب",
// Dialog translations
"dialog.mermaid.title": "من Mermaid إلى Drawnix",
"dialog.mermaid.description": "يدعم حاليًا",
"dialog.mermaid.flowchart": "المخططات الانسيابية",
"dialog.mermaid.sequence": "مخططات التسلسل",
"dialog.mermaid.class": "مخططات الفئات",
"dialog.mermaid.otherTypes": "، وأنواع أخرى من المخططات (تُعرض كصور).",
"dialog.mermaid.syntax": "صيغة Mermaid",
"dialog.mermaid.placeholder": "اكتب تعريف المخطط هنا...",
"dialog.mermaid.preview": "معاينة",
"dialog.mermaid.insert": "إدراج",
"dialog.markdown.description": "يدعم التحويل التلقائي من Markdown إلى خريطة ذهنية.",
"dialog.markdown.syntax": "صيغة Markdown",
"dialog.markdown.placeholder": "اكتب نص Markdown هنا...",
"dialog.markdown.preview": "معاينة",
"dialog.markdown.insert": "إدراج",
"dialog.error.loadMermaid": "فشل في تحميل مكتبة Mermaid",
// Extra tools menu items
"extraTools.mermaidToDrawnix": "من Mermaid إلى Drawnix",
"extraTools.markdownToDrawnix": "من Markdown إلى Drawnix",
// Clean confirm dialog
"cleanConfirm.title": "مسح اللوحة",
"cleanConfirm.description": "سيؤدي هذا إلى مسح اللوحة بالكامل. هل تريد المتابعة؟",
"cleanConfirm.cancel": "إلغاء",
"cleanConfirm.ok": "موافق",
// Link popup items
"popupLink.delLink": "حذف الرابط",
// Tool popup items
"popupToolbar.fillColor": "لون التعبئة",
"popupToolbar.fontSize": "حجم الخط",
"popupToolbar.fontColor": "لون الخط",
"popupToolbar.link": "إدراج رابط",
"popupToolbar.stroke": "الحد",
'popupToolbar.opacity': 'مستوى شفافية',
// Text placeholders
"textPlaceholders.link": "رابط",
"textPlaceholders.text": "نص",
// Line tool
"line.source": "بداية",
"line.target": "نهاية",
"line.arrow": "سهم",
"line.none": "لا شيء",
// Stroke style
"stroke.solid": "صلب",
"stroke.dashed": "متقطع",
"stroke.dotted": "منقط",
//markdown example
// "markdown.example": "# لقد بدأت\n\n- دعني أرى من تسبب بهذا الخطأ 🕵️ ♂️ 🔍\n - 😯 💣\n - اتضح أنه أنا 👈 🎯 💘\n\n- بشكل غير متوقع، لا يعمل؛ لماذا 🚫 ⚙️ ❓\n - بشكل غير متوقع، أصبح يعمل الآن؛ لماذا؟ 🎢 ✨\n - 🤯 ⚡ ➡️ 🎉\n\n- ما الذي يمكن تشغيله 🐞 🚀\n - إذًا لا تلمسه 🛑 ✋\n - 👾 💥 🏹 🎯\n\n## ولد أم بنت 👶 ❓ 🤷 ♂️ ♀️\n\n### مرحبًا بالعالم 👋 🌍 ✨ 💻\n\n#### واو، مبرمج 🤯 ⌨️ 💡 👩 💻",
'markdown.example': `# I have started
- دعني أرى من تسبب بهذا الخطأ 🕵️ ♂️ 🔍
- 😯 💣
- اتضح أنه أنا 👈 🎯 💘
- بشكل غير متوقع، لا يعمل؛ لماذا 🚫 ⚙️ ❓
- بشكل غير متوقع، أصبح يعمل الآن؛ لماذا؟ 🎢 ✨
- 🤯 ⚡ ➡️ 🎉
- ما الذي يمكن تشغيله 🐞 🚀
- إذًا لا تلمسه 🛑 ✋
- 👾 💥 🏹 🎯
## ولد أم بنت 👶 ❓ 🤷 ♂️ ♀️
### Hello world 👋 🌍 ✨ 💻
#### Wow, a programmer 🤯 ⌨️ 💡 👩 💻`,
// Draw elements text
"draw.lineText": "نص",
"draw.geometryText": "نص",
// Mind map elements text
"mind.centralText": "الموضوع المركزي",
"mind.abstractNodeText": "ملخص",
'tutorial.title': 'Drawnix',
'tutorial.description': 'سبورة شاملة تتضمن الخرائط الذهنية والمخططات الانسيابية والرسم الحر وغير ذلك',
'tutorial.dataDescription': 'تُحفظ جميع البيانات محليًا في متصفحك',
'tutorial.appToolbar': 'تصدير، إعدادات اللغة، ...',
'tutorial.creationToolbar': 'اختر أداة لبدء الإنشاء',
'tutorial.themeDescription': 'التبديل بين السمة الفاتحة والداكنة',
};
export default arTranslations;
================================================
FILE: packages/drawnix/src/i18n/translations/en.ts
================================================
import { Translations } from '../types';
const enTranslations: Translations = {
// Toolbar items
'toolbar.hand': 'Hand — H',
'toolbar.selection': 'Selection — V',
'toolbar.mind': 'Mind — M',
'toolbar.text': 'Text — T',
'toolbar.arrow': 'Arrow — A',
'toolbar.shape': 'Shape',
'toolbar.image': 'Image — Cmd+U',
'toolbar.extraTools': 'Extra Tools',
'toolbar.pen': 'Pen — P',
'toolbar.eraser': 'Eraser — E',
'toolbar.arrow.straight': 'Straight Arrow Line',
'toolbar.arrow.elbow': 'Elbow Arrow Line',
'toolbar.arrow.curve': 'Curve Arrow Line',
'toolbar.shape.rectangle': 'Rectangle — R',
'toolbar.shape.ellipse': 'Ellipse — O',
'toolbar.shape.triangle': 'Triangle',
'toolbar.shape.terminal': 'Terminal',
'toolbar.shape.noteCurlyLeft': 'Curly Note — Left',
'toolbar.shape.noteCurlyRight': 'Curly Note — Right',
'toolbar.shape.diamond': 'Diamond',
'toolbar.shape.parallelogram': 'Parallelogram',
'toolbar.shape.roundRectangle': 'Round Rectangle',
// Zoom controls
'zoom.in': 'Zoom In — Cmd++',
'zoom.out': 'Zoom Out — Cmd+-',
'zoom.fit': 'Fit to Screen',
'zoom.100': 'Zoom to 100%',
// Themes
'theme.default': 'Default',
'theme.colorful': 'Colorful',
'theme.soft': 'Soft',
'theme.retro': 'Retro',
'theme.dark': 'Dark',
'theme.starry': 'Starry',
// Colors
'color.none': 'Topic Color',
'color.unknown': 'Other Color',
'color.default': 'Basic Black',
'color.white': 'White',
'color.gray': 'Grey',
'color.deepBlue': 'Deep Blue',
'color.red': 'Red',
'color.green': 'Green',
'color.yellow': 'Yellow',
'color.purple': 'Purple',
'color.orange': 'Orange',
'color.pastelPink': 'Paster Pink',
'color.cyan': 'Cyan',
'color.brown': 'Brown',
'color.forestGreen': 'Forest Green',
'color.lightGray': 'Light Grey',
// General
'general.undo': 'Undo',
'general.redo': 'Redo',
'general.menu': 'App Menu',
'general.duplicate': 'Duplicate',
'general.delete': 'Delete',
// Language
'language.switcher': 'Language',
'language.chinese': '中文',
'language.english': 'English',
'language.russian': 'Русский',
'language.arabic': 'عربي',
'language.vietnamese': 'Tiếng Việt',
// Menu items
'menu.open': 'Open',
'menu.saveFile': 'Save File',
'menu.exportImage': 'Export Image',
'menu.exportImage.svg': 'SVG',
'menu.exportImage.png': 'PNG',
'menu.exportImage.jpg': 'JPG',
'menu.cleanBoard': 'Clear Board',
'menu.github': 'GitHub',
// Dialog translations
'dialog.mermaid.title': 'Mermaid to Drawnix',
'dialog.mermaid.description': 'Currently supports',
'dialog.mermaid.flowchart': 'flowcharts',
'dialog.mermaid.sequence': 'sequence diagrams',
'dialog.mermaid.class': 'class diagrams',
'dialog.mermaid.otherTypes':
', and other diagram types (rendered as images).',
'dialog.mermaid.syntax': 'Mermaid Syntax',
'dialog.mermaid.placeholder': 'Write your Mermaid chart definition here…',
'dialog.mermaid.preview': 'Preview',
'dialog.mermaid.insert': 'Insert',
'dialog.markdown.description':
'Supports automatic conversion of Markdown syntax to mind map.',
'dialog.markdown.syntax': 'Markdown Syntax',
'dialog.markdown.placeholder': 'Write your Markdown text definition here...',
'dialog.markdown.preview': 'Preview',
'dialog.markdown.insert': 'Insert',
'dialog.error.loadMermaid': 'Failed to load Mermaid library',
// Extra tools menu items
'extraTools.mermaidToDrawnix': 'Mermaid to Drawnix',
'extraTools.markdownToDrawnix': 'Markdown to Drawnix',
// Clean confirm dialog
'cleanConfirm.title': 'Clear Board',
'cleanConfirm.description':
'This will clear the entire board. Do you want to continue?',
'cleanConfirm.cancel': 'Cancel',
'cleanConfirm.ok': 'OK',
// Link popup items
'popupLink.delLink': 'Delete Link',
// Tool popup items
'popupToolbar.fillColor': 'Fill Color',
'popupToolbar.fontSize': 'Font Size',
'popupToolbar.fontColor': 'Font Color',
'popupToolbar.link': 'Insert Link',
'popupToolbar.stroke': 'Stroke',
'popupToolbar.opacity': 'Opacity',
// Text placeholders
'textPlaceholders.link': 'Link',
'textPlaceholders.text': 'Text',
// Line tool
'line.source': 'Start',
'line.target': 'End',
'line.arrow': 'Arrow',
'line.none': 'None',
// Stroke style
'stroke.solid': 'Solid',
'stroke.dashed': 'Dashed',
'stroke.dotted': 'Dotted',
//markdown example
'markdown.example': `# I have started
- Let me see who made this bug 🕵️ ♂️ 🔍
- 😯 💣
- Turns out it was me 👈 🎯 💘
- Unexpectedly, it cannot run; why is that 🚫 ⚙️ ❓
- Unexpectedly, it can run now; why is that? 🎢 ✨
- 🤯 ⚡ ➡️ 🎉
- What can run 🐞 🚀
- then do not touch it 🛑 ✋
- 👾 💥 🏹 🎯
## Boy or girl 👶 ❓ 🤷 ♂️ ♀️
### Hello world 👋 🌍 ✨ 💻
#### Wow, a programmer 🤯 ⌨️ 💡 👩 💻`,
// Draw elements text
'draw.lineText': 'Text',
'draw.geometryText': 'Text',
// Mind map elements text
'mind.centralText': 'Central Topic',
'mind.abstractNodeText': 'Summary',
'tutorial.title': 'Drawnix',
'tutorial.description':
'All-in-one whiteboard, including mind maps, flowcharts, free drawing, and more',
'tutorial.dataDescription': 'All data is stored locally in your browser',
'tutorial.appToolbar': 'Export, language settings, ...',
'tutorial.creationToolbar': 'Select a tool to start your creation',
'tutorial.themeDescription': 'Switch between light and dark themes',
};
export default enTranslations;
================================================
FILE: packages/drawnix/src/i18n/translations/index.ts
================================================
import zhTranslations from './zh';
import enTranslations from './en';
import ruTranslations from './ru';
import arTranslations from './ar';
import viTranslations from './vi';
export { zhTranslations, enTranslations, ruTranslations,arTranslations, viTranslations };
================================================
FILE: packages/drawnix/src/i18n/translations/ru.ts
================================================
import { Translations } from '../types';
const ruTranslations: Translations = {
// Toolbar items
'toolbar.hand': 'Рука — H',
'toolbar.selection': 'Выделение — V',
'toolbar.mind': 'Mind-карта — M',
'toolbar.text': 'Текст — T',
'toolbar.arrow': 'Стрелка — A',
'toolbar.shape': 'Фигуры',
'toolbar.image': 'Изображение — Cmd+U',
'toolbar.extraTools': 'Дополнительно',
'toolbar.pen': 'Карандаш — P',
'toolbar.eraser': 'Ластик — E',
'toolbar.arrow.straight': 'Прямая стрелка',
'toolbar.arrow.elbow': 'Ломаная стрелка',
'toolbar.arrow.curve': 'Кривая стрелка',
'toolbar.shape.rectangle': 'Прямоугольник — R',
'toolbar.shape.ellipse': 'Эллипс — O',
'toolbar.shape.triangle': 'Треугольник',
'toolbar.shape.terminal': 'Останов',
'toolbar.shape.noteCurlyLeft': 'Фигурная заметка — слева',
'toolbar.shape.noteCurlyRight': 'Фигурная заметка — справа',
'toolbar.shape.diamond': 'Ромб',
'toolbar.shape.parallelogram': 'Параллелограмм',
'toolbar.shape.roundRectangle': 'Скруглённый прямоугольник',
// Zoom controls
'zoom.in': 'Увеличить — Cmd++',
'zoom.out': 'Уменьшить — Cmd+-',
'zoom.fit': 'По размеру экрана',
'zoom.100': 'Сбросить к 100%',
// Themes
'theme.default': 'Стандартная',
'theme.colorful': 'Красочная',
'theme.soft': 'Мягкая',
'theme.retro': 'Старинная',
'theme.dark': 'Тёмная',
'theme.starry': 'Звёздная',
// Colors
'color.none': 'Автоматически',
'color.unknown': 'Другой цвет',
'color.default': 'Чёрный',
'color.white': 'Белый',
'color.gray': 'Серый',
'color.deepBlue': 'Голубой',
'color.red': 'Красный',
'color.green': 'Зелёный',
'color.yellow': 'Жёлтый',
'color.purple': 'Фиолетовый',
'color.orange': 'Оранжевый',
'color.pastelPink': 'Розовый',
'color.cyan': 'Лиловый',
'color.brown': 'Коричневый',
'color.forestGreen': 'Сосновный',
'color.lightGray': 'Светло-серый',
// General
'general.undo': 'Отменить',
'general.redo': 'Вернуть',
'general.menu': 'Меню приложения',
'general.duplicate': 'Дублировать',
'general.delete': 'Удалить',
// Language
'language.switcher': 'Language',
'language.chinese': '中文',
'language.english': 'English',
'language.russian': 'Русский',
'language.arabic': 'عربي',
'language.vietnamese': 'Tiếng Việt',
// Menu items
'menu.open': 'Открыть',
'menu.saveFile': 'Сохранить',
'menu.exportImage': 'Экспортировать',
'menu.exportImage.svg': 'SVG',
'menu.exportImage.png': 'PNG',
'menu.exportImage.jpg': 'JPG',
'menu.cleanBoard': 'Очистить доску',
'menu.github': 'GitHub',
// Dialog translations
'dialog.mermaid.title': 'Mermaid в Drawnix',
'dialog.mermaid.description': 'Поддерживаются',
'dialog.mermaid.flowchart': 'блок-схемы',
'dialog.mermaid.sequence': 'диаграммы последовательностей',
'dialog.mermaid.class': 'диаграммы классов',
'dialog.mermaid.otherTypes':
' и другие диаграммы (преобразуются в изображения).',
'dialog.mermaid.syntax': 'Синтаксис Mermaid',
'dialog.mermaid.placeholder':
'Введите сюда описание вашей Mermaid-диаграммы…',
'dialog.mermaid.preview': 'Предпросмотр',
'dialog.mermaid.insert': 'Вставить',
'dialog.markdown.description':
'Поддерживается автоматическое преобразование синтаксиса Markdown в mind-карты.',
'dialog.markdown.syntax': 'Синтаксис Markdown',
'dialog.markdown.placeholder':
'Введите сюда описание вашего текста Markdown…',
'dialog.markdown.preview': 'Предпросмотр',
'dialog.markdown.insert': 'Вставить',
'dialog.error.loadMermaid': 'Не удалось загрузить библотеку Mermaid',
// Extra tools menu items
'extraTools.mermaidToDrawnix': 'Mermaid в Drawnix',
'extraTools.markdownToDrawnix': 'Markdown в Drawnix',
// Clean confirm dialog
'cleanConfirm.title': 'Очистить доску',
'cleanConfirm.description':
'Это удалит всё содержимое доски. Вы хотите продолжить?',
'cleanConfirm.cancel': 'Отмена',
'cleanConfirm.ok': 'ОК',
// Link popup items
'popupLink.delLink': 'Удалить ссылку',
// Tool popup items
'popupToolbar.fillColor': 'Цвет заливки',
'popupToolbar.fontSize': 'Размер шрифта',
'popupToolbar.fontColor': 'Цвет текста',
'popupToolbar.link': 'Вставить ссылку',
'popupToolbar.stroke': 'Контур',
'popupToolbar.opacity': 'Прозрачность',
// Text placeholders
'textPlaceholders.link': 'Ссылка',
'textPlaceholders.text': 'Текст',
// Line tool
'line.source': 'Начало',
'line.target': 'Конец',
'line.arrow': 'Стрелка',
'line.none': 'Нет',
// Stroke style
'stroke.solid': 'Сплошной',
'stroke.dashed': 'Штриховой',
'stroke.dotted': 'Пунктирный',
//markdown example
'markdown.example': `# I have started
- Let me see who made this bug 🕵️ ♂️ 🔍
- 😯 💣
- Turns out it was me 👈 🎯 💘
- Unexpectedly, it cannot run; why is that 🚫 ⚙️ ❓
- Unexpectedly, it can run now; why is that? 🎢 ✨
- 🤯 ⚡ ➡️ 🎉
- What can run 🐞 🚀
- then do not touch it 🛑 ✋
- 👾 💥 🏹 🎯
## Boy or girl 👶 ❓ 🤷 ♂️ ♀️
### Hello world 👋 🌍 ✨ 💻
#### Wow, a programmer 🤯 ⌨️ 💡 👩 💻`,
// Draw elements text
'draw.lineText': 'Текст',
'draw.geometryText': 'Текст',
// Mind map elements text
'mind.centralText': 'Центральная тема',
'mind.abstractNodeText': 'Резюме',
'tutorial.title': 'Drawnix',
'tutorial.description':
'Универсальная доска: майнд-карты, блок-схемы, свободное рисование и многое другое',
'tutorial.dataDescription': 'Все данные хранятся локально в вашем браузере',
'tutorial.appToolbar': 'Экспорт, настройки языка, ...',
'tutorial.creationToolbar': 'Выберите инструмент, чтобы начать творить',
'tutorial.themeDescription': 'Переключение между светлой и тёмной темами',
};
export default ruTranslations;
================================================
FILE: packages/drawnix/src/i18n/translations/vi.ts
================================================
import { Translations } from '../types';
const viTranslations: Translations = {
// Toolbar items
'toolbar.hand': 'Kéo — H',
'toolbar.selection': 'Chọn — V',
'toolbar.mind': 'Mind Map — M',
'toolbar.text': 'Văn bản — T',
'toolbar.arrow': 'Mũi tên — A',
'toolbar.shape': 'Hình dạng',
'toolbar.image': 'Hình ảnh — Cmd+U',
'toolbar.extraTools': 'Công cụ mở rộng',
'toolbar.pen': 'Bút vẽ — P',
'toolbar.eraser': 'Tẩy — E',
'toolbar.arrow.straight': 'Mũi tên thẳng',
'toolbar.arrow.elbow': 'Mũi tên vuông góc',
'toolbar.arrow.curve': 'Mũi tên cong',
'toolbar.shape.rectangle': 'Hình chữ nhật — R',
'toolbar.shape.ellipse': 'Hình elip — O',
'toolbar.shape.triangle': 'Hình tam giác',
'toolbar.shape.terminal': 'Terminal',
'toolbar.shape.noteCurlyLeft': 'Ghi chú ngoặc móc trái',
'toolbar.shape.noteCurlyRight': 'Ghi chú ngoặc móc phải',
'toolbar.shape.diamond': 'Hình thoi',
'toolbar.shape.parallelogram': 'Hình bình hành',
'toolbar.shape.roundRectangle': 'Hình chữ nhật bo tròn',
// Zoom controls
'zoom.in': 'Phóng to — Cmd++',
'zoom.out': 'Thu nhỏ — Cmd+-',
'zoom.fit': 'Vừa màn hình',
'zoom.100': 'Zoom 100%',
// Themes
'theme.default': 'Mặc định',
'theme.colorful': 'Đầy màu sắc',
'theme.soft': 'Nhẹ nhàng',
'theme.retro': 'Cổ điển',
'theme.dark': 'Tối',
'theme.starry': 'Bầu trời sao',
// Colors
'color.none': 'Màu chủ đề',
'color.unknown': 'Màu khác',
'color.default': 'Đen cơ bản',
'color.white': 'Trắng',
'color.gray': 'Xám',
'color.deepBlue': 'Xanh đậm',
'color.red': 'Đỏ',
'color.green': 'Xanh lá',
'color.yellow': 'Vàng',
'color.purple': 'Tím',
'color.orange': 'Cam',
'color.pastelPink': 'Hồng phấn',
'color.cyan': 'Xanh lơ',
'color.brown': 'Nâu',
'color.forestGreen': 'Xanh rừng',
'color.lightGray': 'Xám nhạt',
// General
'general.undo': 'Hoàn tác',
'general.redo': 'Làm lại',
'general.menu': 'Menu ứng dụng',
'general.duplicate': 'Nhân bản',
'general.delete': 'Xóa',
// Language
'language.switcher': 'Ngôn ngữ',
'language.chinese': '中文',
'language.english': 'English',
'language.russian': 'Русский',
'language.arabic': 'عربي',
'language.vietnamese': 'Tiếng Việt',
// Menu items
'menu.open': 'Mở',
'menu.saveFile': 'Lưu tệp',
'menu.exportImage': 'Xuất hình ảnh',
'menu.exportImage.svg': 'SVG',
'menu.exportImage.png': 'PNG',
'menu.exportImage.jpg': 'JPG',
'menu.cleanBoard': 'Xóa bảng',
'menu.github': 'GitHub',
// Dialog translations
'dialog.mermaid.title': 'Mermaid sang Drawnix',
'dialog.mermaid.description': 'Hiện hỗ trợ',
'dialog.mermaid.flowchart': 'lưu đồ',
'dialog.mermaid.sequence': 'biểu đồ tuần tự',
'dialog.mermaid.class': 'biểu đồ lớp',
'dialog.mermaid.otherTypes':
', và các loại biểu đồ khác (hiển thị dưới dạng hình ảnh).',
'dialog.mermaid.syntax': 'Cú pháp Mermaid',
'dialog.mermaid.placeholder': 'Viết định nghĩa biểu đồ Mermaid của bạn ở đây...',
'dialog.mermaid.preview': 'Xem trước',
'dialog.mermaid.insert': 'Chèn',
'dialog.markdown.description':
'Hỗ trợ tự động chuyển đổi cú pháp Markdown sang sơ đồ tư duy.',
'dialog.markdown.syntax': 'Cú pháp Markdown',
'dialog.markdown.placeholder': 'Viết nội dung Markdown của bạn ở đây...',
'dialog.markdown.preview': 'Xem trước',
'dialog.markdown.insert': 'Chèn',
'dialog.error.loadMermaid': 'Không thể tải thư viện Mermaid',
// Extra tools menu items
'extraTools.mermaidToDrawnix': 'Mermaid sang Drawnix',
'extraTools.markdownToDrawnix': 'Markdown sang Drawnix',
// Clean confirm dialog
'cleanConfirm.title': 'Xóa bảng',
'cleanConfirm.description':
'Thao tác này sẽ xóa toàn bộ bảng. Bạn có muốn tiếp tục không?',
'cleanConfirm.cancel': 'Hủy',
'cleanConfirm.ok': 'Đồng ý',
// Link popup items
'popupLink.delLink': 'Xóa liên kết',
// Tool popup items
'popupToolbar.fillColor': 'Màu tô',
'popupToolbar.fontSize': 'Cỡ chữ',
'popupToolbar.fontColor': 'Màu chữ',
'popupToolbar.link': 'Chèn liên kết',
'popupToolbar.stroke': 'Đường viền',
'popupToolbar.opacity': 'Độ trong suốt',
// Text placeholders
'textPlaceholders.link': 'Liên kết',
'textPlaceholders.text': 'Văn bản',
// Line tool
'line.source': 'Bắt đầu',
'line.target': 'Kết thúc',
'line.arrow': 'Mũi tên',
'line.none': 'Không',
// Stroke style
'stroke.solid': 'Nét liền',
'stroke.dashed': 'Nét đứt',
'stroke.dotted': 'Nét chấm',
//markdown example
'markdown.example': `# Tôi đã bắt đầu
- Hãy xem ai đã tạo ra lỗi này 🕵️ ♂️ 🔍
- 😯 💣
- Hóa ra là tôi 👈 🎯 💘
- Bất ngờ thay, nó không chạy được; tại sao vậy 🚫 ⚙️ ❓
- Bất ngờ thay, giờ nó chạy được rồi; tại sao vậy? 🎢 ✨
- 🤯 ⚡ ➡️ 🎉
- Cái gì chạy được 🐞 🚀
- thì đừng chạm vào nó 🛑 ✋
- 👾 💥 🏹 🎯
## Trai hay gái 👶 ❓ 🤷 ♂️ ♀️
### Xin chào thế giới 👋 🌍 ✨ 💻
#### Wow, một lập trình viên 🤯 ⌨️ 💡 👩 💻`,
// Draw elements text
'draw.lineText': 'Văn bản',
'draw.geometryText': 'Văn bản',
// Mind map elements text
'mind.centralText': 'Chủ đề trung tâm',
'mind.abstractNodeText': 'Tóm tắt',
'tutorial.title': 'DPIT Draw MindMap',
'tutorial.description': 'Bảng trắng tất cả trong một, bao gồm sơ đồ tư duy, lưu đồ, vẽ tự do và hơn thế nữa',
'tutorial.dataDescription': 'Tất cả dữ liệu được lưu trữ cục bộ trong trình duyệt của bạn',
'tutorial.appToolbar': 'Xuất, cài đặt ngôn ngữ, ...',
'tutorial.creationToolbar': 'Chọn một công cụ để bắt đầu sáng tạo',
'tutorial.themeDescription': 'Chuyển đổi giữa chế độ sáng và tối',
};
export default viTranslations;
================================================
FILE: packages/drawnix/src/i18n/translations/zh.ts
================================================
import { Translations } from '../types';
const zhTranslations: Translations = {
// Toolbar items
'toolbar.hand': '手形工具 — H',
'toolbar.selection': '选择 — V',
'toolbar.mind': '思维导图 — M',
'toolbar.text': '文本 — T',
'toolbar.arrow': '箭头 — A',
'toolbar.shape': '形状',
'toolbar.image': '图片 — Cmd+U',
'toolbar.extraTools': '更多工具',
'toolbar.pen': '画笔 — P',
'toolbar.eraser': '橡皮擦 — E',
'toolbar.arrow.straight': '直线',
'toolbar.arrow.elbow': '肘线',
'toolbar.arrow.curve': '曲线',
'toolbar.shape.rectangle': '长方形 — R',
'toolbar.shape.ellipse': '圆 — O',
'toolbar.shape.triangle': '三角形',
'toolbar.shape.terminal': '椭圆角矩形',
'toolbar.shape.noteCurlyLeft': '左花括注释',
'toolbar.shape.noteCurlyRight': '右花括注释',
'toolbar.shape.diamond': '菱形',
'toolbar.shape.parallelogram': '平行四边形',
'toolbar.shape.roundRectangle': '圆角矩形',
// Zoom controls
'zoom.in': '放大 — Cmd++',
'zoom.out': '缩小 — Cmd+-',
'zoom.fit': '自适应',
'zoom.100': '缩放至 100%',
// Themes
'theme.default': '默认',
'theme.colorful': '缤纷',
'theme.soft': '柔和',
'theme.retro': '复古',
'theme.dark': '暗夜',
'theme.starry': '星空',
// Colors
'color.none': '主题颜色',
'color.unknown': '其他颜色',
'color.default': '黑色',
'color.white': '白色',
'color.gray': '灰色',
'color.deepBlue': '深蓝色',
'color.red': '红色',
'color.green': '绿色',
'color.yellow': '黄色',
'color.purple': '紫色',
'color.orange': '橙色',
'color.pastelPink': '淡粉色',
'color.cyan': '青色',
'color.brown': '棕色',
'color.forestGreen': '森绿色',
'color.lightGray': '浅灰色',
// General
'general.undo': '撤销',
'general.redo': '重做',
'general.menu': '应用菜单',
'general.duplicate': '复制',
'general.delete': '删除',
// Language
'language.switcher': 'Language',
'language.chinese': '中文',
'language.english': 'English',
'language.russian': 'Русский',
'language.arabic': 'عربي',
'language.vietnamese': 'Tiếng Việt',
// Menu items
'menu.open': '打开',
'menu.saveFile': '保存文件',
'menu.exportImage': '导出图片',
'menu.exportImage.svg': 'SVG',
'menu.exportImage.png': 'PNG',
'menu.exportImage.jpg': 'JPG',
'menu.cleanBoard': '清除画布',
'menu.github': 'GitHub',
// Dialog translations
'dialog.mermaid.title': 'Mermaid 转 Drawnix',
'dialog.mermaid.description': '目前仅支持',
'dialog.mermaid.flowchart': '流程图',
'dialog.mermaid.sequence': '序列图',
'dialog.mermaid.class': '类图',
'dialog.mermaid.otherTypes': '。其他类型在 Drawnix 中将以图片呈现。',
'dialog.mermaid.syntax': 'Mermaid 语法',
'dialog.mermaid.placeholder': '在此处编写 Mermaid 图表定义…',
'dialog.mermaid.preview': '预览',
'dialog.mermaid.insert': '插入',
'dialog.markdown.description': '支持 Markdown 语法自动转换为思维导图。',
'dialog.markdown.syntax': 'Markdown 语法',
'dialog.markdown.placeholder': '在此处编写 Markdown 文本定义…',
'dialog.markdown.preview': '预览',
'dialog.markdown.insert': '插入',
'dialog.error.loadMermaid': '加载 Mermaid 库失败',
// Extra tools menu items
'extraTools.mermaidToDrawnix': 'Mermaid 到 Drawnix',
'extraTools.markdownToDrawnix': 'Markdown 到 Drawnix',
// Clean confirm dialog
'cleanConfirm.title': '清除画布',
'cleanConfirm.description': '这将会清除整个画布。你是否要继续?',
'cleanConfirm.cancel': '取消',
'cleanConfirm.ok': '确认',
// Link popup items
'popupLink.delLink': '移除连结',
// Tool popup items
'popupToolbar.fillColor': '填充颜色',
'popupToolbar.fontSize': '字号',
'popupToolbar.fontColor': '字体颜色',
'popupToolbar.link': '链接',
'popupToolbar.stroke': '边框',
'popupToolbar.opacity': '不透明度',
// Text placeholders
'textPlaceholders.link': '链接',
'textPlaceholders.text': '文本',
// Line tool
'line.source': '起点',
'line.target': '终点',
'line.arrow': '箭头',
'line.none': '无',
// Stroke style
'stroke.solid': '实线',
'stroke.dashed': '虚线',
'stroke.dotted': '点线',
// Draw elements text
'draw.lineText': '文本',
'draw.geometryText': '文本',
// Mind map elements text
'mind.centralText': '中心主题',
'mind.abstractNodeText': '摘要',
//markdown example
'markdown.example': `# 我开始了
- 让我看看是谁搞出了这个 bug 🕵️ ♂️ 🔍
- 😯 💣
- 原来是我 👈 🎯 💘
- 竟然不可以运行,为什么呢 🚫 ⚙️ ❓
- 竟然可以运行了,为什么呢?🎢 ✨
- 🤯 ⚡ ➡️ 🎉
- 能运行起来的 🐞 🚀
- 就不要去动它 🛑 ✋
- 👾 💥 🏹 🎯
## 男孩还是女孩 👶 ❓ 🤷 ♂️ ♀️
### Hello world 👋 🌍 ✨ 💻
#### 哇 是个程序员 🤯 ⌨️ 💡 👩 💻`,
'tutorial.title': 'Drawnix',
'tutorial.description': 'All-in-one 白板,包含思维导图、流程图、自由画笔等',
'tutorial.dataDescription': '所有数据被存在你的浏览器本地',
'tutorial.appToolbar': '导出,语言设置,...',
'tutorial.creationToolbar': '选择一个工具开始你的创作',
'tutorial.themeDescription': '在明亮和黑暗主题之间切换',
};
export default zhTranslations;
================================================
FILE: packages/drawnix/src/i18n/types.ts
================================================
import { ReactNode } from 'react';
// Define supported languages
export type Language = 'zh' | 'en' | 'ru' | 'ar' | 'vi';
// Define translation keys and their corresponding values
export interface Translations {
// Toolbar items
'toolbar.hand': string;
'toolbar.selection': string;
'toolbar.mind': string;
'toolbar.text': string;
'toolbar.arrow': string;
'toolbar.shape': string;
'toolbar.image': string;
'toolbar.extraTools': string;
'toolbar.pen': string;
'toolbar.eraser': string;
'toolbar.arrow.straight': string;
'toolbar.arrow.elbow': string;
'toolbar.arrow.curve': string;
'toolbar.shape.rectangle': string;
'toolbar.shape.ellipse': string;
'toolbar.shape.triangle': string;
'toolbar.shape.terminal': string;
'toolbar.shape.noteCurlyLeft': string;
'toolbar.shape.noteCurlyRight': string;
'toolbar.shape.diamond': string;
'toolbar.shape.parallelogram': string;
'toolbar.shape.roundRectangle': string;
// Zoom controls
'zoom.in': string;
'zoom.out': string;
'zoom.fit': string;
'zoom.100': string;
// Themes
'theme.default': string;
'theme.colorful': string;
'theme.soft': string;
'theme.retro': string;
'theme.dark': string;
'theme.starry': string;
// Colors
'color.none': string;
'color.unknown': string;
'color.default': string;
'color.white': string;
'color.gray': string;
'color.deepBlue': string;
'color.red': string;
'color.green': string;
'color.yellow': string;
'color.purple': string;
'color.orange': string;
'color.pastelPink': string;
'color.cyan': string;
'color.brown': string;
'color.forestGreen': string;
'color.lightGray': string;
// General
'general.undo': string;
'general.redo': string;
'general.menu': string;
'general.duplicate': string;
'general.delete': string;
// Language
'language.switcher': string;
'language.chinese': string;
'language.english': string;
'language.russian': string;
'language.arabic': string;
'language.vietnamese': string;
// Menu items
'menu.open': string;
'menu.saveFile': string;
'menu.exportImage': string;
'menu.exportImage.svg': string;
'menu.exportImage.png': string;
'menu.exportImage.jpg': string;
'menu.cleanBoard': string;
'menu.github': string;
// Dialog translations
'dialog.mermaid.title': string;
'dialog.mermaid.description': string;
'dialog.mermaid.flowchart': string;
'dialog.mermaid.sequence': string;
'dialog.mermaid.class': string;
'dialog.mermaid.otherTypes': string;
'dialog.mermaid.syntax': string;
'dialog.mermaid.placeholder': string;
'dialog.mermaid.preview': string;
'dialog.mermaid.insert': string;
'dialog.markdown.description': string;
'dialog.markdown.syntax': string;
'dialog.markdown.placeholder': string;
'dialog.markdown.preview': string;
'dialog.markdown.insert': string;
'dialog.error.loadMermaid': string;
// Extra tools menu items
'extraTools.mermaidToDrawnix': string;
'extraTools.markdownToDrawnix': string;
// Clean confirm dialog
'cleanConfirm.title': string;
'cleanConfirm.description': string;
'cleanConfirm.cancel': string;
'cleanConfirm.ok': string;
// Link popup items
'popupLink.delLink': string;
// Tool popup items
'popupToolbar.fillColor': string;
'popupToolbar.fontSize': string;
'popupToolbar.fontColor': string;
'popupToolbar.link': string;
'popupToolbar.stroke': string;
'popupToolbar.opacity': string;
// Text placeholders
'textPlaceholders.link': string;
'textPlaceholders.text': string;
// Line tool
'line.source': string;
'line.target': string;
'line.arrow': string;
'line.none': string;
// Stroke style
'stroke.solid': string;
'stroke.dashed': string;
'stroke.dotted': string;
//markdown example
'markdown.example': string;
// Draw elements text
'draw.lineText': string;
'draw.geometryText': string;
// Mind map elements text
'mind.centralText': string;
'mind.abstractNodeText': string;
'tutorial.title': string;
'tutorial.description': string;
'tutorial.dataDescription': string;
'tutorial.appToolbar': string;
'tutorial.creationToolbar': string;
'tutorial.themeDescription': string;
}
// I18n context interface
export interface I18nContextType {
language: Language;
setLanguage: (language: Language) => void;
t: (key: keyof Translations) => string;
}
// Provider props
export interface I18nProviderProps {
children: ReactNode;
defaultLanguage?: Language;
}
================================================
FILE: packages/drawnix/src/i18n.tsx
================================================
export { I18nProvider, useI18n, i18nInsidePlaitHook } from './i18n/index';
export type { Language, Translations, I18nContextType } from './i18n/types';
================================================
FILE: packages/drawnix/src/index.ts
================================================
export * from './drawnix';
export * from './utils';
export * from './i18n';
================================================
FILE: packages/drawnix/src/keys.ts
================================================
import { IS_APPLE, IS_IOS } from '@plait/core';
export const CODES = {
EQUAL: 'Equal',
MINUS: 'Minus',
NUM_ADD: 'NumpadAdd',
NUM_SUBTRACT: 'NumpadSubtract',
NUM_ZERO: 'Numpad0',
BRACKET_RIGHT: 'BracketRight',
BRACKET_LEFT: 'BracketLeft',
ONE: 'Digit1',
TWO: 'Digit2',
THREE: 'Digit3',
NINE: 'Digit9',
QUOTE: 'Quote',
ZERO: 'Digit0',
SLASH: 'Slash',
C: 'KeyC',
D: 'KeyD',
H: 'KeyH',
V: 'KeyV',
Z: 'KeyZ',
R: 'KeyR',
S: 'KeyS',
} as const;
export const KEYS = {
ARROW_DOWN: 'ArrowDown',
ARROW_LEFT: 'ArrowLeft',
ARROW_RIGHT: 'ArrowRight',
ARROW_UP: 'ArrowUp',
PAGE_UP: 'PageUp',
PAGE_DOWN: 'PageDown',
BACKSPACE: 'Backspace',
ALT: 'Alt',
CTRL_OR_CMD: IS_IOS || IS_APPLE ? 'metaKey' : 'ctrlKey',
DELETE: 'Delete',
ENTER: 'Enter',
ESCAPE: 'Escape',
QUESTION_MARK: '?',
SPACE: ' ',
TAB: 'Tab',
CHEVRON_LEFT: '<',
CHEVRON_RIGHT: '>',
PERIOD: '.',
COMMA: ',',
SUBTRACT: '-',
SLASH: '/',
A: 'a',
C: 'c',
D: 'd',
E: 'e',
F: 'f',
G: 'g',
H: 'h',
I: 'i',
L: 'l',
O: 'o',
P: 'p',
Q: 'q',
R: 'r',
S: 's',
T: 't',
V: 'v',
X: 'x',
Y: 'y',
Z: 'z',
K: 'k',
W: 'w',
0: '0',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
8: '8',
9: '9',
} as const;
export type Key = keyof typeof KEYS;
================================================
FILE: packages/drawnix/src/libs/image-viewer.ts
================================================
interface ImageViewerOptions {
zoomStep?: number;
minZoom?: number;
maxZoom?: number;
enableKeyboard?: boolean;
}
interface ImageState {
zoom: number;
x: number;
y: number;
isDragging: boolean;
dragStartX: number;
dragStartY: number;
imageStartX: number;
imageStartY: number;
}
export class ImageViewer {
private options: Required;
private overlay: HTMLDivElement | null = null;
private imageContainer: HTMLDivElement | null = null;
private image: HTMLImageElement | null = null;
private closeButton: HTMLDivElement | null = null;
private controlsContainer: HTMLDivElement | null = null;
private delegationHandler: ((e: Event) => void) | null = null;
private dragHandler: ((e: MouseEvent) => void) | null = null;
private mouseUpHandler: (() => void) | null = null;
private animationFrameId: number | null = null;
private pendingUpdate = false;
private state: ImageState = {
zoom: 1,
x: 0,
y: 0,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
imageStartX: 0,
imageStartY: 0,
};
constructor(options: ImageViewerOptions = {}) {
this.options = {
zoomStep: options.zoomStep || 0.2,
minZoom: options.minZoom || 0.1,
maxZoom: options.maxZoom || 5,
enableKeyboard: options.enableKeyboard !== false,
};
this.addStyles();
this.bindEvents();
}
// 打开图片查看器
open(src: string, alt = ''): void {
this.createOverlay();
this.createImage(src, alt);
this.resetState();
document.body.style.overflow = 'hidden';
}
// 关闭图片查看器
close(): void {
if (this.overlay) {
// 清理拖动事件监听器
this.cleanupDragEvents();
// 清理全局事件监听器
document.removeEventListener('mousemove', this.delegationHandler!);
document.removeEventListener('mouseup', this.delegationHandler!);
document.removeEventListener('keydown', this.delegationHandler!);
document.removeEventListener('wheel', this.delegationHandler!);
// 取消动画帧
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
document.body.removeChild(this.overlay);
this.overlay = null;
this.image = null;
this.imageContainer = null;
this.closeButton = null;
this.controlsContainer = null;
this.delegationHandler = null;
this.dragHandler = null;
this.mouseUpHandler = null;
this.pendingUpdate = false;
}
document.body.style.overflow = '';
}
// 创建遮罩层
private createOverlay(): void {
this.overlay = document.createElement('div');
this.overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(45, 45, 45, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
cursor: grab;
`;
// 点击遮罩层关闭
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) {
this.close();
}
});
this.createCloseButton();
this.createControls();
document.body.appendChild(this.overlay);
}
// 创建关闭按钮
private createCloseButton(): void {
this.closeButton = document.createElement('div');
this.closeButton.innerHTML = '×';
this.closeButton.className = 'image-viewer-close-btn';
this.closeButton.addEventListener('click', () => this.close());
this.overlay!.appendChild(this.closeButton);
}
// 创建控制按钮
private createControls(): void {
this.controlsContainer = document.createElement('div');
this.controlsContainer.style.cssText = `
position: absolute;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
z-index: 10001;
`;
this.addStyles();
// 放大按钮
const zoomInBtn = document.createElement('button');
zoomInBtn.innerHTML = '+';
zoomInBtn.className = 'image-viewer-control-btn';
zoomInBtn.addEventListener('click', () => this.zoomIn());
// 缩小按钮
const zoomOutBtn = document.createElement('button');
zoomOutBtn.innerHTML = '-';
zoomOutBtn.className = 'image-viewer-control-btn';
zoomOutBtn.addEventListener('click', () => this.zoomOut());
// 重置按钮
const resetBtn = document.createElement('button');
resetBtn.innerHTML = '⌂';
resetBtn.className = 'image-viewer-control-btn';
resetBtn.addEventListener('click', () => this.resetState());
this.controlsContainer.appendChild(zoomOutBtn);
this.controlsContainer.appendChild(resetBtn);
this.controlsContainer.appendChild(zoomInBtn);
this.overlay!.appendChild(this.controlsContainer);
}
// 创建图片元素
private createImage(src: string, alt: string): void {
this.imageContainer = document.createElement('div');
this.imageContainer.style.cssText = `
position: relative;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
max-width: calc(100vw - 80px);
max-height: calc(100vh - 160px);
`;
this.image = document.createElement('img');
this.image.src = src;
this.image.alt = alt;
this.image.style.cssText = `
max-width: calc(100vw - 80px);
max-height: calc(100vh - 160px);
width: auto;
height: auto;
display: block;
user-select: none;
pointer-events: none;
object-fit: contain;
`;
this.imageContainer.appendChild(this.image);
this.overlay!.appendChild(this.imageContainer);
// 绑定拖拽事件
this.bindDragEvents();
}
// 绑定拖拽事件
private bindDragEvents(): void {
if (!this.imageContainer) return;
// 使用 requestAnimationFrame 优化的拖动处理器
this.dragHandler = (e: MouseEvent) => {
if (!this.state.isDragging) return;
const deltaX = e.clientX - this.state.dragStartX;
const deltaY = e.clientY - this.state.dragStartY;
this.state.x = this.state.imageStartX + deltaX;
this.state.y = this.state.imageStartY + deltaY;
// 使用 requestAnimationFrame 优化渲染
if (!this.pendingUpdate) {
this.pendingUpdate = true;
this.animationFrameId = requestAnimationFrame(() => {
this.updateImageTransform();
this.pendingUpdate = false;
});
}
};
this.mouseUpHandler = () => {
if (this.state.isDragging) {
this.state.isDragging = false;
if (this.imageContainer) {
this.imageContainer.style.cursor = 'grab';
}
if (this.overlay) {
this.overlay.style.cursor = 'grab';
}
this.cleanupDragEvents();
}
};
this.imageContainer.addEventListener('mousedown', (e) => {
e.preventDefault();
this.state.isDragging = true;
this.state.dragStartX = e.clientX;
this.state.dragStartY = e.clientY;
this.state.imageStartX = this.state.x;
this.state.imageStartY = this.state.y;
if (this.imageContainer) {
this.imageContainer.style.cursor = 'grabbing';
}
if (this.overlay) {
this.overlay.style.cursor = 'grabbing';
}
// 添加事件监听器
if (this.dragHandler && this.mouseUpHandler) {
document.addEventListener('mousemove', this.dragHandler, { passive: true });
document.addEventListener('mouseup', this.mouseUpHandler, { once: true });
}
});
}
// 清理拖动事件监听器
private cleanupDragEvents(): void {
if (this.dragHandler) {
document.removeEventListener('mousemove', this.dragHandler);
}
if (this.mouseUpHandler) {
document.removeEventListener('mouseup', this.mouseUpHandler);
}
}
// 绑定全局事件
private bindEvents(): void {
this.delegationHandler = (e: Event) => {
if (!this.overlay) return;
if (e.type === 'keydown' && this.options.enableKeyboard) {
const keyboardEvent = e as KeyboardEvent;
switch (keyboardEvent.key) {
case 'Escape':
this.close();
break;
case '+':
case '=':
keyboardEvent.preventDefault();
this.zoomIn();
break;
case '-':
keyboardEvent.preventDefault();
this.zoomOut();
break;
case '0':
keyboardEvent.preventDefault();
this.resetState();
break;
}
} else if (e.type === 'wheel') {
const wheelEvent = e as WheelEvent;
wheelEvent.preventDefault();
if (wheelEvent.deltaY < 0) {
this.zoomIn();
} else {
this.zoomOut();
}
}
};
document.addEventListener('keydown', this.delegationHandler);
document.addEventListener('wheel', this.delegationHandler, {
passive: false,
});
}
// 放大
private zoomIn(): void {
this.state.zoom = Math.min(
this.state.zoom + this.options.zoomStep,
this.options.maxZoom
);
this.updateImageTransform();
}
// 缩小
private zoomOut(): void {
this.state.zoom = Math.max(
this.state.zoom - this.options.zoomStep,
this.options.minZoom
);
this.updateImageTransform();
}
// 重置状态
private resetState(): void {
this.state.zoom = 1;
this.state.x = 0;
this.state.y = 0;
this.updateImageTransform();
}
// 更新图片变换
private updateImageTransform(): void {
if (!this.imageContainer) return;
this.imageContainer.style.transform = `
translate(${this.state.x}px, ${this.state.y}px)
scale(${this.state.zoom})
`;
}
private styleElement: HTMLStyleElement | null = null;
// 添加样式
private addStyles(): void {
if (!this.styleElement) {
this.styleElement = document.createElement('style');
this.styleElement.textContent = `
.image-viewer-control-btn {
background: rgba(0, 0, 0, 0.8);
color: white;
border: none;
padding: 8px 14px;
border-radius: 4px;
cursor: pointer;
font-size: 18px;
transition: background 0.2s;
user-select: none;
}
.image-viewer-control-btn:hover {
background: rgba(0, 0, 0, 0.4);
}
.image-viewer-close-btn {
position: absolute;
top: 20px;
right: 30px;
color: white;
font-size: 18px;
cursor: pointer;
z-index: 10001;
user-select: none;
width: 36px;
height: 34px;
display: flex;
border-radius: 50%;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
transition: all 0.2s ease;
line-height: 34px;
padding-bottom:2px;
}
.image-viewer-close-btn:hover {
background: rgba(0, 0, 0, 0.4);
}
`;
document.head.appendChild(this.styleElement);
}
}
// 移除样式
private removeStyles(): void {
if (this.styleElement) {
document.head.removeChild(this.styleElement);
this.styleElement = null;
}
}
// 销毁实例
destroy(): void {
this.close();
this.removeStyles();
}
}
================================================
FILE: packages/drawnix/src/plugins/components/emoji.tsx
================================================
import type { EmojiProps } from '@plait/mind';
export const Emoji: React.FC = (props: EmojiProps) => {
return (
{props.emojiItem.name}
);
};
================================================
FILE: packages/drawnix/src/plugins/components/image.tsx
================================================
import type { ImageProps } from '@plait/common';
import classNames from 'classnames';
export const Image: React.FC = (props: ImageProps) => {
const imgProps = {
src: props.imageItem.url,
draggable: false,
width: '100%',
};
return (
);
};
================================================
FILE: packages/drawnix/src/plugins/freehand/freehand.component.ts
================================================
import {
PlaitBoard,
PlaitPluginElementContext,
OnContextChanged,
RectangleClient,
isSelectionMoving,
ACTIVE_STROKE_WIDTH,
} from '@plait/core';
import {
ActiveGenerator,
CommonElementFlavour,
createActiveGenerator,
hasResizeHandle,
} from '@plait/common';
import { Freehand } from './type';
import { FreehandGenerator } from './freehand.generator';
export class FreehandComponent
extends CommonElementFlavour
implements OnContextChanged
{
constructor() {
super();
}
activeGenerator!: ActiveGenerator;
generator!: FreehandGenerator;
initializeGenerator() {
this.activeGenerator = createActiveGenerator(this.board, {
getRectangle: (element: Freehand) => {
return RectangleClient.getRectangleByPoints(element.points);
},
getStrokeWidth: () => ACTIVE_STROKE_WIDTH,
getStrokeOpacity: () => 1,
hasResizeHandle: () => {
return hasResizeHandle(this.board, this.element);
},
});
this.generator = new FreehandGenerator(this.board);
}
initialize(): void {
super.initialize();
this.initializeGenerator();
this.generator.processDrawing(this.element, this.getElementG());
}
onContextChanged(
value: PlaitPluginElementContext,
previous: PlaitPluginElementContext
) {
if (value.element !== previous.element || value.hasThemeChanged) {
this.generator.processDrawing(this.element, this.getElementG());
this.activeGenerator.processDrawing(
this.element,
PlaitBoard.getActiveHost(this.board),
{
selected: this.selected,
}
);
} else {
const needUpdate = value.selected !== previous.selected;
if (needUpdate || value.selected) {
this.activeGenerator.processDrawing(
this.element,
PlaitBoard.getActiveHost(this.board),
{
selected: this.selected,
}
);
}
}
}
destroy(): void {
super.destroy();
this.activeGenerator?.destroy();
}
}
================================================
FILE: packages/drawnix/src/plugins/freehand/freehand.generator.ts
================================================
import { Generator } from '@plait/common';
import { PlaitBoard, setStrokeLinecap } from '@plait/core';
import { Options } from 'roughjs/bin/core';
import { Freehand } from './type';
import {
gaussianSmooth,
getFillByElement,
getStrokeColorByElement,
} from './utils';
import { getStrokeWidthByElement } from '@plait/draw';
export class FreehandGenerator extends Generator {
protected draw(element: Freehand): SVGGElement | undefined {
const strokeWidth = getStrokeWidthByElement(element);
const strokeColor = getStrokeColorByElement(this.board, element);
const fill = getFillByElement(this.board, element);
const option: Options = { strokeWidth, stroke: strokeColor, fill, fillStyle: 'solid' };
const g = PlaitBoard.getRoughSVG(this.board).curve(
gaussianSmooth(element.points, 1, 3),
option
);
setStrokeLinecap(g, 'round');
return g;
}
canDraw(element: Freehand): boolean {
return true;
}
}
================================================
FILE: packages/drawnix/src/plugins/freehand/smoother.ts
================================================
import { distanceBetweenPointAndPoint, Point } from '@plait/core';
interface StrokePoint {
point: Point;
pressure?: number;
timestamp: number;
tiltX?: number;
tiltY?: number;
}
export interface SmootherOptions {
smoothing?: number;
velocityWeight?: number;
curvatureWeight?: number;
minDistance?: number;
maxPoints?: number;
pressureSensitivity?: number;
tiltSensitivity?: number;
velocityThreshold?: number;
samplingRate?: number;
}
export class FreehandSmoother {
private readonly defaultOptions: Required = {
smoothing: 0.65,
velocityWeight: 0.2,
curvatureWeight: 0.3,
minDistance: 0.2, // 降低最小距离阈值
maxPoints: 8,
pressureSensitivity: 0.5,
tiltSensitivity: 0.3,
velocityThreshold: 800,
samplingRate: 5, // 降低采样间隔
};
private options: Required;
private points: StrokePoint[] = [];
private lastProcessedTime = 0;
private movingAverageVelocity: number[] = [];
private readonly velocityWindowSize = 3;
constructor(options: SmootherOptions = {}) {
this.options = { ...this.defaultOptions, ...options };
}
process(
point: Point,
data: Partial> = {}
): Point | null {
const timestamp = data.timestamp ?? Date.now();
// 第一个点直接返回
if (this.points.length === 0) {
const strokePoint: StrokePoint = { point, timestamp, ...data };
this.points.push(strokePoint);
this.lastProcessedTime = timestamp;
return point;
}
// 采样率控制 - 确保不会卡住
if (timestamp - this.lastProcessedTime < this.options.samplingRate) {
const timeDiff = timestamp - this.lastProcessedTime;
if (timeDiff < 2) {
// 如果时间间隔太小,跳过
return null;
}
}
const strokePoint: StrokePoint = {
point,
timestamp,
...data,
};
// 距离检查 - 添加最小距离的动态调整
const distanceOk = this.checkDistance(point);
if (!distanceOk && this.points.length > 1) {
// 如果距离太近,但时间间隔较大,仍然处理该点
const timeDiff = timestamp - this.lastProcessedTime;
if (timeDiff < 32) {
// 32ms ≈ 30fps
return null;
}
}
// 更新历史点
this.updatePoints(strokePoint);
// 计算动态参数
const dynamicParams = this.calculateDynamicParameters(strokePoint);
// 应用平滑
const smoothedPoint = this.smooth(point, dynamicParams);
this.lastProcessedTime = timestamp;
return smoothedPoint;
}
reset(): void {
this.points = [];
this.lastProcessedTime = 0;
this.movingAverageVelocity = [];
}
private updatePoints(point: StrokePoint): void {
this.points.push(point);
if (this.points.length > this.options.maxPoints) {
this.points.shift();
}
}
private checkDistance(point: Point): boolean {
if (this.points.length === 0) return true;
const lastPoint = this.points[this.points.length - 1].point;
const distance = this.getDistance(lastPoint, point);
// 动态最小距离:根据当前速度调整
let minDistance = this.options.minDistance;
if (this.movingAverageVelocity.length > 0) {
const avgVelocity = this.getAverageVelocity();
minDistance *= Math.max(0.5, Math.min(1.5, avgVelocity / 200));
}
return distance >= minDistance;
}
private calculateDynamicParameters(strokePoint: StrokePoint) {
const velocity = this.calculateVelocity(strokePoint);
this.updateMovingAverage(velocity);
const avgVelocity = this.getAverageVelocity();
const params = { ...this.options };
// 压力适应 - 更温和的压力响应
if (strokePoint.pressure !== undefined) {
const pressureWeight = Math.pow(strokePoint.pressure, 1.2);
params.smoothing *= 1 - pressureWeight * params.pressureSensitivity * 0.8;
}
// 速度适应 - 更平滑的过渡
const velocityFactor = Math.min(avgVelocity / params.velocityThreshold, 1);
params.velocityWeight = 0.2 + velocityFactor * 0.3;
params.smoothing *= 1 + velocityFactor * 0.2;
// 倾斜适应 - 更温和的响应
if (strokePoint.tiltX !== undefined && strokePoint.tiltY !== undefined) {
const tiltFactor =
Math.sqrt(strokePoint.tiltX ** 2 + strokePoint.tiltY ** 2) / 90;
params.smoothing *= 1 + tiltFactor * params.tiltSensitivity * 0.7;
}
return params;
}
private smooth(point: Point, params: Required): Point {
if (this.points.length < 2) return point;
const weights = this.calculateWeights(params);
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
if (totalWeight === 0) return point;
const smoothedPoint: Point = [0, 0];
for (let i = 0; i < this.points.length; i++) {
const weight = weights[i] / totalWeight;
smoothedPoint[0] += this.points[i].point[0] * weight;
smoothedPoint[1] += this.points[i].point[1] * weight;
}
return smoothedPoint;
}
private calculateWeights(params: Required): number[] {
const weights: number[] = [];
const lastIndex = this.points.length - 1;
for (let i = 0; i < this.points.length; i++) {
// 基础权重 - 使用更温和的衰减
let weight = Math.pow(params.smoothing, (lastIndex - i) * 0.8);
// 速度权重 - 更平滑的过渡
if (i < lastIndex) {
const velocity = this.getPointVelocity(i);
weight *= 1 + velocity * params.velocityWeight * 0.8;
}
// 曲率权重 - 更温和的影响
if (i > 0 && i < lastIndex) {
const curvature = this.getPointCurvature(i);
weight *= 1 + curvature * params.curvatureWeight * 0.7;
}
weights.push(weight);
}
return weights;
}
// 工具方法保持不变
private getDistance(p1: Point, p2: Point): number {
return distanceBetweenPointAndPoint(p1[0], p1[1], p2[0], p2[1]);
}
private calculateVelocity(point: StrokePoint): number {
if (this.points.length < 2) return 0;
const prevPoint = this.points[this.points.length - 1];
const distance = this.getDistance(prevPoint.point, point.point);
const timeDiff = point.timestamp - prevPoint.timestamp;
return timeDiff > 0 ? distance / timeDiff : 0;
}
private updateMovingAverage(velocity: number): void {
this.movingAverageVelocity.push(velocity);
if (this.movingAverageVelocity.length > this.velocityWindowSize) {
this.movingAverageVelocity.shift();
}
}
private getAverageVelocity(): number {
if (this.movingAverageVelocity.length === 0) return 0;
return (
this.movingAverageVelocity.reduce((a, b) => a + b) /
this.movingAverageVelocity.length
);
}
private getPointVelocity(index: number): number {
if (index >= this.points.length - 1) return 0;
const p1 = this.points[index];
const p2 = this.points[index + 1];
const distance = this.getDistance(p1.point, p2.point);
const timeDiff = p2.timestamp - p1.timestamp;
return timeDiff > 0 ? distance / timeDiff : 0;
}
private getPointCurvature(index: number): number {
if (index <= 0 || index >= this.points.length - 1) return 0;
const p1 = this.points[index - 1].point;
const p2 = this.points[index].point;
const p3 = this.points[index + 1].point;
const a = this.getDistance(p1, p2);
const b = this.getDistance(p2, p3);
const c = this.getDistance(p1, p3);
const s = (a + b + c) / 2;
const area = Math.sqrt(Math.max(0, s * (s - a) * (s - b) * (s - c)));
return (4 * area) / (a * b * c + 0.0001); // 避免除零
}
}
================================================
FILE: packages/drawnix/src/plugins/freehand/type.ts
================================================
import { DEFAULT_COLOR, Point, ThemeColorMode } from '@plait/core';
import { PlaitCustomGeometry } from '@plait/draw';
export const FreehandThemeColors = {
[ThemeColorMode.default]: {
strokeColor: DEFAULT_COLOR,
fill: 'none'
},
[ThemeColorMode.colorful]: {
strokeColor: '#06ADBF',
fill: 'none'
},
[ThemeColorMode.soft]: {
strokeColor: '#6D89C1',
fill: 'none'
},
[ThemeColorMode.retro]: {
strokeColor: '#E9C358',
fill: 'none'
},
[ThemeColorMode.dark]: {
strokeColor: '#FFFFFF',
fill: 'none'
},
[ThemeColorMode.starry]: {
strokeColor: '#42ABE5',
fill: 'none'
}
};
export enum FreehandShape {
eraser = 'eraser',
nibPen = 'nibPen',
feltTipPen = 'feltTipPen',
artisticBrush = 'artisticBrush',
markerHighlight = 'markerHighlight',
}
export const FREEHAND_TYPE = 'freehand';
export type Freehand = PlaitCustomGeometry
export const Freehand = {
isFreehand: (value: any): value is Freehand => {
return value.type === FREEHAND_TYPE;
},
};
================================================
FILE: packages/drawnix/src/plugins/freehand/utils.ts
================================================
import {
getSelectedElements,
idCreator,
isPointInPolygon,
PlaitBoard,
PlaitElement,
Point,
RectangleClient,
rotateAntiPointsByElement,
Selection,
ThemeColorMode,
} from '@plait/core';
import { Freehand, FreehandShape, FreehandThemeColors } from './type';
import {
DefaultDrawStyle,
isClosedCustomGeometry,
isClosedPoints,
isHitPolyLine,
isRectangleHitRotatedPoints,
} from '@plait/draw';
export function getFreehandPointers() {
return [FreehandShape.feltTipPen, FreehandShape.eraser];
}
export const createFreehandElement = (
shape: FreehandShape,
points: Point[]
): Freehand => {
const element: Freehand = {
id: idCreator(),
type: 'freehand',
shape,
points,
};
return element;
};
export const isHitFreehand = (
board: PlaitBoard,
element: Freehand,
point: Point
) => {
const antiPoint = rotateAntiPointsByElement(board, point, element) || point;
const points = element.points;
const fill = getFillByElement(board, element);
if (isClosedPoints(element.points) && fill && fill !== 'none') {
return (
isPointInPolygon(antiPoint, points) || isHitPolyLine(points, antiPoint)
);
} else {
return isHitPolyLine(points, antiPoint);
}
};
export const isRectangleHitFreehand = (
board: PlaitBoard,
element: Freehand,
selection: Selection
) => {
const rangeRectangle = RectangleClient.getRectangleByPoints([
selection.anchor,
selection.focus,
]);
return isRectangleHitRotatedPoints(
rangeRectangle,
element.points,
element.angle
);
};
export const getSelectedFreehandElements = (board: PlaitBoard) => {
return getSelectedElements(board).filter((ele) => Freehand.isFreehand(ele));
};
export const getFreehandDefaultStrokeColor = (theme: ThemeColorMode) => {
return FreehandThemeColors[theme].strokeColor;
};
export const getFreehandDefaultFill = (theme: ThemeColorMode) => {
return FreehandThemeColors[theme].fill;
};
export const getStrokeColorByElement = (
board: PlaitBoard,
element: PlaitElement
) => {
const defaultColor = getFreehandDefaultStrokeColor(
board.theme.themeColorMode
);
const strokeColor = element.strokeColor || defaultColor;
return strokeColor;
};
export const getFillByElement = (board: PlaitBoard, element: PlaitElement) => {
const defaultFill =
Freehand.isFreehand(element) && isClosedCustomGeometry(board, element)
? getFreehandDefaultFill(board.theme.themeColorMode)
: DefaultDrawStyle.fill;
const fill = element.fill || defaultFill;
return fill;
};
export function gaussianWeight(x: number, sigma: number) {
return Math.exp(-(x * x) / (2 * sigma * sigma));
}
export function gaussianSmooth(
points: Point[],
sigma: number,
windowSize: number
) {
if (points.length < 2) return points;
const halfWindow = Math.floor(windowSize / 2);
const smoothedPoints: Point[] = [];
// 方法1:端点镜像
function getMirroredPoint(idx: number): Point {
if (idx < 0) {
// 左端镜像
const mirrorIdx = -idx - 1;
if (mirrorIdx < points.length) {
// 以第一个点为中心的对称点
return [
2 * points[0][0] - points[mirrorIdx][0],
2 * points[0][1] - points[mirrorIdx][1],
];
}
} else if (idx >= points.length) {
// 右端镜像
const mirrorIdx = 2 * points.length - idx - 1;
if (mirrorIdx >= 0) {
// 以最后一个点为中心的对称点
return [
2 * points[points.length - 1][0] - points[mirrorIdx][0],
2 * points[points.length - 1][1] - points[mirrorIdx][1],
];
}
}
return points[idx];
}
// 方法2:自适应窗口
function getAdaptiveWindow(i: number): number {
// 端点处使用较小的窗口
const distToEdge = Math.min(i, points.length - 1 - i);
return Math.min(halfWindow, distToEdge + Math.floor(halfWindow / 2));
}
for (let i = 0; i < points.length; i++) {
let sumX = 0;
let sumY = 0;
let weightSum = 0;
// 对端点使用自适应窗口
const adaptiveWindow = getAdaptiveWindow(i);
for (let j = -adaptiveWindow; j <= adaptiveWindow; j++) {
const idx = i + j;
const point = getMirroredPoint(idx);
// 端点处使用渐变权重
let weight = gaussianWeight(j, sigma);
// 端点权重调整
if (i < halfWindow || i >= points.length - halfWindow) {
// 增加端点原始值的权重
const edgeFactor = 1 + 0.5 * (1 - Math.abs(j) / adaptiveWindow);
weight *= j === 0 ? edgeFactor : 1;
}
sumX += point[0] * weight;
sumY += point[1] * weight;
weightSum += weight;
}
// 端点处的特殊处理
if (i === 0 || i === points.length - 1) {
// 保持端点不变
smoothedPoints.push([points[i][0], points[i][1]]);
} else {
// 平滑中间点
smoothedPoints.push([sumX / weightSum, sumY / weightSum]);
}
}
return smoothedPoints;
}
================================================
FILE: packages/drawnix/src/plugins/freehand/with-freehand-create.ts
================================================
import {
PlaitBoard,
Point,
Transforms,
distanceBetweenPointAndPoint,
toHostPoint,
toViewBoxPoint,
} from '@plait/core';
import { isDrawingMode } from '@plait/common';
import { createFreehandElement, getFreehandPointers } from './utils';
import { Freehand, FreehandShape } from './type';
import { FreehandGenerator } from './freehand.generator';
import { FreehandSmoother } from './smoother';
import { isTwoFingerMode } from '@plait-board/react-board';
export const withFreehandCreate = (board: PlaitBoard) => {
const { pointerDown, pointerMove, pointerUp, globalPointerUp, touchStart } =
board;
let isDrawing = false;
let isSnappingStartAndEnd = false;
let points: Point[] = [];
let originScreenPoint: Point | null = null;
const generator = new FreehandGenerator(board);
const smoother = new FreehandSmoother({
smoothing: 0.7,
pressureSensitivity: 0.6,
});
let temporaryElement: Freehand | null = null;
const complete = (cancel?: boolean) => {
if (isDrawing) {
const pointer = PlaitBoard.getPointer(board) as FreehandShape;
if (isSnappingStartAndEnd) {
points.push(points[0]);
}
temporaryElement = createFreehandElement(pointer, points);
}
if (temporaryElement && !cancel) {
Transforms.insertNode(board, temporaryElement, [board.children.length]);
}
generator?.destroy();
temporaryElement = null;
isDrawing = false;
points = [];
smoother.reset();
};
board.touchStart = (event: TouchEvent) => {
const freehandPointers = getFreehandPointers();
const isFreehandPointer = PlaitBoard.isInPointer(board, freehandPointers);
if (isFreehandPointer && isDrawingMode(board)) {
return event.preventDefault();
}
touchStart(event);
};
board.pointerDown = (event: PointerEvent) => {
const freehandPointers = getFreehandPointers();
const isFreehandPointer = PlaitBoard.isInPointer(board, freehandPointers);
if (isFreehandPointer && isDrawingMode(board)) {
isDrawing = true;
originScreenPoint = [event.x, event.y];
const smoothingPoint = smoother.process(originScreenPoint) as Point;
const point = toViewBoxPoint(
board,
toHostPoint(board, smoothingPoint[0], smoothingPoint[1])
);
points.push(point);
}
pointerDown(event);
};
board.pointerMove = (event: PointerEvent) => {
if (isDrawing && !isTwoFingerMode(board)) {
const currentScreenPoint: Point = [event.x, event.y];
if (
originScreenPoint &&
distanceBetweenPointAndPoint(
originScreenPoint[0],
originScreenPoint[1],
currentScreenPoint[0],
currentScreenPoint[1]
) < 8
) {
isSnappingStartAndEnd = true;
} else {
isSnappingStartAndEnd = false;
}
const smoothingPoint = smoother.process(currentScreenPoint);
if (smoothingPoint) {
generator?.destroy();
const newPoint = toViewBoxPoint(
board,
toHostPoint(board, smoothingPoint[0], smoothingPoint[1])
);
points.push(newPoint);
const pointer = PlaitBoard.getPointer(board) as FreehandShape;
temporaryElement = createFreehandElement(pointer, points);
generator.processDrawing(
temporaryElement,
PlaitBoard.getElementTopHost(board)
);
}
return;
}
if (isTwoFingerMode(board) && isDrawing) {
complete(true);
return;
}
pointerMove(event);
};
board.pointerUp = (event: PointerEvent) => {
complete();
pointerUp(event);
};
board.globalPointerUp = (event: PointerEvent) => {
complete(true);
globalPointerUp(event);
};
return board;
};
================================================
FILE: packages/drawnix/src/plugins/freehand/with-freehand-erase.ts
================================================
import {
PlaitBoard,
PlaitElement,
Point,
throttleRAF,
toHostPoint,
toViewBoxPoint,
} from '@plait/core';
import { isDrawingMode } from '@plait/common';
import { isHitFreehand } from './utils';
import { Freehand, FreehandShape } from './type';
import { CoreTransforms } from '@plait/core';
import { LaserPointer } from '../../utils/laser-pointer';
import { isTwoFingerMode } from '@plait-board/react-board';
export const withFreehandErase = (board: PlaitBoard) => {
const { pointerDown, pointerMove, pointerUp, globalPointerUp, touchStart } =
board;
const laserPointer = new LaserPointer();
let isErasing = false;
const elementsToDelete = new Set();
const checkAndMarkFreehandElementsForDeletion = (point: Point) => {
const viewBoxPoint = toViewBoxPoint(
board,
toHostPoint(board, point[0], point[1])
);
const freehandElements = board.children.filter((element) =>
Freehand.isFreehand(element)
) as Freehand[];
freehandElements.forEach((element) => {
if (
!elementsToDelete.has(element.id) &&
isHitFreehand(board, element, viewBoxPoint)
) {
PlaitElement.getElementG(element).style.opacity = '0.2';
elementsToDelete.add(element.id);
}
});
};
const deleteMarkedElements = () => {
if (elementsToDelete.size > 0) {
const elementsToRemove = board.children.filter((element) =>
elementsToDelete.has(element.id)
);
if (elementsToRemove.length > 0) {
CoreTransforms.removeElements(board, elementsToRemove);
}
}
};
const complete = () => {
if (isErasing) {
deleteMarkedElements();
isErasing = false;
elementsToDelete.clear();
laserPointer.destroy();
}
};
board.touchStart = (event: TouchEvent) => {
const isEraserPointer = PlaitBoard.isInPointer(board, [
FreehandShape.eraser,
]);
if (isEraserPointer && isDrawingMode(board)) {
return event.preventDefault();
}
touchStart(event);
};
board.pointerDown = (event: PointerEvent) => {
const isEraserPointer = PlaitBoard.isInPointer(board, [
FreehandShape.eraser,
]);
if (isEraserPointer && isDrawingMode(board)) {
isErasing = true;
elementsToDelete.clear();
const currentPoint: Point = [event.x, event.y];
checkAndMarkFreehandElementsForDeletion(currentPoint);
laserPointer.init(board);
return;
}
pointerDown(event);
};
board.pointerMove = (event: PointerEvent) => {
if (isErasing && !isTwoFingerMode(board)) {
throttleRAF(board, 'with-freehand-erase', () => {
const currentPoint: Point = [event.x, event.y];
checkAndMarkFreehandElementsForDeletion(currentPoint);
});
return;
}
if (isErasing && isTwoFingerMode(board)) {
complete();
return;
}
pointerMove(event);
};
board.pointerUp = (event: PointerEvent) => {
if (isErasing) {
complete();
return;
}
pointerUp(event);
};
board.globalPointerUp = (event: PointerEvent) => {
if (isErasing) {
complete();
return;
}
globalPointerUp(event);
};
return board;
};
================================================
FILE: packages/drawnix/src/plugins/freehand/with-freehand-fragment.ts
================================================
import {
ClipboardData,
PlaitBoard,
PlaitElement,
Point,
RectangleClient,
WritableClipboardContext,
WritableClipboardOperationType,
WritableClipboardType,
addOrCreateClipboardContext,
} from '@plait/core';
import { getSelectedFreehandElements } from './utils';
import { Freehand } from './type';
import { buildClipboardData, insertClipboardData } from '@plait/common';
export const withFreehandFragment = (baseBoard: PlaitBoard) => {
const board = baseBoard as PlaitBoard;
const { getDeletedFragment, buildFragment, insertFragment } = board;
board.getDeletedFragment = (data: PlaitElement[]) => {
const freehandElements = getSelectedFreehandElements(board);
if (freehandElements.length) {
data.push(...freehandElements);
}
return getDeletedFragment(data);
};
board.buildFragment = (
clipboardContext: WritableClipboardContext | null,
rectangle: RectangleClient | null,
operationType: WritableClipboardOperationType,
originData?: PlaitElement[]
) => {
const freehandElements = getSelectedFreehandElements(board);
if (freehandElements.length) {
const elements = buildClipboardData(
board,
freehandElements,
rectangle ? [rectangle.x, rectangle.y] : [0, 0]
);
clipboardContext = addOrCreateClipboardContext(clipboardContext, {
text: '',
type: WritableClipboardType.elements,
elements,
});
}
return buildFragment(
clipboardContext,
rectangle,
operationType,
originData
);
};
board.insertFragment = (
clipboardData: ClipboardData | null,
targetPoint: Point,
operationType?: WritableClipboardOperationType
) => {
const freehandElements = clipboardData?.elements?.filter((value) =>
Freehand.isFreehand(value)
) as Freehand[];
if (freehandElements && freehandElements.length > 0) {
insertClipboardData(board, freehandElements, targetPoint);
}
insertFragment(clipboardData, targetPoint, operationType);
};
return board;
};
================================================
FILE: packages/drawnix/src/plugins/freehand/with-freehand.ts
================================================
import {
PlaitBoard,
PlaitElement,
PlaitOptionsBoard,
PlaitPluginElementContext,
RectangleClient,
Selection,
} from '@plait/core';
import { Freehand, FREEHAND_TYPE } from './type';
import { FreehandComponent } from './freehand.component';
import { withFreehandCreate } from './with-freehand-create';
import { isHitFreehand, isRectangleHitFreehand } from './utils';
import { withFreehandFragment } from './with-freehand-fragment';
import {
getHitDrawElement,
WithDrawOptions,
WithDrawPluginKey,
} from '@plait/draw';
import { withFreehandErase } from './with-freehand-erase';
export const withFreehand = (board: PlaitBoard) => {
const {
getRectangle,
drawElement,
isHit,
isRectangleHit,
getOneHitElement,
isMovable,
isAlign,
} = board;
board.drawElement = (context: PlaitPluginElementContext) => {
if (Freehand.isFreehand(context.element)) {
return FreehandComponent;
}
return drawElement(context);
};
board.getRectangle = (element: PlaitElement) => {
if (Freehand.isFreehand(element)) {
return RectangleClient.getRectangleByPoints(element.points);
}
return getRectangle(element);
};
board.isRectangleHit = (element: PlaitElement, selection: Selection) => {
if (Freehand.isFreehand(element)) {
return isRectangleHitFreehand(board, element, selection);
}
return isRectangleHit(element, selection);
};
board.isHit = (element, point, isStrict?: boolean) => {
if (Freehand.isFreehand(element)) {
return isHitFreehand(board, element, point);
}
return isHit(element, point, isStrict);
};
board.getOneHitElement = (elements) => {
const isAllFreehand = elements.every((item) => Freehand.isFreehand(item));
if (isAllFreehand) {
return getHitDrawElement(board, elements as Freehand[]);
}
return getOneHitElement(elements);
};
board.isMovable = (element) => {
if (Freehand.isFreehand(element)) {
return true;
}
return isMovable(element);
};
board.isAlign = (element) => {
if (Freehand.isFreehand(element)) {
return true;
}
return isAlign(element);
};
(board as PlaitOptionsBoard).setPluginOptions(
WithDrawPluginKey,
{ customGeometryTypes: [FREEHAND_TYPE] }
);
return withFreehandErase(withFreehandFragment(withFreehandCreate(board)));
};
================================================
FILE: packages/drawnix/src/plugins/with-common.tsx
================================================
import type {
ImageProps,
PlaitImageBoard,
RenderComponentRef,
} from '@plait/common';
import { PlaitBoard, PlaitI18nBoard } from '@plait/core';
import { createRoot } from 'react-dom/client';
import { Image } from './components/image';
import { withImagePlugin } from './with-image';
import { DrawI18nKey } from '@plait/draw';
import { MindI18nKey } from '@plait/mind';
import { i18nInsidePlaitHook } from '../i18n';
export const withCommonPlugin = (board: PlaitBoard) => {
const newBoard = board as PlaitBoard & PlaitImageBoard & PlaitI18nBoard;
newBoard.renderImage = (
container: Element | DocumentFragment,
props: ImageProps
) => {
const root = createRoot(container);
root.render( );
let newProps = { ...props };
const ref: RenderComponentRef = {
destroy: () => {
setTimeout(() => {
root.unmount();
}, 0);
},
update: (updatedProps: Partial) => {
newProps = { ...newProps, ...updatedProps };
root.render( );
},
};
return ref;
};
const { t } = i18nInsidePlaitHook();
newBoard.getI18nValue = (key: string) => {
if (key === DrawI18nKey.lineText) {
return t('draw.lineText');
}
if (key === DrawI18nKey.geometryText) {
return t("draw.geometryText");
}
if (key === MindI18nKey.mindCentralText) {
return t('mind.centralText');
}
if (key === MindI18nKey.abstractNodeText) {
return t('mind.abstractNodeText');
}
return null;
};
return withImagePlugin(newBoard);
};
================================================
FILE: packages/drawnix/src/plugins/with-hotkey.ts
================================================
import {
BoardTransforms,
getSelectedElements,
PlaitBoard,
PlaitPointerType,
} from '@plait/core';
import { isHotkey } from 'is-hotkey';
import { addImage, saveAsImage } from '../utils/image';
import { saveAsJSON } from '../data/json';
import { DrawnixState } from '../hooks/use-drawnix';
import { BoardCreationMode, setCreationMode } from '@plait/common';
import { MindPointerType } from '@plait/mind';
import { FreehandShape } from './freehand/type';
import { ArrowLineShape, BasicShapes } from '@plait/draw';
export const buildDrawnixHotkeyPlugin = (
updateAppState: (appState: Partial) => void
) => {
const withDrawnixHotkey = (board: PlaitBoard) => {
const { globalKeyDown, keyDown } = board;
board.globalKeyDown = (event: KeyboardEvent) => {
const isTypingNormal =
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement;
if (
!isTypingNormal &&
(PlaitBoard.getMovingPointInBoard(board) ||
PlaitBoard.isMovingPointInBoard(board)) &&
!PlaitBoard.hasBeenTextEditing(board)
) {
if (isHotkey(['mod+shift+e'], { byKey: true })(event)) {
saveAsImage(board, true);
event.preventDefault();
return;
}
if (isHotkey(['mod+s'], { byKey: true })(event)) {
saveAsJSON(board);
event.preventDefault();
return;
}
if (
isHotkey(['mod+backspace'])(event) ||
isHotkey(['mod+delete'])(event)
) {
updateAppState({
openCleanConfirm: true,
});
event.preventDefault();
return;
}
if (isHotkey(['mod+u'])(event)) {
addImage(board);
event.preventDefault();
return;
}
if (!event.altKey && !event.metaKey && !event.ctrlKey) {
if (event.key === 'h') {
BoardTransforms.updatePointerType(board, PlaitPointerType.hand);
updateAppState({ pointer: PlaitPointerType.hand });
event.preventDefault();
return;
}
if (event.key === 'v') {
BoardTransforms.updatePointerType(
board,
PlaitPointerType.selection
);
updateAppState({ pointer: PlaitPointerType.selection });
event.preventDefault();
return;
}
if (event.key === 'm') {
setCreationMode(board, BoardCreationMode.dnd);
BoardTransforms.updatePointerType(board, MindPointerType.mind);
updateAppState({ pointer: MindPointerType.mind });
event.preventDefault();
return;
}
if (event.key === 'e') {
setCreationMode(board, BoardCreationMode.drawing);
BoardTransforms.updatePointerType(board, FreehandShape.eraser);
updateAppState({ pointer: FreehandShape.eraser });
event.preventDefault();
return;
}
if (event.key === 'p') {
setCreationMode(board, BoardCreationMode.drawing);
BoardTransforms.updatePointerType(board, FreehandShape.feltTipPen);
updateAppState({ pointer: FreehandShape.feltTipPen });
event.preventDefault();
return;
}
if (event.key === 'a' && !isHotkey(['mod+a'])(event)) {
// will trigger editing text
if (getSelectedElements(board).length === 0) {
setCreationMode(board, BoardCreationMode.drawing);
BoardTransforms.updatePointerType(board, ArrowLineShape.straight);
updateAppState({ pointer: ArrowLineShape.straight });
event.preventDefault();
return;
}
}
if (event.key === 'r' || event.key === 'o' || event.key === 't') {
const keyToPointer = {
r: BasicShapes.rectangle,
o: BasicShapes.ellipse,
t: BasicShapes.text,
};
if (keyToPointer[event.key] === BasicShapes.text) {
setCreationMode(board, BoardCreationMode.dnd);
} else {
setCreationMode(board, BoardCreationMode.drawing);
}
BoardTransforms.updatePointerType(board, keyToPointer[event.key]);
updateAppState({ pointer: keyToPointer[event.key] });
event.preventDefault();
return;
}
}
}
globalKeyDown(event);
};
board.keyDown = (event: KeyboardEvent) => {
if (isHotkey(['mod+z'], { byKey: true })(event)) {
board.undo();
event.preventDefault();
return;
}
if (isHotkey(['mod+shift+z'], { byKey: true })(event)) {
board.redo();
event.preventDefault();
return;
}
keyDown(event);
};
return board;
};
return withDrawnixHotkey;
};
================================================
FILE: packages/drawnix/src/plugins/with-image.tsx
================================================
import {
getElementOfFocusedImage,
isResizing,
type PlaitImageBoard,
} from '@plait/common';
import {
ClipboardData,
getHitElementByPoint,
isDragging,
isSelectionMoving,
PlaitBoard,
Point,
toHostPoint,
toViewBoxPoint,
WritableClipboardOperationType,
} from '@plait/core';
import { isSupportedImageFileType } from '../data/blob';
import { insertImage } from '../data/image';
import { isHitImage, MindElement, ImageData } from '@plait/mind';
import { ImageViewer } from '../libs/image-viewer';
export const withImagePlugin = (board: PlaitBoard) => {
const newBoard = board as PlaitBoard & PlaitImageBoard;
const { insertFragment, drop, pointerUp } = newBoard;
const viewer = new ImageViewer({
zoomStep: 0.3,
minZoom: 0.1,
maxZoom: 5,
enableKeyboard: true,
});
newBoard.insertFragment = (
clipboardData: ClipboardData | null,
targetPoint: Point,
operationType?: WritableClipboardOperationType
) => {
if (
clipboardData?.files?.length &&
isSupportedImageFileType(clipboardData.files[0].type)
) {
const imageFile = clipboardData.files[0];
insertImage(board, imageFile, targetPoint, false);
return;
}
insertFragment(clipboardData, targetPoint, operationType);
};
newBoard.drop = (event: DragEvent) => {
if (event.dataTransfer?.files?.length) {
const imageFile = event.dataTransfer.files[0];
if (isSupportedImageFileType(imageFile.type)) {
const point = toViewBoxPoint(
board,
toHostPoint(board, event.x, event.y)
);
insertImage(board, imageFile, point, true);
return true;
}
}
return drop(event);
};
newBoard.pointerUp = (event: PointerEvent) => {
const focusMindNode = getElementOfFocusedImage(board);
if (
focusMindNode &&
!isResizing(board) &&
!isSelectionMoving(board) &&
!isDragging(board)
) {
const point = toViewBoxPoint(board, toHostPoint(board, event.x, event.y));
const hitElement = getHitElementByPoint(board, point);
const isHittingImage =
hitElement &&
MindElement.isMindElement(board, hitElement) &&
MindElement.hasImage(hitElement) &&
isHitImage(board, hitElement as MindElement, point);
if (isHittingImage && focusMindNode === hitElement) {
viewer.open(hitElement.data.image.url);
}
}
pointerUp(event);
};
return newBoard;
};
================================================
FILE: packages/drawnix/src/plugins/with-mind-extend.tsx
================================================
import type { PlaitBoard, PlaitOptionsBoard } from '@plait/core';
import {
WithMindPluginKey,
type EmojiProps,
type PlaitMindBoard,
type PlaitMindEmojiBoard,
type WithMindOptions,
} from '@plait/mind';
import { createRoot } from 'react-dom/client';
import { Emoji } from './components/emoji';
import type { RenderComponentRef } from '@plait/common';
export const withMindExtend = (board: PlaitBoard) => {
const newBoard = board as PlaitBoard & PlaitMindBoard & PlaitMindEmojiBoard;
(board as PlaitOptionsBoard).setPluginOptions(
WithMindPluginKey,
{
emojiPadding: 0,
spaceBetweenEmojis: 4,
}
);
newBoard.renderEmoji = (
container: Element | DocumentFragment,
props: EmojiProps
) => {
const emojiContainer = document.createElement('span');
container.appendChild(emojiContainer);
const root = createRoot(emojiContainer);
root.render( );
let newProps = { ...props };
const ref: RenderComponentRef = {
destroy: () => {
setTimeout(() => {
root.unmount();
}, 0);
},
update: (updatedProps: Partial) => {
newProps = { ...newProps, ...updatedProps };
root.render( );
},
};
return ref;
};
return newBoard;
};
================================================
FILE: packages/drawnix/src/plugins/with-pencil.ts
================================================
import { isPencilEvent, PlaitBoard } from '@plait/core';
import { DrawnixState } from '../hooks/use-drawnix';
const IS_PENCIL_MODE = new WeakMap();
export const isPencilMode = (board: PlaitBoard) => {
return !!IS_PENCIL_MODE.get(board);
};
export const setIsPencilMode = (board: PlaitBoard, isPencilMode: boolean) => {
IS_PENCIL_MODE.set(board, isPencilMode);
};
export const buildPencilPlugin = (
updateAppState: (appState: Partial) => void
) => {
const withPencil = (board: PlaitBoard) => {
const { pointerDown } = board;
board.pointerDown = (event: PointerEvent) => {
if (isPencilEvent(event) && !isPencilMode(board)) {
setIsPencilMode(board, true);
updateAppState({ isPencilMode: true });
}
if (isPencilMode(board) && !isPencilEvent(event)) {
return;
}
pointerDown(event);
};
return board;
};
return withPencil;
};
================================================
FILE: packages/drawnix/src/plugins/with-text-link.tsx
================================================
import {
isMovingElements,
PlaitBoard,
PlaitPointerType,
throttleRAF,
} from '@plait/core';
import { DrawnixBoard, DrawnixState } from '../hooks/use-drawnix';
import { ReactEditor } from 'slate-react';
import { Editor } from 'slate';
import { isResizing, LinkElement } from '@plait/common';
export const isHovering = (board: PlaitBoard) => {
const { appState } = board as DrawnixBoard;
const isHovering =
appState && appState.linkState && appState.linkState.isHovering;
return isHovering;
};
export const isHoveringOrigin = (board: PlaitBoard) => {
const { appState } = board as DrawnixBoard;
const isHoveringOrigin =
appState && appState.linkState && appState.linkState.isHoveringOrigin;
return isHoveringOrigin;
};
export const isEditing = (board: PlaitBoard) => {
const { appState } = board as DrawnixBoard;
const isEditing =
appState && appState.linkState && appState.linkState.isEditing;
return isEditing;
};
export const buildTextLinkPlugin = (
updateAppState: (appState: Partial) => void
) => {
const withTextLink = (board: PlaitBoard) => {
const { pointerMove } = board;
let target: HTMLElement | null = null;
let timeoutId: any | null = null;
board.pointerMove = (event: PointerEvent) => {
if (
(PlaitBoard.isPointer(board, PlaitPointerType.selection) ||
PlaitBoard.isPointer(board, PlaitPointerType.hand)) &&
!isMovingElements(board) &&
!isResizing(board) &&
!isHovering(board) &&
!isEditing(board)
) {
throttleRAF(board, 'with-text-link', () => {
const textLinkDom = (event.target as HTMLElement).closest(
'.plait-board-link'
) as HTMLElement | null;
if (textLinkDom && textLinkDom !== target) {
const editable = textLinkDom.closest(
'.plait-text-container'
) as HTMLElement;
const editor = ReactEditor.toSlateNode(
undefined as unknown as Editor,
editable
) as Editor;
const node = ReactEditor.toSlateNode(
undefined as unknown as Editor,
textLinkDom
) as LinkElement;
target = textLinkDom;
updateAppState({
linkState: {
targetDom: textLinkDom,
targetElement: node,
editor,
isEditing: false,
isHovering: false,
isHoveringOrigin: true,
},
});
clearTimeout(timeoutId);
} else {
if (!textLinkDom && target) {
timeoutId = setTimeout(() => {
if (!isHovering(board) && !isEditing(board)) {
updateAppState({
linkState: null,
});
}
}, 300);
target = null;
}
}
});
}
pointerMove(event);
};
return board;
};
return withTextLink;
};
================================================
FILE: packages/drawnix/src/styles/index.scss
================================================
@import './../../../../node_modules/@plait/draw/styles/styles.scss';
@import './../../../../node_modules/@plait/mind/styles/styles.scss';
@import './theme.scss';
.drawnix {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Noto Sans', 'Noto Sans CJK SC', 'Microsoft Yahei', 'Hiragino Sans GB', Arial, sans-serif;
.pencil-mode-toolbar {
position: absolute;
top: 82px;
left: 0;
.tool-icon__icon {
width: auto;
padding: 0 8px;
background-color: var(--color-surface-mid);
}
}
.draw-toolbar {
cursor: default;
position: absolute;
top: 36px;
left: 50%;
transform: translateX(-50%);
@include isMobile {
top: 20px;
}
}
.zoom-toolbar {
cursor: default;
position: absolute;
top: 36px;
right: 36px;
@include isMobile {
display: none;
}
.zoom-out-button {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.zoom-menu-trigger {
width: 56px;;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-on-surface);
border-radius: var(--border-radius-sm);;
cursor: pointer;
&:hover, &.active {
--background: var(--color-surface-primary-container);
background-color: var(--background);
}
}
.zoom-in-button{
color: var(--color-on-surface);
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
}
.app-toolbar {
position: absolute;
top: 36px;
left: 36px;
@include isMobile {
bottom: 20px;
top: auto;
width: 86%;
left: 50%;
transform: translateX(-50%);
}
.stack {
@include isMobile {
display: flex;
align-items: center;
justify-content: space-between;
}
}
}
.theme-toolbar {
position: absolute;
bottom: 36px;
right: 36px;
@include isMobile {
display: none;
}
select {
width: 100px;
background-color: var(--color-surface-secondary-container);
color: var(--color-on-surface);
border-radius: var(--border-radius-sm);
padding: 4px 8px;
cursor: pointer;
border: none;
outline: none;
font-size: 14px;
&:hover {
background-color: var(--color-surface-primary-container);
}
}
}
.drawnix-link, a {
text-decoration: none;
color: var(--link-color);
user-select: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
&:active {
text-decoration: none;
}
}
.a {
font-weight: 500;
text-decoration: none;
color: var(--link-color);
-webkit-user-select: none;
user-select: none;
cursor: pointer;
}
textarea {
outline: none;
&:hover, &:focus {
border: 1px solid var(--color-primary);
}
}
.drawnix-button {
@include outlineButtonStyles;
}
[plait-mindmap="true"] {
img.image-origin--focus:hover {
cursor: zoom-in;
}
}
.laser-pointer {
background: transparent;
position: fixed;
left: 0;
top: 0;
z-index: 2022;
width: 100vw;
height: 100vh;
&.mouse-course-hidden {
pointer-events: none;
}
}
}
.plait-board-container {
&.pointer-eraser {
.board-host-svg {
cursor: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTAiIGN5PSIxMCIgcj0iNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2IiBzdHJva2Utd2lkdGg9IjEuNSIvPgo8L3N2Zz4=') 10 10, crosshair !important;
}
}
.slate-editable-container {
cursor: inherit !important;
}
}
================================================
FILE: packages/drawnix/src/styles/theme.scss
================================================
@import "open-color/open-color.scss";
@import "./variables.module.scss";
.drawnix {
--focus-highlight-color: #{$oc-blue-2};
--icon-fill-color: var(--color-on-surface);
--island-bg-color: #ffffff;
--island-border-color: #eeeeee;
--keybinding-color: var(--color-gray-40);
--shadow-island: 0 0 16px #00000014;
--dialog-border-color: var(--color-gray-20);
--button-hover-bg: var(--color-surface-high);
--button-active-border: var(--color-brand-active);
--link-color: var(--color-primary);
--default-button-size: 2rem;
--default-icon-size: 1rem;
--lg-button-size: 2.25rem;
--lg-icon-size: 1.125rem;
--xlg-icon-size: 1.25rem;
--popup-label-size: 1.25rem;
--editor-container-padding: 1rem;
@media screen and (min-device-width: 1921px) {
--lg-button-size: 2.5rem;
--lg-icon-size: 1.25rem;
--default-button-size: 2.25rem;
--default-icon-size: 1.25rem;
}
--space-factor: 0.25rem;
--dx-space-1: 4px;
--dx-space-2: 8px;
--dx-space-3: 12px;
--dx-space-4: 16px;
--dx-space-5: 24px;
--dx-radius-1: 3px;
--dx-radius-2: 4px;
--dx-radius-3: 6px;
--dx-radius-4: 8px;
--dx-radius-full: 9999px;
--text-primary-color: var(--color-on-surface);
--color-icon-white: #{$oc-white};
--color-primary: #6698ff;
--color-primary-darker: #4a7ee6; // 降低亮度和饱和度
--color-primary-darkest: #3366cc; // 进一步降低亮度
--color-primary-light: #e6f0ff; // 提高亮度,降低饱和度
--color-primary-light-darker: #cce0ff; // light 的稍暗版本
--color-primary-hover: #80acff; // 略微提高亮度的互动状态
--button-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high);
--button-active-border: var(--color-brand-active);
--default-border-color: var(--color-surface-high);
--color-gray-10: #f5f5f5;
--color-gray-20: #ebebeb;
--color-gray-30: #d6d6d6;
--color-gray-40: #b8b8b8;
--color-gray-50: #999999;
--color-gray-60: #7a7a7a;
--color-gray-70: #5c5c5c;
--color-gray-80: #3d3d3d;
--color-gray-85: #242424;
--color-gray-90: #1e1e1e;
--color-gray-100: #121212;
--color-disabled: var(--color-gray-40);
--color-promo: var(--color-primary);
--color-success: #268029;
--color-success-lighter: #cafccc;
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
--color-surface-high: hsl(220, 100%, 97%);
--color-surface-mid: hsl(220 25% 96%);
--color-surface-low: hsl(220 25% 94%);
--color-surface-lowest: #ffffff;
--color-on-surface: #666666;
--color-brand-hover: #6698ff;
--color-on-primary-container: #6698ff;
--color-surface-primary-container: rgba(102, 152, 255, .1);
--color-brand-active: #6698ff;
--color-border-outline: #767680;
--color-border-outline-variant: #c5c5d0;
--default-border-color: var(--color-surface-high);
}
================================================
FILE: packages/drawnix/src/styles/variables.module.scss
================================================
@import "open-color/open-color.scss";
@mixin isMobile() {
@at-root .drawnix--mobile#{&} {
@content;
}
}
@mixin toolbarButtonColorStates {
&.fillable {
.tool-icon_type_radio,
.tool-icon_type_checkbox {
&:checked + .tool-icon__icon {
--icon-fill-color: var(--color-on-primary-container);
svg {
fill: var(--icon-fill-color);
}
}
}
}
.tool-icon_type_radio,
.tool-icon_type_checkbox {
&:checked + .tool-icon__icon {
background: var(--color-surface-primary-container);
--keybinding-color: var(--color-on-primary-container);
svg {
color: var(--color-on-primary-container);
}
}
}
.tool-icon__keybinding {
bottom: 4px;
right: 4px;
}
.tool-icon__icon {
&:hover {
background-color: var(--color-surface-primary-container);
color: var(--color-primary);
}
&:active {
background-color: var(--color-surface-primary-container);
border: 1px solid var(--button-active-border);
svg {
color: var(--color-on-primary-container);
}
}
&[aria-disabled="true"] {
background: initial;
border: none;
svg {
color: var(--color-disabled);
}
}
}
}
@mixin outlineButtonStyles {
display: flex;
justify-content: center;
align-items: center;
padding: 0.625rem;
width: var(--button-width, var(--default-button-size));
height: var(--button-height, var(--default-button-size));
box-sizing: border-box;
border: none;
border-style: none;
border-color: var(--button-border, var(--default-border-color));
border-radius: var(--border-radius-lg);
cursor: pointer;
background-color: var(--button-bg, var(--island-bg-color));
color: var(--icon-fill-color);
font-family: var(--ui-font);
svg {
width: var(--button-width, var(--lg-icon-size));
height: var(--button-height, var(--lg-icon-size));
}
&:hover {
background-color: var(--button-hover-bg, var(--island-bg-color));
border-color: var(
--button-hover-border,
var(--button-border, var(--default-border-color))
);
}
&:active {
background-color: var(--button-active-bg, var(--island-bg-color));
border-color: var(--button-active-border, var(--color-primary-darkest));
}
&.active {
background-color: var(
--button-selected-bg,
var(--color-surface-primary-container)
);
border-color: var(
--button-selected-border,
var(--color-surface-primary-container)
);
&:hover {
background-color: var(
--button-selected-hover-bg,
var(--color-surface-primary-container)
);
}
svg {
color: var(--button-color, var(--color-on-primary-container));
}
}
}
$theme-filter: "invert(93%) hue-rotate(180deg)";
$right-sidebar-width: "302px";
:export {
themeFilter: unquote($theme-filter);
rightSidebarWidth: unquote($right-sidebar-width);
}
================================================
FILE: packages/drawnix/src/transforms/property.ts
================================================
import { PropertyTransforms } from '@plait/common';
import {
isNullOrUndefined,
Path,
PlaitBoard,
PlaitElement,
Transforms,
} from '@plait/core';
import { getMemorizeKey } from '@plait/draw';
import {
applyOpacityToHex,
hexAlphaToOpacity,
isFullyOpaque,
isNoColor,
isValidColor,
removeHexAlpha,
} from '../utils/color';
import {
getCurrentFill,
getCurrentStrokeColor,
isClosedElement,
} from '../utils/property';
import { DEFAULT_FONT_SIZE, TextTransforms } from '@plait/text-plugins';
export const setFillColorOpacity = (board: PlaitBoard, fillOpacity: number) => {
PropertyTransforms.setFillColor(board, null, {
getMemorizeKey,
callback: (element: PlaitElement, path: Path) => {
if (!isClosedElement(board, element)) {
return;
}
const currentFill = getCurrentFill(board, element);
if (!isValidColor(currentFill)) {
return;
}
const currentFillColor = removeHexAlpha(currentFill);
const newFill = isFullyOpaque(fillOpacity)
? currentFillColor
: applyOpacityToHex(currentFillColor, fillOpacity);
Transforms.setNode(board, { fill: newFill }, path);
},
});
};
export const setFillColor = (board: PlaitBoard, fillColor: string) => {
PropertyTransforms.setFillColor(board, null, {
getMemorizeKey,
callback: (element: PlaitElement, path: Path) => {
if (!isClosedElement(board, element)) {
return;
}
const currentFill = getCurrentFill(board, element);
const currentOpacity = hexAlphaToOpacity(currentFill);
if (isNoColor(fillColor)) {
Transforms.setNode(board, { fill: null }, path);
} else {
if (
isNullOrUndefined(currentOpacity) ||
isFullyOpaque(currentOpacity)
) {
Transforms.setNode(board, { fill: fillColor }, path);
} else {
Transforms.setNode(
board,
{ fill: applyOpacityToHex(fillColor, currentOpacity) },
path
);
}
}
},
});
};
export const setStrokeColorOpacity = (
board: PlaitBoard,
fillOpacity: number
) => {
PropertyTransforms.setStrokeColor(board, null, {
getMemorizeKey,
callback: (element: PlaitElement, path: Path) => {
const currentStrokeColor = getCurrentStrokeColor(board, element);
const currentStrokeColorValue = removeHexAlpha(currentStrokeColor);
const newStrokeColor = isFullyOpaque(fillOpacity)
? currentStrokeColorValue
: applyOpacityToHex(currentStrokeColorValue, fillOpacity);
Transforms.setNode(board, { strokeColor: newStrokeColor }, path);
},
});
};
export const setStrokeColor = (board: PlaitBoard, newColor: string) => {
PropertyTransforms.setStrokeColor(board, null, {
getMemorizeKey,
callback: (element: PlaitElement, path: Path) => {
const currentStrokeColor = getCurrentStrokeColor(board, element);
const currentOpacity = hexAlphaToOpacity(currentStrokeColor);
if (isNoColor(newColor)) {
Transforms.setNode(board, { strokeColor: null }, path);
} else {
if (
isNullOrUndefined(currentOpacity) ||
isFullyOpaque(currentOpacity)
) {
Transforms.setNode(board, { strokeColor: newColor }, path);
} else {
Transforms.setNode(
board,
{ strokeColor: applyOpacityToHex(newColor, currentOpacity) },
path
);
}
}
},
});
};
export const setTextColor = (
board: PlaitBoard,
currentColor: string,
newColor: string
) => {
const currentOpacity = hexAlphaToOpacity(currentColor);
if (isNoColor(newColor)) {
TextTransforms.setTextColor(board, null);
} else {
TextTransforms.setTextColor(
board,
applyOpacityToHex(newColor, currentOpacity)
);
}
};
export const setTextColorOpacity = (
board: PlaitBoard,
currentColor: string,
opacity: number
) => {
const currentFontColorValue = removeHexAlpha(currentColor);
const newFontColor = isFullyOpaque(opacity)
? currentFontColorValue
: applyOpacityToHex(currentFontColorValue, opacity);
TextTransforms.setTextColor(board, newFontColor);
};
export const setTextFontSize = (board: PlaitBoard, size: number) => {
if (!Number.isFinite(size) || size <= 0) {
return;
}
TextTransforms.setFontSize(board, String(size) as any, DEFAULT_FONT_SIZE);
};
================================================
FILE: packages/drawnix/src/types.ts
================================================
export type EventPointerType = 'mouse' | 'pen' | 'touch';
export type DataURL = string & { _brand: 'DataURL' };
================================================
FILE: packages/drawnix/src/utils/color.ts
================================================
import { DEFAULT_COLOR, PlaitBoard } from '@plait/core';
import { TRANSPARENT, NO_COLOR, WHITE } from '../constants/color';
// 将 0-100 的透明度转换为 0-255 的整数
function transparencyToAlpha255(transparency: number) {
return Math.round(((100 - transparency) / 100) * 255);
}
// 将 0-255 的 alpha 值转换为 0-100 的透明度
function alpha255ToTransparency(alpha255: number) {
return Math.round((1 - alpha255 / 255) * 100);
}
export function applyOpacityToHex(hexColor: string, opacity: number) {
const alpha = transparencyToAlpha255(100 - opacity);
const alphaHex = alpha.toString(16).padStart(2, '0');
return `${hexColor}${alphaHex}`;
}
export function hexAlphaToOpacity(hexColor: string) {
// 移除可能存在的 # 前缀
hexColor = hexColor.replace(/^#/, '');
let alpha;
if (hexColor.length === 8) {
// 8位十六进制,提取最后两位作为 alpha 值
alpha = parseInt(hexColor.slice(6, 8), 16);
} else if (hexColor.length === 4) {
// 4位十六进制(简写形式),提取最后一位并重复
alpha = parseInt(hexColor.slice(3, 4).repeat(2), 16);
} else {
// 如果没有 alpha 通道,则认为是完全不透明
return 100;
}
return 100 - alpha255ToTransparency(alpha);
}
export function isValidColor(color: string) {
if (color === 'none') {
return false;
}
return true;
}
export function removeHexAlpha(hexColor: string) {
// 移除可能存在的 # 前缀,并转换为大写
const hexColorClone = hexColor.replace(/^#/, '').toUpperCase();
if (hexColorClone.length === 8) {
// 8位十六进制,移除最后两位
return '#' + hexColorClone.slice(0, 6);
} else if (hexColorClone.length === 4) {
// 4位十六进制(简写形式),移除最后一位
return '#' + hexColorClone.slice(0, 3);
} else if (hexColorClone.length === 6 || hexColorClone.length === 3) {
// 已经是标准的 6 位或 3 位形式,直接返回
return '#' + hexColorClone;
} else {
return hexColor;
}
}
export function isTransparent(color?: string) {
return color === TRANSPARENT;
}
export function isWhite(color?: string) {
return color === WHITE || color === WHITE.toLocaleLowerCase();
}
export function isFullyTransparent(opacity: number) {
return opacity === 0;
}
export function isFullyOpaque(opacity: number) {
return opacity === 100;
}
export function isNoColor(value: string) {
return value === NO_COLOR;
}
export function isDefaultStroke(color?: string) {
return !color || color === DEFAULT_COLOR;
}
export function getBackgroundColor(board: PlaitBoard) {
const themeColors = PlaitBoard.getThemeColors(board);
const themeColor = themeColors.find(
(val) => val.mode === board.theme.themeColorMode
);
return themeColor?.boardBackground;
}
================================================
FILE: packages/drawnix/src/utils/common.ts
================================================
import { IS_APPLE, IS_MAC, PlaitBoard, toImage, ToImageOptions } from '@plait/core';
import type { ResolutionType } from './utility-types';
export const isPromiseLike = (
value: any
): value is Promise> => {
return (
!!value &&
typeof value === 'object' &&
'then' in value &&
'catch' in value &&
'finally' in value
);
};
// taken from Radix UI
// https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx
export const composeEventHandlers = (
originalEventHandler?: (event: E) => void,
ourEventHandler?: (event: E) => void,
{ checkForDefaultPrevented = true } = {}
) => {
return function handleEvent(event: E) {
originalEventHandler?.(event);
if (
!checkForDefaultPrevented ||
!(event as unknown as Event)?.defaultPrevented
) {
return ourEventHandler?.(event);
}
};
};
export const base64ToBlob = (base64: string) => {
const arr = base64.split(',');
const fileType = arr[0].match(/:(.*?);/)![1];
const bstr = atob(arr[1]);
let l = bstr.length;
const u8Arr = new Uint8Array(l);
while (l--) {
u8Arr[l] = bstr.charCodeAt(l);
}
return new Blob([u8Arr], {
type: fileType,
});
};
export const boardToImage = (
board: PlaitBoard,
options: ToImageOptions = {}
) => {
return toImage(board, {
fillStyle: 'transparent',
inlineStyleClassNames: '.extend,.emojis,.text',
padding: 20,
ratio: 4,
...options,
});
};
export function download(blob: Blob | MediaSource, filename: string) {
const a = document.createElement('a');
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = filename;
document.body.append(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
}
export const splitRows = (shapes: T[], cols: number) => {
const result = [];
for (let i = 0; i < shapes.length; i += cols) {
result.push(shapes.slice(i, i + cols));
}
return result;
};
export const getShortcutKey = (shortcut: string): string => {
shortcut = shortcut
.replace(/\bAlt\b/i, "Alt")
.replace(/\bShift\b/i, "Shift")
.replace(/\b(Enter|Return)\b/i, "Enter");
if (IS_APPLE || IS_MAC) {
return shortcut
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
.replace(/\bAlt\b/i, "Option");
}
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
};
================================================
FILE: packages/drawnix/src/utils/image.ts
================================================
import { getSelectedElements, PlaitBoard, toSvgData } from '@plait/core';
import { base64ToBlob, boardToImage, download } from './common';
import { fileOpen } from '../data/filesystem';
import { IMAGE_MIME_TYPES } from '../constants';
import { insertImage } from '../data/image';
import { getBackgroundColor, isWhite } from './color';
import { TRANSPARENT } from '../constants/color';
export const saveAsSvg = (board: PlaitBoard) => {
const selectedElements = getSelectedElements(board);
const backgroundColor = getBackgroundColor(board);
return toSvgData(board, {
fillStyle: isWhite(backgroundColor) ? TRANSPARENT : backgroundColor,
padding: 20,
ratio: 4,
elements: selectedElements.length > 0 ? selectedElements : undefined,
inlineStyleClassNames: '.plait-text-container',
styleNames: ['position'],
}).then((svgData) => {
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const imageName = `drawnix-${new Date().getTime()}.svg`;
download(blob, imageName);
});
};
export const saveAsImage = (board: PlaitBoard, isTransparent: boolean) => {
const selectedElements = getSelectedElements(board);
const backgroundColor = getBackgroundColor(board) || 'white';
boardToImage(board, {
elements: selectedElements.length > 0 ? selectedElements : undefined,
fillStyle: isTransparent ? 'transparent' : backgroundColor,
}).then((image) => {
if (image) {
const ext = isTransparent ? 'png' : 'jpg';
const pngImage = base64ToBlob(image);
const imageName = `drawnix-${new Date().getTime()}.${ext}`;
download(pngImage, imageName);
}
});
};
export const addImage = async (board: PlaitBoard) => {
const imageFile = await fileOpen({
description: 'Image',
extensions: Object.keys(
IMAGE_MIME_TYPES
) as (keyof typeof IMAGE_MIME_TYPES)[],
});
insertImage(board, imageFile);
};
================================================
FILE: packages/drawnix/src/utils/index.ts
================================================
export * from './color';
export * from './common';
export * from './image';
export * from './property';
export * from './utility-types';
================================================
FILE: packages/drawnix/src/utils/laser-pointer.ts
================================================
import { PlaitBoard } from '@plait/core';
import {
drainPoints,
drawLaserPen,
IOriginalPointData,
setColor,
setDelay,
setMaxWidth,
setMinWidth,
setOpacity,
setRoundCap,
} from 'laser-pen';
export const LASER_POINTER_CLASS_NAME = 'laser-pointer';
const calculateRatio = (context: any): number => {
const backingStore =
context.backingStorePixelRatio ||
context.webkitBackingStorePixelRatio ||
context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio ||
context.oBackingStorePixelRatio ||
context.backingStorePixelRatio ||
1;
return (window.devicePixelRatio || 1) / backingStore;
};
export class LaserPointer {
private mouseTrack: IOriginalPointData[] = [];
private mouseMoveHandler: ((event: MouseEvent) => void) | null = null;
private resizeHandler: (() => void) | null = null;
private cvsDom: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private canvasPos: DOMRect | null = null;
private drawing = false;
private container: HTMLElement | null = null;
public init(board: PlaitBoard): void {
this.container = PlaitBoard.getBoardContainer(board).closest(
'.drawnix'
) as HTMLElement;
this.cvsDom = this.container.querySelector(
`.${LASER_POINTER_CLASS_NAME}`
) as HTMLCanvasElement;
this.ctx = this.cvsDom.getContext('2d') as CanvasRenderingContext2D;
this.canvasPos = this.cvsDom.getBoundingClientRect();
this.mouseMoveHandler = (event: MouseEvent) => {
if (!this.canvasPos) return;
const relativeX = event.clientX - this.canvasPos.x;
const relativeY = event.clientY - this.canvasPos.y;
this.mouseTrack.push({
x: relativeX,
y: relativeY,
time: Date.now(),
});
this.ctx && this.startDraw();
};
this.resizeHandler = () => this.setCanvasSize();
this.container.addEventListener('pointermove', this.mouseMoveHandler);
window.addEventListener('resize', this.resizeHandler);
this.setCanvasSize();
}
public destroy(): void {
if (this.mouseMoveHandler && this.container) {
this.container.removeEventListener('pointermove', this.mouseMoveHandler);
this.mouseMoveHandler = null;
}
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler);
this.resizeHandler = null;
}
if (this.ctx && this.cvsDom) {
this.ctx.clearRect(0, 0, this.cvsDom.width, this.cvsDom.height);
}
this.cvsDom = null;
this.ctx = null;
this.canvasPos = null;
this.drawing = false;
}
private startDraw(): void {
if (!this.drawing) {
this.drawing = true;
this.draw();
}
}
private draw(): void {
if (!this.ctx || !this.cvsDom) return;
this.ctx.clearRect(0, 0, this.cvsDom.width, this.cvsDom.height);
let needDrawInNextFrame = false;
this.mouseTrack = drainPoints(this.mouseTrack);
if (this.mouseTrack.length >= 3) {
setColor(211, 211, 211);
setDelay(180);
setRoundCap(true);
setMaxWidth(10);
setMinWidth(0);
setOpacity(0.6);
drawLaserPen(this.ctx, this.mouseTrack);
needDrawInNextFrame = true;
} else {
const centerPoint = this.mouseTrack[this.mouseTrack.length - 1];
if (!centerPoint) return;
this.ctx.save();
this.ctx.beginPath();
this.ctx.fillStyle = `rgba(211, 211, 211)`;
this.ctx.arc(centerPoint.x, centerPoint.y, 5, 0, Math.PI * 2, false);
this.ctx.closePath();
this.ctx.fill();
this.ctx.restore();
}
if (needDrawInNextFrame) {
requestAnimationFrame(() => this.draw());
} else {
this.drawing = false;
}
}
private setCanvasSize(): void {
if (!this.cvsDom || !this.ctx) return;
const rect = this.cvsDom.getBoundingClientRect();
const ratio = calculateRatio(this.ctx);
this.cvsDom.setAttribute('width', `${rect.width * ratio}px`);
this.cvsDom.setAttribute('height', `${rect.height * ratio}px`);
this.ctx.scale(ratio, ratio);
this.canvasPos = this.cvsDom.getBoundingClientRect();
}
}
================================================
FILE: packages/drawnix/src/utils/property.ts
================================================
import { PlaitBoard, PlaitElement } from '@plait/core';
import {
isClosedCustomGeometry,
isClosedDrawElement,
PlaitDrawElement,
} from '@plait/draw';
import {
getFillByElement,
getStrokeColorByElement,
MindElement,
} from '@plait/mind';
import {
getFillByElement as getFillByDrawElement,
getStrokeColorByElement as getStrokeColorByDrawElement,
} from '@plait/draw';
import { getTextMarksByElement } from '@plait/text-plugins';
export const isClosedElement = (board: PlaitBoard, element: PlaitElement) => {
return (
MindElement.isMindElement(board, element) ||
(PlaitDrawElement.isDrawElement(element) && isClosedDrawElement(element)) ||
isClosedCustomGeometry(board, element)
);
};
export const getCurrentFill = (board: PlaitBoard, element: PlaitElement) => {
let currentFill: string | null = element.fill;
if (!currentFill) {
if (MindElement.isMindElement(board, element)) {
currentFill = getFillByElement(board, element);
}
if (
PlaitDrawElement.isDrawElement(element) ||
PlaitDrawElement.isCustomGeometryElement(board, element)
) {
currentFill = getFillByDrawElement(board, element);
}
}
return currentFill as string;
};
export const getCurrentStrokeColor = (
board: PlaitBoard,
element: PlaitElement
) => {
let strokeColor: string | null = element.strokeColor;
if (!strokeColor) {
if (MindElement.isMindElement(board, element)) {
strokeColor = getStrokeColorByElement(board, element);
}
if (
PlaitDrawElement.isDrawElement(element) ||
PlaitDrawElement.isCustomGeometryElement(board, element)
) {
strokeColor = getStrokeColorByDrawElement(board, element);
}
}
return strokeColor as string;
};
export const getCurrentFontColor = (
board: PlaitBoard,
element: PlaitElement
) => {
const marks = getTextMarksByElement(element);
return marks.color;
};
================================================
FILE: packages/drawnix/src/utils/utility-types.ts
================================================
export type ResolutionType any> = T extends (
...args: any
) => Promise
? R
: any;
export type ValueOf = T[keyof T];
================================================
FILE: packages/drawnix/tsconfig.json
================================================
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["vite/client"]
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../tsconfig.base.json"
}
================================================
FILE: packages/drawnix/tsconfig.lib.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": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}
================================================
FILE: packages/drawnix/tsconfig.spec.json
================================================
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"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: packages/drawnix/vite.config.ts
================================================
///
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/drawnix',
plugins: [
react(),
nxViteTsPaths(),
dts({
entryRoot: 'src',
tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'),
}),
],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: '../../dist/drawnix',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: 'drawnix',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es', 'cjs'],
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: [
'react',
'react-dom',
'react-dom/client',
'react/jsx-runtime',
'@plait-board/react-board',
'@plait-board/mermaid-to-drawnix',
'@plait-board/markdown-to-drawnix',
'classnames',
'open-color',
'mobile-detect',
'@floating-ui/react',
'@plait/core',
'@plait/common',
'@plait/draw',
'@plait/mind',
'@plait/mind',
'roughjs/bin/core',
'@plait/text-plugins',
'lodash',
'slate',
'slate-react',
'slate-dom',
'slate-history',
'laser-pen',
],
},
},
});
================================================
FILE: packages/react-board/.babelrc
================================================
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
================================================
FILE: packages/react-board/.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/react-board/README.md
================================================
# react-board
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test react-board` to execute the unit tests via [Vitest](https://vitest.dev/).
================================================
FILE: packages/react-board/jest.config.ts
================================================
/* eslint-disable */
export default {
displayName: 'react-board',
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/react-board',
};
================================================
FILE: packages/react-board/package.json
================================================
{
"name": "@plait-board/react-board",
"version": "0.4.0-2",
"main": "./index.js",
"types": "./index.d.ts",
"private": false,
"dependencies": {
"@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",
"ahooks": "^3.8.0",
"classnames": "^2.5.1"
},
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.js",
"types": "./index.d.ts"
}
}
}
================================================
FILE: packages/react-board/project.json
================================================
{
"name": "react-board",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/react-board/src",
"projectType": "library",
"tags": [],
"// targets": "to see all targets run: nx show project react-board --web",
"targets": {}
}
================================================
FILE: packages/react-board/src/board.spec.tsx
================================================
import { render } from '@testing-library/react';
import {Board} from './board';
describe('ReactBoard', () => {
it('should render successfully', () => {
// const { baseElement } = render( );
// expect(baseElement).toBeTruthy();
});
});
================================================
FILE: packages/react-board/src/board.tsx
================================================
import rough from 'roughjs/bin/rough';
import {
BOARD_TO_AFTER_CHANGE,
BOARD_TO_CONTEXT,
BOARD_TO_ELEMENT_HOST,
BOARD_TO_HOST,
BOARD_TO_ON_CHANGE,
BOARD_TO_ROUGH_SVG,
HOST_CLASS_NAME,
IS_BOARD_ALIVE,
IS_CHROME,
IS_FIREFOX,
IS_SAFARI,
PlaitBoardContext,
initializeViewBox,
initializeViewportContainer,
initializeViewportOffset,
PlaitBoard,
KEY_TO_ELEMENT_MAP,
} from '@plait/core';
import { useRef, useEffect } from 'react';
import React from 'react';
import classNames from 'classnames';
import useBoardPluginEvent from './hooks/use-plugin-event';
import useBoardEvent from './hooks/use-board-event';
import './styles/index.scss';
import { useBoard, useListRender } from './hooks/use-board';
export type PlaitBoardProps = {
style?: React.CSSProperties;
className?: string;
children?: React.ReactNode;
afterInit?: (board: PlaitBoard) => void;
};
export const Board: React.FC = ({
style,
className,
children,
afterInit,
}) => {
const hostRef = useRef(null);
const elementLowerHostRef = useRef(null);
const elementHostRef = useRef(null);
const elementUpperHostRef = useRef(null);
const elementTopHostRef = useRef(null);
const activeHostRef = useRef(null);
const viewportContainerRef = useRef(null);
const boardContainerRef = useRef(null);
const board = useBoard();
const listRender = useListRender();
useEffect(() => {
const roughSVG = rough.svg(hostRef.current!, {
options: { roughness: 0, strokeWidth: 1 },
});
BOARD_TO_ROUGH_SVG.set(board, roughSVG);
BOARD_TO_HOST.set(board, hostRef.current!);
IS_BOARD_ALIVE.set(board, true);
BOARD_TO_ELEMENT_HOST.set(board, {
lowerHost: elementLowerHostRef.current!,
host: elementHostRef.current!,
upperHost: elementUpperHostRef.current!,
topHost: elementTopHostRef.current!,
activeHost: activeHostRef.current!,
container: boardContainerRef.current!,
viewportContainer: viewportContainerRef.current!,
});
const context = new PlaitBoardContext();
BOARD_TO_CONTEXT.set(board, context);
KEY_TO_ELEMENT_MAP.set(board, new Map());
if (!listRender.initialized) {
listRender.initialize(board.children, {
board: board,
parent: board,
parentG: PlaitBoard.getElementHost(board),
});
if (afterInit) {
afterInit(board);
}
}
initializeViewportContainer(board);
initializeViewBox(board);
initializeViewportOffset(board);
return () => {
BOARD_TO_CONTEXT.delete(board);
BOARD_TO_AFTER_CHANGE.delete(board);
BOARD_TO_ON_CHANGE.delete(board);
BOARD_TO_ELEMENT_HOST.delete(board);
IS_BOARD_ALIVE.delete(board);
BOARD_TO_HOST.delete(board);
BOARD_TO_ROUGH_SVG.delete(board);
KEY_TO_ELEMENT_MAP.delete(board);
};
}, []);
useBoardPluginEvent(board, viewportContainerRef, hostRef);
useBoardEvent(board, viewportContainerRef);
return (
);
};
const getBrowserClassName = () => {
if (IS_SAFARI) {
return 'safari';
}
if (IS_CHROME) {
return 'chrome';
}
if (IS_FIREFOX) {
return 'firefox';
}
return '';
};
================================================
FILE: packages/react-board/src/hooks/use-board-event.ts
================================================
import {
BoardTransforms,
PlaitBoard,
ZOOM_STEP,
initializeViewBox,
initializeViewportContainer,
isFromViewportChange,
setIsFromViewportChange,
updateViewportByScrolling,
updateViewportOffset,
} from '@plait/core';
import { useEventListener } from 'ahooks';
import { useEffect, useRef } from 'react';
const useBoardEvent = (
board: PlaitBoard,
viewportContainerRef: React.RefObject
) => {
useEventListener(
'scroll',
(event) => {
if (isFromViewportChange(board)) {
setIsFromViewportChange(board, false);
} else {
const { scrollLeft, scrollTop } = event.target as HTMLElement;
updateViewportByScrolling(board, scrollLeft, scrollTop);
}
},
{ target: viewportContainerRef }
);
useEventListener(
'contextmenu',
(event) => {
event.preventDefault();
},
{ target: viewportContainerRef }
);
useEventListener(
'wheel',
(event) => {
// Credits to excalidraw
// https://github.com/excalidraw/excalidraw/blob/b7d7ccc929696cc17b4cc34452e4afd846d59f4f/src/components/App.tsx#L9060
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
const { deltaX, deltaY } = event;
const zoom = board.viewport.zoom;
const sign = Math.sign(deltaY);
const MAX_STEP = ZOOM_STEP * 100;
const absDelta = Math.abs(deltaY);
let delta = deltaY;
if (absDelta > MAX_STEP) {
delta = MAX_STEP * sign;
}
let newZoom = zoom - delta / 100;
// increase zoom steps the more zoomed-in we are (applies to >100% only)
newZoom +=
Math.log10(Math.max(1, zoom)) *
-sign *
// reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20);
BoardTransforms.updateZoom(
board,
newZoom,
PlaitBoard.getMovingPointInBoard(board)
);
}
},
{ target: viewportContainerRef, passive: false }
);
const isInitialized = useRef(false);
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
if (!isInitialized.current) {
isInitialized.current = true;
return;
}
initializeViewportContainer(board);
initializeViewBox(board);
updateViewportOffset(board);
});
resizeObserver.observe(PlaitBoard.getBoardContainer(board));
return () => {
resizeObserver && (resizeObserver as ResizeObserver).disconnect();
};
}, []);
};
export default useBoardEvent;
================================================
FILE: packages/react-board/src/hooks/use-board.tsx
================================================
/**
* A React context for sharing the board object, in a way that re-renders the
* context whenever changes occur.
*/
import { ListRender, PlaitBoard } from '@plait/core';
import { createContext, useContext } from 'react';
export interface BoardContextValue {
v: number;
board: PlaitBoard;
listRender: ListRender;
}
export const BoardContext = createContext<{
v: number;
board: PlaitBoard;
listRender: ListRender;
} | null>(null);
export const useBoard = (): PlaitBoard => {
const context = useContext(BoardContext);
if (!context) {
throw new Error(
`The \`useBoard\` hook must be used inside the component's context.`
);
}
const { board } = context;
return board;
};
export const useListRender = (): ListRender => {
const context = useContext(BoardContext);
if (!context) {
throw new Error(
`The \`useBoard\` hook must be used inside the component's context.`
);
}
const { listRender } = context;
return listRender;
};
================================================
FILE: packages/react-board/src/hooks/use-plugin-event.tsx
================================================
import {
BOARD_TO_MOVING_POINT,
BOARD_TO_MOVING_POINT_IN_BOARD,
PlaitBoard,
WritableClipboardOperationType,
deleteFragment,
getClipboardData,
hasInputOrTextareaTarget,
setFragment,
toHostPoint,
toViewBoxPoint,
} from '@plait/core';
import { useEventListener } from 'ahooks';
const useBoardPluginEvent = (
board: PlaitBoard,
viewportContainerRef: React.RefObject,
hostRef: React.RefObject
) => {
useEventListener(
'pointerdown',
(event) => {
board.pointerDown(event);
},
{ target: hostRef }
);
useEventListener(
'pointermove',
(event) => {
BOARD_TO_MOVING_POINT_IN_BOARD.set(board, [event.x, event.y]);
board.pointerMove(event);
},
{ target: viewportContainerRef }
);
useEventListener(
'pointerleave',
(event) => {
BOARD_TO_MOVING_POINT_IN_BOARD.delete(board);
board.pointerLeave(event);
},
{ target: viewportContainerRef }
);
useEventListener(
'pointerup',
(event) => {
board.pointerUp(event);
},
{ target: viewportContainerRef }
);
useEventListener(
'touchstart',
(event) => {
board.touchStart(event);
},
{ target: viewportContainerRef }
);
useEventListener(
'touchmove',
(event) => {
board.touchMove(event);
},
{ target: viewportContainerRef }
);
useEventListener(
'touchend',
(event) => {
board.touchEnd(event);
},
{ target: viewportContainerRef }
);
useEventListener(
'dblclick',
(event) => {
if (PlaitBoard.isFocus(board) && !PlaitBoard.hasBeenTextEditing(board)) {
board.dblClick(event);
}
},
{ target: hostRef }
);
useEventListener('pointermove', (event) => {
BOARD_TO_MOVING_POINT.set(board, [event.x, event.y]);
board.globalPointerMove(event);
});
useEventListener('pointerup', (event) => {
board.globalPointerUp(event);
});
useEventListener('keydown', (event) => {
board.globalKeyDown(event);
if (
PlaitBoard.isFocus(board) &&
!PlaitBoard.hasBeenTextEditing(board) &&
!hasInputOrTextareaTarget(event.target)
) {
board.keyDown(event);
}
});
useEventListener('keyup', (event) => {
if (PlaitBoard.isFocus(board) && !PlaitBoard.hasBeenTextEditing(board)) {
board?.keyUp(event);
}
});
useEventListener('copy', (event) => {
if (PlaitBoard.isFocus(board) && !PlaitBoard.hasBeenTextEditing(board)) {
event.preventDefault();
setFragment(
board,
WritableClipboardOperationType.copy,
event.clipboardData
);
}
});
useEventListener('paste', async (clipboardEvent) => {
if (
PlaitBoard.isFocus(board) &&
!PlaitBoard.isReadonly(board) &&
!PlaitBoard.hasBeenTextEditing(board)
) {
const mousePoint = PlaitBoard.getMovingPointInBoard(board);
if (mousePoint) {
const targetPoint = toViewBoxPoint(
board,
toHostPoint(board, mousePoint[0], mousePoint[1])
);
const clipboardData = await getClipboardData(
clipboardEvent.clipboardData
);
board.insertFragment(
clipboardData,
targetPoint,
WritableClipboardOperationType.paste
);
}
}
});
useEventListener('cut', (event) => {
if (
PlaitBoard.isFocus(board) &&
!PlaitBoard.isReadonly(board) &&
!PlaitBoard.hasBeenTextEditing(board)
) {
event.preventDefault();
setFragment(
board,
WritableClipboardOperationType.cut,
event.clipboardData
);
deleteFragment(board);
}
});
useEventListener(
'drop',
(event) => {
if (!PlaitBoard.isReadonly(board)) {
event.preventDefault();
board.drop(event);
}
},
{ target: viewportContainerRef }
);
useEventListener(
'dragover',
(event) => {
event.preventDefault();
},
{ target: viewportContainerRef }
);
};
export default useBoardPluginEvent;
================================================
FILE: packages/react-board/src/index.ts
================================================
export * from './board';
export * from './plugins/board';
export * from './wrapper';
export * from './hooks/use-board';
export * from './plugins/with-pinch-zoom-plugin';
================================================
FILE: packages/react-board/src/plugins/board.ts
================================================
import type { RenderComponentRef } from '@plait/common';
import {
PlaitElement,
PlaitOperation,
Viewport,
Selection,
type PlaitTheme
} from '@plait/core';
export interface ReactBoard {
renderComponent: (
children: React.ReactNode,
container: Element | DocumentFragment,
props: T
) => RenderComponentRef;
}
export interface BoardChangeData {
children: PlaitElement[];
operations: PlaitOperation[];
viewport: Viewport;
selection: Selection | null;
theme: PlaitTheme;
}
================================================
FILE: packages/react-board/src/plugins/with-pinch-zoom-plugin.ts
================================================
import {
BoardTransforms,
distanceBetweenPointAndPoint,
getPointBetween,
getViewportOrigination,
MAX_ZOOM,
MIN_ZOOM,
PlaitBoard,
Point,
} from '@plait/core';
interface PointerRecord {
pointerId: number;
lastPoint: Point;
currentPoint: Point;
hasMoved: boolean;
}
export const TOUCH_RECORDS = new WeakMap();
export const isTwoFingerMode = (board: PlaitBoard) => {
const pointerRecords = TOUCH_RECORDS.get(board);
return pointerRecords?.length === 2;
};
export const withPinchZoom = (board: PlaitBoard) => {
const { touchStart, touchMove, touchEnd } = board;
let pointerRecords: PointerRecord[] = [];
let initializeZoom = 0;
let isPinching = false;
board.touchStart = (event: TouchEvent) => {
pointerRecords = Array.from(event.touches).map((touch) => ({
pointerId: touch.identifier,
lastPoint: [touch.clientX, touch.clientY],
currentPoint: [touch.clientX, touch.clientY],
hasMoved: false,
}));
TOUCH_RECORDS.set(board, pointerRecords);
if (pointerRecords.length >= 2) {
initializeZoom = board.viewport.zoom;
}
touchStart(event);
};
board.touchMove = (event: TouchEvent) => {
Array.from(event.changedTouches).forEach((touch) => {
const record = pointerRecords.find(
(record) => record.pointerId === touch.identifier
);
if (record) {
record.lastPoint = record.currentPoint;
record.currentPoint = [touch.clientX, touch.clientY];
record.hasMoved = true;
}
});
if (pointerRecords.length === 2) {
event.preventDefault();
}
if (
pointerRecords.length === 2 &&
pointerRecords.every((record) => record.hasMoved)
) {
const [p1, p2] = pointerRecords;
const pinchCenter = getPointBetween(
...p1.lastPoint,
...p2.lastPoint
) as Point;
const newPinchCenter = getPointBetween(
...p1.currentPoint,
...p2.currentPoint
) as Point;
const dx = newPinchCenter[0] - pinchCenter[0];
const dy = newPinchCenter[1] - pinchCenter[1];
// hand moving
const boardContainerRect =
PlaitBoard.getBoardContainer(board).getBoundingClientRect();
const halfOfWidth = boardContainerRect.width / 2;
const halfOfHeight = boardContainerRect.height / 2;
const zoom = board.viewport.zoom;
const origination = getViewportOrigination(board);
let centerX = origination![0] + halfOfWidth / zoom - dx / zoom;
let centerY = origination![1] + halfOfHeight / zoom - dy / zoom;
let newOrigination = [
centerX - boardContainerRect.width / 2 / zoom,
centerY - boardContainerRect.height / 2 / zoom,
] as Point;
let newZoom = zoom;
const lastDistance = distanceBetweenPointAndPoint(
...p1.lastPoint,
...p2.lastPoint
);
const currentDistance = distanceBetweenPointAndPoint(
...p1.currentPoint,
...p2.currentPoint
);
// zoom 处理
const scale = currentDistance / lastDistance;
const v1 = [
p1.currentPoint[0] - p1.lastPoint[0],
p1.currentPoint[1] - p1.lastPoint[1],
] as Point;
const v2 = [
p2.currentPoint[0] - p2.lastPoint[0],
p2.currentPoint[1] - p2.lastPoint[1],
] as Point;
const dotProduct = v1[0] * v2[0] + v1[1] * v2[1];
const v1Magnitude = Math.sqrt(v1[0] * v1[0] + v1[1] * v1[1]);
const v2Magnitude = Math.sqrt(v2[0] * v2[0] + v2[1] * v2[1]);
const cosTheta = dotProduct / (v1Magnitude * v2Magnitude || 1);
// 控制缩放
// 基于余弦相似度(Cosine Similarity)
// 余弦值 > 0.8:判定为平移手势(向量基本同向)
// 余弦值 < -0.7:判定为缩放手势(向量基本反向)
// 其他情况:未知手势
if (cosTheta < -0.7 || (cosTheta <= 0.8 && isPinching && scale >= 0.01)) {
isPinching = true;
} else {
isPinching = false;
}
if (isPinching) {
newZoom = Math.min(
Math.max(board.viewport.zoom * scale, MIN_ZOOM),
MAX_ZOOM
);
const nativeElement = PlaitBoard.getBoardContainer(board);
const nativeElementRect = nativeElement.getBoundingClientRect();
const zoomCenterWidth = newPinchCenter[0] - nativeElementRect.x;
const zoomCenterHeight = newPinchCenter[1] - nativeElementRect.y;
centerX = newOrigination[0] + zoomCenterWidth / zoom;
centerY = newOrigination[1] + zoomCenterHeight / zoom;
newOrigination = [
centerX - zoomCenterWidth / newZoom,
centerY - zoomCenterHeight / newZoom,
] as Point;
}
BoardTransforms.updateViewport(board, newOrigination, newZoom);
pointerRecords[0].lastPoint = p1.currentPoint;
pointerRecords[1].lastPoint = p2.currentPoint;
pointerRecords[0].hasMoved = false;
pointerRecords[1].hasMoved = false;
return;
}
touchMove(event);
};
board.touchEnd = (event: TouchEvent) => {
const index = pointerRecords.findIndex(
(r) => r.pointerId === event.changedTouches[0].identifier
);
if (index !== -1) {
pointerRecords.splice(index, 1);
}
TOUCH_RECORDS.set(board, pointerRecords);
touchEnd(event);
};
return board;
};
================================================
FILE: packages/react-board/src/plugins/with-react.tsx
================================================
import {
type PlaitTextBoard,
type RenderComponentRef,
type TextProps,
} from '@plait/common';
import type { PlaitBoard } from '@plait/core';
import { createRoot } from 'react-dom/client';
import { Text } from '@plait-board/react-text';
import { ReactEditor } from 'slate-react';
import type { ReactBoard } from './board';
export const withReact = (board: PlaitBoard & PlaitTextBoard) => {
const newBoard = board as PlaitBoard & PlaitTextBoard & ReactBoard;
newBoard.renderText = (
container: Element | DocumentFragment,
props: TextProps
) => {
const root = createRoot(container);
let currentEditor: ReactEditor;
const text = (
{
currentEditor = editor as ReactEditor;
props.afterInit && props.afterInit(editor);
}}
>
);
root.render(text);
let newProps = { ...props };
const ref: RenderComponentRef = {
destroy: () => {
setTimeout(() => {
root.unmount();
}, 0);
},
update: (updatedProps: Partial) => {
const hasUpdated =
updatedProps &&
newProps &&
!Object.keys(updatedProps).every(
(key) =>
updatedProps[key as keyof TextProps] ===
newProps[key as keyof TextProps]
);
if (!hasUpdated) {
return;
}
const readonly = ReactEditor.isReadOnly(currentEditor);
newProps = { ...newProps, ...updatedProps };
root.render( );
if (readonly === true && newProps.readonly === false) {
setTimeout(() => {
ReactEditor.focus(currentEditor);
}, 100);
} else if (readonly === false && newProps.readonly === true) {
ReactEditor.blur(currentEditor);
ReactEditor.deselect(currentEditor);
}
},
};
return ref;
};
return newBoard;
};
================================================
FILE: packages/react-board/src/styles/index.scss
================================================
@use './mixins.scss' as mixins;
.plait-board-container {
display: block;
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
foreignObject {
outline: none;
}
// safari can not set this style, it will prevent text being from selected in edit mode
// resolve the issue text being selected when user drag and move on board in firefox
&.firefox {
user-select: none;
}
.viewport-container {
width: 100%;
height: 100%;
overflow: auto;
}
&.disabled-scroll {
.viewport-container {
overflow: hidden;
}
}
svg {
transform: matrix(1, 0, 0, 1, 0, 0);
}
// https://stackoverflow.com/questions/51313873/svg-foreignobject-not-working-properly-on-safari
.plait-text-container {
// chrome show position is not correct, safari not working when don't assigned position property
// can not assign absolute, because safari can not show correctly position
position: initial !important;
}
.text {
foreignObject {
outline: none;
}
.slate-editable-container {
outline: none;
}
}
.plait-toolbar {
position: absolute;
display: flex;
height: 30px;
z-index: 100;
}
&.element-moving {
.element-active-host {
& > g:not(.active-with-moving) {
display: none;
}
}
}
&.element-rotating {
.element-active-host {
g.resize-handle,
g[class^='line-auto-complete-'] {
display: none;
}
}
}
&.pointer-selection {
cursor: default;
}
&.ns-resize {
cursor: ns-resize;
}
&.ew-resize {
cursor: ew-resize;
}
&.nwse-resize {
cursor: nwse-resize;
}
&.nesw-resize {
cursor: nesw-resize;
}
&.crosshair {
cursor: crosshair;
}
foreignObject[class^='foreign-object-'] {
user-select: none;
}
.board-active-svg {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
}
@include mixins.board-background-color();
}
================================================
FILE: packages/react-board/src/styles/mixins.scss
================================================
@mixin board-background-color {
&.theme-colorful .board-host-svg,
&.theme-default .board-host-svg {
background-color: #ffffff;
}
&.theme-soft .board-host-svg {
background-color: #f5f5f5;
}
&.theme-retro .board-host-svg {
background-color: #f9f8ed;
}
&.theme-dark .board-host-svg {
background-color: #141414;
}
&.theme-starry .board-host-svg {
background-color: #0d2537;
}
}
================================================
FILE: packages/react-board/src/wrapper.tsx
================================================
import {
BOARD_TO_ON_CHANGE,
ListRender,
PlaitElement,
Viewport,
createBoard,
withBoard,
withHandPointer,
withHistory,
withHotkey,
withMoving,
withOptions,
withRelatedFragment,
withSelection,
PlaitBoard,
type PlaitPlugin,
type PlaitBoardOptions,
type Selection,
ThemeColorMode,
BOARD_TO_AFTER_CHANGE,
PlaitOperation,
PlaitTheme,
isFromScrolling,
setIsFromScrolling,
getSelectedElements,
updateViewportOffset,
initializeViewBox,
withI18n,
updateViewBox,
FLUSHING,
BoardTransforms,
} from '@plait/core';
import { BoardChangeData } from './plugins/board';
import { useCallback, useEffect, useRef, useState } from 'react';
import { withReact } from './plugins/with-react';
import { PlaitCommonElementRef, withImage, withText } from '@plait/common';
import { BoardContext, BoardContextValue } from './hooks/use-board';
import React from 'react';
import { withPinchZoom } from './plugins/with-pinch-zoom-plugin';
export type WrapperProps = {
value: PlaitElement[];
children: React.ReactNode;
options: PlaitBoardOptions;
plugins: PlaitPlugin[];
viewport?: Viewport;
theme?: PlaitTheme;
onChange?: (data: BoardChangeData) => void;
onSelectionChange?: (selection: Selection | null) => void;
onValueChange?: (value: PlaitElement[]) => void;
onViewportChange?: (value: Viewport) => void;
onThemeChange?: (value: ThemeColorMode) => void;
};
export const Wrapper: React.FC = ({
value,
children,
options,
plugins,
viewport,
theme,
onChange,
onSelectionChange,
onValueChange,
onViewportChange,
onThemeChange,
}) => {
const [context, setContext] = useState(() => {
const board = initializeBoard(value, options, plugins, viewport, theme);
const listRender = initializeListRender(board);
return {
v: 0,
board,
listRender,
};
});
const { board, listRender } = context;
const onContextChange = useCallback(() => {
if (onChange) {
const data: BoardChangeData = {
children: board.children,
operations: board.operations,
viewport: board.viewport,
selection: board.selection,
theme: board.theme,
};
onChange(data);
}
const hasSelectionChanged = board.operations.some((o) =>
PlaitOperation.isSetSelectionOperation(o)
);
const hasViewportChanged = board.operations.some((o) =>
PlaitOperation.isSetViewportOperation(o)
);
const hasThemeChanged = board.operations.some((o) =>
PlaitOperation.isSetThemeOperation(o)
);
const hasChildrenChanged =
board.operations.length > 0 &&
!board.operations.every(
(o) =>
PlaitOperation.isSetSelectionOperation(o) ||
PlaitOperation.isSetViewportOperation(o) ||
PlaitOperation.isSetThemeOperation(o)
);
if (onValueChange && hasChildrenChanged) {
onValueChange(board.children);
}
if (onSelectionChange && hasSelectionChanged) {
onSelectionChange(board.selection);
}
if (onViewportChange && hasViewportChanged) {
onViewportChange(board.viewport);
}
if (onThemeChange && hasThemeChanged) {
onThemeChange(board.theme.themeColorMode);
}
setContext((prevContext) => ({
v: prevContext.v + 1,
board,
listRender,
}));
}, [board, onChange, onSelectionChange, onValueChange]);
useEffect(() => {
BOARD_TO_ON_CHANGE.set(board, () => {
const isOnlySetSelection =
board.operations.length &&
board.operations.every((op) => op.type === 'set_selection');
if (isOnlySetSelection) {
listRender.update(board.children, {
board: board,
parent: board,
parentG: PlaitBoard.getElementHost(board),
});
return;
}
const isSetViewport =
board.operations.length &&
board.operations.some((op) => op.type === 'set_viewport');
if (isSetViewport && isFromScrolling(board)) {
setIsFromScrolling(board, false);
listRender.update(board.children, {
board: board,
parent: board,
parentG: PlaitBoard.getElementHost(board),
});
return;
}
listRender.update(board.children, {
board: board,
parent: board,
parentG: PlaitBoard.getElementHost(board),
});
if (isSetViewport) {
initializeViewBox(board);
} else {
updateViewBox(board);
}
updateViewportOffset(board);
const selectedElements = getSelectedElements(board);
selectedElements.forEach((element) => {
const elementRef =
PlaitElement.getElementRef(element);
elementRef.updateActiveSection();
});
});
BOARD_TO_AFTER_CHANGE.set(board, () => {
onContextChange();
});
return () => {
BOARD_TO_ON_CHANGE.delete(board);
BOARD_TO_AFTER_CHANGE.delete(board);
};
}, [board]);
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (value !== context.board.children && !FLUSHING.get(board)) {
board.children = value;
if (theme) {
board.theme = theme;
}
listRender.update(board.children, {
board: board,
parent: board,
parentG: PlaitBoard.getElementHost(board),
});
BoardTransforms.fitViewport(board);
}
}, [value]);
return (
{children}
);
};
const initializeBoard = (
value: PlaitElement[],
options: PlaitBoardOptions,
plugins: PlaitPlugin[],
viewport?: Viewport,
theme?: PlaitTheme
) => {
let board = withRelatedFragment(
withHotkey(
withHandPointer(
withHistory(
withSelection(
withMoving(
withBoard(
withI18n(
withOptions(
withReact(withImage(withText(createBoard(value, options))))
)
)
)
)
)
)
)
)
);
plugins.forEach((plugin: any) => {
board = plugin(board);
});
withPinchZoom(board);
if (viewport) {
board.viewport = viewport;
}
if (theme) {
board.theme = theme;
}
return board;
};
const initializeListRender = (board: PlaitBoard) => {
const listRender = new ListRender(board);
return listRender;
};
================================================
FILE: packages/react-board/tsconfig.json
================================================
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["vite/client"]
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../tsconfig.base.json"
}
================================================
FILE: packages/react-board/tsconfig.lib.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": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}
================================================
FILE: packages/react-board/tsconfig.spec.json
================================================
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"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: packages/react-board/vite.config.ts
================================================
///
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/react-board',
plugins: [
react(),
nxViteTsPaths(),
dts({
entryRoot: 'src',
tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'),
}),
],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: '../../dist/react-board',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: 'react-board',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es', 'cjs'],
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: [
'react',
'react-dom',
'react-dom/client',
'react/jsx-runtime',
'@plait/common',
'@plait/core',
'@plait/draw',
'@plait/layouts',
'@plait/mind',
'@plait/text-plugins',
'ahooks',
'classnames',
'@plait-board/react-text',
'roughjs/bin/rough',
'slate-react',
],
},
},
});
================================================
FILE: packages/react-text/.babelrc
================================================
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
================================================
FILE: packages/react-text/.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/react-text/README.md
================================================
# react-text
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test react-text` to execute the unit tests via [Vitest](https://vitest.dev/).
================================================
FILE: packages/react-text/jest.config.ts
================================================
/* eslint-disable */
export default {
displayName: 'react-text',
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/react-text',
};
================================================
FILE: packages/react-text/package.json
================================================
{
"name": "@plait-board/react-text",
"version": "0.4.0-2",
"main": "./index.js",
"types": "./index.d.ts",
"private": false,
"dependencies": {
"slate": "^0.116.0",
"slate-dom": "^0.116.0",
"slate-history": "^0.115.0",
"slate-react": "^0.116.0",
"@plait/text-plugins": "^0.92.1",
"@plait/common": "^0.92.1",
"is-hotkey": "^0.2.0"
},
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.js",
"types": "./index.d.ts"
}
}
}
================================================
FILE: packages/react-text/project.json
================================================
{
"name": "react-text",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/react-text/src",
"projectType": "library",
"tags": [],
"// targets": "to see all targets run: nx show project react-text --web",
"targets": {}
}
================================================
FILE: packages/react-text/src/custom-types.ts
================================================
import { BaseEditor, BaseRange, Range, Element } from 'slate';
import { ReactEditor, RenderElementProps } from 'slate-react';
import { HistoryEditor } from 'slate-history';
import { CustomElement, CustomText } from '@plait/common';
export type CustomEditor = BaseEditor &
ReactEditor &
HistoryEditor & {
nodeToDecorations?: Map;
};
export type RenderElementPropsFor = RenderElementProps & {
element: T;
};
declare module 'slate' {
interface CustomTypes {
Editor: CustomEditor;
Element: CustomElement;
Text: CustomText;
Range: BaseRange & {
[key: string]: unknown;
};
}
}
================================================
FILE: packages/react-text/src/index.ts
================================================
export * from './text';
export * from './custom-types';
================================================
FILE: packages/react-text/src/plugins/index.ts
================================================
export * from './with-text';
export * from './with-link';
================================================
FILE: packages/react-text/src/plugins/with-link.tsx
================================================
import { CustomElement, LinkElement } from '@plait/common';
import { CustomEditor, RenderElementPropsFor } from '../custom-types';
import { isUrl, LinkEditor } from '@plait/text-plugins';
// Put this at the start and end of an inline component to work around this Chromium bug:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
export const InlineChromiumBugfix = () => (
{String.fromCodePoint(160) /* Non-breaking space */}
);
export const LinkComponent = ({
attributes,
children,
element,
}: RenderElementPropsFor) => {
return (
{children}
);
};
export const withInlineLink = (editor: CustomEditor) => {
const { insertData, insertText, isInline } = editor;
editor.isInline = (element: CustomElement) => {
return (
((element as LinkElement).type &&
['link'].includes((element as LinkElement).type)) ||
isInline(element)
);
};
editor.insertText = (text) => {
if (text && isUrl(text)) {
LinkEditor.wrapLink(editor, text, text);
} else {
insertText(text);
}
};
editor.insertData = (data) => {
const text = data.getData('text/plain');
if (text && isUrl(text)) {
LinkEditor.wrapLink(editor, text, text);
} else {
insertData(data);
}
};
return editor;
};
================================================
FILE: packages/react-text/src/plugins/with-text.ts
================================================
import { ReactEditor } from 'slate-react';
export const withText = (editor: T) => {
const e = editor as T;
const { insertData } = e;
e.insertBreak = () => {
editor.insertText('\n');
};
e.insertSoftBreak = () => {
editor.insertText('\n');
};
e.insertData = (data: DataTransfer) => {
let text = data.getData('text/plain');
const plaitData = data.getData(`application/x-slate-fragment`);
if (!plaitData && text) {
if (text.endsWith('\n')) {
text = text.substring(0, text.length - 1);
}
text = text.trim().replace(/\t+/g, ' ');
e.insertText(text);
return;
}
insertData(data);
};
return e;
};
================================================
FILE: packages/react-text/src/styles/index.scss
================================================
.plait-board-container {
.text {
foreignObject {
overflow-y: auto;
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
}
}
}
.plait-text-container {
font-size: 14px;
min-height: 20px;
line-height: 20px;
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Noto Sans', 'Noto Sans CJK SC', 'Microsoft Yahei', 'Hiragino Sans GB', Arial, sans-serif;
}
.slate-editable-container {
outline: none;
padding: 0;
cursor: default;
& [data-slate-node='element'] {
user-select: none;
}
&[contenteditable="true"] {
cursor: text;
& [data-slate-node='element'] {
user-select: text;
}
}
[plait-underlined][plait-strike] {
text-decoration: underline line-through;
}
[plait-strike] {
text-decoration: line-through;
}
[plait-underlined] {
text-decoration: underline;
}
[plait-italic] {
font-style: italic;
}
[plait-bold] {
font-weight: bold;
}
@for $size from 8 through 78 {
[plait-font-size='#{$size}'] {
font-size: #{$size}px;
line-height: 1.5;
}
}
}
================================================
FILE: packages/react-text/src/text.spec.tsx
================================================
import { render } from '@testing-library/react';
import { Text } from './text';
import { Element } from 'slate';
describe('Text', () => {
it('should render successfully', () => {
// const ele: Element = { children: [{ text: '' }], type: 'paragraph' };
// const { baseElement } = render( );
// expect(baseElement).toBeTruthy();
});
});
================================================
FILE: packages/react-text/src/text.tsx
================================================
import { createEditor, type Descendant, Range, Transforms } from 'slate';
import { isKeyHotkey } from 'is-hotkey';
import {
Editable,
RenderElementProps,
RenderLeafProps,
Slate,
withReact,
} from 'slate-react';
import {
type CustomElement,
type CustomText,
type LinkElement,
type ParagraphElement,
type TextProps,
} from '@plait/common';
import React, { useMemo, useCallback, useEffect, CSSProperties } from 'react';
import { withHistory } from 'slate-history';
import { isUrl, LinkEditor } from '@plait/text-plugins';
import { withText } from './plugins/with-text';
import { CustomEditor, RenderElementPropsFor } from './custom-types';
import './styles/index.scss';
import { LinkComponent, withInlineLink } from './plugins/with-link';
export type TextComponentProps = TextProps;
export const Text: React.FC = (
props: TextComponentProps
) => {
const { text, readonly, onChange, onComposition, afterInit } = props;
const renderLeaf = useCallback(
(props: RenderLeafProps) => ,
[]
);
const initialValue: Descendant[] = [text];
const editor = useMemo(() => {
const editor = withInlineLink(
withText(withHistory(withReact(createEditor())))
);
afterInit && afterInit(editor);
return editor;
}, []);
useEffect(() => {
if (text === editor.children[0]) {
return;
}
editor.children = [text];
editor.onChange();
}, [text, editor]);
const onKeyDown: React.KeyboardEventHandler = (event) => {
const { selection } = editor;
// Default left/right behavior is unit:'character'.
// This fails to distinguish between two cursor positions, such as
// foo vs foo .
// Here we modify the behavior to unit:'offset'.
// This lets the user step into and out of the inline without stepping over characters.
// You may wish to customize this further to only use unit:'offset' in specific cases.
if (selection && Range.isCollapsed(selection)) {
const { nativeEvent } = event;
if (isKeyHotkey('left', nativeEvent)) {
event.preventDefault();
Transforms.move(editor, { unit: 'offset', reverse: true });
return;
}
if (isKeyHotkey('right', nativeEvent)) {
event.preventDefault();
Transforms.move(editor, { unit: 'offset' });
return;
}
}
};
return (
{
onChange &&
onChange({
newText: editor.children[0] as ParagraphElement,
operations: editor.operations,
});
}}
>
}
renderLeaf={renderLeaf}
readOnly={readonly === undefined ? true : readonly}
onCompositionStart={(event) => {
if (onComposition) {
onComposition(event as unknown as CompositionEvent);
}
}}
onCompositionUpdate={(event) => {
if (onComposition) {
onComposition(event as unknown as CompositionEvent);
}
}}
onCompositionEnd={(event) => {
if (onComposition) {
onComposition(event as unknown as CompositionEvent);
}
}}
onKeyDown={onKeyDown}
/>
);
};
const Element = (props: RenderElementProps) => {
const { attributes, children, element } = props as RenderElementPropsFor<
CustomElement & { type: string }
>;
switch (element.type) {
case 'link':
return (
)} />
);
default:
return (
)}
/>
);
}
};
const ParagraphComponent = ({
attributes,
children,
element,
}: RenderElementPropsFor) => {
const style = { textAlign: element.align } as CSSProperties;
return (
{children}
);
};
const Leaf: React.FC = ({ children, leaf, attributes }) => {
if ((leaf as CustomText).bold) {
children = {children} ;
}
if ((leaf as CustomText).code) {
children = {children};
}
if ((leaf as CustomText).italic) {
children = {children} ;
}
if ((leaf as CustomText).underlined) {
children = {children} ;
}
const fontSizeValue = (leaf as CustomText)['font-size'];
const style: CSSProperties = {
color: (leaf as CustomText).color
};
return (
{children}
);
};
================================================
FILE: packages/react-text/tsconfig.json
================================================
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["vite/client"]
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../tsconfig.base.json"
}
================================================
FILE: packages/react-text/tsconfig.lib.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": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}
================================================
FILE: packages/react-text/tsconfig.spec.json
================================================
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"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"
, "src/text.tsx" ]
}
================================================
FILE: packages/react-text/vite.config.ts
================================================
///
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/react-text',
plugins: [
react(),
nxViteTsPaths(),
dts({
entryRoot: 'src',
tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'),
}),
],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: '../../dist/react-text',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: 'react-text',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es', 'cjs'],
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: ['react', 'react-dom', 'react/jsx-runtime', 'slate', 'slate-react', 'slate-history', 'is-hotkey', '@plait/text-plugins', '@plait/common'],
},
},
resolve: {
alias: {
'@plait': path.resolve(__dirname, 'src'), // 根据项目结构调整路径
'react-text': path.resolve(__dirname, 'packages/react-text/src'), // 配置 lib 包的别名
},
},
});
================================================
FILE: scripts/publish.js
================================================
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const libraries = ['react-board', 'react-text', 'drawnix'];
libraries.forEach(lib => {
const libPath = path.resolve(__dirname, '../dist', lib);
if (fs.existsSync(libPath)) {
const pkgPath = path.join(libPath, 'package.json');
let publishCmd = 'npm publish --access public';
let versionInfo = '';
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const version = pkg.version || '';
versionInfo = version ? ` (version ${version})` : '';
// 识别带有预发布标识的版本(包含 '-'),使用 next 标签
if (typeof version === 'string' && version.includes('-')) {
publishCmd += ' --tag next';
}
} catch (e) {
console.warn(`Unable to read version for ${lib}:`, e.message);
}
} else {
console.warn(`package.json not found in ${libPath}`);
}
console.log(`Publishing ${lib}${versionInfo} with: ${publishCmd}`);
try {
execSync(publishCmd, {
cwd: libPath,
stdio: 'inherit'
});
console.log(`Successfully published ${lib}`);
} catch (error) {
console.error(`Failed to publish ${lib}:`, error);
}
} else {
console.error(`Library path not found: ${libPath}`);
}
});
================================================
FILE: scripts/release-version.js
================================================
const { execSync } = require('child_process');
// Run nx release version
execSync('nx release version', { stdio: 'inherit' });
// Get the new version from react-text/package.json
const version = require('../packages/react-text/package.json').version;
// Run nx release changelog
execSync(`nx release changelog ${version}`, { stdio: 'inherit' });
// Commit all changes with a single commit message
execSync('git add .', { stdio: 'inherit' });
execSync(`git commit -m "chore(release): publish ${version}"`, { stdio: 'inherit' });
// Create a Git tag for the release
execSync(`git tag -a v${version} -m "v${version}"`, { stdio: 'inherit' });
================================================
FILE: tsconfig.base.json
================================================
{
"compileOnSave": false,
"compilerOptions": {
"rootDir": ".",
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "es2015",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"baseUrl": ".",
"paths": {
"@drawnix/drawnix": ["packages/drawnix/src/index.ts"],
"@plait-board/react-board": ["packages/react-board/src/index.ts"],
"@plait-board/react-text": ["packages/react-text/src/index.ts"]
}
},
"exclude": ["node_modules", "tmp"]
}