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-hyper-link-ui
[![npm version](https://img.shields.io/npm/v/@univerjs/sheets-hyper-link-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-hyper-link-ui)
[![license](https://img.shields.io/npm/l/@univerjs/sheets-hyper-link-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-hyper-link-ui)
[![downloads](https://img.shields.io/npm/dm/@univerjs/sheets-hyper-link-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-hyper-link-ui)
`@univerjs/sheets-hyper-link-ui` adds hyperlink menus, dialogs, rendering, and interaction UI for Univer Sheets.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/sheets-hyper-link-ui` | `UniverSheetsHyperLinkUi` | Yes | Yes | Yes |
## Installation
```sh
pnpm add @univerjs/sheets-hyper-link-ui
# or
npm install @univerjs/sheets-hyper-link-ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/sheets-hyper-link-ui/lib/index.css';
import EnUS from '@univerjs/sheets-hyper-link-ui/locale/en-US';
import { UniverSheetsHyperLinkUIPlugin } from '@univerjs/sheets-hyper-link-ui';
univer.registerPlugin(UniverSheetsHyperLinkUIPlugin);
// 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-hyper-link-ui)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,111 @@
{
"name": "@univerjs/sheets-hyper-link-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Hyperlink editing 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",
"hyperlink",
"link",
"ui"
],
"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": {
"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/docs": "workspace:*",
"@univerjs/docs-ui": "workspace:*",
"@univerjs/engine-formula": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/sheets": "workspace:*",
"@univerjs/sheets-data-validation": "workspace:*",
"@univerjs/sheets-formula-ui": "workspace:*",
"@univerjs/sheets-hyper-link": "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,156 @@
/**
* 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 { IOpenHyperLinkEditPanelOperationParams } from '../popup.operations';
import { ICommandService, IUniverInstanceService } from '@univerjs/core';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { IEditorBridgeService } from '@univerjs/sheets-ui';
import { describe, expect, it, vi } from 'vitest';
import { ISheetsHyperLinkPopupService } from '../../../services/popup.service';
import { HyperLinkEditSourceType } from '../../../types/enums/edit-source';
import {
CloseHyperLinkPopupOperation,
InsertHyperLinkOperation,
InsertHyperLinkToolbarOperation,
OpenHyperLinkEditPanelOperation,
} from '../popup.operations';
function createAccessor(pairs: Array<[unknown, unknown]>) {
const map = new Map<unknown, unknown>(pairs);
return {
get(token: unknown) {
if (!map.has(token)) {
throw new Error(`Unknown token: ${String(token)}`);
}
return map.get(token);
},
has(token: unknown) {
return map.has(token);
},
} as never;
}
function createWorksheet(cell: Record<string, unknown> | null = null) {
return {
getSheetId: () => 's1',
getUnitId: () => 'u1',
getCell: vi.fn(() => cell),
};
}
function createWorkbook(worksheet: ReturnType<typeof createWorksheet>) {
return {
getUnitId: () => 'u1',
getActiveSheet: () => worksheet,
getSheetBySheetId: () => worksheet,
};
}
function createUniverInstanceService(options?: {
workbook?: ReturnType<typeof createWorkbook> | null;
}) {
return {
getCurrentUnitOfType: () => options?.workbook ?? null,
};
}
describe('hyper-link popup operations', () => {
it('starts add and edit flows through the popup service', () => {
const startAddEditing = vi.fn();
const startEditing = vi.fn();
const popupService = { startAddEditing, startEditing };
const accessor = createAccessor([[ISheetsHyperLinkPopupService, popupService]]);
const startAddParams: IOpenHyperLinkEditPanelOperationParams = {
unitId: 'u1',
subUnitId: 's1',
row: 1,
col: 2,
type: HyperLinkEditSourceType.VIEWING,
};
const startEditParams: IOpenHyperLinkEditPanelOperationParams = {
...startAddParams,
customRangeId: 'range-1',
type: HyperLinkEditSourceType.EDITING,
};
expect(OpenHyperLinkEditPanelOperation.handler(accessor, undefined)).toBe(false);
expect(OpenHyperLinkEditPanelOperation.handler(accessor, startAddParams)).toBe(true);
expect(OpenHyperLinkEditPanelOperation.handler(accessor, startEditParams)).toBe(true);
expect(startAddEditing).toHaveBeenCalledTimes(1);
expect(startEditing).toHaveBeenCalledTimes(1);
});
it('closes the popup edit session', () => {
const endEditing = vi.fn();
const accessor = createAccessor([[ISheetsHyperLinkPopupService, { endEditing }]]);
expect(CloseHyperLinkPopupOperation.handler(accessor)).toBe(true);
expect(endEditing).toHaveBeenCalledTimes(1);
});
it('derives the correct edit source from the current sheet selection state', () => {
const executeCommand = vi.fn(() => true);
const worksheet = createWorksheet();
const workbook = createWorkbook(worksheet);
const createInsertAccessor = (visible: boolean, hasSelection = true, activeWorkbook: ReturnType<typeof createWorkbook> | null = workbook) => createAccessor([
[IUniverInstanceService, createUniverInstanceService({ workbook: activeWorkbook })],
[ICommandService, { executeCommand }],
[SheetsSelectionsService, { getCurrentLastSelection: () => (hasSelection ? { range: { startRow: 3, startColumn: 4 } } : null) }],
[IEditorBridgeService, { isVisible: () => ({ visible }) }],
]);
expect(InsertHyperLinkOperation.handler(createInsertAccessor(false))).toBe(true);
expect(InsertHyperLinkOperation.handler(createInsertAccessor(true))).toBe(true);
expect(executeCommand).toHaveBeenNthCalledWith(1, OpenHyperLinkEditPanelOperation.id, expect.objectContaining({ type: HyperLinkEditSourceType.VIEWING }));
expect(executeCommand).toHaveBeenNthCalledWith(2, OpenHyperLinkEditPanelOperation.id, expect.objectContaining({ type: HyperLinkEditSourceType.EDITING }));
expect(InsertHyperLinkOperation.handler(createInsertAccessor(false, true, null))).toBe(false);
expect(InsertHyperLinkOperation.handler(createInsertAccessor(false, false))).toBe(false);
});
it('respects disabled cells and toggles between insert and close commands from the toolbar', () => {
const executeCommand = vi.fn(() => true);
const disabledWorksheet = createWorksheet({ f: '=A1' });
const enabledWorksheet = createWorksheet({ v: 'cell' });
expect(InsertHyperLinkToolbarOperation.handler(createAccessor([
[IUniverInstanceService, createUniverInstanceService({ workbook: createWorkbook(disabledWorksheet) })],
[SheetsSelectionsService, { getCurrentSelections: () => [{ range: { startRow: 1, startColumn: 1 } }] }],
[ICommandService, { executeCommand }],
[ISheetsHyperLinkPopupService, { currentEditing: null }],
]))).toBe(false);
expect(InsertHyperLinkToolbarOperation.handler(createAccessor([
[IUniverInstanceService, createUniverInstanceService({ workbook: createWorkbook(enabledWorksheet) })],
[SheetsSelectionsService, { getCurrentSelections: () => [{ range: { startRow: 1, startColumn: 1 } }] }],
[ICommandService, { executeCommand }],
[ISheetsHyperLinkPopupService, { currentEditing: { row: 1 } }],
]))).toBe(true);
expect(InsertHyperLinkToolbarOperation.handler(createAccessor([
[IUniverInstanceService, createUniverInstanceService({ workbook: createWorkbook(enabledWorksheet) })],
[SheetsSelectionsService, { getCurrentSelections: () => [{ range: { startRow: 1, startColumn: 1 } }] }],
[ICommandService, { executeCommand }],
[ISheetsHyperLinkPopupService, { currentEditing: null }],
]))).toBe(true);
expect(executeCommand).toHaveBeenCalledWith(CloseHyperLinkPopupOperation.id);
expect(executeCommand).toHaveBeenCalledWith(InsertHyperLinkOperation.id);
});
});

View File

@@ -0,0 +1,107 @@
/**
* 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 { ICommand } from '@univerjs/core';
import type { ISheetCommandSharedParams } from '@univerjs/sheets';
import { CommandType, ICommandService, IUniverInstanceService } from '@univerjs/core';
import { getSheetCommandTarget, SheetsSelectionsService } from '@univerjs/sheets';
import { IEditorBridgeService } from '@univerjs/sheets-ui';
import { ISheetsHyperLinkPopupService } from '../../services/popup.service';
import { HyperLinkEditSourceType } from '../../types/enums/edit-source';
import { getShouldDisableCurrentCellLink } from '../../utils';
export interface IOpenHyperLinkEditPanelOperationParams extends ISheetCommandSharedParams {
row: number;
col: number;
customRangeId?: string;
type: HyperLinkEditSourceType;
}
export const OpenHyperLinkEditPanelOperation: ICommand<IOpenHyperLinkEditPanelOperationParams> = {
type: CommandType.OPERATION,
id: 'sheet.operation.open-hyper-link-edit-panel',
handler(accessor, params) {
if (!params) {
return false;
}
const popupService = accessor.get(ISheetsHyperLinkPopupService);
if (!params.customRangeId) {
popupService.startAddEditing(params);
} else {
popupService.startEditing(params as Required<IOpenHyperLinkEditPanelOperationParams>);
}
return true;
},
};
export const CloseHyperLinkPopupOperation: ICommand = {
type: CommandType.OPERATION,
id: 'sheet.operation.close-hyper-link-popup',
handler(accessor) {
const popupService = accessor.get(ISheetsHyperLinkPopupService);
popupService.endEditing();
return true;
},
};
export const InsertHyperLinkOperation: ICommand = {
type: CommandType.OPERATION,
id: 'sheet.operation.insert-hyper-link',
handler(accessor) {
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService);
const editorBridgeService = accessor.get(IEditorBridgeService);
if (!target) {
return false;
}
const commandService = accessor.get(ICommandService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const selection = selectionManagerService.getCurrentLastSelection();
if (!selection) {
return false;
}
const row = selection.range.startRow;
const col = selection.range.startColumn;
const visible = editorBridgeService.isVisible();
return commandService.executeCommand(OpenHyperLinkEditPanelOperation.id, {
unitId: target.unitId,
subUnitId: target.subUnitId,
row,
col,
type: visible.visible ? HyperLinkEditSourceType.EDITING : HyperLinkEditSourceType.VIEWING,
});
},
};
export const InsertHyperLinkToolbarOperation: ICommand = {
type: CommandType.OPERATION,
id: 'sheet.operation.insert-hyper-link-toolbar',
handler(accessor) {
if (getShouldDisableCurrentCellLink(accessor)) {
return false;
}
const commandService = accessor.get(ICommandService);
const popupService = accessor.get(ISheetsHyperLinkPopupService);
if (popupService.currentEditing) {
return commandService.executeCommand(CloseHyperLinkPopupOperation.id);
} else {
return commandService.executeCommand(InsertHyperLinkOperation.id);
}
},
};

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 { describe, expect, it } from 'vitest';
import { hasProtocol, isEmail, serializeUrl } from '../util';
describe('hyper-link common util', () => {
it('should detect protocols and email addresses', () => {
expect(hasProtocol('https://univer.ai')).toBe(true);
expect(hasProtocol('univer.ai')).toBe(false);
expect(isEmail('test@example.com')).toBe(true);
expect(isEmail('not-an-email')).toBe(false);
});
it('should normalize external URLs and keep same-page anchors compact', () => {
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: {
hostname: 'localhost',
port: '',
protocol: 'http:',
pathname: '/sheet',
},
});
expect(serializeUrl('https://univer.ai')).toBe('https://univer.ai');
expect(serializeUrl('not a legal link')).toBe('not a legal link');
});
});

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 { Tools } from '@univerjs/core';
export function isLegalLink(link: string) {
return Tools.isLegalUrl(link);
}
export function hasProtocol(urlString: string) {
const pattern = /^[a-zA-Z]+:\/\//;
return pattern.test(urlString);
}
export function isEmail(url: string) {
const pattern = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/;
return pattern.test(url);
}
export function serializeUrl(urlStr: string) {
if (isLegalLink(urlStr)) {
const transformedUrl = hasProtocol(urlStr) ? urlStr : isEmail(urlStr) ? `mailto://${urlStr}` : `http://${urlStr}`;
let url: URL;
try {
url = new URL(transformedUrl);
} catch {
return urlStr;
}
if (
url.hostname === location.hostname &&
url.port === location.port &&
url.protocol === location.protocol &&
url.pathname === location.pathname &&
url.hash &&
!url.search
) {
return url.hash;
}
return transformedUrl;
}
return urlStr;
}

View File

@@ -0,0 +1,31 @@
/**
* 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_HYPER_LINK_UI_PLUGIN_CONFIG_KEY = 'sheets-hyper-link-ui.config';
export const configSymbol = Symbol(SHEETS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY);
export interface IUrlHandler {
navigateToOtherWebsite?: (url: string) => void;
}
export interface IUniverSheetsHyperLinkUIConfig {
menu?: MenuConfig;
urlHandler?: IUrlHandler;
}
export const defaultPluginConfig: IUniverSheetsHyperLinkUIConfig = {};

View File

@@ -0,0 +1,91 @@
/**
* 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 { AUTO_FILL_APPLY_TYPE } from '@univerjs/sheets';
import { AddHyperLinkMutation, RemoveHyperLinkMutation } from '@univerjs/sheets-hyper-link';
import { describe, expect, it, vi } from 'vitest';
import { SheetsHyperLinkAutoFillController } from '../auto-fill.controller';
describe('SheetsHyperLinkAutoFillController', () => {
it('should generate add/remove link mutations when copying', () => {
let capturedHook: any;
const addHook = vi.fn((hook) => {
capturedHook = hook;
return { dispose: vi.fn() };
});
const autoFillService = {
addHook,
} as any;
const hyperLinkModel = {
getHyperLinkByLocation: vi.fn((unitId: string, subUnitId: string, row: number, col: number) => {
if (unitId !== 'u1' || subUnitId !== 's1') return null;
if (row === 0 && col === 0) {
return { id: 'link-1', payload: '#gid=sheet&range=A1', row, column: col };
}
if (row === 1 && col === 0) {
return { id: 'current-link', payload: '#gid=sheet&range=A2', row, column: col };
}
return null;
}),
} as any;
// init registers hook
const controller = new SheetsHyperLinkAutoFillController(autoFillService, hyperLinkModel);
expect(addHook).toHaveBeenCalledTimes(1);
const res = capturedHook.onFillData({
unitId: 'u1',
subUnitId: 's1',
source: { rows: [0], cols: [0] },
// target should be the filled destination range, not including source
target: { rows: [1], cols: [0] },
}, 'DOWN', AUTO_FILL_APPLY_TYPE.COPY);
expect(res.redos.map((m: any) => m.id)).toEqual([RemoveHyperLinkMutation.id, AddHyperLinkMutation.id]);
expect(res.undos.map((m: any) => m.id)).toEqual([RemoveHyperLinkMutation.id, AddHyperLinkMutation.id]);
controller.dispose();
});
it('should return noop mutations for non-copy apply type', () => {
let capturedHook: any;
const autoFillService = {
addHook: vi.fn((hook) => {
capturedHook = hook;
return { dispose: vi.fn() };
}),
} as any;
const hyperLinkModel = {
getHyperLinkByLocation: vi.fn(() => null),
} as any;
const controller = new SheetsHyperLinkAutoFillController(autoFillService, hyperLinkModel);
const res = capturedHook.onFillData({
unitId: 'u1',
subUnitId: 's1',
source: { rows: [0], cols: [0] },
target: { rows: [0, 1], cols: [0] },
}, 'DOWN', AUTO_FILL_APPLY_TYPE.NO_FORMAT);
expect(res).toEqual({ redos: [], undos: [] });
controller.dispose();
});
});

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 { AddHyperLinkMutation, RemoveHyperLinkMutation } from '@univerjs/sheets-hyper-link';
import { COPY_TYPE, PREDEFINED_HOOK_NAME_PASTE } from '@univerjs/sheets-ui';
import { describe, expect, it, vi } from 'vitest';
import { SheetsHyperLinkCopyPasteController } from '../copy-paste.controller';
describe('SheetsHyperLinkCopyPasteController', () => {
it('should generate remove/add mutations when pasting a copied hyperlink cell', () => {
let hook: any;
const addClipboardHook = vi.fn((h) => {
hook = h;
});
const sheetClipboardService = {
addClipboardHook,
} as any;
const hyperLinkModel = {
getHyperLinkByLocation: vi.fn((unitId: string, subUnitId: string, row: number, col: number) => {
if (unitId === 'u1' && subUnitId === 's1' && row === 0 && col === 0) {
return { id: 'old-id', payload: '#gid=s1&range=A1', row, column: col };
}
if (unitId === 'u2' && subUnitId === 's2' && row === 1 && col === 1) {
return { id: 'current-id', payload: '#gid=s2&range=B2', row, column: col };
}
return null;
}),
getHyperLink: vi.fn((unitId: string, subUnitId: string, id: string) => {
if (unitId === 'u1' && subUnitId === 's1' && id === 'old-id') {
return { id, payload: '#gid=s1&range=A1', row: 0, column: 0 };
}
return null;
}),
} as any;
const injector = {
invoke: vi.fn(() => ({ rows: [0], cols: [0] })),
} as any;
const resolverService = {} as any;
const controller = new SheetsHyperLinkCopyPasteController(sheetClipboardService, hyperLinkModel, injector, resolverService);
expect(addClipboardHook).toHaveBeenCalledTimes(1);
// Simulate copy stage
hook.onBeforeCopy('u1', 's1', { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 });
const res = hook.onPasteCells(
{ range: { rows: [0], cols: [0] } },
{ unitId: 'u2', subUnitId: 's2', range: { rows: [1], cols: [1] } },
null,
{ copyType: COPY_TYPE.COPY, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE, copyRange: { rows: [0], cols: [0] } }
);
expect(res.redos.map((m: any) => m.id)).toEqual([RemoveHyperLinkMutation.id, AddHyperLinkMutation.id]);
expect(res.undos.map((m: any) => m.id)).toEqual([RemoveHyperLinkMutation.id, AddHyperLinkMutation.id]);
controller.dispose();
});
it('should ignore special paste types', () => {
let hook: any;
const sheetClipboardService = {
addClipboardHook: vi.fn((h) => {
hook = h;
}),
} as any;
const hyperLinkModel = { getHyperLinkByLocation: vi.fn(), getHyperLink: vi.fn() } as any;
const injector = { invoke: vi.fn(() => ({ rows: [0], cols: [0] })) } as any;
const resolverService = {} as any;
const controller = new SheetsHyperLinkCopyPasteController(sheetClipboardService, hyperLinkModel, injector, resolverService);
hook.onBeforeCopy('u1', 's1', { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 });
const res = hook.onPasteCells(
{ range: { rows: [0], cols: [0] } },
{ unitId: 'u2', subUnitId: 's2', range: { rows: [1], cols: [1] } },
null,
{ copyType: COPY_TYPE.COPY, pasteType: PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE, copyRange: { rows: [0], cols: [0] } }
);
expect(res).toEqual({ redos: [], undos: [] });
controller.dispose();
});
});

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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { InsertLinkShortcut } from '../../menu/menu';
import { SheetsHyperLinkPermissionController } from '../hyper-link-permission.controller';
describe('SheetsHyperLinkPermissionController', () => {
let beforeCommandExecuted: any;
beforeEach(() => {
beforeCommandExecuted = undefined;
});
it('should block shortcut execution when permission fails', () => {
const localeService = { t: (key: string) => key } as any;
const commandService = {
beforeCommandExecuted: vi.fn((fn) => {
beforeCommandExecuted = fn;
return { dispose: vi.fn() };
}),
} as any;
const blockExecuteWithoutPermission = vi.fn();
const sheetPermissionCheckController = {
permissionCheckWithRanges: vi.fn(() => false),
blockExecuteWithoutPermission,
} as any;
const controller = new SheetsHyperLinkPermissionController(localeService, commandService, sheetPermissionCheckController);
beforeCommandExecuted({ id: InsertLinkShortcut.id });
expect(blockExecuteWithoutPermission).toHaveBeenCalledWith('sheets-hyper-link-ui.permission.hyperLinkErr');
controller.dispose();
});
it('should not block when permission passes', () => {
const localeService = { t: (key: string) => key } as any;
const commandService = {
beforeCommandExecuted: vi.fn((fn) => {
beforeCommandExecuted = fn;
return { dispose: vi.fn() };
}),
} as any;
const blockExecuteWithoutPermission = vi.fn();
const sheetPermissionCheckController = {
permissionCheckWithRanges: vi.fn(() => true),
blockExecuteWithoutPermission,
} as any;
const controller = new SheetsHyperLinkPermissionController(localeService, commandService, sheetPermissionCheckController);
beforeCommandExecuted({ id: InsertLinkShortcut.id });
expect(blockExecuteWithoutPermission).not.toHaveBeenCalled();
controller.dispose();
});
});

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 { awaitTime } from '@univerjs/core';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SheetsHyperLinkRenderController } from '../render-controllers/render.controller';
describe('SheetsHyperLinkRenderController', () => {
it('should mark skeleton dirty when link updates', async () => {
const makeForceDirty = vi.fn();
const linkUpdate$ = new Subject<void>();
const controller = new SheetsHyperLinkRenderController({
mainComponent: { makeForceDirty },
} as any, { linkUpdate$ } as any);
linkUpdate$.next();
// debounced by 16ms
await awaitTime(30);
expect(makeForceDirty).toHaveBeenCalledTimes(1);
controller.dispose();
});
});

View File

@@ -0,0 +1,159 @@
/**
* 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 { IMutationInfo } from '@univerjs/core';
import type { IAutoFillLocation, ISheetAutoFillHook } from '@univerjs/sheets';
import { Disposable, generateRandomId, Inject, Range, Rectangle } from '@univerjs/core';
import { AUTO_FILL_APPLY_TYPE, AutoFillTools, IAutoFillService } from '@univerjs/sheets';
import { AddHyperLinkMutation, HyperLinkModel, RemoveHyperLinkMutation } from '@univerjs/sheets-hyper-link';
import { virtualizeDiscreteRanges } from '@univerjs/sheets-ui';
import { SHEET_HYPER_LINK_UI_PLUGIN } from '../types/const';
export class SheetsHyperLinkAutoFillController extends Disposable {
constructor(
@IAutoFillService private readonly _autoFillService: IAutoFillService,
@Inject(HyperLinkModel) private readonly _hyperLinkModel: HyperLinkModel
) {
super();
this._initAutoFill();
}
// eslint-disable-next-line max-lines-per-function
private _initAutoFill() {
const noopReturnFunc = () => ({ redos: [], undos: [] });
// eslint-disable-next-line max-lines-per-function
const generalApplyFunc = (location: IAutoFillLocation, applyType: AUTO_FILL_APPLY_TYPE) => {
const { source: sourceRange, target: targetRange, unitId, subUnitId } = location;
const virtualRange = virtualizeDiscreteRanges([sourceRange, targetRange]);
const [vSourceRange, vTargetRange] = virtualRange.ranges;
const { mapFunc } = virtualRange;
const sourceStartCell = {
row: vSourceRange.startRow,
col: vSourceRange.startColumn,
};
const repeats = AutoFillTools.getAutoFillRepeatRange(vSourceRange, vTargetRange);
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
// eslint-disable-next-line max-lines-per-function
repeats.forEach((repeat) => {
const targetStartCell = repeat.repeatStartCell;
const relativeRange = repeat.relativeRange;
const sourceRange = {
startRow: sourceStartCell.row,
startColumn: sourceStartCell.col,
endColumn: sourceStartCell.col,
endRow: sourceStartCell.row,
};
const targetRange = {
startRow: targetStartCell.row,
startColumn: targetStartCell.col,
endColumn: targetStartCell.col,
endRow: targetStartCell.row,
};
Range.foreach(relativeRange, (row, col) => {
const sourcePositionRange = Rectangle.getPositionRange(
{
startRow: row,
startColumn: col,
endColumn: col,
endRow: row,
},
sourceRange
);
const { row: sourceRow, col: sourceCol } = mapFunc(sourcePositionRange.startRow, sourcePositionRange.startColumn);
const link = this._hyperLinkModel.getHyperLinkByLocation(unitId, subUnitId, sourceRow, sourceCol);
const targetPositionRange = Rectangle.getPositionRange(
{
startRow: row,
startColumn: col,
endColumn: col,
endRow: row,
},
targetRange
);
const { row: targetRow, col: targetCol } = mapFunc(targetPositionRange.startRow, targetPositionRange.startColumn);
const id = generateRandomId();
const currentLink = this._hyperLinkModel.getHyperLinkByLocation(unitId, subUnitId, targetRow, targetCol);
if (currentLink) {
redos.push({
id: RemoveHyperLinkMutation.id,
params: {
unitId,
subUnitId,
id: currentLink.id,
},
});
}
if ((AUTO_FILL_APPLY_TYPE.COPY === applyType || AUTO_FILL_APPLY_TYPE.SERIES === applyType) && link) {
redos.push({
id: AddHyperLinkMutation.id,
params: {
unitId,
subUnitId,
link: {
...link,
id,
row: targetRow,
column: targetCol,
},
},
});
undos.push({
id: RemoveHyperLinkMutation.id,
params: {
unitId,
subUnitId,
id,
},
});
}
if (currentLink) {
undos.push({
id: AddHyperLinkMutation.id,
params: {
unitId,
subUnitId,
link: currentLink,
},
});
}
});
});
return {
undos,
redos,
};
};
const hook: ISheetAutoFillHook = {
id: SHEET_HYPER_LINK_UI_PLUGIN,
onFillData: (location, direction, applyType) => {
if (
applyType === AUTO_FILL_APPLY_TYPE.COPY ||
applyType === AUTO_FILL_APPLY_TYPE.ONLY_FORMAT ||
applyType === AUTO_FILL_APPLY_TYPE.SERIES
) {
return generalApplyFunc(location, applyType);
}
return noopReturnFunc();
},
};
this.disposeWithMe(this._autoFillService.addHook(hook));
}
}

View File

@@ -0,0 +1,48 @@
/**
* 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 { LinkIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { CellLinkEdit } from '../views/CellLinkEdit';
import { CellLinkPopup } from '../views/CellLinkPopup';
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 {
([
[CellLinkPopup.componentKey, CellLinkPopup],
[CellLinkEdit.componentKey, CellLinkEdit],
] as const).forEach(([key, comp]) => {
this.disposeWithMe(this._componentManager.register(key, comp));
});
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
LinkIcon,
}));
}
}

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 { IMutationInfo, IRange, Nullable } from '@univerjs/core';
import type { IDiscreteRange } from '@univerjs/sheets';
import type { IPasteHookValueType, ISheetDiscreteRangeLocation } from '@univerjs/sheets-ui';
import { Disposable, generateRandomId, Inject, Injector, ObjectMatrix, Range, Rectangle } from '@univerjs/core';
import { rangeToDiscreteRange } from '@univerjs/sheets';
import { AddHyperLinkMutation, HyperLinkModel, RemoveHyperLinkMutation } from '@univerjs/sheets-hyper-link';
import { COPY_TYPE, getRepeatRange, ISheetClipboardService, PREDEFINED_HOOK_NAME_PASTE, virtualizeDiscreteRanges } from '@univerjs/sheets-ui';
import { isLegalLink } from '../common/util';
import { SheetsHyperLinkResolverService } from '../services/resolver.service';
import { SHEET_HYPER_LINK_UI_PLUGIN } from '../types/const';
export class SheetsHyperLinkCopyPasteController extends Disposable {
private _plainTextFilter = new Set<(text: string) => boolean>();
registerPlainTextFilter(filter: (text: string) => boolean) {
this._plainTextFilter.add(filter);
}
removePlainTextFilter(filter: (text: string) => boolean) {
this._plainTextFilter.delete(filter);
}
/* If return false the process of paste text will be stop */
private _filterPlainText(text: string) {
return Array.from(this._plainTextFilter).every((filter) => filter(text));
}
private _copyInfo: Nullable<{
matrix: ObjectMatrix<string>;
unitId: string;
subUnitId: string;
}>;
constructor(
@ISheetClipboardService private _sheetClipboardService: ISheetClipboardService,
@Inject(HyperLinkModel) private _hyperLinkModel: HyperLinkModel,
@Inject(Injector) private _injector: Injector,
@Inject(SheetsHyperLinkResolverService) private _resolverService: SheetsHyperLinkResolverService
) {
super();
this._initCopyPaste();
this.disposeWithMe(() => {
this._plainTextFilter.clear();
});
}
private _initCopyPaste() {
this._sheetClipboardService.addClipboardHook({
id: SHEET_HYPER_LINK_UI_PLUGIN,
onBeforeCopy: (unitId, subUnitId, range) => this._collect(unitId, subUnitId, range),
onPasteCells: (pasteFrom, pasteTo, data, payload) => {
const { copyType = COPY_TYPE.COPY, pasteType } = payload;
const { range: copyRange } = pasteFrom || {};
const { range: pastedRange, unitId, subUnitId } = pasteTo;
return this._generateMutations(pastedRange, { copyType, pasteType, copyRange, unitId, subUnitId });
},
onPastePlainText: (pasteTo: ISheetDiscreteRangeLocation, clipText: string) => {
const filterResult = this._filterPlainText(clipText);
if (isLegalLink(clipText) && filterResult) {
const { range, unitId, subUnitId } = pasteTo;
const { ranges: [pasteToRange], mapFunc } = virtualizeDiscreteRanges([range]);
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
Range.foreach(pasteToRange, (originRow, originCol) => {
const { row, col: column } = mapFunc(originRow, originCol);
const link = this._hyperLinkModel.getHyperLinkByLocation(unitId, subUnitId, row, column);
if (link) {
redos.push({
id: RemoveHyperLinkMutation.id,
params: {
unitId,
subUnitId,
id: link.id,
},
});
}
if (link) {
undos.push({
id: AddHyperLinkMutation.id,
params: {
unitId,
subUnitId,
link,
},
});
}
});
return { redos, undos };
}
return { undos: [], redos: [] };
},
priority: 99,
});
}
private _collect(unitId: string, subUnitId: string, range: IRange) {
const matrix = new ObjectMatrix<string>();
this._copyInfo = {
unitId,
subUnitId,
matrix,
};
const discreteRange = this._injector.invoke((accessor) => {
return rangeToDiscreteRange(range, accessor, unitId, subUnitId);
});
if (!discreteRange) {
return;
}
const { rows, cols } = discreteRange;
rows.forEach((row, rowIndex) => {
cols.forEach((col, colIndex) => {
const link = this._hyperLinkModel.getHyperLinkByLocation(unitId, subUnitId, row, col);
matrix.setValue(rowIndex, colIndex, link?.id ?? '');
});
});
}
// eslint-disable-next-line max-lines-per-function
private _generateMutations(
pastedRange: IDiscreteRange,
copyInfo: {
copyType: COPY_TYPE;
copyRange?: IDiscreteRange;
pasteType: IPasteHookValueType;
unitId: string;
subUnitId: string;
}
) {
if (!this._copyInfo) {
return { redos: [], undos: [] };
}
if (!this._copyInfo || !this._copyInfo.matrix.getSizeOf() || !copyInfo.copyRange) {
return { redos: [], undos: [] };
}
const specialPastes: IPasteHookValueType[] = [
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_COL_WIDTH,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMAT,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMULA,
];
if (specialPastes.includes(copyInfo.pasteType)) {
return { redos: [], undos: [] };
}
const { unitId, subUnitId } = this._copyInfo;
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const { ranges: [vCopyRange, vPastedRange], mapFunc } = virtualizeDiscreteRanges([copyInfo.copyRange, pastedRange]);
const repeatRange = getRepeatRange(vCopyRange, vPastedRange, true);
repeatRange.forEach(({ startRange }) => {
this._copyInfo?.matrix.forValue((row, col, ruleId) => {
const range = Rectangle.getPositionRange(
{
startRow: row,
endRow: row,
startColumn: col,
endColumn: col,
},
startRange
);
const oldLink = this._hyperLinkModel.getHyperLink(unitId, subUnitId, ruleId);
const { row: startRow, col: startColumn } = mapFunc(range.startRow, range.startColumn);
const currentLink = this._hyperLinkModel.getHyperLinkByLocation(copyInfo.unitId, copyInfo.subUnitId, startRow, startColumn);
const id = generateRandomId();
if (currentLink) {
redos.push({
id: RemoveHyperLinkMutation.id,
params: {
unitId: copyInfo.unitId,
subUnitId: copyInfo.subUnitId,
id: currentLink.id,
},
});
}
if (oldLink) {
redos.push({
id: AddHyperLinkMutation.id,
params: {
unitId: copyInfo.unitId,
subUnitId: copyInfo.subUnitId,
link: {
...oldLink,
id,
row: startRow,
column: startColumn,
},
},
});
undos.push({
id: RemoveHyperLinkMutation.id,
params: {
unitId: copyInfo.unitId,
subUnitId: copyInfo.subUnitId,
id,
},
});
}
if (currentLink) {
undos.push({
id: AddHyperLinkMutation.id,
params: {
unitId: copyInfo.unitId,
subUnitId: copyInfo.subUnitId,
link: currentLink,
},
});
}
});
});
return { redos, undos };
}
}

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 { ICommandInfo } from '@univerjs/core';
import type { LocaleKey } from '../locale/types';
import { Disposable, ICommandService, Inject, LocaleService } from '@univerjs/core';
import {
RangeProtectionPermissionEditPoint,
SheetPermissionCheckController,
WorkbookEditablePermission,
WorksheetEditPermission,
WorksheetInsertHyperlinkPermission,
WorksheetSetCellValuePermission,
} from '@univerjs/sheets';
import { InsertLinkShortcut } from '../menu/menu';
export class SheetsHyperLinkPermissionController extends Disposable {
constructor(
@Inject(LocaleService) private _localeService: LocaleService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(SheetPermissionCheckController) private readonly _sheetPermissionCheckController: SheetPermissionCheckController
) {
super();
this._commandExecutedListener();
}
private _commandExecutedListener() {
this.disposeWithMe(
this._commandService.beforeCommandExecuted((command: ICommandInfo) => {
if (command.id === InsertLinkShortcut.id) {
const permission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
worksheetTypes: [WorksheetEditPermission, WorksheetSetCellValuePermission, WorksheetInsertHyperlinkPermission],
});
if (!permission) {
this._sheetPermissionCheckController.blockExecuteWithoutPermission(
this._localeService.t<LocaleKey>('sheets-hyper-link-ui.permission.hyperLinkErr')
);
}
}
})
);
}
}

View File

@@ -0,0 +1,243 @@
/**
* 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 { ICellData } from '@univerjs/core';
import type { IHoverRichTextInfo } from '@univerjs/sheets-ui';
import type { IHyperLinkPopupOptions } from '../../../services/popup.service';
import {
CustomRangeType,
ICommandService,
Injector,
IPermissionService,
IUniverInstanceService,
RichTextBuilder,
toDisposable,
} from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SheetPermissionCheckController } from '@univerjs/sheets';
import { HoverManagerService, IEditorBridgeService } from '@univerjs/sheets-ui';
import { of, Subject } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { ISheetsHyperLinkPopupService } from '../../../services/popup.service';
import { SheetsHyperLinkMobilePopupController } from '../popup.controller';
class TestHoverManagerService {
readonly currentClickedCell$ = new Subject<IHoverRichTextInfo>();
}
class TestSheetsHyperLinkPopupService {
readonly shown: IHyperLinkPopupOptions[] = [];
hideCount = 0;
showPopup(location: IHyperLinkPopupOptions): void {
this.shown.push(location);
}
hideCurrentPopup(): void {
this.hideCount += 1;
}
endEditing(): void {}
}
class TestRenderManagerService {
getRenderUnitById() {
return {
with: () => ({
getSkeletonParam: () => ({
skeleton: {
overflowCache: {
forValue: () => {},
},
},
}),
}),
};
}
}
class TestPermissionService {
composePermission() {
return [{ value: true }];
}
}
class TestSheetPermissionCheckController {
permissionCheckWithRanges(): boolean {
return true;
}
}
class TestCommandService {
onCommandExecuted() {
return toDisposable(() => undefined);
}
}
class TestEditorBridgeService {
readonly currentEditCellState$ = of(null);
readonly visible$ = of({ visible: false });
}
class TestDocSelectionManagerService {
readonly textSelection$ = new Subject();
}
class TestUniverInstanceService {
cell: ICellData = {};
readonly worksheet = {
getCell: () => this.cell,
getCellRaw: () => ({}),
getCellStyleOnly: () => ({}),
};
readonly workbook = {
getSheetBySheetId: () => this.worksheet,
getStyles: () => ({
getStyleByCell: () => ({}),
}),
};
getUnit() {
return this.workbook;
}
}
function createMobilePopupControllerTestBed() {
const injector = new Injector();
injector.add([HoverManagerService, { useClass: TestHoverManagerService as never }]);
injector.add([ISheetsHyperLinkPopupService, { useClass: TestSheetsHyperLinkPopupService as never }]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([IPermissionService, { useClass: TestPermissionService as never }]);
injector.add([SheetPermissionCheckController, { useClass: TestSheetPermissionCheckController as never }]);
injector.add([ICommandService, { useClass: TestCommandService as never }]);
injector.add([IEditorBridgeService, { useClass: TestEditorBridgeService as never }]);
injector.add([DocSelectionManagerService, { useClass: TestDocSelectionManagerService as never }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([SheetsHyperLinkMobilePopupController]);
injector.get(SheetsHyperLinkMobilePopupController);
return {
hoverManagerService: injector.get(HoverManagerService) as unknown as TestHoverManagerService,
popupService: injector.get(ISheetsHyperLinkPopupService) as unknown as TestSheetsHyperLinkPopupService,
univerInstanceService: injector.get(IUniverInstanceService) as unknown as TestUniverInstanceService,
};
}
describe('SheetsHyperLinkMobilePopupController', () => {
it('opens the hyperlink popup when the hyperlink text is clicked', () => {
const { hoverManagerService, popupService } = createMobilePopupControllerTestBed();
const customRange = {
rangeId: 'link-1',
rangeType: CustomRangeType.HYPERLINK,
startIndex: 0,
endIndex: 5,
properties: { url: 'https://univer.ai' },
};
hoverManagerService.currentClickedCell$.next({
location: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 2,
col: 3,
},
position: { startX: 0, startY: 0, endX: 20, endY: 20 },
customRange,
rect: { left: 0, top: 0, right: 20, bottom: 20 },
});
expect(popupService.shown).toEqual([
expect.objectContaining({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 2,
col: 3,
customRange,
editPermission: true,
copyPermission: true,
}),
]);
expect(popupService.hideCount).toBe(0);
});
it('opens the hyperlink popup when the empty area of a hyperlink cell is clicked', () => {
const { hoverManagerService, popupService, univerInstanceService } = createMobilePopupControllerTestBed();
const documentData = RichTextBuilder.create()
.insertLink('Univer', 'https://univer.ai')
.getData();
const customRange = documentData.body!.customRanges![0];
univerInstanceService.cell = {
p: documentData,
};
hoverManagerService.currentClickedCell$.next({
location: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 2,
col: 3,
},
position: { startX: 0, startY: 0, endX: 100, endY: 20 },
});
expect(popupService.shown).toEqual([
expect.objectContaining({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 2,
col: 3,
customRange,
editPermission: true,
copyPermission: true,
}),
]);
expect(popupService.hideCount).toBe(0);
});
it('shows all hyperlinks when the empty area of a cell containing multiple hyperlinks is clicked', () => {
const { hoverManagerService, popupService, univerInstanceService } = createMobilePopupControllerTestBed();
const documentData = RichTextBuilder.create()
.insertLink('Univer', 'https://univer.ai')
.insertText(' ')
.insertLink('Docs', 'https://docs.univer.ai')
.getData();
const firstCustomRange = documentData.body!.customRanges![0];
univerInstanceService.cell = {
p: documentData,
};
hoverManagerService.currentClickedCell$.next({
location: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 2,
col: 3,
},
position: { startX: 0, startY: 0, endX: 100, endY: 20 },
});
expect(popupService.shown).toEqual([
expect.objectContaining({
customRange: firstCustomRange,
showAll: true,
}),
]);
expect(popupService.hideCount).toBe(0);
});
});

View File

@@ -0,0 +1,34 @@
/**
* 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 { LinkIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { MobileCellLinkEdit } from '../../views/MobileCellLinkEdit';
import { MobileCellLinkPopup } from '../../views/MobileCellLinkPopup';
export class MobileComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) componentManager: ComponentManager,
@Inject(IconManager) iconManager: IconManager
) {
super();
this.disposeWithMe(componentManager.register(MobileCellLinkPopup.componentKey, MobileCellLinkPopup));
this.disposeWithMe(componentManager.register(MobileCellLinkEdit.componentKey, MobileCellLinkEdit));
this.disposeWithMe(iconManager.register({ LinkIcon }));
}
}

View File

@@ -0,0 +1,298 @@
/**
* 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 { ICustomRange, Nullable, Workbook, Worksheet } from '@univerjs/core';
import type { ISheetLocationBase } from '@univerjs/sheets';
import type { Subscription } from 'rxjs';
import {
CustomRangeType,
Disposable,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
ICommandService,
Inject,
IPermissionService,
IUniverInstanceService,
Rectangle,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DocEventManagerService } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
ClearSelectionAllCommand,
ClearSelectionContentCommand,
ClearSelectionFormatCommand,
RangeProtectionPermissionEditPoint,
RangeProtectionPermissionViewPoint,
SheetPermissionCheckController,
WorkbookCopyPermission,
WorkbookEditablePermission,
WorkbookViewPermission,
WorksheetCopyPermission,
WorksheetEditPermission,
WorksheetInsertHyperlinkPermission,
WorksheetViewPermission,
} from '@univerjs/sheets';
import { HoverManagerService, IEditorBridgeService, SheetSkeletonManagerService } from '@univerjs/sheets-ui';
import { map, switchMap } from 'rxjs';
import { ISheetsHyperLinkPopupService } from '../../services/popup.service';
import { HyperLinkEditSourceType } from '../../types/enums/edit-source';
export class SheetsHyperLinkMobilePopupController extends Disposable {
constructor(
@Inject(HoverManagerService) private readonly _hoverManagerService: HoverManagerService,
@Inject(ISheetsHyperLinkPopupService) private readonly _sheetsHyperLinkPopupService: ISheetsHyperLinkPopupService,
@Inject(IRenderManagerService) private readonly _renderManagerService: IRenderManagerService,
@Inject(IPermissionService) private readonly _permissionService: IPermissionService,
@Inject(SheetPermissionCheckController) private readonly _sheetPermissionCheckController: SheetPermissionCheckController,
@ICommandService private readonly _commandService: ICommandService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._initClickListener();
this._initCommandListener();
this._initClickEditingListener();
this._initTextSelectionListener();
}
private _getLinkPermission(location: ISheetLocationBase) {
const { unitId, subUnitId, row, col } = location;
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(subUnitId);
if (!worksheet) {
return {
viewPermission: false,
editPermission: false,
copyPermission: false,
};
}
const range = [{ startRow: row, startColumn: col, endRow: row, endColumn: col }];
const viewPermission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookViewPermission],
worksheetTypes: [WorksheetViewPermission],
rangeTypes: [RangeProtectionPermissionViewPoint],
}, range, unitId, subUnitId);
let editPermission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission, WorksheetInsertHyperlinkPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
}, range, unitId, subUnitId);
const cell = worksheet.getCellRaw(row, col);
if (cell?.f?.startsWith('=HYPERLINK(')) {
editPermission = false;
}
const copyPermission = this._permissionService.composePermission([
new WorkbookCopyPermission(unitId).id,
new WorksheetCopyPermission(unitId, subUnitId).id,
]).every((permission) => permission.value);
return {
viewPermission,
editPermission,
copyPermission,
};
}
private _getCellHyperLinkInfo(
worksheet: Worksheet,
row: number,
col: number,
clickedCustomRange?: Nullable<ICustomRange>
) {
const customRanges = worksheet.getCell(row, col)?.p?.body?.customRanges ?? [];
const cellHyperLinks = customRanges.filter(
(customRange) => customRange.rangeType === CustomRangeType.HYPERLINK
);
const clickedHyperLink = clickedCustomRange?.rangeType === CustomRangeType.HYPERLINK
? clickedCustomRange
: null;
return {
cellHyperLinks,
clickedHyperLink,
customRange: clickedHyperLink ?? cellHyperLinks[0],
};
}
private _initClickListener(): void {
this.disposeWithMe(this._hoverManagerService.currentClickedCell$.subscribe((currentCell) => {
if (!currentCell) {
this._sheetsHyperLinkPopupService.hideCurrentPopup();
return;
}
const { unitId, subUnitId, row, col } = currentCell.location;
const renderer = this._renderManagerService.getRenderUnitById(unitId);
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(subUnitId);
if (!renderer || !workbook || !worksheet) {
return;
}
const skeleton = renderer.with(SheetSkeletonManagerService)
.getSkeletonParam(subUnitId)
?.skeleton;
let targetRow = row;
let targetCol = col;
if (skeleton) {
skeleton.overflowCache.forValue((overflowRow, overflowCol, value) => {
if (Rectangle.contains(value, {
startColumn: col,
endColumn: col,
startRow: row,
endRow: row,
})) {
targetRow = overflowRow;
targetCol = overflowCol;
}
});
}
const { cellHyperLinks, clickedHyperLink, customRange } = this._getCellHyperLinkInfo(
worksheet,
targetRow,
targetCol,
currentCell.customRange
);
if (!customRange) {
this._sheetsHyperLinkPopupService.hideCurrentPopup();
return;
}
const location = { unitId, subUnitId, row: targetRow, col: targetCol };
const { viewPermission, editPermission, copyPermission } = this._getLinkPermission(location);
if (!viewPermission) {
this._sheetsHyperLinkPopupService.hideCurrentPopup();
return;
}
const cellStyle = worksheet.getCellStyleOnly(targetRow, targetCol);
const style = workbook.getStyles().getStyleByCell(cellStyle);
const showAll = Boolean(style?.tr?.a) || (!clickedHyperLink && cellHyperLinks.length > 1);
this._sheetsHyperLinkPopupService.showPopup({
...location,
editPermission,
copyPermission,
customRange,
customRangeRect: currentCell.rect,
type: HyperLinkEditSourceType.VIEWING,
showAll,
});
}));
}
private _initClickEditingListener(): void {
let subscription: Nullable<Subscription> = null;
this.disposeWithMe(
this._editorBridgeService.currentEditCellState$
.pipe(switchMap((state) => this._editorBridgeService.visible$.pipe(map((visible) => ({ visible, state })))))
.subscribe(({ visible, state }) => {
if (!state || state.editorUnitId !== DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
return;
}
if (!visible.visible) {
subscription?.unsubscribe();
this._sheetsHyperLinkPopupService.hideCurrentPopup(HyperLinkEditSourceType.EDITING);
this._sheetsHyperLinkPopupService.endEditing(HyperLinkEditSourceType.EDITING);
return;
}
const { editorUnitId, unitId, sheetId, row, column } = state;
const renderer = this._renderManagerService.getRenderUnitById(editorUnitId);
if (!renderer) {
return;
}
const { editPermission, viewPermission, copyPermission } = this._getLinkPermission({
unitId,
subUnitId: sheetId,
row,
col: column,
});
if (!viewPermission) {
return;
}
const docEventService = renderer.with(DocEventManagerService);
subscription?.unsubscribe();
subscription = docEventService.clickCustomRanges$.subscribe((customRange) => {
if (customRange.range.rangeType !== CustomRangeType.HYPERLINK) {
this._sheetsHyperLinkPopupService.hideCurrentPopup();
return;
}
const rect = customRange.rects[customRange.rects.length - 1];
const skeleton = this._renderManagerService.getRenderUnitById(unitId)
?.with(SheetSkeletonManagerService)
.getSkeletonParam(sheetId)
?.skeleton;
if (!skeleton || !rect) {
return;
}
const canvasClientRect = renderer.engine.getCanvasElement().getBoundingClientRect();
this._sheetsHyperLinkPopupService.showPopup({
unitId,
subUnitId: sheetId,
row,
col: column,
customRange: customRange.range,
customRangeRect: {
left: rect.left + canvasClientRect.left,
top: rect.top + canvasClientRect.top,
bottom: rect.bottom + canvasClientRect.top,
right: rect.right + canvasClientRect.left,
},
editPermission,
copyPermission,
type: HyperLinkEditSourceType.EDITING,
});
});
})
);
this.disposeWithMe(() => subscription?.unsubscribe());
}
private _initTextSelectionListener(): void {
this.disposeWithMe(this._textSelectionManagerService.textSelection$.subscribe((selection) => {
if (selection?.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
this._sheetsHyperLinkPopupService.endEditing(HyperLinkEditSourceType.EDITING);
}
}));
}
private _initCommandListener(): void {
const hideCommandList = [
ClearSelectionContentCommand.id,
ClearSelectionAllCommand.id,
ClearSelectionFormatCommand.id,
];
this.disposeWithMe(this._commandService.onCommandExecuted((command) => {
if (hideCommandList.includes(command.id)) {
this._sheetsHyperLinkPopupService.hideCurrentPopup();
}
}));
}
}

View File

@@ -0,0 +1,266 @@
/**
* 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, Workbook } from '@univerjs/core';
import type { ISheetLocationBase } from '@univerjs/sheets';
import type { Subscription } from 'rxjs';
import { CustomRangeType, Disposable, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, ICommandService, Inject, IPermissionService, IUniverInstanceService, Rectangle, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DocEventManagerService } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
ClearSelectionAllCommand,
ClearSelectionContentCommand,
ClearSelectionFormatCommand,
RangeProtectionPermissionEditPoint,
RangeProtectionPermissionViewPoint,
SheetPermissionCheckController,
WorkbookCopyPermission,
WorkbookEditablePermission,
WorkbookViewPermission,
WorksheetCopyPermission,
WorksheetEditPermission,
WorksheetInsertHyperlinkPermission,
WorksheetViewPermission,
} from '@univerjs/sheets';
import { HoverManagerService, HoverRenderController, IEditorBridgeService, SheetSkeletonManagerService } from '@univerjs/sheets-ui';
import { debounceTime, map, switchMap } from 'rxjs';
import { ISheetsHyperLinkPopupService } from '../services/popup.service';
import { HyperLinkEditSourceType } from '../types/enums/edit-source';
export class SheetsHyperLinkPopupController extends Disposable {
constructor(
@Inject(HoverManagerService) private readonly _hoverManagerService: HoverManagerService,
@Inject(ISheetsHyperLinkPopupService) private readonly _sheetsHyperLinkPopupService: ISheetsHyperLinkPopupService,
@Inject(IRenderManagerService) private readonly _renderManagerService: IRenderManagerService,
@Inject(IPermissionService) private readonly _permissionService: IPermissionService,
@Inject(SheetPermissionCheckController) private readonly _sheetPermissionCheckController: SheetPermissionCheckController,
@ICommandService private readonly _commandService: ICommandService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._initHoverListener();
this._initCommandListener();
this._initHoverEditingListener();
this._initTextSelectionListener();
}
private _getLinkPermission(location: ISheetLocationBase) {
const { unitId, subUnitId, row: currentRow, col: currentCol } = location;
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(subUnitId);
if (!worksheet) {
return {
viewPermission: false,
editPermission: false,
copyPermission: false,
};
}
const viewPermission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookViewPermission],
worksheetTypes: [WorksheetViewPermission],
rangeTypes: [RangeProtectionPermissionViewPoint],
}, [{ startRow: currentRow, startColumn: currentCol, endRow: currentRow, endColumn: currentCol }], unitId, subUnitId);
let editPermission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission, WorksheetInsertHyperlinkPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
}, [{ startRow: currentRow, startColumn: currentCol, endRow: currentRow, endColumn: currentCol }], unitId, subUnitId);
// Check if the cell is a hyperlink formula, if it is, we don't allow editing
const cell = worksheet.getCellRaw(currentRow, currentCol);
if (cell?.f && cell.f.startsWith('=HYPERLINK(')) {
editPermission = false;
}
const copyPermission = this._permissionService.composePermission([new WorkbookCopyPermission(unitId).id, new WorksheetCopyPermission(unitId, subUnitId).id]).every((permission) => permission.value);
return {
viewPermission,
editPermission,
copyPermission,
};
}
private _initHoverListener() {
this.disposeWithMe(
// hover over not editing cell
this._hoverManagerService.currentRichText$.pipe(debounceTime(200)).subscribe((currentCell) => {
if (!currentCell || currentCell.customRange?.rangeType !== CustomRangeType.HYPERLINK) {
this._sheetsHyperLinkPopupService.hideCurrentPopup();
return;
}
const { unitId, subUnitId, row, col } = currentCell;
const renderer = this._renderManagerService.getRenderUnitById(unitId);
if (!renderer) {
return;
}
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(subUnitId);
if (!worksheet) {
return;
}
const hoverRenderController = renderer.with(HoverRenderController);
if (!hoverRenderController.active) {
this._sheetsHyperLinkPopupService.hideCurrentPopup(HyperLinkEditSourceType.VIEWING);
return;
}
const skeleton = renderer?.with(SheetSkeletonManagerService)
.getSkeletonParam(subUnitId)
?.skeleton;
const currentCol = col;
const currentRow = row;
let targetRow = currentRow;
let targetCol = currentCol;
if (skeleton) {
skeleton.overflowCache.forValue((row, col, value) => {
if (Rectangle.contains(value, { startColumn: currentCol, endColumn: currentCol, startRow: currentRow, endRow: currentRow })) {
targetRow = row;
targetCol = col;
}
});
}
const { viewPermission, editPermission, copyPermission } = this._getLinkPermission(currentCell);
if (!viewPermission) {
this._sheetsHyperLinkPopupService.hideCurrentPopup();
return;
}
const cell = worksheet.getCellStyleOnly(targetRow, targetCol);
const style = workbook!.getStyles().getStyleByCell(cell);
const tr = style?.tr?.a;
if (!tr && !currentCell.customRange) {
this._sheetsHyperLinkPopupService.hideCurrentPopup();
return;
}
this._sheetsHyperLinkPopupService.showPopup({
row: targetRow,
col: targetCol,
editPermission,
copyPermission,
customRange: currentCell.customRange,
customRangeRect: currentCell.rect,
type: HyperLinkEditSourceType.VIEWING,
unitId,
subUnitId,
showAll: Boolean(tr),
});
})
);
}
private _initHoverEditingListener() {
let subscribe: Nullable<Subscription> = null;
this.disposeWithMe(
this._editorBridgeService.currentEditCellState$
.pipe(switchMap((state) => this._editorBridgeService.visible$.pipe(map((visible) => ({ visible, state })))))
.subscribe(({ visible, state }) => {
if (!state) {
return;
}
if (state.editorUnitId !== DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
return;
}
if (!visible.visible) {
subscribe?.unsubscribe();
this._sheetsHyperLinkPopupService.hideCurrentPopup(HyperLinkEditSourceType.EDITING);
this._sheetsHyperLinkPopupService.endEditing(HyperLinkEditSourceType.EDITING);
return;
}
const { editorUnitId, unitId, sheetId, row, column } = state;
const renderer = this._renderManagerService.getRenderUnitById(editorUnitId);
if (!renderer) {
return;
}
const { editPermission, viewPermission, copyPermission } = this._getLinkPermission({ unitId, subUnitId: sheetId, row, col: column });
const docEventService = renderer.with(DocEventManagerService);
if (!viewPermission) {
return;
}
subscribe?.unsubscribe();
subscribe = docEventService.hoverCustomRanges$.pipe(debounceTime(200)).subscribe((customRanges) => {
const customRange = customRanges.find((customRange) => customRange.range.rangeType === CustomRangeType.HYPERLINK);
if (!customRange) {
this._sheetsHyperLinkPopupService.hideCurrentPopup();
return;
}
const rect = customRange.rects[customRange.rects.length - 1];
const skeleton = this._renderManagerService.getRenderUnitById(unitId)
?.with(SheetSkeletonManagerService)
.getSkeletonParam(sheetId)
?.skeleton;
if (!skeleton || !rect) {
return;
}
const canvasClientRect = renderer.engine.getCanvasElement().getBoundingClientRect();
this._sheetsHyperLinkPopupService.showPopup({
unitId,
subUnitId: sheetId,
row,
col: column,
customRange: customRange.range,
customRangeRect: {
left: rect.left + canvasClientRect.left,
top: rect.top + canvasClientRect.top,
bottom: rect.bottom + canvasClientRect.top,
right: rect.right + canvasClientRect.left,
},
editPermission,
copyPermission,
type: HyperLinkEditSourceType.EDITING,
});
});
})
);
this.disposeWithMe(() => {
subscribe?.unsubscribe();
});
}
private _initTextSelectionListener() {
this.disposeWithMe(
this._textSelectionManagerService.textSelection$.subscribe((selection) => {
if (selection && selection.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
this._sheetsHyperLinkPopupService.endEditing(HyperLinkEditSourceType.EDITING);
}
})
);
}
private _initCommandListener() {
const HIDE_COMMAND_LIST = [ClearSelectionContentCommand.id, ClearSelectionAllCommand.id, ClearSelectionFormatCommand.id];
this.disposeWithMe(this._commandService.onCommandExecuted((command) => {
if (HIDE_COMMAND_LIST.includes(command.id)) {
this._sheetsHyperLinkPopupService.hideCurrentPopup();
}
}));
}
}

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 { Workbook } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import { Disposable, Inject } from '@univerjs/core';
import { HyperLinkModel } from '@univerjs/sheets-hyper-link';
import { debounceTime } from 'rxjs';
export class SheetsHyperLinkRenderController extends Disposable implements IRenderModule {
constructor(
private readonly _context: IRenderContext<Workbook>,
@Inject(HyperLinkModel) private readonly _hyperLinkModel: HyperLinkModel
) {
super();
this._initSkeletonChange();
}
private _initSkeletonChange() {
const markSkeletonDirty = () => {
this._context.mainComponent?.makeForceDirty();
};
this.disposeWithMe(this._hyperLinkModel.linkUpdate$.pipe(debounceTime(16)).subscribe(() => {
markSkeletonDirty();
}));
}
}

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 { Disposable, ICommandService, Inject } from '@univerjs/core';
import { IMenuManagerService, IShortcutService } from '@univerjs/ui';
import {
CloseHyperLinkPopupOperation,
InsertHyperLinkOperation,
InsertHyperLinkToolbarOperation,
OpenHyperLinkEditPanelOperation,
} from '../commands/operations/popup.operations';
import { InsertLinkShortcut } from '../menu/menu';
import { menuSchema } from '../menu/schema';
export class SheetsHyperLinkUIController extends Disposable {
constructor(
@ICommandService private _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@Inject(IShortcutService) private _shortcutService: IShortcutService
) {
super();
this._initCommands();
this._initMenus();
this._initShortCut();
}
private _initCommands() {
[
OpenHyperLinkEditPanelOperation,
CloseHyperLinkPopupOperation,
InsertHyperLinkOperation,
InsertHyperLinkToolbarOperation,
].forEach((command) => {
this._commandService.registerCommand(command);
});
}
private _initMenus() {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initShortCut() {
this._shortcutService.registerShortcut(InsertLinkShortcut);
}
}

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 { Disposable, Inject } from '@univerjs/core';
import { SheetsHyperLinkParserService } from '@univerjs/sheets-hyper-link';
import { SheetsHyperLinkResolverService } from '../services/resolver.service';
export class SheetHyperLinkUrlController extends Disposable {
constructor(
@Inject(SheetsHyperLinkParserService) private readonly _parserService: SheetsHyperLinkParserService,
@Inject(SheetsHyperLinkResolverService) private _resolverService: SheetsHyperLinkResolverService
) {
super();
this._handleInitUrl();
}
private _handleInitUrl() {
const hash = location.hash;
if (hash) {
const linkInfo = this._parserService.parseHyperLink(hash);
this._resolverService.navigate(linkInfo);
}
}
}

View File

@@ -0,0 +1,162 @@
/**
* 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, IWorkbookData, UnitModel } from '@univerjs/core';
import type { IMessageProps } from '@univerjs/design';
import {
ICommandService,
ILogService,
Inject,
Injector,
IUniverInstanceService,
LocaleType,
LogLevel,
Plugin,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import {
DefinedNamesService,
FormulaDataModel,
FunctionService,
IDefinedNamesService,
IFunctionService,
ISuperTableService,
LexerTreeBuilder,
SuperTableService,
} from '@univerjs/engine-formula';
import {
RefRangeService,
SetWorksheetActiveOperation,
SheetInterceptorService,
SheetSkeletonService,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { SheetsHyperLinkParserService } from '@univerjs/sheets-hyper-link';
import { SheetsHyperLinkResolverService } from '@univerjs/sheets-hyper-link-ui';
import { IMessageService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import '@univerjs/sheets/facade';
import '@univerjs/sheets-hyper-link-ui/facade';
class TestMessageService {
readonly messages: IMessageProps[] = [];
show(options: IMessageProps): void {
this.messages.push(options);
}
}
function createWorkbookData(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'Workbook',
sheetOrder: ['sheet1', 'sheet2'],
styles: {},
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
cellData: {},
rowCount: 20,
columnCount: 20,
},
sheet2: {
id: 'sheet2',
name: 'Sheet2',
cellData: {},
rowCount: 20,
columnCount: 20,
},
},
};
}
function createHyperLinkFacadeTestBed(workbookData: IWorkbookData, dependencies: Dependency[]) {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
const injector = this._injector;
injector.add([SheetsSelectionsService]);
injector.add([SheetInterceptorService]);
injector.add([IFunctionService, { useClass: FunctionService }]);
injector.add([SheetSkeletonService]);
injector.add([FormulaDataModel]);
injector.add([LexerTreeBuilder]);
injector.add([RefRangeService]);
injector.add([IDefinedNamesService, { useClass: DefinedNamesService }]);
injector.add([ISuperTableService, { useClass: SuperTableService }]);
dependencies.forEach((dependency) => injector.add(dependency));
this._injector.get(SheetInterceptorService);
}
}
univer.registerPlugin(TestPlugin);
const sheet = univer.createUnit<IWorkbookData, UnitModel<IWorkbookData>>(UniverInstanceType.UNIVER_SHEET, workbookData);
injector.get(IUniverInstanceService).focusUnit(sheet.getUnitId());
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
return {
univer,
get: injector.get.bind(injector),
univerAPI: FUniver.newAPI(injector),
};
}
describe('sheets-hyper-link-ui workbook facade', () => {
let testBed: ReturnType<typeof createHyperLinkFacadeTestBed>;
beforeEach(() => {
const dependencies: Dependency[] = [
[SheetsHyperLinkParserService],
[SheetsHyperLinkResolverService],
[IMessageService, { useClass: TestMessageService }],
];
testBed = createHyperLinkFacadeTestBed(createWorkbookData(), dependencies);
testBed.get(ICommandService).registerCommand(SetWorksheetActiveOperation);
});
afterEach(() => {
testBed.univer.dispose();
});
it('navigates hyperlinks through the workbook facade API', async () => {
const workbook = testBed.univerAPI.getActiveWorkbook()!;
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
workbook.navigateToSheetHyperlink('https://univer.ai');
await Promise.resolve();
expect(open).toHaveBeenCalledWith('https://univer.ai', '_blank', 'noopener noreferrer');
open.mockRestore();
});
});

View File

@@ -0,0 +1,63 @@
/**
* 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 { SheetsHyperLinkParserService } from '@univerjs/sheets-hyper-link';
import { SheetsHyperLinkResolverService } from '@univerjs/sheets-hyper-link-ui';
import { FWorkbook } from '@univerjs/sheets/facade';
interface IFWorkbookHyperlinkUIMixin {
/**
* Navigate to the sheet hyperlink.
* @param {string} hyperlink - The hyperlink string
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const sheets = fWorkbook.getSheets();
*
* // Create a hyperlink to the cell F6 in the first sheet
* const sheet1 = sheets[0];
* const range = sheet1.getRange('F6');
* const hyperlink = range.getUrl();
*
* // Switch to the second sheet
* fWorkbook.setActiveSheet(sheets[1]);
* console.log(fWorkbook.getActiveSheet().getSheetName());
*
* // Navigate to the hyperlink after 3 seconds
* setTimeout(() => {
* fWorkbook.navigateToSheetHyperlink(hyperlink);
* console.log(fWorkbook.getActiveSheet().getSheetName());
* }, 3000);
* ```
*/
navigateToSheetHyperlink(this: FWorkbook, hyperlink: string): void;
}
class FWorkbookHyperlinkUIMixin extends FWorkbook implements IFWorkbookHyperlinkUIMixin {
// TODO: this should be migrated back to hyperlink ui plugin
override navigateToSheetHyperlink(hyperlink: string): void {
const parserService = this._injector.get(SheetsHyperLinkParserService);
const resolverService = this._injector.get(SheetsHyperLinkResolverService);
const info = parserService.parseHyperLink(hyperlink);
resolverService.navigate(info);
}
}
FWorkbook.extend(FWorkbookHyperlinkUIMixin);
declare module '@univerjs/sheets/facade' {
// eslint-disable-next-line ts/naming-convention
interface FWorkbook extends IFWorkbookHyperlinkUIMixin {}
}

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.
*/
import './f-workbook';

View File

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

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 './global.css';
export {
CloseHyperLinkPopupOperation,
InsertHyperLinkOperation,
OpenHyperLinkEditPanelOperation,
} from './commands/operations/popup.operations';
export type { IOpenHyperLinkEditPanelOperationParams } from './commands/operations/popup.operations';
export type { IUniverSheetsHyperLinkUIConfig } from './config/config';
export { SheetsHyperLinkCopyPasteController } from './controllers/copy-paste.controller';
export { InsertLinkShortcut } from './menu/menu';
export { menuSchema as SheetsHyperLinkUIMenuSchema } from './menu/schema';
export { UniverSheetsHyperLinkMobileUIPlugin } from './mobile-plugin';
export { UniverSheetsHyperLinkUIPlugin } from './plugin';
export {
ISheetsHyperLinkPopupService,
SheetsHyperLinkPopupService,
} from './services/popup.service';
export { SheetsHyperLinkResolverService } from './services/resolver.service';
export { SheetsHyperLinkSidePanelService } from './services/side-panel.service';
export type { ICustomHyperLinkView } from './services/side-panel.service';

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'إدراج ارتباط تشعبي',
label: 'التسمية',
type: 'النوع',
link: 'الرابط',
linkPlaceholder: 'أدخل الرابط',
range: 'النطاق',
worksheet: 'ورقة العمل',
definedName: 'الاسم المعرف',
ok: 'تأكيد',
cancel: 'إلغاء',
labelPlaceholder: 'أدخل التسمية',
inputError: 'الرجاء الإدخال',
selectError: 'الرجاء التحديد',
linkError: 'الرجاء إدخال رابط صالح',
},
menu: {
add: 'إدراج ارتباط تشعبي',
},
permission: {
hyperLinkErr: 'ليس لديك إذن لإدراج رابط.',
},
message: {
noSheet: 'تم حذف الورقة المستهدفة',
refError: 'نطاق غير صالح',
hiddenSheet: 'تعذر فتح الرابط لأن الورقة المرتبطة مخفية',
coped: 'تم نسخ الرابط إلى الحافظة',
},
popup: {
title: 'ارتباط تشعبي',
open: 'فتح الرابط',
copy: 'نسخ الرابط',
edit: 'تحرير الرابط',
cancel: 'إلغاء الارتباط',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'Insereix enllaç',
label: 'Etiqueta',
type: 'Tipus',
link: 'Enllaç',
linkPlaceholder: 'Introdueix l\'enllaç',
range: 'Interval',
worksheet: 'Full de càlcul',
definedName: 'Nom definit',
ok: 'Confirmar',
cancel: 'Cancel·lar',
labelPlaceholder: 'Introdueix l\'etiqueta',
inputError: 'Si us plau, introdueix',
selectError: 'Si us plau, selecciona',
linkError: 'Si us plau, introdueix un enllaç vàlid',
},
menu: {
add: 'Insereix enllaç',
},
permission: {
hyperLinkErr: 'No teniu permís per inserir un enllaç.',
},
message: {
noSheet: 'El full de destinació ha estat eliminat',
refError: 'Interval no vàlid',
hiddenSheet: 'No es pot obrir l\'enllaç perquè el full enllaçat està amagat',
coped: 'Enllaç copiat al porta-retalls',
},
popup: {
title: 'Enllaç',
open: 'Obre l\'enllaç',
copy: 'Copia l\'enllaç',
edit: 'Edita l\'enllaç',
cancel: 'Cancel·la l\'enllaç',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'Link einfügen',
label: 'Beschriftung',
type: 'Typ',
link: 'Link',
linkPlaceholder: 'Link eingeben',
range: 'Bereich',
worksheet: 'Arbeitsblatt',
definedName: 'Definierter Name',
ok: 'Bestätigen',
cancel: 'Abbrechen',
labelPlaceholder: 'Beschriftung eingeben',
inputError: 'Bitte eingeben',
selectError: 'Bitte auswählen',
linkError: 'Bitte einen gültigen Link eingeben',
},
menu: {
add: 'Link einfügen',
},
permission: {
hyperLinkErr: 'Sie haben keine Berechtigung, einen Link einzufügen.',
},
message: {
noSheet: 'Zielblatt wurde gelöscht',
refError: 'Ungültiger Bereich',
hiddenSheet: 'Der Link kann nicht geöffnet werden, da das verknüpfte Blatt ausgeblendet ist',
coped: 'Link in Zwischenablage kopiert',
},
popup: {
title: 'Hyperlink',
open: 'Link öffnen',
copy: 'Link kopieren',
edit: 'Link bearbeiten',
cancel: 'Link aufheben',
},
},
};
export default locale;

