snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
[*]
charset=utf-8
end_of_line=lf
indent_size=4
indent_style=space
insert_final_newline=true
trim_trailing_whitespace=true

13
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: univer
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

68
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: 🐛 Bug report
description: Create a report to help us improve.
type: Bug
labels: bug 🐛
title: '[Bug] '
body:
- type: markdown
attributes:
value: |
**Thank you for helping us improve Univer!**
> [!CAUTION]
> Before submitting a bug report, please ensure that you have read the [Univer GitHub Issues Community Guide](https://github.com/dream-num/univer/wiki/Univer-GitHub-Issues-Community-Guide).
- type: checkboxes
id: initial-checklist
attributes:
label: Before you submit this issue, have you checked the following
options:
- label: Is this really a problem?
required: true
- label: I have searched the Github Issues for similar issues, but did not find anything.
required: true
- type: input
id: affected-versions
attributes:
label: Affected packages and versions
description: Please test using the latest version of the relevant packages to make sure your issue has not already been fixed.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Reproduction link
description: |
> [!CAUTION]
> Please note that it is essential to provide a reproduction and describe the reproduction steps in detail. **If you cannot provide a reproduction link, your issue may be closed directly.**
We provide a [Vite online template](https://stackblitz.com/edit/univer-sheets-vite) and a [UMD online template](https://stackblitz.com/edit/univer-sheets-umd). If your issue can be reproduced in one of these templates, please provide the reproduction link here.
We also provide some other [template repositories](https://github.com/awesome-univer), you can choose one of them to reproduce your issue.
If your issue cannot be reproduced in the template, please provide as detailed reproduction steps as possible.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: What should happen?
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
description: What happens instead?
validations:
required: true
- type: textarea
id: system-info
attributes:
label: System information
description: Output of `npx envinfo --system --binaries --browsers`

View File

@@ -0,0 +1,84 @@
name: 🐛 Bug 报告
description: 尽管我们提供了中文 Bug 报告的入口,但还是建议您使用英语描述问题,这可以让更多的人来帮助您。
type: Bug
labels: bug 🐛
title: '[Bug] '
body:
- type: markdown
attributes:
value: |
**感谢您帮助我们改进 Univer!**
> [!CAUTION]
> 在提交 Bug 报告之前,请确保您已阅读 [Univer GitHub Issues 社区手册](https://github.com/dream-num/univer/wiki/Univer-GitHub-Issues-%E7%A4%BE%E5%8C%BA%E6%89%8B%E5%86%8C)。
- type: checkboxes
id: initial-checklist
attributes:
label: 在您提交此问题之前,您是否检查了以下内容?
options:
- label: 这真的是个问题吗?
required: true
- label: 我已经在 Github Issues 中搜索过了,但没有找到类似的问题。
required: true
- type: input
id: affected-versions
attributes:
label: 受影响的包和版本
description: 请使用最新版本的相关包进行测试,以确保您的问题尚未解决。
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: 复现链接
description: |
> [!CAUTION]
> **请注意,请务必认真提供复现并详细描述复现步骤,**如果您无法提供复现链接,你的问题会被直接关闭。**
我们提供了 [Vite 在线模板](https://stackblitz.com/edit/univer-sheets-vite) 和 [UMD 在线模板](https://stackblitz.com/edit/univer-sheets-umd),如果您的问题可以在这个模板中复现,那么请在这里提供复现链接。
我们也提供了一些其他你可能会用到的 [模板仓库](https://github.com/awesome-univer),你可以选择其中一个来复现你的问题。
如果您的问题无法在模板中复现,请提供尽可能详细的复现步骤。
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 本应发生什么?
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: 实际行为
description: 实际发生了什么?
validations:
required: true
- type: dropdown
id: javascript-runtime
attributes:
label: 运行环境
description: |
您使用的浏览器是?
multiple: true
options:
- Chrome
- Safari
- Firefox
- Electron
- 其他 (请在复现步骤中说明)
validations:
required: false
- type: textarea
id: system-info
attributes:
label: 系统信息
description: 在这里粘贴 `npx envinfo --system --binaries --browsers` 命令的输出结果

9
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,9 @@
blank_issues_enabled: true
contact_links:
- name: 💬 Github Discussions
url: https://github.com/dream-num/univer/discussions
about: Use github issues for bug reports and feature requests. For questions please use discussions. | Github issues 仅用于错误报告和功能请求。如有疑问请使用 Github Discussions。
- name: 👾 Discord Community
url: https://discord.gg/z3NKNT6D2f
about: Ask for help in the Discord community. | 在 Discord 社区寻求帮助。

View File

@@ -0,0 +1,24 @@
name: 🚀 Feature request
description: Suggest an idea
type: Feature
labels: enhancement 🚀
title: '[Feature] '
body:
- type: checkboxes
id: initial-checklist
attributes:
label: Initial checklist
options:
- label: Is this really a problem?
required: true
- label: I have searched the Github Issues for similar issues, but did not find anything.
required: true
- type: textarea
id: problem
attributes:
label: Problem
description: Please describe the problem you are trying to solve here.
validations:
required: true

View File

@@ -0,0 +1,27 @@
name: 🚀 新功能请求
description: 尽管我们提供了中文 Feature request 的入口,但还是建议您使用英语描述需求,这可以让更多的人来帮助您。
type: Feature
labels: enhancement 🚀
title: '[Feature] '
body:
- type: checkboxes
id: initial-checklist
attributes:
label: 初始清单
options:
- label: 这真的是个问题吗?
required: true
- label: 我已经在 Github Issues 中搜索过了,但没有找到类似的问题。
required: true
- type: textarea
id: problem
attributes:
label: 问题
description: |
请详细描述您的需求,如果您的描述无法被理解,**那么您的问题可能会被直接关闭**。
- 您应当清楚地提供需求的背景、目的、以及您希望的解决方案。
- 如果您有任何的设计稿、流程图、或者其他相关的附件,请在这里提供。
validations:
required: true

41
.github/actions/setup-node/action.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Node Setup
description: Node.js setup for CI, including cache configuration
inputs:
save-cache:
description: Save a new pnpm store cache entry from this job
default: 'false'
runs:
using: composite
steps:
- name: Setup pnpm and Node.js
uses: pnpm/setup@v2
with:
runtime: node@24.3.0
install: false
- name: Get pnpm store path
id: pnpm-store
shell: bash
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store
id: pnpm-cache
uses: actions/cache/restore@v6
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-${{ runner.arch }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Save pnpm store
if: inputs.save-cache == 'true' && github.ref_name == github.event.repository.default_branch && steps.pnpm-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml') }}

13
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: npm # See documentation for possible values
directory: / # Location of package manifests
schedule:
interval: weekly
versioning-strategy: auto
open-pull-requests-limit: 5

26
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,26 @@
<!--
Thank you for submitting a Pull Request.
Please read our Pull Request guidelines:
https://github.com/dream-num/univer/blob/dev/CONTRIBUTING.md#submitting-pull-requests
-->
<!-- Associate issues with the pull request if there is one. Separate them width commas. -->
<!-- Feel free to delete this if there is no related issue. -->
close #xxx
<!-- A description of the proposed changes. -->
<!-- How to test them. -->
<!-- Uncomment the below lines if there are breaking changes introduced in this PR. -->
<!-- BREAKING CHANGE:
Before:
After: -->
## Pull Request Checklist
- [ ] Related tickets or issues have been linked in the PR description (or missing issue).
- [ ] [Naming convention](https://github.com/dream-num/univer/blob/dev/docs/NAMING_CONVENTION.md) is followed (**do please** check it especially when you created new plugins, commands and resources).
- [ ] Unit tests have been added for the changes (if applicable).
- [ ] Breaking changes have been documented (or no breaking changes introduced in this PR).

61
.github/workflows/build-packages.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: 📦 Build
on:
push:
branches:
- dev
pull_request:
branches:
- dev
types:
- opened
- synchronize
- reopened
- ready_for_review
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
TURBO_TELEMETRY_DISABLED: '1'
jobs:
build:
if: github.repository == 'dream-num/univer' && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
save-cache: 'true'
- name: 📦 Build
run: pnpm build:ci
- name: Check for uncommitted changes
run: |
if [[ -n $(git status --porcelain) ]]; then
echo "Error: Uncommitted changes detected after build."
echo "Changed files:"
git status --porcelain
echo "Diff of changes:"
git diff
exit 1
fi

128
.github/workflows/ci-quality-checks.yml vendored Normal file
View File

@@ -0,0 +1,128 @@
name: 🧪 CI Test
on:
push:
branches:
- dev
pull_request:
branches:
- dev
types:
- opened
- synchronize
- reopened
- ready_for_review
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
env:
TURBO_TELEMETRY_DISABLED: '1'
jobs:
test-shard:
name: test shard (${{ matrix.shard-index }}/${{ matrix.shard-total }})
if: github.repository == 'dream-num/univer' && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
shard-index: [1, 2, 3, 4]
shard-total: [4]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: 🩺 Run test
run: node --experimental-strip-types ./scripts/coverage-shard.mts --shard-index=${{ matrix.shard-index }} --shard-total=${{ matrix.shard-total }} --concurrency=2
- name: 🧬 Report coverage
uses: codecov/codecov-action@v7
with:
name: unit-${{ matrix.shard-index }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
test:
name: test
if: always() && github.repository == 'dream-num/univer' && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: minimum-runner
timeout-minutes: 5
needs: test-shard
steps:
- name: Check test shard results
run: |
if [ "${{ needs.test-shard.result }}" != "success" ]; then
echo "One or more test shards failed: ${{ needs.test-shard.result }}"
exit 1
fi
echo "All test shards passed"
eslint:
if: github.repository == 'dream-num/univer' && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Restore ESLint cache
id: eslint-cache
uses: actions/cache/restore@v6
with:
path: .eslintcache
key: eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'eslint.config.ts') }}-${{ github.sha }}
restore-keys: |
eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'eslint.config.ts') }}-
- name: 🌡️ Run ESLint
run: pnpm lint --quiet --cache --cache-location .eslintcache
- name: Save ESLint cache
if: github.ref_name == github.event.repository.default_branch && steps.eslint-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: .eslintcache
key: eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'eslint.config.ts') }}-${{ github.sha }}
typecheck:
if: github.repository == 'dream-num/univer' && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: 🧼 Type checking
run: pnpm typecheck

View File

@@ -0,0 +1,65 @@
name: 🎬 Setup
on:
pull_request:
branches:
- dev
types:
- opened
- synchronize
- reopened
- closed
- ready_for_review
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
TURBO_TELEMETRY_DISABLED: '1'
jobs:
set-pr-info:
name: set PR info
if: github.repository == 'dream-num/univer' && !github.event.pull_request.draft
runs-on: minimum-runner
steps:
- name: Save PR info
env:
PR_ID: ${{ github.event.pull_request.number || '' }}
PR_REF: ${{ github.head_ref || github.ref_name }}
PR_REPO: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
PR_STATE: ${{ github.event.pull_request.state || '' }}
run: |
printf '%s\n' "$PR_ID" > ./pr-id.txt
printf '%s\n' "$PR_REF" > ./pr-ref.txt
printf '%s\n' "$PR_REPO" > ./pr-repo.txt
printf '%s\n' "$PR_STATE" > ./pr-state.txt
- name: Upload PR number
uses: actions/upload-artifact@v7
with:
name: pr-id
path: ./pr-id.txt
- name: Upload PR ref
uses: actions/upload-artifact@v7
with:
name: pr-ref
path: ./pr-ref.txt
- name: Upload PR repo
uses: actions/upload-artifact@v7
with:
name: pr-repo
path: ./pr-repo.txt
- name: Upload PR state
uses: actions/upload-artifact@v7
with:
name: pr-state
path: ./pr-state.txt

View File

@@ -0,0 +1,24 @@
name: 🔫 Sync to Pro
on:
push:
branches:
- dev
jobs:
dispatch-sync:
if: github.repository == 'dream-num/univer'
runs-on: minimum-runner
steps:
- name: Dispatch sync event to univer-pro
uses: peter-evans/repository-dispatch@v4
with:
repository: dream-num/univer-pro
token: ${{ secrets.GH_TOKEN }}
event-type: sync-univer
client-payload: >
{
"ref": "${{ github.ref_name }}",
"sha": "${{ github.sha }}"
}

62
.github/workflows/release-npm.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: 🍰 Release
on:
push:
tags:
- 'v*.*.*'
- 'v*.*.*-alpha.*'
- 'v*.*.*-beta.*'
- 'v*.*.*-rc.*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
TURBO_TELEMETRY_DISABLED: '1'
jobs:
release-npm:
if: github.repository == 'dream-num/univer'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.ref_name }}
persist-credentials: false
- name: Install pnpm and Node.js
uses: pnpm/setup@v2
with:
runtime: node@24.3.0
install: false
- name: Configure npm registry
uses: actions/setup-node@v6
with:
registry-url: https://registry.npmjs.org
package-manager-cache: false
- name: 🚚 Install dependencies and build
run: |
pnpm install --frozen-lockfile
pnpm build
- name: 🐙 Publish
env:
REF_NAME: ${{ github.ref_name }}
run: |
if [[ "$REF_NAME" =~ -(alpha|beta|rc)\. ]]; then
pnpm publish --access public --tag "${BASH_REMATCH[1]}" -r --no-git-checks --provenance
else
pnpm publish --access public -r --no-git-checks --provenance
fi

191
.github/workflows/security-semgrep.yml vendored Normal file
View File

@@ -0,0 +1,191 @@
name: 🔐 Semgrep
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: read
contents: read
security-events: write
jobs:
semgrep:
if: github.repository == 'dream-num/univer'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install Semgrep
id: install-semgrep
continue-on-error: true
run: python -m pip install --upgrade pip semgrep
- name: Run Semgrep
id: semgrep
if: ${{ always() }}
shell: bash
env:
INSTALL_OUTCOME: ${{ steps.install-semgrep.outcome }}
run: |
mkdir -p reports
if [ "$INSTALL_OUTCOME" = "success" ]; then
set +e
semgrep scan \
--config auto \
--sarif \
--output reports/semgrep.sarif
exit_code=$?
set -e
else
exit_code=2
fi
if [ ! -s reports/semgrep.sarif ]; then
printf '%s\n' \
'{' \
' "version": "2.1.0",' \
' "$schema": "https://json.schemastore.org/sarif-2.1.0.json",' \
' "runs": [' \
' {' \
' "tool": {' \
' "driver": {' \
' "name": "Semgrep",' \
' "informationUri": "https://semgrep.dev"' \
' }' \
' },' \
' "invocations": [' \
' {' \
' "executionSuccessful": false,' \
' "toolExecutionNotifications": [' \
' {' \
' "level": "error",' \
' "message": {' \
' "text": "Semgrep did not produce a SARIF report. Check the workflow logs for the setup or scan failure."' \
' }' \
' }' \
' ]' \
' }' \
' ],' \
' "results": []' \
' }' \
' ]' \
'}' > reports/semgrep.sarif
fi
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
- name: Upload Semgrep SARIF to GitHub
id: upload-sarif
if: ${{ always() }}
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: reports/semgrep.sarif
category: semgrep
- name: Upload Semgrep report artifact
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: semgrep-sarif-${{ github.run_id }}
path: reports/semgrep.sarif
if-no-files-found: error
retention-days: 14
- name: Summarize Semgrep report
if: ${{ always() }}
shell: bash
env:
SEMGREP_EXIT_CODE: ${{ steps.semgrep.outputs.exit_code }}
INSTALL_OUTCOME: ${{ steps.install-semgrep.outcome }}
SARIF_UPLOAD_OUTCOME: ${{ steps.upload-sarif.outcome }}
run: |
python - <<'PY'
import json
import os
from pathlib import Path
report_path = Path("reports/semgrep.sarif")
with report_path.open() as file:
sarif = json.load(file)
results = []
for run in sarif.get("runs", []):
results.extend(run.get("results", []))
counts = {"error": 0, "warning": 0, "note": 0, "other": 0}
for result in results:
level = result.get("level", "warning")
counts[level if level in counts else "other"] += 1
sarif_upload_outcome = os.environ.get("SARIF_UPLOAD_OUTCOME") or "skipped"
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
summary.write("## Semgrep report\n")
summary.write(f"- Total findings: {len(results)}\n")
summary.write(f"- Error: {counts['error']}\n")
summary.write(f"- Warning: {counts['warning']}\n")
summary.write(f"- Note: {counts['note']}\n")
summary.write(f"- Other: {counts['other']}\n")
summary.write(f"- Exit code: {os.environ['SEMGREP_EXIT_CODE']}\n")
summary.write(f"- Semgrep install: {os.environ['INSTALL_OUTCOME']}\n")
summary.write("- Artifact: `semgrep-sarif-${{ github.run_id }}`\n")
summary.write(f"- Code scanning upload: {sarif_upload_outcome}\n")
PY
- name: Fail on Semgrep error findings or execution errors
if: ${{ always() }}
shell: bash
env:
SEMGREP_EXIT_CODE: ${{ steps.semgrep.outputs.exit_code }}
run: |
if [ -z "$SEMGREP_EXIT_CODE" ]; then
echo "Semgrep exit code is missing."
exit 2
fi
if [ "$SEMGREP_EXIT_CODE" -ne 0 ]; then
echo "Semgrep execution failed."
echo "See the uploaded SARIF artifact for details."
exit "$SEMGREP_EXIT_CODE"
fi
python - <<'PY'
import json
import sys
from pathlib import Path
report_path = Path("reports/semgrep.sarif")
with report_path.open() as file:
sarif = json.load(file)
error_results = [
result
for run in sarif.get("runs", [])
for result in run.get("results", [])
if result.get("level") == "error"
]
if error_results:
print(f"Semgrep reported {len(error_results)} error-level finding(s).")
print("See the uploaded SARIF artifact for details.")
sys.exit(1)
PY

21
.github/workflows/sync-gitee-mirror.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: 🔮 Sync Mirror
on:
push:
branches:
- main
- dev
jobs:
sync:
if: github.repository == 'dream-num/univer'
runs-on: minimum-runner
steps:
- name: Sync Gitee
uses: jikkai/sync-gitee@main
with:
repository: dream-num/univer
username: ${{ secrets.GITEE_USERNAME }}
password: ${{ secrets.GITEE_PASSWORD }}
tags: true
branches: main dev

25
.github/workflows/validate-pr-title.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: 📌 Check PR
on:
pull_request:
types:
- opened
- edited
- synchronize
- ready_for_review
permissions:
pull-requests: read
jobs:
validate:
name: validate PR title
if: github.repository == 'dream-num/univer' && !github.event.pull_request.draft
runs-on: minimum-runner
steps:
- uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
subjectPattern: ^(?![A-Z]).+$

127
.gitignore vendored Normal file
View File

