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

View File

@@ -0,0 +1,41 @@
# @univerjs/sheets-note-ui
[![npm version](https://img.shields.io/npm/v/@univerjs/sheets-note-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-note-ui)
[![license](https://img.shields.io/npm/l/@univerjs/sheets-note-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-note-ui)
[![downloads](https://img.shields.io/npm/dm/@univerjs/sheets-note-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-note-ui)
`@univerjs/sheets-note-ui` adds the UI for viewing and editing cell notes in Univer Sheets.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/sheets-note-ui` | `UniverSheetsNoteUi` | Yes | Yes | No |
## Installation
```sh
pnpm add @univerjs/sheets-note-ui
# or
npm install @univerjs/sheets-note-ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/sheets-note-ui/lib/index.css';
import EnUS from '@univerjs/sheets-note-ui/locale/en-US';
import { UniverSheetsNoteUIPlugin } from '@univerjs/sheets-note-ui';
univer.registerPlugin(UniverSheetsNoteUIPlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/sheets-note-ui)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,94 @@
{
"name": "@univerjs/sheets-note-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Cell note UI for Univer Sheets.",
"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",
"sheets",
"note",
"annotation",
"ui"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.ts",
"./facade": "./src/facade/index.ts"
},
"main": "./lib/index.js",
"module": "./lib/index.js",
"types": "./lib/index.d.ts",
"files": [
"lib"
],
"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"
},
"./lib/*": "./lib/*"
}
},
"scripts": {
"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",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/sheets": "workspace:*",
"@univerjs/sheets-note": "workspace:*",
"@univerjs/sheets-ui": "workspace:*",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"postcss": "^8.5.28",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

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

View File

@@ -0,0 +1,117 @@
/**
* 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 { IWorkbookData } from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import { ICommandService, IUniverInstanceService, LocaleType, toDisposable, Univer, UniverInstanceType } from '@univerjs/core';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { CellPopupManagerService } from '@univerjs/sheets-ui';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SheetsNotePopupService } from '../../../services/sheets-note-popup.service';
import { AddNotePopupOperation } from '../add-note-popup.operation';
const workbookData: IWorkbookData = {
id: 'note-workbook',
appVersion: '3.0.0-alpha',
name: 'note workbook',
locale: LocaleType.EN_US,
sheetOrder: ['sheet-1'],
styles: {},
sheets: {
'sheet-1': {
id: 'sheet-1',
name: 'Sheet 1',
cellData: {},
},
},
};
class TestCellPopupManagerService {
showPopup() {
return toDisposable(() => {});
}
}
function createSelection(row: number, col: number): ISelectionWithStyle {
return {
range: {
startRow: row,
endRow: row,
startColumn: col,
endColumn: col,
},
primary: {
startRow: row,
endRow: row,
startColumn: col,
endColumn: col,
actualRow: row,
actualColumn: col,
isMerged: false,
isMergedMainCell: false,
},
style: null,
};
}
describe('AddNotePopupOperation', () => {
let univer: Univer;
let commandService: ICommandService;
let selectionsService: SheetsSelectionsService;
let notePopupService: SheetsNotePopupService;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([SheetsSelectionsService]);
injector.add([CellPopupManagerService, { useClass: TestCellPopupManagerService as never }]);
injector.add([SheetsNotePopupService]);
univer.createUnit(UniverInstanceType.UNIVER_SHEET, workbookData);
injector.get(IUniverInstanceService).focusUnit('note-workbook');
commandService = injector.get(ICommandService);
commandService.registerCommand(AddNotePopupOperation);
selectionsService = injector.get(SheetsSelectionsService);
notePopupService = injector.get(SheetsNotePopupService);
});
afterEach(() => {
univer.dispose();
});
it('opens a note popup at the active cell in the current worksheet', async () => {
selectionsService.setSelections('note-workbook', 'sheet-1', [createSelection(2, 3)]);
const result = await commandService.executeCommand(AddNotePopupOperation.id, { trigger: 'context-menu' });
expect(result).toBe(true);
expect(notePopupService.activePopup).toEqual({
unitId: 'note-workbook',
subUnitId: 'sheet-1',
row: 2,
col: 3,
temp: false,
trigger: 'context-menu',
});
});
it('does not open a note popup when there is no active cell selection', async () => {
const result = await commandService.executeCommand(AddNotePopupOperation.id);
expect(result).toBe(false);
expect(notePopupService.activePopup).toBeUndefined();
});
});

View File

@@ -0,0 +1,58 @@
/**
* 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, ICommand, Workbook } from '@univerjs/core';
import { CommandType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { SheetsNotePopupService } from '../../services/sheets-note-popup.service';
export interface IAddNotePopupOperationParams {
trigger?: string;
}
export const AddNotePopupOperation: ICommand = {
id: 'sheet.operation.add-note-popup',
type: CommandType.OPERATION,
handler: async (accessor: IAccessor, params?: IAddNotePopupOperationParams) => {
const selectionService = accessor.get(SheetsSelectionsService);
const notePopupService = accessor.get(SheetsNotePopupService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return false;
}
const worksheet = workbook.getActiveSheet();
const lastSelection = selectionService.getCurrentLastSelection();
if (!lastSelection?.primary) {
return false;
}
const { primary } = lastSelection;
notePopupService.showPopup({
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
row: primary.actualRow,
col: primary.actualColumn,
temp: false,
trigger: params?.trigger ?? 'add-note',
});
return true;
},
};

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 { MenuConfig } from '@univerjs/ui';
export const SHEETS_NOTE_UI_PLUGIN_CONFIG_KEY = 'sheets-note-ui.config';
export const configSymbol = Symbol(SHEETS_NOTE_UI_PLUGIN_CONFIG_KEY);
export interface IUniverSheetsNoteUIConfig {
menu?: MenuConfig;
defaultNoteSize?: {
width: number;
height: number;
};
}
export const defaultPluginConfig: IUniverSheetsNoteUIConfig = {};

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 { IWorkbookData } from '@univerjs/core';
import { IUniverInstanceService, LocaleType, toDisposable, Univer, UniverInstanceType } from '@univerjs/core';
import { SheetsNoteModel } from '@univerjs/sheets-note';
import { CellPopupManagerService } from '@univerjs/sheets-ui';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SheetsNotePopupService } from '../../services/sheets-note-popup.service';
import { SheetsNoteAttachmentController } from '../sheets-note-attachment.controller';
const WORKBOOK_DATA: IWorkbookData = {
id: 'note-host',
appVersion: '3.0.0-alpha',
name: 'Note host',
locale: LocaleType.EN_US,
sheetOrder: ['sheet-1'],
styles: {},
sheets: {
'sheet-1': {
id: 'sheet-1',
name: 'Sheet 1',
cellData: {},
},
},
};
class RecordingCellPopupManagerService {
static activeCount = 0;
showPopup() {
RecordingCellPopupManagerService.activeCount += 1;
return toDisposable(() => {
RecordingCellPopupManagerService.activeCount -= 1;
});
}
}
describe('SheetsNoteAttachmentController', () => {
let univer: Univer;
beforeEach(() => {
RecordingCellPopupManagerService.activeCount = 0;
univer = new Univer();
});
afterEach(() => {
univer.dispose();
});
it('hides persistent Notes while a host is suppressed and restores current model state afterwards', () => {
const injector = univer.__getInjector();
injector.add([SheetsNoteModel]);
injector.add([CellPopupManagerService, { useClass: RecordingCellPopupManagerService as never }]);
injector.add([SheetsNotePopupService]);
injector.add([SheetsNoteAttachmentController]);
univer.createUnit(UniverInstanceType.UNIVER_SHEET, WORKBOOK_DATA);
injector.get(IUniverInstanceService).focusUnit(WORKBOOK_DATA.id);
const noteModel = injector.get(SheetsNoteModel);
noteModel.updateNote(WORKBOOK_DATA.id, 'sheet-1', 1, 2, {
id: 'note-1',
width: 160,
height: 60,
note: 'First note',
show: true,
});
const controller = injector.get(SheetsNoteAttachmentController);
expect(RecordingCellPopupManagerService.activeCount).toBe(1);
controller.setPopupSuppressed(WORKBOOK_DATA.id, true);
expect(RecordingCellPopupManagerService.activeCount).toBe(0);
noteModel.updateNote(WORKBOOK_DATA.id, 'sheet-1', 3, 4, {
id: 'note-2',
width: 160,
height: 60,
note: 'Second note',
show: true,
});
expect(RecordingCellPopupManagerService.activeCount).toBe(0);
controller.setPopupSuppressed(WORKBOOK_DATA.id, false);
expect(RecordingCellPopupManagerService.activeCount).toBe(2);
});
});

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 { Disposable, Inject } from '@univerjs/core';
import { AddNoteIcon, DeleteNoteIcon, HideNoteIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { SHEET_NOTE_COMPONENT } from '../views/config';
import { SheetsNote } from '../views/Note';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerComponents();
this._registerIcons();
}
private _registerComponents(): void {
([
[SHEET_NOTE_COMPONENT, SheetsNote],
] as const).forEach(([key, comp]) => {
this.disposeWithMe(
this._componentManager.register(key, comp)
);
});
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
AddNoteIcon,
DeleteNoteIcon,
HideNoteIcon,
}));
}
}

View File

@@ -0,0 +1,82 @@
/**
* 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 { Workbook } from '@univerjs/core';
import { Disposable, Inject, InterceptorEffectEnum, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets';
import { SheetsNoteModel } from '@univerjs/sheets-note';
import { debounceTime } from 'rxjs';
export class SheetsCellContentController extends Disposable {
constructor(
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@Inject(SheetsNoteModel) private readonly _sheetsNoteModel: SheetsNoteModel,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._initViewModelIntercept();
this._initSkeletonChange();
}
private _initViewModelIntercept() {
this.disposeWithMe(
this._sheetInterceptorService.intercept(
INTERCEPTOR_POINT.CELL_CONTENT,
{
effect: InterceptorEffectEnum.Style,
handler: (cell, pos, next) => {
const { row, col, unitId, subUnitId } = pos;
const note = this._sheetsNoteModel.getNote(unitId, subUnitId, { row, col });
if (note) {
if (!cell || cell === pos.rawData) {
cell = { ...pos.rawData };
}
cell.markers = {
...cell?.markers,
tr: {
color: '#FFBD37',
size: 6,
},
};
return next(cell);
}
return next(cell);
},
priority: 100,
}
)
);
}
private _initSkeletonChange() {
const markSkeletonDirty = () => {
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) return;
const unitId = workbook.getUnitId();
const currentRender = this._renderManagerService.getRenderUnitById(unitId);
currentRender?.mainComponent?.makeForceDirty();
};
this.disposeWithMe(this._sheetsNoteModel.change$.pipe(debounceTime(16)).subscribe(() => {
markSkeletonDirty();
}));
}
}

View File

@@ -0,0 +1,202 @@
/**
* 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, Nullable, Workbook } from '@univerjs/core';
import type { ISheetLocationBase } from '@univerjs/sheets';
import type { ISheetNote } from '@univerjs/sheets-note';
import type { Subscription } from 'rxjs';
import { Disposable, Inject, IUniverInstanceService, ObjectMatrix, UniverInstanceType } from '@univerjs/core';
import { SheetsNoteModel } from '@univerjs/sheets-note';
import { CellPopupManagerService } from '@univerjs/sheets-ui';
import { of, switchMap } from 'rxjs';
import { SheetsNotePopupService } from '../services/sheets-note-popup.service';
import { SHEET_NOTE_COMPONENT } from '../views/config';
export class SheetsNoteAttachmentController extends Disposable {
private _noteMatrix = new ObjectMatrix<IDisposable>();
private readonly _suppressedUnitIds = new Set<string>();
private _activeUnitId: Nullable<string> = null;
private _activeSheetId: Nullable<string> = null;
private _noteChangeSubscription: Nullable<Subscription> = null;
constructor(
@Inject(SheetsNoteModel) private readonly _sheetsNoteModel: SheetsNoteModel,
@Inject(IUniverInstanceService) private readonly _univerInstanceService: IUniverInstanceService,
@Inject(CellPopupManagerService) private readonly _cellPopupManagerService: CellPopupManagerService,
@Inject(SheetsNotePopupService) private readonly _sheetsNotePopupService: SheetsNotePopupService
) {
super();
this._initNoteChangeListener();
}
private _showPopup(unitId: string, sheetId: string, row: number, col: number) {
this._sheetsNotePopupService.hidePopup(true);
return this._cellPopupManagerService.showPopup(
{
unitId,
subUnitId: sheetId,
row,
col,
},
{
componentKey: SHEET_NOTE_COMPONENT,
direction: 'horizontal',
extraProps: {
location: {
unitId,
subUnitId: sheetId,
row,
col,
} as ISheetLocationBase,
},
priority: 3,
}
);
}
override dispose(): void {
super.dispose();
this._clearNoteMatrix();
this._suppressedUnitIds.clear();
this._activeUnitId = null;
this._activeSheetId = null;
this._noteChangeSubscription?.unsubscribe();
this._noteChangeSubscription = null;
}
setPopupSuppressed(unitId: string, suppressed: boolean): void {
if (suppressed) {
this._suppressedUnitIds.add(unitId);
} else {
this._suppressedUnitIds.delete(unitId);
}
if (unitId !== this._activeUnitId || !this._activeSheetId) {
return;
}
if (suppressed) {
this._clearNoteMatrix();
} else {
this._showPersistentNotes(unitId, this._activeSheetId);
}
}
private _initSheet(targetUnitId: string, targetSheetId: string) {
this._clearNoteMatrix();
this._activeUnitId = targetUnitId;
this._activeSheetId = targetSheetId;
const handleNote = (unitId: string, sheetId: string, row: number, col: number, note: Nullable<ISheetNote>) => {
const matrix = this._noteMatrix;
const disposable = matrix.getValue(row, col);
if (note?.show) {
if (this._suppressedUnitIds.has(unitId)) {
disposable?.dispose();
matrix.realDeleteValue(row, col);
return;
}
if (!disposable) {
const newDisposable = this._showPopup(unitId, sheetId, row, col);
if (newDisposable) {
matrix.setValue(row, col, newDisposable);
}
}
} else {
if (disposable) {
disposable.dispose();
matrix.realDeleteValue(row, col);
}
}
};
if (!this._suppressedUnitIds.has(targetUnitId)) {
this._showPersistentNotes(targetUnitId, targetSheetId);
}
return this._sheetsNoteModel.change$.subscribe((change) => {
if (change.unitId !== targetUnitId || change.subUnitId !== targetSheetId) {
return;
}
switch (change.type) {
case 'ref': {
const { unitId, subUnitId, oldNote, newNote } = change;
if (!newNote.show) return;
const matrix = this._noteMatrix;
const { row: oldRow, col: oldCol } = oldNote!;
const { row: newRow, col: newCol } = newNote;
const disposable = matrix.getValue(oldRow, oldCol);
if (disposable) {
disposable.dispose();
matrix.realDeleteValue(oldRow, oldCol);
}
handleNote(unitId, subUnitId, newRow, newCol, newNote);
break;
}
case 'update': {
const { unitId, subUnitId, oldNote, newNote } = change;
const row = newNote ? newNote.row : oldNote!.row;
const col = newNote ? newNote.col : oldNote!.col;
handleNote(unitId, subUnitId, row, col, newNote);
break;
}
default:
break;
}
});
}
private _initNoteChangeListener() {
this.disposeWithMe(
this._univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET).pipe(
switchMap((workbook) => workbook?.activeSheet$ ?? of(null))
).subscribe((sheet) => {
this._noteChangeSubscription?.unsubscribe();
this._noteChangeSubscription = null;
if (sheet) {
this._noteChangeSubscription = this._initSheet(sheet.getUnitId(), sheet.getSheetId());
} else {
this._clearNoteMatrix();
this._activeUnitId = null;
this._activeSheetId = null;
}
})
);
}
private _showPersistentNotes(unitId: string, sheetId: string): void {
this._sheetsNoteModel.getSheetNotes(unitId, sheetId)?.forEach((note) => {
if (!note.show || this._noteMatrix.getValue(note.row, note.col)) {
return;
}
const disposable = this._showPopup(unitId, sheetId, note.row, note.col);
if (disposable) {
this._noteMatrix.setValue(note.row, note.col, disposable);
}
});
}
private _clearNoteMatrix(): void {
this._noteMatrix.forValue((_, __, disposable) => {
disposable.dispose();
});
this._noteMatrix = new ObjectMatrix();
}
}

View File

@@ -0,0 +1,169 @@
/**
* 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 { Nullable } from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import type { IHoverCellPosition } from '@univerjs/sheets-ui';
import { Disposable, Inject, RANGE_TYPE, Rectangle } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { SheetsNoteModel } from '@univerjs/sheets-note';
import { HoverManagerService, IEditorBridgeService, SheetSkeletonManagerService } from '@univerjs/sheets-ui';
import { debounceTime } from 'rxjs/operators';
import { SheetsNotePopupService } from '../services/sheets-note-popup.service';
export class SheetsNotePopupController extends Disposable {
private _isSwitchingSheet = false;
constructor(
@Inject(SheetsNotePopupService) private readonly _sheetsNotePopupService: SheetsNotePopupService,
@Inject(SheetsNoteModel) private readonly _sheetsNoteModel: SheetsNoteModel,
@Inject(SheetsSelectionsService) private readonly _sheetSelectionService: SheetsSelectionsService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(HoverManagerService) private readonly _hoverManagerService: HoverManagerService
) {
super();
this._initSelectionUpdateListener();
this._initEditorBridge();
this._initHoverEvent();
this._initDeleteNoteListener();
}
private _handleSelectionChange(selections: ISelectionWithStyle[], unitId: string, subUnitId: string) {
const range = selections[0]?.range;
const render = this._renderManagerService.getRenderUnitById(unitId);
const skeleton = render?.with(SheetSkeletonManagerService).getSkeletonParam(subUnitId)?.skeleton;
if (!skeleton) {
return;
}
if (!range) {
return;
}
const actualCell = skeleton.getCellWithCoordByIndex(range.startRow, range.startColumn);
const rangeType = range.rangeType ?? RANGE_TYPE.NORMAL;
if ((rangeType !== RANGE_TYPE.NORMAL || range.endColumn - range.startColumn > 0 || range.endRow - range.startRow > 0) && !((actualCell.isMerged || actualCell.isMergedMainCell) && Rectangle.equals(actualCell.mergeInfo, range))) {
this._sheetsNotePopupService.hidePopup();
return;
}
const row = actualCell.actualRow;
const col = actualCell.actualColumn;
const note = this._sheetsNoteModel.getNote(unitId, subUnitId, { row, col });
if (note?.show) return;
if (note) {
this._sheetsNotePopupService.showPopup({
unitId,
subUnitId,
noteId: note.id,
row,
col,
});
} else {
this._sheetsNotePopupService.hidePopup(true);
}
}
private _initSelectionUpdateListener() {
this.disposeWithMe(
this._sheetSelectionService.selectionMoveEnd$.subscribe((selections) => {
if (this._isSwitchingSheet) {
return;
}
const current = this._sheetSelectionService.currentSelectionParam;
if (!current) {
return;
}
this._handleSelectionChange(selections, current.unitId, current.sheetId);
})
);
}
private _initEditorBridge() {
this.disposeWithMe(
this._editorBridgeService.visible$.subscribe((visible) => {
if (visible.visible) {
this._sheetsNotePopupService.hidePopup(true);
}
})
);
}
private _initHoverEvent() {
this.disposeWithMe(
this._hoverManagerService.currentCell$.pipe(debounceTime(100)).subscribe((cell: Nullable<IHoverCellPosition>) => {
if (!cell?.location) return;
const { unitId, subUnitId, row, col } = cell.location;
const render = this._renderManagerService.getRenderUnitById(unitId);
const skeleton = render?.with(SheetSkeletonManagerService).getSkeletonParam(subUnitId)?.skeleton;
let targetRow = row;
let targetCol = col;
let note = this._sheetsNoteModel.getNote(unitId, subUnitId, { row: targetRow, col: targetCol });
if (!note && skeleton) {
const actualCell = skeleton.getCellWithCoordByIndex(row, col);
const { startRow, endRow, startColumn, endColumn } = actualCell.mergeInfo;
if (startRow !== endRow || startColumn !== endColumn) {
const sheetNotes = this._sheetsNoteModel.getSheetNotes(unitId, subUnitId);
if (sheetNotes) {
for (const [_id, _note] of sheetNotes) {
if (_note.row >= startRow && _note.row <= endRow && _note.col >= startColumn && _note.col <= endColumn) {
note = _note;
targetRow = _note.row;
targetCol = _note.col;
break;
}
}
}
}
}
if (note?.show) return;
if (note) {
this._sheetsNotePopupService.showPopup({
unitId,
subUnitId,
noteId: note.id,
row: targetRow,
col: targetCol,
temp: true,
});
} else {
this._sheetsNotePopupService.hidePopup();
}
})
);
}
private _initDeleteNoteListener() {
this.disposeWithMe(
this._sheetsNoteModel.change$.subscribe((change) => {
if (!this._sheetsNotePopupService.activePopup) return;
const { unitId, subUnitId, noteId, row, col } = this._sheetsNotePopupService.activePopup;
const { oldNote, newNote } = change;
if (newNote === null && change.unitId === unitId && change.subUnitId === subUnitId && ((oldNote?.id && oldNote.id === noteId) || (oldNote?.row === row && oldNote.col === col))) {
this._sheetsNotePopupService.hidePopup(true);
}
})
);
}
}

View File

@@ -0,0 +1,39 @@
/**
* 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, ICommandService, Inject } from '@univerjs/core';
import { IMenuManagerService } from '@univerjs/ui';
import { AddNotePopupOperation } from '../commands/operations/add-note-popup.operation';
import { menuSchema } from '../menu/schema';
export class SheetsNoteUIController extends Disposable {
constructor(
@Inject(IMenuManagerService) private readonly _menuManagerService: IMenuManagerService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._initMenu();
this._initCommands();
}
private _initMenu() {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initCommands() {
this._commandService.registerCommand(AddNotePopupOperation);
}
}

View File

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

View File

@@ -0,0 +1,25 @@
/**
* 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 type { IUniverSheetsNoteUIConfig } from './config/config';
export { SheetsCellContentController } from './controllers/sheets-cell-content.controller';
export { SheetsNoteAttachmentController } from './controllers/sheets-note-attachment.controller';
export { SheetsNotePopupController } from './controllers/sheets-note-popup.controller';
export { menuSchema as SheetsNoteUIMenuSchema } from './menu/schema';
export { UniverSheetsNoteUIPlugin } from './plugin';
export { SheetsNotePopupService } from './services/sheets-note-popup.service';

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'اكتب هنا',
},
rightClick: {
addNote: 'إضافة ملاحظة',
deleteNote: 'حذف الملاحظة',
toggleNote: 'إظهار/إخفاء الملاحظة',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'Escriu aquí',
},
rightClick: {
addNote: 'Afegeix nota',
deleteNote: 'Elimina nota',
toggleNote: 'Mostra/Amaga nota',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'Hier eingeben',
},
rightClick: {
addNote: 'Notiz hinzufügen',
deleteNote: 'Notiz löschen',
toggleNote: 'Notiz anzeigen/ausblenden',
},
},
};
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.
*/
const locale = {
'sheets-note-ui': {
note: {
placeholder: 'Type here',
},
rightClick: {
addNote: 'Add Note',
deleteNote: 'Delete Note',
toggleNote: 'Show/Hide Note',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'Escribe aquí',
},
rightClick: {
addNote: 'Agregar nota',
deleteNote: 'Eliminar nota',
toggleNote: 'Mostrar/Ocultar nota',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'اینجا تایپ کنید',
},
rightClick: {
addNote: 'افزودن یادداشت',
deleteNote: 'حذف یادداشت',
toggleNote: 'نمایش/پنهان کردن یادداشت',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'Écrivez ici',
},
rightClick: {
addNote: 'Ajouter une note',
deleteNote: 'Supprimer la note',
toggleNote: 'Afficher/Masquer la note',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'Ketik di sini',
},
rightClick: {
addNote: 'Tambah Catatan',
deleteNote: 'Hapus Catatan',
toggleNote: 'Tampilkan/Sembunyikan Catatan',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'Scrivi qui',
},
rightClick: {
addNote: 'Aggiungi nota',
deleteNote: 'Elimina nota',
toggleNote: 'Mostra/Nascondi nota',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'ここに入力',
},
rightClick: {
addNote: 'コメントを追加',
deleteNote: 'コメントを削除',
toggleNote: 'コメントの表示/非表示',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: '여기에 입력',
},
rightClick: {
addNote: '노트 추가',
deleteNote: '노트 삭제',
toggleNote: '노트 표시/숨기기',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'Wpisz tutaj',
},
rightClick: {
addNote: 'Dodaj notatkę',
deleteNote: 'Usuń notatkę',
toggleNote: 'Pokaż/Ukryj notatkę',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'Digite aqui',
},
rightClick: {
addNote: 'Adicionar Nota',
deleteNote: 'Excluir Nota',
toggleNote: 'Exibir/Ocultar Nota',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'Введите текст здесь',
},
rightClick: {
addNote: 'Добавить примечание',
deleteNote: 'Удалить примечание',
toggleNote: 'Показать/Скрыть примечание',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'Píšte sem',
},
rightClick: {
addNote: 'Pridať poznámku',
deleteNote: 'Odstrániť poznámku',
toggleNote: 'Zobraziť/Skryť poznámku',
},
},
};
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,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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: 'Nhập tại đây',
},
rightClick: {
addNote: 'Thêm ghi chú',
deleteNote: 'Xóa ghi chú',
toggleNote: 'Hiện/Ẩn ghi chú',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: '在此输入',
},
rightClick: {
addNote: '添加批注',
deleteNote: '删除批注',
toggleNote: '显示/隐藏批注',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: '在此輸入',
},
rightClick: {
addNote: '新增批註',
deleteNote: '刪除批註',
toggleNote: '顯示/隱藏批註',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-note-ui': {
note: {
placeholder: '在此輸入',
},
rightClick: {
addNote: '新增批註',
deleteNote: '刪除批註',
toggleNote: '顯示/隱藏批註',
},
},
};
export default locale;

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 { IAccessor } from '@univerjs/core';
import type { IMenuButtonItem } from '@univerjs/ui';
import type { Observable } from 'rxjs';
import type { LocaleKey } from '../locale/types';
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import {
getSheetCommandTarget,
SheetsSelectionsService,
WorkbookEditablePermission,
WorksheetEditPermission,
} from '@univerjs/sheets';
import { SheetDeleteNoteCommand, SheetsNoteModel, SheetToggleNotePopupCommand } from '@univerjs/sheets-note';
import { getCurrentRangeDisable$ } from '@univerjs/sheets-ui';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { combineLatest, map } from 'rxjs';
import { AddNotePopupOperation } from '../commands/operations/add-note-popup.operation';
export const SHEET_NOTE_CONTEXT_MENU_ID = 'sheet.menu.note';
function getHasNote$(accessor: IAccessor): Observable<boolean> {
const sheetsSelectionsService = accessor.get(SheetsSelectionsService);
const univerInstanceService = accessor.get(IUniverInstanceService);
return sheetsSelectionsService.selectionMoveEnd$.pipe(map(() => {
const selection = sheetsSelectionsService.getCurrentLastSelection();
if (!selection?.primary) return false;
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { actualColumn, actualRow } = selection.primary;
const noteModel = accessor.get(SheetsNoteModel);
return Boolean(noteModel.getNote(target.unitId, target.subUnitId, { row: actualRow, col: actualColumn }));
}));
}
export function sheetNoteContextMenuFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: AddNotePopupOperation.id,
type: MenuItemType.BUTTON,
title: 'sheets-note-ui.rightClick.addNote',
icon: 'AddNoteIcon',
hidden$: combineLatest([
getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
getHasNote$(accessor),
]).pipe(map(([hidden, hasNote]) => hidden || hasNote)),
disabled$: getCurrentRangeDisable$(accessor, {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission],
}),
commandId: AddNotePopupOperation.id,
};
}
export function sheetDeleteNoteMenuFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: SheetDeleteNoteCommand.id,
type: MenuItemType.BUTTON,
title: 'sheets-note-ui.rightClick.deleteNote',
icon: 'DeleteNoteIcon',
hidden$: getHasNote$(accessor).pipe(map((hasNote) => !hasNote)),
disabled$: getCurrentRangeDisable$(accessor, {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission],
}),
};
}
export function sheetNoteToggleMenuFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: SheetToggleNotePopupCommand.id,
type: MenuItemType.BUTTON,
title: 'sheets-note-ui.rightClick.toggleNote',
icon: 'HideNoteIcon',
hidden$: getHasNote$(accessor).pipe(map((hasNote) => !hasNote)),
};
}

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 { MenuSchemaType } from '@univerjs/ui';
import { SheetDeleteNoteCommand, SheetToggleNotePopupCommand } from '@univerjs/sheets-note';
import { ContextMenuGroup, ContextMenuPosition } from '@univerjs/ui';
import { AddNotePopupOperation } from '../commands/operations/add-note-popup.operation';
import { sheetDeleteNoteMenuFactory, sheetNoteContextMenuFactory, sheetNoteToggleMenuFactory } from './note.menu';
export const menuSchema: MenuSchemaType = {
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.OTHERS]: {
order: 0,
[AddNotePopupOperation.id]: {
order: 0,
menuItemFactory: sheetNoteContextMenuFactory,
},
[SheetDeleteNoteCommand.id]: {
order: 0,
menuItemFactory: sheetDeleteNoteMenuFactory,
},
[SheetToggleNotePopupCommand.id]: {
order: 0,
menuItemFactory: sheetNoteToggleMenuFactory,
},
},
},
};

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 { Dependency } from '@univerjs/core';
import type { IUniverSheetsNoteUIConfig } from './config/config';
import {
DependentOn,
IConfigService,
Inject,
Injector,
merge,
Plugin,
touchDependencies,
UniverInstanceType,
} from '@univerjs/core';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsNotePlugin } from '@univerjs/sheets-note';
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_NOTE_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { SheetsCellContentController } from './controllers/sheets-cell-content.controller';
import { SheetsNoteAttachmentController } from './controllers/sheets-note-attachment.controller';
import { SheetsNotePopupController } from './controllers/sheets-note-popup.controller';
import { SheetsNoteUIController } from './controllers/ui.controller';
import { SheetsNotePopupService } from './services/sheets-note-popup.service';
@DependentOn(
UniverRenderEnginePlugin,
UniverSheetsPlugin,
UniverSheetsNotePlugin,
UniverSheetsUIPlugin
)
export class UniverSheetsNoteUIPlugin extends Plugin {
static override pluginName = 'SHEET_NOTE_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsNoteUIConfig> = defaultPluginConfig,
@Inject(Injector) protected override readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(SHEETS_NOTE_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([ComponentsController]);
this._injector.get(ComponentsController);
([
[SheetsNotePopupService],
[SheetsCellContentController],
[SheetsNotePopupController],
[SheetsNoteUIController],
[SheetsNoteAttachmentController],
] as Dependency[]).forEach((dependency) => {
this._injector.add(dependency);
});
}
override onReady(): void {
touchDependencies(this._injector, [
[SheetsNoteUIController],
[SheetsCellContentController],
]);
}
override onRendered(): void {
touchDependencies(this._injector, [
[SheetsNotePopupController],
[SheetsNoteAttachmentController],
]);
}
}