View File

@@ -0,0 +1,57 @@
/**
* 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-hyper-link-ui': {
form: {
addTitle: 'Insert Link',
label: 'Label',
type: 'Type',
link: 'Link',
linkPlaceholder: 'Enter link',
range: 'Range',
worksheet: 'Worksheet',
definedName: 'Defined Name',
ok: 'Confirm',
cancel: 'Cancel',
labelPlaceholder: 'Enter label',
inputError: 'Please enter',
selectError: 'Please select',
linkError: 'Please enter a legal link',
},
menu: {
add: 'Insert Link',
},
permission: {
hyperLinkErr: 'You do not have permission to insert a link.',
},
message: {
noSheet: 'Target sheet has been delete',
refError: 'Invalid Range',
hiddenSheet: 'Cannot open the link because the linked sheet is hidden',
coped: 'Link copied to clipboard',
},
popup: {
title: 'Hyperlink',
open: 'Open Link',
copy: 'Copy Link',
edit: 'Edit Link',
cancel: 'Cancel Link',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'Insertar enlace',
label: 'Etiqueta',
type: 'Tipo',
link: 'Enlace',
linkPlaceholder: 'Introduce el enlace',
range: 'Rango',
worksheet: 'Hoja de cálculo',
definedName: 'Nombre definido',
ok: 'Confirmar',
cancel: 'Cancelar',
labelPlaceholder: 'Introduce la etiqueta',
inputError: 'Por favor, introduce',
selectError: 'Por favor, selecciona',
linkError: 'Por favor, introduce un enlace válido',
},
menu: {
add: 'Insertar enlace',
},
permission: {
hyperLinkErr: 'No tiene permiso para insertar un enlace.',
},
message: {
noSheet: 'La hoja de destino ha sido eliminada',
refError: 'Rango no válido',
hiddenSheet: 'No se puede abrir el enlace porque la hoja enlazada está oculta',
coped: 'Enlace copiado al portapapeles',
},
popup: {
title: 'Hipervínculo',
open: 'Abrir enlace',
copy: 'Copiar enlace',
edit: 'Editar enlace',
cancel: 'Cancelar enlace',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'درج لینک',
label: 'برچسب',
type: 'نوع',
link: 'لینک',
linkPlaceholder: 'لینک را وارد کنید',
range: 'محدوده',
worksheet: 'برگ کار',
definedName: 'نام تعریف شده',
ok: 'تایید',
cancel: 'انصراف',
labelPlaceholder: 'برچسب را وارد کنید',
inputError: 'لطفا وارد کنید',
selectError: 'لطفا انتخاب کنید',
linkError: 'لطفا یک لینک معتبر وارد کنید',
},
menu: {
add: 'درج لینک',
},
permission: {
hyperLinkErr: 'شما مجوز درج لینک را ندارید.',
},
message: {
noSheet: 'برگ هدف حذف شده است',
refError: 'محدوده نامعتبر',
hiddenSheet: 'نمی‌توان لینک را باز کرد زیرا برگ مرتبط پنهان است',
coped: 'لینک به کلیپ بورد کپی شد',
},
popup: {
title: 'ابرپیوند',
open: 'باز کردن لینک',
copy: 'کپی لینک',
edit: 'ویرایش لینک',
cancel: 'لینک را لغو کنید',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'Insérer un lien',
label: 'Titre',
type: 'Type',
link: 'Lien',
linkPlaceholder: 'Entrez le lien',
range: 'Plage',
worksheet: 'Feuille de calcul',
definedName: 'Nom défini',
ok: 'Confirmer',
cancel: 'Annuler',
labelPlaceholder: 'Entrez le titre',
inputError: 'Veuillez entrer',
selectError: 'Veuillez sélectionner',
linkError: 'Veuillez entrer un lien valide',
},
menu: {
add: 'Insérer un lien',
},
permission: {
hyperLinkErr: 'Vous n’avez pas l’autorisation d’insérer un lien.',
},
message: {
noSheet: 'La feuille cible a été supprimée',
refError: 'Plage invalide',
hiddenSheet: 'Impossible d\'ouvrir le lien car la feuille liée est masquée',
coped: 'Lien copié dans le presse-papiers',
},
popup: {
title: 'Lien hypertexte',
open: 'Ouvrir le lien',
copy: 'Copier le lien',
edit: 'Modifier le lien',
cancel: 'Annuler le lien',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'Sisipkan Tautan',
label: 'Label',
type: 'Tipe',
link: 'Tautan',
linkPlaceholder: 'Masukkan tautan',
range: 'Rentang',
worksheet: 'Lembar Kerja',
definedName: 'Nama yang Ditentukan',
ok: 'Konfirmasi',
cancel: 'Batal',
labelPlaceholder: 'Masukkan label',
inputError: 'Silakan masukkan',
selectError: 'Silakan pilih',
linkError: 'Silakan masukkan tautan yang valid',
},
menu: {
add: 'Sisipkan Tautan',
},
permission: {
hyperLinkErr: 'Anda tidak memiliki izin untuk menyisipkan tautan.',
},
message: {
noSheet: 'Lembar target telah dihapus',
refError: 'Rentang Tidak Valid',
hiddenSheet: 'Tidak dapat membuka tautan karena lembar yang ditautkan tersembunyi',
coped: 'Tautan disalin ke clipboard',
},
popup: {
title: 'Tautan',
open: 'Buka Tautan',
copy: 'Salin Tautan',
edit: 'Edit Tautan',
cancel: 'Batalkan Tautan',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'Inserisci collegamento',
label: 'Etichetta',
type: 'Tipo',
link: 'Collegamento',
linkPlaceholder: 'Inserisci collegamento',
range: 'Intervallo',
worksheet: 'Foglio di lavoro',
definedName: 'Nome definito',
ok: 'Conferma',
cancel: 'Annulla',
labelPlaceholder: 'Inserisci etichetta',
inputError: 'Inserisci',
selectError: 'Seleziona',
linkError: 'Inserisci un collegamento valido',
},
menu: {
add: 'Inserisci collegamento',
},
permission: {
hyperLinkErr: 'Non hai l’autorizzazione per inserire un collegamento.',
},
message: {
noSheet: 'Il foglio di destinazione è stato eliminato',
refError: 'Intervallo non valido',
hiddenSheet: 'Impossibile aprire il collegamento perché il foglio collegato è nascosto',
coped: 'Collegamento copiato negli appunti',
},
popup: {
title: 'Collegamento ipertestuale',
open: 'Apri collegamento',
copy: 'Copia collegamento',
edit: 'Modifica collegamento',
cancel: 'Annulla collegamento',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'リンクの挿入',
label: '表示文字列',
type: '種類',
link: 'リンク',
linkPlaceholder: 'リンクを入力',
range: '範囲',
worksheet: 'ワークシート',
definedName: '定義済みの名前',
ok: 'OK',
cancel: 'キャンセル',
labelPlaceholder: '表示する文字列を入力',
inputError: '値を入力してください',
selectError: '選択してください',
linkError: '有効なリンクを入力してください',
},
menu: {
add: 'リンクの挿入',
},
permission: {
hyperLinkErr: 'リンクを挿入する権限がありません。',
},
message: {
noSheet: '対象のワークシートが削除されました',
refError: '無効な範囲です',
hiddenSheet: 'リンクを開けません。参照先のワークシートが非表示です',
coped: 'リンクがクリップボードにコピーされました',
},
popup: {
title: 'ハイパーリンク',
open: 'リンクを開く',
copy: 'リンクをコピー',
edit: 'リンクの編集',
cancel: 'リンク解除',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: '링크 삽입',
label: '표시 텍스트',
type: '유형',
link: '링크',
linkPlaceholder: '링크 입력',
range: '범위',
worksheet: '워크시트',
definedName: '정의된 이름',
ok: '확인',
cancel: '취소',
labelPlaceholder: '표시할 텍스트 입력',
inputError: '값을 입력하세요',
selectError: '선택하세요',
linkError: '유효한 링크를 입력하세요',
},
menu: {
add: '링크 삽입',
},
permission: {
hyperLinkErr: '링크를 삽입할 권한이 없습니다.',
},
message: {
noSheet: '대상 워크시트가 삭제되었습니다',
refError: '잘못된 범위입니다',
hiddenSheet: '링크를 열 수 없습니다. 연결된 워크시트가 숨겨져 있습니다',
coped: '링크가 클립보드에 복사되었습니다',
},
popup: {
title: '하이퍼링크',
open: '링크 열기',
copy: '링크 복사',
edit: '링크 편집',
cancel: '링크 취소',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'Wstaw łącze',
label: 'Etykieta',
type: 'Typ',
link: 'Łącze',
linkPlaceholder: 'Wprowadź łącze',
range: 'Zakres',
worksheet: 'Arkusz',
definedName: 'Nazwa zdefiniowana',
ok: 'Potwierdź',
cancel: 'Anuluj',
labelPlaceholder: 'Wprowadź etykietę',
inputError: 'Wprowadź wartość',
selectError: 'Wybierz wartość',
linkError: 'Wprowadź prawidłowe łącze',
},
menu: {
add: 'Wstaw łącze',
},
permission: {
hyperLinkErr: 'Nie masz uprawnień do wstawiania łącza.',
},
message: {
noSheet: 'Arkusz docelowy został usunięty',
refError: 'Nieprawidłowy zakres',
hiddenSheet: 'Nie można otworzyć łącza, ponieważ połączony arkusz jest ukryty',
coped: 'Łącze skopiowane do schowka',
},
popup: {
title: 'Hiperłącze',
open: 'Otwórz łącze',
copy: 'Kopiuj łącze',
edit: 'Edytuj łącze',
cancel: 'Anuluj łącze',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'Inserir Link',
label: 'Rótulo',
type: 'Tipo',
link: 'Link',
linkPlaceholder: 'Digite o link',
range: 'Intervalo',
worksheet: 'Planilha',
definedName: 'Nome Definido',
ok: 'Confirmar',
cancel: 'Cancelar',
labelPlaceholder: 'Digite o rótulo',
inputError: 'Por favor, digite',
selectError: 'Por favor, selecione',
linkError: 'Por favor, digite um link válido',
},
menu: {
add: 'Inserir Link',
},
permission: {
hyperLinkErr: 'Você não tem permissão para inserir um link.',
},
message: {
noSheet: 'A planilha de destino foi excluída',
refError: 'Intervalo Inválido',
hiddenSheet: 'Não é possível abrir o link porque a planilha vinculada está oculta',
coped: 'Link copiado para a área de transferência',
},
popup: {
title: 'Hiperlink',
open: 'Abrir Link',
copy: 'Copiar Link',
edit: 'Editar Link',
cancel: 'Cancelar Link',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'Вставить ссылку',
label: 'Метка',
type: 'Тип',
link: 'Ссылка',
linkPlaceholder: 'Введите ссылку',
range: 'Диапазон',
worksheet: 'Лист',
definedName: 'Определенное имя',
ok: 'ОК',
cancel: 'Отмена',
labelPlaceholder: 'Введите метку',
inputError: 'Пожалуйста, введите',
selectError: 'Пожалуйста, выберите',
linkError: 'Пожалуйста, введите корректную ссылку',
},
menu: {
add: 'Вставить ссылку',
},
permission: {
hyperLinkErr: 'У вас нет разрешения на вставку ссылки.',
},
message: {
noSheet: 'Целевой лист был удален',
refError: 'Недопустимый диапазон',
hiddenSheet: 'Невозможно открыть ссылку, так как связанный лист скрыт',
coped: 'Ссылка скопирована в буфер обмена',
},
popup: {
title: 'Гиперссылка',
open: 'Открыть ссылку',
copy: 'Копировать ссылку',
edit: 'Редактировать ссылку',
cancel: 'Отменить ссылку',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: 'Vložiť odkaz',
label: 'Popisok',
type: 'Typ',
link: 'Odkaz',
linkPlaceholder: 'Zadajte odkaz',
range: 'Rozsah',
worksheet: 'Hárok',
definedName: 'Definovaný názov',
ok: 'Potvrdiť',
cancel: 'Zrušiť',
labelPlaceholder: 'Zadajte popisok',
inputError: 'Zadajte hodnotu',
selectError: 'Vyberte',
linkError: 'Zadajte platný odkaz',
},
menu: {
add: 'Vložiť odkaz',
},
permission: {
hyperLinkErr: 'Nemáte povolenie vložiť odkaz.',
},
message: {
noSheet: 'Cieľový hárok bol odstránený',
refError: 'Neplatný rozsah',
hiddenSheet: 'Odkaz nie je možné otvoriť, pretože prepojený hárok je skrytý',
coped: 'Odkaz skopírovaný do schránky',
},
popup: {
title: 'Hypertextový odkaz',
open: 'Otvoriť odkaz',
copy: 'Kopírovať odkaz',
edit: 'Upraviť odkaz',
cancel: 'Zrušiť odkaz',
},
},
};
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,60 @@
/**
* 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-hyper-link-ui': {
form: {
addTitle: 'Chèn liên kết',
label: 'Văn bản',
labelPlaceholder: 'Nhập văn bản',
type: 'Loại',
link: 'Liên kết',
linkPlaceholder: 'Nhập địa chỉ liên kết',
range: 'Ô',
worksheet: 'Bảng tính',
definedName: 'Tên đã xác định',
ok: 'Xác nhận',
cancel: 'Hủy',
inputError: 'Vui lòng nhập',
selectError: 'Vui lòng chọn',
linkError: 'Vui lòng nhập liên kết hợp lệ',
},
menu: {
add: 'Thêm liên kết',
},
permission: {
hyperLinkErr: 'Bạn không có quyền chèn liên kết.',
},
message: {
noSheet: 'Bảng con này đã bị xóa',
refError: 'Tham chiếu lỗi',
hiddenSheet: 'Không thể mở bảng con bị ẩn',
coped: 'Liên kết đã được sao chép vào clipboard',
},
popup: {
title: 'Siêu liên kết',
open: 'Mở liên kết',
copy: 'Sao chép',
edit: 'Chỉnh sửa',
cancel: 'Hủy liên kết',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: '插入链接',
label: '文本',
labelPlaceholder: '输入文本',
type: '类型',
link: '链接',
linkPlaceholder: '输入链接地址',
range: '单元格',
worksheet: '工作表',
definedName: '定义的名称',
ok: '确认',
cancel: '取消',
inputError: '请输入',
selectError: '请选择',
linkError: '请输入合法的链接',
},
menu: {
add: '添加链接',
},
permission: {
hyperLinkErr: '你没有权限插入链接。',
},
message: {
noSheet: '该子表已被删除',
refError: '错误的引用',
hiddenSheet: '无法打开被隐藏的子表',
coped: '链接已被复制到剪贴板',
},
popup: {
title: '超链接',
open: '打开链接',
copy: '复制',
edit: '编辑',
cancel: '取消链接',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: '插入連結',
label: '文字',
labelPlaceholder: '輸入文字',
type: '類型',
link: '連結',
linkPlaceholder: '輸入連結位址',
range: '儲存格',
worksheet: '工作表',
definedName: '定義的名稱',
ok: '確認',
cancel: '取消',
inputError: '請輸入',
selectError: '請選擇',
linkError: '請輸入合法的連結',
},
menu: {
add: '新增連結',
},
permission: {
hyperLinkErr: '你沒有權限插入連結。',
},
message: {
noSheet: '該子表已被刪除',
refError: '錯誤的引用',
hiddenSheet: '無法開啟被隱藏的子表',
coped: '連結已複製到剪貼簿',
},
popup: {
title: '超連結',
open: '開啟連結',
copy: '複製',
edit: '編輯',
cancel: '取消連結',
},
},
};
export default locale;

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 enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
addTitle: '插入連結',
label: '文字',
labelPlaceholder: '輸入文字',
type: '類型',
link: '連結',
linkPlaceholder: '輸入連結位址',
range: '儲存格',
worksheet: '工作表',
definedName: '定義的名稱',
ok: '確認',
cancel: '取消',
inputError: '請輸入',
selectError: '請選擇',
linkError: '請輸入合法的連結',
},
menu: {
add: '新增連結',
},
permission: {
hyperLinkErr: '你沒有權限插入連結。',
},
message: {
noSheet: '該子表已被刪除',
refError: '錯誤的引用',
hiddenSheet: '無法開啟被隱藏的子表',
coped: '連結已複製到剪貼簿',
},
popup: {
title: '超連結',
open: '開啟連結',
copy: '複製',
edit: '編輯',
cancel: '取消連結',
},
},
};
export default locale;

View File

@@ -0,0 +1,160 @@
/**
* 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, Nullable } from '@univerjs/core';
import type { IEditorBridgeServiceVisibleParam } from '@univerjs/sheets-ui';
import type { IMenuItem, IShortcutItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import {
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
getSheetCommandTarget,
RangeProtectionPermissionEditPoint,
WorkbookEditablePermission,
WorksheetEditPermission,
WorksheetInsertHyperlinkPermission,
WorksheetSetCellValuePermission,
} from '@univerjs/sheets';
import { getCurrentRangeDisable$, IEditorBridgeService, whenSheetEditorFocused } from '@univerjs/sheets-ui';
import { getMenuHiddenObservable, KeyCode, MenuItemType, MetaKeys } from '@univerjs/ui';
import { combineLatest, map, of, switchMap } from 'rxjs';
import { InsertHyperLinkOperation, InsertHyperLinkToolbarOperation } from '../commands/operations/popup.operations';
import { DisableLinkType, getShouldDisableCellLink, shouldDisableAddLink } from '../utils';
const getEditingLinkDisable$ = (accessor: IAccessor, unitId = DOCS_NORMAL_EDITOR_UNIT_ID_KEY) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const docSelctionService = accessor.get(IRenderManagerService)
.getRenderUnitById(unitId)
?.with(DocSelectionRenderService);
if (!docSelctionService) {
return of(true);
}
return docSelctionService.textSelectionInner$.pipe(map(() => {
const editorBridgeService = accessor.get(IEditorBridgeService);
const state = editorBridgeService.getEditCellState();
if (!state) {
return true;
}
const target = getSheetCommandTarget(univerInstanceService, { unitId: state.unitId, subUnitId: state.sheetId });
if (!target?.worksheet) {
return true;
}
if (getShouldDisableCellLink(accessor, target.worksheet, state.row, state.column) === 1) {
return true;
}
return shouldDisableAddLink(accessor);
}));
};
const getLinkDisable$ = (accessor: IAccessor) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const editorBridgeService = accessor.has(IEditorBridgeService) ? accessor.get(IEditorBridgeService) : null;
const disableCell$ = editorBridgeService?.currentEditCellState$.pipe(
map((state) => {
if (!state) {
return (DisableLinkType.DISABLED_BY_CELL);
}
const target = getSheetCommandTarget(univerInstanceService, {
unitId: state.unitId,
subUnitId: state.sheetId,
});
if (!target) {
return (DisableLinkType.DISABLED_BY_CELL);
}
return (getShouldDisableCellLink(accessor, target.worksheet, state.row, state.column));
}),
switchMap((disableCell) => {
if (disableCell === DisableLinkType.DISABLED_BY_CELL) {
return of(true);
}
const isEditing$ = (editorBridgeService ? editorBridgeService.visible$ : of<Nullable<IEditorBridgeServiceVisibleParam>>(null));
return combineLatest([
isEditing$,
univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC),
]).pipe(
switchMap(
([editing, focusingDoc]) => {
return editing?.visible ?
focusingDoc?.getUnitId() === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY
? of(true)
: getEditingLinkDisable$(accessor, DOCS_NORMAL_EDITOR_UNIT_ID_KEY)
: of(disableCell !== DisableLinkType.ALLOWED);
}
)
);
})
) ?? of(true);
return disableCell$.pipe(
switchMap((disableCell) => {
if (disableCell) {
return of(true);
} else {
return getCurrentRangeDisable$(accessor, {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [
WorksheetEditPermission,
WorksheetSetCellValuePermission,
WorksheetInsertHyperlinkPermission,
],
rangeTypes: [RangeProtectionPermissionEditPoint],
}, true);
}
})
);
};
export const insertLinkMenuFactory = (accessor: IAccessor): IMenuItem<LocaleKey> => {
return {
commandId: InsertHyperLinkOperation.id,
type: MenuItemType.BUTTON,
title: 'sheets-hyper-link-ui.menu.add',
icon: 'LinkIcon',
id: InsertHyperLinkOperation.id,
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
disabled$: getLinkDisable$(accessor),
};
};
export const insertLinkMenuToolbarFactory = (accessor: IAccessor): IMenuItem<LocaleKey> => {
return {
tooltip: 'sheets-hyper-link-ui.form.addTitle',
commandId: InsertHyperLinkToolbarOperation.id,
type: MenuItemType.BUTTON,
icon: 'LinkIcon',
id: InsertHyperLinkToolbarOperation.id,
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
disabled$: getLinkDisable$(accessor),
};
};
export const InsertLinkShortcut: IShortcutItem = {
id: InsertHyperLinkToolbarOperation.id,
binding: KeyCode.K | MetaKeys.CTRL_COMMAND,
preconditions: whenSheetEditorFocused,
};

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 type { MenuSchemaType } from '@univerjs/ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import { InsertHyperLinkToolbarOperation } from '../commands/operations/popup.operations';
import { insertLinkMenuFactory, insertLinkMenuToolbarFactory } from './menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
[InsertHyperLinkToolbarOperation.id]: {
order: 1,
gridLayout: { row: 1, column: 2, rowSpan: 2, showLabel: true },
menuItemFactory: insertLinkMenuToolbarFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.OTHERS]: {
order: 1,
[InsertHyperLinkToolbarOperation.id]: {
order: 0,
menuItemFactory: insertLinkMenuFactory,
},
},
},
};

View File

@@ -0,0 +1,124 @@
/**
* 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 { IUniverSheetsHyperLinkUIConfig } from './config/config';
import {
DependentOn,
IConfigService,
Inject,
Injector,
merge,
Plugin,
registerDependencies,
UniverInstanceType,
} from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsHyperLinkPlugin } from '@univerjs/sheets-hyper-link';
import { UniverSheetsMobileUIPlugin } from '@univerjs/sheets-ui';
import { UniverMobileUIPlugin } from '@univerjs/ui';
import pkg from '../package.json';
import {
defaultPluginConfig,
SHEETS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY,
} from './config/config';
import { SheetsHyperLinkAutoFillController } from './controllers/auto-fill.controller';
import { SheetsHyperLinkCopyPasteController } from './controllers/copy-paste.controller';
import { SheetsHyperLinkPermissionController } from './controllers/hyper-link-permission.controller';
import { MobileComponentsController } from './controllers/mobile/components.controller';
import { SheetsHyperLinkMobilePopupController } from './controllers/mobile/popup.controller';
import { SheetsHyperLinkRenderController } from './controllers/render-controllers/render.controller';
import { SheetsHyperLinkUIController } from './controllers/ui.controller';
import { SheetHyperLinkUrlController } from './controllers/url.controller';
import { SheetsHyperLinkMobilePopupService } from './services/mobile-popup.service';
import { ISheetsHyperLinkPopupService } from './services/popup.service';
import { SheetsHyperLinkResolverService } from './services/resolver.service';
import { SheetsHyperLinkSidePanelService } from './services/side-panel.service';
import { SHEET_HYPER_LINK_UI_PLUGIN } from './types/const';
@DependentOn(
UniverDocsPlugin,
UniverFormulaEnginePlugin,
UniverRenderEnginePlugin,
UniverSheetsPlugin,
UniverDocsUIPlugin,
UniverSheetsHyperLinkPlugin,
UniverSheetsMobileUIPlugin,
UniverMobileUIPlugin
)
export class UniverSheetsHyperLinkMobileUIPlugin extends Plugin {
static override pluginName: string = SHEET_HYPER_LINK_UI_PLUGIN;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsHyperLinkUIConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
const { menu, ...rest } = merge({}, defaultPluginConfig, this._config);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(SHEETS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
const dependencies = [
[MobileComponentsController],
[SheetsHyperLinkResolverService],
[ISheetsHyperLinkPopupService, { useClass: SheetsHyperLinkMobilePopupService }],
[SheetsHyperLinkSidePanelService],
[SheetsHyperLinkMobilePopupController],
[SheetsHyperLinkUIController],
[SheetsHyperLinkAutoFillController],
[SheetsHyperLinkCopyPasteController],
[SheetsHyperLinkPermissionController],
[SheetHyperLinkUrlController],
[SheetsHyperLinkRenderController],
] as Dependency[];
registerDependencies(this._injector, dependencies);
this._injector.get(MobileComponentsController);
}
override onReady(): void {
const renderManager = this._injector.get(IRenderManagerService);
this.disposeWithMe(
renderManager.registerRenderModule(
UniverInstanceType.UNIVER_SHEET,
[SheetsHyperLinkRenderController] as Dependency
)
);
this._injector.get(SheetsHyperLinkAutoFillController);
this._injector.get(SheetsHyperLinkCopyPasteController);
this._injector.get(SheetsHyperLinkUIController);
}
override onRendered(): void {
this._injector.get(SheetsHyperLinkPermissionController);
this._injector.get(SheetHyperLinkUrlController);
this._injector.get(SheetsHyperLinkMobilePopupController);
}
}

View File

@@ -0,0 +1,109 @@
/**
* 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 { IUniverSheetsHyperLinkUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsHyperLinkPlugin } from '@univerjs/sheets-hyper-link';
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { SheetsHyperLinkAutoFillController } from './controllers/auto-fill.controller';
import { ComponentsController } from './controllers/components.controller';
import { SheetsHyperLinkCopyPasteController } from './controllers/copy-paste.controller';
import { SheetsHyperLinkPermissionController } from './controllers/hyper-link-permission.controller';
import { SheetsHyperLinkPopupController } from './controllers/popup.controller';
import { SheetsHyperLinkRenderController } from './controllers/render-controllers/render.controller';
import { SheetsHyperLinkUIController } from './controllers/ui.controller';
import { SheetHyperLinkUrlController } from './controllers/url.controller';
import { ISheetsHyperLinkPopupService, SheetsHyperLinkPopupService } from './services/popup.service';
import { SheetsHyperLinkResolverService } from './services/resolver.service';
import { SheetsHyperLinkSidePanelService } from './services/side-panel.service';
import { SHEET_HYPER_LINK_UI_PLUGIN } from './types/const';
@DependentOn(
UniverDocsPlugin,
UniverFormulaEnginePlugin,
UniverRenderEnginePlugin,
UniverSheetsPlugin,
UniverDocsUIPlugin,
UniverSheetsHyperLinkPlugin,
UniverSheetsUIPlugin
)
export class UniverSheetsHyperLinkUIPlugin extends Plugin {
static override pluginName: string = SHEET_HYPER_LINK_UI_PLUGIN;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsHyperLinkUIConfig> = defaultPluginConfig,
@Inject(Injector) protected override _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_HYPER_LINK_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([ComponentsController]);
this._injector.get(ComponentsController);
const dependencies: Dependency[] = [
[SheetsHyperLinkResolverService],
[ISheetsHyperLinkPopupService, { useClass: SheetsHyperLinkPopupService }],
[SheetsHyperLinkSidePanelService],
[SheetsHyperLinkPopupController],
[SheetsHyperLinkUIController],
[SheetsHyperLinkAutoFillController],
[SheetsHyperLinkCopyPasteController],
[SheetsHyperLinkPermissionController],
[SheetHyperLinkUrlController],
];
dependencies.forEach((dep) => this._injector.add(dep));
}
override onReady(): void {
const renderManager = this._injector.get(IRenderManagerService);
renderManager.registerRenderModule(UniverInstanceType.UNIVER_SHEET, [SheetsHyperLinkRenderController] as Dependency);
this._injector.get(SheetsHyperLinkAutoFillController);
this._injector.get(SheetsHyperLinkCopyPasteController);
this._injector.get(SheetsHyperLinkUIController);
}
override onRendered(): void {
this._injector.get(SheetsHyperLinkPermissionController);
this._injector.get(SheetHyperLinkUrlController);
this._injector.get(SheetsHyperLinkPopupController);
}
}

View File

@@ -0,0 +1,455 @@
/**
* 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 { ContextService, CustomRangeType, IContextService, Injector, IUniverInstanceService } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IEditorBridgeService, SheetCanvasPopManagerService } from '@univerjs/sheets-ui';
import { IDialogService } from '@univerjs/ui';
import { of } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { HyperLinkEditSourceType } from '../../types/enums/edit-source';
import { SheetsHyperLinkPopupService } from '../popup.service';
class TestDisposable {
disposeCount = 0;
canDisposeResult = true;
dispose(): void {
this.disposeCount += 1;
}
canDispose(): boolean {
return this.canDisposeResult;
}
}
class TestSheetCanvasPopManagerService {
readonly cellDisposable = new TestDisposable();
readonly positionDisposable = new TestDisposable();
readonly absoluteDisposable = new TestDisposable();
readonly cellPopups: Array<{ row: number; col: number; popup: any; unitId?: string; subUnitId?: string }> = [];
readonly positionPopups: Array<{ rect: any; popup: any; location: any }> = [];
readonly absolutePopups: Array<{ rect: any; popup: any; unitId?: string; subUnitId?: string }> = [];
attachPopupToCell(row: number, col: number, popup: any, unitId?: string, subUnitId?: string): TestDisposable {
this.cellPopups.push({ row, col, popup, unitId, subUnitId });
return this.cellDisposable;
}
attachPopupByPosition(rect: any, popup: any, location: any): TestDisposable {
this.positionPopups.push({ rect, popup, location });
return this.positionDisposable;
}
attachPopupToAbsolutePosition(rect: any, popup: any, unitId?: string, subUnitId?: string): TestDisposable {
this.absolutePopups.push({ rect, popup, unitId, subUnitId });
return this.absoluteDisposable;
}
}
class TestUniverInstanceService {
workbook: any = null;
getUnit(): any {
return this.workbook;
}
}
class TestEditorBridgeService {
visible = false;
editCellState: any = null;
isVisible(): { visible: boolean } {
return { visible: this.visible };
}
getEditCellState(): any {
return this.editCellState;
}
}
class TestDocSelectionManagerService {
activeTextRange: any = { startOffset: 1, endOffset: 3, collapsed: false };
readonly replacedDocRanges: any[] = [];
getActiveTextRange(): any {
return this.activeTextRange;
}
replaceDocRanges(ranges: any, unitRange: any): void {
this.replacedDocRanges.push({ ranges, unitRange });
}
}
class TestRenderManagerService {
render: any = null;
getRenderUnitById(): any {
return this.render;
}
}
class TestDialogService {
open(): TestDisposable {
return new TestDisposable();
}
close(): void {}
closeAll(): void {}
getDialogs$() {
return of([]);
}
}
function createService() {
const injector = new Injector();
injector.add([IContextService, { useClass: ContextService }]);
injector.add([SheetCanvasPopManagerService, { useClass: TestSheetCanvasPopManagerService as never }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([IEditorBridgeService, { useClass: TestEditorBridgeService as never }]);
injector.add([DocSelectionManagerService, { useClass: TestDocSelectionManagerService as never }]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([IDialogService, { useClass: TestDialogService }]);
injector.add([SheetsHyperLinkPopupService]);
return {
service: injector.get(SheetsHyperLinkPopupService),
sheetCanvasPopManagerService: injector.get(SheetCanvasPopManagerService) as unknown as TestSheetCanvasPopManagerService,
editorBridgeService: injector.get(IEditorBridgeService) as unknown as TestEditorBridgeService,
textSelectionManagerService: injector.get(DocSelectionManagerService) as unknown as TestDocSelectionManagerService,
univerInstanceService: injector.get(IUniverInstanceService) as unknown as TestUniverInstanceService,
};
}
describe('SheetsHyperLinkPopupService', () => {
it('shows and hides cell popups without recreating the same viewing popup', () => {
const { service, sheetCanvasPopManagerService } = createService();
service.showPopup({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 2,
showAll: true,
editPermission: true,
copyPermission: true,
type: HyperLinkEditSourceType.VIEWING,
});
expect(sheetCanvasPopManagerService.cellPopups).toHaveLength(1);
expect(service.currentPopup).toEqual(expect.objectContaining({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 2,
showAll: true,
type: HyperLinkEditSourceType.VIEWING,
}));
service.showPopup({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 2,
showAll: true,
type: HyperLinkEditSourceType.VIEWING,
});
expect(sheetCanvasPopManagerService.cellPopups).toHaveLength(1);
service.hideCurrentPopup();
expect(sheetCanvasPopManagerService.cellDisposable.disposeCount).toBe(1);
expect(service.currentPopup).toBeNull();
});
it('routes popups by source type and popup callbacks close the active popup', () => {
const { service, sheetCanvasPopManagerService } = createService();
service.showPopup({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 0,
col: 0,
customRange: { rangeId: 'r1', startIndex: 1, endIndex: 3, rangeType: CustomRangeType.HYPERLINK },
customRangeRect: { left: 0, top: 0, right: 10, bottom: 10 },
type: HyperLinkEditSourceType.EDITING,
});
expect(sheetCanvasPopManagerService.absolutePopups).toHaveLength(1);
sheetCanvasPopManagerService.absolutePopups[0].popup.onClickOutside();
expect(sheetCanvasPopManagerService.absoluteDisposable.disposeCount).toBe(1);
expect(service.currentPopup).toBeNull();
service.showPopup({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 0,
col: 0,
customRange: { rangeId: 'r2', startIndex: 2, endIndex: 4, rangeType: CustomRangeType.HYPERLINK },
customRangeRect: { left: 1, top: 1, right: 9, bottom: 9 },
type: HyperLinkEditSourceType.VIEWING,
});
expect(sheetCanvasPopManagerService.positionPopups).toHaveLength(1);
sheetCanvasPopManagerService.positionPopups[0].popup.onClick();
expect(sheetCanvasPopManagerService.positionDisposable.disposeCount).toBe(1);
expect(service.currentPopup).toBeNull();
});
it('does not open incomplete popups and replaces an older visible popup', () => {
const { service, sheetCanvasPopManagerService } = createService();
service.showPopup({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 0,
col: 0,
customRangeRect: { left: 0, top: 0, right: 10, bottom: 10 },
type: HyperLinkEditSourceType.EDITING,
});
service.showPopup({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 0,
col: 0,
customRangeRect: { left: 0, top: 0, right: 10, bottom: 10 },
type: HyperLinkEditSourceType.VIEWING,
});
expect(sheetCanvasPopManagerService.absolutePopups).toHaveLength(0);
expect(sheetCanvasPopManagerService.positionPopups).toHaveLength(0);
service.showPopup({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 0,
col: 0,
showAll: true,
type: HyperLinkEditSourceType.VIEWING,
});
service.showPopup({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 2,
col: 3,
showAll: true,
type: HyperLinkEditSourceType.VIEWING,
});
expect(sheetCanvasPopManagerService.cellPopups).toHaveLength(2);
expect(sheetCanvasPopManagerService.cellDisposable.disposeCount).toBe(1);
expect(service.currentPopup).toEqual(expect.objectContaining({ row: 2, col: 3 }));
});
it('starts viewing edits from cell content and preserves editor state while keep-visible is enabled', () => {
const { service, sheetCanvasPopManagerService, univerInstanceService } = createService();
univerInstanceService.workbook = {
getSheetBySheetId: () => ({
getCellRaw: () => ({ v: 'Cell Value' }),
}),
};
service.startAddEditing({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 1,
type: HyperLinkEditSourceType.VIEWING,
});
expect(sheetCanvasPopManagerService.cellPopups).toHaveLength(1);
expect(service.currentEditing).toEqual(expect.objectContaining({
label: 'Cell Value',
type: HyperLinkEditSourceType.VIEWING,
}));
service.setIsKeepVisible(true);
service.endEditing();
expect(service.currentEditing).toEqual(expect.objectContaining({ type: HyperLinkEditSourceType.VIEWING }));
service.setIsKeepVisible(false);
service.endEditing(HyperLinkEditSourceType.VIEWING);
expect(service.currentEditing).toBeNull();
});
it('uses rich text cell content as the default label when starting a viewing edit', () => {
const { service, univerInstanceService } = createService();
univerInstanceService.workbook = {
getSheetBySheetId: () => ({
getCellRaw: () => ({ p: { body: { dataStream: 'Rich Text\r\n' } } }),
}),
};
service.startAddEditing({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 1,
type: HyperLinkEditSourceType.VIEWING,
});
expect(service.currentEditing).toEqual(expect.objectContaining({ label: 'Rich Text' }));
});
it('keeps a popup hidden when the same link is already being edited', () => {
const { service, sheetCanvasPopManagerService } = createService();
service.startAddEditing({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 1,
type: HyperLinkEditSourceType.VIEWING,
});
service.showPopup({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 1,
showAll: true,
type: HyperLinkEditSourceType.VIEWING,
});
expect(sheetCanvasPopManagerService.cellPopups).toHaveLength(1);
expect(service.currentPopup).toBeNull();
});
it('does not start document-editing flow when editor state has no document body', () => {
const { service, editorBridgeService, textSelectionManagerService } = createService();
editorBridgeService.visible = true;
editorBridgeService.editCellState = {
documentLayoutObject: {
documentModel: {
getBody: () => null,
},
},
};
service.startAddEditing({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 1,
type: HyperLinkEditSourceType.EDITING,
});
expect(textSelectionManagerService.replacedDocRanges).toHaveLength(0);
expect(service.currentEditing).toBeNull();
});
it('does not start document-editing flow when the editor is hidden', () => {
const { service, textSelectionManagerService } = createService();
service.startAddEditing({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 1,
type: HyperLinkEditSourceType.EDITING,
});
expect(textSelectionManagerService.replacedDocRanges).toHaveLength(0);
expect(service.currentEditing).toBeNull();
});
it('selects the full editor text when adding a link from a collapsed editing selection', () => {
const { service, editorBridgeService, textSelectionManagerService, sheetCanvasPopManagerService } = createService();
editorBridgeService.visible = true;
editorBridgeService.editCellState = {
documentLayoutObject: {
documentModel: {
getBody: () => ({ dataStream: 'Full Label\r\n', customRanges: [] }),
},
},
};
textSelectionManagerService.activeTextRange = { startOffset: 2, endOffset: 2, collapsed: true };
service.startAddEditing({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 1,
type: HyperLinkEditSourceType.EDITING,
});
expect(textSelectionManagerService.replacedDocRanges[0].ranges).toEqual([{
startOffset: 0,
endOffset: 10,
collapsed: false,
label: 'Full Label',
}]);
expect(sheetCanvasPopManagerService.absolutePopups).toHaveLength(0);
expect(service.currentEditing).toBeNull();
});
it('expands an editor selection to the full overlapping hyperlink range', () => {
const { service, editorBridgeService, textSelectionManagerService } = createService();
editorBridgeService.visible = true;
editorBridgeService.editCellState = {
documentLayoutObject: {
documentModel: {
getBody: () => ({
dataStream: 'abcdef\r\n',
customRanges: [{ rangeId: 'link-1', startIndex: 1, endIndex: 4, rangeType: CustomRangeType.HYPERLINK }],
}),
},
},
};
textSelectionManagerService.activeTextRange = { startOffset: 2, endOffset: 3, collapsed: false };
service.startAddEditing({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 1,
type: HyperLinkEditSourceType.EDITING,
});
expect(textSelectionManagerService.replacedDocRanges[0].ranges).toEqual([{
startOffset: 1,
endOffset: 5,
collapsed: false,
label: 'bcde',
}]);
expect(service.currentEditing).toBeNull();
});
it('ends viewing edit sessions from edit popup click handlers', () => {
const { service, sheetCanvasPopManagerService } = createService();
service.startAddEditing({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 1,
type: HyperLinkEditSourceType.VIEWING,
});
sheetCanvasPopManagerService.cellPopups[0].popup.onClickOutside();
expect(service.currentEditing).toBeNull();
service.startAddEditing({
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 1,
col: 1,
type: HyperLinkEditSourceType.VIEWING,
});
sheetCanvasPopManagerService.cellPopups[1].popup.onContextMenu();
expect(service.currentEditing).toBeNull();
});
});

View File

@@ -0,0 +1,259 @@
/**
* 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 { IMessageProps } from '@univerjs/design';
import { ICommandService, IConfigService, Injector, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { IDefinedNamesService } from '@univerjs/engine-formula';
import { SetSelectionsOperation, SetWorksheetActiveOperation } from '@univerjs/sheets';
import { ERROR_RANGE, SheetHyperLinkType } from '@univerjs/sheets-hyper-link';
import { ScrollToRangeOperation } from '@univerjs/sheets-ui';
import { IMessageService } from '@univerjs/ui';
import { afterEach, describe, expect, it } from 'vitest';
import { SheetsHyperLinkResolverService } from '../resolver.service';
class TestCommandService {
readonly commands: Array<{ id: string; params: any }> = [];
activateResult = true;
async executeCommand(id: string, params: any): Promise<boolean> {
this.commands.push({ id, params });
return id === SetWorksheetActiveOperation.id ? this.activateResult : true;
}
}
class TestDefinedNamesService {
namedRanges = new Map<string, { formulaOrRefString: string }>();
worksheetsByRef: any[] = [];
readonly focusedRanges: Array<{ unitId: string; rangeId: string }> = [];
getValueById(_unitId: string, rangeId: string): { formulaOrRefString: string } | undefined {
return this.namedRanges.get(rangeId);
}
getWorksheetByRef(): any {
return this.worksheetsByRef.shift();
}
focusRange(unitId: string, rangeId: string): void {
this.focusedRanges.push({ unitId, rangeId });
}
}
class TestMessageService {
readonly messages: IMessageProps[] = [];
show(options: IMessageProps): void {
this.messages.push(options);
}
}
class TestLocaleService {
t(key: string): string {
return key;
}
}
class TestConfigService {
configs: any[] = [];
getConfig(): any {
return this.configs.length ? this.configs.shift() : undefined;
}
}
class TestUniverInstanceService {
currentUnit: any = null;
unit: any = null;
getCurrentUnitOfType(): any {
return this.currentUnit;
}
getUnit(): any {
return this.unit ?? this.currentUnit;
}
}
function createWorksheet(sheetId: string, options?: { hidden?: boolean }) {
return {
getSheetId: () => sheetId,
getMergeData: () => [{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 2 }],
getMaxColumns: () => 10,
getMaxRows: () => 10,
isSheetHidden: () => !!options?.hidden,
};
}
function createWorkbook(options?: {
activeSheet?: any;
targetSheet?: any;
hiddenSheets?: string[];
}) {
return {
getUnitId: () => 'unit-1',
getActiveSheet: () => options?.activeSheet ?? createWorksheet('sheet-1'),
getSheetBySheetId: (sheetId: string) => (options?.targetSheet?.getSheetId() === sheetId ? options.targetSheet : null),
getHiddenWorksheets: () => options?.hiddenSheets ?? [],
};
}
function createResolver() {
const injector = new Injector();
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([ICommandService, { useClass: TestCommandService as never }]);
injector.add([IDefinedNamesService, { useClass: TestDefinedNamesService as never }]);
injector.add([IMessageService, { useClass: TestMessageService as never }]);
injector.add([LocaleService, { useClass: TestLocaleService as never }]);
injector.add([IConfigService, { useClass: TestConfigService as never }]);
injector.add([SheetsHyperLinkResolverService]);
return {
resolver: injector.get(SheetsHyperLinkResolverService),
commandService: injector.get(ICommandService) as unknown as TestCommandService,
definedNamesService: injector.get(IDefinedNamesService) as unknown as TestDefinedNamesService,
messageService: injector.get(IMessageService) as unknown as TestMessageService,
configService: injector.get(IConfigService) as unknown as TestConfigService,
univerInstanceService: injector.get(IUniverInstanceService) as unknown as TestUniverInstanceService,
};
}
const originalOpen = window.open;
const waitForNavigationJobs = () => new Promise((resolve) => setTimeout(resolve));
describe('SheetsHyperLinkResolverService', () => {
afterEach(() => {
window.open = originalOpen;
});
it('should navigate to sheet ranges and execute selection and scroll commands', async () => {
const { resolver, commandService, univerInstanceService } = createResolver();
const targetSheet = createWorksheet('sheet-2');
univerInstanceService.currentUnit = createWorkbook({ targetSheet });
await resolver.navigateToRange('unit-1', 'sheet-2', { startRow: 1, endRow: 20, startColumn: 1, endColumn: 20 }, true);
expect(commandService.commands).toEqual([
{ id: SetWorksheetActiveOperation.id, params: { unitId: 'unit-1', subUnitId: 'sheet-2' } },
{
id: SetSelectionsOperation.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-2',
selections: [{ range: { startRow: 1, endRow: 9, startColumn: 1, endColumn: 9 }, primary: null }],
},
},
{
id: ScrollToRangeOperation.id,
params: { range: { startRow: 1, endRow: 9, startColumn: 1, endColumn: 9 }, forceTop: true },
},
]);
});
it('should show user-facing errors when target sheets or defined names are invalid', async () => {
const { resolver, definedNamesService, messageService, univerInstanceService } = createResolver();
univerInstanceService.currentUnit = createWorkbook({ hiddenSheets: ['sheet-hidden'] });
definedNamesService.namedRanges.set('named-range', { formulaOrRefString: 'Sheet1!A1' });
definedNamesService.worksheetsByRef.push(null, createWorksheet('sheet-hidden', { hidden: true }), createWorksheet('sheet-1'));
resolver.navigate({ type: SheetHyperLinkType.SHEET, searchObj: { rangeid: 'missing-range' } } as any);
resolver.navigate({ type: SheetHyperLinkType.SHEET, searchObj: { rangeid: 'named-range' } } as any);
resolver.navigate({ type: SheetHyperLinkType.SHEET, searchObj: { rangeid: 'named-range' } } as any);
resolver.navigate({ type: SheetHyperLinkType.SHEET, searchObj: { rangeid: 'named-range' } } as any);
await resolver.navigateToSheetById('unit-1', 'sheet-missing');
await resolver.navigateToSheetById('unit-1', 'sheet-hidden');
expect(messageService.messages).toEqual([
{ content: 'sheets-hyper-link-ui.message.refError', type: 'error' },
{ content: 'sheets-hyper-link-ui.message.hiddenSheet', type: 'error' },
{ content: 'sheets-hyper-link-ui.message.noSheet', type: 'error' },
{ content: 'sheets-hyper-link-ui.message.noSheet', type: 'error' },
]);
expect(definedNamesService.focusedRanges).toEqual([{ unitId: 'unit-1', rangeId: 'named-range' }]);
});
it('should open external links via configured handlers or fallback window.open', async () => {
const { resolver, configService } = createResolver();
const navigatedUrls: string[] = [];
const openedUrls: any[] = [];
window.open = ((...args: any[]) => {
openedUrls.push(args);
return null;
}) as typeof window.open;
configService.configs.push(
{ urlHandler: { navigateToOtherWebsite: (url: string) => navigatedUrls.push(url) } },
undefined
);
await resolver.navigateToOtherWebsite('https://univer.ai');
await resolver.navigateToOtherWebsite('https://openai.com');
await resolver.navigateToOtherWebsite('javascript:alert(1)');
resolver.navigate({ type: SheetHyperLinkType.URL, url: 'https://example.com' } as any);
expect(navigatedUrls).toEqual(['https://univer.ai']);
expect(openedUrls).toEqual([
['https://openai.com', '_blank', 'noopener noreferrer'],
['https://example.com', '_blank', 'noopener noreferrer'],
]);
});
it('should interpret workbook-style links through the public navigate entry', async () => {
const { resolver, commandService, univerInstanceService } = createResolver();
univerInstanceService.currentUnit = createWorkbook({ targetSheet: createWorksheet('sheet-2') });
resolver.navigate({ type: SheetHyperLinkType.SHEET, searchObj: { gid: 'sheet-2', range: 'B2:C3' } } as any);
await waitForNavigationJobs();
expect(commandService.commands.map((command) => command.id)).toEqual([
SetWorksheetActiveOperation.id,
SetSelectionsOperation.id,
ScrollToRangeOperation.id,
]);
});
it('should ignore invalid workbook targets without executing navigation commands', async () => {
const { resolver, commandService, univerInstanceService } = createResolver();
resolver.navigate({ type: SheetHyperLinkType.SHEET, searchObj: { gid: 'sheet-1' } } as any);
await resolver.navigateToSheetById('unit-1', 'sheet-1');
univerInstanceService.currentUnit = createWorkbook({ activeSheet: null });
await resolver.navigateToSheetById('unit-1', 'sheet-1');
univerInstanceService.currentUnit = createWorkbook({ targetSheet: createWorksheet('sheet-2') });
commandService.activateResult = false;
await resolver.navigateToSheetById('unit-1', 'sheet-2');
resolver.navigate({ type: SheetHyperLinkType.SHEET, searchObj: { gid: 'sheet-2', range: ERROR_RANGE } } as any);
await Promise.resolve();
expect(commandService.commands).toEqual([
{ id: SetWorksheetActiveOperation.id, params: { unitId: 'unit-1', subUnitId: 'sheet-2' } },
]);
});
it('should stay on the current worksheet when the hyperlink already points there', async () => {
const { resolver, commandService, univerInstanceService } = createResolver();
const activeSheet = createWorksheet('sheet-1');
univerInstanceService.currentUnit = createWorkbook({ activeSheet });
const result = await resolver.navigateToSheetById('unit-1', 'sheet-1');
expect(result).toBe(activeSheet);
expect(commandService.commands).toEqual([]);
});
});

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 { ISheetHyperLink } from '@univerjs/sheets-hyper-link';
import type { ICustomHyperLinkView } from '../side-panel.service';
import { Injector } from '@univerjs/core';
import { SheetHyperLinkType } from '@univerjs/sheets-hyper-link';
import { describe, expect, it } from 'vitest';
import { SheetsHyperLinkSidePanelService } from '../side-panel.service';
function createService(): SheetsHyperLinkSidePanelService {
const injector = new Injector();
injector.add([SheetsHyperLinkSidePanelService]);
return injector.get(SheetsHyperLinkSidePanelService);
}
describe('SheetsHyperLinkSidePanelService', () => {
it('should register, resolve and remove custom hyperlink views', () => {
const service = createService();
const customView: ICustomHyperLinkView = {
type: 'sheet-anchor',
option: { label: 'Sheet Anchor', value: 'sheet-anchor' },
Form: () => null,
convert: () => ({ display: 'A1', payload: '#A1', type: 'sheet-anchor' }),
match: (link) => link.payload === '#A1',
};
expect(service.isBuiltInLinkType(SheetHyperLinkType.URL)).toBe(false);
expect(service.isBuiltInLinkType('custom')).toBe(true);
service.registerCustomHyperLink(customView);
expect(service.getOptions()).toEqual([customView.option]);
expect(service.getCustomHyperLink('sheet-anchor')).toBe(customView);
expect(service.findCustomHyperLink({ payload: '#A1' } as ISheetHyperLink)).toBe(customView);
service.removeCustomHyperLink('sheet-anchor');
expect(service.getCustomHyperLink('sheet-anchor')).toBeUndefined();
service.registerCustomHyperLink(customView);
service.dispose();
expect(service.getOptions()).toEqual([]);
});
});

View File

@@ -0,0 +1,189 @@
/**
* 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 { ICustomRange, INeedCheckDisposable, Nullable, Workbook } from '@univerjs/core';
import type { IHyperLinkEditing, IHyperLinkPopupOptions } from './popup.service';
import { BuildTextUtils, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, UniverInstanceType } from '@univerjs/core';
import { getCustomRangePosition, getEditingCustomRangePosition } from '@univerjs/sheets-ui';
import { HyperLinkEditSourceType } from '../types/enums/edit-source';
import { MobileCellLinkEdit } from '../views/MobileCellLinkEdit';
import { MobileCellLinkPopup } from '../views/MobileCellLinkPopup';
import { SheetsHyperLinkPopupService } from './popup.service';
const MOBILE_HYPER_LINK_EDITOR_DIALOG_ID = 'sheet-mobile-hyper-link-editor';
const MOBILE_HYPER_LINK_VIEWER_DIALOG_ID = 'sheet-mobile-hyper-link-viewer';
export class SheetsHyperLinkMobilePopupService extends SheetsHyperLinkPopupService {
override showPopup(location: IHyperLinkPopupOptions): void {
if (this.getIsKeepVisible()) {
return;
}
if (this._currentPopup && this._isEqualLink(location, this._currentPopup)) {
return;
}
this.hideCurrentPopup(undefined, true);
const currentEditing = this._currentEditing$.getValue();
if (currentEditing && this._isEqualLink(location, currentEditing)) {
return;
}
const { unitId, subUnitId, row, col, customRange } = location;
if (!location.showAll && !customRange) {
return;
}
const disposable: INeedCheckDisposable = {
canDispose: () => true,
dispose: () => this._dialogService.close(MOBILE_HYPER_LINK_VIEWER_DIALOG_ID),
};
this._currentPopup = {
unitId,
subUnitId,
disposable,
row,
col,
editPermission: !!location.editPermission,
copyPermission: !!location.copyPermission,
customRange,
type: location.type,
showAll: location.showAll,
};
this._currentPopup$.next(this._currentPopup);
this._dialogService.open({
id: MOBILE_HYPER_LINK_VIEWER_DIALOG_ID,
title: { title: 'sheets-hyper-link-ui.popup.title' },
children: { label: MobileCellLinkPopup.componentKey },
onClose: () => this.hideCurrentPopup(undefined, true),
});
}
override startAddEditing(link: IHyperLinkEditing): void {
const { unitId, subUnitId, type } = link;
let label: string;
if (type === HyperLinkEditSourceType.EDITING) {
const range = this._getEditingRange();
if (!range) {
return;
}
this._textSelectionManagerService.replaceDocRanges([{ ...range }], {
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
});
label = range.label;
} else {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(subUnitId);
const cell = worksheet?.getCellRaw(link.row, link.col);
label = cell?.p
? BuildTextUtils.transform.getPlainText(cell.p.body?.dataStream ?? '')
: (cell?.v ?? '').toString();
}
this._openMobileEditor({ ...link, label });
}
override startEditing(link: Required<IHyperLinkEditing>): void {
this._currentEditingPopup?.dispose();
this.hideCurrentPopup(undefined, true);
let customRange: ICustomRange;
let label: string;
if (link.type === HyperLinkEditSourceType.EDITING) {
const customRangeInfo = getEditingCustomRangePosition(
this._injector,
link.unitId,
link.subUnitId,
link.row,
link.col,
link.customRangeId
);
if (!customRangeInfo || !customRangeInfo.rects?.length) {
return;
}
customRange = customRangeInfo.customRange;
label = customRangeInfo.label;
this._textSelectionManagerService.replaceDocRanges([{
startOffset: customRange.startIndex,
endOffset: customRange.endIndex + 1,
}]);
} else {
const customRangeInfo = getCustomRangePosition(
this._injector,
link.unitId,
link.subUnitId,
link.row,
link.col,
link.customRangeId
);
if (!customRangeInfo || !customRangeInfo.rects?.length) {
return;
}
customRange = customRangeInfo.customRange;
label = customRangeInfo.label;
}
this._openMobileEditor({ ...link, customRange, label });
}
override endEditing(type?: HyperLinkEditSourceType): void {
if (this.getIsKeepVisible()) {
return;
}
const current = this._currentEditing$.getValue();
if (current && (!type || type === current.type)) {
this._currentEditingPopup?.dispose();
this._dialogService.close(MOBILE_HYPER_LINK_EDITOR_DIALOG_ID);
this._currentEditing$.next(null);
}
}
private _openMobileEditor(editing: IHyperLinkEditing & { customRange?: ICustomRange; label?: string }): void {
this._currentEditing$.next(editing);
this._dialogService.open({
id: MOBILE_HYPER_LINK_EDITOR_DIALOG_ID,
title: { title: 'sheets-hyper-link-ui.form.addTitle' },
children: { label: MobileCellLinkEdit.componentKey },
maskClosable: false,
onClose: () => this.endEditing(editing.type),
});
}
private _isEqualLink(
location: IHyperLinkPopupOptions,
current: {
unitId: string;
subUnitId: string;
row: number;
col: number;
customRange?: Nullable<ICustomRange>;
type: HyperLinkEditSourceType;
} | null
): boolean {
if (!current) {
return false;
}
return location.unitId === current.unitId
&& location.subUnitId === current.subUnitId
&& location.row === current.row
&& location.col === current.col
&& location.customRange?.rangeId === current.customRange?.rangeId
&& location.type === current.type;
}
}

View File

@@ -0,0 +1,411 @@
/**
* 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 { ICustomRange, IDisposable, INeedCheckDisposable, ITextRange, Nullable, Workbook } from '@univerjs/core';
import type { IBoundRectNoAngle } from '@univerjs/engine-render';
import type { ISheetLocationBase } from '@univerjs/sheets';
import type { ICanvasPopup } from '@univerjs/sheets-ui';
import type { Observable } from 'rxjs';
import {
BuildTextUtils,
createIdentifier,
CustomRangeType,
Disposable,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
Inject,
Injector,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { calcDocRangePositions } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { getCustomRangePosition, getEditingCustomRangePosition, IEditorBridgeService, SheetCanvasPopManagerService } from '@univerjs/sheets-ui';
import { IDialogService } from '@univerjs/ui';
import { BehaviorSubject, Subject } from 'rxjs';
import { HyperLinkEditSourceType } from '../types/enums/edit-source';
import { CellLinkEdit } from '../views/CellLinkEdit';
import { CellLinkPopup } from '../views/CellLinkPopup';
export interface IHyperLinkPopup {
unitId: string;
subUnitId: string;
disposable: INeedCheckDisposable;
row: number;
col: number;
editPermission?: boolean;
copyPermission?: boolean;
customRange?: Nullable<ICustomRange>;
type: HyperLinkEditSourceType;
showAll?: boolean;
}
export interface IHyperLinkEditing {
unitId: string;
subUnitId: string;
row: number;
col: number;
customRangeId?: string;
type: HyperLinkEditSourceType;
}
export interface ISheetsHyperLinkPopupService {
readonly currentPopup: IHyperLinkPopup | null;
readonly currentPopup$: Observable<IHyperLinkPopup | null>;
readonly currentEditing: (IHyperLinkEditing & { customRange?: ICustomRange; label?: string }) | null;
readonly currentEditing$: Observable<(IHyperLinkEditing & { customRange?: ICustomRange; label?: string }) | null>;
setIsKeepVisible(value: boolean): void;
getIsKeepVisible(): boolean;
showPopup(location: IHyperLinkPopupOptions): void;
hideCurrentPopup(type?: HyperLinkEditSourceType, force?: boolean): void;
startAddEditing(link: IHyperLinkEditing): void;
startEditing(link: Required<IHyperLinkEditing>): void;
endEditing(type?: HyperLinkEditSourceType): void;
}
export const ISheetsHyperLinkPopupService = createIdentifier<ISheetsHyperLinkPopupService>('sheets-hyper-link-ui.popup.service');
const isEqualLink = (a: IHyperLinkPopupOptions, b: Omit<IHyperLinkPopup, 'disposable' | 'editPermission'>) => {
return (
a.unitId === b.unitId
&& a.subUnitId === b.subUnitId
&& a.row === b.row
&& a.col === b.col
&& a.customRange?.rangeId === b.customRange?.rangeId
&& a.type === b.type
);
};
export interface IHyperLinkPopupOptions extends ISheetLocationBase {
editPermission?: boolean;
copyPermission?: boolean;
customRange?: Nullable<ICustomRange>;
customRangeRect?: Nullable<IBoundRectNoAngle>;
showAll?: boolean;
type: HyperLinkEditSourceType;
}
export class SheetsHyperLinkPopupService extends Disposable implements ISheetsHyperLinkPopupService {
protected _currentPopup: IHyperLinkPopup | null = null;
protected _currentPopup$ = new Subject<IHyperLinkPopup | null>();
currentPopup$ = this._currentPopup$.asObservable();
protected _currentEditingPopup: Nullable<IDisposable> = null;
protected _currentEditing$ = new BehaviorSubject<(IHyperLinkEditing & { customRange?: ICustomRange; label?: string }) | null>(null);
currentEditing$ = this._currentEditing$.asObservable();
private _isKeepVisible: boolean = false;
get currentPopup() {
return this._currentPopup;
}
get currentEditing() {
return this._currentEditing$.getValue();
}
constructor(
@Inject(SheetCanvasPopManagerService) protected readonly _sheetCanvasPopManagerService: SheetCanvasPopManagerService,
@Inject(Injector) protected readonly _injector: Injector,
@IUniverInstanceService protected readonly _univerInstanceService: IUniverInstanceService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@Inject(DocSelectionManagerService) protected readonly _textSelectionManagerService: DocSelectionManagerService,
@IRenderManagerService protected readonly _renderManagerService: IRenderManagerService,
@IDialogService protected readonly _dialogService: IDialogService
) {
super();
this.disposeWithMe(() => {
this.hideCurrentPopup();
this.endEditing();
this._currentEditing$.complete();
this._currentPopup$.complete();
});
}
public setIsKeepVisible(v: boolean) {
this._isKeepVisible = v;
}
public getIsKeepVisible() {
return this._isKeepVisible;
}
showPopup(location: IHyperLinkPopupOptions) {
if (this._currentPopup && isEqualLink(location, this._currentPopup)) {
return;
}
this.hideCurrentPopup(undefined, true);
const currentEditing = this._currentEditing$.getValue();
if (currentEditing && isEqualLink(location, currentEditing)) {
return;
}
const { unitId, subUnitId, row, col, customRangeRect, customRange } = location;
let disposable: Nullable<INeedCheckDisposable>;
const popup: ICanvasPopup = {
componentKey: CellLinkPopup.componentKey,
direction: 'bottom',
onClickOutside: () => {
this.hideCurrentPopup();
},
onClick: () => {
this.hideCurrentPopup(location.type, true);
},
};
if (location.type === HyperLinkEditSourceType.EDITING) {
if (!customRange) {
return;
}
disposable = customRangeRect && this._sheetCanvasPopManagerService.attachPopupToAbsolutePosition(
customRangeRect,
popup
);
} else {
if (location.showAll) {
disposable = this._sheetCanvasPopManagerService.attachPopupToCell(location.row, location.col, popup, unitId, subUnitId);
} else {
if (!customRange) {
return;
}
disposable = customRangeRect && this._sheetCanvasPopManagerService.attachPopupByPosition(
customRangeRect,
popup,
location
);
}
}
if (disposable) {
if (this._currentPopup) {
this._currentPopup.disposable?.dispose();
}
this._currentPopup = {
unitId,
subUnitId,
disposable,
row,
col,
editPermission: !!location.editPermission,
copyPermission: !!location.copyPermission,
customRange,
type: location.type,
showAll: location.showAll,
};
this._currentPopup$.next(this._currentPopup);
}
}
hideCurrentPopup(type?: HyperLinkEditSourceType, force?: boolean) {
if (!this._currentPopup) {
return;
}
if (((!type || type === this._currentPopup.type) && this._currentPopup.disposable.canDispose()) || force) {
this._currentPopup?.disposable?.dispose();
this._currentPopup = null;
this._currentPopup$.next(null);
}
}
override dispose(): void {
super.dispose();
this.hideCurrentPopup();
this.endEditing();
this._currentPopup$.complete();
this._currentEditing$.complete();
}
protected _getEditingRange(): Nullable<ITextRange & { label: string }> {
const visible = this._editorBridgeService.isVisible().visible;
const state = this._editorBridgeService.getEditCellState();
if (visible && state) {
const textRange = this._textSelectionManagerService.getActiveTextRange();
const body = state.documentLayoutObject.documentModel?.getBody();
if (!body) {
return null;
}
if (!textRange || textRange.collapsed) {
return {
startOffset: 0,
endOffset: body.dataStream.length - 2,
collapsed: body.dataStream.length - 2 === 0,
label: BuildTextUtils.transform.getPlainText(body.dataStream),
};
}
const links = BuildTextUtils.customRange.getCustomRangesInterestsWithSelection(textRange, body.customRanges?.filter((i) => i.rangeType === CustomRangeType.HYPERLINK) ?? []);
let start = textRange.startOffset;
let end = textRange.endOffset;
links.forEach((link) => {
start = Math.min(start, link.startIndex);
end = Math.max(end, link.endIndex + 1);
});
return {
startOffset: start,
endOffset: end,
collapsed: start === end,
label: BuildTextUtils.transform.getPlainText(body.dataStream.slice(start, end)),
};
}
return null;
}
private get _editPopup() {
const popup: ICanvasPopup = {
componentKey: CellLinkEdit.componentKey,
direction: 'vertical',
onClickOutside: () => {
this.endEditing();
},
onContextMenu: () => {
this.endEditing();
},
hiddenType: 'hide',
};
return popup;
}
startAddEditing(link: IHyperLinkEditing) {
const { unitId, subUnitId, type } = link;
if (type === HyperLinkEditSourceType.EDITING) {
const range = this._getEditingRange();
if (!range) {
return;
}
this._textSelectionManagerService.replaceDocRanges([{ ...range }], { unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY });
const currentRender = this._renderManagerService.getRenderUnitById(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
if (!currentRender) {
return;
}
const rects = calcDocRangePositions(range, currentRender);
if (!rects?.length) {
return;
}
this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupToAbsolutePosition(
rects.pop()!,
this._editPopup,
unitId,
subUnitId
);
this._currentEditing$.next({
...link,
label: range?.label ?? '',
});
} else {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(subUnitId);
const cell = worksheet?.getCellRaw(link.row, link.col);
const label = cell?.p ? BuildTextUtils.transform.getPlainText(cell.p.body?.dataStream ?? '') : (cell?.v ?? '').toString();
this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupToCell(
link.row,
link.col,
this._editPopup,
unitId,
subUnitId
);
this._currentEditing$.next({
...link,
label,
});
}
}
startEditing(link: Required<IHyperLinkEditing>) {
this._currentEditingPopup?.dispose();
this.hideCurrentPopup(undefined, true);
const { unitId, subUnitId } = link;
let customRange;
let label;
if (link.type === HyperLinkEditSourceType.EDITING) {
const customRangeInfo = getEditingCustomRangePosition(this._injector, link.unitId, link.subUnitId, link.row, link.col, link.customRangeId);
if (!customRangeInfo || !customRangeInfo.rects?.length) {
return;
}
customRange = customRangeInfo.customRange;
label = customRangeInfo.label;
this._textSelectionManagerService.replaceDocRanges([
{
startOffset: customRange.startIndex,
endOffset: customRange.endIndex + 1,
},
]);
this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupToAbsolutePosition(
customRangeInfo.rects.pop()!,
this._editPopup,
unitId,
subUnitId
);
} else {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(subUnitId);
const cell = worksheet?.getCellRaw(link.row, link.col);
const style = workbook?.getStyles().getStyleByCell(cell);
const tr = style?.tr;
const customRangeInfo = getCustomRangePosition(this._injector, link.unitId, link.subUnitId, link.row, link.col, link.customRangeId);
if (!customRangeInfo || !customRangeInfo.rects?.length) {
return;
}
customRange = customRangeInfo.customRange;
label = customRangeInfo.label;
if (tr) {
this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupToCell(
link.row,
link.col,
this._editPopup,
unitId,
subUnitId
);
} else {
this._currentEditingPopup = this._sheetCanvasPopManagerService.attachPopupByPosition(
customRangeInfo.rects.pop()!,
this._editPopup,
{
unitId,
subUnitId,
row: link.row,
col: link.col,
}
);
}
}
const editing = {
...link,
customRange,
label,
};
this._currentEditing$.next(editing);
}
endEditing(type?: HyperLinkEditSourceType) {
if (this.getIsKeepVisible()) {
return;
}
const current = this._currentEditing$.getValue();
if (current && (!type || type === current.type)) {
this._currentEditingPopup?.dispose();
this._currentEditing$.next(null);
}
}
}

View File

@@ -0,0 +1,223 @@
/**
* 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 { IRange, Workbook, Worksheet } from '@univerjs/core';
import type { ISetSelectionsOperationParams } from '@univerjs/sheets';
import type { ISheetHyperLinkInfo, ISheetUrlParams } from '@univerjs/sheets-hyper-link';
import type { IUniverSheetsHyperLinkUIConfig } from '../config/config';
import type { LocaleKey } from '../locale/types';
import {
ICommandService,
IConfigService,
Inject,
isSafeUrl,
isValidRange,
IUniverInstanceService,
LocaleService,
RANGE_TYPE,
Rectangle,
UniverInstanceType,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { deserializeRangeWithSheet, IDefinedNamesService } from '@univerjs/engine-formula';
import { SetSelectionsOperation, SetWorksheetActiveOperation } from '@univerjs/sheets';
import { ERROR_RANGE, SheetHyperLinkType } from '@univerjs/sheets-hyper-link';
import { ScrollToRangeOperation } from '@univerjs/sheets-ui';
import { IMessageService } from '@univerjs/ui';
import { SHEETS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY } from '../config/config';
function getContainRange(range: IRange, worksheet: Worksheet) {
const mergedCells = worksheet.getMergeData();
const maxCol = worksheet.getMaxColumns() - 1;
const maxRow = worksheet.getMaxRows() - 1;
if (maxCol < range.endColumn) {
range.endColumn = maxCol;
}
if (maxRow < range.endRow) {
range.endRow = maxRow;
}
if (range.rangeType === RANGE_TYPE.COLUMN || RANGE_TYPE.ROW) {
return range;
}
const relativeCells: IRange[] = [];
mergedCells.forEach((cell) => {
if (Rectangle.intersects(range, cell)) {
relativeCells.push(cell);
}
});
return Rectangle.realUnion(range, ...relativeCells);
}
export class SheetsHyperLinkResolverService {
constructor(
@IUniverInstanceService private _univerInstanceService: IUniverInstanceService,
@ICommandService private _commandService: ICommandService,
@IDefinedNamesService private _definedNamesService: IDefinedNamesService,
@IMessageService private _messageService: IMessageService,
@Inject(LocaleService) private _localeService: LocaleService,
@IConfigService private _configService: IConfigService
) { }
navigate(info: ISheetHyperLinkInfo): void {
switch (info.type) {
case SheetHyperLinkType.URL:
this.navigateToOtherWebsite(info.url);
break;
default:
this._navigateToUniver(info.searchObj!);
}
}
private _navigateToUniver(params: ISheetUrlParams) {
// NOTE: should we always use current unit and active worksheet?
const { gid, range, rangeid } = params;
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return;
}
const unitId = workbook.getUnitId();
if (rangeid) {
const item = this._definedNamesService.getValueById(unitId, rangeid);
if (!item) {
return;
}
const { formulaOrRefString } = item;
const worksheet = this._definedNamesService.getWorksheetByRef(unitId, formulaOrRefString);
if (!worksheet) {
this._messageService.show({
content: this._localeService.t<LocaleKey>('sheets-hyper-link-ui.message.refError'),
type: MessageType.Error,
});
return;
}
const isHidden = worksheet.isSheetHidden();
// The worksheet may be hidden
if (isHidden) {
this._messageService.show({
content: this._localeService.t<LocaleKey>('sheets-hyper-link-ui.message.hiddenSheet'),
type: MessageType.Error,
});
return;
}
this.navigateToDefineName(unitId, rangeid);
}
if (!gid) {
return;
}
if (range) {
const rangeInfo = deserializeRangeWithSheet(range);
if (isValidRange(rangeInfo.range) && range !== ERROR_RANGE) {
this.navigateToRange(unitId, gid, rangeInfo.range);
}
return;
}
this.navigateToSheetById(unitId, gid);
}
async navigateToRange(unitId: string, subUnitId: string, range: IRange, forceTop?: boolean) {
const worksheet = await this.navigateToSheetById(unitId, subUnitId);
if (worksheet) {
const realRange = getContainRange(range, worksheet);
await this._commandService.executeCommand(
SetSelectionsOperation.id,
{
unitId,
subUnitId,
selections: [{
range: realRange,
primary: null,
}],
} as ISetSelectionsOperationParams
);
await this._commandService.executeCommand(ScrollToRangeOperation.id, {
range: realRange,
forceTop,
});
}
}
async navigateToSheetById(unitId: string, subUnitId: string) {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return false;
}
const worksheet = workbook.getActiveSheet();
if (!worksheet) {
return false;
}
if (worksheet.getSheetId() === subUnitId) {
return worksheet;
}
const targetSheet = workbook.getSheetBySheetId(subUnitId);
if (!targetSheet) {
this._messageService.show({
content: this._localeService.t<LocaleKey>('sheets-hyper-link-ui.message.noSheet'),
type: MessageType.Error,
});
return false;
}
if (workbook.getHiddenWorksheets().indexOf(subUnitId) > -1) {
this._messageService.show({
content: this._localeService.t<LocaleKey>('sheets-hyper-link-ui.message.hiddenSheet'),
type: MessageType.Error,
});
return false;
}
if (await this._commandService.executeCommand(SetWorksheetActiveOperation.id, { unitId, subUnitId })) {
return targetSheet;
}
return false;
}
async navigateToDefineName(unitId: string, rangeId: string) {
this._definedNamesService.focusRange(unitId, rangeId);
return true;
}
async navigateToOtherWebsite(url: string) {
if (!isSafeUrl(url)) {
return;
}
const config = this._configService.getConfig<IUniverSheetsHyperLinkUIConfig>(SHEETS_HYPER_LINK_UI_PLUGIN_CONFIG_KEY);
if (config?.urlHandler?.navigateToOtherWebsite) {
return config.urlHandler.navigateToOtherWebsite(url);
}
window.open(url, '_blank', 'noopener noreferrer');
}
}

View File

@@ -0,0 +1,78 @@
/**
* 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 { ISheetHyperLink } from '@univerjs/sheets-hyper-link';
import type * as React from 'react';
import { Disposable } from '@univerjs/core';
import { SheetHyperLinkType } from '@univerjs/sheets-hyper-link';
export interface ICustomHyperLinkFormProps {
linkId: string;
payload: string;
display: string;
getIsDisplaySyncedWithPayload: () => boolean;
showError: boolean;
setDisplaySyncedWithPayload: (synced: boolean) => void;
setDisplay: (display: string) => void;
setPayload: (payload: string) => void;
}
export interface ICustomHyperLinkView {
type: string;
option: {
label: string;
value: string;
};
Form: React.FC<ICustomHyperLinkFormProps>;
convert: (link: ISheetHyperLink) => { display: string; payload: string; type: string };
match: (link: ISheetHyperLink) => boolean;
}
export class SheetsHyperLinkSidePanelService extends Disposable {
private _customHyperLinks = new Map<string, ICustomHyperLinkView>();
isBuiltInLinkType(type: SheetHyperLinkType | string) {
return type !== SheetHyperLinkType.URL;
}
getOptions() {
return Array.from(this._customHyperLinks.values()).map(({ option }) => option);
}
findCustomHyperLink(link: ISheetHyperLink) {
const customLink = Array.from(this._customHyperLinks.values()).find((item) => item.match(link));
return customLink;
}
registerCustomHyperLink(customHyperLink: ICustomHyperLinkView) {
this._customHyperLinks.set(customHyperLink.type, customHyperLink);
}
getCustomHyperLink(type: string) {
return this._customHyperLinks.get(type);
}
removeCustomHyperLink(type: string) {
const { _customHyperLinks } = this;
_customHyperLinks.delete(type);
}
override dispose() {
super.dispose();
this._customHyperLinks.clear();
}
}

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_HYPER_LINK_UI_PLUGIN = 'SHEET_HYPER_LINK_UI_PLUGIN';

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.
*/
export enum HyperLinkEditSourceType {
EDITING = 'editing',
VIEWING = 'viewing',
}

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 { HyperLinkEditSourceType } from './edit-source';

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 { DataValidationType, IUniverInstanceService } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import { describe, expect, it } from 'vitest';
import { DisableLinkType, getShouldDisableCellLink, getShouldDisableCurrentCellLink, shouldDisableAddLink } from '../index';
function createAccessor(map: Array<[unknown, unknown]>) {
const tokenMap = new Map(map);
return {
get: (token: unknown) => tokenMap.get(token),
has: (token: unknown) => tokenMap.has(token),
} as any;
}
describe('hyper-link utils', () => {
it('should disable cell links for formulas, custom blocks and data validation rules', () => {
const worksheet = {
getCell: () => ({ f: '=SUM(A1:A2)' }),
getUnitId: () => 'unit-1',
getSheetId: () => 'sheet-1',
};
expect(getShouldDisableCellLink(createAccessor([]), worksheet as any, 0, 0)).toBe(DisableLinkType.DISABLED_BY_CELL);
const worksheetWithBlocks = {
...worksheet,
getCell: () => ({ p: { body: { customBlocks: [{}] } } }),
};
expect(getShouldDisableCellLink(createAccessor([]), worksheetWithBlocks as any, 0, 0)).toBe(DisableLinkType.DISABLED_BY_CELL);
const accessor = createAccessor([
[SheetDataValidationModel, { getRuleByLocation: () => ({ type: DataValidationType.CHECKBOX }) }],
]);
const plainWorksheet = {
...worksheet,
getCell: () => ({}),
};
expect(getShouldDisableCellLink(accessor, plainWorksheet as any, 1, 1)).toBe(true);
});
it('should allow editing-only links for drawing cells and inspect the current selection', () => {
const worksheet = {
getCell: () => ({ p: { drawingsOrder: ['drawing-1'] } }),
getUnitId: () => 'unit-1',
getSheetId: () => 'sheet-1',
};
expect(getShouldDisableCellLink(createAccessor([]), worksheet as any, 0, 0)).toBe(DisableLinkType.ALLOW_ON_EDITING);
const currentAccessor = createAccessor([
[IUniverInstanceService, { getCurrentUnitOfType: () => ({ getActiveSheet: () => worksheet }) }],
[SheetsSelectionsService, { getCurrentSelections: () => [{ range: { startRow: 0, startColumn: 0 } }] }],
]);
expect(getShouldDisableCurrentCellLink(currentAccessor)).toBe(false);
});
it('should disable add-link when editor selection is missing and allow it with valid rich text context', () => {
const noSelectionAccessor = createAccessor([
[DocSelectionManagerService, { getTextRanges: () => [] }],
[IUniverInstanceService, { getCurrentUnitOfType: () => null }],
]);
expect(shouldDisableAddLink(noSelectionAccessor)).toBe(true);
const validAccessor = createAccessor([
[DocSelectionManagerService, { getTextRanges: () => [{ collapsed: false, segmentId: 'body' }] }],
[IUniverInstanceService, {
getCurrentUnitOfType: () => ({
getSelfOrHeaderFooterModel: () => ({ getBody: () => ({ dataStream: 'hello\r\n' }) }),
}),
}],
]);
expect(shouldDisableAddLink(validAccessor)).toBe(false);
});
});