@@ -0,0 +1,127 @@
#ide
*.DS_Store
.idea
.cursor
# .vscode
#npm
package-lock.json
yarn.lock
dist
local
lib
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Runtime config
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL History
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# next.js build output
.next
#lerna-changelog
.changelog
#editor workspace file
.idea/
.history
# examples
screenshot
*.heapsnapshot
univer-custom-build
universheet-custom-build
temp
etc
# api extractor, api documenter
docs/input
docs/markdown
doc
# turbo
.turbo
.vercel
# storybook
storybook-static
# playwright
/test-results/
/playwright-report/
/playwright-assets/
/blob-report/
/playwright/.cache/
playwright-report.json
/artifacts/
*storybook.log
typedoc.json
# tsx
**/tsx-0
.pnpm-store
# Generated preset locales
/presets/src/locales/
/presets/packages/*/src/locales/

1
.husky/commit-msg Executable file
View File

@@ -0,0 +1 @@
npx --no -- commitlint --edit ${1}

1
.husky/pre-commit Executable file
View File

@@ -0,0 +1 @@
pnpm lint-staged

12
.semgrepignore Normal file
View File

@@ -0,0 +1,12 @@
submodules/
node_modules/
coverage/
dist/
lib/
.next/
.turbo/
.vercel/
test-results/
playwright-report/
blob-report/
playwright/.cache/

14
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"gruntfuggly.todo-tree",
"intellsmi.comment-translate",
"kingwl.vscode-vitest-runner",
"streetsidesoftware.code-spell-checker",
"tldraw-org.tldraw-vscode",
"usernamehw.errorlens",
"vitest.explorer",
"davidanson.vscode-markdownlint"
]
}

88
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,88 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Sheet (Chrome)",
"request": "launch",
"type": "chrome",
"url": "http://localhost:5173/#sheets",
"webRoot": "${workspaceFolder}/examples",
"presentation": {
"group": "0_example",
"order": 2
}
},
{
"name": "Launch Sheet (Edge)",
"request": "launch",
"type": "msedge",
"url": "http://localhost:5173/#sheets",
"webRoot": "${workspaceFolder}/examples",
"presentation": {
"group": "0_example",
"order": 1
}
},
{
"name": "Launch Doc (Chrome)",
"request": "launch",
"type": "chrome",
"url": "http://localhost:5173/#docs",
"webRoot": "${workspaceFolder}/examples",
"presentation": {
"group": "0_example",
"order": 4
}
},
{
"name": "Launch Doc (Edge)",
"request": "launch",
"type": "msedge",
"url": "http://localhost:5173/#docs",
"webRoot": "${workspaceFolder}/examples",
"presentation": {
"group": "0_example",
"order": 3
}
},
{
"name": "Launch Slide (Chrome)",
"request": "launch",
"type": "chrome",
"url": "http://localhost:5173/#slides",
"webRoot": "${workspaceFolder}/examples",
"presentation": {
"group": "0_example",
"order": 6
}
},
{
"name": "Launch Slide (Edge)",
"request": "launch",
"type": "msedge",
"url": "http://localhost:5173/#slides",
"webRoot": "${workspaceFolder}/examples",
"presentation": {
"group": "0_example",
"order": 5
}
},
{
"type": "node",
"name": "vscode-jest-tests",
"request": "launch",
"args": ["--runInBand", "--watchAll=false"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"program": "${workspaceFolder}/node_modules/.bin/jest",
"windows": {
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
}
}
]
}

30
.vscode/redi.code-snippets vendored Normal file
View File

@@ -0,0 +1,30 @@
{
// Place your univer workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
// Placeholders with the same ids are connected.
// Example:
// "Print to console": {
// "scope": "javascript,typescript",
// "prefix": "log",
// "body": [
// "console.log('$1');",
// "$2"
// ],
// "description": "Log output to console"
// }
"Redi Injection Identifier": {
"prefix": ["@I"],
"body": ["@I${1:identifier} private readonly _${2:name}: I${1:identifier},"],
"description": "Inject an identifier with Redi.",
"scope": "typescript,typescriptreact"
},
"Redi Injection": {
"prefix": ["@In"],
"body": ["@Inject(${1:identifier}) private readonly _${2:name}: ${1:identifier},"],
"description": "Inject a class item with Redi.",
"scope": "typescript,typescriptreact"
}
}

63
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,63 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.rulers": [120],
"javascript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.importModuleSpecifier": "relative",
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
],
"editor.quickSuggestions": {
"strings": "on"
},
"vitest.maximumConfigs": 80
}

46
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,46 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [huwenzhao@univer.ai](mailto:huwenzhao@univer.ai). The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version].
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

223
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,223 @@
<!-- https://univer.ai/guides/sheet/contributing/ -->
# Univer Contributing Guide
Welcome, and thank you for your interest in contributing to Univer!
Please read our [Code of Conduct](./CODE_OF_CONDUCT.md) before you join in the Univer community.
## Asking Questions
If you encounter any issues while using Univer, you can seek help in the following communities:
- [Discord community](https://discord.gg/z3NKNT6D2f)
- [GitHub Discussions](https://github.com/dream-num/univer/discussions)
- Join the Univer Chinese community (please scan the QR code [here](https://univer.ai/contact))
## Reporting Issues
If you encounter any issues or have any suggestions while using Univer, please feel free to raise them in the Github Issues. We will respond promptly.
To better assist you, we recommend:
- Before submitting an issue, please search to see if someone has already raised a similar question.
- We provide an [issue template](https://github.com/dream-num/univer/issues/new/choose) and encourage you to fill it out with sufficient information, which helps us quickly identify the problem.
- Please try to describe the issue in English, as it enables more people to participate in the discussion. We will also make an effort to reply in English to benefit a wider audience.
> If you're new to submitting issues, we recommend to read [How To Ask Questions The Smart Way](http://www.catb.org/~esr/faqs/smart-questions.html) and [How to Report Bugs Effectively](https://www.chiark.greenend.org.uk/~sgtatham/bugs.html) before posting. Well-written bug reports help us and help you!
## Contributing Code
### Install dependencies
The development of Univer requires Node.js >= 22.18 and pnpm >= 11. Please make sure you have the correct versions installed.
```shell
git clone https://github.com/dream-num/univer
cd univer
# install package manager pnpm
npm i -g pnpm
# install dependencies
pnpm install
```
### Start dev server
```shell
pnpm dev
```
### Architecture
Please refer to [Architecture](https://docs.univer.ai/guides/recipes/architecture/univer), and also [ISOMORPHIC.md](./docs/ISOMORPHIC.md) for more guidance on how to set up plugins.
For public API compatibility expectations, experimental APIs, internal APIs, and deprecation rules, read [API_STABILITY.md](./docs/API_STABILITY.md).
### Source code organization
The structure of the repository is as follows:
```txt
.
├── common/ shared configuration, mock data, Storybook, and utilities
├── docs/ documentation
├── examples/ all-in-one Vite workbench for browser development
├── packages/ Univer core and plugins
├── presets/ curated plugin collections
└── tests/ additional integration test projects
```
The file structure of a plugin should be organized as follows:
```txt
|- common/
|- models/
|- services/
|- commands/
|- commands/
|- mutations/
|- operations/
|- controllers/
|- views/
|- components/
|- parts/
|- plugin.ts
|- index.ts
```
There are some limits on what paths could a file import from.
- common cannot import file in other folders
- models can only import files from common
- services can only import files from models and common
- commands can only import files from common, models and services
During the refactoring process, it is recommended to remove legacy folders such as `Enum`, `Interface`, `Basics`, and `Shared`.
Avoid creating barrel imports (index.ts) unless it is the main root index.ts file of a plugin.
#### Desktop & Mobile
We added experimental support for mobile platforms since June 2024. After that, all UI plugins should split ui related code by their running platforms:
```txt
|- controllers/
|- render-controllers/
|- common/
|- desktop/
|- mobile/
|- views/
|- components/
|- common/
|- desktop/
|- mobile/
|- parts/
|- common/
|- desktop/
|- mobile/
```
### Connecting context
To effectively contribute as a member of a software engineering team (and community as well!), it is crucial to establish strong contextual connections. Providing links to relevant resources can greatly assist others in understanding the context. Consider the following practices:
- Include the link to the corresponding issue in your commit message.
- Leave comments with links to the relevant context within the code.
- Whenever possible, document important information directly within the codebase itself.
By adopting these practices, you can enhance collaboration and facilitate a better understanding of the codebase within our community.
### Naming convention
Please refer to [Univer Naming Convention](./docs/NAMING_CONVENTION.md).
### Submitting pull requests
Before merging a pull request, please make sure the following requirements are met:
- All tests pass. ESLint, including its formatting checks, passes.
- Test coverage is not decreased.
We provide preview deployments for pull requests. You can view the preview deployment by clicking the "Preview" link in the "View Deployment" section.
### Storybook
We use Storybook to develop and test components in isolation. It is a great tool to develop UI components in isolation, which can improve component reuse, testability, and development efficiency. You can use the following command to start Storybook:
```shell
pnpm storybook:dev
```
When a new pull request is submitted, a Storybook deployment will be automatically generated. You can view the Storybook deployment by clicking the "Preview" link in the "View Storybook" section.
### Debugging
We provided some Visual Studio Code (vscode) tasks to help you debug in vscode.
After stating dev server, you can launch Edge or Chrome instance and debug in vscode, and you can debug directly in vscode!
![image](https://github.com/dream-num/univer/assets/12122021/25da436c-2155-4ea0-8646-ff3b3dc23112)
### Unit test
To ensure the quality of the code and move with confidence, we require that all code must be covered by unit tests. We use vitest as the unit test framework. You can use the following command to run unit tests:
```shell
pnpm test
```
Also, with the help of vscode and its rich ecosystem, you could directly debug unit tests in vscode. Please install the extension we recommend, and you will see the debug button in the side bar. The `packages/*` glob in `vitest.workspace.ts` automatically includes new plugins created under `packages/`.
![vitest](./docs/img/vitest.png)
### Browser workbench
`pnpm dev` starts one Vite workbench with lazy-loaded Sheets, Docs, and Slides routes. Use the navigation bar or `#sheets`, `#docs`, and `#slides` URLs to switch products; only one Univer instance stays mounted. The Settings menu provides every built-in language, LTR/RTL direction, region, theme, appearance, ribbon, UI chrome, and zoom controls. Preferences are stored in localStorage and applied to the active instance through runtime APIs without remounting it. Development locale data is loaded on demand so unused product and language graphs do not remain in Vite's memory. End-to-end suites should consume built packages outside this repository instead of coupling tests to source examples.
### Build Preview
Build the production artifacts to check for differences between the source and compiled output:
```shell
pnpm build
pnpm dev:umd
```
### Clean code
> Programs are meant to be ready by humans and only incidentally for computers to execute. - Harold Abelson
- Do not expose properties or methods those are not necessary to be exposed.
- Group related methods or properties together. Do not always use blank lines to separate them.
- Keep your concepts consistent by naming your variables consistently.
References:
- [Make your code readable](https://www.notonlycode.org/make-your-code-readable/)
### New Package
Create a new package with our CLI tool:
```shell
# pnpm
pnpm create @univerjs/cli init <project-name>
# npm
npm create @univerjs/cli init <project-name>
```
### How to Contribute to Facade API
Please refer to [How to Contribute to Facade API](./docs/CONTRIBUTING-FACADE.md).
### Deprecate API
If you are going to deprecate an API, follow the [API Stability Policy](./docs/API_STABILITY.md) and the steps below:
1. Mark the API as deprecated in the JSDoc, and use `{@link}` to refer to the new API.
2. In the implementation, call `deprecate` of `ILogService` to log a deprecation message.
3. Document the migration path in the relevant guide, README, changelog, or release notes when the API is broadly used.
4. Deprecated stable APIs should remain available until the next major release unless the API is unsafe, broken, or impossible to preserve. In pre-1.0 releases, removals may happen in a minor release, but they should be documented as breaking changes.

46
DREAMNUM.md Normal file
View File

@@ -0,0 +1,46 @@
# dream-num/univer
> The open-source, isomorphic office SDK for building embeddable spreadsheet, document, and presentation experiences in browsers and Node.js.
## Responsibilities
### Owns
- The open-source Univer runtime, plugin system, command and service infrastructure, Facade API, formula engine, and Canvas rendering engine. See the [repository overview](README.md#-what-is-univer).
- Open-source models, editing capabilities, and UI plugins for Sheets, Docs, and Slides, including shared drawing, commenting, validation, formatting, and localization infrastructure. See the [open-source capability boundary](README.md#-open-source-and-pro).
- Browser, Node.js, and Web Worker integration paths, plus React-based UI and Vue 3 and Web Component adapters. See the [compatibility and integration overview](README.md#-compatibility).
- First-party open-source package presets and the examples and test projects used to exercise the SDK. See the [workspace definition](pnpm-workspace.yaml) and [repository guide](README.md#-repository-guide).
- Public API compatibility rules for stable, experimental, internal, and deprecated interfaces. See the [API Stability Policy](docs/API_STABILITY.md).
The repository does not own Univer Pro's commercial collaboration, import/export, server, or enterprise capabilities; those are a separate extension layer described in the [open-source and Pro boundary](README.md#-open-source-and-pro).
## Provides
- `@univerjs/core`, `@univerjs/engine-formula`, and `@univerjs/engine-render` — Core runtime and data models, Facade APIs, formula calculation, and shared rendering. Contract: [package manifests](packages/core/package.json) and [API reference](https://docs.univer.ai/reference/classes/univer).
- `@univerjs/sheets`, `@univerjs/docs`, `@univerjs/slides`, and their UI and feature packages — Composable office models, plugins, and editing interfaces. Contract: [package directory](packages) and [API stability rules](docs/API_STABILITY.md).
- `@univerjs/presets` and `@univerjs/preset-*` — Curated plugin collections for browser and Node.js integrations. Contract: [preset packages](presets/packages) and [preset-mode guide](README.md#-quick-start).
- `@univerjs/ui-adapter-vue3` and `@univerjs/ui-adapter-web-component` — Framework adapters for integrating Univer UI services. Contract: [Vue 3 adapter](packages/ui-adapter-vue3/README.md) and [Web Component adapter](packages/ui-adapter-web-component/README.md).
- `sync-univer` repository-dispatch event — Notifies the separate Univer Pro repository after changes reach `dev`. Contract: [dispatch workflow](.github/workflows/dispatch-sync-univer-pro.yml).
## Depends on
- [`dream-num/univer-icons`](https://github.com/dream-num/univer-icons) — Owns the React icon components and SVG assets consumed by Univer UI packages. Contract: [`@univerjs/ui` manifest](packages/ui/package.json) and [SVG asset dependency](packages/sheets-conditional-formatting/package.json).
- [`jikkai/verso`](https://github.com/jikkai/verso) — Owns the workspace release CLI used to version and tag Univer packages. Contract: [root package manifest](package.json).
## Authoritative sources
- **Product documentation:** [docs.univer.ai](https://docs.univer.ai).
- **API reference:** [Univer Facade API](https://docs.univer.ai/reference/classes/univer).
- **Development guidance:** [README.md](README.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
- **Package contracts:** [`packages/`](packages), [`presets/`](presets), and [API Stability Policy](docs/API_STABILITY.md).
- **Package release source:** [npm release workflow](.github/workflows/release-npm.yml).
- **Security policy:** [SECURITY.md](SECURITY.md).
## Update contract
Update this file in the same change when any of these facts change:
- repository responsibilities or boundaries;
- outgoing cross-repository dependencies;
- public packages, APIs, protocols, events, images, or data contracts;
- deployment source, runbook, or data classification.

176
LICENSE Normal file
View File

@@ -0,0 +1,176 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

372
README.md Normal file
View File

@@ -0,0 +1,372 @@
<div align="center">
<img src="./docs/img/banner.png" alt="Univer SDK" width="800" />
**A full-stack, isomorphic office SDK for building spreadsheets, documents, and presentations.**
Build embeddable productivity experiences with a plugin architecture, Canvas-based rendering,
a formula engine, and one Facade API that works in the browser and on Node.js.
English | [简体中文](./docs/readme/zh-CN.md) | [繁體中文](./docs/readme/zh-TW.md) | [日本語](./docs/readme/ja-JP.md) | [한국어](./docs/readme/ko-KR.md) | [Español](./docs/readme/es-ES.md)
[📖 Documentation](https://docs.univer.ai) | [✨ Showcase](https://docs.univer.ai/showcase) | [📘 API Reference](https://docs.univer.ai/reference/classes/univer) | [📝 Blog](https://docs.univer.ai/blog)
[![Release](https://img.shields.io/github/v/release/dream-num/univer?style=flat-square)](https://github.com/dream-num/univer/releases)
[![License](https://img.shields.io/github/license/dream-num/univer?style=flat-square)](./LICENSE)
[![Build](https://img.shields.io/github/actions/workflow/status/dream-num/univer/build-packages.yml?style=flat-square)](https://github.com/dream-num/univer/actions/workflows/build-packages.yml)
[![CodeFactor](https://www.codefactor.io/repository/github/dream-num/univer/badge/dev?style=flat-square)](https://www.codefactor.io/repository/github/dream-num/univer/overview/dev)
[![Codecov](https://img.shields.io/codecov/c/gh/dream-num/univer?token=aPfyW2pIMN&style=flat-square)](https://codecov.io/gh/dream-num/univer)
[![Stars](https://img.shields.io/github/stars/dream-num/univer?style=flat-square)](https://github.com/dream-num/univer/stargazers)
[![Contributors](https://img.shields.io/github/contributors/dream-num/univer?style=flat-square)](https://github.com/dream-num/univer/graphs/contributors)
[![Issues](https://img.shields.io/github/issues/dream-num/univer?style=flat-square)](https://github.com/dream-num/univer/issues)
[![Last Commit](https://img.shields.io/github/last-commit/dream-num/univer?style=flat-square)](https://github.com/dream-num/univer/commits/dev/)
[![Discord](https://img.shields.io/discord/1136129819961217077?logo=discord&logoColor=FFFFFF&label=discord&color=5865F2&style=flat-square)](https://discord.gg/z3NKNT6D2f)
[![Twitter](https://img.shields.io/twitter/follow/univerhq?style=flat-square&logo=x)](https://twitter.com/univerhq)
[![Open Collective](https://img.shields.io/opencollective/all/univer?logo=opencollective&style=flat-square)](https://opencollective.com/univer)
[![Trendshift](https://trendshift.io/api/badge/repositories/4376)](https://trendshift.io/repositories/4376)
</div>
## ✨ What is Univer?
Univer is an open-source SDK for creating office applications inside your own product. It gives you the building blocks for spreadsheet, document, and presentation experiences without forcing you into a hosted app or a fixed UI.
Use Univer when you need to:
- Embed spreadsheet or document editing into a SaaS product, internal tool, BI workflow, or AI application.
- Run workbook/document processing on the server with the same architecture used in the browser.
- Compose only the features you need through plugins or start quickly with presets.
- Extend behavior through custom plugins, commands, services, UI components, and Facade APIs.
Univer is not a spreadsheet file viewer only. It is a framework for building your own productivity surface.
## 🌟 Highlights
<table>
<tr>
<td align="center" width="33%">
⚡<br />
<strong>Built for large surfaces</strong><br />
<sub>Canvas-based rendering and a dedicated formula engine keep complex workbooks responsive.</sub>
</td>
<td align="center" width="33%">
🧩<br />
<strong>Plugin-shaped by default</strong><br />
<sub>Compose, replace, lazy-load, or extend capabilities without taking the whole stack.</sub>
</td>
<td align="center" width="33%">
🤖<br />
<strong>Headless for AI infrastructure</strong><br />
<sub>Run workbook and document logic in Node.js to power agents, automation, and server-side workflows.</sub>
</td>
</tr>
<tr>
<td align="center" width="33%">
🛠️<br />
<strong>Product-ready SDK</strong><br />
<sub>Framework adapters, Facade APIs, presets, and headless runtime fit real integration paths.</sub>
</td>
<td align="center" width="33%">
🌗<br />
<strong>Dark-mode ready</strong><br />
<sub>UI components and the rendering engine both adapt to light and dark themes.</sub>
</td>
<td align="center" width="33%">
🔌<br />
<strong>Unified Facade API</strong><br />
<sub>One consistent API surface for workbooks, ranges, formulas, and documents across browser and Node.js.</sub>
</td>
</tr>
</table>
## 🚀 Why Univer?
- **Isomorphic by design**: run UI apps in browsers and headless processing in Node.js.
- **Plugin-first architecture**: every capability is delivered as a composable plugin, so features can be added, removed, replaced, or lazy-loaded.
- **Preset mode for fast integration**: use the curated plugin collections in [`presets/`](./presets) when you want a working app quickly.
- **Plugin mode for full control**: manually compose packages when you need custom loading, smaller bundles, or deep integration.
- **Facade API**: work with workbooks, worksheets, ranges, documents, formulas, commands, and events through a higher-level API.
- **Canvas rendering engine**: support large editable document surfaces with a rendering layer shared across document types.
- **Extensible UI**: integrate with React, Vue, Web Components, and framework-specific application shells.
## ⚡ Quick Start
Use **Plugin Mode** for complete product coverage and precise composition. For supported Sheets, Docs, and Node profiles, **Preset Mode** provides a shorter curated setup.
<details open>
<summary><strong>Plugin Mode</strong></summary>
Plugin Mode gives you lower-level control over packages, style imports, locale merging, Facade API registration, and plugin configuration.
```bash
pnpm add @univerjs/core @univerjs/design @univerjs/docs @univerjs/docs-ui @univerjs/engine-formula @univerjs/engine-render @univerjs/sheets @univerjs/sheets-formula @univerjs/sheets-formula-ui @univerjs/sheets-numfmt @univerjs/sheets-numfmt-ui @univerjs/sheets-ui @univerjs/ui
```
```ts
import { LocaleType, mergeLocales, Univer } from '@univerjs/core'
import { FUniver } from '@univerjs/core/facade'
import DesignEnUS from '@univerjs/design/locale/en-US'
import { UniverDocsPlugin } from '@univerjs/docs'
import { UniverDocsUIPlugin } from '@univerjs/docs-ui'
import DocsUIEnUS from '@univerjs/docs-ui/locale/en-US'
import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula'
import { UniverRenderEnginePlugin } from '@univerjs/engine-render'
import { UniverSheetsPlugin } from '@univerjs/sheets'
import SheetsEnUS from '@univerjs/sheets/locale/en-US'
import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'
import SheetsFormulaEnUS from '@univerjs/sheets-formula/locale/en-US'
import { UniverSheetsFormulaUIPlugin } from '@univerjs/sheets-formula-ui'
import SheetsFormulaUIEnUS from '@univerjs/sheets-formula-ui/locale/en-US'
import { UniverSheetsNumfmtPlugin } from '@univerjs/sheets-numfmt'
import { UniverSheetsNumfmtUIPlugin } from '@univerjs/sheets-numfmt-ui'
import SheetsNumfmtUIEnUS from '@univerjs/sheets-numfmt-ui/locale/en-US'
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'
import SheetsUIEnUS from '@univerjs/sheets-ui/locale/en-US'
import { UniverUIPlugin } from '@univerjs/ui'
import UIEnUS from '@univerjs/ui/locale/en-US'
import '@univerjs/design/lib/index.css'
import '@univerjs/ui/lib/index.css'
import '@univerjs/docs-ui/lib/index.css'
import '@univerjs/sheets-ui/lib/index.css'
import '@univerjs/sheets-formula-ui/lib/index.css'
import '@univerjs/sheets-numfmt-ui/lib/index.css'
import '@univerjs/engine-formula/facade'
import '@univerjs/ui/facade'
import '@univerjs/sheets/facade'
import '@univerjs/sheets-ui/facade'
import '@univerjs/sheets-formula/facade'
import '@univerjs/sheets-numfmt/facade'
const univer = new Univer({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: mergeLocales(
DesignEnUS,
UIEnUS,
DocsUIEnUS,
SheetsEnUS,
SheetsUIEnUS,
SheetsFormulaEnUS,
SheetsFormulaUIEnUS,
SheetsNumfmtUIEnUS,
),
},
})
univer.registerPlugin(UniverRenderEnginePlugin)
univer.registerPlugin(UniverFormulaEnginePlugin)
univer.registerPlugin(UniverUIPlugin, { container: 'app' })
univer.registerPlugin(UniverDocsPlugin)
univer.registerPlugin(UniverDocsUIPlugin)
univer.registerPlugin(UniverSheetsPlugin)
univer.registerPlugin(UniverSheetsUIPlugin)
univer.registerPlugin(UniverSheetsFormulaPlugin)
univer.registerPlugin(UniverSheetsFormulaUIPlugin)
univer.registerPlugin(UniverSheetsNumfmtPlugin)
univer.registerPlugin(UniverSheetsNumfmtUIPlugin)
const univerAPI = FUniver.newAPI(univer)
univerAPI.createWorkbook({})
```
</details>
<details>
<summary><strong>Preset Mode</strong></summary>
Presets are curated collections of Univer plugins that include the required Facade API registrations and styles.
```bash
pnpm add @univerjs/presets @univerjs/preset-sheets-core
```
```ts
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'
import UniverPresetSheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US'
import { createUniver, LocaleType, mergeLocales } from '@univerjs/presets'
import '@univerjs/preset-sheets-core/lib/index.css'
const { univerAPI } = createUniver({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: mergeLocales(UniverPresetSheetsCoreEnUS),
},
presets: [
UniverSheetsCorePreset({
container: 'app',
}),
],
})
univerAPI.createWorkbook({})
```
</details>
Your page needs a container:
```html
<div id="app" style="height: 100vh"></div>
```
Learn more in the [Installation & Basic Usage guide](https://docs.univer.ai/guides/sheets/getting-started/installation), the [`createUniver` reference](https://docs.univer.ai/reference/methods/create-univer), and the [Facade API reference](https://docs.univer.ai/reference/classes/univer).
## 🧩 Preset Mode vs Plugin Mode
| Choose | When to use it | Start here |
| --- | --- | --- |
| **Plugin Mode** | You need strict control over packages, configured dependencies, lazy loading, or custom runtime composition. | This repository's [`examples/`](./examples) and [architecture guide](https://docs.univer.ai/guides/recipes/architecture/univer) |
| **Preset Mode** | You want a working Sheets, Docs, or Node setup with minimal configuration. | This repository's [`presets/`](./presets) and the [getting started guide](https://docs.univer.ai/guides/sheets/getting-started/installation) |
| **Headless Mode** | You need server-side workbook/document processing, formula calculation, or automation without UI. | [Headless Univer](https://docs.univer.ai/guides/sheets/getting-started/node) |
Keep the `@univerjs/*` SDK packages in the same coordinated Univer release line on the same version. Independently released packages, including `@univerjs/icons` and `@univerjs/icons-svg`, use the compatible versions declared by package manifests instead of the SDK version. If you use Univer Pro packages, keep `@univerjs-pro/*` aligned with the matching coordinated release line.
For API compatibility expectations, experimental APIs, internal APIs, and deprecation rules, see the [API Stability Policy](./docs/API_STABILITY.md).
## 🧭 Compatibility
- **Browser runtime**: Univer is compiled with a Chrome 88 target and aims to work on Edge `>=88`, Firefox `>=90`, Chrome `>=88`, Safari `>=14.1`, and Electron `>=12`.
- **Polyfills**: Univer relies on `Intl.Segmenter`. Add a polyfill such as `@formatjs/intl-segmenter` if your target browser or runtime does not provide it.
- **Build tools**: We recommend Vite, esbuild, or Webpack 5. If your build tool does not support the `exports` field in `package.json` (common in Webpack 4), you may need extra path mapping.
- **React**: Univer's view layer is built on React 18, supports React 18 and 19, and provides minimal compatibility support for React 16.9+ and 17.
- **Node.js runtime**: Headless Univer supports Node.js `>=18.17.0`. Developing this monorepo requires Node.js `>=22.18`.
## 🛠️ What You Can Build
| Area | Open-source capabilities | Univer Pro extensions |
| --- | --- | --- |
| **Sheets** | Workbooks, worksheets, ranges, selection, formulas, number formatting, filtering, sorting, data validation, conditional formatting, hyperlinks, comments, find and replace, notes, tables, drawing integration, and extensible UI plugins. | Real-time collaboration, edit history, import/export, printing, charts, pivot tables, sparklines, outlines, shapes, in-cell graphics, data connectors, server-side calculation, and performance-enhanced formula features. |
| **Docs** | Rich document model, editing UI, lists, hyperlinks, drawing integration, comments, quick insert, and shared document architecture. | Collaboration, import/export, printing, enhanced tables and lists, columns, callouts, code blocks, quotes, shapes, and remote comment resources. |
| **Slides** | Presentation data model and UI packages under active development. | Pro slide model and UI, slide import/export, chart and table model/UI plugins, and shared shape-editing infrastructure. |
| **Bases** | Build custom structured-data experiences on top of Univer's plugin, command, and model architecture. | Base database model, commands, formula integration, workbench UI, field editors, and render-engine views. |
| **Runtime** | Browser apps, Node.js headless usage, Web Worker/RPC patterns, multi-instance usage, and server-oriented automation. | Collaboration client/server packages, Node.js collaboration client, Pro server services, SSR, computing delegation, server-side calculation, and changeset replay tooling. |
| **Integrations** | React, Vue, Web Components, framework templates, theming, localization, and custom plugins. | Pro presets and enterprise deployment packages. |
Sheets are the most mature product surface today. Docs and Slides share Univer's architecture and continue to evolve in the same SDK.
## 🔓 Open Source and Pro
This repository contains Univer's open-source core and first-party OSS plugins. Univer Pro is developed separately as a commercial extension layer for advanced product surfaces, collaboration, server features, and enterprise integrations.
| Category | Open source | Univer Pro / commercial |
| --- | --- | --- |
| **Foundation** | Core SDK, plugin system, rendering engine, formula engine, Facade API, themes, i18n, and framework adapters. | Pro presets and enterprise deployment packages. |
| **Sheets** | Core spreadsheet editing, formulas, number formatting, filter/sort, data validation, conditional formatting, notes, tables, hyperlinks, comments, drawing, find and replace. | Collaboration, edit history, import/export, print, charts, pivot tables, sparklines, outlines, shapes, in-cell graphics, data connectors, range preprocessing, and enhanced formula engine features. |
| **Docs** | Document model and editor UI, lists, hyperlinks, comments, quick insert, and drawing integration. | Collaboration, import/export, print, enhanced tables/lists, columns, callouts, code blocks, quotes, shapes, and remote thread-comment resources. |
| **Slides** | OSS presentation model and UI packages. | Pro slide model/UI packages, slide import/export, charts, tables, and reusable shape editor UI. |
| **Bases** | Extensible plugin architecture for custom data-centric products. | Base database core model, commands, mutations, formula integration, workbench UI, field editors, and render-engine integration. |
| **Server and runtime** | Node.js headless runtime, RPC/Web Worker patterns, and server-oriented automation primitives. | Collaboration server, Node.js collaboration client, SSR services, computing delegation, server-side calculation, and collaboration changeset replay tooling. |
Pro features are documented in the [Univer Pro guide](https://docs.univer.ai/guides/pro). They are intentionally separated here so the OSS package surface is clear.
Boundary principles:
- OSS packages in this repository are intended to be useful on their own under the Apache-2.0 license. Univer Pro is optional and is not required to use the public OSS SDK APIs.
- Bugs, regressions, and security issues in OSS packages should be reported and fixed in the OSS repository, even when a related Pro feature exists.
- OSS documentation should not imply that Pro-only capabilities are available in public `@univerjs/*` packages. Pro-only APIs, packages, and deployment paths should be named explicitly.
- When an OSS feature has a Pro enhancement, the OSS behavior should remain documented independently so users can evaluate the open-source surface without reading commercial docs first.
## 🌐 Ecosystem
- **Core SDK**: [`dream-num/univer`](https://github.com/dream-num/univer), this monorepo.
- **Presets**: this repository's [`presets/`](./presets), curated plugin collections for browser and Node.js apps.
- **AI agent skills**: [`dream-num/univer-sdk-skills`](https://github.com/dream-num/univer-sdk-skills), reusable instructions for AI agents working with Univer integration, Pro features, plugin development, and Node backends. See the [AI Skills guide](https://docs.univer.ai/guides/skills).
- **Documentation**: [docs.univer.ai](https://docs.univer.ai), including Sheets, Docs, Slides, recipes, and Pro guides.
- **API Reference**: [docs.univer.ai/reference](https://docs.univer.ai/reference/classes/univer), the Facade API and generated API reference.
- **Examples and showcase**: [Univer Showcase](https://docs.univer.ai/showcase) and this repository's [`examples/`](./examples).
- **AI-native spreadsheets**: [`dream-num/univer-mcp`](https://github.com/dream-num/univer-mcp), Univer Platform / MCP integration for driving Univer Sheets with natural language.
## 📦 Repository Guide
```text
.
├── packages/ Core packages, engines, document types, UI plugins, and feature plugins
├── examples/ All-in-one Vite workbench used for local browser development
├── common/ Shared internal tooling, storybook, and utilities
├── tests/ Additional integration test projects
└── docs/ Architecture notes, images, and repository-local documentation
```
Package-level READMEs live beside each package under [`packages/`](./packages).
## 🧑‍💻 Development
Requirements:
- Node.js `>=22.18`
- pnpm `>=11`
```bash
git clone https://github.com/dream-num/univer.git
cd univer
pnpm install
pnpm dev
```
Useful commands:
| Command | Purpose |
| --- | --- |
| `pnpm dev` | Start the all-in-one Sheets, Docs, and Slides workbench with Vite 8 Bundled Dev and HMR. |
| `pnpm build` | Build workspace packages, excluding internal common packages. |
| `pnpm test` | Run unit tests through Turbo. |
| `pnpm typecheck` | Run TypeScript checks through Turbo. |
| `pnpm lint` | Run ESLint. |
| `pnpm storybook:dev` | Start Storybook for UI component development. |
Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull request.
## 📝 Contributor Notes
Repository-local notes worth reading before deeper changes:
- [Building Isomorphic Univer](./docs/ISOMORPHIC.md): how to split browser, Node.js, UI, and shared plugin logic.
- [API Stability Policy](./docs/API_STABILITY.md): stable, experimental, internal, deprecated, and breaking-change expectations.
- [Contributing to Facade API](./docs/CONTRIBUTING-FACADE.md): API design expectations for `FUniver`, `FWorkbook`, `FRange`, and related Facade classes.
- [Naming Convention](./docs/NAMING_CONVENTION.md): file, folder, interface, plugin, command, and dependency injection token conventions.
- [Fixing Memory Leaks](./docs/FIX_MEMORY_LEAK.md): common leak patterns and debugging workflow for Univer instances.
- [Architecture TLDRs](./docs/tldr): concise notes for formula engine, web worker, permission, selection, and ref-range behavior.
## 💬 Community
- Ask questions in [GitHub Discussions](https://github.com/dream-num/univer/discussions).
- Chat with the community on [Discord](https://discord.gg/z3NKNT6D2f).
- Follow [Twitter / X](https://twitter.com/univerhq) and [YouTube](https://www.youtube.com/@dreamNum).
Please read the [Code of Conduct](./CODE_OF_CONDUCT.md) before participating.
## 🔒 Security
If you believe you have found a security issue, please follow the [Security Policy](./SECURITY.md).
## ❤️ Sponsors
Univer is supported by the community and sponsors. You can support the project through [Open Collective](https://opencollective.com/univer).
<a href="https://opencollective.com/univer/sponsor/0/website"><img src="https://opencollective.com/univer/sponsor/0/avatar.svg" alt="Sponsor 0" /></a>
<a href="https://opencollective.com/univer/sponsor/1/website"><img src="https://opencollective.com/univer/sponsor/1/avatar.svg" alt="Sponsor 1" /></a>
<a href="https://opencollective.com/univer/sponsor/2/website"><img src="https://opencollective.com/univer/sponsor/2/avatar.svg" alt="Sponsor 2" /></a>
<a href="https://opencollective.com/univer/sponsor/3/website"><img src="https://opencollective.com/univer/sponsor/3/avatar.svg" alt="Sponsor 3" /></a>
<a href="https://opencollective.com/univer/sponsor/4/website"><img src="https://opencollective.com/univer/sponsor/4/avatar.svg" alt="Sponsor 4" /></a>
<a href="https://opencollective.com/univer/sponsor/5/website"><img src="https://opencollective.com/univer/sponsor/5/avatar.svg" alt="Sponsor 5" /></a>
<a href="https://opencollective.com/univer/sponsor/6/website"><img src="https://opencollective.com/univer/sponsor/6/avatar.svg" alt="Sponsor 6" /></a>
<a href="https://opencollective.com/univer/backer/0/website"><img src="https://opencollective.com/univer/backer/0/avatar.svg" alt="Backer 0" /></a>
<a href="https://opencollective.com/univer/backer/1/website"><img src="https://opencollective.com/univer/backer/1/avatar.svg" alt="Backer 1" /></a>
<a href="https://opencollective.com/univer/backer/2/website"><img src="https://opencollective.com/univer/backer/2/avatar.svg" alt="Backer 2" /></a>
<a href="https://opencollective.com/univer/backer/3/website"><img src="https://opencollective.com/univer/backer/3/avatar.svg" alt="Backer 3" /></a>
<a href="https://opencollective.com/univer/backer/4/website"><img src="https://opencollective.com/univer/backer/4/avatar.svg" alt="Backer 4" /></a>
<a href="https://opencollective.com/univer/backer/5/website"><img src="https://opencollective.com/univer/backer/5/avatar.svg" alt="Backer 5" /></a>
<a href="https://opencollective.com/univer/backer/6/website"><img src="https://opencollective.com/univer/backer/6/avatar.svg" alt="Backer 6" /></a>
## 📄 License
Copyright (c) 2021-present DreamNum Co., Ltd.
Licensed under the [Apache-2.0](./LICENSE) license.

72
SECURITY.md Normal file
View File

@@ -0,0 +1,72 @@
# Security Policy
This Security Policy outlines the security practices for Univer (SDK and Univer Services) to ensure the integrity of our code, dependencies, and data. We welcome contributions from community members, developers, and security researchers to identify and address potential security issues collaboratively.
## Supported Versions
We provide security updates for the following versions of Univer.
| Version | Supported |
| ------- | ------------------ |
| latest | :white_check_mark: |
As Univer is still not reaching version 1.0 and we are moving fast with latest feature updates, unsupported versions may contain unpatched vulnerabilities. We recommend upgrading to the latest supported version.
## Reporting a Vulnerability
We encourage responsible disclosure of security vulnerabilities. If you discover a potential issue, please follow these steps:
1. How to Report:
- Email us at: developer@univer.ai
- Alternatively, submit a private Security Advisory via GitHub (if applicable).
2. What to Include:
- Description: A detailed explanation of the vulnerability and its potential impact.
- Steps to Reproduce: Clear instructions to replicate the issue.
- Affected Versions: Specify which versions are impacted.
- (Optional) Suggested Fix: Any recommendations for resolution.
3. Response Timeline:
- We will acknowledge your report within 1 business days.
- Depending on the severity, we aim to release a fix within 7-30 days.
4. Confidentiality:
- Please refrain from disclosing vulnerability details in public Issues, forums, or social media until a fix is released.
- We will publicly acknowledge your contribution after the fix (unless you request anonymity).
## Security Update Process
- **Detection**: We use Dependabot and GitHub Security Alerts to automatically monitor for known vulnerabilities in dependencies.
- **Resolution**: Upon identifying a vulnerability:
1. We prioritize releasing security patches for supported versions.
2. Dependencies are updated to non-vulnerable versions.
3. Updates are announced via GitHub Releases.
- **Recommendation:** Users should regularly check for updates and apply the latest security patches.
## Guidelines for Developers
To maintain a secure codebase, developers should adhere to these best practices:
- **Dependency Management**:
- Use Dependabot or similar tools to keep dependencies up to date.
- Avoid using dependencies with known vulnerabilities.
- **Code Review**:
- All Pull Requests must be reviewed by at least one team member.
- Check for proper input validation, access controls, and handling of sensitive data.
- **Security Testing**:
- Integrate static analysis tools (e.g., CodeQL) into the CI/CD pipeline.
- Conduct periodic manual penetration testing.
## User Responsibilities
- Keep your software updated to the latest version to benefit from security fixes.
- Monitor the security status of your dependencies to avoid introducing third-party risks.
- Report any discovered issues to us promptly.
## Contact Us
For questions or further assistance, reach out to:
- Email: developer@univer.ai
- GitHub Issues: https://github.com/dream-num/univer/issues
## Acknowledgments
We extend our gratitude to all developers, researchers, and users who contribute to the security of this project!

13
codecov.yml Normal file
View File

@@ -0,0 +1,13 @@
coverage:
status:
project:
default:
# basic
target: 80%
threshold: 0
base: auto
# advanced settings
if_ci_failed: error
informational: false
only_pulls: false
patch: off

1
commitlint.config.cjs Normal file
View File

@@ -0,0 +1 @@
module.exports = { extends: ['@commitlint/config-conventional'] };

32
common/debugger/README.md Normal file
View File

@@ -0,0 +1,32 @@
# @univerjs/debugger
> [!CAUTION]
> NEVER use this plugin in your production environment!
## Package Overview
| Package Name | UMD Namespace | Version | License | Downloads | Contains CSS | Contains i18n locales |
| --- | --- | --- | --- | --- | :---: | :---: |
| `@univerjs/debugger` | `UniverDebugger` | [![][npm-version-shield]][npm-version-link] | ![][npm-license-shield] | ![][npm-downloads-shield] | ⭕️ | ❌ |
## Introduction
> This plugin provides a lot of utilities to help you debug Univer.
## Usage
### Installation
```shell
# Using npm
npm install @univerjs/debugger
# Using pnpm
pnpm add @univerjs/debugger
```
<!-- Links -->
[npm-version-shield]: https://img.shields.io/npm/v/@univerjs/debugger?style=flat-square
[npm-version-link]: https://npmjs.com/package/@univerjs/debugger
[npm-license-shield]: https://img.shields.io/npm/l/@univerjs/debugger?style=flat-square
[npm-downloads-shield]: https://img.shields.io/npm/dm/@univerjs/debugger?style=flat-square

View File

@@ -0,0 +1,54 @@
{
"name": "@univerjs/debugger",
"version": "1.0.0-rc.0",
"private": true,
"description": "",
"author": "DreamNum <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [],
"exports": {
".": "./src/index.ts",
"./*": "./src/*"
},
"main": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/docs-drawing-ui": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/sheets": "workspace:*",
"@univerjs/sheets-drawing-ui": "workspace:*",
"@univerjs/themes": "workspace:*",
"@univerjs/ui": "workspace:*",
"@univerjs/watermark": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"postcss": "^8.5.28",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vue": "^3.5.42"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -0,0 +1,68 @@
export const FloatButton = () => {
const divStyle = {
width: '100px',
height: '30px',
backgroundColor: '#fff',
border: '1px solid #ccc',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center' as const,
};
const clickHandler = () => {
console.warn('click');
};
return (
<div style={divStyle} onClick={clickHandler}>
FloatButton
</div>
);
};
export const AIButton = () => {
const divStyle = {
width: '80px',
height: '50px',
backgroundColor: '#fff',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center' as const,
borderRadius: '25px',
border: 'none',
color: 'white',
cursor: 'pointer',
transition: 'all 0.3s ease',
background: 'linear-gradient(90deg, #00C9FF 0%, #92FE9D 50%, #00C9FF 100%)',
backgroundSize: '200% auto',
animation: 'gradient 3s linear infinite',
':hover': {
transform: 'translateY(-2px)',
boxShadow: '0 10px 20px rgba(0, 201, 255, 0.3)',
},
};
const clickHandler = () => {
console.warn('click');
};
return (
<button type="button" style={divStyle} onClick={clickHandler}>
AI
<style>
{`
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0, 201, 255, 0.3);
}
`}
</style>
</button>
);
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
import { borderClassName, clsx } from '@univerjs/design';
export const RangeLoading = () => {
return (
<div
className={clsx(`
univer-flex univer-size-full univer-origin-top-left univer-items-center univer-justify-center
univer-bg-gray-0
dark:!univer-bg-gray-900
`, borderClassName)}
>
Loading...
</div>
);
};

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { ref } from 'vue';
const state = ref<'sun' | 'moon'>('sun');
function toggleState() {
state.value = state.value === 'sun' ? 'moon' : 'sun';
}
</script>
<template>
<div @click="toggleState">
<span v-if="state === 'sun'">🌞</span>
<span v-if="state === 'moon'">🌚</span>
</div>
</template>

View File

@@ -0,0 +1,47 @@
export class CounterComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const container = document.createElement('div');
container.setAttribute('class', 'counter-container');
const countDisplay = document.createElement('span');
countDisplay.setAttribute('class', 'count-display');
countDisplay.textContent = '0';
const a = document.createElement('a');
a.textContent = '🍔';
a.addEventListener('click', (e) => {
e.preventDefault();
const currentCount = Number.parseInt(countDisplay.textContent || '0', 10);
countDisplay.textContent = (currentCount + 1).toString();
});
const style = document.createElement('style');
style.textContent = `
.counter-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.count-display {
position: relative;
top: 2px;
font-size: 10px;
}
a {
font-size: 10px;
}`;
shadow.appendChild(style);
shadow.appendChild(container);
container.appendChild(countDisplay);
container.appendChild(a);
}
}

View File

@@ -0,0 +1,25 @@
import type { ILanguagePack, LocaleType, UniverInstanceType } from '@univerjs/core';
import type { MenuConfig } from '@univerjs/ui';
export const DEBUGGER_PLUGIN_CONFIG_KEY = 'debugger.config';
export const configSymbol = Symbol(DEBUGGER_PLUGIN_CONFIG_KEY);
export type UniverDebuggerLocaleLoader = (locale: LocaleType) => ILanguagePack | Promise<ILanguagePack>;
export interface IUniverDebuggerConfig {
menu?: MenuConfig;
fab?: boolean;
fabEntryUnitType: UniverInstanceType;
localeLoader: UniverDebuggerLocaleLoader;
performanceMonitor?: {
enabled: boolean;
};
}
export const defaultPluginConfig: Pick<IUniverDebuggerConfig, 'fab' | 'performanceMonitor'> = {
fab: true,
performanceMonitor: {
enabled: true,
},
};

View File

@@ -0,0 +1,44 @@
import { Disposable, Inject } from '@univerjs/core';
import { ComponentManager } from '@univerjs/ui';
import { AIButton, FloatButton } from '../components/FloatButton';
import { ImageDemo } from '../components/Image';
import { RangeLoading } from '../components/RangeLoading';
// @ts-ignore
// import VueComponent from '../components/VueComponent.vue';
// import { CounterComponent } from '../components/WebComponent';
import { WatermarkPanel } from '../views/watermark/WatermarkPanel';
import { WatermarkPanelFooter } from '../views/watermark/WatermarkPanelFooter';
export const WATERMARK_PANEL = 'WATERMARK_PANEL';
export const WATERMARK_PANEL_FOOTER = 'WATERMARK_PANEL_FOOTER';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager
) {
super();
this._registerComponents();
}
private _registerComponents(): void {
([
['ImageDemo', ImageDemo],
['RangeLoading', RangeLoading],
['FloatButton', FloatButton],
['AIButton', AIButton],
[WATERMARK_PANEL, WatermarkPanel],
[WATERMARK_PANEL_FOOTER, WatermarkPanelFooter],
] as const).forEach(([key, component]) => {
this.disposeWithMe(this._componentManager.register(key, component));
});
// this.disposeWithMe(this._componentManager.register('VueComponent', VueComponent, {
// framework: 'vue3',
// }));
// this.disposeWithMe(this._componentManager.register('counter-component', CounterComponent, {
// framework: 'web-component',
// }));
}
}

View File

@@ -0,0 +1,62 @@
import type { IUniverDebuggerConfig } from '../config/config';
import { Disposable, IConfigService, Inject, Injector } from '@univerjs/core';
import {
BuiltInUIPart,
connectInjector,
IMenuManagerService,
IUIPartsService,
// MenuItemType,
// RibbonStartGroup,
} from '@univerjs/ui';
import { DEBUGGER_PLUGIN_CONFIG_KEY } from '../config/config';
import { Fab } from '../views/Fab';
import { RecordController } from './local-save/record.controller';
export class DebuggerController extends Disposable {
constructor(
@Inject(Injector) private readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService,
@IUIPartsService protected readonly _uiPartsService: IUIPartsService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService
) {
super();
this._initCustomComponents();
// this._menuManagerService.mergeMenu({
// [RibbonStartGroup.OTHERS]: {
// EMOJI: {
// order: 9999,
// menuItemFactory: () => {
// return {
// id: 'EMOJI',
// icon: 'VueComponent',
// type: MenuItemType.BUTTON,
// };
// },
// },
// COUNTER: {
// order: 9999,
// menuItemFactory: () => {
// return {
// id: 'COUNTER',
// icon: 'counter-component',
// type: MenuItemType.BUTTON,
// };
// },
// },
// },
// });
this._injector.add([RecordController]);
}
private _initCustomComponents(): void {
const configs = this._configService.getConfig<IUniverDebuggerConfig>(DEBUGGER_PLUGIN_CONFIG_KEY);
if (configs?.fab) {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(Fab, this._injector))
);
}
}
}

View File

@@ -0,0 +1,59 @@
import { CommandType, ICommandService, Inject } from '@univerjs/core';
import { Observable } from 'rxjs';
export class RecordController {
constructor(@Inject(ICommandService) private _commandService: ICommandService) {
// empty
}
record() {
return new Observable<{ type: 'start' } | { type: 'finish'; data: Blob }>((subscribe) => {
navigator.mediaDevices.getDisplayMedia({ video: true }).then((stream) => {
subscribe.next({ type: 'start' });
const mime = MediaRecorder.isTypeSupported('video/webm; codecs=vp9')
? 'video/webm; codecs=vp9'
: 'video/webm';
const mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
const chunks: Blob[] = [];
mediaRecorder.addEventListener('dataavailable', function (e) {
chunks.push(e.data);
});
mediaRecorder.addEventListener('stop', function () {
const blob = new Blob(chunks, { type: chunks[0].type });
subscribe.next({ type: 'finish', data: blob });
subscribe.complete();
});
mediaRecorder.start();
});
});
}
startSaveCommands() {
type SecondsString = string & {};
type CommandString = string & {};
type TypeString = string & {};
type ParamsString = string & {};
const result: Array<[SecondsString, CommandString, TypeString, ParamsString]> = [];
const startTime = performance.now();
const disposable = this._commandService.beforeCommandExecuted((commandInfo) => {
try {
result.push([
String((performance.now() - startTime) / 1000),
commandInfo.id,
String(commandInfo.type || CommandType.COMMAND),
JSON.stringify(commandInfo.params || ''),
]);
} catch (err) {
console.error(`${commandInfo.id} unable to serialize`);
console.error(err);
}
});
return () => {
disposable.dispose();
return result;
};
}
}

View File

@@ -0,0 +1,8 @@
export function createDownloadElement(fileName: string, blob: Blob) {
const a = document.createElement('a');
a.download = fileName;
a.href = window.URL.createObjectURL(blob);
return () => {
a.click();
};
}

View File

@@ -0,0 +1,60 @@
import type { Nullable } from '@univerjs/core';
import type { Subscription } from 'rxjs';
import { Inject, IUniverInstanceService, LifecycleService, LifecycleStages, RxDisposable } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { distinctUntilChanged, filter, take, takeUntil } from 'rxjs';
export class PerformanceMonitorController extends RxDisposable {
private _containerElement: HTMLDivElement | null;
private _currentUnitSub: Nullable<Subscription>;
constructor(
@Inject(LifecycleService) lifecycleService: LifecycleService,
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService
) {
super();
lifecycleService.subscribeWithPrevious()
.pipe(
filter((stage) => stage === LifecycleStages.Rendered),
take(1)
)
.subscribe(() => this._listenDocumentTypeChange());
}
override dispose(): void {
super.dispose();
this._disposeCurrentObserver();
}
private _disposeCurrentObserver(): void {
this._currentUnitSub?.unsubscribe();
this._currentUnitSub = null;
}
private _listenDocumentTypeChange() {
this._instanceService.focused$.pipe(takeUntil(this.dispose$), distinctUntilChanged()).subscribe((unitId) => {
this._disposeCurrentObserver();
if (unitId) {
this._listenToRenderer(unitId);
}
});
}
private _listenToRenderer(unitId: string) {
const renderer = this._renderManagerService.getRenderUnitById(unitId);
if (renderer) {
const { engine } = renderer;
this._currentUnitSub = engine.endFrame$.subscribe(() => {
if (!this._containerElement) {
this._containerElement = document.querySelector('[data-u-comp=debugger-fps]');
} else {
this._containerElement.textContent = `FPS: ${Math.round(engine.getFps()).toString()}`;
}
});
}
}
}

View File

@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,4 @@
import './global.css';
export type { IUniverDebuggerConfig, UniverDebuggerLocaleLoader } from './config/config';
export { UniverDebuggerPlugin } from './plugin';

View File

@@ -0,0 +1,66 @@
import type { Dependency } from '@univerjs/core';
import type { IUniverDebuggerConfig } from './config/config';
import { IConfigService, Inject, Injector, merge, Plugin, registerDependencies, touchDependencies } from '@univerjs/core';
import pkg from '../package.json';
import { DEBUGGER_PLUGIN_CONFIG_KEY, defaultPluginConfig } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { DebuggerController } from './controllers/debugger.controller';
import { PerformanceMonitorController } from './controllers/performance-monitor.controller';
export class UniverDebuggerPlugin extends Plugin {
static override pluginName = 'UNIVER_DEBUGGER_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
private _debuggerController!: DebuggerController;
constructor(
private readonly _config: IUniverDebuggerConfig,
@Inject(Injector) override readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(DEBUGGER_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
const dependencies: Dependency[] = [
[DebuggerController],
[ComponentsController],
];
if (this._config.performanceMonitor?.enabled !== false) {
dependencies.push([PerformanceMonitorController]);
}
registerDependencies(this._injector, dependencies);
}
override onReady(): void {
touchDependencies(this._injector, [
[ComponentsController],
[DebuggerController],
]);
}
override onRendered(): void {
touchDependencies(this._injector, [
[PerformanceMonitorController],
]);
}
getDebuggerController() {
this._debuggerController = this._injector.get(DebuggerController);
return this._debuggerController;
}
}

View File

@@ -0,0 +1,116 @@
import type { IDropdownMenuProps } from '@univerjs/design';
import type { IUniverDebuggerConfig } from '../config/config';
import { IConfigService, UniverInstanceType } from '@univerjs/core';
import { borderClassName, clsx, DropdownMenu } from '@univerjs/design';
import { useDependency } from '@univerjs/ui';
import { DEBUGGER_PLUGIN_CONFIG_KEY } from '../config/config';
import { useCellContent } from './use-cell-content';
import { useDarkMode } from './use-dark-mode';
import { useDialog } from './use-dialog';
import { useDispose } from './use-dispose';
import { useEditable } from './use-editable';
import { useFloatingDom } from './use-floating-dom';
import { useLocale } from './use-locale';
import { useMessage } from './use-message';
import { useNotification } from './use-notification';
import { useRTL } from './use-rtl';
import { useSidebar } from './use-sidebar';
import { useSnapshot } from './use-snapshot';
import { useTheme } from './use-theme';
import { useUnits } from './use-units';
import { useUser } from './use-user';
import { useWatermark } from './use-watermark';
export function Fab() {
const configService = useDependency(IConfigService);
const configs = configService.getConfig<IUniverDebuggerConfig>(DEBUGGER_PLUGIN_CONFIG_KEY);
const performanceMonitor = configs?.performanceMonitor;
const fabEntryUnitType = configs?.fabEntryUnitType;
const locale = useLocale();
const rtl = useRTL();
const darkMode = useDarkMode();
const theme = useTheme();
const watermark = useWatermark();
const notification = useNotification();
const message = useMessage();
const dialog = useDialog();
const sidebar = useSidebar();
const floatingDom = useFloatingDom(fabEntryUnitType);
const cellContent = useCellContent(fabEntryUnitType);
const units = useUnits();
const snapshot = useSnapshot();
const editable = useEditable();
const user = useUser();
const dispose = useDispose();
const globalItems = [locale, rtl, darkMode, theme];
const lightweightItems = [
...globalItems,
watermark,
];
const commonDebugItems = [
watermark,
{ type: 'separator' as const },
notification,
message,
dialog,
sidebar,
{ type: 'separator' as const },
snapshot,
editable,
dispose,
];
const sheetItems = [
...globalItems,
watermark,
{ type: 'separator' as const },
notification,
message,
dialog,
sidebar,
{ type: 'separator' as const },
floatingDom,
cellContent,
units,
snapshot,
editable,
user,
dispose,
];
const items: IDropdownMenuProps['items'] = fabEntryUnitType === UniverInstanceType.UNIVER_BASE || fabEntryUnitType === UniverInstanceType.UNIVER_SLIDE
? lightweightItems
: fabEntryUnitType === UniverInstanceType.UNIVER_DOC
? [...globalItems, ...commonDebugItems, floatingDom].filter(Boolean) as IDropdownMenuProps['items']
: sheetItems.filter(Boolean) as IDropdownMenuProps['items'];
return (
<div
data-u-comp="debugger-fab"
className={`
univer-fixed univer-bottom-12 univer-right-8 univer-z-[9999] univer-flex univer-select-none
univer-flex-col univer-items-center univer-gap-1
`}
>
<DropdownMenu align="end" items={items}>
<button
className={clsx(`
univer-flex univer-size-9 univer-cursor-pointer univer-items-center univer-justify-center
univer-rounded-full univer-bg-gray-0 univer-text-base univer-text-gray-900 univer-shadow
univer-outline-none univer-transition-shadow
hover:univer-ring-1 hover:univer-ring-primary-400
dark:!univer-bg-gray-900 dark:!univer-text-gray-200
`, borderClassName)}
type="button"
>
🏗️
</button>
</DropdownMenu>
{performanceMonitor?.enabled && (
<span data-u-comp="debugger-fps" className="univer-text-xs univer-text-gray-400" />
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export const TEST_EDITOR_CONTAINER_COMPONENT = 'TestEditorContainer';

View File

@@ -0,0 +1,28 @@
import { ILogService, IUniverInstanceService, ObjectMatrix, Range, UniverInstanceType } from '@univerjs/core';
import { getSheetCommandTarget, SheetsSelectionsService } from '@univerjs/sheets';
import { useDependency } from '@univerjs/ui';
export function useCellContent(fabEntryUnitType?: UniverInstanceType) {
const logService = useDependency(ILogService);
const selectionManagerService = fabEntryUnitType === UniverInstanceType.UNIVER_SHEET ? useDependency(SheetsSelectionsService) : null;
const univerInstanceService = useDependency(IUniverInstanceService);
const onSelect = () => {
if (!selectionManagerService) return;
const selections = selectionManagerService.getCurrentSelections();
const target = getSheetCommandTarget(univerInstanceService);
const matrix = new ObjectMatrix();
selections.forEach((selection) => {
Range.foreach(selection.range, (row, col) => {
matrix.setValue(row, col, target?.worksheet.getCell(row, col));
});
});
logService.debug('cell-content', matrix);
};
return {
type: 'item' as const,
children: '🗒️ Console cell content',
onSelect,
};
}

View File

@@ -0,0 +1,31 @@
import { ThemeService } from '@univerjs/core';
import { useDependency } from '@univerjs/ui';
import { useEffect } from 'react';
export function useDarkMode() {
const themeService = useDependency(ThemeService);
useEffect(() => {
const darkMode = localStorage.getItem('local.darkMode');
if (darkMode === 'dark') {
document.documentElement.classList.add('univer-dark');
themeService.setDarkMode(true);
} else if (darkMode === 'system') {
themeService.setDarkMode(false);
}
}, []);
const onSelect = () => {
const darkMode = themeService.darkMode ? 'light' : 'dark';
themeService.setDarkMode(darkMode === 'dark');
localStorage.setItem('local.darkMode', darkMode);
};
return {
type: 'item' as const,
children: '🌓 Toggle dark mode',
onSelect,
};
}

View File

@@ -0,0 +1,89 @@
import { IConfirmService } from '@univerjs/core';
import { IDialogService, useDependency } from '@univerjs/ui';
const menu = [
{
label: 'Open dialog',
value: 'dialog',
},
{
label: 'Draggable dialog',
value: 'draggable',
},
{
label: 'Open confirm',
value: 'confirm',
},
];
export function useDialog() {
const dialogService = useDependency(IDialogService);
const confirmService = useDependency(IConfirmService);
const onSelect = (value: string) => {
if (value === 'draggable') {
dialogService.open({
id: 'draggable',
children: { title: 'Draggable Dialog Content' },
title: { title: 'Draggable Dialog' },
draggable: true,
preservePositionOnDestroy: true,
width: 350,
onClose() {
// dialogService.close('draggable');
},
onOpenChange(open: boolean) {
if (!open) {
dialogService.close('draggable');
}
},
});
} else if (value === 'dialog') {
dialogService.open({
id: 'dialog1',
children: { title: 'Dialog Content' },
footer: { title: 'Dialog Footer' },
title: { title: 'Dialog Title' },
draggable: false,
onClose() {
// dialogService.close('dialog1');
},
onOpenChange(open: boolean) {
if (!open) {
dialogService.close('dialog1');
}
},
});
} else if (value === 'confirm') {
confirmService.open({
id: 'confirm1',
children: { title: 'Confirm Content' },
title: { title: 'Confirm Title' },
confirmText: 'hello',
cancelText: 'world',
onClose() {
confirmService.close('confirm1');
},
});
confirmService.open({
id: 'confirm2',
children: { title: 'Confirm2 Content' },
title: { title: 'Confirm2 Title' },
onClose() {
confirmService.close('confirm2');
},
});
}
};
return {
type: 'subItem' as const,
children: '💬 Dialog & Confirm',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,42 @@
import type { Univer } from '@univerjs/core';
import { IUniverInstanceService } from '@univerjs/core';
import { useDependency } from '@univerjs/ui';
const menu = [
{
label: 'Dispose Univer',
value: 'univer',
},
{
label: 'Dispose current unit',
value: 'unit',
},
];
export function useDispose() {
const univerInstanceService = useDependency(IUniverInstanceService);
const onSelect = (value: string) => {
if (value === 'univer') {
const debuggerWindow = window as typeof window & { univer?: Univer };
debuggerWindow.univer?.dispose();
Reflect.deleteProperty(debuggerWindow, 'univer');
Reflect.deleteProperty(debuggerWindow, 'univerAPI');
} else if (value === 'unit') {
const focused = univerInstanceService.getFocusedUnit();
if (!focused) return false;
return univerInstanceService.disposeUnit(focused.getUnitId());
}
};
return {
type: 'subItem' as const,
children: '🗑️ Dispose',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,48 @@
import { IPermissionService, IUniverInstanceService } from '@univerjs/core';
import { getSheetCommandTarget, WorkbookEditablePermission, WorksheetEditPermission } from '@univerjs/sheets';
import { useDependency } from '@univerjs/ui';
const menu = [
{
label: 'Change workbook editable',
value: 'univer',
},
{
label: 'Change worksheet editable',
value: 'sheet',
},
];
export function useEditable() {
const univerInstanceService = useDependency(IUniverInstanceService);
const permissionService = useDependency(IPermissionService);
const onSelect = (value: string) => {
const target = getSheetCommandTarget(univerInstanceService);
if (!target) {
return false;
}
const { workbook, worksheet, unitId, subUnitId } = target;
if (!workbook || !worksheet) {
return false;
}
if (value === 'sheet') {
const editable = permissionService.getPermissionPoint(new WorksheetEditPermission(unitId, subUnitId).id);
permissionService.updatePermissionPoint(new WorksheetEditPermission(unitId, subUnitId).id, !editable);
} else {
const unitId = workbook!.getUnitId();
const editable = permissionService.getPermissionPoint(new WorkbookEditablePermission(unitId).id);
permissionService.updatePermissionPoint(new WorkbookEditablePermission(unitId).id, !editable);
}
};
return {
type: 'subItem' as const,
children: '✍️ Editable',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,50 @@
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocFloatDomController } from '@univerjs/docs-drawing-ui';
import { SheetCanvasFloatDomManagerService } from '@univerjs/sheets-drawing-ui';
import { useDependency } from '@univerjs/ui';
export function useFloatingDom(entryUnitType?: UniverInstanceType) {
const univerInstanceService = useDependency(IUniverInstanceService);
const floatDomService = entryUnitType === UniverInstanceType.UNIVER_SHEET ? useDependency(SheetCanvasFloatDomManagerService) : null;
const floatDomController = entryUnitType === UniverInstanceType.UNIVER_DOC ? useDependency(DocFloatDomController) : null;
const onSelect = () => {
if (entryUnitType === UniverInstanceType.UNIVER_SHEET) {
const currentSheet = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET);
if (!currentSheet) return;
floatDomService?.addFloatDomToPosition({
allowTransform: true,
initPosition: {
startX: 200,
endX: 400,
startY: 200,
endY: 400,
},
componentKey: 'ImageDemo',
data: {
aa: '128',
},
});
} else if (entryUnitType === UniverInstanceType.UNIVER_DOC) {
const currentDoc = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC);
if (!currentDoc) return;
floatDomController?.insertFloatDom({
allowTransform: true,
componentKey: 'ImageDemo',
data: {
aa: '128',
},
}, {
height: 300,
});
}
};
return {
type: 'item' as const,
children: '☁️ Create floating DOM',
onSelect,
};
}

View File

@@ -0,0 +1,78 @@
import type { IUniverDebuggerConfig } from '../config/config';
import { IConfigService, LocaleService, LocaleType } from '@univerjs/core';
import { useDependency } from '@univerjs/ui';
import { useCallback, useEffect } from 'react';
import { DEBUGGER_PLUGIN_CONFIG_KEY } from '../config/config';
const localeLabels: Record<LocaleType, string> = {
[LocaleType.AR_SA]: 'العربية',
[LocaleType.CA_ES]: 'Català',
[LocaleType.DE_DE]: 'Deutsch',
[LocaleType.EN_US]: 'English',
[LocaleType.ES_ES]: 'Español',
[LocaleType.FA_IR]: 'فارسی',
[LocaleType.FR_FR]: 'Français',
[LocaleType.ID_ID]: 'Bahasa Indonesia',
[LocaleType.IT_IT]: 'Italiano',
[LocaleType.JA_JP]: '日本語',
[LocaleType.KO_KR]: '한국어',
[LocaleType.PL_PL]: 'Polski',
[LocaleType.PT_BR]: 'Português (Brasil)',
[LocaleType.RU_RU]: 'Русский',
[LocaleType.SK_SK]: 'Slovenčina',
[LocaleType.VI_VN]: 'Tiếng Việt',
[LocaleType.ZH_CN]: '简体中文',
[LocaleType.ZH_HK]: '繁體中文(香港)',
[LocaleType.ZH_TW]: '繁體中文(台灣)',
};
export function useLocale() {
const configService = useDependency(IConfigService);
const localeService = useDependency(LocaleService);
const configs = configService.getConfig<IUniverDebuggerConfig>(DEBUGGER_PLUGIN_CONFIG_KEY);
const localeLoader = configs?.localeLoader;
const loadLocales = useCallback(async (value: string) => {
const locale = value as LocaleType;
if (!localeLoader) {
throw new Error('[UniverDebuggerPlugin]: localeLoader is required.');
}
const localePack = await localeLoader(locale);
localeService.load({
[locale]: localePack,
});
}, [localeLoader, localeService]);
useEffect(() => {
const locale = localStorage.getItem('local.locale');
if (locale) {
loadLocales(locale).then(() => {
localeService.setLocale(locale as LocaleType);
});
}
}, [loadLocales, localeService]);
const onSelect = async (value: string) => {
await loadLocales(value);
localeService.setLocale(value as LocaleType);
localStorage.setItem('local.locale', value);
};
return {
type: 'subItem' as const,
children: '🌐 Languages',
options: Object.values(LocaleType).map((locale) => ({
type: 'radio' as const,
value: localeService.getCurrentLocale(),
options: [{
label: localeLabels[locale],
value: locale,
}],
onSelect,
})),
};
}

View File

@@ -0,0 +1,43 @@
import { MessageType } from '@univerjs/design';
import { IMessageService, useDependency } from '@univerjs/ui';
const menu = [
{
label: 'Open message',
value: '',
},
{
label: 'Open loading message',
value: 'loading',
},
];
export function useMessage() {
const messageService = useDependency(IMessageService);
const onSelect = (value: string) => {
if (value === 'loading') {
messageService.show({
type: MessageType.Loading,
content: 'Loading message',
duration: 3000,
});
} else {
messageService.show({
type: MessageType.Success,
content: 'Demo message',
duration: 1500,
});
}
};
return {
type: 'subItem' as const,
children: '✉️ Message',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,43 @@
import type { INotificationOptions } from '@univerjs/ui';
import { INotificationService, useDependency } from '@univerjs/ui';
const menu = [
{
label: 'Notification success',
value: 'success',
},
{
label: 'Notification info',
value: 'info',
},
{
label: 'Notification warning',
value: 'warning',
},
{
label: 'Notification error',
value: 'error',
},
];
export function useNotification() {
const notificationService = useDependency(INotificationService);
const onSelect = (value: string) => {
notificationService.show({
type: value as INotificationOptions['type'],
content: 'Lorem Ipusm dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
title: 'Notification Title',
});
};
return {
type: 'subItem' as const,
children: '🔔 Notification',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,29 @@
import { LocaleService } from '@univerjs/core';
import { useDependency } from '@univerjs/ui';
import { useEffect } from 'react';
export function useRTL() {
const localeService = useDependency(LocaleService);
useEffect(() => {
const dir = localStorage.getItem('local.direction');
if (dir === 'rtl' || dir === 'ltr') {
localeService.setDirection(dir);
}
}, [localeService]);
const onSelect = () => {
const current = localeService.getDirection();
const nextDirection = current === 'rtl' ? 'ltr' : 'rtl';
localeService.setDirection(nextDirection);
localStorage.setItem('local.direction', nextDirection);
};
return {
type: 'item' as const,
children: '↔️ Toggle RTL',
onSelect,
};
}

View File

@@ -0,0 +1,41 @@
import { ISidebarService, useDependency } from '@univerjs/ui';
import { TEST_EDITOR_CONTAINER_COMPONENT } from './test-editor/component-name';
const menu = [
{
label: 'Open sidebar',
value: 'open',
},
{
label: 'Close sidebar',
value: 'close',
},
];
export function useSidebar() {
const sidebarService = useDependency(ISidebarService);
const onSelect = (value: string) => {
if (value === 'open') {
sidebarService.open({
header: { title: 'Sidebar title' },
children: { label: TEST_EDITOR_CONTAINER_COMPONENT },
footer: { title: 'Sidebar Footer' },
onClose: () => {
},
});
} else if (value === 'close') {
sidebarService.close();
}
};
return {
type: 'subItem' as const,
children: '🧩 Sidebar',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,128 @@
/* eslint-disable node/prefer-global/process */
import type { DocumentDataModel, IStyleData, IWorkbookData, Workbook } from '@univerjs/core';
import { IResourceLoaderService, IUniverInstanceService, ObjectMatrix, UniverInstanceType } from '@univerjs/core';
import { ILocalFileService, useDependency } from '@univerjs/ui';
import { RecordController } from '../controllers/local-save/record.controller';
const menu = [
{
label: 'Save workbook',
value: 'workbook',
},
{
label: 'Save worksheet',
value: 'sheet',
},
{
label: 'Record',
value: 'record',
},
{
label: 'Load snapshot',
value: 'load',
},
];
export interface ISaveSnapshotParams {
value: 'sheet' | 'workbook' | 'record';
}
const filterStyle = (workbookData: IWorkbookData) => {
const sheets = workbookData.sheets;
const cacheStyle: Record<string, IStyleData> = {};
Object.keys(sheets).forEach((sheetId) => {
const sheet = sheets[sheetId];
new ObjectMatrix(sheet.cellData).forValue((_r, _c, value) => {
const s = value?.s;
if (s && typeof s === 'string') {
const style = workbookData.styles[s];
if (style) {
cacheStyle[s] = style;
}
}
});
});
workbookData.styles = cacheStyle;
return workbookData;
};
export function useSnapshot() {
const univerInstanceService = useDependency(IUniverInstanceService);
const resourceLoaderService = useDependency(IResourceLoaderService);
const localFileService = useDependency(ILocalFileService);
const recordController = useDependency(RecordController);
const onSelect = async (value: string) => {
const preName = new Date().toLocaleString();
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) {
const doc = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)!;
const snapshot = resourceLoaderService.saveUnit(doc.getUnitId());
if (process.env.NODE_ENV === 'production') {
const gitHash = process.env.GIT_COMMIT_HASH;
const gitBranch = process.env.GIT_REF_NAME;
const buildTime = process.env.BUILD_TIME;
(snapshot as any).__env__ = { gitHash, gitBranch, buildTime };
}
const text = JSON.stringify(snapshot, null, 2);
localFileService.downloadFile(new Blob([text]), `${preName} snapshot.json`);
return true;
}
const worksheet = workbook.getActiveSheet();
if (!worksheet) {
return false;
}
const snapshot = resourceLoaderService.saveUnit<ReturnType<typeof workbook.getSnapshot>>(workbook.getUnitId())!;
if (process.env.NODE_ENV === 'production') {
const gitHash = process.env.GIT_COMMIT_HASH;
const gitBranch = process.env.GIT_REF_NAME;
const buildTime = process.env.BUILD_TIME;
(snapshot as any).__env__ = { gitHash, gitBranch, buildTime };
}
if (value === 'sheet') {
const sheetId = worksheet.getSheetId();
const sheet = snapshot.sheets[sheetId];
snapshot.sheets = { [sheetId]: sheet };
snapshot.sheetOrder = [sheetId];
const text = JSON.stringify(filterStyle(snapshot), null, 2);
localFileService.downloadFile(new Blob([text]), `${preName} snapshot.json`);
} else if (value === 'workbook') {
const text = JSON.stringify(filterStyle(snapshot), null, 2);
localFileService.downloadFile(new Blob([text]), `${preName} snapshot.json`);
} else if (value === 'record') {
let endCommands = () => [];
recordController.record().subscribe((v) => {
if (v.type === 'start') {
endCommands = recordController.startSaveCommands() as any;
}
if (v.type === 'finish') {
const commands = endCommands();
localFileService.downloadFile(v.data, `${preName} video.webm`);
localFileService.downloadFile(new Blob([JSON.stringify(commands, null, 2)]), `${preName} commands.json`);
}
});
} else if (value === 'load') {
const snapshotFile = await localFileService.openFile({ multiple: false, accept: '.json' });
if (snapshotFile.length !== 1) return false;
const text = await snapshotFile[0].text();
univerInstanceService.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, JSON.parse(text));
return true;
}
};
return {
type: 'subItem' as const,
children: '📷 Snapshot',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,77 @@
import { ThemeService } from '@univerjs/core';
import {
blueTheme,
darkBlueTheme,
greenTheme,
orangeTheme,
purpleTheme,
redTheme,
yellowTheme,
} from '@univerjs/themes';
import { useDependency } from '@univerjs/ui';
import { useEffect } from 'react';
const themes = [
{
label: '🟢',
value: greenTheme,
},
{
label: '🔵',
value: blueTheme,
},
{
label: '🔵🌑',
value: darkBlueTheme,
},
{
label: '🟠',
value: orangeTheme,
},
{
label: '🔴',
value: redTheme,
},
{
label: '🟣',
value: purpleTheme,
},
{
label: '🟡',
value: yellowTheme,
},
];
export function useTheme() {
const themeService = useDependency(ThemeService);
useEffect(() => {
const themeKey = localStorage.getItem('local.theme');
const theme = themes.find((theme) => theme.label === themeKey);
if (theme) {
themeService.setTheme(theme.value);
}
}, [themeService]);
const onSelect = (value: string) => {
localStorage.setItem('local.theme', value);
const theme = themes.find((theme) => theme.label === value);
if (theme) {
themeService.setTheme(theme.value);
}
};
return {
type: 'subItem' as const,
children: '🎨 Themes',
options: themes.map((theme) => ({
type: 'item' as const,
children: theme.label,
onSelect: () => onSelect(theme.label),
})),
};
}

View File

@@ -0,0 +1,50 @@
import type { Workbook } from '@univerjs/core';
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { useDependency, useObservable } from '@univerjs/ui';
import { useEffect, useState } from 'react';
const defaultMenu = [
{
label: 'Create another sheet',
value: 'create',
},
];
export function useUnits() {
const univerInstanceService = useDependency(IUniverInstanceService);
const [menu, setMenu] = useState<{ label: string; value: string }[]>([...defaultMenu]);
const unitAdded = useObservable(univerInstanceService.unitAdded$);
const unitDisposed = useObservable(univerInstanceService.unitDisposed$);
useEffect(() => {
const sheets = univerInstanceService.getAllUnitsForType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const options = sheets.map((sheet) => ({
label: sheet.name || sheet.getUnitId(),
value: sheet.getUnitId(),
}));
setMenu([
...defaultMenu,
...(options as any[]),
]);
}, [unitAdded, unitDisposed]);
const onSelect = (value: string) => {
if (value === 'create') {
univerInstanceService.createUnit(UniverInstanceType.UNIVER_SHEET, {});
} else {
if (!univerInstanceService.getUnit(value)) return false;
univerInstanceService.setCurrentUnitForType(value);
}
};
return {
type: 'subItem' as const,
children: '🪸 Units',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,54 @@
import type { Workbook } from '@univerjs/core';
import { createDefaultUser, IPermissionService, IUniverInstanceService, UniverInstanceType, UserManagerService } from '@univerjs/core';
import { WorkbookManageCollaboratorPermission } from '@univerjs/sheets';
import { useDependency } from '@univerjs/ui';
enum UnitRole {
Reader = 0,
Editor = 1,
Owner = 2,
UNRECOGNIZED = -1,
}
const menu = [
{
label: 'Owner',
value: UnitRole.Owner,
},
{
label: 'Editor',
value: UnitRole.Editor,
},
{
label: 'Reader',
value: UnitRole.Reader,
},
];
export function useUser() {
const univerInstanceService = useDependency(IUniverInstanceService);
const userManagerService = useDependency(UserManagerService);
const permissionService = useDependency(IPermissionService);
const onSelect = (value: UnitRole) => {
userManagerService.setCurrentUser(createDefaultUser(value));
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const unitId = workbook.getUnitId();
if (value === UnitRole.Owner) {
permissionService.updatePermissionPoint(new WorkbookManageCollaboratorPermission(unitId).id, true);
} else {
permissionService.updatePermissionPoint(new WorkbookManageCollaboratorPermission(unitId).id, false);
}
};
return {
type: 'subItem' as const,
children: '👥 Change user\'s role',
options: menu.map((item) => ({
type: 'item' as const,
children: item.label,
onSelect: () => onSelect(item.value),
})),
};
}

View File

@@ -0,0 +1,22 @@
import { ISidebarService, useDependency } from '@univerjs/ui';
import { WATERMARK_PANEL, WATERMARK_PANEL_FOOTER } from '../controllers/components.controller';
export function useWatermark() {
const sidebarService = useDependency(ISidebarService);
const onSelect = () => {
sidebarService.open({
header: { title: 'Watermark' },
children: { label: WATERMARK_PANEL },
footer: { label: WATERMARK_PANEL_FOOTER },
onClose: () => { },
width: 330,
});
};
return {
type: 'item' as const,
children: '🌊 Watermark Settings',
onSelect,
};
}

View File

@@ -0,0 +1,224 @@
import type { IImageWatermarkConfig } from '@univerjs/engine-render';
import { Button, Checkbox, InputNumber } from '@univerjs/design';
import { ILocalFileService, useDependency } from '@univerjs/ui';
import { WATERMARK_IMAGE_ALLOW_IMAGE_LIST } from '@univerjs/watermark';
interface IWatermarkImageSettingProps {
config?: IImageWatermarkConfig;
onChange: (config: IImageWatermarkConfig) => void;
}
export function WatermarkImageSetting({ config, onChange }: IWatermarkImageSettingProps) {
const fileOpenService = useDependency(ILocalFileService);
if (!config) return null;
const handleUpdateImageUrl = async () => {
const files = await fileOpenService.openFile({
multiple: false,
accept: WATERMARK_IMAGE_ALLOW_IMAGE_LIST.map((image) => `.${image.replace('image/', '')}`).join(','),
});
const fileLength = files.length;
if (fileLength === 0) {
return false;
}
const file = files[0];
const reader = new FileReader();
reader.onload = function (event) {
if (event.target?.result) {
const base64String = event.target.result;
const img = new Image();
img.onload = function () {
onChange({ ...config, url: base64String as string, width: Math.max(20, img.width), height: Math.max(img.height, 20), originRatio: img.width / img.height });
};
img.src = base64String as string;
}
};
reader.readAsDataURL(file);
};
return (
<div className="univer-grid univer-gap-2">
<div className="univer-text-gray-400">Image</div>
<div className="univer-mb-4 univer-grid univer-gap-1">
<Button
className="univer-mb-2"
onClick={handleUpdateImageUrl}
>
{config.url ? 'Replace Image' : 'Upload Image'}
</Button>
<div className="univer-flex univer-gap-2 univer-text-center">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Opacity</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.opacity}
max={1}
min={0}
step={0.05}
onChange={(val) => {
if (val != null) {
onChange({ ...config, opacity: Number.parseFloat(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Keep Ratio</div>
<Checkbox
className="univer-justify-center univer-self-baseline"
checked={config.maintainAspectRatio}
onChange={(val) => {
if (val === true) {
onChange({ ...config, maintainAspectRatio: val as boolean, height: Math.round(config.width / config.originRatio) });
} else {
onChange({ ...config, maintainAspectRatio: val as boolean });
}
}}
/>
</div>
</div>
</div>
<div className="univer-grid univer-gap-2 univer-text-center">
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Width</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.width}
min={20}
onChange={(val) => {
if (val != null) {
const newWidth = Math.max(20, Number.parseInt(val.toString()));
if (config.maintainAspectRatio) {
onChange({ ...config, width: newWidth, height: Math.round(newWidth / config.originRatio) });
} else {
onChange({ ...config, width: newWidth });
}
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Height</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.height}
min={20}
onChange={(val) => {
if (val != null) {
const newHeight = Math.max(20, Number.parseInt(val.toString()));
if (config.maintainAspectRatio) {
onChange({ ...config, height: newHeight, width: Math.round(newHeight * config.originRatio) });
} else {
onChange({ ...config, height: Number.parseInt(val.toString()) });
}
}
}}
/>
</div>
</div>
</div>
<div className="univer-text-gray-400">Layout Settings</div>
<div className="univer-grid univer-gap-2 univer-text-center">
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Rotate</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.rotate}
max={360}
min={-360}
onChange={(val) => {
if (val != null) {
onChange({ ...config, rotate: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Repeat</div>
<Checkbox
className="univer-justify-center univer-self-baseline"
checked={config.repeat}
onChange={(val) => onChange({ ...config, repeat: val as boolean })}
/>
</div>
</div>
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Horizontal Spacing</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.spacingX}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, spacingX: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Vertical Spacing</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.spacingY}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, spacingY: Number.parseInt(val.toString()) });
}
}}
/>
</div>
</div>
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Horizontal Start Position</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.x}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, x: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Vertical Start Position</div>
<InputNumber
className="univer-box-border univer-h-7"
value={config.y}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, y: Number.parseInt(val.toString()) });
}
}}
/>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,65 @@
import type { IWatermarkConfig, IWatermarkConfigWithType } from '@univerjs/engine-render';
import { ILocalStorageService } from '@univerjs/core';
import { Select } from '@univerjs/design';
import { IWatermarkTypeEnum, UNIVER_WATERMARK_STORAGE_KEY } from '@univerjs/engine-render';
import { useDependency, useObservable } from '@univerjs/ui';
import { WatermarkImageBaseConfig, WatermarkService, WatermarkTextBaseConfig } from '@univerjs/watermark';
import { useCallback, useEffect, useState } from 'react';
import { WatermarkImageSetting } from './WatermarkImageSetting';
import { WatermarkTextSetting } from './WatermarkTextSetting';
export function WatermarkPanel() {
const [watermarkType, setWatermarkType] = useState<IWatermarkTypeEnum>(IWatermarkTypeEnum.Text);
const [config, setConfig] = useState<IWatermarkConfig>();
const watermarkService = useDependency(WatermarkService);
const localStorageService = useDependency(ILocalStorageService);
const _refresh = useObservable(watermarkService.refresh$);
function handleConfigChange(config: IWatermarkConfig, type?: IWatermarkTypeEnum) {
setConfig(config);
watermarkService.updateWatermarkConfig({ type: type ?? watermarkType, config });
}
const getWatermarkConfig = useCallback(async () => {
const watermarkConfig = await localStorageService.getItem<IWatermarkConfigWithType>(UNIVER_WATERMARK_STORAGE_KEY);
if (watermarkConfig) {
setWatermarkType(watermarkConfig.type);
setConfig(watermarkConfig.config);
} else {
setConfig({ text: WatermarkTextBaseConfig });
}
}, []);
useEffect(() => {
getWatermarkConfig();
}, [_refresh, getWatermarkConfig]);
return (
<div className="univer-grid univer-gap-3 univer-text-sm">
{/* Watermark type */}
<div className="univer-grid univer-gap-2">
<div className="univer-text-gray-400">Type</div>
<Select
value={watermarkType}
options={[
{ label: 'Text', value: IWatermarkTypeEnum.Text },
{ label: 'Image', value: IWatermarkTypeEnum.Image },
]}
onChange={(v) => {
setWatermarkType(v as IWatermarkTypeEnum);
if (v === IWatermarkTypeEnum.Text) {
handleConfigChange({ text: WatermarkTextBaseConfig }, IWatermarkTypeEnum.Text);
} else if (v === IWatermarkTypeEnum.Image) {
handleConfigChange({ image: WatermarkImageBaseConfig }, IWatermarkTypeEnum.Image);
}
}}
/>
</div>
<div className="univer-grid univer-gap-2">
{watermarkType === IWatermarkTypeEnum.Text && <WatermarkTextSetting config={config?.text} onChange={(v) => handleConfigChange({ text: v })} />}
{watermarkType === IWatermarkTypeEnum.Image && <WatermarkImageSetting config={config?.image} onChange={(v) => handleConfigChange({ image: v })} />}
</div>
</div>
);
};

View File

@@ -0,0 +1,57 @@
import { Button } from '@univerjs/design';
import { IWatermarkTypeEnum } from '@univerjs/engine-render';
import { IClipboardInterfaceService, ISidebarService, useDependency } from '@univerjs/ui';
import { WatermarkService, WatermarkTextBaseConfig } from '@univerjs/watermark';
export function WatermarkPanelFooter() {
const sidebarService = useDependency(ISidebarService);
const watermarkService = useDependency(WatermarkService);
const clipboardService = useDependency(IClipboardInterfaceService);
return (
<div className="univer-flex univer-items-center univer-justify-between">
<a
className="univer-text-sm univer-text-primary-600 univer-underline"
onClick={() => {
watermarkService.updateWatermarkConfig({
type: IWatermarkTypeEnum.Text,
config: { text: WatermarkTextBaseConfig },
});
watermarkService.refresh();
}}
>
Cancel Watermark
</a>
<div className="univer-flex univer-items-center univer-gap-2">
<Button
onClick={async () => {
const watermarkConfig = await watermarkService.getWatermarkConfig();
let config;
if (watermarkConfig?.type === IWatermarkTypeEnum.Text) {
config = watermarkConfig.config.text;
} else if (watermarkConfig?.type === IWatermarkTypeEnum.Image) {
config = watermarkConfig.config.image;
}
clipboardService.writeText(JSON.stringify(config));
}}
>
Copy Config
</Button>
<Button
onClick={async () => {
const watermarkConfig = await watermarkService.getWatermarkConfig();
if (watermarkConfig?.type === IWatermarkTypeEnum.Text && !watermarkConfig.config.text?.content) {
watermarkService.deleteWatermarkConfig();
} else if (watermarkConfig?.type === IWatermarkTypeEnum.Image && !watermarkConfig.config.image?.url) {
watermarkService.deleteWatermarkConfig();
}
sidebarService.close();
}}
>
Close Panel
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,205 @@
import type { ITextWatermarkConfig } from '@univerjs/engine-render';
import { Checkbox, clsx, ColorPicker, Dropdown, Input, InputNumber, Select } from '@univerjs/design';
import { BoldIcon, FontColorDoubleIcon, ItalicIcon } from '@univerjs/icons';
interface IWatermarkTextSettingProps {
config?: ITextWatermarkConfig;
onChange: (config: ITextWatermarkConfig) => void;
}
export function WatermarkTextSetting(props: IWatermarkTextSettingProps) {
const { config, onChange } = props;
if (!config) return null;
return (
<div className="univer-grid univer-gap-2">
<div className="univer-text-gray-400">Style Settings</div>
<div className="univer-mb-4 univer-grid univer-gap-1">
<div>Content</div>
<Input
value={config.content}
onChange={(val) => onChange({ ...config, content: val })}
placeholder="Enter text"
/>
</div>
<div className="univer-grid univer-gap-4">
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-gap-1">
<div>Font Size</div>
<InputNumber
value={config.fontSize}
max={72}
min={12}
onChange={(val) => {
if (val != null) {
onChange({ ...config, fontSize: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-gap-1">
<div>Direction</div>
<Select
value={config.direction}
options={[
{ label: 'Left to Right', value: 'ltr' },
{ label: 'Right to Left', value: 'rtl' },
]}
onChange={(v) => onChange({ ...config, direction: v as 'ltr' | 'rtl' })}
/>
</div>
<div className="univer-grid univer-gap-1">
<div>Opacity</div>
<InputNumber
max={1}
min={0}
step={0.05}
value={config.opacity}
onChange={(val) => {
if (val != null) {
onChange({ ...config, opacity: Number.parseFloat(val.toString()) });
}
}}
/>
</div>
</div>
<div
className={`
univer-flex univer-justify-around univer-gap-4
[&_a]:univer-flex [&_a]:univer-size-6 [&_a]:univer-items-center [&_a]:univer-justify-center
[&_a]:univer-rounded
`}
>
<Dropdown
overlay={(
<div className="univer-rounded-lg univer-p-4">
<ColorPicker value={config.color} onChange={(val) => onChange({ ...config, color: val })} />
</div>
)}
>
<a
className={`
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
`}
>
<FontColorDoubleIcon extend={{ colorChannel1: config.color ?? '#2c53f1' }} />
</a>
</Dropdown>
<a
className={clsx(`
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
`, {
'univer-bg-gray-200 dark:!univer-bg-gray-600': config.bold,
})}
onClick={() => { onChange({ ...config, bold: !config.bold }); }}
>
<BoldIcon />
</a>
<a
className={clsx(`
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
`, {
'univer-bg-gray-200 dark:!univer-bg-gray-600': config.italic,
})}
onClick={() => { onChange({ ...config, italic: !config.italic }); }}
>
<ItalicIcon />
</a>
</div>
</div>
{/* Layout */}
<div className="univer-text-gray-400">Layout Settings</div>
<div className="univer-grid univer-gap-2 univer-text-center">
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Rotate</div>
<InputNumber
value={config.rotate}
max={360}
min={-360}
onChange={(val) => {
if (val != null) {
onChange({ ...config, rotate: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-flex-1 univer-gap-1">
<div>Repeat</div>
<Checkbox
className="univer-justify-center univer-self-baseline"
checked={config.repeat}
onChange={(val) => onChange({ ...config, repeat: val as boolean })}
/>
</div>
</div>
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-gap-1">
<div>Horizontal Spacing</div>
<InputNumber
value={config.spacingX}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, spacingX: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-gap-1">
<div>Vertical Spacing</div>
<InputNumber
value={config.spacingY}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, spacingY: Number.parseInt(val.toString()) });
}
}}
/>
</div>
</div>
<div className="univer-flex univer-gap-2">
<div className="univer-grid univer-gap-1">
<div>Horizontal Start Position</div>
<InputNumber
value={config.x}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, x: Number.parseInt(val.toString()) });
}
}}
/>
</div>
<div className="univer-grid univer-gap-1">
<div>Vertical Start Position</div>
<InputNumber
value={config.y}
min={0}
onChange={(val) => {
if (val != null) {
onChange({ ...config, y: Number.parseInt(val.toString()) });
}
}}
/>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,11 @@
import type { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;

View File

@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

88
common/shared/bin/index.mts Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env -S node --experimental-strip-types
import type { IBuildOptions } from '../tsdown/types.ts';
import process from 'node:process';
import { buildPresetPackage, preparePresetPackage } from '../preset-build/index.ts';
import { build } from '../tsdown/index.ts';
interface IPresetCliBuildOptions {
cleanup?: boolean;
tsdownConfigPath?: string;
}
const argvs = process.argv.slice(2);
const [command, ...args] = argvs;
function collectOptionValues(argv: string[], optionName: string) {
const values: string[] = [];
for (let index = 0; index < argv.length; index++) {
if (argv[index] !== optionName) {
continue;
}
const next = argv[index + 1];
if (!next || next.startsWith('--')) {
continue;
}
values.push(...next.split(',').map((value) => value.trim()).filter(Boolean));
index++;
}
return [...new Set(values)];
}
if (command === 'preset') {
const [presetCommand, ...presetArgs] = args;
if (!presetCommand) {
throw new Error('Missing preset subcommand. Expected "prepare" or "build".');
}
if (presetCommand === 'prepare') {
preparePresetPackage();
} else if (presetCommand === 'build') {
const options: IPresetCliBuildOptions = {};
if (presetArgs.includes('--cleanup')) {
options.cleanup = true;
}
const configIdx = presetArgs.indexOf('--config');
if (configIdx !== -1 && presetArgs[configIdx + 1]) {
options.tsdownConfigPath = presetArgs[configIdx + 1];
}
// eslint-disable-next-line antfu/no-top-level-await
await buildPresetPackage(options);
} else {
throw new Error(`Unknown preset subcommand "${presetCommand}". Expected "prepare" or "build".`);
}
}
if (command === 'build') {
const options: IBuildOptions = {};
if (args.includes('--skipUMD')) {
options.skipUMD = true;
}
if (args.includes('--cleanup')) {
options.cleanup = true;
}
if (args.includes('--nodeFirst')) {
options.nodeFirst = true;
}
const ignorePackages = collectOptionValues(args, '--ignorePackages');
if (ignorePackages.length > 0) {
options.ignorePackages = ignorePackages;
}
const configIdx = args.indexOf('--config');
if (configIdx !== -1 && args[configIdx + 1]) {
options.tsdownConfigPath = args[configIdx + 1];
}
// eslint-disable-next-line antfu/no-top-level-await
await build(options);
}

View File

@@ -0,0 +1,76 @@
import fs from 'node:fs';
interface IResolveArgs {
importer: string;
path: string;
}
interface ILoadArgs {
path: string;
}
interface IPluginBuild {
onResolve(
options: { filter: RegExp; namespace?: string },
callback: (args: IResolveArgs) => unknown
): void;
onLoad(
options: { filter: RegExp; namespace?: string },
callback: (args: ILoadArgs) => unknown
): void;
}
interface IEsbuildPlugin {
name: string;
setup: (build: IPluginBuild) => void;
}
export function ignoreGlobalCssPlugin(): IEsbuildPlugin {
return {
name: 'ignore-global-css',
setup(build: IPluginBuild) {
build.onResolve({ filter: /\/global\.css$/ }, (args: IResolveArgs) => {
if (args.importer.includes('packages')) {
return {
path: args.path,
namespace: 'ignore-global-css',
pluginData: {
importer: args.importer,
},
};
}
});
build.onLoad({ filter: /\/global\.css$/, namespace: 'ignore-global-css' }, () => {
return { contents: '' };
});
},
};
}
export function removeClassnameNewlinesPlugin(): IEsbuildPlugin {
return {
name: 'remove-classname-newlines',
setup(build: IPluginBuild) {
build.onLoad({ filter: /\.(tsx)$/ }, (args: ILoadArgs) => {
const source = fs.readFileSync(args.path, 'utf8');
const transformedSource = source.replace(
/className\s*=\s*{([^}]*?)}/gs,
(_match: string, classNameValue: string) => {
const cleanedValue = classNameValue.replace(/`([^`]*?)`/gs, (_templateMatch: string, templateContent: string) => {
return `\`${templateContent.replace(/\s*\n\s*/g, ' ').trim()}\``;
});
return `className={${cleanedValue.trim()}}`;
}
);
return {
contents: transformedSource,
loader: 'tsx',
};
});
},
};
}

View File

@@ -0,0 +1,80 @@
import type { Rules } from '@antfu/eslint-config';
import type { ESLint, Linter } from 'eslint';
import antfu from '@antfu/eslint-config';
import reactHooks from 'eslint-plugin-react-hooks';
import {
facadePreset,
headerPreset,
indexEntryPreset,
noBarrelImportPreset,
penetratingPreset,
specPreset,
tailwindcssPreset,
typescriptPreset,
univerSourcePreset,
} from './presets';
import { baseRules } from './rules';
export interface IUniverEslintConfigOptions {
ignores?: string[];
header?: boolean;
noFacadeImportsOutsideFacade?: {
ignore?: string[];
};
rules?: Partial<Rules>;
}
export const createUniverEslintConfig = (options: IUniverEslintConfigOptions = {}) => {
const configs: Linter.Config[] = [
{
files: ['**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}'],
plugins: {
'react-hooks': reactHooks as unknown as ESLint.Plugin,
},
rules: {
...baseRules,
...options.rules,
},
},
penetratingPreset(),
typescriptPreset(),
indexEntryPreset(),
univerSourcePreset({
noFacadeImportsOutsideFacade: options.noFacadeImportsOutsideFacade,
}),
facadePreset(),
noBarrelImportPreset(),
tailwindcssPreset(),
specPreset(),
];
if (options.header ?? true) {
configs.push(headerPreset());
}
return antfu(
{
ignores: options.ignores,
stylistic: {
indent: 4,
semi: true,
},
e18e: false,
regexp: false,
react: true,
pnpm: false,
yaml: {
overrides: {
'yaml/indent': ['error', 4, { indicatorValueIndent: 2 }],
},
},
markdown: false,
typescript: true,
formatters: {
css: true,
html: true,
},
},
...configs
);
};

View File

@@ -0,0 +1,14 @@
export { createUniverEslintConfig } from './config';
export type { IUniverEslintConfigOptions } from './config';
export {
facadePreset,
headerPreset,
indexEntryPreset,
noBarrelImportPreset,
penetratingPreset,
specPreset,
tailwindcssPreset,
typescriptPreset,
univerSourcePreset,
} from './presets';
export { baseRules } from './rules';

View File

@@ -0,0 +1,54 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import { describe, it } from 'vitest';
import noExternalImportsInFacade from './no-external-imports-in-facade';
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});
describe('no-external-imports-in-facade', () => {
it('explains the facade package-entry migration path', () => {
ruleTester.run('no-external-imports-in-facade', noExternalImportsInFacade, {
valid: [
{
code: 'import { PublicCommand } from "@univerjs/example";',
filename: '/repo/packages/example/src/facade/f-univer.ts',
},
{
code: 'import { FThing } from "./f-thing";',
filename: '/repo/packages/example/src/facade/f-univer.ts',
},
],
invalid: [
{
code: 'import { PrivateCommand } from "../commands/private.command";',
filename: '/repo/packages/example/src/facade/f-univer.ts',
errors: [
{
message: 'Facade files are published as a separate package entry. Do not import package-internal runtime modules through "../commands/private.command"; it can duplicate module singletons when consumers import both the package root and the facade entry. Move the needed symbol to the package root export if necessary, then import it through the package name. Type-only imports should also use a public package entry.',
},
],
},
],
});
});
});

View File

@@ -0,0 +1,72 @@
import type { Rule } from 'eslint';
import path from 'node:path';
function getImportSourceValue(node: Rule.Node): string | null {
if (!('source' in node)) {
return null;
}
const source = (node as { source?: { value?: unknown } }).source;
if (!source || typeof source.value !== 'string') {
return null;
}
return source.value;
}
function getRuleFilename(context: Rule.RuleContext): string {
const filenameFromProperty = (context as { filename?: unknown }).filename;
if (typeof filenameFromProperty === 'string' && filenameFromProperty) {
return filenameFromProperty;
}
const getFilename = (context as { getFilename?: () => string }).getFilename;
return typeof getFilename === 'function' ? getFilename.call(context) : '';
}
const rule: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'Disallow imports from outside facade directory in facade files',
},
messages: {
noExternalImports: 'Facade files are published as a separate package entry. Do not import package-internal runtime modules through "{{importPath}}"; it can duplicate module singletons when consumers import both the package root and the facade entry. Move the needed symbol to the package root export if necessary, then import it through the package name. Type-only imports should also use a public package entry.',
},
},
create(context) {
const filename = getRuleFilename(context);
const normalizedPath = filename.split(path.sep).join('/');
const isFacadeFile = normalizedPath.includes('/facade/');
if (!isFacadeFile) {
return {};
}
const currentDir = path.dirname(filename);
return {
ImportDeclaration(node: Rule.Node) {
const importPath = getImportSourceValue(node);
if (!importPath || !importPath.startsWith('.')) {
return;
}
const resolvedPath = path.resolve(currentDir, importPath).split(path.sep).join('/');
if (!resolvedPath.includes('/facade/')) {
context.report({
node,
messageId: 'noExternalImports',
data: { importPath },
});
}
},
};
},
};
export default rule;

