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

2
packages/ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
src/views/emoji-picker/emojis.generated.ts
src/locale/emoji-locale/*.generated.ts

46
packages/ui/README.md Normal file
View File

@@ -0,0 +1,46 @@
# @univerjs/ui
[![npm version](https://img.shields.io/npm/v/@univerjs/ui?style=flat-square)](https://npmjs.com/package/@univerjs/ui)
[![license](https://img.shields.io/npm/l/@univerjs/ui?style=flat-square)](https://npmjs.com/package/@univerjs/ui)
[![downloads](https://img.shields.io/npm/dm/@univerjs/ui?style=flat-square)](https://npmjs.com/package/@univerjs/ui)
`@univerjs/ui` provides Univer's shared application UI framework, workbench services, menu infrastructure, dialogs, clipboard services, and Facade UI APIs.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/ui` | `UniverUi` | Yes | Yes | Yes |
## Installation
```sh
pnpm add @univerjs/ui
# or
npm install @univerjs/ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/ui/lib/index.css';
import EnUS from '@univerjs/ui/locale/en-US';
import { UniverUIPlugin } from '@univerjs/ui';
univer.registerPlugin(UniverUIPlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
Exported plugin classes:
- `UniverUIPlugin`
- `UniverMobileUIPlugin`
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/ui)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,719 @@
{
"type": "excalidraw",
"version": 2,
"source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor",
"elements": [
{
"id": "VtEzghQeh1SW-9I-0gUNP",
"type": "rectangle",
"x": 297.55470125108866,
"y": 469.3333435058594,
"width": 561.6666870117188,
"height": 218.00003051757812,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": {
"type": 3
},
"seed": 1071526398,
"version": 161,
"versionNonce": 2107998654,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "bGE6WD2ACAKp96MY4MIG-"
}
],
"updated": 1706536654220,
"link": null,
"locked": false
},
{
"id": "bGE6WD2ACAKp96MY4MIG-",
"type": "text",
"x": 521.2180999937644,
"y": 474.3333435058594,
"width": 114.33988952636719,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": null,
"seed": 1268058494,
"version": 130,
"versionNonce": 985334050,
"isDeleted": false,
"boundElements": null,
"updated": 1706536654220,
"link": null,
"locked": false,
"text": "@univerjs/ui",
"fontSize": 20,
"fontFamily": 1,
"textAlign": "center",
"verticalAlign": "top",
"baseline": 17,
"containerId": "VtEzghQeh1SW-9I-0gUNP",
"originalText": "@univerjs/ui",
"lineHeight": 1.25
},
{
"id": "SbsCBeknqPfCuZMuQ-9BF",
"type": "rectangle",
"x": 606.5548233214012,
"y": 617.3334350585938,
"width": 230.3333740234375,
"height": 48.6666259765625,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": {
"type": 3
},
"seed": 1138101346,
"version": 298,
"versionNonce": 592091646,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "vWGSTC-jxJrQq6VkJt0uv"
},
{
"id": "g2Miwc2X8XnGeZn0nwuxz",
"type": "arrow"
},
{
"id": "c62GN6iH8nwKGuwpFSsh9",
"type": "arrow"
}
],
"updated": 1706536654220,
"link": null,
"locked": false
},
{
"id": "vWGSTC-jxJrQq6VkJt0uv",
"type": "text",
"x": 659.0315689268699,
"y": 629.166748046875,
"width": 125.3798828125,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": null,
"seed": 1156702754,
"version": 250,
"versionNonce": 2143456482,
"isDeleted": false,
"boundElements": null,
"updated": 1706536654220,
"link": null,
"locked": false,
"text": "IMenuService",
"fontSize": 20,
"fontFamily": 1,
"textAlign": "center",
"verticalAlign": "middle",
"baseline": 17,
"containerId": "SbsCBeknqPfCuZMuQ-9BF",
"originalText": "IMenuService",
"lineHeight": 1.25
},
{
"type": "rectangle",
"version": 345,
"versionNonce": 1025681982,
"isDeleted": false,
"id": "qaNNS3ZWr9QLYImJu6f0V",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 328.38807527452616,
"y": 514,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"width": 230.3333740234375,
"height": 54.66662597656249,
"seed": 1807478334,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": {
"type": 3
},
"boundElements": [
{
"type": "text",
"id": "zGoE_Pec2wTnxBdD4cLqT"
},
{
"id": "g2Miwc2X8XnGeZn0nwuxz",
"type": "arrow"
},
{
"id": "c62GN6iH8nwKGuwpFSsh9",
"type": "arrow"
}
],
"updated": 1706536654220,
"link": null,
"locked": false
},
{
"type": "text",
"version": 328,
"versionNonce": 459448482,
"isDeleted": false,
"id": "zGoE_Pec2wTnxBdD4cLqT",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 386.3148178282371,
"y": 528.8333129882812,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"width": 114.47988891601562,
"height": 25,
"seed": 1289713278,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": null,
"boundElements": [],
"updated": 1706536654220,
"link": null,
"locked": false,
"fontSize": 20,
"fontFamily": 1,
"text": "Toolbar.tsx",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "qaNNS3ZWr9QLYImJu6f0V",
"originalText": "Toolbar.tsx",
"lineHeight": 1.25,
"baseline": 17
},
{
"id": "g2Miwc2X8XnGeZn0nwuxz",
"type": "arrow",
"x": 501.1425331770429,
"y": 573.9999389648438,
"width": 134.8211291601421,
"height": 34.666748046875,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": {
"type": 2
},
"seed": 637273022,
"version": 201,
"versionNonce": 974041726,
"isDeleted": false,
"boundElements": null,
"updated": 1706536654220,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
134.8211291601421,
34.666748046875
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "qaNNS3ZWr9QLYImJu6f0V",
"focus": 0.3117257409988943,
"gap": 5.33331298828125
},
"endBinding": {
"elementId": "SbsCBeknqPfCuZMuQ-9BF",
"focus": 0.20296158897009264,
"gap": 8.666748046875
},
"startArrowhead": null,
"endArrowhead": "arrow"
},
{
"id": "6dTc23JbI67ggrfFdEfO1",
"type": "rectangle",
"x": 230.67964200237145,
"y": 145.37571352683727,
"width": 582.3333129882812,
"height": 206.66668701171875,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": {
"type": 3
},
"seed": 1932714814,
"version": 143,
"versionNonce": 491847074,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "62nXjv0x5uTLXBAbJtg8X"
}
],
"updated": 1706536667506,
"link": null,
"locked": false
},
{
"id": "62nXjv0x5uTLXBAbJtg8X",
"type": "text",
"x": 428.17639950969567,
"y": 150.37571352683727,
"width": 187.3397979736328,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": null,
"seed": 600293118,
"version": 117,
"versionNonce": 124057954,
"isDeleted": false,
"boundElements": null,
"updated": 1706536667506,
"link": null,
"locked": false,
"text": "@univerjs/sheets-ui",
"fontSize": 20,
"fontFamily": 1,
"textAlign": "center",
"verticalAlign": "top",
"baseline": 17,
"containerId": "6dTc23JbI67ggrfFdEfO1",
"originalText": "@univerjs/sheets-ui",
"lineHeight": 1.25
},
{
"id": "cp81RscM_e44FdVDdQN0V",
"type": "rectangle",
"x": 385.55470125108866,
"y": 201.33334350585938,
"width": 278.33343505859375,
"height": 50.000000000000014,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": {
"type": 3
},
"seed": 704894974,
"version": 155,
"versionNonce": 927490082,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "TTKnT5Hajj7IVode179PK"
},
{
"id": "c62GN6iH8nwKGuwpFSsh9",
"type": "arrow"
}
],
"updated": 1706536654220,
"link": null,
"locked": false
},
{
"id": "TTKnT5Hajj7IVode179PK",
"type": "text",
"x": 431.9215072813621,
"y": 213.83334350585938,
"width": 185.59982299804688,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": null,
"seed": 536988030,
"version": 142,
"versionNonce": 134654718,
"isDeleted": false,
"boundElements": null,
"updated": 1706536654220,
"link": null,
"locked": false,
"text": "SheetsUIController",
"fontSize": 20,
"fontFamily": 1,
"textAlign": "center",
"verticalAlign": "middle",
"baseline": 17,
"containerId": "cp81RscM_e44FdVDdQN0V",
"originalText": "SheetsUIController",
"lineHeight": 1.25
},
{
"id": "c62GN6iH8nwKGuwpFSsh9",
"type": "arrow",
"x": 524.2504770548852,
"y": 260,
"width": 197.2149674323823,
"height": 344.6666259765625,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": {
"type": 2
},
"seed": 1441502434,
"version": 229,
"versionNonce": 1632373730,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "uiBjGTtd0V1YcCjsgGAt0"
}
],
"updated": 1706536654220,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
197.2149674323823,
344.6666259765625
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "cp81RscM_e44FdVDdQN0V",
"focus": 0.12858859894652533,
"gap": 8.666656494140625
},
"endBinding": {
"elementId": "SbsCBeknqPfCuZMuQ-9BF",
"focus": 0.16201918030680326,
"gap": 12.66680908203125
},
"startArrowhead": null,
"endArrowhead": "arrow"
},
{
"id": "uiBjGTtd0V1YcCjsgGAt0",
"type": "text",
"x": 439.568090838491,
"y": 371.1666564941406,
"width": 75.63990783691406,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": null,
"seed": 1268350846,
"version": 99,
"versionNonce": 842308414,
"isDeleted": false,
"boundElements": null,
"updated": 1706536654220,
"link": null,
"locked": false,
"text": "register",
"fontSize": 20,
"fontFamily": 1,
"textAlign": "center",
"verticalAlign": "middle",
"baseline": 17,
"containerId": "c62GN6iH8nwKGuwpFSsh9",
"originalText": "register",
"lineHeight": 1.25
},
{
"id": "AW2CrkjNUHMoqcnoQzTlY",
"type": "ellipse",
"x": 588.3520732266394,
"y": 384.4148217913581,
"width": 28.66668701171875,
"height": 28.66668701171875,
"angle": 0,
"strokeColor": "#f08c00",
"backgroundColor": "#ffd8a8",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": {
"type": 2
},
"seed": 1046304830,
"version": 258,
"versionNonce": 2042266494,
"isDeleted": false,
"boundElements": [
{
"id": "YkpUCEgSJTyLDamPqe8FM",
"type": "arrow"
}
],
"updated": 1706536654220,
"link": null,
"locked": false
},
{
"id": "_ymJpeL6P5cesVwfS5R36",
"type": "ellipse",
"x": 879.2213272276512,
"y": 199.3333740234375,
"width": 321.33343505859375,
"height": 321.33343505859375,
"angle": 0,
"strokeColor": "#f08c00",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dotted",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": {
"type": 2
},
"seed": 219251070,
"version": 332,
"versionNonce": 2009070434,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "kEWHaCZxwxldwji5ZIGjS"
},
{
"id": "YkpUCEgSJTyLDamPqe8FM",
"type": "arrow"
}
],
"updated": 1706536654220,
"link": null,
"locked": false
},
{
"id": "kEWHaCZxwxldwji5ZIGjS",
"type": "text",
"x": 931.2795192809984,
"y": 308.9525989406348,
"width": 204.45358276367188,
"height": 101.87793427230042,
"angle": 0,
"strokeColor": "#f08c00",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": null,
"seed": 15749950,
"version": 435,
"versionNonce": 1624859582,
"isDeleted": false,
"boundElements": null,
"updated": 1706536654220,
"link": null,
"locked": false,
"text": "{\n id: \"insert-col\",\n hidden$: Observe$\n}",
"fontSize": 20.375586854460085,
"fontFamily": 1,
"textAlign": "left",
"verticalAlign": "middle",
"baseline": 94,
"containerId": "_ymJpeL6P5cesVwfS5R36",
"originalText": "{\n id: \"insert-col\",\n hidden$: Observe$\n}",
"lineHeight": 1.25
},
{
"id": "YkpUCEgSJTyLDamPqe8FM",
"type": "arrow",
"x": 870.3718048036619,
"y": 370.90577080338596,
"width": 243.37321739124275,
"height": 26.79649772127908,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": "ZhupNcsv9SpdMhI_xBSHi",
"roundness": {
"type": 2
},
"seed": 457678242,
"version": 391,
"versionNonce": 1051606818,
"isDeleted": false,
"boundElements": null,
"updated": 1706536654220,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
-63.81701199983888,
17.094290231770287
],
[
-243.37321739124275,
26.79649772127908
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "_ymJpeL6P5cesVwfS5R36",
"gap": 9.199963864725078,
"focus": 0.2074277707652223
},
"endBinding": {
"elementId": "AW2CrkjNUHMoqcnoQzTlY",
"focus": 0.01865999104105891,
"gap": 10.002312815057488
},
"startArrowhead": null,
"endArrowhead": "dot"
},
{
"id": "ZhupNcsv9SpdMhI_xBSHi",
"type": "frame",
"x": 132.7028938896051,
"y": 96.16411463066936,
"width": 1118.5926659825714,
"height": 634.4384638443128,
"angle": 0,
"strokeColor": "#bbb",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"roundness": null,
"seed": 704468990,
"version": 188,
"versionNonce": 123216638,
"isDeleted": false,
"boundElements": null,
"updated": 1706536653938,
"link": null,
"locked": false,
"name": null
}
],
"appState": {
"gridSize": null,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

110
packages/ui/package.json Normal file
View File

@@ -0,0 +1,110 @@
{
"name": "@univerjs/ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Shared application UI framework, workbench services, menus, dialogs, and Facade UI APIs for Univer.",
"author": "DreamNum Co., Ltd. <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": [
"univer",
"ui",
"workbench",
"menus",
"react"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.ts",
"./facade": "./src/facade/index.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./locale/*": {
"import": "./lib/es/locale/*.js",
"require": "./lib/cjs/locale/*.js",
"types": "./lib/types/locale/*.d.ts"
},
"./facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"prepare": "node --experimental-strip-types ./scripts/generate-emojis.mts",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^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/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/protocol": "workspace:*",
"@wendellhu/redi": "1.1.3"
},
"devDependencies": {
"@testing-library/react": "^16.3.3",
"@univerjs-infra/shared": "workspace:*",
"@univerjs/themes": "workspace:*",
"postcss": "^8.5.28",
"react": "18.3.1",
"react-dom": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"tailwindcss-animate": "^1.0.7",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

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

View File

@@ -0,0 +1,504 @@
import type { PathLike } from 'node:fs';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { discoverUniverUiLocales } from '@univerjs-infra/shared/locale';
interface IEmojiVariant {
emoji: string;
title: string;
}
interface IEmojiItem extends IEmojiVariant {
skinToneVariants?: IEmojiVariant[];
}
interface ICldrAnnotationsData {
annotations?: {
annotations?: Record<string, {
default?: string[];
tts?: string[];
}>;
};
annotationsDerived?: {
annotations?: Record<string, {
default?: string[];
tts?: string[];
}>;
};
}
interface IGeneratedEmojiLocale {
emojiSearchIndex: Record<string, string>;
emojiTitles: Record<string, string>;
}
type EmojiCategory = 'people' | 'nature' | 'foods' | 'activity' | 'places' | 'objects' | 'symbols';
type EmojiGroups = Record<'frequent' | EmojiCategory, IEmojiItem[]>;
type UnicodeEmojiGroup =
| 'Activities'
| 'Animals & Nature'
| 'Flags'
| 'Food & Drink'
| 'Objects'
| 'People & Body'
| 'Smileys & Emotion'
| 'Symbols'
| 'Travel & Places';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '../../..');
const outputPath = path.resolve(__dirname, '../src/views/emoji-picker/emojis.generated.ts');
const emojiLocaleOutputDir = path.resolve(__dirname, '../src/locale/emoji-locale');
const unicodeEmojiTestUrl = 'https://www.unicode.org/Public/17.0.0/emoji/emoji-test.txt';
const cldrJsonVersion = '48.2.0';
const cldrJsonBaseUrl = `https://cdn.jsdelivr.net/gh/unicode-org/cldr-json@${cldrJsonVersion}/cldr-json`;
const cldrJsonDownloadConcurrency = 4;
const downloadMaxAttempts = 3;
const downloadRetryBaseDelayMs = 500;
const downloadTimeoutMs = 30_000;
const envProxyNodeArg = '--use-env-proxy';
const envProxyRelaunchEnv = 'UNIVER_UI_EMOJI_ENV_PROXY_REEXEC';
const emojiCategories: EmojiCategory[] = ['people', 'nature', 'foods', 'activity', 'places', 'objects', 'symbols'];
const emojiSkinToneModifierRegex = /[\u{1F3FB}-\u{1F3FF}]/u;
const emojiSkinToneNames = new Set([
'Light Skin Tone',
'Medium-light Skin Tone',
'Medium Skin Tone',
'Medium-dark Skin Tone',
'Dark Skin Tone',
]);
const cldrLocaleMap: Record<string, string> = {
'ar-SA': 'ar',
'ca-ES': 'ca',
'de-DE': 'de',
'en-US': 'en',
'es-ES': 'es',
'fa-IR': 'fa',
'fr-FR': 'fr',
'id-ID': 'id',
'it-IT': 'it',
'ja-JP': 'ja',
'ko-KR': 'ko',
'pl-PL': 'pl',
'pt-BR': 'pt',
'ru-RU': 'ru',
'sk-SK': 'sk',
'vi-VN': 'vi',
'zh-CN': 'zh',
'zh-HK': 'zh-Hant-HK',
'zh-TW': 'zh-Hant',
};
const localeFiles = discoverUniverUiLocales({ rootDir: root }).map((locale) => {
const cldrLocale = cldrLocaleMap[locale];
if (!cldrLocale) {
throw new Error(`Missing CLDR locale mapping for ${locale}`);
}
return [locale, cldrLocale] as const;
});
const frequentEmojis = [
'👍',
'😀',
'😘',
'😍',
'😆',
'😜',
'😅',
'😂',
'😱',
'😞',
'😒',
];
const groupCategoryMap: Partial<Record<UnicodeEmojiGroup, EmojiCategory>> = {
Activities: 'activity',
'Animals & Nature': 'nature',
'Food & Drink': 'foods',
Objects: 'objects',
'People & Body': 'people',
'Smileys & Emotion': 'people',
Symbols: 'symbols',
'Travel & Places': 'places',
};
export function buildEmojisFromEmojiTest(emojiTest: string, options: { supportedEmojis?: Set<string> } = {}): EmojiGroups {
const groups = createEmptyEmojiGroups();
let currentGroup: UnicodeEmojiGroup | null = null;
const titleByEmoji = new Map<string, string>();
emojiTest.split(/\r?\n/).forEach((line) => {
const group = line.match(/^# group: (.+)$/);
if (group) {
currentGroup = group[1] as UnicodeEmojiGroup;
return;
}
const category = currentGroup ? groupCategoryMap[currentGroup] : undefined;
if (!category) {
return;
}
const item = parseEmojiTestLine(line);
if (!item || (options.supportedEmojis && !options.supportedEmojis.has(item.emoji))) {
return;
}
titleByEmoji.set(item.emoji, item.title);
groups[category].push(item);
});
groups.frequent = frequentEmojis
.map((emoji) => {
const title = titleByEmoji.get(emoji);
return title ? { emoji, title } : null;
})
.filter(isEmojiItem);
emojiCategories.forEach((category) => {
groups[category] = groupSkinToneVariants(groups[category]);
});
return groups;
}
export function groupSkinToneVariants(items: IEmojiItem[]): IEmojiItem[] {
const baseItemsByTitle = new Map(
items
.filter((item) => !hasSkinToneModifier(item.emoji))
.map((item) => [item.title, item])
);
const variantsByBaseEmoji = new Map<string, IEmojiVariant[]>();
const groupedVariants = new Set<string>();
items.forEach((item) => {
if (!hasSkinToneModifier(item.emoji)) {
return;
}
const familyTitle = getSkinToneFamilyTitle(item.title, baseItemsByTitle);
const baseItem = baseItemsByTitle.get(familyTitle);
if (!baseItem) {
return;
}
const variants = variantsByBaseEmoji.get(baseItem.emoji) ?? [];
variants.push({ emoji: item.emoji, title: item.title });
variantsByBaseEmoji.set(baseItem.emoji, variants);
groupedVariants.add(item.emoji);
});
return items
.filter((item) => !groupedVariants.has(item.emoji))
.map((item) => {
const skinToneVariants = variantsByBaseEmoji.get(item.emoji);
return skinToneVariants?.length ? { ...item, skinToneVariants } : item;
});
}
export function buildEmojiSearchIndexFromCldrAnnotations(emojis: Array<string | IEmojiItem>, ...sources: ICldrAnnotationsData[]): IGeneratedEmojiLocale {
const emojiTitles: Record<string, string> = {};
const emojiSearchIndex: Record<string, string> = {};
emojis.forEach((entry) => {
const emoji = typeof entry === 'string' ? entry : entry.emoji;
const fallbackTitle = typeof entry === 'string' ? undefined : entry.title;
const annotations = sources
.map((source) => getCldrAnnotation(source, emoji))
.filter((annotation): annotation is NonNullable<ReturnType<typeof getCldrAnnotation>> => Boolean(annotation));
const title = annotations.find((annotation) => annotation.tts?.[0])?.tts?.[0] ?? fallbackTitle;
const keywords = [...new Set(annotations.flatMap((annotation) => [
...(annotation.tts ?? []),
...(annotation.default ?? []),
]).filter(Boolean))];
if (!keywords.length && fallbackTitle) {
keywords.push(fallbackTitle);
}
if (title) {
emojiTitles[emoji] = title;
}
if (keywords.length) {
emojiSearchIndex[emoji] = keywords.join(' ');
}
});
return {
emojiSearchIndex,
emojiTitles,
};
}
function createEmptyEmojiGroups(): EmojiGroups {
return Object.fromEntries([
['frequent', []],
...emojiCategories.map((category) => [category, []]),
]) as EmojiGroups;
}
function parseEmojiTestLine(line: string): IEmojiItem | null {
const match = line.match(/^\S(?:.+?)\s+;\s+fully-qualified\s+#\s+(\S+)\s+E[\d.]+\s+(.+)$/);
if (!match) {
return null;
}
return {
emoji: match[1],
title: toTitleCase(match[2]),
};
}
function isEmojiItem(item: IEmojiItem | null): item is IEmojiItem {
return item !== null;
}
function getCldrAnnotation(source: ICldrAnnotationsData, emoji: string) {
const normalizedEmoji = stripEmojiVariationSelectors(emoji);
const annotations = source.annotations?.annotations ?? source.annotationsDerived?.annotations;
return annotations?.[emoji] ?? annotations?.[normalizedEmoji];
}
function stripEmojiVariationSelectors(emoji: string): string {
return emoji.replace(/\uFE0F/g, '');
}
function hasSkinToneModifier(emoji: string): boolean {
return emojiSkinToneModifierRegex.test(emoji);
}
function getSkinToneFamilyTitle(title: string, baseItemsByTitle: Map<string, IEmojiItem>): string {
const separatorIndex = title.indexOf(': ');
if (separatorIndex < 0) {
return title;
}
const baseTitle = title.slice(0, separatorIndex);
const descriptors = title
.slice(separatorIndex + 2)
.split(', ')
.filter((descriptor) => !emojiSkinToneNames.has(descriptor));
const familyTitle = descriptors.length ? `${baseTitle}: ${descriptors.join(', ')}` : baseTitle;
// Unicode uses neutral person ZWJ sequences for mixed-tone variants while the unmodified
// kiss/couple emoji is a single code point with the shorter base title.
if (familyTitle.endsWith(': Person, Person') && baseItemsByTitle.has(baseTitle)) {
return baseTitle;
}
return familyTitle;
}
function toTitleCase(value: string): string {
return value
.split(' ')
.map((word) => word ? `${word[0].toUpperCase()}${word.slice(1)}` : word)
.join(' ');
}
interface IShouldRelaunchWithEnvProxyOptions {
allowedFlags: ReadonlySet<string>;
env: NodeJS.ProcessEnv | Record<string, string | undefined>;
execArgv: readonly string[];
}
export function shouldRelaunchWithEnvProxy(options: IShouldRelaunchWithEnvProxyOptions): boolean {
const proxyConfigured = [
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'http_proxy',
'https_proxy',
'all_proxy',
].some((name) => Boolean(options.env[name]));
if (!proxyConfigured || options.env[envProxyRelaunchEnv] || !options.allowedFlags.has(envProxyNodeArg)) {
return false;
}
return !options.execArgv.includes(envProxyNodeArg) && !options.env.NODE_OPTIONS?.split(/\s+/).includes(envProxyNodeArg);
}
function relaunchWithEnvProxyIfNeeded(): boolean {
if (!shouldRelaunchWithEnvProxy({
allowedFlags: process.allowedNodeEnvironmentFlags,
env: process.env,
execArgv: process.execArgv,
})) {
return false;
}
const result = spawnSync(process.execPath, [
envProxyNodeArg,
...process.execArgv,
...process.argv.slice(1),
], {
env: {
...process.env,
[envProxyRelaunchEnv]: '1',
},
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
process.exitCode = result.status ?? 1;
return true;
}
function getCldrAnnotationsUrl(cldrLocale: string, type: 'annotations' | 'annotationsDerived'): string {
if (type === 'annotations') {
return `${cldrJsonBaseUrl}/cldr-annotations-full/annotations/${cldrLocale}/annotations.json`;
}
return `${cldrJsonBaseUrl}/cldr-annotations-derived-full/annotationsDerived/${cldrLocale}/annotations.json`;
}
export function getEmojiSourceUrls(): string[] {
return [
unicodeEmojiTestUrl,
...localeFiles.flatMap(([, cldrLocale]) => [
getCldrAnnotationsUrl(cldrLocale, 'annotations'),
getCldrAnnotationsUrl(cldrLocale, 'annotationsDerived'),
]),
];
}
function isRetryableDownloadStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500;
}
async function waitForDownloadRetry(url: string, attempt: number, reason: unknown): Promise<void> {
const delayMs = downloadRetryBaseDelayMs * 2 ** (attempt - 1);
const message = reason instanceof Error ? reason.message : String(reason);
console.warn(`Failed to download ${url}: ${message}. Retrying in ${delayMs}ms (${attempt}/${downloadMaxAttempts - 1}).`);
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}
async function downloadText(url: string): Promise<string> {
for (let attempt = 1; attempt <= downloadMaxAttempts; attempt += 1) {
let response: Response;
try {
response = await fetch(url, {
signal: AbortSignal.timeout(downloadTimeoutMs),
});
} catch (error) {
if (attempt === downloadMaxAttempts) {
throw new Error(`Failed to download ${url} after ${downloadMaxAttempts} attempts`, { cause: error });
}
await waitForDownloadRetry(url, attempt, error);
continue;
}
if (response.ok) {
return response.text();
}
const error = new Error(`${response.status} ${response.statusText}`);
if (!isRetryableDownloadStatus(response.status) || attempt === downloadMaxAttempts) {
throw new Error(`Failed to download ${url}: ${error.message}`);
}
await waitForDownloadRetry(url, attempt, error);
}
throw new Error(`Failed to download ${url}`);
}
async function downloadUnicodeEmojiTest(): Promise<string> {
return downloadText(unicodeEmojiTestUrl);
}
async function downloadJson<T>(url: string): Promise<T> {
return JSON.parse(await downloadText(url)) as T;
}
export async function mapWithConcurrencyLimit<T, R>(
values: readonly T[],
limit: number,
mapper: (value: T, index: number) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(values.length);
let nextIndex = 0;
const workerCount = Math.min(Math.max(1, limit), values.length);
await Promise.all(Array.from({ length: workerCount }, async () => {
while (nextIndex < values.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await mapper(values[currentIndex], currentIndex);
}
}));
return results;
}
function serializeEmojis(groups: EmojiGroups): string {
return [
'/* eslint-disable */',
'// Generated by scripts/generate-emojis.mts. Do not edit manually.',
'',
`export const emojis = ${JSON.stringify(groups, null, 4)};`,
'',
].join('\n');
}
function writeGeneratedEmojis(filePath: PathLike, groups: EmojiGroups): void {
fs.mkdirSync(path.dirname(filePath.toString()), { recursive: true });
fs.writeFileSync(filePath, serializeEmojis(groups));
}
function serializeEmojiLocale(data: IGeneratedEmojiLocale): string {
return [
'/* eslint-disable */',
'// Generated by scripts/generate-emojis.mts. Do not edit manually.',
'',
`const emojiLocale: { emojiSearchIndex: Record<string, string>; emojiTitles: Record<string, string> } = ${JSON.stringify(data, null, 4)};`,
'',
'export default emojiLocale;',
'',
].join('\n');
}
function writeGeneratedEmojiLocale(locale: string, data: IGeneratedEmojiLocale): void {
fs.mkdirSync(emojiLocaleOutputDir, { recursive: true });
fs.writeFileSync(path.resolve(emojiLocaleOutputDir, `${locale}.generated.ts`), serializeEmojiLocale(data));
}
async function generate(): Promise<void> {
const emojiTest = await downloadUnicodeEmojiTest();
const emojis = buildEmojisFromEmojiTest(emojiTest);
const allEmojis = [
...new Map(
Object.values(emojis)
.flat()
.flatMap((item) => [item, ...(item.skinToneVariants ?? [])])
.map((item) => [item.emoji, item])
).values(),
];
writeGeneratedEmojis(outputPath, emojis);
await mapWithConcurrencyLimit(localeFiles, cldrJsonDownloadConcurrency, async ([locale, cldrLocale]) => {
const annotations = await downloadJson<ICldrAnnotationsData>(getCldrAnnotationsUrl(cldrLocale, 'annotations'));
const annotationsDerived = await downloadJson<ICldrAnnotationsData>(getCldrAnnotationsUrl(cldrLocale, 'annotationsDerived'));
writeGeneratedEmojiLocale(locale, buildEmojiSearchIndexFromCldrAnnotations(allEmojis, annotations, annotationsDerived));
});
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const task = relaunchWithEnvProxyIfNeeded()
? Promise.resolve()
: generate();
task.catch((error) => {
console.error(error);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,44 @@
/**
* 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 { ConfigService, ContextService, IConfigService, IContextService, Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { MOBILE_UI_MODE } from '../const';
import { UniverMobileUIPlugin } from '../mobile-plugin';
describe('UniverMobileUIPlugin', () => {
it('exposes mobile interaction mode to consumers without changing another runtime', () => {
const injector = new Injector([
[IConfigService, { useClass: ConfigService }],
[IContextService, { useClass: ContextService }],
[UniverMobileUIPlugin, {
useFactory: (injector: Injector) => injector.createInstance(UniverMobileUIPlugin, {}),
deps: [Injector],
}],
]);
const peerInjector = new Injector([[IContextService, { useClass: ContextService }]]);
try {
const contextService = injector.get(IContextService);
expect(contextService.getContextValue(MOBILE_UI_MODE)).toBeFalsy();
injector.get(UniverMobileUIPlugin);
expect(contextService.getContextValue(MOBILE_UI_MODE)).toBe(true);
expect(peerInjector.get(IContextService).getContextValue(MOBILE_UI_MODE)).toBeFalsy();
} finally {
injector.dispose();
peerInjector.dispose();
}
});
});

View File

@@ -0,0 +1,59 @@
/**
* 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 type { IAccessor } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { ILayoutService } from '../../../services/layout/layout.service';
import { ToggleFullscreenOperation } from '../toggle-fullscreen.operation';
function createAccessor(rootContainerElement: HTMLElement | null) {
return {
get: (token: unknown) => {
if (token === ILayoutService) return { rootContainerElement };
throw new Error('Unknown token');
},
} as IAccessor;
}
describe('ToggleFullscreenOperation', () => {
it('requests fullscreen for the Univer root container', () => {
const requestFullscreen = vi.fn(async () => undefined);
const root = {
ownerDocument: { fullscreenElement: null },
requestFullscreen,
} as unknown as HTMLElement;
expect(ToggleFullscreenOperation.handler(createAccessor(root), {})).toBe(true);
expect(requestFullscreen).toHaveBeenCalledOnce();
});
it('exits fullscreen when the Univer root container is fullscreen', () => {
const exitFullscreen = vi.fn(async () => undefined);
const root = {} as HTMLElement;
Object.assign(root, {
ownerDocument: { fullscreenElement: root, exitFullscreen },
requestFullscreen: vi.fn(),
});
expect(ToggleFullscreenOperation.handler(createAccessor(root), {})).toBe(true);
expect(exitFullscreen).toHaveBeenCalledOnce();
expect(root.requestFullscreen).not.toHaveBeenCalled();
});
it('does nothing when the root container is unavailable', () => {
expect(ToggleFullscreenOperation.handler(createAccessor(null), {})).toBe(false);
});
});

View File

@@ -0,0 +1,66 @@
/**
* 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 type { IAccessor } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { ShortcutPanelService } from '../../../services/shortcut/shortcut-panel.service';
import { ISidebarService } from '../../../services/sidebar/sidebar.service';
import { ShortcutPanelComponentName, ToggleShortcutPanelOperation } from '../toggle-shortcut-panel.operation';
function createAccessor(isOpen: boolean) {
const shortcutPanelService = {
isOpen,
open: vi.fn(),
close: vi.fn(),
} as unknown as ShortcutPanelService;
const sidebarService = {
open: vi.fn(),
close: vi.fn(),
};
const accessor = {
get: (token: unknown) => {
if (token === ShortcutPanelService) return shortcutPanelService;
if (token === ISidebarService) return sidebarService;
throw new Error('Unknown token');
},
} as unknown as IAccessor;
return { accessor, shortcutPanelService, sidebarService };
}
describe('ToggleShortcutPanelOperation', () => {
it('should close shortcut panel and sidebar when panel is open', () => {
const { accessor, shortcutPanelService, sidebarService } = createAccessor(true);
expect(ToggleShortcutPanelOperation.handler!(accessor, {})).toBe(true);
expect(shortcutPanelService.close).toHaveBeenCalledOnce();
expect(sidebarService.close).toHaveBeenCalledOnce();
expect(sidebarService.open).not.toHaveBeenCalled();
});
it('should open shortcut panel and sidebar when panel is closed', () => {
const { accessor, shortcutPanelService, sidebarService } = createAccessor(false);
expect(ToggleShortcutPanelOperation.handler!(accessor, {})).toBe(true);
expect(shortcutPanelService.open).toHaveBeenCalledOnce();
expect(sidebarService.open).toHaveBeenCalledWith({
header: { title: 'ui.shortcut-panel.title' },
children: { label: ShortcutPanelComponentName },
});
});
});

View File

@@ -0,0 +1,43 @@
/**
* 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 type { IOperation } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { CommandType, LocaleService } from '@univerjs/core';
import { IDialogService } from '../../services/dialog/dialog.service';
import {
FEATURE_SEARCH_COMPONENT,
FEATURE_SEARCH_DIALOG_ID,
} from '../../views/components/feature-search/FeatureSearch';
export const OpenFeatureSearchOperation: IOperation = {
id: 'ui.operation.open-feature-search',
type: CommandType.OPERATION,
handler: (accessor) => {
const dialogService = accessor.get(IDialogService);
const localeService = accessor.get(LocaleService);
dialogService.open({
id: FEATURE_SEARCH_DIALOG_ID,
width: 560,
title: { title: localeService.t<LocaleKey>('ui.featureSearch.title') },
children: { label: FEATURE_SEARCH_COMPONENT },
onClose: () => dialogService.close(FEATURE_SEARCH_DIALOG_ID),
});
return true;
},
};

View File

@@ -0,0 +1,44 @@
/**
* 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 type { IOperation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { ILayoutService } from '../../services/layout/layout.service';
export const ToggleFullscreenOperation: IOperation = {
id: 'base-ui.operation.toggle-fullscreen',
type: CommandType.OPERATION,
handler: (accessor) => {
const root = accessor.get(ILayoutService).rootContainerElement;
if (!root) return false;
const document = root.ownerDocument;
try {
if (document.fullscreenElement) {
if (!document.exitFullscreen) return false;
void document.exitFullscreen().catch(() => {});
} else {
if (!root.requestFullscreen) return false;
void root.requestFullscreen().catch(() => {});
}
return true;
} catch {
return false;
}
},
};

View File

@@ -0,0 +1,46 @@
/**
* 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 type { IAccessor, IOperation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { ShortcutPanelService } from '../../services/shortcut/shortcut-panel.service';
import { ISidebarService } from '../../services/sidebar/sidebar.service';
export const ShortcutPanelComponentName = 'ShortcutPanel';
export const ToggleShortcutPanelOperation: IOperation = {
id: 'base-ui.operation.toggle-shortcut-panel',
type: CommandType.OPERATION,
handler: (accessor: IAccessor) => {
const shortcutPanelService = accessor.get(ShortcutPanelService);
const sidebarService = accessor.get(ISidebarService);
const isOpen = shortcutPanelService.isOpen;
if (isOpen) {
shortcutPanelService.close();
sidebarService.close();
} else {
shortcutPanelService.open();
sidebarService.open({
header: { title: 'ui.shortcut-panel.title' },
children: { label: ShortcutPanelComponentName },
});
}
return true;
},
};

View File

@@ -0,0 +1,136 @@
/**
* 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 type { ILogService } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ComponentManager } from '../component-manager';
function createLogService(): ILogService {
return {
debug: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
deprecate: vi.fn(),
setLogLevel: vi.fn(),
};
}
describe('ComponentManager', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should not expose built-in icons and should expose react utils', () => {
const logService = createLogService();
const manager = new ComponentManager(logService);
expect(manager.get('ShortcutIcon')).toBeUndefined();
expect(manager.reactUtils.createElement).toBeTypeOf('function');
expect(manager.reactUtils.useEffect).toBeTypeOf('function');
expect(manager.reactUtils.useRef).toBeTypeOf('function');
manager.dispose();
expect(manager.get('ShortcutIcon')).toBeUndefined();
});
it('should register default react component and support get/delete/disposable', () => {
const logService = createLogService();
const manager = new ComponentManager(logService);
const Comp = () => null;
const disposable = manager.register('test-comp', Comp);
expect(manager.get('test-comp')).toBe(Comp);
expect('getKey' in manager).toBe(false);
manager.delete('test-comp');
expect(manager.get('test-comp')).toBeUndefined();
manager.register('test-comp', Comp);
disposable.dispose();
expect(manager.get('test-comp')).toBeUndefined();
manager.dispose();
});
it('should warn through log service when registering duplicate component', () => {
const logService = createLogService();
const manager = new ComponentManager(logService);
const Comp = () => null;
manager.register('dup-comp', Comp);
manager.register('dup-comp', Comp);
expect(logService.warn).toHaveBeenCalledWith('[ComponentManager]', 'Component dup-comp already exists.');
manager.dispose();
});
it('should allow direct construction without log service', () => {
const manager = new ComponentManager();
const Comp = () => null;
expect(() => {
manager.register('dup-comp', Comp);
manager.register('dup-comp', Comp);
}).not.toThrow();
expect(manager.get('dup-comp')).toBe(Comp);
manager.dispose();
});
it('should throw when registering vue3 without handler', () => {
const logService = createLogService();
const manager = new ComponentManager(logService);
expect(() => manager.register('vue-comp', () => null, { framework: 'vue3' })).toThrow(
'[ComponentManager] Vue3 support is no longer built-in since v0.9.0, please install @univerjs/ui-adapter-vue3 plugin.'
);
manager.dispose();
});
it('should use custom framework handler and throw when handler missing at get', () => {
const logService = createLogService();
const manager = new ComponentManager(logService);
const CustomComp = () => null;
manager.setHandler('custom', (component, name) => ({
component,
name,
wrapped: true,
}));
manager.register('custom-comp', CustomComp, { framework: 'custom' });
expect(manager.get('custom-comp')).toEqual({
component: CustomComp,
name: 'custom-comp',
wrapped: true,
});
expect(() => manager.register('unknown-framework-comp', CustomComp, { framework: 'unknown' })).not.toThrow();
expect(() => manager.get('unknown-framework-comp')).toThrow(
'[ComponentManager] No handler found for framework: unknown'
);
expect(manager.get('')).toBeUndefined();
expect(manager.get('not-exists')).toBeUndefined();
manager.dispose();
});
});

View File

@@ -0,0 +1,122 @@
/**
* 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 type { ILogService } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { IconManager } from '../icon-manager';
function createLogService(): ILogService {
return {
debug: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
deprecate: vi.fn(),
setLogLevel: vi.fn(),
};
}
describe('IconManager', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should start without built-in icons', () => {
const logService = createLogService();
const manager = new IconManager(logService);
expect(manager.has('ShortcutIcon')).toBe(false);
expect(manager.get('ShortcutIcon')).toBeDefined();
expect(logService.warn).toHaveBeenCalledWith('[IconManager]', 'Icon ShortcutIcon does not exist.');
manager.dispose();
expect(manager.has('ShortcutIcon')).toBe(false);
});
it('should register icon and support get/delete/disposable', () => {
const logService = createLogService();
const manager = new IconManager(logService);
const Icon = () => null;
const disposable = manager.register('test-icon', Icon);
expect(manager.has('test-icon')).toBe(true);
expect(manager.get('test-icon')).toBe(Icon);
manager.delete('test-icon');
expect(manager.has('test-icon')).toBe(false);
expect(manager.get('test-icon')).toBeDefined();
manager.register('test-icon', Icon);
disposable.dispose();
expect(manager.has('test-icon')).toBe(false);
expect(manager.get('test-icon')).toBeDefined();
manager.dispose();
});
it('should register icons in batch and dispose them together', () => {
const logService = createLogService();
const manager = new IconManager(logService);
const FirstIcon = () => null;
const SecondIcon = () => null;
const disposable = manager.register({
FirstIcon,
SecondIcon,
});
expect(manager.get('FirstIcon')).toBe(FirstIcon);
expect(manager.get('SecondIcon')).toBe(SecondIcon);
disposable.dispose();
expect(manager.has('FirstIcon')).toBe(false);
expect(manager.has('SecondIcon')).toBe(false);
manager.dispose();
});
it('should warn through log service when registering duplicate icon', () => {
const logService = createLogService();
const manager = new IconManager(logService);
const Icon = () => null;
const OverrideIcon = () => null;
manager.register('dup-icon', Icon);
manager.register('dup-icon', Icon).dispose();
expect(manager.has('dup-icon')).toBe(true);
expect(logService.warn).not.toHaveBeenCalledWith('[IconManager]', 'Icon dup-icon already exists.');
manager.register('dup-icon', OverrideIcon);
expect(logService.warn).toHaveBeenCalledWith('[IconManager]', 'Icon dup-icon already exists.');
manager.dispose();
});
it('should return empty icon for empty and missing names', () => {
const logService = createLogService();
const manager = new IconManager(logService);
expect(manager.get('')).toBeDefined();
expect(manager.get('not-exists')).toBeDefined();
expect(logService.warn).toHaveBeenCalledWith('[IconManager]', 'Icon does not exist.');
expect(logService.warn).toHaveBeenCalledWith('[IconManager]', 'Icon not-exists does not exist.');
manager.dispose();
});
});

View File

@@ -0,0 +1,51 @@
/**
* 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 { afterEach, describe, expect, it, vi } from 'vitest';
import { fromEvent, fromGlobalEvent } from '../lifecycle';
describe('lifecycle helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should register and dispose global event listener', () => {
const addSpy = vi.spyOn(window, 'addEventListener');
const removeSpy = vi.spyOn(window, 'removeEventListener');
const listener = vi.fn();
const disposable = fromGlobalEvent('click', listener);
expect(addSpy).toHaveBeenCalledWith('click', listener, undefined);
disposable.dispose();
expect(removeSpy).toHaveBeenCalledWith('click', listener, undefined);
});
it('should register and dispose element event listener', () => {
const button = document.createElement('button');
const addSpy = vi.spyOn(button, 'addEventListener');
const removeSpy = vi.spyOn(button, 'removeEventListener');
const listener = vi.fn();
const disposable = fromEvent(button, 'click', listener);
expect(addSpy).toHaveBeenCalledWith('click', listener, undefined);
disposable.dispose();
expect(removeSpy).toHaveBeenCalledWith('click', listener, undefined);
});
});

View File

@@ -0,0 +1,210 @@
/**
* 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 type { IAccessor } from '@univerjs/core';
import { DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DocumentFlavor, UniverInstanceType } from '@univerjs/core';
import { Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getHeaderFooterMenuHiddenObservable, getMenuHiddenObservable } from '../menu-hidden-observable';
function createDocModel(flavor: DocumentFlavor) {
return {
getSnapshot: () => ({
documentStyle: {
documentFlavor: flavor,
},
}),
};
}
function createMenuAccessor(options?: {
focusedUnitId?: string | null;
unitType?: UniverInstanceType;
docFlavorByUnitId?: Record<string, DocumentFlavor>;
currentDocFlavor?: DocumentFlavor | null;
}) {
const focused$ = new Subject<string | null>();
const unitType = options?.unitType ?? UniverInstanceType.UNIVER_SHEET;
const hasFocusedUnitId = options != null && Object.prototype.hasOwnProperty.call(options, 'focusedUnitId');
const focusedUnitId = hasFocusedUnitId ? options.focusedUnitId! : 'unit-1';
const docFlavorByUnitId = options?.docFlavorByUnitId ?? {};
const hasCurrentDocFlavor = options != null && Object.prototype.hasOwnProperty.call(options, 'currentDocFlavor');
const currentDocFlavor = hasCurrentDocFlavor ? options.currentDocFlavor! : null;
const service = {
focused$,
getUnitType: vi.fn(() => unitType),
getFocusedUnit: vi.fn(() => (focusedUnitId == null ? null : { getUnitId: () => focusedUnitId })),
getUnit: vi.fn((unitId: string) => {
const flavor = docFlavorByUnitId[unitId];
return flavor == null ? null : createDocModel(flavor);
}),
getCurrentUnitOfType: vi.fn((type: UniverInstanceType) => {
if (type === UniverInstanceType.UNIVER_DOC) {
if (currentDocFlavor == null) {
return null;
}
return createDocModel(currentDocFlavor);
}
if (focusedUnitId == null) {
return null;
}
return { getUnitId: () => focusedUnitId };
}),
};
const accessor = {
get: () => service,
} as unknown as IAccessor;
return { accessor, service };
}
describe('getMenuHiddenObservable', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should emit true when there is no focused unit initially', () => {
const { accessor } = createMenuAccessor({ focusedUnitId: null });
const hiddenValues: boolean[] = [];
const sub = getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET).subscribe((hidden) => hiddenValues.push(hidden));
expect(hiddenValues).toEqual([true]);
sub.unsubscribe();
});
it('should emit false when focused unit type matches target type', () => {
const { accessor } = createMenuAccessor({ unitType: UniverInstanceType.UNIVER_SHEET });
const hiddenValues: boolean[] = [];
const sub = getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET).subscribe((hidden) => hiddenValues.push(hidden));
expect(hiddenValues).toEqual([false]);
sub.unsubscribe();
});
it('should emit true when focused unit type does not match target type', () => {
const { accessor } = createMenuAccessor({ unitType: UniverInstanceType.UNIVER_DOC });
const hiddenValues: boolean[] = [];
const sub = getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET).subscribe((hidden) => hiddenValues.push(hidden));
expect(hiddenValues).toEqual([true]);
sub.unsubscribe();
});
it('should treat the formula bar document as part of its sheet host', () => {
const { accessor } = createMenuAccessor({
focusedUnitId: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
unitType: UniverInstanceType.UNIVER_DOC,
});
const sheetHiddenValues: boolean[] = [];
const docHiddenValues: boolean[] = [];
const sheetSub = getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET).subscribe((hidden) => sheetHiddenValues.push(hidden));
const docSub = getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC).subscribe((hidden) => docHiddenValues.push(hidden));
expect(sheetHiddenValues).toEqual([false]);
expect(docHiddenValues).toEqual([true]);
sheetSub.unsubscribe();
docSub.unsubscribe();
});
it('should hide when focused unit does not match matchUnitId', () => {
const { accessor, service } = createMenuAccessor({ focusedUnitId: 'expected-unit' });
const hiddenValues: boolean[] = [];
const sub = getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET, 'expected-unit').subscribe((hidden) => hiddenValues.push(hidden));
service.focused$.next('other-unit');
expect(hiddenValues).toEqual([false, true]);
sub.unsubscribe();
});
it('should hide when needHideUnitId matches current unit (string and array)', () => {
const { accessor, service } = createMenuAccessor({ focusedUnitId: 'unit-1' });
const hiddenWithString: boolean[] = [];
const hiddenWithArray: boolean[] = [];
const sub1 = getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET, undefined, 'blocked-unit').subscribe((hidden) =>
hiddenWithString.push(hidden)
);
const sub2 = getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET, undefined, ['blocked-unit']).subscribe((hidden) =>
hiddenWithArray.push(hidden)
);
service.focused$.next('blocked-unit');
expect(hiddenWithString).toEqual([false, true]);
expect(hiddenWithArray).toEqual([false, true]);
sub1.unsubscribe();
sub2.unsubscribe();
});
it('should hide when focused$ emits null unit id', () => {
const { accessor, service } = createMenuAccessor({ focusedUnitId: 'unit-1' });
const hiddenValues: boolean[] = [];
const sub = getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET).subscribe((hidden) => hiddenValues.push(hidden));
service.focused$.next(null);
expect(hiddenValues).toEqual([false, true]);
sub.unsubscribe();
});
});
describe('getHeaderFooterMenuHiddenObservable', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should emit true when current doc instance is missing', () => {
const { accessor } = createMenuAccessor({ currentDocFlavor: null });
const hiddenValues: boolean[] = [];
const sub = getHeaderFooterMenuHiddenObservable(accessor).subscribe((hidden) => hiddenValues.push(hidden));
expect(hiddenValues).toEqual([true]);
sub.unsubscribe();
});
it('should emit false when current document flavor is traditional', () => {
const { accessor } = createMenuAccessor({ currentDocFlavor: DocumentFlavor.TRADITIONAL });
const hiddenValues: boolean[] = [];
const sub = getHeaderFooterMenuHiddenObservable(accessor).subscribe((hidden) => hiddenValues.push(hidden));
expect(hiddenValues).toEqual([false]);
sub.unsubscribe();
});
it('should react to focused unit changes based on document flavor', () => {
const { accessor, service } = createMenuAccessor({
currentDocFlavor: DocumentFlavor.TRADITIONAL,
docFlavorByUnitId: {
modernDoc: DocumentFlavor.MODERN,
traditionalDoc: DocumentFlavor.TRADITIONAL,
},
});
const hiddenValues: boolean[] = [];
const sub = getHeaderFooterMenuHiddenObservable(accessor).subscribe((hidden) => hiddenValues.push(hidden));
service.focused$.next('modernDoc');
service.focused$.next('traditionalDoc');
service.focused$.next(null);
expect(hiddenValues).toEqual([false, true, false, true]);
sub.unsubscribe();
});
});

View File

@@ -0,0 +1,92 @@
/**
* 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 type { Observable } from 'rxjs';
import type { MenuItemConfig } from '../../services/menu/menu';
import { firstValueFrom, of } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { mergeMenuConfigs } from '../menu-merge-configs';
type MenuItemConfigWithReactiveState = MenuItemConfig & {
hidden$?: Observable<boolean>;
disabled$?: Observable<boolean>;
activated$?: Observable<boolean>;
};
describe('mergeMenuConfigs', () => {
it('should return base config when additional config is null', () => {
const base = { title: 'Base title' };
expect(mergeMenuConfigs(base, null)).toBe(base);
});
it('should return base config when base config is falsy', () => {
expect(mergeMenuConfigs(null as unknown as MenuItemConfig, { title: 'New title' })).toBeNull();
});
it('should merge plain properties and ignore undefined values', () => {
const base = {
type: 1,
icon: 'old-icon',
title: 'old-title',
tooltip: 'old-tooltip',
} as unknown as MenuItemConfig;
const additional = {
title: 'new-title',
tooltip: 'new-tooltip',
icon: undefined,
} as unknown as MenuItemConfig;
const merged = mergeMenuConfigs(base, additional);
expect(merged.title).toBe('new-title');
expect(merged.tooltip).toBe('new-tooltip');
expect(merged.icon).toBe('old-icon');
});
it('should create reactive observables when base config has no reactive properties', async () => {
const base = {} as MenuItemConfigWithReactiveState;
const merged = mergeMenuConfigs<MenuItemConfigWithReactiveState>(base, {
hidden: true,
disabled: false,
activated: true,
});
await expect(firstValueFrom(merged.hidden$!)).resolves.toBe(true);
await expect(firstValueFrom(merged.disabled$!)).resolves.toBe(false);
await expect(firstValueFrom(merged.activated$!)).resolves.toBe(true);
});
it('should replace values from existing reactive observables', async () => {
const base = {
hidden$: of(false),
disabled$: of(true),
activated$: of(false),
} as MenuItemConfigWithReactiveState;
const merged = mergeMenuConfigs<MenuItemConfigWithReactiveState>(base, {
hidden: true,
disabled: false,
activated: true,
});
await expect(firstValueFrom(merged.hidden$!)).resolves.toBe(true);
await expect(firstValueFrom(merged.disabled$!)).resolves.toBe(false);
await expect(firstValueFrom(merged.activated$!)).resolves.toBe(true);
});
});

View File

@@ -0,0 +1,46 @@
/**
* 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 { describe, expect, it } from 'vitest';
import { ZIndexManager } from '../z-index-manager';
describe('ZIndexManager', () => {
it('should set, get and remove index values', () => {
const manager = new ZIndexManager();
manager.setIndex('dialog', 1000);
expect(manager.getIndex('dialog')).toBe(1000);
manager.removeIndex('dialog');
expect(manager.getIndex('dialog')).toBeUndefined();
});
it('should return maximum index from all values', () => {
const manager = new ZIndexManager();
manager.setIndex('dialog', 2000);
manager.setIndex('sidebar', 1500);
manager.setIndex('tooltip', 9999);
expect(manager.getMaxIndex()).toBe(9999);
});
it('should return default minimum when no index exists', () => {
const manager = new ZIndexManager();
expect(manager.getMaxIndex()).toBe(-9999999);
});
});

View File

@@ -0,0 +1,103 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import { Disposable, ILogService, Optional, toDisposable } from '@univerjs/core';
import { createElement, useEffect, useRef } from 'react';
type ComponentFramework = string;
export interface IComponentOptions {
framework?: ComponentFramework;
}
export interface IComponent<T = any> {
framework: string;
component: any;
};
export type ComponentType<T = any> = any;
export type ComponentList = Map<string, IComponent>;
export class ComponentManager extends Disposable {
private _components: ComponentList = new Map();
constructor(@Optional(ILogService) private readonly _logService?: ILogService) {
super();
}
register(name: string, component: ComponentType, options?: IComponentOptions): IDisposable {
const { framework = 'react' } = options || {};
if (framework === 'vue3' && !this._handler.vue3) {
throw new Error('[ComponentManager] Vue3 support is no longer built-in since v0.9.0, please install @univerjs/ui-adapter-vue3 plugin.');
}
if (this._components.has(name)) {
this._logService?.warn('[ComponentManager]', `Component ${name} already exists.`);
}
this._components.set(name, {
framework,
component,
});
return toDisposable(() => {
this._components.delete(name);
});
}
reactUtils: {
createElement: typeof createElement;
useEffect: typeof useEffect;
useRef: typeof useRef;
} = {
createElement,
useEffect,
useRef,
};
private _handler: Record<string, (component: IComponent['component'], name?: string) => any> = {
react: (component: IComponent['component']) => {
return component;
},
};
setHandler(framework: string, handler: (component: IComponent['component'], name?: string) => any) {
this._handler[framework] = handler;
}
get(name: string) {
if (!name) return;
const value = this._components.get(name);
if (!value) return;
const frameworkHandler = this._handler[value.framework];
if (!frameworkHandler) {
throw new Error(`[ComponentManager] No handler found for framework: ${value.framework}`);
}
return frameworkHandler(value.component, name);
}
delete(name: string) {
this._components.delete(name);
}
}

View File

@@ -0,0 +1,147 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import type { ComponentType, CSSProperties, ForwardRefExoticComponent, MouseEventHandler, RefAttributes } from 'react';
import { Disposable, ILogService, toDisposable } from '@univerjs/core';
/**
* Extra color channels consumed by Univer icon components.
*/
export interface IIconExtendProps {
colorChannel1: string;
}
/**
* Common props that IconManager-managed icons receive when rendered by UI components.
*/
export interface IIconProps {
className?: string;
style?: CSSProperties;
extend?: IIconExtendProps;
preserveStrokeWidth?: boolean;
onClick?: MouseEventHandler<SVGSVGElement>;
}
/**
* React component type accepted by IconManager.
*/
export type IconType = ComponentType<IIconProps> | ForwardRefExoticComponent<IIconProps & RefAttributes<SVGElement>>;
/**
* Icon name to icon component map used for batch registration.
*/
export type IconMap = Record<string, IconType>;
/**
* Runtime icon registry storage.
*/
export type IconList = Map<string, IconType>;
const EmptyIcon: IconType = () => null;
/**
* Registers and resolves toolbar/menu icons by name.
*
* `get()` is render-safe and returns an empty icon when the name is missing. Use `has()`
* when the caller needs to know whether an icon was actually registered.
*/
export class IconManager extends Disposable {
private _icons: IconList = new Map();
constructor(@ILogService private readonly _logService: ILogService) {
super();
}
/**
* Check whether an icon name is currently registered.
*
* Prefer this for existence checks because `get()` always returns a renderable icon.
*/
has(name: string): boolean {
return this._icons.has(name);
}
private _register(name: string, icon: IconType): IDisposable {
const existingIcon = this._icons.get(name);
if (existingIcon === icon) {
return toDisposable(() => {});
}
if (existingIcon) {
this._logService.warn('[IconManager]', `Icon ${name} already exists.`);
}
this._icons.set(name, icon);
return toDisposable(() => {
this._icons.delete(name);
});
}
/**
* Register one icon by name.
*
* The returned disposable removes this registration when disposed.
*/
register(name: string, icon: IconType): IDisposable;
/**
* Register a batch of icons.
*
* The returned disposable removes every icon registered by this call when disposed.
*/
register(icons: IconMap): IDisposable;
register(nameOrIcons: string | IconMap, icon?: IconType): IDisposable {
if (typeof nameOrIcons === 'string') {
return this._register(nameOrIcons, icon!);
}
const disposables: IDisposable[] = [];
for (const name in nameOrIcons) {
disposables.push(this._register(name, nameOrIcons[name]));
}
return toDisposable(() => {
disposables.forEach((disposable) => disposable.dispose());
});
}
/**
* Resolve an icon by name.
*
* Missing names are reported through the log service and return an empty component so
* render paths can safely use the result as `<Icon />`.
*/
get(name: string): IconType {
const icon = this._icons.get(name);
if (icon) {
return icon;
} else {
this._logService.warn('[IconManager]', `Icon ${name} does not exist.`);
return EmptyIcon;
}
}
/**
* Remove an icon registration if it exists.
*/
delete(name: string) {
this._icons.delete(name);
}
}

View File

@@ -0,0 +1,19 @@
/**
* 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.
*/
export * from './component-manager';
export * from './icon-manager';
export * from './z-index-manager';

View File

@@ -0,0 +1,37 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import { toDisposable } from '@univerjs/core';
export function fromGlobalEvent<K extends keyof WindowEventMap>(
type: K,
listener: (this: Window, ev: WindowEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
): IDisposable {
window.addEventListener(type, listener, options);
return toDisposable(() => window.removeEventListener(type, listener, options));
}
export function fromEvent<K extends keyof HTMLElementEventMap>(
target: HTMLElement,
type: K,
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
): IDisposable {
target.addEventListener(type, listener, options);
return toDisposable(() => target.removeEventListener(type, listener, options));
}

View File

@@ -0,0 +1,90 @@
/**
* 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 type { DocumentDataModel, IAccessor } from '@univerjs/core';
import { DocumentFlavor, IUniverInstanceService, SHEET_EDITOR_UNITS, UniverInstanceType } from '@univerjs/core';
import { Observable } from 'rxjs';
export function getMenuHiddenObservable(
accessor: IAccessor,
targetUniverType: UniverInstanceType,
matchUnitId?: string,
needHideUnitId?: string | string[]
): Observable<boolean> {
const univerInstanceService = accessor.get(IUniverInstanceService);
const getUnitType = (unitId: string) => SHEET_EDITOR_UNITS.includes(unitId)
? UniverInstanceType.UNIVER_SHEET
: univerInstanceService.getUnitType(unitId);
return new Observable((subscriber) => {
const subscription = univerInstanceService.focused$.subscribe((unitId) => {
if (unitId == null) {
return subscriber.next(true);
}
if (matchUnitId && matchUnitId !== unitId) {
return subscriber.next(true);
}
if (needHideUnitId && (Array.isArray(needHideUnitId) ? needHideUnitId.includes(unitId) : needHideUnitId === unitId)) {
return subscriber.next(true);
}
const univerType = getUnitType(unitId);
subscriber.next(univerType !== targetUniverType);
});
const focusedUniverInstance = univerInstanceService.getFocusedUnit();
if (focusedUniverInstance == null) {
const currentUnit = univerInstanceService.getCurrentUnitOfType(targetUniverType);
subscriber.next(currentUnit == null);
} else {
const univerType = getUnitType(focusedUniverInstance.getUnitId());
subscriber.next(univerType !== targetUniverType);
}
return () => subscription.unsubscribe();
});
}
export function getHeaderFooterMenuHiddenObservable(
accessor: IAccessor
): Observable<boolean> {
const univerInstanceService = accessor.get(IUniverInstanceService);
return new Observable((subscriber) => {
const subscription = univerInstanceService.focused$.subscribe((unitId) => {
if (unitId == null) {
return subscriber.next(true);
}
const docDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const documentFlavor = docDataModel?.getSnapshot().documentStyle.documentFlavor;
subscriber.next(documentFlavor !== DocumentFlavor.TRADITIONAL);
});
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (docDataModel == null) {
subscriber.next(true);
} else {
const documentFlavor = docDataModel?.getSnapshot().documentStyle.documentFlavor;
subscriber.next(documentFlavor !== DocumentFlavor.TRADITIONAL);
}
return () => subscription.unsubscribe();
});
}

View File

@@ -0,0 +1,55 @@
/**
* 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 type { MenuConfig, MenuItemConfig } from '../services/menu/menu';
import { BehaviorSubject, Observable, switchMap } from 'rxjs';
export function mergeMenuConfigs<T = MenuConfig>(baseConfig: T, additionalConfig: MenuItemConfig | null): T {
if (!additionalConfig || !baseConfig) return baseConfig;
// Update properties directly if they exist in additionalConfig
const properties: (keyof MenuItemConfig)[] = ['type', 'icon', 'title', 'tooltip'];
properties.forEach((prop) => {
if (additionalConfig[prop] !== undefined) {
// Use type assertion to assure TypeScript about the operation's safety
(baseConfig as any)[prop] = additionalConfig[prop];
}
});
// Update reactive properties
const observableProperties = ['hidden', 'disabled', 'activated'];
observableProperties.forEach((prop) => {
updateReactiveProperty(baseConfig, `${prop}$` as keyof typeof baseConfig, (additionalConfig as any)[prop]);
});
return baseConfig;
}
// Helper function to update reactive properties
function updateReactiveProperty<T, K extends keyof T>(baseConfig: T, key: K, value: any): void {
if (value !== undefined) {
if (baseConfig[key]) {
const subject$ = (baseConfig[key] as any).pipe(
switchMap(() => new BehaviorSubject(value))
);
baseConfig[key] = subject$;
} else {
baseConfig[key] = new Observable((subscriber) => {
subscriber.next(value);
}) as any;
}
}
}

View File

@@ -0,0 +1,56 @@
/**
* 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 type { IAccessor, UniverInstanceType } from '@univerjs/core';
import type { UnitObject } from '@univerjs/protocol';
import type { IMenuButtonItem } from '../services/menu/menu';
import { isInternalEditorID, IUniverInstanceService, ObjectPermissionService } from '@univerjs/core';
import { combineLatest, map, of, switchMap } from 'rxjs';
import { MenuItemType } from '../services/menu/menu';
import { IWorkbenchService } from '../services/workbench/workbench.service';
export function objectPermissionMenuItemFactory(
accessor: IAccessor,
id: string,
unitType: UniverInstanceType,
objectType: UnitObject
): IMenuButtonItem {
const instances = accessor.get(IUniverInstanceService);
const state$ = combineLatest([
accessor.get(IWorkbenchService).rootUnitType$,
instances.getCurrentTypeOfUnit$(unitType),
]).pipe(switchMap(([rootType, model]) => {
if (rootType !== unitType || !model || isInternalEditorID(model.getUnitId())) {
return of({ hidden: true, disabled: true });
}
// Resolve only after a product unit exists so late Authz overrides can register first.
const permissions = accessor.get(ObjectPermissionService);
const unitId = model.getUnitId();
return permissions.changed$.pipe(map(() => ({
disabled: !permissions.canView({ unitId, objectId: unitId, objectType }),
hidden: !permissions.supports({ unitId, objectId: unitId, objectType }),
})));
}));
return {
id,
type: MenuItemType.BUTTON,
icon: 'ProtectIcon',
title: 'ui.objectPermission.title',
tooltip: 'ui.objectPermission.title',
disabled$: state$.pipe(map((state) => state.disabled)),
hidden$: state$.pipe(map((state) => state.hidden)),
};
}

View File

@@ -0,0 +1,44 @@
/**
* 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.
*/
export class ZIndexManager {
static #MAX_INDEX = 2147483647;
static #MIN_INDEX = -2147483647;
private _list: Map<string, number> = new Map();
setIndex(name: string, index: number) {
this._list.set(name, index);
}
getIndex(name: string) {
return this._list.get(name);
}
removeIndex(name: string) {
this._list.delete(name);
}
getMaxIndex() {
let max = -9999999;
this._list.forEach((item) => {
if (+item > max) {
max = +item;
}
});
return max;
}
}

View File

@@ -0,0 +1,41 @@
/**
* 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 type { DependencyOverride } from '@univerjs/core';
import type { IWorkbenchOptions } from '../controllers/ui/ui.controller';
import type { MenuConfig } from '../services/menu/menu';
export const UI_PLUGIN_CONFIG_KEY = 'ui.config';
export const configSymbol = Symbol(UI_PLUGIN_CONFIG_KEY);
export interface IUniverUIConfig extends IWorkbenchOptions {
/** Disable auto focus when Univer bootstraps. */
disableAutoFocus?: true;
override?: DependencyOverride;
menu?: MenuConfig;
popupRootId?: string;
/**
* The fallback avatar for user.
*/
avatarFallback?: string;
}
export const defaultPluginConfig: IUniverUIConfig = {};

22
packages/ui/src/const.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* 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.
*/
/**
* @ignore
*/
export const UNI_DISABLE_CHANGING_FOCUS_KEY = 'UNI_DISABLE_CHANGING_FOCUS';
export const MOBILE_UI_MODE = 'MOBILE_UI_MODE';

View File

@@ -0,0 +1,231 @@
/**
* 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.
*/
/**
* @vitest-environment jsdom
*/
import {
CommandService,
ConfigService,
ContextService,
DesktopLogService,
EDITOR_ACTIVATED,
FOCUSING_FX_BAR_EDITOR,
FOCUSING_UNIVER_EDITOR,
ICommandService,
IConfigService,
IContextService,
ILogService,
Injector,
RedoCommand,
UndoCommand,
} from '@univerjs/core';
import { afterEach, describe, expect, it } from 'vitest';
import { ToggleFullscreenOperation } from '../../commands/operations/toggle-fullscreen.operation';
import { CopyCommand } from '../../services/clipboard/clipboard.command';
import { IPlatformService } from '../../services/platform/platform.service';
import { IUIRuntimeScopeService, UIRuntimeScopeService } from '../../services/runtime-scope/ui-runtime-scope.service';
import { KeyCode } from '../../services/shortcut/keycode';
import { IShortcutService, ShortcutService } from '../../services/shortcut/shortcut.service';
import {
CopyShortcutItem,
CutShortcutItem,
OnlyDisplayPasteShortcutItem,
RedoShortcutItem,
SharedController,
UndoShortcutItem,
} from '../shared-shortcut.controller';
const injectors: Injector[] = [];
afterEach(() => {
injectors.splice(0).forEach((injector) => injector.dispose());
document.body.replaceChildren();
});
function createInjector(): Injector {
const injector = new Injector();
injectors.push(injector);
injector.add([IContextService, { useClass: ContextService }]);
return injector;
}
function createContextService(values: Record<string, boolean>): IContextService {
const context = createInjector().get(IContextService);
Object.entries(values).forEach(([key, value]) => context.setContextValue(key, value));
return context;
}
function createControllerTestBed(platform: 'mac' | 'windows' | 'linux') {
const injector = createInjector();
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IPlatformService, { useValue: {
isMac: platform === 'mac',
isWindows: platform === 'windows',
isLinux: platform === 'linux',
} }]);
injector.add([IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }]);
injector.add([IShortcutService, { useClass: ShortcutService }]);
injector.add([SharedController]);
const context = injector.get(IContextService);
context.setContextValue(FOCUSING_UNIVER_EDITOR, true);
return {
controller: injector.get(SharedController),
commandService: injector.get(ICommandService),
shortcutService: injector.get(IShortcutService),
context,
};
}
describe('shared shortcut items', () => {
it('should require editor focus for copy/cut and never trigger display-only paste', () => {
const focused = createContextService({ [FOCUSING_UNIVER_EDITOR]: true });
const notFocused = createContextService({ [FOCUSING_UNIVER_EDITOR]: false });
expect(CopyShortcutItem.preconditions!(focused)).toBe(true);
expect(CopyShortcutItem.preconditions!(notFocused)).toBe(false);
expect(CutShortcutItem.preconditions!(focused)).toBe(true);
expect(CutShortcutItem.preconditions!(notFocused)).toBe(false);
expect(OnlyDisplayPasteShortcutItem.preconditions!(focused)).toBe(false);
});
it('should block undo/redo when editor or fx bar is activated', () => {
const allowed = createContextService({
[FOCUSING_UNIVER_EDITOR]: true,
[EDITOR_ACTIVATED]: false,
[FOCUSING_FX_BAR_EDITOR]: false,
});
const blockedByEditor = createContextService({
[FOCUSING_UNIVER_EDITOR]: true,
[EDITOR_ACTIVATED]: true,
[FOCUSING_FX_BAR_EDITOR]: false,
});
const blockedByFxBar = createContextService({
[FOCUSING_UNIVER_EDITOR]: true,
[EDITOR_ACTIVATED]: false,
[FOCUSING_FX_BAR_EDITOR]: true,
});
expect(UndoShortcutItem.preconditions!(allowed)).toBe(true);
expect(RedoShortcutItem.preconditions!(allowed)).toBe(true);
expect(UndoShortcutItem.preconditions!(blockedByEditor)).toBe(false);
expect(RedoShortcutItem.preconditions!(blockedByFxBar)).toBe(false);
});
});
describe('SharedController', () => {
it.each((['mac', 'windows', 'linux'] as const).flatMap((platform) => [
{ platform, compact: false },
{ platform, compact: true },
]))('routes history from a focused command selector on $platform (compact: $compact)', ({ platform, compact }) => {
const { controller, shortcutService, context } = createControllerTestBed(platform);
context.setContextValue(FOCUSING_UNIVER_EDITOR, false);
const container = document.createElement('div');
const trigger = document.createElement(compact ? 'button' : 'div');
if (compact) {
container.setAttribute('data-embed-floating-menu', 'true');
} else {
trigger.dataset.uCommand = 'sheet.command.set-horizontal-text-align';
trigger.setAttribute('role', 'button');
}
trigger.tabIndex = 0;
container.appendChild(trigger);
document.body.appendChild(container);
trigger.focus();
const undo = new KeyboardEvent('history-test', {
keyCode: KeyCode.Z,
metaKey: platform === 'mac',
ctrlKey: platform !== 'mac',
});
const redo = new KeyboardEvent('history-test', {
keyCode: platform === 'mac' ? KeyCode.Z : KeyCode.Y,
shiftKey: platform === 'mac',
metaKey: platform === 'mac',
ctrlKey: platform !== 'mac',
});
trigger.dispatchEvent(undo);
trigger.dispatchEvent(redo);
expect(shortcutService.dispatch(undo)?.id).toBe(UndoCommand.id);
expect(shortcutService.dispatch(redo)?.id).toBe(RedoCommand.id);
for (const key of [EDITOR_ACTIVATED, FOCUSING_FX_BAR_EDITOR]) {
context.setContextValue(key, true);
expect(shortcutService.dispatch(undo)).toBeUndefined();
expect(shortcutService.dispatch(redo)).toBeUndefined();
context.setContextValue(key, false);
}
const input = document.createElement('input');
trigger.appendChild(input);
input.focus();
input.dispatchEvent(undo);
input.dispatchEvent(redo);
expect(shortcutService.dispatch(undo)).toBeUndefined();
expect(shortcutService.dispatch(redo)).toBeUndefined();
trigger.focus();
trigger.dispatchEvent(undo);
trigger.blur();
expect(shortcutService.dispatch(undo)).toBeUndefined();
trigger.focus();
trigger.removeAttribute('data-u-command');
container.removeAttribute('data-embed-floating-menu');
expect(shortcutService.dispatch(undo)).toBeUndefined();
if (compact) {
container.setAttribute('data-embed-floating-menu', 'true');
} else {
trigger.dataset.uCommand = 'sheet.command.set-horizontal-text-align';
}
controller.dispose();
expect(shortcutService.dispatch(undo)).toBeUndefined();
});
it('registers working clipboard/history shortcuts and releases its registrations', () => {
const { controller, commandService, shortcutService } = createControllerTestBed('windows');
const copy = new KeyboardEvent('keydown', { keyCode: KeyCode.C, ctrlKey: true });
const undo = new KeyboardEvent('keydown', { keyCode: KeyCode.Z, ctrlKey: true });
expect(commandService.hasCommand(CopyCommand.id)).toBe(true);
expect(commandService.hasCommand(ToggleFullscreenOperation.id)).toBe(true);
expect(shortcutService.dispatch(copy)?.id).toBe(CopyCommand.id);
expect(shortcutService.dispatch(undo)?.id).toBe(UndoCommand.id);
controller.dispose();
expect(commandService.hasCommand(CopyCommand.id)).toBe(false);
expect(commandService.hasCommand(ToggleFullscreenOperation.id)).toBe(false);
expect(shortcutService.dispatch(copy)).toBeUndefined();
expect(shortcutService.dispatch(undo)).toBeUndefined();
});
it.each(['mac', 'windows', 'linux'] as const)('preserves redo bindings and editor isolation on %s', (platform) => {
const { controller, shortcutService, context } = createControllerTestBed(platform);
const modifier = { metaKey: platform === 'mac', ctrlKey: platform !== 'mac' };
const legacyRedo = new KeyboardEvent('keydown', { ...modifier, keyCode: KeyCode.Y });
const macRedo = new KeyboardEvent('keydown', { ...modifier, shiftKey: true, keyCode: KeyCode.Z });
expect(shortcutService.dispatch(legacyRedo)?.id).toBe(RedoCommand.id);
expect(shortcutService.dispatch(macRedo)?.id).toBe(platform === 'mac' ? RedoCommand.id : undefined);
for (const editorContext of [EDITOR_ACTIVATED, FOCUSING_FX_BAR_EDITOR]) {
context.setContextValue(editorContext, true);
expect(shortcutService.dispatch(legacyRedo)).toBeUndefined();
expect(shortcutService.dispatch(macRedo)).toBeUndefined();
context.setContextValue(editorContext, false);
}
controller.dispose();
expect(shortcutService.dispatch(legacyRedo)).toBeUndefined();
expect(shortcutService.dispatch(macRedo)).toBeUndefined();
});
});

View File

@@ -0,0 +1,86 @@
/**
* 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 { RedoCommand, UndoCommand } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { OpenFeatureSearchOperation } from '../../commands/operations/open-feature-search.operation';
import { ToggleShortcutPanelOperation } from '../../commands/operations/toggle-shortcut-panel.operation';
import { menuSchema } from '../../menu/schema';
import { RibbonStartGroup } from '../../services/menu/types';
import { KeyCode, MetaKeys } from '../../services/shortcut/keycode';
import { FeatureSearchController } from '../feature-search/feature-search.controller';
import { ShortcutPanelController } from '../shortcut-display/shortcut-panel.controller';
describe('shortcut-display controllers', () => {
it('should register command and shortcut', () => {
const shortcutService = {
registerShortcut: vi.fn(() => ({ dispose: vi.fn() })),
};
const commandService = {
registerCommand: vi.fn(() => ({ dispose: vi.fn() })),
};
const controller = new ShortcutPanelController(
shortcutService as any,
{} as any,
commandService as any
);
expect(commandService.registerCommand).toHaveBeenCalledWith(ToggleShortcutPanelOperation);
expect(shortcutService.registerShortcut).toHaveBeenCalledTimes(1);
controller.dispose();
});
});
describe('FeatureSearchController', () => {
it('registers the operation and a non-conflicting shortcut', () => {
const disposable = () => ({ dispose: vi.fn() });
const commandService = { registerCommand: vi.fn(disposable) };
const shortcutService = { registerShortcut: vi.fn(disposable) };
let controller: FeatureSearchController | undefined;
expect(() => {
controller = new FeatureSearchController(commandService as never, shortcutService as never);
}).not.toThrow();
expect(commandService.registerCommand).toHaveBeenCalledWith(OpenFeatureSearchOperation);
expect(shortcutService.registerShortcut).toHaveBeenCalledWith(expect.objectContaining({
id: OpenFeatureSearchOperation.id,
binding: MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT | KeyCode.P,
}));
controller?.dispose();
});
});
describe('menu schema and ui token', () => {
it('should expose static menu schema entries', () => {
const groupedSchema = menuSchema as Record<RibbonStartGroup.HISTORY | RibbonStartGroup.OTHERS, Record<string, unknown>>;
const history = groupedSchema[RibbonStartGroup.HISTORY];
const others = groupedSchema[RibbonStartGroup.OTHERS];
expect(history).toBeDefined();
expect(others).toBeDefined();
expect(Object.keys(history).length).toBeGreaterThanOrEqual(2);
expect(Object.keys(others).length).toBeGreaterThanOrEqual(1);
expect((history[UndoCommand.id] as { gridLayout?: unknown }).gridLayout).toEqual({ row: 1, column: 1, iconSize: 18 });
expect((history[RedoCommand.id] as { gridLayout?: unknown }).gridLayout).toEqual({ row: 2, column: 1, iconSize: 18 });
const shortcutPanel = others[ToggleShortcutPanelOperation.id] as { order?: number; gridLayout?: unknown };
expect(shortcutPanel.order).toBe(1);
expect(shortcutPanel.gridLayout).toBeUndefined();
});
});

View File

@@ -0,0 +1,102 @@
/**
* 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 { Disposable, Inject } from '@univerjs/core';
import { ColorPicker } from '@univerjs/design';
import {
ExpandIcon,
FeatureSearchIcon,
IncreaseIcon,
KeyboardIcon,
MoreDownIcon,
ProtectIcon,
RedoIcon,
ReduceIcon,
ShortcutIcon,
ShrinkIcon,
UndoIcon,
} from '@univerjs/icons';
import { ShortcutPanelComponentName } from '../commands/operations/toggle-shortcut-panel.operation';
import { ComponentManager } from '../common/component-manager';
import { IconManager } from '../common/icon-manager';
import { COLOR_PICKER_COMPONENT } from '../views/color-picker/interface';
import { COMMON_LABEL_COMPONENT, CommonLabel } from '../views/CommonLabel';
import { FEATURE_SEARCH_COMPONENT, FeatureSearch } from '../views/components/feature-search/FeatureSearch';
import { ShortcutPanel } from '../views/components/shortcut-panel/ShortcutPanel';
import { EMOJI_PICKER_COMPONENT, EmojiPicker } from '../views/emoji-picker/index';
import {
FONT_FAMILY_COMPONENT,
FONT_FAMILY_ITEM_COMPONENT,
FontFamily,
FontFamilyItem,
} from '../views/font-family/index';
import { FontSize } from '../views/font-size/FontSize';
import { FONT_SIZE_COMPONENT } from '../views/font-size/interface';
import { HEADING_ITEM_COMPONENT, HeadingItem } from '../views/index';
import { OBJECT_PERMISSION_DIALOG, ObjectPermissionDialog } from '../views/object-permission/ObjectPermissionButton';
import { OBJECT_PERMISSION_PANEL, ObjectPermissionPanel } from '../views/object-permission/ObjectPermissionPanel';
import { SYMBOL_PICKER_COMPONENT, SymbolPicker } from '../views/symbol-picker/index';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerIcons();
this._registerComponents();
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
ExpandIcon,
IncreaseIcon,
KeyboardIcon,
MoreDownIcon,
ProtectIcon,
RedoIcon,
ReduceIcon,
FeatureSearchIcon,
ShortcutIcon,
ShrinkIcon,
UndoIcon,
}));
}
private _registerComponents(): void {
([
[COMMON_LABEL_COMPONENT, CommonLabel],
[OBJECT_PERMISSION_DIALOG, ObjectPermissionDialog],
[OBJECT_PERMISSION_PANEL, ObjectPermissionPanel],
[HEADING_ITEM_COMPONENT, HeadingItem],
[FONT_FAMILY_COMPONENT, FontFamily],
[FONT_FAMILY_ITEM_COMPONENT, FontFamilyItem],
[FONT_SIZE_COMPONENT, FontSize],
[COLOR_PICKER_COMPONENT, ColorPicker],
[EMOJI_PICKER_COMPONENT, EmojiPicker],
[SYMBOL_PICKER_COMPONENT, SymbolPicker],
[FEATURE_SEARCH_COMPONENT, FeatureSearch],
] as const).forEach(([key, comp]) => {
this.disposeWithMe(
this._componentManager.register(key, comp)
);
});
// register the panel
this.disposeWithMe(this._componentManager.register(ShortcutPanelComponentName, ShortcutPanel));
}
}

View File

@@ -0,0 +1,132 @@
/**
* 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 {
ErrorService,
Injector,
IPermissionService,
LocaleService,
LocaleType,
PermissionService,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import zhCN from '../../../locale/zh-CN';
import { DesktopMessageService } from '../../../services/message/desktop-message.service';
import { IMessageService } from '../../../services/message/message.service';
import { IUIPartsService, UIPartsService } from '../../../services/parts/parts.service';
import { ErrorController } from '../error.controller';
describe('permission denied feedback', () => {
let injector: Injector;
beforeEach(() => {
vi.useFakeTimers();
injector = new Injector([
[ErrorService],
[LocaleService],
[IPermissionService, { useClass: PermissionService }],
[IUIPartsService, { useClass: UIPartsService }],
[IMessageService, { useClass: DesktopMessageService }],
[ErrorController],
]);
const locale = injector.get(LocaleService);
locale.load({ [LocaleType.EN_US]: enUS, [LocaleType.ZH_CN]: zhCN });
locale.setLocale(LocaleType.EN_US);
injector.get(ErrorController);
});
afterEach(() => {
injector.dispose();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('shows one warning for continuous attempts and allows a new warning after a quiet interval', () => {
const show = vi.spyOn(injector.get(IMessageService), 'show');
const errors = injector.get(ErrorService);
errors.emitPermissionDenied('doc-1', ['paragraph-1']);
for (let i = 0; i < 6; i++) {
vi.advanceTimersByTime(1000);
errors.emitPermissionDenied('doc-1', ['paragraph-1']);
}
expect(show).toHaveBeenCalledTimes(1);
expect(show).toHaveBeenCalledWith(expect.objectContaining({
content: enUS.ui.objectPermission.operationDenied,
type: MessageType.Warning,
duration: 3000,
}));
vi.advanceTimersByTime(3000);
errors.emitPermissionDenied('doc-1', ['paragraph-1']);
expect(show).toHaveBeenCalledTimes(2);
});
it('immediately refreshes the warning when switching objects or documents', () => {
const show = vi.spyOn(injector.get(IMessageService), 'show');
const errors = injector.get(ErrorService);
errors.emitPermissionDenied('doc-1', ['paragraph-1']);
errors.emitPermissionDenied('doc-1', ['paragraph-2']);
expect(show).toHaveBeenCalledTimes(2);
errors.emitPermissionDenied('doc-1', ['paragraph-2']);
expect(show).toHaveBeenCalledTimes(2);
errors.emitPermissionDenied('doc-2', ['paragraph-2']);
errors.emitPermissionDenied('doc-1', ['paragraph-1']);
expect(show).toHaveBeenCalledTimes(4);
expect(new Set(show.mock.calls.map(([options]) => options.id)).size).toBe(1);
});
it('deduplicates the same multi-object selection regardless of target order or duplicates', () => {
const show = vi.spyOn(injector.get(IMessageService), 'show');
const errors = injector.get(ErrorService);
errors.emitPermissionDenied('doc-1', ['section-1', 'paragraph-1', 'paragraph-2']);
errors.emitPermissionDenied('doc-1', ['paragraph-2', 'paragraph-1', 'section-1', 'paragraph-1']);
expect(show).toHaveBeenCalledTimes(1);
errors.emitPermissionDenied('doc-1', ['paragraph-1']);
expect(show).toHaveBeenCalledTimes(2);
});
it('honors the shared permission visibility setting without suppressing other errors', () => {
const show = vi.spyOn(injector.get(IMessageService), 'show');
injector.get(IPermissionService).setShowComponents(false);
injector.get(ErrorService).emitPermissionDenied('doc-1', ['paragraph-1']);
expect(show).not.toHaveBeenCalled();
injector.get(ErrorService).emit('Network unavailable');
expect(show).toHaveBeenCalledWith({ content: 'Network unavailable', type: MessageType.Error });
injector.get(IPermissionService).setShowComponents(true);
injector.get(ErrorService).emitPermissionDenied('doc-1', ['paragraph-1']);
expect(show).toHaveBeenCalledTimes(2);
});
it('uses the current locale when an operation is rejected', () => {
const show = vi.spyOn(injector.get(IMessageService), 'show');
injector.get(LocaleService).setLocale(LocaleType.ZH_CN);
injector.get(ErrorService).emitPermissionDenied('doc-1', ['paragraph-1']);
expect(show).toHaveBeenCalledWith(expect.objectContaining({
content: zhCN.ui.objectPermission.operationDenied,
}));
});
it('stops feedback and dismisses its message on disposal', () => {
const service = injector.get(IMessageService);
const show = vi.spyOn(service, 'show');
const remove = vi.spyOn(service, 'remove');
injector.get(ErrorController).dispose();
injector.get(ErrorService).emitPermissionDenied('doc-1', ['paragraph-1']);
expect(show).not.toHaveBeenCalled();
expect(remove).toHaveBeenCalledWith('ui.permission-denied');
});
});

View File

@@ -0,0 +1,67 @@
/**
* 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 { Disposable, ErrorService, Inject, IPermissionService, LocaleService } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { IMessageService } from '../../services/message/message.service';
const PERMISSION_MESSAGE_ID = 'ui.permission-denied';
const PERMISSION_MESSAGE_DURATION = 3000;
export class ErrorController extends Disposable {
private _lastPermissionAttempt = Number.NEGATIVE_INFINITY;
private _lastPermissionTarget: string | undefined;
constructor(
@Inject(ErrorService) private readonly _errorService: ErrorService,
@IMessageService private readonly _messageService: IMessageService,
@IPermissionService private readonly _permissionService: IPermissionService,
@Inject(LocaleService) private readonly _localeService: LocaleService
) {
super();
this.disposeWithMe(this._errorService.error$.subscribe((error) => {
if (error.code === 'PERMISSION_DENIED') {
if (!this._permissionService.getShowComponents()) {
return;
}
const now = Date.now();
const repeated = error.permissionTarget === this._lastPermissionTarget
&& now - this._lastPermissionAttempt < PERMISSION_MESSAGE_DURATION;
this._lastPermissionAttempt = now;
this._lastPermissionTarget = error.permissionTarget;
if (!repeated) {
this._messageService.show({
id: PERMISSION_MESSAGE_ID,
content: this._localeService.t('ui.objectPermission.operationDenied'),
type: MessageType.Warning,
duration: PERMISSION_MESSAGE_DURATION,
});
}
return;
}
this._messageService.show({
content: error.errorKey,
type: MessageType.Error,
});
}));
}
override dispose(): void {
this._messageService.remove(PERMISSION_MESSAGE_ID);
super.dispose();
}
}

View File

@@ -0,0 +1,41 @@
/**
* 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 type { IShortcutItem } from '../../services/shortcut/shortcut.service';
import { Disposable, ICommandService } from '@univerjs/core';
import { OpenFeatureSearchOperation } from '../../commands/operations/open-feature-search.operation';
import { KeyCode, MetaKeys } from '../../services/shortcut/keycode';
import { IShortcutService } from '../../services/shortcut/shortcut.service';
const FeatureSearchShortcut: IShortcutItem = {
id: OpenFeatureSearchOperation.id,
binding: MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT | KeyCode.P,
description: 'ui.featureSearch.title',
group: '10_global-shortcut',
groupTitle: 'ui.global-shortcut',
};
export class FeatureSearchController extends Disposable {
constructor(
@ICommandService commandService: ICommandService,
@IShortcutService shortcutService: IShortcutService
) {
super();
this.disposeWithMe(commandService.registerCommand(OpenFeatureSearchOperation));
this.disposeWithMe(shortcutService.registerShortcut(FeatureSearchShortcut));
}
}

View File

@@ -0,0 +1,80 @@
/**
* 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 { Disposable, Inject } from '@univerjs/core';
import { MobileColorPicker } from '@univerjs/design';
import {
ExpandIcon,
IncreaseIcon,
KeyboardIcon,
MoreDownIcon,
RedoIcon,
ReduceIcon,
ShrinkIcon,
UndoIcon,
} from '@univerjs/icons';
import { ComponentManager } from '../../common/component-manager';
import { IconManager } from '../../common/icon-manager';
import { COLOR_PICKER_COMPONENT } from '../../views/color-picker/interface';
import { COMMON_LABEL_COMPONENT, CommonLabel } from '../../views/CommonLabel';
import { EMOJI_PICKER_COMPONENT } from '../../views/emoji-picker/EmojiPicker';
import { MobileEmojiPicker } from '../../views/emoji-picker/MobileEmojiPicker';
import { FONT_FAMILY_COMPONENT, FontFamily } from '../../views/font-family/FontFamily';
import { FONT_FAMILY_ITEM_COMPONENT, FontFamilyItem } from '../../views/font-family/FontFamilyItem';
import { FontSize } from '../../views/font-size/FontSize';
import { FONT_SIZE_COMPONENT } from '../../views/font-size/interface';
import { HEADING_ITEM_COMPONENT, HeadingItem } from '../../views/HeadingItem';
import { SYMBOL_PICKER_COMPONENT, SymbolPicker } from '../../views/symbol-picker/SymbolPicker';
export class MobileComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerIcons();
this._registerComponents();
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
ExpandIcon,
IncreaseIcon,
KeyboardIcon,
MoreDownIcon,
RedoIcon,
ReduceIcon,
ShrinkIcon,
UndoIcon,
}));
}
private _registerComponents(): void {
([
[COMMON_LABEL_COMPONENT, CommonLabel],
[HEADING_ITEM_COMPONENT, HeadingItem],
[FONT_FAMILY_COMPONENT, FontFamily],
[FONT_FAMILY_ITEM_COMPONENT, FontFamilyItem],
[FONT_SIZE_COMPONENT, FontSize],
[COLOR_PICKER_COMPONENT, MobileColorPicker],
[EMOJI_PICKER_COMPONENT, MobileEmojiPicker],
[SYMBOL_PICKER_COMPONENT, SymbolPicker],
] as const).forEach(([key, component]) => {
this.disposeWithMe(this._componentManager.register(key, component));
});
}
}

View File

@@ -0,0 +1,150 @@
/**
* 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 type { IContextService } from '@univerjs/core';
import type { IShortcutItem } from '../services/shortcut/shortcut.service';
import {
Disposable,
EDITOR_ACTIVATED,
FOCUSING_FX_BAR_EDITOR,
FOCUSING_UNIVER_EDITOR,
ICommandService,
RedoCommand,
UndoCommand,
} from '@univerjs/core';
import { ToggleFullscreenOperation } from '../commands/operations/toggle-fullscreen.operation';
import { CopyCommand, CutCommand, PasteCommand } from '../services/clipboard/clipboard.command';
import { KeyCode, MetaKeys } from '../services/shortcut/keycode';
import { IShortcutService } from '../services/shortcut/shortcut.service';
// Not that the clipboard shortcut items would only be invoked when the browser fully supports clipboard API.
// If not, the corresponding shortcut would not be triggered and we will perform clipboard operations
// through clipboard events (editorElement.addEventListener('paste')).
function whenEditorFocused(contextService: IContextService): boolean {
return contextService.getContextValue(FOCUSING_UNIVER_EDITOR);
}
function whenEditorFocusedButNotCellEditor(contextService: IContextService): boolean {
return (
contextService.getContextValue(FOCUSING_UNIVER_EDITOR) &&
!(contextService.getContextValue(EDITOR_ACTIVATED) || contextService.getContextValue(FOCUSING_FX_BAR_EDITOR))
);
}
export const CopyShortcutItem: IShortcutItem = {
id: CopyCommand.id,
description: 'ui.shortcut.copy',
group: '1_common-edit',
groupTitle: 'ui.common-edit',
binding: KeyCode.C | MetaKeys.CTRL_COMMAND,
preconditions: whenEditorFocused,
};
export const CutShortcutItem: IShortcutItem = {
id: CutCommand.id,
description: 'ui.shortcut.cut',
group: '1_common-edit',
groupTitle: 'ui.common-edit',
binding: KeyCode.X | MetaKeys.CTRL_COMMAND,
preconditions: whenEditorFocused,
};
/**
* This shortcut item is just for displaying shortcut info, do not use it.
*/
export const OnlyDisplayPasteShortcutItem: IShortcutItem = {
id: PasteCommand.id,
description: 'ui.shortcut.paste',
group: '1_common-edit',
groupTitle: 'ui.common-edit',
binding: KeyCode.V | MetaKeys.CTRL_COMMAND,
preconditions: () => false,
};
export const UndoShortcutItem: IShortcutItem = {
id: UndoCommand.id,
description: 'ui.shortcut.undo',
group: '1_common-edit',
groupTitle: 'ui.common-edit',
binding: KeyCode.Z | MetaKeys.CTRL_COMMAND,
preconditions: whenEditorFocusedButNotCellEditor,
};
export const RedoShortcutItem: IShortcutItem = {
id: RedoCommand.id,
description: 'ui.shortcut.redo',
group: '1_common-edit',
groupTitle: 'ui.common-edit',
binding: KeyCode.Y | MetaKeys.CTRL_COMMAND,
preconditions: whenEditorFocusedButNotCellEditor,
};
const RedoMacShortcutItem: IShortcutItem = {
...RedoShortcutItem,
binding: undefined,
mac: KeyCode.Z | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
};
/**
* Define shared UI behavior across Univer business. Including undo / redo and clipboard operations.
*/
export class SharedController extends Disposable {
constructor(
@IShortcutService private readonly _shortcutService: IShortcutService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this.initialize();
}
initialize(): void {
this._registerCommands();
this._registerShortcuts();
}
private _registerCommands(): void {
this.disposeWithMe(this._commandService.registerCommand(ToggleFullscreenOperation));
[CutCommand, CopyCommand, PasteCommand].forEach((command) =>
this.disposeWithMe(this._commandService.registerMultipleCommand(command))
);
}
private _registerShortcuts(): void {
const shortcutItems = [UndoShortcutItem, RedoShortcutItem, RedoMacShortcutItem];
for (const shortcut of shortcutItems) {
// Keyboard menu dismissal restores its command trigger, not the editor focus context.
this.disposeWithMe(this._shortcutService.registerShortcut({
...shortcut,
eventPreconditions: (event) => {
const target = event.target;
return target instanceof HTMLElement &&
target === target.ownerDocument.activeElement &&
!target.isContentEditable &&
target.matches('button[data-u-command], [data-u-command][role="button"], [data-embed-floating-menu="true"] button');
},
preconditions: (contextService) => !(
contextService.getContextValue(EDITOR_ACTIVATED) ||
contextService.getContextValue(FOCUSING_FX_BAR_EDITOR)
),
}));
}
shortcutItems.push(CutShortcutItem, CopyShortcutItem, OnlyDisplayPasteShortcutItem);
shortcutItems.forEach((shortcut) => this.disposeWithMe(this._shortcutService.registerShortcut(shortcut)));
}
}

View File

@@ -0,0 +1,49 @@
/**
* 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 type { IShortcutItem } from '../../services/shortcut/shortcut.service';
import { Disposable, ICommandService } from '@univerjs/core';
import {
ToggleShortcutPanelOperation,
} from '../../commands/operations/toggle-shortcut-panel.operation';
import { IMenuManagerService } from '../../services/menu/menu-manager.service';
import { KeyCode, MetaKeys } from '../../services/shortcut/keycode';
import { IShortcutService } from '../../services/shortcut/shortcut.service';
const ToggleShortcutPanelShortcut: IShortcutItem = {
id: ToggleShortcutPanelOperation.id,
binding: MetaKeys.CTRL_COMMAND | KeyCode.BACK_SLASH,
description: 'ui.shortcut.shortcut-panel',
group: '10_global-shortcut',
groupTitle: 'ui.global-shortcut',
};
/**
* This controller add a side panel to the application to display the shortcuts.
*/
export class ShortcutPanelController extends Disposable {
constructor(
@IShortcutService shortcutService: IShortcutService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@ICommandService commandService: ICommandService
) {
super();
this.disposeWithMe(commandService.registerCommand(ToggleShortcutPanelOperation));
// register the shortcut
this.disposeWithMe(shortcutService.registerShortcut(ToggleShortcutPanelShortcut));
}
}

View File

@@ -0,0 +1,357 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import {
ContextService,
DesktopLogService,
IContextService,
ILogService,
Injector,
IUniverInstanceService,
LifecycleStages,
LifecycleUnreachableError,
UniverInstanceService,
} from '@univerjs/core';
import { Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { IWorkbenchService, WorkbenchService } from '../../../services/workbench/workbench.service';
import { SingleUnitUIController } from '../ui-shared.controller';
class TestSingleUnitUIController extends SingleUnitUIController {
callbackPromise: Promise<void> | null = null;
constructor(
injector: any,
instanceService: any,
layoutService: any,
lifecycleService: any,
renderManagerService: any,
private readonly _contentElement: HTMLElement,
private readonly _containerElement: HTMLElement
) {
super(injector, instanceService, layoutService, lifecycleService, renderManagerService);
}
runBootstrap() {
this._bootstrapWorkbench();
}
override bootstrap(callback: (contentElement: HTMLElement, containerElement: HTMLElement) => void): IDisposable {
this.callbackPromise = Promise.resolve(callback(this._contentElement, this._containerElement));
return {
dispose: vi.fn(),
};
}
}
function createRenderer(unitId: string, isMainScene = true) {
const canvas = document.createElement('canvas');
return {
unitId,
isMainScene,
activate: vi.fn(),
deactivate: vi.fn(),
engine: {
getCanvasElement: () => canvas,
mount: vi.fn((element: HTMLElement) => element.appendChild(canvas)),
unmount: vi.fn(() => canvas.remove()),
},
};
}
describe('SingleUnitUIController', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should bootstrap and switch renderers with lifecycle updates', async () => {
vi.useFakeTimers();
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 0;
});
const injector = new Injector([
[IContextService, { useClass: ContextService }],
[ILogService, { useClass: DesktopLogService }],
[IUniverInstanceService, { useClass: UniverInstanceService }],
[IWorkbenchService, { useClass: WorkbenchService }],
]);
const skeletonStates: boolean[] = [];
injector.get(IWorkbenchService).skeletonVisible$.subscribe((visible) => skeletonStates.push(visible));
const layoutService = {
registerRootContainerElement: vi.fn(() => ({ dispose: vi.fn() })),
registerContentElement: vi.fn(() => ({ dispose: vi.fn() })),
};
const focused$ = new Subject<string>();
const created$ = new Subject<any>();
const disposed$ = new Subject<string>();
const render1 = createRenderer('__INTERNAL_EDITOR__skip');
const render2 = createRenderer('render-2');
const render3 = createRenderer('render-3');
const rendererMap = new Map<string, any>([
['render-1', render1],
['render-2', render2],
['render-3', render3],
]);
const renderManagerService = {
getRenderAll: vi.fn(() => rendererMap),
getRenderUnitById: vi.fn((id: string) => rendererMap.get(id)),
created$,
disposed$,
};
const instanceService = {
focused$,
getFocusedUnit: vi.fn(() => ({ getUnitId: () => 'render-3' })),
getUnitCreateOptions: vi.fn(() => null),
};
const lifecycleService = {
onStage: vi.fn().mockResolvedValue(undefined),
stage: LifecycleStages.Starting,
};
const controller = new TestSingleUnitUIController(
injector,
instanceService,
layoutService,
lifecycleService,
renderManagerService,
document.createElement('div'),
document.createElement('div')
);
controller.runBootstrap();
expect(skeletonStates).toEqual([false, true]);
await controller.callbackPromise;
expect(layoutService.registerRootContainerElement).toHaveBeenCalledTimes(1);
expect(layoutService.registerContentElement).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(300);
expect(render2.engine.mount).toHaveBeenCalledTimes(1);
expect(render2.activate).toHaveBeenCalledTimes(1);
expect(lifecycleService.stage).toBe(LifecycleStages.Rendered);
expect(skeletonStates).toEqual([false, true, false]);
focused$.next('render-2');
expect(render2.engine.mount).toHaveBeenCalledTimes(1);
render2.engine.getCanvasElement().remove();
focused$.next('render-2');
expect(render2.engine.mount).toHaveBeenCalledTimes(2);
focused$.next('render-3');
expect(render2.deactivate).toHaveBeenCalledTimes(1);
expect(render2.engine.unmount).toHaveBeenCalledTimes(1);
expect(render3.engine.mount).toHaveBeenCalledTimes(1);
created$.next({ unitId: 'render-3' });
expect(render3.engine.mount).toHaveBeenCalledTimes(1);
disposed$.next('render-3');
focused$.next('render-3');
expect(render3.engine.mount).toHaveBeenCalledTimes(1);
expect(render3.activate).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(3000);
expect(lifecycleService.stage).toBe(LifecycleStages.Steady);
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
controller.dispose();
expect(clearTimeoutSpy).toHaveBeenCalled();
});
it('preserves a renderer mounted in a product-owned workbench host', async () => {
vi.useFakeTimers();
const contentElement = document.createElement('div');
const productCanvasHost = document.createElement('div');
const renderer = createRenderer('render-1');
productCanvasHost.appendChild(renderer.engine.getCanvasElement());
contentElement.appendChild(productCanvasHost);
const renderManagerService = {
getRenderAll: vi.fn(() => new Map([['render-1', renderer]])),
getRenderUnitById: vi.fn(() => renderer),
created$: new Subject<unknown>(),
disposed$: new Subject<string>(),
};
const controller = new TestSingleUnitUIController(
new Injector(),
{
focused$: new Subject<string>(),
getFocusedUnit: vi.fn(() => ({ getUnitId: () => 'render-1' })),
getUnitCreateOptions: vi.fn(() => null),
},
{
registerRootContainerElement: vi.fn(() => ({ dispose: vi.fn() })),
registerContentElement: vi.fn(() => ({ dispose: vi.fn() })),
},
{
onStage: vi.fn().mockResolvedValue(undefined),
stage: LifecycleStages.Starting,
},
renderManagerService,
contentElement,
document.createElement('div')
);
controller.runBootstrap();
await controller.callbackPromise;
vi.advanceTimersByTime(300);
expect(renderer.engine.mount).not.toHaveBeenCalled();
expect(renderer.engine.getCanvasElement().parentElement).toBe(productCanvasHost);
expect(renderer.activate).toHaveBeenCalledTimes(1);
});
it('should not switch the global workbench to a non-main renderer', async () => {
vi.useFakeTimers();
const layoutService = {
registerRootContainerElement: vi.fn(() => ({ dispose: vi.fn() })),
registerContentElement: vi.fn(() => ({ dispose: vi.fn() })),
};
const focused$ = new Subject<string>();
const created$ = new Subject<any>();
const normalRender = createRenderer('normal-render');
const embeddedRender = createRenderer('embedded-render', false);
const productCanvasHost = document.createElement('div');
productCanvasHost.appendChild(embeddedRender.engine.getCanvasElement());
const rendererMap = new Map<string, any>([
['normal-render', normalRender],
['embedded-render', embeddedRender],
]);
const renderManagerService = {
getRenderAll: vi.fn(() => rendererMap),
getRenderUnitById: vi.fn((id: string) => rendererMap.get(id)),
created$,
disposed$: new Subject<string>(),
};
const instanceService = {
focused$,
getFocusedUnit: vi.fn(() => ({ getUnitId: () => 'embedded-render' })),
getUnitCreateOptions: vi.fn(() => null),
};
const lifecycleService = {
onStage: vi.fn().mockResolvedValue(undefined),
stage: LifecycleStages.Starting,
};
const controller = new TestSingleUnitUIController(
new Injector(),
instanceService,
layoutService,
lifecycleService,
renderManagerService,
document.createElement('div'),
document.createElement('div')
);
controller.runBootstrap();
await controller.callbackPromise;
vi.advanceTimersByTime(300);
expect(normalRender.engine.mount).toHaveBeenCalledTimes(1);
expect(embeddedRender.engine.mount).not.toHaveBeenCalled();
focused$.next('embedded-render');
expect(normalRender.deactivate).not.toHaveBeenCalled();
expect(normalRender.engine.unmount).not.toHaveBeenCalled();
expect(embeddedRender.engine.mount).not.toHaveBeenCalled();
expect(embeddedRender.engine.getCanvasElement().parentElement).toBe(productCanvasHost);
expect(embeddedRender.activate).not.toHaveBeenCalled();
created$.next({ unitId: 'embedded-render' });
expect(embeddedRender.engine.mount).not.toHaveBeenCalled();
});
it('should ignore LifecycleUnreachableError during bootstrap callback', async () => {
const layoutService = {
registerRootContainerElement: vi.fn(() => ({ dispose: vi.fn() })),
registerContentElement: vi.fn(() => ({ dispose: vi.fn() })),
};
const controller = new TestSingleUnitUIController(
new Injector(),
{
focused$: new Subject<string>(),
getFocusedUnit: vi.fn(() => null),
getUnitCreateOptions: vi.fn(() => null),
},
layoutService,
{
onStage: vi.fn().mockRejectedValue(new LifecycleUnreachableError(LifecycleStages.Ready)),
stage: LifecycleStages.Starting,
},
{
getRenderAll: vi.fn(() => new Map()),
getRenderUnitById: vi.fn(() => null),
created$: new Subject<any>(),
disposed$: new Subject<string>(),
},
document.createElement('div'),
document.createElement('div')
);
controller.runBootstrap();
await expect(controller.callbackPromise).resolves.toBeUndefined();
});
it('should rethrow non-lifecycle errors from bootstrap callback', async () => {
const error = new Error('unexpected');
const controller = new TestSingleUnitUIController(
new Injector(),
{
focused$: new Subject<string>(),
getFocusedUnit: vi.fn(() => null),
},
{
registerRootContainerElement: vi.fn(() => ({ dispose: vi.fn() })),
registerContentElement: vi.fn(() => ({ dispose: vi.fn() })),
},
{
onStage: vi.fn().mockRejectedValue(error),
stage: LifecycleStages.Starting,
},
{
getRenderAll: vi.fn(() => new Map()),
getRenderUnitById: vi.fn(() => null),
created$: new Subject<any>(),
disposed$: new Subject<string>(),
},
document.createElement('div'),
document.createElement('div')
);
controller.runBootstrap();
await expect(controller.callbackPromise).rejects.toThrow('unexpected');
});
});

View File

@@ -0,0 +1,279 @@
/**
* 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 { Injector, LifecycleStages } from '@univerjs/core';
import { render, unmount } from '@univerjs/design';
import { Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopUIController } from '../ui-desktop.controller';
import { MobileUIController } from '../ui-mobile.controller';
vi.mock('../../../utils/di', () => ({
connectInjector: vi.fn((Comp: any) => Comp),
}));
vi.mock('@univerjs/design', async (importOriginal) => {
const actual = await importOriginal<any>();
return {
...actual,
ColorPicker: () => null,
render: vi.fn((element, _container) => {
element.props?.onRendered?.(document.createElement('div'));
}),
unmount: vi.fn(),
};
});
function createCommonDeps() {
return {
injector: new Injector(),
lifecycleService: {
onStage: vi.fn().mockResolvedValue(undefined),
stage: LifecycleStages.Starting,
},
renderManagerService: {
getRenderAll: vi.fn(() => new Map()),
getRenderUnitById: vi.fn(() => null),
created$: new Subject<any>(),
disposed$: new Subject<string>(),
},
layoutService: {
registerRootContainerElement: vi.fn(() => ({ dispose: vi.fn() })),
registerContentElement: vi.fn(() => ({ dispose: vi.fn() })),
},
instanceService: {
focused$: new Subject<string>(),
getFocusedUnit: vi.fn(() => null),
},
};
}
describe('DesktopUIController', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should bootstrap with existing container id and dispose resources', () => {
vi.useFakeTimers();
vi.mocked(render).mockClear();
vi.mocked(unmount).mockClear();
const deps = createCommonDeps();
const container = document.createElement('div');
container.id = 'existing-container';
document.body.appendChild(container);
const menuManagerService = {
mergeMenu: vi.fn(),
};
const uiPartsService = {
registerComponent: vi.fn(() => ({ dispose: vi.fn() })),
};
const controller = new DesktopUIController(
{ container: 'existing-container' } as any,
deps.injector as any,
deps.lifecycleService as any,
deps.renderManagerService as any,
deps.layoutService as any,
deps.instanceService as any,
menuManagerService as any,
uiPartsService as any
);
vi.advanceTimersByTime(3300);
expect(menuManagerService.mergeMenu).toHaveBeenCalledTimes(1);
expect(uiPartsService.registerComponent).toHaveBeenCalledTimes(3);
expect(render).toHaveBeenCalledWith(expect.any(Object), container);
controller.dispose();
expect(unmount).toHaveBeenCalledWith(container);
});
it('should create container when id is missing and when no container is provided', () => {
vi.useFakeTimers();
vi.mocked(render).mockClear();
vi.mocked(unmount).mockClear();
const deps = createCommonDeps();
const menuManagerService = { mergeMenu: vi.fn() };
const uiPartsService = { registerComponent: vi.fn(() => ({ dispose: vi.fn() })) };
const withMissingId = new DesktopUIController(
{ container: 'missing-container' } as any,
deps.injector as any,
deps.lifecycleService as any,
deps.renderManagerService as any,
deps.layoutService as any,
deps.instanceService as any,
menuManagerService as any,
uiPartsService as any
);
const withDefault = new DesktopUIController(
{} as any,
deps.injector as any,
deps.lifecycleService as any,
deps.renderManagerService as any,
deps.layoutService as any,
deps.instanceService as any,
menuManagerService as any,
uiPartsService as any
);
vi.advanceTimersByTime(3300);
const mountContainers = vi.mocked(render).mock.calls.map(([, container]) => container);
expect(mountContainers).toEqual([
expect.objectContaining({ id: 'missing-container' }),
expect.objectContaining({ id: 'univer' }),
]);
withMissingId.dispose();
withDefault.dispose();
});
it('should use HTMLElement container directly', () => {
vi.useFakeTimers();
vi.mocked(render).mockClear();
vi.mocked(unmount).mockClear();
const deps = createCommonDeps();
const mountContainer = document.createElement('div');
const menuManagerService = { mergeMenu: vi.fn() };
const uiPartsService = { registerComponent: vi.fn(() => ({ dispose: vi.fn() })) };
const controller = new DesktopUIController(
{ container: mountContainer } as any,
deps.injector as any,
deps.lifecycleService as any,
deps.renderManagerService as any,
deps.layoutService as any,
deps.instanceService as any,
menuManagerService as any,
uiPartsService as any
);
vi.advanceTimersByTime(3300);
expect(render).toHaveBeenCalledWith(expect.any(Object), mountContainer);
controller.dispose();
expect(unmount).toHaveBeenCalledWith(mountContainer);
});
});
describe('MobileUIController', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should bootstrap and register mobile ui parts', () => {
vi.useFakeTimers();
vi.mocked(render).mockClear();
vi.mocked(unmount).mockClear();
const deps = createCommonDeps();
const menuManagerService = {
mergeMenu: vi.fn(),
};
const uiPartsService = {
registerComponent: vi.fn(() => ({ dispose: vi.fn() })),
};
const controller = new MobileUIController(
{ container: 'missing-mobile' } as any,
deps.injector as any,
deps.lifecycleService as any,
deps.renderManagerService as any,
deps.layoutService as any,
deps.instanceService as any,
menuManagerService as any,
uiPartsService as any
);
vi.advanceTimersByTime(3300);
expect(menuManagerService.mergeMenu).toHaveBeenCalledTimes(1);
expect(uiPartsService.registerComponent).toHaveBeenCalledTimes(3);
expect(render).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ id: 'missing-mobile' }));
controller.dispose();
expect(unmount).toHaveBeenCalled();
});
it('should support existing id, html container and default container', () => {
vi.useFakeTimers();
vi.mocked(render).mockClear();
vi.mocked(unmount).mockClear();
const deps = createCommonDeps();
const existing = document.createElement('div');
existing.id = 'mobile-existing';
document.body.appendChild(existing);
const htmlContainer = document.createElement('section');
const menuManagerService = {
mergeMenu: vi.fn(),
};
const uiPartsService = {
registerComponent: vi.fn(() => ({ dispose: vi.fn() })),
};
const byExistingId = new MobileUIController(
{ container: 'mobile-existing' } as any,
deps.injector as any,
deps.lifecycleService as any,
deps.renderManagerService as any,
deps.layoutService as any,
deps.instanceService as any,
menuManagerService as any,
uiPartsService as any
);
const byElement = new MobileUIController(
{ container: htmlContainer } as any,
deps.injector as any,
deps.lifecycleService as any,
deps.renderManagerService as any,
deps.layoutService as any,
deps.instanceService as any,
menuManagerService as any,
uiPartsService as any
);
const byDefault = new MobileUIController(
{} as any,
deps.injector as any,
deps.lifecycleService as any,
deps.renderManagerService as any,
deps.layoutService as any,
deps.instanceService as any,
menuManagerService as any,
uiPartsService as any
);
vi.advanceTimersByTime(3300);
const mountContainers = vi.mocked(render).mock.calls.map(([, container]) => container);
expect(mountContainers).toEqual([
existing,
htmlContainer,
expect.objectContaining({ id: 'univer' }),
]);
byExistingId.dispose();
byElement.dispose();
byDefault.dispose();
});
});

View File

@@ -0,0 +1,110 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import type { IUniverUIConfig } from '../../config/config';
import type { IWorkbenchOptions } from './ui.controller';
import { Inject, Injector, IUniverInstanceService, LifecycleService, toDisposable } from '@univerjs/core';
import { unmount } from '@univerjs/design';
import { IRenderManagerService } from '@univerjs/engine-render';
import { menuSchema } from '../../menu/schema';
import { ILayoutService } from '../../services/layout/layout.service';
import { IMenuManagerService } from '../../services/menu/menu-manager.service';
import { BuiltInUIPart, IUIPartsService } from '../../services/parts/parts.service';
import { connectInjector } from '../../utils/di';
import { FloatDom } from '../../views/components/dom/FloatDom';
import { CanvasPopup } from '../../views/components/popup/CanvasPopup';
import { Ribbon } from '../../views/components/ribbon/Ribbon';
import { mountDesktopWorkbench } from '../../views/workbench/Workbench';
import { SingleUnitUIController } from './ui-shared.controller';
export class DesktopUIController extends SingleUnitUIController {
constructor(
private readonly _config: IUniverUIConfig,
@Inject(Injector) injector: Injector,
@Inject(LifecycleService) lifecycleService: LifecycleService,
@IRenderManagerService renderManagerService: IRenderManagerService,
@ILayoutService layoutService: ILayoutService,
@IUniverInstanceService instanceService: IUniverInstanceService,
@IMenuManagerService menuManagerService: IMenuManagerService,
@IUIPartsService uiPartsService: IUIPartsService
) {
super(injector, instanceService, layoutService, lifecycleService, renderManagerService);
menuManagerService.mergeMenu(menuSchema);
this._initBuiltinComponents(uiPartsService);
this._bootstrapWorkbench();
}
override dispose(): void {
super.dispose();
}
override bootstrap(callback: (contentElement: HTMLElement, containerElement: HTMLElement) => void): IDisposable {
return bootstrap(this._injector, this._config, callback);
}
private _initBuiltinComponents(uiPartsService: IUIPartsService): void {
this.disposeWithMe(uiPartsService.registerComponent(BuiltInUIPart.FLOATING, () => connectInjector(CanvasPopup, this._injector)));
this.disposeWithMe(uiPartsService.registerComponent(BuiltInUIPart.CONTENT, () => connectInjector(FloatDom, this._injector)));
this.disposeWithMe(uiPartsService.registerComponent(BuiltInUIPart.TOOLBAR, () => connectInjector(Ribbon, this._injector)));
}
}
function bootstrap(
injector: Injector,
options: IWorkbenchOptions,
callback: (contentEl: HTMLElement, containerElement: HTMLElement) => void
): IDisposable {
let mountContainer: HTMLElement;
const container = options.container;
if (typeof container === 'string') {
const containerElement = document.getElementById(container);
if (!containerElement) {
mountContainer = createContainer(container);
} else {
mountContainer = containerElement;
}
} else if (container instanceof HTMLElement) {
mountContainer = container;
} else {
mountContainer = createContainer('univer');
}
const onRendered = (contentElement: HTMLElement) => callback(contentElement, mountContainer);
function render() {
mountDesktopWorkbench(injector, options, mountContainer, onRendered);
}
render();
return toDisposable(() => {
// https://github.com/facebook/react/issues/26031
// createRoot(<div />, mountContainer);
// setTimeout(() => createRoot(<div />, mountContainer), 200);
// setTimeout(() => unmount(mountContainer), 500);
unmount(mountContainer);
});
}
function createContainer(id: string): HTMLElement {
const element = document.createElement('div');
element.id = id;
return element;
}

View File

@@ -0,0 +1,106 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import type { IUniverUIConfig } from '../../config/config';
import type { IUIController, IWorkbenchOptions } from './ui.controller';
import { Inject, Injector, IUniverInstanceService, LifecycleService, toDisposable } from '@univerjs/core';
import { unmount } from '@univerjs/design';
import { IRenderManagerService } from '@univerjs/engine-render';
import { mobileMenuSchema } from '../../menu/mobile-schema';
import { ILayoutService } from '../../services/layout/layout.service';
import { IMenuManagerService } from '../../services/menu/menu-manager.service';
import { BuiltInUIPart, IUIPartsService } from '../../services/parts/parts.service';
import { connectInjector } from '../../utils/di';
import { FloatDom } from '../../views/components/dom/FloatDom';
import { CanvasPopup } from '../../views/components/popup/CanvasPopup';
import { MobileRibbon } from '../../views/components/ribbon/MobileRibbon';
import { mountMobileWorkbench } from '../../views/mobile-workbench/MobileWorkbench';
import { SingleUnitUIController } from './ui-shared.controller';
export class MobileUIController extends SingleUnitUIController implements IUIController {
constructor(
private readonly _config: IUniverUIConfig,
@Inject(Injector) injector: Injector,
@Inject(LifecycleService) lifecycleService: LifecycleService,
@IRenderManagerService renderManagerService: IRenderManagerService,
@ILayoutService layoutService: ILayoutService,
@IUniverInstanceService instanceService: IUniverInstanceService,
@IMenuManagerService menuManagerService: IMenuManagerService,
@IUIPartsService uiPartsService: IUIPartsService
) {
super(injector, instanceService, layoutService, lifecycleService, renderManagerService);
menuManagerService.mergeMenu(mobileMenuSchema);
this._initBuiltinComponents(uiPartsService);
this._bootstrapWorkbench();
}
override dispose(): void {
super.dispose();
}
override bootstrap(callback: (contentElement: HTMLElement, containerElement: HTMLElement) => void): IDisposable {
return bootstrap(this._injector, this._config, callback);
}
private _initBuiltinComponents(uiPartsService: IUIPartsService) {
this.disposeWithMe(uiPartsService.registerComponent(BuiltInUIPart.CONTENT, () => connectInjector(CanvasPopup, this._injector)));
this.disposeWithMe(uiPartsService.registerComponent(BuiltInUIPart.CONTENT, () => connectInjector(FloatDom, this._injector)));
this.disposeWithMe(uiPartsService.registerComponent(BuiltInUIPart.TOOLBAR, () => connectInjector(MobileRibbon, this._injector)));
}
}
function bootstrap(
injector: Injector,
options: IWorkbenchOptions,
callback: (canvasEl: HTMLElement, containerElement: HTMLElement) => void
): IDisposable {
let mountContainer: HTMLElement;
const container = options.container;
if (typeof container === 'string') {
const containerElement = document.getElementById(container);
if (!containerElement) {
mountContainer = createContainer(container);
} else {
mountContainer = containerElement;
}
} else if (container instanceof HTMLElement) {
mountContainer = container;
} else {
mountContainer = createContainer('univer');
}
const onRendered = (canvasElement: HTMLElement) => callback(canvasElement, mountContainer);
function render() {
mountMobileWorkbench(injector, options, mountContainer, onRendered);
}
render();
return toDisposable(() => {
unmount(mountContainer);
});
}
function createContainer(id: string): HTMLElement {
const element = document.createElement('div');
element.id = id;
// FIXME: the element is not append to the DOM tree. So it won't be rendered.
return element;
}

View File

@@ -0,0 +1,142 @@
/**
* 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 type { IDisposable, Injector, IUniverInstanceService, LifecycleService } from '@univerjs/core';
import type { IRenderManagerService } from '@univerjs/engine-render';
import type { ILayoutService } from '../../services/layout/layout.service';
import { Disposable, isInternalEditorID, LifecycleStages, LifecycleUnreachableError, Quantity } from '@univerjs/core';
import { IWorkbenchService } from '../../services/workbench/workbench.service';
const STEADY_TIMEOUT = 3000;
/**
* @ignore
*/
export abstract class SingleUnitUIController extends Disposable {
protected _steadyTimeout: number;
protected _renderTimeout: number;
constructor(
protected readonly _injector: Injector,
protected readonly _instanceService: IUniverInstanceService,
protected readonly _layoutService: ILayoutService,
protected readonly _lifecycleService: LifecycleService,
protected readonly _renderManagerService: IRenderManagerService
) {
super();
}
override dispose(): void {
super.dispose();
clearTimeout(this._steadyTimeout);
clearTimeout(this._renderTimeout);
//@ts-ignore
delete this._instanceService;
//@ts-ignore
delete this._layoutService;
//@ts-ignore
delete this._lifecycleService;
//@ts-ignore
delete this._renderManagerService;
}
protected _bootstrapWorkbench() {
const initialSkeleton = this._injector.get(IWorkbenchService, Quantity.OPTIONAL)?.acquireSkeleton();
if (initialSkeleton) {
this.disposeWithMe(initialSkeleton);
}
this.disposeWithMe(this.bootstrap(async (contentElement, containerElement) => {
if (this._layoutService) {
this.disposeWithMe(this._layoutService.registerRootContainerElement(containerElement));
this.disposeWithMe(this._layoutService.registerContentElement(contentElement));
}
try {
await this._lifecycleService.onStage(LifecycleStages.Ready);
this._renderTimeout = window.setTimeout(() => {
// First render.
const allRenders = this._renderManagerService.getRenderAll();
for (const [key] of allRenders) {
if (this._changeRenderUnit(key, contentElement)) break;
}
requestAnimationFrame(() => requestAnimationFrame(() => initialSkeleton?.dispose()));
// Render as focus shifts.
this.disposeWithMe(this._instanceService.focused$.subscribe((unit) => {
if (unit) this._changeRenderUnit(unit, contentElement);
}));
// When renderer created, check if matches the focused.
this.disposeWithMe(this._renderManagerService.created$.subscribe((renderer) => {
if (renderer.unitId === this._instanceService.getFocusedUnit()?.getUnitId()) this._changeRenderUnit(renderer.unitId, contentElement);
}));
this.disposeWithMe(this._renderManagerService.disposed$.subscribe((renderer) => {
if (this._currentRenderId === renderer) this._currentRenderId = null;
}));
this._lifecycleService.stage = LifecycleStages.Rendered;
this._steadyTimeout = window.setTimeout(() => {
this._lifecycleService.stage = LifecycleStages.Steady;
}, STEADY_TIMEOUT);
}, 300);
} catch (error) {
clearTimeout(this._steadyTimeout);
clearTimeout(this._renderTimeout);
if (error instanceof LifecycleUnreachableError) {
return;
}
throw error;
}
}));
}
private _currentRenderId: string | null = null;
private _changeRenderUnit(rendererId: string, contentElement: HTMLElement): boolean {
const renderer = this._renderManagerService.getRenderUnitById(rendererId);
if (
!renderer ||
!renderer.unitId ||
renderer.isMainScene === false ||
isInternalEditorID(renderer.unitId)
) {
return false;
}
const canvas = renderer.engine.getCanvasElement();
const isCurrentRenderer = this._currentRenderId === rendererId;
if (isCurrentRenderer && contentElement.contains(canvas)) return false;
if (!isCurrentRenderer) {
const currentRenderer = this._currentRenderId
? this._renderManagerService.getRenderUnitById(this._currentRenderId)
: null;
currentRenderer?.deactivate();
currentRenderer?.engine.unmount();
}
if (!contentElement.contains(canvas)) {
renderer.engine.mount(contentElement);
}
renderer.activate();
this._currentRenderId = rendererId;
return true;
}
abstract bootstrap(callback: (contentElement: HTMLElement, containerElement: HTMLElement) => void): IDisposable;
}

View File

@@ -0,0 +1,65 @@
/**
* 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 type { IFontConfig } from '../../services/font.service';
import { createIdentifier } from '@univerjs/core';
export type RibbonType = 'collapsed' | 'simple' | 'classic' | 'grid';
export interface IWorkbenchOptions {
container?: string | HTMLElement;
/**
* If Univer should make the header bar visible.
*/
header?: boolean;
/**
* If Univer should make the toolbar bar visible.
*/
toolbar?: boolean;
/**
* The type of ribbon to be used in the UI.
*/
ribbonType?: RibbonType;
/**
* Custom font family list to be added to the font service.
*/
customFontFamily?: IFontConfig[] | {
override?: boolean;
list: IFontConfig[];
};
/**
* If Univer should make the footer bar visible.
*/
footer?: boolean;
/**
* If Univer should make the context menu usable.
*/
contextMenu?: boolean;
/**
* If Univer should make the header menu visible.
*/
headerMenu?: boolean;
}
export interface IUIController { }
export const IUIController = createIdentifier<IUIController>('univer.ui.ui-controller');

View File

@@ -0,0 +1,285 @@
/**
* 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 {
CommandType,
ICommandService,
IConfigService,
ILogService,
Injector,
IUniverInstanceService,
LogLevel,
Univer,
} from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { MessageType } from '@univerjs/design';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import {
BuiltInUIPart,
ComponentManager,
CopyCommand,
DesktopDialogService,
DesktopMessageService,
DesktopSidebarService,
FontService,
IDialogService,
IFontService,
IMenuManagerService,
IMessageService,
IPlatformService,
IShortcutService,
ISidebarService,
IUIPartsService,
IUIRuntimeScopeService,
KeyCode,
MenuManagerService,
PasteCommand,
PlatformService,
ShortcutService,
UI_PLUGIN_CONFIG_KEY,
UIPartsService,
UIRuntimeScopeService,
} from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { FShortcut } from '../f-shortcut';
import '@univerjs/ui/facade';
class FakeShortcutService {
static dispatched: KeyboardEvent[] = [];
forceDisable() {
return { dispose: () => {} };
}
dispatch(event: KeyboardEvent) {
FakeShortcutService.dispatched.push(event);
return { id: 'fake.shortcut', binding: event.keyCode };
}
}
class FakeRenderManagerService {
static renderUnit: unknown;
getRenderUnitById(_unitId: string) {
return FakeRenderManagerService.renderUnit;
}
}
class FakeUniverInstanceService {
static workbook: unknown;
getCurrentUnitOfType() {
return FakeUniverInstanceService.workbook;
}
}
describe('ui facade', () => {
let univer: Univer;
let univerAPI: FUniver;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([ComponentManager]);
injector.add([IDialogService, { useClass: DesktopDialogService }]);
injector.add([IFontService, { useClass: FontService }]);
injector.add([IMenuManagerService, { useClass: MenuManagerService }]);
injector.add([IMessageService, { useClass: DesktopMessageService }]);
injector.add([IPlatformService, { useClass: PlatformService }]);
injector.add([ISidebarService, { useClass: DesktopSidebarService }]);
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }]);
injector.add([IShortcutService, { useClass: ShortcutService }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
univerAPI = FUniver.newAPI(injector);
});
afterEach(() => {
univer.dispose();
});
it('adds custom menus and executes their user action through command service', async () => {
const injector = univer.__getInjector();
const menuManagerService = injector.get(IMenuManagerService);
const commandService = injector.get(ICommandService);
const actionCalls: string[] = [];
univerAPI.createMenu({
id: 'custom-menu',
title: 'Custom Menu',
action: () => actionCalls.push('clicked'),
order: 1,
}).appendTo('contextMenu.others');
const menuItem = menuManagerService.getFlatMenuByPositionKey('contextMenu').find((item) => item.item?.id === 'custom-menu')?.item;
expect(menuItem?.title).toBe('Custom Menu');
expect(menuItem?.commandId).toBeDefined();
await commandService.executeCommand(menuItem!.commandId!);
expect(actionCalls).toEqual(['clicked']);
});
it('adds grouped submenu entries into menu positions', () => {
const menuManagerService = univer.__getInjector().get(IMenuManagerService);
const first = univerAPI.createMenu({ id: 'first-action', title: 'First', action: 'first.command' });
const second = univerAPI.createMenu({ id: 'second-action', title: 'Second', action: 'second.command' });
univerAPI.createSubmenu({ id: 'custom-submenu', title: 'Actions' })
.addSubmenu(first)
.addSeparator()
.addSubmenu(second)
.appendTo('contextMenu.others');
const menuKeys = menuManagerService.getFlatMenuByPositionKey('contextMenu').map((item) => item.key);
expect(menuKeys).toContain('custom-submenu');
expect(menuKeys).toContain('first-action');
expect(menuKeys).toContain('second-action');
});
it('registers UI parts, toggles visibility, and exposes UI enums from the facade', () => {
const component = () => null;
const disposable = univerAPI.registerUIPart(BuiltInUIPart.CUSTOM_HEADER, component);
const uiPartsService = univer.__getInjector().get(IUIPartsService);
expect(univerAPI.Enum.BuiltInUIPart.CUSTOM_HEADER).toBe(BuiltInUIPart.CUSTOM_HEADER);
expect(univerAPI.setUIVisible(BuiltInUIPart.CUSTOM_HEADER, false)).toBe(univerAPI);
expect(univerAPI.isUIVisible(BuiltInUIPart.CUSTOM_HEADER)).toBe(false);
expect(uiPartsService.getComponents(BuiltInUIPart.CUSTOM_HEADER).size).toBe(1);
disposable.dispose();
expect(uiPartsService.getComponents(BuiltInUIPart.CUSTOM_HEADER).size).toBe(0);
});
it('changes the ribbon type without replacing the remaining UI config', () => {
const configService = univer.__getInjector().get(IConfigService);
configService.setConfig(UI_PLUGIN_CONFIG_KEY, {
ribbonType: 'classic',
toolbar: false,
});
expect(univerAPI.setRibbonType('grid')).toBe(univerAPI);
expect(configService.getConfig(UI_PLUGIN_CONFIG_KEY)).toEqual({
ribbonType: 'grid',
toolbar: false,
});
});
it('dispatches shortcut events unless the shortcut facade is disabled', () => {
const shortcutService = univer.__getInjector().get(IShortcutService);
const shortcut = univerAPI.getShortcut();
shortcutService.registerShortcut({
id: 'custom.shortcut',
binding: KeyCode.B,
});
const event = new KeyboardEvent('keydown', { key: 'b', keyCode: KeyCode.B });
expect(shortcut.disableShortcut().dispatchShortcutEvent(event)).toBeUndefined();
expect(shortcut.enableShortcut().dispatchShortcutEvent(event)?.id).toBe('custom.shortcut');
});
it('executes copy and paste commands', async () => {
const commandService = univer.__getInjector().get(ICommandService);
commandService.registerCommand({
id: CopyCommand.id,
type: CommandType.COMMAND,
handler: () => true,
});
commandService.registerCommand({
id: PasteCommand.id,
type: CommandType.COMMAND,
handler: () => true,
});
await expect(univerAPI.copy()).resolves.toBe(true);
await expect(univerAPI.paste()).resolves.toBe(true);
});
it('opens and disposes UI surfaces through facade services', () => {
const injector = univer.__getInjector();
const sidebarService = injector.get(ISidebarService);
const dialogService = injector.get(IDialogService);
const dialogs: string[][] = [];
const subscription = dialogService.getDialogs$().subscribe((items) => dialogs.push(items.map((item) => item.id)));
const sidebarDisposable = univerAPI.openSidebar({ id: 'inspector', width: 320 });
expect(sidebarService.visible).toBe(true);
expect(sidebarService.options.id).toBe('inspector');
sidebarDisposable.dispose();
expect(sidebarService.visible).toBe(false);
const dialogDisposable = univerAPI.openDialog({ id: 'confirm-dialog', title: { label: 'Confirm' } });
expect(dialogs.at(-1)).toEqual(['confirm-dialog']);
dialogDisposable.dispose();
expect(dialogs.at(-1)).toEqual([]);
expect(univerAPI.showMessage({ content: 'Saved', type: MessageType.Success, id: 'saved-message' })).toBe(univerAPI);
subscription.unsubscribe();
});
it('registers components and appends custom fonts through facade APIs', () => {
const injector = univer.__getInjector();
const componentManager = injector.get(ComponentManager);
const fontService = injector.get(IFontService);
const component = () => null;
const disposable = univerAPI.registerComponent('custom-component', component);
univerAPI.addFonts([{ value: 'CustomSans', label: 'Custom Sans', category: 'sans-serif' }]);
expect(univerAPI.getURL().href).toBe(window.location.href);
expect(univerAPI.getComponentManager()).toBe(componentManager);
expect(componentManager.get('custom-component')).toBe(component);
expect(fontService.getFontByValue('CustomSans')).toMatchObject({ label: 'Custom Sans' });
disposable.dispose();
expect(componentManager.get('custom-component')).toBeUndefined();
});
it('triggers shortcuts through the active workbook render canvas', () => {
const injector = new Injector();
injector.add([IShortcutService, { useClass: FakeShortcutService as never }]);
injector.add([IRenderManagerService, { useClass: FakeRenderManagerService as never }]);
injector.add([IUniverInstanceService, { useClass: FakeUniverInstanceService as never }]);
const shortcut = injector.createInstance(FShortcut);
const event = new KeyboardEvent('keydown', { key: 'k', keyCode: KeyCode.K });
const canvasEvents: KeyboardEvent[] = [];
FakeShortcutService.dispatched = [];
FakeUniverInstanceService.workbook = undefined;
FakeRenderManagerService.renderUnit = undefined;
expect(shortcut.triggerShortcut(event)).toBeUndefined();
FakeUniverInstanceService.workbook = { getUnitId: () => 'sheet-1' };
expect(shortcut.triggerShortcut(event)).toBeUndefined();
FakeRenderManagerService.renderUnit = {
engine: {
getCanvasElement: () => ({
dispatchEvent: (dispatchedEvent: KeyboardEvent) => canvasEvents.push(dispatchedEvent),
}),
},
};
expect(shortcut.triggerShortcut(event)?.id).toBe('fake.shortcut');
expect(canvasEvents).toEqual([event]);
expect(FakeShortcutService.dispatched).toEqual([event]);
});
});

View File

@@ -0,0 +1,53 @@
/**
* 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 { FEnum } from '@univerjs/core/facade';
import { BuiltInUIPart, KeyCode } from '@univerjs/ui';
/**
* @ignore
*/
interface IFUIEnumMixin {
/**
* Built-in UI parts.
*/
BuiltInUIPart: typeof BuiltInUIPart;
/**
* Key codes.
*/
KeyCode: typeof KeyCode;
}
/**
* @ignore
*/
export class FUIEnumMixin extends FEnum implements IFUIEnumMixin {
override get BuiltInUIPart(): typeof BuiltInUIPart {
return BuiltInUIPart;
};
override get KeyCode(): typeof KeyCode {
return KeyCode;
}
}
FEnum.extend(FUIEnumMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FEnum extends IFUIEnumMixin {}
}

View File

@@ -0,0 +1,343 @@
/**
* 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 type { IAccessor } from '@univerjs/core';
import type { IMenuButtonItem, IMenuItem, MenuSchemaType } from '@univerjs/ui';
import { CommandType, generateRandomId, ICommandService, Inject, Injector } from '@univerjs/core';
import { FBase } from '@univerjs/core/facade';
import { IMenuManagerService, MenuItemType, MenuManagerPosition, RibbonPosition, RibbonStartGroup } from '@univerjs/ui';
/**
* @ignore
*/
export interface IFacadeMenuItem {
/**
* The unique identifier of the menu item.
*/
id: string;
/**
* Icon of the menu item.
*/
icon?: string;
/**
* Title of the menu item.
*/
title: string;
/**
* The tooltip to show when the mouse hovers over the menu item.
*/
tooltip?: string;
/**
* The command to execute when the menu item is clicked. It can also be a callback function to
* execute any custom logic.
*/
action: string | (() => void);
/**
* The order of the menu item in the submenu.
*/
order?: number;
}
/**
* @ignore
*/
export interface IFacadeSubmenuItem {
/**
* The unique identifier of the menu item.
*/
id: string;
/**
* Icon of the menu item.
*/
icon?: string;
/**
* Title of the menu item.
*/
title: string;
/**
* The tooltip to show when the mouse hovers over the menu item.
*/
tooltip?: string;
/**
* The order of the menu item in the submenu.
*/
order?: number;
}
/**
* @ignore
*/
type FAllMenu = FMenu | FSubmenu;
/**
* @ignore
*/
abstract class FMenuBase extends FBase {
protected abstract readonly _menuManagerService: IMenuManagerService;
abstract __getSchema(): { [key: string]: MenuSchemaType };
/**
* Append the menu to any menu position on Univer UI.
* @param {string | string[]} path - Some predefined path to append the menu. The paths can be an array,
* or an array joined by `|` separator. Since lots of submenus reuse the same name,
* you may need to specify their parent menus as well.
*
* @example
* ```typescript
* // This menu item will appear on every `contextMenu.others` section.
* univerAPI.createMenu({
* id: 'custom-menu-id-1',
* title: 'Custom Menu 1',
* action: () => {
* console.log('Custom Menu 1 clicked');
* },
* }).appendTo('contextMenu.others');
*
* // This menu item will only appear on the `contextMenu.others` section on the main area.
* univerAPI.createMenu({
* id: 'custom-menu-id-2',
* title: 'Custom Menu 2',
* action: () => {
* console.log('Custom Menu 2 clicked');
* },
* }).appendTo(['contextMenu.mainArea', 'contextMenu.others']);
* ```
*/
appendTo(path: string | string[]): void {
const paths = typeof path === 'string' ? path.split('|') : path;
const len = paths.length;
const menuConfig: Record<string, MenuSchemaType> = {};
let obj = menuConfig;
const schema = this.__getSchema();
paths.forEach((p, index) => {
if (index === len - 1) {
obj[p] = schema;
} else {
obj[p] = {};
}
obj = obj[p] as Record<string, MenuSchemaType>;
});
this._menuManagerService.mergeMenu(menuConfig);
}
}
/**
* This is the builder for adding a menu to Univer. You shall never construct this
* class by yourself. Instead, call `createMenu` of {@link FUniver} to create a instance.
*
* Please notice that until the `appendTo` method is called, the menu item is not added to the UI.
*
* Please note that this menu cannot have submenus. If you want to
* have submenus, please use {@link FSubmenu}.
*
* @hideconstructor
*/
export class FMenu extends FMenuBase {
static RibbonStartGroup = RibbonStartGroup;
static RibbonPosition = RibbonPosition;
static MenuManagerPosition = MenuManagerPosition;
private _commandToRegister = new Map<string, (accessor: IAccessor) => void>();
private _buildingSchema: {
order?: number;
menuItemFactory?: (accessor: IAccessor) => IMenuItem;
};
constructor(
private readonly _item: IFacadeMenuItem,
@Inject(Injector) protected readonly _injector: Injector,
@ICommandService protected readonly _commandService: ICommandService,
@IMenuManagerService protected readonly _menuManagerService: IMenuManagerService
) {
super();
const commandId = typeof _item.action === 'string' ? _item.action : generateRandomId(12);
if (commandId !== _item.action) {
this._commandToRegister.set(commandId, _item.action as unknown as () => void);
}
this._buildingSchema = {
menuItemFactory: (): IMenuButtonItem => ({
id: _item.id,
type: MenuItemType.BUTTON, // we only support button for now
icon: _item.icon,
title: _item.title,
tooltip: _item.tooltip,
commandId,
}),
};
if (typeof _item.order !== 'undefined') {
this._buildingSchema.order = _item.order;
}
}
/**
* @ignore
* @returns The generated menu schema.
*/
__getSchema(): { [key: string]: MenuSchemaType } {
this._commandToRegister.forEach((command, id) => {
if (!this._commandService.hasCommand(id)) {
this._commandService.registerCommand({
id,
type: CommandType.COMMAND,
handler: command,
});
}
});
return { [this._item.id]: this._buildingSchema };
}
}
/**
* This is the builder for add a menu that can contains submenus to Univer. You shall
* never construct this class by yourself. Instead, call `createSubmenu` of {@link FUniver} to
* create a instance.
*
* Please notice that until the `appendTo` method is called, the menu item is not added to the UI.
*
* @hideconstructor
*/
export class FSubmenu extends FMenuBase {
private _menuByGroups: FAllMenu[][] = [];
private _submenus: FAllMenu[] = [];
private _buildingSchema: {
order?: number;
menuItemFactory?: (accessor: IAccessor) => IMenuItem;
};
constructor(
private readonly _item: IFacadeSubmenuItem,
@Inject(Injector) protected readonly _injector: Injector,
@IMenuManagerService protected readonly _menuManagerService: IMenuManagerService
) {
super();
this._buildingSchema = {
menuItemFactory: (): IMenuItem => ({
id: _item.id,
type: MenuItemType.SUBITEMS,
icon: _item.icon,
title: _item.title,
tooltip: _item.tooltip,
}),
};
if (typeof _item.order !== 'undefined') {
this._buildingSchema.order = _item.order;
}
}
/**
* Add a menu to the submenu. It can be a {@link FMenu} or a {@link FSubmenu}.
* @param {FMenu | FSubmenu} submenu - Menu to add to the submenu.
* @returns {FSubmenu} The FSubmenu itself for chaining calls.
* @example
* ```typescript
* // Create two leaf menus.
* const menu1 = univerAPI.createMenu({
* id: 'submenu-nested-1',
* title: 'Item 1',
* action: () => {
* console.log('Item 1 clicked');
* }
* });
* const menu2 = univerAPI.createMenu({
* id: 'submenu-nested-2',
* title: 'Item 2',
* action: () => {
* console.log('Item 2 clicked');
* }
* });
*
* // Add the leaf menus to a submenu.
* const submenu = univerAPI.createSubmenu({ id: 'submenu-nested', title: 'Nested Submenu' })
* .addSubmenu(menu1)
* .addSeparator()
* .addSubmenu(menu2);
*
* // Create a root submenu append to the `contextMenu.others` section.
* univerAPI.createSubmenu({ id: 'custom-submenu', title: 'Custom Submenu' })
* .addSubmenu(submenu)
* .appendTo('contextMenu.others');
* ```
*/
addSubmenu(submenu: FMenu | FSubmenu): this {
this._submenus.push(submenu);
return this;
}
/**
* Add a separator to the submenu.
* @returns {FSubmenu} The FSubmenu itself for chaining calls.
* @example
* ```typescript
* // Create two leaf menus.
* const menu1 = univerAPI.createMenu({
* id: 'submenu-nested-1',
* title: 'Item 1',
* action: () => {
* console.log('Item 1 clicked');
* }
* });
* const menu2 = univerAPI.createMenu({
* id: 'submenu-nested-2',
* title: 'Item 2',
* action: () => {
* console.log('Item 2 clicked');
* }
* });
*
* // Add the leaf menus to a submenu and add a separator between them.
* // Append the submenu to the `contextMenu.others` section.
* univerAPI.createSubmenu({ id: 'submenu-nested', title: 'Nested Submenu' })
* .addSubmenu(menu1)
* .addSeparator()
* .addSubmenu(menu2)
* .appendTo('contextMenu.others');
* ```
*/
addSeparator(): this {
this._menuByGroups.push(this._submenus);
this._submenus = [];
return this;
}
/**
* @ignore
* @returns The generated submenu schema.
*/
__getSchema(): { [key: string]: MenuSchemaType } {
const schema: { [key: string]: MenuSchemaType } = {};
this.addSeparator();
this._menuByGroups.forEach((group, index) => {
const groupSchema: MenuSchemaType = {};
group.forEach((menu) => {
Object.assign(groupSchema, menu.__getSchema());
});
schema[`${this._item.id}-group-${index}`] = groupSchema;
});
return { [this._item.id]: Object.assign(this._buildingSchema, schema) };
}
}

View File

@@ -0,0 +1,141 @@
/**
* 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 type { IDisposable, Workbook } from '@univerjs/core';
import type { IShortcutItem } from '@univerjs/ui';
import { Inject, Injector, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { FBase } from '@univerjs/core/facade';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IShortcutService } from '@univerjs/ui';
/**
* The Facade API object to handle shortcuts in Univer
* @hideconstructor
*/
export class FShortcut extends FBase {
private _forceDisableDisposable: IDisposable | null = null;
constructor(
@Inject(Injector) protected readonly _injector: Injector,
@Inject(IRenderManagerService) private _renderManagerService: IRenderManagerService,
@IUniverInstanceService protected readonly _univerInstanceService: IUniverInstanceService,
@IShortcutService protected readonly _shortcutService: IShortcutService
) {
super();
}
/**
* Enable shortcuts of Univer.
* @returns {FShortcut} The Facade API instance itself for chaining.
*
* @example
* ```typescript
* fShortcut.enableShortcut(); // Use the FShortcut instance used by disableShortcut before, do not create a new instance
* ```
*/
enableShortcut(): this {
this._forceDisableDisposable?.dispose();
this._forceDisableDisposable = null;
return this;
}
/**
* Disable shortcuts of Univer.
* @returns {FShortcut} The Facade API instance itself for chaining.
*
* @example
* ```typescript
* const fShortcut = univerAPI.getShortcut();
* fShortcut.disableShortcut();
* ```
*/
disableShortcut(): this {
if (!this._forceDisableDisposable) {
this._forceDisableDisposable = this._shortcutService.forceDisable();
}
return this;
}
/**
* Trigger shortcut of Univer by a KeyboardEvent and return the matched shortcut item.
* @param {KeyboardEvent} e - The KeyboardEvent to trigger.
* @returns {IShortcutItem<object> | undefined} The matched shortcut item.
*
* @example
* ```typescript
* // Assum the current sheet is empty sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1');
*
* // Set A1 cell active and set value to 'Hello Univer'.
* fRange.activate();
* fRange.setValue('Hello Univer');
* console.log(fRange.getCellStyle().bold); // false
*
* // Set A1 cell bold by shortcut.
* const fShortcut = univerAPI.getShortcut();
* const pseudoEvent = new KeyboardEvent('keydown', {
* key: 'b',
* ctrlKey: true,
* keyCode: univerAPI.Enum.KeyCode.B
* });
* const ifShortcutItem = fShortcut.triggerShortcut(pseudoEvent);
* if (ifShortcutItem) {
* const commandId = ifShortcutItem.id;
* console.log(fRange.getCellStyle().bold); // true
* }
* ```
*/
triggerShortcut(e: KeyboardEvent): IShortcutItem<object> | undefined {
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return;
}
const renderUnit = this._renderManagerService.getRenderUnitById(workbook.getUnitId());
if (!renderUnit) {
return;
}
const canvas = renderUnit.engine.getCanvasElement();
canvas.dispatchEvent(e);
return this._shortcutService.dispatch(e);
}
/**
* Dispatch a KeyboardEvent to the shortcut service and return the matched shortcut item.
* @param {KeyboardEvent} e - The KeyboardEvent to dispatch.
* @returns {IShortcutItem<object> | undefined} The matched shortcut item.
*
* @example
* ```typescript
* const fShortcut = univerAPI.getShortcut();
* const pseudoEvent = new KeyboardEvent('keydown', { key: 's', ctrlKey: true });
* const ifShortcutItem = fShortcut.dispatchShortcutEvent(pseudoEvent);
* if (ifShortcutItem) {
* const commandId = ifShortcutItem.id;
* // Do something with the commandId.
* }
* ```
*/
dispatchShortcutEvent(e: KeyboardEvent): IShortcutItem<object> | undefined {
return this._shortcutService.dispatch(e);
}
}

View File

@@ -0,0 +1,556 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import type { IMessageProps } from '@univerjs/design';
import type { BuiltInUIPart, ComponentType, IComponentOptions, IDialogPartMethodOptions, IFontConfig, ISidebarMethodOptions, RibbonType } from '@univerjs/ui';
import type { IFacadeMenuItem, IFacadeSubmenuItem } from './f-menu-builder';
import { IConfigService } from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ComponentManager, connectInjector, CopyCommand, IDialogService, IFontService, IMessageService, ISidebarService, IUIPartsService, PasteCommand, UI_PLUGIN_CONFIG_KEY } from '@univerjs/ui';
import { FMenu, FSubmenu } from './f-menu-builder';
import { FShortcut } from './f-shortcut';
/**
* @ignore
*/
export interface IFUniverUIMixin {
/**
* Return the URL of the current page.
* @returns {URL} the [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) object
* @example
* ```ts
* console.log(univerAPI.getURL());
* ```
*/
getURL(): URL;
/**
* Get the Shortcut handler to interact with Univer's shortcut functionalities.
* @returns the {@link FShortcut} object
* @example
* ```ts
* const fShortcut = univerAPI.getShortcut();
*
* // Disable shortcuts of Univer
* fShortcut.disableShortcut();
*
* // Enable shortcuts of Univer
* fShortcut.enableShortcut();
*
* // Trigger a shortcut
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1');
* fRange.activate();
* fRange.setValue('Hello Univer');
* console.log(fRange.getCellStyle().bold); // false
* const pseudoEvent = new KeyboardEvent('keydown', {
* key: 'b',
* ctrlKey: true,
* keyCode: univerAPI.Enum.KeyCode.B
* });
* const ifShortcutItem = fShortcut.triggerShortcut(pseudoEvent);
* if (ifShortcutItem) {
* const commandId = ifShortcutItem.id;
* console.log(fRange.getCellStyle().bold); // true
* }
* ```
*/
getShortcut(): FShortcut;
/**
* Copy the current selected content of the currently focused unit into your system clipboard.
* @returns {Promise<boolean>} whether the copy operation is successful
* @example
* ```ts
* // Prevent failure due to loss of focus when executing copy and paste code in the console,
* // this example listens for the cell click event and executes the copy and paste code.
* univerAPI.addEvent(univerAPI.Event.CellClicked, async (params) => {
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Copy the range A1:B2 to the clipboard
* const fRange = fWorksheet.getRange('A1:B2');
* fRange.activate().setValues([
* [1, 2],
* [3, 4]
* ]);
* await univerAPI.copy();
*
* // Paste the copied content to the range C1:D2
* const fRange2 = fWorksheet.getRange('C1');
* fRange2.activate();
* await univerAPI.paste();
*
* // Check the pasted content
* console.log(fWorksheet.getRange('C1:D2').getValues()); // [[1, 2], [3, 4]]
* });
* ```
*/
copy(): Promise<boolean>;
/**
* Paste into the current selected position of the currently focused unit from your system clipboard.
* @returns {Promise<boolean>} whether the paste operation is successful
* @example
* ```ts
* // Prevent failure due to loss of focus when executing copy and paste code in the console,
* // this example listens for the cell click event and executes the copy and paste code.
* univerAPI.addEvent(univerAPI.Event.CellClicked, async (params) => {
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Copy the range A1:B2 to the clipboard
* const fRange = fWorksheet.getRange('A1:B2');
* fRange.activate().setValues([
* [1, 2],
* [3, 4]
* ]);
* await univerAPI.copy();
*
* // Paste the copied content to the range C1:D2
* const fRange2 = fWorksheet.getRange('C1');
* fRange2.activate();
* await univerAPI.paste();
*
* // Check the pasted content
* console.log(fWorksheet.getRange('C1:D2').getValues()); // [[1, 2], [3, 4]]
* });
* ```
*/
paste(): Promise<boolean>;
/**
* Create a menu build object. You can insert new menus into the UI.
* @param {IFacadeMenuItem} menuItem the menu item
* @returns the {@link FMenu} object
* @example
* ```tsx
* // Univer Icon can be viewed at https://docs.univer.ai/icons
* import { SmileIcon } from '@univerjs/icons'
*
* // Create a custom menu with an univer icon
* univerAPI.registerComponent('custom-menu-icon', SmileIcon);
* univerAPI.createMenu({
* id: 'custom-menu',
* icon: 'custom-menu-icon',
* title: 'Custom Menu',
* tooltip: 'Custom Menu Tooltip',
* action: () => {
* console.log('Custom Menu Clicked');
* },
* }).appendTo('ribbon.start.others');
*
* // Or
* // Create a custom menu with an image icon
* univerAPI.registerComponent('custom-menu-icon', () => {
* return <img src="https://avatars.githubusercontent.com/u/61444807?s=48&v=4" alt="" style={{ width: '16px', height: '16px' }} />;
* });
* univerAPI.createMenu({
* id: 'custom-menu',
* icon: 'custom-menu-icon',
* title: 'Custom Menu',
* tooltip: 'Custom Menu Tooltip',
* action: () => {
* console.log('Custom Menu Clicked');
* },
* }).appendTo('ribbon.start.others');
*
* // Or
* // Create a custom menu without an icon
* univerAPI.createMenu({
* id: 'custom-menu',
* title: 'Custom Menu',
* tooltip: 'Custom Menu Tooltip',
* action: () => {
* console.log('Custom Menu Clicked');
* },
* }).appendTo('ribbon.start.others');
* ```
*/
createMenu(menuItem: IFacadeMenuItem): FMenu;
/**
* Create a menu that contains submenus, and later you can append this menu and its submenus to the UI.
* @param {IFacadeSubmenuItem} submenuItem the submenu item
* @returns the {@link FSubmenu} object
* @example
* ```ts
* // Create two leaf menus.
* const menu1 = univerAPI.createMenu({
* id: 'submenu-nested-1',
* title: 'Item 1',
* action: () => {
* console.log('Item 1 clicked');
* }
* });
* const menu2 = univerAPI.createMenu({
* id: 'submenu-nested-2',
* title: 'Item 2',
* action: () => {
* console.log('Item 2 clicked');
* }
* });
*
* // Add the leaf menus to a submenu.
* const submenu = univerAPI.createSubmenu({ id: 'submenu-nested', title: 'Nested Submenu' })
* .addSubmenu(menu1)
* .addSeparator()
* .addSubmenu(menu2);
*
* // Create a root submenu append to the `contextMenu.others` section.
* univerAPI.createSubmenu({ id: 'custom-submenu', title: 'Custom Submenu' })
* .addSubmenu(submenu)
* .appendTo('contextMenu.others');
* ```
*/
createSubmenu(submenuItem: IFacadeSubmenuItem): FSubmenu;
/**
* Open a sidebar.
* @param {ISidebarMethodOptions} params the sidebar options
* @returns {IDisposable} the disposable object
* @example
* ```ts
* univerAPI.openSidebar({
* id: 'mock-sidebar-id',
* width: 300,
* header: {
* label: 'Sidebar Header',
* },
* children: {
* label: 'Sidebar Content',
* },
* footer: {
* label: 'Sidebar Footer',
* },
* onClose: () => {
* console.log('Sidebar closed')
* },
* });
* ```
*/
openSidebar(params: ISidebarMethodOptions): IDisposable;
/**
* Open a dialog.
* @param {IDialogPartMethodOptions} dialog the dialog options
* @returns {IDisposable} the disposable object
* @example
* ```tsx
* import { Button } from '@univerjs/design';
*
* univerAPI.openDialog({
* id: 'mock-dialog-id',
* width: 500,
* title: {
* label: 'Dialog Title',
* },
* children: {
* label: 'Dialog Content',
* },
* footer: {
* title: (
* <>
* <Button onClick={() => { console.log('Cancel clicked') }}>Cancel</Button>
* <Button variant="primary" onClick={() => { console.log('Confirm clicked') }} style={{marginLeft: '10px'}}>Confirm</Button>
* </>
* )
* },
* draggable: true,
* mask: true,
* maskClosable: true,
* });
* ```
*/
openDialog(dialog: IDialogPartMethodOptions): IDisposable;
/**
* Get the component manager
* @returns {ComponentManager} The component manager
* @example
* ```ts
* const componentManager = univerAPI.getComponentManager();
* console.log(componentManager);
* ```
*/
getComponentManager(): ComponentManager;
/**
* Show a message.
* @returns {FUniver} the {@link FUniver} instance for chaining
* @param {IMessageProps} options Message content, type, duration, and other display options.
* @example
* ```ts
* univerAPI.showMessage({
* content: 'Success',
* type: 'success',
* duration: 3000,
* });
* ```
*/
showMessage(options: IMessageProps): FUniver;
/**
* Set the ribbon layout type.
* @param {RibbonType} ribbonType The ribbon layout type.
* @returns the {@link FUniver} instance for chaining
* @example
* ```ts
* univerAPI.setRibbonType('grid');
* ```
*/
setRibbonType(ribbonType: RibbonType): FUniver;
/**
* Set the visibility of a built-in UI part.
* @param {BuiltInUIPart} key the built-in UI part
* @param {boolean} visible the visibility
* @returns the {@link FUniver} instance for chaining
* @example
* ```ts
* // Hide header, footer, and toolbar
* univerAPI.setUIVisible(univerAPI.Enum.BuiltInUIPart.HEADER, false)
* .setUIVisible(univerAPI.Enum.BuiltInUIPart.FOOTER, false)
* .setUIVisible(univerAPI.Enum.BuiltInUIPart.TOOLBAR, false);
*
* // Show in 3 seconds
* setTimeout(() => {
* univerAPI.setUIVisible(univerAPI.Enum.BuiltInUIPart.HEADER, true)
* .setUIVisible(univerAPI.Enum.BuiltInUIPart.FOOTER, true)
* .setUIVisible(univerAPI.Enum.BuiltInUIPart.TOOLBAR, true);
* }, 3000);
* ```
*/
setUIVisible(key: BuiltInUIPart, visible: boolean): FUniver;
/**
* Get the visibility of a built-in UI part.
* @param {BuiltInUIPart} key the built-in UI part
* @returns {boolean} the visibility
* @example
* ```ts
* // Hide header
* univerAPI.setUIVisible(univerAPI.Enum.BuiltInUIPart.HEADER, false);
* console.log(univerAPI.isUIVisible(univerAPI.Enum.BuiltInUIPart.HEADER)); // false
* ```
*/
isUIVisible(key: BuiltInUIPart): boolean;
/**
* Register an component to a built-in UI part
* @param {BuiltInUIPart} key the built-in UI part
* @param component the react component
* @example
* ```ts
* univerAPI.registerUIPart(univerAPI.Enum.BuiltInUIPart.CUSTOM_HEADER, () => React.createElement('h1', null, 'Custom Header'));
* ```
*/
registerUIPart(key: BuiltInUIPart, component: ComponentType): IDisposable;
/**
* Register an component.
* @param {string} name - The name of the component.
* @param {ComponentType} component - The component.
* @param {IComponentOptions} [options] - The options of the component.
* @returns {IDisposable} The disposable object.
* @example
* ```tsx
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Register a range loading component
* const RangeLoading = () => {
* const divStyle = {
* width: '100%',
* height: '100%',
* backgroundColor: '#fff',
* border: '1px solid #ccc',
* boxSizing: 'border-box' as const,
* display: 'flex',
* justifyContent: 'center',
* alignItems: 'center',
* textAlign: 'center' as const,
* transformOrigin: 'top left',
* };
*
* return (
* <div style={divStyle}>
* Loading...
* </div>
* );
* };
* univerAPI.registerComponent('RangeLoading', RangeLoading);
*
* // Add the range loading component covering the range A1:C3
* const range = fWorksheet.getRange('A1:C3');
* const disposeable = fWorksheet.addFloatDomToRange(range, { componentKey: 'RangeLoading' }, {}, 'myRangeLoading');
*
* setTimeout(() => {
* disposeable?.dispose();
* }, 2000);
* ```
*/
registerComponent(name: string, component: ComponentType, options?: IComponentOptions): IDisposable;
/**
* Set a unit as the current unit and render a unit in the workbench's main area. If you have multiple units in Univer,
* you should call this method to render the unit.
* @param {string} unitId Unit to be rendered.
*
* @example
* Let's assume you have created two units, `unit1` and `unit2`. Univer is rendering `unit1` and you want to
* render `unit2`.
*
* ```ts
* univerAPI.setCurrent('unit2');
* ```
*
* This will render `unit2` in the workbench's main area.
*/
setCurrent(unitId: string): void;
/**
* Append custom fonts to the font list.
* @param fonts The array of font configurations to add.
* @example
* ```ts
* univerAPI.addFonts([
* {
* value: 'CustomFont1',
* label: 'Custom Font 1',
* category: 'sans-serif',
* },
* {
* value: 'CustomFont2',
* label: 'Custom Font 2',
* category: 'serif',
* },
* ]);
* ```
*/
addFonts(fonts: IFontConfig[]): void;
}
/**
* @ignore
*/
export class FUniverUIMixin extends FUniver implements IFUniverUIMixin {
override getURL(): URL {
return new URL(window.location.href);
}
override getShortcut(): FShortcut {
return this._injector.createInstance(FShortcut);
}
override copy(): Promise<boolean> {
return this._commandService.executeCommand(CopyCommand.id);
}
override paste(): Promise<boolean> {
return this._commandService.executeCommand(PasteCommand.id);
}
override createMenu(menuItem: IFacadeMenuItem): FMenu {
return this._injector.createInstance(FMenu, menuItem);
}
override createSubmenu(submenuItem: IFacadeSubmenuItem): FSubmenu {
return this._injector.createInstance(FSubmenu, submenuItem);
}
override openSidebar(params: ISidebarMethodOptions): IDisposable {
const sideBarService = this._injector.get(ISidebarService);
return sideBarService.open(params);
}
override openDialog(dialog: IDialogPartMethodOptions): IDisposable {
const dialogService = this._injector.get(IDialogService);
const disposable = dialogService.open({
...dialog,
onClose: () => {
disposable.dispose();
},
});
return disposable;
}
override getComponentManager(): ComponentManager {
return this._injector.get(ComponentManager);
}
override showMessage(options: IMessageProps): FUniver {
const messageService = this._injector.get(IMessageService);
messageService.show(options);
return this;
}
override setRibbonType(ribbonType: RibbonType): FUniver {
const configService = this._injector.get(IConfigService);
configService.setConfig(UI_PLUGIN_CONFIG_KEY, { ribbonType }, { merge: true });
return this;
}
override setUIVisible(ui: BuiltInUIPart, visible: boolean): FUniver {
const uiPartService = this._injector.get(IUIPartsService);
uiPartService.setUIVisible(ui, visible);
return this;
}
override isUIVisible(ui: BuiltInUIPart): boolean {
const uiPartService = this._injector.get(IUIPartsService);
return uiPartService.isUIVisible(ui);
}
override registerUIPart(key: BuiltInUIPart, component: ComponentType): IDisposable {
const uiPartService = this._injector.get(IUIPartsService);
return uiPartService.registerComponent(key, () => connectInjector(component, this._injector));
}
override registerComponent(name: string, component: ComponentType, options?: IComponentOptions): IDisposable {
const componentManager = this._injector.get(ComponentManager);
return this.disposeWithMe(componentManager.register(name, component, options));
}
override setCurrent(unitId: string): void {
const rendererManagerService = this._injector.get(IRenderManagerService);
const renderUnit = rendererManagerService.getRenderUnitById(unitId);
if (!renderUnit) {
throw new Error('Unit not found');
}
this._univerInstanceService.setCurrentUnitForType(unitId);
}
override addFonts(fonts: IFontConfig[]): void {
const fontService = this._injector.get(IFontService);
fonts.forEach((font) => {
fontService.addFont(font);
});
}
}
FUniver.extend(FUniverUIMixin);
declare module '@univerjs/core/facade' {
interface FUniver extends IFUniverUIMixin { }
}

View File

@@ -0,0 +1,22 @@
/**
* 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 './f-univer';
import './f-menu-builder';
import './f-enum';
export type * from './f-menu-builder';
export type * from './f-univer';

View File

@@ -0,0 +1,6 @@
@tailwind components;
@tailwind utilities;
.univer-context-menu-hover-suppressed button:hover:not(:focus) {
background-color: transparent !important;
}

205
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,205 @@
/**
* 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 './global.css';
export { ToggleFullscreenOperation } from './commands/operations/toggle-fullscreen.operation';
export { ToggleShortcutPanelOperation } from './commands/operations/toggle-shortcut-panel.operation';
export * from './common';
export { getHeaderFooterMenuHiddenObservable, getMenuHiddenObservable } from './common/menu-hidden-observable';
export { mergeMenuConfigs } from './common/menu-merge-configs';
export { objectPermissionMenuItemFactory } from './common/object-permission-menu';
export { UI_PLUGIN_CONFIG_KEY } from './config/config';
export type { IUniverUIConfig } from './config/config';
export { MOBILE_UI_MODE, UNI_DISABLE_CHANGING_FOCUS_KEY } from './const';
export { ErrorController } from './controllers/error/error.controller';
export {
CopyShortcutItem,
CutShortcutItem,
RedoShortcutItem,
SharedController,
UndoShortcutItem,
} from './controllers/shared-shortcut.controller';
export { ShortcutPanelController } from './controllers/shortcut-display/shortcut-panel.controller';
export { DesktopUIController } from './controllers/ui/ui-desktop.controller';
export { SingleUnitUIController } from './controllers/ui/ui-shared.controller';
export { IUIController } from './controllers/ui/ui.controller';
export type { IWorkbenchOptions, RibbonType } from './controllers/ui/ui.controller';
export { menuSchema as UIMenuSchema } from './menu/schema';
export { UniverMobileUIPlugin } from './mobile-plugin';
export { DISABLE_AUTO_FOCUS_KEY, UniverUIPlugin } from './plugin';
export { DesktopBeforeCloseService, IBeforeCloseService } from './services/before-close/before-close.service';
export {
BrowserClipboardService,
FILE__BMP_CLIPBOARD_MIME_TYPE,
FILE__JPEG_CLIPBOARD_MIME_TYPE,
FILE__WEBP_CLIPBOARD_MIME_TYPE,
FILE_PNG_CLIPBOARD_MIME_TYPE,
FILE_SVG_XML_CLIPBOARD_MIME_TYPE,
HTML_CLIPBOARD_MIME_TYPE,
IClipboardInterfaceService,
imageMimeTypeSet,
PLAIN_TEXT_CLIPBOARD_MIME_TYPE,
} from './services/clipboard/clipboard-interface.service';
export { supportClipboardAPI } from './services/clipboard/clipboard-utils';
export {
CopyCommand,
CutCommand,
PasteCommand,
SheetPasteShortKeyCommandName,
} from './services/clipboard/clipboard.command';
export { DesktopConfirmService } from './services/confirm/desktop-confirm.service';
export { ContextMenuHostService, IContextMenuHostService } from './services/contextmenu/contextmenu-host.service';
export { ContextMenuService, IContextMenuService } from './services/contextmenu/contextmenu.service';
export type { IContextMenuHandler } from './services/contextmenu/contextmenu.service';
export { DesktopDialogService } from './services/dialog/desktop-dialog.service';
export { IDialogService } from './services/dialog/dialog.service';
export { MobileDialogService } from './services/dialog/mobile-dialog.service';
export { CanvasFloatDomPreviewService, CanvasFloatDomService } from './services/dom/canvas-dom-layer.service';
export type {
ICanvasFloatDomPreview,
ICanvasFloatDomPreviewRequest,
IFloatDom,
IFloatDomLayout,
} from './services/dom/canvas-dom-layer.service';
export { FontService, IFontService } from './services/font.service';
export type { IFontConfig } from './services/font.service';
export { DesktopGalleryService } from './services/gallery/desktop-gallery.service';
export { IGalleryService } from './services/gallery/gallery.service';
export { DesktopLayoutService, ILayoutService } from './services/layout/layout.service';
export { DesktopLocalFileService } from './services/local-file/desktop-local-file.service';
export { ILocalFileService } from './services/local-file/local-file.service';
export type { IOpenFileOptions } from './services/local-file/local-file.service';
export { DesktopLocalStorageService } from './services/local-storage/local-storage.service';
export { MenuItemType } from './services/menu/menu';
export type {
ICustomComponentProps,
IDisplayMenuItem,
IMenuButtonItem,
IMenuItem,
IMenuItemFactory,
IMenuSelectorItem,
IValueOption,
MenuConfig,
MenuItemConfig,
MenuItemDefaultValueType,
} from './services/menu/menu';
export { IMenuManagerService, MenuManagerService } from './services/menu/menu-manager.service';
export type { IMenuSchema, IRibbonGridLayout, MenuSchemaType } from './services/menu/menu-manager.service';
export {
ContextMenuGroup,
ContextMenuPosition,
FloatingObjectToolbarPosition,
MenuManagerPosition,
RibbonDataGroup,
RibbonFormulasGroup,
RibbonInsertGroup,
RibbonOthersGroup,
RibbonPosition,
RibbonStartGroup,
RibbonViewGroup,
} from './services/menu/types';
export { MockMessageService } from './services/message/__tests__/mock-message.service';
export { DesktopMessageService } from './services/message/desktop-message.service';
export { IMessageService } from './services/message/message.service';
export { DesktopNotificationService } from './services/notification/desktop-notification.service';
export { INotificationService } from './services/notification/notification.service';
export { BuiltInUIPart, IUIPartsService, UIPartsService } from './services/parts/parts.service';
export { IPlatformService, PlatformService } from './services/platform/platform.service';
export { CanvasPopupService, ICanvasPopupService } from './services/popup/canvas-popup.service';
export type { IPopup, IPopupWithExtraProps } from './services/popup/canvas-popup.service';
export type {
ILocalUnitPresenceState,
IRemoteUnitPresenceState,
IUnitPresencePoint,
IUnitPresenceUIAdapter,
} from './services/presence/unit-presence-ui-adapter.service';
export {
IUnitPresenceUIAdapterRegistry,
UnitPresenceUIAdapterRegistry,
} from './services/presence/unit-presence-ui-adapter.service';
export { IRibbonOverrideService, RibbonOverrideService } from './services/ribbon/ribbon-override.service';
export type { IRibbonOverride } from './services/ribbon/ribbon-override.service';
export { DesktopRibbonService, IRibbonService } from './services/ribbon/ribbon.service';
export { IUIRuntimeScopeService, UIRuntimeScopeService } from './services/runtime-scope/ui-runtime-scope.service';
export type { IUIRuntimeScope } from './services/runtime-scope/ui-runtime-scope.service';
export { KeyCode, MetaKeys } from './services/shortcut/keycode';
export { ShortcutPanelService } from './services/shortcut/shortcut-panel.service';
export {
IShortcutService,
NativeTextEditorShortcutBehavior,
ShortcutService,
} from './services/shortcut/shortcut.service';
export type { IShortcutItem } from './services/shortcut/shortcut.service';
export { DesktopSidebarService } from './services/sidebar/desktop-sidebar.service';
export { useSidebarClick } from './services/sidebar/hooks/use-sidebar-click';
export { ILeftSidebarService, ISidebarService } from './services/sidebar/sidebar.service';
export { ThemeSwitcherService } from './services/theme-switcher/theme-switcher.service';
export { UndoRedoGroupService } from './services/undo-redo/undo-redo-group.service';
export { IWorkbenchService, WorkbenchService } from './services/workbench/workbench.service';
export * from './utils';
export { MobileCanvasGesture } from './utils/mobile-canvas-gesture';
export { COLOR_PICKER_COMPONENT } from './views/color-picker/interface';
export { ComponentContainer, useComponentsOfPart } from './views/components/ComponentContainer';
export type { IComponentContainerProps } from './views/components/ComponentContainer';
export type { IConfirmChildrenProps } from './views/components/confirm-part/interface';
export { type IConfirmPartMethodOptions } from './views/components/confirm-part/interface';
export { AnchoredContextMenu } from './views/components/context-menu/AnchoredContextMenu';
export type { IContextMenuAnchorRect } from './views/components/context-menu/AnchoredContextMenu';
export { DesktopContextMenu as ContextMenu } from './views/components/context-menu/ContextMenu';
export { ContextMenuPanel } from './views/components/context-menu/ContextMenuPanel';
export { MobileContextMenu } from './views/components/context-menu/MobileContextMenu';
export { type IDialogPartMethodOptions } from './views/components/dialog-part/interface';
export type { IMobileDialogPartMethodOptions } from './views/components/dialog-part/MobileDialogPart';
export { FloatDomSingle } from './views/components/dom/FloatDom';
export { FloatDom } from './views/components/dom/FloatDom';
export { PrintFloatDomSingle } from './views/components/dom/Print';
export { HOVER_TRACK_HOST_CLASS_NAME, HoverTrack } from './views/components/hover-track/HoverTrack';
export { MobileDrawer } from './views/components/mobile-drawer/MobileDrawer';
export type { MobileDrawerSnap } from './views/components/mobile-drawer/MobileDrawer';
export { MobileZoomIndicator } from './views/components/mobile-zoom-indicator/mobile-zoom-indicator';
export { CanvasPopup, SingleCanvasPopup } from './views/components/popup/CanvasPopup';
export { RectPopup } from './views/components/popup/RectPopup';
export type { RectPopupDirection } from './views/components/popup/RectPopup';
export { useToolbarItemStatus } from './views/components/ribbon/hook';
export { Ribbon } from './views/components/ribbon/Ribbon';
export { ToolbarButton } from './views/components/ribbon/ToolbarButton';
export { ToolbarItem } from './views/components/ribbon/ToolbarItem';
export type { IMobileSidebarMethodOptions } from './views/components/sidebar/MobileSidebar';
export { Sidebar } from './views/components/sidebar/Sidebar';
export type { ISidebarMethodOptions } from './views/components/sidebar/Sidebar';
export { FONT_FAMILY_COMPONENT, FontFamily } from './views/font-family/FontFamily';
export type { IFontFamilyProps } from './views/font-family/FontFamily';
export { FontFamilyDropdown } from './views/font-family/FontFamilyDropdown';
export type { IFontFamilyDropdownProps } from './views/font-family/FontFamilyDropdown';
export { FONT_FAMILY_ITEM_COMPONENT, FontFamilyItem } from './views/font-family/FontFamilyItem';
export type { IFontFamilyItemProps } from './views/font-family/FontFamilyItem';
export { MobileFontFamilyDropdown } from './views/font-family/MobileFontFamilyDropdown';
export { FontSize } from './views/font-size/FontSize';
export { FONT_SIZE_COMPONENT, FONT_SIZE_LIST, HEADING_LIST } from './views/font-size/interface';
export * from './views/hooks/index';
export { preventBrowserZoomInContainers } from './views/hooks/prevent-browser-zoom';
export * from './views/index';
export { MobileMenu } from './views/menu/mobile/MobileMenu';
export { MobileKeyboardInsetContext } from './views/mobile-workbench/mobile-keyboard-inset-context';
export { getMobileCanvasPanDelta, useMobileCanvasPanel, useMobileCanvasViewport } from './views/mobile-workbench/MobileCanvasLayout';
export type { MobilePanelLayout } from './views/mobile-workbench/MobileCanvasLayout';
export type { INotificationOptions } from './views/notification/Notification';
export { ObjectPermissionButton, openObjectPermissionDialog } from './views/object-permission/ObjectPermissionButton';
export type { IObjectPermissionButtonProps } from './views/object-permission/ObjectPermissionButton';
export { ObjectPermissionPanelButton, openObjectPermissionPanel } from './views/object-permission/ObjectPermissionPanel';
export { ProgressBar } from './views/progress-bar/ProgressBar';

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/ar-SA.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'القائمة',
zoom: 'التكبير والتصغير',
zoomIn: 'تكبير',
zoomOut: 'تصغير',
resetZoom: 'إعادة ضبط التكبير',
},
objectPermission: {
operationDenied: 'هذا المحتوى محمي. لا يُسمح بهذا الإجراء.',
remove: 'إزالة الحماية',
roleOwner: 'مالك الملف',
roleEditor: 'محرر الملف',
selectedCount: 'المحددون: {0}',
searchPeople: 'البحث عن أشخاص',
noMatchingPeople: 'لا يوجد أشخاص مطابقون',
loadMore: 'تحميل المزيد',
fileHint: 'تحدد مشاركة الملف العضوية. تقيد هذه الإعدادات إجراءات هؤلاء الأعضاء.',
documentParent: 'تنطبق قيود تحرير المستند أيضًا على هذا القسم.',
paragraphParent: 'تنطبق قيود تحرير المستند والقسم الذي يحتوي على هذه الفقرة أيضًا عليها.',
documentObjectParent: 'قد تقيد أيضًا إعدادات المستند والأقسام والفقرات التي تحتوي على هذا الكائن أو تثبته عملية التحرير.',
slideParent: 'تنطبق أيضًا قيود تحرير العرض التقديمي.',
slideObjectParent: 'تنطبق أيضًا قيود تحرير العرض التقديمي والشريحة أو الشريحة الرئيسية التي تحتوي على هذا الكائن.',
baseParent: 'تنطبق أيضًا قيود تحرير Base.',
baseObjectParent: 'تنطبق أيضًا قيود تحرير Base والجدول الذي يحتوي على هذا الكائن.',
recordParent: 'تظل قيود Base والجدول سارية. يتطلب تعديل قيمة أيضًا إذنًا لحقلها.',
boardParent: 'تنطبق قيود تحرير اللوحة أيضًا على هذا الكائن.',
ownerInherit: 'مالك الملف، وصول موروث',
peopleError: 'تعذر تحميل الأشخاص. يرجى المحاولة مرة أخرى.',
document: 'المستند',
section: 'القسم',
paragraph: 'الفقرة',
entity: 'الكائن',
presentation: 'العرض التقديمي',
page: 'الشريحة',
master: 'عرض الشريحة الرئيسية',
base: 'Base',
table: 'الجدول',
field: 'الحقل',
record: 'السجل',
view: 'طريقة العرض',
board: 'اللوحة',
objectName: '{0}: {1}',
search: 'البحث عن كائنات',
empty: 'لا توجد كائنات مطابقة',
more: 'يتم عرض أول 100 كائن. استخدم البحث لتضييق القائمة.',
title: 'الأذونات',
cancel: 'إلغاء',
save: 'حفظ',
saving: 'جارٍ الحفظ…',
loading: 'جارٍ التحميل…',
conflict: 'تم تغيير الأذونات. أعد التحميل قبل الحفظ.',
error: 'تعذر تحميل الأذونات أو حفظها. تم الاحتفاظ بتغييراتك.',
reload: 'إعادة التحميل',
denied: 'لا يمكنك إدارة أذونات هذا الكائن.',
edit: 'من يمكنه التحرير',
all: 'جميع محرري الملف',
owner: 'مالك الكائن فقط',
members: 'أعضاء محددون',
copy: 'السماح للمحررين بالنسخ',
print: 'السماح للمحررين بالطباعة',
export: 'السماح للمحررين بالتصدير',
comment: 'السماح للمحررين بالتعليق',
parentHint: 'تظل قيود الملف والكائن الأصل سارية.',
},
featureSearch: {
title: 'بحث عن الميزات',
placeholder: 'اكتب اسم ميزة أو قائمة...',
empty: 'لم يتم العثور على ميزات متاحة',
ribbon: 'الشريط',
contextMenu: 'قائمة السياق',
},
emojiPicker: {
search: 'بحث',
random: 'رمز تعبيري عشوائي',
recents: 'الأخيرة',
emojis: 'الرموز التعبيرية',
animals: 'الحيوانات',
food: 'الطعام',
activities: 'الأنشطة',
places: 'الأماكن',
objects: 'العناصر',
symbols: 'الرموز',
searchResults: 'نتائج البحث',
noResults: 'لم يتم العثور على رمز تعبيري',
...emojiLocale,
},
symbolPicker: {
mathematics: 'الرياضيات',
greek: 'اليونانية',
common: 'شائع',
},
toolbar: {
heading: {
normal: 'عادي',
title: 'عنوان',
subTitle: 'عنوان فرعي',
1: 'عنوان 1',
2: 'عنوان 2',
3: 'عنوان 3',
4: 'عنوان 4',
5: 'عنوان 5',
},
},
ribbon: {
start: 'ابدأ',
startDesc: 'تهيئة ورقة العمل وتعيين المعاملات الأساسية.',
insert: 'إدراج',
insertDesc: 'إدراج صفوف وأعمدة ومخططات وعناصر أخرى متنوعة.',
formulas: 'صيغ',
formulasDesc: 'استخدم الدوال والصيغ لحسابات البيانات.',
data: 'بيانات',
dataDesc: 'إدارة البيانات، بما في ذلك الاستيراد والفرز والتصفية.',
view: 'عرض',
viewDesc: 'تبديل أوضاع العرض وضبط تأثير العرض.',
others: 'أخرى',
othersDesc: 'الوظائف والإعدادات الأخرى.',
more: 'المزيد',
},
fontFamily: {
'not-supported': 'لم يُعثر على هذا الخط في النظام، وسيتم استخدام الخط الافتراضي.',
},
'shortcut-panel': {
title: 'اختصارات',
},
shortcut: {
undo: 'تراجع',
redo: 'إعادة',
cut: 'قص',
copy: 'نسخ',
paste: 'لصق',
'shortcut-panel': 'تبديل لوحة الاختصارات',
},
'common-edit': 'اختصارات التحرير الشائعة',
'toggle-shortcut-panel': 'تبديل لوحة الاختصارات',
navigation: {
back: 'رجوع',
previous: 'السابق',
next: 'التالي',
},
sidebar: {
panel: 'لوحة جانبية',
resize: 'تغيير حجم الشريط الجانبي',
close: 'إغلاق الشريط الجانبي',
},
beforeClose: {
title: 'لم يتم حفظ بعض التغييرات',
},
clipboard: {
authentication: {
title: 'تم رفض الإذن',
content: 'يرجى السماح لـ Univer بالوصول إلى الحافظة.',
},
},
rangeSelector: {
cancel: 'إلغاء',
},
'global-shortcut': 'اختصار عام',
row: 'صف',
column: 'عمود',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/ca-ES.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'Menú',
zoom: 'Zoom',
zoomIn: 'Apropa',
zoomOut: 'Allunya',
resetZoom: 'Restableix el zoom',
},
objectPermission: {
operationDenied: 'Aquest contingut està protegit. No es pot fer aquesta acció.',
remove: 'Elimina la protecció',
roleOwner: 'Propietari del fitxer',
roleEditor: 'Editor del fitxer',
selectedCount: 'Seleccionats: {0}',
searchPeople: 'Cerca persones',
noMatchingPeople: 'No hi ha persones coincidents',
loadMore: 'Carrega’n més',
fileHint: 'La compartició del fitxer determina els membres. Aquests paràmetres limiten les accions d’aquests membres.',
documentParent: 'Les restriccions d’edició del document també s’apliquen a aquesta secció.',
paragraphParent: 'Les restriccions d’edició del document i de la secció que conté aquest paràgraf també s’hi apliquen.',
documentObjectParent: 'El document i les seccions i paràgrafs que contenen o ancoren aquest objecte també poden restringir-ne l’edició.',
slideParent: 'Les restriccions d’edició de la presentació també s’apliquen.',
slideObjectParent: 'Les restriccions d’edició de la presentació i de la diapositiva o patró que conté aquest objecte també s’apliquen.',
baseParent: 'Les restriccions d’edició de Base també s’apliquen.',
baseObjectParent: 'Les restriccions d’edició de Base i de la taula que conté aquest objecte també s’apliquen.',
recordParent: 'Les restriccions de Base i de la taula continuen vigents. Editar un valor també requereix permís per al seu camp.',
boardParent: 'Les restriccions d’edició de la pissarra també s’apliquen a aquest objecte.',
ownerInherit: 'Propietari del fitxer, accés heretat',
peopleError: 'No s’han pogut carregar les persones. Torna-ho a provar.',
document: 'Document',
section: 'Secció',
paragraph: 'Paràgraf',
entity: 'Objecte',
presentation: 'Presentació',
page: 'Diapositiva',
master: 'Vista del patró',
base: 'Base',
table: 'Taula',
field: 'Camp',
record: 'Registre',
view: 'Vista',
board: 'Pissarra',
objectName: '{0}: {1}',
search: 'Cerca objectes',
empty: 'No hi ha objectes coincidents',
more: 'Es mostren els primers 100 objectes. Fes servir la cerca per reduir la llista.',
title: 'Permisos',
cancel: 'Cancel·la',
save: 'Desa',
saving: 'S’està desant…',
loading: 'S’està carregant…',
conflict: 'Els permisos han canviat. Torna a carregar abans de desar.',
error: 'No s’han pogut carregar o desar els permisos. S’han conservat els canvis.',
reload: 'Torna a carregar',
denied: 'No pots gestionar els permisos d’aquest objecte.',
edit: 'Qui pot editar',
all: 'Tots els editors del fitxer',
owner: 'Només el propietari de l’objecte',
members: 'Membres seleccionats',
copy: 'Permet que els editors copiïn',
print: 'Permet que els editors imprimeixin',
export: 'Permet que els editors exportin',
comment: 'Permet que els editors comentin',
parentHint: 'Les restriccions del fitxer i de l’objecte superior continuen vigents.',
},
featureSearch: {
title: 'Cerca funcions',
placeholder: 'Escriviu una funció o un nom de menú...',
empty: 'No s\'han trobat funcions disponibles',
ribbon: 'Cinta',
contextMenu: 'Menú contextual',
},
emojiPicker: {
search: 'Cerca',
random: 'Emoji aleatori',
recents: 'Recents',
emojis: 'Emojis',
animals: 'Animals',
food: 'Menjar',
activities: 'Activitats',
places: 'Llocs',
objects: 'Objectes',
symbols: 'Símbols',
searchResults: 'Resultats de cerca',
noResults: 'No s’ha trobat cap emoji',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Matemàtiques',
greek: 'Grec',
common: 'Comuns',
},
toolbar: {
heading: {
normal: 'Normal',
title: 'Títol',
subTitle: 'Subtítol',
1: 'Encapçalament 1',
2: 'Encapçalament 2',
3: 'Encapçalament 3',
4: 'Encapçalament 4',
5: 'Encapçalament 5',
},
},
ribbon: {
start: 'Inici',
startDesc: 'Inicia el full de càlcul i estableix els paràmetres bàsics.',
insert: 'Insereix',
insertDesc: 'Insereix files, columnes, gràfics i altres elements.',
formulas: 'Fórmules',
formulasDesc: 'Utilitza funcions i fórmules per a càlculs de dades.',
data: 'Dades',
dataDesc: 'Gestiona les dades, incloent importació, ordenació i filtratge.',
view: 'Vista',
viewDesc: 'Canvia els modes de vista i ajusta l\'efecte de visualització.',
others: 'Altres',
othersDesc: 'Altres funcions i configuracions.',
more: 'Més',
},
fontFamily: {
'not-supported': 'No s\'ha trobat aquesta font al sistema, s\'utilitza la font per defecte.',
},
'shortcut-panel': {
title: 'Dreceres',
},
shortcut: {
undo: 'Desfer',
redo: 'Refer',
cut: 'Retalla',
copy: 'Copia',
paste: 'Enganxa',
'shortcut-panel': 'Alterna el panell de dreceres',
},
'common-edit': 'Dreceres d\'edició comunes',
'toggle-shortcut-panel': 'Alterna el panell de dreceres',
navigation: {
back: 'Enrere',
previous: 'Anterior',
next: 'Següent',
},
sidebar: {
panel: 'Panell lateral',
resize: 'Canvia la mida del panell lateral',
close: 'Tanca el panell lateral',
},
beforeClose: {
title: 'Alguns canvis no s\'han desat',
},
clipboard: {
authentication: {
title: 'Permís denegat',
content: 'Si us plau, permet que Univer accedeixi al teu porta-retalls.',
},
},
rangeSelector: {
cancel: 'Cancel·la',
},
'global-shortcut': 'Drecera global',
row: 'Fila',
column: 'Columna',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/de-DE.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'Menü',
zoom: 'Zoom',
zoomIn: 'Vergrößern',
zoomOut: 'Verkleinern',
resetZoom: 'Zoom zurücksetzen',
},
objectPermission: {
operationDenied: 'Dieser Inhalt ist geschützt. Diese Aktion ist nicht erlaubt.',
remove: 'Schutz entfernen',
roleOwner: 'Dateieigentümer',
roleEditor: 'Dateibearbeiter',
selectedCount: 'Ausgewählt: {0}',
searchPeople: 'Personen suchen',
noMatchingPeople: 'Keine passenden Personen',
loadMore: 'Mehr laden',
fileHint: 'Die Dateifreigabe regelt die Mitgliedschaft. Diese Einstellungen schränken die Aktionen dieser Mitglieder ein.',
documentParent: 'Die Bearbeitungseinschränkungen des Dokuments gelten auch für diesen Abschnitt.',
paragraphParent: 'Die Bearbeitungseinschränkungen des Dokuments und des übergeordneten Abschnitts gelten auch für diesen Absatz.',
documentObjectParent: 'Das Dokument sowie die Abschnitte und Absätze, die dieses Objekt enthalten oder verankern, können die Bearbeitung ebenfalls einschränken.',
slideParent: 'Die Bearbeitungseinschränkungen der Präsentation gelten ebenfalls.',
slideObjectParent: 'Die Bearbeitungseinschränkungen der Präsentation und der übergeordneten Folie oder des Masters gelten ebenfalls.',
baseParent: 'Die Bearbeitungseinschränkungen von Base gelten ebenfalls.',
baseObjectParent: 'Die Bearbeitungseinschränkungen von Base und der übergeordneten Tabelle gelten ebenfalls.',
recordParent: 'Die Einschränkungen von Base und der Tabelle gelten weiterhin. Zum Bearbeiten eines Werts ist auch eine Berechtigung für das zugehörige Feld erforderlich.',
boardParent: 'Die Bearbeitungseinschränkungen des Boards gelten auch für dieses Objekt.',
ownerInherit: 'Dateieigentümer, geerbter Zugriff',
peopleError: 'Personen konnten nicht geladen werden. Bitte versuchen Sie es erneut.',
document: 'Dokument',
section: 'Abschnitt',
paragraph: 'Absatz',
entity: 'Objekt',
presentation: 'Präsentation',
page: 'Folie',
master: 'Masteransicht',
base: 'Base',
table: 'Tabelle',
field: 'Feld',
record: 'Datensatz',
view: 'Ansicht',
board: 'Board',
objectName: '{0}: {1}',
search: 'Objekte suchen',
empty: 'Keine passenden Objekte',
more: 'Die ersten 100 Objekte werden angezeigt. Grenzen Sie die Liste mit der Suche ein.',
title: 'Berechtigungen',
cancel: 'Abbrechen',
save: 'Speichern',
saving: 'Wird gespeichert…',
loading: 'Wird geladen…',
conflict: 'Die Berechtigungen wurden geändert. Laden Sie vor dem Speichern neu.',
error: 'Berechtigungen konnten nicht geladen oder gespeichert werden. Ihre Änderungen wurden beibehalten.',
reload: 'Neu laden',
denied: 'Sie können die Berechtigungen für dieses Objekt nicht verwalten.',
edit: 'Wer darf bearbeiten',
all: 'Alle Dateibearbeiter',
owner: 'Nur der Objekteigentümer',
members: 'Ausgewählte Mitglieder',
copy: 'Bearbeitern das Kopieren erlauben',
print: 'Bearbeitern das Drucken erlauben',
export: 'Bearbeitern das Exportieren erlauben',
comment: 'Bearbeitern das Kommentieren erlauben',
parentHint: 'Die Einschränkungen der Datei und des übergeordneten Objekts gelten weiterhin.',
},
featureSearch: {
title: 'Funktionen suchen',
placeholder: 'Funktion oder Menüname eingeben...',
empty: 'Keine verfügbaren Funktionen gefunden',
ribbon: 'Menüband',
contextMenu: 'Kontextmenü',
},
emojiPicker: {
search: 'Suchen',
random: 'Zufälliges Emoji',
recents: 'Zuletzt verwendet',
emojis: 'Emojis',
animals: 'Tiere',
food: 'Essen',
activities: 'Aktivitäten',
places: 'Orte',
objects: 'Objekte',
symbols: 'Symbole',
searchResults: 'Suchergebnisse',
noResults: 'Kein Emoji gefunden',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Mathematik',
greek: 'Griechisch',
common: 'Allgemein',
},
toolbar: {
heading: {
normal: 'Normal',
title: 'Titel',
subTitle: 'Untertitel',
1: 'Überschrift 1',
2: 'Überschrift 2',
3: 'Überschrift 3',
4: 'Überschrift 4',
5: 'Überschrift 5',
},
},
ribbon: {
start: 'Start',
startDesc: 'Arbeitsblatt initiieren und grundlegende Parameter festlegen.',
insert: 'Einfügen',
insertDesc: 'Zeilen, Spalten, Diagramme und verschiedene andere Elemente einfügen.',
formulas: 'Formeln',
formulasDesc: 'Funktionen und Formeln für Datenberechnungen verwenden.',
data: 'Daten',
dataDesc: 'Daten verwalten, einschließlich Import, Sortierung und Filterung.',
view: 'Ansicht',
viewDesc: 'Ansichtsmodi wechseln und Anzeigeeffekt anpassen.',
others: 'Sonstiges',
othersDesc: 'Weitere Funktionen und Einstellungen.',
more: 'Mehr',
},
fontFamily: {
'not-supported': 'Schriftart nicht im System gefunden, Standard-Schriftart wird verwendet.',
},
'shortcut-panel': {
title: 'Tastenkürzel',
},
shortcut: {
undo: 'Rückgängig',
redo: 'Wiederholen',
cut: 'Ausschneiden',
copy: 'Kopieren',
paste: 'Einfügen',
'shortcut-panel': 'Tastenkürzel-Panel ein-/ausblenden',
},
'common-edit': 'Häufige Bearbeitungstastenkürzel',
'toggle-shortcut-panel': 'Tastenkürzel-Panel ein-/ausblenden',
navigation: {
back: 'Zurück',
previous: 'Vorherige',
next: 'Nächste',
},
sidebar: {
panel: 'Seitenleiste',
resize: 'Größe der Seitenleiste ändern',
close: 'Seitenleiste schließen',
},
beforeClose: {
title: 'Einige Änderungen wurden nicht gespeichert',
},
clipboard: {
authentication: {
title: 'Berechtigung verweigert',
content: 'Bitte erlauben Sie Univer den Zugriff auf Ihre Zwischenablage.',
},
},
rangeSelector: {
cancel: 'Abbrechen',
},
'global-shortcut': 'Globale Tastenkürzel',
row: 'Zeile',
column: 'Spalte',
},
};
export default locale;

View File

@@ -0,0 +1,184 @@
/**
* 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 emojiLocale from './emoji-locale/en-US.generated';
const locale = {
ui: {
accessibility: {
menu: 'Menu',
zoom: 'Zoom',
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
resetZoom: 'Reset zoom',
},
objectPermission: {
operationDenied: 'This content is protected. This action is not allowed.',
remove: 'Remove protection',
roleOwner: 'File owner',
roleEditor: 'File editor',
selectedCount: 'Selected: {0}',
searchPeople: 'Search people',
noMatchingPeople: 'No matching people',
loadMore: 'Load more',
fileHint: 'File sharing controls membership. These settings restrict actions within that membership.',
documentParent: 'Document editing restrictions also apply to this section.',
paragraphParent: 'Document and containing section editing restrictions also apply to this paragraph.',
documentObjectParent: 'Document and the sections and paragraphs containing or anchoring this object may also restrict editing.',
slideParent: 'Presentation editing restrictions also apply.',
slideObjectParent: 'Presentation and the containing page or master editing restrictions also apply.',
baseParent: 'Base editing restrictions also apply.',
baseObjectParent: 'Base and containing table editing restrictions also apply.',
recordParent: 'Base and table restrictions still apply. Editing a value also requires permission for its field.',
boardParent: 'Board editing restrictions also apply to this object.',
ownerInherit: 'File owner, inherited access',
peopleError: 'Could not load people. Please retry.',
document: 'Document',
section: 'Section',
paragraph: 'Paragraph',
entity: 'Object',
presentation: 'Presentation',
page: 'Slide',
master: 'Master view',
base: 'Base',
table: 'Table',
field: 'Field',
record: 'Record',
view: 'View',
board: 'Board',
objectName: '{0}: {1}',
search: 'Search objects',
empty: 'No matching objects',
more: 'Showing the first 100 objects. Search to narrow the list.',
title: 'Permissions',
cancel: 'Cancel',
save: 'Save',
saving: 'Saving…',
loading: 'Loading…',
conflict: 'Permissions changed. Reload before saving.',
error: 'Could not load or save permissions. Your changes have been kept.',
reload: 'Reload',
denied: 'You cannot manage permissions for this object.',
edit: 'Who can edit',
all: 'All file editors',
owner: 'Object owner only',
members: 'Selected members',
copy: 'Allow editors to copy',
print: 'Allow editors to print',
export: 'Allow editors to export',
comment: 'Allow editors to comment',
parentHint: 'File and parent object restrictions still apply.',
},
featureSearch: {
title: 'Search features',
placeholder: 'Type a feature or menu name...',
empty: 'No available features found',
ribbon: 'Ribbon',
contextMenu: 'Context menu',
},
emojiPicker: {
search: 'Search',
random: 'Random emoji',
recents: 'Recents',
emojis: 'Emojis',
animals: 'Animals',
food: 'Food',
activities: 'Activities',
places: 'Places',
objects: 'Objects',
symbols: 'Symbols',
searchResults: 'Search results',
noResults: 'No emoji found',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Mathematics',
greek: 'Greek',
common: 'Common',
},
toolbar: {
heading: {
normal: 'Normal',
title: 'Title',
subTitle: 'Sub Title',
1: 'Heading 1',
2: 'Heading 2',
3: 'Heading 3',
4: 'Heading 4',
5: 'Heading 5',
},
},
ribbon: {
start: 'Start',
startDesc: 'Initiate the worksheet and set basic parameters.',
insert: 'Insert',
insertDesc: 'Insert rows, columns, charts and various other elements.',
formulas: 'Formulas',
formulasDesc: 'Use functions and formulas for data calculations.',
data: 'Data',
dataDesc: 'Manage data, including import, sorting and filtering.',
view: 'View',
viewDesc: 'Switch view modes and adjust the display effect.',
others: 'Others',
othersDesc: 'Other functions and settings.',
more: 'More',
},
fontFamily: {
'not-supported': 'No such font found in the system, using default font.',
},
'shortcut-panel': {
title: 'Shortcuts',
},
shortcut: {
undo: 'Undo',
redo: 'Redo',
cut: 'Cut',
copy: 'Copy',
paste: 'Paste',
'shortcut-panel': 'Toggle Shortcut Panel',
},
'common-edit': 'Common Editing Shortcuts',
'toggle-shortcut-panel': 'Toggle Shortcut Panel',
navigation: {
back: 'Back',
previous: 'Previous',
next: 'Next',
},
sidebar: {
panel: 'Sidebar panel',
resize: 'Resize sidebar',
close: 'Close sidebar',
},
beforeClose: {
title: 'Some changes are not saved',
},
clipboard: {
authentication: {
title: 'Permission Denied',
content: 'Please allow Univer to access your clipboard.',
},
},
rangeSelector: {
cancel: 'Cancel',
},
'global-shortcut': 'Global Shortcut',
row: 'Row',
column: 'Column',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/es-ES.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'Menú',
zoom: 'Zoom',
zoomIn: 'Acercar',
zoomOut: 'Alejar',
resetZoom: 'Restablecer zoom',
},
objectPermission: {
operationDenied: 'Este contenido está protegido. No se permite esta acción.',
remove: 'Eliminar protección',
roleOwner: 'Propietario del archivo',
roleEditor: 'Editor del archivo',
selectedCount: 'Seleccionados: {0}',
searchPeople: 'Buscar personas',
noMatchingPeople: 'No hay personas que coincidan',
loadMore: 'Cargar más',
fileHint: 'El uso compartido del archivo determina los miembros. Estos ajustes limitan las acciones de esos miembros.',
documentParent: 'Las restricciones de edición del documento también se aplican a esta sección.',
paragraphParent: 'Las restricciones de edición del documento y de la sección que contiene este párrafo también se aplican.',
documentObjectParent: 'El documento y las secciones y párrafos que contienen o anclan este objeto también pueden restringir su edición.',
slideParent: 'Las restricciones de edición de la presentación también se aplican.',
slideObjectParent: 'Las restricciones de edición de la presentación y de la diapositiva o patrón que contiene este objeto también se aplican.',
baseParent: 'Las restricciones de edición de Base también se aplican.',
baseObjectParent: 'Las restricciones de edición de Base y de la tabla que contiene este objeto también se aplican.',
recordParent: 'Las restricciones de Base y de la tabla siguen aplicándose. Editar un valor también requiere permiso para su campo.',
boardParent: 'Las restricciones de edición de la pizarra también se aplican a este objeto.',
ownerInherit: 'Propietario del archivo, acceso heredado',
peopleError: 'No se pudieron cargar las personas. Vuelve a intentarlo.',
document: 'Documento',
section: 'Sección',
paragraph: 'Párrafo',
entity: 'Objeto',
presentation: 'Presentación',
page: 'Diapositiva',
master: 'Vista de patrón',
base: 'Base',
table: 'Tabla',
field: 'Campo',
record: 'Registro',
view: 'Vista',
board: 'Pizarra',
objectName: '{0}: {1}',
search: 'Buscar objetos',
empty: 'No hay objetos que coincidan',
more: 'Se muestran los primeros 100 objetos. Usa la búsqueda para acotar la lista.',
title: 'Permisos',
cancel: 'Cancelar',
save: 'Guardar',
saving: 'Guardando…',
loading: 'Cargando…',
conflict: 'Los permisos han cambiado. Vuelve a cargar antes de guardar.',
error: 'No se pudieron cargar o guardar los permisos. Se han conservado tus cambios.',
reload: 'Volver a cargar',
denied: 'No puedes gestionar los permisos de este objeto.',
edit: 'Quién puede editar',
all: 'Todos los editores del archivo',
owner: 'Solo el propietario del objeto',
members: 'Miembros seleccionados',
copy: 'Permitir que los editores copien',
print: 'Permitir que los editores impriman',
export: 'Permitir que los editores exporten',
comment: 'Permitir que los editores comenten',
parentHint: 'Las restricciones del archivo y del objeto superior siguen aplicándose.',
},
featureSearch: {
title: 'Buscar funciones',
placeholder: 'Escribe una función o nombre de menú...',
empty: 'No se encontraron funciones disponibles',
ribbon: 'Cinta',
contextMenu: 'Menú contextual',
},
emojiPicker: {
search: 'Buscar',
random: 'Emoji aleatorio',
recents: 'Recientes',
emojis: 'Emojis',
animals: 'Animales',
food: 'Comida',
activities: 'Actividades',
places: 'Lugares',
objects: 'Objetos',
symbols: 'Símbolos',
searchResults: 'Resultados de búsqueda',
noResults: 'No se encontraron emojis',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Matemáticas',
greek: 'Griego',
common: 'Comunes',
},
toolbar: {
heading: {
normal: 'Normal',
title: 'Título',
subTitle: 'Subtítulo',
1: 'Encabezado 1',
2: 'Encabezado 2',
3: 'Encabezado 3',
4: 'Encabezado 4',
5: 'Encabezado 5',
},
},
ribbon: {
start: 'Inicio',
startDesc: 'Inicia la hoja de cálculo y establece los parámetros básicos.',
insert: 'Insertar',
insertDesc: 'Inserta filas, columnas, gráficos y otros elementos.',
formulas: 'Fórmulas',
formulasDesc: 'Utiliza funciones y fórmulas para cálculos de datos.',
data: 'Datos',
dataDesc: 'Gestiona los datos, incluyendo importación, ordenación y filtrado.',
view: 'Vista',
viewDesc: 'Cambia los modos de vista y ajusta el efecto de visualización.',
others: 'Otros',
othersDesc: 'Otras funciones y configuraciones.',
more: 'Más',
},
fontFamily: {
'not-supported': 'No se encontró esta fuente en el sistema, se utiliza la fuente predeterminada.',
},
'shortcut-panel': {
title: 'Atajos',
},
shortcut: {
undo: 'Deshacer',
redo: 'Rehacer',
cut: 'Cortar',
copy: 'Copiar',
paste: 'Pegar',
'shortcut-panel': 'Alternar panel de atajos',
},
'common-edit': 'Atajos de edición comunes',
'toggle-shortcut-panel': 'Alternar panel de atajos',
navigation: {
back: 'Atrás',
previous: 'Anterior',
next: 'Siguiente',
},
sidebar: {
panel: 'Panel lateral',
resize: 'Cambiar el tamaño del panel lateral',
close: 'Cerrar el panel lateral',
},
beforeClose: {
title: 'Algunos cambios no se han guardado',
},
clipboard: {
authentication: {
title: 'Permiso denegado',
content: 'Por favor, permite que Univer acceda a tu portapapeles.',
},
},
rangeSelector: {
cancel: 'Cancelar',
},
'global-shortcut': 'Atajo global',
row: 'Fila',
column: 'Columna',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/fa-IR.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'منو',
zoom: 'بزرگ‌نمایی',
zoomIn: 'بزرگ‌نمایی',
zoomOut: 'کوچک‌نمایی',
resetZoom: 'بازنشانی بزرگ‌نمایی',
},
objectPermission: {
operationDenied: 'این محتوا محافظت شده است. انجام این عملیات مجاز نیست.',
remove: 'حذف محافظت',
roleOwner: 'مالک فایل',
roleEditor: 'ویرایشگر فایل',
selectedCount: 'انتخاب‌شده: {0}',
searchPeople: 'جستجوی افراد',
noMatchingPeople: 'فردی مطابق جستجو یافت نشد',
loadMore: 'بارگذاری بیشتر',
fileHint: 'اشتراک‌گذاری فایل، اعضا را تعیین می‌کند. این تنظیمات اقدامات همان اعضا را محدود می‌کنند.',
documentParent: 'محدودیت‌های ویرایش سند برای این بخش نیز اعمال می‌شوند.',
paragraphParent: 'محدودیت‌های ویرایش سند و بخش دربرگیرندهٔ این بند، برای این بند نیز اعمال می‌شوند.',
documentObjectParent: 'سند و بخش‌ها و بندهایی که این شیء را دربر می‌گیرند یا محل اتصال آن هستند، ممکن است ویرایش را نیز محدود کنند.',
slideParent: 'محدودیت‌های ویرایش ارائه نیز اعمال می‌شوند.',
slideObjectParent: 'محدودیت‌های ویرایش ارائه و اسلاید یا الگوی دربرگیرندهٔ این شیء نیز اعمال می‌شوند.',
baseParent: 'محدودیت‌های ویرایش Base نیز اعمال می‌شوند.',
baseObjectParent: 'محدودیت‌های ویرایش Base و جدول دربرگیرندهٔ این شیء نیز اعمال می‌شوند.',
recordParent: 'محدودیت‌های Base و جدول همچنان اعمال می‌شوند. ویرایش مقدار به مجوز فیلد مربوطه نیز نیاز دارد.',
boardParent: 'محدودیت‌های ویرایش تخته برای این شیء نیز اعمال می‌شوند.',
ownerInherit: 'مالک فایل، دسترسی به‌ارث‌رسیده',
peopleError: 'بارگذاری افراد ممکن نشد. دوباره تلاش کنید.',
document: 'سند',
section: 'بخش',
paragraph: 'بند',
entity: 'شیء',
presentation: 'ارائه',
page: 'اسلاید',
master: 'نمای الگو',
base: 'Base',
table: 'جدول',
field: 'فیلد',
record: 'رکورد',
view: 'نما',
board: 'تخته',
objectName: '{0}: {1}',
search: 'جستجوی اشیا',
empty: 'شیئی مطابق جستجو یافت نشد',
more: '۱۰۰ شیء نخست نمایش داده می‌شوند. برای محدود کردن فهرست جستجو کنید.',
title: 'مجوزها',
cancel: 'لغو',
save: 'ذخیره',
saving: 'در حال ذخیره…',
loading: 'در حال بارگذاری…',
conflict: 'مجوزها تغییر کرده‌اند. پیش از ذخیره دوباره بارگذاری کنید.',
error: 'بارگذاری یا ذخیرهٔ مجوزها ممکن نشد. تغییرات شما حفظ شده‌اند.',
reload: 'بارگذاری مجدد',
denied: 'نمی‌توانید مجوزهای این شیء را مدیریت کنید.',
edit: 'چه کسانی می‌توانند ویرایش کنند',
all: 'همهٔ ویرایشگران فایل',
owner: 'فقط مالک شیء',
members: 'اعضای انتخاب‌شده',
copy: 'اجازهٔ کپی به ویرایشگران',
print: 'اجازهٔ چاپ به ویرایشگران',
export: 'اجازهٔ خروجی گرفتن به ویرایشگران',
comment: 'اجازهٔ نظر دادن به ویرایشگران',
parentHint: 'محدودیت‌های فایل و شیء والد همچنان اعمال می‌شوند.',
},
featureSearch: {
title: 'جستجوی قابلیت‌ها',
placeholder: 'نام قابلیت یا منو را وارد کنید…',
empty: 'هیچ قابلیت در دسترسی یافت نشد',
ribbon: 'نوار ابزار',
contextMenu: 'منوی زمینه‌ای',
},
emojiPicker: {
search: 'جستجو',
random: 'ایموجی تصادفی',
recents: 'اخیر',
emojis: 'ایموجی‌ها',
animals: 'حیوانات',
food: 'غذا',
activities: 'فعالیت‌ها',
places: 'مکان‌ها',
objects: 'اشیا',
symbols: 'نمادها',
searchResults: 'نتایج جستجو',
noResults: 'ایموجی پیدا نشد',
...emojiLocale,
},
symbolPicker: {
mathematics: 'ریاضیات',
greek: 'یونانی',
common: 'رایج',
},
toolbar: {
heading: {
normal: 'متن عادی',
title: 'عنوان',
subTitle: 'زیر عنوان',
1: 'عنوان 1',
2: 'عنوان 2',
3: 'عنوان 3',
4: 'عنوان 4',
5: 'عنوان 5',
},
},
ribbon: {
start: 'شروع',
startDesc: 'ایجاد کاربرگ و تنظیم پارامترهای اولیه.',
insert: 'درج',
insertDesc: 'درج ردیف‌ها، ستون‌ها، نمودارها و عناصر مختلف دیگر.',
formulas: 'فرمول‌ها',
formulasDesc: 'استفاده از توابع و فرمول‌ها برای محاسبات داده‌ها.',
data: 'داده‌ها',
dataDesc: 'ادغام و تجزیه داده‌ها.',
view: 'نمایش',
viewDesc: 'تغییر حالت نمایش و تنظیم اثرات نمایشی.',
others: 'دیگر',
othersDesc: 'سایر عملکردها و تنظیمات.',
more: 'بیشتر',
},
fontFamily: {
'not-supported': 'هیچ فونتی با این نام در سیستم یافت نشد، از فونت پیش‌فرض استفاده می‌شود.',
},
'shortcut-panel': {
title: 'کلیدهای میانبر',
},
shortcut: {
undo: 'بازگرداندن',
redo: 'تکرار',
cut: 'بریدن',
copy: 'کپی کردن',
paste: 'چسباندن',
'shortcut-panel': 'نمایش/مخفی کردن پنل کلیدهای میانبر',
},
'common-edit': 'کلیدهای میانبر ویرایش عمومی',
'toggle-shortcut-panel': 'نمایش/مخفی کردن پنل کلیدهای میانبر',
navigation: {
back: 'بازگشت',
previous: 'قبلی',
next: 'بعدی',
},
sidebar: {
panel: 'پنل کناری',
resize: 'تغییر اندازه پنل کناری',
close: 'بستن پنل کناری',
},
beforeClose: {
title: 'برخی تغییرات ذخیره نشده‌اند',
},
clipboard: {
authentication: {
title: 'اجازه دسترسی داده نشده است',
content: 'لطفا به Univer اجازه دسترسی به کلیپ بورد خود را بدهید.',
},
},
rangeSelector: {
cancel: 'انصراف',
},
'global-shortcut': 'کلید میانبر جهانی',
row: 'سطر',
column: 'ستون',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/fr-FR.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'Menu',
zoom: 'Zoom',
zoomIn: 'Zoom avant',
zoomOut: 'Zoom arrière',
resetZoom: 'Réinitialiser le zoom',
},
objectPermission: {
operationDenied: 'Ce contenu est protégé. Cette action est interdite.',
remove: 'Supprimer la protection',
roleOwner: 'Propriétaire du fichier',
roleEditor: 'Éditeur du fichier',
selectedCount: 'Sélectionnés : {0}',
searchPeople: 'Rechercher des personnes',
noMatchingPeople: 'Aucune personne correspondante',
loadMore: 'Charger plus',
fileHint: 'Le partage du fichier détermine les membres. Ces paramètres limitent les actions de ces membres.',
documentParent: 'Les restrictions de modification du document s’appliquent également à cette section.',
paragraphParent: 'Les restrictions de modification du document et de la section contenant ce paragraphe s’appliquent également.',
documentObjectParent: 'Le document ainsi que les sections et paragraphes contenant ou ancrant cet objet peuvent également limiter sa modification.',
slideParent: 'Les restrictions de modification de la présentation s’appliquent également.',
slideObjectParent: 'Les restrictions de modification de la présentation et de la diapositive ou du masque contenant cet objet s’appliquent également.',
baseParent: 'Les restrictions de modification de Base s’appliquent également.',
baseObjectParent: 'Les restrictions de modification de Base et de la table contenant cet objet s’appliquent également.',
recordParent: 'Les restrictions de Base et de la table restent applicables. Modifier une valeur nécessite également une autorisation sur son champ.',
boardParent: 'Les restrictions de modification du tableau blanc s’appliquent également à cet objet.',
ownerInherit: 'Propriétaire du fichier, accès hérité',
peopleError: 'Impossible de charger les personnes. Veuillez réessayer.',
document: 'Document',
section: 'Section',
paragraph: 'Paragraphe',
entity: 'Objet',
presentation: 'Présentation',
page: 'Diapositive',
master: 'Vue du masque',
base: 'Base',
table: 'Table',
field: 'Champ',
record: 'Enregistrement',
view: 'Vue',
board: 'Tableau blanc',
objectName: '{0}: {1}',
search: 'Rechercher des objets',
empty: 'Aucun objet correspondant',
more: 'Affichage des 100 premiers objets. Utilisez la recherche pour affiner la liste.',
title: 'Autorisations',
cancel: 'Annuler',
save: 'Enregistrer',
saving: 'Enregistrement…',
loading: 'Chargement…',
conflict: 'Les autorisations ont changé. Rechargez avant d’enregistrer.',
error: 'Impossible de charger ou d’enregistrer les autorisations. Vos modifications ont été conservées.',
reload: 'Recharger',
denied: 'Vous ne pouvez pas gérer les autorisations de cet objet.',
edit: 'Qui peut modifier',
all: 'Tous les éditeurs du fichier',
owner: 'Propriétaire de l’objet uniquement',
members: 'Membres sélectionnés',
copy: 'Autoriser les éditeurs à copier',
print: 'Autoriser les éditeurs à imprimer',
export: 'Autoriser les éditeurs à exporter',
comment: 'Autoriser les éditeurs à commenter',
parentHint: 'Les restrictions du fichier et de l’objet parent restent applicables.',
},
featureSearch: {
title: 'Rechercher des fonctionnalités',
placeholder: 'Saisissez une fonctionnalité ou un nom de menu...',
empty: 'Aucune fonctionnalité disponible trouvée',
ribbon: 'Ruban',
contextMenu: 'Menu contextuel',
},
emojiPicker: {
search: 'Rechercher',
random: 'Emoji aléatoire',
recents: 'Récents',
emojis: 'Emojis',
animals: 'Animaux',
food: 'Nourriture',
activities: 'Activités',
places: 'Lieux',
objects: 'Objets',
symbols: 'Symboles',
searchResults: 'Résultats de recherche',
noResults: 'Aucun emoji trouvé',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Mathématiques',
greek: 'Grec',
common: 'Courants',
},
toolbar: {
heading: {
normal: 'Normal',
title: 'Titre',
subTitle: 'Sous-titre',
1: 'Titre 1',
2: 'Titre 2',
3: 'Titre 3',
4: 'Titre 4',
5: 'Titre 5',
},
},
ribbon: {
start: 'Démarrer',
startDesc: 'Initialiser la feuille de calcul et définir les paramètres de base.',
insert: 'Insérer',
insertDesc: 'Insérer des lignes, des colonnes, des graphiques et divers autres éléments.',
formulas: 'Formules',
formulasDesc: 'Utiliser des fonctions et des formules pour les calculs de données.',
data: 'Données',
dataDesc: 'Gérer les données, y compris l\'importation, le tri et le filtrage.',
view: 'Vue',
viewDesc: 'Changer les modes d\'affichage et ajuster l\'effet d\'affichage.',
others: 'Autres',
othersDesc: 'Autres fonctions et paramètres.',
more: 'Plus',
},
fontFamily: {
'not-supported': 'Aucune police de ce type trouvée dans le système, utilisation de la police par défaut.',
},
'shortcut-panel': {
title: 'Raccourcis',
},
shortcut: {
undo: 'Annuler',
redo: 'Refaire',
cut: 'Couper',
copy: 'Copier',
paste: 'Coller',
'shortcut-panel': 'Basculer le panneau de raccourcis',
},
'common-edit': 'Raccourcis d\'édition courants',
'toggle-shortcut-panel': 'Basculer le panneau de raccourcis',
navigation: {
back: 'Retour',
previous: 'Précédent',
next: 'Suivant',
},
sidebar: {
panel: 'Panneau latéral',
resize: 'Redimensionner le panneau latéral',
close: 'Fermer le panneau latéral',
},
beforeClose: {
title: 'Certaines modifications n\'ont pas été enregistrées',
},
clipboard: {
authentication: {
title: 'Permission refusée',
content: 'Veuillez autoriser Univer à accéder à votre presse-papiers.',
},
},
rangeSelector: {
cancel: 'Annuler',
},
'global-shortcut': 'Raccourci global',
row: 'Ligne',
column: 'Colonne',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/id-ID.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'Menu',
zoom: 'Zoom',
zoomIn: 'Perbesar',
zoomOut: 'Perkecil',
resetZoom: 'Atur ulang zoom',
},
objectPermission: {
operationDenied: 'Konten ini dilindungi. Tindakan ini tidak diizinkan.',
remove: 'Hapus perlindungan',
roleOwner: 'Pemilik file',
roleEditor: 'Editor file',
selectedCount: 'Dipilih: {0}',
searchPeople: 'Cari pengguna',
noMatchingPeople: 'Tidak ada pengguna yang cocok',
loadMore: 'Muat lebih banyak',
fileHint: 'Berbagi file mengatur keanggotaan. Pengaturan ini membatasi tindakan anggota tersebut.',
documentParent: 'Pembatasan pengeditan dokumen juga berlaku untuk bagian ini.',
paragraphParent: 'Pembatasan pengeditan dokumen dan bagian yang memuat paragraf ini juga berlaku.',
documentObjectParent: 'Dokumen serta bagian dan paragraf yang memuat atau menjadi jangkar objek ini juga dapat membatasi pengeditan.',
slideParent: 'Pembatasan pengeditan presentasi juga berlaku.',
slideObjectParent: 'Pembatasan pengeditan presentasi dan slide atau master yang memuat objek ini juga berlaku.',
baseParent: 'Pembatasan pengeditan Base juga berlaku.',
baseObjectParent: 'Pembatasan pengeditan Base dan tabel yang memuat objek ini juga berlaku.',
recordParent: 'Pembatasan Base dan tabel tetap berlaku. Mengedit nilai juga memerlukan izin untuk kolomnya.',
boardParent: 'Pembatasan pengeditan papan juga berlaku untuk objek ini.',
ownerInherit: 'Pemilik file, akses yang diwariskan',
peopleError: 'Tidak dapat memuat pengguna. Silakan coba lagi.',
document: 'Dokumen',
section: 'Bagian',
paragraph: 'Paragraf',
entity: 'Objek',
presentation: 'Presentasi',
page: 'Slide',
master: 'Tampilan master',
base: 'Base',
table: 'Tabel',
field: 'Kolom',
record: 'Rekaman',
view: 'Tampilan',
board: 'Papan',
objectName: '{0}: {1}',
search: 'Cari objek',
empty: 'Tidak ada objek yang cocok',
more: 'Menampilkan 100 objek pertama. Cari untuk mempersempit daftar.',
title: 'Izin',
cancel: 'Batal',
save: 'Simpan',
saving: 'Menyimpan…',
loading: 'Memuat…',
conflict: 'Izin telah berubah. Muat ulang sebelum menyimpan.',
error: 'Tidak dapat memuat atau menyimpan izin. Perubahan Anda tetap disimpan.',
reload: 'Muat ulang',
denied: 'Anda tidak dapat mengelola izin untuk objek ini.',
edit: 'Siapa yang dapat mengedit',
all: 'Semua editor file',
owner: 'Hanya pemilik objek',
members: 'Anggota yang dipilih',
copy: 'Izinkan editor menyalin',
print: 'Izinkan editor mencetak',
export: 'Izinkan editor mengekspor',
comment: 'Izinkan editor berkomentar',
parentHint: 'Pembatasan file dan objek induk tetap berlaku.',
},
featureSearch: {
title: 'Cari fitur',
placeholder: 'Ketik nama fitur atau menu...',
empty: 'Tidak ada fitur yang tersedia',
ribbon: 'Pita',
contextMenu: 'Menu konteks',
},
emojiPicker: {
search: 'Cari',
random: 'Emoji acak',
recents: 'Terbaru',
emojis: 'Emoji',
animals: 'Hewan',
food: 'Makanan',
activities: 'Aktivitas',
places: 'Tempat',
objects: 'Objek',
symbols: 'Simbol',
searchResults: 'Hasil pencarian',
noResults: 'Emoji tidak ditemukan',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Matematika',
greek: 'Yunani',
common: 'Umum',
},
toolbar: {
heading: {
normal: 'Normal',
title: 'Judul',
subTitle: 'Sub Judul',
1: 'Judul 1',
2: 'Judul 2',
3: 'Judul 3',
4: 'Judul 4',
5: 'Judul 5',
},
},
ribbon: {
start: 'Mulai',
startDesc: 'Inisiasi lembar kerja dan atur parameter dasar.',
insert: 'Sisipkan',
insertDesc: 'Sisipkan baris, kolom, grafik, dan berbagai elemen lainnya.',
formulas: 'Rumus',
formulasDesc: 'Gunakan fungsi dan rumus untuk perhitungan data.',
data: 'Data',
dataDesc: 'Kelola data, termasuk impor, pengurutan, dan penyaringan.',
view: 'Tampilan',
viewDesc: 'Ganti mode tampilan dan sesuaikan efek tampilan.',
others: 'Lainnya',
othersDesc: 'Fungsi dan pengaturan lainnya.',
more: 'Lebih Banyak',
},
fontFamily: {
'not-supported': 'Font tidak ditemukan di sistem, menggunakan font default.',
},
'shortcut-panel': {
title: 'Pintasan',
},
shortcut: {
undo: 'Batalkan',
redo: 'Ulangi',
cut: 'Potong',
copy: 'Salin',
paste: 'Tempel',
'shortcut-panel': 'Alihkan Panel Pintasan',
},
'common-edit': 'Pintasan Edit Umum',
'toggle-shortcut-panel': 'Alihkan Panel Pintasan',
navigation: {
back: 'Kembali',
previous: 'Sebelumnya',
next: 'Berikutnya',
},
sidebar: {
panel: 'Panel samping',
resize: 'Ubah ukuran panel samping',
close: 'Tutup panel samping',
},
beforeClose: {
title: 'Beberapa perubahan belum disimpan',
},
clipboard: {
authentication: {
title: 'Izin Ditolak',
content: 'Izinkan Univer untuk mengakses clipboard Anda.',
},
},
rangeSelector: {
cancel: 'Batal',
},
'global-shortcut': 'Pintasan Global',
row: 'Baris',
column: 'Kolom',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/it-IT.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'Menu',
zoom: 'Zoom',
zoomIn: 'Ingrandisci',
zoomOut: 'Riduci',
resetZoom: 'Reimposta zoom',
},
objectPermission: {
operationDenied: 'Questo contenuto è protetto. Questa azione non è consentita.',
remove: 'Rimuovi protezione',
roleOwner: 'Proprietario del file',
roleEditor: 'Editor del file',
selectedCount: 'Selezionati: {0}',
searchPeople: 'Cerca persone',
noMatchingPeople: 'Nessuna persona corrispondente',
loadMore: 'Carica altro',
fileHint: 'La condivisione del file determina i membri. Queste impostazioni limitano le azioni di tali membri.',
documentParent: 'Le restrizioni di modifica del documento si applicano anche a questa sezione.',
paragraphParent: 'Le restrizioni di modifica del documento e della sezione che contiene questo paragrafo si applicano anche al paragrafo.',
documentObjectParent: 'Il documento e le sezioni e i paragrafi che contengono o ancorano questo oggetto possono limitarne la modifica.',
slideParent: 'Si applicano anche le restrizioni di modifica della presentazione.',
slideObjectParent: 'Si applicano anche le restrizioni di modifica della presentazione e della diapositiva o dello schema che contiene questo oggetto.',
baseParent: 'Si applicano anche le restrizioni di modifica di Base.',
baseObjectParent: 'Si applicano anche le restrizioni di modifica di Base e della tabella che contiene questo oggetto.',
recordParent: 'Le restrizioni di Base e della tabella restano valide. La modifica di un valore richiede anche l’autorizzazione per il relativo campo.',
boardParent: 'Le restrizioni di modifica della lavagna si applicano anche a questo oggetto.',
ownerInherit: 'Proprietario del file, accesso ereditato',
peopleError: 'Impossibile caricare le persone. Riprova.',
document: 'Documento',
section: 'Sezione',
paragraph: 'Paragrafo',
entity: 'Oggetto',
presentation: 'Presentazione',
page: 'Diapositiva',
master: 'Visualizzazione schema',
base: 'Base',
table: 'Tabella',
field: 'Campo',
record: 'Record',
view: 'Visualizzazione',
board: 'Lavagna',
objectName: '{0}: {1}',
search: 'Cerca oggetti',
empty: 'Nessun oggetto corrispondente',
more: 'Sono visualizzati i primi 100 oggetti. Usa la ricerca per restringere l’elenco.',
title: 'Autorizzazioni',
cancel: 'Annulla',
save: 'Salva',
saving: 'Salvataggio…',
loading: 'Caricamento…',
conflict: 'Le autorizzazioni sono cambiate. Ricarica prima di salvare.',
error: 'Impossibile caricare o salvare le autorizzazioni. Le modifiche sono state conservate.',
reload: 'Ricarica',
denied: 'Non puoi gestire le autorizzazioni di questo oggetto.',
edit: 'Chi può modificare',
all: 'Tutti gli editor del file',
owner: 'Solo il proprietario dell’oggetto',
members: 'Membri selezionati',
copy: 'Consenti agli editor di copiare',
print: 'Consenti agli editor di stampare',
export: 'Consenti agli editor di esportare',
comment: 'Consenti agli editor di commentare',
parentHint: 'Le restrizioni del file e dell’oggetto padre restano valide.',
},
featureSearch: {
title: 'Cerca funzionalità',
placeholder: 'Digita una funzionalità o il nome di un menu...',
empty: 'Nessuna funzionalità disponibile trovata',
ribbon: 'Barra multifunzione',
contextMenu: 'Menu contestuale',
},
emojiPicker: {
search: 'Cerca',
random: 'Emoji casuale',
recents: 'Recenti',
emojis: 'Emoji',
animals: 'Animali',
food: 'Cibo',
activities: 'Attività',
places: 'Luoghi',
objects: 'Oggetti',
symbols: 'Simboli',
searchResults: 'Risultati di ricerca',
noResults: 'Nessun emoji trovato',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Matematica',
greek: 'Greco',
common: 'Comuni',
},
toolbar: {
heading: {
normal: 'Normale',
title: 'Titolo',
subTitle: 'Sottotitolo',
1: 'Intestazione 1',
2: 'Intestazione 2',
3: 'Intestazione 3',
4: 'Intestazione 4',
5: 'Intestazione 5',
},
},
ribbon: {
start: 'Inizio',
startDesc: 'Avvia il foglio di lavoro e imposta i parametri di base.',
insert: 'Inserisci',
insertDesc: 'Inserisci righe, colonne, grafici e vari altri elementi.',
formulas: 'Formule',
formulasDesc: 'Utilizza funzioni e formule per i calcoli dei dati.',
data: 'Dati',
dataDesc: 'Gestisci i dati, inclusi importazione, ordinamento e filtro.',
view: 'Visualizza',
viewDesc: 'Cambia le modalità di visualizzazione e regola l\'effetto di visualizzazione.',
others: 'Altri',
othersDesc: 'Altre funzioni e impostazioni.',
more: 'Altro',
},
fontFamily: {
'not-supported': 'Carattere non trovato nel sistema, verrà utilizzato il carattere predefinito.',
},
'shortcut-panel': {
title: 'Scorciatoie',
},
shortcut: {
undo: 'Annulla',
redo: 'Ripristina',
cut: 'Taglia',
copy: 'Copia',
paste: 'Incolla',
'shortcut-panel': 'Attiva/Disattiva Pannello Scorciatoie',
},
'common-edit': 'Scorciatoie di Modifica Comuni',
'toggle-shortcut-panel': 'Attiva/Disattiva Pannello Scorciatoie',
navigation: {
back: 'Indietro',
previous: 'Precedente',
next: 'Successivo',
},
sidebar: {
panel: 'Pannello laterale',
resize: 'Ridimensiona il pannello laterale',
close: 'Chiudi il pannello laterale',
},
beforeClose: {
title: 'Alcune modifiche non sono state salvate',
},
clipboard: {
authentication: {
title: 'Autorizzazione Negata',
content: 'Consenti a Univer di accedere alla tua clipboard.',
},
},
rangeSelector: {
cancel: 'Annulla',
},
'global-shortcut': 'Scorciatoia Globale',
row: 'Riga',
column: 'Colonna',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/ja-JP.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'メニュー',
zoom: 'ズーム',
zoomIn: '拡大',
zoomOut: '縮小',
resetZoom: 'ズームをリセット',
},
objectPermission: {
operationDenied: 'このコンテンツは保護されているため、操作できません。',
remove: '保護を削除',
roleOwner: 'ファイル所有者',
roleEditor: 'ファイル編集者',
selectedCount: '選択済み: {0}',
searchPeople: 'ユーザーを検索',
noMatchingPeople: '一致するユーザーはいません',
loadMore: 'さらに読み込む',
fileHint: 'メンバーはファイルの共有設定で管理します。これらの設定はメンバーの操作を制限します。',
documentParent: 'このセクションには文書の編集制限も適用されます。',
paragraphParent: 'この段落には文書と所属するセクションの編集制限も適用されます。',
documentObjectParent: '文書、およびこのオブジェクトを含むか固定先となるセクションや段落の編集制限も適用される場合があります。',
slideParent: 'プレゼンテーションの編集制限も適用されます。',
slideObjectParent: 'プレゼンテーションと所属するスライドまたはマスターの編集制限も適用されます。',
baseParent: 'Base の編集制限も適用されます。',
baseObjectParent: 'Base と所属するテーブルの編集制限も適用されます。',
recordParent: 'Base とテーブルの制限も適用されます。値の編集にはフィールドの権限も必要です。',
boardParent: 'このオブジェクトにはボードの編集制限も適用されます。',
ownerInherit: 'ファイル所有者、継承されたアクセス権',
peopleError: 'ユーザーを読み込めませんでした。再試行してください。',
document: '文書',
section: 'セクション',
paragraph: '段落',
entity: 'オブジェクト',
presentation: 'プレゼンテーション',
page: 'スライド',
master: 'マスタービュー',
base: 'Base',
table: 'テーブル',
field: 'フィールド',
record: 'レコード',
view: 'ビュー',
board: 'ボード',
objectName: '{0}: {1}',
search: 'オブジェクトを検索',
empty: '一致するオブジェクトはありません',
more: '最初の 100 件を表示しています。検索して絞り込んでください。',
title: '権限',
cancel: 'キャンセル',
save: '保存',
saving: '保存中…',
loading: '読み込み中…',
conflict: '権限が変更されました。保存する前に再読み込みしてください。',
error: '権限を読み込みまたは保存できませんでした。変更内容は保持されています。',
reload: '再読み込み',
denied: 'このオブジェクトの権限を管理することはできません。',
edit: '編集できるユーザー',
all: 'すべてのファイル編集者',
owner: 'オブジェクト所有者のみ',
members: '選択したメンバー',
copy: '編集者によるコピーを許可',
print: '編集者による印刷を許可',
export: '編集者によるエクスポートを許可',
comment: '編集者によるコメントを許可',
parentHint: 'ファイルと親オブジェクトの制限も適用されます。',
},
featureSearch: {
title: '機能を検索',
placeholder: '機能またはメニュー名を入力…',
empty: '利用可能な機能が見つかりません',
ribbon: 'リボン',
contextMenu: 'コンテキストメニュー',
},
emojiPicker: {
search: '検索',
random: 'ランダム絵文字',
recents: '最近使用',
emojis: '絵文字',
animals: '動物',
food: '食べ物',
activities: 'アクティビティ',
places: '場所',
objects: 'オブジェクト',
symbols: '記号',
searchResults: '検索結果',
noResults: '絵文字が見つかりません',
...emojiLocale,
},
symbolPicker: {
mathematics: '数学',
greek: 'ギリシャ文字',
common: '一般',
},
toolbar: {
heading: {
normal: '標準',
title: 'タイトル',
subTitle: 'サブタイトル',
1: '見出し 1',
2: '見出し 2',
3: '見出し 3',
4: '見出し 4',
5: '見出し 5',
},
},
ribbon: {
start: '開始',
startDesc: 'ワークシートを初期化し、基本パラメータを設定します。',
insert: '挿入',
insertDesc: '行、列、グラフなどさまざまな要素を挿入します。',
formulas: '数式',
formulasDesc: 'データ計算のための関数と数式を使用します。',
data: 'データ',
dataDesc: 'データの管理(インポート、並べ替え、フィルタリングを含む)。',
view: '表示',
viewDesc: '表示モードを切り替え、表示効果を調整します。',
others: 'その他',
othersDesc: 'その他の機能と設定。',
more: 'もっと見る',
},
fontFamily: {
'not-supported': 'システムにそのようなフォントが見つからないため、デフォルトのフォントを使用しています。',
},
'shortcut-panel': {
title: 'ショートカット',
},
shortcut: {
undo: '元に戻す',
redo: 'やり直す',
cut: '切り取り',
copy: 'コピー',
paste: '貼り付け',
'shortcut-panel': 'ショートカットパネルを切り替え',
},
'common-edit': '一般編集ショートカット',
'toggle-shortcut-panel': 'ショートカットパネルを切り替え',
navigation: {
back: '戻る',
previous: '前へ',
next: '次へ',
},
sidebar: {
panel: 'サイドバーパネル',
resize: 'サイドバーのサイズを変更',
close: 'サイドバーを閉じる',
},
beforeClose: {
title: '一部の変更が保存されていません',
},
clipboard: {
authentication: {
title: '権限が拒否されました',
content: 'Univerにクリップボードアクセスの権限を付与してください。',
},
},
rangeSelector: {
cancel: 'キャンセル',
},
'global-shortcut': 'グローバルショートカット',
row: '行',
column: '列',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/ko-KR.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: '메뉴',
zoom: '확대/축소',
zoomIn: '확대',
zoomOut: '축소',
resetZoom: '확대/축소 초기화',
},
objectPermission: {
operationDenied: '이 콘텐츠는 보호되어 있어 작업을 수행할 수 없습니다.',
remove: '보호 삭제',
roleOwner: '파일 소유자',
roleEditor: '파일 편집자',
selectedCount: '선택됨: {0}',
searchPeople: '사용자 검색',
noMatchingPeople: '일치하는 사용자가 없습니다',
loadMore: '더 불러오기',
fileHint: '파일 공유 설정에서 구성원을 관리합니다. 이 설정은 해당 구성원의 작업을 제한합니다.',
documentParent: '이 섹션에는 문서 편집 제한도 적용됩니다.',
paragraphParent: '이 단락에는 문서와 상위 섹션의 편집 제한도 적용됩니다.',
documentObjectParent: '문서와 이 개체를 포함하거나 고정하는 섹션 및 단락의 편집 제한도 적용될 수 있습니다.',
slideParent: '프레젠테이션 편집 제한도 적용됩니다.',
slideObjectParent: '프레젠테이션과 상위 슬라이드 또는 마스터의 편집 제한도 적용됩니다.',
baseParent: 'Base 편집 제한도 적용됩니다.',
baseObjectParent: 'Base와 상위 테이블의 편집 제한도 적용됩니다.',
recordParent: 'Base와 테이블 제한도 적용됩니다. 값을 편집하려면 해당 필드의 권한도 필요합니다.',
boardParent: '이 개체에는 보드 편집 제한도 적용됩니다.',
ownerInherit: '파일 소유자, 상속된 액세스 권한',
peopleError: '사용자를 불러올 수 없습니다. 다시 시도하세요.',
document: '문서',
section: '섹션',
paragraph: '단락',
entity: '개체',
presentation: '프레젠테이션',
page: '슬라이드',
master: '마스터 보기',
base: 'Base',
table: '테이블',
field: '필드',
record: '레코드',
view: '보기',
board: '보드',
objectName: '{0}: {1}',
search: '개체 검색',
empty: '일치하는 개체가 없습니다',
more: '처음 100개 개체를 표시합니다. 검색하여 목록을 좁히세요.',
title: '권한',
cancel: '취소',
save: '저장',
saving: '저장 중…',
loading: '불러오는 중…',
conflict: '권한이 변경되었습니다. 저장하기 전에 새로고침하세요.',
error: '권한을 불러오거나 저장할 수 없습니다. 변경 사항은 유지됩니다.',
reload: '새로고침',
denied: '이 개체의 권한을 관리할 수 없습니다.',
edit: '편집할 수 있는 사용자',
all: '모든 파일 편집자',
owner: '개체 소유자만',
members: '선택한 구성원',
copy: '편집자의 복사 허용',
print: '편집자의 인쇄 허용',
export: '편집자의 내보내기 허용',
comment: '편집자의 댓글 허용',
parentHint: '파일 및 상위 개체의 제한도 적용됩니다.',
},
featureSearch: {
title: '기능 검색',
placeholder: '기능 또는 메뉴 이름 입력...',
empty: '사용 가능한 기능을 찾을 수 없습니다',
ribbon: '리본',
contextMenu: '상황에 맞는 메뉴',
},
emojiPicker: {
search: '검색',
random: '무작위 이모지',
recents: '최근 항목',
emojis: '이모지',
animals: '동물',
food: '음식',
activities: '활동',
places: '장소',
objects: '사물',
symbols: '기호',
searchResults: '검색 결과',
noResults: '이모지를 찾을 수 없음',
...emojiLocale,
},
symbolPicker: {
mathematics: '수학',
greek: '그리스 문자',
common: '일반',
},
toolbar: {
heading: {
normal: '일반',
title: '제목',
subTitle: '부제목',
1: '제목 1',
2: '제목 2',
3: '제목 3',
4: '제목 4',
5: '제목 5',
},
},
ribbon: {
start: '시작',
startDesc: '워크시트를 초기화하고 기본 매개변수를 설정합니다.',
insert: '삽입',
insertDesc: '행, 열, 차트 및 다양한 요소를 삽입합니다.',
formulas: '수식',
formulasDesc: '데이터 계산을 위한 함수와 수식을 사용합니다.',
data: '데이터',
dataDesc: '데이터를 관리하며, 가져오기, 정렬 및 필터링을 포함합니다.',
view: '보기',
viewDesc: '보기 모드를 전환하고 표시 효과를 조정합니다.',
others: '기타',
othersDesc: '기타 함수와 설정.',
more: '더 보기',
},
fontFamily: {
'not-supported': '시스템에 해당 폰트가 없어 기본 폰트를 사용합니다.',
},
'shortcut-panel': {
title: '단축키',
},
shortcut: {
undo: '실행 취소',
redo: '다시 실행',
cut: '자르기',
copy: '복사',
paste: '붙여넣기',
'shortcut-panel': '단축키 패널 전환',
},
'common-edit': '일반 편집 단축키',
'toggle-shortcut-panel': '단축키 패널 전환',
navigation: {
back: '뒤로',
previous: '이전',
next: '다음',
},
sidebar: {
panel: '사이드바 패널',
resize: '사이드바 크기 조정',
close: '사이드바 닫기',
},
beforeClose: {
title: '일부 변경 사항이 저장되지 않았습니다',
},
clipboard: {
authentication: {
title: '권한 거절',
content: 'Univer에 클립보드 접근 권한을 부여해주세요.',
},
},
rangeSelector: {
cancel: '취소',
},
'global-shortcut': '전역 단축키',
row: '행',
column: '열',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/pl-PL.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'Menu',
zoom: 'Powiększenie',
zoomIn: 'Powiększ',
zoomOut: 'Pomniejsz',
resetZoom: 'Resetuj powiększenie',
},
objectPermission: {
operationDenied: 'Ta treść jest chroniona. Nie można wykonać tej czynności.',
remove: 'Usuń ochronę',
roleOwner: 'Właściciel pliku',
roleEditor: 'Edytor pliku',
selectedCount: 'Wybrano: {0}',
searchPeople: 'Szukaj osób',
noMatchingPeople: 'Brak pasujących osób',
loadMore: 'Wczytaj więcej',
fileHint: 'Udostępnianie pliku określa członkostwo. Te ustawienia ograniczają działania tych członków.',
documentParent: 'Ograniczenia edycji dokumentu dotyczą również tej sekcji.',
paragraphParent: 'Ograniczenia edycji dokumentu i sekcji zawierającej ten akapit dotyczą również tego akapitu.',
documentObjectParent: 'Dokument oraz sekcje i akapity zawierające ten obiekt lub stanowiące jego zakotwiczenie również mogą ograniczać edycję.',
slideParent: 'Ograniczenia edycji prezentacji również obowiązują.',
slideObjectParent: 'Ograniczenia edycji prezentacji oraz slajdu lub wzorca zawierającego ten obiekt również obowiązują.',
baseParent: 'Ograniczenia edycji Base również obowiązują.',
baseObjectParent: 'Ograniczenia edycji Base i tabeli zawierającej ten obiekt również obowiązują.',
recordParent: 'Ograniczenia Base i tabeli nadal obowiązują. Edycja wartości wymaga także uprawnienia do odpowiedniego pola.',
boardParent: 'Ograniczenia edycji tablicy dotyczą również tego obiektu.',
ownerInherit: 'Właściciel pliku, odziedziczony dostęp',
peopleError: 'Nie udało się wczytać osób. Spróbuj ponownie.',
document: 'Dokument',
section: 'Sekcja',
paragraph: 'Akapit',
entity: 'Obiekt',
presentation: 'Prezentacja',
page: 'Slajd',
master: 'Widok wzorca',
base: 'Base',
table: 'Tabela',
field: 'Pole',
record: 'Rekord',
view: 'Widok',
board: 'Tablica',
objectName: '{0}: {1}',
search: 'Szukaj obiektów',
empty: 'Brak pasujących obiektów',
more: 'Wyświetlono pierwszych 100 obiektów. Użyj wyszukiwania, aby zawęzić listę.',
title: 'Uprawnienia',
cancel: 'Anuluj',
save: 'Zapisz',
saving: 'Zapisywanie…',
loading: 'Wczytywanie…',
conflict: 'Uprawnienia uległy zmianie. Wczytaj ponownie przed zapisaniem.',
error: 'Nie udało się wczytać lub zapisać uprawnień. Twoje zmiany zostały zachowane.',
reload: 'Wczytaj ponownie',
denied: 'Nie możesz zarządzać uprawnieniami tego obiektu.',
edit: 'Kto może edytować',
all: 'Wszyscy edytorzy pliku',
owner: 'Tylko właściciel obiektu',
members: 'Wybrani członkowie',
copy: 'Zezwól edytorom na kopiowanie',
print: 'Zezwól edytorom na drukowanie',
export: 'Zezwól edytorom na eksportowanie',
comment: 'Zezwól edytorom na komentowanie',
parentHint: 'Ograniczenia pliku i obiektu nadrzędnego nadal obowiązują.',
},
featureSearch: {
title: 'Wyszukaj funkcje',
placeholder: 'Wpisz nazwę funkcji lub menu...',
empty: 'Nie znaleziono dostępnych funkcji',
ribbon: 'Wstążka',
contextMenu: 'Menu kontekstowe',
},
emojiPicker: {
search: 'Szukaj',
random: 'Losowe emoji',
recents: 'Ostatnie',
emojis: 'Emoji',
animals: 'Zwierzęta',
food: 'Jedzenie',
activities: 'Aktywności',
places: 'Miejsca',
objects: 'Obiekty',
symbols: 'Symbole',
searchResults: 'Wyniki wyszukiwania',
noResults: 'Nie znaleziono emoji',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Matematyka',
greek: 'Grecki',
common: 'Ogólne',
},
toolbar: {
heading: {
normal: 'Normalny',
title: 'Tytuł',
subTitle: 'Podtytuł',
1: 'Nagłówek 1',
2: 'Nagłówek 2',
3: 'Nagłówek 3',
4: 'Nagłówek 4',
5: 'Nagłówek 5',
},
},
ribbon: {
start: 'Start',
startDesc: 'Zainicjuj arkusz i ustaw podstawowe parametry.',
insert: 'Wstaw',
insertDesc: 'Wstaw wiersze, kolumny, wykresy i inne elementy.',
formulas: 'Formuły',
formulasDesc: 'Użyj funkcji i formuł do obliczeń danych.',
data: 'Dane',
dataDesc: 'Zarządzaj danymi, w tym import, sortowanie i filtrowanie.',
view: 'Widok',
viewDesc: 'Przełączaj tryby widoku i dostosowuj efekt wyświetlania.',
others: 'Inne',
othersDesc: 'Inne funkcje i ustawienia.',
more: 'Więcej',
},
fontFamily: {
'not-supported': 'Nie znaleziono takiej czcionki w systemie, używana jest czcionka domyślna.',
},
'shortcut-panel': {
title: 'Skróty klawiszowe',
},
shortcut: {
undo: 'Cofnij',
redo: 'Ponów',
cut: 'Wytnij',
copy: 'Kopiuj',
paste: 'Wklej',
'shortcut-panel': 'Przełącz panel skrótów',
},
'common-edit': 'Skróty do częstej edycji',
'toggle-shortcut-panel': 'Przełącz panel skrótów',
navigation: {
back: 'Wstecz',
previous: 'Poprzedni',
next: 'Następny',
},
sidebar: {
panel: 'Panel boczny',
resize: 'Zmień rozmiar panelu bocznego',
close: 'Zamknij panel boczny',
},
beforeClose: {
title: 'Niektóre zmiany nie zostały zapisane',
},
clipboard: {
authentication: {
title: 'Brak uprawnień',
content: 'Zezwól Univer na dostęp do schowka.',
},
},
rangeSelector: {
cancel: 'Anuluj',
},
'global-shortcut': 'Globalny skrót klawiszowy',
row: 'Wiersz',
column: 'Kolumna',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/pt-BR.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'Menu',
zoom: 'Zoom',
zoomIn: 'Ampliar',
zoomOut: 'Reduzir',
resetZoom: 'Redefinir zoom',
},
objectPermission: {
operationDenied: 'Este conteúdo está protegido. Esta ação não é permitida.',
remove: 'Remover proteção',
roleOwner: 'Proprietário do arquivo',
roleEditor: 'Editor do arquivo',
selectedCount: 'Selecionados: {0}',
searchPeople: 'Buscar pessoas',
noMatchingPeople: 'Nenhuma pessoa correspondente',
loadMore: 'Carregar mais',
fileHint: 'O compartilhamento do arquivo define os membros. Estas configurações restringem as ações desses membros.',
documentParent: 'As restrições de edição do documento também se aplicam a esta seção.',
paragraphParent: 'As restrições de edição do documento e da seção que contém este parágrafo também se aplicam.',
documentObjectParent: 'O documento e as seções e parágrafos que contêm ou ancoram este objeto também podem restringir sua edição.',
slideParent: 'As restrições de edição da apresentação também se aplicam.',
slideObjectParent: 'As restrições de edição da apresentação e do slide ou mestre que contém este objeto também se aplicam.',
baseParent: 'As restrições de edição do Base também se aplicam.',
baseObjectParent: 'As restrições de edição do Base e da tabela que contém este objeto também se aplicam.',
recordParent: 'As restrições do Base e da tabela continuam válidas. Editar um valor também requer permissão para seu campo.',
boardParent: 'As restrições de edição do quadro também se aplicam a este objeto.',
ownerInherit: 'Proprietário do arquivo, acesso herdado',
peopleError: 'Não foi possível carregar as pessoas. Tente novamente.',
document: 'Documento',
section: 'Seção',
paragraph: 'Parágrafo',
entity: 'Objeto',
presentation: 'Apresentação',
page: 'Slide',
master: 'Exibição mestre',
base: 'Base',
table: 'Tabela',
field: 'Campo',
record: 'Registro',
view: 'Exibição',
board: 'Quadro',
objectName: '{0}: {1}',
search: 'Buscar objetos',
empty: 'Nenhum objeto correspondente',
more: 'Exibindo os primeiros 100 objetos. Use a busca para refinar a lista.',
title: 'Permissões',
cancel: 'Cancelar',
save: 'Salvar',
saving: 'Salvando…',
loading: 'Carregando…',
conflict: 'As permissões foram alteradas. Recarregue antes de salvar.',
error: 'Não foi possível carregar ou salvar as permissões. Suas alterações foram mantidas.',
reload: 'Recarregar',
denied: 'Você não pode gerenciar as permissões deste objeto.',
edit: 'Quem pode editar',
all: 'Todos os editores do arquivo',
owner: 'Apenas o proprietário do objeto',
members: 'Membros selecionados',
copy: 'Permitir que editores copiem',
print: 'Permitir que editores imprimam',
export: 'Permitir que editores exportem',
comment: 'Permitir que editores comentem',
parentHint: 'As restrições do arquivo e do objeto pai continuam válidas.',
},
featureSearch: {
title: 'Pesquisar recursos',
placeholder: 'Digite um recurso ou nome de menu...',
empty: 'Nenhum recurso disponível encontrado',
ribbon: 'Faixa de opções',
contextMenu: 'Menu de contexto',
},
emojiPicker: {
search: 'Buscar',
random: 'Emoji aleatório',
recents: 'Recentes',
emojis: 'Emojis',
animals: 'Animais',
food: 'Comida',
activities: 'Atividades',
places: 'Lugares',
objects: 'Objetos',
symbols: 'Símbolos',
searchResults: 'Resultados da busca',
noResults: 'Nenhum emoji encontrado',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Matemática',
greek: 'Grego',
common: 'Comuns',
},
toolbar: {
heading: {
normal: 'Normal',
title: 'Título',
subTitle: 'Subtítulo',
1: 'Título 1',
2: 'Título 2',
3: 'Título 3',
4: 'Título 4',
5: 'Título 5',
},
},
ribbon: {
start: 'Início',
startDesc: 'Inicie a planilha e defina os parâmetros básicos.',
insert: 'Inserir',
insertDesc: 'Insira linhas, colunas, gráficos e vários outros elementos.',
formulas: 'Fórmulas',
formulasDesc: 'Use funções e fórmulas para cálculos de dados.',
data: 'Dados',
dataDesc: 'Gerencie dados, incluindo importação, classificação e filtragem.',
view: 'Exibir',
viewDesc: 'Alterne modos de exibição e ajuste o efeito de visualização.',
others: 'Outros',
othersDesc: 'Outras funções e configurações.',
more: 'Mais',
},
fontFamily: {
'not-supported': 'Fonte não encontrada no sistema, usando a fonte padrão.',
},
'shortcut-panel': {
title: 'Atalhos',
},
shortcut: {
undo: 'Desfazer',
redo: 'Refazer',
cut: 'Recortar',
copy: 'Copiar',
paste: 'Colar',
'shortcut-panel': 'Alternar Painel de Atalhos',
},
'common-edit': 'Atalhos Comuns de Edição',
'toggle-shortcut-panel': 'Alternar Painel de Atalhos',
navigation: {
back: 'Voltar',
previous: 'Anterior',
next: 'Próximo',
},
sidebar: {
panel: 'Painel lateral',
resize: 'Redimensionar o painel lateral',
close: 'Fechar o painel lateral',
},
beforeClose: {
title: 'Algumas alterações não foram salvas',
},
clipboard: {
authentication: {
title: 'Permissão Negada',
content: 'Permita que o Univer acesse sua área de transferência.',
},
},
rangeSelector: {
cancel: 'Cancelar',
},
'global-shortcut': 'Atalho Global',
row: 'Linha',
column: 'Coluna',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/ru-RU.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'Меню',
zoom: 'Масштаб',
zoomIn: 'Увеличить',
zoomOut: 'Уменьшить',
resetZoom: 'Сбросить масштаб',
},
objectPermission: {
operationDenied: 'Этот контент защищён. Действие недоступно.',
remove: 'Удалить защиту',
roleOwner: 'Владелец файла',
roleEditor: 'Редактор файла',
selectedCount: 'Выбрано: {0}',
searchPeople: 'Поиск пользователей',
noMatchingPeople: 'Подходящие пользователи не найдены',
loadMore: 'Загрузить ещё',
fileHint: 'Состав участников определяется настройками общего доступа к файлу. Эти настройки ограничивают действия участников.',
documentParent: 'Ограничения на редактирование документа также действуют для этого раздела.',
paragraphParent: 'Ограничения на редактирование документа и содержащего раздела также действуют для этого абзаца.',
documentObjectParent: 'Документ, а также разделы и абзацы, содержащие этот объект или служащие его привязкой, также могут ограничивать редактирование.',
slideParent: 'Ограничения на редактирование презентации также действуют.',
slideObjectParent: 'Ограничения на редактирование презентации и содержащего слайда или образца также действуют.',
baseParent: 'Ограничения на редактирование Base также действуют.',
baseObjectParent: 'Ограничения на редактирование Base и содержащей таблицы также действуют.',
recordParent: 'Ограничения Base и таблицы сохраняются. Для изменения значения также требуется разрешение на редактирование его поля.',
boardParent: 'Ограничения на редактирование доски также действуют для этого объекта.',
ownerInherit: 'Владелец файла, унаследованный доступ',
peopleError: 'Не удалось загрузить пользователей. Повторите попытку.',
document: 'Документ',
section: 'Раздел',
paragraph: 'Абзац',
entity: 'Объект',
presentation: 'Презентация',
page: 'Слайд',
master: 'Режим образца',
base: 'Base',
table: 'Таблица',
field: 'Поле',
record: 'Запись',
view: 'Представление',
board: 'Доска',
objectName: '{0}: {1}',
search: 'Поиск объектов',
empty: 'Подходящие объекты не найдены',
more: 'Показаны первые 100 объектов. Используйте поиск, чтобы сузить список.',
title: 'Права доступа',
cancel: 'Отмена',
save: 'Сохранить',
saving: 'Сохранение…',
loading: 'Загрузка…',
conflict: 'Разрешения изменились. Перезагрузите их перед сохранением.',
error: 'Не удалось загрузить или сохранить разрешения. Ваши изменения сохранены.',
reload: 'Перезагрузить',
denied: 'Вы не можете управлять разрешениями для этого объекта.',
edit: 'Кто может редактировать',
all: 'Все редакторы файла',
owner: 'Только владелец объекта',
members: 'Выбранные участники',
copy: 'Разрешить редакторам копирование',
print: 'Разрешить редакторам печать',
export: 'Разрешить редакторам экспорт',
comment: 'Разрешить редакторам комментарии',
parentHint: 'Ограничения файла и родительского объекта сохраняются.',
},
featureSearch: {
title: 'Поиск функций',
placeholder: 'Введите название функции или меню...',
empty: 'Доступные функции не найдены',
ribbon: 'Лента',
contextMenu: 'Контекстное меню',
},
emojiPicker: {
search: 'Поиск',
random: 'Случайный эмодзи',
recents: 'Недавние',
emojis: 'Эмодзи',
animals: 'Животные',
food: 'Еда',
activities: 'Активности',
places: 'Места',
objects: 'Объекты',
symbols: 'Символы',
searchResults: 'Результаты поиска',
noResults: 'Эмодзи не найден',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Математика',
greek: 'Греческие буквы',
common: 'Общие',
},
toolbar: {
heading: {
normal: 'Обычный текст',
title: 'Заголовок',
subTitle: 'Подзаголовок',
1: 'Заголовок 1',
2: 'Заголовок 2',
3: 'Заголовок 3',
4: 'Заголовок 4',
5: 'Заголовок 5',
},
},
ribbon: {
start: 'Начало',
startDesc: 'Инициализация рабочей таблицы и установка основных параметров.',
insert: 'Вставка',
insertDesc: 'Вставка строк, столбцов, графиков и различных других элементов.',
formulas: 'Формулы',
formulasDesc: 'Использование функций и формул для вычислений данных.',
data: 'Данные',
dataDesc: 'Управление данными, включая импорт, сортировку и фильтрацию.',
view: 'Вид',
viewDesc: 'Смена режимов отображения и настройка эффекта отображения.',
others: 'Другие',
othersDesc: 'Другие функции и настройки.',
more: 'Больше',
},
fontFamily: {
'not-supported': 'В системе не найден такой шрифт, используется шрифт по умолчанию.',
},
'shortcut-panel': {
title: 'Сочетания клавиш',
},
shortcut: {
undo: 'Отменить',
redo: 'Повторить',
cut: 'Вырезать',
copy: 'Копировать',
paste: 'Вставить',
'shortcut-panel': 'Переключить панель сочетания клавиш',
},
'common-edit': 'Общие команды редактирования',
'toggle-shortcut-panel': 'Переключить панель сочетания клавиш',
navigation: {
back: 'Назад',
previous: 'Предыдущий',
next: 'Следующий',
},
sidebar: {
panel: 'Боковая панель',
resize: 'Изменить размер боковой панели',
close: 'Закрыть боковую панель',
},
beforeClose: {
title: 'Некоторые изменения не сохранены',
},
clipboard: {
authentication: {
title: 'Доступ запрещен',
content: 'Пожалуйста, разрешите Univer доступ к вашему буферу обмена.',
},
},
rangeSelector: {
cancel: 'Отменить',
},
'global-shortcut': 'Сочетания клавиш',
row: 'Строка',
column: 'Столбец',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/sk-SK.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'Ponuka',
zoom: 'Priblíženie',
zoomIn: 'Priblížiť',
zoomOut: 'Oddialiť',
resetZoom: 'Obnoviť priblíženie',
},
objectPermission: {
operationDenied: 'Tento obsah je chránený. Túto akciu nemožno vykonať.',
remove: 'Odstrániť ochranu',
roleOwner: 'Vlastník súboru',
roleEditor: 'Editor súboru',
selectedCount: 'Vybrané: {0}',
searchPeople: 'Hľadať osoby',
noMatchingPeople: 'Žiadne zodpovedajúce osoby',
loadMore: 'Načítať viac',
fileHint: 'Zdieľanie súboru určuje členstvo. Tieto nastavenia obmedzujú činnosti týchto členov.',
documentParent: 'Obmedzenia úprav dokumentu sa vzťahujú aj na túto sekciu.',
paragraphParent: 'Obmedzenia úprav dokumentu a sekcie obsahujúcej tento odsek sa vzťahujú aj na tento odsek.',
documentObjectParent: 'Dokument a sekcie či odseky, ktoré obsahujú alebo ukotvujú tento objekt, môžu tiež obmedzovať úpravy.',
slideParent: 'Platia aj obmedzenia úprav prezentácie.',
slideObjectParent: 'Platia aj obmedzenia úprav prezentácie a snímky alebo predlohy obsahujúcej tento objekt.',
baseParent: 'Platia aj obmedzenia úprav Base.',
baseObjectParent: 'Platia aj obmedzenia úprav Base a tabuľky obsahujúcej tento objekt.',
recordParent: 'Obmedzenia Base a tabuľky naďalej platia. Úprava hodnoty vyžaduje aj oprávnenie pre príslušné pole.',
boardParent: 'Obmedzenia úprav tabule sa vzťahujú aj na tento objekt.',
ownerInherit: 'Vlastník súboru, zdedený prístup',
peopleError: 'Osoby sa nepodarilo načítať. Skúste to znova.',
document: 'Dokument',
section: 'Sekcia',
paragraph: 'Odsek',
entity: 'Objekt',
presentation: 'Prezentácia',
page: 'Snímka',
master: 'Zobrazenie predlohy',
base: 'Base',
table: 'Tabuľka',
field: 'Pole',
record: 'Záznam',
view: 'Zobrazenie',
board: 'Tabuľa',
objectName: '{0}: {1}',
search: 'Hľadať objekty',
empty: 'Žiadne zodpovedajúce objekty',
more: 'Zobrazuje sa prvých 100 objektov. Pomocou vyhľadávania zúžte zoznam.',
title: 'Oprávnenia',
cancel: 'Zrušiť',
save: 'Uložiť',
saving: 'Ukladá sa…',
loading: 'Načítava sa…',
conflict: 'Oprávnenia sa zmenili. Pred uložením ich znova načítajte.',
error: 'Oprávnenia sa nepodarilo načítať alebo uložiť. Vaše zmeny zostali zachované.',
reload: 'Znova načítať',
denied: 'Nemôžete spravovať oprávnenia tohto objektu.',
edit: 'Kto môže upravovať',
all: 'Všetci editori súboru',
owner: 'Iba vlastník objektu',
members: 'Vybraní členovia',
copy: 'Povoliť editorom kopírovanie',
print: 'Povoliť editorom tlač',
export: 'Povoliť editorom export',
comment: 'Povoliť editorom komentovanie',
parentHint: 'Obmedzenia súboru a nadradeného objektu naďalej platia.',
},
featureSearch: {
title: 'Vyhľadať funkcie',
placeholder: 'Zadajte názov funkcie alebo ponuky...',
empty: 'Nenašli sa žiadne dostupné funkcie',
ribbon: 'Pás s nástrojmi',
contextMenu: 'Kontextová ponuka',
},
emojiPicker: {
search: 'Hľadať',
random: 'Náhodné emoji',
recents: 'Nedávne',
emojis: 'Emoji',
animals: 'Zvieratá',
food: 'Jedlo',
activities: 'Aktivity',
places: 'Miesta',
objects: 'Objekty',
symbols: 'Symboly',
searchResults: 'Výsledky vyhľadávania',
noResults: 'Nenašli sa žiadne emoji',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Matematika',
greek: 'Grécke písmená',
common: 'Bežné',
},
toolbar: {
heading: {
normal: 'Normálne',
title: 'Nadpis',
subTitle: 'Podnadpis',
1: 'Nadpis 1',
2: 'Nadpis 2',
3: 'Nadpis 3',
4: 'Nadpis 4',
5: 'Nadpis 5',
},
},
ribbon: {
start: 'Začiatok',
startDesc: 'Inicializujte hárok a nastavte základné parametre.',
insert: 'Vložiť',
insertDesc: 'Vložte riadky, stĺpce, grafy a ďalšie prvky.',
formulas: 'Vzorce',
formulasDesc: 'Používajte funkcie a vzorce na výpočty údajov.',
data: 'Údaje',
dataDesc: 'Spravujte údaje vrátane importu, triedenia a filtrovania.',
view: 'Zobrazenie',
viewDesc: 'Prepínajte režimy zobrazenia a upravte efekt zobrazenia.',
others: 'Ostatné',
othersDesc: 'Ďalšie funkcie a nastavenia.',
more: 'Viac',
},
fontFamily: {
'not-supported': 'Toto písmo sa v systéme nenašlo, používa sa predvolené písmo.',
},
'shortcut-panel': {
title: 'Skratky',
},
shortcut: {
undo: 'Späť',
redo: 'Znova',
cut: 'Vystrihnúť',
copy: 'Kopírovať',
paste: 'Prilepiť',
'shortcut-panel': 'Prepnúť panel skratiek',
},
'common-edit': 'Bežné úpravové skratky',
'toggle-shortcut-panel': 'Prepnúť panel skratiek',
navigation: {
back: 'Späť',
previous: 'Predchádzajúce',
next: 'Ďalšie',
},
sidebar: {
panel: 'Bočný panel',
resize: 'Zmeniť veľkosť bočného panela',
close: 'Zavrieť bočný panel',
},
beforeClose: {
title: 'Niektoré zmeny neboli uložené',
},
clipboard: {
authentication: {
title: 'Povolenie zamietnuté',
content: 'Povoľte Univeru prístup k schránke.',
},
},
rangeSelector: {
cancel: 'Zrušiť',
},
'global-shortcut': 'Globálna skratka',
row: 'Riadok',
column: 'Stĺpec',
},
};
export default locale;

View File

@@ -0,0 +1,20 @@
/**
* 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 type { LocaleLeafKeys } from '@univerjs/core';
import type enUS from './en-US';
export type LocaleKey = LocaleLeafKeys<typeof enUS>;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/vi-VN.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: 'Trình đơn',
zoom: 'Thu phóng',
zoomIn: 'Phóng to',
zoomOut: 'Thu nhỏ',
resetZoom: 'Đặt lại thu phóng',
},
objectPermission: {
operationDenied: 'Nội dung này được bảo vệ. Không thể thực hiện thao tác này.',
remove: 'Xóa bảo vệ',
roleOwner: 'Chủ sở hữu tệp',
roleEditor: 'Người chỉnh sửa tệp',
selectedCount: 'Đã chọn: {0}',
searchPeople: 'Tìm người dùng',
noMatchingPeople: 'Không có người dùng phù hợp',
loadMore: 'Tải thêm',
fileHint: 'Cài đặt chia sẻ tệp quản lý thành viên. Các cài đặt này giới hạn thao tác của những thành viên đó.',
documentParent: 'Giới hạn chỉnh sửa tài liệu cũng áp dụng cho phần này.',
paragraphParent: 'Giới hạn chỉnh sửa tài liệu và phần chứa đoạn văn cũng áp dụng cho đoạn văn này.',
documentObjectParent: 'Tài liệu và các phần, đoạn văn chứa hoặc neo đối tượng này cũng có thể giới hạn việc chỉnh sửa.',
slideParent: 'Giới hạn chỉnh sửa bản trình bày cũng áp dụng.',
slideObjectParent: 'Giới hạn chỉnh sửa bản trình bày và trang chiếu hoặc trang chiếu cái chứa đối tượng cũng áp dụng.',
baseParent: 'Giới hạn chỉnh sửa Base cũng áp dụng.',
baseObjectParent: 'Giới hạn chỉnh sửa Base và bảng chứa đối tượng cũng áp dụng.',
recordParent: 'Giới hạn của Base và bảng vẫn áp dụng. Chỉnh sửa giá trị cũng cần quyền đối với trường tương ứng.',
boardParent: 'Giới hạn chỉnh sửa bảng vẽ cũng áp dụng cho đối tượng này.',
ownerInherit: 'Chủ sở hữu tệp, quyền truy cập kế thừa',
peopleError: 'Không thể tải người dùng. Vui lòng thử lại.',
document: 'Tài liệu',
section: 'Phần',
paragraph: 'Đoạn văn',
entity: 'Đối tượng',
presentation: 'Bản trình bày',
page: 'Trang chiếu',
master: 'Chế độ xem trang chiếu cái',
base: 'Base',
table: 'Bảng',
field: 'Trường',
record: 'Bản ghi',
view: 'Chế độ xem',
board: 'Bảng vẽ',
objectName: '{0}: {1}',
search: 'Tìm đối tượng',
empty: 'Không có đối tượng phù hợp',
more: 'Đang hiển thị 100 đối tượng đầu tiên. Hãy tìm kiếm để thu hẹp danh sách.',
title: 'Quyền',
cancel: 'Hủy',
save: 'Lưu',
saving: 'Đang lưu…',
loading: 'Đang tải…',
conflict: 'Quyền đã thay đổi. Hãy tải lại trước khi lưu.',
error: 'Không thể tải hoặc lưu quyền. Các thay đổi của bạn vẫn được giữ lại.',
reload: 'Tải lại',
denied: 'Bạn không thể quản lý quyền của đối tượng này.',
edit: 'Ai có thể chỉnh sửa',
all: 'Tất cả người chỉnh sửa tệp',
owner: 'Chỉ chủ sở hữu đối tượng',
members: 'Thành viên được chọn',
copy: 'Cho phép người chỉnh sửa sao chép',
print: 'Cho phép người chỉnh sửa in',
export: 'Cho phép người chỉnh sửa xuất',
comment: 'Cho phép người chỉnh sửa bình luận',
parentHint: 'Giới hạn của tệp và đối tượng cấp trên vẫn áp dụng.',
},
featureSearch: {
title: 'Tìm kiếm tính năng',
placeholder: 'Nhập tên tính năng hoặc menu...',
empty: 'Không tìm thấy tính năng khả dụng',
ribbon: 'Dải băng',
contextMenu: 'Menu ngữ cảnh',
},
emojiPicker: {
search: 'Tìm kiếm',
random: 'Emoji ngẫu nhiên',
recents: 'Gần đây',
emojis: 'Emoji',
animals: 'Động vật',
food: 'Đồ ăn',
activities: 'Hoạt động',
places: 'Địa điểm',
objects: 'Đồ vật',
symbols: 'Ký hiệu',
searchResults: 'Kết quả tìm kiếm',
noResults: 'Không tìm thấy emoji',
...emojiLocale,
},
symbolPicker: {
mathematics: 'Toán học',
greek: 'Chữ Hy Lạp',
common: 'Thông dụng',
},
toolbar: {
heading: {
normal: 'Văn bản',
title: 'Tiêu đề',
subTitle: 'Tiêu đề phụ',
1: 'Tiêu đề 1',
2: 'Tiêu đề 2',
3: 'Tiêu đề 3',
4: 'Tiêu đề 4',
5: 'Tiêu đề 5',
},
},
ribbon: {
start: 'Bắt đầu',
startDesc: 'Khởi tạo bảng tính và thiết lập các tham số cơ bản.',
insert: 'Chèn',
insertDesc: 'Chèn hàng, cột, biểu đồ và các phần tử khác.',
formulas: 'Công thức',
formulasDesc: 'Sử dụng hàm và công thức để tính toán dữ liệu.',
data: 'Dữ liệu',
dataDesc: 'Sắp xếp, lọc và phân tích dữ liệu.',
view: 'Xem',
viewDesc: 'Chuyển đổi chế độ xem và điều chỉnh hiệu ứng hiển thị.',
others: 'Khác',
othersDesc: 'Các chức năng và cài đặt khác.',
more: 'Thêm',
},
fontFamily: {
'not-supported': 'Không tìm thấy phông chữ này trong hệ thống, sử dụng phông chữ mặc định.',
},
'shortcut-panel': {
title: 'Bảng phím tắt',
},
shortcut: {
undo: 'Hoàn tác',
redo: 'Làm lại',
cut: 'Cắt',
copy: 'Sao chép',
paste: 'Dán',
'shortcut-panel': 'Mở/Đóng bảng phím tắt',
},
'common-edit': 'Chỉnh sửa thường dùng',
'toggle-shortcut-panel': 'Mở/Đóng bảng phím tắt',
navigation: {
back: 'Quay lại',
previous: 'Trước',
next: 'Tiếp theo',
},
sidebar: {
panel: 'Bảng bên',
resize: 'Thay đổi kích thước bảng bên',
close: 'Đóng bảng bên',
},
beforeClose: {
title: 'Một số thay đổi chưa được lưu',
},
clipboard: {
authentication: {
title: 'Không thể truy cập vào bảng nhớ tạm',
content: 'Vui lòng cho phép Univer truy cập vào bảng nhớ tạm của bạn.',
},
},
rangeSelector: {
cancel: 'Hủy',
},
'global-shortcut': 'Phím tắt toàn cầu',
row: 'Hàng',
column: 'Cột',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/zh-CN.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: '菜单',
zoom: '缩放',
zoomIn: '放大',
zoomOut: '缩小',
resetZoom: '重置缩放',
},
objectPermission: {
operationDenied: '该内容已受保护,无法执行此操作。',
remove: '删除保护',
roleOwner: '文件所有者',
roleEditor: '文件编辑者',
selectedCount: '已选择 {0} 人',
searchPeople: '搜索人员',
noMatchingPeople: '没有匹配的人员',
loadMore: '加载更多',
fileHint: '文件成员由分享功能管理;此处设置成员可以执行的操作。',
documentParent: '此节同时受整文档编辑权限限制。',
paragraphParent: '此段落同时受整文档及所在节的编辑权限限制。',
documentObjectParent: '此对象还可能受到整文档及其所在或锚定段落、节的编辑权限限制。',
slideParent: '同时受整份演示文稿的编辑权限限制。',
slideObjectParent: '同时受整份演示文稿及所在页面或母版的编辑权限限制。',
baseParent: '同时受整个 Base 的编辑权限限制。',
baseObjectParent: '同时受整个 Base 及所在表的编辑权限限制。',
recordParent: '同时受整个 Base 及所在表的限制;修改字段值还需具备对应字段的编辑权限。',
boardParent: '此对象同时受整个白板的编辑权限限制。',
ownerInherit: '文件所有者,继承权限',
peopleError: '人员列表加载失败,请重试。',
document: '文档',
section: '节',
paragraph: '段落',
entity: '对象',
presentation: '演示文稿',
page: '页面',
master: '母版视图',
base: '多维表格',
table: '数据表',
field: '字段',
record: '记录',
view: '视图',
board: '白板',
objectName: '{0}:{1}',
search: '搜索对象',
empty: '没有匹配的对象',
more: '显示前 100 个对象,请搜索以缩小范围。',
title: '权限',
cancel: '取消',
save: '保存',
saving: '正在保存…',
loading: '正在加载…',
conflict: '权限已被修改,请重新加载后再保存。',
error: '无法读取或保存权限,已保留你的修改。',
reload: '重新加载',
denied: '你没有管理此对象权限的权限。',
edit: '谁可以编辑',
all: '所有文件编辑者',
owner: '仅对象所有者',
members: '指定成员',
copy: '允许编辑者复制',
print: '允许编辑者打印',
export: '允许编辑者导出',
comment: '允许编辑者评论',
parentHint: '仍受文件及上级对象权限的限制。',
},
featureSearch: {
title: '搜索功能',
placeholder: '输入功能或菜单名称…',
empty: '未找到当前可用的功能',
ribbon: '功能区',
contextMenu: '右键菜单',
},
emojiPicker: {
search: '搜索',
random: '随机表情',
recents: '最近使用',
emojis: '表情',
animals: '动物',
food: '食物',
activities: '活动',
places: '地点',
objects: '物品',
symbols: '符号',
searchResults: '搜索结果',
noResults: '未找到表情',
...emojiLocale,
},
symbolPicker: {
mathematics: '数学',
greek: '希腊字母',
common: '常用',
},
toolbar: {
heading: {
normal: '正文',
title: '标题',
subTitle: '副标题',
1: '标题1',
2: '标题2',
3: '标题3',
4: '标题4',
5: '标题5',
},
},
ribbon: {
start: '开始',
startDesc: '初始化工作表并设置基本参数。',
insert: '插入',
insertDesc: '插入行、列、图表和各种其他元素。',
formulas: '公式',
formulasDesc: '使用函数和公式进行数据计算。',
data: '数据',
dataDesc: '管理数据,包括导入、排序和筛选。',
view: '视图',
viewDesc: '切换视图模式并调整显示效果。',
others: '其他',
othersDesc: '其他功能和设置。',
more: '更多',
},
fontFamily: {
'not-supported': '系统中未找到该字体,使用默认字体。',
},
'shortcut-panel': {
title: '快捷键面板',
},
shortcut: {
undo: '撤销',
redo: '重做',
cut: '剪切',
copy: '复制',
paste: '粘贴',
'shortcut-panel': '打开收起快捷键面板',
},
'common-edit': '常用编辑',
'toggle-shortcut-panel': '打开收起快捷键面板',
navigation: {
back: '返回',
previous: '上一个',
next: '下一个',
},
sidebar: {
panel: '侧边栏',
resize: '调整侧边栏大小',
close: '关闭侧边栏',
},
beforeClose: {
title: '部分更改尚未保存',
},
clipboard: {
authentication: {
title: '无法访问剪贴板',
content: '请允许 Univer 访问您的剪贴板。',
},
},
rangeSelector: {
cancel: '取消',
},
'global-shortcut': '全局快捷键',
row: '行',
column: '列',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/zh-HK.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: '選單',
zoom: '縮放',
zoomIn: '放大',
zoomOut: '縮小',
resetZoom: '重設縮放',
},
objectPermission: {
operationDenied: '此內容已受保護,無法執行此操作。',
remove: '刪除保護',
roleOwner: '檔案擁有者',
roleEditor: '檔案編輯者',
selectedCount: '已選擇 {0} 人',
searchPeople: '搜尋人員',
noMatchingPeople: '沒有符合的人員',
loadMore: '載入更多',
fileHint: '檔案成員由分享功能管理;此處設定成員可以執行的操作。',
documentParent: '此節同時受整份文件的編輯權限限制。',
paragraphParent: '此段落同時受整份文件及所在節的編輯權限限制。',
documentObjectParent: '此物件還可能受到整份文件及其所在或錨定段落、節的編輯權限限制。',
slideParent: '同時受整份簡報的編輯權限限制。',
slideObjectParent: '同時受整份簡報及所在頁面或母片的編輯權限限制。',
baseParent: '同時受整個 Base 的編輯權限限制。',
baseObjectParent: '同時受整個 Base 及所在表的編輯權限限制。',
recordParent: '同時受整個 Base 及所在表的限制;修改欄位值還需具備對應欄位的編輯權限。',
boardParent: '此物件同時受整個白板的編輯權限限制。',
ownerInherit: '檔案擁有者,繼承權限',
peopleError: '人員清單載入失敗,請重試。',
document: '文件',
section: '節',
paragraph: '段落',
entity: '物件',
presentation: '簡報',
page: '頁面',
master: '母版檢視',
base: '多維表格',
table: '資料表',
field: '欄位',
record: '記錄',
view: '檢視',
board: '白板',
objectName: '{0}:{1}',
search: '搜尋物件',
empty: '沒有匹配的物件',
more: '顯示前 100 個物件,請搜尋以缩小範圍。',
title: '權限',
cancel: '取消',
save: '儲存',
saving: '正在儲存…',
loading: '正在載入…',
conflict: '權限已被修改,請重新載入后再儲存。',
error: '无法讀取或儲存權限,已保留你的修改。',
reload: '重新載入',
denied: '你沒有管理此物件權限的權限。',
edit: '誰可以編輯',
all: '所有文件編輯者',
owner: '僅物件所有者',
members: '指定成員',
copy: '允許編輯者複製',
print: '允許編輯者列印',
export: '允許編輯者匯出',
comment: '允許編輯者評論',
parentHint: '仍受檔案及上級物件權限的限制。',
},
featureSearch: {
title: '搜尋功能',
placeholder: '輸入功能或選單名稱…',
empty: '找不到目前可用的功能',
ribbon: '功能區',
contextMenu: '內容選單',
},
emojiPicker: {
search: '搜尋',
random: '隨機表情',
recents: '最近使用',
emojis: '表情',
animals: '動物',
food: '食物',
activities: '活動',
places: '地點',
objects: '物品',
symbols: '符號',
searchResults: '搜尋結果',
noResults: '找不到表情',
...emojiLocale,
},
symbolPicker: {
mathematics: '數學',
greek: '希臘字母',
common: '常用',
},
toolbar: {
heading: {
normal: '正文',
title: '標題',
subTitle: '副標題',
1: '標題 1',
2: '標題 2',
3: '標題 3',
4: '標題 4',
5: '標題 5',
},
},
ribbon: {
start: '開始',
startDesc: '初始化工作表並設定基本參數。',
insert: '插入',
insertDesc: '插入行、列、圖表和各種其他元素。',
formulas: '公式',
formulasDesc: '使用函數和公式進行數據計算。',
data: '資料',
dataDesc: '管理資料,包括匯入、排序和篩選。',
view: '視圖',
viewDesc: '切換視圖模式並調整顯示效果。',
others: '其他',
othersDesc: '其他功能和設定。',
more: '更多',
},
fontFamily: {
'not-supported': '系統中未找到該字型,使用預設字型。',
},
'shortcut-panel': {
title: '快捷鍵面板',
},
shortcut: {
undo: '撤銷',
redo: '重做',
cut: '剪切',
copy: '複製',
paste: '貼上',
'shortcut-panel': '開啟收起快捷鍵面板',
},
'common-edit': '常用編輯',
'toggle-shortcut-panel': '開啟收起快速鍵面板',
navigation: {
back: '返回',
previous: '上一個',
next: '下一個',
},
sidebar: {
panel: '側邊欄',
resize: '調整側邊欄大小',
close: '關閉側邊欄',
},
beforeClose: {
title: '部分變更尚未儲存',
},
clipboard: {
authentication: {
title: '無法存取剪貼簿',
content: '請允許 Univer 存取您的剪貼簿。 ',
},
},
rangeSelector: {
cancel: '取消',
},
'global-shortcut': '全域快捷鍵',
row: '列',
column: '欄',
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type enUS from './en-US';
import emojiLocale from './emoji-locale/zh-TW.generated';
const locale: typeof enUS = {
ui: {
accessibility: {
menu: '選單',
zoom: '縮放',
zoomIn: '放大',
zoomOut: '縮小',
resetZoom: '重設縮放',
},
objectPermission: {
operationDenied: '此內容已受保護,無法執行此操作。',
remove: '刪除保護',
roleOwner: '檔案擁有者',
roleEditor: '檔案編輯者',
selectedCount: '已選擇 {0} 人',
searchPeople: '搜尋人員',
noMatchingPeople: '沒有符合的人員',
loadMore: '載入更多',
fileHint: '檔案成員由分享功能管理;此處設定成員可以執行的操作。',
documentParent: '此節同時受整份文件的編輯權限限制。',
paragraphParent: '此段落同時受整份文件及所在節的編輯權限限制。',
documentObjectParent: '此物件還可能受到整份文件及其所在或錨定段落、節的編輯權限限制。',
slideParent: '同時受整份簡報的編輯權限限制。',
slideObjectParent: '同時受整份簡報及所在頁面或母片的編輯權限限制。',
baseParent: '同時受整個 Base 的編輯權限限制。',
baseObjectParent: '同時受整個 Base 及所在表的編輯權限限制。',
recordParent: '同時受整個 Base 及所在表的限制;修改欄位值還需具備對應欄位的編輯權限。',
boardParent: '此物件同時受整個白板的編輯權限限制。',
ownerInherit: '檔案擁有者,繼承權限',
peopleError: '人員清單載入失敗,請重試。',
document: '文件',
section: '節',
paragraph: '段落',
entity: '物件',
presentation: '簡報',
page: '頁面',
master: '母版檢視',
base: '多維表格',
table: '資料表',
field: '欄位',
record: '記錄',
view: '檢視',
board: '白板',
objectName: '{0}:{1}',
search: '搜尋物件',
empty: '沒有匹配的物件',
more: '顯示前 100 個物件,請搜尋以缩小範圍。',
title: '權限',
cancel: '取消',
save: '儲存',
saving: '正在儲存…',
loading: '正在載入…',
conflict: '權限已被修改,請重新載入后再儲存。',
error: '无法讀取或儲存權限,已保留你的修改。',
reload: '重新載入',
denied: '你沒有管理此物件權限的權限。',
edit: '誰可以編輯',
all: '所有文件編輯者',
owner: '僅物件所有者',
members: '指定成員',
copy: '允許編輯者複製',
print: '允許編輯者列印',
export: '允許編輯者匯出',
comment: '允許編輯者評論',
parentHint: '仍受檔案及上級物件權限的限制。',
},
featureSearch: {
title: '搜尋功能',
placeholder: '輸入功能或選單名稱…',
empty: '找不到目前可用的功能',
ribbon: '功能區',
contextMenu: '快顯功能表',
},
emojiPicker: {
search: '搜尋',
random: '隨機表情',
recents: '最近使用',
emojis: '表情',
animals: '動物',
food: '食物',
activities: '活動',
places: '地點',
objects: '物品',
symbols: '符號',
searchResults: '搜尋結果',
noResults: '找不到表情',
...emojiLocale,
},
symbolPicker: {
mathematics: '數學',
greek: '希臘字母',
common: '常用',
},
toolbar: {
heading: {
normal: '正文',
title: '標題',
subTitle: '副標題',
1: '標題 1',
2: '標題 2',
3: '標題 3',
4: '標題 4',
5: '標題 5',
},
},
ribbon: {
start: '開始',
startDesc: '初始化工作表並設定基本參數。',
insert: '插入',
insertDesc: '插入行、列、圖表和各種其他元素。',
formulas: '公式',
formulasDesc: '使用函數和公式進行數據計算。',
data: '資料',
dataDesc: '管理資料,包括匯入、排序和篩選。',
view: '視圖',
viewDesc: '切換視圖模式並調整顯示效果。',
others: '其他',
othersDesc: '其他功能和設定。',
more: '更多',
},
fontFamily: {
'not-supported': '系統中未找到該字體,使用預設字體。',
},
'shortcut-panel': {
title: '快捷鍵面板',
},
shortcut: {
undo: '撤銷',
redo: '重做',
cut: '剪切',
copy: '複製',
paste: '貼上',
'shortcut-panel': '開啟收起快捷鍵面板',
},
'common-edit': '常用編輯',
'toggle-shortcut-panel': '開啟收起快速鍵面板',
navigation: {
back: '返回',
previous: '上一個',
next: '下一個',
},
sidebar: {
panel: '側邊欄',
resize: '調整側邊欄大小',
close: '關閉側邊欄',
},
beforeClose: {
title: '部分變更尚未儲存',
},
clipboard: {
authentication: {
title: '無法存取剪貼簿',
content: '請允許 Univer 存取您的剪貼簿。 ',
},
},
rangeSelector: {
cancel: '取消',
},
'global-shortcut': '全域快捷鍵',
row: '列',
column: '欄',
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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 type { LocaleKey } from '../locale/types';
import type { IMenuButtonItem } from '../services/menu/menu';
import { OpenFeatureSearchOperation } from '../commands/operations/open-feature-search.operation';
import { MenuItemType } from '../services/menu/menu';
export function FeatureSearchMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: OpenFeatureSearchOperation.id,
title: 'ui.featureSearch.title',
tooltip: 'ui.featureSearch.title',
icon: 'FeatureSearchIcon',
type: MenuItemType.BUTTON,
};
}

View File

@@ -0,0 +1,71 @@
/**
* 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 type { IAccessor } from '@univerjs/core';
import type { LocaleKey } from '../locale/types';
import type { IMenuButtonItem } from '../services/menu/menu';
import {
EDITOR_ACTIVATED,
FOCUSING_FX_BAR_EDITOR,
IContextService,
IUndoRedoService,
LocaleService,
RedoCommand,
UndoCommand,
} from '@univerjs/core';
import { combineLatest, merge, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { MenuItemType } from '../services/menu/menu';
const undoRedoDisableFactory$ = (accessor: IAccessor, isUndo: boolean) => {
const undoRedoService = accessor.get(IUndoRedoService);
const contextService = accessor.get(IContextService);
return combineLatest([
undoRedoService.undoRedoStatus$.pipe(map((v) => isUndo ? v.undos <= 0 : v.redos <= 0)),
merge([of({}), contextService.contextChanged$]),
]).pipe(map(([undoDisable]) => {
return undoDisable || contextService.getContextValue(EDITOR_ACTIVATED) || contextService.getContextValue(FOCUSING_FX_BAR_EDITOR);
}));
};
const rtlIconFactory$ = (accessor: IAccessor, ltrIcon: string, rtlIcon: string) => {
const localeService = accessor.get(LocaleService);
return localeService.direction$.pipe(map((direction) => direction === 'rtl' ? rtlIcon : ltrIcon));
};
export function UndoMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: UndoCommand.id,
type: MenuItemType.BUTTON,
icon: rtlIconFactory$(accessor, 'UndoIcon', 'RedoIcon'),
title: 'ui.shortcut.undo',
tooltip: 'ui.shortcut.undo',
disabled$: undoRedoDisableFactory$(accessor, true),
};
}
export function RedoMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: RedoCommand.id,
type: MenuItemType.BUTTON,
icon: rtlIconFactory$(accessor, 'RedoIcon', 'UndoIcon'),
title: 'ui.shortcut.redo',
tooltip: 'ui.shortcut.redo',
disabled$: undoRedoDisableFactory$(accessor, false),
};
}

View File

@@ -0,0 +1,35 @@
/**
* 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 type { MenuSchemaType } from '../services/menu/menu-manager.service';
import { RedoCommand, UndoCommand } from '@univerjs/core';
import { RibbonStartGroup } from '../services/menu/types';
import { RedoMenuItemFactory, UndoMenuItemFactory } from './history.menu';
export const mobileMenuSchema: MenuSchemaType = {
[RibbonStartGroup.HISTORY]: {
[UndoCommand.id]: {
order: 0,
gridLayout: { row: 1, column: 1, iconSize: 18 },
menuItemFactory: UndoMenuItemFactory,
},
[RedoCommand.id]: {
order: 1,
gridLayout: { row: 2, column: 1, iconSize: 18 },
menuItemFactory: RedoMenuItemFactory,
},
},
};

View File

@@ -0,0 +1,49 @@
/**
* 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 type { MenuSchemaType } from '../services/menu/menu-manager.service';
import { RedoCommand, UndoCommand } from '@univerjs/core';
import { OpenFeatureSearchOperation } from '../commands/operations/open-feature-search.operation';
import { ToggleShortcutPanelOperation } from '../commands/operations/toggle-shortcut-panel.operation';
import { RibbonStartGroup } from '../services/menu/types';
import { FeatureSearchMenuItemFactory } from './feature-search.menu';
import { RedoMenuItemFactory, UndoMenuItemFactory } from './history.menu';
import { ShortcutPanelMenuItemFactory } from './shortcut-panel.menu';
export const menuSchema: MenuSchemaType = {
[RibbonStartGroup.HISTORY]: {
[UndoCommand.id]: {
order: 0,
gridLayout: { row: 1, column: 1, iconSize: 18 },
menuItemFactory: UndoMenuItemFactory,
},
[RedoCommand.id]: {
order: 1,
gridLayout: { row: 2, column: 1, iconSize: 18 },
menuItemFactory: RedoMenuItemFactory,
},
},
[RibbonStartGroup.OTHERS]: {
[ToggleShortcutPanelOperation.id]: {
order: 1,
menuItemFactory: ShortcutPanelMenuItemFactory,
},
[OpenFeatureSearchOperation.id]: {
order: 2,
menuItemFactory: FeatureSearchMenuItemFactory,
},
},
};

View File

@@ -0,0 +1,30 @@
/**
* 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 type { LocaleKey } from '../locale/types';
import type { IMenuButtonItem } from '../services/menu/menu';
import { ToggleShortcutPanelOperation } from '../commands/operations/toggle-shortcut-panel.operation';
import { MenuItemType } from '../services/menu/menu';
export function ShortcutPanelMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: ToggleShortcutPanelOperation.id,
title: 'ui.toggle-shortcut-panel',
tooltip: 'ui.toggle-shortcut-panel',
icon: 'KeyboardIcon',
type: MenuItemType.BUTTON,
};
}

View File

@@ -0,0 +1,178 @@
/**
* 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 type { IUniverUIConfig } from './config/config';
import {
DependentOn,
generateRandomId,
IConfigService,
IConfirmService,
IContextService,
ILocalStorageService,
Inject,
Injector,
merge,
mergeOverrideWithDependencies,
Plugin,
registerDependencies,
touchDependencies,
} from '@univerjs/core';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { ComponentManager } from './common/component-manager';
import { IconManager } from './common/icon-manager';
import { ZIndexManager } from './common/z-index-manager';
import { defaultPluginConfig, UI_PLUGIN_CONFIG_KEY } from './config/config';
import { MOBILE_UI_MODE } from './const';
import { ErrorController } from './controllers/error/error.controller';
import { MobileComponentsController } from './controllers/mobile/components.controller';
import { SharedController } from './controllers/shared-shortcut.controller';
import { MobileUIController } from './controllers/ui/ui-mobile.controller';
import { IUIController } from './controllers/ui/ui.controller';
import { DesktopBeforeCloseService, IBeforeCloseService } from './services/before-close/before-close.service';
import { BrowserClipboardService, IClipboardInterfaceService } from './services/clipboard/clipboard-interface.service';
import { MobileConfirmService } from './services/confirm/mobile-confirm.service';
import { ContextMenuHostService, IContextMenuHostService } from './services/contextmenu/contextmenu-host.service';
import { ContextMenuService, IContextMenuService } from './services/contextmenu/contextmenu.service';
import { IDialogService } from './services/dialog/dialog.service';
import { MobileDialogService } from './services/dialog/mobile-dialog.service';
import { CanvasFloatDomPreviewService, CanvasFloatDomService } from './services/dom/canvas-dom-layer.service';
import { FontService, IFontService } from './services/font.service';
import { DesktopGalleryService } from './services/gallery/desktop-gallery.service';
import { IGalleryService } from './services/gallery/gallery.service';
import { DesktopLayoutService, ILayoutService } from './services/layout/layout.service';
import { DesktopLocalFileService } from './services/local-file/desktop-local-file.service';
import { ILocalFileService } from './services/local-file/local-file.service';
import { DesktopLocalStorageService } from './services/local-storage/local-storage.service';
import { IMenuManagerService, MenuManagerService } from './services/menu/menu-manager.service';
import { DesktopMessageService } from './services/message/desktop-message.service';
import { IMessageService } from './services/message/message.service';
import { DesktopNotificationService } from './services/notification/desktop-notification.service';
import { INotificationService } from './services/notification/notification.service';
import { IUIPartsService, UIPartsService } from './services/parts/parts.service';
import { IPlatformService, PlatformService } from './services/platform/platform.service';
import { CanvasPopupService, ICanvasPopupService } from './services/popup/canvas-popup.service';
import {
IUnitPresenceUIAdapterRegistry,
UnitPresenceUIAdapterRegistry,
} from './services/presence/unit-presence-ui-adapter.service';
import { DesktopRibbonService, IRibbonService } from './services/ribbon/ribbon.service';
import { IUIRuntimeScopeService, UIRuntimeScopeService } from './services/runtime-scope/ui-runtime-scope.service';
import { IShortcutService, ShortcutService } from './services/shortcut/shortcut.service';
import { DesktopSidebarService } from './services/sidebar/desktop-sidebar.service';
import { ISidebarService } from './services/sidebar/sidebar.service';
import { ThemeSwitcherService } from './services/theme-switcher/theme-switcher.service';
import { UndoRedoGroupService } from './services/undo-redo/undo-redo-group.service';
import { IWorkbenchService, WorkbenchService } from './services/workbench/workbench.service';
export const DISABLE_AUTO_FOCUS_KEY = 'DISABLE_AUTO_FOCUS';
/**
* UI plugin provides basic interaction with users. Including workbench (menus, UI parts, notifications etc.), copy paste, shortcut.
*/
@DependentOn(UniverRenderEnginePlugin)
export class UniverMobileUIPlugin extends Plugin {
static override pluginName = 'UNIVER_MOBILE_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverUIConfig> = defaultPluginConfig,
@IContextService private readonly _contextService: IContextService,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{
popupRootId: `univer-popup-portal-${generateRandomId(6)}`,
disableAutoFocus: true,
},
defaultPluginConfig,
this._config
);
if (rest.disableAutoFocus) {
this._contextService.setContextValue(DISABLE_AUTO_FOCUS_KEY, true);
}
this._contextService.setContextValue(MOBILE_UI_MODE, true);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
registerDependencies(this._injector, mergeOverrideWithDependencies([
[ComponentManager],
[IconManager],
[MobileComponentsController],
[ThemeSwitcherService],
[UndoRedoGroupService],
[IWorkbenchService, { useClass: WorkbenchService }],
[ZIndexManager],
[IUIPartsService, { useClass: UIPartsService }],
[ILayoutService, { useClass: DesktopLayoutService }],
[IRibbonService, { useClass: DesktopRibbonService }],
[IShortcutService, { useClass: ShortcutService }],
[IPlatformService, { useClass: PlatformService }],
[IUnitPresenceUIAdapterRegistry, { useClass: UnitPresenceUIAdapterRegistry }],
[IMenuManagerService, { useClass: MenuManagerService }],
[IContextMenuHostService, { useClass: ContextMenuHostService }],
[IContextMenuService, { useClass: ContextMenuService }],
[IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }],
[IClipboardInterfaceService, { useClass: BrowserClipboardService, lazy: true }],
[INotificationService, { useClass: DesktopNotificationService, lazy: true }],
[IGalleryService, { useClass: DesktopGalleryService, lazy: true }],
[IDialogService, { useClass: MobileDialogService, lazy: true }],
[IConfirmService, { useClass: MobileConfirmService, lazy: true }],
[ISidebarService, { useClass: DesktopSidebarService, lazy: true }],
// Mount the message host before a command can publish its first error.
[IMessageService, { useClass: DesktopMessageService }],
[ILocalStorageService, { useClass: DesktopLocalStorageService, lazy: true }],
[IBeforeCloseService, { useClass: DesktopBeforeCloseService }],
[ILocalFileService, { useClass: DesktopLocalFileService }],
[ICanvasPopupService, { useClass: CanvasPopupService }],
[IFontService, { useClass: FontService }],
[CanvasFloatDomService],
[CanvasFloatDomPreviewService],
[
IUIController,
{
useFactory: (injector: Injector) => injector.createInstance(MobileUIController, this._config),
deps: [Injector],
},
],
[SharedController],
[ErrorController],
], this._config.override));
touchDependencies(this._injector, [
[MobileComponentsController],
[IUIController],
[ErrorController],
]);
}
override onReady(): void {
touchDependencies(this._injector, [
[SharedController],
]);
}
}

186
packages/ui/src/plugin.ts Normal file
View File

@@ -0,0 +1,186 @@
/**
* 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 type { IUniverUIConfig } from './config/config';
import {
DependentOn,
generateRandomId,
IConfigService,
IConfirmService,
IContextService,
ILocalStorageService,
Inject,
Injector,
merge,
mergeOverrideWithDependencies,
Plugin,
registerDependencies,
touchDependencies,
} from '@univerjs/core';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { ComponentManager } from './common/component-manager';
import { IconManager } from './common/icon-manager';
import { ZIndexManager } from './common/z-index-manager';
import { defaultPluginConfig, UI_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { ErrorController } from './controllers/error/error.controller';
import { FeatureSearchController } from './controllers/feature-search/feature-search.controller';
import { SharedController } from './controllers/shared-shortcut.controller';
import { ShortcutPanelController } from './controllers/shortcut-display/shortcut-panel.controller';
import { DesktopUIController } from './controllers/ui/ui-desktop.controller';
import { IUIController } from './controllers/ui/ui.controller';
import { DesktopBeforeCloseService, IBeforeCloseService } from './services/before-close/before-close.service';
import { BrowserClipboardService, IClipboardInterfaceService } from './services/clipboard/clipboard-interface.service';
import { DesktopConfirmService } from './services/confirm/desktop-confirm.service';
import { ContextMenuHostService, IContextMenuHostService } from './services/contextmenu/contextmenu-host.service';
import { ContextMenuService, IContextMenuService } from './services/contextmenu/contextmenu.service';
import { DesktopDialogService } from './services/dialog/desktop-dialog.service';
import { IDialogService } from './services/dialog/dialog.service';
import { CanvasFloatDomPreviewService, CanvasFloatDomService } from './services/dom/canvas-dom-layer.service';
import { FontService, IFontService } from './services/font.service';
import { DesktopGalleryService } from './services/gallery/desktop-gallery.service';
import { IGalleryService } from './services/gallery/gallery.service';
import { DesktopLayoutService, ILayoutService } from './services/layout/layout.service';
import { DesktopLocalFileService } from './services/local-file/desktop-local-file.service';
import { ILocalFileService } from './services/local-file/local-file.service';
import { DesktopLocalStorageService } from './services/local-storage/local-storage.service';
import { IMenuManagerService, MenuManagerService } from './services/menu/menu-manager.service';
import { DesktopMessageService } from './services/message/desktop-message.service';
import { IMessageService } from './services/message/message.service';
import { DesktopNotificationService } from './services/notification/desktop-notification.service';
import { INotificationService } from './services/notification/notification.service';
import { IUIPartsService, UIPartsService } from './services/parts/parts.service';
import { IPlatformService, PlatformService } from './services/platform/platform.service';
import { CanvasPopupService, ICanvasPopupService } from './services/popup/canvas-popup.service';
import {
IUnitPresenceUIAdapterRegistry,
UnitPresenceUIAdapterRegistry,
} from './services/presence/unit-presence-ui-adapter.service';
import { IRibbonOverrideService, RibbonOverrideService } from './services/ribbon/ribbon-override.service';
import { DesktopRibbonService, IRibbonService } from './services/ribbon/ribbon.service';
import { IUIRuntimeScopeService, UIRuntimeScopeService } from './services/runtime-scope/ui-runtime-scope.service';
import { ShortcutPanelService } from './services/shortcut/shortcut-panel.service';
import { IShortcutService, ShortcutService } from './services/shortcut/shortcut.service';
import { DesktopSidebarService } from './services/sidebar/desktop-sidebar.service';
import { ISidebarService } from './services/sidebar/sidebar.service';
import { ThemeSwitcherService } from './services/theme-switcher/theme-switcher.service';
import { UndoRedoGroupService } from './services/undo-redo/undo-redo-group.service';
import { IWorkbenchService, WorkbenchService } from './services/workbench/workbench.service';
export const DISABLE_AUTO_FOCUS_KEY = 'DISABLE_AUTO_FOCUS';
/**
* UI plugin provides basic interaction with users. Including workbench (menus, UI parts, notifications etc.), copy paste, shortcut.
*/
@DependentOn(UniverRenderEnginePlugin)
export class UniverUIPlugin extends Plugin {
static override pluginName = 'UNIVER_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverUIConfig> = defaultPluginConfig,
@IContextService private readonly _contextService: IContextService,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{
popupRootId: `univer-popup-portal-${generateRandomId(6)}`,
},
defaultPluginConfig,
this._config
);
if (rest.disableAutoFocus) {
this._contextService.setContextValue(DISABLE_AUTO_FOCUS_KEY, true);
}
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
registerDependencies(this._injector, mergeOverrideWithDependencies([
[ComponentManager],
[IconManager],
[ComponentsController],
[ThemeSwitcherService],
[UndoRedoGroupService],
[ZIndexManager],
[ShortcutPanelService],
[IUIPartsService, { useClass: UIPartsService }],
[IWorkbenchService, { useClass: WorkbenchService }],
[ILayoutService, { useClass: DesktopLayoutService }],
[IRibbonService, { useClass: DesktopRibbonService }],
[IRibbonOverrideService, { useClass: RibbonOverrideService }],
[IShortcutService, { useClass: ShortcutService }],
[IPlatformService, { useClass: PlatformService }],
[IUnitPresenceUIAdapterRegistry, { useClass: UnitPresenceUIAdapterRegistry }],
[IMenuManagerService, { useClass: MenuManagerService }],
[IContextMenuHostService, { useClass: ContextMenuHostService }],
[IContextMenuService, { useClass: ContextMenuService }],
[IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }],
[IClipboardInterfaceService, { useClass: BrowserClipboardService, lazy: true }],
[INotificationService, { useClass: DesktopNotificationService, lazy: true }],
[IGalleryService, { useClass: DesktopGalleryService, lazy: true }],
[IDialogService, { useClass: DesktopDialogService, lazy: true }],
[IConfirmService, { useClass: DesktopConfirmService, lazy: true }],
[ISidebarService, { useClass: DesktopSidebarService, lazy: true }],
// Mount the message host before a command can publish its first error.
[IMessageService, { useClass: DesktopMessageService }],
[ILocalStorageService, { useClass: DesktopLocalStorageService, lazy: true }],
[IBeforeCloseService, { useClass: DesktopBeforeCloseService }],
[ILocalFileService, { useClass: DesktopLocalFileService }],
[ICanvasPopupService, { useClass: CanvasPopupService }],
[IFontService, { useClass: FontService }],
[CanvasFloatDomService],
[CanvasFloatDomPreviewService],
[IUIController, {
useFactory: (injector: Injector) => injector.createInstance(DesktopUIController, this._config),
deps: [Injector],
}],
[SharedController],
[ErrorController],
[FeatureSearchController],
[ShortcutPanelController],
], this._config.override));
touchDependencies(this._injector, [
[ComponentsController],
[IUIController],
[ErrorController],
]);
}
override onReady(): void {
touchDependencies(this._injector, [
[SharedController],
[FeatureSearchController],
]);
}
override onSteady(): void {
touchDependencies(this._injector, [
[ShortcutPanelController],
]);
}
}

View File

@@ -0,0 +1,126 @@
/**
* 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 type { IUniverUIConfig } from '../../config/config';
import { ConfigService, IConfigService, Injector } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { UI_PLUGIN_CONFIG_KEY } from '../../config/config';
import { FontService, IFontService } from '../font.service';
class CustomFontConfigService extends ConfigService {
override getConfig<T>(key: string): T {
if (key === UI_PLUGIN_CONFIG_KEY) {
return {
customFontFamily: {
override: true,
list: [{ value: 'OnlyCustom', label: 'Only Custom', category: 'display' }],
},
} as IUniverUIConfig as T;
}
return super.getConfig<T>(key);
}
}
function createService(configService: new (...args: any[]) => IConfigService = ConfigService): IFontService {
const injector = new Injector();
injector.add([IConfigService, { useClass: configService }]);
injector.add([IFontService, { useClass: FontService }]);
return injector.get(IFontService);
}
describe('FontService', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('maintains the font catalog used by font family pickers', () => {
const service = createService();
service.addFont({ value: 'Inter', label: 'Inter', category: 'sans-serif' });
service.updateFont('Inter', { label: 'Inter UI' });
expect(service.getFontByValue('Inter')).toMatchObject({ label: 'Inter UI' });
expect(service.removeFont('Inter')).toBe(true);
expect(service.getFontByValue('Inter')).toBeUndefined();
});
it('uses the filtered reference catalog as the default font list', () => {
const values = createService().getFonts().map((font) => font.value);
expect(values).toHaveLength(197);
expect(values.slice(0, 8)).toEqual([
'宋体',
'Arial',
'Baskerville',
'Comic Sans MS',
'Courier New',
'Helvetica',
'Times New Roman',
'Verdana',
]);
expect(values).toContain('Zapfino');
expect(values).not.toContain('Microsoft YaHei');
expect(values).not.toContain('正文字体');
expect(values.some((value) => value.startsWith('MiSans'))).toBe(false);
expect(values.some((value) => value.toUpperCase().includes('WPS'))).toBe(false);
});
it('rejects duplicate font values to keep font selection stable', () => {
const service = createService();
const arial = service.getFontByValue('Arial')!;
expect(() => service.addFont(arial)).toThrow('[FontService]: Font with value "Arial" already exists.');
});
it('loads override font configuration and rejects updates to missing fonts', () => {
const service = createService(CustomFontConfigService);
expect(service.getFonts()).toEqual([{ value: 'OnlyCustom', label: 'Only Custom', category: 'display' }]);
expect(() => service.updateFont('Arial', { label: 'Missing' })).toThrow('Font with value "Arial" not found.');
expect(service.removeFont('Arial')).toBe(false);
});
it('checks font support by comparing measured text widths', () => {
const measured: string[] = [];
vi.stubGlobal('document', {
createElement: () => ({
getContext: () => ({
font: '',
measureText() {
measured.push(this.font);
return { width: this.font.includes('"CustomFont"') ? 120 : 100 };
},
}),
}),
});
const service = createService();
expect(service.isFontSupported('CustomFont')).toBe(true);
expect(measured).toContain('72px "CustomFont", monospace');
});
it('returns false when the canvas context is unavailable', () => {
vi.stubGlobal('document', {
createElement: () => ({
getContext: () => null,
}),
});
const service = createService();
expect(service.isFontSupported('MissingFont')).toBe(false);
});
});

View File

@@ -0,0 +1,99 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import type { INotificationOptions } from '../../../views/notification/Notification';
import { Injector, LocaleService, LocaleType } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopNotificationService } from '../../notification/desktop-notification.service';
import { INotificationService } from '../../notification/notification.service';
import { BuiltInUIPart, IUIPartsService, UIPartsService } from '../../parts/parts.service';
import { DesktopBeforeCloseService, IBeforeCloseService } from '../before-close.service';
class FakeNotificationService extends DesktopNotificationService {
static notifications: INotificationOptions[] = [];
override show(params: INotificationOptions): IDisposable {
FakeNotificationService.notifications.push(params);
return { dispose: () => {} };
}
}
function createService(): IBeforeCloseService {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([LocaleService, { useClass: LocaleService }]);
injector.add([INotificationService, { useClass: FakeNotificationService }]);
injector.add([IBeforeCloseService, { useClass: DesktopBeforeCloseService }]);
const localeService = injector.get(LocaleService);
localeService.load({
[LocaleType.EN_US]: {},
});
return injector.get(IBeforeCloseService);
}
describe('DesktopBeforeCloseService', () => {
afterEach(() => {
vi.unstubAllGlobals();
FakeNotificationService.notifications = [];
});
it('blocks unload with combined messages and clears handlers on dispose', () => {
const windowTarget = new EventTarget();
vi.stubGlobal('window', windowTarget);
const service = createService();
service.registerBeforeClose(() => 'Unsaved sheet');
const disposable = service.registerBeforeClose(() => 'Pending upload');
const event = new Event('beforeunload') as BeforeUnloadEvent;
windowTarget.dispatchEvent(event);
expect(event.returnValue).toBe('Unsaved sheet\nPending upload');
disposable.dispose();
(service as DesktopBeforeCloseService).dispose();
const nextEvent = new Event('beforeunload') as BeforeUnloadEvent;
windowTarget.dispatchEvent(nextEvent);
expect(nextEvent.returnValue).toBeUndefined();
});
it('runs close callbacks when the page unloads and stops after disposal', () => {
const windowTarget = new EventTarget();
vi.stubGlobal('window', windowTarget);
const service = createService();
const closed: string[] = [];
const disposable = service.registerOnClose(() => closed.push('closed'));
windowTarget.dispatchEvent(new Event('unload'));
disposable.dispose();
windowTarget.dispatchEvent(new Event('unload'));
expect(closed).toEqual(['closed']);
});
it('registers the notification UI part used for blocked close messages', () => {
const windowTarget = new EventTarget();
vi.stubGlobal('window', windowTarget);
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([LocaleService, { useClass: LocaleService }]);
injector.add([INotificationService, { useClass: FakeNotificationService }]);
injector.add([IBeforeCloseService, { useClass: DesktopBeforeCloseService }]);
injector.get(IBeforeCloseService);
expect(injector.get(IUIPartsService).getComponents(BuiltInUIPart.GLOBAL).size).toBe(1);
});
});

View File

@@ -0,0 +1,114 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { createIdentifier, Disposable, Inject, LocaleService } from '@univerjs/core';
import { INotificationService } from '../notification/notification.service';
export interface IBeforeCloseService {
/**
* Provide a callback to check if the web page could be closed safely.
*
* @param callback The callback to check if the web page could be closed safely.
* It should return a string to show a message to the user. If the return value is undefined,
* the web page could be closed safely.
*/
registerBeforeClose(callback: () => string | undefined): IDisposable;
/**
* Provide a callback to be called when the web page is closed.
*
* @param callback The callback to be called when the web page is closed.
*/
registerOnClose(callback: () => void): IDisposable;
}
export const IBeforeCloseService = createIdentifier<IBeforeCloseService>('univer.ui.before-close-service');
export class DesktopBeforeCloseService extends Disposable implements IBeforeCloseService {
private _beforeUnloadCallbacks: Array<() => string | undefined> = [];
private _onCloseCallbacks: Array<() => void> = [];
private readonly _beforeUnloadHandler: (event: BeforeUnloadEvent) => string | undefined;
private readonly _unloadHandler: () => void;
constructor(
@INotificationService private readonly _notificationService: INotificationService,
@Inject(LocaleService) private readonly _localeService: LocaleService
) {
super();
this._beforeUnloadHandler = (_event: BeforeUnloadEvent) => {
let event = _event;
const message = this._beforeUnloadCallbacks
.map((callback) => callback())
.filter((m) => !!m)
.join('\n');
if (message) {
this._notificationService.show({
type: 'error',
title: this._localeService.t<LocaleKey>('ui.beforeClose.title'),
content: message,
});
if (typeof event === 'undefined') {
event = window.event as BeforeUnloadEvent;
}
event.returnValue = message;
return message;
}
};
this._unloadHandler = () => {
this._onCloseCallbacks.forEach((callback) => callback());
};
this._init();
}
override dispose(): void {
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
window.removeEventListener('unload', this._unloadHandler);
this._beforeUnloadCallbacks = [];
this._onCloseCallbacks = [];
super.dispose();
}
registerBeforeClose(callback: () => string | undefined): IDisposable {
this._beforeUnloadCallbacks.push(callback);
return {
dispose: () => {
this._beforeUnloadCallbacks = this._beforeUnloadCallbacks.filter((cb) => cb !== callback);
},
};
}
registerOnClose(callback: () => void): IDisposable {
this._onCloseCallbacks.push(callback);
return {
dispose: () => {
this._onCloseCallbacks = this._onCloseCallbacks.filter((cb) => cb !== callback);
},
};
}
private _init(): void {
window.addEventListener('beforeunload', this._beforeUnloadHandler);
window.addEventListener('unload', this._unloadHandler);
}
}

View File

@@ -0,0 +1,241 @@
/**
* 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 type { ILogService, LocaleService } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BrowserClipboardService } from '../clipboard-interface.service';
import { supportClipboardAPI } from '../clipboard-utils';
vi.mock('../clipboard-utils', () => ({
supportClipboardAPI: vi.fn(),
}));
class MockClipboardItem {
constructor(public readonly items: Record<string, Blob>) {}
}
function createService() {
const localeService = {
t: vi.fn((key: string) => key),
} as unknown as LocaleService;
const logService = {
error: vi.fn(),
} as unknown as ILogService;
const notificationService = {
show: vi.fn(),
};
const service = new BrowserClipboardService(localeService, logService, notificationService as any);
return { service, localeService, logService, notificationService };
}
describe('BrowserClipboardService', () => {
beforeEach(() => {
vi.mocked(supportClipboardAPI).mockReturnValue(true);
vi.stubGlobal('ClipboardItem', MockClipboardItem);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
write: vi.fn().mockResolvedValue(undefined),
writeText: vi.fn().mockResolvedValue(undefined),
read: vi.fn().mockResolvedValue([]),
readText: vi.fn().mockResolvedValue('clipboard-text'),
},
});
(document as any).execCommand = vi.fn(() => true);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('should expose clipboard support from utility', () => {
vi.mocked(supportClipboardAPI).mockReturnValue(true);
const { service } = createService();
expect(service.supportClipboard).toBe(true);
});
it('should write plain text and html with modern clipboard api', async () => {
const { service } = createService();
await service.write('plain', '<b>plain</b>');
expect(navigator.clipboard.write).toHaveBeenCalledTimes(1);
});
it('should write text with modern clipboard api', async () => {
const { service } = createService();
await service.writeText('hello');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello');
});
it('should fallback to legacy copy when clipboard api is unsupported', async () => {
vi.mocked(supportClipboardAPI).mockReturnValue(false);
const { service } = createService();
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
await service.writeText('legacy-text');
await service.write('legacy', '<i>legacy</i>');
expect(document.execCommand).toHaveBeenCalledWith('copy');
});
it('should fallback to legacy copy when ClipboardItem is unavailable', async () => {
vi.stubGlobal('ClipboardItem', undefined);
const { service } = createService();
await service.write('legacy', '<i>legacy</i>');
expect(document.execCommand).toHaveBeenCalledWith('copy');
expect(navigator.clipboard.write).not.toHaveBeenCalled();
});
it('should write raw sanitized html during legacy copy event', async () => {
vi.mocked(supportClipboardAPI).mockReturnValue(false);
const { service } = createService();
const clipboardData = {
setData: vi.fn(),
};
vi.mocked(document.execCommand).mockImplementation(() => {
const event = new Event('copy', { cancelable: true }) as ClipboardEvent;
Object.defineProperty(event, 'clipboardData', {
value: clipboardData,
});
document.dispatchEvent(event);
return true;
});
await service.write('plain', '<div onclick="alert(1)" style="color:red">rich</div>');
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'plain');
expect(clipboardData.setData).toHaveBeenCalledWith('text/html', '<div style="color: red">rich</div>');
expect(document.body.lastElementChild?.textContent).not.toBe('rich');
});
it('should preserve Univer Excel metadata while sanitizing legacy html', async () => {
vi.mocked(supportClipboardAPI).mockReturnValue(false);
const { service } = createService();
const clipboardData = {
setData: vi.fn(),
};
vi.mocked(document.execCommand).mockImplementation(() => {
const event = new Event('copy', { cancelable: true }) as ClipboardEvent;
Object.defineProperty(event, 'clipboardData', {
value: clipboardData,
});
document.dispatchEvent(event);
return true;
});
await service.write(
'merged',
'<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><meta name="ProgId" content="Excel.Sheet"><meta name="Generator" content="Univer"><script>alert(1)</script></head><body><!--StartFragment--><table data-copy-id="copy-id"><tbody><tr><td rowspan="3" onclick="alert(1)">merged</td></tr></tbody></table><!--EndFragment--></body></html>'
);
const copiedHtml = clipboardData.setData.mock.calls.find(([type]) => type === 'text/html')?.[1];
expect(copiedHtml).toContain('xmlns:x="urn:schemas-microsoft-com:office:excel"');
expect(copiedHtml).toContain('<meta name="ProgId" content="Excel.Sheet">');
expect(copiedHtml).toContain('<!--StartFragment--><table data-copy-id="copy-id">');
expect(copiedHtml).toContain('<td rowspan="3">merged</td>');
expect(copiedHtml).toContain('</table><!--EndFragment-->');
expect(copiedHtml).not.toContain('<script');
expect(copiedHtml).not.toContain('onclick');
});
it('should sanitize html before legacy copy', async () => {
vi.mocked(supportClipboardAPI).mockReturnValue(false);
const { service } = createService();
let copiedHtml = '';
vi.mocked(document.execCommand).mockImplementation(() => {
copiedHtml = document.body.lastElementChild?.innerHTML ?? '';
return true;
});
await service.write(
'legacy',
'<div data-copy-id="copy-id" onclick="alert(1)"><a href="javascript:alert(1)">unsafe</a><img src="data:image/png;base64,ZmFrZQ==" onerror="alert(1)" /><span style="color: red; background-image: url(javascript:alert(1))">safe</span><script>alert(1)</script></div>'
);
const copiedDocument = new DOMParser().parseFromString(copiedHtml, 'text/html');
const wrapper = copiedDocument.body.firstElementChild as HTMLDivElement | null;
const link = copiedDocument.querySelector('a');
const image = copiedDocument.querySelector('img');
const span = copiedDocument.querySelector('span');
expect(wrapper?.getAttribute('data-copy-id')).toBe('copy-id');
expect(wrapper?.hasAttribute('onclick')).toBe(false);
expect(copiedDocument.querySelector('script')).toBeNull();
expect(link?.hasAttribute('href')).toBe(false);
expect(image?.getAttribute('src')).toBe('data:image/png;base64,ZmFrZQ==');
expect(image?.hasAttribute('onerror')).toBe(false);
expect(span?.getAttribute('style')).toContain('color: red');
expect(span?.getAttribute('style')).not.toContain('url(');
});
it('should show notification when write/writeText fails', async () => {
const { service, logService, notificationService } = createService();
vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('writeText failed'));
vi.mocked(navigator.clipboard.write).mockRejectedValueOnce(new Error('write failed'));
await service.writeText('failed');
await service.write('failed', '<b>failed</b>');
expect(logService.error).toHaveBeenCalledTimes(2);
expect(notificationService.show).toHaveBeenCalledWith({
type: 'warning',
title: 'ui.clipboard.authentication.title',
content: 'ui.clipboard.authentication.content',
});
});
it('should throw for read/readText when clipboard api is unsupported', async () => {
vi.mocked(supportClipboardAPI).mockReturnValue(false);
const { service } = createService();
await expect(service.read()).rejects.toThrow('[BrowserClipboardService] read() is not supported on this platform.');
await expect(service.readText()).rejects.toThrow('[BrowserClipboardService] read() is not supported on this platform.');
});
it('should read clipboard data when supported', async () => {
const { service } = createService();
await expect(service.read()).resolves.toEqual([]);
await expect(service.readText()).resolves.toBe('clipboard-text');
});
it('should return fallback values and notify when read/readText fails', async () => {
const { service, logService, notificationService } = createService();
vi.mocked(navigator.clipboard.read).mockImplementationOnce(() => {
throw new Error('read failed');
});
vi.mocked(navigator.clipboard.readText).mockRejectedValueOnce(new Error('readText failed'));
await expect(service.read()).resolves.toEqual([]);
await expect(service.readText()).resolves.toBe('');
expect(logService.error).toHaveBeenCalledTimes(2);
expect(notificationService.show).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,32 @@
/**
* 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 { afterEach, describe, expect, it, vi } from 'vitest';
import { supportClipboardAPI } from '../clipboard-utils';
describe('clipboard-utils', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('detects whether text clipboard reads are available in the browser', () => {
vi.stubGlobal('navigator', { clipboard: { readText: () => Promise.resolve('copied') } });
expect(supportClipboardAPI()).toBe(true);
vi.stubGlobal('navigator', { clipboard: {} });
expect(supportClipboardAPI()).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
/**
* 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 type { IAccessor, IContextService } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { CopyCommand, CutCommand, PasteCommand, SheetPasteShortKeyCommandName } from '../clipboard.command';
describe('clipboard commands', () => {
it('should define copy/cut/paste command metadata', () => {
expect(CopyCommand.id).toBe('univer.command.copy');
expect(CutCommand.id).toBe('univer.command.cut');
expect(PasteCommand.id).toBe('univer.command.paste');
expect(CopyCommand.type).toBe(CommandType.COMMAND);
expect(CutCommand.type).toBe(CommandType.COMMAND);
expect(PasteCommand.type).toBe(CommandType.COMMAND);
expect(CopyCommand.multi).toBe(true);
expect(CutCommand.multi).toBe(true);
expect(PasteCommand.multi).toBe(true);
});
it('should return fixed command behavior and short key name', async () => {
const contextService = {} as IContextService;
const accessor = {} as IAccessor;
expect(CopyCommand.preconditions!(contextService)).toBe(false);
expect(CutCommand.preconditions!(contextService)).toBe(false);
expect(PasteCommand.preconditions!(contextService)).toBe(false);
expect(CopyCommand.handler(accessor)).toBe(true);
await expect(CutCommand.handler(accessor)).resolves.toBe(true);
expect(PasteCommand.handler(accessor)).toBe(true);
expect(SheetPasteShortKeyCommandName).toBe('sheet.command.paste-by-short-key');
});
});

View File

@@ -0,0 +1,556 @@
/**
* 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 type { LocaleKey } from '../../locale/types';
import { createIdentifier, Disposable, ILogService, Inject, LocaleService, Optional } from '@univerjs/core';
import { sanitizeParsedHtml } from '../../utils/html';
import { INotificationService } from '../notification/notification.service';
import { supportClipboardAPI } from './clipboard-utils';
export const PLAIN_TEXT_CLIPBOARD_MIME_TYPE = 'text/plain';
export const HTML_CLIPBOARD_MIME_TYPE = 'text/html';
export const FILE_PNG_CLIPBOARD_MIME_TYPE = 'image/png';
export const FILE__JPEG_CLIPBOARD_MIME_TYPE = 'image/jpeg';
export const FILE__BMP_CLIPBOARD_MIME_TYPE = 'image/bmp';
export const FILE__WEBP_CLIPBOARD_MIME_TYPE = 'image/webp';
export const FILE_SVG_XML_CLIPBOARD_MIME_TYPE = 'image/svg+xml';
export const imageMimeTypeSet = new Set([
FILE__BMP_CLIPBOARD_MIME_TYPE,
FILE__JPEG_CLIPBOARD_MIME_TYPE,
FILE__WEBP_CLIPBOARD_MIME_TYPE,
FILE_PNG_CLIPBOARD_MIME_TYPE,
]);
const DROP_CONTENT_TAGS = new Set([
'base',
'button',
'embed',
'form',
'frame',
'frameset',
'head',
'iframe',
'input',
'link',
'meta',
'object',
'script',
'select',
'style',
'template',
'textarea',
]);
const ALLOWED_HTML_TAGS = new Set([
'a',
'b',
'blockquote',
'br',
'caption',
'code',
'col',
'colgroup',
'dd',
'div',
'dl',
'dt',
'em',
'font',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'li',
'ol',
'p',
'pre',
's',
'span',
'strike',
'strong',
'sub',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
'u',
'ul',
]);
const ALLOWED_HTML_ATTRIBUTES = new Set([
'align',
'alt',
'aria-colindex',
'aria-colspan',
'aria-label',
'aria-labelledby',
'aria-rowindex',
'aria-rowspan',
'bgcolor',
'border',
'cellpadding',
'cellspacing',
'class',
'colspan',
'color',
'dir',
'face',
'headers',
'height',
'href',
'lang',
'rel',
'role',
'rowspan',
'size',
'src',
'style',
'target',
'title',
'valign',
'width',
]);
/**
* This interface provides an interface to access system's clipboard.
*/
export interface IClipboardInterfaceService {
/**
* Write plain text into clipboard. Use write() to write both plain text and html.
* @param text
*/
writeText(text: string): Promise<void>;
/**
* Write both plain text and html into clipboard.
* @param text
* @param html
*/
write(text: string, html: string, customData?: Record<string, string>): Promise<void>;
/**
* Read plain text from clipboard. Use read() to read both plain text and html.
* @returns plain text
*/
readText(): Promise<string>;
/**
* Read `ClipboardItem[]` from clipboard.
*/
read(): Promise<ClipboardItem[]>;
/**
* This property tells if the platform supports reading data directly from the clipboard.
*/
readonly supportClipboard: boolean;
}
export const IClipboardInterfaceService = createIdentifier<IClipboardInterfaceService>(
'univer.clipboard-interface-service'
);
export class BrowserClipboardService extends Disposable implements IClipboardInterfaceService {
get supportClipboard(): boolean {
return supportClipboardAPI();
}
constructor(
@Inject(LocaleService) private readonly _localeService: LocaleService,
@ILogService private readonly _logService: ILogService,
@Optional(INotificationService) private readonly _notificationService?: INotificationService
) {
super();
}
async write(text: string, html: string, customData?: Record<string, string>): Promise<void> {
if (!this.supportClipboard || typeof ClipboardItem === 'undefined' || typeof navigator.clipboard.write !== 'function') {
return this._legacyCopyHtml(text, html);
}
try {
// write both pure text content and html content to the clipboard
return await navigator.clipboard.write([
new ClipboardItem({
[PLAIN_TEXT_CLIPBOARD_MIME_TYPE]: new Blob([text], { type: PLAIN_TEXT_CLIPBOARD_MIME_TYPE }),
[HTML_CLIPBOARD_MIME_TYPE]: new Blob([html], { type: HTML_CLIPBOARD_MIME_TYPE }),
...Object.fromEntries(Object.entries(customData ?? {}).map(([type, value]) => [type, new Blob([value], { type })])),
}),
]);
} catch (error) {
if (customData && Object.keys(customData).length) {
try {
return await navigator.clipboard.write([
new ClipboardItem({
[PLAIN_TEXT_CLIPBOARD_MIME_TYPE]: new Blob([text], { type: PLAIN_TEXT_CLIPBOARD_MIME_TYPE }),
[HTML_CLIPBOARD_MIME_TYPE]: new Blob([html], { type: HTML_CLIPBOARD_MIME_TYPE }),
}),
]);
} catch (fallbackError) {
this._logService.error('[BrowserClipboardService]', fallbackError);
this._showClipboardAuthenticationNotification();
return;
}
}
this._logService.error('[BrowserClipboardService]', error);
this._showClipboardAuthenticationNotification();
}
}
async writeText(text: string): Promise<void> {
if (!this.supportClipboard) {
return this._legacyCopyText(text);
}
// use new Clipboard API first
try {
return await navigator.clipboard.writeText(text);
} catch (error) {
this._logService.error('[BrowserClipboardService]', error);
this._showClipboardAuthenticationNotification();
}
}
async read(): Promise<ClipboardItem[]> {
if (!this.supportClipboard) {
throw new Error('[BrowserClipboardService] read() is not supported on this platform.');
}
try {
return navigator.clipboard.read();
} catch (e) {
this._logService.error('[BrowserClipboardService]', e);
this._showClipboardAuthenticationNotification();
return [];
}
}
async readText(): Promise<string> {
if (!this.supportClipboard) {
throw new Error('[BrowserClipboardService] read() is not supported on this platform.');
}
try {
return await navigator.clipboard.readText();
} catch (e) {
this._logService.error('[BrowserClipboardService]', e);
this._showClipboardAuthenticationNotification();
return '';
}
}
private _legacyCopyHtml(text: string, html: string): void {
const activeElement = document.activeElement;
const sanitizedHtml = serializeSanitizedHtmlForClipboard(html);
let handledByClipboardEvent = false;
const onCopy = (event: ClipboardEvent) => {
if (!event.clipboardData) {
return;
}
event.preventDefault();
event.clipboardData.setData(PLAIN_TEXT_CLIPBOARD_MIME_TYPE, text);
event.clipboardData.setData(HTML_CLIPBOARD_MIME_TYPE, sanitizedHtml);
handledByClipboardEvent = true;
};
document.addEventListener('copy', onCopy);
try {
document.execCommand('copy');
if (!handledByClipboardEvent) {
const container = createCopyHtmlContainer();
document.body.appendChild(container);
container.innerHTML = sanitizedHtml;
try {
select(container);
document.execCommand('copy');
} finally {
document.body.removeChild(container);
}
}
} finally {
document.removeEventListener('copy', onCopy);
if (activeElement instanceof HTMLElement) {
activeElement.focus();
}
}
}
private _legacyCopyText(text: string): void {
const activeElement = document.activeElement;
const container = createCopyTextContainer();
document.body.appendChild(container);
container.value = text;
try {
select(container);
document.execCommand('copy');
} finally {
// reset previous elements focus state
if (activeElement instanceof HTMLElement) {
activeElement.focus();
}
document.body.removeChild(container);
}
}
private _showClipboardAuthenticationNotification(): void {
this._notificationService?.show({
type: 'warning',
title: this._localeService.t<LocaleKey>('ui.clipboard.authentication.title'),
content: this._localeService.t<LocaleKey>('ui.clipboard.authentication.content'),
});
}
}
function createCopyTextContainer() {
const textArea = document.createElement('textarea');
textArea.style.position = 'absolute';
textArea.style.height = '1px';
textArea.style.width = '1px';
textArea.style.opacity = '0';
textArea.readOnly = true;
return textArea;
}
function createCopyHtmlContainer() {
const div = document.createElement('div');
div.contentEditable = 'true';
div.style.position = 'absolute';
div.style.opacity = '0';
div.style.height = '1px';
div.style.width = '1px';
return div;
}
function sanitizeHtmlForClipboard(html: string): DocumentFragment {
const parser = new DOMParser();
const doc = parser.parseFromString(html, HTML_CLIPBOARD_MIME_TYPE);
sanitizeParsedHtml(doc, {
strippedSelector: 'script, iframe, object, embed',
});
const fragment = document.createDocumentFragment();
Array.from(doc.body.childNodes).forEach((node) => {
const sanitizedNode = sanitizeHtmlNode(node);
if (sanitizedNode) {
fragment.appendChild(sanitizedNode);
}
});
return fragment;
}
function serializeSanitizedHtmlForClipboard(html: string): string {
const container = document.createElement('div');
container.appendChild(sanitizeHtmlForClipboard(html));
const sanitizedHtml = container.innerHTML;
const isUniverExcelHtml = html.includes('xmlns:x="urn:schemas-microsoft-com:office:excel"')
&& html.includes('<meta name="ProgId" content="Excel.Sheet">')
&& html.includes('<meta name="Generator" content="Univer">');
if (!isUniverExcelHtml) {
return sanitizedHtml;
}
return `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><meta name="ProgId" content="Excel.Sheet"><meta name="Generator" content="Univer"></head><body><!--StartFragment-->${sanitizedHtml}<!--EndFragment--></body></html>`;
}
function sanitizeHtmlNode(node: Node): Node | null {
if (node.nodeType === Node.TEXT_NODE) {
return document.createTextNode(node.textContent ?? '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return null;
}
const sourceElement = node as HTMLElement;
const tagName = sourceElement.tagName.toLowerCase();
if (DROP_CONTENT_TAGS.has(tagName)) {
return null;
}
if (!ALLOWED_HTML_TAGS.has(tagName)) {
const fragment = document.createDocumentFragment();
Array.from(sourceElement.childNodes).forEach((childNode) => {
const sanitizedChildNode = sanitizeHtmlNode(childNode);
if (sanitizedChildNode) {
fragment.appendChild(sanitizedChildNode);
}
});
return fragment;
}
const sanitizedElement = document.createElement(tagName);
copySanitizedAttributes(sourceElement, sanitizedElement);
Array.from(sourceElement.childNodes).forEach((childNode) => {
const sanitizedChildNode = sanitizeHtmlNode(childNode);
if (sanitizedChildNode) {
sanitizedElement.appendChild(sanitizedChildNode);
}
});
return sanitizedElement;
}
function copySanitizedAttributes(sourceElement: HTMLElement, targetElement: HTMLElement) {
Array.from(sourceElement.attributes).forEach((attribute) => {
const attributeName = attribute.name.toLowerCase();
if (attributeName.startsWith('on')) {
return;
}
if (attributeName.startsWith('data-') || attributeName.startsWith('aria-')) {
targetElement.setAttribute(attribute.name, attribute.value);
return;
}
if (!ALLOWED_HTML_ATTRIBUTES.has(attributeName)) {
return;
}
if (attributeName === 'href' || attributeName === 'src') {
const sanitizedUrl = sanitizeHtmlUrl(attribute.value, attributeName === 'src');
if (sanitizedUrl) {
targetElement.setAttribute(attribute.name, sanitizedUrl);
}
return;
}
if (attributeName === 'style') {
const sanitizedStyle = sanitizeInlineStyle(attribute.value);
if (sanitizedStyle) {
targetElement.setAttribute(attribute.name, sanitizedStyle);
}
return;
}
targetElement.setAttribute(attribute.name, attribute.value);
});
}
function sanitizeHtmlUrl(value: string, allowDataImage: boolean): string | null {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
if (trimmedValue.startsWith('#')) {
return trimmedValue;
}
if (/^(?:https?:|mailto:|tel:)/i.test(trimmedValue)) {
return trimmedValue;
}
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(trimmedValue)) {
return trimmedValue;
}
if (/^(?:\/|\.\/|\.\.\/)/.test(trimmedValue)) {
return trimmedValue;
}
return null;
}
function sanitizeInlineStyle(styleText: string): string {
const probe = document.createElement('div');
probe.style.cssText = styleText;
const sanitizedDeclarations: string[] = [];
Array.from(probe.style).forEach((propertyName) => {
const propertyValue = probe.style.getPropertyValue(propertyName).trim();
if (!propertyValue || isUnsafeStyleValue(propertyValue)) {
return;
}
sanitizedDeclarations.push(`${propertyName}: ${propertyValue}`);
});
return sanitizedDeclarations.join('; ');
}
function isUnsafeStyleValue(styleValue: string): boolean {
return /(expression\s*\(|url\s*\(|javascript:|vbscript:|mocha:|-moz-binding|behavior\s*:|@import)/i.test(styleValue);
}
function select(element: HTMLTextAreaElement | HTMLDivElement): string {
if (element instanceof HTMLTextAreaElement) {
const isReadOnly = element.hasAttribute('readonly');
if (!isReadOnly) {
element.setAttribute('readonly', '');
}
element.select();
element.setSelectionRange(0, element.value.length);
if (!isReadOnly) {
element.removeAttribute('readonly');
}
return element.value;
}
if (element.hasAttribute('contenteditable')) {
element.focus();
}
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
if (!selection) {
throw new Error();
}
selection.removeAllRanges();
selection.addRange(range);
return selection.toString();
}

View File

@@ -0,0 +1,26 @@
/**
* 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.
*/
/**
* This function is used to check if the system supports full clipboard API,
* especially for the clipboard.readText() API.
*
* @returns if the system supports clipboard API
*/
export function supportClipboardAPI(): boolean {
// In unsecure context, navigator.clipboard does not exist.
return typeof navigator.clipboard !== 'undefined' && typeof navigator.clipboard.readText !== 'undefined';
}

View File

@@ -0,0 +1,53 @@
/**
* 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 type { IMultiCommand } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
const CopyCommandName = 'univer.command.copy';
export const CopyCommand: IMultiCommand = {
id: CopyCommandName,
name: CopyCommandName,
multi: true,
priority: 0,
type: CommandType.COMMAND,
preconditions: () => false,
handler: () => true,
};
const CutCommandName = 'univer.command.cut';
export const CutCommand: IMultiCommand = {
id: CutCommandName,
name: CutCommandName,
multi: true,
priority: 0,
type: CommandType.COMMAND,
preconditions: () => false,
handler: async () => true,
};
const PasteCommandName = 'univer.command.paste';
export const PasteCommand: IMultiCommand = {
id: PasteCommandName,
name: PasteCommandName,
multi: true,
priority: 0,
type: CommandType.COMMAND,
preconditions: () => false,
handler: () => true,
};
export const SheetPasteShortKeyCommandName = 'sheet.command.paste-by-short-key';

View File

@@ -0,0 +1,73 @@
/**
* 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 type { IConfirmPartMethodOptions } from '../../../views/components/confirm-part/interface';
import { Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { BuiltInUIPart, IUIPartsService, UIPartsService } from '../../parts/parts.service';
import { DesktopConfirmService } from '../desktop-confirm.service';
function createService(): DesktopConfirmService {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([DesktopConfirmService]);
return injector.get(DesktopConfirmService);
}
describe('DesktopConfirmService', () => {
it('opens a confirm request and resolves it through the confirm callback', async () => {
const service = createService();
const opened: IConfirmPartMethodOptions[][] = [];
const sub = service.confirmOptions$.subscribe((options) => opened.push(options));
const promise = service.confirm({ id: 'save-confirm', children: 'Save changes?' });
opened.at(-1)?.[0].onConfirm?.();
await expect(promise).resolves.toBe(true);
expect(opened.at(-1)).toEqual([]);
sub.unsubscribe();
});
it('updates duplicate confirm ids and resolves close callbacks as cancellation', async () => {
const service = createService();
const snapshots: IConfirmPartMethodOptions[][] = [];
const sub = service.confirmOptions$.subscribe((options) => snapshots.push(options));
const disposable = service.open({ id: 'save-confirm', children: 'Save changes?' });
service.open({ id: 'save-confirm', children: 'Discard changes?' });
service.close('save-confirm');
expect(snapshots.at(-2)?.[0]).toMatchObject({ id: 'save-confirm', children: 'Discard changes?', visible: true });
expect(snapshots.at(-1)?.[0]).toMatchObject({ id: 'save-confirm', visible: false });
const promise = service.confirm({ id: 'cancel-confirm', children: 'Cancel?' });
snapshots.at(-1)?.find((item) => item.id === 'cancel-confirm')?.onClose?.();
await expect(promise).resolves.toBe(false);
disposable.dispose();
expect(snapshots.at(-1)).toEqual([]);
sub.unsubscribe();
});
it('registers the global confirm UI part when constructed', () => {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([DesktopConfirmService]);
injector.get(DesktopConfirmService);
expect(injector.get(IUIPartsService).getComponents(BuiltInUIPart.GLOBAL).size).toBe(1);
});
});

View File

@@ -0,0 +1,87 @@
/**
* 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 type { IConfirmService, IDisposable } from '@univerjs/core';
import type { IConfirmPartMethodOptions } from '../../views/components/confirm-part/interface';
import { Disposable, Inject, Injector, toDisposable } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { connectInjector } from '../../utils/di';
import { ConfirmPart } from '../../views/components/confirm-part/ConfirmPart';
import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service';
export class DesktopConfirmService extends Disposable implements IConfirmService<IConfirmPartMethodOptions> {
private _confirmOptions: IConfirmPartMethodOptions[] = [];
readonly confirmOptions$ = new BehaviorSubject<IConfirmPartMethodOptions[]>([]);
constructor(
@Inject(Injector) protected readonly _injector: Injector,
@IUIPartsService protected readonly _uiPartsService: IUIPartsService
) {
super();
this._initUIPart();
}
open(option: IConfirmPartMethodOptions): IDisposable {
if (this._confirmOptions.find((item) => item.id === option.id)) {
this._confirmOptions = this._confirmOptions.map((item) => ({
...(item.id === option.id ? option : item),
visible: item.id === option.id ? true : item.visible,
}));
} else {
this._confirmOptions.push({
...option,
visible: true,
});
}
this.confirmOptions$.next(this._confirmOptions);
return toDisposable(() => {
this._confirmOptions = [];
this.confirmOptions$.next([]);
});
}
confirm(params: IConfirmPartMethodOptions): Promise<boolean> {
return new Promise((resolve) => {
const disposeHandler = this.open({
...params,
onConfirm: () => {
disposeHandler.dispose();
resolve(true);
},
onClose: () => {
disposeHandler.dispose();
resolve(false);
},
});
});
}
close(id: string): void {
this._confirmOptions = this._confirmOptions.map((item) => ({
...item,
visible: item.id === id ? false : item.visible,
}));
this.confirmOptions$.next([...this._confirmOptions]);
}
protected _initUIPart(): void {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(ConfirmPart, this._injector))
);
}
}

View File

@@ -0,0 +1,89 @@
/**
* 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 type { IConfirmService, IDisposable } from '@univerjs/core';
import type { IConfirmPartMethodOptions } from '../../views/components/confirm-part/interface';
import { Disposable, Inject, Injector, toDisposable } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { connectInjector } from '../../utils/di';
import { MobileConfirmPart } from '../../views/components/confirm-part/MobileConfirmPart';
import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service';
export class MobileConfirmService extends Disposable implements IConfirmService<IConfirmPartMethodOptions> {
private _confirmOptions: IConfirmPartMethodOptions[] = [];
readonly confirmOptions$ = new BehaviorSubject<IConfirmPartMethodOptions[]>([]);
constructor(
@Inject(Injector) private readonly _injector: Injector,
@IUIPartsService uiPartsService: IUIPartsService
) {
super();
this.disposeWithMe(
uiPartsService.registerComponent(
BuiltInUIPart.GLOBAL,
() => connectInjector(MobileConfirmPart, this._injector)
)
);
}
override dispose(): void {
super.dispose();
this._confirmOptions = [];
this.confirmOptions$.complete();
}
open(option: IConfirmPartMethodOptions): IDisposable {
const existing = this._confirmOptions.some((item) => item.id === option.id);
if (existing) {
this._confirmOptions = this._confirmOptions.map((item) => item.id === option.id
? { ...option, visible: true }
: item);
} else {
this._confirmOptions.push({ ...option, visible: true });
}
this.confirmOptions$.next(this._confirmOptions);
return toDisposable(() => {
this._confirmOptions = [];
this.confirmOptions$.next([]);
});
}
confirm(params: IConfirmPartMethodOptions): Promise<boolean> {
return new Promise((resolve) => {
const disposable = this.open({
...params,
onConfirm: () => {
disposable.dispose();
resolve(true);
},
onClose: () => {
disposable.dispose();
resolve(false);
},
});
});
}
close(id: string): void {
this._confirmOptions = this._confirmOptions.map((item) => ({
...item,
visible: item.id === id ? false : item.visible,
}));
this.confirmOptions$.next([...this._confirmOptions]);
}
}

View File

@@ -0,0 +1,64 @@
/**
* 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 { Injector } from '@univerjs/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ContextMenuHostService, IContextMenuHostService } from '../contextmenu-host.service';
describe('ContextMenuHostService', () => {
let service: IContextMenuHostService;
beforeEach(() => {
const injector = new Injector();
injector.add([IContextMenuHostService, { useClass: ContextMenuHostService }]);
service = injector.get(IContextMenuHostService);
});
it('hides the previously active menu when another menu becomes active', () => {
const hideMainMenu = vi.fn();
const hideFooterMenu = vi.fn();
service.registerMenu('main', hideMainMenu);
service.registerMenu('footer', hideFooterMenu);
service.activateMenu('main');
service.activateMenu('footer');
expect(hideMainMenu).toHaveBeenCalledTimes(1);
expect(hideFooterMenu).not.toHaveBeenCalled();
expect(service.activeMenuId).toBe('footer');
});
it('clears active menu state when a registered menu is disposed', () => {
const disposable = service.registerMenu('main', vi.fn());
service.activateMenu('main');
disposable.dispose();
expect(service.activeMenuId).toBeNull();
});
it('publishes active menu changes', () => {
const activeMenuIds: Array<string | null> = [];
service.registerMenu('main', vi.fn());
const subscription = service.activeMenuId$.subscribe((menuId) => activeMenuIds.push(menuId));
service.activateMenu('main');
service.deactivateMenu('main');
expect(activeMenuIds).toEqual([null, 'main', null]);
subscription.unsubscribe();
});
});

View File

@@ -0,0 +1,74 @@
/**
* 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 { Injector } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { ContextMenuService, IContextMenuService } from '../contextmenu.service';
function createService(): IContextMenuService {
const injector = new Injector();
injector.add([IContextMenuService, { useClass: ContextMenuService }]);
return injector.get(IContextMenuService);
}
describe('ContextMenuService', () => {
it('routes context menu requests to the registered handler while enabled', () => {
const service = createService();
const handled: string[] = [];
let visible = false;
const event = { stopPropagation: vi.fn() };
service.registerContextMenuHandler({
get visible() {
return visible;
},
handleContextMenu: (_event, menuType) => {
visible = true;
handled.push(menuType);
},
hideContextMenu: () => {
visible = false;
},
});
service.triggerContextMenu(event as never, 'cell');
expect(handled).toEqual(['cell']);
expect(event.stopPropagation).toHaveBeenCalledTimes(1);
expect(service.visible).toBe(true);
service.hideContextMenu();
expect(service.visible).toBe(false);
});
it('suppresses handler execution while disabled and resumes after enable', () => {
const service = createService();
const handled: string[] = [];
const event = { stopPropagation: vi.fn() };
service.registerContextMenuHandler({
visible: false,
handleContextMenu: (_event, menuType) => handled.push(menuType),
hideContextMenu: () => {},
});
service.disable();
service.triggerContextMenu(event as never, 'row');
service.enable();
service.triggerContextMenu(event as never, 'column');
expect(handled).toEqual(['column']);
});
});

View File

@@ -0,0 +1,79 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import type { Observable } from 'rxjs';
import { createIdentifier, Disposable, toDisposable } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
export interface IContextMenuHostService {
readonly activeMenuId: string | null;
readonly activeMenuId$: Observable<string | null>;
registerMenu(menuId: string, hide: () => void): IDisposable;
activateMenu(menuId: string): void;
deactivateMenu(menuId: string): void;
hideActiveMenu(exceptMenuId?: string): void;
}
export const IContextMenuHostService = createIdentifier<IContextMenuHostService>('ui.contextmenu.host.service');
export class ContextMenuHostService extends Disposable implements IContextMenuHostService {
private readonly _menuMap = new Map<string, () => void>();
private readonly _activeMenuId$ = new BehaviorSubject<string | null>(null);
readonly activeMenuId$ = this._activeMenuId$.asObservable();
get activeMenuId(): string | null {
return this._activeMenuId$.value;
}
registerMenu(menuId: string, hide: () => void): IDisposable {
this._menuMap.set(menuId, hide);
return toDisposable(() => {
this._menuMap.delete(menuId);
if (this.activeMenuId === menuId) {
this._activeMenuId$.next(null);
}
});
}
activateMenu(menuId: string): void {
this.hideActiveMenu(menuId);
this._activeMenuId$.next(menuId);
}
deactivateMenu(menuId: string): void {
if (this.activeMenuId === menuId) {
this._activeMenuId$.next(null);
}
}
hideActiveMenu(exceptMenuId?: string): void {
const activeMenuId = this.activeMenuId;
if (!activeMenuId || activeMenuId === exceptMenuId) {
return;
}
const hide = this._menuMap.get(activeMenuId);
this._activeMenuId$.next(null);
hide?.();
}
override dispose(): void {
this._activeMenuId$.complete();
super.dispose();
}
}

View File

@@ -0,0 +1,86 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import type { IMouseEvent } from '@univerjs/engine-render';
import { createIdentifier, Disposable, toDisposable } from '@univerjs/core';
export interface IContextMenuTriggerContext {
autoFocus?: boolean;
caretAnchor?: boolean;
unitId?: string;
subUnitId?: string;
}
export type ContextMenuEvent = Pick<IMouseEvent, 'clientX' | 'clientY' | 'stopPropagation'>;
export interface IContextMenuHandler {
/** A callback to open context menu with given position and menu type. */
handleContextMenu(event: ContextMenuEvent, menuType: string, context?: IContextMenuTriggerContext): void;
hideContextMenu(): void;
get visible(): boolean;
}
export interface IContextMenuService {
disabled: boolean;
get visible(): boolean;
enable(): void;
disable(): void;
triggerContextMenu(event: ContextMenuEvent, menuType: string, context?: IContextMenuTriggerContext): void;
hideContextMenu(): void;
registerContextMenuHandler(handler: IContextMenuHandler): IDisposable;
}
export const IContextMenuService = createIdentifier<IContextMenuService>('ui.contextmenu.service');
export class ContextMenuService extends Disposable implements IContextMenuService {
private _currentHandler: IContextMenuHandler | null = null;
disabled: boolean = false;
get visible(): boolean {
return this._currentHandler?.visible ?? false;
}
disable(): void {
this.disabled = true;
}
enable(): void {
this.disabled = false;
}
triggerContextMenu(event: ContextMenuEvent, menuType: string, context?: IContextMenuTriggerContext): void {
event.stopPropagation();
if (this.disabled) return;
this._currentHandler?.handleContextMenu(event, menuType, context);
}
hideContextMenu(): void {
this._currentHandler?.hideContextMenu();
}
registerContextMenuHandler(handler: IContextMenuHandler): IDisposable {
if (this._currentHandler) {
throw new Error('There is already a context menu handler!');
}
this._currentHandler = handler;
return toDisposable(() => this._currentHandler = null);
}
}

View File

@@ -0,0 +1,66 @@
/**
* 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 type { IDialogPartMethodOptions } from '../../../views/components/dialog-part/interface';
import { Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { BuiltInUIPart, IUIPartsService, UIPartsService } from '../../parts/parts.service';
import { DesktopDialogService } from '../desktop-dialog.service';
import { IDialogService } from '../dialog.service';
function createService(): IDialogService {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IDialogService, { useClass: DesktopDialogService }]);
return injector.get(IDialogService);
}
describe('DesktopDialogService', () => {
it('tracks opened dialogs and closes only the requested dialog', () => {
const service = createService();
const snapshots: IDialogPartMethodOptions[][] = [];
const sub = service.getDialogs$().subscribe((options) => snapshots.push(options));
service.open({ id: 'first' });
service.open({ id: 'second' });
service.close('first');
expect(snapshots.at(-1)).toMatchObject([{ id: 'first', open: false }, { id: 'second', open: true }]);
sub.unsubscribe();
});
it('keeps excepted dialogs open when closing all', () => {
const service = createService();
const snapshots: IDialogPartMethodOptions[][] = [];
const sub = service.getDialogs$().subscribe((options) => snapshots.push(options));
service.open({ id: 'first' });
service.open({ id: 'second' });
service.closeAll(['second']);
expect(snapshots.at(-1)).toMatchObject([{ id: 'first', open: false }, { id: 'second', open: true }]);
sub.unsubscribe();
});
it('registers the global dialog UI part when constructed', () => {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IDialogService, { useClass: DesktopDialogService }]);
injector.get(IDialogService);
expect(injector.get(IUIPartsService).getComponents(BuiltInUIPart.GLOBAL).size).toBe(1);
});
});

View File

@@ -0,0 +1,94 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import type { IDialogPartMethodOptions } from '../../views/components/dialog-part/interface';
import type { IDialogService } from './dialog.service';
import { Disposable, Inject, Injector, toDisposable } from '@univerjs/core';
import { Subject } from 'rxjs';
import { connectInjector } from '../../utils/di';
import { DialogPart } from '../../views/components/dialog-part/DialogPart';
import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service';
export class DesktopDialogService extends Disposable implements IDialogService {
protected _dialogOptions: IDialogPartMethodOptions[] = [];
protected readonly _dialogOptions$ = new Subject<IDialogPartMethodOptions[]>();
constructor(
@Inject(Injector) protected readonly _injector: Injector,
@IUIPartsService protected readonly _uiPartsService: IUIPartsService
) {
super();
this._initUIPart();
}
override dispose(): void {
super.dispose();
this._dialogOptions = [];
this._dialogOptions$.complete();
}
open(option: IDialogPartMethodOptions): IDisposable {
if (this._dialogOptions.find((item) => item.id === option.id)) {
this._dialogOptions = this._dialogOptions.map((item) => ({
...(item.id === option.id ? option : item),
open: item.id === option.id ? true : item.open,
}));
} else {
this._dialogOptions.push({
...option,
open: true,
});
}
this._dialogOptions$.next(this._dialogOptions);
return toDisposable(() => {
this._dialogOptions = [];
this._dialogOptions$.next([]);
});
}
close(id: string) {
this._dialogOptions = this._dialogOptions.map((item) => ({
...item,
open: item.id === id ? false : item.open,
}));
this._dialogOptions$.next([...this._dialogOptions]);
}
closeAll(expectIds?: string[]): void {
const expectIdSet = new Set(expectIds);
this._dialogOptions = this._dialogOptions.map((item) => ({
...item,
open: expectIdSet.has(item.id) ? item.open : false,
}));
this._dialogOptions$.next([...this._dialogOptions]);
}
getDialogs$() {
return this._dialogOptions$.asObservable();
}
protected _initUIPart(): void {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(DialogPart, this._injector))
);
}
}

View File

@@ -0,0 +1,32 @@
/**
* 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 type { IDisposable } from '@univerjs/core';
import type { Observable } from 'rxjs';
import type { IDialogPartMethodOptions } from '../../views/components/dialog-part/interface';
import { createIdentifier } from '@univerjs/core';
export const IDialogService = createIdentifier<IDialogService>('univer.ui.dialog-service');
export interface IDialogService {
open(params: IDialogPartMethodOptions): IDisposable;
close(id: string): void;
/**
* @description close all dialogs except the specified ones
* @param {string[]} [expectIds] The specified dialog ids
*/
closeAll(expectIds?: string[]): void;
getDialogs$(): Observable<IDialogPartMethodOptions[]>;
}

View File

@@ -0,0 +1,42 @@
/**
* 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 { Inject, Injector } from '@univerjs/core';
import { connectInjector } from '../../utils/di';
import { MobileDialogPart } from '../../views/components/dialog-part/MobileDialogPart';
import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service';
import { DesktopDialogService } from './desktop-dialog.service';
export class MobileDialogService extends DesktopDialogService {
constructor(
@Inject(Injector) injector: Injector,
@IUIPartsService uiPartsService: IUIPartsService
) {
super(injector, uiPartsService);
}
override close(id: string): void {
const dialog = this._dialogOptions.find((item) => item.id === id);
if (!dialog || dialog.open === false) return;
super.close(id);
}
protected override _initUIPart(): void {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(MobileDialogPart, this._injector))
);
}
}

View File

@@ -0,0 +1,125 @@
/**
* 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 type { IFloatDom, IFloatDomLayout } from '../canvas-dom-layer.service';
import { Injector } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { CanvasFloatDomPreviewService, CanvasFloatDomService } from '../canvas-dom-layer.service';
function createService(): CanvasFloatDomService {
const injector = new Injector();
injector.add([CanvasFloatDomService]);
return injector.get(CanvasFloatDomService);
}
function createFloatDom(id: string): IFloatDom {
return {
id,
componentKey: 'shape',
onPointerDown: () => {},
onPointerMove: () => {},
onPointerUp: () => {},
onWheel: () => {},
position$: new BehaviorSubject<IFloatDomLayout>({
startX: 0,
startY: 0,
endX: 10,
endY: 10,
rotate: 0,
width: 10,
height: 10,
absolute: { left: true, top: true },
}),
unitId: 'unit-1',
};
}
describe('CanvasFloatDomService', () => {
it('publishes floating DOM layer changes for canvas overlays', () => {
const service = createService();
const snapshots: Array<[string, IFloatDom][]> = [];
const sub = service.domLayers$.subscribe((layers) => snapshots.push(layers));
service.addFloatDom(createFloatDom('dom-1'));
service.updateFloatDom('dom-1', { domId: 'runtime-dom-1' });
service.removeFloatDom('dom-1');
expect(snapshots.map((layers) => layers.length)).toEqual([0, 1, 1, 0]);
expect(snapshots[2][0][1].domId).toBe('runtime-dom-1');
sub.unsubscribe();
});
it('ignores missing updates and publishes remove-all changes', () => {
const service = createService();
const sizes: number[] = [];
const sub = service.domLayers$.subscribe((layers) => sizes.push(layers.length));
service.updateFloatDom('missing', { domId: 'ignored' });
service.addFloatDom(createFloatDom('dom-1'));
service.addFloatDom(createFloatDom('dom-2'));
service.removeFloatDom('missing');
service.removeAll();
expect(service.domLayers).toEqual([]);
expect(sizes).toEqual([0, 1, 2, 0]);
sub.unsubscribe();
});
it('clears retained layer callbacks when disposed', () => {
const service = createService();
const sizes: number[] = [];
let completed = false;
service.domLayers$.subscribe({
next: (layers) => sizes.push(layers.length),
complete: () => {
completed = true;
},
});
service.addFloatDom(createFloatDom('dom-1'));
service.dispose();
expect(service.domLayers).toEqual([]);
expect(sizes).toEqual([0, 1, 0]);
expect(completed).toBe(true);
});
it('clears preview state when disposed', () => {
const service = new CanvasFloatDomPreviewService();
let previewCompleted = false;
let requestCompleted = false;
service.previewUpdated$.subscribe({
complete: () => {
previewCompleted = true;
},
});
service.previewRequested$.subscribe({
complete: () => {
requestCompleted = true;
},
});
service.requestPreview({ id: 'dom-1', width: 20, height: 10 });
service.setPreview({ id: 'dom-1', image: 'data:image/png;base64,', updatedAt: 1 });
service.dispose();
expect(service.getPreview('dom-1')).toBeUndefined();
expect(service.getPendingRequests()).toEqual([]);
expect(previewCompleted).toBe(true);
expect(requestCompleted).toBe(true);
});
});

View File

@@ -0,0 +1,206 @@
/**
* 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 type { IPosition, Serializable } from '@univerjs/core';
import type { Observable } from 'rxjs';
import { Disposable, toDisposable } from '@univerjs/core';
import { BehaviorSubject, Subject } from 'rxjs';
export interface IFloatDomLayout extends IPosition {
rotate: number;
width: number;
height: number;
absolute: {
left: boolean;
top: boolean;
};
opacity?: number;
}
export interface IFloatDomContentBoxConfig {
/** Amount subtracted from the visible wrapper width and height. @default 2 */
wrapperInset?: number;
/** Amount subtracted from the content width and height. @default 4 */
contentInset?: number;
}
export interface IFloatDom {
position$: Observable<IFloatDomLayout>;
id: string;
domId?: string; // Ensure unique id for dom element at runtime
componentKey: string | React.ComponentType;
/**
* Whether pointer and wheel events inside the floating DOM should be
* forwarded back to the host canvas. Existing canvas-owned float DOMs keep
* forwarding by default; interactive embed runtimes can opt out.
*/
eventPassThrough?: boolean;
/**
* Keep rendering this host-owned layer even when focus temporarily moves
* into a child runtime unit. Interactive embed float blocks need this so
* their DOM portal is not filtered out when the child handles focus.
*/
preserveOnFocusChange?: boolean;
/**
* Insets subtracted from FloatDom's wrapper and content dimensions.
* Omitted values preserve the historical wrapper/content insets of 2/4.
*/
contentBox?: IFloatDomContentBoxConfig;
onPointerMove: (evt: PointerEvent | MouseEvent) => void;
onPointerDown: (evt: PointerEvent | MouseEvent) => void;
onPointerUp: (evt: PointerEvent | MouseEvent) => void;
onWheel: (evt: WheelEvent) => void;
props?: Record<string, any>;
data?: Serializable;
unitId: string;
}
export function shouldForwardFloatDomEvents(layer: Pick<IFloatDom, 'eventPassThrough'>): boolean {
return layer.eventPassThrough !== false;
}
export function shouldRenderFloatDomLayer(layer: Pick<IFloatDom, 'unitId' | 'preserveOnFocusChange'>, currentUnitId: string | null | undefined): boolean {
return layer.unitId === currentUnitId || layer.preserveOnFocusChange === true;
}
export class CanvasFloatDomService extends Disposable {
private _domLayerMap = new Map<string, IFloatDom>();
private _scopedRenderRootCount = new Map<string, number>();
private _domLayers$ = new BehaviorSubject<[string, IFloatDom][]>([]);
domLayers$ = this._domLayers$.asObservable();
get domLayers() {
return Array.from(this._domLayerMap.entries());
}
private _notice() {
this._domLayers$.next(Array.from(this._domLayerMap.entries()));
}
updateFloatDom(id: string, item: Partial<IFloatDom>) {
const current = this._domLayerMap.get(id);
if (!current) {
return;
}
this._domLayerMap.set(id, {
...current,
...item,
});
this._notice();
}
addFloatDom(item: IFloatDom) {
this._domLayerMap.set(item.id, item);
this._notice();
}
hasScopedRenderRoot(unitId: string): boolean {
return this._scopedRenderRootCount.has(unitId);
}
registerScopedRenderRoot(unitId: string) {
this._scopedRenderRootCount.set(unitId, (this._scopedRenderRootCount.get(unitId) ?? 0) + 1);
this._notice();
return toDisposable(() => {
const count = this._scopedRenderRootCount.get(unitId);
if (count == null) {
return;
}
if (count === 1) {
this._scopedRenderRootCount.delete(unitId);
} else {
this._scopedRenderRootCount.set(unitId, count - 1);
}
this._notice();
});
}
removeFloatDom(id: string): void {
if (this._domLayerMap.delete(id)) {
this._notice();
}
}
removeAll(): void {
this._domLayerMap.clear();
this._notice();
}
override dispose(): void {
this._domLayerMap.clear();
this._scopedRenderRootCount.clear();
this._domLayers$.next([]);
this._domLayers$.complete();
super.dispose();
}
}
export interface ICanvasFloatDomPreview {
id: string;
image: string;
updatedAt: number;
}
export interface ICanvasFloatDomPreviewRequest {
id: string;
width: number;
height: number;
data?: unknown;
}
export class CanvasFloatDomPreviewService extends Disposable {
readonly previewUpdated$ = new Subject<ICanvasFloatDomPreview>();
readonly previewRequested$ = new Subject<ICanvasFloatDomPreviewRequest>();
private readonly _previewMap = new Map<string, ICanvasFloatDomPreview>();
private readonly _requestMap = new Map<string, ICanvasFloatDomPreviewRequest>();
getPreview(id: string): ICanvasFloatDomPreview | undefined {
return this._previewMap.get(id);
}
getPendingRequests(): ICanvasFloatDomPreviewRequest[] {
return Array.from(this._requestMap.values());
}
setPreview(preview: ICanvasFloatDomPreview): void {
this._previewMap.set(preview.id, preview);
this._requestMap.delete(preview.id);
this.previewUpdated$.next(preview);
}
removePreview(id: string): void {
this._previewMap.delete(id);
this._requestMap.delete(id);
}
requestPreview(request: ICanvasFloatDomPreviewRequest): void {
this._requestMap.set(request.id, request);
this.previewRequested$.next(request);
}
override dispose(): void {
this._previewMap.clear();
this._requestMap.clear();
this.previewUpdated$.complete();
this.previewRequested$.complete();
super.dispose();
}
}

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