View File

@@ -0,0 +1,91 @@
/**
* 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, Workbook, Worksheet } from '@univerjs/core';
import { DataValidationType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { SheetDataValidationModel } from '@univerjs/sheets-data-validation';
export enum DisableLinkType {
ALLOWED = 0,
DISABLED_BY_CELL = 1,
ALLOW_ON_EDITING = 2,
}
const disables = new Set<string>([
DataValidationType.CHECKBOX,
DataValidationType.LIST,
DataValidationType.LIST_MULTIPLE,
]);
export const getShouldDisableCellLink = (accessor: IAccessor, worksheet: Worksheet, row: number, col: number) => {
const cell = worksheet.getCell(row, col);
if (cell?.f || cell?.si) {
return DisableLinkType.DISABLED_BY_CELL;
}
if (cell?.p?.body?.customBlocks?.length) {
return DisableLinkType.DISABLED_BY_CELL;
}
const dataValidationModel = accessor.has(SheetDataValidationModel) ? accessor.get(SheetDataValidationModel) : null;
const rule = dataValidationModel?.getRuleByLocation(worksheet.getUnitId(), worksheet.getSheetId(), row, col);
if (rule && disables.has(rule.type)) {
return true;
}
if (cell?.p?.drawingsOrder?.length) {
return DisableLinkType.ALLOW_ON_EDITING;
}
return DisableLinkType.ALLOWED;
};
export const getShouldDisableCurrentCellLink = (accessor: IAccessor) => {
const unit = accessor.get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!unit) {
return true;
}
const worksheet = unit.getActiveSheet();
const selections = accessor.get(SheetsSelectionsService).getCurrentSelections();
if (!selections.length) {
return true;
}
const row = selections[0].range.startRow;
const col = selections[0].range.startColumn;
return getShouldDisableCellLink(accessor, worksheet, row, col) === DisableLinkType.DISABLED_BY_CELL;
};
export const shouldDisableAddLink = (accessor: IAccessor) => {
const textSelectionService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const textRanges = textSelectionService.getTextRanges();
if (!textRanges?.length) {
return true;
}
const doc = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!doc || textRanges.every((range) => range.collapsed)) {
return true;
}
const body = doc.getSelfOrHeaderFooterModel(textRanges[0].segmentId)?.getBody();
if (!body) {
return true;
}
return false;
};

View File

@@ -0,0 +1,592 @@
/**
* 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, Nullable, Workbook } from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import type { LocaleKey } from '../locale/types';
import {
BuildTextUtils,
ColorKit,
CustomRangeType,
DataStreamTreeTokenType,
DisposableCollection,
generateRandomId,
ICommandService,
IUniverInstanceService,
LocaleService,
ThemeService,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { ActionRow, borderClassName, Button, clsx, FormLayout, Input, Select } from '@univerjs/design';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import {
deserializeRangeWithSheet,
IDefinedNamesService,
serializeRange,
serializeRangeWithSheet,
} from '@univerjs/engine-formula';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SetWorksheetActiveOperation, SheetsSelectionsService } from '@univerjs/sheets';
import { RangeSelector } from '@univerjs/sheets-formula-ui';
import {
AddHyperLinkCommand,
AddRichHyperLinkCommand,
SheetHyperLinkType,
SheetsHyperLinkParserService,
UpdateHyperLinkCommand,
UpdateRichHyperLinkCommand,
} from '@univerjs/sheets-hyper-link';
import { IEditorBridgeService, IMarkSelectionService, ScrollToRangeOperation } from '@univerjs/sheets-ui';
import { KeyCode, useDependency, useEvent, useObservable } from '@univerjs/ui';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CloseHyperLinkPopupOperation } from '../commands/operations/popup.operations';
import { isLegalLink, serializeUrl } from '../common/util';
import { ISheetsHyperLinkPopupService } from '../services/popup.service';
import { SheetsHyperLinkResolverService } from '../services/resolver.service';
import { SheetsHyperLinkSidePanelService } from '../services/side-panel.service';
import { HyperLinkEditSourceType } from '../types/enums/edit-source';
import { isBlankInput, resolveRangePayload } from './CellLinkEdit/utils';
export const CellLinkEdit = () => {
const [id, setId] = useState('');
const [display, setDisplay] = useState('');
const [showLabel, setShowLabel] = useState(true);
const [type, setType] = useState<SheetHyperLinkType | string>(SheetHyperLinkType.URL);
const [payload, setPayload] = useState('');
const localeService = useDependency(LocaleService);
const definedNameService = useDependency(IDefinedNamesService);
const editorBridgeService = useDependency(IEditorBridgeService);
const univerInstanceService = useDependency(IUniverInstanceService);
const popupService = useDependency(ISheetsHyperLinkPopupService);
const editing = useObservable(popupService.currentEditing$);
const parserService = useDependency(SheetsHyperLinkParserService);
const resolverService = useDependency(SheetsHyperLinkResolverService);
const commandService = useDependency(ICommandService);
const sidePanelService = useDependency(SheetsHyperLinkSidePanelService);
const sidePanelOptions = useMemo(() => sidePanelService.getOptions(), [sidePanelService]);
const renderManagerService = useDependency(IRenderManagerService);
const markSelectionService = useDependency(IMarkSelectionService);
const textSelectionService = useDependency(DocSelectionManagerService);
const themeService = useDependency(ThemeService);
const [selectorDialogVisible, setSelectorDialogVisible] = useState(false);
const sheetsSelectionService = useDependency(SheetsSelectionsService);
const selections = useMemo(() => sheetsSelectionService.getCurrentSelections(), []);
const customHyperLinkSidePanel = useMemo(() => {
if (sidePanelService.isBuiltInLinkType(type)) {
return;
}
return sidePanelService.getCustomHyperLink(type);
}, [sidePanelService, type]);
const [showError, setShowError] = useState(false);
const [isFocusRangeSelector, setIsFocusRangeSelector] = useState(false);
const isDisplaySyncedWithPayloadRef = useRef(false);
const getIsDisplaySyncedWithPayload = useEvent(() => isDisplaySyncedWithPayloadRef.current);
const setDisplaySyncedWithPayload = useEvent((synced: boolean) => {
isDisplaySyncedWithPayloadRef.current = synced;
});
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const subUnitId = workbook?.getActiveSheet().getSheetId() || '';
// to polyfill the display value on old version data
// case split tag is no longer needed
const setNormalizedDisplay = useCallback((value: string) => {
setDisplay(value.replaceAll(DataStreamTreeTokenType.CUSTOM_RANGE_START, '').replaceAll(DataStreamTreeTokenType.CUSTOM_RANGE_END, ''));
}, [setDisplay]);
useEffect(() => {
if (editing?.row !== undefined && editing.col !== undefined) {
const { customRange, row, col } = editing;
let { label } = editing;
if (typeof label === 'number') {
label = `${label}`;
}
let link;
if (customRange) {
link = {
id: customRange?.rangeId ?? '',
display: label ?? '',
payload: customRange?.properties?.url ?? '',
row,
column: col,
};
} else {
if (editing.type === HyperLinkEditSourceType.VIEWING) {
const workbook = univerInstanceService.getUnit<Workbook>(editing.unitId);
const worksheet = workbook?.getSheetBySheetId(editing.subUnitId);
const cell = worksheet?.getCellRaw(editing.row, editing.col);
const range = cell?.p?.body?.customRanges?.find((range) => range.rangeType === CustomRangeType.HYPERLINK && range.properties?.url);
const cellValue = cell?.v;
if (cell && (!BuildTextUtils.transform.isEmptyDocument(cell.p?.body?.dataStream) || Tools.isDefine(cellValue))) {
setShowLabel(false);
}
link = {
id: '',
display: '',
payload: range?.properties?.url ?? '',
row,
column: col,
};
} else {
const doc = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const currentSelection = textSelectionService.getActiveTextRange();
const body = doc?.getBody();
const selection = currentSelection && body ? currentSelection : null;
const customRange = selection && BuildTextUtils.customRange.getCustomRangesInterestsWithSelection(selection, body?.customRanges ?? [])?.[0];
setShowLabel(false);
link = {
id: '',
display: label ?? '',
payload: customRange?.properties?.url ?? '',
row,
column: col,
};
}
}
setId(link.id);
const customLink = sidePanelService.findCustomHyperLink(link);
if (customLink) {
const customLinkInfo = customLink.convert(link);
setType(customLinkInfo.type);
setPayload(customLinkInfo.payload);
setNormalizedDisplay(customLinkInfo.display);
return;
}
setNormalizedDisplay(link.display);
const linkInfo = parserService.parseHyperLink(link.payload);
setType(linkInfo.type === SheetHyperLinkType.INVALID ? SheetHyperLinkType.RANGE : linkInfo.type);
switch (linkInfo.type) {
case SheetHyperLinkType.URL: {
setPayload(linkInfo.url);
if (linkInfo.url === link.display) {
setDisplaySyncedWithPayload(true);
}
break;
}
case SheetHyperLinkType.RANGE: {
const params = linkInfo.searchObj!;
const sheetName = params.gid ?
univerInstanceService
.getUnit<Workbook>(editing.unitId)
?.getSheetBySheetId(params.gid)
?.getName()
?? ''
: '';
const payload = (serializeRangeWithSheet(sheetName, deserializeRangeWithSheet(params.range!).range));
setPayload(payload);
if (payload === link.display) {
setDisplaySyncedWithPayload(true);
}
break;
}
case SheetHyperLinkType.SHEET: {
const params = linkInfo.searchObj!;
setPayload(params.gid!);
break;
}
case SheetHyperLinkType.DEFINE_NAME: {
const params = linkInfo.searchObj!;
setPayload(params.rangeid!);
break;
}
default:
setPayload('');
break;
}
}
}, [editing, resolverService, sidePanelService, textSelectionService, univerInstanceService]);
useEffect(() => {
let id: Nullable<string> = null;
if (editing && !editing.customRangeId && editing.type === HyperLinkEditSourceType.VIEWING && Tools.isDefine(editing.row) && Tools.isDefine(editing.col)) {
const workbook = univerInstanceService.getUnit<Workbook>(editing.unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(editing.subUnitId);
const mergeInfo = worksheet?.getMergedCell(editing.row, editing.col);
const color = new ColorKit(themeService.getColorFromTheme('primary.600')).toRgb();
id = markSelectionService.addShape(
{
range: mergeInfo ?? {
startColumn: editing.col,
endColumn: editing.col,
startRow: editing.row,
endRow: editing.row,
},
style: {
// hasAutoFill: false,
fill: `rgb(${color.r}, ${color.g}, ${color.b}, 0.12)`,
strokeWidth: 1,
stroke: '#FFBD37',
widgets: {},
},
primary: null,
},
[],
-1
);
}
return () => {
if (id) {
markSelectionService.removeShape(id);
}
};
}, [editing, markSelectionService, themeService, univerInstanceService]);
useEffect(() => {
setIsFocusRangeSelector(type === SheetHyperLinkType.RANGE);
}, [type]);
useEffect(() => {
const render = renderManagerService.getRenderUnitById(editorBridgeService.getCurrentEditorId());
const disposeCollection = new DisposableCollection();
if (render) {
const selectionRenderService = render.with(DocSelectionRenderService);
selectionRenderService.setReserveRangesStatus(true);
disposeCollection.add(() => {
selectionRenderService.setReserveRangesStatus(false);
});
}
return () => {
editorBridgeService.disableForceKeepVisible();
disposeCollection.dispose();
};
}, [editorBridgeService, renderManagerService]);
useEffect(() => {
if (isFocusRangeSelector) {
popupService.setIsKeepVisible(isFocusRangeSelector);
}
popupService.setIsKeepVisible(selectorDialogVisible);
return () => {
popupService.setIsKeepVisible(false);
};
}, [isFocusRangeSelector, selectorDialogVisible, popupService]);
useEffect(() => {
if (isFocusRangeSelector) {
editorBridgeService.enableForceKeepVisible();
return () => {
editorBridgeService.disableForceKeepVisible();
};
}
}, [isFocusRangeSelector, editorBridgeService]);
const linkTypeOptions: Array<{
label: string;
value: SheetHyperLinkType | string;
}> = [
{
label: localeService.t<LocaleKey>('sheets-hyper-link-ui.form.link'),
value: SheetHyperLinkType.URL,
},
{
label: localeService.t<LocaleKey>('sheets-hyper-link-ui.form.range'),
value: SheetHyperLinkType.RANGE,
},
{
label: localeService.t<LocaleKey>('sheets-hyper-link-ui.form.worksheet'),
value: SheetHyperLinkType.SHEET,
},
{
label: localeService.t<LocaleKey>('sheets-hyper-link-ui.form.definedName'),
value: SheetHyperLinkType.DEFINE_NAME,
},
...sidePanelOptions,
];
const handleRangeChange = useEvent((rangeText: string) => {
const newPayload = resolveRangePayload(rangeText, workbook?.getActiveSheet()?.getName() || '');
if (!newPayload) {
setPayload('');
return;
}
setPayload(newPayload);
if (getIsDisplaySyncedWithPayload() || !display) {
setNormalizedDisplay(newPayload);
setDisplaySyncedWithPayload(true);
}
});
if (!workbook) {
return;
}
const hiddens = workbook.getHiddenWorksheets();
const sheetsOption = workbook.getSheets().map((sheet) => ({ label: sheet.getName(), value: sheet.getSheetId() })).filter((opt) => hiddens.indexOf(opt.value) === -1);
const definedNames = Object.values(definedNameService.getDefinedNameMap(workbook.getUnitId()) ?? {}).map((value) => ({
label: value.name,
value: value.id,
}));
const formatUrl = (type: SheetHyperLinkType | string, payload: string) => {
if (type === SheetHyperLinkType.URL) {
return serializeUrl(payload);
}
if (type === SheetHyperLinkType.RANGE) {
const info = deserializeRangeWithSheet(payload);
const worksheet = workbook.getSheetBySheetName(info.sheetName);
if (worksheet) {
return `#gid=${worksheet.getSheetId()}&range=${serializeRange(info.range)}`;
}
}
return `#${type}=${payload}`;
};
const handleSubmit = async () => {
if ((showLabel && isBlankInput(display)) || !payload || (type === SheetHyperLinkType.URL && !isLegalLink(payload))) {
setShowError(true);
return;
}
if (editing) {
if (id) {
const commandId = editing.type === HyperLinkEditSourceType.EDITING ? UpdateRichHyperLinkCommand.id : UpdateHyperLinkCommand.id;
await commandService.executeCommand(commandId, {
id,
unitId: editing.unitId,
subUnitId: editing.subUnitId,
payload: {
display: showLabel ? display : '',
payload: formatUrl(type, payload),
},
row: editing.row,
column: editing.col,
documentId: editorBridgeService.getCurrentEditorId(),
});
} else {
const commandId = editing.type === HyperLinkEditSourceType.EDITING ? AddRichHyperLinkCommand.id : AddHyperLinkCommand.id;
await commandService.executeCommand(commandId, {
unitId: editing.unitId,
subUnitId: editing.subUnitId,
link: {
id: generateRandomId(),
row: editing.row,
column: editing.col,
payload: formatUrl(type, payload),
display: showLabel ? display : '',
},
documentId: editorBridgeService.getCurrentEditorId(),
});
}
}
if (editing?.type === HyperLinkEditSourceType.VIEWING) {
await commandService.executeCommand(SetWorksheetActiveOperation.id, {
unitId: editing.unitId,
subUnitId: editing.subUnitId,
});
const GAP = 1;
await commandService.executeCommand(ScrollToRangeOperation.id, {
range: {
startRow: Math.max(editing.row - GAP, 0),
endRow: editing.row + GAP,
startColumn: Math.max(editing.col - GAP, 0),
endColumn: editing.col + GAP,
},
});
}
await commandService.executeCommand(CloseHyperLinkPopupOperation.id);
};
if (!editing) {
return null;
}
return (
<div
className={clsx(
`
univer-box-border univer-w-[296px] univer-rounded-xl univer-bg-gray-0 univer-p-4 univer-shadow-md
dark:!univer-bg-gray-900
`,
borderClassName
)}
>
{showLabel
? (
<FormLayout
label={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.label')}
error={showError && isBlankInput(display) ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.inputError') : ''}
>
<Input
value={display}
onChange={(v) => {
setNormalizedDisplay(v);
setDisplaySyncedWithPayload(false);
}}
placeholder={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.labelPlaceholder')}
autoFocus
onKeyDown={(e) => {
if (e.keyCode === KeyCode.ENTER) {
handleSubmit();
}
}}
/>
</FormLayout>
)
: null}
<FormLayout label={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.type')}>
<Select
className="univer-w-full"
options={linkTypeOptions}
value={type}
onChange={(newType) => {
setType(newType as SheetHyperLinkType);
setPayload('');
}}
/>
</FormLayout>
{type === SheetHyperLinkType.URL && (
<FormLayout
error={showError ? !payload ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.inputError') : !isLegalLink(payload) ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.linkError') : '' : ''}
>
<Input
dir="ltr"
value={payload}
onChange={(newLink) => {
setPayload(newLink);
if (newLink && (getIsDisplaySyncedWithPayload() || !display || display === newLink)) {
setNormalizedDisplay(newLink);
setDisplaySyncedWithPayload(true);
}
}}
placeholder={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.linkPlaceholder')}
autoFocus
onKeyDown={(e) => {
if (e.keyCode === KeyCode.ENTER) {
handleSubmit();
}
}}
/>
</FormLayout>
)}
{type === SheetHyperLinkType.RANGE && (
<FormLayout error={showError && !payload ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.inputError') : ''}>
<RangeSelector
unitId={workbook.getUnitId()}
subUnitId={subUnitId}
maxRangeCount={1}
supportAcrossSheet
initialValue={payload}
resetRange={selections as ISelectionWithStyle[]}
onChange={(_, text) => handleRangeChange(text)}
onRangeSelectorDialogVisibleChange={async (visible) => {
setSelectorDialogVisible(visible);
if (visible) {
if (editing.type !== HyperLinkEditSourceType.VIEWING) {
editorBridgeService.enableForceKeepVisible();
}
} else {
await resolverService.navigateToRange(editing.unitId, editing.subUnitId, { startRow: editing.row, endRow: editing.row, startColumn: editing.col, endColumn: editing.col }, true);
editorBridgeService.disableForceKeepVisible();
}
}}
onFocusChange={(focus) => setIsFocusRangeSelector(focus)}
/>
</FormLayout>
)}
{type === SheetHyperLinkType.SHEET && (
<FormLayout error={showError && !payload ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.selectError') : ''}>
<Select
className="univer-w-full"
options={sheetsOption}
value={payload}
onChange={(newPayload) => {
setPayload(newPayload);
const label = sheetsOption.find((i) => i.value === newPayload)?.label;
const oldLabel = sheetsOption.find((i) => i.value === payload)?.label;
if (label && (getIsDisplaySyncedWithPayload() || !display || display === oldLabel)) {
setNormalizedDisplay(label);
setDisplaySyncedWithPayload(true);
}
}}
/>
</FormLayout>
)}
{type === SheetHyperLinkType.DEFINE_NAME && (
<FormLayout error={showError && !payload ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.selectError') : ''}>
<Select
className="univer-w-full"
options={definedNames}
value={payload}
onChange={(newValue) => {
setPayload(newValue);
const label = definedNames.find((i) => i.value === newValue)?.label;
const oldLabel = definedNames.find((i) => i.value === payload)?.label;
if (label && (getIsDisplaySyncedWithPayload() || !display || display === oldLabel)) {
setNormalizedDisplay(label);
setDisplaySyncedWithPayload(true);
}
}}
/>
</FormLayout>
)}
{customHyperLinkSidePanel?.Form && (
<customHyperLinkSidePanel.Form
linkId={id}
payload={payload}
display={display}
showError={showError}
getIsDisplaySyncedWithPayload={getIsDisplaySyncedWithPayload}
setDisplaySyncedWithPayload={setDisplaySyncedWithPayload}
setDisplay={(newLink) => {
setNormalizedDisplay(newLink);
setDisplaySyncedWithPayload(true);
}}
setPayload={setPayload}
/>
)}
<ActionRow className="univer-flex univer-flex-row univer-justify-end univer-gap-2">
<Button
onClick={async () => {
if (editing) {
await resolverService.navigateToRange(editing.unitId, editing.subUnitId, { startRow: editing.row, endRow: editing.row, startColumn: editing.col, endColumn: editing.col }, true);
}
await commandService.executeCommand(CloseHyperLinkPopupOperation.id);
}}
>
{localeService.t<LocaleKey>('sheets-hyper-link-ui.form.cancel')}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
>
{localeService.t<LocaleKey>('sheets-hyper-link-ui.form.ok')}
</Button>
</ActionRow>
</div>
);
};
CellLinkEdit.componentKey = 'univer.sheet.cell-link-edit';

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 { describe, expect, it } from 'vitest';
import { isBlankInput, resolveRangePayload } from '../utils';
describe('CellLinkEdit utils', () => {
it('treats whitespace-only labels as blank input', () => {
expect(isBlankInput('')).toBe(true);
expect(isBlankInput(' ')).toBe(true);
expect(isBlankInput('\n\t')).toBe(true);
expect(isBlankInput(' label ')).toBe(false);
});
it('returns an empty payload for invalid range text', () => {
expect(resolveRangePayload('', 'Sheet1')).toBe('');
expect(resolveRangePayload('not-a-range', 'Sheet1')).toBe('');
});
it('normalizes valid range text and falls back to the active sheet name', () => {
expect(resolveRangePayload('A1:B2', 'Sheet1')).toBe('Sheet1!A1:B2');
expect(resolveRangePayload('Sheet2!A1:B2', 'Sheet1')).toBe('Sheet2!A1:B2');
});
});

View File

@@ -0,0 +1,36 @@
/**
* 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 { isValidRange } from '@univerjs/core';
import { deserializeRangeWithSheet, serializeRangeToRefString } from '@univerjs/engine-formula';
export function isBlankInput(value: string) {
return value.trim().length === 0;
}
export function resolveRangePayload(rangeText: string, defaultSheetName: string) {
const [range] = rangeText.split(',').map(deserializeRangeWithSheet);
if (!range || !isValidRange(range.range)) {
return '';
}
if (!range.sheetName) {
range.sheetName = defaultSheetName;
}
return serializeRangeToRefString(range);
}

View File

@@ -0,0 +1,225 @@
/**
* 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 { ICustomRange, Nullable, Workbook } from '@univerjs/core';
import type { LocaleKey } from '../locale/types';
import { ICommandService, IUniverInstanceService, LocaleService, UniverInstanceType } from '@univerjs/core';
import { borderClassName, Button, clsx, MessageType, Tooltip } from '@univerjs/design';
import { AllBorderIcon, CopyIcon, LinkIcon, SheetsMultiIcon, UnlinkIcon, WriteIcon } from '@univerjs/icons';
import {
CancelHyperLinkCommand,
CancelRichHyperLinkCommand,
SheetHyperLinkType,
SheetsHyperLinkParserService,
} from '@univerjs/sheets-hyper-link';
import { IEditorBridgeService } from '@univerjs/sheets-ui';
import { IMessageService, useDependency, useObservable } from '@univerjs/ui';
import { OpenHyperLinkEditPanelOperation } from '../commands/operations/popup.operations';
import { ISheetsHyperLinkPopupService } from '../services/popup.service';
import { SheetsHyperLinkResolverService } from '../services/resolver.service';
import { HyperLinkEditSourceType } from '../types/enums/edit-source';
const iconsMap = {
[SheetHyperLinkType.URL]: <LinkIcon />,
[SheetHyperLinkType.SHEET]: <SheetsMultiIcon className="univer-text-green-500" />,
[SheetHyperLinkType.RANGE]: <AllBorderIcon />,
[SheetHyperLinkType.DEFINE_NAME]: <AllBorderIcon />,
[SheetHyperLinkType.INVALID]: <AllBorderIcon />,
};
interface ICellLinkPopupPureProps {
customRange?: Nullable<ICustomRange>;
row: number;
col: number;
unitId: string;
subUnitId: string;
editPermission?: boolean;
copyPermission?: boolean;
type: HyperLinkEditSourceType;
}
export const CellLinkPopupPure = (props: ICellLinkPopupPureProps) => {
const popupService = useDependency(ISheetsHyperLinkPopupService);
const commandService = useDependency(ICommandService);
const messageService = useDependency(IMessageService);
const localeService = useDependency(LocaleService);
const resolverService = useDependency(SheetsHyperLinkResolverService);
const editorBridgeService = useDependency(IEditorBridgeService);
const parserHyperLinkService = useDependency(SheetsHyperLinkParserService);
const { customRange, row, col, unitId, subUnitId, editPermission, copyPermission, type } = props;
if (!customRange?.properties?.url) {
return null;
}
const linkObj = parserHyperLinkService.parseHyperLink(customRange.properties.url ?? '');
const isError = linkObj.type === SheetHyperLinkType.INVALID;
return (
<div
className={clsx(`
univer-mb-1 univer-flex univer-max-w-80 univer-flex-row univer-items-center univer-justify-between
univer-gap-2 univer-overflow-hidden univer-rounded-lg univer-bg-gray-0 univer-p-2 univer-shadow-md
dark:!univer-bg-gray-900
`, borderClassName)}
onClick={() => popupService.hideCurrentPopup()}
>
<div
className={clsx(`
univer-flex univer-h-6 univer-flex-1 univer-cursor-pointer univer-flex-row univer-items-center
univer-gap-1 univer-truncate univer-text-sm univer-leading-5 univer-text-primary-600
`, { 'univer-text-red-500': isError })}
onClick={() => {
if (isError) {
return;
}
resolverService.navigate(linkObj);
}}
>
<div
className={`
univer-flex univer-size-5 univer-flex-none univer-items-center univer-justify-center
univer-text-base univer-text-gray-900
dark:!univer-text-gray-0
`}
>
{iconsMap[linkObj.type]}
</div>
<Tooltip showIfEllipsis title={linkObj.name} asChild>
<span className="univer-flex-1 univer-truncate">{linkObj.name}</span>
</Tooltip>
</div>
<div
className={`
univer-flex univer-h-6 univer-flex-none univer-flex-row univer-items-center univer-justify-center
univer-gap-2
`}
>
<Button
type="button"
variant="text"
size="small"
data-u-comp="cell-link-popup-copy"
aria-label={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.copy')}
className={clsx(`
!univer-flex univer-size-6 !univer-rounded !univer-border-0 !univer-p-0 !univer-text-base
!univer-font-normal
disabled:!univer-pointer-events-auto
`, { 'univer-text-red-500': isError })}
disabled={!copyPermission || isError}
onClick={async () => {
if (linkObj.type !== SheetHyperLinkType.URL) {
const url = new URL(window.location.href);
url.hash = linkObj.url.slice(1);
await navigator.clipboard.writeText(url.href);
} else {
await navigator.clipboard.writeText(linkObj.url);
}
messageService.show({
content: localeService.t<LocaleKey>('sheets-hyper-link-ui.message.coped'),
type: MessageType.Info,
});
}}
>
<Tooltip placement="bottom" title={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.copy')}>
<CopyIcon className="dark:!univer-text-gray-0" />
</Tooltip>
</Button>
{editPermission && (
<>
<div
data-u-comp="cell-link-popup-edit"
className={`
univer-flex univer-size-6 univer-cursor-pointer univer-flex-row univer-items-center
univer-justify-center univer-rounded univer-text-base
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
`}
onClick={() => {
return commandService.executeCommand(OpenHyperLinkEditPanelOperation.id, {
unitId,
subUnitId,
row,
col,
customRangeId: customRange.rangeId,
type,
});
}}
>
<Tooltip placement="bottom" title={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.edit')}>
<WriteIcon className="dark:!univer-text-gray-0" />
</Tooltip>
</div>
<div
data-u-comp="cell-link-popup-remove"
className={`
univer-flex univer-size-6 univer-cursor-pointer univer-flex-row univer-items-center
univer-justify-center univer-rounded univer-text-base
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-700
`}
onClick={() => {
const commandId = type === HyperLinkEditSourceType.EDITING ? CancelRichHyperLinkCommand.id : CancelHyperLinkCommand.id;
if (commandService.syncExecuteCommand(commandId, {
unitId,
subUnitId,
id: customRange.rangeId,
row,
column: col,
documentId: editorBridgeService.getCurrentEditorId(),
})) {
popupService.hideCurrentPopup(undefined, true);
}
}}
>
<Tooltip placement="bottom" title={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.cancel')}>
<UnlinkIcon className="dark:!univer-text-gray-0" />
</Tooltip>
</div>
</>
)}
</div>
</div>
);
};
export const CellLinkPopup = () => {
const popupService = useDependency(ISheetsHyperLinkPopupService);
const currentPopup = useObservable(popupService.currentPopup$, popupService.currentPopup);
const univerInstanceService = useDependency(IUniverInstanceService);
if (!currentPopup) {
return null;
}
if (currentPopup.showAll) {
const workbook = univerInstanceService.getUnit<Workbook>(currentPopup.unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(currentPopup.subUnitId);
const cell = worksheet?.getCell(currentPopup.row, currentPopup.col);
const customRanges = cell?.p?.body?.customRanges;
return customRanges?.length
? (
<div>
{customRanges.map((customRange) => {
return <CellLinkPopupPure key={customRange.rangeId} {...currentPopup} customRange={customRange} />;
})}
</div>
)
: null;
}
return <CellLinkPopupPure {...currentPopup} />;
};
CellLinkPopup.componentKey = 'univer.sheet.cell-link-popup';

View File

@@ -0,0 +1,589 @@
/**
* 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, Nullable, Workbook } from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import type { LocaleKey } from '../locale/types';
import {
BuildTextUtils,
ColorKit,
CustomRangeType,
DataStreamTreeTokenType,
DisposableCollection,
generateRandomId,
ICommandService,
IUniverInstanceService,
LocaleService,
ThemeService,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { Button, FormLayout, Input, MobileActionRowGroup, MobileSelect } from '@univerjs/design';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import {
deserializeRangeWithSheet,
IDefinedNamesService,
serializeRange,
serializeRangeWithSheet,
} from '@univerjs/engine-formula';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SetWorksheetActiveOperation, SheetsSelectionsService } from '@univerjs/sheets';
import { MobileRangeSelector } from '@univerjs/sheets-formula-ui';
import {
AddHyperLinkCommand,
AddRichHyperLinkCommand,
SheetHyperLinkType,
SheetsHyperLinkParserService,
UpdateHyperLinkCommand,
UpdateRichHyperLinkCommand,
} from '@univerjs/sheets-hyper-link';
import { IEditorBridgeService, IMarkSelectionService, ScrollToRangeOperation } from '@univerjs/sheets-ui';
import { KeyCode, useDependency, useEvent, useObservable } from '@univerjs/ui';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CloseHyperLinkPopupOperation } from '../commands/operations/popup.operations';
import { isLegalLink, serializeUrl } from '../common/util';
import { ISheetsHyperLinkPopupService } from '../services/popup.service';
import { SheetsHyperLinkResolverService } from '../services/resolver.service';
import { SheetsHyperLinkSidePanelService } from '../services/side-panel.service';
import { HyperLinkEditSourceType } from '../types/enums/edit-source';
import { isBlankInput, resolveRangePayload } from './CellLinkEdit/utils';
export const MobileCellLinkEdit = () => {
const [id, setId] = useState('');
const [display, setDisplay] = useState('');
const [showLabel, setShowLabel] = useState(true);
const [type, setType] = useState<SheetHyperLinkType | string>(SheetHyperLinkType.URL);
const [payload, setPayload] = useState('');
const localeService = useDependency(LocaleService);
const definedNameService = useDependency(IDefinedNamesService);
const editorBridgeService = useDependency(IEditorBridgeService);
const univerInstanceService = useDependency(IUniverInstanceService);
const popupService = useDependency(ISheetsHyperLinkPopupService);
const editing = useObservable(popupService.currentEditing$);
const parserService = useDependency(SheetsHyperLinkParserService);
const resolverService = useDependency(SheetsHyperLinkResolverService);
const commandService = useDependency(ICommandService);
const sidePanelService = useDependency(SheetsHyperLinkSidePanelService);
const sidePanelOptions = useMemo(() => sidePanelService.getOptions(), [sidePanelService]);
const renderManagerService = useDependency(IRenderManagerService);
const markSelectionService = useDependency(IMarkSelectionService);
const textSelectionService = useDependency(DocSelectionManagerService);
const themeService = useDependency(ThemeService);
const [selectorDialogVisible, setSelectorDialogVisible] = useState(false);
const sheetsSelectionService = useDependency(SheetsSelectionsService);
const selections = useMemo(() => sheetsSelectionService.getCurrentSelections(), []);
const customHyperLinkSidePanel = useMemo(() => {
if (sidePanelService.isBuiltInLinkType(type)) {
return;
}
return sidePanelService.getCustomHyperLink(type);
}, [sidePanelService, type]);
const [showError, setShowError] = useState(false);
const [isFocusRangeSelector, setIsFocusRangeSelector] = useState(false);
const isDisplaySyncedWithPayloadRef = useRef(false);
const getIsDisplaySyncedWithPayload = useEvent(() => isDisplaySyncedWithPayloadRef.current);
const setDisplaySyncedWithPayload = useEvent((synced: boolean) => {
isDisplaySyncedWithPayloadRef.current = synced;
});
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const subUnitId = workbook?.getActiveSheet().getSheetId() || '';
// to polyfill the display value on old version data
// case split tag is no longer needed
const setNormalizedDisplay = useCallback((value: string) => {
setDisplay(value.replaceAll(DataStreamTreeTokenType.CUSTOM_RANGE_START, '').replaceAll(DataStreamTreeTokenType.CUSTOM_RANGE_END, ''));
}, [setDisplay]);
useEffect(() => {
if (editing?.row !== undefined && editing.col !== undefined) {
const { customRange, row, col } = editing;
let { label } = editing;
if (typeof label === 'number') {
label = `${label}`;
}
let link;
if (customRange) {
link = {
id: customRange?.rangeId ?? '',
display: label ?? '',
payload: customRange?.properties?.url ?? '',
row,
column: col,
};
} else {
if (editing.type === HyperLinkEditSourceType.VIEWING) {
const workbook = univerInstanceService.getUnit<Workbook>(editing.unitId);
const worksheet = workbook?.getSheetBySheetId(editing.subUnitId);
const cell = worksheet?.getCellRaw(editing.row, editing.col);
const range = cell?.p?.body?.customRanges?.find((range) => range.rangeType === CustomRangeType.HYPERLINK && range.properties?.url);
const cellValue = cell?.v;
if (cell && (!BuildTextUtils.transform.isEmptyDocument(cell.p?.body?.dataStream) || Tools.isDefine(cellValue))) {
setShowLabel(false);
}
link = {
id: '',
display: '',
payload: range?.properties?.url ?? '',
row,
column: col,
};
} else {
const doc = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const currentSelection = textSelectionService.getActiveTextRange();
const body = doc?.getBody();
const selection = currentSelection && body ? currentSelection : null;
const customRange = selection && BuildTextUtils.customRange.getCustomRangesInterestsWithSelection(selection, body?.customRanges ?? [])?.[0];
setShowLabel(false);
link = {
id: '',
display: label ?? '',
payload: customRange?.properties?.url ?? '',
row,
column: col,
};
}
}
setId(link.id);
const customLink = sidePanelService.findCustomHyperLink(link);
if (customLink) {
const customLinkInfo = customLink.convert(link);
setType(customLinkInfo.type);
setPayload(customLinkInfo.payload);
setNormalizedDisplay(customLinkInfo.display);
return;
}
setNormalizedDisplay(link.display);
const linkInfo = parserService.parseHyperLink(link.payload);
setType(linkInfo.type === SheetHyperLinkType.INVALID ? SheetHyperLinkType.RANGE : linkInfo.type);
switch (linkInfo.type) {
case SheetHyperLinkType.URL: {
setPayload(linkInfo.url);
if (linkInfo.url === link.display) {
setDisplaySyncedWithPayload(true);
}
break;
}
case SheetHyperLinkType.RANGE: {
const params = linkInfo.searchObj!;
const sheetName = params.gid ?
univerInstanceService
.getUnit<Workbook>(editing.unitId)
?.getSheetBySheetId(params.gid)
?.getName()
?? ''
: '';
const payload = (serializeRangeWithSheet(sheetName, deserializeRangeWithSheet(params.range!).range));
setPayload(payload);
if (payload === link.display) {
setDisplaySyncedWithPayload(true);
}
break;
}
case SheetHyperLinkType.SHEET: {
const params = linkInfo.searchObj!;
setPayload(params.gid!);
break;
}
case SheetHyperLinkType.DEFINE_NAME: {
const params = linkInfo.searchObj!;
setPayload(params.rangeid!);
break;
}
default:
setPayload('');
break;
}
}
}, [editing, resolverService, sidePanelService, textSelectionService, univerInstanceService]);
useEffect(() => {
let id: Nullable<string> = null;
if (editing && !editing.customRangeId && editing.type === HyperLinkEditSourceType.VIEWING && Tools.isDefine(editing.row) && Tools.isDefine(editing.col)) {
const workbook = univerInstanceService.getUnit<Workbook>(editing.unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(editing.subUnitId);
const mergeInfo = worksheet?.getMergedCell(editing.row, editing.col);
const color = new ColorKit(themeService.getColorFromTheme('primary.600')).toRgb();
id = markSelectionService.addShape(
{
range: mergeInfo ?? {
startColumn: editing.col,
endColumn: editing.col,
startRow: editing.row,
endRow: editing.row,
},
style: {
// hasAutoFill: false,
fill: `rgb(${color.r}, ${color.g}, ${color.b}, 0.12)`,
strokeWidth: 1,
stroke: '#FFBD37',
widgets: {},
},
primary: null,
},
[],
-1
);
}
return () => {
if (id) {
markSelectionService.removeShape(id);
}
};
}, [editing, markSelectionService, themeService, univerInstanceService]);
useEffect(() => {
setIsFocusRangeSelector(type === SheetHyperLinkType.RANGE);
}, [type]);
useEffect(() => {
const render = renderManagerService.getRenderUnitById(editorBridgeService.getCurrentEditorId());
const disposeCollection = new DisposableCollection();
if (render) {
const selectionRenderService = render.with(DocSelectionRenderService);
selectionRenderService.setReserveRangesStatus(true);
disposeCollection.add(() => {
selectionRenderService.setReserveRangesStatus(false);
});
}
return () => {
editorBridgeService.disableForceKeepVisible();
disposeCollection.dispose();
};
}, [editorBridgeService, renderManagerService]);
useEffect(() => {
if (isFocusRangeSelector) {
popupService.setIsKeepVisible(isFocusRangeSelector);
}
popupService.setIsKeepVisible(selectorDialogVisible);
return () => {
popupService.setIsKeepVisible(false);
};
}, [isFocusRangeSelector, selectorDialogVisible, popupService]);
useEffect(() => {
if (isFocusRangeSelector) {
editorBridgeService.enableForceKeepVisible();
return () => {
editorBridgeService.disableForceKeepVisible();
};
}
}, [isFocusRangeSelector, editorBridgeService]);
const linkTypeOptions: Array<{
label: string;
value: SheetHyperLinkType | string;
}> = [
{
label: localeService.t<LocaleKey>('sheets-hyper-link-ui.form.link'),
value: SheetHyperLinkType.URL,
},
{
label: localeService.t<LocaleKey>('sheets-hyper-link-ui.form.range'),
value: SheetHyperLinkType.RANGE,
},
{
label: localeService.t<LocaleKey>('sheets-hyper-link-ui.form.worksheet'),
value: SheetHyperLinkType.SHEET,
},
{
label: localeService.t<LocaleKey>('sheets-hyper-link-ui.form.definedName'),
value: SheetHyperLinkType.DEFINE_NAME,
},
...sidePanelOptions,
];
const handleRangeChange = useEvent((rangeText: string) => {
const newPayload = resolveRangePayload(rangeText, workbook?.getActiveSheet()?.getName() || '');
if (!newPayload) {
setPayload('');
return;
}
setPayload(newPayload);
if (getIsDisplaySyncedWithPayload() || !display) {
setNormalizedDisplay(newPayload);
setDisplaySyncedWithPayload(true);
}
});
if (!workbook) {
return;
}
const hiddens = workbook.getHiddenWorksheets();
const sheetsOption = workbook.getSheets().map((sheet) => ({ label: sheet.getName(), value: sheet.getSheetId() })).filter((opt) => hiddens.indexOf(opt.value) === -1);
const definedNames = Object.values(definedNameService.getDefinedNameMap(workbook.getUnitId()) ?? {}).map((value) => ({
label: value.name,
value: value.id,
}));
const formatUrl = (type: SheetHyperLinkType | string, payload: string) => {
if (type === SheetHyperLinkType.URL) {
return serializeUrl(payload);
}
if (type === SheetHyperLinkType.RANGE) {
const info = deserializeRangeWithSheet(payload);
const worksheet = workbook.getSheetBySheetName(info.sheetName);
if (worksheet) {
return `#gid=${worksheet.getSheetId()}&range=${serializeRange(info.range)}`;
}
}
return `#${type}=${payload}`;
};
const handleSubmit = async () => {
if ((showLabel && isBlankInput(display)) || !payload || (type === SheetHyperLinkType.URL && !isLegalLink(payload))) {
setShowError(true);
return;
}
if (editing) {
if (id) {
const commandId = editing.type === HyperLinkEditSourceType.EDITING ? UpdateRichHyperLinkCommand.id : UpdateHyperLinkCommand.id;
await commandService.executeCommand(commandId, {
id,
unitId: editing.unitId,
subUnitId: editing.subUnitId,
payload: {
display: showLabel ? display : '',
payload: formatUrl(type, payload),
},
row: editing.row,
column: editing.col,
documentId: editorBridgeService.getCurrentEditorId(),
});
} else {
const commandId = editing.type === HyperLinkEditSourceType.EDITING ? AddRichHyperLinkCommand.id : AddHyperLinkCommand.id;
await commandService.executeCommand(commandId, {
unitId: editing.unitId,
subUnitId: editing.subUnitId,
link: {
id: generateRandomId(),
row: editing.row,
column: editing.col,
payload: formatUrl(type, payload),
display: showLabel ? display : '',
},
documentId: editorBridgeService.getCurrentEditorId(),
});
}
}
if (editing?.type === HyperLinkEditSourceType.VIEWING) {
await commandService.executeCommand(SetWorksheetActiveOperation.id, {
unitId: editing.unitId,
subUnitId: editing.subUnitId,
});
const GAP = 1;
await commandService.executeCommand(ScrollToRangeOperation.id, {
range: {
startRow: Math.max(editing.row - GAP, 0),
endRow: editing.row + GAP,
startColumn: Math.max(editing.col - GAP, 0),
endColumn: editing.col + GAP,
},
});
}
await commandService.executeCommand(CloseHyperLinkPopupOperation.id);
};
if (!editing) {
return null;
}
return (
<div
className="
univer-box-border univer-w-full univer-bg-gray-0 univer-p-0
dark:!univer-bg-gray-900
"
>
{showLabel
? (
<FormLayout
label={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.label')}
error={showError && isBlankInput(display) ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.inputError') : ''}
>
<Input
value={display}
onChange={(v) => {
setNormalizedDisplay(v);
setDisplaySyncedWithPayload(false);
}}
placeholder={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.labelPlaceholder')}
autoFocus
onKeyDown={(e) => {
if (e.keyCode === KeyCode.ENTER) {
handleSubmit();
}
}}
/>
</FormLayout>
)
: null}
<FormLayout label={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.type')}>
<MobileSelect
className="univer-w-full"
options={linkTypeOptions}
value={type}
onChange={(newType) => {
setType(newType as SheetHyperLinkType);
setPayload('');
}}
/>
</FormLayout>
{type === SheetHyperLinkType.URL && (
<FormLayout
error={showError ? !payload ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.inputError') : !isLegalLink(payload) ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.linkError') : '' : ''}
>
<Input
dir="ltr"
value={payload}
onChange={(newLink) => {
setPayload(newLink);
if (newLink && (getIsDisplaySyncedWithPayload() || !display || display === newLink)) {
setNormalizedDisplay(newLink);
setDisplaySyncedWithPayload(true);
}
}}
placeholder={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.linkPlaceholder')}
autoFocus
onKeyDown={(e) => {
if (e.keyCode === KeyCode.ENTER) {
handleSubmit();
}
}}
/>
</FormLayout>
)}
{type === SheetHyperLinkType.RANGE && (
<FormLayout error={showError && !payload ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.inputError') : ''}>
<MobileRangeSelector
unitId={workbook.getUnitId()}
subUnitId={subUnitId}
maxRangeCount={1}
supportAcrossSheet
initialValue={payload}
resetRange={selections as ISelectionWithStyle[]}
onChange={(_, text) => handleRangeChange(text)}
onRangeSelectorDialogVisibleChange={async (visible) => {
setSelectorDialogVisible(visible);
if (visible) {
if (editing.type !== HyperLinkEditSourceType.VIEWING) {
editorBridgeService.enableForceKeepVisible();
}
} else {
await resolverService.navigateToRange(editing.unitId, editing.subUnitId, { startRow: editing.row, endRow: editing.row, startColumn: editing.col, endColumn: editing.col }, true);
editorBridgeService.disableForceKeepVisible();
}
}}
onFocusChange={(focus) => setIsFocusRangeSelector(focus)}
/>
</FormLayout>
)}
{type === SheetHyperLinkType.SHEET && (
<FormLayout error={showError && !payload ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.selectError') : ''}>
<MobileSelect
className="univer-w-full"
options={sheetsOption}
value={payload}
onChange={(newPayload) => {
setPayload(newPayload);
const label = sheetsOption.find((i) => i.value === newPayload)?.label;
const oldLabel = sheetsOption.find((i) => i.value === payload)?.label;
if (label && (getIsDisplaySyncedWithPayload() || !display || display === oldLabel)) {
setNormalizedDisplay(label);
setDisplaySyncedWithPayload(true);
}
}}
/>
</FormLayout>
)}
{type === SheetHyperLinkType.DEFINE_NAME && (
<FormLayout error={showError && !payload ? localeService.t<LocaleKey>('sheets-hyper-link-ui.form.selectError') : ''}>
<MobileSelect
className="univer-w-full"
options={definedNames}
value={payload}
onChange={(newValue) => {
setPayload(newValue);
const label = definedNames.find((i) => i.value === newValue)?.label;
const oldLabel = definedNames.find((i) => i.value === payload)?.label;
if (label && (getIsDisplaySyncedWithPayload() || !display || display === oldLabel)) {
setNormalizedDisplay(label);
setDisplaySyncedWithPayload(true);
}
}}
/>
</FormLayout>
)}
{customHyperLinkSidePanel?.Form && (
<customHyperLinkSidePanel.Form
linkId={id}
payload={payload}
display={display}
showError={showError}
getIsDisplaySyncedWithPayload={getIsDisplaySyncedWithPayload}
setDisplaySyncedWithPayload={setDisplaySyncedWithPayload}
setDisplay={(newLink) => {
setNormalizedDisplay(newLink);
setDisplaySyncedWithPayload(true);
}}
setPayload={setPayload}
/>
)}
<MobileActionRowGroup className="univer-mt-5">
<Button
onClick={async () => {
if (editing) {
await resolverService.navigateToRange(editing.unitId, editing.subUnitId, { startRow: editing.row, endRow: editing.row, startColumn: editing.col, endColumn: editing.col }, true);
}
await commandService.executeCommand(CloseHyperLinkPopupOperation.id);
}}
>
{localeService.t<LocaleKey>('sheets-hyper-link-ui.form.cancel')}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
>
{localeService.t<LocaleKey>('sheets-hyper-link-ui.form.ok')}
</Button>
</MobileActionRowGroup>
</div>
);
};
MobileCellLinkEdit.componentKey = 'univer.sheet.mobile-cell-link-edit';

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 { ICustomRange, Nullable, Workbook } from '@univerjs/core';
import type { LocaleKey } from '../locale/types';
import { ICommandService, IUniverInstanceService, LocaleService, UniverInstanceType } from '@univerjs/core';
import { MessageType, MobileActionRow } from '@univerjs/design';
import {
CancelHyperLinkCommand,
CancelRichHyperLinkCommand,
SheetHyperLinkType,
SheetsHyperLinkParserService,
} from '@univerjs/sheets-hyper-link';
import { IEditorBridgeService } from '@univerjs/sheets-ui';
import { IMessageService, useDependency, useObservable } from '@univerjs/ui';
import { OpenHyperLinkEditPanelOperation } from '../commands/operations/popup.operations';
import { ISheetsHyperLinkPopupService } from '../services/popup.service';
import { SheetsHyperLinkResolverService } from '../services/resolver.service';
import { HyperLinkEditSourceType } from '../types/enums/edit-source';
interface IMobileCellLinkPopupPureProps {
customRange?: Nullable<ICustomRange>;
row: number;
col: number;
unitId: string;
subUnitId: string;
editPermission?: boolean;
copyPermission?: boolean;
type: HyperLinkEditSourceType;
}
export function MobileCellLinkPopupPure(props: IMobileCellLinkPopupPureProps) {
const popupService = useDependency(ISheetsHyperLinkPopupService);
const commandService = useDependency(ICommandService);
const messageService = useDependency(IMessageService);
const localeService = useDependency(LocaleService);
const resolverService = useDependency(SheetsHyperLinkResolverService);
const editorBridgeService = useDependency(IEditorBridgeService);
const parserHyperLinkService = useDependency(SheetsHyperLinkParserService);
const { customRange, row, col, unitId, subUnitId, editPermission, copyPermission, type } = props;
if (!customRange?.properties?.url) {
return null;
}
const linkObj = parserHyperLinkService.parseHyperLink(customRange.properties.url);
const isError = linkObj.type === SheetHyperLinkType.INVALID;
const close = () => popupService.hideCurrentPopup(undefined, true);
return (
<div className="univer-flex univer-flex-col univer-gap-2">
<MobileActionRow
title={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.open')}
value={linkObj.name}
valueType="text"
aria-label={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.open')}
variant="subtle"
disabled={isError}
onClick={() => {
resolverService.navigate(linkObj);
close();
}}
/>
{copyPermission && (
<MobileActionRow
title={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.copy')}
aria-label={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.copy')}
variant="subtle"
disabled={isError}
onClick={() => {
let url = linkObj.url;
if (linkObj.type !== SheetHyperLinkType.URL) {
const currentUrl = new URL(window.location.href);
currentUrl.hash = linkObj.url.slice(1);
url = currentUrl.href;
}
return navigator.clipboard.writeText(url).then(() => {
messageService.show({
content: localeService.t<LocaleKey>('sheets-hyper-link-ui.message.coped'),
type: MessageType.Info,
});
close();
});
}}
/>
)}
{editPermission && (
<>
<MobileActionRow
title={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.edit')}
aria-label={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.edit')}
variant="subtle"
onClick={() => {
close();
return commandService.executeCommand(OpenHyperLinkEditPanelOperation.id, {
unitId,
subUnitId,
row,
col,
customRangeId: customRange.rangeId,
type,
});
}}
/>
<MobileActionRow
title={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.cancel')}
aria-label={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.cancel')}
variant="subtle"
onClick={() => {
const commandId = type === HyperLinkEditSourceType.EDITING
? CancelRichHyperLinkCommand.id
: CancelHyperLinkCommand.id;
if (commandService.syncExecuteCommand(commandId, {
unitId,
subUnitId,
id: customRange.rangeId,
row,
column: col,
documentId: editorBridgeService.getCurrentEditorId(),
})) {
close();
}
}}
/>
</>
)}
</div>
);
}
export function MobileCellLinkPopup() {
const popupService = useDependency(ISheetsHyperLinkPopupService);
const currentPopup = useObservable(popupService.currentPopup$, popupService.currentPopup);
const univerInstanceService = useDependency(IUniverInstanceService);
if (!currentPopup) {
return null;
}
if (currentPopup.showAll) {
const workbook = univerInstanceService.getUnit<Workbook>(currentPopup.unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(currentPopup.subUnitId);
const cell = worksheet?.getCell(currentPopup.row, currentPopup.col);
const customRanges = cell?.p?.body?.customRanges;
return customRanges?.length
? (
<div>
{customRanges.map((customRange) => (
<MobileCellLinkPopupPure
key={customRange.rangeId}
{...currentPopup}
customRange={customRange}
/>
))}
</div>
)
: null;
}
return <MobileCellLinkPopupPure {...currentPopup} />;
}
MobileCellLinkPopup.componentKey = 'univer.sheet.mobile-cell-link-popup';

View File

@@ -0,0 +1,629 @@
/**
* 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 { ICustomRange, IWorkbookData, Workbook } from '@univerjs/core';
import type { Root } from 'react-dom/client';
import {
CellValueType,
CustomRangeType,
ICommandService,
IUniverInstanceService,
LocaleService,
LocaleType,
toDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { IDefinedNamesService } from '@univerjs/engine-formula';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
SetRangeValuesMutation,
SetWorksheetActiveOperation,
SheetInterceptorService,
SheetsSelectionsService,
} from '@univerjs/sheets';
import {
AddHyperLinkCommand,
AddHyperLinkMutation,
CancelHyperLinkCommand,
HyperLinkModel,
RemoveHyperLinkMutation,
SheetHyperLinkType,
SheetsHyperLinkParserService,
UpdateHyperLinkMutation,
} from '@univerjs/sheets-hyper-link';
import {
IEditorBridgeService,
IMarkSelectionService,
ScrollToRangeOperation,
SheetCanvasPopManagerService,
} from '@univerjs/sheets-ui';
import { IDialogService, IMessageService, RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { of } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { CloseHyperLinkPopupOperation, OpenHyperLinkEditPanelOperation } from '../../commands/operations/popup.operations';
import { ISheetsHyperLinkPopupService, SheetsHyperLinkPopupService } from '../../services/popup.service';
import { SheetsHyperLinkResolverService } from '../../services/resolver.service';
import { SheetsHyperLinkSidePanelService } from '../../services/side-panel.service';
import { HyperLinkEditSourceType } from '../../types/enums/edit-source';
import { CellLinkEdit } from '../CellLinkEdit';
import { CellLinkPopupPure } from '../CellLinkPopup';
import { MobileCellLinkEdit } from '../MobileCellLinkEdit';
import { MobileCellLinkPopupPure } from '../MobileCellLinkPopup';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const UNIT_ID = 'sheet-link-popup-workbook';
const SUB_UNIT_ID = 'sheet-1';
class TestCanvasPopManagerService {
attachPopupToCell() {
return toDisposable(() => undefined);
}
attachPopupByPosition() {
return toDisposable(() => undefined);
}
attachPopupToAbsolutePosition() {
return toDisposable(() => undefined);
}
}
class TestRenderManagerService {
enableScrollRender = false;
readonly scrollRanges: unknown[] = [];
getRenderUnitById(id: string) {
if (id !== UNIT_ID || !this.enableScrollRender) {
return undefined;
}
return {
with: () => ({
scrollToRange: (range: unknown) => {
this.scrollRanges.push(range);
return true;
},
}),
};
}
}
class TestMarkSelectionService {
readonly shapes = new Map<string, unknown>();
addShape(selection: unknown) {
const id = `shape-${this.shapes.size + 1}`;
this.shapes.set(id, selection);
return id;
}
addShapeWithNoFresh(selection: unknown) {
return this.addShape(selection);
}
removeShape(id: string) {
this.shapes.delete(id);
}
removeAllShapes() {
this.shapes.clear();
}
refreshShapes() {}
getShapeMap() {
return this.shapes;
}
}
class TestEditorBridgeService {
forceKeepVisible = false;
getCurrentEditorId() {
return 'sheet-popup-editor';
}
isVisible() {
return { visible: false };
}
getEditCellState() {
return null;
}
enableForceKeepVisible() {
this.forceKeepVisible = true;
}
disableForceKeepVisible() {
this.forceKeepVisible = false;
}
}
class TestDefinedNamesService {
getValueById() {
return undefined;
}
getDefinedNameMap() {
return {};
}
getWorksheetByRef() {
return undefined;
}
focusRange() {
// Defined-name navigation is outside these popup component scenarios.
}
}
class TestMessageService {
readonly messages: unknown[] = [];
show(message: unknown) {
this.messages.push(message);
}
}
class TestDialogService {
open() {
return toDisposable(() => undefined);
}
close(): void {}
closeAll(): void {}
getDialogs$() {
return of([]);
}
}
function createCellDocument(text: string, range: ICustomRange) {
return {
id: `${range.rangeId}-doc`,
documentStyle: {
pageSize: { width: 100, height: 40 },
marginTop: 0,
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
},
body: {
dataStream: `${text}\r\n`,
customRanges: [range],
},
};
}
function createWorkbookData(customRange: ICustomRange): IWorkbookData {
return {
id: UNIT_ID,
appVersion: '3.0.0-alpha',
name: 'hyperlink popup workbook',
locale: LocaleType.EN_US,
sheetOrder: [SUB_UNIT_ID],
styles: {},
sheets: {
[SUB_UNIT_ID]: {
id: SUB_UNIT_ID,
name: 'Sheet 1',
cellData: {
0: {
0: {
p: createCellDocument('Univer', customRange),
t: CellValueType.STRING,
},
},
},
},
},
};
}
function createHyperLinkRange(): ICustomRange {
return {
rangeId: 'link-range',
rangeType: CustomRangeType.HYPERLINK,
startIndex: 0,
endIndex: 5,
properties: {
url: 'https://univer.ai',
},
};
}
function createPopupTestBed() {
const customRange = createHyperLinkRange();
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([SheetInterceptorService]);
injector.add([HyperLinkModel]);
injector.add([DocSelectionManagerService]);
injector.add([IDefinedNamesService, { useClass: TestDefinedNamesService as never }]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([IMessageService, { useClass: TestMessageService as never }]);
injector.add([IDialogService, { useClass: TestDialogService }]);
injector.add([IEditorBridgeService, { useClass: TestEditorBridgeService as never }]);
injector.add([IMarkSelectionService, { useClass: TestMarkSelectionService as never }]);
injector.add([SheetCanvasPopManagerService, { useClass: TestCanvasPopManagerService as never }]);
injector.add([SheetsSelectionsService]);
injector.add([SheetsHyperLinkParserService]);
injector.add([SheetsHyperLinkResolverService]);
injector.add([ISheetsHyperLinkPopupService, { useClass: SheetsHyperLinkPopupService }]);
injector.add([SheetsHyperLinkSidePanelService]);
const workbook = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, createWorkbookData(customRange));
injector.get(IUniverInstanceService).focusUnit(UNIT_ID);
injector.get(LocaleService).load({
[LocaleType.EN_US]: {
'sheets-hyper-link-ui': {
popup: {
open: 'Open link',
edit: 'Edit',
cancel: 'Remove link',
},
form: {
label: 'Display text',
labelPlaceholder: 'Text',
type: 'Link type',
link: 'Web link',
range: 'Range',
worksheet: 'Worksheet',
definedName: 'Defined name',
linkPlaceholder: 'URL',
inputError: 'Required',
linkError: 'Invalid link',
selectError: 'Select a target',
cancel: 'Cancel',
ok: 'OK',
},
message: {
coped: 'Copied',
},
},
'sheets-hyper-link': {
message: {
refError: 'Invalid reference',
},
},
},
});
const commandService = injector.get(ICommandService);
commandService.registerCommand(SetRangeValuesMutation);
commandService.registerCommand(AddHyperLinkMutation);
commandService.registerCommand(UpdateHyperLinkMutation);
commandService.registerCommand(RemoveHyperLinkMutation);
commandService.registerCommand(AddHyperLinkCommand);
commandService.registerCommand(CancelHyperLinkCommand);
commandService.registerCommand(OpenHyperLinkEditPanelOperation);
commandService.registerCommand(CloseHyperLinkPopupOperation);
commandService.registerCommand(SetWorksheetActiveOperation);
commandService.registerCommand(ScrollToRangeOperation);
return {
univer,
injector,
commandService,
workbook,
customRange,
};
}
function inputText(input: HTMLInputElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
async function selectOption(trigger: Element, optionText: string) {
trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }));
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
const option = Array.from(document.querySelectorAll('button, [data-slot="dropdown-menu-radio-item"]')).find((item) => item.textContent === optionText);
if (!option) {
throw new Error(`Option ${optionText} was not rendered`);
}
option.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
function renderPopup(
root: Root,
container: HTMLDivElement,
testBed: ReturnType<typeof createPopupTestBed>
) {
act(() => {
root.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
<CellLinkPopupPure
unitId={UNIT_ID}
subUnitId={SUB_UNIT_ID}
row={0}
col={0}
customRange={testBed.customRange}
type={HyperLinkEditSourceType.VIEWING}
editPermission
copyPermission={false}
/>
</RediContext.Provider>
);
});
return {
copy: container.querySelector<HTMLButtonElement>('[data-u-comp="cell-link-popup-copy"]'),
edit: container.querySelector('[data-u-comp="cell-link-popup-edit"]') as HTMLElement,
remove: container.querySelector('[data-u-comp="cell-link-popup-remove"]') as HTMLElement,
};
}
describe('CellLinkPopupPure', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createPopupTestBed> | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('opens the edit panel for the selected sheet hyperlink range', async () => {
currentTestBed = createPopupTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const executedCommands: Array<{ id: string; params: unknown }> = [];
currentTestBed.commandService.onCommandExecuted((command) => {
executedCommands.push({ id: command.id, params: command.params });
});
const actions = renderPopup(root, container, currentTestBed);
expect(actions.copy?.disabled).toBe(true);
await act(async () => {
actions.edit.click();
await Promise.resolve();
});
expect(executedCommands).toContainEqual({
id: OpenHyperLinkEditPanelOperation.id,
params: {
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
row: 0,
col: 0,
customRangeId: 'link-range',
type: HyperLinkEditSourceType.VIEWING,
},
});
});
it('labels the mobile navigation action and shows the hyperlink name', () => {
currentTestBed = createPopupTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
currentTestBed.injector.get(LocaleService).setLocale(LocaleType.EN_US);
act(() => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<MobileCellLinkPopupPure
unitId={UNIT_ID}
subUnitId={SUB_UNIT_ID}
row={0}
col={0}
customRange={currentTestBed!.customRange}
type={HyperLinkEditSourceType.VIEWING}
/>
</RediContext.Provider>
);
});
const openLinkButton = container.querySelector('button');
expect(openLinkButton?.textContent).toContain('Open link');
expect(openLinkButton?.textContent).toContain('https://univer.ai');
});
it('removes the hyperlink custom range from the sheet cell', () => {
currentTestBed = createPopupTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const actions = renderPopup(root, container, currentTestBed);
act(() => {
actions.remove.click();
});
const cell = currentTestBed.workbook.getSheetBySheetId(SUB_UNIT_ID)?.getCellRaw(0, 0);
expect(cell?.p?.body?.customRanges?.some((range) => range.rangeId === 'link-range')).toBe(false);
expect(cell?.p?.body?.dataStream).toBe('Univer\r\n');
});
});
describe('CellLinkEdit', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createPopupTestBed> | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('inherits the page direction for labels and uses left-to-right direction for URLs', () => {
currentTestBed = createPopupTestBed();
container = document.createElement('div');
container.dir = 'rtl';
document.body.appendChild(container);
root = createRoot(container);
const popupService = currentTestBed.injector.get(ISheetsHyperLinkPopupService);
act(() => {
popupService.startAddEditing({
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
row: 1,
col: 0,
type: HyperLinkEditSourceType.VIEWING,
});
});
for (const LinkEdit of [CellLinkEdit, MobileCellLinkEdit]) {
act(() => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<LinkEdit />
</RediContext.Provider>
);
});
const inputs = Array.from(container.querySelectorAll('input'));
expect(inputs).toHaveLength(2);
expect(inputs[0].hasAttribute('dir')).toBe(false);
expect(inputs[1].dir).toBe('ltr');
}
});
it('adds a URL hyperlink to the current cell text and closes the popup', async () => {
currentTestBed = createPopupTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const popupService = currentTestBed.injector.get(ISheetsHyperLinkPopupService);
const renderManagerService = currentTestBed.injector.get(IRenderManagerService) as unknown as TestRenderManagerService;
act(() => {
popupService.startAddEditing({
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
row: 0,
col: 0,
type: HyperLinkEditSourceType.VIEWING,
});
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<CellLinkEdit />
</RediContext.Provider>
);
});
const inputs = Array.from(container.querySelectorAll('input'));
expect(inputs).toHaveLength(1);
await act(async () => {
inputText(inputs[0], 'docs.univer.ai');
await Promise.resolve();
});
renderManagerService.enableScrollRender = true;
const formButtons = Array.from(container.querySelectorAll<HTMLButtonElement>('[data-u-comp="button"]'));
const okButton = formButtons[formButtons.length - 1];
expect(okButton).toBeDefined();
await act(async () => {
okButton!.click();
await Promise.resolve();
});
const cell = currentTestBed.workbook.getSheetBySheetId(SUB_UNIT_ID)?.getCellRaw(0, 0);
const body = cell?.p?.body;
const linkRange = body?.customRanges?.find((range) => range.rangeType === CustomRangeType.HYPERLINK);
expect(body?.dataStream).toBe('Univer\r\n');
expect(linkRange?.properties?.url).toBe('http://docs.univer.ai');
expect(popupService.currentEditing).toBeNull();
});
it('switches from URL to worksheet link and saves the selected sheet target', async () => {
currentTestBed = createPopupTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const popupService = currentTestBed.injector.get(ISheetsHyperLinkPopupService);
const renderManagerService = currentTestBed.injector.get(IRenderManagerService) as unknown as TestRenderManagerService;
act(() => {
popupService.startAddEditing({
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
row: 0,
col: 0,
type: HyperLinkEditSourceType.VIEWING,
});
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<CellLinkEdit />
</RediContext.Provider>
);
});
const typeSelect = container.querySelector('[data-u-comp="select"]');
expect(typeSelect?.textContent).toContain('sheets-hyper-link-ui.form.link');
await act(async () => {
await selectOption(typeSelect!, 'sheets-hyper-link-ui.form.worksheet');
await Promise.resolve();
});
const selects = Array.from(container.querySelectorAll('[data-u-comp="select"]'));
expect(selects).toHaveLength(2);
await act(async () => {
await selectOption(selects[1], 'Sheet 1');
await Promise.resolve();
});
renderManagerService.enableScrollRender = true;
const formButtons = Array.from(container.querySelectorAll<HTMLButtonElement>('[data-u-comp="button"]'));
const okButton = formButtons[formButtons.length - 1];
await act(async () => {
okButton!.click();
await Promise.resolve();
});
const cell = currentTestBed.workbook.getSheetBySheetId(SUB_UNIT_ID)?.getCellRaw(0, 0);
const body = cell?.p?.body;
const sheetLink = body?.customRanges?.find((range) => range.properties?.url === `#${SheetHyperLinkType.SHEET}=${SUB_UNIT_ID}`);
expect(body?.dataStream).toBe('Univer\r\n');
expect(sheetLink?.rangeType).toBe(CustomRangeType.HYPERLINK);
expect(popupService.currentEditing).toBeNull();
});
});

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();