View File

@@ -0,0 +1,137 @@
import type { Rule } from 'eslint';
import path from 'node:path';
interface IRuleOptions { ignore?: unknown[] }
function getImportSourceValue(node: Rule.Node): string | null {
if (!('source' in node)) {
return null;
}
const source = (node as { source?: { value?: unknown } }).source;
if (!source || typeof source.value !== 'string') {
return null;
}
return source.value;
}
function normalizePath(filePath: string): string {
return filePath.split(path.sep).join('/');
}
function shouldIgnoreFile(normalizedFilePath: string, ignorePaths: readonly unknown[]): boolean {
return ignorePaths.some((ignorePath: unknown) => {
if (typeof ignorePath !== 'string') {
return false;
}
const normalizedIgnorePath = normalizePath(ignorePath).replace(/^\.\//, '');
if (!normalizedIgnorePath) {
return false;
}
return normalizedFilePath === normalizedIgnorePath
|| normalizedFilePath.endsWith(`/${normalizedIgnorePath}`)
|| normalizedFilePath.includes(normalizedIgnorePath);
});
}
function getRuleFilename(context: Rule.RuleContext): string {
const filenameFromProperty = (context as { filename?: unknown }).filename;
if (typeof filenameFromProperty === 'string' && filenameFromProperty) {
return filenameFromProperty;
}
const getFilename = (context as { getFilename?: () => string }).getFilename;
return typeof getFilename === 'function' ? getFilename.call(context) : '';
}
const rule: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'Disallow imports containing facade in non-facade files',
},
schema: [
{
type: 'object',
properties: {
ignore: {
type: 'array',
items: {
type: 'string',
},
},
},
additionalProperties: false,
},
],
messages: {
noFacadeImports: 'Imports containing "facade" are not allowed in non-facade files: "{{importPath}}"',
},
},
create(context) {
const filename = getRuleFilename(context);
const normalizedPath = normalizePath(filename);
const [ruleOptions = {} as IRuleOptions] = context.options as [IRuleOptions?];
const ignorePaths = Array.isArray(ruleOptions.ignore) ? ruleOptions.ignore : [];
if (shouldIgnoreFile(normalizedPath, ignorePaths)) {
return {};
}
const isInPackages = normalizedPath.includes('/packages/');
const isInFacade = normalizedPath.includes('/facade/');
if (!isInPackages || isInFacade) {
return {};
}
// get parent dir
const parentDirMatch = normalizedPath.match(/\/([^/]+)\/packages\//);
if (!parentDirMatch) {
return {};
}
return {
ImportDeclaration(node: Rule.Node) {
const importPath = getImportSourceValue(node);
// Check if the import path contains 'facade'
if (importPath?.includes('facade')) {
context.report({
node,
messageId: 'noFacadeImports',
data: { importPath },
});
}
},
ExportNamedDeclaration(node: Rule.Node) {
const exportPath = getImportSourceValue(node);
if (exportPath?.includes('facade')) {
context.report({
node,
messageId: 'noFacadeImports',
data: { importPath: exportPath },
});
}
},
ExportAllDeclaration(node: Rule.Node) {
const exportPath = getImportSourceValue(node);
if (exportPath?.includes('facade')) {
context.report({
node,
messageId: 'noFacadeImports',
data: { importPath: exportPath },
});
}
},
};
},
};
export default rule;

