diff --git a/e2e/visual-comparison/docs/docs-visual-comparison.spec.ts-snapshots/default-doc-ci-chromium-linux.png b/e2e/visual-comparison/docs/docs-visual-comparison.spec.ts-snapshots/default-doc-ci-chromium-linux.png index 6026816bc5..63c0c69485 100644 Binary files a/e2e/visual-comparison/docs/docs-visual-comparison.spec.ts-snapshots/default-doc-ci-chromium-linux.png and b/e2e/visual-comparison/docs/docs-visual-comparison.spec.ts-snapshots/default-doc-ci-chromium-linux.png differ diff --git a/packages/core/src/docs/data-model/__tests__/empty-snapshot.spec.ts b/packages/core/src/docs/data-model/__tests__/empty-snapshot.spec.ts new file mode 100644 index 0000000000..dbf14f1f58 --- /dev/null +++ b/packages/core/src/docs/data-model/__tests__/empty-snapshot.spec.ts @@ -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 }, + }); + }); +}); diff --git a/packages/core/src/docs/data-model/__tests__/utils.spec.ts b/packages/core/src/docs/data-model/__tests__/utils.spec.ts index a45399f1a1..043c75c0e4 100644 --- a/packages/core/src/docs/data-model/__tests__/utils.spec.ts +++ b/packages/core/src/docs/data-model/__tests__/utils.spec.ts @@ -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', () => { diff --git a/packages/core/src/docs/data-model/empty-snapshot.ts b/packages/core/src/docs/data-model/empty-snapshot.ts index 2e85a75299..0e19d495a5 100644 --- a/packages/core/src/docs/data-model/empty-snapshot.ts +++ b/packages/core/src/docs/data-model/empty-snapshot.ts @@ -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, diff --git a/packages/core/src/docs/data-model/text-x/__tests__/block-ranges.spec.ts b/packages/core/src/docs/data-model/text-x/__tests__/block-ranges.spec.ts new file mode 100644 index 0000000000..556e9748e7 --- /dev/null +++ b/packages/core/src/docs/data-model/text-x/__tests__/block-ranges.spec.ts @@ -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'); + }); +}); diff --git a/packages/core/src/docs/data-model/text-x/apply-utils/common.ts b/packages/core/src/docs/data-model/text-x/apply-utils/common.ts index 2b9bedf703..fcc19fcc83 100644 --- a/packages/core/src/docs/data-model/text-x/apply-utils/common.ts +++ b/packages/core/src/docs/data-model/text-x/apply-utils/common.ts @@ -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; diff --git a/packages/core/src/docs/data-model/text-x/apply-utils/delete-apply.ts b/packages/core/src/docs/data-model/text-x/apply-utils/delete-apply.ts index d2a89b465e..edb18112f2 100644 --- a/packages/core/src/docs/data-model/text-x/apply-utils/delete-apply.ts +++ b/packages/core/src/docs/data-model/text-x/apply-utils/delete-apply.ts @@ -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, }; diff --git a/packages/core/src/docs/data-model/text-x/apply-utils/insert-apply.ts b/packages/core/src/docs/data-model/text-x/apply-utils/insert-apply.ts index 8225463aae..357439963a 100644 --- a/packages/core/src/docs/data-model/text-x/apply-utils/insert-apply.ts +++ b/packages/core/src/docs/data-model/text-x/apply-utils/insert-apply.ts @@ -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); diff --git a/packages/core/src/docs/data-model/text-x/apply-utils/update-apply.ts b/packages/core/src/docs/data-model/text-x/apply-utils/update-apply.ts index 4012ba7390..ae9019a86e 100644 --- a/packages/core/src/docs/data-model/text-x/apply-utils/update-apply.ts +++ b/packages/core/src/docs/data-model/text-x/apply-utils/update-apply.ts @@ -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, diff --git a/packages/core/src/docs/data-model/text-x/build-utils/parse.ts b/packages/core/src/docs/data-model/text-x/build-utils/parse.ts index 043a23ae90..a15973ffc7 100644 --- a/packages/core/src/docs/data-model/text-x/build-utils/parse.ts +++ b/packages/core/src/docs/data-model/text-x/build-utils/parse.ts @@ -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) => { diff --git a/packages/core/src/docs/data-model/text-x/transform-utils.ts b/packages/core/src/docs/data-model/text-x/transform-utils.ts index 6cb4be1e06..f2b3f9c36a 100644 --- a/packages/core/src/docs/data-model/text-x/transform-utils.ts +++ b/packages/core/src/docs/data-model/text-x/transform-utils.ts @@ -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; + }); +} diff --git a/packages/core/src/docs/data-model/text-x/utils.ts b/packages/core/src/docs/data-model/text-x/utils.ts index dfa71ef493..c13417f317 100644 --- a/packages/core/src/docs/data-model/text-x/utils.ts +++ b/packages/core/src/docs/data-model/text-x/utils.ts @@ -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; } diff --git a/packages/core/src/docs/data-model/types.ts b/packages/core/src/docs/data-model/types.ts index 997dfea70f..8836e2b3cd 100644 --- a/packages/core/src/docs/data-model/types.ts +++ b/packages/core/src/docs/data-model/types.ts @@ -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 diff --git a/packages/core/src/docs/data-model/utils.ts b/packages/core/src/docs/data-model/utils.ts index 6a326f082e..f8a8f3edda 100644 --- a/packages/core/src/docs/data-model/utils.ts +++ b/packages/core/src/docs/data-model/utils.ts @@ -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. diff --git a/packages/core/src/sheets/__tests__/sheet-skeleton.integration.spec.ts b/packages/core/src/sheets/__tests__/sheet-skeleton.integration.spec.ts index 7f9a586cba..01913ece21 100644 --- a/packages/core/src/sheets/__tests__/sheet-skeleton.integration.spec.ts +++ b/packages/core/src/sheets/__tests__/sheet-skeleton.integration.spec.ts @@ -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, }, diff --git a/packages/core/src/sheets/__tests__/util.spec.ts b/packages/core/src/sheets/__tests__/util.spec.ts index 220ce3db35..e387634c52 100644 --- a/packages/core/src/sheets/__tests__/util.spec.ts +++ b/packages/core/src/sheets/__tests__/util.spec.ts @@ -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, diff --git a/packages/core/src/sheets/sheet-skeleton.ts b/packages/core/src/sheets/sheet-skeleton.ts index 402724c8a8..43d76255c7 100644 --- a/packages/core/src/sheets/sheet-skeleton.ts +++ b/packages/core/src/sheets/sheet-skeleton.ts @@ -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, diff --git a/packages/core/src/sheets/util.ts b/packages/core/src/sheets/util.ts index 4887e9547b..cf9d5b3cbb 100644 --- a/packages/core/src/sheets/util.ts +++ b/packages/core/src/sheets/util.ts @@ -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, diff --git a/packages/core/src/sheets/worksheet.ts b/packages/core/src/sheets/worksheet.ts index 56ee72a23f..8989f1f445 100644 --- a/packages/core/src/sheets/worksheet.ts +++ b/packages/core/src/sheets/worksheet.ts @@ -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, diff --git a/packages/core/src/types/const/const.ts b/packages/core/src/types/const/const.ts index aa037ab67d..6925511f53 100644 --- a/packages/core/src/types/const/const.ts +++ b/packages/core/src/types/const/const.ts @@ -184,6 +184,10 @@ export const NAMED_STYLE_MAP: Record> = { 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.HEADING_1]: { spaceAbove: { @@ -227,10 +231,10 @@ export const NAMED_STYLE_SPACE_MAP: Record> = { height: 1632, }, }; + +export enum ModernDocumentWidthMode { + NARROW = 'narrow', + MEDIUM = 'medium', + WIDE = 'wide', +} + +export const MODERN_DOCUMENT_WIDTH: Record = { + [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; diff --git a/packages/core/src/types/interfaces/i-document-data.ts b/packages/core/src/types/interfaces/i-document-data.ts index b595c22c95..c3dfce0298 100644 --- a/packages/core/src/types/interfaces/i-document-data.ts +++ b/packages/core/src/types/interfaces/i-document-data.ts @@ -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; +export interface IDocumentBlockRange { + startIndex: number; + endIndex: number; + blockId: string; + blockType: string; +} + export enum CustomRangeType { HYPERLINK, FIELD, // 17.16 Fields and Hyperlinks diff --git a/packages/docs-drawing-ui/src/commands/commands/__tests__/drawing-commands.spec.ts b/packages/docs-drawing-ui/src/commands/commands/__tests__/drawing-commands.spec.ts index 371ddb05cd..58e8ced678 100644 --- a/packages/docs-drawing-ui/src/commands/commands/__tests__/drawing-commands.spec.ts +++ b/packages/docs-drawing-ui/src/commands/commands/__tests__/drawing-commands.spec.ts @@ -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('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('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()); diff --git a/packages/docs-drawing-ui/src/commands/commands/insert-doc-drawing.command.ts b/packages/docs-drawing-ui/src/commands/commands/insert-doc-drawing.command.ts index 8d38f82c95..600199f327 100644 --- a/packages/docs-drawing-ui/src/commands/commands/insert-doc-drawing.command.ts +++ b/packages/docs-drawing-ui/src/commands/commands/insert-doc-drawing.command.ts @@ -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; + } +} diff --git a/packages/docs-drawing-ui/src/commands/commands/update-doc-drawing.command.ts b/packages/docs-drawing-ui/src/commands/commands/update-doc-drawing.command.ts index 414c179a38..43e261c0fb 100644 --- a/packages/docs-drawing-ui/src/commands/commands/update-doc-drawing.command.ts +++ b/packages/docs-drawing-ui/src/commands/commands/update-doc-drawing.command.ts @@ -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; diff --git a/packages/docs-drawing-ui/src/controllers/doc-drawing-transformer-update.controller.ts b/packages/docs-drawing-ui/src/controllers/doc-drawing-transformer-update.controller.ts index 03e051a28a..685d99afa4 100644 --- a/packages/docs-drawing-ui/src/controllers/doc-drawing-transformer-update.controller.ts +++ b/packages/docs-drawing-ui/src/controllers/doc-drawing-transformer-update.controller.ts @@ -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 { + 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, diff --git a/packages/docs-drawing-ui/src/controllers/render-controllers/__tests__/doc-drawing-transform-update.controller.spec.ts b/packages/docs-drawing-ui/src/controllers/render-controllers/__tests__/doc-drawing-transform-update.controller.spec.ts new file mode 100644 index 0000000000..be8d7bfa54 --- /dev/null +++ b/packages/docs-drawing-ui/src/controllers/render-controllers/__tests__/doc-drawing-transform-update.controller.spec.ts @@ -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, + }, + }); + }); +}); diff --git a/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts b/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts index f1e75dbae1..77dfa65cb2 100644 --- a/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts +++ b/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts @@ -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, + 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(); diff --git a/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-update.render-controller.ts b/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-update.render-controller.ts index 5b213b905d..0078651b26 100644 --- a/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-update.render-controller.ts +++ b/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-update.render-controller.ts @@ -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, @@ -68,6 +73,7 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen } async insertDocImage(): Promise { + 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) { let imageParams: Nullable[] = []; 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 ): Nullable { - 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 { + 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) => { diff --git a/packages/docs-drawing-ui/src/index.ts b/packages/docs-drawing-ui/src/index.ts index de509e785c..489e6065b2 100644 --- a/packages/docs-drawing-ui/src/index.ts +++ b/packages/docs-drawing-ui/src/index.ts @@ -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'; diff --git a/packages/docs-drawing-ui/src/menu/__tests__/schema.spec.ts b/packages/docs-drawing-ui/src/menu/__tests__/schema.spec.ts new file mode 100644 index 0000000000..906e5bf322 --- /dev/null +++ b/packages/docs-drawing-ui/src/menu/__tests__/schema.spec.ts @@ -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'); + }); +}); diff --git a/packages/docs-drawing-ui/src/menu/drawing-popup-menu.controller.ts b/packages/docs-drawing-ui/src/menu/drawing-popup-menu.controller.ts index e2ec189311..18eaa0bebb 100644 --- a/packages/docs-drawing-ui/src/menu/drawing-popup-menu.controller.ts +++ b/packages/docs-drawing-ui/src/menu/drawing-popup-menu.controller.ts @@ -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(); 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$(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) { 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', diff --git a/packages/docs-drawing-ui/src/menu/image.menu.ts b/packages/docs-drawing-ui/src/menu/image.menu.ts index 7fb04c4947..f82ed8f8a4 100644 --- a/packages/docs-drawing-ui/src/menu/image.menu.ts +++ b/packages/docs-drawing-ui/src/menu/image.menu.ts @@ -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), }; } diff --git a/packages/docs-drawing-ui/src/menu/schema.ts b/packages/docs-drawing-ui/src/menu/schema.ts index 1db0639ac6..9b2a96fee8 100644 --- a/packages/docs-drawing-ui/src/menu/schema.ts +++ b/packages/docs-drawing-ui/src/menu/schema.ts @@ -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, + }, + }, + }, + }, }; diff --git a/packages/docs-drawing-ui/src/views/doc-image-panel/DocDrawingPanel.tsx b/packages/docs-drawing-ui/src/views/doc-image-panel/DocDrawingPanel.tsx index 1286126eef..c25126cfd9 100644 --- a/packages/docs-drawing-ui/src/views/doc-image-panel/DocDrawingPanel.tsx +++ b/packages/docs-drawing-ui/src/views/doc-image-panel/DocDrawingPanel.tsx @@ -40,7 +40,7 @@ export const DocDrawingPanel = () => { return !!drawings?.length && (
- +
diff --git a/packages/docs-hyper-link-ui/src/controllers/render-controllers/__tests__/hyper-link-event.render-controller.spec.ts b/packages/docs-hyper-link-ui/src/controllers/render-controllers/__tests__/hyper-link-event.render-controller.spec.ts new file mode 100644 index 0000000000..3391d55876 --- /dev/null +++ b/packages/docs-hyper-link-ui/src/controllers/render-controllers/__tests__/hyper-link-event.render-controller.spec.ts @@ -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(); + const clickCustomRanges$ = new Subject(); + 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; + } + }); +}); diff --git a/packages/docs-hyper-link-ui/src/controllers/render-controllers/hyper-link-event.render-controller.ts b/packages/docs-hyper-link-ui/src/controllers/render-controllers/hyper-link-event.render-controller.ts index 03ce4dcea2..1c5730f81a 100644 --- a/packages/docs-hyper-link-ui/src/controllers/render-controllers/hyper-link-event.render-controller.ts +++ b/packages/docs-hyper-link-ui/src/controllers/render-controllers/hyper-link-event.render-controller.ts @@ -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; diff --git a/packages/docs-hyper-link-ui/src/menu/__tests__/schema.spec.ts b/packages/docs-hyper-link-ui/src/menu/__tests__/schema.spec.ts new file mode 100644 index 0000000000..c24c5b54fd --- /dev/null +++ b/packages/docs-hyper-link-ui/src/menu/__tests__/schema.spec.ts @@ -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(); + }); +}); diff --git a/packages/docs-hyper-link-ui/src/menu/schema.ts b/packages/docs-hyper-link-ui/src/menu/schema.ts index 3091de80ab..dc3656ae3e 100644 --- a/packages/docs-hyper-link-ui/src/menu/schema.ts +++ b/packages/docs-hyper-link-ui/src/menu/schema.ts @@ -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, + }, + }, + }, + }, }; diff --git a/packages/docs-quick-insert-ui/src/services/doc-quick-insert-popup.service.ts b/packages/docs-quick-insert-ui/src/services/doc-quick-insert-popup.service.ts index d6c4ceaf95..ee5bcbd12d 100644 --- a/packages/docs-quick-insert-ui/src/services/doc-quick-insert-popup.service.ts +++ b/packages/docs-quick-insert-ui/src/services/doc-quick-insert-popup.service.ts @@ -54,7 +54,7 @@ const noopDisposable = { dispose: () => {}, }; -interface IKeywordInputPlaceholderExtraProps { +interface IKeywordInputPlaceholderExtraProps extends Record { fontSize?: number; fontString?: string; fontFamily?: string; diff --git a/packages/docs-ui/src/commands/commands/__tests__/core-editing.command.spec.ts b/packages/docs-ui/src/commands/commands/__tests__/core-editing.command.spec.ts index 4b13236343..56c07dd2c2 100644 --- a/packages/docs-ui/src/commands/commands/__tests__/core-editing.command.spec.ts +++ b/packages/docs-ui/src/commands/commands/__tests__/core-editing.command.spec.ts @@ -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', diff --git a/packages/docs-ui/src/commands/commands/__tests__/doc-block-move.command.spec.ts b/packages/docs-ui/src/commands/commands/__tests__/doc-block-move.command.spec.ts new file mode 100644 index 0000000000..a164d21e03 --- /dev/null +++ b/packages/docs-ui/src/commands/commands/__tests__/doc-block-move.command.spec.ts @@ -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>): 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, + }, + }; +} diff --git a/packages/docs-ui/src/commands/commands/__tests__/inline-format.command.spec.ts b/packages/docs-ui/src/commands/commands/__tests__/inline-format.command.spec.ts index d9db328ee6..b11079d6c9 100644 --- a/packages/docs-ui/src/commands/commands/__tests__/inline-format.command.spec.ts +++ b/packages/docs-ui/src/commands/commands/__tests__/inline-format.command.spec.ts @@ -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', () => { diff --git a/packages/docs-ui/src/commands/commands/__tests__/misc.command.spec.ts b/packages/docs-ui/src/commands/commands/__tests__/misc.command.spec.ts index d987aac803..c103503482 100644 --- a/packages/docs-ui/src/commands/commands/__tests__/misc.command.spec.ts +++ b/packages/docs-ui/src/commands/commands/__tests__/misc.command.spec.ts @@ -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 = []; + 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 = []; + 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 = []; + 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', - }), ], })); diff --git a/packages/docs-ui/src/commands/commands/doc-block-move.command.ts b/packages/docs-ui/src/commands/commands/doc-block-move.command.ts new file mode 100644 index 0000000000..753a376c62 --- /dev/null +++ b/packages/docs-ui/src/commands/commands/doc-block-move.command.ts @@ -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 = { + 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(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( + 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( + 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); +} diff --git a/packages/docs-ui/src/commands/commands/doc-delete.command.ts b/packages/docs-ui/src/commands/commands/doc-delete.command.ts index 26d6e1fe67..5387e05922 100644 --- a/packages/docs-ui/src/commands/commands/doc-delete.command.ts +++ b/packages/docs-ui/src/commands/commands/doc-delete.command.ts @@ -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): 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[0], commandId: string): Promise { + 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, diff --git a/packages/docs-ui/src/commands/commands/doc-horizontal-line.command.ts b/packages/docs-ui/src/commands/commands/doc-horizontal-line.command.ts index 1b5bf65364..746c3cd449 100644 --- a/packages/docs-ui/src/commands/commands/doc-horizontal-line.command.ts +++ b/packages/docs-ui/src/commands/commands/doc-horizontal-line.command.ts @@ -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 { const commandService = accessor.get(ICommandService); + const doc = accessor.get(IUniverInstanceService).getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); + let contentInsertRange: ReturnType = 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; diff --git a/packages/docs-ui/src/commands/commands/doc-page-setup.command.ts b/packages/docs-ui/src/commands/commands/doc-page-setup.command.ts index e4de7f056f..5d5ac76d61 100644 --- a/packages/docs-ui/src/commands/commands/doc-page-setup.command.ts +++ b/packages/docs-ui/src/commands/commands/doc-page-setup.command.ts @@ -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, 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 = { id: 'docs.command.page-setup', type: CommandType.COMMAND, @@ -38,9 +102,20 @@ export const DocPageSetupCommand: ICommand = { const commandService = accessor.get(ICommandService); const docDataModel = univerInstanceService.getCurrentUnitOfType(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 = { marginTop: oldMarginTop, pageOrient: oldPageOrient, pageSize: oldPageSize, + documentFlavor: oldDocumentFlavor, } = documentStyle; - const rawActions: JSONXActions = []; + const rawActions: NonNullable = []; + + 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); diff --git a/packages/docs-ui/src/commands/commands/doc-select-all.command.ts b/packages/docs-ui/src/commands/commands/doc-select-all.command.ts index ff7d0a88c3..82e57ce7ab 100644 --- a/packages/docs-ui/src/commands/commands/doc-select-all.command.ts +++ b/packages/docs-ui/src/commands/commands/doc-select-all.command.ts @@ -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 = { 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 = { 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 | null, + tables: ICustomTable[], + activeOffset: number +): Pick | 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 | 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; +} diff --git a/packages/docs-ui/src/commands/commands/inline-format.command.ts b/packages/docs-ui/src/commands/commands/inline-format.command.ts index dd83e4014c..80b263248d 100644 --- a/packages/docs-ui/src/commands/commands/inline-format.command.ts +++ b/packages/docs-ui/src/commands/commands/inline-format.command.ts @@ -253,7 +253,10 @@ export const SetInlineFormatCommand: ICommand = { 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) { diff --git a/packages/docs-ui/src/commands/commands/list.command.ts b/packages/docs-ui/src/commands/commands/list.command.ts index e0ad91cdbc..14fdf86b5e 100644 --- a/packages/docs-ui/src/commands/commands/list.command.ts +++ b/packages/docs-ui/src/commands/commands/list.command.ts @@ -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 = { interface IOrderListCommandParams { value?: PresetListType; + docRange?: ITextRangeWithStyle[]; } export const OrderListCommand: ICommand = { @@ -357,11 +359,13 @@ export const OrderListCommand: ICommand = { 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 = { 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(UniverInstanceType.UNIVER_DOC); if (!docDataModel) { return false; } + let contentInsertRange: ReturnType = 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, diff --git a/packages/docs-ui/src/commands/commands/set-heading.command.ts b/packages/docs-ui/src/commands/commands/set-heading.command.ts index e9d4ce849c..5d5beaae2e 100644 --- a/packages/docs-ui/src/commands/commands/set-heading.command.ts +++ b/packages/docs-ui/src/commands/commands/set-heading.command.ts @@ -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[0], unitId: string) { + try { + return accessor.get(DocContentInsertService).consumeInsertRange(unitId); + } catch { + return null; + } +} + +function insertNamedStyleParagraph( + accessor: Parameters[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(RichTextEditingMutation.id, { + actions: jsonX.editOp(textX.serialize(), getRichTextEditPath(doc)), + isEditing: false, + textRanges: [{ + startOffset, + endOffset: startOffset, + collapsed: true, + }], + unitId: doc.getUnitId(), + })); +} + export const QuickHeadingCommand: ICommand = { id: 'doc.command.quick-heading', type: CommandType.COMMAND, diff --git a/packages/docs-ui/src/commands/commands/table/doc-table-create.command.ts b/packages/docs-ui/src/commands/commands/table/doc-table-create.command.ts index 153cfe9303..f1e1d7516b 100644 --- a/packages/docs-ui/src/commands/commands/table/doc-table-create.command.ts +++ b/packages/docs-ui/src/commands/commands/table/doc-table-create.command.ts @@ -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 = { 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 = 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 = { 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); diff --git a/packages/docs-ui/src/commands/operations/open-page-setting.operation.ts b/packages/docs-ui/src/commands/operations/open-page-setting.operation.ts index d46b8cf219..276bd8c0e5 100644 --- a/packages/docs-ui/src/commands/operations/open-page-setting.operation.ts +++ b/packages/docs-ui/src/commands/operations/open-page-setting.operation.ts @@ -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, diff --git a/packages/docs-ui/src/components/float-toolbar/FloatToolbar.stories.tsx b/packages/docs-ui/src/components/float-toolbar/FloatToolbar.stories.tsx index 44bd2afac9..983b2053c4 100644 --- a/packages/docs-ui/src/components/float-toolbar/FloatToolbar.stories.tsx +++ b/packages/docs-ui/src/components/float-toolbar/FloatToolbar.stories.tsx @@ -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 = {
{ 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) { diff --git a/packages/docs-ui/src/components/paragraph-menu/__tests__/paragraph-menu.spec.ts b/packages/docs-ui/src/components/paragraph-menu/__tests__/paragraph-menu.spec.ts new file mode 100644 index 0000000000..41d2930553 --- /dev/null +++ b/packages/docs-ui/src/components/paragraph-menu/__tests__/paragraph-menu.spec.ts @@ -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); + }); +}); diff --git a/packages/docs-ui/src/components/paragraph-menu/index.tsx b/packages/docs-ui/src/components/paragraph-menu/index.tsx index 79801454ca..ff9e6730bb 100644 --- a/packages/docs-ui/src/components/paragraph-menu/index.tsx +++ b/packages/docs-ui/src/components/paragraph-menu/index.tsx @@ -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 = { + [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> = { + [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(null); + const targetRangeRef = useRef(null); + const dragTargetOffsetRef = useRef(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(null); const isMouseOver = useRef(false); + const hideTimerRef = useRef(null); const renderManagerService = useDependency(IRenderManagerService); const univerInstanceService = useDependency(IUniverInstanceService); const renderUnit = renderManagerService.getRenderById(popup.unitId); const doc = univerInstanceService.getUnit(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 ( <>
{ 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(); }} > - - + {currentActiveTarget?.draggable && ( + + )}
{visible && (
{ popup.onPointerEnter?.(e); isMouseOver.current = true; + clearHideTimer(); }} onMouseLeave={() => { isMouseOver.current = false; + scheduleHideMenu(); }} > { - 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 }) => {
)} + {dropRect && ( +
+ )} ); }; + +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 ( +