View File

@@ -0,0 +1,69 @@
/**
* 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 { CellPopupManagerService } from '@univerjs/sheets-ui';
import { beforeEach, describe, expect, it } from 'vitest';
import { SheetsNotePopupService } from '../sheets-note-popup.service';
class TestCellPopupManagerService {
static disposedCount = 0;
static reset() {
this.disposedCount = 0;
}
showPopup() {
return {
dispose: () => {
TestCellPopupManagerService.disposedCount += 1;
},
};
}
}
describe('SheetsNotePopupService', () => {
let service: SheetsNotePopupService;
beforeEach(() => {
TestCellPopupManagerService.reset();
const injector = new Injector();
injector.add([CellPopupManagerService, { useClass: TestCellPopupManagerService as never }]);
injector.add([SheetsNotePopupService]);
service = injector.get(SheetsNotePopupService);
});
it('shows a temporary note popup and hides it when requested', () => {
const active: unknown[] = [];
service.activePopup$.subscribe((popup) => active.push(popup));
service.showPopup({ unitId: 'book-1', subUnitId: 'sheet-1', row: 1, col: 2, noteId: 'note-1', temp: true });
service.hidePopup();
expect(service.activePopup).toBeNull();
expect(active.at(-2)).toEqual({ unitId: 'book-1', subUnitId: 'sheet-1', row: 1, col: 2, noteId: 'note-1', temp: true });
expect(active.at(-1)).toBeNull();
expect(TestCellPopupManagerService.disposedCount).toBe(1);
});
it('persists an opened temporary note popup', () => {
service.showPopup({ unitId: 'book-1', subUnitId: 'sheet-1', row: 1, col: 2, temp: true });
service.persistPopup();
expect(service.activePopup?.temp).toBe(false);
});
});

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 { IDisposable, Nullable } from '@univerjs/core';
import type { ISheetLocationBase } from '@univerjs/sheets';
import { Disposable, DisposableCollection, Inject } from '@univerjs/core';
import { CellPopupManagerService } from '@univerjs/sheets-ui';
import { BehaviorSubject } from 'rxjs';
import { SHEET_NOTE_COMPONENT } from '../views/config';
interface INotePopup extends ISheetLocationBase {
noteId?: string;
temp?: boolean;
trigger?: string;
}
export class SheetsNotePopupService extends Disposable {
private _lastPopup: Nullable<IDisposable> = null;
private _activePopup: Nullable<INotePopup>;
private _activePopup$ = new BehaviorSubject<Nullable<INotePopup>>(null);
activePopup$ = this._activePopup$.asObservable();
get activePopup() {
return this._activePopup;
}
constructor(
@Inject(CellPopupManagerService) private readonly _cellPopupManagerService: CellPopupManagerService
) {
super();
this.disposeWithMe(() => {
this._activePopup$.complete();
});
}
override dispose(): void {
super.dispose();
this.hidePopup();
}
showPopup(location: INotePopup, onHide?: () => void) {
const { row, col, unitId, subUnitId } = location;
if (
this._activePopup &&
row === this._activePopup.row &&
col === this._activePopup.col &&
unitId === this._activePopup.unitId &&
subUnitId === this.activePopup?.subUnitId
) {
this._activePopup = location;
this._activePopup$.next(location);
return;
}
if (this._lastPopup) {
this._lastPopup.dispose();
};
this._activePopup = location;
this._activePopup$.next(location);
const popupDisposable = this._cellPopupManagerService.showPopup(
{
unitId,
subUnitId,
row,
col,
},
{
componentKey: SHEET_NOTE_COMPONENT,
onClickOutside: () => {
this.hidePopup();
},
direction: 'horizontal',
extraProps: {
location,
},
priority: 3,
}
);
if (!popupDisposable) {
throw new Error('[SheetsNotePopupService]: cannot show popup!');
}
const disposableCollection = new DisposableCollection();
disposableCollection.add(popupDisposable);
disposableCollection.add({
dispose: () => {
onHide?.();
},
});
this._lastPopup = disposableCollection;
}
hidePopup(force?: boolean) {
if (!this._activePopup) {
return;
}
if (!force && !this._activePopup.temp) return;
if (this._lastPopup) {
this._lastPopup.dispose();
}
this._lastPopup = null;
this._activePopup = null;
this._activePopup$.next(null);
}
persistPopup() {
if (!this._activePopup || !this._activePopup.temp) {
return;
}
this._activePopup = {
...this._activePopup,
temp: false,
};
this._activePopup$.next(this._activePopup);
}
}

View File

@@ -0,0 +1,174 @@
/**
* 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 { ISheetLocationBase } from '@univerjs/sheets';
import type { ISheetNote } from '@univerjs/sheets-note';
import type { IPopup } from '@univerjs/ui';
import type { IUniverSheetsNoteUIConfig } from '../config/config';
import type { LocaleKey } from '../locale/types';
import { generateRandomId, ICommandService, LocaleService } from '@univerjs/core';
import { clsx, Textarea } from '@univerjs/design';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SheetsNoteModel, SheetUpdateNoteCommand } from '@univerjs/sheets-note';
import { useConfigValue, useDebounceFn, useDependency } from '@univerjs/ui';
import { useCallback, useEffect, useRef, useState } from 'react';
import { SHEETS_NOTE_UI_PLUGIN_CONFIG_KEY } from '../config/config';
import { SheetsNotePopupService } from '../services/sheets-note-popup.service';
type INotePopupLocation = ISheetLocationBase & {
temp?: boolean;
trigger?: string;
};
export const SheetsNote = (props: { popup: IPopup<{ location: INotePopupLocation }> }) => {
const activePopup = props.popup.extraProps?.location;
if (!activePopup) {
console.error('Popup extraProps or location is undefined.');
return null;
}
return <SheetsNoteContent activePopup={activePopup} />;
};
function SheetsNoteContent({ activePopup }: { activePopup: INotePopupLocation }) {
const noteModel = useDependency(SheetsNoteModel);
const localeService = useDependency(LocaleService);
const renderManagerService = useDependency(IRenderManagerService);
const notePopupService = useDependency(SheetsNotePopupService);
const config = useConfigValue<IUniverSheetsNoteUIConfig>(SHEETS_NOTE_UI_PLUGIN_CONFIG_KEY);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const initialWidthRef = useRef<number | null>(null);
const currentRender = renderManagerService.getRenderUnitById(activePopup.unitId)!;
const [note, setNote] = useState<Partial<ISheetNote> | null>(null);
const applyResizeOffset = useCallback((width: number) => {
const textarea = textareaRef.current;
if (!textarea) return;
if (localeService.getDirection() === 'rtl' && initialWidthRef.current != null) {
const offset = initialWidthRef.current - width;
textarea.style.transform = offset ? `translateX(${offset}px)` : '';
} else {
textarea.style.transform = '';
}
}, [localeService]);
useEffect(() => {
const { unitId, subUnitId, row, col } = activePopup;
const note = noteModel.getNote(unitId, subUnitId, { row, col });
const width = note?.width ?? config?.defaultNoteSize?.width ?? 160;
const height = note?.height ?? config?.defaultNoteSize?.height ?? 72;
if (!note) {
const initNote: Partial<ISheetNote> = {
id: generateRandomId(6),
width,
height,
note: '',
};
setNote(initNote);
updateNote(initNote);
} else {
setNote(note);
}
if (textareaRef.current) {
initialWidthRef.current = width;
textareaRef.current.style.width = `${width}px`;
textareaRef.current.style.height = `${height}px`;
textareaRef.current.style.transform = '';
}
}, [activePopup, textareaRef]);
useEffect(() => {
if (!activePopup || activePopup.temp || !activePopup.trigger) return;
if (!textareaRef.current) return;
const focusId = requestAnimationFrame(() => {
textareaRef.current?.focus();
});
return () => cancelAnimationFrame(focusId);
}, [activePopup]);
const commandService = useDependency(ICommandService);
const updateNote = useDebounceFn((newNote: Partial<ISheetNote>) => {
if (!activePopup) return;
const { unitId, subUnitId, row, col } = activePopup;
const result = commandService.syncExecuteCommand(SheetUpdateNoteCommand.id, {
unitId,
sheetId: subUnitId,
row,
col,
note: newNote,
});
// If the update fails
if (!result) {
const oldNote = noteModel.getNote(unitId, subUnitId, { noteId: newNote.id, row, col });
if (oldNote) {
// Revert to old note
setNote(oldNote);
} else {
// Hide popup if no old note exists
notePopupService.hidePopup(true);
}
}
});
const handleNoteChange = useCallback((value: string) => {
if (!note) return;
if (value === note.note) return;
const newNote = { ...note, note: value };
setNote(newNote);
updateNote(newNote);
}, [note]);
const handleResize = useCallback((width: number, height: number) => {
if (!note) return;
applyResizeOffset(width);
if (width === note.width && height === note.height) return;
const newNote = { ...note, width, height };
setNote(newNote);
updateNote(newNote);
}, [applyResizeOffset, note]);
return (
<Textarea
ref={textareaRef}
data-u-comp="note-textarea"
className={clsx(`
univer-ml-px univer-min-h-1 univer-min-w-1 univer-bg-gray-0 !univer-text-sm univer-shadow
dark:!univer-bg-gray-800
`)}
value={note?.note}
placeholder={localeService.t<LocaleKey>('sheets-note-ui.note.placeholder')}
onResize={handleResize}
onValueChange={handleNoteChange}
onWheel={(e) => {
if (document.activeElement !== textareaRef.current) {
currentRender.engine.getCanvasElement().dispatchEvent(new WheelEvent(e.type, e.nativeEvent));
}
}}
/>
);
}

View File

@@ -0,0 +1,355 @@
/**
* 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, IWorkbookData, Workbook } from '@univerjs/core';
import type { IPopup } from '@univerjs/ui';
import type { Root } from 'react-dom/client';
import {
awaitTime,
CommandType,
ICommandService,
IConfigService,
ILogService,
IUndoRedoService,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
toDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
RemoveNoteMutation,
SheetsNoteModel,
SheetUpdateNoteCommand,
UpdateNoteMutation,
} from '@univerjs/sheets-note';
import { CellPopupManagerService } from '@univerjs/sheets-ui';
import { RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it } from 'vitest';
import { SHEETS_NOTE_UI_PLUGIN_CONFIG_KEY } from '../../config/config';
import { SheetsNotePopupService } from '../../services/sheets-note-popup.service';
import { SheetsNote } from '../Note';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const UNIT_ID = 'note-view-workbook';
const SUB_UNIT_ID = 'sheet-1';
const FailingUpdateNoteMutation = {
id: UpdateNoteMutation.id,
type: CommandType.MUTATION,
handler: () => false,
};
class TestRenderManagerService {
getRenderUnitById() {
return {
engine: {
getCanvasElement() {
return document.createElement('canvas');
},
},
};
}
}
class TestCellPopupManagerService {
showPopup(): IDisposable {
return toDisposable(() => undefined);
}
}
class TestResizeObserver {
static callback: ResizeObserverCallback | undefined;
constructor(callback: ResizeObserverCallback) {
TestResizeObserver.callback = callback;
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
function createWorkbookData(): IWorkbookData {
return {
id: UNIT_ID,
appVersion: '3.0.0-alpha',
name: 'note workbook',
locale: LocaleType.EN_US,
sheetOrder: [SUB_UNIT_ID],
styles: {},
sheets: {
[SUB_UNIT_ID]: {
id: SUB_UNIT_ID,
name: 'Sheet 1',
cellData: {},
},
},
};
}
function createNoteLocation(row: number, col: number) {
return {
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
row,
col,
trigger: 'context-menu',
};
}
function createNotePopup(row: number, col: number): IPopup<{ location: { unitId: string; subUnitId: string; row: number; col: number; trigger: string } }> {
return {
extraProps: {
location: createNoteLocation(row, col),
},
} as IPopup<{ location: { unitId: string; subUnitId: string; row: number; col: number; trigger: string } }>;
}
function createNoteViewTestBed() {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([SheetsNoteModel]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([CellPopupManagerService, { useClass: TestCellPopupManagerService as never }]);
injector.add([SheetsNotePopupService]);
injector.get(IUndoRedoService);
injector.get(IConfigService).setConfig(SHEETS_NOTE_UI_PLUGIN_CONFIG_KEY, {
defaultNoteSize: {
width: 180,
height: 96,
},
});
injector.get(LocaleService).load({
[LocaleType.EN_US]: {
'sheets-note-ui': {
note: {
placeholder: 'Write a note',
},
},
},
});
injector.get(LocaleService).setLocale(LocaleType.EN_US);
const workbook = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, createWorkbookData());
injector.get(IUniverInstanceService).focusUnit(UNIT_ID);
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
const commandService = injector.get(ICommandService);
commandService.registerCommand(UpdateNoteMutation);
commandService.registerCommand(RemoveNoteMutation);
commandService.registerCommand(SheetUpdateNoteCommand);
return {
univer,
injector,
workbook,
noteModel: injector.get(SheetsNoteModel),
commandService,
notePopupService: injector.get(SheetsNotePopupService),
};
}
function inputText(textarea: HTMLTextAreaElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
valueSetter?.call(textarea, value);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function resizeTextarea(textarea: HTMLTextAreaElement, width: number, height: number) {
textarea.style.width = `${width}px`;
textarea.style.height = `${height}px`;
TestResizeObserver.callback?.([{
target: {
getBoundingClientRect: () => ({ width, height }),
},
} as unknown as ResizeObserverEntry], {} as ResizeObserver);
}
async function renderNote(root: Root, testBed: ReturnType<typeof createNoteViewTestBed>, row = 2, col = 3) {
await act(async () => {
root.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
<SheetsNote popup={createNotePopup(row, col)} />
</RediContext.Provider>
);
await awaitTime(20);
});
}
describe('SheetsNote', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createNoteViewTestBed> | undefined;
let resizeObserverDescriptor: PropertyDescriptor | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
if (resizeObserverDescriptor) {
Object.defineProperty(globalThis, 'ResizeObserver', resizeObserverDescriptor);
} else {
delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
}
root = undefined;
container = undefined;
currentTestBed = undefined;
resizeObserverDescriptor = undefined;
});
function createMountedNote() {
resizeObserverDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'ResizeObserver');
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: TestResizeObserver,
});
currentTestBed = createNoteViewTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
return currentTestBed;
}
it('creates a note with the configured default size when an empty cell popup opens', async () => {
const testBed = createMountedNote();
await renderNote(root!, testBed);
await act(async () => {
await awaitTime(350);
});
const note = testBed.noteModel.getNote(UNIT_ID, SUB_UNIT_ID, { row: 2, col: 3 });
expect(note?.note).toBe('');
expect(note?.width).toBe(180);
expect(note?.height).toBe(96);
expect(container!.querySelector('textarea')?.placeholder).toBe('Write a note');
});
it('persists edited note text through the sheet note command pipeline', async () => {
const testBed = createMountedNote();
await renderNote(root!, testBed);
await act(async () => {
await awaitTime(350);
});
const textarea = container!.querySelector('textarea') as HTMLTextAreaElement;
await act(async () => {
inputText(textarea, 'Follow up with finance');
await awaitTime(350);
});
const note = testBed.noteModel.getNote(UNIT_ID, SUB_UNIT_ID, { row: 2, col: 3 });
expect(note?.note).toBe('Follow up with finance');
expect(note?.width).toBe(180);
expect(note?.height).toBe(96);
});
it('moves an RTL note left while resizing wider, but reopens at the anchored position with the saved size', async () => {
const testBed = createMountedNote();
testBed.injector.get(LocaleService).setDirection('rtl');
await renderNote(root!, testBed);
await act(async () => {
await awaitTime(350);
});
let textarea = container!.querySelector('textarea') as HTMLTextAreaElement;
await act(async () => {
resizeTextarea(textarea, 220, 96);
await awaitTime(350);
});
expect(textarea.style.transform).toBe('translateX(-40px)');
expect(testBed.noteModel.getNote(UNIT_ID, SUB_UNIT_ID, { row: 2, col: 3 })?.width).toBe(220);
act(() => {
root!.unmount();
});
root = createRoot(container!);
await renderNote(root!, testBed);
textarea = container!.querySelector('textarea') as HTMLTextAreaElement;
expect(textarea.style.width).toBe('220px');
expect(textarea.style.transform).toBe('');
});
it('closes the popup when creating the initial note cannot be saved', async () => {
const testBed = createMountedNote();
testBed.commandService.unregisterCommand(UpdateNoteMutation.id);
testBed.commandService.registerCommand(FailingUpdateNoteMutation);
testBed.notePopupService.showPopup(createNoteLocation(2, 3));
await renderNote(root!, testBed);
await act(async () => {
await awaitTime(350);
});
expect(testBed.noteModel.getNote(UNIT_ID, SUB_UNIT_ID, { row: 2, col: 3 })).toBeUndefined();
expect(testBed.notePopupService.activePopup).toBeNull();
});
it('restores the previous note text when an edit cannot be saved', async () => {
const testBed = createMountedNote();
testBed.commandService.syncExecuteCommand(SheetUpdateNoteCommand.id, {
unitId: UNIT_ID,
sheetId: SUB_UNIT_ID,
row: 2,
col: 3,
note: {
id: 'existing-note',
width: 180,
height: 96,
note: 'Original note',
},
});
testBed.commandService.unregisterCommand(UpdateNoteMutation.id);
testBed.commandService.registerCommand(FailingUpdateNoteMutation);
await renderNote(root!, testBed);
await act(async () => {
await awaitTime(350);
});
const textarea = container!.querySelector('textarea') as HTMLTextAreaElement;
await act(async () => {
inputText(textarea, 'Unsaved edit');
await awaitTime(350);
});
const note = testBed.noteModel.getNote(UNIT_ID, SUB_UNIT_ID, { row: 2, col: 3 });
expect(note?.note).toBe('Original note');
expect(textarea.value).toBe('Original note');
});
});

View File

@@ -0,0 +1,17 @@
/**
* 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 const SHEET_NOTE_COMPONENT = 'SHEET_NOTE_COMPONENT';

View File

@@ -0,0 +1,27 @@
/**
* 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 { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;

View File

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

View File

@@ -0,0 +1,18 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/node",
"compilerOptions": {
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "lib/types"
},
"include": [
"src"
],
"exclude": [
"src/**/__tests__/**",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();