View File

@@ -0,0 +1,122 @@
import type { Rule, SourceCode } from 'eslint';
import path from 'node:path';
type ImportOrExportSpecifier = Rule.Node & {
importKind?: 'type' | 'value';
exportKind?: 'type' | 'value';
};
function normalizePath(filePath: string): string {
return filePath.split(path.sep).join('/');
}
function getRuleFilename(context: Rule.RuleContext): string {
const filenameFromProperty = (context as { filename?: unknown }).filename;
if (typeof filenameFromProperty === 'string' && filenameFromProperty) {
return filenameFromProperty;
}
const getFilename = (context as { getFilename?: () => string }).getFilename;
return typeof getFilename === 'function' ? getFilename.call(context) : '';
}
function isSrcIndexFile(context: Rule.RuleContext): boolean {
return normalizePath(getRuleFilename(context)).endsWith('/src/index.ts');
}
function isInlineTypeSpecifier(specifier: ImportOrExportSpecifier, kind: 'import' | 'export'): boolean {
return kind === 'import' ? specifier.importKind === 'type' : specifier.exportKind === 'type';
}
function trimInlineTypeKeyword(specifierText: string): string {
return specifierText.replace(/^type\s+/, '');
}
function buildReplacement(
sourceCode: SourceCode,
node: Rule.Node,
kind: 'import' | 'export'
): string | null {
const specifiers = (node as { specifiers?: ImportOrExportSpecifier[] }).specifiers;
if (!specifiers?.length) {
return null;
}
const typeSpecifiers = specifiers.filter((specifier) => isInlineTypeSpecifier(specifier, kind));
const valueSpecifiers = specifiers.filter((specifier) => !isInlineTypeSpecifier(specifier, kind));
if (typeSpecifiers.length === 0 || valueSpecifiers.length === 0) {
return null;
}
const nodeText = sourceCode.getText(node);
const openingBraceIndex = nodeText.indexOf('{');
const closingBraceIndex = nodeText.lastIndexOf('}');
if (openingBraceIndex === -1 || closingBraceIndex === -1 || closingBraceIndex < openingBraceIndex) {
return null;
}
const suffixWithSemicolon = nodeText.slice(closingBraceIndex + 1);
const hasSemicolon = suffixWithSemicolon.trimEnd().endsWith(';');
const suffix = hasSemicolon ? suffixWithSemicolon.replace(/;\s*$/, '') : suffixWithSemicolon;
const semicolon = hasSemicolon ? ';' : '';
const valueSpecifierText = valueSpecifiers.map((specifier) => sourceCode.getText(specifier)).join(', ');
const typeSpecifierText = typeSpecifiers
.map((specifier) => trimInlineTypeKeyword(sourceCode.getText(specifier)))
.join(', ');
return `${kind} { ${valueSpecifierText} }${suffix}${semicolon}\n${kind} type { ${typeSpecifierText} }${suffix}${semicolon}`;
}
const rule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'Disallow mixing inline type specifiers with value specifiers in src/index.ts',
},
fixable: 'code',
messages: {
noMixedExportSpecifiers: 'Do not mix type and value export specifiers in src/index.ts.',
noMixedImportSpecifiers: 'Do not mix type and value import specifiers in src/index.ts.',
},
},
create(context) {
if (!isSrcIndexFile(context)) {
return {};
}
return {
ImportDeclaration(node: Rule.Node) {
const replacement = buildReplacement(context.sourceCode, node, 'import');
if (!replacement) {
return;
}
context.report({
node,
messageId: 'noMixedImportSpecifiers',
fix(fixer) {
return fixer.replaceText(node, replacement);
},
});
},
ExportNamedDeclaration(node: Rule.Node) {
const replacement = buildReplacement(context.sourceCode, node, 'export');
if (!replacement) {
return;
}
context.report({
node,
messageId: 'noMixedExportSpecifiers',
fix(fixer) {
return fixer.replaceText(node, replacement);
},
});
},
};
},
};
export default rule;

