mirror of
https://github.com/dream-num/univer.git
synced 2026-10-29 18:06:28 +08:00
feat(docs): polish docs table rendering and menus (#6926)
This commit is contained in:
BIN
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+41
-16
@@ -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,
|
||||
|
||||
+101
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+79
-3
@@ -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();
|
||||
|
||||
+32
-7
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+53
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+32
-3
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: 'أسفل',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'پایین',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '下',
|
||||
|
||||
@@ -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: '아래',
|
||||
|
||||
@@ -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ół',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Низ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '下',
|
||||
|
||||
@@ -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: '下',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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
Reference in New Issue
Block a user