feat(docs): polish docs table rendering and menus (#6926)

This commit is contained in:
Univer
2026-05-27 17:35:37 +08:00
committed by GitHub
parent 390c54b85b
commit e156edcc6a
162 changed files with 11227 additions and 902 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

@@ -0,0 +1,28 @@
/**
* 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 { getEmptySnapshot } from '../empty-snapshot';
describe('getEmptySnapshot', () => {
it('uses comfortable paragraph spacing defaults for new docs', () => {
expect(getEmptySnapshot().body?.paragraphs?.[0].paragraphStyle).toEqual({
spaceAbove: { v: 0 },
lineSpacing: 1.5,
spaceBelow: { v: 8 },
});
});
});
@@ -35,6 +35,10 @@ describe('document data-model utils', () => {
it: BooleanNumber.FALSE,
bl: BooleanNumber.FALSE,
});
const fontStackStyle = getFontStyleString({
ff: '"SF Mono", "Cascadia Code", Consolas, monospace',
fs: 12,
});
expect(defaultStyle).toEqual({
fontCache: `${DEFAULT_STYLES.fs}pt ${DEFAULT_STYLES.ff}`,
@@ -48,6 +52,8 @@ describe('document data-model utils', () => {
expect(superscriptStyle.fontSize).toBeCloseTo(7.8);
expect(superscriptStyle.fontString).toContain(DEFAULT_FONTFACE_PLANE);
expect(normalStyle.fontCache).toBe('normal normal 14pt "Ping Fang"');
expect(fontStackStyle.fontFamily).toBe('"SF Mono", "Cascadia Code", Consolas, monospace');
expect(fontStackStyle.fontString).toContain('"SF Mono", "Cascadia Code", Consolas, monospace');
});
it('should expose baseline offsets and convert text rotation', () => {
@@ -16,6 +16,7 @@
import type { IDocumentData } from '../../types/interfaces';
import { generateRandomId } from '../../shared/tools';
import { DEFAULT_DOCUMENT_PARAGRAPH_LINE_SPACING, DEFAULT_DOCUMENT_PARAGRAPH_SPACE_ABOVE, DEFAULT_DOCUMENT_PARAGRAPH_SPACE_BELOW, MODERN_DOCUMENT_WIDTH, ModernDocumentWidthMode } from '../../types/const';
import { BooleanNumber } from '../../types/enum';
import { LocaleType } from '../../types/enum/locale-type';
import { DocumentFlavor } from '../../types/interfaces';
@@ -43,9 +44,9 @@ export function getEmptySnapshot(
{
startIndex: 0,
paragraphStyle: {
spaceAbove: { v: 5 },
lineSpacing: 1,
spaceBelow: { v: 0 },
spaceAbove: { v: DEFAULT_DOCUMENT_PARAGRAPH_SPACE_ABOVE },
lineSpacing: DEFAULT_DOCUMENT_PARAGRAPH_LINE_SPACING,
spaceBelow: { v: DEFAULT_DOCUMENT_PARAGRAPH_SPACE_BELOW },
},
},
],
@@ -57,10 +58,10 @@ export function getEmptySnapshot(
},
documentStyle: {
pageSize: {
width: 595 / 0.75,
width: MODERN_DOCUMENT_WIDTH[ModernDocumentWidthMode.MEDIUM],
height: 842 / 0.75,
},
documentFlavor: DocumentFlavor.TRADITIONAL,
documentFlavor: DocumentFlavor.MODERN,
marginTop: 50,
marginBottom: 50,
marginRight: 50,
@@ -0,0 +1,82 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentBody } from '../../../../types/interfaces';
import { describe, expect, it } from 'vitest';
import { DataStreamTreeTokenType } from '../../types';
import { deleteBlockRanges, insertBlockRanges } from '../apply-utils/common';
import { getPlainText } from '../build-utils/parse';
import { getBodySlice } from '../utils';
describe('document block ranges', () => {
it('moves following block ranges when text is inserted before them', () => {
const body: IDocumentBody = {
dataStream: 'A\rB\r\n',
blockRanges: [{ blockId: 'callout-1', blockType: 'callout', startIndex: 2, endIndex: 3 }],
};
insertBlockRanges(body, { dataStream: 'XX' }, 2, 1);
expect(body.blockRanges).toEqual([
{ blockId: 'callout-1', blockType: 'callout', startIndex: 4, endIndex: 5 },
]);
});
it('expands containing block ranges when text is inserted inside them', () => {
const body: IDocumentBody = {
dataStream: `${DataStreamTreeTokenType.BLOCK_START}A\r${DataStreamTreeTokenType.BLOCK_END}\n`,
blockRanges: [{ blockId: 'callout-1', blockType: 'callout', startIndex: 0, endIndex: 3 }],
};
insertBlockRanges(body, { dataStream: 'XX' }, 2, 2);
expect(body.blockRanges).toEqual([
{ blockId: 'callout-1', blockType: 'callout', startIndex: 0, endIndex: 5 },
]);
});
it('cuts block ranges to the copied body with relative indexes', () => {
const body: IDocumentBody = {
dataStream: `A\r${DataStreamTreeTokenType.BLOCK_START}B\r${DataStreamTreeTokenType.BLOCK_END}C\r\n`,
blockRanges: [{ blockId: 'callout-1', blockType: 'callout', startIndex: 2, endIndex: 5 }],
};
const slice = getBodySlice(body, 2, 6);
expect(slice.dataStream).toBe(`${DataStreamTreeTokenType.BLOCK_START}B\r${DataStreamTreeTokenType.BLOCK_END}`);
expect(slice.blockRanges).toEqual([
{ blockId: 'callout-1', blockType: 'callout', startIndex: 0, endIndex: 3 },
]);
});
it('removes fully deleted block ranges', () => {
const body: IDocumentBody = {
dataStream: `${DataStreamTreeTokenType.BLOCK_START}A\r${DataStreamTreeTokenType.BLOCK_END}\n`,
blockRanges: [{ blockId: 'callout-1', blockType: 'callout', startIndex: 0, endIndex: 3 }],
};
const removed = deleteBlockRanges(body, 4, 0);
expect(removed).toEqual([{ blockId: 'callout-1', blockType: 'callout', startIndex: 0, endIndex: 3 }]);
expect(body.blockRanges).toEqual([]);
});
it('removes block tokens from plain text', () => {
const stream = `${DataStreamTreeTokenType.BLOCK_START}Callout\r${DataStreamTreeTokenType.BLOCK_END}\n`;
expect(getPlainText(stream)).toBe('Callout');
});
});
@@ -20,6 +20,7 @@ import type {
ICustomDecoration,
ICustomRange,
ICustomTable,
IDocumentBlockRange,
IDocumentBody,
IParagraph,
ISectionBreak,
@@ -315,6 +316,41 @@ export function insertTables(body: IDocumentBody, insertBody: IDocumentBody, tex
}
}
export function insertBlockRanges(body: IDocumentBody, insertBody: IDocumentBody, textLength: number, currentIndex: number) {
if (!body.blockRanges && !insertBody.blockRanges?.length) {
return;
}
if (!body.blockRanges) {
body.blockRanges = [];
}
const { blockRanges } = body;
for (let i = 0, len = blockRanges.length; i < len; i++) {
const blockRange = blockRanges[i];
const { startIndex, endIndex } = blockRange;
if (startIndex >= currentIndex) {
blockRange.startIndex += textLength;
blockRange.endIndex += textLength;
} else if (endIndex >= currentIndex) {
blockRange.endIndex += textLength;
}
}
const insertBlockRanges = insertBody.blockRanges;
if (insertBlockRanges) {
for (let i = 0, len = insertBlockRanges.length; i < len; i++) {
const blockRange = insertBlockRanges[i];
blockRange.startIndex += currentIndex;
blockRange.endIndex += currentIndex;
}
blockRanges.push(...insertBlockRanges);
blockRanges.sort(sortRulesFactory('startIndex'));
}
}
export function sliceByParagraph(body: IDocumentBody) {
const { dataStream, paragraphs = [] } = body;
const ranges = [];
@@ -873,6 +909,44 @@ export function deleteCustomRanges(body: IDocumentBody, textLength: number, curr
return removeCustomRanges;
}
export function deleteBlockRanges(body: IDocumentBody, textLength: number, currentIndex: number) {
const { blockRanges } = body;
const startIndex = currentIndex;
const endIndex = currentIndex + textLength - 1;
const removeBlockRanges: IDocumentBlockRange[] = [];
if (blockRanges) {
const newBlockRanges = [];
for (let i = 0, len = blockRanges.length; i < len; i++) {
const blockRange = blockRanges[i];
const { startIndex: st, endIndex: ed } = blockRange;
if (st >= startIndex && ed <= endIndex) {
removeBlockRanges.push(blockRange);
continue;
} else if (Math.max(startIndex, st) <= Math.min(endIndex, ed)) {
const segments = horizontalLineSegmentsSubtraction(st, ed, startIndex, endIndex);
if (segments.length === 0) {
removeBlockRanges.push(blockRange);
continue;
}
blockRange.startIndex = segments[0];
blockRange.endIndex = segments[1];
} else if (endIndex < st) {
blockRange.startIndex -= textLength;
blockRange.endIndex -= textLength;
}
newBlockRanges.push(blockRange);
}
body.blockRanges = newBlockRanges;
}
return removeBlockRanges;
}
export function deleteCustomDecorations(body: IDocumentBody, textLength: number, currentIndex: number, needOffset = true) {
const { customDecorations } = body;
@@ -17,6 +17,7 @@
import type { IDocumentBody } from '../../../../types/interfaces';
import { deleteContent } from '../../../../shared';
import {
deleteBlockRanges,
deleteCustomBlocks,
deleteCustomDecorations,
deleteCustomRanges,
@@ -42,6 +43,8 @@ export function updateAttributeByDelete(body: IDocumentBody, textLength: number,
const removeTables = deleteTables(body, textLength, currentIndex);
const removeBlockRanges = deleteBlockRanges(body, textLength, currentIndex);
const removeCustomRanges = deleteCustomRanges(body, textLength, currentIndex);
const removeCustomDecorations = deleteCustomDecorations(body, textLength, currentIndex);
@@ -59,6 +62,7 @@ export function updateAttributeByDelete(body: IDocumentBody, textLength: number,
sectionBreaks: removeSectionBreaks,
customBlocks: removeCustomBlocks,
tables: removeTables,
blockRanges: removeBlockRanges,
customRanges: removeCustomRanges,
customDecorations: removeCustomDecorations,
};
@@ -17,6 +17,7 @@
import type { IDocumentBody } from '../../../../types/interfaces';
import { insertTextToContent } from '../../../../shared';
import {
insertBlockRanges,
insertCustomBlocks,
insertCustomDecorations,
insertCustomRanges,
@@ -44,6 +45,8 @@ export function updateAttributeByInsert(
insertTables(body, insertBody, textLength, currentIndex);
insertBlockRanges(body, insertBody, textLength, currentIndex);
insertCustomRanges(body, insertBody, textLength, currentIndex);
insertCustomDecorations(body, insertBody, textLength, currentIndex);
@@ -20,11 +20,13 @@ import { Tools, UpdateDocsAttributeType } from '../../../../shared';
import { CustomDecorationType } from '../../../../types/interfaces';
import { PresetListType } from '../../preset-list-type';
import {
deleteBlockRanges,
deleteCustomBlocks,
deleteParagraphs,
deleteSectionBreaks,
deleteTables,
deleteTextRuns,
insertBlockRanges,
insertCustomBlocks,
insertParagraphs,
insertSectionBreaks,
@@ -49,6 +51,7 @@ export function updateAttribute(
const removeSectionBreaks = updateSectionBreaks(body, updateBody, textLength, currentIndex, coverType);
const removeCustomBlocks = updateCustomBlocks(body, updateBody, textLength, currentIndex, coverType);
const removeTables = updateTables(body, updateBody, textLength, currentIndex, coverType);
const removeBlockRanges = updateBlockRanges(body, updateBody, textLength, currentIndex, coverType);
const removeCustomRanges = updateCustomRanges(body, updateBody, textLength, currentIndex, coverType);
const removeCustomDecorations = updateCustomDecorations(body, updateBody, textLength, currentIndex, coverType);
@@ -59,6 +62,7 @@ export function updateAttribute(
sectionBreaks: removeSectionBreaks,
customBlocks: removeCustomBlocks,
tables: removeTables,
blockRanges: removeBlockRanges,
customRanges: removeCustomRanges,
customDecorations: removeCustomDecorations,
};
@@ -433,6 +437,33 @@ function updateTables(
return removeTables;
}
function updateBlockRanges(
body: IDocumentBody,
updateBody: IDocumentBody,
textLength: number,
currentIndex: number,
coverType: UpdateDocsAttributeType
) {
const { blockRanges } = body;
const { blockRanges: updateDataBlockRanges } = updateBody;
if (blockRanges == null || updateDataBlockRanges == null) {
return;
}
const removeBlockRanges = deleteBlockRanges(body, textLength, currentIndex);
if (coverType !== UpdateDocsAttributeType.REPLACE) {
updateBody.blockRanges = updateDataBlockRanges.map((updateBlockRange) => {
const removeBlockRange = removeBlockRanges.find((blockRange) => blockRange.blockId === updateBlockRange.blockId);
return removeBlockRange ? { ...removeBlockRange, ...updateBlockRange } : updateBlockRange;
});
}
insertBlockRanges(body, updateBody, textLength, currentIndex);
return removeBlockRanges;
}
// retain
function updateCustomRanges(
body: IDocumentBody,
@@ -28,6 +28,8 @@ const tags = [
DataStreamTreeTokenType.TABLE_CELL_END, // table start
DataStreamTreeTokenType.TABLE_ROW_END, // table start
DataStreamTreeTokenType.TABLE_END, // table end
DataStreamTreeTokenType.BLOCK_START, // block start
DataStreamTreeTokenType.BLOCK_END, // block end
// DataStreamTreeTokenType.COLUMN_BREAK, // column break
// DataStreamTreeTokenType.PAGE_BREAK, // page break
// DataStreamTreeTokenType.DOCS_END, // document end
@@ -36,8 +38,8 @@ const tags = [
];
export const getPlainText = (dataStream: string) => {
const text = dataStream.endsWith('\r\n') ? dataStream.slice(0, -2) : dataStream;
return tags.reduce((res, curr) => res.replaceAll(curr, ''), text);
const text = tags.reduce((res, curr) => res.replaceAll(curr, ''), dataStream);
return text.endsWith('\r\n') ? text.slice(0, -2) : text;
};
export const isEmptyDocument = (dataStream?: string) => {
@@ -17,7 +17,7 @@
/* eslint-disable no-param-reassign */
import type { Nullable } from '../../../shared';
import type { ICustomDecoration, ICustomRange, IDocumentBody, IParagraph, IParagraphStyle, ITextRun, ITextStyle } from '../../../types/interfaces';
import type { ICustomDecoration, ICustomRange, IDocumentBlockRange, IDocumentBody, IParagraph, IParagraphStyle, ITextRun, ITextStyle } from '../../../types/interfaces';
import type { IRetainAction } from './action-types';
import { Tools, UpdateDocsAttributeType } from '../../../shared';
import { CustomDecorationType } from '../../../types/interfaces';
@@ -422,12 +422,14 @@ export function transformBody(
const {
textRuns: thisTextRuns,
paragraphs: thisParagraphs = [],
blockRanges: thisBlockRanges = [],
customRanges: thisCustomRanges,
customDecorations: thisCustomDecorations,
} = thisBody;
const {
textRuns: otherTextRuns,
paragraphs: otherParagraphs = [],
blockRanges: otherBlockRanges = [],
customRanges: otherCustomRanges,
customDecorations: otherCustomDecorations,
} = otherBody;
@@ -521,8 +523,28 @@ export function transformBody(
retBody.paragraphs = paragraphs;
}
const blockRanges = transformBlockRanges(thisBlockRanges, otherBlockRanges, priority);
if (blockRanges.length) {
retBody.blockRanges = blockRanges;
}
return {
coverType,
body: retBody,
};
}
function transformBlockRanges(thisBlockRanges: IDocumentBlockRange[], otherBlockRanges: IDocumentBlockRange[], priority: boolean): IDocumentBlockRange[] {
if (!thisBlockRanges.length) {
return otherBlockRanges;
}
if (!otherBlockRanges.length) {
return [];
}
return otherBlockRanges.map((otherBlockRange) => {
const thisBlockRange = thisBlockRanges.find((blockRange) => blockRange.blockId === otherBlockRange.blockId);
return thisBlockRange && priority ? Tools.deepMerge(otherBlockRange, thisBlockRange) : otherBlockRange;
});
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { ICustomBlock, ICustomDecoration, ICustomRange, IDocumentBody, IParagraph, ISectionBreak, ITextRun } from '../../../types/interfaces/i-document-data';
import type { ICustomBlock, ICustomDecoration, ICustomRange, IDocumentBlockRange, IDocumentBody, IParagraph, ISectionBreak, ITextRun } from '../../../types/interfaces/i-document-data';
import type { IRetainAction } from './action-types';
import { UpdateDocsAttributeType } from '../../../shared/command-enum';
import { Tools } from '../../../shared/tools';
@@ -103,6 +103,30 @@ export function getTableSlice(
return newTables;
}
export function getBlockRangeSlice(
body: IDocumentBody,
startOffset: number,
endOffset: number
) {
const { blockRanges = [] } = body;
const newBlockRanges: IDocumentBlockRange[] = [];
for (const blockRange of blockRanges) {
const clonedBlockRange = Tools.deepClone(blockRange);
const { startIndex, endIndex } = clonedBlockRange;
if (startIndex >= startOffset && endIndex < endOffset) {
newBlockRanges.push({
...clonedBlockRange,
startIndex: startIndex - startOffset,
endIndex: endIndex - startOffset,
});
}
}
return newBlockRanges;
}
export function getParagraphsSlice(
body: IDocumentBody,
startOffset: number,
@@ -193,6 +217,11 @@ export function getBodySlice(
docBody.tables = newTables;
}
const newBlockRanges = getBlockRangeSlice(body, startOffset, endOffset);
if (newBlockRanges.length) {
docBody.blockRanges = newBlockRanges;
}
docBody.paragraphs = getParagraphsSlice(body, startOffset, endOffset);
if (type === SliceBodyType.cut) {
@@ -216,7 +245,7 @@ export function getBodySlice(
}
export function normalizeBody(body: IDocumentBody): IDocumentBody {
const { dataStream, textRuns, paragraphs, customRanges, customDecorations, tables } = body;
const { dataStream, textRuns, paragraphs, customRanges, customDecorations, tables, blockRanges } = body;
let leftOffset = 0;
let rightOffset = 0;
@@ -274,6 +303,7 @@ export function normalizeBody(body: IDocumentBody): IDocumentBody {
customRanges,
customDecorations,
tables,
blockRanges,
};
}
@@ -409,12 +439,14 @@ export function composeBody(
paragraphs: thisParagraphs = [],
customRanges: thisCustomRanges,
customDecorations: thisCustomDecorations = [],
blockRanges: thisBlockRanges = [],
} = thisBody;
const {
textRuns: otherTextRuns,
paragraphs: otherParagraphs = [],
customRanges: otherCustomRanges,
customDecorations: otherCustomDecorations = [],
blockRanges: otherBlockRanges = [],
} = otherBody;
retBody.textRuns = composeTextRuns(otherTextRuns, thisTextRuns, coverType);
@@ -463,9 +495,32 @@ export function composeBody(
retBody.paragraphs = paragraphs;
}
const blockRanges = composeDocumentBlockRanges(thisBlockRanges, otherBlockRanges);
if (blockRanges.length) {
retBody.blockRanges = blockRanges;
}
return retBody;
}
function composeDocumentBlockRanges(
thisRanges: IDocumentBlockRange[],
otherRanges: IDocumentBlockRange[]
): IDocumentBlockRange[] {
if (!thisRanges.length) {
return otherRanges;
}
if (!otherRanges.length) {
return thisRanges;
}
const byId = new Map(thisRanges.map((range) => [range.blockId, Tools.deepClone(range)]));
otherRanges.forEach((range) => byId.set(range.blockId, Tools.deepClone(range)));
return Array.from(byId.values()).sort((left, right) => left.startIndex - right.startIndex);
}
export function isUselessRetainAction(action: IRetainAction): boolean {
const { body } = action;
@@ -473,9 +528,9 @@ export function isUselessRetainAction(action: IRetainAction): boolean {
return true;
}
const { textRuns, paragraphs, customRanges, customBlocks, customDecorations, tables } = body;
const { textRuns, paragraphs, customRanges, customBlocks, customDecorations, tables, blockRanges } = body;
if (textRuns == null && paragraphs == null && customRanges == null && customBlocks == null && customDecorations == null && tables == null) {
if (textRuns == null && paragraphs == null && customRanges == null && customBlocks == null && customDecorations == null && tables == null && blockRanges == null) {
return true;
}
@@ -24,6 +24,7 @@ export enum DataStreamTreeNodeType {
TABLE = 'TABLE',
TABLE_ROW = 'TABLE_ROW',
TABLE_CELL = 'TABLE_CELL',
BLOCK = 'BLOCK',
CUSTOM_BLOCK = 'CUSTOM_BLOCK', // \b images, mentions, etc. that do not participate in document flow
}
@@ -37,6 +38,9 @@ export enum DataStreamTreeTokenType {
TABLE_CELL_END = '\x1D', // table cell end
TABLE_ROW_END = '\x0E', // table row end
TABLE_END = '\x0F', // table end
// block.
BLOCK_START = '\x10', // block start
BLOCK_END = '\x11', // block end
// custom range.
/**
* @deprecated
+16 -21
View File
@@ -31,7 +31,6 @@ export interface IDocumentSkeletonFontStyle {
fontCache: string;
}
// eslint-disable-next-line max-lines-per-function
export function getFontStyleString(
textStyle?: IStyleBase
): IDocumentSkeletonFontStyle {
@@ -72,26 +71,7 @@ export function getFontStyleString(
originFontSize = Math.ceil(textStyle.fs);
}
let fontFamilyResult = defaultFont;
if (textStyle.ff) {
let fontFamily = textStyle.ff;
fontFamily = fontFamily.replace(/"/g, '').replace(/'/g, '');
if (fontFamily.indexOf(' ') > -1) {
fontFamily = `"${fontFamily}"`;
}
// if (fontFamily != null && document.fonts && !document.fonts.check('12px ' + fontFamily)) {
// menuButton.addFontToList(fontFamily);
// }
if (fontFamily == null) {
fontFamily = defaultFont;
}
fontFamilyResult = fontFamily;
}
const fontFamilyResult = normalizeFontFamily(textStyle.ff, defaultFont);
const { va: baselineOffset } = textStyle;
@@ -119,6 +99,21 @@ export function getFontStyleString(
};
}
function normalizeFontFamily(fontFamily: string | null | undefined | void, defaultFont: string): string {
if (!fontFamily?.trim()) {
return defaultFont;
}
return fontFamily
.split(',')
.map((item) => {
const family = item.trim().replace(/^['"]|['"]$/g, '');
return family.includes(' ') ? `"${family}"` : family;
})
.filter(Boolean)
.join(', ');
}
export function getBaselineOffsetInfo(_fontFamily: string, fontSize: number) {
// The origin FontCache.getBaselineOffsetInfo needs _fontDataMap
// But now _fontDataMap didn't not set value. So we can use getDefault.
@@ -25,6 +25,7 @@ import { ThemeService } from '../../services/theme/theme.service';
import { ColorKit } from '../../shared';
import { BooleanNumber, HorizontalAlign } from '../../types/enum';
import { LocaleType } from '../../types/enum/locale-type';
import { DocumentFlavor } from '../../types/interfaces';
import { createSheetGapTestConfig, SheetSkeleton } from '../sheet-skeleton';
import { RANGE_TYPE } from '../typedef';
import { createCoreTestBed } from './create-core-test-bed';
@@ -271,6 +272,8 @@ describe('SheetSkeleton integration', () => {
width: Number.POSITIVE_INFINITY,
height: Number.POSITIVE_INFINITY,
},
documentFlavor: DocumentFlavor.UNSPECIFIED,
paragraphLineGapDefault: 0,
renderConfig: {
horizontalAlign: HorizontalAlign.CENTER,
},
@@ -16,7 +16,7 @@
import { describe, expect, it } from 'vitest';
import { BaselineOffset, BooleanNumber, HorizontalAlign, VerticalAlign, WrapStrategy } from '../../types/enum';
import { CustomRangeType } from '../../types/interfaces';
import { CustomRangeType, DocumentFlavor } from '../../types/interfaces';
import {
addLinkToDocumentModel,
createDocumentModelWithStyle,
@@ -48,6 +48,8 @@ describe('sheet util helpers', () => {
marginRight: 4,
marginBottom: 3,
marginLeft: 2,
documentFlavor: DocumentFlavor.UNSPECIFIED,
paragraphLineGapDefault: 0,
renderConfig: {
horizontalAlign: HorizontalAlign.CENTER,
verticalAlign: VerticalAlign.MIDDLE,
@@ -46,6 +46,7 @@ import { ImageCacheMap } from '../shared/cache/image-cache';
import { getIntersectRange } from '../shared/range';
import { Skeleton } from '../skeleton';
import { BooleanNumber, HorizontalAlign } from '../types/enum';
import { DocumentFlavor } from '../types/interfaces';
/**
* Configuration for a single gap (visual separator between rows or columns).
@@ -1174,6 +1175,8 @@ export class SheetSkeleton extends Skeleton {
width: Number.POSITIVE_INFINITY,
height: Number.POSITIVE_INFINITY,
};
documentData.documentStyle.documentFlavor = DocumentFlavor.UNSPECIFIED;
documentData.documentStyle.paragraphLineGapDefault = 0;
documentData.documentStyle.renderConfig = {
...documentData.documentStyle.renderConfig,
+3 -1
View File
@@ -24,7 +24,7 @@ import { TextX } from '../docs/data-model/text-x/text-x';
import { convertTextRotation } from '../docs/data-model/utils';
import { Rectangle } from '../shared';
import { HorizontalAlign, VerticalAlign, WrapStrategy } from '../types/enum';
import { CustomRangeType } from '../types/interfaces';
import { CustomRangeType, DocumentFlavor } from '../types/interfaces';
export interface IFontLocale {
fontList: string[];
@@ -109,10 +109,12 @@ export function createDocumentModelWithStyle(content: string, textStyle: ITextSt
width: Number.POSITIVE_INFINITY,
height: Number.POSITIVE_INFINITY,
},
documentFlavor: DocumentFlavor.UNSPECIFIED,
marginTop,
marginBottom,
marginRight,
marginLeft,
paragraphLineGapDefault: 0,
renderConfig: {
horizontalAlign,
verticalAlign,
+3
View File
@@ -28,6 +28,7 @@ import { createRowColIter } from '../shared/row-col-iter';
import { generateRandomId } from '../shared/tools';
import { DEFAULT_STYLES } from '../types/const';
import { CellValueType } from '../types/enum';
import { DocumentFlavor } from '../types/interfaces';
import { cloneWorksheetData } from './clone';
import { ColumnManager } from './column-manager';
import { Range } from './range';
@@ -1269,6 +1270,8 @@ export class Worksheet {
width: Number.POSITIVE_INFINITY,
height: Number.POSITIVE_INFINITY,
};
documentData.documentStyle.documentFlavor = DocumentFlavor.UNSPECIFIED;
documentData.documentStyle.paragraphLineGapDefault = 0;
documentData.documentStyle.renderConfig = {
...documentData.documentStyle.renderConfig,
+6 -2
View File
@@ -184,6 +184,10 @@ export const NAMED_STYLE_MAP: Record<NamedStyleType, Nullable<ITextStyle>> = {
const BOTTOM_P = 4;
export const DEFAULT_DOCUMENT_PARAGRAPH_LINE_SPACING = 1.5;
export const DEFAULT_DOCUMENT_PARAGRAPH_SPACE_ABOVE = 0;
export const DEFAULT_DOCUMENT_PARAGRAPH_SPACE_BELOW = 8;
export const NAMED_STYLE_SPACE_MAP: Record<NamedStyleType, Nullable<IParagraphStyle>> = {
[NamedStyleType.HEADING_1]: {
spaceAbove: {
@@ -227,10 +231,10 @@ export const NAMED_STYLE_SPACE_MAP: Record<NamedStyleType, Nullable<IParagraphSt
},
[NamedStyleType.NORMAL_TEXT]: {
spaceAbove: {
v: 0,
v: DEFAULT_DOCUMENT_PARAGRAPH_SPACE_ABOVE,
},
spaceBelow: {
v: 0,
v: DEFAULT_DOCUMENT_PARAGRAPH_SPACE_BELOW,
},
},
[NamedStyleType.TITLE]: {
@@ -63,3 +63,17 @@ export const PAGE_SIZE: Record<PaperType, Required<ISize>> = {
height: 1632,
},
};
export enum ModernDocumentWidthMode {
NARROW = 'narrow',
MEDIUM = 'medium',
WIDE = 'wide',
}
export const MODERN_DOCUMENT_WIDTH: Record<ModernDocumentWidthMode, number> = {
[ModernDocumentWidthMode.NARROW]: PAGE_SIZE[PaperType.A4].width,
[ModernDocumentWidthMode.MEDIUM]: 960,
[ModernDocumentWidthMode.WIDE]: PAGE_SIZE[PaperType.A3].width,
};
export const MODERN_DOCUMENT_DEFAULT_MARGIN = 50 / 0.75;
@@ -131,6 +131,8 @@ export interface IDocumentBody {
tables?: ICustomTable[]; // Table
blockRanges?: IDocumentBlockRange[]; // Generic structured block range, e.g. callout, quote, code.
// tableOfContents?: { [index: number]: ITableOfContent }; // tableOfContents
// links?: { [index: number]: IHyperlink }; // links
@@ -363,6 +365,13 @@ export type IHyperLinkCustomRange = ICustomRange<{ url: string }>;
export type IMentionCustomRange = ICustomRange<IMention>;
export interface IDocumentBlockRange {
startIndex: number;
endIndex: number;
blockId: string;
blockType: string;
}
export enum CustomRangeType {
HYPERLINK,
FIELD, // 17.16 Fields and Hyperlinks
@@ -20,15 +20,17 @@ import {
Direction,
ICommandService,
IUniverInstanceService,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
UniverInstanceType,
WrapTextType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocSelectionManagerService, DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocDrawingController as CoreDocDrawingController, DocDrawingService, IDocDrawingService } from '@univerjs/docs-drawing';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { DocContentInsertService, DocSelectionRenderService } from '@univerjs/docs-ui';
import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createDocUiTestBed } from '../../../__tests__/create-doc-ui-test-bed';
import { DocDrawingAddRemoveController } from '../../../controllers/doc-drawing-notification.controller';
@@ -36,7 +38,7 @@ import { DeleteDocDrawingsCommand } from '../delete-doc-drawing.command';
import { InsertDocDrawingCommand } from '../insert-doc-drawing.command';
import { MoveDocDrawingsCommand } from '../move-drawings.command';
import { RemoveDocDrawingCommand } from '../remove-doc-drawing.command';
import { UpdateDocDrawingDistanceCommand, UpdateDocDrawingWrapTextCommand, UpdateDrawingDocTransformCommand } from '../update-doc-drawing.command';
import { TextWrappingStyle, UpdateDocDrawingDistanceCommand, UpdateDocDrawingWrappingStyleCommand, UpdateDocDrawingWrapTextCommand, UpdateDrawingDocTransformCommand } from '../update-doc-drawing.command';
function createBaseDocData(): IDocumentData {
return {
@@ -127,6 +129,7 @@ function setupDrawingTestBed(docData: IDocumentData) {
injector.add([DocDrawingService]);
injector.add([IDocDrawingService, { useClass: DocDrawingService }]);
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
injector.add([DocContentInsertService]);
injector.add([CoreDocDrawingController]);
injector.add([DocDrawingAddRemoveController]);
@@ -137,6 +140,7 @@ function setupDrawingTestBed(docData: IDocumentData) {
DeleteDocDrawingsCommand,
MoveDocDrawingsCommand,
UpdateDocDrawingDistanceCommand,
UpdateDocDrawingWrappingStyleCommand,
UpdateDocDrawingWrapTextCommand,
UpdateDrawingDocTransformCommand,
RichTextEditingMutation as unknown as ICommand,
@@ -211,6 +215,48 @@ describe('docs drawing commands integration', () => {
testBed.univer.dispose();
});
it('uses the explicit content insert range for drawing insertion', async () => {
const insertOffset = 5;
const testBed = setupDrawingTestBed(createBaseDocData());
testBed.selectionManager.__TEST_ONLY_add([{
startOffset: 0,
endOffset: 0,
collapsed: true,
isActive: true,
segmentId: '',
style: null as never,
}]);
testBed.get(DocContentInsertService).setInsertRange({
unitId: 'test-doc',
startOffset: insertOffset,
endOffset: insertOffset,
});
expect(await testBed.commandService.executeCommand(InsertDocDrawingCommand.id, {
drawings: [{
drawingId: 'shape-1',
unitId: 'test-doc',
subUnitId: 'test-doc',
drawingType: 'image',
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
docTransform: {
positionH: { posOffset: 1 },
positionV: { posOffset: 2 },
},
}],
})).toBe(true);
await awaitTime(0);
const doc = testBed.get(IUniverInstanceService)
.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)!;
expect(doc.getBody()?.dataStream).toBe('Hello\b\r\n');
expect(doc.getBody()?.customBlocks).toEqual([{ startIndex: insertOffset, blockId: 'shape-1' }]);
testBed.univer.dispose();
});
it('deletes a focused drawing through the command pipeline and removes it from the document and services', async () => {
const testBed = setupDrawingTestBed(createDrawingDocData());
@@ -303,6 +349,96 @@ describe('docs drawing commands integration', () => {
testBed.univer.dispose();
});
it('keeps table cell drawing position when switching from inline to floating layout', async () => {
const docData = createDrawingDocData();
docData.drawings!['shape-1'].layoutType = PositionedObjectLayoutType.INLINE;
docData.drawings!['shape-1'].docTransform = {
size: {
width: 40,
height: 24,
},
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 999,
},
positionV: {
relativeFrom: ObjectRelativeFromV.PARAGRAPH,
posOffset: 999,
},
angle: 0,
} as never;
const testBed = setupDrawingTestBed(docData);
const skeletonManager = testBed.injector.get(DocSkeletonManagerService);
vi.spyOn(skeletonManager, 'getViewModel').mockReturnValue({
getEditArea: () => DocumentEditArea.BODY,
reset: vi.fn(),
} as never);
vi.spyOn(skeletonManager, 'getSkeleton').mockReturnValue({
getSkeletonData: () => ({
pages: [{
marginTop: 72,
marginLeft: 90,
marginBottom: 72,
pageHeight: 800,
pageWidth: 600,
headerId: '',
footerId: '',
skeDrawings: new Map(),
skeTables: new Map([
['table-1', {
rows: [{
cells: [{
marginTop: 4,
marginLeft: 5,
skeDrawings: new Map([
['shape-1', {
drawingId: 'shape-1',
aLeft: 12,
aTop: 20,
columnLeft: 3,
lineTop: 7,
blockAnchorTop: 15,
drawingOrigin: docData.drawings!['shape-1'],
}],
]),
skeTables: new Map(),
}],
}],
}],
]),
}],
skeHeaders: new Map(),
skeFooters: new Map(),
}),
} as never);
expect(await testBed.commandService.executeCommand(UpdateDocDrawingWrappingStyleCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawings: [{
drawingId: 'shape-1',
}],
wrappingStyle: TextWrappingStyle.WRAP_SQUARE,
})).toBe(true);
await awaitTime(0);
const doc = testBed.get(IUniverInstanceService)
.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)!;
const drawing = doc.getSnapshot().drawings?.['shape-1'];
expect(drawing?.layoutType).toBe(PositionedObjectLayoutType.WRAP_SQUARE);
expect(drawing?.docTransform.positionH).toEqual({
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 12,
});
expect(drawing?.docTransform.positionV).toEqual({
relativeFrom: ObjectRelativeFromV.PARAGRAPH,
posOffset: 5,
});
testBed.univer.dispose();
});
it('updates drawing doc transform through the command pipeline', async () => {
const testBed = setupDrawingTestBed(createDrawingDocData());
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { IAccessor, ICommand, IMutationInfo, JSONXActions } from '@univerjs/core';
import type { IAccessor, ICommand, IMutationInfo, ITextRangeParam, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IInsertDrawingCommandParams } from './interfaces';
import {
@@ -27,7 +27,7 @@ import {
TextXActionType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getCustomBlockIdsInSelections, getRichTextEditPath } from '@univerjs/docs-ui';
import { DocContentInsertService, getCustomBlockIdsInSelections, getRichTextEditPath } from '@univerjs/docs-ui';
/**
* The command to insert new drawings
@@ -48,13 +48,28 @@ export const InsertDocDrawingCommand: ICommand = {
const activeTextRange = docSelectionManagerService.getActiveTextRange();
const documentDataModel = univerInstanceService.getCurrentUniverDocInstance();
if (activeTextRange == null || documentDataModel == null) {
if (documentDataModel == null) {
return false;
}
const unitId = documentDataModel.getUnitId();
const contentInsertRange = getContentInsertRange(accessor, unitId);
const targetTextRange = contentInsertRange
? {
...activeTextRange,
startOffset: contentInsertRange.startOffset,
endOffset: contentInsertRange.endOffset,
collapsed: contentInsertRange.startOffset === contentInsertRange.endOffset,
segmentId: contentInsertRange.segmentId ?? activeTextRange?.segmentId ?? '',
}
: activeTextRange;
if (targetTextRange == null) {
return false;
}
const { drawings } = params;
const { collapsed, startOffset, segmentId } = activeTextRange;
const { collapsed, startOffset, segmentId = '' } = targetTextRange;
const body = documentDataModel.getSelfOrHeaderFooterModel(segmentId).getBody();
if (body == null) {
@@ -76,10 +91,10 @@ export const InsertDocDrawingCommand: ICommand = {
});
}
} else {
const dos = BuildTextUtils.selection.delete([activeTextRange], body, 0, null, false);
const dos = BuildTextUtils.selection.delete([targetTextRange], body, 0, null, false);
textX.push(...dos);
const removedCustomBlockIds = getCustomBlockIdsInSelections(body, [activeTextRange]);
const removedCustomBlockIds = getCustomBlockIdsInSelections(body, [targetTextRange]);
const drawings = documentDataModel.getDrawings() ?? {};
const drawingOrder = documentDataModel.getDrawingsOrder() ?? [];
const sortedRemovedCustomBlockIds = removedCustomBlockIds.sort((a, b) => {
@@ -159,3 +174,21 @@ export const InsertDocDrawingCommand: ICommand = {
return Boolean(result);
},
};
function getContentInsertRange(accessor: IAccessor, unitId: string): ITextRangeParam | null {
try {
const range = accessor.get(DocContentInsertService).consumeInsertRange(unitId);
if (range == null) {
return null;
}
return {
startOffset: range.startOffset,
endOffset: range.endOffset,
collapsed: range.startOffset === range.endOffset,
segmentId: range.segmentId,
};
} catch {
return null;
}
}
@@ -17,6 +17,7 @@
import type { DocumentDataModel, IAccessor, ICommand, IDocDrawingBase, IDocDrawingPosition, IMutationInfo, IObjectPositionH, IObjectPositionV, ISize, JSONXActions, WrapTextType } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IDocDrawing } from '@univerjs/docs-drawing';
import type { IDocumentSkeletonDrawing, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage } from '@univerjs/engine-render';
import {
BooleanNumber,
CommandType,
@@ -51,6 +52,41 @@ const WRAPPING_STYLE_TO_LAYOUT_TYPE = {
[TextWrappingStyle.BEHIND_TEXT]: PositionedObjectLayoutType.WRAP_NONE,
};
interface IDrawingAnchorInPage {
skeDrawing: IDocumentSkeletonDrawing;
pageMarginTop: number;
pageMarginLeft: number;
}
export function findDrawingAnchorInPage(
page: IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter,
drawingId: string,
pageMarginTop: number,
pageMarginLeft: number
): IDrawingAnchorInPage | null {
const skeDrawing = page.skeDrawings.get(drawingId);
if (skeDrawing) {
return {
skeDrawing,
pageMarginTop,
pageMarginLeft,
};
}
for (const table of page.skeTables.values()) {
for (const row of table.rows) {
for (const cell of row.cells) {
const cellAnchor = findDrawingAnchorInPage(cell, drawingId, cell.marginTop, cell.marginLeft);
if (cellAnchor) {
return cellAnchor;
}
}
}
}
return null;
}
// eslint-disable-next-line max-lines-per-function
function getDeleteAndInsertCustomBlockActions(
segmentId: string,
@@ -280,9 +316,7 @@ export const UpdateDocDrawingWrappingStyleCommand: ICommand = {
}
// Update positionH and positionV if layoutType is not inline.
let skeDrawing = null;
let pageMarginTop = 0;
let pageMarginLeft = 0;
let drawingAnchor: IDrawingAnchorInPage | null = null;
for (const page of pages) {
const { headerId, footerId, marginTop, marginLeft, marginBottom, pageWidth, pageHeight } = page;
@@ -290,10 +324,8 @@ export const UpdateDocDrawingWrappingStyleCommand: ICommand = {
case DocumentEditArea.HEADER: {
const headerSke = skeHeaders.get(headerId)?.get(pageWidth);
if (headerSke != null && headerSke.skeDrawings.has(drawingId)) {
skeDrawing = headerSke.skeDrawings.get(drawingId);
pageMarginTop = headerSke.marginTop;
pageMarginLeft = marginLeft;
if (headerSke != null) {
drawingAnchor = findDrawingAnchorInPage(headerSke, drawingId, headerSke.marginTop, marginLeft);
}
break;
@@ -301,30 +333,25 @@ export const UpdateDocDrawingWrappingStyleCommand: ICommand = {
case DocumentEditArea.FOOTER: {
const footerSke = skeFooters.get(footerId)?.get(pageWidth);
if (footerSke != null && footerSke.skeDrawings.has(drawingId)) {
skeDrawing = footerSke.skeDrawings.get(drawingId);
pageMarginTop = pageHeight - marginBottom + footerSke.marginTop;
pageMarginLeft = marginLeft;
if (footerSke != null) {
drawingAnchor = findDrawingAnchorInPage(footerSke, drawingId, pageHeight - marginBottom + footerSke.marginTop, marginLeft);
}
break;
}
case DocumentEditArea.BODY: {
if (page.skeDrawings.has(drawingId)) {
skeDrawing = page.skeDrawings.get(drawingId);
pageMarginTop = marginTop;
pageMarginLeft = marginLeft;
}
drawingAnchor = findDrawingAnchorInPage(page, drawingId, marginTop, marginLeft);
break;
}
}
if (skeDrawing != null) {
if (drawingAnchor != null) {
break;
}
}
if (skeDrawing != null) {
if (drawingAnchor != null) {
const { skeDrawing, pageMarginTop, pageMarginLeft } = drawingAnchor;
const { aTop, aLeft } = skeDrawing;
const oldPositionH = oldDrawings[drawingId].docTransform.positionH;
let posOffsetH = aLeft;
@@ -15,7 +15,7 @@
*/
import type { IDocDrawingBase, IDocDrawingPosition, Nullable } from '@univerjs/core';
import type { BaseObject, Documents, IDocumentSkeletonGlyph, IDocumentSkeletonPage, Image, INodeSearch, IPoint, Viewport } from '@univerjs/engine-render';
import type { BaseObject, Documents, IDocumentSkeletonGlyph, IDocumentSkeletonPage, IDocumentSkeletonRow, IDocumentSkeletonTable, Image, IPoint, Viewport } from '@univerjs/engine-render';
import type { IDrawingDocTransform } from '../commands/commands/update-doc-drawing.command';
import {
BooleanNumber,
@@ -36,6 +36,7 @@ import { DocSelectionRenderService, getAnchorBounding, getDocObject, getOneTextS
import { IDrawingManagerService } from '@univerjs/drawing';
import { DocumentSkeletonPageType, getColor, IRenderManagerService, Liquid, PageLayoutType, Rect, Vector2 } from '@univerjs/engine-render';
import { IMoveInlineDrawingCommand, ITransformNonInlineDrawingCommand, UpdateDrawingDocTransformCommand } from '../commands/commands/update-doc-drawing.command';
import { getDocsTableCellDrawingOffset } from './render-controllers/doc-drawing-transform-update.controller';
const INLINE_DRAWING_ANCHOR_KEY_PREFIX = '__InlineDrawingAnchor__';
@@ -56,10 +57,33 @@ interface IDrawingAnchor {
contentBoxPointGroup?: IPoint[][];
}
function isInTableCell(nodePosition: INodeSearch) {
const { path } = nodePosition;
export interface IDocsTableCellAnchorContext {
cell: IDocumentSkeletonPage;
hostPage: IDocumentSkeletonPage;
offset: {
left: number;
top: number;
};
row: IDocumentSkeletonRow;
table: IDocumentSkeletonTable;
}
return path.some((p) => p === 'cells');
export function getDocsTableCellAnchorContext(unitId: string, cell: IDocumentSkeletonPage): Nullable<IDocsTableCellAnchorContext> {
const row = cell.parent as IDocumentSkeletonRow | undefined;
const table = row?.parent as IDocumentSkeletonTable | undefined;
const hostPage = table?.parent as IDocumentSkeletonPage | undefined;
if (!row || !table || !hostPage || !row.cells?.includes(cell)) {
return null;
}
return {
cell,
hostPage,
offset: getDocsTableCellDrawingOffset(unitId, table, row, cell),
row,
table,
};
}
// Listen doc drawing transformer change, and update drawing data.
@@ -434,11 +458,6 @@ export class DocDrawingTransformerController extends Disposable {
return;
}
// TODO: @JOCS, table cell do not support drawings now. so need to disable it.
if (isInTableCell(nodePosition)) {
return;
}
const positionWithIsBack = {
...nodePosition,
isBack,
@@ -534,7 +553,9 @@ export class DocDrawingTransformerController extends Disposable {
this._liquid.reset();
const pageType = page.type;
const tableCellContext = page.type === DocumentSkeletonPageType.CELL ? getDocsTableCellAnchorContext(drawing.unitId, page) : null;
const anchorPage = tableCellContext?.hostPage ?? page;
const pageType = anchorPage.type;
for (const p of pages) {
const { headerId, footerId, pageHeight, pageWidth, marginLeft, marginBottom } = p;
@@ -571,13 +592,18 @@ export class DocDrawingTransformerController extends Disposable {
break;
}
default: {
this._liquid.translatePagePadding(p);
break;
}
}
break;
}
this._liquid.translatePagePadding(p);
if (p === page) {
if (p === anchorPage) {
break;
}
@@ -585,6 +611,10 @@ export class DocDrawingTransformerController extends Disposable {
this._liquid.translatePage(p, pageLayoutType, pageMarginLeft, pageMarginTop);
}
if (tableCellContext) {
this._liquid.translate(tableCellContext.offset.left, tableCellContext.offset.top);
}
if (positionV.relativeFrom === ObjectRelativeFromV.LINE) {
glyphAnchor = line.divides[0].glyphGroup[0];
} else {
@@ -637,11 +667,6 @@ export class DocDrawingTransformerController extends Disposable {
return;
}
// TODO: @JOCS, table cell do not support drawings now. so need to disable it.
if (isInTableCell(nodePosition)) {
return;
}
const positionWithIsBack = {
...nodePosition,
isBack,
@@ -0,0 +1,101 @@
/**
* 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 { setDocsTableRenderViewportProvider } from '@univerjs/engine-render';
import { afterEach, describe, expect, it } from 'vitest';
import { getDocsTableCellAnchorContext } from '../../doc-drawing-transformer-update.controller';
import { getDocsTableCellDrawingOffset } from '../doc-drawing-transform-update.controller';
describe('DocDrawingTransformUpdateController', () => {
afterEach(() => {
setDocsTableRenderViewportProvider(null);
});
it('projects drawings in table cells through table, row, cell and scroll offsets', () => {
setDocsTableRenderViewportProvider((unitId, tableId) => {
if (unitId !== 'unit-1' || tableId !== 'table-1') {
return null;
}
return {
contentWidth: 480,
scrollLeft: 30,
viewportWidth: 160,
};
});
const table = {
left: 40,
tableId: 'table-1#-#0',
top: 80,
};
const row = {
top: 12,
};
const cell = {
left: 120,
marginLeft: 8,
marginTop: 6,
};
expect(getDocsTableCellDrawingOffset('unit-1', table as never, row as never, cell as never)).toEqual({
left: 138,
top: 98,
});
});
it('resolves a table cell drawing anchor to the host page and scrolled cell offset', () => {
setDocsTableRenderViewportProvider((unitId, tableId) => {
if (unitId !== 'unit-1' || tableId !== 'table-1') {
return null;
}
return {
contentWidth: 480,
scrollLeft: 30,
viewportWidth: 160,
};
});
const hostPage = { type: 'body' };
const cell = {
left: 120,
marginLeft: 8,
marginTop: 6,
};
const row = {
cells: [cell],
top: 12,
};
const table = {
left: 40,
parent: hostPage,
rows: [row],
tableId: 'table-1#-#0',
top: 80,
};
Object.assign(row, { parent: table });
Object.assign(cell, { parent: row });
expect(getDocsTableCellAnchorContext('unit-1', cell as never)).toMatchObject({
hostPage,
offset: {
left: 138,
top: 98,
},
});
});
});
@@ -16,7 +16,7 @@
import type { DocumentDataModel, ICommandInfo, IDrawingParam, ITransformState } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { Documents, DocumentSkeleton, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage, Image, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { Documents, DocumentSkeleton, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage, IDocumentSkeletonRow, IDocumentSkeletonTable, Image, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import {
BooleanNumber,
Disposable,
@@ -32,7 +32,7 @@ import {
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { IEditorService, SetDocZoomRatioOperation } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import { Liquid, TRANSFORM_CHANGE_OBSERVABLE_TYPE } from '@univerjs/engine-render';
import { getDocsTableRenderViewport, getTableIdAndSliceIndex, Liquid, TRANSFORM_CHANGE_OBSERVABLE_TYPE } from '@univerjs/engine-render';
import { debounceTime, filter } from 'rxjs';
import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service';
@@ -48,6 +48,23 @@ interface IDrawingParamsWithBehindText {
isMultiTransform: BooleanNumber;
}
export function getDocsTableCellDrawingOffset(
unitId: string,
table: IDocumentSkeletonTable,
row: IDocumentSkeletonRow,
cell: IDocumentSkeletonPage
) {
const sourceTableId = getTableIdAndSliceIndex(table.tableId).tableId;
const viewport = getDocsTableRenderViewport(unitId, sourceTableId);
const hasHorizontalViewport = viewport && viewport.contentWidth > viewport.viewportWidth;
const scrollLeft = hasHorizontalViewport ? viewport.scrollLeft : 0;
return {
left: table.left + cell.left - scrollLeft + cell.marginLeft,
top: table.top + row.top + cell.marginTop,
};
}
export class DocDrawingTransformUpdateController extends Disposable implements IRenderModule {
private _liquid = new Liquid();
@@ -178,6 +195,15 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
headerPage.marginTop,
page.marginLeft
);
this._calculateTableCellDrawingPositions(
unitId,
headerPage,
docsLeft,
docsTop,
updateDrawingMap,
headerPage.marginTop,
page.marginLeft
);
}
}
@@ -185,19 +211,30 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
const footerPage = skeFooters.get(footerId)?.get(pageWidth);
if (footerPage) {
const footerTop = page.pageHeight - page.marginBottom + footerPage.marginTop;
this._calculateDrawingPosition(
unitId,
footerPage,
docsLeft,
docsTop,
updateDrawingMap,
page.pageHeight - page.marginBottom + footerPage.marginTop,
footerTop,
page.marginLeft
);
this._calculateTableCellDrawingPositions(
unitId,
footerPage,
docsLeft,
docsTop,
updateDrawingMap,
footerTop,
page.marginLeft
);
}
}
this._calculateDrawingPosition(unitId, page, docsLeft, docsTop, updateDrawingMap, page.marginTop, page.marginLeft);
this._calculateTableCellDrawingPositions(unitId, page, docsLeft, docsTop, updateDrawingMap, page.marginTop, page.marginLeft);
this._liquid.translatePage(page, pageLayoutType, pageMarginLeft, pageMarginTop);
}
@@ -298,6 +335,45 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
} as IDocumentSkeletonPage);
}
private _calculateTableCellDrawingPositions(
unitId: string,
page: IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter,
docsLeft: number,
docsTop: number,
updateDrawingMap: Record<string, IDrawingParamsWithBehindText>,
baseMarginTop: number,
baseMarginLeft: number
) {
page.skeTables?.forEach((table) => {
table.rows.forEach((row) => {
row.cells.forEach((cell) => {
const cellOffset = getDocsTableCellDrawingOffset(unitId, table, row, cell);
const marginTop = baseMarginTop + cellOffset.top;
const marginLeft = baseMarginLeft + cellOffset.left;
this._calculateDrawingPosition(
unitId,
cell,
docsLeft,
docsTop,
updateDrawingMap,
marginTop,
marginLeft
);
this._calculateTableCellDrawingPositions(
unitId,
cell,
docsLeft,
docsTop,
updateDrawingMap,
marginTop,
marginLeft
);
});
});
});
}
private _drawingInitializeListener() {
const init = () => {
const skeleton = this._docSkeletonManagerService.getSkeleton();
@@ -36,6 +36,11 @@ import { SetDocDrawingArrangeCommand } from '../../commands/commands/set-drawing
import { UngroupDocDrawingCommand } from '../../commands/commands/ungroup-doc-drawing.command';
import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service';
interface IImageInsertPosition {
left: number;
top: number;
}
export class DocDrawingUpdateRenderController extends Disposable implements IRenderModule {
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@@ -68,6 +73,7 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
}
async insertDocImage(): Promise<boolean> {
const insertPosition = this._getCurrentImageInsertPosition();
const files = await this._fileOpenerService.openFile({
multiple: true,
accept: DRAWING_IMAGE_ALLOW_IMAGE_LIST.map((image) => `.${image.replace('image/', '')}`).join(','),
@@ -84,12 +90,12 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
return false;
}
await this._insertFloatImages(files);
await this._insertFloatImages(files, insertPosition);
return true;
}
// eslint-disable-next-line max-lines-per-function
private async _insertFloatImages(files: File[]) {
private async _insertFloatImages(files: File[], insertPosition: Nullable<IImageInsertPosition>) {
let imageParams: Nullable<IImageIoServiceParam>[] = [];
try {
@@ -141,12 +147,18 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
scale = Math.min(scaleWidth, scaleHeight);
}
const docTransform = this._getImagePosition(width * scale, height * scale);
const imagePosition = insertPosition ?? this._getCurrentImageInsertPosition();
const docTransform = this._getImagePosition(width * scale, height * scale, imagePosition);
if (docTransform == null) {
return;
}
const transform = docDrawingPositionToTransform(docTransform);
if (transform != null && imagePosition != null) {
transform.top = imagePosition.top;
}
const docDrawingParam: IDocDrawing = {
unitId,
subUnitId: unitId,
@@ -154,7 +166,7 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType,
source,
transform: docDrawingPositionToTransform(docTransform),
transform,
docTransform,
behindDoc: BooleanNumber.FALSE,
title: '',
@@ -196,11 +208,11 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
private _getImagePosition(
imageWidth: number,
imageHeight: number
imageHeight: number,
insertPosition?: Nullable<IImageInsertPosition>
): Nullable<IDocDrawingPosition> {
const activeTextRange = this._docSelectionRenderService.getActiveTextRange();
// TODO: NO need to get the cursor position, because the insert image is inline.
const position = activeTextRange?.getAbsolutePosition() || {
const position = insertPosition ?? this._getCurrentImageInsertPosition() ?? {
left: 0,
top: 0,
};
@@ -222,6 +234,19 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
};
}
private _getCurrentImageInsertPosition(): Nullable<IImageInsertPosition> {
const position = this._docSelectionRenderService.getActiveTextRange()?.getAbsolutePosition();
if (position == null) {
return null;
}
return {
left: position.left,
top: position.top,
};
}
private _updateOrderListener() {
this.disposeWithMe(
this._drawingManagerService.featurePluginOrderUpdate$.subscribe((params) => {
+2
View File
@@ -31,3 +31,5 @@ export { type IUniverDocsDrawingUIConfig } from './config/config';
export { DocFloatDomController } from './controllers/doc-float-dom.controller';
export { DOCS_IMAGE_MENU_ID } from './menu/image.menu';
export { UniverDocsDrawingUIPlugin } from './plugin';
export { DocDrawingPosition } from './views/doc-image-panel/DocDrawingPosition';
export { DocDrawingTextWrap } from './views/doc-image-panel/DocDrawingTextWrap';
@@ -0,0 +1,38 @@
/**
* 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 { DOC_CONTENT_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { InsertDocImageCommand } from '../../commands/commands/insert-image.command';
import { UploadFloatImageMenuFactory } from '../image.menu';
import { menuSchema } from '../schema';
describe('docs drawing menu schema', () => {
it('adds image to paragraph insert menus', () => {
const paragraph = (menuSchema as any)[ContextMenuPosition.PARAGRAPH];
expect(paragraph[ContextMenuGroup.LAYOUT][INSERT_BELLOW_MENU_ID][InsertDocImageCommand.id].menuItemFactory).toBeDefined();
expect(paragraph[EMPTY_PARAGRAPH_MENU_ID][ContextMenuGroup.LAYOUT][InsertDocImageCommand.id].menuItemFactory).toBeDefined();
expect(paragraph[DOC_CONTENT_INSERT_MENU_ID][ContextMenuGroup.LAYOUT][InsertDocImageCommand.id].menuItemFactory).toBeDefined();
});
it('uses the same image icon in paragraph insert menus', () => {
const item = UploadFloatImageMenuFactory({ get: () => undefined } as never);
expect(item.icon).toBe('AddImageIcon');
});
});
@@ -16,7 +16,7 @@
import type { DocumentDataModel, IDisposable, Nullable } from '@univerjs/core';
import type { BaseObject, Scene } from '@univerjs/engine-render';
import { DrawingTypeEnum, FOCUSING_COMMON_DRAWINGS, IContextService, Inject, isInternalEditorID, IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core';
import { DrawingTypeEnum, FOCUSING_COMMON_DRAWINGS, ICommandService, IContextService, Inject, isInternalEditorID, IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import { COMPONENT_IMAGE_POPUP_MENU, ImageCropperObject, ImageResetSizeOperation, OpenImageCropOperation } from '@univerjs/drawing-ui';
@@ -24,17 +24,20 @@ import { IRenderManagerService } from '@univerjs/engine-render';
import { takeUntil } from 'rxjs';
import { RemoveDocDrawingCommand } from '../commands/commands/remove-doc-drawing.command';
import { EditDocDrawingOperation } from '../commands/operations/edit-doc-drawing.operation';
import { SidebarDocDrawingOperation } from '../commands/operations/open-drawing-panel.operation';
export class DocDrawingPopupMenuController extends RxDisposable {
private _initImagePopupMenu = new Set<string>();
private _disposePopups: IDisposable[] = [];
private _isDrawingPanelOpen = false;
constructor(
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(DocCanvasPopManagerService) private readonly _canvasPopManagerService: DocCanvasPopManagerService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IContextService private readonly _contextService: IContextService
@IContextService private readonly _contextService: IContextService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
@@ -43,6 +46,29 @@ export class DocDrawingPopupMenuController extends RxDisposable {
}
private _init(): void {
this.disposeWithMe(
this._commandService.onCommandExecuted((command) => {
if (command.id === EditDocDrawingOperation.id) {
this._isDrawingPanelOpen = true;
this._clearPopups();
}
if (command.id === SidebarDocDrawingOperation.id) {
const params = command.params as { value?: string } | undefined;
this._isDrawingPanelOpen = params?.value === 'open';
if (this._isDrawingPanelOpen) {
this._clearPopups();
}
}
})
);
this.disposeWithMe(
this._drawingManagerService.focus$.subscribe((params) => {
if (params.length === 0) {
this._isDrawingPanelOpen = false;
}
})
);
this.disposeWithMe(
this._univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).pipe(takeUntil(this.dispose$)).subscribe((documentDataModel) => this._create(documentDataModel))
);
@@ -56,13 +82,15 @@ export class DocDrawingPopupMenuController extends RxDisposable {
private _dispose(documentDataModel: DocumentDataModel) {
const unitId = documentDataModel.getUnitId();
if (this._disposePopups.length) {
this._disposePopups.forEach((dispose) => dispose.dispose());
this._disposePopups.length = 0;
}
this._clearPopups();
this._renderManagerService.removeRender(unitId);
}
private _clearPopups() {
this._disposePopups.forEach((dispose) => dispose.dispose());
this._disposePopups.length = 0;
}
private _create(documentDataModel: Nullable<DocumentDataModel>) {
if (!documentDataModel) {
return;
@@ -112,6 +140,9 @@ export class DocDrawingPopupMenuController extends RxDisposable {
const selectedObjects = transformer.getSelectedObjectMap();
disposePopups.forEach((dispose) => dispose.dispose());
disposePopups.length = 0;
if (this._isDrawingPanelOpen) {
return;
}
if (selectedObjects.size > 1) {
return;
}
@@ -123,19 +154,28 @@ export class DocDrawingPopupMenuController extends RxDisposable {
const oKey = object.oKey;
const drawingParam = this._drawingManagerService.getDrawingOKey(oKey);
if (!drawingParam || drawingParam.drawingType === DrawingTypeEnum.DRAWING_DOM) {
if (
!drawingParam ||
drawingParam.drawingType === DrawingTypeEnum.DRAWING_DOM ||
drawingParam.drawingType === DrawingTypeEnum.DRAWING_SHAPE
) {
return;
}
const { unitId, subUnitId, drawingId, drawingType } = drawingParam;
const isImage = drawingType === DrawingTypeEnum.DRAWING_IMAGE;
const popup = this._canvasPopManagerService.attachPopupToObject(
object,
{
componentKey: COMPONENT_IMAGE_POPUP_MENU,
direction: 'horizontal',
offset: [2, 0],
direction: isImage ? 'top-center' : 'horizontal',
offset: isImage ? [0, 8] : [2, 0],
extraProps: {
menuItems: this._getImageMenuItems(unitId, subUnitId, drawingId, drawingType),
variant: isImage ? 'doc-floating-toolbar' : undefined,
unitId,
subUnitId,
drawingId,
},
},
unitId
@@ -191,8 +231,7 @@ export class DocDrawingPopupMenuController extends RxDisposable {
index: 0,
commandId: EditDocDrawingOperation.id,
commandParams: { unitId, subUnitId, drawingId },
// disable: !!SHEET_EDITOR_UNITS.includes(unitId) || drawingType === DrawingTypeEnum.DRAWING_DOM,
disable: true,
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
{
label: 'docs-drawing-ui.image-popup.delete',
@@ -206,7 +245,7 @@ export class DocDrawingPopupMenuController extends RxDisposable {
index: 2,
commandId: OpenImageCropOperation.id,
commandParams: { unitId, subUnitId, drawingId },
disable: true, // TODO: @JOCS, feature is not ready.
disable: drawingType === DrawingTypeEnum.DRAWING_DOM,
},
{
label: 'docs-drawing-ui.image-popup.reset',
@@ -77,6 +77,7 @@ export function UploadFloatImageMenuFactory(_accessor: IAccessor): IMenuItem {
id: IMAGE_MENU_UPLOAD_FLOAT_ID,
title: 'docs-drawing-ui.upload.float',
type: MenuItemType.BUTTON,
icon: 'AddImageIcon',
hidden$: getMenuHiddenObservable(_accessor, UniverInstanceType.UNIVER_DOC, undefined, DOCS_ZEN_EDITOR_UNIT_ID_KEY),
};
}
+28 -1
View File
@@ -15,7 +15,8 @@
*/
import type { MenuSchemaType } from '@univerjs/ui';
import { RibbonInsertGroup } from '@univerjs/ui';
import { DOC_CONTENT_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import {
DOCS_IMAGE_MENU_ID,
IMAGE_MENU_UPLOAD_FLOAT_ID,
@@ -34,4 +35,30 @@ export const menuSchema: MenuSchemaType = {
},
},
},
[ContextMenuPosition.PARAGRAPH]: {
[ContextMenuGroup.LAYOUT]: {
[INSERT_BELLOW_MENU_ID]: {
[IMAGE_MENU_UPLOAD_FLOAT_ID]: {
order: 5,
menuItemFactory: UploadFloatImageMenuFactory,
},
},
},
[EMPTY_PARAGRAPH_MENU_ID]: {
[ContextMenuGroup.LAYOUT]: {
[IMAGE_MENU_UPLOAD_FLOAT_ID]: {
order: 5,
menuItemFactory: UploadFloatImageMenuFactory,
},
},
},
[DOC_CONTENT_INSERT_MENU_ID]: {
[ContextMenuGroup.LAYOUT]: {
[IMAGE_MENU_UPLOAD_FLOAT_ID]: {
order: 5,
menuItemFactory: UploadFloatImageMenuFactory,
},
},
},
},
};
@@ -40,7 +40,7 @@ export const DocDrawingPanel = () => {
return !!drawings?.length && (
<div className="univer-text-sm">
<DrawingCommonPanel drawings={drawings} hasAlign={false} hasCropper={false} hasGroup={false} hasTransform={false} />
<DrawingCommonPanel drawings={drawings} hasAlign={false} hasCropper hasGroup={false} hasTransform={false} />
<DocDrawingTextWrap drawings={drawings} />
<DocDrawingPosition drawings={drawings} />
</div>
@@ -0,0 +1,53 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { config, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DocHyperLinkEventRenderController } from '../hyper-link-event.render-controller';
describe('DocHyperLinkEventRenderController', () => {
it('ignores hover ranges when the current selection has no text ranges', async () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const onUnhandledError = vi.fn();
const previousUnhandledError = config.onUnhandledError;
const commandService = {
executeCommand: vi.fn(),
};
config.onUnhandledError = onUnhandledError;
try {
const controller = new DocHyperLinkEventRenderController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$ } as never,
commandService as never,
{ showing: false } as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [] } as never
);
hoverCustomRanges$.next([]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onUnhandledError).not.toHaveBeenCalled();
expect(commandService.executeCommand).not.toHaveBeenCalled();
controller.dispose();
} finally {
config.onUnhandledError = previousUnhandledError;
}
});
});
@@ -58,7 +58,7 @@ export class DocHyperLinkEventRenderController extends Disposable implements IRe
this._docEventManagerService.hoverCustomRanges$.subscribe((ranges) => {
const link = ranges.find((range) => range.range.rangeType === CustomRangeType.HYPERLINK);
const activeRanges = this._docSelectionManagerService.getTextRanges();
const currentSegmentId = activeRanges?.[0].segmentId;
const currentSegmentId = activeRanges?.[0]?.segmentId;
if ((link?.segmentId ?? '') !== currentSegmentId) {
this._hideInfoPopup();
return;
@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { ShowDocHyperLinkEditPopupOperation } from '../../commands/operations/popup.operation';
import { menuSchema } from '../schema';
describe('docs hyperlink menu schema', () => {
it('adds hyperlink to paragraph insert menus', () => {
const paragraph = (menuSchema as any)[ContextMenuPosition.PARAGRAPH];
expect(paragraph[ContextMenuGroup.LAYOUT][INSERT_BELLOW_MENU_ID][ShowDocHyperLinkEditPopupOperation.id].menuItemFactory).toBeDefined();
expect(paragraph[EMPTY_PARAGRAPH_MENU_ID][ContextMenuGroup.LAYOUT][ShowDocHyperLinkEditPopupOperation.id].menuItemFactory).toBeDefined();
});
});
@@ -15,6 +15,7 @@
*/
import type { MenuSchemaType } from '@univerjs/ui';
import { EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import { ShowDocHyperLinkEditPopupOperation } from '../commands/operations/popup.operation';
import { AddHyperLinkMenuItemFactory } from './menu';
@@ -34,4 +35,22 @@ export const menuSchema: MenuSchemaType = {
},
},
},
[ContextMenuPosition.PARAGRAPH]: {
[ContextMenuGroup.LAYOUT]: {
[INSERT_BELLOW_MENU_ID]: {
[ShowDocHyperLinkEditPopupOperation.id]: {
order: 6,
menuItemFactory: AddHyperLinkMenuItemFactory,
},
},
},
[EMPTY_PARAGRAPH_MENU_ID]: {
[ContextMenuGroup.LAYOUT]: {
[ShowDocHyperLinkEditPopupOperation.id]: {
order: 6,
menuItemFactory: AddHyperLinkMenuItemFactory,
},
},
},
},
};
@@ -54,7 +54,7 @@ const noopDisposable = {
dispose: () => {},
};
interface IKeywordInputPlaceholderExtraProps {
interface IKeywordInputPlaceholderExtraProps extends Record<string, unknown> {
fontSize?: number;
fontString?: string;
fontFamily?: string;
@@ -19,15 +19,18 @@ import {
awaitTime,
BooleanNumber,
CustomRangeType,
DataStreamTreeTokenType,
HorizontalAlign,
ICommandService,
IUniverInstanceService,
UniverInstanceType,
UpdateDocsAttributeType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DocSelectionManagerService, DocSkeletonManagerService, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DeleteDirection } from '../../../types/delete-direction';
import { DeleteCommand, InsertCommand, UpdateCommand } from '../core-editing.command';
import { DeleteLeftCommand, DeleteRightCommand, isDeleteOffsetInsideBlockRange } from '../doc-delete.command';
import { createCommandTestBed } from './create-command-test-bed';
function getDocumentData(): IDocumentData {
@@ -63,6 +66,108 @@ function getDocumentData(): IDocumentData {
};
}
function getCenteredSingleCharacterDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: 'A\r\n',
textRuns: [{
st: 0,
ed: 1,
ts: {},
}],
paragraphs: [{
startIndex: 1,
paragraphStyle: {
horizontalAlign: HorizontalAlign.CENTER,
},
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getCenteredEmptyParagraphDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: '\r\n',
paragraphs: [{
startIndex: 0,
paragraphStyle: {
horizontalAlign: HorizontalAlign.CENTER,
},
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getIndentedBlockRangeDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: `${DataStreamTreeTokenType.BLOCK_START}A${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.BLOCK_END}\n`,
paragraphs: [{
startIndex: 2,
paragraphStyle: {
indentStart: { v: 22 },
},
}],
blockRanges: [{
blockId: 'quote-1',
blockType: 'quote',
startIndex: 0,
endIndex: 3,
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function createFirstGlyph(paragraphIndex: number, content: string) {
const line = { paragraphIndex, divides: [] as any[] };
const divide = { parent: line, glyphGroup: [] as any[] };
const glyph = {
parent: divide,
content,
streamType: content,
count: 1,
};
divide.glyphGroup.push(glyph);
line.divides.push(divide);
return glyph;
}
describe('core editing commands', () => {
let univer: Univer;
let get: Injector['get'];
@@ -87,6 +192,28 @@ describe('core editing commands', () => {
}
}
function setActiveSelection(offset: number) {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
selectionManager.__TEST_ONLY_add([{ startOffset: offset, endOffset: offset, collapsed: true, isActive: true, segmentId: '', style: null as never }]);
}
function mockSkeleton() {
const skeletonManager = get(DocSkeletonManagerService) as unknown as { getSkeleton: () => unknown };
skeletonManager.getSkeleton = () => ({});
}
function registerDeleteKeyCommands() {
commandService.registerCommand(DeleteLeftCommand);
commandService.registerCommand(DeleteRightCommand);
commandService.registerCommand(UpdateCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
}
beforeEach(() => {
const testBed = createCommandTestBed(getDocumentData());
univer = testBed.univer;
@@ -140,6 +267,156 @@ describe('core editing commands', () => {
expect(getBody()?.customRanges).toEqual([]);
});
it('keeps center alignment when deleting the last character from a centered paragraph', async () => {
univer.dispose();
const testBed = createCommandTestBed(getCenteredSingleCharacterDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(DeleteCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
await commandService.executeCommand(DeleteCommand.id, {
unitId: 'test-doc',
segmentId: '',
range: { startOffset: 0, endOffset: 0, collapsed: true },
direction: DeleteDirection.RIGHT,
});
await awaitTime(0);
expect(getDataStream()).toBe('\r\n');
expect(getBody()?.paragraphs?.[0].paragraphStyle?.horizontalAlign).toBe(HorizontalAlign.CENTER);
});
it('keeps center alignment when backspacing the last character from a centered paragraph', async () => {
univer.dispose();
const testBed = createCommandTestBed(getCenteredSingleCharacterDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(DeleteCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
await commandService.executeCommand(DeleteCommand.id, {
unitId: 'test-doc',
segmentId: '',
range: { startOffset: 1, endOffset: 1, collapsed: true },
direction: DeleteDirection.LEFT,
});
await awaitTime(0);
expect(getDataStream()).toBe('\r\n');
expect(getBody()?.paragraphs?.[0].paragraphStyle?.horizontalAlign).toBe(HorizontalAlign.CENTER);
});
it('resets an empty centered paragraph to left alignment on a second delete', async () => {
univer.dispose();
const testBed = createCommandTestBed(getCenteredEmptyParagraphDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
registerDeleteKeyCommands();
mockSkeleton();
setActiveSelection(0);
await commandService.executeCommand(DeleteRightCommand.id);
await awaitTime(0);
expect(getDataStream()).toBe('\r\n');
expect(getBody()?.paragraphs?.[0].paragraphStyle?.horizontalAlign).toBe(HorizontalAlign.LEFT);
});
it('resets an empty centered paragraph to left alignment on a second backspace', async () => {
univer.dispose();
const testBed = createCommandTestBed(getCenteredEmptyParagraphDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
registerDeleteKeyCommands();
mockSkeleton();
setActiveSelection(0);
await commandService.executeCommand(DeleteLeftCommand.id);
await awaitTime(0);
expect(getDataStream()).toBe('\r\n');
expect(getBody()?.paragraphs?.[0].paragraphStyle?.horizontalAlign).toBe(HorizontalAlign.LEFT);
});
it('detects delete offsets inside block ranges so backspace does not clear block paragraph indent', () => {
const dataStream = `${DataStreamTreeTokenType.BLOCK_START}A${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.BLOCK_END}\n`;
const body = {
dataStream,
blockRanges: [{
blockId: 'block-1',
blockType: 'quote',
startIndex: 0,
endIndex: 3,
}],
};
expect(isDeleteOffsetInsideBlockRange(body, 1)).toBe(true);
expect(isDeleteOffsetInsideBlockRange(body, 2)).toBe(true);
expect(isDeleteOffsetInsideBlockRange(body, 0)).toBe(false);
expect(isDeleteOffsetInsideBlockRange(body, 3)).toBe(false);
});
it('detects block ranges whose endIndex points before the block end token', () => {
const dataStream = `${DataStreamTreeTokenType.BLOCK_START}A${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.BLOCK_END}\n`;
const body = {
dataStream,
blockRanges: [{
blockId: 'block-1',
blockType: 'callout',
startIndex: 0,
endIndex: 2,
}],
};
expect(isDeleteOffsetInsideBlockRange(body, 1)).toBe(true);
expect(isDeleteOffsetInsideBlockRange(body, 2)).toBe(true);
expect(isDeleteOffsetInsideBlockRange(body, 3)).toBe(false);
});
it('does not clear paragraph indent when backspacing at the start of a block range paragraph', async () => {
univer.dispose();
const testBed = createCommandTestBed(getIndentedBlockRangeDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
registerDeleteKeyCommands();
setActiveSelection(1);
const curGlyph = createFirstGlyph(2, 'A');
const preGlyph = createFirstGlyph(2, DataStreamTreeTokenType.BLOCK_START);
const skeletonManager = get(DocSkeletonManagerService) as unknown as { getSkeleton: () => unknown };
skeletonManager.getSkeleton = () => ({
findNodeByCharIndex: (offset: number) => offset === 1 ? curGlyph : preGlyph,
});
const originalExecuteCommand = commandService.executeCommand.bind(commandService);
const executeSpy = vi.spyOn(commandService, 'executeCommand').mockImplementation(async (id: string, params?: object) => {
if (id === DeleteLeftCommand.id) {
return originalExecuteCommand(id, params);
}
return true;
});
await commandService.executeCommand(DeleteLeftCommand.id);
expect(executeSpy).not.toHaveBeenCalledWith(UpdateCommand.id, expect.anything());
expect(executeSpy).toHaveBeenCalledWith(DeleteCommand.id, expect.objectContaining({
direction: DeleteDirection.LEFT,
}));
});
it('updates text styles through the shared rich text mutation flow', async () => {
await commandService.executeCommand(UpdateCommand.id, {
unitId: 'test-doc',
@@ -0,0 +1,117 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentData } from '@univerjs/core';
import { BlockType, CustomRangeType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { buildMoveDocBlockActions } from '../doc-block-move.command';
describe('buildMoveDocBlockActions', () => {
it('moves a paragraph and remaps paragraph indexes', () => {
const documentData = createDocument('A\rB\rC\r\n', {
paragraphs: [{ startIndex: 1 }, { startIndex: 3 }, { startIndex: 5 }],
sectionBreaks: [{ startIndex: 6 }],
});
const { nextDocumentData, movedRange } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: 0, endOffset: 2 },
targetOffset: 6,
});
expect(nextDocumentData.body?.dataStream).toBe('B\rC\rA\r\n');
expect(nextDocumentData.body?.paragraphs?.map((item) => item.startIndex)).toEqual([1, 3, 5]);
expect(movedRange).toEqual({ startOffset: 4, endOffset: 6 });
});
it('moves a block range as one unit', () => {
const documentData = createDocument('aa\rBB\rcc\r\n', {
paragraphs: [{ startIndex: 2 }, { startIndex: 5 }, { startIndex: 8 }],
sectionBreaks: [{ startIndex: 9 }],
blockRanges: [{ blockId: 'quote-1', blockType: 'quote', startIndex: 3, endIndex: 5 }],
});
const { nextDocumentData, movedRange } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: 3, endOffset: 6 },
targetOffset: 0,
});
expect(nextDocumentData.body?.dataStream).toBe('BB\raa\rcc\r\n');
expect(nextDocumentData.body?.blockRanges?.[0]).toMatchObject({ startIndex: 0, endIndex: 2 });
expect(nextDocumentData.body?.paragraphs?.map((item) => item.startIndex)).toEqual([2, 5, 8]);
expect(movedRange).toEqual({ startOffset: 0, endOffset: 3 });
});
it('moves a table range and remaps custom ranges and text runs', () => {
const documentData = createDocument('aa\rTT\rcc\r\n', {
paragraphs: [{ startIndex: 2 }, { startIndex: 5 }, { startIndex: 8 }],
sectionBreaks: [{ startIndex: 9 }],
tables: [{ tableId: 'table-1', startIndex: 3, endIndex: 6 }],
customRanges: [{ rangeId: 'comment-1', rangeType: CustomRangeType.COMMENT, startIndex: 6, endIndex: 7 }],
textRuns: [{ st: 6, ed: 8, ts: {} }],
});
const { nextDocumentData } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: 3, endOffset: 6 },
targetOffset: 0,
});
expect(nextDocumentData.body?.dataStream).toBe('TT\raa\rcc\r\n');
expect(nextDocumentData.body?.tables?.[0]).toMatchObject({ startIndex: 0, endIndex: 3 });
expect(nextDocumentData.body?.customRanges?.[0]).toMatchObject({ startIndex: 6, endIndex: 7 });
expect(nextDocumentData.body?.textRuns?.[0]).toMatchObject({ st: 6, ed: 8 });
});
it('moves a custom block paragraph and keeps the custom block attached', () => {
const documentData = createDocument('\b\raa\r\n', {
paragraphs: [{ startIndex: 1 }, { startIndex: 4 }],
sectionBreaks: [{ startIndex: 5 }],
customBlocks: [{ blockId: 'custom-1', blockType: BlockType.CUSTOM, startIndex: 0 }],
});
const { nextDocumentData } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: 0, endOffset: 2 },
targetOffset: 5,
});
expect(nextDocumentData.body?.dataStream).toBe('aa\r\b\r\n');
expect(nextDocumentData.body?.customBlocks?.[0]).toMatchObject({ startIndex: 3 });
});
});
function createDocument(dataStream: string, body: Partial<NonNullable<IDocumentData['body']>>): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream,
customBlocks: [],
...body,
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginLeft: 72,
marginRight: 72,
},
};
}
@@ -15,7 +15,7 @@
*/
import type { DocumentDataModel, ICommand, Injector, IStyleBase, Univer } from '@univerjs/core';
import { BooleanNumber, ICommandService, IUniverInstanceService, RedoCommand, UndoCommand, UniverInstanceType } from '@univerjs/core';
import { BooleanNumber, DOC_RANGE_TYPE, ICommandService, IUniverInstanceService, RedoCommand, UndoCommand, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
@@ -116,6 +116,51 @@ describe('Test inline format commands', () => {
expect(getFormatValueAt('bl', 21)).toBe(BooleanNumber.TRUE);
expect(getFormatValueAt('bl', 25)).toBe(BooleanNumber.TRUE);
});
it('formats text ranges without applying stale table rect ranges', async () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__replaceTextRangesWithNoRefresh({
textRanges: [{
startOffset: 0,
endOffset: 5,
collapsed: false,
isActive: true,
rangeType: DOC_RANGE_TYPE.TEXT,
}],
rectRanges: [{
startOffset: 20,
endOffset: 30,
collapsed: false,
rangeType: DOC_RANGE_TYPE.RECT,
tableId: 'table-1',
startRow: 0,
endRow: 0,
startColumn: 0,
endColumn: 0,
}],
segmentId: '',
segmentPage: -1,
isEditing: true,
style: {},
} as never, {
unitId: 'test-doc',
subUnitId: 'test-doc',
});
expect(getFormatValueAt('ff', 1)).toBe(undefined);
expect(getFormatValueAt('ff', 21)).toBe(undefined);
await commandService.executeCommand(SetInlineFormatCommand.id, {
segmentId: '',
preCommandId: SetInlineFormatFontFamilyCommand.id,
value: 'Arial',
});
expect(getFormatValueAt('ff', 1)).toBe('Arial');
expect(getFormatValueAt('ff', 21)).toBe(undefined);
await commandService.executeCommand(UndoCommand.id);
});
});
describe('Set Italic by SetInlineFormatCommand', () => {
@@ -163,6 +163,61 @@ function createTableDoc(): IDocumentData {
};
}
function createTableDocWithParagraphsBeforeTable(): IDocumentData {
const table = genEmptyTable(2, 2);
const prefix = 'Title\rBody\r';
const suffix = 'Tail\r\n';
const dataStream = `${prefix}${table.dataStream}${suffix}`;
return {
id: 'test-doc',
body: {
dataStream,
textRuns: [{
st: 0,
ed: dataStream.length - 2,
ts: {},
}],
paragraphs: [
{ startIndex: 5 },
{ startIndex: 10 },
...table.paragraphs.map((paragraph) => ({
...paragraph,
startIndex: paragraph.startIndex + prefix.length,
})),
{
startIndex: dataStream.length - 2,
},
],
sectionBreaks: [
...table.sectionBreaks.map((sectionBreak) => ({
...sectionBreak,
startIndex: sectionBreak.startIndex + prefix.length,
})),
{
startIndex: dataStream.length - 1,
},
],
tables: [{
startIndex: prefix.length,
endIndex: prefix.length + table.dataStream.length,
tableId: 'table-1',
}],
customBlocks: [],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
describe('misc document commands', () => {
let univer: Univer;
let get: Injector['get'];
@@ -290,7 +345,7 @@ describe('misc document commands', () => {
subscription.unsubscribe();
});
it('selects text and table ranges when tables are present', async () => {
it('selects the current paragraph first when tables are present', async () => {
({ univer, get } = createCommandTestBed(createTableDoc()));
commandService = get(ICommandService);
commandService.registerCommand(DocSelectAllCommand);
@@ -317,16 +372,136 @@ describe('misc document commands', () => {
startOffset: 0,
endOffset: 1,
}),
],
}));
subscription.unsubscribe();
});
it('expands to the whole body when the current paragraph selection is split into visual ranges', async () => {
({ univer, get } = createCommandTestBed(createTableDoc()));
commandService = get(ICommandService);
commandService.registerCommand(DocSelectAllCommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
selectionManager.__TEST_ONLY_add([{
startOffset: 0,
endOffset: 0,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
}, {
startOffset: 1,
endOffset: 1,
collapsed: false,
isActive: false,
segmentId: '',
style: null as never,
}], false);
const refreshEvents: Array<unknown> = [];
const subscription = selectionManager.refreshSelection$.subscribe((event) => {
if (event) {
refreshEvents.push(event);
}
});
const result = await commandService.executeCommand(DocSelectAllCommand.id);
await awaitTime(0);
expect(result).toBe(true);
expect(refreshEvents.at(-1)).toEqual(expect.objectContaining({
unitId: 'test-doc',
subUnitId: 'test-doc',
isEditing: false,
docRanges: [
expect.objectContaining({ startOffset: 0, endOffset: 1 }),
expect.objectContaining({ startOffset: 5, endOffset: 19, rangeType: 'RECT' }),
expect.objectContaining({ startOffset: 24, endOffset: 26 }),
],
}));
subscription.unsubscribe();
});
it('expands to the whole body by keeping text before tables selectable across paragraphs', async () => {
({ univer, get } = createCommandTestBed(createTableDocWithParagraphsBeforeTable()));
commandService = get(ICommandService);
commandService.registerCommand(DocSelectAllCommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
selectionManager.__TEST_ONLY_add([{
startOffset: 0,
endOffset: 5,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
}], false);
const refreshEvents: Array<unknown> = [];
const subscription = selectionManager.refreshSelection$.subscribe((event) => {
if (event) {
refreshEvents.push(event);
}
});
const result = await commandService.executeCommand(DocSelectAllCommand.id);
await awaitTime(0);
expect(result).toBe(true);
expect(refreshEvents.at(-1)).toEqual(expect.objectContaining({
unitId: 'test-doc',
subUnitId: 'test-doc',
isEditing: false,
docRanges: [
expect.objectContaining({ startOffset: 0, endOffset: 5 }),
expect.objectContaining({ startOffset: 6, endOffset: 10 }),
expect.objectContaining({ rangeType: 'RECT' }),
expect.objectContaining({ startOffset: expect.any(Number), endOffset: expect.any(Number), rangeType: 'TEXT' }),
],
}));
subscription.unsubscribe();
});
it('selects the current table first when the cursor is inside a table', async () => {
({ univer, get } = createCommandTestBed(createTableDoc()));
commandService = get(ICommandService);
commandService.registerCommand(DocSelectAllCommand);
setCollapsedSelection(6);
const selectionManager = get(DocSelectionManagerService);
const refreshEvents: Array<unknown> = [];
const subscription = selectionManager.refreshSelection$.subscribe((event) => {
if (event) {
refreshEvents.push(event);
}
});
const result = await commandService.executeCommand(DocSelectAllCommand.id);
await awaitTime(0);
expect(result).toBe(true);
expect(refreshEvents.at(-1)).toEqual(expect.objectContaining({
unitId: 'test-doc',
subUnitId: 'test-doc',
isEditing: false,
docRanges: [
expect.objectContaining({
startOffset: 5,
endOffset: 19,
rangeType: 'RECT',
}),
expect.objectContaining({
startOffset: 24,
endOffset: 26,
rangeType: 'TEXT',
}),
],
}));
@@ -0,0 +1,267 @@
/**
* 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, ICommand, IDocumentBody, IDocumentData, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import { CommandType, ICommandService, IUniverInstanceService, JSONX, Tools, UniverInstanceType } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
export interface IMoveDocBlockCommandParams {
unitId?: string;
sourceRange: {
startOffset: number;
endOffset: number;
};
targetOffset: number;
}
export interface IMoveDocBlockActionResult {
nextDocumentData: IDocumentData;
movedRange: {
startOffset: number;
endOffset: number;
};
}
export const MoveDocBlockCommand: ICommand<IMoveDocBlockCommandParams> = {
id: 'doc.command.move-block',
type: CommandType.COMMAND,
handler: async (accessor, params) => {
if (!params) {
return false;
}
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const doc = (params.unitId
? univerInstanceService.getUnit(params.unitId, UniverInstanceType.UNIVER_DOC)
: univerInstanceService.getCurrentUniverDocInstance()) as DocumentDataModel | undefined;
if (!doc) {
return false;
}
const previousDocumentData = doc.getSnapshot();
const { nextDocumentData, movedRange } = buildMoveDocBlockActions({
documentData: previousDocumentData,
sourceRange: params.sourceRange,
targetOffset: params.targetOffset,
});
const actions = buildReplaceDocumentBodyActions(previousDocumentData, nextDocumentData);
if (!actions) {
return false;
}
const textRanges: ITextRangeWithStyle[] = [{
startOffset: movedRange.startOffset,
endOffset: movedRange.endOffset,
collapsed: false,
}];
return Boolean(commandService.syncExecuteCommand<IRichTextEditingMutationParams>(RichTextEditingMutation.id, {
unitId: doc.getUnitId(),
actions,
textRanges,
}));
},
};
export function buildMoveDocBlockActions(params: {
documentData: IDocumentData;
sourceRange: {
startOffset: number;
endOffset: number;
};
targetOffset: number;
}): IMoveDocBlockActionResult {
const nextDocumentData = Tools.deepClone(params.documentData);
const body = nextDocumentData.body;
if (!body?.dataStream) {
return {
nextDocumentData,
movedRange: params.sourceRange,
};
}
const dataStreamLength = body.dataStream.length;
const startOffset = clamp(params.sourceRange.startOffset, 0, dataStreamLength);
const endOffset = clamp(params.sourceRange.endOffset, startOffset, dataStreamLength);
const targetOffset = clamp(params.targetOffset, 0, dataStreamLength);
if (startOffset === endOffset || (targetOffset >= startOffset && targetOffset <= endOffset)) {
return {
nextDocumentData,
movedRange: {
startOffset,
endOffset,
},
};
}
const movingText = body.dataStream.slice(startOffset, endOffset);
const moveLength = movingText.length;
const insertOffset = targetOffset > endOffset ? targetOffset - moveLength : targetOffset;
const withoutMovingText = body.dataStream.slice(0, startOffset) + body.dataStream.slice(endOffset);
body.dataStream = withoutMovingText.slice(0, insertOffset) + movingText + withoutMovingText.slice(insertOffset);
remapBodyIndexesAfterMove(body, startOffset, endOffset, targetOffset, insertOffset, moveLength);
return {
nextDocumentData,
movedRange: {
startOffset: insertOffset,
endOffset: insertOffset + moveLength,
},
};
}
function buildReplaceDocumentBodyActions(previousDocumentData: IDocumentData, nextDocumentData: IDocumentData): JSONXActions | null {
const jsonX = JSONX.getInstance();
const previousBody = previousDocumentData.body;
const nextBody = nextDocumentData.body;
if (!previousBody || !nextBody) {
return null;
}
const rawActions = [
jsonX.replaceOp(['body', 'dataStream'], previousBody.dataStream, nextBody.dataStream),
jsonX.replaceOp(['body', 'paragraphs'], previousBody.paragraphs, nextBody.paragraphs),
jsonX.replaceOp(['body', 'sectionBreaks'], previousBody.sectionBreaks, nextBody.sectionBreaks),
jsonX.replaceOp(['body', 'tables'], previousBody.tables, nextBody.tables),
jsonX.replaceOp(['body', 'customBlocks'], previousBody.customBlocks, nextBody.customBlocks),
jsonX.replaceOp(['body', 'blockRanges'], previousBody.blockRanges, nextBody.blockRanges),
jsonX.replaceOp(['body', 'customRanges'], previousBody.customRanges, nextBody.customRanges),
jsonX.replaceOp(['body', 'customDecorations'], previousBody.customDecorations, nextBody.customDecorations),
jsonX.replaceOp(['body', 'textRuns'], previousBody.textRuns, nextBody.textRuns),
].filter(Boolean) as JSONXActions[];
return rawActions.reduce((acc, cur) => JSONX.compose(acc, cur), null as JSONXActions);
}
function remapBodyIndexesAfterMove(
body: IDocumentBody,
startOffset: number,
endOffset: number,
targetOffset: number,
insertOffset: number,
moveLength: number
) {
body.paragraphs = body.paragraphs?.map((paragraph) => ({
...paragraph,
startIndex: remapIndexAfterMove(paragraph.startIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
})).sort((left, right) => left.startIndex - right.startIndex);
body.sectionBreaks = body.sectionBreaks?.map((sectionBreak) => ({
...sectionBreak,
startIndex: remapIndexAfterMove(sectionBreak.startIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
})).sort((left, right) => left.startIndex - right.startIndex);
body.customBlocks = body.customBlocks?.map((customBlock) => ({
...customBlock,
startIndex: remapIndexAfterMove(customBlock.startIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
})).sort((left, right) => left.startIndex - right.startIndex);
body.tables = body.tables?.map((table) => remapExclusiveRange(table, startOffset, endOffset, targetOffset, insertOffset, moveLength))
.sort((left, right) => left.startIndex - right.startIndex);
body.blockRanges = body.blockRanges?.map((blockRange) => remapInclusiveRange(blockRange, startOffset, endOffset, targetOffset, insertOffset, moveLength))
.sort((left, right) => left.startIndex - right.startIndex);
body.customRanges = body.customRanges?.map((customRange) => remapInclusiveRange(customRange, startOffset, endOffset, targetOffset, insertOffset, moveLength))
.sort((left, right) => left.startIndex - right.startIndex);
body.customDecorations = body.customDecorations?.map((customDecoration) => remapInclusiveRange(customDecoration, startOffset, endOffset, targetOffset, insertOffset, moveLength))
.sort((left, right) => left.startIndex - right.startIndex);
body.textRuns = body.textRuns?.map((textRun) => {
const remapped = remapExclusiveRange({ startIndex: textRun.st, endIndex: textRun.ed }, startOffset, endOffset, targetOffset, insertOffset, moveLength);
return {
...textRun,
st: remapped.startIndex,
ed: remapped.endIndex,
};
}).sort((left, right) => left.st - right.st);
}
function remapExclusiveRange<T extends { startIndex: number; endIndex: number }>(
range: T,
startOffset: number,
endOffset: number,
targetOffset: number,
insertOffset: number,
moveLength: number
): T {
if (range.startIndex >= startOffset && range.endIndex <= endOffset) {
return {
...range,
startIndex: insertOffset + range.startIndex - startOffset,
endIndex: insertOffset + range.endIndex - startOffset,
};
}
return {
...range,
startIndex: remapIndexAfterMove(range.startIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
endIndex: remapIndexAfterMove(range.endIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
};
}
function remapInclusiveRange<T extends { startIndex: number; endIndex: number }>(
range: T,
startOffset: number,
endOffset: number,
targetOffset: number,
insertOffset: number,
moveLength: number
): T {
if (range.startIndex >= startOffset && range.endIndex < endOffset) {
return {
...range,
startIndex: insertOffset + range.startIndex - startOffset,
endIndex: insertOffset + range.endIndex - startOffset,
};
}
return {
...range,
startIndex: remapIndexAfterMove(range.startIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
endIndex: remapIndexAfterMove(range.endIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
};
}
function remapIndexAfterMove(index: number, startOffset: number, endOffset: number, targetOffset: number, insertOffset: number, moveLength: number): number {
if (index >= startOffset && index < endOffset) {
return insertOffset + index - startOffset;
}
if (targetOffset < startOffset && index >= targetOffset && index < startOffset) {
return index + moveLength;
}
if (targetOffset > endOffset && index >= endOffset && index < targetOffset) {
return index - moveLength;
}
return index;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IMutationInfo, IParagraph, ITextRange, JSONXActions, Nullable } from '@univerjs/core';
import type { DocumentDataModel, ICommand, IDocumentBlockRange, IDocumentBody, IMutationInfo, IParagraph, ITextRange, JSONXActions, Nullable } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IRectRangeWithStyle, ITextRangeWithStyle } from '@univerjs/engine-render';
import {
@@ -22,10 +22,12 @@ import {
BuildTextUtils,
CommandType,
DataStreamTreeTokenType,
HorizontalAlign,
ICommandService,
IUniverInstanceService,
JSONX,
PositionedObjectLayoutType,
sequenceExecuteAsync,
TextX,
TextXActionType,
Tools,
@@ -35,6 +37,7 @@ import {
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getParagraphByGlyph, hasListGlyph, isFirstGlyph, isIndentByGlyph } from '@univerjs/engine-render';
import { DocAutoFormatService } from '../../services/doc-auto-format.service';
import { DeleteDirection } from '../../types/delete-direction';
import { getCommandSkeleton, getRichTextEditPath } from '../util';
import { CutContentCommand } from './clipboard.inner.command';
@@ -401,6 +404,21 @@ export function getCursorWhenDelete(textRanges: Readonly<Nullable<ITextRangeWith
return cursor;
}
function getBlockRangeEndTokenOffset(body: IDocumentBody, blockRange: Pick<IDocumentBlockRange, 'endIndex'>): number {
return body.dataStream[blockRange.endIndex] === DataStreamTreeTokenType.BLOCK_END
? blockRange.endIndex
: body.dataStream[blockRange.endIndex + 1] === DataStreamTreeTokenType.BLOCK_END
? blockRange.endIndex + 1
: blockRange.endIndex;
}
export function isDeleteOffsetInsideBlockRange(body: IDocumentBody, offset: number): boolean {
return body.blockRanges?.some((blockRange) => {
const endTokenOffset = getBlockRangeEndTokenOffset(body, blockRange);
return blockRange.startIndex < offset && offset < endTokenOffset;
}) ?? false;
}
// Handle BACKSPACE key.
export const DeleteLeftCommand: ICommand = {
id: 'doc.command.delete-left',
@@ -449,6 +467,11 @@ export const DeleteLeftCommand: ICommand = {
return false;
}
const autoFormatResult = await executeDeleteAutoFormat(accessor, DeleteLeftCommand.id);
if (autoFormatResult != null) {
return autoFormatResult;
}
const { segmentId, style, segmentPage } = activeRange;
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId).getBody();
@@ -459,6 +482,13 @@ export const DeleteLeftCommand: ICommand = {
const actualRange = activeRange;
const { startOffset, collapsed } = actualRange;
if (collapsed) {
const emptyCenteredParagraph = getEmptyCenteredParagraphAtOffset(body, startOffset);
if (emptyCenteredParagraph != null) {
return resetEmptyCenteredParagraphAlignment(commandService, docDataModel, segmentId ?? '', emptyCenteredParagraph, style);
}
}
const curGlyph = skeleton.findNodeByCharIndex(startOffset, segmentId, segmentPage);
// is in bullet list?
@@ -470,9 +500,10 @@ export const DeleteLeftCommand: ICommand = {
// Get the deleted glyph. It maybe null or undefined when the curGlyph is first glyph in skeleton.
const preGlyph = skeleton.findNodeByCharIndex(startOffset - 1, segmentId, segmentPage);
const isInBlockRange = isDeleteOffsetInsideBlockRange(body, startOffset);
const isUpdateParagraph =
isFirstGlyph(curGlyph) && preGlyph !== curGlyph && (isBullet === true || isIndent === true);
!isInBlockRange && isFirstGlyph(curGlyph) && preGlyph !== curGlyph && (isBullet === true || isIndent === true);
if (isUpdateParagraph && collapsed) {
const paragraph = getParagraphByGlyph(curGlyph, body);
@@ -669,6 +700,11 @@ export const DeleteRightCommand: ICommand = {
return false;
}
const autoFormatResult = await executeDeleteAutoFormat(accessor, DeleteRightCommand.id);
if (autoFormatResult != null) {
return autoFormatResult;
}
const { segmentId, style, segmentPage } = activeRange;
const body = docDataModel?.getSelfOrHeaderFooterModel(segmentId).getBody();
@@ -678,6 +714,13 @@ export const DeleteRightCommand: ICommand = {
const actualRange = activeRange;
const { startOffset, endOffset, collapsed } = actualRange;
if (collapsed) {
const emptyCenteredParagraph = getEmptyCenteredParagraphAtOffset(body, startOffset);
if (emptyCenteredParagraph != null) {
return resetEmptyCenteredParagraphAlignment(commandService, docDataModel, segmentId ?? '', emptyCenteredParagraph, style);
}
}
// No need to delete when the cursor is at the last position of the last paragraph.
if (startOffset === body.dataStream.length - 2 && collapsed) {
return true;
@@ -775,6 +818,20 @@ export const DeleteRightCommand: ICommand = {
},
};
async function executeDeleteAutoFormat(accessor: Parameters<ICommand['handler']>[0], commandId: string): Promise<boolean | null> {
if (!accessor.has(DocAutoFormatService)) {
return null;
}
const commandService = accessor.get(ICommandService);
const mutations = accessor.get(DocAutoFormatService).onAutoFormat(commandId);
if (!mutations.length) {
return null;
}
return (await sequenceExecuteAsync(mutations, commandService)).result;
}
// get cursor position when BACKSPACE/DELETE excuse the CutContentCommand.
function getTextRangesWhenDelete(activeRange: ITextRangeWithStyle, ranges: readonly ITextRange[]) {
let cursor = activeRange.endOffset;
@@ -802,6 +859,56 @@ function getTextRangesWhenDelete(activeRange: ITextRangeWithStyle, ranges: reado
return textRanges;
}
function getEmptyCenteredParagraphAtOffset(body: IDocumentBody, offset: number) {
const paragraphs = body.paragraphs ?? [];
for (let i = 0; i < paragraphs.length; i++) {
const paragraph = paragraphs[i];
const paragraphTextStart = i === 0 ? 0 : paragraphs[i - 1].startIndex + 1;
if (
paragraph.startIndex === paragraphTextStart &&
paragraph.startIndex === offset &&
paragraph.paragraphStyle?.horizontalAlign === HorizontalAlign.CENTER
) {
return paragraph;
}
}
}
function resetEmptyCenteredParagraphAlignment(
commandService: ICommandService,
docDataModel: DocumentDataModel,
segmentId: string,
paragraph: IParagraph,
style: ITextRangeWithStyle['style']
) {
return commandService.executeCommand(UpdateCommand.id, {
unitId: docDataModel.getUnitId(),
updateBody: {
dataStream: '',
paragraphs: [{
...paragraph,
startIndex: 0,
paragraphStyle: {
...paragraph.paragraphStyle,
horizontalAlign: HorizontalAlign.LEFT,
},
}],
},
range: {
startOffset: paragraph.startIndex,
endOffset: paragraph.startIndex + 1,
},
textRanges: [{
startOffset: paragraph.startIndex,
endOffset: paragraph.startIndex,
collapsed: true,
style,
}],
coverType: UpdateDocsAttributeType.REPLACE,
segmentId,
});
}
export const DeleteCurrentParagraphCommand: ICommand = {
id: 'doc.command.delete-current-paragraph',
type: CommandType.COMMAND,
@@ -15,7 +15,8 @@
*/
import type { ICommand, ITextRangeParam } from '@univerjs/core';
import { CommandType, DashStyleType, ICommandService } from '@univerjs/core';
import { CommandType, DashStyleType, ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocContentInsertService } from '../../services/doc-content-insert.service';
import { BreakLineCommand } from './break-line.command';
import { getCurrentParagraph } from './util';
@@ -50,6 +51,22 @@ export const InsertHorizontalLineBellowCommand: ICommand<IHorizontalCommandParam
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
const doc = accessor.get(IUniverInstanceService).getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC);
let contentInsertRange: ReturnType<DocContentInsertService['consumeInsertRange']> = null;
try {
contentInsertRange = accessor.get(DocContentInsertService).consumeInsertRange(doc?.getUnitId());
} catch {
contentInsertRange = null;
}
if (contentInsertRange) {
return commandService.syncExecuteCommand(HorizontalLineCommand.id, {
insertRange: {
startOffset: contentInsertRange.startOffset,
endOffset: contentInsertRange.endOffset,
},
});
}
const paragraph = getCurrentParagraph(accessor);
if (!paragraph) {
return false;
@@ -14,20 +14,84 @@
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, ICommandInfo, ISize, JSONXActions, PageOrientType } from '@univerjs/core';
import type { DocumentDataModel, ICommand, ICommandInfo, IDocumentStyle, ISize, ITable, ITables, JSONXActions, PageOrientType } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import { CommandType, ICommandService, IUniverInstanceService, JSONX, UniverInstanceType } from '@univerjs/core';
import { CommandType, DocumentFlavor, ICommandService, IUniverInstanceService, JSONX, MODERN_DOCUMENT_DEFAULT_MARGIN, UniverInstanceType } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
export interface IDocPageSetupCommandParams {
pageSize: ISize;
pageOrient: PageOrientType;
documentFlavor?: DocumentFlavor;
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
}
const PAGE_FILL_TABLE_TOLERANCE = 2;
interface IPageContentWidthConfig {
documentFlavor?: DocumentFlavor;
pageSize?: ISize;
marginLeft?: number;
marginRight?: number;
}
function getPageContentWidth(config: IPageContentWidthConfig) {
const pageWidth = config.pageSize?.width ?? 0;
if (!pageWidth) {
return 0;
}
if (config.documentFlavor === DocumentFlavor.MODERN) {
return Math.max(0, pageWidth - MODERN_DOCUMENT_DEFAULT_MARGIN * 2);
}
return Math.max(0, pageWidth - (config.marginLeft ?? 0) - (config.marginRight ?? 0));
}
function getTableColumnTotalWidth(table: ITable) {
return table.tableColumns.reduce((total, column) => total + (column.size.width.v ?? 0), 0);
}
function isPageFillTable(table: ITable, oldContentWidth: number) {
if (oldContentWidth <= 0 || table.tableColumns.length === 0) {
return false;
}
const columnTotalWidth = getTableColumnTotalWidth(table);
const tableWidth = columnTotalWidth || table.size.width.v;
return Math.abs(tableWidth - oldContentWidth) <= PAGE_FILL_TABLE_TOLERANCE;
}
function resizePageFillTables(jsonX: JSONX, rawActions: NonNullable<JSONXActions>, tableSource: ITables | undefined, oldContentWidth: number, newContentWidth: number) {
if (!tableSource || oldContentWidth <= 0 || newContentWidth <= 0 || Math.abs(oldContentWidth - newContentWidth) <= PAGE_FILL_TABLE_TOLERANCE) {
return;
}
Object.entries(tableSource).forEach(([tableId, table]) => {
if (!isPageFillTable(table, oldContentWidth)) {
return;
}
const totalColumnWidth = getTableColumnTotalWidth(table);
if (totalColumnWidth <= 0) {
return;
}
const tableWidthAction = jsonX.replaceOp(['tableSource', tableId, 'size', 'width', 'v'], table.size.width.v, newContentWidth);
tableWidthAction && rawActions.push(tableWidthAction);
table.tableColumns.forEach((column, index) => {
const nextWidth = (column.size.width.v / totalColumnWidth) * newContentWidth;
const action = jsonX.replaceOp(['tableSource', tableId, 'tableColumns', index, 'size', 'width', 'v'], column.size.width.v, nextWidth);
action && rawActions.push(action);
});
});
}
export const DocPageSetupCommand: ICommand<IDocPageSetupCommandParams> = {
id: 'docs.command.page-setup',
type: CommandType.COMMAND,
@@ -38,9 +102,20 @@ export const DocPageSetupCommand: ICommand<IDocPageSetupCommandParams> = {
const commandService = accessor.get(ICommandService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!docDataModel) return false;
const { marginLeft, marginRight, marginBottom, marginTop, pageOrient, pageSize } = params;
const { documentFlavor, marginLeft, marginRight, marginBottom, marginTop, pageOrient, pageSize } = params;
const jsonX = JSONX.getInstance();
const documentStyle = docDataModel.getDocumentStyle();
const snapshot = docDataModel.getSnapshot();
const newDocumentStyle: IDocumentStyle = {
...documentStyle,
documentFlavor: documentFlavor ?? documentStyle.documentFlavor,
marginBottom,
marginLeft,
marginRight,
marginTop,
pageOrient,
pageSize,
};
const {
marginBottom: oldMarginBottom,
marginLeft: oldMarginLeft,
@@ -48,8 +123,27 @@ export const DocPageSetupCommand: ICommand<IDocPageSetupCommandParams> = {
marginTop: oldMarginTop,
pageOrient: oldPageOrient,
pageSize: oldPageSize,
documentFlavor: oldDocumentFlavor,
} = documentStyle;
const rawActions: JSONXActions = [];
const rawActions: NonNullable<JSONXActions> = [];
resizePageFillTables(
jsonX,
rawActions,
snapshot.tableSource,
getPageContentWidth(documentStyle),
getPageContentWidth(newDocumentStyle)
);
if (documentFlavor !== undefined) {
if (oldDocumentFlavor === undefined) {
const action = jsonX.insertOp(['documentStyle', 'documentFlavor'], documentFlavor);
action && rawActions.push(action);
} else {
const action = jsonX.replaceOp(['documentStyle', 'documentFlavor'], oldDocumentFlavor, documentFlavor);
action && rawActions.push(action);
}
}
if (oldMarginBottom === undefined) {
const action = jsonX.insertOp(['documentStyle', 'marginBottom'], marginBottom);
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { DocumentDataModel, ICommand } from '@univerjs/core';
import type { DocumentDataModel, ICommand, ICustomTable, IDocumentBody, IParagraph } from '@univerjs/core';
import type { ISuccinctDocRangeParam } from '@univerjs/engine-render';
import { CommandType, DOC_RANGE_TYPE, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
@@ -41,40 +41,14 @@ export const DocSelectAllCommand: ICommand<ISelectAllCommandParams> = {
return false;
}
const { tables = [], dataStream } = body;
const { dataStream } = body;
if (dataStream === '\r\n') {
return true;
}
const textRanges: ISuccinctDocRangeParam[] = [];
let offset = 0;
for (const table of tables) {
const { startIndex, endIndex } = table;
if (offset !== startIndex) {
textRanges.push({
startOffset: offset,
endOffset: startIndex - 1,
rangeType: DOC_RANGE_TYPE.TEXT,
});
}
// Push the rect range.
textRanges.push({
startOffset: startIndex + 3, // 3 is TABLE_START, ROW_START, CELL_START.
endOffset: endIndex - 5, // 4 is CELL_END, ROW_END, TABLE_END AND \n.
rangeType: DOC_RANGE_TYPE.RECT,
});
offset = endIndex;
}
if (offset !== body.dataStream.length - 2) {
textRanges.push({
startOffset: offset,
endOffset: body.dataStream.length - 2,
rangeType: DOC_RANGE_TYPE.TEXT,
});
}
const wholeDocRanges = getWholeDocumentRanges(body);
const scopedRange = getScopedSelectAllRange(body, activeRange);
const textRanges = scopedRange && !isSameRanges(docRanges, scopedRange) ? scopedRange : wholeDocRanges;
docSelectionManagerService.replaceDocRanges(textRanges, {
unitId,
@@ -84,3 +58,187 @@ export const DocSelectAllCommand: ICommand<ISelectAllCommandParams> = {
return true;
},
};
function getWholeDocumentRanges(body: IDocumentBody): ISuccinctDocRangeParam[] {
const textRanges: ISuccinctDocRangeParam[] = [];
let offset = 0;
for (const table of body.tables ?? []) {
const { startIndex, endIndex } = table;
if (offset !== startIndex) {
textRanges.push(...getTextRangesByParagraphs(body, offset, startIndex - 1));
}
textRanges.push(getTableRectRange(table));
offset = endIndex;
}
if (offset !== body.dataStream.length - 2) {
textRanges.push(...getTextRangesByParagraphs(body, offset, body.dataStream.length - 2));
}
return textRanges;
}
function getTextRangesByParagraphs(body: IDocumentBody, startOffset: number, endOffset: number): ISuccinctDocRangeParam[] {
if (startOffset > endOffset) {
return [];
}
const paragraphs = [...(body.paragraphs ?? [])].sort((left, right) => left.startIndex - right.startIndex);
const ranges: ISuccinctDocRangeParam[] = [];
let offset = startOffset;
for (const paragraph of paragraphs) {
if (paragraph.startIndex < offset) {
continue;
}
if (paragraph.startIndex > endOffset) {
break;
}
ranges.push({
startOffset: offset,
endOffset: paragraph.startIndex,
rangeType: DOC_RANGE_TYPE.TEXT,
});
offset = paragraph.startIndex + 1;
}
if (offset <= endOffset) {
ranges.push({
startOffset: offset,
endOffset,
rangeType: DOC_RANGE_TYPE.TEXT,
});
}
return ranges;
}
function getScopedSelectAllRange(body: IDocumentBody, activeRange: ISuccinctDocRangeParam): ISuccinctDocRangeParam[] | null {
const startOffset = activeRange.startOffset;
const endOffset = activeRange.endOffset;
if (startOffset == null || endOffset == null) {
return null;
}
const table = (body.tables ?? []).find((item) => isRangeInside(startOffset, endOffset, item.startIndex, item.endIndex));
if (table) {
return [getTableRectRange(table)];
}
const customBlock = (body.customBlocks ?? []).find((item) => item.startIndex >= startOffset && item.startIndex <= endOffset);
if (customBlock) {
return [{
endOffset: customBlock.startIndex,
rangeType: DOC_RANGE_TYPE.TEXT,
startOffset: customBlock.startIndex,
}];
}
const blockRange = (body.blockRanges ?? []).find((item) => isRangeInside(startOffset, endOffset, item.startIndex, item.endIndex));
if (blockRange) {
return [{
endOffset: Math.max(blockRange.startIndex + 1, blockRange.endIndex - 1),
rangeType: DOC_RANGE_TYPE.TEXT,
startOffset: blockRange.startIndex + 1,
}];
}
const paragraphRange = clampParagraphRangeByTables(getParagraphRangeAtOffset(body.paragraphs ?? [], startOffset), body.tables ?? [], startOffset);
return paragraphRange
? [{
...paragraphRange,
rangeType: DOC_RANGE_TYPE.TEXT,
}]
: null;
}
function clampParagraphRangeByTables(
paragraphRange: Pick<ISuccinctDocRangeParam, 'endOffset' | 'startOffset'> | null,
tables: ICustomTable[],
activeOffset: number
): Pick<ISuccinctDocRangeParam, 'endOffset' | 'startOffset'> | null {
if (!paragraphRange) {
return null;
}
const nextRange = { ...paragraphRange };
for (const table of tables) {
if (activeOffset < table.startIndex && nextRange.startOffset! < table.startIndex && table.startIndex <= nextRange.endOffset!) {
nextRange.endOffset = table.startIndex - 1;
} else if (activeOffset >= table.endIndex && nextRange.startOffset! <= table.endIndex && table.endIndex < nextRange.endOffset!) {
nextRange.startOffset = table.endIndex;
}
}
return nextRange.startOffset! <= nextRange.endOffset! ? nextRange : null;
}
function getTableRectRange(table: ICustomTable): ISuccinctDocRangeParam {
return {
startOffset: table.startIndex + 3,
endOffset: table.endIndex - 5,
rangeType: DOC_RANGE_TYPE.RECT,
};
}
function getParagraphRangeAtOffset(paragraphs: IParagraph[], offset: number): Pick<ISuccinctDocRangeParam, 'endOffset' | 'startOffset'> | null {
const sortedParagraphs = [...paragraphs].sort((left, right) => left.startIndex - right.startIndex);
for (let index = 0; index < sortedParagraphs.length; index++) {
const paragraph = sortedParagraphs[index];
const startOffset = index === 0 ? 0 : sortedParagraphs[index - 1].startIndex + 1;
if (startOffset <= offset && offset <= paragraph.startIndex) {
return {
endOffset: paragraph.startIndex,
startOffset,
};
}
}
return null;
}
function isRangeInside(startOffset: number, endOffset: number, scopeStart: number, scopeEnd: number): boolean {
return startOffset >= scopeStart && endOffset <= scopeEnd;
}
function isSameRanges(currentRanges: ISuccinctDocRangeParam[], nextRanges: ISuccinctDocRangeParam[]): boolean {
if (currentRanges.length !== nextRanges.length) {
return isSameTextRangeCoverage(currentRanges, nextRanges);
}
return currentRanges.every((currentRange, index) => {
const nextRange = nextRanges[index];
return currentRange.startOffset === nextRange.startOffset &&
currentRange.endOffset === nextRange.endOffset &&
getRangeType(currentRange) === getRangeType(nextRange);
});
}
function isSameTextRangeCoverage(currentRanges: ISuccinctDocRangeParam[], nextRanges: ISuccinctDocRangeParam[]): boolean {
if (nextRanges.length !== 1 || getRangeType(nextRanges[0]) !== DOC_RANGE_TYPE.TEXT) {
return false;
}
const targetRange = nextRanges[0];
if (targetRange.startOffset == null || targetRange.endOffset == null) {
return false;
}
if (currentRanges.some((range) => getRangeType(range) !== DOC_RANGE_TYPE.TEXT || range.startOffset == null || range.endOffset == null)) {
return false;
}
const startOffset = Math.min(...currentRanges.map((range) => range.startOffset!));
const endOffset = Math.max(...currentRanges.map((range) => range.endOffset!));
return startOffset === targetRange.startOffset &&
endOffset === targetRange.endOffset &&
currentRanges.every((range) => range.startOffset! >= targetRange.startOffset! && range.endOffset! <= targetRange.endOffset!);
}
function getRangeType(range: ISuccinctDocRangeParam): DOC_RANGE_TYPE {
return range.rangeType ?? DOC_RANGE_TYPE.TEXT;
}
@@ -253,7 +253,10 @@ export const SetInlineFormatCommand: ICommand<ISetInlineFormatCommandParams> = {
const univerInstanceService = accessor.get(IUniverInstanceService);
const docMenuStyleService = accessor.get(DocMenuStyleService);
const docRanges = docSelectionManagerService.getDocRanges();
const textRanges = docSelectionManagerService.getTextRanges() ?? [];
const docRanges = textRanges.length > 0
? textRanges.filter((range) => range.startOffset != null && range.endOffset != null)
: docSelectionManagerService.getDocRanges();
const activeRange = docRanges.find((r) => r.isActive) ?? docRanges[0];
if (docRanges.length === 0) {
@@ -33,6 +33,7 @@ import {
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocContentInsertService } from '../../services/doc-content-insert.service';
import { getRichTextEditPath } from '../util';
import { getCurrentParagraph } from './util';
@@ -345,6 +346,7 @@ export const ToggleCheckListCommand: ICommand<IToggleCheckListCommandParams> = {
interface IOrderListCommandParams {
value?: PresetListType;
docRange?: ITextRangeWithStyle[];
}
export const OrderListCommand: ICommand<IOrderListCommandParams> = {
@@ -357,11 +359,13 @@ export const OrderListCommand: ICommand<IOrderListCommandParams> = {
if (params?.value) {
return commandService.syncExecuteCommand(ChangeListTypeCommand.id, {
listType: params.value,
docRange: params.docRange,
});
}
return commandService.syncExecuteCommand(ListOperationCommand.id, {
listType: PresetListType.ORDER_LIST,
docRange: params?.docRange,
});
},
};
@@ -483,19 +487,28 @@ export const QuickListCommand: ICommand<IQuickListCommandParams> = {
function insertList(accessor: IAccessor, listType: PresetListType) {
const commandService = accessor.get(ICommandService);
const paragraph = getCurrentParagraph(accessor);
if (!paragraph) {
return false;
}
const docDataModel = accessor.get(IUniverInstanceService).getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!docDataModel) {
return false;
}
let contentInsertRange: ReturnType<DocContentInsertService['consumeInsertRange']> = null;
try {
contentInsertRange = accessor.get(DocContentInsertService).consumeInsertRange(docDataModel.getUnitId());
} catch {
contentInsertRange = null;
}
const paragraph = contentInsertRange ? null : getCurrentParagraph(accessor);
if (!contentInsertRange && !paragraph) {
return false;
}
const sourceParagraph = paragraph || undefined;
const insertOffset = contentInsertRange?.startOffset ?? sourceParagraph!.startIndex + 1;
const sourceBullet = sourceParagraph?.bullet;
const textX = BuildTextUtils.selection.replace({
doc: docDataModel,
selection: {
startOffset: paragraph.startIndex + 1,
endOffset: paragraph.startIndex + 1,
startOffset: insertOffset,
endOffset: contentInsertRange?.endOffset ?? insertOffset,
collapsed: true,
},
body: {
@@ -504,12 +517,12 @@ function insertList(accessor: IAccessor, listType: PresetListType) {
{
startIndex: 0,
paragraphStyle: {
...paragraph.paragraphStyle,
...(sourceParagraph?.paragraphStyle ?? {}),
},
bullet: {
listType,
listId: paragraph.bullet?.listType === listType ? paragraph.bullet.listId : generateRandomId(6),
nestingLevel: paragraph.bullet?.listType === listType ? paragraph.bullet.nestingLevel : 0,
listId: sourceBullet?.listType === listType ? sourceBullet.listId : generateRandomId(6),
nestingLevel: sourceBullet?.listType === listType ? sourceBullet.nestingLevel : 0,
},
},
],
@@ -525,8 +538,8 @@ function insertList(accessor: IAccessor, listType: PresetListType) {
unitId: docDataModel.getUnitId(),
actions: [],
textRanges: [{
startOffset: paragraph.startIndex + 1,
endOffset: paragraph.startIndex + 1,
startOffset: insertOffset,
endOffset: insertOffset,
collapsed: true,
}],
isEditing: false,
@@ -19,6 +19,7 @@ import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import { BuildTextUtils, CommandType, generateRandomId, ICommandService, IUniverInstanceService, JSONX, NamedStyleType, TextX, TextXActionType, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocContentInsertService } from '../../services/doc-content-insert.service';
import { getRichTextEditPath } from '../util';
export interface ISetParagraphNamedStyleCommandParams {
@@ -41,6 +42,11 @@ export const SetParagraphNamedStyleCommand: ICommand<ISetParagraphNamedStyleComm
}
const unitId = doc.getUnitId();
const selectionService = accessor.get(DocSelectionManagerService);
const contentInsertRange = params.textRanges ? null : consumeContentInsertRange(accessor, unitId);
if (contentInsertRange) {
return insertNamedStyleParagraph(accessor, doc, params.value, contentInsertRange.startOffset, contentInsertRange.endOffset);
}
const selections = params.textRanges ?? selectionService.getTextRanges({ unitId, subUnitId: unitId });
if (!selections?.length) {
return false;
@@ -79,6 +85,58 @@ export const SetParagraphNamedStyleCommand: ICommand<ISetParagraphNamedStyleComm
},
};
function consumeContentInsertRange(accessor: Parameters<ICommand['handler']>[0], unitId: string) {
try {
return accessor.get(DocContentInsertService).consumeInsertRange(unitId);
} catch {
return null;
}
}
function insertNamedStyleParagraph(
accessor: Parameters<ICommand['handler']>[0],
doc: DocumentDataModel,
namedStyleType: NamedStyleType,
startOffset: number,
endOffset: number
): boolean {
const textX = BuildTextUtils.selection.replace({
doc,
selection: {
startOffset,
endOffset,
collapsed: startOffset === endOffset,
},
body: {
dataStream: '\r',
paragraphs: [{
startIndex: 0,
paragraphStyle: {
namedStyleType,
headingId: !namedStyleType || namedStyleType === NamedStyleType.NORMAL_TEXT ? undefined : generateRandomId(6),
},
}],
},
});
if (!textX) {
return false;
}
const jsonX = JSONX.getInstance();
const commandService = accessor.get(ICommandService);
return Boolean(commandService.syncExecuteCommand<IRichTextEditingMutationParams>(RichTextEditingMutation.id, {
actions: jsonX.editOp(textX.serialize(), getRichTextEditPath(doc)),
isEditing: false,
textRanges: [{
startOffset,
endOffset: startOffset,
collapsed: true,
}],
unitId: doc.getUnitId(),
}));
}
export const QuickHeadingCommand: ICommand<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.quick-heading',
type: CommandType.COMMAND,
@@ -20,6 +20,7 @@ import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import { CommandType, DataStreamTreeTokenType, ICommandService, IUniverInstanceService, JSONX, TextX, TextXActionType } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getTextRunAtPosition } from '../../../basics/paragraph';
import { DocContentInsertService } from '../../../services/doc-content-insert.service';
import { DocMenuStyleService } from '../../../services/doc-menu-style.service';
import { getCommandSkeleton, getRichTextEditPath } from '../../util';
import { generateParagraphs } from '../break-line.command';
@@ -46,14 +47,25 @@ export const CreateDocTableCommand: ICommand<ICreateDocTableCommandParams> = {
const commandService = accessor.get(ICommandService);
const docMenuStyleService = accessor.get(DocMenuStyleService);
const activeRange = docSelectionManagerService.getActiveTextRange();
if (activeRange == null) {
const docDataModel = univerInstanceService.getCurrentUniverDocInstance();
if (docDataModel == null) {
return false;
}
const { segmentId, segmentPage } = activeRange;
const docDataModel = univerInstanceService.getCurrentUniverDocInstance();
let contentInsertRange: ReturnType<DocContentInsertService['consumeInsertRange']> = null;
try {
contentInsertRange = accessor.get(DocContentInsertService).consumeInsertRange(docDataModel.getUnitId());
} catch {
contentInsertRange = null;
}
const activeRange = docSelectionManagerService.getActiveTextRange();
if (activeRange == null && contentInsertRange == null) {
return false;
}
const segmentId = contentInsertRange?.segmentId ?? activeRange?.segmentId ?? '';
const segmentPage = activeRange?.segmentPage;
const body = docDataModel?.getSelfOrHeaderFooterModel(segmentId).getBody();
if (docDataModel == null || body == null) {
if (body == null) {
return false;
}
@@ -64,7 +76,7 @@ export const CreateDocTableCommand: ICommand<ICreateDocTableCommandParams> = {
if (skeleton == null) {
return false;
}
const { startOffset } = activeRange;
const startOffset = contentInsertRange?.startOffset ?? activeRange!.startOffset;
const paragraphs = body.paragraphs ?? [];
const prevParagraph = paragraphs.find((p) => p.startIndex >= startOffset);
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import type { ICommand, PaperType } from '@univerjs/core';
import type { ICommand, ModernDocumentWidthMode, PaperType } from '@univerjs/core';
import type { IConfirmPartMethodOptions } from '@univerjs/ui';
import type { IDocPageSetupCommandParams } from '../commands/doc-page-setup.command';
import { CommandType, ICommandService, IConfirmService, LocaleService, PAGE_SIZE } from '@univerjs/core';
import { CommandType, DocumentFlavor, ICommandService, IConfirmService, LocaleService, MODERN_DOCUMENT_WIDTH, PAGE_SIZE } from '@univerjs/core';
import { PAGE_SETTING_COMPONENT_ID } from '../../views/page-settings';
import { DocPageSetupCommand } from '../commands/doc-page-setup.command';
@@ -43,8 +43,16 @@ export const DocOpenPageSettingCommand: ICommand = {
onConfirm: (result) => {
disposable.dispose();
if (!result) return;
const paperSize = PAGE_SIZE[result.paperSize as PaperType];
const paperSize = result.mode === DocumentFlavor.MODERN
? {
width: MODERN_DOCUMENT_WIDTH[result.modernWidth as ModernDocumentWidthMode],
height: PAGE_SIZE.A4.height,
}
: PAGE_SIZE[result.paperSize as PaperType];
commandService.executeCommand(DocPageSetupCommand.id, {
documentFlavor: result.mode,
pageOrient: result.orientation,
marginTop: result.margins.top,
marginBottom: result.margins.bottom,
@@ -36,6 +36,7 @@ import {
} from '../../commands/commands/inline-format.command';
import enUS from '../../locale/en-US';
import zhCN from '../../locale/zh-CN';
import { FLOAT_TEXT_STYLE_MENU_ID } from '../../menu/menu';
import { menuSchema } from '../../menu/schema';
import { FloatToolbar } from './FloatToolbar';
@@ -95,6 +96,7 @@ export const Playground = {
<div>
<FloatToolbar
avaliableMenus={[
FLOAT_TEXT_STYLE_MENU_ID,
SetInlineFormatFontFamilyCommand.id,
SetInlineFormatFontSizeCommand.id,
SetInlineFormatBoldCommand.id,
@@ -29,12 +29,14 @@ import {
SetInlineFormatTextColorCommand,
SetInlineFormatUnderlineCommand,
} from '../../commands/commands/inline-format.command';
import { FLOAT_TEXT_STYLE_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION } from '../../menu/menu';
interface IFloatToolbarProps {
avaliableMenus?: string[];
}
const DEFAULT_AVALIABLE_MENUS: string[] = [
FLOAT_TEXT_STYLE_MENU_ID,
SetInlineFormatFontSizeCommand.id,
SetInlineFormatBoldCommand.id,
SetInlineFormatItalicCommand.id,
@@ -56,7 +58,10 @@ export function FloatToolbar(props: IFloatToolbarProps) {
// subscribe to menu changes
useEffect(() => {
function getRibbon(): void {
const flatMenus = menuManagerService.getFlatMenuByPositionKey(MenuManagerPosition.RIBBON);
const flatMenus = [
...menuManagerService.getFlatMenuByPositionKey(FLOAT_TOOLBAR_MENU_POSITION),
...menuManagerService.getFlatMenuByPositionKey(MenuManagerPosition.RIBBON),
];
const menus: IMenuSchema[] = [];
for (const key of avaliableMenus) {
@@ -0,0 +1,215 @@
/**
* 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 { NamedStyleType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getParagraphMenuActiveHeadingCommandId, getParagraphMenuCommand, getParagraphMenuHiddenHeadingCommandIds, getParagraphMenuIconSizeClass, getParagraphMenuPopupDirection, getParagraphMenuTargetRange, isEmptyParagraphMenuTarget, shouldShowParagraphSettingMenu, shouldUseInsertBelowRange } from '..';
import { HorizontalLineCommand } from '../../../commands/commands/doc-horizontal-line.command';
import { BulletListCommand, InsertBulletListBellowCommand, OrderListCommand } from '../../../commands/commands/list.command';
import { H1HeadingCommand, H3HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../../commands/commands/set-heading.command';
import { CreateDocTableCommand } from '../../../commands/commands/table/doc-table-create.command';
import {
EmptyParagraphBulletListMenuItemFactory,
EmptyParagraphH1MenuItemFactory,
EmptyParagraphHorizontalLineMenuItemFactory,
HEADING_ICON_MAP,
INSERT_BELLOW_MENU_ID,
shouldShowParagraphHeadingOption,
TableBlockCopyMenuItemFactory,
TableBlockDeleteMenuItemFactory,
TableBlockPasteMenuItemFactory,
} from '../../../menu/paragraph-menu';
describe('ParagraphMenu', () => {
it('uses a smaller icon for normal text paragraph triggers', () => {
expect(getParagraphMenuIconSizeClass('TextTypeIcon')).toBe('univer-size-3');
expect(getParagraphMenuIconSizeClass('TitleTypeIcon')).toBe('univer-size-4');
expect(getParagraphMenuIconSizeClass('SubtitleTypeIcon')).toBe('univer-size-4');
expect(getParagraphMenuIconSizeClass('H1Icon')).toBe('univer-size-4');
expect(HEADING_ICON_MAP[NamedStyleType.TITLE].key).toBe('TitleTypeIcon');
expect(HEADING_ICON_MAP[NamedStyleType.SUBTITLE].key).toBe('SubtitleTypeIcon');
});
it('uses fully-qualified locale keys for paragraph context menu labels', () => {
const accessor = {
get: () => ({
get: () => undefined,
register: () => undefined,
}),
} as never;
expect(EmptyParagraphH1MenuItemFactory(accessor).title).toBe('ui.toolbar.heading.1');
expect(EmptyParagraphBulletListMenuItemFactory(accessor).title).toBe('docs-ui.rightClick.bulletList');
expect(EmptyParagraphHorizontalLineMenuItemFactory(accessor).title).toBe('docs-ui.toolbar.horizontalLine');
expect(TableBlockCopyMenuItemFactory(accessor).title).toBe('docs-ui.rightClick.copy');
expect(TableBlockPasteMenuItemFactory(accessor).title).toBe('docs-ui.rightClick.paste');
expect(TableBlockDeleteMenuItemFactory(accessor).title).toBe('docs-ui.rightClick.delete');
});
it('opens the popup away from the drag handle when there is not enough left space', () => {
expect(getParagraphMenuPopupDirection(170)).toBe('right');
expect(getParagraphMenuPopupDirection(260)).toBe('left');
});
it('shows title and subtitle heading shortcuts only when they are the current paragraph style', () => {
expect(shouldShowParagraphHeadingOption(NamedStyleType.HEADING_5, NamedStyleType.NORMAL_TEXT)).toBe(true);
expect(shouldShowParagraphHeadingOption(NamedStyleType.TITLE, NamedStyleType.NORMAL_TEXT)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.SUBTITLE, NamedStyleType.NORMAL_TEXT)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.HEADING_5, NamedStyleType.TITLE)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.TITLE, NamedStyleType.TITLE)).toBe(true);
expect(shouldShowParagraphHeadingOption(NamedStyleType.SUBTITLE, NamedStyleType.TITLE)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.HEADING_5, NamedStyleType.SUBTITLE)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.TITLE, NamedStyleType.SUBTITLE)).toBe(false);
expect(shouldShowParagraphHeadingOption(NamedStyleType.SUBTITLE, NamedStyleType.SUBTITLE)).toBe(true);
});
it('maps paragraph named styles to the active heading menu item', () => {
expect(getParagraphMenuActiveHeadingCommandId(NamedStyleType.HEADING_1)).toBe(H1HeadingCommand.id);
expect(getParagraphMenuActiveHeadingCommandId(NamedStyleType.HEADING_3)).toBe(H3HeadingCommand.id);
expect(getParagraphMenuActiveHeadingCommandId(NamedStyleType.NORMAL_TEXT)).toBe(NormalTextHeadingCommand.id);
expect(getParagraphMenuActiveHeadingCommandId(undefined)).toBe(NormalTextHeadingCommand.id);
expect(getParagraphMenuActiveHeadingCommandId(NamedStyleType.TITLE)).toBe(TitleHeadingCommand.id);
expect(getParagraphMenuActiveHeadingCommandId(NamedStyleType.SUBTITLE)).toBe(SubtitleHeadingCommand.id);
});
it('hides the alternate title shortcuts for the hovered paragraph style', () => {
expect(getParagraphMenuHiddenHeadingCommandIds(NamedStyleType.TITLE)).toEqual([
H5HeadingCommand.id,
SubtitleHeadingCommand.id,
]);
expect(getParagraphMenuHiddenHeadingCommandIds(NamedStyleType.SUBTITLE)).toEqual([
H5HeadingCommand.id,
TitleHeadingCommand.id,
]);
expect(getParagraphMenuHiddenHeadingCommandIds(NamedStyleType.NORMAL_TEXT)).toEqual([
TitleHeadingCommand.id,
SubtitleHeadingCommand.id,
]);
});
it('shows paragraph settings only for paragraph and list menu targets', () => {
expect(shouldShowParagraphSettingMenu(null)).toBe(true);
expect(shouldShowParagraphSettingMenu({ kind: 'paragraph' } as never)).toBe(true);
expect(shouldShowParagraphSettingMenu({ kind: 'paragraph', icon: 'OrderIcon' } as never)).toBe(true);
expect(shouldShowParagraphSettingMenu({ kind: 'blockRange' } as never)).toBe(false);
expect(shouldShowParagraphSettingMenu({ kind: 'table' } as never)).toBe(false);
expect(shouldShowParagraphSettingMenu({ kind: 'customBlock' } as never)).toBe(false);
});
it('detects empty paragraph menu targets', () => {
const paragraph = {
paragraphStart: 2,
paragraphEnd: 2,
};
const nonEmptyParagraph = {
paragraphStart: 2,
paragraphEnd: 3,
};
expect(isEmptyParagraphMenuTarget('a\r\r', paragraph as never)).toBe(true);
expect(isEmptyParagraphMenuTarget('a\r\r', { paragraphStart: 1, paragraphEnd: 2 } as never)).toBe(true);
expect(isEmptyParagraphMenuTarget('a\n\n', { paragraphStart: 1, paragraphEnd: 2 } as never)).toBe(true);
expect(isEmptyParagraphMenuTarget('a\rb\r', nonEmptyParagraph as never)).toBe(false);
});
it('builds a collapsed selection range for the hovered paragraph', () => {
expect(getParagraphMenuTargetRange({
paragraphStart: 3,
paragraphEnd: 8,
segmentId: 'header-1',
} as never)).toEqual({
collapsed: true,
endOffset: 3,
segmentId: 'header-1',
startOffset: 3,
});
});
it('preserves context menu command params for paragraph menu actions', () => {
expect(getParagraphMenuCommand({
commandId: CreateDocTableCommand.id,
id: 'doc.operation.create-table',
label: 'doc.operation.create-table',
params: { rowCount: 3, colCount: 5 },
})).toEqual({
commandId: CreateDocTableCommand.id,
params: { rowCount: 3, colCount: 5 },
});
expect(getParagraphMenuCommand({
label: 'doc.command.h1-heading',
}, { startOffset: 3, endOffset: 3, collapsed: true })).toEqual({
commandId: SetParagraphNamedStyleCommand.id,
params: {
value: NamedStyleType.HEADING_1,
textRanges: [{ startOffset: 3, endOffset: 3, collapsed: true }],
},
});
});
it('passes the hovered paragraph range to current-paragraph menu commands', () => {
const targetRange = { startOffset: 3, endOffset: 3, collapsed: true };
expect(getParagraphMenuCommand({
label: BulletListCommand.id,
}, targetRange)).toEqual({
commandId: BulletListCommand.id,
params: { docRange: [targetRange] },
});
expect(getParagraphMenuCommand({
label: OrderListCommand.id,
}, targetRange)).toEqual({
commandId: OrderListCommand.id,
params: { docRange: [targetRange] },
});
expect(getParagraphMenuCommand({
label: HorizontalLineCommand.id,
}, targetRange)).toEqual({
commandId: HorizontalLineCommand.id,
params: { insertRange: targetRange },
});
expect(getParagraphMenuCommand({
label: H1HeadingCommand.id,
}, null)).toEqual({
commandId: H1HeadingCommand.id,
params: undefined,
});
});
it('detects menu commands that should use the hovered block insert-below anchor', () => {
expect(shouldUseInsertBelowRange(InsertBulletListBellowCommand.id, {
id: InsertBulletListBellowCommand.id,
})).toBe(true);
expect(shouldUseInsertBelowRange('docs-callout.command.insert-below', {
id: 'docs-callout.command.insert-below',
})).toBe(true);
expect(shouldUseInsertBelowRange('doc.command.insert-float-image', {
id: 'doc.command.insert-float-image',
})).toBe(true);
expect(shouldUseInsertBelowRange(CreateDocTableCommand.id, {
id: 'doc.operation.create-table',
})).toBe(true);
expect(shouldUseInsertBelowRange(H1HeadingCommand.id, {
id: H1HeadingCommand.id,
})).toBe(false);
expect(shouldUseInsertBelowRange(H1HeadingCommand.id, {
id: INSERT_BELLOW_MENU_ID,
})).toBe(true);
});
});
@@ -15,37 +15,208 @@
*/
import type { DocumentDataModel } from '@univerjs/core';
import type { IPopup } from '@univerjs/ui';
import { ICommandService, IUniverInstanceService, NamedStyleType, UniverInstanceType } from '@univerjs/core';
import { borderClassName, clsx } from '@univerjs/design';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IPopup, IValueOption } from '@univerjs/ui';
import type { IMutiPageParagraphBound } from '../../services/doc-event-manager.service';
import type { IDocBlockMenuTarget } from '../../services/doc-paragraph-menu.service';
import { ICommandService, IUniverInstanceService, NamedStyleType, SliceBodyType, UniverInstanceType } from '@univerjs/core';
import { clsx } from '@univerjs/design';
import { DocSelectionManagerService } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { DownIcon } from '@univerjs/icons';
import { ContextMenuPanel, ContextMenuPosition, ILayoutService, RectPopup, useDependency, useObservable } from '@univerjs/ui';
import { useMemo, useRef, useState } from 'react';
import { ComponentManager, ContextMenuPanel, ContextMenuPosition, IClipboardInterfaceService, ILayoutService, RectPopup, useDependency, useObservable } from '@univerjs/ui';
import { useEffect, useMemo, useRef, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import { HEADING_ICON_MAP } from '../../menu/paragraph-menu';
import { DocCopyCommand, DocPasteCommand } from '../../commands/commands/clipboard.command';
import { MoveDocBlockCommand } from '../../commands/commands/doc-block-move.command';
import { HorizontalLineCommand } from '../../commands/commands/doc-horizontal-line.command';
import { BulletListCommand, CheckListCommand, OrderListCommand } from '../../commands/commands/list.command';
import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../commands/commands/set-heading.command';
import { DocTableDeleteTableCommand } from '../../commands/commands/table/doc-table-delete.command';
import { DocParagraphSettingPanelOperation } from '../../commands/operations/doc-paragraph-setting-panel.operation';
import { DOC_TABLE_BLOCK_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, HEADING_ICON_MAP, INSERT_BELLOW_MENU_ID } from '../../menu/paragraph-menu';
import { IDocClipboardService } from '../../services/clipboard/clipboard.service';
import { DocContentInsertService } from '../../services/doc-content-insert.service';
import { DocEventManagerService } from '../../services/doc-event-manager.service';
import { DocParagraphMenuService } from '../../services/doc-paragraph-menu.service';
export function getParagraphMenuIconSizeClass(iconKey: string): string {
return iconKey === 'TextTypeIcon' ? 'univer-size-3' : 'univer-size-4';
}
export function getParagraphMenuPopupDirection(anchorLeft: number, menuWidth = 212, viewportPadding = 8): 'left' | 'right' {
return anchorLeft - menuWidth < viewportPadding ? 'right' : 'left';
}
export function isEmptyParagraphMenuTarget(dataStream: string, paragraph?: IMutiPageParagraphBound | null | void): boolean {
if (!paragraph) {
return false;
}
return dataStream.slice(paragraph.paragraphStart, paragraph.paragraphEnd).replace(/[\r\n]/g, '') === '';
}
export function getParagraphMenuTargetRange(paragraph?: IMutiPageParagraphBound | null | void): ITextRangeWithStyle | null {
if (!paragraph) {
return null;
}
const blockRange = (paragraph as IMutiPageParagraphBound & { blockRange?: { endIndex: number; startIndex: number } }).blockRange;
if (blockRange) {
return {
startOffset: blockRange.startIndex,
endOffset: blockRange.endIndex + 1,
collapsed: false,
segmentId: paragraph.segmentId,
};
}
return {
startOffset: paragraph.paragraphStart,
endOffset: paragraph.paragraphStart,
collapsed: true,
segmentId: paragraph.segmentId,
};
}
const HEADING_COMMAND_VALUES: Record<string, NamedStyleType> = {
[H1HeadingCommand.id]: NamedStyleType.HEADING_1,
[H2HeadingCommand.id]: NamedStyleType.HEADING_2,
[H3HeadingCommand.id]: NamedStyleType.HEADING_3,
[H4HeadingCommand.id]: NamedStyleType.HEADING_4,
[H5HeadingCommand.id]: NamedStyleType.HEADING_5,
[NormalTextHeadingCommand.id]: NamedStyleType.NORMAL_TEXT,
[TitleHeadingCommand.id]: NamedStyleType.TITLE,
[SubtitleHeadingCommand.id]: NamedStyleType.SUBTITLE,
};
const NAMED_STYLE_HEADING_COMMAND_IDS: Partial<Record<NamedStyleType, string>> = {
[NamedStyleType.HEADING_1]: H1HeadingCommand.id,
[NamedStyleType.HEADING_2]: H2HeadingCommand.id,
[NamedStyleType.HEADING_3]: H3HeadingCommand.id,
[NamedStyleType.HEADING_4]: H4HeadingCommand.id,
[NamedStyleType.HEADING_5]: H5HeadingCommand.id,
[NamedStyleType.NORMAL_TEXT]: NormalTextHeadingCommand.id,
[NamedStyleType.TITLE]: TitleHeadingCommand.id,
[NamedStyleType.SUBTITLE]: SubtitleHeadingCommand.id,
};
export function getParagraphMenuActiveHeadingCommandId(namedStyleType?: NamedStyleType): string {
return NAMED_STYLE_HEADING_COMMAND_IDS[namedStyleType ?? NamedStyleType.NORMAL_TEXT] ?? NormalTextHeadingCommand.id;
}
export function getParagraphMenuHiddenHeadingCommandIds(namedStyleType?: NamedStyleType): string[] {
if (namedStyleType === NamedStyleType.TITLE) {
return [H5HeadingCommand.id, SubtitleHeadingCommand.id];
}
if (namedStyleType === NamedStyleType.SUBTITLE) {
return [H5HeadingCommand.id, TitleHeadingCommand.id];
}
return [TitleHeadingCommand.id, SubtitleHeadingCommand.id];
}
export function getParagraphMenuCommand(params: IValueOption, targetRange?: ITextRangeWithStyle | null): { commandId?: string; params?: object } {
const commandId = params.commandId ?? params.id ?? (typeof params.label === 'string' ? params.label : undefined);
if (commandId && targetRange && commandId in HEADING_COMMAND_VALUES) {
return {
commandId: SetParagraphNamedStyleCommand.id,
params: {
value: HEADING_COMMAND_VALUES[commandId],
textRanges: [targetRange],
},
};
}
if (commandId && targetRange && (commandId === BulletListCommand.id || commandId === OrderListCommand.id || commandId === CheckListCommand.id)) {
return {
commandId,
params: {
docRange: [targetRange],
},
};
}
if (commandId === HorizontalLineCommand.id && targetRange) {
return {
commandId,
params: {
insertRange: targetRange,
},
};
}
const fallbackParams = typeof params.params === 'function' ? params.params() : params.params;
const commandParams = typeof params.value === 'undefined'
? fallbackParams
: { value: params.value };
return {
commandId,
params: commandParams && typeof commandParams === 'object' ? commandParams : undefined,
};
}
function getParagraphMenuType(target: IDocBlockMenuTarget | null | undefined, emptyMode: boolean): string {
if (target?.kind === 'table') {
return DOC_TABLE_BLOCK_MENU_ID;
}
return emptyMode ? EMPTY_PARAGRAPH_MENU_ID : ContextMenuPosition.PARAGRAPH;
}
export function shouldShowParagraphSettingMenu(target: IDocBlockMenuTarget | null | undefined): boolean {
return !target || target.kind === 'paragraph';
}
export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
const [visible, setVisible] = useState(false);
const [emptyMode, setEmptyMode] = useState(false);
const [dropRect, setDropRect] = useState<{ left: number; right: number; top: number; bottom: number } | null>(null);
const [menuDirection, setMenuDirection] = useState<'left' | 'right'>('left');
const contentRef = useRef<HTMLDivElement>(null);
const targetRangeRef = useRef<ITextRangeWithStyle | null>(null);
const dragTargetOffsetRef = useRef<number | null>(null);
const dragRangeRef = useRef<{ startOffset: number; endOffset: number } | null>(null);
const isDraggingRef = useRef(false);
const commandService = useDependency(ICommandService);
const docSelectionManagerService = useDependency(DocSelectionManagerService);
const docClipboardService = useDependency(IDocClipboardService);
const docContentInsertService = useDependency(DocContentInsertService);
const clipboardInterfaceService = useDependency(IClipboardInterfaceService);
const layoutService = useDependency(ILayoutService);
const componentManager = useDependency(ComponentManager);
const anchorRef = useRef<HTMLDivElement>(null);
const isMouseOver = useRef(false);
const hideTimerRef = useRef<number | null>(null);
const renderManagerService = useDependency(IRenderManagerService);
const univerInstanceService = useDependency(IUniverInstanceService);
const renderUnit = renderManagerService.getRenderById(popup.unitId);
const doc = univerInstanceService.getUnit<DocumentDataModel>(popup.unitId, UniverInstanceType.UNIVER_DOC);
const docParagraphMenuService = renderUnit?.with(DocParagraphMenuService);
const docEventManagerService = renderUnit?.with(DocEventManagerService);
const activeTarget = useObservable(docParagraphMenuService?.activeTarget$);
const paragraph = useObservable(docEventManagerService?.hoverParagraph$);
const paragraphLeft = useObservable(docEventManagerService?.hoverParagraphLeft$);
const startIndex = (paragraph ?? paragraphLeft)?.startIndex;
const paragraphObj = useMemo(() => doc?.getBody()?.paragraphs?.find((p) => p.startIndex === startIndex), [doc, paragraph]);
const currentActiveTarget = activeTarget ?? docParagraphMenuService?.activeTarget;
const activeParagraphBound = currentActiveTarget?.paragraph ?? docParagraphMenuService?.activeParagraph ?? paragraph ?? paragraphLeft;
const startIndex = activeParagraphBound?.startIndex;
const dataStream = doc?.getBody()?.dataStream ?? '';
const paragraphObj = useMemo(() => doc?.getBody()?.paragraphs?.find((p) => p.startIndex === startIndex), [doc, startIndex]);
const isEmptyParagraph = currentActiveTarget?.emptyMode ?? isEmptyParagraphMenuTarget(dataStream, activeParagraphBound);
const namedStyleType = paragraphObj?.paragraphStyle?.namedStyleType;
const activeHeadingCommandId = getParagraphMenuActiveHeadingCommandId(namedStyleType);
const hiddenHeadingCommandIds = useMemo(() => getParagraphMenuHiddenHeadingCommandIds(namedStyleType), [namedStyleType]);
const hiddenItemIds = useMemo(() => {
if (!shouldShowParagraphSettingMenu(currentActiveTarget)) {
return [...hiddenHeadingCommandIds, DocParagraphSettingPanelOperation.id];
}
return hiddenHeadingCommandIds;
}, [currentActiveTarget, hiddenHeadingCommandIds]);
const icon = HEADING_ICON_MAP[namedStyleType ?? NamedStyleType.NORMAL_TEXT];
const targetIconKey = currentActiveTarget?.icon ?? icon.key;
const TargetIcon = componentManager.get(targetIconKey) ?? icon.component;
const anchorRect$ = useMemo(() => new BehaviorSubject({
left: 0,
right: 0,
@@ -53,84 +224,272 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
bottom: 0,
}), []);
const updateAnchorRect = () => {
const boundingRect = anchorRef.current?.getBoundingClientRect();
const left = (boundingRect?.left ?? 0) - 4;
setMenuDirection(getParagraphMenuPopupDirection(left));
anchorRect$.next({
left,
right: boundingRect?.right ?? 0,
top: boundingRect?.top ?? 0,
bottom: boundingRect?.bottom ?? 0,
});
};
const handleHideMenu = () => {
setVisible(false);
docParagraphMenuService?.hideParagraphMenu(true);
targetRangeRef.current = null;
};
const clearHideTimer = () => {
if (hideTimerRef.current != null) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
};
const scheduleHideMenu = () => {
clearHideTimer();
hideTimerRef.current = window.setTimeout(() => {
if (!isMouseOver.current && !isDraggingRef.current) {
handleHideMenu();
}
}, 180);
};
const handleOpenMenu = () => {
clearHideTimer();
const latestTarget = docParagraphMenuService?.activeTarget ?? activeTarget;
const targetRange = latestTarget
? {
...latestTarget.menuRange,
segmentId: activeParagraphBound?.segmentId,
}
: getParagraphMenuTargetRange(activeParagraphBound);
targetRangeRef.current = targetRange;
updateAnchorRect();
setEmptyMode(isEmptyParagraph);
setVisible(true);
};
useEffect(() => () => clearHideTimer(), []);
return (
<>
<div
data-u-comp="paragraph-menu"
ref={anchorRef}
className={clsx(`
univer-mr-1 univer-inline-flex univer-h-7 univer-cursor-pointer univer-items-center univer-gap-1
univer-rounded-full univer-px-2.5 univer-py-0
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-600
`, borderClassName, {
'univer-bg-gray-100 dark:!univer-bg-gray-700': visible,
'univer-bg-white dark:!univer-bg-gray-700': !visible,
univer-mr-1 univer-inline-flex univer-h-8 univer-cursor-pointer univer-items-center univer-gap-1.5
univer-rounded-lg univer-border univer-border-gray-200 univer-bg-white univer-px-2.5 univer-py-0
univer-shadow-sm univer-transition-colors
hover:univer-bg-gray-50 hover:univer-shadow-md
dark:!univer-border-gray-700 dark:!univer-bg-gray-900
dark:hover:!univer-bg-gray-800
`, {
'univer-bg-gray-100 univer-shadow-md dark:!univer-bg-gray-800': visible,
})}
onMouseEnter={(e) => {
popup.onPointerEnter?.(e);
isMouseOver.current = true;
const boundingRect = anchorRef.current?.getBoundingClientRect();
anchorRect$.next({
left: (boundingRect?.left ?? 0) - 4,
right: boundingRect?.right ?? 0,
top: boundingRect?.top ?? 0,
bottom: boundingRect?.bottom ?? 0,
});
handleOpenMenu();
}}
onMouseLeave={() => {
isMouseOver.current = false;
scheduleHideMenu();
}}
onClick={() => {
setVisible(true);
docParagraphMenuService?.setParagraphMenuActive(true);
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
isMouseOver.current = true;
handleOpenMenu();
}}
>
<icon.component
className={`
univer-size-4 univer-text-gray-900
dark:!univer-text-white
`}
/>
<DownIcon
className={`
univer-size-3 univer-text-gray-600
dark:!univer-text-gray-200
`}
<TargetIcon
className={clsx(
getParagraphMenuIconSizeClass(targetIconKey),
`
univer-text-gray-700
dark:!univer-text-white
`
)}
/>
{currentActiveTarget?.draggable && (
<button
type="button"
className={`
univer-group univer-flex univer-h-4 univer-w-2.5 univer-cursor-grab univer-items-center
univer-justify-center univer-border-none univer-bg-transparent univer-p-0
active:univer-cursor-grabbing
`}
aria-label="Drag block"
title="Drag block"
onPointerDown={(event) => {
const latestTarget = docParagraphMenuService?.activeTarget ?? activeTarget;
const moveRange = latestTarget?.moveRange;
if (!moveRange || !latestTarget?.draggable) {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
clearHideTimer();
isMouseOver.current = true;
isDraggingRef.current = true;
docParagraphMenuService?.setBlockMenuDragging(true);
dragRangeRef.current = moveRange;
dragTargetOffsetRef.current = null;
setDropRect(null);
const pointerId = event.pointerId;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
moveEvent.preventDefault();
const range = dragRangeRef.current;
if (!range) {
return;
}
const target = docParagraphMenuService?.getDropTargetFromClientPoint(moveEvent.clientX, moveEvent.clientY, range);
dragTargetOffsetRef.current = target?.targetOffset ?? null;
setDropRect(target?.rect ?? null);
};
const finishDrag = (shouldDrop: boolean) => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerCancel);
window.removeEventListener('blur', handleWindowBlur);
const range = dragRangeRef.current;
const targetOffset = dragTargetOffsetRef.current;
dragRangeRef.current = null;
dragTargetOffsetRef.current = null;
isDraggingRef.current = false;
docParagraphMenuService?.setBlockMenuDragging(false);
setDropRect(null);
if (shouldDrop && range && targetOffset != null) {
commandService.executeCommand(MoveDocBlockCommand.id, {
unitId: popup.unitId,
sourceRange: range,
targetOffset,
});
}
};
const handlePointerUp = (upEvent: PointerEvent) => {
if (upEvent.pointerId !== pointerId) {
return;
}
upEvent.preventDefault();
finishDrag(true);
};
const handlePointerCancel = (cancelEvent: PointerEvent) => {
if (cancelEvent.pointerId !== pointerId) {
return;
}
finishDrag(false);
};
const handleWindowBlur = () => {
finishDrag(false);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerCancel);
window.addEventListener('blur', handleWindowBlur, { once: true });
}}
>
<DragHandleDotsIcon />
</button>
)}
</div>
{visible && (
<RectPopup
portal
mask
maskZIndex={100}
anchorRect$={anchorRect$}
direction="left"
onMaskClick={handleHideMenu}
direction={menuDirection}
>
<section
ref={contentRef}
onMouseEnter={(e) => {
popup.onPointerEnter?.(e);
isMouseOver.current = true;
clearHideTimer();
}}
onMouseLeave={() => {
isMouseOver.current = false;
scheduleHideMenu();
}}
>
<ContextMenuPanel
className="univer-w-[212px]"
menuType={ContextMenuPosition.PARAGRAPH}
onOptionSelect={(params) => {
const { label: id, commandId, value } = params;
menuType={getParagraphMenuType(currentActiveTarget, emptyMode)}
activeItemIds={[activeHeadingCommandId]}
hiddenItemIds={hiddenItemIds}
onOptionSelect={async (params) => {
const targetRange = targetRangeRef.current ?? getParagraphMenuTargetRange(activeParagraphBound);
const { commandId, params: commandParams } = getParagraphMenuCommand(params, targetRange);
const latestTarget = docParagraphMenuService?.activeTarget ?? activeTarget;
if (commandService) {
commandService.executeCommand(commandId ?? id as string, { value });
if (commandId && shouldUseInsertBelowRange(commandId, params) && latestTarget?.moveRange) {
docContentInsertService.setInsertRange({
unitId: popup.unitId,
startOffset: latestTarget.moveRange.endOffset,
endOffset: latestTarget.moveRange.endOffset,
segmentId: targetRange?.segmentId ?? '',
});
}
if (latestTarget?.kind === 'table' && commandId && targetRange) {
const tableRange = {
...targetRange,
segmentId: targetRange.segmentId ?? '',
collapsed: false,
};
const afterTableRange = {
startOffset: latestTarget.moveRange.endOffset,
endOffset: latestTarget.moveRange.endOffset,
collapsed: true,
segmentId: targetRange.segmentId ?? '',
};
if (commandId === DocCopyCommand.id || commandId === DocCopyCommand.name) {
await docClipboardService.copy(SliceBodyType.copy, [tableRange]);
layoutService.focus();
handleHideMenu();
return;
}
if (commandId === DocPasteCommand.id) {
docSelectionManagerService.replaceTextRanges([afterTableRange], false);
const clipboardItems = await clipboardInterfaceService.read();
await docClipboardService.paste(clipboardItems);
layoutService.focus();
handleHideMenu();
return;
}
if (commandId === DocTableDeleteTableCommand.id) {
docSelectionManagerService.replaceTextRanges([tableRange], false);
} else if (params.id === INSERT_BELLOW_MENU_ID || commandId !== INSERT_BELLOW_MENU_ID) {
docSelectionManagerService.replaceTextRanges([afterTableRange], false);
}
}
if (commandService && commandId) {
const blockRangeParams = latestTarget?.kind === 'blockRange' && latestTarget.blockRange && commandParams && typeof commandParams === 'object'
? {
...commandParams,
unitId: popup.unitId,
blockId: latestTarget.blockRange.blockId,
}
: commandParams;
commandService.executeCommand(commandId, blockRangeParams);
}
layoutService.focus();
@@ -140,6 +499,62 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => {
</section>
</RectPopup>
)}
{dropRect && (
<div
className="
univer-pointer-events-none univer-fixed univer-z-[10000] univer-h-0.5 univer-rounded-full
univer-bg-primary-600
"
style={{
left: dropRect.left,
top: dropRect.top,
width: Math.max(60, dropRect.right - dropRect.left),
}}
/>
)}
</>
);
};
export function shouldUseInsertBelowRange(commandId: string, params: IValueOption): boolean {
if (params.id === INSERT_BELLOW_MENU_ID) {
return true;
}
const normalized = commandId.toLowerCase();
if (normalized.includes('insert') && (normalized.includes('below') || normalized.includes('bellow'))) {
return true;
}
if (normalized.includes('insert') && normalized.includes('image')) {
return true;
}
return normalized === 'doc.command.create-table' || normalized === 'doc.operation.create-table';
}
function DragHandleDotsIcon() {
return (
<span
className={`
univer-grid univer-h-3.5 univer-w-2 univer-grid-cols-2 univer-place-items-center univer-gap-x-0.5
univer-gap-y-px
`}
aria-hidden="true"
>
{Array.from({ length: 6 }).map((_, index) => (
<span
// eslint-disable-next-line react/no-array-index-key
key={index}
className={`
univer-size-0.5 univer-rounded-full univer-bg-gray-400 univer-transition-colors
group-hover:univer-bg-gray-500
dark:!univer-bg-gray-500
dark:group-hover:!univer-bg-gray-300
`}
/>
))}
</span>
);
}
@@ -0,0 +1,186 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommandInfo } from '@univerjs/core';
import { DocumentFlavor } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DocRenderController } from '../render-controllers/doc.render-controller';
vi.mock('@univerjs/engine-render', async (importOriginal) => {
const actual = await importOriginal<typeof import('@univerjs/engine-render')>();
const PageLayoutType = {
VERTICAL: 0,
HORIZONTAL: 1,
};
class MockDocComponent {
width = 0;
height = 0;
pageMarginLeft: number;
pageMarginTop: number;
pageLayoutType = PageLayoutType.VERTICAL;
zIndex = 0;
constructor(_key: string, _skeleton?: unknown, config?: { pageMarginLeft?: number; pageMarginTop?: number }) {
this.pageMarginLeft = config?.pageMarginLeft ?? 0;
this.pageMarginTop = config?.pageMarginTop ?? 0;
}
changeSkeleton() {
return this;
}
resize(width: number, height: number) {
this.width = width;
this.height = height;
return this;
}
makeDirty() {
return this;
}
}
return {
...actual,
DOCS_COMPONENT_BACKGROUND_LAYER_INDEX: 0,
DocBackground: MockDocComponent,
Documents: MockDocComponent,
IRenderManagerService: () => undefined,
Layer: class MockLayer {
constructor(..._args: unknown[]) { }
},
PageLayoutType,
ScrollBar: class MockScrollBar {
constructor(..._args: unknown[]) { }
},
Viewport: class MockViewport {
constructor(..._args: unknown[]) { }
onMouseWheel() { }
},
};
});
function createControllerFixture() {
const commandCallbacks: Array<(command: ICommandInfo) => void> = [];
const skeleton = {
calculate: vi.fn(),
getSkeletonData: vi.fn(() => ({
pages: [{
pageWidth: 640,
pageHeight: 900,
}],
})),
getViewModel: vi.fn(() => ({
getDataModel: vi.fn(() => ({
getSnapshot: vi.fn(() => ({ disabled: false })),
})),
})),
};
const skeletonManager = {
currentSkeletonBefore$: new Subject(),
getSkeleton: vi.fn(() => skeleton),
};
const context = {
unitId: 'doc-unit',
unit: {
getUnitId: vi.fn(() => 'doc-unit'),
getSnapshot: vi.fn(() => ({
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
},
})),
},
scene: {
attachControl: vi.fn(),
onMouseWheel$: { subscribeEvent: vi.fn() },
addLayer: vi.fn(),
addObjects: vi.fn(),
enableLayerCache: vi.fn(),
resize: vi.fn(),
},
engine: {
runRenderLoop: vi.fn(),
stopRenderLoop: vi.fn(),
getCanvas: vi.fn(() => ({
getCanvasEle: vi.fn(() => ({ style: {} })),
})),
},
components: new Map(),
activated$: new Subject<boolean>(),
};
const commandService = {
onCommandExecuted: vi.fn((callback) => {
commandCallbacks.push(callback);
return { dispose: vi.fn() };
}),
};
const pageLayoutService = {
calculatePagePosition: vi.fn(),
};
const selectionManager = {
refreshSelection: vi.fn(),
};
const Controller = DocRenderController as unknown as new (...args: unknown[]) => DocRenderController;
new Controller(
context,
commandService,
{ __attachScrollEvent: vi.fn() },
skeletonManager,
{
isEditor: vi.fn(() => false),
getEditor: vi.fn(() => null),
},
{
getRenderById: vi.fn(() => ({
with: vi.fn(() => skeletonManager),
})),
},
{
getCurrentUnitOfType: vi.fn(() => ({
getUnitId: vi.fn(() => 'doc-unit'),
})),
},
pageLayoutService,
selectionManager
);
return {
commandCallbacks,
pageLayoutService,
selectionManager,
};
}
describe('doc render controller', () => {
it('refreshes page layout and selection after rich text mutations resize the document', () => {
const { commandCallbacks, pageLayoutService, selectionManager } = createControllerFixture();
commandCallbacks[0]({
id: RichTextEditingMutation.id,
params: {
unitId: 'doc-unit',
actions: [],
},
} as unknown as ICommandInfo);
expect(pageLayoutService.calculatePagePosition).toHaveBeenCalledTimes(1);
expect(selectionManager.refreshSelection).toHaveBeenCalledTimes(1);
});
});
@@ -30,7 +30,7 @@ import { CoreHeaderFooterCommand, OpenHeaderFooterPanelCommand } from '../comman
import { SidebarDocHeaderFooterPanelOperation } from '../commands/operations/doc-header-footer-panel.operation';
import { BULLET_LIST_TYPE_COMPONENT, BulletListTypePicker, ORDER_LIST_TYPE_COMPONENT, OrderListTypePicker } from '../components/list-type-picker';
import { ParagraphMenu } from '../components/paragraph-menu';
import { menuSchema } from '../menu/schema';
import { floatToolbarMenuSchema, menuSchema } from '../menu/schema';
import { DocSelectionRenderService } from '../services/selection/doc-selection-render.service';
import { TabShortCut } from '../shortcuts/format.shortcut';
import {
@@ -90,6 +90,7 @@ export class DocUIController extends Disposable {
}
private _initMenus(): void {
this._menuManagerService.appendRootMenu(floatToolbarMenuSchema);
this._menuManagerService.mergeMenu(menuSchema);
}
@@ -31,6 +31,7 @@ import {
import { takeUntil } from 'rxjs';
import { whenDocOrEditor } from '../../commands/commands/clipboard.command';
import { IDocClipboardService } from '../../services/clipboard/clipboard.service';
import { DOC_INTERNAL_FRAGMENT_MIME } from '../../services/clipboard/internal-fragment';
import { IEditorService } from '../../services/editor/editor-manager.service';
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
@@ -62,6 +63,7 @@ export class DocClipboardController extends RxDisposable implements IRenderModul
config!.event.preventDefault();
const clipboardEvent = config!.event as ClipboardEvent;
let htmlContent = clipboardEvent.clipboardData?.getData(HTML_CLIPBOARD_MIME_TYPE);
const internalJson = clipboardEvent.clipboardData?.getData(DOC_INTERNAL_FRAGMENT_MIME);
const textContent = clipboardEvent.clipboardData?.getData(PLAIN_TEXT_CLIPBOARD_MIME_TYPE);
const files = [...(clipboardEvent.clipboardData?.items || [])]
@@ -78,7 +80,7 @@ export class DocClipboardController extends RxDisposable implements IRenderModul
htmlContent = '';
}
this._docClipboardService.legacyPaste({ html: htmlContent, text: textContent, files });
this._docClipboardService.legacyPaste({ html: htmlContent, internalJson, text: textContent, files });
});
}
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Workbook } from '@univerjs/core';
import type { DocumentDataModel, ITextRangeParam, Workbook } from '@univerjs/core';
import type { Documents, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import {
DEFAULT_EMPTY_DOCUMENT_VALUE,
@@ -23,9 +23,13 @@ import {
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
DOCS_ZEN_EDITOR_UNIT_ID_KEY,
ICommandService,
Inject,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { ContextMenuPosition, IContextMenuService } from '@univerjs/ui';
import { DocEventManagerService } from '../../services/doc-event-manager.service';
const SKIP_UNIT_IDS = [
DEFAULT_EMPTY_DOCUMENT_VALUE,
@@ -42,7 +46,10 @@ export class DocContextMenuRenderController extends Disposable implements IRende
constructor(
private readonly _context: IRenderContext<Workbook>,
@IContextMenuService private readonly _contextMenuService: IContextMenuService,
@ICommandService private readonly _commandService: ICommandService
@ICommandService private readonly _commandService: ICommandService,
@Inject(DocEventManagerService) private readonly _docEventManagerService: DocEventManagerService,
@Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService,
@Inject(IUniverInstanceService) private readonly _univerInstanceService: IUniverInstanceService
) {
super();
@@ -57,6 +64,13 @@ export class DocContextMenuRenderController extends Disposable implements IRende
// Content range context menu
const documentsSubscription = documentsPointerDownObserver.subscribeEvent((event) => {
if (event.button === 2) {
if (this._docEventManagerService.isPointerOnBullet(event.offsetX, event.offsetY)) {
return;
}
if (this._isSelectionInCodeBlock()) {
return;
}
this._contextMenuService.triggerContextMenu(event, ContextMenuPosition.MAIN_AREA);
}
});
@@ -74,4 +88,19 @@ export class DocContextMenuRenderController extends Disposable implements IRende
})
);
}
private _isSelectionInCodeBlock(): boolean {
const documentDataModel = this._univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const blockRanges = documentDataModel?.getBody()?.blockRanges?.filter((range) => range.blockType === 'code') ?? [];
const textRanges = this._docSelectionManagerService.getTextRanges() ?? [];
if (!blockRanges.length || !textRanges.length) {
return false;
}
return blockRanges.some((blockRange) => textRanges.some((range) => isRangeIntersectingBlock(range, blockRange.startIndex, blockRange.endIndex)));
}
}
function isRangeIntersectingBlock(range: ITextRangeParam, startIndex: number, endIndex: number): boolean {
return Math.max(range.startOffset, startIndex) <= Math.min(range.endOffset, endIndex);
}
@@ -18,10 +18,12 @@ import type { DocumentDataModel, EventState, ICommandInfo, Nullable } from '@uni
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { DocumentSkeleton, IDocumentSkeletonPage, IRenderContext, IRenderModule, IWheelEvent } from '@univerjs/engine-render';
import { DocumentFlavor, ICommandService, Inject, IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocSelectionManagerService, DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocBackground, Documents, IRenderManagerService, Layer, PageLayoutType, ScrollBar, Viewport } from '@univerjs/engine-render';
import { takeUntil } from 'rxjs';
import { DOCS_COMPONENT_BACKGROUND_LAYER_INDEX, DOCS_COMPONENT_DEFAULT_Z_INDEX, DOCS_COMPONENT_HEADER_LAYER_INDEX, DOCS_COMPONENT_MAIN_LAYER_INDEX, DOCS_VIEW_KEY, VIEWPORT_KEY } from '../../basics/docs-view-key';
import { DocPageLayoutService } from '../../services/doc-page-layout.service';
import { getDocsCanvasBackgroundColor } from '../../services/docs-render.service';
import { IEditorService } from '../../services/editor/editor-manager.service';
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
@@ -33,7 +35,9 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
@Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService,
@IEditorService private readonly _editorService: IEditorService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(DocPageLayoutService) private readonly _docPageLayoutService: DocPageLayoutService,
@Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService
) {
super();
@@ -66,6 +70,7 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
}
this._recalculateSizeBySkeleton(skeleton);
this._refreshPagePositionAndSelection();
}
private _addNewRender() {
@@ -147,6 +152,7 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
const config = {
pageMarginLeft: DEFAULT_PAGE_MARGIN_LEFT,
pageMarginTop: DEFAULT_PAGE_MARGIN_TOP,
...this._getEditorBackgroundConfig(),
};
const documents = new Documents(DOCS_VIEW_KEY.MAIN, undefined, config);
@@ -184,6 +190,7 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
docsComponent.changeSkeleton(skeleton);
docBackground.changeSkeleton(skeleton);
this._syncCanvasBackground();
const { unitId } = this._context;
@@ -196,6 +203,7 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
}
this._recalculateSizeBySkeleton(skeleton);
this._refreshPagePositionAndSelection();
}
private _initCommandListener() {
@@ -212,6 +220,15 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
}));
}
private _refreshPagePositionAndSelection() {
if (this._editorService.isEditor(this._context.unitId)) {
return;
}
this._docPageLayoutService.calculatePagePosition();
this._textSelectionManagerService.refreshSelection();
}
private _recalculateSizeBySkeleton(skeleton: DocumentSkeleton) {
const { mainComponent, scene, unitId, components } = this._context;
@@ -230,6 +247,7 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
const docDataModel = this._context.unit;
const documentFlavor = docDataModel.getSnapshot().documentStyle.documentFlavor;
this._syncCanvasBackground(documentFlavor);
for (let i = 0, len = pages.length; i < len; i++) {
const page = pages[i];
@@ -273,6 +291,26 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
scene.resize(width, height);
}
}
private _syncCanvasBackground(documentFlavor = this._context.unit.getSnapshot().documentStyle.documentFlavor) {
const editor = this._editorService.getEditor(this._context.unitId);
const editorBackgroundColor = editor?.params.canvasStyle?.backgroundColor;
this._context.engine.getCanvas().getCanvasEle().style.backgroundColor = editorBackgroundColor ?? getDocsCanvasBackgroundColor(documentFlavor);
const docBackground = this._context.components.get(DOCS_VIEW_KEY.BACKGROUND) as DocBackground | undefined;
docBackground?.setFillColors?.(editorBackgroundColor, editorBackgroundColor, editorBackgroundColor, editorBackgroundColor);
}
private _getEditorBackgroundConfig() {
const editorBackgroundColor = this._editorService.getEditor(this._context.unitId)?.params.canvasStyle?.backgroundColor;
return editorBackgroundColor == null
? {}
: {
backgroundFillColor: editorBackgroundColor,
pageFillColor: editorBackgroundColor,
pageStrokeColor: editorBackgroundColor,
marginStrokeColor: editorBackgroundColor,
};
}
}
function getPageSizeInModernMode(page: IDocumentSkeletonPage) {
@@ -17,6 +17,7 @@
import type { DocumentDataModel, ICommandInfo, Workbook } from '@univerjs/core';
import type { IRenderContext, IRenderModule, IWheelEvent } from '@univerjs/engine-render';
import type { IDocPageSetupCommandParams } from '../../commands/commands/doc-page-setup.command';
import type { ISetDocZoomRatioOperationParams } from '../../commands/operations/set-doc-zoom-ratio.operation';
import {
Disposable,
@@ -31,8 +32,9 @@ import {
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { getNextWheelZoomRatio, IRenderManagerService } from '@univerjs/engine-render';
import { neoGetDocObject } from '../../basics/component-tools';
import { DocPageSetupCommand } from '../../commands/commands/doc-page-setup.command';
import { SetDocZoomRatioCommand } from '../../commands/commands/set-doc-zoom-ratio.command';
import { SwitchDocModeCommand } from '../../commands/commands/switch-doc-mode.command';
import { SetDocZoomRatioOperation } from '../../commands/operations/set-doc-zoom-ratio.operation';
@@ -59,7 +61,6 @@ export class DocZoomRenderController extends Disposable implements IRenderModule
this._initSkeletonListener();
this._initCommandExecutedListener();
this._initRenderRefresher();
this._isSheetEditor = this._context.unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY;
const currentSheet = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const sheetRenderer = currentSheet && this._renderManagerService.getRenderById(currentSheet.getUnitId());
@@ -76,56 +77,6 @@ export class DocZoomRenderController extends Disposable implements IRenderModule
window.clearTimeout(this._updateTimer);
}
private _initRenderRefresher() {
this._docSkeletonManagerService.currentSkeleton$.subscribe((param) => {
if (param == null) {
return;
}
const { unitId, scene } = this._context;
if (this._editorService.isEditor(unitId)) {
return;
}
this.disposeWithMe(scene.onMouseWheel$.subscribeEvent((e: IWheelEvent) => {
if (!e.ctrlKey || !this._contextService.getContextValue(FOCUSING_DOC)) {
return;
}
const documentModel = this._univerInstanceService.getCurrentUniverDocInstance();
if (!documentModel) {
return;
}
const { documentFlavor } = documentModel.getSnapshot().documentStyle;
// Modern document does not support zooming.
if (documentFlavor === DocumentFlavor.MODERN) {
return;
}
const deltaFactor = Math.abs(e.deltaX);
let ratioDelta = deltaFactor < 40 ? 0.2 : deltaFactor < 80 ? 0.4 : 0.2;
ratioDelta *= e.deltaY > 0 ? -1 : 1;
if (scene.scaleX < 1) {
ratioDelta /= 2;
}
const currentRatio = documentModel.zoomRatio;
let nextRatio = +Number.parseFloat(`${currentRatio + ratioDelta}`).toFixed(1);
nextRatio = nextRatio >= 4 ? 4 : nextRatio <= 0.1 ? 0.1 : nextRatio;
this._commandService.executeCommand(SetDocZoomRatioCommand.id, {
zoomRatio: nextRatio,
unitId: documentModel.getUnitId(),
});
e.preventDefault();
}));
});
}
private _initSkeletonListener() {
this.disposeWithMe(this._docSkeletonManagerService.currentSkeleton$.subscribe((param) => {
if (param == null) {
@@ -158,7 +109,10 @@ export class DocZoomRenderController extends Disposable implements IRenderModule
this.disposeWithMe(
this._commandService.beforeCommandExecuted((command: ICommandInfo) => {
if (command.id === SwitchDocModeCommand.id) {
const shouldResetZoom = command.id === SwitchDocModeCommand.id ||
(command.id === DocPageSetupCommand.id && (command.params as IDocPageSetupCommandParams | undefined)?.documentFlavor === DocumentFlavor.MODERN);
if (shouldResetZoom) {
this._commandService.executeCommand(SetDocZoomRatioCommand.id, {
zoomRatio: 1,
unitId: this._context.unitId,
@@ -192,24 +146,28 @@ export class DocZoomRenderController extends Disposable implements IRenderModule
this.disposeWithMe(
// hold ctrl & mousewheel ---> zoom
scene.onMouseWheel$.subscribeEvent((e: IWheelEvent) => {
if (!e.ctrlKey) {
if (!e.ctrlKey || !this._contextService.getContextValue(FOCUSING_DOC)) {
return;
}
const deltaFactor = Math.abs(e.deltaX);
let ratioDelta = deltaFactor < 40 ? 0.2 : deltaFactor < 80 ? 0.4 : 0.2;
ratioDelta *= e.deltaY > 0 ? -1 : 1;
if (scene.scaleX < 1) {
ratioDelta /= 2;
const documentModel = this._univerInstanceService.getCurrentUniverDocInstance();
if (!documentModel) {
return;
}
const currentRatio = this._context.unit.zoomRatio;
let nextRatio = +Number.parseFloat(`${currentRatio + ratioDelta}`).toFixed(1);
nextRatio = nextRatio >= 4 ? 4 : nextRatio <= 0.1 ? 0.1 : nextRatio;
const { documentFlavor } = documentModel.getSnapshot().documentStyle;
if (documentFlavor === DocumentFlavor.MODERN) {
e.preventDefault();
return;
}
const currentRatio = documentModel.zoomRatio || 1;
const nextRatio = getNextWheelZoomRatio(currentRatio, e);
this._commandService.executeCommand(SetDocZoomRatioCommand.id, {
zoomRatio: Math.round(nextRatio * 10) / 10,
documentId: this._context.unitId,
zoomRatio: nextRatio,
documentId: documentModel.getUnitId(),
});
e.preventDefault();
+7 -2
View File
@@ -39,6 +39,7 @@ export {
type IUpdateCommandParams,
UpdateCommand,
} from './commands/commands/core-editing.command';
export { buildMoveDocBlockActions, type IMoveDocBlockCommandParams, MoveDocBlockCommand } from './commands/commands/doc-block-move.command';
export { DeleteCustomBlockCommand, DeleteLeftCommand, DeleteRightCommand, type IDeleteCustomBlockParams, MergeTwoParagraphCommand } from './commands/commands/doc-delete.command';
export { getCursorWhenDelete } from './commands/commands/doc-delete.command';
export { HorizontalLineCommand } from './commands/commands/doc-horizontal-line.command';
@@ -119,15 +120,19 @@ export type { IUniverDocsUIConfig } from './config/config';
export { DocUIController } from './controllers/doc-ui.controller';
export { DocBackScrollRenderController } from './controllers/render-controllers/back-scroll.render-controller';
export { DocRenderController } from './controllers/render-controllers/doc.render-controller';
export { FLOAT_TEXT_STYLE_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION, hideMenuWhenSelectionInBlockRange, isTextRangeInAnyBlockRange } from './menu/menu';
export { DOC_CONTENT_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, getDocBlockRangeMenuId, INSERT_BELLOW_MENU_ID } from './menu/paragraph-menu';
export { menuSchema as DocsUIMenuSchema } from './menu/schema';
export { UniverDocsUIPlugin } from './plugin';
export * from './services';
export { IDocClipboardService } from './services/clipboard/clipboard.service';
export { convertBodyToHtml } from './services/clipboard/udm-to-html/convertor';
export { DocAutoFormatService } from './services/doc-auto-format.service';
export { DocEventManagerService } from './services/doc-event-manager.service';
export { DocContentInsertService, type IDocContentInsertRange } from './services/doc-content-insert.service';
export { DocEventManagerService, type IBulletBound } from './services/doc-event-manager.service';
export { DocIMEInputManagerService } from './services/doc-ime-input-manager.service';
export { DocParagraphMenuService } from './services/doc-paragraph-menu.service';
export { calcDocRangePositions, DocCanvasPopManagerService } from './services/doc-popup-manager.service';
export { DocPrintInterceptorService, type IDocPrintComponentContext, type IDocPrintContext } from './services/doc-print-interceptor.service';
export { DocStateChangeManagerService } from './services/doc-state-change-manager.service';
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'إعداد المستند',
mode: 'الوضع',
'modern-mode': 'حديث',
'classic-mode': 'كلاسيكي',
'modern-width': 'عرض المحتوى',
'modern-width-narrow': 'ضيق',
'modern-width-medium': 'متوسط',
'modern-width-wide': 'واسع',
'paper-size': 'حجم الورق',
'page-size': {
main: 'حجم الورق',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Executive',
folio: 'Folio',
},
orientation: 'الاتجاه',
portrait: 'عمودي',
landscape: 'أفقي',
'custom-paper-size': 'حجم ورق مخصص',
top: 'أعلى',
bottom: 'أسفل',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'Configuració de document',
mode: 'Mode',
'modern-mode': 'Modern',
'classic-mode': 'Clàssic',
'modern-width': 'Amplada del contingut',
'modern-width-narrow': 'Estreta',
'modern-width-medium': 'Mitjana',
'modern-width-wide': 'Ampla',
'paper-size': 'Mida del paper',
'page-size': {
main: 'Mida del paper',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Executiu',
folio: 'Foli',
},
orientation: 'Orientació',
portrait: 'Vertical',
landscape: 'Horitzontal',
'custom-paper-size': 'Mida de paper personalitzada',
top: 'Superior',
bottom: 'Inferior',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'Dokumenteneinstellung',
mode: 'Modus',
'modern-mode': 'Modern',
'classic-mode': 'Klassisch',
'modern-width': 'Inhaltsbreite',
'modern-width-narrow': 'Schmal',
'modern-width-medium': 'Mittel',
'modern-width-wide': 'Breit',
'paper-size': 'Papierformat',
'page-size': {
main: 'Papierformat',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Executive',
folio: 'Folio',
},
orientation: 'Ausrichtung',
portrait: 'Hochformat',
landscape: 'Querformat',
'custom-paper-size': 'Benutzerdefiniertes Papierformat',
top: 'Oben',
bottom: 'Unten',
+11
View File
@@ -108,8 +108,16 @@ const locale = {
},
'page-settings': {
'document-setting': 'Document Setting',
mode: 'Mode',
'modern-mode': 'Modern',
'classic-mode': 'Classic',
'modern-width': 'Content width',
'modern-width-narrow': 'Narrow',
'modern-width-medium': 'Medium',
'modern-width-wide': 'Wide',
'paper-size': 'Paper size',
'page-size': {
main: 'Paper size',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -122,6 +130,9 @@ const locale = {
executive: 'Executive',
folio: 'Folio',
},
orientation: 'Orientation',
portrait: 'Portrait',
landscape: 'Landscape',
'custom-paper-size': 'Custom Paper size',
top: 'Top',
bottom: 'Bottom',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'Configuración de documento',
mode: 'Modo',
'modern-mode': 'Moderno',
'classic-mode': 'Clásico',
'modern-width': 'Ancho del contenido',
'modern-width-narrow': 'Estrecho',
'modern-width-medium': 'Medio',
'modern-width-wide': 'Ancho',
'paper-size': 'Tamaño de papel',
'page-size': {
main: 'Tamaño de papel',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Ejecutivo',
folio: 'Folio',
},
orientation: 'Orientación',
portrait: 'Vertical',
landscape: 'Horizontal',
'custom-paper-size': 'Tamaño de papel personalizado',
top: 'Superior',
bottom: 'Inferior',
+12 -1
View File
@@ -110,8 +110,15 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'تنظیمات سند',
'paper-size': 'اندازه کاغذ',
mode: 'حالت',
'modern-mode': 'مدرن',
'classic-mode': 'کلاسیک',
'modern-width': 'عرض محتوا',
'modern-width-narrow': 'باریک',
'modern-width-medium': 'متوسط',
'modern-width-wide': 'عریض',
'page-size': {
main: 'اندازه کاغذ',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +131,10 @@ const locale: typeof enUS = {
executive: 'کاغذ اجرایی',
folio: 'کاغذ فولیو',
},
'paper-size': 'اندازه کاغذ',
orientation: 'جهت',
portrait: 'عمودی',
landscape: 'افقی',
'custom-paper-size': 'اندازه کاغذ سفارشی',
top: 'بالا',
bottom: 'پایین',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'Paramètres du document',
mode: 'Mode',
'modern-mode': 'Moderne',
'classic-mode': 'Classique',
'modern-width': 'Largeur du contenu',
'modern-width-narrow': 'Étroit',
'modern-width-medium': 'Moyen',
'modern-width-wide': 'Large',
'paper-size': 'Format du papier',
'page-size': {
main: 'Format du papier',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Format Exécutif',
folio: 'Format Folio',
},
orientation: 'Orientation',
portrait: 'Portrait',
landscape: 'Paysage',
'custom-paper-size': 'Format de papier personnalisé',
top: 'Haut',
bottom: 'Bas',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'Pengaturan Dokumen',
mode: 'Mode',
'modern-mode': 'Modern',
'classic-mode': 'Klasik',
'modern-width': 'Lebar konten',
'modern-width-narrow': 'Sempit',
'modern-width-medium': 'Sedang',
'modern-width-wide': 'Lebar',
'paper-size': 'Ukuran kertas',
'page-size': {
main: 'Ukuran kertas',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Executive',
folio: 'Folio',
},
orientation: 'Orientasi',
portrait: 'Potret',
landscape: 'Lanskap',
'custom-paper-size': 'Ukuran kertas kustom',
top: 'Atas',
bottom: 'Bawah',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'Impostazione Documento',
mode: 'Modalità',
'modern-mode': 'Moderna',
'classic-mode': 'Classica',
'modern-width': 'Larghezza contenuto',
'modern-width-narrow': 'Stretta',
'modern-width-medium': 'Media',
'modern-width-wide': 'Ampia',
'paper-size': 'Dimensione carta',
'page-size': {
main: 'Dimensione carta',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Executive',
folio: 'Folio',
},
orientation: 'Orientamento',
portrait: 'Verticale',
landscape: 'Orizzontale',
'custom-paper-size': 'Dimensione carta personalizzata',
top: 'Superiore',
bottom: 'Inferiore',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': '文書設定',
mode: 'モード',
'modern-mode': 'モダン',
'classic-mode': 'クラシック',
'modern-width': 'コンテンツ幅',
'modern-width-narrow': '狭い',
'modern-width-medium': '標準',
'modern-width-wide': '広い',
'paper-size': '用紙サイズ',
'page-size': {
main: '用紙サイズ',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Executive',
folio: 'Folio',
},
orientation: '方向',
portrait: '縦',
landscape: '横',
'custom-paper-size': 'カスタム用紙サイズ',
top: '上',
bottom: '下',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': '문서 설정',
mode: '모드',
'modern-mode': '모던',
'classic-mode': '클래식',
'modern-width': '콘텐츠 너비',
'modern-width-narrow': '좁게',
'modern-width-medium': '보통',
'modern-width-wide': '넓게',
'paper-size': '용지 크기',
'page-size': {
main: '용지 크기',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Executive',
folio: 'Folio',
},
orientation: '방향',
portrait: '세로',
landscape: '가로',
'custom-paper-size': '사용자 정의 용지 크기',
top: '위',
bottom: '아래',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'Ustawienia dokumentu',
mode: 'Tryb',
'modern-mode': 'Nowoczesny',
'classic-mode': 'Klasyczny',
'modern-width': 'Szerokość treści',
'modern-width-narrow': 'Wąski',
'modern-width-medium': 'Średni',
'modern-width-wide': 'Szeroki',
'paper-size': 'Rozmiar papieru',
'page-size': {
main: 'Rozmiar papieru',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Executive',
folio: 'Folio',
},
orientation: 'Orientacja',
portrait: 'Pionowa',
landscape: 'Pozioma',
'custom-paper-size': 'Niestandardowy rozmiar papieru',
top: 'Góra',
bottom: 'Dół',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'Configuração do documento',
mode: 'Modo',
'modern-mode': 'Moderno',
'classic-mode': 'Clássico',
'modern-width': 'Largura do conteúdo',
'modern-width-narrow': 'Estreito',
'modern-width-medium': 'Médio',
'modern-width-wide': 'Amplo',
'paper-size': 'Tamanho do papel',
'page-size': {
main: 'Tamanho do papel',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Executive',
folio: 'Folio',
},
orientation: 'Orientação',
portrait: 'Retrato',
landscape: 'Paisagem',
'custom-paper-size': 'Tamanho de papel personalizado',
top: 'Superior',
bottom: 'Inferior',
+12 -1
View File
@@ -110,8 +110,15 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'Настройки документа',
'paper-size': 'Размер бумаги',
mode: 'Режим',
'modern-mode': 'Современный',
'classic-mode': 'Классический',
'modern-width': 'Ширина содержимого',
'modern-width-narrow': 'Узкая',
'modern-width-medium': 'Средняя',
'modern-width-wide': 'Широкая',
'page-size': {
main: 'Размер бумаги',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +131,10 @@ const locale: typeof enUS = {
executive: 'Исполнительный формат',
folio: 'Формат фолио',
},
'paper-size': 'Размер бумаги',
orientation: 'Ориентация',
portrait: 'Книжная',
landscape: 'Альбомная',
'custom-paper-size': 'Пользовательский размер бумаги',
top: 'Верх',
bottom: 'Низ',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'Nastavenia dokumentu',
mode: 'Režim',
'modern-mode': 'Moderný',
'classic-mode': 'Klasický',
'modern-width': 'Šírka obsahu',
'modern-width-narrow': 'Úzka',
'modern-width-medium': 'Stredná',
'modern-width-wide': 'Široká',
'paper-size': 'Veľkosť papiera',
'page-size': {
main: 'Veľkosť papiera',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Executive',
folio: 'Folio',
},
orientation: 'Orientácia',
portrait: 'Na výšku',
landscape: 'Na šírku',
'custom-paper-size': 'Vlastná veľkosť papiera',
top: 'Hore',
bottom: 'Dole',
+20 -9
View File
@@ -55,15 +55,15 @@ const locale: typeof enUS = {
pageSetup: 'Cài đặt trang',
},
table: {
insert: 'Insert',
insertRowAbove: 'Insert row above',
insertRowBelow: 'Insert row below',
insertColumnLeft: 'Insert column left',
insertColumnRight: 'Insert column right',
delete: 'Table delete',
deleteRows: 'Delete row',
deleteColumns: 'Delete column',
deleteTable: 'Delete table',
insert: 'Chèn',
insertRowAbove: 'Chèn hàng phía trên',
insertRowBelow: 'Chèn hàng phía dưới',
insertColumnLeft: 'Chèn cột bên trái',
insertColumnRight: 'Chèn cột bên phải',
delete: 'Xóa bảng',
deleteRows: 'Xóa hàng',
deleteColumns: 'Xóa cột',
deleteTable: 'Xóa bảng',
},
headerFooter: {
header: 'Đầu trang',
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': 'Cài đặt tài liệu',
mode: 'Chế độ',
'modern-mode': 'Hiện đại',
'classic-mode': 'Cổ điển',
'modern-width': 'Độ rộng nội dung',
'modern-width-narrow': 'Hẹp',
'modern-width-medium': 'Vừa',
'modern-width-wide': 'Rộng',
'paper-size': 'Kích thước giấy',
'page-size': {
main: 'Kích thước giấy',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: 'Giấy hành chính',
folio: 'Giấy folio',
},
orientation: 'Hướng',
portrait: 'Dọc',
landscape: 'Ngang',
'custom-paper-size': 'Kích thước giấy tùy chỉnh',
top: 'Trên',
bottom: 'Dưới',
+12 -1
View File
@@ -110,8 +110,15 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': '文档设置',
'paper-size': '纸张大小',
mode: '模式',
'modern-mode': '现代模式',
'classic-mode': '经典模式',
'modern-width': '内容宽度',
'modern-width-narrow': '窄',
'modern-width-medium': '适中',
'modern-width-wide': '宽',
'page-size': {
main: '纸张大小',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +131,10 @@ const locale: typeof enUS = {
executive: '行政用纸',
folio: '对开纸',
},
'paper-size': '纸张大小',
orientation: '方向',
portrait: '纵向',
landscape: '横向',
'custom-paper-size': '自定义纸张大小',
top: '上',
bottom: '下',
+11
View File
@@ -110,8 +110,16 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': '文檔設置',
mode: '模式',
'modern-mode': '現代模式',
'classic-mode': '經典模式',
'modern-width': '內容寬度',
'modern-width-narrow': '窄',
'modern-width-medium': '適中',
'modern-width-wide': '寬',
'paper-size': '紙張大小',
'page-size': {
main: '紙張大小',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +132,9 @@ const locale: typeof enUS = {
executive: '行政用紙',
folio: '對開紙',
},
orientation: '方向',
portrait: '縱向',
landscape: '橫向',
'custom-paper-size': '自定義紙張大小',
top: '上',
bottom: '下',
+21 -10
View File
@@ -55,15 +55,15 @@ const locale: typeof enUS = {
pageSetup: '頁面設置',
},
table: {
insert: 'Insert',
insertRowAbove: 'Insert row above',
insertRowBelow: 'Insert row below',
insertColumnLeft: 'Insert column left',
insertColumnRight: 'Insert column right',
delete: 'Table delete',
deleteRows: 'Delete row',
deleteColumns: 'Delete column',
deleteTable: 'Delete table',
insert: '插入',
insertRowAbove: '在上方插入行',
insertRowBelow: '在下方插入行',
insertColumnLeft: '在左側插入列',
insertColumnRight: '在右側插入列',
delete: '刪除表格',
deleteRows: '刪除行',
deleteColumns: '刪除列',
deleteTable: '刪除表格',
},
headerFooter: {
header: '頁眉',
@@ -110,8 +110,15 @@ const locale: typeof enUS = {
},
'page-settings': {
'document-setting': '文檔設置',
'paper-size': '紙張大小',
mode: '模式',
'modern-mode': '現代模式',
'classic-mode': '經典模式',
'modern-width': '內容寬度',
'modern-width-narrow': '窄',
'modern-width-medium': '適中',
'modern-width-wide': '寬',
'page-size': {
main: '紙張大小',
a4: 'A4',
a3: 'A3',
a5: 'A5',
@@ -124,6 +131,10 @@ const locale: typeof enUS = {
executive: '行政用紙',
folio: '對開紙',
},
'paper-size': '紙張大小',
orientation: '方向',
portrait: '縱向',
landscape: '橫向',
'custom-paper-size': '自定義紙張大小',
top: '上',
bottom: '下',
@@ -0,0 +1,186 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NamedStyleType, PresetListType } from '@univerjs/core';
import { ContextMenuGroup, ContextMenuPosition, MenuItemType, RibbonInsertGroup, RibbonStartGroup } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { HorizontalLineCommand, InsertHorizontalLineBellowCommand } from '../../commands/commands/doc-horizontal-line.command';
import { BulletListCommand, CheckListCommand, InsertBulletListBellowCommand, InsertCheckListBellowCommand, InsertOrderListBellowCommand, OrderListCommand } from '../../commands/commands/list.command';
import { AlignCenterCommand, AlignJustifyCommand, AlignLeftCommand, AlignOperationCommand, AlignRightCommand } from '../../commands/commands/paragraph-align.command';
import { H1HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../commands/commands/set-heading.command';
import { SwitchDocModeCommand } from '../../commands/commands/switch-doc-mode.command';
import { CreateDocTableCommand } from '../../commands/commands/table/doc-table-create.command';
import { DocTableDeleteTableCommand } from '../../commands/commands/table/doc-table-delete.command';
import { DocCreateTableOperation } from '../../commands/operations/doc-create-table.operation';
import { DocParagraphSettingPanelOperation } from '../../commands/operations/doc-paragraph-setting-panel.operation';
import { AlignMenuItemFactory, DocSwitchModeMenuItemFactory, FLOAT_TEXT_STYLE_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION, FloatTextStyleMenuItemFactory, InsertDefaultTableMenuFactory, InsertTableMenuFactory } from '../menu';
import { DOC_CONTENT_INSERT_MENU_ID, DOC_TABLE_BLOCK_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '../paragraph-menu';
import { menuSchema } from '../schema';
describe('docs ui ribbon schema', () => {
it('uses one align dropdown instead of separate toolbar buttons', () => {
const layout = (menuSchema as any)[RibbonStartGroup.LAYOUT];
expect(layout[AlignOperationCommand.id].menuItemFactory).toBe(AlignMenuItemFactory);
expect(layout[AlignLeftCommand.id]).toBeUndefined();
expect(layout[AlignCenterCommand.id]).toBeUndefined();
expect(layout[AlignRightCommand.id]).toBeUndefined();
expect(layout[AlignJustifyCommand.id]).toBeUndefined();
const item = AlignMenuItemFactory({ get: () => ({}) } as never);
expect(item.type).toBe(MenuItemType.SELECTOR);
expect((item.selections as Array<{ id?: string }>).map((option) => option.id)).toEqual([
AlignLeftCommand.id,
AlignCenterCommand.id,
AlignRightCommand.id,
AlignJustifyCommand.id,
]);
expect((item.selections as Array<{ label?: string }>).map((option) => option.label)).toEqual([
'docs-ui.toolbar.alignLeft',
'docs-ui.toolbar.alignCenter',
'docs-ui.toolbar.alignRight',
'docs-ui.toolbar.alignJustify',
]);
});
it('moves horizontal line and checklist to the insert tab', () => {
const layout = (menuSchema as any)[RibbonStartGroup.LAYOUT];
const media = (menuSchema as any)[RibbonInsertGroup.MEDIA];
expect(layout[HorizontalLineCommand.id]).toBeUndefined();
expect(layout[CheckListCommand.id]).toBeUndefined();
expect(media[HorizontalLineCommand.id].menuItemFactory).toBeDefined();
expect(media[CheckListCommand.id].menuItemFactory).toBeDefined();
});
it('keeps the doc mode switch beside page settings', () => {
const layout = (menuSchema as any)[RibbonStartGroup.LAYOUT];
expect(layout[SwitchDocModeCommand.id].order).toBe(11);
expect(layout[SwitchDocModeCommand.id].menuItemFactory).toBe(DocSwitchModeMenuItemFactory);
});
it('adds a text style menu for the floating toolbar', () => {
const format = (menuSchema as any)[RibbonStartGroup.FORMAT];
const floatToolbar = (menuSchema as any)[FLOAT_TOOLBAR_MENU_POSITION];
expect(format[FLOAT_TEXT_STYLE_MENU_ID]).toBeUndefined();
expect(floatToolbar[FLOAT_TEXT_STYLE_MENU_ID].order).toBe(0);
const item = FloatTextStyleMenuItemFactory({ get: () => ({ get: () => undefined, register: () => undefined }) } as never);
expect(item.type).toBe(MenuItemType.SELECTOR);
expect(item.commandId).toBe(SetParagraphNamedStyleCommand.id);
expect((item.selections as Array<{ id?: string; value?: string | number }>).map((option) => [option.id, option.value])).toEqual([
[undefined, NamedStyleType.NORMAL_TEXT],
[undefined, NamedStyleType.HEADING_1],
[undefined, NamedStyleType.HEADING_2],
[undefined, NamedStyleType.HEADING_3],
[undefined, NamedStyleType.HEADING_4],
[undefined, NamedStyleType.HEADING_5],
[OrderListCommand.id, PresetListType.ORDER_LIST],
[BulletListCommand.id, PresetListType.BULLET_LIST],
[CheckListCommand.id, PresetListType.CHECK_LIST],
]);
expect((item.selections as Array<{ label?: string }>).map((option) => option.label)).toEqual([
'ui.toolbar.heading.normal',
'ui.toolbar.heading.1',
'ui.toolbar.heading.2',
'ui.toolbar.heading.3',
'ui.toolbar.heading.4',
'ui.toolbar.heading.5',
'docs-ui.toolbar.order',
'docs-ui.toolbar.unorder',
'docs-ui.toolbar.checklist',
]);
});
it('uses current-paragraph first-level actions for empty paragraph menus', () => {
const paragraph = (menuSchema as any)[ContextMenuPosition.PARAGRAPH];
const insertBelow = paragraph[ContextMenuGroup.LAYOUT][INSERT_BELLOW_MENU_ID];
const contentInsertQuick = paragraph[DOC_CONTENT_INSERT_MENU_ID][ContextMenuGroup.QUICK];
const contentInsert = paragraph[DOC_CONTENT_INSERT_MENU_ID][ContextMenuGroup.LAYOUT];
const emptyMenu = paragraph[EMPTY_PARAGRAPH_MENU_ID];
const quick = emptyMenu[ContextMenuGroup.QUICK];
const layout = emptyMenu[ContextMenuGroup.LAYOUT];
expect(quick.order).toBe(-1);
expect(contentInsertQuick.order).toBe(-2);
expect(contentInsertQuick.quickLayout).toBe('icon');
expect(layout.order).toBe(1);
expect(quick[DocParagraphSettingPanelOperation.id]).toBeUndefined();
expect(paragraph[ContextMenuGroup.LAYOUT][DocParagraphSettingPanelOperation.id].order).toBeLessThan(insertBelow.order);
expect(layout[DocParagraphSettingPanelOperation.id].order).toBeLessThan(layout[BulletListCommand.id].order);
expect(quick[H1HeadingCommand.id].menuItemFactory).toBeDefined();
expect(paragraph[ContextMenuGroup.QUICK][TitleHeadingCommand.id].menuItemFactory).toBeDefined();
expect(paragraph[ContextMenuGroup.QUICK][SubtitleHeadingCommand.id].menuItemFactory).toBeDefined();
expect(paragraph[ContextMenuGroup.QUICK][TitleHeadingCommand.id].order).toBeLessThan(quick[H1HeadingCommand.id].order);
expect(paragraph[ContextMenuGroup.QUICK][SubtitleHeadingCommand.id].order).toBeLessThan(quick[H1HeadingCommand.id].order);
expect(quick[NormalTextHeadingCommand.id].menuItemFactory).toBeDefined();
expect(contentInsertQuick[H1HeadingCommand.id].menuItemFactory).toBeDefined();
expect(contentInsertQuick[NormalTextHeadingCommand.id].menuItemFactory).toBeDefined();
expect(paragraph[ContextMenuGroup.QUICK][OrderListCommand.id]).toBeUndefined();
expect(paragraph[ContextMenuGroup.QUICK][BulletListCommand.id]).toBeUndefined();
expect(paragraph[ContextMenuGroup.QUICK][CheckListCommand.id]).toBeUndefined();
expect(insertBelow[InsertOrderListBellowCommand.id].menuItemFactory).toBeDefined();
expect(insertBelow[InsertBulletListBellowCommand.id].menuItemFactory).toBeDefined();
expect(insertBelow[InsertCheckListBellowCommand.id].menuItemFactory).toBeDefined();
expect(insertBelow[InsertHorizontalLineBellowCommand.id].menuItemFactory).toBeDefined();
expect(insertBelow[DocCreateTableOperation.id].menuItemFactory).toBe(InsertDefaultTableMenuFactory);
expect(contentInsert[InsertOrderListBellowCommand.id].menuItemFactory).toBeDefined();
expect(contentInsert[InsertBulletListBellowCommand.id].menuItemFactory).toBeDefined();
expect(contentInsert[InsertCheckListBellowCommand.id].menuItemFactory).toBeDefined();
expect(contentInsert[InsertHorizontalLineBellowCommand.id].menuItemFactory).toBeDefined();
expect(contentInsert[DocCreateTableOperation.id].menuItemFactory).toBe(InsertDefaultTableMenuFactory);
expect(layout[OrderListCommand.id].menuItemFactory).toBeDefined();
expect(layout[BulletListCommand.id].menuItemFactory).toBeDefined();
expect(layout[CheckListCommand.id].menuItemFactory).toBeDefined();
expect(layout[HorizontalLineCommand.id].menuItemFactory).toBeDefined();
expect(layout[DocCreateTableOperation.id].menuItemFactory).toBe(InsertDefaultTableMenuFactory);
expect(emptyMenu[ContextMenuGroup.FORMAT]).toBeUndefined();
expect(emptyMenu[INSERT_BELLOW_MENU_ID]).toBeUndefined();
expect(layout[InsertHorizontalLineBellowCommand.id]).toBeUndefined();
});
it('uses the same table icon in paragraph insert menus', () => {
const item = InsertTableMenuFactory({ get: () => undefined } as never);
expect(item.icon).toBe('GridIcon');
});
it('uses a compact table block menu without text style actions', () => {
const paragraph = (menuSchema as any)[ContextMenuPosition.PARAGRAPH];
const tableMenu = paragraph[DOC_TABLE_BLOCK_MENU_ID];
const format = tableMenu[ContextMenuGroup.FORMAT];
const layout = tableMenu[ContextMenuGroup.LAYOUT];
expect(tableMenu[ContextMenuGroup.QUICK]).toBeUndefined();
expect(format[DocTableDeleteTableCommand.id].menuItemFactory).toBeDefined();
expect(format[H1HeadingCommand.id]).toBeUndefined();
expect(format[NormalTextHeadingCommand.id]).toBeUndefined();
expect(layout[INSERT_BELLOW_MENU_ID][DocCreateTableOperation.id].menuItemFactory).toBe(InsertDefaultTableMenuFactory);
});
it('uses direct table creation for paragraph insert table actions', () => {
const item = InsertDefaultTableMenuFactory({ get: () => undefined } as never);
expect(item.commandId).toBe(CreateDocTableCommand.id);
expect(item.params).toEqual({ rowCount: 3, colCount: 5 });
expect(item.icon).toBe('GridIcon');
expect(item.title).toBe('docs-ui.toolbar.table.insert');
});
});
+249 -2
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, PresetListType } from '@univerjs/core';
import type { DocumentDataModel, IAccessor, ITextRangeParam, Nullable } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IMenuButtonItem, IMenuItem, IMenuSelectorItem } from '@univerjs/ui';
import {
@@ -29,6 +29,7 @@ import {
IUniverInstanceService,
NAMED_STYLE_MAP,
NamedStyleType,
PresetListType,
ThemeService,
Tools,
UniverInstanceType,
@@ -40,9 +41,11 @@ import {
SetTextSelectionsOperation,
} from '@univerjs/docs';
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
import { H1Icon, H2Icon, H3Icon, H4Icon, H5Icon, TextTypeIcon } from '@univerjs/icons';
import {
COLOR_PICKER_COMPONENT,
COMMON_LABEL_COMPONENT,
ComponentManager,
FONT_FAMILY_COMPONENT,
FONT_FAMILY_ITEM_COMPONENT,
FONT_SIZE_COMPONENT,
@@ -61,6 +64,7 @@ import { BulletListCommand, CheckListCommand, OrderListCommand } from '../comman
import { AlignCenterCommand, AlignJustifyCommand, AlignLeftCommand, AlignOperationCommand, AlignRightCommand } from '../commands/commands/paragraph-align.command';
import { SetParagraphNamedStyleCommand } from '../commands/commands/set-heading.command';
import { SwitchDocModeCommand } from '../commands/commands/switch-doc-mode.command';
import { CreateDocTableCommand } from '../commands/commands/table/doc-table-create.command';
import { DocCreateTableOperation } from '../commands/operations/doc-create-table.operation';
import { DocOpenPageSettingCommand } from '../commands/operations/open-page-setting.operation';
import { getCommandSkeleton } from '../commands/util';
@@ -216,6 +220,7 @@ function getTableDisabledObservable(accessor: IAccessor): Observable<boolean> {
export function disableMenuWhenNoDocRange(accessor: IAccessor): Observable<boolean> {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
return new Observable((subscriber) => {
const subscription = docSelectionManagerService.textSelection$.subscribe((selection) => {
@@ -231,6 +236,15 @@ export function disableMenuWhenNoDocRange(accessor: IAccessor): Observable<boole
return;
}
const document = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const codeBlockRanges = document?.getBody()?.blockRanges?.filter((range) => range.blockType === 'code') ?? [];
if (codeBlockRanges.some((blockRange) => textRanges.some((range) => (
Math.max(range.startOffset, blockRange.startIndex) <= Math.min(range.endOffset, blockRange.endIndex)
)))) {
subscriber.next(true);
return;
}
subscriber.next(false);
});
@@ -238,6 +252,38 @@ export function disableMenuWhenNoDocRange(accessor: IAccessor): Observable<boole
});
}
export function isTextRangeInAnyBlockRange(document: Nullable<DocumentDataModel>, range: ITextRangeParam): boolean {
const blockRanges = document?.getBody()?.blockRanges ?? [];
const startOffset = range.startOffset;
const endOffset = range.collapsed ? range.startOffset : range.endOffset - 1;
return blockRanges.some((blockRange) => (
Math.max(startOffset, blockRange.startIndex) <= Math.min(endOffset, blockRange.endIndex)
));
}
export function hideMenuWhenSelectionInBlockRange(accessor: IAccessor): Observable<boolean> {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
return new Observable((subscriber) => {
const calc = (selection?: { textRanges?: ITextRangeParam[]; unitId?: string }) => {
const currentSelection = (docSelectionManagerService as { __getCurrentSelection?: () => { unitId?: string } | null }).__getCurrentSelection?.();
const textRanges = selection?.textRanges ?? [...(docSelectionManagerService.getTextRanges() ?? [])];
const unitId = selection?.unitId ?? currentSelection?.unitId;
const document = unitId
? univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC)
: univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
subscriber.next(textRanges.some((range) => isTextRangeInAnyBlockRange(document, range)));
};
calc();
const subscription = docSelectionManagerService.textSelection$.subscribe((selection) => calc(selection));
return () => subscription.unsubscribe();
});
}
export function BoldMenuItemFactory(accessor: IAccessor): IMenuButtonItem {
const commandService = accessor.get(ICommandService);
@@ -577,7 +623,7 @@ export function HeadingSelectorMenuItemFactory(accessor: IAccessor): IMenuSelect
return {
id: SetParagraphNamedStyleCommand.id,
type: MenuItemType.SELECTOR,
tooltip: 'docs-ui.toolbar.heading.tooltip',
tooltip: 'ui.toolbar.heading.tooltip',
label: {
name: COMMON_LABEL_COMPONENT,
props: {
@@ -624,6 +670,133 @@ export function HeadingSelectorMenuItemFactory(accessor: IAccessor): IMenuSelect
};
}
export const FLOAT_TEXT_STYLE_MENU_ID = 'doc.menu.float-text-style';
export const FLOAT_TOOLBAR_MENU_POSITION = 'doc.menu.float-toolbar';
const FLOAT_TEXT_STYLE_OPTIONS = [
{
icon: 'TextTypeIcon',
label: 'ui.toolbar.heading.normal',
value: NamedStyleType.NORMAL_TEXT,
},
{
icon: 'H1Icon',
label: 'ui.toolbar.heading.1',
value: NamedStyleType.HEADING_1,
},
{
icon: 'H2Icon',
label: 'ui.toolbar.heading.2',
value: NamedStyleType.HEADING_2,
},
{
icon: 'H3Icon',
label: 'ui.toolbar.heading.3',
value: NamedStyleType.HEADING_3,
},
{
icon: 'H4Icon',
label: 'ui.toolbar.heading.4',
value: NamedStyleType.HEADING_4,
},
{
icon: 'H5Icon',
label: 'ui.toolbar.heading.5',
value: NamedStyleType.HEADING_5,
},
{
id: OrderListCommand.id,
icon: 'OrderIcon',
label: 'docs-ui.toolbar.order',
value: PresetListType.ORDER_LIST,
},
{
id: BulletListCommand.id,
icon: 'UnorderIcon',
label: 'docs-ui.toolbar.unorder',
value: PresetListType.BULLET_LIST,
},
{
id: CheckListCommand.id,
icon: 'TodoListDoubleIcon',
label: 'docs-ui.toolbar.checklist',
value: PresetListType.CHECK_LIST,
},
];
function registerFloatingTextStyleIcons(componentManager: ComponentManager): void {
([
['TextTypeIcon', TextTypeIcon],
['H1Icon', H1Icon],
['H2Icon', H2Icon],
['H3Icon', H3Icon],
['H4Icon', H4Icon],
['H5Icon', H5Icon],
] as const).forEach(([key, component]) => {
if (!componentManager.get(key)) {
componentManager.register(key, component);
}
});
}
function normalizeFloatingTextStyleValue(paragraph: ReturnType<typeof getParagraphStyleAtCursor>): string | number {
const listType = paragraph?.bullet?.listType;
if (listType?.startsWith(PresetListType.ORDER_LIST)) {
return PresetListType.ORDER_LIST;
}
if (listType?.startsWith(PresetListType.BULLET_LIST)) {
return PresetListType.BULLET_LIST;
}
if (listType === PresetListType.CHECK_LIST || listType === PresetListType.CHECK_LIST_CHECKED) {
return PresetListType.CHECK_LIST;
}
return paragraph?.paragraphStyle?.namedStyleType ?? NamedStyleType.NORMAL_TEXT;
}
export function FloatTextStyleMenuItemFactory(accessor: IAccessor): IMenuSelectorItem<string | number> {
const commandService = accessor.get(ICommandService);
const componentManager = accessor.get(ComponentManager);
registerFloatingTextStyleIcons(componentManager);
return {
id: FLOAT_TEXT_STYLE_MENU_ID,
commandId: SetParagraphNamedStyleCommand.id,
type: MenuItemType.SELECTOR,
icon: 'TextTypeIcon',
tooltip: 'ui.toolbar.heading.tooltip',
selections: FLOAT_TEXT_STYLE_OPTIONS,
value$: new Observable((subscriber) => {
const calc = () => {
subscriber.next(normalizeFloatingTextStyleValue(getParagraphStyleAtCursor(accessor)));
};
const disposable = commandService.onCommandExecuted((c) => {
const id = c.id;
if (
id === SetTextSelectionsOperation.id ||
id === SetParagraphNamedStyleCommand.id ||
id === OrderListCommand.id ||
id === BulletListCommand.id ||
id === CheckListCommand.id
) {
calc();
}
});
calc();
return disposable.dispose;
}),
disabled$: disableMenuWhenNoDocRange(accessor),
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function TextColorSelectorMenuItemFactory(accessor: IAccessor): IMenuSelectorItem<string, string | undefined> {
const commandService = accessor.get(ICommandService);
const themeService = accessor.get(ThemeService);
@@ -719,6 +892,22 @@ export function InsertTableMenuFactory(_accessor: IAccessor): IMenuButtonItem {
id: DocCreateTableOperation.id,
title: 'docs-ui.toolbar.table.insert',
type: MenuItemType.BUTTON,
icon: TableIcon,
hidden$: getMenuHiddenObservable(_accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function InsertDefaultTableMenuFactory(_accessor: IAccessor): IMenuButtonItem {
return {
id: DocCreateTableOperation.id,
commandId: CreateDocTableCommand.id,
params: {
rowCount: 3,
colCount: 5,
},
title: 'docs-ui.toolbar.table.insert',
type: MenuItemType.BUTTON,
icon: TableIcon,
hidden$: getMenuHiddenObservable(_accessor, UniverInstanceType.UNIVER_DOC),
};
}
@@ -859,6 +1048,64 @@ export function AlignJustifyMenuItemFactory(accessor: IAccessor): IMenuButtonIte
};
}
const HORIZONTAL_ALIGN_OPTIONS = [
{
id: AlignLeftCommand.id,
value: HorizontalAlign.LEFT,
label: 'docs-ui.toolbar.alignLeft',
icon: 'LeftJustifyingIcon',
},
{
id: AlignCenterCommand.id,
value: HorizontalAlign.CENTER,
label: 'docs-ui.toolbar.alignCenter',
icon: 'HorizontallyIcon',
},
{
id: AlignRightCommand.id,
value: HorizontalAlign.RIGHT,
label: 'docs-ui.toolbar.alignRight',
icon: 'RightJustifyingIcon',
},
{
id: AlignJustifyCommand.id,
value: HorizontalAlign.JUSTIFIED,
label: 'docs-ui.toolbar.alignJustify',
icon: 'AlignTextBothIcon',
},
];
export function AlignMenuItemFactory(accessor: IAccessor): IMenuSelectorItem<HorizontalAlign, HorizontalAlign> {
const commandService = accessor.get(ICommandService);
const value$ = new Observable<HorizontalAlign>((subscriber) => {
const calc = () => {
const paragraph = getParagraphStyleAtCursor(accessor);
subscriber.next(paragraph?.paragraphStyle?.horizontalAlign ?? HorizontalAlign.LEFT);
};
const disposable = commandService.onCommandExecuted((c) => {
if (c.id === SetTextSelectionsOperation.id || c.id === AlignOperationCommand.id) {
calc();
}
});
calc();
return disposable.dispose;
});
return {
id: AlignOperationCommand.id,
type: MenuItemType.SELECTOR,
icon: value$.pipe(map((alignType) => HORIZONTAL_ALIGN_OPTIONS.find((option) => option.value === alignType)?.icon ?? 'LeftJustifyingIcon')),
tooltip: 'docs-ui.toolbar.alignLeft',
selections: HORIZONTAL_ALIGN_OPTIONS,
value$,
disabled$: disableMenuWhenNoDocRange(accessor),
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC, undefined, DOCS_ZEN_EDITOR_UNIT_ID_KEY),
};
}
export function HorizontalLineFactory(accessor: IAccessor): IMenuButtonItem {
return {
id: HorizontalLineCommand.id,
+149 -8
View File
@@ -15,18 +15,21 @@
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import type { IMenuItem, IMenuSelectorItem } from '@univerjs/ui';
import type { IMenuButtonItem, IMenuItem, IMenuSelectorItem } from '@univerjs/ui';
import type { ComponentType } from 'react';
import { ICommandService, NamedStyleType, UniverInstanceType } from '@univerjs/core';
import { SetTextSelectionsOperation } from '@univerjs/docs';
import { H1Icon, H2Icon, H3Icon, H4Icon, H5Icon, TextTypeIcon } from '@univerjs/icons';
import { ComponentManager, getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { createElement } from 'react';
import { Observable } from 'rxjs';
import { DocCopyCurrentParagraphCommand, DocCutCurrentParagraphCommand } from '../commands/commands/clipboard.command';
import { DocCopyCommand, DocCopyCurrentParagraphCommand, DocCutCurrentParagraphCommand, DocPasteCommand } from '../commands/commands/clipboard.command';
import { DeleteCurrentParagraphCommand } from '../commands/commands/doc-delete.command';
import { InsertHorizontalLineBellowCommand } from '../commands/commands/doc-horizontal-line.command';
import { HorizontalLineCommand, InsertHorizontalLineBellowCommand } from '../commands/commands/doc-horizontal-line.command';
import { SetInlineFormatFontSizeCommand } from '../commands/commands/inline-format.command';
import { InsertBulletListBellowCommand, InsertCheckListBellowCommand, InsertOrderListBellowCommand } from '../commands/commands/list.command';
import { BulletListCommand, CheckListCommand, InsertBulletListBellowCommand, InsertCheckListBellowCommand, InsertOrderListBellowCommand, OrderListCommand } from '../commands/commands/list.command';
import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../commands/commands/set-heading.command';
import { DocTableDeleteTableCommand } from '../commands/commands/table/doc-table-delete.command';
import { disableMenuWhenNoDocRange, getParagraphStyleAtCursor } from './menu';
const HEADING_MAP: Record<NamedStyleType, ICommand> = {
@@ -41,18 +44,90 @@ const HEADING_MAP: Record<NamedStyleType, ICommand> = {
[NamedStyleType.NAMED_STYLE_TYPE_UNSPECIFIED]: NormalTextHeadingCommand,
};
export const HEADING_ICON_MAP: Record<NamedStyleType, { key: string; component: React.ComponentType<{ className: string }> }> = {
const HEADING_TITLE_MAP: Partial<Record<NamedStyleType, string>> = {
[NamedStyleType.HEADING_1]: 'ui.toolbar.heading.1',
[NamedStyleType.HEADING_2]: 'ui.toolbar.heading.2',
[NamedStyleType.HEADING_3]: 'ui.toolbar.heading.3',
[NamedStyleType.HEADING_4]: 'ui.toolbar.heading.4',
[NamedStyleType.HEADING_5]: 'ui.toolbar.heading.5',
[NamedStyleType.NORMAL_TEXT]: 'ui.toolbar.heading.normal',
[NamedStyleType.TITLE]: 'ui.toolbar.heading.title',
[NamedStyleType.SUBTITLE]: 'ui.toolbar.heading.subTitle',
};
function TitleTypeIcon({ className }: { className: string }) {
return createElement(
'svg',
{
className,
viewBox: '0 0 24 24',
fill: 'currentColor',
'aria-hidden': true,
},
createElement('text', {
x: 2,
y: 19,
fontFamily: 'Arial, sans-serif',
fontSize: 19,
fontWeight: 500,
}, 'T'),
createElement('text', {
x: 15,
y: 19,
fontFamily: 'Arial, sans-serif',
fontSize: 13,
fontWeight: 500,
}, 't')
);
}
function SubtitleTypeIcon({ className }: { className: string }) {
return createElement(
'svg',
{
className,
viewBox: '0 0 24 24',
fill: 'currentColor',
'aria-hidden': true,
},
createElement('text', {
x: 5,
y: 19,
fontFamily: 'Arial, sans-serif',
fontSize: 19,
fontWeight: 500,
}, 'S')
);
}
export const HEADING_ICON_MAP: Record<NamedStyleType, { key: string; component: ComponentType<{ className: string }> }> = {
[NamedStyleType.HEADING_1]: { key: 'H1Icon', component: H1Icon },
[NamedStyleType.HEADING_2]: { key: 'H2Icon', component: H2Icon },
[NamedStyleType.HEADING_3]: { key: 'H3Icon', component: H3Icon },
[NamedStyleType.HEADING_4]: { key: 'H4Icon', component: H4Icon },
[NamedStyleType.HEADING_5]: { key: 'H5Icon', component: H5Icon },
[NamedStyleType.NORMAL_TEXT]: { key: 'TextTypeIcon', component: TextTypeIcon },
[NamedStyleType.TITLE]: { key: 'TextTypeIcon', component: TextTypeIcon },
[NamedStyleType.SUBTITLE]: { key: 'TextTypeIcon', component: TextTypeIcon },
[NamedStyleType.TITLE]: { key: 'TitleTypeIcon', component: TitleTypeIcon },
[NamedStyleType.SUBTITLE]: { key: 'SubtitleTypeIcon', component: SubtitleTypeIcon },
[NamedStyleType.NAMED_STYLE_TYPE_UNSPECIFIED]: { key: 'TextTypeIcon', component: TextTypeIcon },
};
export function shouldShowParagraphHeadingOption(headingType: NamedStyleType, currentType = NamedStyleType.NORMAL_TEXT): boolean {
if (headingType === NamedStyleType.HEADING_5) {
return currentType !== NamedStyleType.TITLE && currentType !== NamedStyleType.SUBTITLE;
}
if (headingType === NamedStyleType.TITLE) {
return currentType === NamedStyleType.TITLE;
}
if (headingType === NamedStyleType.SUBTITLE) {
return currentType === NamedStyleType.SUBTITLE;
}
return true;
}
const createHeadingSelectorMenuItemFactory = (headingType: NamedStyleType) => (accessor: IAccessor): IMenuItem => {
const commandService = accessor.get(ICommandService);
const componentManager = accessor.get(ComponentManager);
@@ -65,7 +140,8 @@ const createHeadingSelectorMenuItemFactory = (headingType: NamedStyleType) => (a
id: HEADING_MAP[headingType]!.id,
type: MenuItemType.BUTTON,
icon: icon.key,
tooltip: 'docs-ui.toolbar.heading.tooltip',
title: HEADING_TITLE_MAP[headingType],
tooltip: 'ui.toolbar.heading.tooltip',
disabled$: disableMenuWhenNoDocRange(accessor),
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
activated$: new Observable((subscriber) => {
@@ -104,6 +180,37 @@ export const NormalTextHeadingMenuItemFactory = createHeadingSelectorMenuItemFac
export const TitleHeadingMenuItemFactory = createHeadingSelectorMenuItemFactory(NamedStyleType.TITLE);
export const SubtitleHeadingMenuItemFactory = createHeadingSelectorMenuItemFactory(NamedStyleType.SUBTITLE);
const createEmptyParagraphButtonFactory = (
command: ICommand,
icon: string,
title: string
) => (accessor: IAccessor): IMenuButtonItem => {
const componentManager = accessor.get(ComponentManager);
const headingIcon = Object.values(HEADING_ICON_MAP).find((item) => item.key === icon);
if (headingIcon && !componentManager.get(headingIcon.key)) {
componentManager.register(headingIcon.key, headingIcon.component);
}
return {
id: command.id,
type: MenuItemType.BUTTON,
icon,
title,
};
};
export const EMPTY_PARAGRAPH_MENU_ID = 'doc.menu.empty-paragraph';
export const EmptyParagraphH1MenuItemFactory = createEmptyParagraphButtonFactory(H1HeadingCommand, 'H1Icon', 'ui.toolbar.heading.1');
export const EmptyParagraphH2MenuItemFactory = createEmptyParagraphButtonFactory(H2HeadingCommand, 'H2Icon', 'ui.toolbar.heading.2');
export const EmptyParagraphH3MenuItemFactory = createEmptyParagraphButtonFactory(H3HeadingCommand, 'H3Icon', 'ui.toolbar.heading.3');
export const EmptyParagraphH4MenuItemFactory = createEmptyParagraphButtonFactory(H4HeadingCommand, 'H4Icon', 'ui.toolbar.heading.4');
export const EmptyParagraphH5MenuItemFactory = createEmptyParagraphButtonFactory(H5HeadingCommand, 'H5Icon', 'ui.toolbar.heading.5');
export const EmptyParagraphNormalTextMenuItemFactory = createEmptyParagraphButtonFactory(NormalTextHeadingCommand, 'TextTypeIcon', 'ui.toolbar.heading.normal');
export const EmptyParagraphOrderListMenuItemFactory = createEmptyParagraphButtonFactory(OrderListCommand, 'OrderIcon', 'docs-ui.rightClick.orderList');
export const EmptyParagraphBulletListMenuItemFactory = createEmptyParagraphButtonFactory(BulletListCommand, 'UnorderIcon', 'docs-ui.rightClick.bulletList');
export const EmptyParagraphCheckListMenuItemFactory = createEmptyParagraphButtonFactory(CheckListCommand, 'TodoListDoubleIcon', 'docs-ui.rightClick.checkList');
export const EmptyParagraphHorizontalLineMenuItemFactory = createEmptyParagraphButtonFactory(HorizontalLineCommand, 'ReduceIcon', 'docs-ui.toolbar.horizontalLine');
export const CopyCurrentParagraphMenuItemFactory = (accessor: IAccessor): IMenuItem => {
return {
id: DocCopyCurrentParagraphCommand.id,
@@ -168,6 +275,40 @@ export const InsertHorizontalLineBellowMenuItemFactory = (accessor: IAccessor):
};
export const INSERT_BELLOW_MENU_ID = 'doc.menu.insert-bellow';
export const DOC_CONTENT_INSERT_MENU_ID = 'doc.menu.content-insert';
export const DOC_TABLE_BLOCK_MENU_ID = 'doc.menu.table-block';
export function getDocBlockRangeMenuId(blockType: string): string {
return `doc.block-range.${blockType}.menu`;
}
export const TableBlockCopyMenuItemFactory = (accessor: IAccessor): IMenuItem => {
return {
id: DocCopyCommand.name,
commandId: DocCopyCommand.id,
type: MenuItemType.BUTTON,
icon: 'CopyDoubleIcon',
title: 'docs-ui.rightClick.copy',
};
};
export const TableBlockPasteMenuItemFactory = (accessor: IAccessor): IMenuItem => {
return {
id: DocPasteCommand.id,
type: MenuItemType.BUTTON,
icon: 'PasteSpecialDoubleIcon',
title: 'docs-ui.rightClick.paste',
};
};
export const TableBlockDeleteMenuItemFactory = (accessor: IAccessor): IMenuItem => {
return {
id: DocTableDeleteTableCommand.id,
type: MenuItemType.BUTTON,
icon: 'DeleteIcon',
title: 'docs-ui.rightClick.delete',
};
};
export function DocInsertBellowMenuItemFactory(accessor: IAccessor): IMenuSelectorItem<string> {
return {
+197 -42
View File
@@ -23,8 +23,8 @@ import { HorizontalLineCommand, InsertHorizontalLineBellowCommand } from '../com
import { ResetInlineFormatTextBackgroundColorCommand, SetInlineFormatBoldCommand, SetInlineFormatFontFamilyCommand, SetInlineFormatFontSizeCommand, SetInlineFormatItalicCommand, SetInlineFormatStrikethroughCommand, SetInlineFormatSubscriptCommand, SetInlineFormatSuperscriptCommand, SetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextColorCommand, SetInlineFormatUnderlineCommand } from '../commands/commands/inline-format.command';
import { BulletListCommand, CheckListCommand, InsertBulletListBellowCommand, InsertCheckListBellowCommand, InsertOrderListBellowCommand, OrderListCommand } from '../commands/commands/list.command';
import { AlignCenterCommand, AlignJustifyCommand, AlignLeftCommand, AlignRightCommand } from '../commands/commands/paragraph-align.command';
import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand } from '../commands/commands/set-heading.command';
import { AlignOperationCommand } from '../commands/commands/paragraph-align.command';
import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../commands/commands/set-heading.command';
import { SwitchDocModeCommand } from '../commands/commands/switch-doc-mode.command';
import { DocTableDeleteColumnsCommand, DocTableDeleteRowsCommand, DocTableDeleteTableCommand } from '../commands/commands/table/doc-table-delete.command';
import { DocTableInsertColumnLeftCommand, DocTableInsertColumnRightCommand, DocTableInsertRowAboveCommand, DocTableInsertRowBellowCommand } from '../commands/commands/table/doc-table-insert.command';
@@ -50,20 +50,21 @@ import {
TableInsertMenuItemFactory,
} from './context-menu';
import {
AlignCenterMenuItemFactory,
AlignJustifyMenuItemFactory,
AlignLeftMenuItemFactory,
AlignRightMenuItemFactory,
AlignMenuItemFactory,
BackgroundColorSelectorMenuItemFactory,
BoldMenuItemFactory,
BulletListMenuItemFactory,
CheckListMenuItemFactory,
DocSwitchModeMenuItemFactory,
FLOAT_TEXT_STYLE_MENU_ID,
FLOAT_TOOLBAR_MENU_POSITION,
FloatTextStyleMenuItemFactory,
FontFamilySelectorMenuItemFactory,
FontSizeSelectorMenuItemFactory,
HeaderFooterMenuItemFactory,
HeadingSelectorMenuItemFactory,
HorizontalLineFactory,
InsertDefaultTableMenuFactory,
InsertTableMenuFactory,
ItalicMenuItemFactory,
OrderListMenuItemFactory,
@@ -77,9 +78,19 @@ import {
TextColorSelectorMenuItemFactory,
UnderlineMenuItemFactory,
} from './menu';
import { CopyCurrentParagraphMenuItemFactory, CutCurrentParagraphMenuItemFactory, DeleteCurrentParagraphMenuItemFactory, DocInsertBellowMenuItemFactory, H1HeadingMenuItemFactory, H2HeadingMenuItemFactory, H3HeadingMenuItemFactory, H4HeadingMenuItemFactory, H5HeadingMenuItemFactory, INSERT_BELLOW_MENU_ID, InsertBulletListBellowMenuItemFactory, InsertCheckListBellowMenuItemFactory, InsertHorizontalLineBellowMenuItemFactory, InsertOrderListBellowMenuItemFactory, NormalTextHeadingMenuItemFactory } from './paragraph-menu';
import { CopyCurrentParagraphMenuItemFactory, CutCurrentParagraphMenuItemFactory, DeleteCurrentParagraphMenuItemFactory, DOC_CONTENT_INSERT_MENU_ID, DOC_TABLE_BLOCK_MENU_ID, DocInsertBellowMenuItemFactory, EMPTY_PARAGRAPH_MENU_ID, EmptyParagraphBulletListMenuItemFactory, EmptyParagraphCheckListMenuItemFactory, EmptyParagraphH1MenuItemFactory, EmptyParagraphH2MenuItemFactory, EmptyParagraphH3MenuItemFactory, EmptyParagraphH4MenuItemFactory, EmptyParagraphH5MenuItemFactory, EmptyParagraphHorizontalLineMenuItemFactory, EmptyParagraphNormalTextMenuItemFactory, EmptyParagraphOrderListMenuItemFactory, H1HeadingMenuItemFactory, H2HeadingMenuItemFactory, H3HeadingMenuItemFactory, H4HeadingMenuItemFactory, H5HeadingMenuItemFactory, INSERT_BELLOW_MENU_ID, InsertBulletListBellowMenuItemFactory, InsertCheckListBellowMenuItemFactory, InsertHorizontalLineBellowMenuItemFactory, InsertOrderListBellowMenuItemFactory, NormalTextHeadingMenuItemFactory, SubtitleHeadingMenuItemFactory, TableBlockCopyMenuItemFactory, TableBlockDeleteMenuItemFactory, TableBlockPasteMenuItemFactory, TitleHeadingMenuItemFactory } from './paragraph-menu';
export const floatToolbarMenuSchema: MenuSchemaType = {
[FLOAT_TOOLBAR_MENU_POSITION]: {
[FLOAT_TEXT_STYLE_MENU_ID]: {
order: 0,
menuItemFactory: FloatTextStyleMenuItemFactory,
},
},
};
export const menuSchema: MenuSchemaType = {
...floatToolbarMenuSchema,
[RibbonStartGroup.FORMAT]: {
[SetInlineFormatBoldCommand.id]: {
order: 0,
@@ -131,25 +142,9 @@ export const menuSchema: MenuSchemaType = {
},
},
[RibbonStartGroup.LAYOUT]: {
[AlignLeftCommand.id]: {
[AlignOperationCommand.id]: {
order: 2,
menuItemFactory: AlignLeftMenuItemFactory,
},
[AlignCenterCommand.id]: {
order: 3,
menuItemFactory: AlignCenterMenuItemFactory,
},
[AlignRightCommand.id]: {
order: 4,
menuItemFactory: AlignRightMenuItemFactory,
},
[AlignJustifyCommand.id]: {
order: 5,
menuItemFactory: AlignJustifyMenuItemFactory,
},
[HorizontalLineCommand.id]: {
order: 6,
menuItemFactory: HorizontalLineFactory,
menuItemFactory: AlignMenuItemFactory,
},
[OrderListCommand.id]: {
order: 7,
@@ -159,10 +154,6 @@ export const menuSchema: MenuSchemaType = {
order: 8,
menuItemFactory: BulletListMenuItemFactory,
},
[CheckListCommand.id]: {
order: 9,
menuItemFactory: CheckListMenuItemFactory,
},
[OpenHeaderFooterPanelCommand.id]: {
order: 10,
menuItemFactory: HeaderFooterMenuItemFactory,
@@ -185,6 +176,14 @@ export const menuSchema: MenuSchemaType = {
menuItemFactory: InsertTableMenuFactory,
},
},
[HorizontalLineCommand.id]: {
order: 3,
menuItemFactory: HorizontalLineFactory,
},
[CheckListCommand.id]: {
order: 4,
menuItemFactory: CheckListMenuItemFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.QUICK]: ({
@@ -273,22 +272,18 @@ export const menuSchema: MenuSchemaType = {
order: 4,
menuItemFactory: H5HeadingMenuItemFactory,
},
[TitleHeadingCommand.id]: {
order: -1,
menuItemFactory: TitleHeadingMenuItemFactory,
},
[SubtitleHeadingCommand.id]: {
order: -1,
menuItemFactory: SubtitleHeadingMenuItemFactory,
},
[NormalTextHeadingCommand.id]: {
order: 5,
menuItemFactory: NormalTextHeadingMenuItemFactory,
},
[OrderListCommand.id]: {
order: 6,
menuItemFactory: OrderListMenuItemFactory,
},
[BulletListCommand.id]: {
order: 7,
menuItemFactory: BulletListMenuItemFactory,
},
[CheckListCommand.id]: {
order: 8,
menuItemFactory: CheckListMenuItemFactory,
},
},
[ContextMenuGroup.FORMAT]: {
[DocCopyCurrentParagraphCommand.id]: {
@@ -305,8 +300,12 @@ export const menuSchema: MenuSchemaType = {
},
},
[ContextMenuGroup.LAYOUT]: {
// title: 'docs-ui.rightClick.insertBellow',
[DocParagraphSettingPanelOperation.id]: {
order: 0,
menuItemFactory: ParagraphSettingMenuFactory,
},
[INSERT_BELLOW_MENU_ID]: {
order: 1,
menuItemFactory: DocInsertBellowMenuItemFactory,
[InsertBulletListBellowCommand.id]: {
order: 0,
@@ -324,6 +323,162 @@ export const menuSchema: MenuSchemaType = {
order: 3,
menuItemFactory: InsertHorizontalLineBellowMenuItemFactory,
},
[DocCreateTableOperation.id]: {
order: 4,
menuItemFactory: InsertDefaultTableMenuFactory,
},
},
},
[DOC_CONTENT_INSERT_MENU_ID]: {
[ContextMenuGroup.QUICK]: {
order: -2,
quickLayout: 'icon',
[H1HeadingCommand.id]: {
order: 0,
menuItemFactory: H1HeadingMenuItemFactory,
},
[H2HeadingCommand.id]: {
order: 1,
menuItemFactory: H2HeadingMenuItemFactory,
},
[H3HeadingCommand.id]: {
order: 2,
menuItemFactory: H3HeadingMenuItemFactory,
},
[H4HeadingCommand.id]: {
order: 3,
menuItemFactory: H4HeadingMenuItemFactory,
},
[H5HeadingCommand.id]: {
order: 4,
menuItemFactory: H5HeadingMenuItemFactory,
},
[NormalTextHeadingCommand.id]: {
order: 5,
menuItemFactory: NormalTextHeadingMenuItemFactory,
},
},
[ContextMenuGroup.LAYOUT]: {
order: 1,
[InsertBulletListBellowCommand.id]: {
order: 0,
menuItemFactory: InsertBulletListBellowMenuItemFactory,
},
[InsertOrderListBellowCommand.id]: {
order: 1,
menuItemFactory: InsertOrderListBellowMenuItemFactory,
},
[InsertCheckListBellowCommand.id]: {
order: 2,
menuItemFactory: InsertCheckListBellowMenuItemFactory,
},
[InsertHorizontalLineBellowCommand.id]: {
order: 3,
menuItemFactory: InsertHorizontalLineBellowMenuItemFactory,
},
[DocCreateTableOperation.id]: {
order: 4,
menuItemFactory: InsertDefaultTableMenuFactory,
},
},
},
[EMPTY_PARAGRAPH_MENU_ID]: {
[ContextMenuGroup.QUICK]: {
order: -1,
quickLayout: 'icon',
[H1HeadingCommand.id]: {
order: 0,
menuItemFactory: EmptyParagraphH1MenuItemFactory,
},
[H2HeadingCommand.id]: {
order: 1,
menuItemFactory: EmptyParagraphH2MenuItemFactory,
},
[H3HeadingCommand.id]: {
order: 2,
menuItemFactory: EmptyParagraphH3MenuItemFactory,
},
[H4HeadingCommand.id]: {
order: 3,
menuItemFactory: EmptyParagraphH4MenuItemFactory,
},
[H5HeadingCommand.id]: {
order: 4,
menuItemFactory: EmptyParagraphH5MenuItemFactory,
},
[NormalTextHeadingCommand.id]: {
order: 5,
menuItemFactory: EmptyParagraphNormalTextMenuItemFactory,
},
},
[ContextMenuGroup.LAYOUT]: {
order: 1,
[DocParagraphSettingPanelOperation.id]: {
order: 0,
menuItemFactory: ParagraphSettingMenuFactory,
},
[BulletListCommand.id]: {
order: 1,
menuItemFactory: EmptyParagraphBulletListMenuItemFactory,
},
[OrderListCommand.id]: {
order: 2,
menuItemFactory: EmptyParagraphOrderListMenuItemFactory,
},
[CheckListCommand.id]: {
order: 3,
menuItemFactory: EmptyParagraphCheckListMenuItemFactory,
},
[HorizontalLineCommand.id]: {
order: 4,
menuItemFactory: EmptyParagraphHorizontalLineMenuItemFactory,
},
[DocCreateTableOperation.id]: {
order: 5,
menuItemFactory: InsertDefaultTableMenuFactory,
},
},
},
[DOC_TABLE_BLOCK_MENU_ID]: {
[ContextMenuGroup.FORMAT]: {
[DocCopyCommand.name]: {
order: 0,
menuItemFactory: TableBlockCopyMenuItemFactory,
},
[DocPasteCommand.id]: {
order: 1,
menuItemFactory: TableBlockPasteMenuItemFactory,
},
[DocTableDeleteTableCommand.id]: {
order: 2,
menuItemFactory: TableBlockDeleteMenuItemFactory,
},
},
[ContextMenuGroup.LAYOUT]: {
[INSERT_BELLOW_MENU_ID]: {
order: 0,
menuItemFactory: DocInsertBellowMenuItemFactory,
[InsertBulletListBellowCommand.id]: {
order: 0,
menuItemFactory: InsertBulletListBellowMenuItemFactory,
},
[InsertOrderListBellowCommand.id]: {
order: 1,
menuItemFactory: InsertOrderListBellowMenuItemFactory,
},
[InsertCheckListBellowCommand.id]: {
order: 2,
menuItemFactory: InsertCheckListBellowMenuItemFactory,
},
[InsertHorizontalLineBellowCommand.id]: {
order: 3,
menuItemFactory: InsertHorizontalLineBellowMenuItemFactory,
},
[DocCreateTableOperation.id]: {
order: 4,
menuItemFactory: InsertDefaultTableMenuFactory,
},
},
},
},
},
+4
View File
@@ -39,6 +39,7 @@ import { BreakLineCommand } from './commands/commands/break-line.command';
import { DocCopyCommand, DocCopyCurrentParagraphCommand, DocCutCommand, DocCutCurrentParagraphCommand, DocPasteCommand } from './commands/commands/clipboard.command';
import { CutContentCommand, InnerPasteCommand } from './commands/commands/clipboard.inner.command';
import { DeleteCommand, InsertCommand, UpdateCommand } from './commands/commands/core-editing.command';
import { MoveDocBlockCommand } from './commands/commands/doc-block-move.command';
import { DeleteCurrentParagraphCommand, DeleteCustomBlockCommand, DeleteLeftCommand, DeleteRightCommand, MergeTwoParagraphCommand, RemoveHorizontalLineCommand } from './commands/commands/doc-delete.command';
import { CloseHeaderFooterCommand } from './commands/commands/doc-header-footer.command';
import { HorizontalLineCommand, InsertHorizontalLineBellowCommand } from './commands/commands/doc-horizontal-line.command';
@@ -114,6 +115,7 @@ import { DocRenderController } from './controllers/render-controllers/doc.render
import { DocZoomRenderController } from './controllers/render-controllers/zoom.render-controller';
import { DocClipboardService, IDocClipboardService } from './services/clipboard/clipboard.service';
import { DocAutoFormatService } from './services/doc-auto-format.service';
import { DocContentInsertService } from './services/doc-content-insert.service';
import { DocEventManagerService } from './services/doc-event-manager.service';
import { DocIMEInputManagerService } from './services/doc-ime-input-manager.service';
import { DocMenuStyleService } from './services/doc-menu-style.service';
@@ -220,6 +222,7 @@ export class UniverDocsUIPlugin extends Plugin {
InsertCommand,
DeleteCommand,
DeleteCustomBlockCommand,
MoveDocBlockCommand,
UpdateCommand,
MergeTwoParagraphCommand,
RemoveHorizontalLineCommand,
@@ -331,6 +334,7 @@ export class UniverDocsUIPlugin extends Plugin {
[DocsRenderService],
[DocStateChangeManagerService],
[DocAutoFormatService],
[DocContentInsertService],
[DocMenuStyleService],
], this._config.override);
@@ -0,0 +1,383 @@
/**
* 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 { IMutiPageParagraphBound } from '../doc-event-manager.service';
import { DataStreamTreeTokenType, PresetListType } from '@univerjs/core';
import { DocumentEditArea } from '@univerjs/engine-render';
import { BehaviorSubject, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { getPreferredParagraphBoundsInRange, getTableBlockMenuHoverRect, getTableHorizontalViewportGeometry } from '../doc-event-manager.service';
import { DocParagraphMenuService } from '../doc-paragraph-menu.service';
describe('DocParagraphMenuService', () => {
it('shows the paragraph menu for empty paragraphs', () => {
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const service = createService({
attachPopupToRect,
dataStream: '\r',
});
service.showParagraphMenu(createParagraphBound({
paragraphStart: 0,
paragraphEnd: 0,
startIndex: 0,
}));
expect(attachPopupToRect).toHaveBeenCalledTimes(1);
});
it('keeps hiding the paragraph menu for image-only paragraphs', () => {
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const service = createService({
attachPopupToRect,
dataStream: '\b\r',
});
service.showParagraphMenu(createParagraphBound({
paragraphStart: 0,
paragraphEnd: 1,
startIndex: 1,
}));
expect(attachPopupToRect).not.toHaveBeenCalled();
});
it('anchors paragraph menu to the latest paragraph bound after layout changes', () => {
const latestFirstLine = { bottom: 30, left: 20, right: 120, top: 10 };
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const service = createService({
attachPopupToRect,
dataStream: 'Title\r',
findParagraphBoundByIndex: vi.fn(() => ({
firstLine: latestFirstLine,
})),
});
service.showParagraphMenu(createParagraphBound({
paragraphStart: 0,
paragraphEnd: 5,
startIndex: 5,
}));
const [anchor] = attachPopupToRect.mock.calls[0] as unknown as [() => unknown];
expect(typeof anchor).toBe('function');
expect(anchor()).toBe(latestFirstLine);
});
it('uses one paragraph menu for paragraphs in the same block range', () => {
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const blockText = `${DataStreamTreeTokenType.BLOCK_START}A\rB\r${DataStreamTreeTokenType.BLOCK_END}`;
const service = createService({
attachPopupToRect,
dataStream: blockText,
blockRanges: [{ blockId: 'block-1', blockType: 'quote', startIndex: 0, endIndex: blockText.length - 1 }],
});
service.showParagraphMenu(createParagraphBound({
paragraphStart: 1,
paragraphEnd: 3,
startIndex: 2,
}));
service.showParagraphMenu(createParagraphBound({
paragraphStart: 3,
paragraphEnd: 5,
startIndex: 4,
}));
expect(attachPopupToRect).toHaveBeenCalledTimes(1);
});
it('anchors a block range menu to the block top-left instead of the visible paragraph', () => {
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const blockText = `${DataStreamTreeTokenType.BLOCK_START}A\rB\r${DataStreamTreeTokenType.BLOCK_END}`;
const firstBlockParagraph = createParagraphBound({
paragraphStart: 1,
paragraphEnd: 3,
startIndex: 2,
});
firstBlockParagraph.firstLine = { bottom: 20, left: 10, right: 100, top: 0 };
const secondBlockParagraph = createParagraphBound({
paragraphStart: 3,
paragraphEnd: 5,
startIndex: 4,
});
secondBlockParagraph.firstLine = { bottom: 90, left: 20, right: 100, top: 70 };
const service = createService({
attachPopupToRect,
dataStream: blockText,
blockRanges: [{ blockId: 'block-1', blockType: 'quote', startIndex: 0, endIndex: blockText.length - 1 }],
paragraphBounds: new Map([
[2, firstBlockParagraph],
[4, secondBlockParagraph],
]),
viewportScrollY: 60,
});
service.showParagraphMenu(secondBlockParagraph);
const [anchor] = attachPopupToRect.mock.calls[0] as unknown as [() => { bottom: number; left: number; right: number; top: number }];
expect(anchor()).toMatchObject({
bottom: 0,
left: 10,
right: 10,
top: 0,
});
});
it('anchors the table menu at the table top-left drag handle position', () => {
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const service = createService({
attachPopupToRect,
dataStream: '',
tables: [{ tableId: 'table-1', startIndex: 10, endIndex: 30 }],
});
service.showTableMenu({
pageIndex: 0,
rect: {
bottom: 170,
left: 100,
right: 400,
top: 80,
},
tableId: 'table-1',
});
const [anchor, options] = attachPopupToRect.mock.calls[0] as unknown as [() => { bottom: number; left: number; right: number; top: number }, { direction: string }];
expect(options.direction).toBe('top-right');
expect(anchor()).toEqual({
bottom: 76,
left: 96,
right: 96,
top: 76,
});
});
it('shows a compact paragraph menu for content inside table cells', () => {
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const service = createService({
attachPopupToRect,
dataStream: `${DataStreamTreeTokenType.TABLE_START}${DataStreamTreeTokenType.TABLE_ROW_START}${DataStreamTreeTokenType.TABLE_CELL_START}Cell\r${DataStreamTreeTokenType.TABLE_CELL_END}${DataStreamTreeTokenType.TABLE_ROW_END}${DataStreamTreeTokenType.TABLE_END}`,
tables: [{ tableId: 'table-1', startIndex: 0, endIndex: 9 }],
});
service.showParagraphMenu(createParagraphBound({
paragraphStart: 3,
paragraphEnd: 7,
startIndex: 7,
}));
expect(attachPopupToRect).toHaveBeenCalledTimes(1);
expect(service.activeTarget?.kind).toBe('paragraph');
expect(service.activeTarget?.draggable).toBe(true);
expect(service.activeTarget?.cellRange).toEqual({ startOffset: 2, endOffset: 8 });
});
it('uses list icons for list paragraph menus', () => {
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const service = createService({
attachPopupToRect,
dataStream: 'Item\r',
paragraphs: [{
startIndex: 4,
bullet: {
listType: PresetListType.ORDER_LIST,
},
}],
});
service.showParagraphMenu(createParagraphBound({
paragraphStart: 0,
paragraphEnd: 4,
startIndex: 4,
}));
expect(service.activeTarget?.icon).toBe('OrderIcon');
});
it('keeps cell paragraph move ranges inside the current table cell', () => {
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose: vi.fn() }));
const dataStream = `${DataStreamTreeTokenType.TABLE_START}${DataStreamTreeTokenType.TABLE_ROW_START}${DataStreamTreeTokenType.TABLE_CELL_START}A\rB\r${DataStreamTreeTokenType.TABLE_CELL_END}${DataStreamTreeTokenType.TABLE_ROW_END}${DataStreamTreeTokenType.TABLE_END}`;
const service = createService({
attachPopupToRect,
dataStream,
paragraphs: [
{ startIndex: 4 },
{ startIndex: 6 },
],
tables: [{ tableId: 'table-1', startIndex: 0, endIndex: dataStream.length - 1 }],
});
service.showParagraphMenu(createParagraphBound({
paragraphStart: 3,
paragraphEnd: 4,
startIndex: 4,
}));
expect(service.activeTarget?.moveRange).toEqual({
startOffset: 3,
endOffset: 5,
});
});
it('keeps a hover bridge between the table and its top-left block menu', () => {
expect(getTableBlockMenuHoverRect({
bottom: 170,
left: 100,
right: 400,
top: 80,
})).toEqual({
bottom: 170,
left: 28,
right: 400,
top: 38,
});
});
it('projects table cell block-menu geometry through the horizontal table viewport', () => {
expect(getTableHorizontalViewportGeometry(100, 600, {
contentWidth: 600,
scrollLeft: 160,
viewportWidth: 240,
})).toEqual({
scrollLeft: 160,
visibleLeft: 100,
visibleRight: 340,
});
});
it('prefers table paragraph bounds over body bounds for block range anchors', () => {
const bodyBound = createParagraphBound({
paragraphStart: 1,
paragraphEnd: 3,
startIndex: 2,
});
bodyBound.firstLine = { bottom: 20, left: 10, right: 100, top: 0 };
const tableBound = createParagraphBound({
paragraphStart: 1,
paragraphEnd: 3,
startIndex: 2,
});
tableBound.firstLine = { bottom: 20, left: 140, right: 240, top: 0 };
expect(getPreferredParagraphBoundsInRange([bodyBound], [tableBound], 0, 4)).toEqual([tableBound]);
});
it('keeps the current menu mounted while block dragging is locked', () => {
const dispose = vi.fn();
const attachPopupToRect = vi.fn(() => ({ canDispose: () => true, dispose }));
const service = createService({
attachPopupToRect,
dataStream: 'Title\r',
});
service.showParagraphMenu(createParagraphBound({
paragraphStart: 0,
paragraphEnd: 5,
startIndex: 5,
}));
service.setBlockMenuDragging(true);
service.hideParagraphMenu(true);
expect(dispose).not.toHaveBeenCalled();
expect(service.activeTarget?.kind).toBe('paragraph');
service.setBlockMenuDragging(false);
service.hideParagraphMenu(true);
expect(dispose).toHaveBeenCalledTimes(1);
expect(service.activeTarget).toBeNull();
});
});
function createService(options: {
attachPopupToRect: ReturnType<typeof vi.fn>;
blockRanges?: Array<{ blockId: string; blockType: string; endIndex: number; startIndex: number }>;
dataStream: string;
findParagraphBoundByIndex?: (index: number) => unknown;
paragraphs?: Array<{ bullet?: { listType?: PresetListType }; startIndex: number }>;
paragraphBounds?: Map<number, IMutiPageParagraphBound>;
tables?: Array<{ endIndex: number; startIndex: number; tableId: string }>;
viewportScrollY?: number;
}) {
return new DocParagraphMenuService(
{
unitId: 'doc-1',
unit: {
getBody: () => ({
blockRanges: options.blockRanges ?? [],
dataStream: options.dataStream,
paragraphs: options.paragraphs ?? [],
tables: options.tables ?? [],
}),
getDisabled: () => false,
},
engine: {
getCanvasElement: () => ({
getBoundingClientRect: () => ({ left: 0, top: 0 }),
}),
},
scene: {
getViewport: () => ({
height: 300,
onScrollAfter$: {
subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })),
},
viewportScrollY: options.viewportScrollY ?? 0,
}),
},
} as never,
{
getActiveTextRange: () => null,
replaceDocRanges: vi.fn(),
textSelection$: new Subject(),
} as never,
{
hoverParagraphRealTime$: new BehaviorSubject(null),
hoverParagraphLeft$: new BehaviorSubject(null),
clickCustomRanges$: new Subject(),
findParagraphBoundByIndex: options.findParagraphBoundByIndex ?? vi.fn(() => null),
findParagraphBoundsInRange: vi.fn((startIndex: number, endIndex: number) => [...(options.paragraphBounds ?? new Map()).values()]
.filter((bound) => Math.max(bound.paragraphStart, startIndex) <= Math.min(bound.paragraphEnd, endIndex))),
paragraphBounds: options.paragraphBounds ?? new Map(),
tableBounds: new Map(),
} as never,
{
attachPopupToRect: options.attachPopupToRect,
} as never,
{
getViewModel: () => ({
getEditArea: () => DocumentEditArea.BODY,
}),
} as never,
{
floatMenu: null,
} as never
);
}
function createParagraphBound(partial: Pick<IMutiPageParagraphBound, 'paragraphEnd' | 'paragraphStart' | 'startIndex'>): IMutiPageParagraphBound {
const rect = { bottom: 20, left: 10, right: 100, top: 0 };
return {
...partial,
firstLine: rect,
pageIndex: 0,
rect,
rects: [rect],
};
}
@@ -0,0 +1,73 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IPopup } from '@univerjs/ui';
import { EventSubject } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { DocCanvasPopManagerService } from '../doc-popup-manager.service';
describe('DocCanvasPopManagerService', () => {
it('refreshes rect popup positions after scene scale changes', () => {
let scale = 1;
const onTransformChange$ = new EventSubject();
const onScrollAfter$ = new EventSubject();
let popup: IPopup | undefined;
const removePopup = vi.fn();
const service = new DocCanvasPopManagerService(
{
addPopup: vi.fn((param: IPopup) => {
popup = param;
return 'popup-1';
}),
removePopup,
activePopupId: 'other-popup',
} as never,
{
getRenderById: () => ({
engine: {
getCanvasElement: () => ({
getBoundingClientRect: () => ({ left: 10, top: 20, width: 1000 }),
style: { width: '1000px' },
}),
},
scene: {
getAncestorScale: () => ({ scaleX: scale, scaleY: scale }),
getViewport: () => ({
onScrollAfter$,
viewportScrollX: 0,
viewportScrollY: 0,
}),
onTransformChange$,
},
}),
} as never,
{} as never,
{
onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })),
} as never
);
service.attachPopupToRect({ left: 10, right: 110, top: 20, bottom: 40 }, { componentKey: 'test' }, 'doc-1');
expect(popup?.anchorRect).toEqual({ left: 20, right: 120, top: 40, bottom: 60 });
scale = 1.5;
onTransformChange$.emitEvent({} as never);
const anchorRect$ = popup?.anchorRect$ as { value?: unknown } | undefined;
expect(anchorRect$?.value).toEqual({ left: 25, right: 175, top: 50, bottom: 80 });
});
});
@@ -15,7 +15,7 @@
*/
import type { IDocumentData, Injector, ITextStyle, Univer } from '@univerjs/core';
import { CustomRangeType, IUniverInstanceService } from '@univerjs/core';
import { CustomRangeType, DocumentFlavor, IUniverInstanceService } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DocumentEditArea } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
@@ -197,10 +197,19 @@ describe('docs ui services', () => {
const created$ = new Subject<Record<string, unknown>>();
const added$ = new Subject<{ unit: Record<string, unknown> }>();
const disposed$ = new Subject<Record<string, unknown>>();
const existingDoc = { getUnitId: () => 'doc-1' };
const addedDoc = { getUnitId: () => 'doc-2' };
const existingDoc = {
getUnitId: () => 'doc-1',
getSnapshot: () => ({ documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL } }),
};
const addedDoc = {
getUnitId: () => 'doc-2',
getSnapshot: () => ({ documentStyle: { documentFlavor: DocumentFlavor.MODERN } }),
};
const removedDoc = { getUnitId: () => 'doc-3' };
const canvas = { setId: vi.fn() };
const canvasElement = { style: {} as Record<string, string> };
const canvas = { setId: vi.fn(), getCanvasEle: vi.fn(() => canvasElement) };
const modernCanvasElement = { style: {} as Record<string, string> };
const modernCanvas = { setId: vi.fn(), getCanvasEle: vi.fn(() => modernCanvasElement) };
const context = { setId: vi.fn() };
const renderManagerService = {
createRender$,
@@ -233,8 +242,22 @@ describe('docs ui services', () => {
expect(canvas.setId).toHaveBeenCalledWith('univer-doc-main-canvas');
expect(context.setId).toHaveBeenCalledWith('univer-doc-main-canvas');
expect(canvasElement.style.backgroundColor).toBe('#fafafa');
added$.next({ unit: addedDoc });
created$.next({
unitId: 'doc-2',
engine: {
getCanvas: () => ({
...modernCanvas,
getContext: () => context,
}),
},
});
expect(modernCanvas.setId).toHaveBeenCalledWith('univer-doc-main-canvas');
expect(modernCanvasElement.style.backgroundColor).toBe('#fff');
createRender$.next('doc-4');
disposed$.next(removedDoc);
@@ -21,6 +21,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { HtmlToUDMService } from '../html-to-udm/converter';
import PastePluginLark from '../html-to-udm/paste-plugins/plugin-lark';
import PastePluginWord from '../html-to-udm/paste-plugins/plugin-word';
import { createInternalClipboardFragment, embedInternalClipboardFragment, extractInternalClipboardFragmentFromHtml, wrapClipboardHtml } from '../internal-fragment';
import { UDMToHtmlService } from '../udm-to-html/convertor';
HtmlToUDMService.use(PastePluginWord);
@@ -118,6 +119,32 @@ describe('test case in html and udm convert', () => {
expect(udm.body!.dataStream).toBe('hello\rworld');
});
it('should parse semantic html blocks and lists to udm metadata', () => {
const convertor = new HtmlToUDMService();
const udm = convertor.convert(`
<blockquote><p>Quote text</p></blockquote>
<pre><code>const a = 1;</code></pre>
<aside role="note" data-doc-type="callout"><p>💡 Callout text</p></aside>
<ol><li>First</li><li>Second</li></ol>
`);
expect(udm.body?.blockRanges?.map((range) => range.blockType)).toEqual(['quote', 'code', 'callout']);
expect(udm.body?.paragraphs?.filter((paragraph) => paragraph.bullet).map((paragraph) => paragraph.bullet?.listType)).toEqual(['ORDER_LIST', 'ORDER_LIST']);
expect(udm.body?.dataStream).toContain('Quote text\r');
expect(udm.body?.dataStream).toContain('const a = 1;\r');
});
it('should normalize word and wps mso lists to real list paragraphs', () => {
const convertor = new HtmlToUDMService();
const udm = convertor.convert(`
<p class="MsoListParagraph" style="mso-list:l0 level1 lfo1">1. Alpha</p>
<p class="MsoListParagraph" style="mso-list:l0 level2 lfo1">a. Nested</p>
`);
expect(udm.body?.dataStream).toBe('Alpha\rNested\r');
expect(udm.body?.paragraphs?.map((paragraph) => paragraph.bullet?.nestingLevel)).toEqual([0, 1]);
});
});
describe('test cases in udm-to-html', () => {
@@ -127,5 +154,66 @@ describe('test case in html and udm convert', () => {
expect(html).toBe('<p class="UniverNormal" ><strong>hello</strong><strong><i>world</i></strong></p>');
});
it('should serialize structured doc metadata as semantic clipboard html', () => {
const convertor = new UDMToHtmlService();
const html = convertor.convert([{
id: '',
documentStyle: {},
body: {
dataStream: 'Quote\rCode\rCallout\rItem\r',
blockRanges: [
{ blockId: 'quote-1', blockType: 'quote', startIndex: 0, endIndex: 5 },
{ blockId: 'code-1', blockType: 'code', startIndex: 6, endIndex: 10 },
{ blockId: 'callout-1', blockType: 'callout', startIndex: 11, endIndex: 18 },
],
paragraphs: [
{ startIndex: 5 },
{ startIndex: 10 },
{ startIndex: 18 },
{ startIndex: 23, bullet: { listId: 'list-1', listType: 'ORDER_LIST', nestingLevel: 0 } },
],
},
}]);
expect(html).toContain('data-doc-type="quote"');
expect(html).toContain('data-doc-type="code-block"');
expect(html).toContain('data-doc-type="callout"');
expect(html).toContain('data-doc-type="ordered-list"');
});
it('should serialize adjacent and nested list paragraphs as one semantic list tree', () => {
const convertor = new UDMToHtmlService();
const html = convertor.convert([{
id: '',
documentStyle: {},
body: {
dataStream: 'One\rNested\rTwo\r',
paragraphs: [
{ startIndex: 3, bullet: { listId: 'list-1', listType: 'ORDER_LIST', nestingLevel: 0 } },
{ startIndex: 10, bullet: { listId: 'list-1', listType: 'BULLET_LIST', nestingLevel: 1 } },
{ startIndex: 14, bullet: { listId: 'list-1', listType: 'ORDER_LIST', nestingLevel: 0 } },
],
},
}]);
expect(html.match(/data-doc-type="ordered-list"/g)?.length).toBe(1);
expect(html.match(/data-doc-type="bullet-list"/g)?.length).toBe(1);
expect(html).toContain('<li><p class="UniverNormal" >One</p><ul');
expect(html).toContain('</ul></li><li><p class="UniverNormal" >Two</p>');
});
it('should embed and extract an internal clipboard fragment through html', () => {
const fragment = createInternalClipboardFragment({
body: {
dataStream: 'Internal\r',
paragraphs: [{ startIndex: 8 }],
},
});
const html = wrapClipboardHtml(embedInternalClipboardFragment('<p>Internal</p>', fragment));
expect(html).toContain('StartFragment');
expect(extractInternalClipboardFragmentFromHtml(html)?.body?.dataStream).toBe('Internal\r');
});
});
});
@@ -58,6 +58,14 @@ import { HtmlToUDMService } from './html-to-udm/converter';
import LarkPastePlugin from './html-to-udm/paste-plugins/plugin-lark';
import UniverPastePlugin from './html-to-udm/paste-plugins/plugin-univer';
import WordPastePlugin from './html-to-udm/paste-plugins/plugin-word';
import {
createInternalClipboardFragment,
DOC_INTERNAL_FRAGMENT_MIME,
embedInternalClipboardFragment,
extractInternalClipboardFragmentFromHtml,
parseInternalClipboardFragment,
wrapClipboardHtml,
} from './internal-fragment';
import { UDMToHtmlService } from './udm-to-html/convertor';
HtmlToUDMService.use(LarkPastePlugin);
@@ -77,7 +85,7 @@ export interface IDocClipboardService {
copy(sliceType?: SliceBodyType, ranges?: ITextRangeWithStyle[]): Promise<boolean>;
cut(ranges?: ITextRangeWithStyle[]): Promise<boolean>;
paste(items: ClipboardItem[]): Promise<boolean>;
legacyPaste(options: { html?: string; text?: string; files: File[] }): Promise<boolean>;
legacyPaste(options: { html?: string; text?: string; internalJson?: string; files: File[] }): Promise<boolean>;
addClipboardHook(hook: IDocClipboardHook): IDisposable;
}
@@ -162,10 +170,11 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
async legacyPaste(options: {
html?: string;
internalJson?: string;
text?: string;
files: File[];
}): Promise<boolean> {
let { html, text, files } = options;
let { html, internalJson, text, files } = options;
const currentDocInstance = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC);
const docUnitId = currentDocInstance?.getUnitId() || '';
if (!html && !text && files.length) {
@@ -175,7 +184,7 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
this._logService.warn('[DocClipboardController] html and text cannot be both empty!');
return false;
}
const partDocData = this._genDocDataFromHtmlAndText(html, text, docUnitId);
const partDocData = this._genDocDataFromHtmlAndText(html, text, docUnitId, internalJson);
// Paste in sheet editing mode without paste style, so we give textRuns empty array;
if (docUnitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
if (text) {
@@ -342,6 +351,8 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
.replaceAll('\r\n', ' ');
let html = this._umdToHtml.convert(documentList);
let internalJson = '';
let internalDocData: Partial<IDocumentData> | null = null;
// Only cache copy content when the range is 1.
if (documentList.length === 1 && needCache) {
@@ -370,9 +381,19 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
}
copyContentCache.set(copyId, cache);
internalDocData = cache;
} else if (documentList.length === 1) {
internalDocData = { body: documentList[0].body };
}
return this._clipboardInterfaceService.write(text, html);
if (internalDocData) {
internalJson = createInternalClipboardFragment(internalDocData);
html = embedInternalClipboardFragment(html, internalJson);
}
html = wrapClipboardHtml(html);
return this._clipboardInterfaceService.write(text, html, internalJson ? { [DOC_INTERNAL_FRAGMENT_MIME]: internalJson } : undefined);
}
addClipboardHook(hook: IDocClipboardHook): IDisposable {
@@ -453,10 +474,15 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
try {
let html = '';
let text = '';
let internalJson = '';
const files: File[] = [];
for (const clipboardItem of items) {
for (const type of clipboardItem.types) {
switch (type) {
case DOC_INTERNAL_FRAGMENT_MIME: {
internalJson = await clipboardItem.getType(type).then((blob) => blob && blob.text());
break;
}
case PLAIN_TEXT_CLIPBOARD_MIME_TYPE: {
text = await clipboardItem.getType(type).then((blob) => blob && blob.text());
break;
@@ -481,13 +507,18 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
html = await this._createImagePasteHtml(files);
}
return this._genDocDataFromHtmlAndText(html, text);
return this._genDocDataFromHtmlAndText(html, text, undefined, internalJson);
} catch (e) {
return Promise.reject(e);
}
}
private _genDocDataFromHtmlAndText(html?: string, text?: string, _unitId?: string): Partial<IDocumentData> {
private _genDocDataFromHtmlAndText(html?: string, text?: string, _unitId?: string, internalJson?: string): Partial<IDocumentData> {
const internalDocData = parseInternalClipboardFragment(internalJson) ?? extractInternalClipboardFragmentFromHtml(html);
if (internalDocData?.body) {
return internalDocData;
}
if (!html) {
if (text) {
const body = BuildTextUtils.transform.fromPlainText(text);
@@ -14,16 +14,18 @@
* limitations under the License.
*/
import type { IDocumentBody, IDocumentData, ITable, ITextStyle, Nullable } from '@univerjs/core';
import type { IDocumentBody, IDocumentData, IParagraph, ITable, ITextStyle, Nullable } from '@univerjs/core';
import type { IAfterProcessRule, IPastePlugin, IStyleRule } from './paste-plugins/type';
import {
CustomRangeType,
DataStreamTreeTokenType,
DrawingTypeEnum,
generateRandomId,
NamedStyleType,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
PresetListType,
skipParseTagNames,
Tools,
} from '@univerjs/core';
@@ -54,6 +56,12 @@ interface ITableCache {
startIndex: number;
}
interface IListContext {
listId: string;
listType: PresetListType;
nestingLevel: number;
}
/**
* Convert html strings into data structures in univer, IDocumentBody.
* Support plug-in, add custom rules,
@@ -77,6 +85,10 @@ export class HtmlToUDMService {
private _afterProcessRules: IAfterProcessRule[] = [];
private _listStack: IListContext[] = [];
private _lastParagraphIndex = -1;
convert(html: string, metaConfig: { unitId?: string } = {}): Partial<IDocumentData> {
const pastePlugin = HtmlToUDMService._pluginList.find((plugin) => plugin.checkPasteType(html));
const dom = parseToDom(html)!;
@@ -102,6 +114,8 @@ export class HtmlToUDMService {
}
this._tableCache = [];
this._listStack = [];
this._lastParagraphIndex = -1;
this._styleCache.clear();
this._process(null, dom.childNodes, docData);
this._styleCache.clear();
@@ -183,7 +197,13 @@ export class HtmlToUDMService {
continue;
} else if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
if (this._processCodeBlock(element, doc)) {
continue;
}
const linkStart = this._processBeforeLink(element, doc);
const blockStart = this._processBeforeStructuredBlock(element, doc);
const listContext = this._processBeforeList(element);
const parentStyles = parent ? this._styleCache.get(parent) : {};
const styleRule = this._styleRules.find(({ filter }) => matchFilter(node as HTMLElement, filter));
@@ -200,19 +220,155 @@ export class HtmlToUDMService {
this._process(node, childNodes, doc);
this._processAfterTable(node as HTMLElement, doc);
this._processAfterList(listContext);
const afterProcessRule = this._afterProcessRules.find(({ filter }) =>
matchFilter(node as HTMLElement, filter)
);
const paragraphAddedByPlugin = Boolean(afterProcessRule);
if (afterProcessRule) {
afterProcessRule.handler(doc, node as HTMLElement);
}
this._processAfterDefaultBlock(element, doc, paragraphAddedByPlugin);
this._processAfterStructuredBlock(element, doc, blockStart);
this._processAfterLink(element, doc, linkStart);
}
}
}
private _processCodeBlock(node: HTMLElement, doc: Partial<IDocumentData>): boolean {
if (node.tagName.toUpperCase() !== 'PRE') {
return false;
}
const body = doc.body!;
const startIndex = body.dataStream.length;
const text = node.textContent ?? '';
body.dataStream += text;
this._appendParagraph(doc, node);
body.blockRanges ??= [];
body.blockRanges.push({
blockId: generateRandomId(6),
blockType: 'code',
startIndex,
endIndex: body.dataStream.length - 1,
});
return true;
}
private _processBeforeStructuredBlock(node: HTMLElement, doc: Partial<IDocumentData>): number | null {
const blockType = getStructuredBlockType(node);
if (!blockType) {
return null;
}
return doc.body!.dataStream.length;
}
private _processAfterStructuredBlock(node: HTMLElement, doc: Partial<IDocumentData>, startIndex: number | null): void {
const blockType = getStructuredBlockType(node);
const body = doc.body!;
if (!blockType || startIndex == null || body.dataStream.length <= startIndex) {
return;
}
body.blockRanges ??= [];
body.blockRanges.push({
blockId: generateRandomId(6),
blockType,
startIndex,
endIndex: body.dataStream.length - 1,
});
}
private _processBeforeList(node: HTMLElement): IListContext | null {
const tagName = node.tagName.toUpperCase();
if (tagName !== 'OL' && tagName !== 'UL') {
return null;
}
const context: IListContext = {
listId: generateRandomId(6),
listType: tagName === 'OL' ? PresetListType.ORDER_LIST : PresetListType.BULLET_LIST,
nestingLevel: this._listStack.length,
};
this._listStack.push(context);
return context;
}
private _processAfterList(context: IListContext | null): void {
if (!context) {
return;
}
this._listStack.pop();
}
private _processAfterDefaultBlock(node: HTMLElement, doc: Partial<IDocumentData>, paragraphAddedByPlugin: boolean): void {
if (paragraphAddedByPlugin) {
this._applyWordListInfo(node, doc);
this._lastParagraphIndex = doc.body!.dataStream.length - 1;
return;
}
if (!isDefaultParagraphElement(node)) {
return;
}
this._appendParagraph(doc, node);
}
private _appendParagraph(doc: Partial<IDocumentData>, node: HTMLElement): void {
const body = doc.body!;
if (body.dataStream[this._lastParagraphIndex] === '\r' && this._lastParagraphIndex === body.dataStream.length - 1) {
return;
}
body.paragraphs ??= [];
const paragraph: IParagraph = {
startIndex: body.dataStream.length,
};
const heading = getHeadingNamedStyleType(node);
if (heading != null) {
paragraph.paragraphStyle = {
...paragraph.paragraphStyle,
headingId: generateRandomId(6),
namedStyleType: heading,
};
}
const listContext = this._listStack[this._listStack.length - 1] ?? extractWordListInfo(node);
if (listContext) {
paragraph.bullet = {
listId: listContext.listId,
listType: listContext.listType,
nestingLevel: listContext.nestingLevel,
};
paragraph.startIndex -= stripListMarkerFromCurrentParagraph(body);
}
body.paragraphs.push(paragraph);
body.dataStream += '\r';
this._lastParagraphIndex = body.dataStream.length - 1;
}
private _applyWordListInfo(node: HTMLElement, doc: Partial<IDocumentData>): void {
const listContext = extractWordListInfo(node);
const paragraph = doc.body?.paragraphs?.[doc.body.paragraphs.length - 1];
if (!listContext || !paragraph) {
return;
}
paragraph.bullet = {
listId: listContext.listId,
listType: listContext.listType,
nestingLevel: listContext.nestingLevel,
};
paragraph.startIndex -= stripListMarkerFromCurrentParagraph(doc.body!);
}
private _processBeforeTable(node: HTMLElement, doc: Partial<IDocumentData>): void {
const tagName = node.tagName.toUpperCase();
const body = doc.body!;
@@ -347,14 +503,106 @@ export class HtmlToUDMService {
const element = node as HTMLElement;
if (element.tagName.toUpperCase() === 'A') {
const href = (element as HTMLAnchorElement).href;
if (!isSafeUrl(href)) {
return;
}
body.customRanges = body.customRanges ?? [];
body.customRanges.push({
startIndex: start,
endIndex: body.dataStream.length - 1,
rangeId: element.dataset.rangeid ?? generateRandomId(),
rangeType: CustomRangeType.HYPERLINK,
properties: { url: (element as HTMLAnchorElement).href },
properties: { url: href },
});
}
}
}
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url, window.location.origin);
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
} catch {
return false;
}
}
function getStructuredBlockType(node: HTMLElement): string | null {
const docType = node.dataset.docType;
if (docType === 'quote') {
return 'quote';
}
if (docType === 'callout') {
return 'callout';
}
const tagName = node.tagName.toUpperCase();
if (tagName === 'BLOCKQUOTE') {
return docType === 'callout' ? 'callout' : 'quote';
}
if (tagName === 'ASIDE' && node.getAttribute('role') === 'note') {
return 'callout';
}
return null;
}
function isDefaultParagraphElement(node: HTMLElement): boolean {
const tagName = node.tagName.toUpperCase();
return ['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI'].includes(tagName);
}
function getHeadingNamedStyleType(node: HTMLElement): NamedStyleType | null {
switch (node.tagName.toUpperCase()) {
case 'H1': return NamedStyleType.HEADING_1;
case 'H2': return NamedStyleType.HEADING_2;
case 'H3': return NamedStyleType.HEADING_3;
case 'H4': return NamedStyleType.HEADING_4;
case 'H5': return NamedStyleType.HEADING_5;
default: return null;
}
}
function extractWordListInfo(node: HTMLElement): IListContext | null {
const style = node.getAttribute('style') ?? '';
const match = style.match(/mso-list:\s*([^ ;]+)\s+level(\d+)/i);
if (!match && !/MsoListParagraph/i.test(node.className)) {
return null;
}
const marker = (node.textContent ?? '').trim().match(/^([0-9]+\.|[a-zA-Z]\.|[ivxlcdmIVXLCDM]+\.|[·•●○\-])/);
const ordered = Boolean(marker && !/[·•●○\-]/.test(marker[1]));
return {
listId: match?.[1] ?? 'mso-list',
listType: ordered ? PresetListType.ORDER_LIST : PresetListType.BULLET_LIST,
nestingLevel: Math.max(0, Number(match?.[2] ?? 1) - 1),
};
}
function stripListMarkerFromCurrentParagraph(body: IDocumentBody): number {
const paragraphEnd = body.dataStream.endsWith('\r') ? body.dataStream.length - 1 : body.dataStream.length;
const paragraphStart = Math.max(0, body.dataStream.lastIndexOf('\r', paragraphEnd - 1) + 1);
const text = body.dataStream.slice(paragraphStart, paragraphEnd);
const marker = text.match(/^(\s*(?:[0-9]+\.|[a-zA-Z]\.|[ivxlcdmIVXLCDM]+\.|[·•●○\-])\s*)/);
if (!marker) {
return 0;
}
const length = marker[1].length;
body.dataStream = body.dataStream.slice(0, paragraphStart) + body.dataStream.slice(paragraphStart + length);
body.textRuns?.forEach((textRun) => {
if (textRun.st >= paragraphStart + length) {
textRun.st -= length;
textRun.ed -= length;
} else if (textRun.ed > paragraphStart) {
textRun.ed = Math.max(textRun.st, textRun.ed - length);
}
});
return length;
}
@@ -23,7 +23,7 @@ import { getParagraphStyle } from '../utils';
const WordPastePlugin: IPastePlugin = {
name: 'univer-doc-paste-plugin-word',
checkPasteType(html: string) {
return /word|mso/i.test(html);
return /word|mso|wps|kingsoft/i.test(html);
},
stylesRules: [
@@ -0,0 +1,82 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentData } from '@univerjs/core';
import { Tools } from '@univerjs/core';
export const DOC_INTERNAL_FRAGMENT_MIME = 'application/x-doc-fragment+json';
export const DOC_INTERNAL_FRAGMENT_COMMENT_PREFIX = 'univer-doc-fragment:';
export interface IDocInternalClipboardFragment {
version: 1;
kind: 'univer-doc-fragment';
doc: Partial<IDocumentData>;
}
export function createInternalClipboardFragment(doc: Partial<IDocumentData>): string {
return JSON.stringify({
version: 1,
kind: 'univer-doc-fragment',
doc: Tools.deepClone(doc),
} satisfies IDocInternalClipboardFragment);
}
export function parseInternalClipboardFragment(value?: string): Partial<IDocumentData> | null {
if (!value) {
return null;
}
try {
const parsed = JSON.parse(value) as Partial<IDocInternalClipboardFragment>;
if (parsed?.version === 1 && parsed.kind === 'univer-doc-fragment' && parsed.doc?.body) {
return parsed.doc as Partial<IDocumentData>;
}
} catch {
return null;
}
return null;
}
export function embedInternalClipboardFragment(html: string, fragmentJson: string): string {
return `<!--${DOC_INTERNAL_FRAGMENT_COMMENT_PREFIX}${encodeBase64(fragmentJson)}-->${html}`;
}
export function extractInternalClipboardFragmentFromHtml(html?: string): Partial<IDocumentData> | null {
if (!html) {
return null;
}
const escapedPrefix = DOC_INTERNAL_FRAGMENT_COMMENT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = html.match(new RegExp(`<!--\\s*${escapedPrefix}([A-Za-z0-9+/=]+)\\s*-->`));
if (!match) {
return null;
}
return parseInternalClipboardFragment(decodeBase64(match[1]));
}
export function wrapClipboardHtml(fragmentHtml: string): string {
return `<html><head><meta charset="utf-8"></head><body><!--StartFragment-->${fragmentHtml}<!--EndFragment--></body></html>`;
}
function encodeBase64(value: string): string {
return btoa(unescape(encodeURIComponent(value)));
}
function decodeBase64(value: string): string {
return decodeURIComponent(escape(atob(value)));
}
@@ -17,7 +17,7 @@
import type { IDocumentBody, IDocumentData, IParagraph, ITextRun } from '@univerjs/core';
import type { IDocImage } from '@univerjs/docs-drawing';
import type { DataStreamTreeNode } from '@univerjs/engine-render';
import { BaselineOffset, BooleanNumber, CustomRangeType, DataStreamTreeNodeType, DrawingTypeEnum, Tools } from '@univerjs/core';
import { BaselineOffset, BooleanNumber, CustomRangeType, DataStreamTreeNodeType, DrawingTypeEnum, NamedStyleType, PresetListType, Tools } from '@univerjs/core';
import { ImageSourceType } from '@univerjs/drawing';
import { parseDataStreamToTree } from '@univerjs/engine-render';
@@ -47,7 +47,7 @@ export function covertTextRunToHtml(dataStream: string, textRun: ITextRun): stri
const { st: start, ed, ts = {} } = textRun;
const { ff, fs, it, bl, ul, st, ol, bg, cl, va } = ts;
let html = dataStream.slice(start, ed);
let html = escapeHtml(dataStream.slice(start, ed));
const style: string[] = [];
// italic
@@ -117,7 +117,7 @@ function getBodyInlineSlice(body: IDocumentBody, startIndex: number, endIndex: n
const { st, ed } = textRun;
if (Tools.hasIntersectionBetweenTwoRanges(startIndex, endIndex, st, ed)) {
if (st > cursorIndex) {
spanList.push(dataStream.slice(cursorIndex, st));
spanList.push(escapeHtml(dataStream.slice(cursorIndex, st)));
spanList.push(covertTextRunToHtml(dataStream, {
...textRun,
@@ -136,7 +136,7 @@ function getBodyInlineSlice(body: IDocumentBody, startIndex: number, endIndex: n
}
if (cursorIndex !== endIndex) {
spanList.push(dataStream.slice(cursorIndex, endIndex));
spanList.push(escapeHtml(dataStream.slice(cursorIndex, endIndex)));
}
return spanList.join('');
@@ -207,6 +207,13 @@ export function getBodySliceHtml(doc: IDocumentData, startIndex: number, endInde
interface IHtmlResult {
html: string;
listStack?: IListHtmlContext[];
}
interface IListHtmlContext {
tag: 'ol' | 'ul';
level: number;
listType: string;
}
export function convertBodyToHtml(doc: IDocumentData): string {
@@ -238,6 +245,8 @@ export function convertBodyToHtml(doc: IDocumentData): string {
processNode(node, doc, result);
}
closeListStack(result);
return result.html;
}
@@ -279,15 +288,18 @@ function processNode(node: DataStreamTreeNode, doc: IDocumentData, result: IHtml
style.push(`line-height: ${lineSpacing}`);
}
result.html += `<p class="UniverNormal" ${style.length ? `style="${style.join('; ')};"` : ''}>`;
const innerHtml = (() => {
const childHtml = children.map((table) => {
const childResult: IHtmlResult = { html: '' };
processNode(table, doc, childResult);
closeListStack(childResult);
return childResult.html;
}).join('');
if (children.length) {
for (const table of children) {
processNode(table, doc, result);
}
}
return `${childHtml}${getBodySliceHtml(doc, startIndex, endIndex)}`;
})();
result.html += `${getBodySliceHtml(doc, startIndex, endIndex)}</p>`;
renderParagraphNodeHtml(doc, paragraph, startIndex, endIndex, innerHtml, style, result);
break;
}
@@ -295,6 +307,7 @@ function processNode(node: DataStreamTreeNode, doc: IDocumentData, result: IHtml
case DataStreamTreeNodeType.TABLE: {
const { children } = node;
closeListStack(result);
result.html += '<table class="UniverTable" style="width: 100%; border-collapse: collapse;"><tbody>';
for (const row of children) {
@@ -325,6 +338,7 @@ function processNode(node: DataStreamTreeNode, doc: IDocumentData, result: IHtml
for (const n of children) {
processNode(n, doc, result);
}
closeListStack(result);
result.html += '</td>';
break;
}
@@ -335,6 +349,139 @@ function processNode(node: DataStreamTreeNode, doc: IDocumentData, result: IHtml
}
}
function renderParagraphNodeHtml(doc: IDocumentData, paragraph: IParagraph, startIndex: number, endIndex: number, innerHtml: string, style: string[], result: IHtmlResult): void {
const listItemHtml = renderListParagraphItemHtml(doc, paragraph, innerHtml, style);
if (listItemHtml) {
appendListParagraphHtml(result, listItemHtml);
return;
}
closeListStack(result);
result.html += renderBlockParagraphHtml(doc, paragraph, startIndex, endIndex, innerHtml, style);
}
function renderBlockParagraphHtml(doc: IDocumentData, paragraph: IParagraph, startIndex: number, endIndex: number, innerHtml: string, style: string[]): string {
const blockRange = doc.body?.blockRanges?.find((range) => range.startIndex <= startIndex && endIndex <= range.endIndex + 1);
const paragraphHtml = renderHeadingParagraphHtml(paragraph, innerHtml, style)
?? `<p class="UniverNormal" ${style.length ? `style="${style.join('; ')};"` : ''}>${innerHtml}</p>`;
switch (blockRange?.blockType) {
case 'quote':
return `<blockquote data-doc-type="quote" style="border-left: 4px solid #d0d7de; margin: 8px 0; padding: 4px 0 4px 12px; color: #57606a;">${paragraphHtml}</blockquote>`;
case 'code':
return `<pre data-doc-type="code-block" style="font-family: Consolas, 'Courier New', monospace; background: #f6f8fa; padding: 12px; border-radius: 6px; white-space: pre-wrap;"><code>${innerHtml.replace(/<\/?[^>]+>/g, '')}</code></pre>`;
case 'callout':
return `<aside data-doc-type="callout" role="note" style="background: #fff8c5; border-left: 4px solid #d4a72c; padding: 8px 12px; margin: 8px 0; border-radius: 4px;"><p><span data-callout-icon="true" style="margin-right: 6px;">💡</span>${innerHtml}</p></aside>`;
default:
return paragraphHtml;
}
}
function renderHeadingParagraphHtml(paragraph: IParagraph, innerHtml: string, style: string[]): string | null {
const headingLevel = getHeadingLevel(paragraph.paragraphStyle?.namedStyleType);
if (!headingLevel) {
return null;
}
return `<h${headingLevel} class="UniverHeading" ${style.length ? `style="${style.join('; ')};"` : ''}>${innerHtml}</h${headingLevel}>`;
}
function renderListParagraphItemHtml(doc: IDocumentData, paragraph: IParagraph, innerHtml: string, style: string[]): { tag: 'ol' | 'ul'; level: number; listType: string; listStyle: string | null; html: string } | null {
const bullet = paragraph.bullet;
if (!bullet) {
return null;
}
const ordered = isOrderedListType(String(bullet.listType));
const tag = ordered ? 'ol' : 'ul';
const level = bullet.nestingLevel ?? 0;
const listStyle = getListStyleType(doc, String(bullet.listType), level);
return {
tag,
level,
listType: String(bullet.listType),
listStyle,
html: `<p class="UniverNormal" ${style.length ? `style="${style.join('; ')};"` : ''}>${innerHtml}</p>`,
};
}
function appendListParagraphHtml(result: IHtmlResult, listItem: NonNullable<ReturnType<typeof renderListParagraphItemHtml>>): void {
result.listStack ??= [];
const stack = result.listStack;
while (stack.length > listItem.level + 1) {
result.html += `</li></${stack.pop()!.tag}>`;
}
const current = stack[stack.length - 1];
if (current && current.level === listItem.level) {
if (current.tag === listItem.tag && current.listType === listItem.listType) {
result.html += '</li><li>';
} else {
result.html += `</li></${stack.pop()!.tag}>`;
openList(result, listItem);
}
} else {
while (stack.length < listItem.level) {
openList(result, {
...listItem,
level: stack.length,
});
}
openList(result, listItem);
}
result.html += listItem.html;
}
function openList(result: IHtmlResult, listItem: NonNullable<ReturnType<typeof renderListParagraphItemHtml>>): void {
const listStyleHtml = listItem.listStyle ? ` list-style-type: ${listItem.listStyle};` : '';
const marginLeft = listItem.level > 0 ? ` margin-left: ${listItem.level * 24}px;` : '';
result.html += `<${listItem.tag} data-doc-type="${listItem.tag === 'ol' ? 'ordered-list' : 'bullet-list'}" style="margin-top: 0; margin-bottom: 0;${marginLeft}${listStyleHtml}"><li>`;
result.listStack!.push({ tag: listItem.tag, level: listItem.level, listType: listItem.listType });
}
function closeListStack(result: IHtmlResult): void {
while (result.listStack?.length) {
result.html += `</li></${result.listStack.pop()!.tag}>`;
}
}
function getHeadingLevel(namedStyleType?: NamedStyleType): number | null {
switch (namedStyleType) {
case NamedStyleType.HEADING_1: return 1;
case NamedStyleType.HEADING_2: return 2;
case NamedStyleType.HEADING_3: return 3;
case NamedStyleType.HEADING_4: return 4;
case NamedStyleType.HEADING_5: return 5;
default: return null;
}
}
function isOrderedListType(listType: string): boolean {
return listType.includes('ORDER') || listType === PresetListType.ORDER_LIST;
}
function getListStyleType(doc: IDocumentData, listType: string, level: number): string | null {
const glyphType = doc.lists?.[listType]?.nestingLevel?.[level]?.glyphType;
switch (glyphType) {
case 3: return 'upper-alpha';
case 4: return 'lower-alpha';
case 5: return 'upper-roman';
case 6: return 'lower-roman';
default: return isOrderedListType(listType) ? 'decimal' : null;
}
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export class UDMToHtmlService {
convert(docList: IDocumentData[]): string {
if (docList.length === 0) {
@@ -0,0 +1,50 @@
/**
* 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 } from '@univerjs/core';
export interface IDocContentInsertRange {
unitId: string;
startOffset: number;
endOffset: number;
segmentId?: string;
}
export class DocContentInsertService extends Disposable {
private _range: IDocContentInsertRange | null = null;
setInsertRange(range: IDocContentInsertRange): void {
this._range = range;
}
consumeInsertRange(unitId?: string): IDocContentInsertRange | null {
if (!this._range) {
return null;
}
if (unitId && this._range.unitId !== unitId) {
return null;
}
const range = this._range;
this._range = null;
return range;
}
clearInsertRange(): void {
this._range = null;
}
}
@@ -19,6 +19,7 @@ import type {
Documents,
DocumentSkeleton,
IBoundRectNoAngle,
IDocsTableRenderViewport,
IDocumentSkeletonGlyph,
IDocumentSkeletonPage,
IDocumentSkeletonSection,
@@ -27,7 +28,7 @@ import type {
} from '@univerjs/engine-render';
import { Disposable, fromEventSubject, Inject } from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { CURSOR_TYPE, TRANSFORM_CHANGE_OBSERVABLE_TYPE } from '@univerjs/engine-render';
import { CURSOR_TYPE, getDocsTableRenderViewport, getTableIdAndSliceIndex, TRANSFORM_CHANGE_OBSERVABLE_TYPE } from '@univerjs/engine-render';
import { BehaviorSubject, distinctUntilChanged, filter, map, Subject, switchMap, take, throttleTime } from 'rxjs';
import { DOC_VERTICAL_PADDING } from '../types/const/padding';
import { transformOffset2Bound } from './doc-popup-manager.service';
@@ -145,6 +146,9 @@ export const calcDocGlyphPosition = (glyph: IDocumentSkeletonGlyph, documents: D
const { borderBoxPointGroup } = convertor.getRangePointData(startPosition, startPosition);
const bounds = getLineBounding(borderBoxPointGroup);
const rect = bounds[0];
if (!rect) {
return;
}
return {
top: rect.top + documentOffsetConfig.docsTop,
@@ -206,6 +210,9 @@ export interface ITableCellBound {
tableId: string;
}
const TABLE_BLOCK_MENU_HOVER_GUTTER_LEFT = 72;
const TABLE_BLOCK_MENU_HOVER_GUTTER_TOP = 42;
function isPointInRect(x: number, y: number, rect: IBoundRectNoAngle) {
const { left, right, top, bottom } = rect;
if (x >= left && x <= right && y >= top && y <= bottom) {
@@ -214,6 +221,25 @@ function isPointInRect(x: number, y: number, rect: IBoundRectNoAngle) {
return false;
}
export function getTableBlockMenuHoverRect(tableRect: IBoundRectNoAngle): IBoundRectNoAngle {
return {
bottom: tableRect.bottom,
left: tableRect.left - TABLE_BLOCK_MENU_HOVER_GUTTER_LEFT,
right: tableRect.right,
top: tableRect.top - TABLE_BLOCK_MENU_HOVER_GUTTER_TOP,
};
}
export function getTableHorizontalViewportGeometry(tableLeft: number, tableWidth: number, viewport: Nullable<IDocsTableRenderViewport>) {
const hasHorizontalViewport = viewport != null && viewport.contentWidth > viewport.viewportWidth;
return {
scrollLeft: hasHorizontalViewport ? viewport.scrollLeft : 0,
visibleLeft: tableLeft,
visibleRight: tableLeft + (hasHorizontalViewport ? viewport.viewportWidth : tableWidth),
};
}
export class DocEventManagerService extends Disposable implements IRenderModule {
private readonly _hoverCustomRanges$ = new BehaviorSubject<ICustomRangeActive[]>([]);
readonly hoverCustomRanges$ = this._hoverCustomRanges$.pipe(distinctUntilChanged((pre, aft) => pre.length === aft.length && pre.every((item, i) => aft[i].range.rangeId === item.range.rangeId && aft[i].segmentId === item.segmentId && aft[i].segmentPageIndex === item.segmentPageIndex && aft[i].range.startIndex === item.range.startIndex)));
@@ -227,6 +253,9 @@ export class DocEventManagerService extends Disposable implements IRenderModule
private readonly _clickBullet$ = new Subject<IBulletActive>();
readonly clickBullets$ = this._clickBullet$.asObservable();
private readonly _contextMenuBullet$ = new Subject<IBulletActive & { x: number; y: number }>();
readonly contextMenuBullets$ = this._contextMenuBullet$.asObservable();
private readonly _hoverParagraph$ = new BehaviorSubject<Nullable<IMutiPageParagraphBound>>(null);
readonly hoverParagraph$ = this._hoverParagraph$.pipe(distinctUntilChanged((pre, aft) => pre?.startIndex === aft?.startIndex && pre?.segmentId === aft?.segmentId && pre?.pageIndex === aft?.pageIndex));
readonly hoverParagraphRealTime$ = this._hoverParagraph$.asObservable();
@@ -273,6 +302,7 @@ export class DocEventManagerService extends Disposable implements IRenderModule
private _paragraphLeftBounds: IMutiPageParagraphBound[] = [];
private _tableParagraphBounds: Map<string, ITableParagraphBound[]> = new Map();
private _segmentParagraphBounds: Map<string, Map<number, IMutiPageParagraphBound[]>> = new Map();
private _tableViewportSignature = '';
private _tableCellBounds: Map<string, ITableCellBound[]> = new Map();
private _tableBounds: Map<string, ITableBound> = new Map();
@@ -330,6 +360,16 @@ export class DocEventManagerService extends Disposable implements IRenderModule
this._paragraphDirty = true;
})
);
this.disposeWithMe(
fromEventSubject(this._context.scene.onTransformChange$).pipe(
filter((evt) => evt.type === TRANSFORM_CHANGE_OBSERVABLE_TYPE.scale)
).subscribe(() => {
this._customRangeDirty = true;
this._bulletDirty = true;
this._paragraphDirty = true;
})
);
}
private _initEvents() {
@@ -370,10 +410,21 @@ export class DocEventManagerService extends Disposable implements IRenderModule
switchMap((down) => onPointerUp$.pipe(take(1), map((up) => ({ down, up })))),
filter(({ down, up }) => down.target === up.target && up.timeStamp - down.timeStamp < 300)
).subscribe(({ down }) => {
const point = transformOffset2Bound(down.offsetX, down.offsetY, this._context.scene);
if (down.button === 2) {
const bullet = this._calcActiveBullet(point);
if (bullet) {
this._contextMenuBullet$.next({
...bullet,
x: down.offsetX,
y: down.offsetY,
});
}
return;
}
const point = transformOffset2Bound(down.offsetX, down.offsetY, this._context.scene);
const ranges = this._calcActiveRanges(point);
if (ranges.length) {
this._clickCustomRanges$.next(ranges.pop()!);
@@ -386,6 +437,16 @@ export class DocEventManagerService extends Disposable implements IRenderModule
}));
}
isPointerOnBullet(offsetX: number, offsetY: number): boolean {
return Boolean(this._calcActiveBullet(transformOffset2Bound(offsetX, offsetY, this._context.scene)));
}
getBulletBounds(): IBulletBound[] {
this._buildBulletBounds();
return [...this._bulletBounds];
}
private _buildCustomRangeBoundsBySegment(segmentId?: string, segmentPage = -1) {
const customRanges = this._context.unit.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.customRanges ?? [];
const layouts: ICustomRangeBound[] = [];
@@ -453,7 +514,7 @@ export class DocEventManagerService extends Disposable implements IRenderModule
private _buildBulletBoundsBySegment(segmentId?: string, segmentPage = -1): IBulletBound[] {
const body = this._context.unit.getSelfOrHeaderFooterModel(segmentId)?.getBody();
const paragraphs = (body?.paragraphs ?? []).filter((p) => p.bullet && p.bullet.listType.indexOf('CHECK_LIST') === 0);
const paragraphs = (body?.paragraphs ?? []).filter((p) => p.bullet);
const bounds: IBulletBound[] = [];
const skeletonData = this._skeleton.getSkeletonData();
if (!skeletonData) {
@@ -586,7 +647,13 @@ export class DocEventManagerService extends Disposable implements IRenderModule
Array.from(page.skeTables.values()).forEach((table) => {
const tableLeft = table.left + left;
const tableTop = table.top + top;
const tableRight = tableLeft + table.width;
const sourceTableId = getTableIdAndSliceIndex(table.tableId).tableId;
const tableViewport = getTableHorizontalViewportGeometry(
tableLeft,
table.width,
getDocsTableRenderViewport(this._context.unitId, sourceTableId)
);
const tableRight = tableViewport.visibleRight;
const tableBottom = tableTop + table.height;
const tableId = table.tableId;
this._tableBounds.set(tableId, {
@@ -603,8 +670,18 @@ export class DocEventManagerService extends Disposable implements IRenderModule
table.rows.forEach((row, rowIndex) => {
row.cells.forEach((cell, colIndex) => {
const top = (((page.pageHeight === Infinity ? 0 : page.pageHeight) + documentOffsetConfig.pageMarginTop) * i) + table.top + documentOffsetConfig.docsTop + page.marginTop + row.top + cell.marginTop;
const left = table.left + documentOffsetConfig.docsLeft + page.marginLeft + cell.left + cell.marginLeft;
const bounds = calcDocParagraphPositions(cell.sections, top, left, cell.pageWidth - cell.marginLeft - cell.marginRight);
const cellLeft = tableLeft + cell.left - tableViewport.scrollLeft + cell.marginLeft;
const cellContentWidth = cell.pageWidth - cell.marginLeft - cell.marginRight;
const cellRight = cellLeft + cellContentWidth;
const visibleCellLeft = Math.max(cellLeft, tableViewport.visibleLeft);
const visibleCellRight = Math.min(cellRight, tableViewport.visibleRight);
if (visibleCellRight <= visibleCellLeft) {
return;
}
const bounds = calcDocParagraphPositions(cell.sections, top, cellLeft, cellContentWidth)
.map((bound) => clipParagraphBoundHorizontally(bound, tableViewport.visibleLeft, tableViewport.visibleRight))
.filter((bound): bound is ICustomRangeBoundBase => bound != null);
let arr = this._tableParagraphBounds.get(tableId);
if (!arr) {
arr = [];
@@ -635,8 +712,8 @@ export class DocEventManagerService extends Disposable implements IRenderModule
cellBounds.push({
rect: {
top,
left,
right: left + cell.pageWidth - cell.marginLeft - cell.marginRight,
left: visibleCellLeft,
right: visibleCellRight,
bottom: top + cell.pageHeight - cell.marginBottom - cell.marginTop,
},
pageIndex: i,
@@ -667,10 +744,12 @@ export class DocEventManagerService extends Disposable implements IRenderModule
}
private _buildParagraphBounds() {
if (!this._paragraphDirty) {
const tableViewportSignature = this._getTableViewportSignature();
if (!this._paragraphDirty && this._tableViewportSignature === tableViewportSignature) {
return;
}
this._paragraphDirty = false;
this._tableViewportSignature = tableViewportSignature;
this._tableParagraphBounds = new Map();
this._tableCellBounds = new Map();
this._tableBounds = new Map();
@@ -704,7 +783,7 @@ export class DocEventManagerService extends Disposable implements IRenderModule
const { x, y } = evt;
const table = Array.from(this._tableBounds.values()).find((bound) => isPointInRect(x, y, bound.rect));
const table = Array.from(this._tableBounds.values()).find((bound) => isPointInRect(x, y, getTableBlockMenuHoverRect(bound.rect)));
this._hoverTable$.next(table);
if (table) {
@@ -747,14 +826,104 @@ export class DocEventManagerService extends Disposable implements IRenderModule
return this._paragraphBounds;
}
get tableBounds() {
this._buildParagraphBounds();
return this._tableBounds;
}
findParagraphBoundByIndex(index: number) {
this._buildParagraphBounds();
const tableParagraph = Array.from(this._tableParagraphBounds.values()).flat().find((bound) => bound.startIndex === index);
if (tableParagraph) {
return tableParagraph;
}
const paragraph = this._paragraphBounds.get(index);
if (paragraph) {
return paragraph;
}
}
const tableParagraph = Array.from(this._tableParagraphBounds.values()).flat().find((bound) => bound.startIndex === index);
return tableParagraph;
findParagraphBoundsInRange(startIndex: number, endIndex: number): IMutiPageParagraphBound[] {
this._buildParagraphBounds();
const bodyBounds = [...this._paragraphBounds.values()];
const tableBounds = Array.from(this._tableParagraphBounds.values()).flat().map((bound) => ({
rect: bound.rect,
paragraphStart: bound.paragraphStart,
paragraphEnd: bound.paragraphEnd,
startIndex: bound.startIndex,
rects: [bound.rect],
pageIndex: bound.pageIndex,
segmentId: bound.segmentId,
firstLine: bound.firstLine,
}));
return getPreferredParagraphBoundsInRange(bodyBounds, tableBounds, startIndex, endIndex);
}
private _getTableViewportSignature(): string {
const pages = this._skeleton.getSkeletonData()?.pages ?? [];
const signatures: string[] = [];
pages.forEach((page) => {
page.skeTables?.forEach((table) => {
const tableId = getTableIdAndSliceIndex(table.tableId).tableId;
const viewport = getDocsTableRenderViewport(this._context.unitId, tableId);
if (!viewport) {
signatures.push(`${tableId}:none`);
return;
}
signatures.push(`${tableId}:${viewport.contentWidth}:${viewport.viewportWidth}:${viewport.scrollLeft}`);
});
});
return signatures.join('|');
}
}
export function getPreferredParagraphBoundsInRange(
bodyBounds: IMutiPageParagraphBound[],
tableBounds: IMutiPageParagraphBound[],
startIndex: number,
endIndex: number
): IMutiPageParagraphBound[] {
const matchedTableBounds = tableBounds.filter((bound) => isRangeOverlappingParagraph(bound, startIndex, endIndex));
if (matchedTableBounds.length) {
return matchedTableBounds;
}
return bodyBounds.filter((bound) => isRangeOverlappingParagraph(bound, startIndex, endIndex));
}
function isRangeOverlappingParagraph(bound: Pick<IMutiPageParagraphBound, 'paragraphEnd' | 'paragraphStart'>, startIndex: number, endIndex: number): boolean {
return Math.max(bound.paragraphStart, startIndex) <= Math.min(bound.paragraphEnd, endIndex);
}
function clipParagraphBoundHorizontally(bound: ICustomRangeBoundBase, visibleLeft: number, visibleRight: number): ICustomRangeBoundBase | null {
const rect = clipRectHorizontally(bound.rect, visibleLeft, visibleRight);
if (!rect) {
return null;
}
return {
...bound,
rect,
fisrtLine: clipRectHorizontally(bound.fisrtLine, visibleLeft, visibleRight) ?? rect,
};
}
function clipRectHorizontally(rect: IBoundRectNoAngle, visibleLeft: number, visibleRight: number): IBoundRectNoAngle | null {
const left = Math.max(rect.left, visibleLeft);
const right = Math.min(rect.right, visibleRight);
if (right <= left) {
return null;
}
return {
...rect,
left,
right,
};
}

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