View File

@@ -0,0 +1,95 @@
import type { Rule } from 'eslint';
import path from 'node:path';
function getImportSourceValue(node: Rule.Node): string | null {
if (!('source' in node)) {
return null;
}
const source = (node as { source?: { value?: unknown } }).source;
if (!source || typeof source.value !== 'string') {
return null;
}
return source.value;
}
function getRuleFilename(context: Rule.RuleContext): string {
const filenameFromProperty = (context as { filename?: unknown }).filename;
if (typeof filenameFromProperty === 'string' && filenameFromProperty) {
return filenameFromProperty;
}
const getFilename = (context as { getFilename?: () => string }).getFilename;
return typeof getFilename === 'function' ? getFilename.call(context) : '';
}
const rule: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'Disallow self package imports in packages directory except facade',
},
messages: {
noSelfImport: 'Package cannot import itself: "{{importPath}}" in {{packageName}}',
},
},
create(context) {
const filename = getRuleFilename(context);
const normalizedPath = filename.split(path.sep).join('/');
const isInPackages = normalizedPath.includes('/packages/');
const isInFacade = normalizedPath.includes('/facade/');
if (!isInPackages || isInFacade) {
return {};
}
// get parent dir
const parentDirMatch = normalizedPath.match(/\/([^/]+)\/packages\//);
if (!parentDirMatch) {
return {};
}
const parentDir = parentDirMatch[1];
const packagePrefix = parentDir === 'univer'
? '@univerjs/' :
parentDir === 'univer-pro'
? '@univerjs-pro/' :
null;
if (!packagePrefix) {
return {};
}
// get package name
const packageMatch = normalizedPath.match(/\/packages\/([^/]+)/);
if (!packageMatch) {
return {};
}
const packageName = packageMatch[1];
const possiblePackageName = `${packagePrefix}${packageName}`;
return {
ImportDeclaration(node: Rule.Node) {
const importPath = getImportSourceValue(node);
if (importPath && importPath === possiblePackageName) {
context.report({
node,
messageId: 'noSelfImport',
data: {
importPath,
packageName: importPath,
},
});
}
},
};
},
};
export default rule;

View File

@@ -0,0 +1,296 @@
import type { ESLint, Linter } from 'eslint';
import os from 'node:os';
import path from 'node:path';
import { fixupPluginRules } from '@eslint/compat';
import typescriptParser from '@typescript-eslint/parser';
import eslintPluginBetterTailwindcss from 'eslint-plugin-better-tailwindcss';
import header from 'eslint-plugin-header';
import barrel from 'eslint-plugin-no-barrel-import';
import penetrating from 'eslint-plugin-no-penetrating-import';
import noExternalImportsInFacade from './plugins/no-external-imports-in-facade';
import noFacadeImportsOutsideFacade from './plugins/no-facade-imports-outside-facade';
import noMixedTypeSpecifiersInIndex from './plugins/no-mixed-type-specifiers-in-index';
import noSelfPackageImports from './plugins/no-self-package-imports';
const univerPlugin = {
rules: {
'no-external-imports-in-facade': noExternalImportsInFacade,
'no-self-package-imports': noSelfPackageImports,
'no-facade-imports-outside-facade': noFacadeImportsOutsideFacade,
'no-mixed-type-specifiers-in-index': noMixedTypeSpecifiersInIndex,
},
} satisfies ESLint.Plugin;
/**
* TypeScript preset configuration for ESLint.
* Applies TypeScript-specific rules and custom Univer rules.
*
* @returns ESLint configuration object for TypeScript files
*/
export const typescriptPreset = (): Linter.Config => {
return {
files: ['**/*.{ts,tsx,mts,cts}'],
rules: {
'ts/naming-convention': [
'warn',
// Interfaces' names should start with a capital 'I'.
{
selector: 'interface',
format: ['PascalCase'],
custom: {
regex: '^I[A-Z0-9]',
match: true,
},
},
// Private fields of a class should start with an underscore '_'.
{
selector: ['classMethod', 'classProperty'],
modifiers: ['private'],
format: ['camelCase'],
leadingUnderscore: 'require',
},
],
// 'ts/consistent-type-exports': 'warn',
},
languageOptions: {
parser: typescriptParser,
},
};
};
/**
* Index entry preset configuration for ESLint.
* Keeps type-only imports and exports separate from value imports and exports.
*
* @returns ESLint configuration object for package entry files
*/
export const indexEntryPreset = (): Linter.Config => {
return {
files: ['**/src/index.ts'],
rules: {
'univer/no-mixed-type-specifiers-in-index': 'error',
},
plugins: {
univer: univerPlugin,
},
languageOptions: {
parser: typescriptParser,
},
};
};
/**
* Univer source code preset configuration.
* Enforces package structure rules for source files.
*
* @returns ESLint configuration object for Univer source files
*/
export const univerSourcePreset = (options: {
noFacadeImportsOutsideFacade?: {
ignore?: string[];
};
} = {}): Linter.Config => {
return {
files: ['**/*.ts', '**/*.tsx'],
ignores: [
'**/__tests__/**/*',
'**/*.spec.ts',
'**/*.test.ts',
],
rules: {
'univer/no-self-package-imports': 'error',
'univer/no-facade-imports-outside-facade': ['error', {
ignore: options.noFacadeImportsOutsideFacade?.ignore ?? [],
}],
},
plugins: {
univer: univerPlugin,
},
languageOptions: {
parser: typescriptParser,
},
};
};
/**
* Facade preset configuration for ESLint.
* Enforces strict typing and import rules for facade files.
*
* @returns ESLint configuration object for facade files
*/
export const facadePreset = (): Linter.Config => {
return {
files: ['**/src/facade/**/*.ts'],
ignores: [
'**/__tests__/**/*',
'**/*.spec.ts',
'**/*.test.ts',
],
rules: {
'ts/explicit-function-return-type': 'error',
'univer/no-external-imports-in-facade': 'error',
},
plugins: {
univer: univerPlugin,
},
};
};
/**
* Tailwind CSS preset configuration for ESLint.
* Enforces Tailwind CSS class usage and formatting rules.
*
* @returns ESLint configuration object for files using Tailwind CSS
*/
export const tailwindcssPreset = (): Linter.Config => {
const isWindows = os.platform() === 'win32';
const lineBreakStyle = isWindows ? 'windows' : 'unix';
return {
files: ['**/*.tsx'],
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'better-tailwindcss': eslintPluginBetterTailwindcss,
},
settings: {
'better-tailwindcss': {
tailwindConfig: path.resolve(__dirname, '../tailwind/tailwind.config.ts'),
},
},
rules: {
// enable all recommended rules to error
...eslintPluginBetterTailwindcss.configs['recommended-error'].rules,
'better-tailwindcss/enforce-consistent-line-wrapping': ['error', {
printWidth: 120,
group: 'newLine',
lineBreakStyle,
}],
'better-tailwindcss/no-unknown-classes': 'off',
'better-tailwindcss/no-conflicting-classes': 'off',
},
};
};
/**
* Test/spec files preset configuration for ESLint.
* Relaxed rules for test files to allow more flexible testing patterns.
*
* @returns ESLint configuration object for test files
*/
export const specPreset = (): Linter.Config => {
return {
files: [
'**/*.spec.ts',
'**/__tests__/**/*.ts',
],
rules: {
'ts/explicit-function-return-type': 'off',
},
};
};
/**
* Penetrating import preset configuration for ESLint.
* Prevents direct imports from internal package directories.
*
* @returns ESLint configuration object for preventing penetrating imports
*/
export const penetratingPreset = (): Linter.Config => {
return {
// Not penetrating for source files
files: ['**/*.ts', '**/*.tsx'],
plugins: {
penetrating: fixupPluginRules(penetrating),
},
ignores: [
'**/__tests__/**/*',
'**/__testing__/**/*',
'examples/**/*',
],
rules: {
'penetrating/no-penetrating-import': 2,
},
};
};
/**
* No barrel import preset configuration for ESLint.
* Prevents barrel imports and enforces code complexity limits.
*
* @returns ESLint configuration object for preventing barrel imports
*/
export const noBarrelImportPreset = (): Linter.Config => {
return {
files: ['**/*.ts', '**/*.tsx'],
ignores: [
'packages/engine-render/src/components/docs/**/*.ts',
'**/*.tsx',
'**/*.d.ts',
'**/tsdown.config.ts',
'**/*.spec.ts',
'**/*.spec.tsx',
'**/*.test.ts',
'**/*.test.tsx',
], // do not check test files
plugins: {
barrel: fixupPluginRules(barrel),
},
rules: {
'barrel/no-barrel-import': 2,
complexity: ['warn', { max: 20 }],
'max-lines-per-function': ['warn', 80],
},
};
};
/**
* Header preset configuration for ESLint.
* Enforces Apache License 2.0 header in package and preset source files.
*
* @returns ESLint configuration object for enforcing file headers
*/
export const headerPreset = (): Linter.Config => {
header.rules.header.meta.schema = false;
return {
files: ['{packages,presets}/**/*.{ts,tsx}'],
ignores: [
'**/*.d.ts',
'**/vitest.config.ts',
'**/vitest.workspace.ts',
],
plugins: {
header: fixupPluginRules(header),
},
rules: {
'header/header': [
2,
'block',
[
'*',
' * Copyright 2023-present DreamNum Co., Ltd.',
' *',
' * Licensed under the Apache License, Version 2.0 (the "License");',
' * you may not use this file except in compliance with the License.',
' * You may obtain a copy of the License at',
' *',
' * http://www.apache.org/licenses/LICENSE-2.0',
' *',
' * Unless required by applicable law or agreed to in writing, software',
' * distributed under the License is distributed on an "AS IS" BASIS,',
' * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.',
' * See the License for the specific language governing permissions and',
' * limitations under the License.',
' ',
],
2,
],
},
};
};

View File

@@ -0,0 +1,201 @@
import type { Rules } from '@antfu/eslint-config';
/**
* Base ESLint rules configuration for Univer project.
* These rules apply to all TypeScript and JavaScript files.
*/
const qualityRules: Partial<Rules> = {
// Code style and formatting rules
curly: ['error', 'multi-line'],
'antfu/if-newline': 'off',
'no-param-reassign': ['warn'],
'eol-last': ['error', 'always'],
'no-empty-function': 'off',
};
const typescriptRules: Partial<Rules> = {
// TypeScript specific rules
'ts/no-explicit-any': 'warn',
'ts/no-redeclare': 'off',
'ts/method-signature-style': 'off',
};
const styleRules: Partial<Rules> = {
// Style and formatting rules
'style/no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }],
'style/brace-style': ['warn', '1tbs', { allowSingleLine: true }],
'style/jsx-first-prop-new-line': ['warn', 'multiline'],
'style/arrow-parens': ['error', 'always'],
'style/spaced-comment': 'off',
'style/indent-binary-ops': 'off',
'style/operator-linebreak': 'off',
'style/indent': ['error', 4, {
ObjectExpression: 'first',
SwitchCase: 1,
ignoreComments: true,
}],
'style/quotes': ['warn', 'single', { avoidEscape: true }],
'style/jsx-closing-tag-location': 'warn',
'style/jsx-curly-newline': ['warn', { multiline: 'forbid', singleline: 'forbid' }],
'style/jsx-wrap-multilines': 'warn',
'style/quote-props': ['warn', 'as-needed'],
'style/jsx-curly-brace-presence': 'warn',
'style/multiline-ternary': 'warn',
// Comma and punctuation rules
'style/comma-dangle': ['error', {
arrays: 'always-multiline',
objects: 'always-multiline',
imports: 'always-multiline',
exports: 'always-multiline',
enums: 'always-multiline',
functions: 'never',
}],
};
const filenameRules: Partial<Rules> = {
// Filename conventions
'unicorn/filename-case': [
'error',
{
cases: {
kebabCase: true,
pascalCase: true,
},
ignore: [
'^README-(\w+)?\\.md$',
'^[a-z]{2}-[A-Z]{2}\.ts$',
'^__tests__$',
'^__testing__$',
'^FUNDING.yml$',
'^bug_report.yml$',
'^bug_report.zh-CN.yml$',
'^feature_request.yml$',
'^feature_request.zh-CN.yml$',
],
},
],
};
const reactRules: Partial<Rules> = {
// JSX and React rules
'style/jsx-self-closing-comp': ['error', {
component: true,
html: true,
}],
'react-refresh/only-export-components': 'off',
'react/no-unstable-context-value': 'warn',
'react/no-unstable-default-props': 'warn',
'react/no-direct-mutation-state': 'warn',
'react/no-create-ref': 'warn',
'react/rules-of-hooks': 'off',
'react/static-components': 'off',
'react/dom-no-render': 'off',
'react/unsupported-syntax': 'off',
'react/jsx-no-key-after-spread': 'off',
'react/use-memo': 'warn',
'react-hooks/static-components': 'off',
'react-hooks/preserve-manual-memoization': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/refs': 'off',
'react-hooks/immutability': 'warn',
'react-hooks/use-memo': 'warn',
'react-hooks/purity': 'warn',
'react-hooks/globals': 'warn',
};
const importRules: Partial<Rules> = {
// Import and export rules
'sort-imports': [
'error',
{
allowSeparatedGroups: false,
ignoreCase: true,
ignoreDeclarationSort: true,
ignoreMemberSort: false,
memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'],
},
],
'perfectionist/sort-imports': 'warn',
'perfectionist/sort-named-imports': 'off',
'perfectionist/sort-named-exports': 'warn',
// Code quality rules
'accessor-pairs': 'warn',
'react-hooks/exhaustive-deps': 'off',
'react-hooks/rules-of-hooks': 'off',
'jsdoc/tag-lines': 'off',
// Restricted imports for consistency
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'clsx',
message: 'Please use `import { clsx } from \'@univerjs/design\'` instead.',
},
],
},
],
};
const disabledDebatableRules: Partial<Rules> = {
// IMPORTANT: To ensure compatibility, some features of React 19 will be disabled.
'react/no-forward-ref': 'off',
'react/no-context-provider': 'off',
'react-dom/no-render': 'off',
'react/no-use-context': 'off',
// Debatable rules - currently disabled
'test/prefer-lowercase-title': 'off',
'antfu/top-level-function': 'off',
'unicorn/no-new-array': 'off',
'unicorn/prefer-includes': 'off',
'prefer-arrow-callback': 'off',
'no-restricted-globals': 'off',
'unicorn/prefer-string-starts-ends-with': 'warn',
};
const migrationWarningRules: Partial<Rules> = {
// Compatibility rules for legacy code - set to warn for gradual migration
'unused-imports/no-unused-vars': 'warn',
'ts/no-restricted-types': 'warn',
'ts/no-wrapper-object-types': 'warn',
'ts/no-empty-object-type': 'warn',
'ts/no-unsafe-function-type': 'warn',
'ts/no-unused-expressions': 'warn',
'unicorn/prefer-dom-node-text-content': 'warn',
'unicorn/prefer-number-properties': 'warn',
'no-prototype-builtins': 'warn',
'eslint-comments/no-unlimited-disable': 'off',
'ts/prefer-ts-expect-error': 'off',
'ts/ban-ts-comment': 'off',
'ts/no-duplicate-enum-values': 'off',
'no-cond-assign': 'warn',
'ts/no-use-before-define': 'warn',
'test/no-identical-title': 'warn',
'ts/no-non-null-asserted-optional-chain': 'warn',
'no-restricted-syntax': 'warn',
'prefer-regex-literals': 'warn',
'ts/no-this-alias': 'warn',
'prefer-promise-reject-errors': 'warn',
'no-new': 'warn',
'unicorn/error-message': 'warn',
'ts/prefer-literal-enum-member': 'warn',
'no-control-regex': 'warn',
'ts/no-import-type-side-effects': 'warn',
'unicorn/number-literal-case': 'warn',
'unicorn/prefer-type-error': 'warn',
};
export const baseRules: Partial<Rules> = {
...qualityRules,
...typescriptRules,
...styleRules,
...filenameRules,
...reactRules,
...importRules,
...disabledDebatableRules,
...migrationWarningRules,
};

3
common/shared/eslint/third-party.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare module 'eslint-plugin-header';
declare module 'eslint-plugin-no-barrel-import';
declare module 'eslint-plugin-no-penetrating-import';

View File

@@ -0,0 +1,61 @@
import { existsSync, readdirSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const LOCALE_FILE_NAME_PATTERN = /^[a-z]{2}-[A-Z]{2}\.ts$/;
export interface IDiscoverUniverUiLocalesOptions {
rootDir?: string;
uiPackageDir?: string;
}
function getCandidateUiPackageDirs(rootDir: string): string[] {
const dirs: string[] = [];
let current = path.resolve(rootDir);
while (true) {
dirs.push(
path.join(current, 'node_modules/@univerjs/ui'),
path.join(current, 'packages/ui'),
path.join(current, 'submodules/univer/packages/ui')
);
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return dirs;
}
function resolveUniverUiPackageDir(options: IDiscoverUniverUiLocalesOptions): string {
if (options.uiPackageDir) {
return path.resolve(options.uiPackageDir);
}
const rootDir = options.rootDir ?? process.cwd();
const uiPackageDir = getCandidateUiPackageDirs(rootDir).find((candidate) => existsSync(path.join(candidate, 'src/locale')));
if (!uiPackageDir) {
throw new Error(`Cannot find @univerjs/ui src/locale from ${path.resolve(rootDir)}`);
}
return uiPackageDir;
}
export function discoverUniverUiLocales(options: IDiscoverUniverUiLocalesOptions = {}): string[] {
const uiPackageDir = resolveUniverUiPackageDir(options);
const localeDir = path.join(uiPackageDir, 'src/locale');
if (!existsSync(localeDir)) {
throw new Error(`Cannot find @univerjs/ui locale directory: ${localeDir}`);
}
return readdirSync(localeDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && LOCALE_FILE_NAME_PATTERN.test(entry.name))
.map((entry) => path.basename(entry.name, '.ts'))
.sort((left, right) => left.localeCompare(right));
}

View File

@@ -0,0 +1,59 @@
{
"name": "@univerjs-infra/shared",
"type": "module",
"version": "1.0.0-rc.0",
"private": true,
"description": "Some infrastructures for univerjs",
"author": "DreamNum <developer@univer.ai>",
"license": "Apache-2.0",
"homepage": "https://github.com/dream-num/univer",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer.git"
},
"keywords": [],
"exports": {
"./eslint": "./eslint/index.ts",
"./tsconfigs/*": "./tsconfigs/*.json",
"./vitest": "./vitest/index.ts",
"./esbuild": "./esbuild/index.ts",
"./tailwind": "./tailwind/tailwind.config.ts",
"./postcss": "./postcss/postcss.config.mjs",
"./tsdown": "./tsdown/index.ts",
"./locale": "./locale/index.ts",
"./preset-build": "./preset-build/index.ts"
},
"main": "bin/index.mts",
"bin": {
"univer-cli": "bin/index.mts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tsdown/css": "^0.23.0",
"@typescript-eslint/parser": "^8.65.0",
"@vitest/coverage-istanbul": "^5.0.0",
"autoprefixer": "^10.6.0",
"eslint-plugin-better-tailwindcss": "^4.7.0",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-no-barrel-import": "^0.0.2",
"eslint-plugin-no-penetrating-import": "^0.0.1",
"happy-dom": "20.14.5",
"javascript-obfuscator": "^5.7.0",
"postcss-preset-env": "^11.5.3",
"postcss-replace": "^2.0.1",
"sort-keys": "^6.0.1",
"tailwind-scrollbar": "^3",
"tailwindcss": "3.4.18",
"tsdown": "^0.23.0",
"unplugin-vue": "^7.2.0",
"vitest": "^5.0.0"
},
"devDependencies": {
"@univerjs/icons": "1.38.0",
"@univerjs/icons-svg": "^1.38.0",
"typescript": "^6.0.3",
"vue-tsc": "^3.3.11"
}
}

View File

@@ -0,0 +1,26 @@
import autoprefixer from 'autoprefixer';
import env from 'postcss-preset-env';
import replace from 'postcss-replace';
import tailwindcss from 'tailwindcss';
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: [
tailwindcss,
autoprefixer,
env({
features: {
'color-functional-notation': true,
'hexadecimal-alpha-notation': true,
},
}),
replace({
pattern: /(--tw|\*)/g,
data: {
'--tw': '--univer-tw',
},
}),
],
};
export default config;

View File

@@ -0,0 +1 @@
export const PRESET_LOCALE_SOURCE_DIR = 'src/locales';

View File

@@ -0,0 +1,49 @@
import type { IEntryConfig } from '../tsdown/types.ts';
import { existsSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { PRESET_LOCALE_SOURCE_DIR } from './constants.ts';
function getPresetLocaleEntries(packageDir: string): IEntryConfig[] {
const localeDir = path.join(packageDir, PRESET_LOCALE_SOURCE_DIR);
if (!existsSync(localeDir)) {
return [];
}
const entries: IEntryConfig[] = [];
for (const fileName of readdirSync(localeDir).sort((left, right) => left.localeCompare(right))) {
const fullPath = path.join(localeDir, fileName);
if (statSync(fullPath).isDirectory() || !fileName.endsWith('.ts') || !fileName.includes('-')) {
continue;
}
entries.push({
key: `locales/${fileName.replace(/\.ts$/, '')}`,
path: fullPath,
type: 'locale',
});
}
return entries;
}
export function getPresetModuleEntries(packageDir: string): IEntryConfig[] {
const entries: IEntryConfig[] = [{
key: 'index',
path: path.join(packageDir, 'src/index.ts'),
type: 'index',
}];
const workerEntry = path.join(packageDir, 'src/worker.ts');
if (existsSync(workerEntry)) {
entries.push({
key: 'worker',
path: workerEntry,
type: 'worker',
});
}
return [...entries, ...getPresetLocaleEntries(packageDir)];
}

View File

@@ -0,0 +1,130 @@
import type { TModuleFormat } from '../tsdown/configs/module.ts';
import type { IPackageJson } from '../tsdown/types.ts';
import type { IPresetBuildOptions, IPresetPackageJson } from './types.ts';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
import { mergeConfig, build as tsdownBuild } from 'tsdown';
import { createModuleConfig } from '../tsdown/configs/module.ts';
import { BUILD_OUTPUT_DIRECTORIES, CLEANUP_DIRECTORIES } from '../tsdown/constants.ts';
import { createBaseConfig, createInputPlugins } from '../tsdown/utils/base-config.ts';
import { getPresetModuleEntries } from './entries.ts';
import { generatePresetLocales } from './locale.ts';
import { buildPresetStyles } from './style.ts';
export { generatePresetLocales } from './locale.ts';
export type { IPresetBuildOptions } from './types.ts';
function readPackageJson(packageDir: string): IPresetPackageJson {
return JSON.parse(readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as IPresetPackageJson;
}
function getExternalPackages(packageJson: IPresetPackageJson) {
return [...new Set([
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.peerDependencies ?? {}),
'react',
'react-dom',
'rxjs',
])]
.sort((left, right) => left.localeCompare(right))
.flatMap((packageName) => [packageName, `${packageName}/*`]);
}
async function loadUserConfig(options: IPresetBuildOptions, packageDir: string) {
if (!options.tsdownConfigPath) {
return null;
}
const configPath = path.resolve(packageDir, options.tsdownConfigPath);
let userConfig = (await import(pathToFileURL(configPath).href)).default;
if (typeof userConfig === 'function') {
userConfig = await userConfig();
}
return userConfig;
}
function mergeUserConfig(configs: any[], userConfig: any) {
if (!userConfig) {
return configs;
}
return configs.map((config) => mergeConfig(config, userConfig));
}
export function createPresetModuleEntryGroups(entries: ReturnType<typeof getPresetModuleEntries>) {
const primaryEntries = entries.filter((entry) => entry.type === 'index' || entry.type === 'locale');
const isolatedEntries = entries.filter((entry) => entry.type !== 'index' && entry.type !== 'locale');
return [
...(primaryEntries.length > 0 ? [primaryEntries] : []),
...isolatedEntries.map((entry) => [entry]),
];
}
export function removePresetOutputs(packageDir = process.cwd()) {
for (const dir of CLEANUP_DIRECTORIES) {
const targetDir = path.resolve(packageDir, dir);
if (existsSync(targetDir)) {
rmSync(targetDir, { force: true, recursive: true });
}
}
}
export function preparePresetPackage(packageDir = process.cwd()) {
const packageJson = readPackageJson(packageDir);
if (packageJson.name === '@univerjs/presets') {
return [];
}
return generatePresetLocales({ packageDir });
}
export async function buildPresetPackage(options: IPresetBuildOptions = {}) {
const packageDir = process.cwd();
const packageJson = readPackageJson(packageDir);
if (options.cleanup) {
removePresetOutputs(packageDir);
}
const externalPackages = getExternalPackages(packageJson);
const baseConfig = createBaseConfig({
entries: [],
externalPackages,
facadeExternalPackages: externalPackages,
packageDir,
packageJson: packageJson as IPackageJson,
plugins: [],
});
const plugins = createInputPlugins(packageDir);
const userConfig = await loadUserConfig(options, packageDir);
const moduleFormats: TModuleFormat[] = ['esm', 'cjs'];
const moduleConfigs = createPresetModuleEntryGroups(getPresetModuleEntries(packageDir)).flatMap((entries) => {
return moduleFormats.map((format) => createModuleConfig({
baseConfig,
enableObfuscation: false,
entries,
externalPackages,
facadeExternalPackages: externalPackages,
format,
outDir: BUILD_OUTPUT_DIRECTORIES[format],
packageDir,
plugins,
}));
});
for (const config of mergeUserConfig(moduleConfigs, userConfig)) {
await tsdownBuild(config);
}
await buildPresetStyles({
packageDir,
packageJson,
});
}

View File

@@ -0,0 +1,98 @@
import type { IGeneratePresetLocalesOptions, IPresetPackageJson } from './types.ts';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { discoverUniverUiLocales } from '../locale/index.ts';
import { PRESET_LOCALE_SOURCE_DIR } from './constants.ts';
function readPackageJson(packageDir: string): IPresetPackageJson {
return JSON.parse(readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as IPresetPackageJson;
}
function convertImportNameFromPackageName(name: string) {
return name
.replace(/^@univerjs(?:-[^/]+)?\//, 'univerjs')
.replace(/[^a-zA-Z0-9_$]/g, '');
}
function compareStrings(left: string, right: string) {
if (left < right) {
return -1;
}
if (left > right) {
return 1;
}
return 0;
}
function getDependencyNames(pkg: IPresetPackageJson) {
return Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith('@univerjs'))
.sort(compareStrings);
}
function getDependencyLocaleSubpath(packageDir: string, dependencyName: string, locale: string) {
const dependencyDir = path.join(packageDir, 'node_modules', dependencyName);
if (
existsSync(path.join(dependencyDir, 'lib/es/locale', `${locale}.js`))
|| existsSync(path.join(dependencyDir, 'src/locale', `${locale}.ts`))
) {
return 'locale';
}
if (
existsSync(path.join(dependencyDir, 'lib/es/locales', `${locale}.js`))
|| existsSync(path.join(dependencyDir, 'src/locales', `${locale}.ts`))
) {
return 'locales';
}
return null;
}
export function generatePresetLocales(options: IGeneratePresetLocalesOptions): string[] {
const { packageDir } = options;
const pkg = readPackageJson(packageDir);
const dependencyNames = getDependencyNames(pkg);
const localeDir = path.join(packageDir, PRESET_LOCALE_SOURCE_DIR);
rmSync(localeDir, { force: true, recursive: true });
mkdirSync(localeDir, { recursive: true });
const generated: string[] = [];
for (const locale of discoverUniverUiLocales({ rootDir: packageDir })) {
const dependenciesWithLocale = dependencyNames
.map((dependencyName) => ({
dependencyName,
localeSubpath: getDependencyLocaleSubpath(packageDir, dependencyName, locale),
}))
.filter((dependency): dependency is { dependencyName: string; localeSubpath: 'locale' | 'locales' } => dependency.localeSubpath !== null);
if (dependenciesWithLocale.length === 0) {
continue;
}
let content = '';
for (const { dependencyName, localeSubpath } of dependenciesWithLocale) {
content += `import ${convertImportNameFromPackageName(dependencyName)} from '${dependencyName}/${localeSubpath}/${locale}';\n`;
}
content += '\nexport default Object.assign(\n';
content += ' {},\n';
for (const { dependencyName } of dependenciesWithLocale) {
content += ` ${convertImportNameFromPackageName(dependencyName)},\n`;
}
content += ');\n';
writeFileSync(path.join(localeDir, `${locale}.ts`), content);
generated.push(locale);
}
return generated;
}

View File

@@ -0,0 +1,58 @@
import type { IPresetPackageJson } from './types.ts';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
function getDependencyDir(packageDir: string, dependencyName: string) {
return path.join(packageDir, 'node_modules', dependencyName);
}
function getBuiltStyleDependencyCssFiles(packageDir: string, packageJson: IPresetPackageJson) {
return Object.keys(packageJson.dependencies ?? {})
.flatMap((dependencyName) => {
if (!dependencyName.startsWith('@univerjs')) {
return [];
}
const dependencyDir = getDependencyDir(packageDir, dependencyName);
const sourceGlobalCss = path.join(dependencyDir, 'src/global.css');
const builtCss = path.join(dependencyDir, 'lib/index.css');
if (!existsSync(sourceGlobalCss)) {
return [];
}
if (!existsSync(builtCss)) {
throw new Error(`Missing built CSS for ${dependencyName}. Build dependency packages before building preset CSS.`);
}
return [builtCss];
});
}
function writePresetCss(packageDir: string, cssFiles: string[]) {
const outputFile = path.join(packageDir, 'lib/index.css');
mkdirSync(path.dirname(outputFile), { recursive: true });
const content = cssFiles
.map((cssFile) => readFileSync(cssFile, 'utf8').trimEnd())
.filter(Boolean)
.join('\n');
writeFileSync(outputFile, `${content}\n`);
}
export async function buildPresetStyles(options: {
packageDir: string;
packageJson: IPresetPackageJson;
}) {
const { packageDir, packageJson } = options;
const cssFiles = getBuiltStyleDependencyCssFiles(packageDir, packageJson);
if (cssFiles.length === 0) {
rmSync(path.join(packageDir, 'lib/index.css'), { force: true });
return;
}
writePresetCss(packageDir, cssFiles);
}

View File

@@ -0,0 +1,15 @@
export interface IPresetBuildOptions {
cleanup?: boolean;
tsdownConfigPath?: string;
}
export interface IPresetPackageJson {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
name: string;
peerDependencies?: Record<string, string>;
}
export interface IGeneratePresetLocalesOptions {
packageDir: string;
}

View File

@@ -0,0 +1,90 @@
/* eslint-disable */
/**
* Starting from Univer 0.6.0, with the support for React 19[^1], UMD users may need additional adaptation.
* It is recommended to migrate to module script[^2] and a more modern build system.
*
* [^1]: [Module Script] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#applying_the_module_to_your_html
* [^2]: [support for React 19] https://github.com/dream-num/univer/pull/4247
*/
/**
* Fix `Uncaught TypeError: client.createRoot is not a function`
* If using UMD React < 18, you might need the following code.
*/
(function (global) {
'use strict';
if (!global.ReactDOM) {
throw new Error('ReactDOM must be loaded before ReactCreateRoot.');
}
const ReactDOM = global.ReactDOM;
if (!ReactDOM.createRoot) {
ReactDOM.createRoot = function (container) {
return {
render: (element) => {
ReactDOM.render(element, container);
},
};
};
}
})(this);
/**
* Fix `Uncaught TypeError: jsxRuntime.jsx is not a function`
* If using UMD React, you might need the following code.
* Reference: https://unpkg.com/react@18.3.1/cjs/react-jsx-runtime.production.min.js
*/
(function (global) {
'use strict';
if (!global.React) {
throw new Error('React must be loaded before ReactJSXRuntime.');
}
const React = global.React;
if (!React.jsx || !React.jsxs) {
const REACT_ELEMENT_TYPE = Symbol.for('react.element');
const hasOwnProperty = Object.prototype.hasOwnProperty;
const RESERVED_PROPS = {
key: true,
ref: true,
__self: true,
__source: true,
};
const ReactCurrentOwner = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner;
function createReactElement(type, config, maybeKey) {
const props = {};
let key = null;
let ref = null;
if (maybeKey !== undefined) {
key = `${maybeKey}`;
}
if (config.key !== undefined) {
key = `${config.key}`;
}
if (config.ref !== undefined) {
ref = config.ref;
}
for (var propName in config) {
if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
props[propName] = config[propName];
}
}
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (var propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
return {
$$typeof: REACT_ELEMENT_TYPE,
type,
key,
ref,
props,
_owner: ReactCurrentOwner.current,
};
}
React.jsx = createReactElement;
React.jsxs = createReactElement;
}
})(this);

View File

@@ -0,0 +1,78 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import tailwindConfig, { createTailwindContent } from '../tailwind.config';
const roots: string[] = [];
function makePackageRoot() {
const root = mkdtempSync(path.join(os.tmpdir(), 'univer-tailwind-content-'));
roots.push(root);
writeFileSync(path.join(root, 'tailwind.config.ts'), '');
return root;
}
function writeJson(filePath: string, value: unknown) {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(value, null, 4)}\n`);
}
function writeGlobalCss(packageRoot: string, dependencyName: string) {
const cssPath = path.join(packageRoot, 'node_modules', dependencyName, 'src/global.css');
mkdirSync(path.dirname(cssPath), { recursive: true });
writeFileSync(cssPath, '');
}
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { force: true, recursive: true });
}
});
describe('createTailwindContent', () => {
it('limits hover styles to devices that support hover', () => {
expect(tailwindConfig.future).toMatchObject({ hoverOnlyWhenSupported: true });
});
it('returns current package source content by default', () => {
const packageRoot = makePackageRoot();
writeJson(path.join(packageRoot, 'package.json'), {
name: '@univerjs/preset-demo',
dependencies: {
'@univerjs/design': 'workspace:*',
},
});
writeGlobalCss(packageRoot, '@univerjs/design');
expect(createTailwindContent(path.join(packageRoot, 'tailwind.config.ts'))).toEqual([
'./src/**/*.{js,ts,jsx,tsx}',
]);
});
it('includes Univer dependencies with global styles when requested', () => {
const packageRoot = makePackageRoot();
writeJson(path.join(packageRoot, 'package.json'), {
name: '@univerjs/preset-demo',
dependencies: {
'@univerjs/sheets-ui': 'workspace:*',
'@univerjs/design': 'workspace:*',
'@univerjs/core': 'workspace:*',
'@univerjs-pro/sheets-chart-ui': 'workspace:*',
react: '^18.3.1',
},
});
writeGlobalCss(packageRoot, '@univerjs/sheets-ui');
writeGlobalCss(packageRoot, '@univerjs/design');
writeGlobalCss(packageRoot, '@univerjs-pro/sheets-chart-ui');
expect(createTailwindContent(path.join(packageRoot, 'tailwind.config.ts'), {
includeStyleDependencies: true,
})).toEqual([
'./src/**/*.{js,ts,jsx,tsx}',
'./node_modules/@univerjs/design/src/**/*.{js,ts,jsx,tsx}',
'./node_modules/@univerjs/sheets-ui/src/**/*.{js,ts,jsx,tsx}',
'./node_modules/@univerjs-pro/sheets-chart-ui/src/**/*.{js,ts,jsx,tsx}',
]);
});
});

View File

@@ -0,0 +1,237 @@
import type { Config } from 'tailwindcss';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import scrollbar from 'tailwind-scrollbar';
const SOURCE_CONTENT = './src/**/*.{js,ts,jsx,tsx}';
const DEPENDENCY_SOURCE_CONTENT = './node_modules/{name}/src/**/*.{js,ts,jsx,tsx}';
interface ITailwindContentOptions {
includeStyleDependencies?: boolean;
}
interface IPackageJson {
dependencies?: Record<string, string>;
}
function getPackageDir(configUrl: string) {
const configPath = configUrl.startsWith('file:')
? fileURLToPath(configUrl)
: configUrl;
return path.dirname(configPath);
}
function getPackageJson(packageDir: string): IPackageJson {
const packageJsonPath = path.join(packageDir, 'package.json');
if (!existsSync(packageJsonPath)) {
return {};
}
return JSON.parse(readFileSync(packageJsonPath, 'utf8')) as IPackageJson;
}
function getDependencyDir(packageDir: string, dependencyName: string) {
return path.join(packageDir, 'node_modules', dependencyName);
}
function compareDependencyName(left: string, right: string) {
const leftScope = left.startsWith('@univerjs-pro/') ? 1 : 0;
const rightScope = right.startsWith('@univerjs-pro/') ? 1 : 0;
if (leftScope !== rightScope) {
return leftScope - rightScope;
}
return left.localeCompare(right);
}
export function createTailwindContent(configUrl: string, options: ITailwindContentOptions = {}) {
const content = [SOURCE_CONTENT];
if (!options.includeStyleDependencies) {
return content;
}
const packageDir = getPackageDir(configUrl);
const packageJson = getPackageJson(packageDir);
const dependencyNames = Object.keys(packageJson.dependencies ?? {})
.filter((dependencyName) => {
if (!dependencyName.startsWith('@univerjs')) {
return false;
}
return existsSync(path.join(getDependencyDir(packageDir, dependencyName), 'src/global.css'));
})
.sort(compareDependencyName);
return [
...content,
...dependencyNames.map((dependencyName) => DEPENDENCY_SOURCE_CONTENT.replace('{name}', dependencyName)),
];
}
const config: Omit<Config, 'content'> = {
prefix: 'univer-',
darkMode: 'selector',
future: {
hoverOnlyWhenSupported: true,
},
corePlugins: {
preflight: false,
},
theme: {
extend: {
colors: {
primary: {
50: 'var(--univer-primary-50)',
100: 'var(--univer-primary-100)',
200: 'var(--univer-primary-200)',
300: 'var(--univer-primary-300)',
400: 'var(--univer-primary-400)',
500: 'var(--univer-primary-500)',
600: 'var(--univer-primary-600)',
700: 'var(--univer-primary-700)',
800: 'var(--univer-primary-800)',
900: 'var(--univer-primary-900)',
},
gray: {
0: 'var(--univer-gray-0)',
50: 'var(--univer-gray-50)',
100: 'var(--univer-gray-100)',
200: 'var(--univer-gray-200)',
300: 'var(--univer-gray-300)',
400: 'var(--univer-gray-400)',
500: 'var(--univer-gray-500)',
600: 'var(--univer-gray-600)',
700: 'var(--univer-gray-700)',
800: 'var(--univer-gray-800)',
900: 'var(--univer-gray-900)',
1000: 'var(--univer-gray-1000)',
},
blue: {
50: 'var(--univer-blue-50)',
100: 'var(--univer-blue-100)',
200: 'var(--univer-blue-200)',
300: 'var(--univer-blue-300)',
400: 'var(--univer-blue-400)',
500: 'var(--univer-blue-500)',
600: 'var(--univer-blue-600)',
700: 'var(--univer-blue-700)',
800: 'var(--univer-blue-800)',
900: 'var(--univer-blue-900)',
},
red: {
50: 'var(--univer-red-50)',
100: 'var(--univer-red-100)',
200: 'var(--univer-red-200)',
300: 'var(--univer-red-300)',
400: 'var(--univer-red-400)',
500: 'var(--univer-red-500)',
600: 'var(--univer-red-600)',
700: 'var(--univer-red-700)',
800: 'var(--univer-red-800)',
900: 'var(--univer-red-900)',
},
orange: {
50: 'var(--univer-orange-50)',
100: 'var(--univer-orange-100)',
200: 'var(--univer-orange-200)',
300: 'var(--univer-orange-300)',
400: 'var(--univer-orange-400)',
500: 'var(--univer-orange-500)',
600: 'var(--univer-orange-600)',
700: 'var(--univer-orange-700)',
800: 'var(--univer-orange-800)',
900: 'var(--univer-orange-900)',
},
yellow: {
50: 'var(--univer-yellow-50)',
100: 'var(--univer-yellow-100)',
200: 'var(--univer-yellow-200)',
300: 'var(--univer-yellow-300)',
400: 'var(--univer-yellow-400)',
500: 'var(--univer-yellow-500)',
600: 'var(--univer-yellow-600)',
700: 'var(--univer-yellow-700)',
800: 'var(--univer-yellow-800)',
900: 'var(--univer-yellow-900)',
},
green: {
50: 'var(--univer-green-50)',
100: 'var(--univer-green-100)',
200: 'var(--univer-green-200)',
300: 'var(--univer-green-300)',
400: 'var(--univer-green-400)',
500: 'var(--univer-green-500)',
600: 'var(--univer-green-600)',
700: 'var(--univer-green-700)',
800: 'var(--univer-green-800)',
900: 'var(--univer-green-900)',
},
jiqing: {
50: 'var(--univer-jiqing-50)',
100: 'var(--univer-jiqing-100)',
200: 'var(--univer-jiqing-200)',
300: 'var(--univer-jiqing-300)',
400: 'var(--univer-jiqing-400)',
500: 'var(--univer-jiqing-500)',
600: 'var(--univer-jiqing-600)',
700: 'var(--univer-jiqing-700)',
800: 'var(--univer-jiqing-800)',
900: 'var(--univer-jiqing-900)',
},
indigo: {
50: 'var(--univer-indigo-50)',
100: 'var(--univer-indigo-100)',
200: 'var(--univer-indigo-200)',
300: 'var(--univer-indigo-300)',
400: 'var(--univer-indigo-400)',
500: 'var(--univer-indigo-500)',
600: 'var(--univer-indigo-600)',
700: 'var(--univer-indigo-700)',
800: 'var(--univer-indigo-800)',
900: 'var(--univer-indigo-900)',
},
purple: {
50: 'var(--univer-purple-50)',
100: 'var(--univer-purple-100)',
200: 'var(--univer-purple-200)',
300: 'var(--univer-purple-300)',
400: 'var(--univer-purple-400)',
500: 'var(--univer-purple-500)',
600: 'var(--univer-purple-600)',
700: 'var(--univer-purple-700)',
800: 'var(--univer-purple-800)',
900: 'var(--univer-purple-900)',
},
pink: {
50: 'var(--univer-pink-50)',
100: 'var(--univer-pink-100)',
200: 'var(--univer-pink-200)',
300: 'var(--univer-pink-300)',
400: 'var(--univer-pink-400)',
500: 'var(--univer-pink-500)',
600: 'var(--univer-pink-600)',
700: 'var(--univer-pink-700)',
800: 'var(--univer-pink-800)',
900: 'var(--univer-pink-900)',
},
},
boxShadow: {
sm: '0px 1px 2px 0px rgba(30, 40, 77, 0.08)',
md: '0px 1px 6px -2px rgba(30, 40, 77, 0.08), 0px 2px 6px -1px rgba(30, 40, 77, 0.10)',
lg: '0px 4px 6px 0px rgba(30, 40, 77, 0.05), 0px 10px 15px -3px rgba(30, 40, 77, 0.10)',
xl: '0px 10px 10px 0px rgba(30, 40, 77, 0.04), 0px 20px 24px -5px rgba(30, 40, 77, 0.10)',
'2xl': '0px 24px 50px -12px rgba(30, 40, 77, 0.24)',
},
},
},
plugins: [
scrollbar,
],
};
export default config;

View File

@@ -0,0 +1,20 @@
{
"extends": "./tsconfigs/base.json",
"compilerOptions": {
"rootDir": ".",
"skipLibCheck": true
},
"include": [
"bin/**/*.mts",
"esbuild/**/*.ts",
"eslint/**/*.ts",
"eslint/**/*.d.ts",
"locale/**/*.ts",
"tailwind/**/*.ts",
"tsdown/**/*.ts",
"vitest/**/*.ts"
],
"exclude": [
"node_modules"
]
}

Some files were not shown because too many files have changed in this diff Show More