feat(docs): integrate table of contents and Office layout fidelity (#7670)

This commit is contained in:
Univer
2026-09-17 11:44:01 +08:00
committed by GitHub
parent d139c3ddec
commit 3c1bcc176f
259 changed files with 23728 additions and 3500 deletions
@@ -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 type { ISdtCustomRange } from '../../types/interfaces/i-document-data';
import { describe, expect, it } from 'vitest';
import { CustomRangeType } from '../../types/interfaces/i-document-data';
import { getSdtBindingKey, getSdtBindingValue } from '../sdt-binding';
describe('SDT scalar bindings', () => {
it('uses the XML value instead of a localized list label or structural cell anchors', () => {
const body = { dataStream: '\x1CYes\r\n\x1D' };
const range: ISdtCustomRange = {
rangeId: 'answer',
rangeType: CustomRangeType.SDT,
startIndex: 0,
endIndex: body.dataStream.length - 1,
properties: { kind: 'dropDownList', placement: 'cell', listItems: [{ displayText: 'Yes', value: '1' }] },
};
expect(getSdtBindingValue(body, range)).toBe('1');
range.properties.showingPlaceholder = true;
expect(getSdtBindingValue(body, range)).toBe('');
});
it('requires a complete binding and keeps distinct XML stores and namespaces separate', () => {
const range: ISdtCustomRange = {
rangeId: 'answer',
rangeType: CustomRangeType.SDT,
startIndex: 0,
endIndex: 0,
properties: { kind: 'text', placement: 'inline', dataBinding: { xpath: '/x:value' } },
};
expect(getSdtBindingKey(range)).toBeUndefined();
range.properties.dataBinding!.storeItemID = 'STORE';
const key = getSdtBindingKey(range);
range.properties.dataBinding!.storeItemID = 'store';
expect(getSdtBindingKey(range)).toBe(key);
range.properties.dataBinding!.prefixMappings = 'xmlns:x="urn:other"';
expect(getSdtBindingKey(range)).not.toBe(key);
});
});
@@ -14,10 +14,38 @@
* limitations under the License.
*/
import type { IDocumentData } from '../../../types/interfaces';
import { describe, expect, it } from 'vitest';
import { BooleanNumber } from '../../../types/enum';
import { DocumentFlavor } from '../../../types/interfaces';
import { DocumentDataModel } from '../document-data-model';
import { getEmptySnapshot } from '../empty-snapshot';
describe('getEmptySnapshot', () => {
it.each([
[DocumentFlavor.DRAWINGML, BooleanNumber.FALSE],
[DocumentFlavor.UNSPECIFIED, BooleanNumber.TRUE],
[DocumentFlavor.TRADITIONAL, BooleanNumber.TRUE],
[DocumentFlavor.MODERN, BooleanNumber.TRUE],
] as const)('defaults hyphenation by flavor without overriding authored settings (%s)', (documentFlavor, expected) => {
expect(getEmptySnapshot(undefined, undefined, undefined, documentFlavor).documentStyle.autoHyphenation).toBe(expected);
for (const autoHyphenation of [undefined, BooleanNumber.FALSE, BooleanNumber.TRUE]) {
const source: IDocumentData = {
id: 'imported-shape-text',
body: { dataStream: 'Representation\r\n' },
documentStyle: { documentFlavor },
};
if (autoHyphenation !== undefined) {
source.documentStyle.autoHyphenation = autoHyphenation;
}
const model = new DocumentDataModel(source);
expect(model.getSnapshot().documentStyle.autoHyphenation).toBe(autoHyphenation ?? expected);
expect(source.documentStyle.autoHyphenation).toBe(autoHyphenation);
model.dispose();
}
});
it('uses the configured paragraph spacing defaults for new docs', () => {
const snapshot = getEmptySnapshot();
@@ -81,7 +81,7 @@ export function getEmptySnapshot(
marginBottom: TRADITIONAL_DOCUMENT_DEFAULT_MARGIN,
marginRight: TRADITIONAL_DOCUMENT_DEFAULT_MARGIN,
marginLeft: TRADITIONAL_DOCUMENT_DEFAULT_MARGIN,
autoHyphenation: BooleanNumber.TRUE,
autoHyphenation: documentFlavor === DocumentFlavor.DRAWINGML ? BooleanNumber.FALSE : BooleanNumber.TRUE,
doNotHyphenateCaps: BooleanNumber.FALSE,
consecutiveHyphenLimit: 2,
defaultHeaderId: '',
@@ -16,11 +16,48 @@
import type { IDocumentBody } from '../../../../types/interfaces';
import { describe, expect, it } from 'vitest';
import { DocumentBlockRangeType } from '../../../../types/interfaces';
import { CustomRangeType, DocumentBlockRangeType } from '../../../../types/interfaces';
import { DataStreamTreeTokenType } from '../../types';
import { validateDocBodyStructure, validateDocumentStructure } from '../structure-validator';
describe('validateDocBodyStructure', () => {
it.each([1, 2, 3])('accepts valid tables nested %i levels without accepting duplicate metadata', (depth) => {
const T = DataStreamTreeTokenType;
let content = 'Cell';
for (let level = 0; level < depth; level++) {
content = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}${content}${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
}
const body: IDocumentBody = { dataStream: `${content}${T.PARAGRAPH}${T.SECTION_BREAK}`, tables: [] };
const starts: number[] = [];
Array.from(body.dataStream).forEach((token, index) => {
if (token === T.TABLE_START) {
starts.push(index);
} else if (token === T.TABLE_END) {
const startIndex = starts.pop()!;
body.tables!.push({ tableId: `table_${startIndex}`, startIndex, endIndex: index + 1 });
}
});
expect(validateDocBodyStructure(body)).toEqual([]);
body.tables!.push({ ...body.tables![0], tableId: 'duplicate' });
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toContain('overlapping-table');
});
it('rejects misplaced table rows, cells and nested tables', () => {
const T = DataStreamTreeTokenType;
const cell = `${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}`;
const table = `${T.TABLE_START}${T.TABLE_ROW_START}${cell}${T.TABLE_ROW_END}${T.TABLE_END}`;
for (const dataStream of [
`${T.TABLE_ROW_START}${cell}${T.TABLE_ROW_END}`,
`${T.TABLE_START}${cell}${T.TABLE_END}`,
`${T.TABLE_START}${table}${T.TABLE_END}`,
table.replace(T.TABLE_CELL_END, T.TABLE_ROW_END),
table.replace(T.TABLE_ROW_END, T.TABLE_CELL_END),
]) {
expect(validateDocBodyStructure({ dataStream }).map((issue) => issue.code)).toContain('unbalanced-table');
}
});
it('accepts a valid plain document body', () => {
const body: IDocumentBody = {
dataStream: `A${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.SECTION_BREAK}`,
@@ -31,6 +68,24 @@ describe('validateDocBodyStructure', () => {
expect(validateDocBodyStructure(body)).toEqual([]);
});
it('accepts nested marker-backed fields and rejects crossing metadata', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.CUSTOM_RANGE_START}A${T.CUSTOM_RANGE_START}1${T.CUSTOM_RANGE_END}B${T.CUSTOM_RANGE_END}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 7, paragraphId: 'root' }],
sectionBreaks: [{ sectionId: 'section_field', startIndex: 8 }],
customRanges: [
{ startIndex: 0, endIndex: 6, rangeId: 'toc', rangeType: CustomRangeType.FIELD },
{ startIndex: 2, endIndex: 4, rangeId: 'page-ref', rangeType: CustomRangeType.FIELD },
],
};
expect(validateDocBodyStructure(body)).toEqual([]);
body.customRanges![1].endIndex = 6;
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toContain('custom-range-token-mismatch');
});
it('reports root bodies without a minimum paragraph and section pair', () => {
const body: IDocumentBody = {
dataStream: 'A',
@@ -14,15 +14,400 @@
* limitations under the License.
*/
import type { IDocumentBody } from '../../../../types/interfaces/i-document-data';
import type { IDocumentBody, IDocumentData } from '../../../../types/interfaces/i-document-data';
import type { IInsertAction, IRetainAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared/command-enum';
import { Tools } from '../../../../shared/tools';
import { BooleanNumber } from '../../../../types/enum/text-style';
import { CustomDecorationType, DocumentBlockRangeType } from '../../../../types/interfaces/i-document-data';
import {
CustomDecorationType,
CustomRangeType,
DocumentBlockRangeType,
} from '../../../../types/interfaces/i-document-data';
import { JSONX } from '../../json-x/json-x';
import { DataStreamTreeTokenType } from '../../types';
import { TextXActionType } from '../action-types';
import { BuildTextUtils } from '../build-utils';
import { TextX } from '../text-x';
import { composeBody } from '../utils';
describe('test TextX methods and branches', () => {
it('keeps scalar identity distinct from adjacent typing, different controls and document snapshots', () => {
const value = { t: TextXActionType.INSERT, len: 1, body: { dataStream: 'A' }, valueRangeId: 'first' } as IInsertAction;
const edit = new TextX().insert(1, { dataStream: 'X' }).push(value).delete(1).serialize();
const peer = new TextX().push({ ...value, body: { dataStream: 'B' } }).delete(1).serialize();
const original = { dataStream: 'Old\r\n' };
for (const priority of ['left', 'right'] as const) {
const left = TextX.apply(TextX.apply(Tools.deepClone(original), edit), TextX.transform(peer, edit, priority === 'left' ? 'right' : 'left'));
const right = TextX.apply(TextX.apply(Tools.deepClone(original), peer), TextX.transform(edit, peer, priority));
expect(left).toEqual(right);
expect(left.dataStream.replace('X', '')).toBe(`${priority === 'left' ? 'A' : 'B'}ld\r\n`);
expect(left.dataStream).toContain('X');
expect(JSON.stringify(left)).not.toContain('valueRangeId');
}
const independent = new TextX().push({ ...value, valueRangeId: 'second', body: { dataStream: 'C' } }).serialize();
const left = TextX.apply(TextX.apply(Tools.deepClone(original), [value]), TextX.transform(independent, [value], 'right'));
const right = TextX.apply(TextX.apply(Tools.deepClone(original), independent), TextX.transform([value], independent, 'left'));
expect(left).toEqual(right);
expect(left.dataStream).toBe('ACOld\r\n');
TextX.makeInvertible(edit, original);
const applied = TextX.apply(Tools.deepClone(original), edit);
TextX.apply(applied, TextX.invert(edit));
expect(applied.dataStream).toBe(original.dataStream);
});
it('rejects malformed scalar identities before any text is applied', () => {
for (const valueRangeId of ['', null, 1, [], {}]) {
const original = { dataStream: 'Old\r\n' };
const actions = new TextX().insert(1, { dataStream: 'X' }).push({
t: TextXActionType.INSERT,
len: 1,
body: { dataStream: 'A' },
valueRangeId,
} as IInsertAction).serialize();
expect(() => TextX.apply(original, actions)).toThrow('Invalid scalar value range identity');
expect(original.dataStream).toBe('Old\r\n');
}
});
it('composes property edits with positional metadata and preserves independent concurrent metadata', () => {
const range = { startIndex: 0, endIndex: 4, rangeId: 'sdt', rangeType: CustomRangeType.SDT, properties: { kind: 'text', alias: 'Old', tag: 'Old tag' } };
const original: IDocumentBody = { dataStream: 'Value\r\n', customRanges: [range] };
const edit = new TextX().updateCustomRangeProperties(range, { ...range.properties, alias: 'New' }).serialize();
const legacy = new TextX().retain(5, { dataStream: '', customRanges: [{ ...range, properties: { ...range.properties, tag: 'Peer tag' } }] }).serialize();
const left = Tools.deepClone(original);
const right = Tools.deepClone(original);
TextX.apply(left, edit);
TextX.apply(left, TextX.transform(legacy, edit, 'right'));
TextX.apply(right, legacy);
TextX.apply(right, TextX.transform(edit, legacy, 'left'));
expect(left).toEqual(right);
expect(left.customRanges![0].properties).toMatchObject({ alias: 'New', tag: 'Peer tag' });
const composed = Tools.deepClone(original);
const sequential = Tools.deepClone(original);
TextX.apply(composed, TextX.compose(edit, legacy));
TextX.apply(sequential, edit);
TextX.apply(sequential, legacy);
expect(composed).toEqual(sequential);
});
it('roundtrips new nested property edits without leaving empty objects or reverting peers', () => {
const range = { startIndex: 0, endIndex: 4, rangeId: 'sdt', rangeType: CustomRangeType.SDT, properties: { kind: 'date' } };
const original: IDocumentBody = { dataStream: 'Value\r\n', customRanges: [range] };
const first = new TextX().updateCustomRangeProperties(range, { ...range.properties, date: { format: 'yyyy' } }).serialize();
TextX.makeInvertible(first, original);
const body = Tools.deepClone(original);
TextX.apply(body, first);
TextX.apply(body, TextX.invert(first));
expect(body).toEqual(original);
const peer = new TextX().updateCustomRangeProperties(range, { ...range.properties, date: { locale: 'zh-CN' } }).serialize();
TextX.apply(body, TextX.compose(first, peer));
TextX.apply(body, TextX.transform(TextX.invert(first), peer, 'left'));
expect(body.customRanges![0].properties).toEqual({ kind: 'date', date: { locale: 'zh-CN' } });
});
it.each(['left', 'right'] as const)('converges on nested property removal versus child edits (%s)', (priority) => {
const range = { startIndex: 0, endIndex: 4, rangeId: 'sdt', rangeType: CustomRangeType.SDT, properties: { kind: 'date', date: { format: 'yyyy', locale: 'en-US' } } };
const original: IDocumentBody = { dataStream: 'Value\r\n', customRanges: [range] };
const remove = new TextX().updateCustomRangeProperties(range, { kind: 'date' }).serialize();
const edit = new TextX().updateCustomRangeProperties(range, { ...range.properties, date: { format: 'dd', locale: 'en-US' } }).serialize();
const left = Tools.deepClone(original);
const right = Tools.deepClone(original);
TextX.apply(left, remove);
TextX.apply(left, TextX.transform(edit, remove, priority === 'left' ? 'right' : 'left'));
TextX.apply(right, edit);
TextX.apply(right, TextX.transform(remove, edit, priority));
expect(left).toEqual(right);
expect(left.customRanges![0].properties).toEqual(priority === 'left' ? { kind: 'date' } : { kind: 'date', date: { format: 'dd', locale: 'en-US' } });
});
it('rejects malformed identity metadata before applying any text edits', () => {
const range = { startIndex: 0, endIndex: 4, rangeId: 'sdt', rangeType: CustomRangeType.SDT };
const original: IDocumentBody = { dataStream: 'Value\r\n', customRanges: [range] };
const invalidUpdates: unknown[] = [
...[{ startIndex: -1 }, { endIndex: 20 }, { rangeId: 'other' }]
.map((patch) => ({ rangeId: 'sdt', range: { ...range, ...patch } })),
{ rangeId: 'sdt' },
...[false, 0, '', []].map((value) => ({ rangeId: 'sdt', range: value })),
{ rangeId: 'sdt', range, nextRangeId: 0 },
...[null, 'alias', [[]], [['__proto__', 'polluted']], [['alias', 1]]]
.map((propertyPaths) => ({ rangeId: 'sdt', range, propertyPaths })),
];
for (const update of invalidUpdates) {
const body = Tools.deepClone(original);
const actions = new TextX().insert(1, { dataStream: 'X' }).updateCustomRanges([
update as NonNullable<IRetainAction['rangeUpdates']>[number],
]).serialize();
expect(() => TextX.apply(body, actions)).toThrow('Invalid custom range');
expect(body).toEqual(original);
}
});
it.each(['', 'Value'])('roundtrips identity removal and history through JSONX for %j', (value) => {
const original = {
id: 'doc',
documentStyle: {},
body: {
dataStream: `\x1F${value}\x1EAfter\r\n`,
customRanges: [{ startIndex: 0, endIndex: value.length + 1, rangeId: 'sdt', rangeType: CustomRangeType.SDT, properties: { kind: 'text', placement: 'inline' } }],
},
} as IDocumentData;
const edit = new TextX().delete(1).retain(value.length).delete(1).updateCustomRanges([{ rangeId: 'sdt', range: null }]).serialize();
const op = JSONX.getInstance().editOp(edit);
const undo = JSONX.invertWithDoc(op, Tools.deepClone(original));
const removed = JSONX.apply(Tools.deepClone(original), JSON.parse(JSON.stringify(op))) as unknown as IDocumentData;
expect(removed.body!.dataStream).toBe(`${value}After\r\n`);
expect(removed.body!.customRanges).toEqual([]);
const restored = JSONX.apply(Tools.deepClone(removed), JSON.parse(JSON.stringify(undo))) as unknown as IDocumentData;
expect(restored.body!.dataStream).toBe(original.body!.dataStream);
expect(restored.body!.customRanges).toEqual(original.body!.customRanges);
const combined = JSONX.apply(Tools.deepClone(original), JSONX.compose(op, undo)) as unknown as IDocumentData;
expect(combined.body!.dataStream).toBe(original.body!.dataStream);
expect(combined.body!.customRanges).toEqual(original.body!.customRanges);
const peer = new TextX().retain(1).insert(1, { dataStream: 'X' }).serialize();
const peerAfterRemove = TextX.transform(peer, edit, 'right');
const afterBoth = Tools.deepClone(removed.body!);
TextX.apply(afterBoth, peerAfterRemove);
const inverse = (undo![1] as { e: ReturnType<TextX['serialize']> }).e;
TextX.apply(afterBoth, TextX.transform(inverse, peerAfterRemove, 'left'));
expect(afterBoth.dataStream).toBe(`\x1FX${value}\x1EAfter\r\n`);
expect(afterBoth.customRanges).toEqual([{ ...original.body!.customRanges![0], endIndex: value.length + 2 }]);
});
it('composes identity edits with later typing and resolves concurrent identity conflicts', () => {
const range = { startIndex: 0, endIndex: 4, rangeId: 'sdt', rangeType: CustomRangeType.SDT, properties: { kind: 'text', alias: 'Old' } };
const original: IDocumentBody = { dataStream: 'Value\r\n', customRanges: [range] };
const first = new TextX().updateCustomRanges([{ rangeId: 'sdt', range: { ...range, properties: { ...range.properties, alias: 'First' } } }]).serialize();
const second = new TextX().updateCustomRanges([{ rangeId: 'sdt', range: { ...range, properties: { ...range.properties, alias: 'Second' } } }]).serialize();
const removal = new TextX().updateCustomRanges([{ rangeId: 'sdt', range: null }]).serialize();
const insert = new TextX().retain(2).insert(1, { dataStream: 'X' }).serialize();
const legacy = new TextX().retain(5, { dataStream: '', customRanges: [{ ...range, properties: { kind: 'text', alias: 'Legacy' } }] }).serialize();
for (const next of [second, removal, insert, legacy]) {
const composed = Tools.deepClone(original);
TextX.apply(composed, TextX.compose(first, next));
const sequential = Tools.deepClone(original);
TextX.apply(sequential, first);
TextX.apply(sequential, next);
expect(composed.dataStream).toBe(sequential.dataStream);
expect(composed.customRanges).toEqual(sequential.customRanges);
for (const priority of ['left', 'right'] as const) {
const left = Tools.deepClone(original);
const right = Tools.deepClone(original);
TextX.apply(left, first);
TextX.apply(left, TextX.transform(next, first, priority === 'left' ? 'right' : 'left'));
TextX.apply(right, next);
TextX.apply(right, TextX.transform(first, next, priority));
expect(left.dataStream).toBe(right.dataStream);
expect(left.customRanges).toEqual(right.customRanges);
}
}
const reinsert = new TextX().insert(1, { dataStream: 'X', customRanges: [{ ...range, startIndex: 0, endIndex: 0 }] }).serialize();
const sequential = Tools.deepClone(original);
TextX.apply(sequential, removal);
TextX.apply(sequential, reinsert);
const composed = Tools.deepClone(original);
TextX.apply(composed, TextX.compose(removal, reinsert));
expect(composed.dataStream).toBe(sequential.dataStream);
expect(composed.customRanges).toEqual(sequential.customRanges);
});
it('joins inserted SDT content with its surviving paragraph anchor', () => {
const anchor = {
startIndex: 1,
endIndex: 1,
rangeId: 'control',
rangeType: CustomRangeType.SDT,
properties: { kind: 'dropDownList', placement: 'block' },
};
const body: IDocumentBody = { dataStream: 'X\r\n', customRanges: [anchor] };
TextX.apply(body, new TextX().retain(1).insert(3, {
dataStream: 'New',
customRanges: [{ ...anchor, startIndex: 0, endIndex: 2 }],
}).serialize());
expect(body.dataStream).toBe('XNew\r\n');
expect(body.customRanges).toEqual([{ ...anchor, startIndex: 1, endIndex: 4 }]);
});
it('keeps soft page-break tokens anchored through UTF-16 insertion, undo, deletion and redo', () => {
const body: IDocumentBody = { dataStream: 'A\fB\fC\fD', renderedPageBreaks: [1, 5] };
const inserted = new TextX().retain(1).insert(2, { dataStream: '😀' }).serialize();
TextX.makeInvertible(inserted, body);
TextX.apply(body, inserted);
expect(body.renderedPageBreaks).toEqual([3, 7]);
expect(body.renderedPageBreaks?.map((offset) => body.dataStream[offset])).toEqual(['\f', '\f']);
TextX.apply(body, TextX.invert(inserted));
expect(body.dataStream).toBe('A\fB\fC\fD');
expect(body.renderedPageBreaks).toEqual([1, 5]);
const deleted = new TextX().retain(1).delete(4).serialize();
TextX.makeInvertible(deleted, body);
TextX.apply(body, deleted);
expect(body.dataStream).toBe('A\fD');
expect(body.renderedPageBreaks).toEqual([1]);
TextX.apply(body, TextX.invert(deleted));
expect(body.dataStream).toBe('A\fB\fC\fD');
expect(body.renderedPageBreaks).toEqual([1, 5]);
TextX.apply(body, deleted);
expect(body.renderedPageBreaks).toEqual([1]);
});
it('preserves soft page breaks when an imported insert is split and composed with text formatting', () => {
const source: IDocumentBody = { dataStream: 'A\fB\fC\fD', renderedPageBreaks: [1, 5] };
const inserted = new TextX().insert(source.dataStream.length, source).serialize();
const formatted = new TextX().retain(4, { dataStream: '', textRuns: [{ st: 0, ed: 4, ts: { bl: BooleanNumber.TRUE } }] }).serialize();
const body: IDocumentBody = { dataStream: '' };
TextX.apply(body, TextX.compose(inserted, formatted));
expect(body.dataStream).toBe(source.dataStream);
expect(body.renderedPageBreaks).toEqual([1, 5]);
expect(source.renderedPageBreaks).toEqual([1, 5]);
});
it('keeps every enclosing field intact when editing a nested field result', () => {
const start = DataStreamTreeTokenType.CUSTOM_RANGE_START;
const end = DataStreamTreeTokenType.CUSTOM_RANGE_END;
const body: IDocumentBody = {
dataStream: `${start}A${start}1${end}\rB${end}\0`,
customRanges: [
{
startIndex: 0,
endIndex: 7,
rangeId: 'toc',
rangeType: CustomRangeType.FIELD,
wholeEntity: false,
properties: { fieldType: 'TOC', instruction: 'TOC \\o "1-3"' },
},
{
startIndex: 2,
endIndex: 4,
rangeId: 'page-ref',
rangeType: CustomRangeType.FIELD,
wholeEntity: false,
properties: { fieldType: 'PAGEREF', instruction: 'PAGEREF _Toc1 \\h' },
},
],
};
TextX.apply(body, new TextX().retain(4).insert(1, { dataStream: '0' }).serialize());
expect(body.dataStream).toBe(`${start}A${start}10${end}\rB${end}\0`);
expect(body.customRanges).toMatchObject([
{ rangeId: 'toc', startIndex: 0, endIndex: 8 },
{ rangeId: 'page-ref', startIndex: 2, endIndex: 5 },
]);
});
it('keeps a nested SDT as one entity and expands it when typing inside', () => {
const body: IDocumentBody = {
dataStream: 'Accept: Value\r',
customRanges: [{
startIndex: 8,
endIndex: 12,
rangeId: 'sdt-text-1',
rangeType: CustomRangeType.SDT,
wholeEntity: false,
properties: { kind: 'text', placement: 'inline' },
}],
};
TextX.apply(body, new TextX().retain(10).insert(1, { dataStream: 'X' }).serialize());
expect(body.dataStream).toBe('Accept: VaXlue\r');
expect(body.customRanges).toMatchObject([{
rangeId: 'sdt-text-1',
startIndex: 8,
endIndex: 13,
}]);
});
it('converges concurrent edits inside one SDT without duplicating the wrapper', () => {
const original: IDocumentBody = {
dataStream: 'Accept: Value\r',
customRanges: [{
startIndex: 8,
endIndex: 12,
rangeId: 'sdt-text-1',
rangeType: CustomRangeType.SDT,
wholeEntity: false,
properties: { kind: 'text', placement: 'inline' },
}],
};
const left = new TextX().retain(10).insert(1, { dataStream: 'X' }).serialize();
const right = new TextX().retain(11).insert(1, { dataStream: 'Y' }).serialize();
const leftFirst = JSON.parse(JSON.stringify(original)) as IDocumentBody;
const rightFirst = JSON.parse(JSON.stringify(original)) as IDocumentBody;
TextX.apply(leftFirst, TextX.compose(left, TextX.transform(right, left, 'right')));
TextX.apply(rightFirst, TextX.compose(right, TextX.transform(left, right, 'left')));
expect(leftFirst).toEqual(rightFirst);
expect(leftFirst.customRanges).toEqual([expect.objectContaining({
rangeId: 'sdt-text-1',
startIndex: 8,
endIndex: 14,
})]);
});
it('composes updates for multiple nested custom ranges by range id', () => {
const ranges = [
{ startIndex: 0, endIndex: 7, rangeId: 'toc', rangeType: CustomRangeType.FIELD },
{ startIndex: 2, endIndex: 4, rangeId: 'page-ref', rangeType: CustomRangeType.FIELD },
];
const result = composeBody(
{ dataStream: '', customRanges: ranges },
{
dataStream: '',
customRanges: ranges.map((range) => ({ ...range, properties: { dirty: BooleanNumber.TRUE } })),
},
UpdateDocsAttributeType.COVER
);
expect(result.customRanges).toEqual(ranges.map((range) => ({
...range,
properties: { dirty: BooleanNumber.TRUE },
})));
});
it('updates metadata without splitting an SDT around structural tokens', () => {
const body: IDocumentBody = {
dataStream: 'X\x1Cdd mmm yyyy\r\n\x1D',
customRanges: [{
startIndex: 1,
endIndex: 15,
rangeId: 'date-cell',
rangeType: CustomRangeType.SDT,
wholeEntity: false,
properties: { kind: 'date', placement: 'cell', showingPlaceholder: true },
}],
};
const replacement = BuildTextUtils.selection.delete([{
startOffset: 2,
endOffset: 13,
collapsed: false,
}], body, 0, { dataStream: '06 Sep 2026' });
const metadata = new TextX().retain(1).retain(15, {
dataStream: '',
customRanges: [{
...body.customRanges![0],
startIndex: 0,
endIndex: 14,
properties: { kind: 'date', placement: 'cell', showingPlaceholder: false },
}],
}).serialize();
TextX.apply(body, TextX.compose(replacement, metadata));
expect(body.dataStream).toBe('X\x1C06 Sep 2026\r\n\x1D');
expect(body.customRanges).toEqual([{
startIndex: 1,
endIndex: 15,
rangeId: 'date-cell',
rangeType: CustomRangeType.SDT,
wholeEntity: false,
properties: { kind: 'date', placement: 'cell', showingPlaceholder: false },
}]);
});
describe('test TextX methods', () => {
it('test TextX insert method', () => {
const textX = new TextX();
@@ -19,9 +19,10 @@ import type { IRetainAction } from '../action-types';
import { describe, expect, it, vi } from 'vitest';
import { Tools } from '../../../../shared/tools';
import { BooleanNumber } from '../../../../types/enum/text-style';
import { CustomRangeType } from '../../../../types/interfaces/i-document-data';
import { PresetListType } from '../../preset-list-type';
import { TextXActionType } from '../action-types';
import { composeBody, getBodySlice, getTextRunSlice, isUselessRetainAction } from '../utils';
import { composeBody, getBodySlice, getTextRunSlice, isUselessRetainAction, SliceBodyType } from '../utils';
describe('test text-x utils', () => {
it('only clones text runs intersecting the requested slice', () => {
@@ -170,6 +171,34 @@ describe('test text-x utils', () => {
});
});
it('drops a clipped outer field while preserving a fully copied nested field', () => {
const body: IDocumentBody = {
dataStream: '\u001FOld\t\u001F1\u001E\r\u001E',
customRanges: [{
startIndex: 0,
endIndex: 9,
rangeId: 'toc',
rangeType: CustomRangeType.FIELD,
wholeEntity: false,
}, {
startIndex: 5,
endIndex: 7,
rangeId: 'page-ref',
rangeType: CustomRangeType.FIELD,
wholeEntity: false,
}],
};
const copied = getBodySlice(body, 1, 9, false, SliceBodyType.copy);
expect(copied.dataStream).toBe('Old\t\u001F1\u001E\r');
expect(copied.customRanges).toEqual([expect.objectContaining({
rangeId: 'page-ref',
startIndex: 4,
endIndex: 6,
})]);
});
it('test composeBody fn with textRuns', () => {
const thisBody: IDocumentBody = {
dataStream: 'hello\nworld',
@@ -15,7 +15,7 @@
*/
import type { UpdateDocsAttributeType } from '../../../shared/command-enum';
import type { IDocumentBody } from '../../../types/interfaces/i-document-data';
import type { ICustomRange, IDocumentBody } from '../../../types/interfaces/i-document-data';
// Internal TextX structural-replacement marker. Inserted paragraphs replace the
// same logical paragraphs later in the operation, so their ids must stay stable.
@@ -36,6 +36,20 @@ export interface IRetainAction {
body?: IDocumentBody;
oldBody?: IDocumentBody;
coverType?: UpdateDocsAttributeType;
/**
* Identity edits applied after the text operation, with absolute result-document offsets.
* nextRangeId restores array order by identity (null means the end), including coincident SDT nesting.
* Collaboration peers must support this extension; legacy TextX implementations ignore it.
*/
rangeUpdates?: Array<{
rangeId: string;
range: ICustomRange | null;
nextRangeId?: string | null;
/** Only these property paths are written; missing values remove properties. Arrays are atomic. */
propertyPaths?: string[][];
}>;
/** Before-document values captured by makeInvertible; never persisted in a document body. */
oldRangeUpdates?: IRetainAction['rangeUpdates'];
}
/**
@@ -45,6 +59,8 @@ export interface IInsertAction {
t: TextXActionType.INSERT;
body: IDocumentBody;
len: number;
/** Scalar selection intent, not snapshot data. All collaboration peers must support this extension. */
valueRangeId?: string;
}
/**
@@ -54,6 +70,8 @@ export interface IDeleteAction {
t: TextXActionType.DELETE;
len: number;
body?: IDocumentBody; // Add a body property to make this action invertible.
/** Preserved through inversion so undo participates in scalar conflict resolution. */
valueRangeId?: string;
}
export type TextXAction = IRetainAction | IInsertAction | IDeleteAction;
@@ -0,0 +1,72 @@
/**
* 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, ISdtCustomRange } from '../../../../../types/interfaces/i-document-data';
import { describe, expect, it } from 'vitest';
import { CustomRangeType } from '../../../../../types/interfaces/i-document-data';
import { deleteCustomRanges, insertCustomRanges, mergeContinuousRanges } from '../common';
describe('overlapping bookmark identity', () => {
it('merges each bookmark independently of interleaved overlapping ranges', () => {
const ranges = [0, 4].flatMap((startIndex) => ['a', 'b'].map((rangeId) => ({
rangeId,
startIndex,
endIndex: startIndex + 3,
rangeType: CustomRangeType.BOOKMARK,
properties: { bookmarkId: rangeId },
})));
expect(mergeContinuousRanges(ranges)).toEqual(['a', 'b'].map((rangeId) => ({
rangeId,
startIndex: 0,
endIndex: 7,
rangeType: CustomRangeType.BOOKMARK,
properties: { bookmarkId: rangeId },
})));
});
it('keeps an enclosing bookmark intact when inserting and deleting text inside it', () => {
const bookmark = { rangeId: 'bookmark', startIndex: 0, endIndex: 7, rangeType: CustomRangeType.BOOKMARK, properties: { bookmarkId: 'target' } };
const body: IDocumentBody = { dataStream: 'abcdefgh', customRanges: [{ ...bookmark }] };
insertCustomRanges(body, { dataStream: 'XY' }, 2, 4);
expect(body.customRanges).toEqual([{ ...bookmark, endIndex: 9 }]);
deleteCustomRanges(body, 2, 4);
expect(body.customRanges).toEqual([bookmark]);
});
});
function sdt(rangeId: string, startIndex: number, endIndex: number): ISdtCustomRange {
return { rangeId, startIndex, endIndex, rangeType: CustomRangeType.SDT, properties: { kind: 'richText', placement: 'block' } };
}
describe('SDT nesting through text changes', () => {
it('retains child-before-parent order when deletion gives a child the same bounds as its parent', () => {
const body: IDocumentBody = { dataStream: 'A\rB\r\n', customRanges: [
sdt('first', 0, 0),
sdt('parent', 0, 2),
sdt('last', 2, 2),
] };
deleteCustomRanges(body, 2, 0);
expect(body.customRanges?.map((range) => range.rangeId)).toEqual(['last', 'parent']);
expect(body.customRanges?.map(({ startIndex, endIndex }) => [startIndex, endIndex])).toEqual([[0, 0], [0, 0]]);
});
it('restores the source nesting order when an inserted child precedes a retained parent fragment', () => {
const body: IDocumentBody = { dataStream: 'B\r\n', customRanges: [sdt('last', 0, 0), sdt('parent', 0, 0)] };
insertCustomRanges(body, { dataStream: 'A\r', customRanges: [sdt('first', 0, 0), sdt('parent', 0, 1)] }, 2, 0);
expect(body.customRanges?.map((range) => range.rangeId)).toEqual(['first', 'parent', 'last']);
expect(body.customRanges?.find((range) => range.rangeId === 'parent')).toMatchObject({ startIndex: 0, endIndex: 2 });
});
});
@@ -0,0 +1,73 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentBody, ISdtCustomRange } from '../../../../../types/interfaces';
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../../shared';
import { CustomRangeType } from '../../../../../types/interfaces';
import { updateAttribute } from '../update-apply';
describe('retained SDT metadata boundaries', () => {
it('restores child-before-parent order when a retained parent shrinks onto its child', () => {
const child: ISdtCustomRange = {
rangeId: 'child',
rangeType: CustomRangeType.SDT,
startIndex: 0,
endIndex: 0,
properties: { kind: 'repeatingSectionItem', placement: 'block' },
};
const parent: ISdtCustomRange = {
...child,
rangeId: 'parent',
endIndex: 4,
properties: { kind: 'repeatingSection', placement: 'block' },
};
const body: IDocumentBody = { dataStream: 'A\rA\rB\r\n', customRanges: [child, parent] };
updateAttribute(body, {
dataStream: '',
customRanges: [child, { ...parent, endIndex: 0 }],
}, 2, 0, UpdateDocsAttributeType.REPLACE);
expect(body.customRanges?.map((range) => range.rangeId)).toEqual(['child', 'parent']);
expect(body.customRanges?.map((range) => range.endIndex)).toEqual([0, 0]);
});
it.each([
{ name: 'keeps both clipped edges', offset: 2, length: 3, start: 0, end: 2, expectedStart: 0, expectedEnd: 8 },
{ name: 'restores an explicit end before the retain edge', offset: 0, length: 5, start: 0, end: 2, expectedStart: 0, expectedEnd: 2 },
{ name: 'restores an explicit start after the retain edge', offset: 2, length: 7, start: 2, end: 6, expectedStart: 4, expectedEnd: 8 },
])('$name', ({ offset, length, start, end, expectedStart, expectedEnd }) => {
const range: ISdtCustomRange = {
rangeId: 'sdt',
rangeType: CustomRangeType.SDT,
startIndex: 0,
endIndex: 8,
properties: { kind: 'repeatingSection', placement: 'block', alias: 'before' },
};
const body: IDocumentBody = { dataStream: '123456789\r\n', customRanges: [range] };
updateAttribute(body, {
dataStream: '',
customRanges: [{ ...range, startIndex: start, endIndex: end, properties: { ...range.properties, alias: 'after' } }],
}, length, offset, UpdateDocsAttributeType.REPLACE);
expect(body.customRanges).toHaveLength(1);
expect(body.customRanges![0]).toMatchObject({
rangeId: 'sdt',
startIndex: expectedStart,
endIndex: expectedEnd,
properties: { alias: 'after' },
});
});
});
@@ -32,6 +32,7 @@ import type {
import { shallowEqual } from '../../../../common/equal';
import { horizontalLineSegmentsSubtraction, sortRulesFactory, Tools } from '../../../../shared';
import { isSameStyleTextRun } from '../../../../shared/compare';
import { CustomRangeType } from '../../../../types/interfaces';
import { cloneParagraphWithId } from '../../../paragraph-id';
import { cloneSectionBreakWithId } from '../../../section-break-id';
import { DataStreamTreeTokenType } from '../../types';
@@ -688,31 +689,29 @@ const ID_SPLIT_SYMBOL = '$';
const getRootId = (id: string) => id.split(ID_SPLIT_SYMBOL)[0];
export function mergeContinuousRanges(ranges: ICustomRange[]): ICustomRange[] {
if (ranges.length <= 1) return ranges;
if (ranges.length <= 1) {
return ranges;
}
ranges.sort((a, b) => a.startIndex - b.startIndex);
const mergedRanges: ICustomRange[] = [];
let currentRange = { ...ranges[0] };
currentRange.rangeId = getRootId(currentRange.rangeId);
for (let i = 1; i < ranges.length; i++) {
const nextRange = ranges[i];
nextRange.rangeId = getRootId(nextRange.rangeId);
const latestById = new Map<string, ICustomRange>();
for (const range of ranges) {
const nextRange = { ...range, rangeId: getRootId(range.rangeId) };
const currentRange = latestById.get(nextRange.rangeId);
if (
nextRange.rangeId === currentRange.rangeId &&
currentRange &&
nextRange.rangeType === currentRange.rangeType &&
shallowEqual(currentRange.properties, nextRange.properties) &&
currentRange.endIndex + 1 >= nextRange.startIndex
) {
// Merge continuous ranges with same rangeId
currentRange.endIndex = nextRange.endIndex;
// Overlapping identities can interleave fragments of the same range.
currentRange.endIndex = Math.max(currentRange.endIndex, nextRange.endIndex);
} else {
// Push current range and start a new one
mergedRanges.push(currentRange);
currentRange = { ...nextRange };
mergedRanges.push(nextRange);
latestById.set(nextRange.rangeId, nextRange);
}
}
// Push the last range
mergedRanges.push(currentRange);
const idMap: Record<string, number> = Object.create(null);
for (let i = 0, len = mergedRanges.length; i < len; i++) {
@@ -730,20 +729,24 @@ export function mergeContinuousRanges(ranges: ICustomRange[]): ICustomRange[] {
}
export function splitCustomRangesByIndex(customRanges: ICustomRange[], currentIndex: number) {
const matchedCustomRangeIndex = customRanges.findIndex((c) => c.startIndex < currentIndex && c.endIndex >= currentIndex);
const matchedCustomRange = customRanges[matchedCustomRangeIndex];
const matches = customRanges.filter((range) =>
range.rangeType !== CustomRangeType.FIELD &&
range.rangeType !== CustomRangeType.SDT &&
range.rangeType !== CustomRangeType.BOOKMARK &&
range.startIndex < currentIndex &&
range.endIndex >= currentIndex
);
if (matchedCustomRange) {
customRanges.splice(matchedCustomRangeIndex, 1, {
...matchedCustomRange,
startIndex: matchedCustomRange.startIndex,
for (const matched of matches) {
const index = customRanges.indexOf(matched);
customRanges.splice(index, 1, {
...matched,
endIndex: currentIndex - 1,
properties: { ...matchedCustomRange.properties },
properties: { ...matched.properties },
}, {
...matchedCustomRange,
...matched,
startIndex: currentIndex,
endIndex: matchedCustomRange.endIndex,
properties: { ...matchedCustomRange.properties },
properties: { ...matched.properties },
});
}
}
@@ -810,6 +813,16 @@ export function insertCustomRanges(
for (let i = 0, len = customRanges.length; i < len; i++) {
const customRange = customRanges[i];
const { startIndex } = customRange;
if (
(customRange.rangeType === CustomRangeType.FIELD ||
customRange.rangeType === CustomRangeType.SDT ||
customRange.rangeType === CustomRangeType.BOOKMARK) &&
startIndex < currentIndex &&
customRange.endIndex >= currentIndex
) {
customRange.endIndex += textLength;
continue;
}
// move custom range when insert text before it
if (startIndex >= currentIndex) {
customRange.startIndex += textLength;
@@ -821,6 +834,18 @@ export function insertCustomRanges(
if (insertBody.customRanges) {
for (let i = 0, len = insertBody.customRanges.length; i < len; i++) {
const customRange = insertBody.customRanges[i];
const existingSdt = customRange.rangeType === CustomRangeType.SDT
? customRanges.find((range) => range.rangeType === CustomRangeType.SDT && range.rangeId === customRange.rangeId)
: undefined;
if (existingSdt) {
existingSdt.startIndex = Math.min(existingSdt.startIndex, currentIndex + customRange.startIndex);
existingSdt.endIndex = Math.max(existingSdt.endIndex, currentIndex + customRange.endIndex);
existingSdt.properties = Tools.deepClone(customRange.properties);
existingSdt.wholeEntity = customRange.wholeEntity;
// An undo fragment carries its original child-before-parent order.
customRanges.splice(customRanges.indexOf(existingSdt), 0, ...insertRanges.splice(0));
continue;
}
customRange.startIndex += currentIndex;
customRange.endIndex += currentIndex;
// new custom range
@@ -1244,7 +1269,11 @@ export function deleteCustomRanges(body: IDocumentBody, textLength: number, curr
const removeCustomRanges: ICustomRange[] = [];
if (customRanges) {
const newCustomRanges = [];
// Preserve nesting before deletion can collapse a parent and child onto identical bounds.
const originalSdtLengths = new Map(customRanges.filter((range) => range.rangeType === CustomRangeType.SDT)
.map((range) => [range, range.endIndex - range.startIndex]));
const coincidentSdts = new Map<string, number[]>();
const newCustomRanges: ICustomRange[] = [];
for (let i = 0, len = customRanges.length; i < len; i++) {
const customRange = customRanges[i];
const transformed = shiftInclusiveRangeOnDelete(customRange, currentIndex, textLength);
@@ -1253,8 +1282,24 @@ export function deleteCustomRanges(body: IDocumentBody, textLength: number, curr
continue;
}
Object.assign(customRange, transformed);
if (customRange.rangeType === CustomRangeType.SDT) {
const key = `${customRange.startIndex}:${customRange.endIndex}`;
const positions = coincidentSdts.get(key) ?? [];
positions.push(newCustomRanges.length);
coincidentSdts.set(key, positions);
}
newCustomRanges.push(customRange);
}
for (const positions of coincidentSdts.values()) {
if (positions.length < 2) {
continue;
}
const ordered = positions.map((index) => newCustomRanges[index])
.sort((left, right) => originalSdtLengths.get(left)! - originalSdtLengths.get(right)!);
positions.forEach((index, offset) => {
newCustomRanges[index] = ordered[offset];
});
}
body.customRanges = mergeContinuousRanges(newCustomRanges);
}
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import type { IDocumentBody } from '../../../../types/interfaces';
import { deleteContent } from '../../../../shared';
import type { IDocumentBody } from '../../../../types/interfaces/i-document-data';
import { deleteContent } from '../../../../shared/doc-tool';
import {
deleteBlockRanges,
deleteColumnGroups,
@@ -37,6 +37,15 @@ export function updateAttributeByDelete(body: IDocumentBody, textLength: number,
const startIndex = currentIndex;
const endIndex = currentIndex + textLength;
const removedRenderedPageBreaks = body.renderedPageBreaks
?.filter((offset) => offset >= startIndex && offset < endIndex)
.map((offset) => offset - startIndex);
if (body.renderedPageBreaks != null) {
body.renderedPageBreaks = body.renderedPageBreaks
.filter((offset) => offset < startIndex || offset >= endIndex)
.map((offset) => offset >= endIndex ? offset - textLength : offset);
}
const removeTextRuns = deleteTextRuns(body, textLength, currentIndex);
const removeParagraphs = deleteParagraphs(body, textLength, currentIndex);
@@ -69,6 +78,7 @@ export function updateAttributeByDelete(body: IDocumentBody, textLength: number,
return {
dataStream: removeDataStream,
renderedPageBreaks: removedRenderedPageBreaks,
textRuns: removeTextRuns,
paragraphs: removeParagraphs,
sectionBreaks: removeSectionBreaks,
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import type { IDocumentBody } from '../../../../types/interfaces';
import { insertTextToContent } from '../../../../shared';
import type { IDocumentBody } from '../../../../types/interfaces/i-document-data';
import { insertTextToContent } from '../../../../shared/doc-tool';
import {
insertBlockRanges,
insertColumnGroups,
@@ -40,6 +40,13 @@ export function updateAttributeByInsert(
const originalDataStream = body.dataStream;
body.dataStream = insertTextToContent(body.dataStream, currentIndex, insertBody.dataStream);
if (body.renderedPageBreaks != null || insertBody.renderedPageBreaks != null) {
body.renderedPageBreaks = [
...(body.renderedPageBreaks ?? []).map((offset) => offset >= currentIndex ? offset + textLength : offset),
...(insertBody.renderedPageBreaks ?? []).map((offset) => offset + currentIndex),
].sort((left, right) => left - right);
}
insertTextRuns(body, insertBody, textLength, currentIndex);
insertParagraphs(body, insertBody, textLength, currentIndex, false, originalDataStream);
@@ -27,7 +27,7 @@ import type {
ITextRun,
} from '../../../../types/interfaces';
import { Tools, UpdateDocsAttributeType } from '../../../../shared';
import { CustomDecorationType } from '../../../../types/interfaces';
import { CustomDecorationType, CustomRangeType } from '../../../../types/interfaces';
import { PresetListType } from '../../preset-list-type';
import {
deleteBlockRanges,
@@ -628,6 +628,27 @@ function updateCustomRanges(
updateDataCustomRanges.forEach((customRange) => {
const { startIndex, endIndex } = customRange;
const existingSdt = customRange.rangeType === CustomRangeType.SDT
? newCustomRanges.find((range) => range.rangeType === CustomRangeType.SDT && range.rangeId === customRange.rangeId)
: undefined;
// A retain carrying metadata can cover only one fragment of an enclosing SDT
// after TextX composition (for example, a value replacement between cell
// boundary tokens). In that case the retained range is a property update for
// the existing entity, not a second SDT with clipped boundaries.
if (existingSdt) {
// A clipped fragment touches the retain edge. An interior boundary is
// explicit (for example, undoing an extension past a paragraph mark).
if (startIndex > 0) {
existingSdt.startIndex = startIndex + currentIndex;
}
if (endIndex < textLength - 1) {
existingSdt.endIndex = endIndex + currentIndex;
}
existingSdt.properties = Tools.deepClone(customRange.properties);
existingSdt.wholeEntity = customRange.wholeEntity;
return;
}
newCustomRanges.push({
...customRange,
startIndex: startIndex + currentIndex,
@@ -635,6 +656,26 @@ function updateCustomRanges(
});
});
// A retained parent can shrink onto a fully replaced child. Preserve the
// update's nesting order only for coincident SDTs; leave other ranges alone.
const updateOrder = new Map(updateDataCustomRanges.map((range, index) => [range.rangeId, index]));
const coincidentSdts = new Map<string, number[]>();
newCustomRanges.forEach((range, index) => {
if (range.rangeType !== CustomRangeType.SDT || !updateOrder.has(range.rangeId)) {
return;
}
const key = `${range.startIndex}:${range.endIndex}`;
const positions = coincidentSdts.get(key) ?? [];
positions.push(index);
coincidentSdts.set(key, positions);
});
for (const positions of coincidentSdts.values()) {
const ordered = positions.map((index) => newCustomRanges[index])
.sort((left, right) => updateOrder.get(left.rangeId)! - updateOrder.get(right.rangeId)!);
positions.forEach((position, index) => {
newCustomRanges[position] = ordered[index];
});
}
body.customRanges = mergeContinuousRanges(newCustomRanges);
return removeCustomRanges;
@@ -14,15 +14,16 @@
* limitations under the License.
*/
import type { IDocumentBody } from '../../../types/interfaces';
import type { IDocumentBody } from '../../../types/interfaces/i-document-data';
import type { TextXAction } from './action-types';
import { MemoryCursor } from '../../../common/memory-cursor';
import { Tools } from '../../../shared';
import { UpdateDocsAttributeType } from '../../../shared/command-enum';
import { Tools } from '../../../shared/tools';
import { TextXActionType } from './action-types';
import { updateAttributeByDelete } from './apply-utils/delete-apply';
import { updateAttributeByInsert } from './apply-utils/insert-apply';
import { updateAttribute } from './apply-utils/update-apply';
import { applyRangeUpdates, getRangeUpdates, validateRangeUpdates } from './custom-range-update';
function updateApply(
doc: IDocumentBody,
@@ -61,6 +62,23 @@ function insertApply(
}
export function textXApply(doc: IDocumentBody, actions: TextXAction[]): IDocumentBody {
if (actions.some((action) => action.t !== TextXActionType.RETAIN && action.valueRangeId !== undefined &&
(typeof action.valueRangeId !== 'string' || !action.valueRangeId))) {
throw new Error('Invalid scalar value range identity.');
}
const updates = getRangeUpdates(actions);
if (updates.length) {
const resultLength = actions.reduce((length, action) => {
if (action.t === TextXActionType.INSERT) {
return length + action.len;
}
if (action.t === TextXActionType.DELETE) {
return length - action.len;
}
return length;
}, doc.dataStream.length);
validateRangeUpdates(updates, resultLength);
}
const memoryCursor = new MemoryCursor();
memoryCursor.reset();
@@ -98,6 +116,7 @@ export function textXApply(doc: IDocumentBody, actions: TextXAction[]): IDocumen
}
});
applyRangeUpdates(doc, updates);
return doc;
}
@@ -0,0 +1,50 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentBody } from '../../../../../types/interfaces/i-document-data';
import { describe, expect, it } from 'vitest';
import { CustomRangeType } from '../../../../../types/interfaces/i-document-data';
import { TextX } from '../../text-x';
import { deleteSelectionTextX } from '../text-x-utils';
describe('deleteSelectionTextX structural expansion', () => {
it.each([0, 3])('preserves an unrelated blank bookmark when deleting character %i', (startOffset) => {
const body: IDocumentBody = {
dataStream: 'A\r\rB\r\n',
paragraphs: [1, 2, 4].map((startIndex) => ({ startIndex, paragraphId: `p-${startIndex}` })),
sectionBreaks: [{ startIndex: 5, sectionId: 'body' }],
customRanges: [{ startIndex: 2, endIndex: 2, rangeId: 'blank-bookmark', rangeType: CustomRangeType.BOOKMARK }],
};
const expected = body.dataStream.slice(0, startOffset) + body.dataStream.slice(startOffset + 1);
const actions = deleteSelectionTextX([{ startOffset, endOffset: startOffset + 1, collapsed: false }], body);
TextX.apply(body, actions);
expect(body.dataStream).toBe(expected);
expect(body.paragraphs?.map((paragraph) => paragraph.paragraphId)).toEqual(['p-1', 'p-2', 'p-4']);
expect(body.customRanges?.map((range) => range.rangeId)).toEqual(['blank-bookmark']);
});
it('still deletes an explicitly selected blank bookmark paragraph', () => {
const body: IDocumentBody = {
dataStream: 'A\r\rB\r\n',
paragraphs: [1, 2, 4].map((startIndex) => ({ startIndex, paragraphId: `p-${startIndex}` })),
sectionBreaks: [{ startIndex: 5, sectionId: 'body' }],
customRanges: [{ startIndex: 2, endIndex: 2, rangeId: 'blank-bookmark', rangeType: CustomRangeType.BOOKMARK }],
};
TextX.apply(body, deleteSelectionTextX([{ startOffset: 2, endOffset: 3, collapsed: false }], body));
expect(body.dataStream).toBe('A\rB\r\n');
expect(body.customRanges).toHaveLength(0);
});
});
@@ -220,11 +220,13 @@ function isIntervalCoveredBySelections(
implicitTokens: Set<string>
): boolean {
let cursor = interval.startOffset;
let intersectsSelection = false;
for (const selection of selections) {
if (selection.endOffset <= cursor || selection.startOffset >= interval.endOffset) {
continue;
}
intersectsSelection = true;
const selectionStart = Math.max(selection.startOffset, interval.startOffset);
const selectionEnd = Math.min(selection.endOffset, interval.endOffset);
@@ -245,7 +247,7 @@ function isIntervalCoveredBySelections(
}
}
return true;
return intersectsSelection;
}
function addCoveredIntervals(
@@ -0,0 +1,306 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICustomRange, IDocumentBody } from '../../../types/interfaces/i-document-data';
import type { IRetainAction, TextXAction } from './action-types';
import { UpdateDocsAttributeType } from '../../../shared/command-enum';
import { Tools } from '../../../shared/tools';
import { DataStreamTreeTokenType } from '../types';
import { TextXActionType } from './action-types';
import { deleteCustomRanges, insertCustomRanges } from './apply-utils/common';
import { updateAttribute } from './apply-utils/update-apply';
import { getBodySliceForSplitTextXAction } from './utils';
export function getChangedPropertyPaths(before: unknown, after: unknown, path: string[] = []): string[][] {
if (Tools.diffValue(before, after)) {
return [];
}
if (after && typeof after === 'object' && !Array.isArray(after) &&
(before == null || (typeof before === 'object' && !Array.isArray(before)))) {
const previous = (before ?? {}) as Record<string, unknown>;
const next = after as Record<string, unknown>;
const keys = Array.from(new Set([...Object.keys(previous), ...Object.keys(next)]));
if (!keys.length && path.length) {
return [path];
}
return keys
.flatMap((key) => getChangedPropertyPaths(previous[key], next[key], [...path, key]));
}
return [path];
}
function validatePropertyPaths(paths: string[][]): void {
if (!Array.isArray(paths) || paths.some((path) => !Array.isArray(path) || !path.length || path.some((key) =>
typeof key !== 'string' || ['__proto__', 'prototype', 'constructor'].includes(key)))) {
throw new Error('Invalid custom range property paths.');
}
}
function patchRangeProperties(target: ICustomRange, source: ICustomRange, paths: string[][]): ICustomRange {
validatePropertyPaths(paths);
const result = Tools.deepClone(target);
for (const path of paths) {
let current = (result.properties ??= {}) as Record<string, unknown>;
let value: unknown = source.properties;
const parents: Array<{ object: Record<string, unknown>; key: string; value: unknown }> = [];
for (let index = 0; index < path.length; index++) {
const key = path[index];
value = value && typeof value === 'object' ? (value as Record<string, unknown>)[key] : undefined;
if (index === path.length - 1) {
if (value === undefined) {
delete current[key];
} else {
current[key] = Tools.deepClone(value);
}
} else {
if (!current[key] || typeof current[key] !== 'object' || Array.isArray(current[key])) {
current[key] = {};
}
parents.push({ object: current, key, value });
current = current[key] as Record<string, unknown>;
}
}
for (const parent of parents.reverse()) {
if (parent.value === undefined && Object.keys(parent.object[parent.key] as object).length === 0) {
delete parent.object[parent.key];
}
}
}
if (source.properties === undefined && !Object.keys(result.properties ?? {}).length) {
delete result.properties;
}
return result;
}
export function composeRangeUpdate(
first: NonNullable<IRetainAction['rangeUpdates']>[number] | undefined,
second: NonNullable<IRetainAction['rangeUpdates']>[number]
): NonNullable<IRetainAction['rangeUpdates']>[number] {
if (!first || !second.propertyPaths || !second.range) {
return second;
}
if (!first.range) {
return first;
}
const propertyPaths = first.propertyPaths && [...first.propertyPaths, ...second.propertyPaths.filter((path) => !first.propertyPaths!.some((parent) =>
parent.length <= path.length && parent.every((key, index) => path[index] === key)))];
return { ...first, range: patchRangeProperties(first.range, second.range, second.propertyPaths), propertyPaths };
}
export function transformRangeUpdate(
update: NonNullable<IRetainAction['rangeUpdates']>[number],
concurrent: NonNullable<IRetainAction['rangeUpdates']>[number] | undefined,
priority: 'left' | 'right'
): NonNullable<IRetainAction['rangeUpdates']> {
if (!concurrent || !update.range) {
return [update];
}
if (!concurrent.range) {
return [];
}
if (!update.propertyPaths || !concurrent.propertyPaths) {
return priority === 'left' ? [{ ...update, propertyPaths: undefined }] : [];
}
const propertyPaths = update.propertyPaths.filter((path) => priority === 'left' ||
!concurrent.propertyPaths!.some((other) => path.slice(0, Math.min(path.length, other.length))
.every((key, index) => other[index] === key)))
.map((path) => concurrent.propertyPaths!.find((other) => other.length < path.length &&
other.every((key, index) => path[index] === key)) ?? path);
return propertyPaths.length ? [{ ...update, propertyPaths }] : [];
}
export function getRangeUpdates(actions: TextXAction[]): NonNullable<IRetainAction['rangeUpdates']> {
const updates = new Map<string, NonNullable<IRetainAction['rangeUpdates']>[number]>();
for (const action of actions) {
if (action.t === TextXActionType.RETAIN) {
for (const update of action.rangeUpdates ?? []) {
updates.set(update.rangeId, composeRangeUpdate(updates.get(update.rangeId), update));
}
}
}
return updates.size ? Tools.deepClone(Array.from(updates.values())) : [];
}
// Identity edits must never enter the positional iterator: an insertion may
// split every character of a retain, or a deletion may consume it altogether.
export function withoutRangeUpdates(actions: TextXAction[]): TextXAction[] {
return actions.flatMap((action) => {
if (action.t === TextXActionType.INSERT && action.len > 1 &&
Array.from(action.body.dataStream).every((token) => token === DataStreamTreeTokenType.CUSTOM_RANGE_START || token === DataStreamTreeTokenType.CUSTOM_RANGE_END)) {
return Array.from(action.body.dataStream, (_, index) => ({
...action,
len: 1,
body: getBodySliceForSplitTextXAction(action.body, index, index + 1, false),
}));
}
if (action.t !== TextXActionType.RETAIN || !action.rangeUpdates) {
return [action];
}
const { rangeUpdates, oldRangeUpdates, ...textAction } = action;
return textAction.len === 0 && !textAction.body ? [] : [textAction];
});
}
export function appendRangeUpdates(actions: TextXAction[], updates: IRetainAction['rangeUpdates']): TextXAction[] {
return updates?.length ? [...actions, { t: TextXActionType.RETAIN, len: 0, rangeUpdates: updates }] : actions;
}
export function validateRangeUpdates(updates: IRetainAction['rangeUpdates'], resultLength: number): void {
for (const { rangeId, range, nextRangeId, propertyPaths } of updates ?? []) {
if (propertyPaths !== undefined) {
validatePropertyPaths(propertyPaths);
if (!range) {
throw new Error('Invalid custom range property paths.');
}
}
if (typeof rangeId !== 'string' || !rangeId ||
(nextRangeId !== undefined && nextRangeId !== null && typeof nextRangeId !== 'string') ||
(range !== null && (range == null || typeof range !== 'object' || Array.isArray(range) ||
range.rangeId !== rangeId || !Number.isInteger(range.startIndex) || !Number.isInteger(range.endIndex) ||
range.startIndex < 0 || range.endIndex < range.startIndex || range.endIndex >= resultLength))) {
throw new Error('Invalid custom range identity or bounds.');
}
}
}
export function applyRangeUpdates(doc: IDocumentBody, updates: IRetainAction['rangeUpdates']): void {
if (!updates?.length) {
return;
}
const ranges = doc.customRanges ?? [];
for (const { rangeId, range, nextRangeId, propertyPaths } of [...updates].reverse()) {
const index = ranges.findIndex((item) => item.rangeId === rangeId);
if (range && propertyPaths) {
if (index >= 0) {
ranges[index] = patchRangeProperties(ranges[index], range, propertyPaths);
}
continue;
}
if (range) {
if (nextRangeId !== undefined) {
if (index >= 0) {
ranges.splice(index, 1);
}
const nextIndex = ranges.findIndex((item) => item.rangeId === nextRangeId);
ranges.splice(nextIndex < 0 ? ranges.length : nextIndex, 0, Tools.deepClone(range));
} else if (index >= 0) {
ranges[index] = Tools.deepClone(range);
} else {
ranges.push(Tools.deepClone(range));
}
} else if (index >= 0) {
ranges.splice(index, 1);
}
}
doc.customRanges = ranges;
}
export function moveRangeUpdates(
updates: NonNullable<IRetainAction['rangeUpdates']>,
actions: TextXAction[],
preserveProperties = false
): NonNullable<IRetainAction['rangeUpdates']> {
return updates.map((update) => {
const { rangeId, range } = update;
if (!range && preserveProperties) {
return update;
}
const body: IDocumentBody = { dataStream: '', customRanges: range ? [Tools.deepClone(range)] : [] };
let offset = 0;
for (const action of actions) {
if (action.t === TextXActionType.INSERT) {
insertCustomRanges(body, {
dataStream: '',
customRanges: Tools.deepClone(action.body.customRanges?.filter((item) => item.rangeId === rangeId)),
}, action.len, offset);
} else if (action.t === TextXActionType.DELETE) {
deleteCustomRanges(body, action.len, offset);
continue;
} else if (action.body?.customRanges) {
updateAttribute(body, {
dataStream: '',
customRanges: Tools.deepClone(action.body.customRanges.filter((item) => item.rangeId === rangeId)),
}, action.len, offset, action.coverType ?? UpdateDocsAttributeType.COVER);
}
offset += action.len;
}
const moved = body.customRanges?.find((item) => item.rangeId === rangeId);
return { ...update, propertyPaths: moved ? update.propertyPaths : undefined, range: moved && range && preserveProperties
? { ...Tools.deepClone(range), startIndex: moved.startIndex, endIndex: moved.endIndex }
: moved ?? null };
});
}
/** A concurrent metadata payload must not resurrect an explicitly removed identity. */
export function reconcileRangeUpdates(actions: TextXAction[], updates: IRetainAction['rangeUpdates']): TextXAction[] {
const byId = new Map(updates?.map((update) => [update.rangeId, update]));
if (!byId.size) {
return actions;
}
return actions.map((action) => {
if (!action.body?.customRanges) {
return action;
}
return { ...action, body: { ...action.body, customRanges: action.body.customRanges.flatMap((range) => {
if (!byId.has(range.rangeId)) {
return [range];
}
const updated = byId.get(range.rangeId)!;
if (!updated.range) {
return [];
}
const replacement = updated.propertyPaths
? patchRangeProperties(range, updated.range, updated.propertyPaths)
: Tools.deepClone(updated.range);
return [{ ...replacement, startIndex: range.startIndex, endIndex: range.endIndex }];
}) } };
});
}
/** Dropping a concurrent restoration also drops its delimiters, not its text. */
export function dropRemovedRangeAnchors(
actions: TextXAction[],
suppressed: ICustomRange[],
updates: NonNullable<IRetainAction['rangeUpdates']>
): TextXAction[] {
if (!suppressed.length) {
return appendRangeUpdates(actions, updates);
}
const kept: TextXAction[] = [];
const removed: TextXAction[] = [];
let offset = 0;
let deletedEnd = 0;
for (const action of actions) {
const token = action.body?.dataStream;
const isSuppressedAnchor = action.t === TextXActionType.INSERT && action.len === 1 &&
suppressed.some((range) =>
(token === DataStreamTreeTokenType.CUSTOM_RANGE_START && offset === range.startIndex) ||
(token === DataStreamTreeTokenType.CUSTOM_RANGE_END && offset === range.endIndex));
if (isSuppressedAnchor) {
if (offset > deletedEnd) {
removed.push({ t: TextXActionType.RETAIN, len: offset - deletedEnd });
}
removed.push({ t: TextXActionType.DELETE, len: 1 });
deletedEnd = offset + 1;
} else {
kept.push(action);
}
if (action.t !== TextXActionType.DELETE) {
offset += action.len;
}
}
return appendRangeUpdates(kept, moveRangeUpdates(updates, removed));
}
@@ -14,13 +14,12 @@
* limitations under the License.
*/
import type { ICustomTable, IDocumentBody, IDocumentData, IDocumentNote } from '../../../types/interfaces/i-document-data';
import type { ICustomRange, ICustomTable, IDocumentBody, IDocumentData, IDocumentNote } from '../../../types/interfaces/i-document-data';
import { CustomRangeType, PositionedObjectLayoutType, TableTextWrapType } from '../../../types/interfaces/i-document-data';
import { DataStreamTreeTokenType } from '../types';
import {
getBlockRangeInterval,
getColumnGroupRangeInterval,
getTableRangeInterval,
intersectsOperationalIntervals,
} from './build-utils/range-interval';
@@ -54,6 +53,11 @@ export type DocStructureIssueCode =
| 'custom-block-token-mismatch'
| 'missing-custom-block-metadata'
| 'duplicate-custom-block-metadata'
| 'custom-range-token-mismatch'
| 'missing-custom-range-metadata'
| 'duplicate-custom-range-id'
| 'crossing-custom-range'
| 'unbalanced-custom-range'
| 'empty-column'
| 'empty-table-cell'
| 'unbalanced-column-group'
@@ -278,7 +282,7 @@ function scanDocumentStructuralTokens(dataStream: string): IDocumentStructuralTo
function validateTableMetadata(body: IDocumentBody, scan: IDocumentStructuralTokenScan, issues: IDocStructureIssue[], context: IValidationContext) {
const pairedTables = scan.tableRanges.pairs;
const metadataStarts = new Set<number>();
let previousTable: ICustomTable | undefined;
const parents: ICustomTable[] = [];
const tables = sortStructuralRanges(body.tables ?? []);
for (const table of tables) {
@@ -307,10 +311,14 @@ function validateTableMetadata(body: IDocumentBody, scan: IDocumentStructuralTok
));
}
if (previousTable && intersectsOperationalIntervals(getTableRangeInterval(previousTable), getTableRangeInterval(table))) {
issues.push(createIssue(context, 'overlapping-table', 'Table ranges must not overlap.', table.startIndex));
while (parents.length > 0 && parents[parents.length - 1].endIndex <= table.startIndex) {
parents.pop();
}
previousTable = table;
const parent = parents[parents.length - 1];
if (parent && (table.startIndex === parent.startIndex || table.endIndex >= parent.endIndex)) {
issues.push(createIssue(context, 'overlapping-table', 'Table ranges may nest but must not cross or duplicate.', table.startIndex));
}
parents.push(table);
}
for (const startIndex of pairedTables.keys()) {
@@ -452,16 +460,18 @@ function validateCustomBlockMetadata(body: IDocumentBody, scan: IDocumentStructu
function validateStructuralContainers(body: IDocumentBody, issues: IDocStructureIssue[], context: IValidationContext) {
const columnGroupStack: number[] = [];
const columnStack: Array<{ startIndex: number; hasChild: boolean }> = [];
const tableStack: number[] = [];
const tableRowStack: number[] = [];
const tableCellStack: Array<{ startIndex: number; hasParagraph: boolean; hasSectionBreak: boolean }> = [];
const tableStack: Array<{
inRow: boolean;
cell?: { startIndex: number; hasParagraph: boolean; hasSectionBreak: boolean };
}> = [];
const tokenPattern = new RegExp(DOCUMENT_STRUCTURAL_TOKEN_PATTERN_SOURCE, 'g');
for (const match of body.dataStream.matchAll(tokenPattern)) {
const i = match.index;
const char = match[0];
const column = columnStack[columnStack.length - 1];
const cell = tableCellStack[tableCellStack.length - 1];
const table = tableStack[tableStack.length - 1];
const cell = table?.cell;
if (char === DataStreamTreeTokenType.PARAGRAPH) {
if (column) {
@@ -498,33 +508,45 @@ function validateStructuralContainers(body: IDocumentBody, issues: IDocStructure
columnGroupStack.pop();
}
} else if (char === DataStreamTreeTokenType.TABLE_START) {
tableStack.push(i);
if (table && !cell) {
issues.push(createIssue(context, 'unbalanced-table', 'Nested table must be inside a parent cell.', i));
}
tableStack.push({ inRow: false });
} else if (char === DataStreamTreeTokenType.TABLE_ROW_START) {
tableRowStack.push(i);
if (!table || table.inRow) {
issues.push(createIssue(context, 'unbalanced-table', 'Table row must start inside a table without an open row.', i));
}
if (table) {
table.inRow = true;
}
} else if (char === DataStreamTreeTokenType.TABLE_CELL_START) {
tableCellStack.push({ startIndex: i, hasParagraph: false, hasSectionBreak: false });
if (!table?.inRow || cell) {
issues.push(createIssue(context, 'unbalanced-table', 'Table cell must start inside a row without an open cell.', i));
}
if (table) {
table.cell = { startIndex: i, hasParagraph: false, hasSectionBreak: false };
}
} else if (char === DataStreamTreeTokenType.TABLE_CELL_END) {
const closedCell = tableCellStack.pop();
if (!closedCell) {
if (!cell) {
issues.push(createIssue(context, 'unbalanced-table', 'Table cell end token has no matching start.', i));
} else if (!closedCell.hasParagraph || !closedCell.hasSectionBreak) {
issues.push(createIssue(context, 'empty-table-cell', 'Table cell must contain a paragraph and section break child.', closedCell.startIndex));
} else if (!cell.hasParagraph || !cell.hasSectionBreak) {
issues.push(createIssue(context, 'empty-table-cell', 'Table cell must contain a paragraph and section break child.', cell.startIndex));
}
if (table) {
table.cell = undefined;
}
} else if (char === DataStreamTreeTokenType.TABLE_ROW_END) {
if (tableCellStack.length > 0 || tableRowStack.length === 0) {
if (!table?.inRow || cell) {
issues.push(createIssue(context, 'unbalanced-table', 'Table row closes while a cell is still open.', i));
tableCellStack.length = 0;
} else {
tableRowStack.pop();
}
if (table) {
table.inRow = false;
}
} else if (char === DataStreamTreeTokenType.TABLE_END) {
if (tableCellStack.length > 0 || tableRowStack.length > 0 || tableStack.length === 0) {
if (!table || table.inRow || cell) {
issues.push(createIssue(context, 'unbalanced-table', 'Table closes while a row or cell is still open.', i));
tableCellStack.length = 0;
tableRowStack.length = 0;
} else {
tableStack.pop();
}
tableStack.pop();
}
}
@@ -532,11 +554,79 @@ function validateStructuralContainers(body: IDocumentBody, issues: IDocStructure
issues.push(createIssue(context, 'unbalanced-column-group', 'Column group or column token is not closed.', body.dataStream.length));
}
if (tableStack.length > 0 || tableRowStack.length > 0 || tableCellStack.length > 0) {
if (tableStack.length > 0) {
issues.push(createIssue(context, 'unbalanced-table', 'Table, row, or cell token is not closed.', body.dataStream.length));
}
}
function validateCustomRangeMetadata(body: IDocumentBody, issues: IDocStructureIssue[], context: IValidationContext) {
const pairs = new Map<number, number>();
const starts: number[] = [];
for (let index = 0; index < body.dataStream.length; index++) {
const token = body.dataStream[index];
if (token === DataStreamTreeTokenType.CUSTOM_RANGE_START) {
starts.push(index);
} else if (token === DataStreamTreeTokenType.CUSTOM_RANGE_END) {
const start = starts.pop();
if (start == null) {
issues.push(createIssue(context, 'unbalanced-custom-range', 'Custom range end token has no matching start.', index));
} else {
pairs.set(start, index);
}
}
}
for (const start of starts) {
issues.push(createIssue(context, 'unbalanced-custom-range', 'Custom range start token has no matching end.', start));
}
const ranges = body.customRanges ?? [];
const ids = new Set<string>();
for (const range of ranges) {
if (ids.has(range.rangeId)) {
issues.push(createIssue(context, 'duplicate-custom-range-id', 'Custom range ids must be unique.', range.startIndex));
}
ids.add(range.rangeId);
if (range.rangeType !== CustomRangeType.FIELD) {
continue;
}
if (
body.dataStream[range.startIndex] !== DataStreamTreeTokenType.CUSTOM_RANGE_START ||
body.dataStream[range.endIndex] !== DataStreamTreeTokenType.CUSTOM_RANGE_END ||
pairs.get(range.startIndex) !== range.endIndex
) {
issues.push(createIssue(
context,
'custom-range-token-mismatch',
'Field metadata must point to a matching custom range token pair.',
range.startIndex
));
}
}
for (const [start, end] of pairs) {
if (!ranges.some((range) => range.startIndex === start && range.endIndex === end)) {
issues.push(createIssue(context, 'missing-custom-range-metadata', 'Custom range token pair has no metadata.', start));
}
}
const sortedFields = ranges
.filter((range): range is ICustomRange => range.rangeType === CustomRangeType.FIELD)
.slice()
.sort((left, right) => left.startIndex - right.startIndex || right.endIndex - left.endIndex);
const parents: ICustomRange[] = [];
for (const range of sortedFields) {
while (parents.length > 0 && parents[parents.length - 1].endIndex < range.startIndex) {
parents.pop();
}
const parent = parents[parents.length - 1];
if (parent && range.endIndex > parent.endIndex) {
issues.push(createIssue(context, 'crossing-custom-range', 'Field ranges may nest but must not cross.', range.startIndex));
}
parents.push(range);
}
}
export function validateDocBodyStructure(
body: IDocumentBody,
context: IValidationContext = { segmentType: 'body' }
@@ -552,6 +642,7 @@ export function validateDocBodyStructure(
validateColumnGroupMetadata(body, scan, issues, context);
validateCustomBlockMetadata(body, scan, issues, context);
validateStructuralContainers(body, issues, context);
validateCustomRangeMetadata(body, issues, context);
if (context.segmentType === 'header' || context.segmentType === 'footer') {
for (const range of body.customRanges ?? []) {
@@ -15,10 +15,11 @@
*/
import type { ITextRange } from '../../../sheets/typedef';
import type { IDocumentBody } from '../../../types/interfaces/i-document-data';
import type { ICustomRange, IDocumentBody } from '../../../types/interfaces/i-document-data';
import type { IDeleteAction, IInsertAction, IRetainAction, TextXAction } from './action-types';
import { UpdateDocsAttributeType } from '../../../shared/command-enum';
import { Tools } from '../../../shared/tools';
import { DataStreamTreeTokenType } from '../types';
import { ActionIterator } from './action-iterator';
import { PRESERVE_INSERTED_PARAGRAPH_IDS, TextXActionType } from './action-types';
import { textXApply } from './apply';
@@ -27,6 +28,17 @@ import {
normalizeInsertedSectionIdsForDocument,
RESTORE_INSERTED_PARAGRAPH_IDS,
} from './apply-utils/common';
import {
appendRangeUpdates,
composeRangeUpdate,
dropRemovedRangeAnchors,
getChangedPropertyPaths,
getRangeUpdates,
moveRangeUpdates,
reconcileRangeUpdates,
transformRangeUpdate,
withoutRangeUpdates,
} from './custom-range-update';
import { transformBody } from './transform-utils';
import { composeBody, getBodySlice, getBodySliceForTextXAction, isUselessRetainAction } from './utils';
@@ -34,6 +46,43 @@ function onlyHasDataStream(body: IDocumentBody) {
return Object.keys(body).length === 1;
}
function getValueRangeIds(actions: TextXAction[]): Set<string> {
return new Set(actions.flatMap((action) => action.t !== TextXActionType.RETAIN && action.valueRangeId ? [action.valueRangeId] : []));
}
function withoutValueRangeMetadata(actions: TextXAction[], suppressed: Set<string>): TextXAction[] {
return actions.map((action) => {
if (!action.body?.customRanges?.some((range) => suppressed.has(range.rangeId))) {
return action;
}
const body: IDocumentBody = { ...action.body, customRanges: action.body.customRanges.filter((range) => !suppressed.has(range.rangeId)) };
if (!body.customRanges?.length) {
delete body.customRanges;
}
return { ...action, body };
});
}
function insertedAnchorPriority(first: TextXAction, second: TextXAction, priority: TPriority): boolean {
if (first?.t !== TextXActionType.INSERT || second?.t !== TextXActionType.INSERT) {
return priority === 'left';
}
const firstText = first.body.dataStream;
const secondText = second.body.dataStream;
if (firstText === secondText) {
return priority === 'left';
}
// Restored delimiters enclose text typed at the collapsed boundary. Neither
// side of a removed delimiter remains distinguishable as a text position.
if (firstText === DataStreamTreeTokenType.CUSTOM_RANGE_START || secondText === DataStreamTreeTokenType.CUSTOM_RANGE_END) {
return true;
}
if (firstText === DataStreamTreeTokenType.CUSTOM_RANGE_END || secondText === DataStreamTreeTokenType.CUSTOM_RANGE_START) {
return false;
}
return priority === 'left';
}
function normalizeInsertActionParagraphIds(
body: IDocumentBody,
doc: IDocumentBody,
@@ -63,6 +112,17 @@ export class TextX {
// eslint-disable-next-line complexity
static compose(thisActions: TextXAction[], otherActions: TextXAction[]): TextXAction[] {
const thisUpdates = getRangeUpdates(thisActions);
const otherUpdates = getRangeUpdates(otherActions);
if (thisUpdates.length || otherUpdates.length) {
const thisText = withoutRangeUpdates(thisActions);
const otherText = withoutRangeUpdates(otherActions);
const updates = new Map(moveRangeUpdates(thisUpdates, otherText).map((update) => [update.rangeId, update]));
for (const update of otherUpdates) {
updates.set(update.rangeId, composeRangeUpdate(updates.get(update.rangeId), update));
}
return appendRangeUpdates(TextX.compose(thisText, otherText), Array.from(updates.values()));
}
const thisIter = new ActionIterator(thisActions);
const otherIter = new ActionIterator(otherActions);
@@ -148,26 +208,56 @@ export class TextX {
// priority - if true, this actions takes priority over other, that is, this actions are considered to happen "first".
// thisActions is the target action.
static transform(thisActions: TextXAction[], otherActions: TextXAction[], priority: TPriority = 'right'): TextXAction[] {
const thisUpdates = getRangeUpdates(thisActions);
const otherUpdates = getRangeUpdates(otherActions);
if (thisUpdates.length || otherUpdates.length) {
const thisText = withoutRangeUpdates(thisActions);
const otherText = withoutRangeUpdates(otherActions);
const otherAfterThis = TextX._transform(thisText, otherText, priority, true);
const movedUpdates = moveRangeUpdates(thisUpdates, otherAfterThis, true);
const updates = movedUpdates.flatMap((update) => transformRangeUpdate(
update,
otherUpdates.find((item) => item.rangeId === update.rangeId),
priority
));
const suppressed = movedUpdates.flatMap((update) => update.range &&
otherUpdates.some((other) => other.rangeId === update.rangeId && !other.range)
? [update.range]
: []);
return dropRemovedRangeAnchors(
reconcileRangeUpdates(TextX._transform(otherText, thisText, priority === 'left' ? 'right' : 'left', true), otherUpdates),
suppressed,
updates
);
}
return this._transform(otherActions, thisActions, priority === 'left' ? 'right' : 'left');
}
// otherActions is the actions to be transformed.
static _transform(thisActions: TextXAction[], otherActions: TextXAction[], priority: TPriority = 'right'): TextXAction[] {
const thisIter = new ActionIterator(thisActions);
const otherIter = new ActionIterator(otherActions);
static _transform(thisActions: TextXAction[], otherActions: TextXAction[], priority: TPriority = 'right', rangeAnchorAffinity = false): TextXAction[] {
const thisValues = getValueRangeIds(thisActions);
const conflicts = new Set(Array.from(getValueRangeIds(otherActions)).filter((id) => thisValues.has(id)));
const thisIter = new ActionIterator(conflicts.size && priority === 'right' ? withoutValueRangeMetadata(thisActions, conflicts) : thisActions);
const otherIter = new ActionIterator(conflicts.size && priority === 'left' ? withoutValueRangeMetadata(otherActions, conflicts) : otherActions);
const textX = new TextX();
while (thisIter.hasNext() || otherIter.hasNext()) {
if (
thisIter.peekType() === TextXActionType.INSERT &&
(priority === 'left' || otherIter.peekType() !== TextXActionType.INSERT)
((rangeAnchorAffinity ? insertedAnchorPriority(thisIter.peek(), otherIter.peek(), priority) : priority === 'left') || otherIter.peekType() !== TextXActionType.INSERT)
) {
const thisAction = thisIter.next();
textX.retain(thisAction.len);
if (thisAction.t === TextXActionType.INSERT && thisAction.valueRangeId && conflicts.has(thisAction.valueRangeId) && priority === 'right') {
textX.push({ t: TextXActionType.DELETE, len: thisAction.len, valueRangeId: thisAction.valueRangeId });
} else {
textX.retain(thisAction.len);
}
} else if (otherIter.peekType() === TextXActionType.INSERT) {
textX.push(otherIter.next());
const otherAction = otherIter.next();
if (otherAction.t !== TextXActionType.INSERT || !otherAction.valueRangeId || !conflicts.has(otherAction.valueRangeId) || priority !== 'left') {
textX.push(otherAction);
}
} else {
const length = Math.min(thisIter.peekLength(), otherIter.peekLength());
const thisAction = thisIter.next(length);
@@ -235,6 +325,18 @@ export class TextX {
}
static invert(actions: TextXAction[]): TextXAction[] {
if (getRangeUpdates(actions).length) {
const restored = actions.flatMap((action) => {
if (action.t !== TextXActionType.RETAIN || !action.rangeUpdates) {
return [];
}
if (!action.oldRangeUpdates) {
throw new Error('makeInvertible must capture custom ranges before inversion.');
}
return action.oldRangeUpdates;
});
return appendRangeUpdates(TextX.invert(withoutRangeUpdates(actions)), Tools.deepClone(restored));
}
const invertedActions: TextXAction[] = [];
for (const action of actions) {
@@ -243,6 +345,7 @@ export class TextX {
t: TextXActionType.DELETE,
len: action.len,
body: action.body,
valueRangeId: action.valueRangeId,
});
} else if (action.t === TextXActionType.DELETE) {
if (action.body == null) {
@@ -258,6 +361,7 @@ export class TextX {
t: TextXActionType.INSERT,
body: action.body,
len: action.len,
valueRangeId: action.valueRangeId,
});
} else {
if (action.body != null) {
@@ -289,6 +393,17 @@ export class TextX {
const reservedSectionIds = new Set<string>();
for (const action of actions) {
if (action.t === TextXActionType.RETAIN && action.rangeUpdates) {
action.oldRangeUpdates = action.rangeUpdates.map(({ rangeId, propertyPaths }) => {
const index = doc.customRanges?.findIndex((range) => range.rangeId === rangeId) ?? -1;
return {
rangeId,
range: index < 0 ? null : Tools.deepClone(doc.customRanges![index]),
nextRangeId: index < 0 ? undefined : doc.customRanges![index + 1]?.rangeId ?? null,
propertyPaths: index < 0 ? undefined : propertyPaths,
};
});
}
if (action.t === TextXActionType.INSERT) {
normalizeInsertActionParagraphIds(action.body, doc, index, reservedParagraphIds);
normalizeInsertedSectionIdsForDocument(doc.sectionBreaks, action.body.sectionBreaks, reservedSectionIds);
@@ -331,6 +446,21 @@ export class TextX {
private _actions: TextXAction[] = [];
/** Edits only changed property leaves; does not replace range identity, bounds or peer properties. */
updateCustomRangeProperties(range: ICustomRange, properties: NonNullable<ICustomRange['properties']>): this {
const propertyPaths = getChangedPropertyPaths(range.properties ?? {}, properties);
if (propertyPaths.length) {
this.updateCustomRanges([{ rangeId: range.rangeId, range: { ...range, properties }, propertyPaths }]);
}
return this;
}
/** Updates identities after all text edits; range positions refer to the resulting document. */
updateCustomRanges(updates: NonNullable<IRetainAction['rangeUpdates']>): this {
this.push({ t: TextXActionType.RETAIN, len: 0, rangeUpdates: updates });
return this;
}
insert(len: number, body: IDocumentBody): this {
const insertAction: IInsertAction = {
t: TextXActionType.INSERT,
@@ -397,13 +527,13 @@ export class TextX {
const newAction = Tools.deepClone(args[0]);
// Nothing need to do, if the retain 0 and body is null.
if (newAction.t === TextXActionType.RETAIN && newAction.len === 0 && newAction.body == null) {
if (newAction.t === TextXActionType.RETAIN && newAction.len === 0 && newAction.body == null && !newAction.rangeUpdates) {
return this;
}
if (typeof lastAction === 'object') {
// if lastAction and newAction are both delete action, merge the two actions and return this.
if (lastAction.t === TextXActionType.DELETE && newAction.t === TextXActionType.DELETE) {
if (lastAction.t === TextXActionType.DELETE && newAction.t === TextXActionType.DELETE && lastAction.valueRangeId === newAction.valueRangeId) {
lastAction.len += newAction.len;
return this;
@@ -423,14 +553,14 @@ export class TextX {
}
// if lastAction and newAction are both retain action and has no body, merge the two actions and return this.
if (lastAction.t === TextXActionType.RETAIN && newAction.t === TextXActionType.RETAIN && lastAction.body == null && newAction.body == null) {
if (lastAction.t === TextXActionType.RETAIN && newAction.t === TextXActionType.RETAIN && lastAction.body == null && newAction.body == null && !lastAction.rangeUpdates && !newAction.rangeUpdates) {
lastAction.len += newAction.len;
return this;
}
// Both are insert action, and has no styles, merge it.
if (lastAction.t === TextXActionType.INSERT && onlyHasDataStream(lastAction.body) && newAction.t === TextXActionType.INSERT && onlyHasDataStream(newAction.body)) {
if (lastAction.t === TextXActionType.INSERT && onlyHasDataStream(lastAction.body) && newAction.t === TextXActionType.INSERT && onlyHasDataStream(newAction.body) && lastAction.valueRangeId === newAction.valueRangeId) {
lastAction.len += newAction.len;
lastAction.body.dataStream += newAction.body.dataStream;
@@ -32,6 +32,7 @@ import type { IRetainAction } from './action-types';
import { merge } from '../../../common/lodash';
import { UpdateDocsAttributeType } from '../../../shared/command-enum';
import { Tools } from '../../../shared/tools';
import { CustomRangeType } from '../../../types/interfaces/i-document-data';
import { DataStreamTreeTokenType } from '../types';
import { PRESERVE_INSERTED_PARAGRAPH_IDS } from './action-types';
import { normalizeTextRuns } from './apply-utils/common';
@@ -350,6 +351,12 @@ export function getBodySlice(
dataStream: dataStream.slice(startOffset, endOffset),
};
if (body.renderedPageBreaks != null) {
docBody.renderedPageBreaks = body.renderedPageBreaks
.filter((offset) => offset >= startOffset && offset < endOffset)
.map((offset) => offset - startOffset);
}
if ((body as IDocumentBody & Record<string, unknown>)[PRESERVE_INSERTED_PARAGRAPH_IDS]) {
(docBody as IDocumentBody & Record<string, unknown>)[PRESERVE_INSERTED_PARAGRAPH_IDS] = true;
}
@@ -389,7 +396,15 @@ export function getBodySlice(
}
const { customRanges } = getCustomRangeSlice(body, startOffset, endOffset);
if (customRanges) {
docBody.customRanges = customRanges;
docBody.customRanges = type === SliceBodyType.copy
? customRanges.filter((range) =>
range.rangeType !== CustomRangeType.FIELD ||
(
docBody.dataStream[range.startIndex] === DataStreamTreeTokenType.CUSTOM_RANGE_START &&
docBody.dataStream[range.endIndex] === DataStreamTreeTokenType.CUSTOM_RANGE_END
)
)
: customRanges;
} else if (returnEmptyArray) {
docBody.customRanges = [];
}
@@ -429,6 +444,9 @@ export function getBodySliceForSplitTextXAction(
}
function shiftBodyMetadata(body: IDocumentBody, leftOffset: number, rightOffset: number): void {
if (body.renderedPageBreaks != null) {
body.renderedPageBreaks = body.renderedPageBreaks.map((offset) => offset + leftOffset);
}
body.textRuns?.forEach((textRun) => {
textRun.st += leftOffset;
textRun.ed += leftOffset;
@@ -577,20 +595,27 @@ function composeCustomRanges(
return updateDataCustomRanges;
}
if (originCustomRanges.length > 1 || updateDataCustomRanges.length > 1) {
throw new Error('Cannot cover multiple customRanges');
if (coverType === UpdateDocsAttributeType.REPLACE) {
return updateDataCustomRanges.map((range) => ({ ...range }));
}
if (coverType === UpdateDocsAttributeType.REPLACE) {
return [{
...updateDataCustomRanges[0],
}];
} else {
return [{
...originCustomRanges[0],
...updateDataCustomRanges[0],
}];
}
const updates = new Map(updateDataCustomRanges.map((range) => [range.rangeId, range]));
// Hyperlinks are exclusive: a replacement can have a new id at the same
// boundaries. Nested FIELD/SDT entities still compose by their own ids.
const composed = originCustomRanges.filter((range) => !updateDataCustomRanges.some((update) =>
range.rangeType === CustomRangeType.HYPERLINK &&
update.rangeType === CustomRangeType.HYPERLINK &&
update.rangeId !== range.rangeId &&
update.startIndex === range.startIndex && update.endIndex === range.endIndex
)).map((range) => {
const update = updates.get(range.rangeId);
if (update == null) {
return range;
}
updates.delete(range.rangeId);
return { ...range, ...update };
});
return [...composed, ...updates.values()];
}
function composeCustomDecorations(
@@ -629,6 +654,11 @@ export function composeBody(
dataStream: thisBody.dataStream,
};
// Soft-break provenance belongs to the inserted tokens, not to text formatting.
if (thisBody.renderedPageBreaks != null) {
retBody.renderedPageBreaks = [...thisBody.renderedPageBreaks];
}
if (
(thisBody as IDocumentBody & Record<string, unknown>)[PRESERVE_INSERTED_PARAGRAPH_IDS] ||
(otherBody as IDocumentBody & Record<string, unknown>)[PRESERVE_INSERTED_PARAGRAPH_IDS]
@@ -749,6 +779,9 @@ function composeColumnGroups(
}
export function isUselessRetainAction(action: IRetainAction): boolean {
if (action.rangeUpdates?.length) {
return false;
}
const { body } = action;
if (body == null) {
+44
View File
@@ -0,0 +1,44 @@
/**
* 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, ISdtCustomRange } from '../types/interfaces/i-document-data';
export function getSdtBindingKey(range: ISdtCustomRange): string | undefined {
const binding = range.properties?.dataBinding;
if (!binding?.storeItemID || !binding.xpath) {
return;
}
return JSON.stringify([binding.storeItemID.toLowerCase(), binding.xpath, binding.prefixMappings ?? '']);
}
/** Scalar XML values, independent of the control's display label or date format. */
export function getSdtBindingValue(body: IDocumentBody, range: ISdtCustomRange): string | undefined {
if (range.properties.showingPlaceholder) {
return '';
}
if (range.properties.kind === 'checkbox') {
return range.properties.checkbox?.checked ? 'true' : 'false';
}
const text = body.dataStream.slice(range.startIndex, range.endIndex + 1)
.replace(/^[\x00-\x08\x0A-\x1F]+|[\x00-\x08\x0A-\x1F]+$/g, '');
if (range.properties.kind === 'date') {
return range.properties.date?.fullDate ?? text;
}
if (range.properties.kind === 'dropDownList' || range.properties.kind === 'comboBox') {
return range.properties.listItems?.find((item) => item.displayText === text)?.value ?? text;
}
return ['text', 'richText'].includes(range.properties.kind) ? text : undefined;
}
+1
View File
@@ -61,6 +61,7 @@ export { mergeSets } from './common/set';
export { UnitModel, UniverInstanceType } from './common/unit';
export { isSafeUrl, normalizeUrl, resolveWithBasePath } from './common/url';
export * from './docs';
export { getSdtBindingKey, getSdtBindingValue } from './docs/sdt-binding';
export { EventState, EventSubject, fromEventSubject } from './observer/observable';
export type { IEventObserver } from './observer/observable';
export { AuthzIoLocalService } from './services/authz-io/authz-io-local.service';
@@ -314,6 +314,7 @@ describe('LocalUndoRedoService', () => {
it('should resolve focused unit id from sheet editor contexts and clear unit stacks', () => {
contextService.setContextValue(FOCUSING_SHEET, true);
contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, true);
contextService.setContextValue(EDITOR_ACTIVATED, true);
undoRedoService.pushUndoRedo({
unitID: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
undoMutations: [{ id: MUTATION_ID, params: { label: 'fx-undo' } }],
@@ -322,6 +323,17 @@ describe('LocalUndoRedoService', () => {
});
expect(undoRedoService.pitchTopUndoElement()?.id).toBe('fx');
undoRedoService.pushUndoRedo({
unitID: 'unit-1',
undoMutations: [{ id: MUTATION_ID, params: { label: 'sheet-undo' } }],
redoMutations: [{ id: MUTATION_ID, params: { label: 'sheet-redo' } }],
});
// Committing a formula ends editing even if its focus context outlives the editor.
contextService.setContextValue(EDITOR_ACTIVATED, false);
expect(commandService.syncExecuteCommand(UndoCommandId)).toBe(true);
expect(commandService.syncExecuteCommand(RedoCommandId)).toBe(true);
expect(mutationLog).toEqual(['sheet-undo', 'sheet-redo']);
contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, false);
contextService.setContextValue(EDITOR_ACTIVATED, true);
undoRedoService.pushUndoRedo({
@@ -424,7 +424,7 @@ export class LocalUndoRedoService extends Disposable implements IUndoRedoService
const isFocusEditor = this._contextService.getContextValue(EDITOR_ACTIVATED);
if (isFocusSheet) {
if (isFocusFormulaEditor) {
if (isFocusFormulaEditor && isFocusEditor) {
unitID = DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY;
} else if (isFocusEditor) {
unitID = DOCS_NORMAL_EDITOR_UNIT_ID_KEY;
@@ -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 { isSameStyleTextRun } from '../compare';
describe('isSameStyleTextRun', () => {
it.each(['directTextStyle', 'sourceTextStyle'])('preserves different imported %s when display styles match', (key) => {
const first = { st: 0, ed: 3, ts: { ff: 'Calibri' }, [key]: { ff: 'Calibri', eastAsiaLanguage: 'nl-NL' } };
const second = { st: 3, ed: 6, ts: { ff: 'Calibri' }, [key]: { ff: 'Calibri' } };
expect(isSameStyleTextRun(first, second)).toBe(false);
expect(isSameStyleTextRun(second, first)).toBe(false);
expect(isSameStyleTextRun(first, { ...first, st: 3, ed: 6 })).toBe(true);
});
});
+14 -2
View File
@@ -40,11 +40,23 @@ export function isSameStyleTextRun(tr1: ITextRun, tr2: ITextRun) {
const ts1 = tr1.ts || {};
const ts2 = tr2.ts || {};
if (tr1.sId !== tr2.sId) {
if (tr1.sId !== tr2.sId || !deepCompare(ts1, ts2)) {
return false;
}
return deepCompare(ts1, ts2);
// Importers retain formatting provenance alongside ts; merging different
// metadata would silently discard it even outside the edited range.
const metadataKeys = new Set([...Object.keys(tr1), ...Object.keys(tr2)]);
for (const key of metadataKeys) {
if (key === 'st' || key === 'ed' || key === 'ts' || key === 'sId') {
continue;
}
if (!deepCompare((tr1 as IAnyObject)[key], (tr2 as IAnyObject)[key])) {
return false;
}
}
return true;
}
export function checkForSubstrings(searchString: string, substrings: string[]): boolean {
@@ -50,6 +50,8 @@ export interface IReferenceSource {
tableSource?: ITables; // Table
footers?: IFooters;
headers?: IHeaders;
footnotes?: Record<string, IDocxNoteStory>;
endnotes?: Record<string, IDocxNoteStory>;
lists?: ILists;
drawings?: IDrawings;
drawingsOrder?: string[];
@@ -113,10 +115,19 @@ export interface IDocumentNote {
customMark?: string;
/** Style of the generated note marker; it does not affect text inserted at body offset zero. */
referenceTextStyle?: ITextStyle;
/** False preserves an imported note body without a reference marker; the main-story anchor is unaffected. */
showBodyReference?: boolean;
}
export type IFootnoteCustomRange = ICustomRange<{ noteId: string }>;
/** A note story keyed by its OOXML id, independently of its displayed number. */
export interface IDocxNoteStory {
noteId: string;
type: 'normal' | 'separator' | 'continuationSeparator' | 'continuationNotice';
body: IDocumentBody;
tableSource?: ITables;
}
/**
* Set of headers
*/
@@ -431,6 +442,8 @@ export interface IHyperlink {
url?: string;
bookmarkId?: string; // bookmarkId
headingId?: string; // headingId
/** Use authored text formatting instead of the default link color and underline. */
textStyleMode?: 'link' | 'text';
}
/**
@@ -469,7 +482,123 @@ export interface ICustomRange<T extends Record<string, any> = Record<string, any
properties?: T;
}
export type IHyperLinkCustomRange = ICustomRange<{ url: string }>;
export type IHyperLinkCustomRange = ICustomRange<IHyperlink>;
/** Legacy Word form field settings. Macro names are retained as data, never executed by the SDK. */
export interface IFormFieldData {
name?: string;
enabled?: BooleanNumber;
calcOnExit?: BooleanNumber;
entryMacro?: string;
exitMacro?: string;
helpText?: { type?: 'text' | 'autoText'; value?: string };
statusText?: { type?: 'text' | 'autoText'; value?: string };
tabIndex?: number;
textInput?: { type?: string; default?: string; maxLength?: number; format?: string };
checkBox?: { size?: number; sizeAuto?: BooleanNumber; default?: BooleanNumber; checked?: BooleanNumber };
ddList?: { default?: number; result?: number; entries?: string[] };
}
export interface IFieldRangeProperties {
/** Original OOXML field instruction. This is the round-trip source of truth. */
instruction: string;
/** Upper-case first token of `instruction`, used only for field dispatch. */
fieldType: string;
sourceKind?: 'complex' | 'simple';
dirty?: BooleanNumber;
locked?: BooleanNumber;
formData?: IFormFieldData;
cachedResult?: string;
}
/** A field range includes its CUSTOM_RANGE_START/END sentinels and may contain nested fields. */
export type IFieldCustomRange = ICustomRange<IFieldRangeProperties> & {
rangeType: CustomRangeType.FIELD;
wholeEntity?: false;
};
export type SdtKind =
| 'richText'
| 'text'
| 'checkbox'
| 'date'
| 'dropDownList'
| 'comboBox'
| 'picture'
| 'group'
| 'repeatingSection'
| 'repeatingSectionItem'
| 'docPartObj';
export type SdtLock = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked';
export type SdtPlacement = 'inline' | 'block' | 'table' | 'row' | 'cell' | 'header' | 'footer';
export interface ISdtListItem {
displayText?: string;
value: string;
}
export interface ISdtRangeProperties {
kind: SdtKind;
placement: SdtPlacement;
alias?: string;
tag?: string;
ooxmlId?: string;
lock?: SdtLock;
appearance?: string;
color?: string;
showingPlaceholder?: boolean;
temporary?: boolean;
placeholder?: string;
/** Imported OOXML placeholder reference and its original display text; not user-facing configuration. */
placeholderDocPart?: { name?: string; text?: string };
checkbox?: {
checked: boolean;
checkedState?: { value?: string; font?: string };
uncheckedState?: { value?: string; font?: string };
};
text?: {
/** Whether a plain-text content control accepts paragraph breaks. */
multiLine?: boolean;
};
date?: {
fullDate?: string;
format?: string;
locale?: string;
calendar?: string;
storage?: string;
};
listItems?: ISdtListItem[];
repeatingSection?: {
sectionTitle?: string;
doNotAllowInsertDelete?: boolean;
};
docPart?: {
gallery?: string;
category?: string;
unique?: boolean;
};
dataBinding?: {
prefixMappings?: string;
xpath?: string;
storeItemID?: string;
/** Visible result captured during import, used to detect direct text edits. */
cachedResult?: string;
/** Set after a content-control interaction so export updates the bound custom XML node. */
dirty?: boolean;
};
/** Original property XML retained for lossless OOXML round-trip. */
rawSdtPr?: string;
/** Original end-run properties retained for lossless OOXML round-trip. */
rawSdtEndPr?: string;
}
/** A Word structured document tag. SDTs may nest and span structural table tokens. */
export type ISdtCustomRange = ICustomRange<ISdtRangeProperties> & {
rangeType: CustomRangeType.SDT;
wholeEntity?: false;
properties: ISdtRangeProperties;
};
export type IMentionCustomRange = ICustomRange<IMention>;
@@ -517,6 +646,9 @@ export interface ICustomBlock {
blockType?: BlockType;
// A unique ID associated with a custom block.
blockId: string;
/** A note reference in the body, or the matching number/rule inside a note story. */
noteType?: 'footnote' | 'endnote' | 'separator' | 'continuationSeparator';
noteId?: string;
/** Original DOCX run XML retained by the exchange layer for lossless round-trip. */
docxRawXml?: string;
@@ -572,19 +704,39 @@ export enum DocumentFlavor {
UNSPECIFIED,
TRADITIONAL,
MODERN,
/** DrawingML text semantics for host-prepared shape and slide text models. */
DRAWINGML,
}
/**
* Basics properties of doc style
*/
/** Page border geometry is expressed in 96-DPI layout pixels. */
export interface IPageBorder {
/** Word line/art border style name; separate from table dash patterns. */
style?: string;
width?: number;
padding?: number;
color?: IColorStyle;
}
export interface IPageBorders {
offsetFrom?: 'page' | 'text';
display?: 'allPages' | 'firstPage' | 'notFirstPage';
zOrder?: 'front' | 'back';
top?: IPageBorder;
left?: IPageBorder;
bottom?: IPageBorder;
right?: IPageBorder;
}
/** Basics properties of doc style. */
export interface IDocStyleBase extends IMargin {
pageBorders?: IPageBorders;
pageNumberStart?: number; // pageNumberStart
/** Page size in 96-DPI layout pixels. */
pageSize?: ISize;
pageOrient?: PageOrientType;
documentFlavor?: DocumentFlavor; // DocumentFlavor: TRADITIONAL, MODERN
documentFlavor?: DocumentFlavor;
/** Distance from the page edge to the header, in 96-DPI layout pixels. */
marginHeader?: number;
@@ -602,6 +754,8 @@ export interface IDocumentLayout {
useFELayout?: BooleanNumber;
/** OOXML `splitPgBreakAndParaMark`: move a trailing paragraph mark after a manual page break. */
splitPageBreakAndParagraphMark?: BooleanNumber;
/** Preserve the OOXML half/full-width space compatibility switch. */
balanceSingleByteDoubleByteWidth?: BooleanNumber;
/** Align automatic line height inside tables to the active document line grid. */
adjustLineHeightInTable?: BooleanNumber;
paragraphLineGapDefault?: number; // paragraphLineGapDefault default line spacing
@@ -622,7 +776,13 @@ export enum GridType {
}
export interface IDocumentStyle extends IDocStyleBase, IDocumentLayout, IHeaderAndFooterBase {
/** Imported Word on/off compatibility flags, keyed by their OOXML local names. */
compatibilityFlags?: Record<string, BooleanNumber>;
/** Imported OOXML compatSetting values, including the Word compatibilityMode. */
compatibilitySettings?: Record<string, string>;
textStyle?: ITextStyle; // default style for text
/** Document-declared alternate families, tried only after the authored font is unavailable. */
fontFamilyFallbacks?: Record<string, string>;
defaultParagraphStyle?: IDocumentDefaultParagraphStyle; // default style inherited by paragraphs
background?: IDocumentBackground; // Page background image.
}
@@ -656,8 +816,9 @@ export interface IDocumentRenderConfig {
wrapStrategy?: WrapStrategy; // wrap to the next line, for sheet cell
cellValueType?: CellValueType; // sheet cell type, In a spreadsheet cell, without any alignment settings applied, text should be left-aligned, numbers should be right-aligned, and Boolean values should be center-aligned.
isRenderStyle?: BooleanNumber; // Whether to render the style(textRuns), used in formula bar editor. the default value is TRUE.
zeroWidthParagraphBreak?: BooleanNumber; // Whether to render the paragraph \r to zero width. the default value is false.
zeroWidthParagraphBreak?: BooleanNumber; // Override paragraph-mark width; DrawingML defaults to zero without custom glyph metrics.
shapeTextOpticalVerticalAlign?: BooleanNumber; // Align shape text by visible glyph bounds instead of the font line box.
lineWrapTolerance?: number; // Allowed line-width overflow in layout pixels; 0 enforces the content-box boundary.
}
export interface ISectionBreakBase {
@@ -735,6 +896,10 @@ export interface IParagraph {
styleId?: string;
paragraphStyle?: IParagraphStyle; // paragraphStyle
bullet?: IBullet; // bullet
/** Imported resolved numbering, used to distinguish later edits from unchanged inheritance. */
sourceBullet?: IBullet | null;
/** Imported direct numbering. Absent inherits from the style; null explicitly disables numbering. */
directBullet?: IBullet | null;
// dIds?: string[]; // drawingIds drawingId
}
@@ -837,6 +1002,10 @@ export interface IDocDrawingBase extends IDrawingParam {
distR?: number; // wrapSquare | wrapThrough | wrapTight
distT?: number; // wrapSquare | wrapTopAndBottom
distB?: number; // wrapSquare | wrapTopAndBottom
/** Effect bounds outside the drawing box, in layout pixels (OOXML wp:effectExtent). */
effectExtent?: { left?: number; top?: number; right?: number; bottom?: number };
/** Picture-local DrawingML size / layout size; retained on resize without changing the layout box. */
drawingMLSizeScale?: { width: number; height: number };
}
/**
@@ -951,10 +1120,28 @@ export interface IDocTextOutline {
}
export interface ITextStyle extends IStyleBase {
/** Retain text in the document without painting it or reserving layout space (OOXML vanish). Absent inherits. */
hidden?: boolean;
/** Word font-type hint for characters shared by Latin, East Asian and complex scripts. */
fontHint?: 'default' | 'eastAsia' | 'cs';
/** Alignment tab anchored to the page margins or paragraph indents. Applies only to a tab character. */
positionedTab?: { alignment: TabStopAlignment; relativeTo: 'margin' | 'indent'; leader?: TabStopLeader };
/** Original font-specific OOXML symbol; only applies to the matching character. */
symbol?: { font: string; code: number };
/** Authored text language (BCP 47), used by language-sensitive text layout. Absent inherits. */
lang?: string;
/** Alternate language from the authored text properties (BCP 47). Absent inherits. */
altLang?: string;
/** Minimum font size for pair kerning, in the same units as fs. Zero disables it; absent inherits. */
kerning?: number;
/** Display capital letters without changing the source text. Absent inherits. */
caps?: boolean;
/** Display lowercase letters as smaller capitals without changing source text. Absent inherits. */
smallCaps?: boolean;
// bo?: BaselineOffset; // BaselineOffset, sup, sub
sc?: number; // character spacing in points
pos?: number; // position
sa?: number; // scale
sa?: number; // Horizontal character scale in percent; omitted means 100.
textFill?: IDocTextFill;
textOutline?: IDocTextOutline;
/**
@@ -993,22 +1180,28 @@ export type ParagraphFontAlign = 'auto' | 'top' | 'center' | 'baseline' | 'botto
export interface IParagraphStyle extends IParagraphProperties {
// Not achieved, aligned with Excel's standards.
textStyle?: ITextStyle; // paragraph textStyle
/** Formatting of the paragraph terminator only, not inherited by body text. */
paragraphMarkTextStyle?: ITextStyle;
}
/**
* Paragraph properties that may be inherited from the document defaults.
* Paragraph identity and named/text styles have their own inheritance mechanisms.
*/
export type IDocumentDefaultParagraphStyle = Omit<IParagraphStyle, 'headingId' | 'namedStyleType' | 'textStyle'>;
export type IDocumentDefaultParagraphStyle = Omit<IParagraphStyle, 'headingId' | 'namedStyleType' | 'outlineLevel' | 'textStyle' | 'paragraphMarkTextStyle'>;
export interface IParagraphProperties extends IIndentStart {
headingId?: string; // headingId
namedStyleType?: NamedStyleType; // namedStyleType
/** Zero-based OOXML outline level used by TOC generation; 9 denotes body text. */
outlineLevel?: number;
defaultTabStop?: number; // Distance between automatic tab stops for this paragraph.
/** Whether East Asian kinsoku line-breaking rules apply to this paragraph. */
eastAsianLineBreak?: BooleanNumber;
/** Whether punctuation may hang outside the paragraph text bounds. */
hangingPunctuation?: BooleanNumber;
/** Whether full-width punctuation at a line start may be compressed (OOXML topLinePunct). */
topLinePunct?: BooleanNumber;
fontAlign?: ParagraphFontAlign;
horizontalAlign?: HorizontalAlign; // Horizontal alignment
lineSpacing?: number; // lineSpacing 17.3.1.33 spacing (Spacing Between Lines and Above/Below Paragraph)
@@ -1076,6 +1269,17 @@ export interface IParagraphProperties extends IIndentStart {
widowControl?: BooleanNumber;
shading?: IShading; // shading
suppressHyphenation?: BooleanNumber; // 17.3.1.34 suppressAutoHyphens (Suppress Hyphenation for Paragraph)
/** Positioned Word frame metadata; legacy snapshots may contain only a presence flag. */
paragraphFrame?: BooleanNumber | {
horizontalSpace?: number;
wrap?: string;
verticalAnchor?: string;
horizontalAnchor?: string;
x?: string;
y?: string;
xAlign?: string;
yAlign?: string;
};
}
/**
@@ -1151,6 +1355,7 @@ export interface ITabStop {
offset: number; // offset
alignment: TabStopAlignment; // alignment
leader?: TabStopLeader; // leader drawn between the preceding text and this tab stop
clear?: boolean; // removes an inherited stop at this position; not a layout stop
}
/**
@@ -1275,6 +1480,8 @@ export interface ITable {
tableColumns: ITableColumn[]; // tableColumns
align: TableAlignmentType; // 17.4.28 jc (Table Alignment)
indent: INumberUnit; // left align only. leftIndent
/** Imported pre-2013 Word text-to-frame indent correction for top-level tables. */
legacyIndentAdjustment?: INumberUnit;
textWrap: TableTextWrapType; // 17.4.57 tblpPr (Floating Table Positioning)
position: ITableAnchor; // 17.4.57 tblpPr (Floating Table Positioning)
dist: IDistFromText; // 17.4.57 tblpPr (Floating Table Positioning)
@@ -1314,6 +1521,8 @@ export interface ITableRowSize {
* Properties of row of table
*/
export interface ITableRow {
/** Authored row-level default margins (OOXML tblPrEx); imported cells contain resolved margins. */
cellMargin?: ITableCellMargin;
/**
* Rows do not persist stream offsets. Their ordinal position must match the
* corresponding `TABLE_ROW_START`/`TABLE_ROW_END` pair in `dataStream`.
@@ -1323,6 +1532,10 @@ export interface ITableRow {
gridBefore?: number;
/** Number of table-grid columns omitted after the last cell in this row. */
gridAfter?: number;
/** Preferred width of the omitted leading grid columns (OOXML wBefore). */
widthBefore?: IWidthInTableSize;
/** Preferred width of the omitted trailing grid columns (OOXML wAfter). */
widthAfter?: IWidthInTableSize;
// If omitted, then the table row shall automatically resize its height to the height required by its contents
// (the equivalent of an hRule value of auto)
trHeight: ITableRowSize; // 17.4.80 trHeight (Table Row Height)
@@ -1349,6 +1562,10 @@ export interface ITableCell {
borderBottom?: ITableCellBorder; // borderBottom
size?: IWidthInTableSize; // size
tcFitText?: BooleanNumber; // 17.4.67 tcFitText (Fit Text Within Cell)
/** Exclude the cell's final paragraph mark from content height (OOXML w:hideMark). */
tcHideMark?: BooleanNumber;
/** OOXML table-cell writing direction; independent of paragraph bidirectional ordering. */
textDirection?: 'lrTb' | 'tbRl' | 'btLr' | 'lrTbV' | 'tbRlV' | 'tbLrV';
// hAlign: use paragraph align to instead.
vAlign?: VerticalAlignmentType; // 17.4.83 vAlign (Table Cell Vertical Alignment)
}
@@ -1367,6 +1584,15 @@ export interface ITableCellBorder {
color: IColorStyle; // color
width?: INumberUnit; // width
dashStyle?: DashStyleType; // dashStyle
/**
* Resolved outer table top when borderTop inherits the table's insideH edge.
* At the top of a traditional table page fragment, use this instead of the
* internal edge. Absent for explicit cell borders and legacy snapshots.
* Replacing or editing the cell border must discard this inheritance.
*/
tableTopBorder?: ITableCellBorder;
/** Resolved outer bottom for an inherited insideH edge; follows the same editing rules as tableTopBorder. */
tableBottomBorder?: ITableCellBorder;
}
// 17.18.101ST_VerticalJc (Vertical Alignment Type)
@@ -391,7 +391,7 @@ describe('DocDrawingTransformerController business methods', () => {
hostPage: bodyPage,
page: headerPage,
width: 80,
})).toBe(false);
})).toBe(true);
expect(shouldUseDocsDrawingOuterPageOrigin({
drawing: overlayDrawing,
height: 1055,
@@ -23,17 +23,18 @@ import {
ObjectRelativeFromV,
PositionedObjectLayoutType,
} from '@univerjs/core';
import { Liquid, setDocsTableRenderViewportProvider } from '@univerjs/engine-render';
import { DocumentSkeletonPageType, Liquid, setDocsTableRenderViewportProvider } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getDocsTableCellAnchorContext } from '../../doc-drawing-transformer-update.controller';
import { doesDocMutationAffectDrawingPresentation, getDocMutationAffectedDrawingIds } from '../doc-drawing-mutation';
import {
DocDrawingPublicationTracker,
DocDrawingTransformUpdateController,
getDocsDrawingBehindText,
getDocsDrawingClipPage,
getDocsDrawingPageClipBounds,
getDocsOverlayRuntimeDrawing,
getDocsPageRelativeDrawingAnchorPage,
getDocsPageRelativeDrawingLeft,
getDocsPageRelativeDrawingTop,
@@ -46,6 +47,38 @@ describe('DocDrawingTransformUpdateController', () => {
setDocsTableRenderViewportProvider(null);
});
it('refreshes drawings when scrolling materializes pages without a layout progress event', () => {
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
vi.stubGlobal('cancelAnimationFrame', vi.fn());
const controller = Object.create(DocDrawingTransformUpdateController.prototype);
const currentSkeleton$ = new Subject();
const skeleton = {
layoutProgress$: new Subject(),
layoutPageMaterialized$: new Subject<void>(),
};
const subscriptions: Array<{ unsubscribe: () => void }> = [];
controller.disposeWithMe = (subscription: { unsubscribe: () => void }) => subscriptions.push(subscription);
controller._context = { mainComponent: null };
controller._docSkeletonManagerService = { currentSkeleton$ };
controller._docRefreshDrawingsService = { refreshDrawings$: new Subject() };
controller._publicationTracker = new DocDrawingPublicationTracker();
controller._refreshDrawing = vi.fn();
controller._initialRenderRefresh();
try {
currentSkeleton$.next(skeleton);
expect(controller._refreshDrawing).toHaveBeenCalledTimes(1);
skeleton.layoutPageMaterialized$.next();
expect(controller._refreshDrawing).toHaveBeenCalledTimes(2);
expect(controller._refreshDrawing).toHaveBeenLastCalledWith(skeleton);
currentSkeleton$.next(null);
skeleton.layoutPageMaterialized$.next();
expect(controller._refreshDrawing).toHaveBeenCalledTimes(2);
} finally {
subscriptions.forEach((subscription) => subscription.unsubscribe());
vi.unstubAllGlobals();
}
});
it('does not eagerly refresh drawings for table metadata mutations', () => {
expect(doesDocMutationAffectDrawingPresentation([
'tableSource',
@@ -619,11 +652,11 @@ describe('DocDrawingTransformUpdateController', () => {
}),
expect.objectContaining({
drawingId: 'square-drawing',
transform: expect.objectContaining({ left: 26, top: 40, width: 30, height: 40, angle: 5 }),
transform: expect.objectContaining({ left: 104, top: 95, width: 30, height: 40, angle: 5 }),
}),
expect.objectContaining({
drawingId: 'top-bottom-drawing',
transform: expect.objectContaining({ left: 28, top: 42, width: 30, height: 40, angle: 5 }),
transform: expect.objectContaining({ left: 104, top: 95, width: 30, height: 40, angle: 5 }),
}),
expect.objectContaining({
drawingId: 'inline-drawing',
@@ -704,6 +737,108 @@ describe('DocDrawingTransformUpdateController', () => {
});
});
it('does not crop cell-anchored pictures to the vertically aligned text origin', () => {
expect(getDocsDrawingPageClipBounds({
docsLeft: 20,
docsTop: 30,
pageOffsetLeft: 100,
pageOffsetTop: 200,
clipOffsetLeft: 138,
clipOffsetTop: 98 + 141,
page: {
type: DocumentSkeletonPageType.CELL,
pageWidth: 321,
pageHeight: 300,
marginLeft: 8,
marginTop: 141,
},
})).toEqual({ left: 250, top: 328, width: 321, height: 300 });
});
it('restores visibility and presentation when publishing a previously hidden header drawing', () => {
const controller = Object.create(DocDrawingTransformUpdateController.prototype);
const drawing = { drawingId: 'logo', hidden: true, source: 'image-source' };
const update = {
drawingId: 'logo',
hidden: false,
behindText: true,
selectable: false,
isMultiTransform: BooleanNumber.TRUE,
transform: { left: 10, top: 20, width: 30, height: 40 },
transforms: [{ left: 10, top: 20, width: 30, height: 40 }],
};
controller._context = {
unitId: 'doc',
scene: { getTransformerByCreate: () => ({ getSelectedObjectMap: () => new Map() }) },
};
controller._drawingManagerService = {
getDrawingByParam: () => drawing,
getDrawingData: () => ({ logo: drawing }),
removeNotification: vi.fn(),
addNotification: vi.fn(() => expect(drawing).toMatchObject(update)),
};
controller._handleMultiDrawingsTransform([update]);
expect(controller._drawingManagerService.addNotification).toHaveBeenCalledWith([update]);
expect(drawing.source).toBe('image-source');
});
it('anchors footer drawings at the same bottom-aligned origin as footer text', () => {
const controller = Object.create(DocDrawingTransformUpdateController.prototype);
controller._collectSegmentDrawingPositions = vi.fn();
const page = { pageWidth: 800, pageHeight: 1100, marginLeft: 50, marginBottom: 70, footerId: 'footer' };
const footer = { height: 16, marginTop: 5, marginBottom: 30 };
controller._collectPublishedPageDrawingPositions('doc', page, new Map(), new Map([['footer', new Map([[800, footer]])]]), 0, 0, {});
expect(controller._collectSegmentDrawingPositions.mock.calls[0][5]).toBe(1054);
});
it('allows floating pictures to overflow a cell without crossing the document page', () => {
const config = {
docsLeft: 20,
docsTop: 30,
pageOffsetLeft: 0,
pageOffsetTop: 0,
clipOffsetLeft: 60,
clipOffsetTop: 900,
overflowClipBounds: { left: 20, top: 30, width: 794, height: 1123 },
page: { type: DocumentSkeletonPageType.CELL, pageWidth: 200, pageHeight: 155 },
};
expect(getDocsDrawingPageClipBounds({
...config,
drawingLayoutType: PositionedObjectLayoutType.WRAP_NONE,
})).toEqual(config.overflowClipBounds);
expect(getDocsDrawingPageClipBounds({
...config,
drawingLayoutType: PositionedObjectLayoutType.INLINE,
})).toEqual({ left: 80, top: 930, width: 200, height: 155 });
});
it('paints measured inline-object dimensions without changing source or overriding live floating transforms', () => {
const source = {
layoutType: PositionedObjectLayoutType.INLINE,
docTransform: {
angle: 0,
size: { width: 100, height: 44 },
positionH: { relativeFrom: ObjectRelativeFromH.COLUMN },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH },
},
};
const measured = getDocsOverlayRuntimeDrawing(source, undefined, { width: 100, height: 76 });
expect(measured.docTransform.size).toEqual({ width: 100, height: 76 });
expect(source.docTransform.size.height).toBe(44);
const overlay = { ...source, layoutType: PositionedObjectLayoutType.WRAP_NONE };
const edited = { ...overlay, docTransform: { ...overlay.docTransform, size: { width: 120, height: 90 } } };
expect(getDocsOverlayRuntimeDrawing(overlay, edited, { width: 100, height: 76 })).toBe(edited);
});
it.each([DocumentSkeletonPageType.HEADER, DocumentSkeletonPageType.FOOTER])('clips small foreground story drawings to physical page bounds (story: %s)', (type) => {
const hostPage = { pageWidth: 794, pageHeight: 1123 };
expect(getDocsDrawingClipPage({
page: { type, pageWidth: 602, pageHeight: 510 },
hostPage,
drawing: { behindText: false, transform: { width: 175, height: 33 } },
})).toBe(hostPage);
});
it('clips page-sized header background drawings to the host document page', () => {
const headerPage = {
pageWidth: 589,
@@ -799,6 +934,29 @@ describe('DocDrawingTransformUpdateController', () => {
})).toBe(1);
});
it.each([
PositionedObjectLayoutType.WRAP_NONE,
PositionedObjectLayoutType.WRAP_SQUARE,
PositionedObjectLayoutType.WRAP_POLYGON,
PositionedObjectLayoutType.WRAP_THROUGH,
PositionedObjectLayoutType.WRAP_TIGHT,
PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM,
])('uses page coordinates for floating drawing layout %s regardless of text wrapping', (drawingLayoutType) => {
const page = { pageWidth: 793.4, pageHeight: 1121.13 };
const anchor = getDocsPageRelativeDrawingAnchorPage({ page, clipPage: page, drawingLayoutType });
expect(anchor).toBe(page);
expect(getDocsPageRelativeDrawingLeft({
hostPage: anchor!,
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 35.4 },
width: 755.4,
})).toBe(35.4);
expect(getDocsPageRelativeDrawingAnchorPage({
page,
clipPage: page,
drawingLayoutType: PositionedObjectLayoutType.INLINE,
})).toBeUndefined();
});
it('uses body pages as page-relative drawing anchors when no host page exists', () => {
const bodyPage = {
pageWidth: 816,
@@ -811,6 +969,15 @@ describe('DocDrawingTransformUpdateController', () => {
})).toBe(bodyPage);
});
it('keeps a table-cell drawing in the translated cell coordinate space', () => {
const cell = { type: DocumentSkeletonPageType.CELL, pageWidth: 200, pageHeight: 40 };
expect(getDocsPageRelativeDrawingAnchorPage({
page: cell,
clipPage: cell,
drawingLayoutType: PositionedObjectLayoutType.WRAP_NONE,
})).toBeUndefined();
});
it('renders header and footer drawings in the host page behind-text layer', () => {
expect(getDocsDrawingBehindText({
drawingOrigin: {
@@ -498,7 +498,7 @@ describe('DocDrawingUpdateRenderController', () => {
expect(scene.attachTransformerTo).toHaveBeenCalledWith(getShape('body-drawing'));
expect(getShape('body-drawing').setOpacity).toHaveBeenLastCalledWith(1);
expect(getShape('header-drawing').setOpacity).toHaveBeenLastCalledWith(0.5);
expect(getShape('header-drawing').setOpacity).toHaveBeenLastCalledWith(1);
scene.attachTransformerTo.mockClear();
setEditArea(DocumentEditArea.HEADER);
@@ -588,7 +588,7 @@ describe('DocDrawingUpdateRenderController', () => {
expect(scene.attachTransformerTo).toHaveBeenCalledWith(getShape('body-drawing'));
expect(getShape('body-drawing').setOpacity).toHaveBeenLastCalledWith(1);
expect(getShape('header-drawing').setOpacity).toHaveBeenLastCalledWith(0.5);
expect(getShape('header-drawing').setOpacity).toHaveBeenLastCalledWith(1);
setIsFocusing(false);
onBlur$.next({});
@@ -51,6 +51,7 @@ import { IEditorService, SetDocZoomRatioOperation } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import {
Documents,
DocumentSkeletonPageType,
getDocsTableRenderViewport,
getTableIdAndSliceIndex,
Liquid,
@@ -138,6 +139,7 @@ interface IDrawingPositionContext {
selectable: boolean;
hostPage?: IDocumentSkeletonPage;
clipOffset?: { left: number; top: number };
overflowClipBounds?: IDrawingClipBounds;
}
/**
@@ -147,11 +149,15 @@ interface IDrawingPositionContext {
*/
export function getDocsOverlayRuntimeDrawing(
skeletonDrawing: Pick<IDocDrawingBase, 'docTransform' | 'layoutType'> & Partial<IDocDrawingBase>,
currentDrawing: (Pick<IDocDrawingBase, 'docTransform' | 'layoutType'> & Partial<IDocDrawingBase>) | undefined
currentDrawing: (Pick<IDocDrawingBase, 'docTransform' | 'layoutType'> & Partial<IDocDrawingBase>) | undefined,
measuredSize?: { width: number; height: number }
): Pick<IDocDrawingBase, 'docTransform' | 'layoutType'> & Partial<IDocDrawingBase> {
return skeletonDrawing.layoutType === PositionedObjectLayoutType.WRAP_NONE &&
currentDrawing?.layoutType === PositionedObjectLayoutType.WRAP_NONE
? currentDrawing
if (skeletonDrawing.layoutType === PositionedObjectLayoutType.WRAP_NONE &&
currentDrawing?.layoutType === PositionedObjectLayoutType.WRAP_NONE) {
return currentDrawing;
}
return measuredSize
? { ...skeletonDrawing, docTransform: { ...skeletonDrawing.docTransform, size: measuredSize } }
: skeletonDrawing;
}
@@ -242,17 +248,30 @@ export function getDocsDrawingPageClipBounds(config: {
pageOffsetTop: number;
clipOffsetLeft?: number;
clipOffsetTop?: number;
page: Pick<IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter, 'pageWidth' | 'pageHeight'>;
drawingLayoutType?: PositionedObjectLayoutType;
overflowClipBounds?: IDrawingClipBounds;
page: Pick<IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter, 'pageWidth' | 'pageHeight'> &
Partial<Pick<IDocumentSkeletonPage, 'type' | 'marginLeft' | 'marginTop'>>;
}): IDrawingClipBounds | undefined {
const { docsLeft, docsTop, pageOffsetLeft, pageOffsetTop, clipOffsetLeft = 0, clipOffsetTop = 0, page } = config;
if (
page.type === DocumentSkeletonPageType.CELL &&
config.drawingLayoutType === PositionedObjectLayoutType.WRAP_NONE &&
config.overflowClipBounds != null
) {
return config.overflowClipBounds;
}
const { pageWidth, pageHeight } = page;
if (!Number.isFinite(pageWidth) || !Number.isFinite(pageHeight) || pageWidth <= 0 || pageHeight <= 0) {
return;
}
// A cell clip covers its physical box, not the vertically aligned text origin passed to drawing layout.
const cellInsetLeft = page.type === DocumentSkeletonPageType.CELL ? page.marginLeft ?? 0 : 0;
const cellInsetTop = page.type === DocumentSkeletonPageType.CELL ? page.marginTop ?? 0 : 0;
return {
left: docsLeft + pageOffsetLeft + clipOffsetLeft,
top: docsTop + pageOffsetTop + clipOffsetTop,
left: docsLeft + pageOffsetLeft + clipOffsetLeft - cellInsetLeft,
top: docsTop + pageOffsetTop + clipOffsetTop - cellInsetTop,
width: pageWidth,
height: pageHeight,
};
@@ -263,9 +282,13 @@ export function getDocsDrawingClipPage(config: {
transform?: Pick<ITransformState, 'width' | 'height'>;
};
hostPage?: Pick<IDocumentSkeletonPage, 'pageWidth' | 'pageHeight'>;
page: Pick<IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter, 'pageWidth' | 'pageHeight'>;
page: Pick<IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter, 'pageWidth' | 'pageHeight'> &
Partial<Pick<IDocumentSkeletonPage, 'type'>>;
}): Pick<IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter, 'pageWidth' | 'pageHeight'> {
const { drawing, hostPage, page } = config;
if (hostPage && (page.type === DocumentSkeletonPageType.HEADER || page.type === DocumentSkeletonPageType.FOOTER)) {
return hostPage;
}
if (hostPage == null || drawing.behindText !== true || drawing.transform == null) {
return page;
}
@@ -343,15 +366,21 @@ export function getDocsPageRelativeDrawingTop(config: {
}
export function getDocsPageRelativeDrawingAnchorPage(config: {
page: Pick<IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter, 'pageWidth' | 'pageHeight'>;
page: Pick<IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter, 'pageWidth' | 'pageHeight'> &
Partial<Pick<IDocumentSkeletonPage, 'type'>>;
clipPage: Pick<IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter, 'pageWidth' | 'pageHeight'>;
hostPage?: Pick<IDocumentSkeletonPage, 'pageWidth' | 'pageHeight'>;
drawingLayoutType?: PositionedObjectLayoutType;
}): Pick<IDocumentSkeletonPage | IDocumentSkeletonHeaderFooter, 'pageWidth' | 'pageHeight'> | undefined {
const { page, clipPage, hostPage } = config;
if (config.drawingLayoutType === PositionedObjectLayoutType.INLINE) {
return;
}
if (hostPage != null && hostPage === clipPage) {
return hostPage;
}
if (hostPage == null && page === clipPage) {
// Cell drawings use the cell's translated origin, not the physical page origin.
if (hostPage == null && page === clipPage && page.type !== DocumentSkeletonPageType.CELL) {
return page;
}
}
@@ -555,6 +584,9 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
documentSkeleton.layoutProgress$.pipe(
map((progress) => ({ documentSkeleton, progress }))
),
documentSkeleton.layoutPageMaterialized$.pipe(
map(() => ({ documentSkeleton, progress: null }))
),
positionRefresh$
);
})
@@ -762,7 +794,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
const footerPage = footerId ? skeFooters.get(footerId)?.get(pageWidth) : undefined;
if (footerPage != null) {
const footerTop = page.pageHeight - page.marginBottom + footerPage.marginTop;
const footerTop = page.pageHeight - footerPage.height - footerPage.marginBottom;
this._collectSegmentDrawingPositions(
unitId,
footerPage,
@@ -815,6 +847,13 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
selectable: boolean,
clipOffset?: { left: number; top: number }
): void {
const overflowClipBounds = getDocsDrawingPageClipBounds({
docsLeft,
docsTop,
pageOffsetLeft: this._liquid.x,
pageOffsetTop: this._liquid.y,
page: hostPage ?? page,
});
this._calculateDrawingPosition(
unitId,
page,
@@ -825,7 +864,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
marginLeft,
hostPage,
selectable,
clipOffset
clipOffset,
overflowClipBounds
);
this._calculateTableCellDrawingPositions(
unitId,
@@ -835,7 +875,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap,
marginTop,
marginLeft,
selectable
selectable,
overflowClipBounds
);
this._calculateColumnGroupDrawingPositions(
unitId,
@@ -845,7 +886,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap,
marginTop,
marginLeft,
selectable
selectable,
overflowClipBounds
);
}
@@ -884,9 +926,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
return;
}
param.transform = updateParam.transform;
param.transforms = updateParam.transforms;
param.isMultiTransform = updateParam.isMultiTransform;
// A segment drawing may have been hidden before its first layout publication.
Object.assign(param, updateParam);
});
// Step 2: remove all drawing shapes.
@@ -921,7 +962,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
marginLeft: number,
hostPage?: IDocumentSkeletonPage,
selectable = true,
clipOffset?: { left: number; top: number }
clipOffset?: { left: number; top: number },
overflowClipBounds?: IDrawingClipBounds
) {
const { skeDrawings } = page;
const pageOffsetLeft = this._liquid.x;
@@ -942,6 +984,7 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
hostPage,
selectable,
clipOffset,
overflowClipBounds,
};
skeDrawings.forEach((drawing) => this._collectDrawingPosition(drawing, drawingPositionContext));
@@ -957,7 +1000,11 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
): void {
const { aLeft, aTop, angle: skeletonAngle, drawingId, drawingOrigin, height: skeletonHeight, width: skeletonWidth } = drawing;
const currentDrawing = this._context.unit?.getSnapshot?.().drawings?.[drawingId];
const runtimeDrawing = getDocsOverlayRuntimeDrawing(drawingOrigin, currentDrawing);
const runtimeDrawing = getDocsOverlayRuntimeDrawing(
drawingOrigin,
currentDrawing,
drawing.customBlockRenderViewport ? { width: skeletonWidth, height: skeletonHeight } : undefined
);
const { angle = skeletonAngle, size } = runtimeDrawing.docTransform;
const height = size?.height ?? skeletonHeight;
const width = size?.width ?? skeletonWidth;
@@ -977,14 +1024,15 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
clipOffsetLeft,
clipOffsetTop,
page: clipPage,
drawingLayoutType: runtimeDrawing.layoutType,
overflowClipBounds: context.overflowClipBounds,
});
const anchorPage = getDocsPageRelativeDrawingAnchorPage({
page: context.page,
clipPage,
hostPage: context.hostPage,
drawingLayoutType: runtimeDrawing.layoutType,
});
const anchorPage = runtimeDrawing.layoutType === PositionedObjectLayoutType.WRAP_NONE
? getDocsPageRelativeDrawingAnchorPage({
page: context.page,
clipPage,
hostPage: context.hostPage,
})
: undefined;
const pageRelativeLeft = anchorPage == null
? undefined
: getDocsPageRelativeDrawingLeft({
@@ -1032,7 +1080,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap: Record<string, IDrawingParamsWithBehindText>,
baseMarginTop: number,
baseMarginLeft: number,
selectable: boolean
selectable: boolean,
overflowClipBounds?: IDrawingClipBounds
) {
page.skeTables?.forEach((table) => {
table.rows.forEach((row) => {
@@ -1059,7 +1108,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
marginLeft,
undefined,
selectable,
{ left: marginLeft, top: marginTop }
{ left: marginLeft, top: marginTop },
overflowClipBounds
);
this._calculateTableCellDrawingPositions(
unitId,
@@ -1069,7 +1119,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap,
marginTop,
marginLeft,
selectable
selectable,
overflowClipBounds
);
this._calculateColumnGroupDrawingPositions(
unitId,
@@ -1079,7 +1130,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap,
marginTop,
marginLeft,
selectable
selectable,
overflowClipBounds
);
});
});
@@ -1094,7 +1146,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap: Record<string, IDrawingParamsWithBehindText>,
baseMarginTop: number,
baseMarginLeft: number,
selectable: boolean
selectable: boolean,
overflowClipBounds?: IDrawingClipBounds
): void {
page.skeColumnGroups?.forEach((columnGroup) => {
columnGroup.columns.forEach((column) => {
@@ -1113,7 +1166,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
marginLeft,
undefined,
selectable,
clipOffset
clipOffset,
overflowClipBounds
);
this._calculateTableCellDrawingPositions(
unitId,
@@ -1123,7 +1177,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap,
marginTop,
marginLeft,
selectable
selectable,
overflowClipBounds
);
this._calculateColumnGroupDrawingPositions(
unitId,
@@ -1133,7 +1188,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
updateDrawingMap,
marginTop,
marginLeft,
selectable
selectable,
overflowClipBounds
);
});
});
@@ -496,7 +496,10 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
scene.detachTransformerFrom(shape);
this._setTransformerEditable(shape, editable);
try {
(shape as Image).setOpacity(isDocInteractionFocusing ? 0.5 : 1);
// Repeated header/footer drawings are faded by the content cover,
// never individually: an opaque picture must still hide pictures below it.
const fadeBody = isDocInteractionFocusing && drawing.isMultiTransform !== BooleanNumber.TRUE;
(shape as Image).setOpacity(fadeBody ? 0.5 : 1);
} catch {
}
if (!isDocInteractionFocusing) {
@@ -40,6 +40,7 @@ export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
[DOCS_IMAGE_MENU_ID]: {
order: 0,
gridLayout: { row: 1, column: 4 },
menuItemFactory: ImageMenuFactory,
},
},
@@ -15,7 +15,18 @@
*/
import type { IUpdateDrawingDocTransformCommandParams } from '../update-doc-drawing-transform.command';
import { ICommandService, ImageSourceType } from '@univerjs/core';
import {
awaitTime,
DocumentFlavor,
DrawingTypeEnum,
ICommandService,
ImageSourceType,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
RedoCommand,
UndoCommand,
} from '@univerjs/core';
import { DocHistoryAction, RichTextEditingMutation } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFacadeTestBed } from '../../../facade/__tests__/create-test-bed';
@@ -119,4 +130,50 @@ describe('UpdateDrawingDocTransformCommand', () => {
expect.objectContaining({ historyAction: DocHistoryAction.UpdateImage })
);
});
it('retains picture-local scale through resize, undo and redo', async () => {
testBed.univer.dispose();
testBed = createFacadeTestBed({
id: 'test-doc',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: {
dataStream: '\b\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'paragraph-1' }],
customBlocks: [{ startIndex: 0, blockId: 'picture' }],
},
drawingsOrder: ['picture'],
drawings: {
picture: {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawingId: 'picture',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
layoutType: PositionedObjectLayoutType.INLINE,
docTransform: {
angle: 0,
size: { width: 100, height: 50 },
positionH: { relativeFrom: ObjectRelativeFromH.PAGE },
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH },
},
drawingMLSizeScale: { width: 1.1, height: 1.2 },
},
},
});
const commandService = testBed.injector.get(ICommandService);
expect(commandService.syncExecuteCommand(UpdateDrawingDocTransformCommand.id, {
unitId: 'test-doc',
subUnitId: 'test-doc',
drawings: [{ drawingId: 'picture', key: 'size', value: { width: 200, height: 75 } }],
})).toBe(true);
await awaitTime(350);
const drawing = () => testBed.documentDataModel.getSnapshot().drawings!.picture;
expect(drawing().docTransform.size).toEqual({ width: 200, height: 75 });
expect(drawing().drawingMLSizeScale).toEqual({ width: 1.1, height: 1.2 });
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(drawing().docTransform.size).toEqual({ width: 100, height: 50 });
expect(drawing().drawingMLSizeScale).toEqual({ width: 1.1, height: 1.2 });
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(drawing().docTransform.size).toEqual({ width: 200, height: 75 });
expect(drawing().drawingMLSizeScale).toEqual({ width: 1.1, height: 1.2 });
});
});
@@ -0,0 +1,78 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IUpdateDocHyperLinkCommandParams } from '../update-link.command';
import { CustomRangeType, ICommandService, IUniverInstanceService, Tools, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { describe, expect, it } from 'vitest';
import { UpdateDocHyperLinkCommand } from '../update-link.command';
describe('UpdateDocHyperLinkCommand', () => {
it.each([{}, { textStyleMode: 'text' }, { textColorMode: 'text' }])('retains formatting policy %j when changing label and URL', async (policy) => {
const univer = new Univer();
try {
const injector = univer.__getInjector();
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'link-style',
body: {
dataStream: 'Mail\r\n',
textRuns: [{ st: 0, ed: 4, ts: { cl: { rgb: '#AA1122' }, ul: { s: 0 } } }],
customRanges: [{
startIndex: 0,
endIndex: 3,
rangeId: 'link',
rangeType: CustomRangeType.HYPERLINK,
properties: { url: 'mailto:before@example.test', ...policy },
}],
},
documentStyle: {},
});
injector.get(IUniverInstanceService).focusUnit('link-style');
const commands = injector.get(ICommandService);
commands.registerCommand(UpdateDocHyperLinkCommand);
commands.registerCommand(RichTextEditingMutation);
const selection = injector.get(DocSelectionManagerService);
selection.__TEST_ONLY_setCurrentSelection({ unitId: 'link-style', subUnitId: 'link-style' });
selection.__TEST_ONLY_add([{ startOffset: 0, endOffset: 4, collapsed: false, isActive: true, segmentId: '', style: null as never }]);
const before = Tools.deepClone(doc.getBody());
const undo = await commands.executeCommand<IUpdateDocHyperLinkCommandParams, IRichTextEditingMutationParams>(UpdateDocHyperLinkCommand.id, {
unitId: 'link-style',
linkId: 'link',
payload: 'mailto:after@example.test',
label: 'New mail',
segmentId: '',
});
expect(undo).toBeTruthy();
expect(doc.getBody()?.dataStream).toBe('New mail\r\n');
expect(doc.getBody()?.customRanges?.[0].properties).toEqual({ url: 'mailto:after@example.test', ...policy });
expect(doc.getBody()?.textRuns?.[0].ts).toEqual({ cl: { rgb: '#AA1122' }, ul: { s: 0 } });
const updated = Tools.deepClone(doc.getBody());
const redo = commands.syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(RichTextEditingMutation.id, undo);
expect(doc.getBody()?.dataStream).toBe(before?.dataStream);
expect(doc.getBody()?.customRanges).toEqual(before?.customRanges);
commands.syncExecuteCommand(RichTextEditingMutation.id, redo);
expect(doc.getBody()).toEqual(updated);
} finally {
univer.dispose();
}
});
});
@@ -49,6 +49,8 @@ export const UpdateDocHyperLinkCommand: ICommand<IUpdateDocHyperLinkCommandParam
return false;
}
const oldLink = oldBody.customRanges?.find((range) => range.rangeId === linkId && range.rangeType === CustomRangeType.HYPERLINK);
const textRun = getBodySlice(oldBody, currentSelection.startOffset!, currentSelection.endOffset!).textRuns?.[0];
if (textRun) {
textRun.ed = params.label.length + 1;
@@ -65,6 +67,8 @@ export const UpdateDocHyperLinkCommand: ICommand<IUpdateDocHyperLinkCommandParam
endIndex: params.label.length + 1,
properties: {
url: payload,
...(oldLink?.properties?.textStyleMode ? { textStyleMode: oldLink.properties.textStyleMode } : null),
...(oldLink?.properties?.textColorMode ? { textColorMode: oldLink.properties.textColorMode } : null),
},
}],
textRuns: textRun ? [textRun] : undefined,
@@ -21,7 +21,7 @@ import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { IDialogService } from '@univerjs/ui';
import { of } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DocHyperLinkPopupService } from '../../../services/hyper-link-popup.service';
import {
ClickDocHyperLinkOperation,
@@ -44,7 +44,29 @@ function createDocData(): IDocumentData {
properties: {
url: 'javascript:alert(1)',
},
}, {
rangeId: 'bookmark-link',
rangeType: CustomRangeType.HYPERLINK,
startIndex: 6,
endIndex: 10,
properties: { bookmarkId: '_Toc1' },
}, {
rangeId: 'bookmark-range',
rangeType: CustomRangeType.BOOKMARK,
startIndex: 11,
endIndex: 11,
properties: { bookmarkId: '_Toc1' },
}, {
rangeId: 'heading-link',
rangeType: CustomRangeType.HYPERLINK,
startIndex: 0,
endIndex: 4,
properties: { headingId: 'heading-2' },
}],
paragraphs: [
{ startIndex: 5, paragraphId: 'paragraph-1' },
{ startIndex: 11, paragraphId: 'paragraph-2', paragraphStyle: { headingId: 'heading-2' } },
],
},
documentStyle: {
pageSize: {
@@ -169,4 +191,40 @@ describe('doc hyperlink popup operations', () => {
expect(result).toBe(false);
});
it('moves the caret to an internal bookmark', async () => {
const replaceDocRanges = vi.spyOn(selectionManager, 'replaceDocRanges');
const result = await commandService.executeCommand(ClickDocHyperLinkOperation.id, {
unitId,
linkId: 'bookmark-link',
segmentId: '',
});
expect(result).toBe(true);
expect(replaceDocRanges).toHaveBeenCalledWith(
[{ startOffset: 11, endOffset: 11, collapsed: true }],
{ unitId, subUnitId: unitId },
false,
{ preserveCaret: true }
);
});
it('moves the caret to the start of an internal heading', async () => {
const replaceDocRanges = vi.spyOn(selectionManager, 'replaceDocRanges');
const result = await commandService.executeCommand(ClickDocHyperLinkOperation.id, {
unitId,
linkId: 'heading-link',
segmentId: '',
});
expect(result).toBe(true);
expect(replaceDocRanges).toHaveBeenCalledWith(
[{ startOffset: 6, endOffset: 6, collapsed: true }],
{ unitId, subUnitId: unitId },
false,
{ preserveCaret: true }
);
});
});
@@ -14,9 +14,11 @@
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand } from '@univerjs/core';
import type { DocumentDataModel, IAccessor, ICommand, IDocumentBody } from '@univerjs/core';
import { CommandType, CustomRangeType, isSafeUrl, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DOC_SELECTION_OPTION_PRESERVE_CARET, DocSelectionManagerService } from '@univerjs/docs';
import { DocBackScrollRenderController } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { DocHyperLinkPopupService } from '../../services/hyper-link-popup.service';
export const shouldDisableAddLink = (accessor: IAccessor) => {
@@ -74,6 +76,7 @@ export interface IShowDocHyperLinkInfoPopupOperationParams {
segmentPage?: number;
startIndex: number;
endIndex: number;
fromHover?: boolean;
}
export const ToggleDocHyperLinkInfoPopupOperation: ICommand<IShowDocHyperLinkInfoPopupOperationParams> = {
@@ -86,7 +89,11 @@ export const ToggleDocHyperLinkInfoPopupOperation: ICommand<IShowDocHyperLinkInf
return true;
}
hyperLinkService.showInfoPopup(params);
if (params.fromHover) {
hyperLinkService.showInfoPopupFromHover(params);
} else {
hyperLinkService.showInfoPopup(params);
}
return true;
},
};
@@ -101,13 +108,65 @@ export const ClickDocHyperLinkOperation: ICommand<{ unitId: string; linkId: stri
const { unitId, linkId, segmentId } = params;
const univerInstanceService = accessor.get(IUniverInstanceService);
const doc = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const body = doc?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
const link = body?.customRanges?.find((range) => range.rangeId === linkId && range.rangeType === CustomRangeType.HYPERLINK)?.properties?.url;
const sourceBody = doc?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
const link = sourceBody?.customRanges?.find((range) => range.rangeId === linkId && range.rangeType === CustomRangeType.HYPERLINK)?.properties;
if (!isSafeUrl(link)) {
if (link?.bookmarkId || link?.headingId) {
const targetBody = doc?.getBody();
const targetOffset = findInternalLinkTarget(targetBody, link.bookmarkId, link.headingId);
if (targetOffset == null) {
return false;
}
const range = { startOffset: targetOffset, endOffset: targetOffset, collapsed: true };
const selectionManager = accessor.get(DocSelectionManagerService);
const selectTarget = (): void => selectionManager.replaceDocRanges(
[range],
{ unitId, subUnitId: unitId },
false,
{ [DOC_SELECTION_OPTION_PRESERVE_CARET]: true }
);
const render = accessor.get(IRenderManagerService).getRenderUnitById(unitId);
const renderInjector = render?.getInjector?.();
const backScrollController = renderInjector?.has(DocBackScrollRenderController)
? render?.with(DocBackScrollRenderController)
: null;
if (backScrollController) {
backScrollController.scrollToRange(range, selectTarget);
} else {
selectTarget();
}
return true;
}
if (!link?.url || !isSafeUrl(link.url)) {
return false;
}
window.open(link, '_blank', 'noopener noreferrer');
window.open(link.url, '_blank', 'noopener noreferrer');
return true;
},
};
function findInternalLinkTarget(body: IDocumentBody | undefined, bookmarkId?: string, headingId?: string): number | undefined {
if (!body) {
return;
}
if (bookmarkId) {
const bookmark = body.customRanges?.find((range) =>
range.rangeType === CustomRangeType.BOOKMARK &&
(range.rangeId === bookmarkId || range.properties?.bookmarkId === bookmarkId)
);
if (bookmark) {
return bookmark.startIndex;
}
}
if (headingId) {
const paragraphIndex = body.paragraphs?.findIndex((paragraph) => paragraph.paragraphStyle?.headingId === headingId) ?? -1;
if (paragraphIndex >= 0) {
return paragraphIndex === 0 ? 0 : body.paragraphs![paragraphIndex - 1].startIndex + 1;
}
}
}
@@ -17,8 +17,16 @@
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { ISetTextSelectionsOperationParams } from '@univerjs/docs';
import type { RenderUnit } from '@univerjs/engine-render';
import { BooleanNumber, CustomRangeType, DocumentFlavor, ICommandService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService, SetTextSelectionsOperation } from '@univerjs/docs';
import { BooleanNumber, CustomRangeType, DocumentFlavor, ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import {
DocLayoutExecutorService,
DocSelectionManagerService,
DocSkeletonManagerService,
DocStateEmitService,
InsertTextCommand,
RichTextEditingMutation,
SetTextSelectionsOperation,
} from '@univerjs/docs';
import { DocCanvasPopManagerService } from '@univerjs/docs-ui';
import { CanvasColorService, Documents, ICanvasColorService, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { CanvasPopupService, DesktopDialogService, ICanvasPopupService, IDialogService, IUIPartsService, UIPartsService } from '@univerjs/ui';
@@ -57,6 +65,7 @@ describe.each([1, 3])('DocHyperLinkSelectionController with a %i-character link'
injector.add([IDialogService, { useClass: DesktopDialogService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocCanvasPopManagerService]);
injector.add([DocHyperLinkPopupService]);
injector.add([DocHyperLinkSelectionController]);
@@ -81,16 +90,11 @@ describe.each([1, 3])('DocHyperLinkSelectionController with a %i-character link'
marginRight: 20,
},
});
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
render.deactivate();
render.engine.resizeBySize(300, 400);
vi.spyOn(render.engine.getCanvasElement()!, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 300, 400));
render.addRenderDependencies([[DocSkeletonManagerService]]);
const documents = new Documents('link-selection-document', render.with(DocSkeletonManagerService).getSkeleton());
render.mainComponent = documents;
render.scene.addObject(documents);
mountDocument(model);
commands = injector.get(ICommandService);
commands.registerCommand(SetTextSelectionsOperation);
commands.registerCommand(InsertTextCommand);
commands.registerCommand(RichTextEditingMutation);
popup = injector.get(DocHyperLinkPopupService);
injector.get(DocHyperLinkSelectionController);
});
@@ -100,12 +104,23 @@ describe.each([1, 3])('DocHyperLinkSelectionController with a %i-character link'
vi.restoreAllMocks();
});
async function select(startOffset: number, endOffset = startOffset): Promise<void> {
function mountDocument(model: DocumentDataModel): void {
const render = univer.__getInjector().get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
render.deactivate();
render.engine.resizeBySize(300, 400);
vi.spyOn(render.engine.getCanvasElement()!, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 300, 400));
render.addRenderDependencies([[DocSkeletonManagerService]]);
const documents = new Documents('link-selection-document', render.with(DocSkeletonManagerService).getSkeleton());
render.mainComponent = documents;
render.scene.addObject(documents);
}
async function select(startOffset: number, endOffset = startOffset, isEditing = false): Promise<void> {
const params: ISetTextSelectionsOperationParams = {
unitId: 'doc-unit',
subUnitId: 'doc-unit',
segmentId: '',
isEditing: false,
isEditing,
style: { fill: '', stroke: '', strokeActive: '', strokeWidth: 0 },
ranges: [{ startOffset, endOffset, collapsed: startOffset === endOffset, segmentPage: 0 }],
};
@@ -156,4 +171,42 @@ describe.each([1, 3])('DocHyperLinkSelectionController with a %i-character link'
await select(startIndex, endIndex + 1);
expect(popup.showing).toBeNull();
});
it('releases link details after editing and does not reopen them on a layout selection refresh', async () => {
await select(startIndex);
expect(popup.showing?.linkId).toBe('link-1');
expect(await commands.executeCommand(InsertTextCommand.id, {
unitId: 'doc-unit',
body: { dataStream: '!' },
range: { startOffset: 5, endOffset: 5, collapsed: true },
textRanges: [],
noNeedSetTextRange: true,
debounce: false,
})).toBe(true);
expect(popup.showing).toBeNull();
await select(startIndex);
expect(popup.showing).toBeNull();
await select(0);
await select(startIndex);
expect(popup.showing?.linkId).toBe('link-1');
});
it('does not open link details for an editing caret', async () => {
await select(startIndex, startIndex, true);
expect(popup.showing).toBeNull();
});
it('opens link details at the same caret after closing and reopening the document', async () => {
const instances = univer.__getInjector().get(IUniverInstanceService);
const snapshot = instances.getUnit<DocumentDataModel>('doc-unit', UniverInstanceType.UNIVER_DOC)!.getSnapshot();
await select(startIndex);
expect(popup.showing?.linkId).toBe('link-1');
univer.__getInjector().get(IRenderManagerService).removeRender('doc-unit');
expect(instances.disposeUnit('doc-unit')).toBe(true);
expect(popup.showing).toBeNull();
mountDocument(univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, snapshot));
await select(startIndex);
expect(popup.showing?.linkId).toBe('link-1');
});
});
@@ -15,7 +15,7 @@
*/
import type { DocumentDataModel } from '@univerjs/core';
import type { ISetTextSelectionsOperationParams } from '@univerjs/docs';
import type { IRichTextEditingMutationParams, ISetTextSelectionsOperationParams } from '@univerjs/docs';
import {
CustomRangeType,
Disposable,
@@ -24,10 +24,12 @@ import {
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { SetTextSelectionsOperation } from '@univerjs/docs';
import { RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import { DocHyperLinkPopupService } from '../services/hyper-link-popup.service';
export class DocHyperLinkSelectionController extends Disposable {
private _selection: { unitId: string; key: string } | null = null;
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@@ -36,17 +38,38 @@ export class DocHyperLinkSelectionController extends Disposable {
super();
this._initSelectionChange();
this.disposeWithMe(this._univerInstanceService.unitDisposed$.subscribe((unit) => {
if (this._selection?.unitId === unit.getUnitId()) {
this._selection = null;
}
}));
}
private _initSelectionChange() {
this.disposeWithMe(
this._commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === RichTextEditingMutation.id) {
const params = commandInfo.params as IRichTextEditingMutationParams;
if (params.unitId === this._docHyperLinkService.showing?.unitId) {
this._docHyperLinkService.hideInfoPopup();
}
return;
}
if (commandInfo.id === SetTextSelectionsOperation.id) {
const params = commandInfo.params as ISetTextSelectionsOperationParams;
const { unitId, ranges, segmentId } = params;
const doc = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const primary = ranges[0];
const selectionKey = JSON.stringify([
unitId,
segmentId ?? '',
primary?.startOffset,
primary?.endOffset,
primary?.segmentPage,
]);
const sameSelection = selectionKey === this._selection?.key;
this._selection = { unitId, key: selectionKey };
const editing = this._docHyperLinkService.editing;
// Opening the editor selects its own link; that is not a user dismissal.
if (editing?.unitId === unitId &&
@@ -54,6 +77,15 @@ export class DocHyperLinkSelectionController extends Disposable {
primary?.startOffset === editing.startIndex && primary.endOffset === editing.endIndex + 1) {
return;
}
if (params.isEditing) {
this._docHyperLinkService.hideInfoPopup();
return;
}
// Layout republishes the current caret. Reopening a popup here
// acquires an interaction lock and can suspend Worker publication.
if (sameSelection) {
return;
}
if (primary?.collapsed && doc) {
const { startOffset, endOffset, segmentPage } = primary;
const customRanges = doc.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.customRanges;
@@ -48,6 +48,45 @@ function createController(
}
describe('DocHyperLinkEventRenderController', () => {
it('treats a containing table render segment as the main document body', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const commandService = {
executeCommand: vi.fn(),
};
const controller = createController(
{
unitId: 'doc-unit',
unit: { getBody: () => ({ tables: [{ tableId: 'table-4' }] }) },
} as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
{ showing: false } as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [{ segmentId: 'table-4' }] } as never
);
hoverCustomRanges$.next([{
range: {
rangeId: 'link-1',
rangeType: CustomRangeType.HYPERLINK,
startIndex: 4,
endIndex: 10,
},
segmentId: '',
segmentPageIndex: -1,
rects: [],
}]);
expect(commandService.executeCommand).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ linkId: 'link-1', fromHover: true })
);
controller.dispose();
});
it('keeps the mobile event controller independent from the desktop controller', () => {
expect(MobileDocHyperLinkEventRenderController.prototype).not.toBeInstanceOf(DocHyperLinkEventRenderController);
});
@@ -65,6 +65,14 @@ export class DocHyperLinkEventRenderController extends Disposable implements IRe
}
}
private _normalizeSegmentId(segmentId?: string): string {
if (!segmentId) {
return '';
}
return this._context.unit.getBody()?.tables?.some((table) => table.tableId === segmentId) ? '' : segmentId;
}
private _initPointerDown() {
this.disposeWithMe(
this._docEventManagerService.pointerDownCustomRanges$.subscribe((ranges) => {
@@ -82,7 +90,7 @@ export class DocHyperLinkEventRenderController extends Disposable implements IRe
const link = ranges.find((range) => range.range.rangeType === CustomRangeType.HYPERLINK);
const activeRanges = this._docSelectionManagerService.getTextRanges();
const currentSegmentId = activeRanges?.[0]?.segmentId;
if ((link?.segmentId ?? '') !== currentSegmentId) {
if (this._normalizeSegmentId(link?.segmentId) !== this._normalizeSegmentId(currentSegmentId)) {
this._hideInfoPopup();
return;
}
@@ -98,7 +106,7 @@ export class DocHyperLinkEventRenderController extends Disposable implements IRe
};
this._commandService.executeCommand(
ToggleDocHyperLinkInfoPopupOperation.id,
info
{ ...info, fromHover: true }
);
} else {
this._hideInfoPopup();
@@ -24,6 +24,7 @@ export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
[ShowDocHyperLinkEditPopupOperation.id]: {
order: 1,
gridLayout: { row: 1, column: 5 },
menuItemFactory: AddHyperLinkMenuItemFactory,
},
},
@@ -265,6 +265,27 @@ describe('DocHyperLinkPopupService', () => {
}
});
it('keeps the current popup while the pointer crosses adjacent links', () => {
const { service, attached, disposed } = createService();
const firstLink = { unitId: 'doc-1', linkId: 'link-1', startIndex: 4, endIndex: 8 };
const secondLink = { unitId: 'doc-1', linkId: 'link-2', startIndex: 9, endIndex: 13 };
service.showInfoPopup(firstLink);
service.showInfoPopupFromHover(secondLink);
expect(service.showing).toEqual(firstLink);
expect(attached).toHaveLength(1);
expect(disposed).toEqual([]);
service.hideInfoPopup();
service.showInfoPopupFromHover(secondLink);
expect(service.showing).toEqual(secondLink);
expect(attached).toHaveLength(2);
expect(disposed).toEqual([1]);
service.dispose();
});
it('does not show link information when the target document is not loaded', () => {
const { service, attached } = createService();
@@ -245,6 +245,16 @@ export class DocHyperLinkPopupService extends Disposable {
);
}
showInfoPopupFromHover(info: ILinkInfo): void {
this.cancelScheduledHideInfoPopup();
if (this._infoPopupPinned || this._infoPopupSuppressed) {
return;
}
if (!this.showing) {
this.showInfoPopup(info);
}
}
hideInfoPopup() {
this.cancelScheduledHideInfoPopup();
this._infoPopupPinned = false;
@@ -30,7 +30,7 @@ import { CopyIcon, LinkIcon, UnlinkIcon, WriteIcon } from '@univerjs/icons';
import { UnitAction } from '@univerjs/protocol';
import { IMessageService, useDependency, useObservable } from '@univerjs/ui';
import { DeleteDocHyperLinkCommand } from '../commands/commands/delete-link.command';
import { ShowDocHyperLinkEditPopupOperation } from '../commands/operations/popup.operation';
import { ClickDocHyperLinkOperation, ShowDocHyperLinkEditPopupOperation } from '../commands/operations/popup.operation';
import { DocHyperLinkPopupService } from '../services/hyper-link-popup.service';
export const DocLinkPopup = () => {
@@ -56,7 +56,7 @@ export const DocLinkPopup = () => {
return null;
}
const url = link.properties?.url;
const target = link.properties?.url ?? `#${link.properties?.bookmarkId ?? link.properties?.headingId ?? ''}`;
const canEdit = hyperLinkService.canEditLink(unitId, currentPopup);
const canCopy = getDocumentPermissionValue(permissionService, unitId, unitId, UnitAction.Copy);
return (
@@ -81,7 +81,7 @@ export const DocLinkPopup = () => {
univer-flex univer-h-6 univer-flex-1 univer-cursor-pointer univer-items-center univer-truncate
univer-text-sm univer-leading-5 univer-text-primary-500
`}
onClick={() => window.open(url, undefined, 'noopener noreferrer')}
onClick={() => commandService.executeCommand(ClickDocHyperLinkOperation.id, { unitId, linkId, segmentId })}
>
<div
className={`
@@ -92,8 +92,8 @@ export const DocLinkPopup = () => {
>
<LinkIcon />
</div>
<Tooltip showIfEllipsis title={url}>
<span className="univer-flex-1 univer-truncate">{url}</span>
<Tooltip showIfEllipsis title={target}>
<span className="univer-flex-1 univer-truncate">{target}</span>
</Tooltip>
</div>
<div className="univer-flex univer-h-6 univer-flex-[0_0_auto] univer-items-center univer-justify-center">
@@ -111,7 +111,7 @@ export const DocLinkPopup = () => {
"
disabled={!canCopy}
onClick={() => {
navigator.clipboard.writeText(url);
navigator.clipboard.writeText(target);
messageService.show({
content: localeService.t<LocaleKey>('docs-hyper-link-ui.info.coped'),
type: MessageType.Info,
@@ -19,7 +19,7 @@ const locale = {
panel: {
title: 'Comment Management',
addComment: 'Add Comment',
openComments: 'Open Comments',
openComments: 'Comments',
},
},
};
@@ -25,15 +25,15 @@ import {
import { AddDocCommentMenuItemFactory, AddDocDrawingCommentMenuItemFactory, ToolbarDocCommentMenuItemFactory } from './menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
[RibbonInsertGroup.OTHERS]: {
[ToggleCommentPanelOperation.id]: {
order: 3,
gridLayout: { row: 1, column: 4, showLabel: true },
order: 0,
gridLayout: { row: 1, column: 1, showLabel: true },
menuItemFactory: ToolbarDocCommentMenuItemFactory,
},
[StartAddCommentOperation.id]: {
order: 3.1,
gridLayout: { row: 2, column: 4, showLabel: true },
order: 0.1,
gridLayout: { row: 2, column: 1, showLabel: true },
menuItemFactory: AddDocCommentMenuItemFactory,
},
},
+30
View File
@@ -0,0 +1,30 @@
# @univerjs/docs-toc-ui
`@univerjs/docs-toc-ui` adds Ribbon, context-menu, update dialog, and active-range feedback for `@univerjs/docs-toc`.
## Installation
```sh
pnpm add @univerjs/docs-toc-ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/docs-toc-ui/lib/index.css';
import EnUS from '@univerjs/docs-toc-ui/locale/en-US';
import { UniverDocsTocUIPlugin } from '@univerjs/docs-toc-ui';
univer.registerPlugin(UniverDocsTocUIPlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
Register this package after Docs, Docs UI, Render Engine, and Docs TOC core packages.
## Resources
- [Documentation](https://docs.univer.ai)
- [GitHub repository](https://github.com/dream-num/univer)
+96
View File
@@ -0,0 +1,96 @@
{
"name": "@univerjs/docs-toc-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Table of contents UI for Univer Docs.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"docs",
"table-of-contents",
"toc",
"ui"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./locale/*": {
"import": "./lib/es/locale/*.js",
"require": "./lib/cjs/locale/*.js",
"types": "./lib/types/locale/*.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/docs": "workspace:*",
"@univerjs/docs-toc": "workspace:*",
"@univerjs/docs-ui": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/protocol": "workspace:*",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"postcss": "^8.5.26",
"react": "18.3.1",
"react-dom": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';
@@ -0,0 +1,92 @@
/**
* 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, IDocumentBody, IDocumentData } from '@univerjs/core';
import { ICommandService, IUniverInstanceService, LocaleService, LocaleType, Univer, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { InsertTableOfContentsCommand } from '@univerjs/docs-toc';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { IMessageService } from '@univerjs/ui';
import { afterEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../locale/en-US';
import { InsertTableOfContentsOperation } from '../table-of-contents-dialog.operation';
describe('InsertTableOfContentsOperation', () => {
let univer: Univer;
afterEach(() => univer?.dispose());
function createAccessor(body: IDocumentBody) {
univer = new Univer();
univer.registerPlugin(UniverDocsPlugin);
const injector = univer.__getInjector();
const show = vi.fn(() => ({ dispose: vi.fn() }));
// The native notification surface is unavailable in this headless test.
injector.add([IMessageService, { useValue: { show, remove: vi.fn(), removeAll: vi.fn() } }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, { id: 'doc-1', body, documentStyle: {} });
injector.get(IUniverInstanceService).focusUnit('doc-1');
const locale = injector.get(LocaleService);
locale.load({ [LocaleType.EN_US]: enUS });
locale.setLocale(LocaleType.EN_US);
locale.setDirection('ltr');
const commands = injector.get(ICommandService);
commands.registerCommand(InsertTableOfContentsCommand);
commands.registerCommand(InsertTableOfContentsOperation);
const dispatched: unknown[] = [];
const subscription = commands.beforeCommandExecuted((command) => {
if (command.id === InsertTableOfContentsCommand.id) {
dispatched.push(command.params);
}
});
return { commands, dispatched, show, subscription };
}
it('shows a localized warning and does not invoke insertion without headings', async () => {
const { commands, dispatched, show, subscription } = createAccessor({
dataStream: 'Body\r\n',
paragraphs: [{ startIndex: 4, paragraphId: 'body' }],
});
expect(await commands.executeCommand(InsertTableOfContentsOperation.id, { mode: 'automatic' })).toBe(false);
expect(show).toHaveBeenCalledWith(expect.objectContaining({ content: enUS['docs-toc-ui'].tableOfContents.noHeadings }));
expect(dispatched).toHaveLength(0);
subscription.dispose();
});
it('dispatches localized automatic options and rejects insertion without rendered layout', async () => {
const { commands, dispatched, show, subscription } = createAccessor({
dataStream: 'Heading\r\n',
paragraphs: [{
startIndex: 7,
paragraphId: 'heading',
paragraphStyle: { headingId: 'heading-1', outlineLevel: 0 },
}],
});
expect(await commands.executeCommand(InsertTableOfContentsOperation.id, { mode: 'automatic' })).toBe(false);
expect(show).not.toHaveBeenCalled();
expect(dispatched).toEqual([expect.objectContaining({
unitId: 'doc-1',
levels: 3,
showPageNumbers: true,
rightAlignPageNumbers: true,
tabLeader: 'dots',
format: 'fromTemplate',
title: 'Contents',
})]);
subscription.dispose();
});
});
@@ -0,0 +1,165 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand } from '@univerjs/core';
import type { LocaleKey } from '../locale/types';
import type { IInsertTableOfContentsDialogProps, IInsertTableOfContentsDialogValue } from '../views/InsertTableOfContentsDialog';
import type { ITableOfContentsDialogProps } from '../views/TableOfContentsDialog';
import {
BooleanNumber,
CommandType,
ICommandService,
IConfirmService,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { DocSelectionManagerService } from '@univerjs/docs';
import { countTableOfContentsHeadings, findTableOfContentsAtOffset, InsertTableOfContentsCommand, UpdateTableOfContentsCommand } from '@univerjs/docs-toc';
import { IMessageService } from '@univerjs/ui';
import { INSERT_TABLE_OF_CONTENTS_DIALOG_COMPONENT } from '../views/InsertTableOfContentsDialog';
import { TABLE_OF_CONTENTS_DIALOG_COMPONENT } from '../views/TableOfContentsDialog';
const TABLE_OF_CONTENTS_DIALOG_ID = 'doc.dialog.update-table-of-contents';
const INSERT_TABLE_OF_CONTENTS_DIALOG_ID = 'doc.dialog.insert-table-of-contents';
export interface IInsertTableOfContentsOperationParams {
mode: 'automatic' | 'custom';
}
const DEFAULT_INSERT_VALUE: IInsertTableOfContentsDialogValue = {
levels: 3,
showPageNumbers: true,
rightAlignPageNumbers: true,
tabLeader: 'dots',
format: 'fromTemplate',
};
function showNoHeadingsMessage(accessor: IAccessor): void {
accessor.get(IMessageService).show({
type: MessageType.Warning,
content: accessor.get(LocaleService).t<LocaleKey>('docs-toc-ui.tableOfContents.noHeadings'),
});
}
async function insertTableOfContents(accessor: IAccessor, value: IInsertTableOfContentsDialogValue, title?: string): Promise<boolean> {
const doc = accessor.get(IUniverInstanceService)
.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!doc || countTableOfContentsHeadings(doc.getBody(), value.levels) === 0) {
showNoHeadingsMessage(accessor);
return false;
}
return Boolean(await accessor.get(ICommandService).executeCommand(InsertTableOfContentsCommand.id, {
unitId: doc.getUnitId(),
...value,
title,
}));
}
export const InsertTableOfContentsOperation: ICommand<IInsertTableOfContentsOperationParams> = {
id: 'doc.operation.insert-table-of-contents',
type: CommandType.OPERATION,
async handler(accessor, params) {
if (params?.mode === 'automatic') {
const title = accessor.get(LocaleService).t<LocaleKey>('docs-toc-ui.tableOfContents.contentsTitle');
return insertTableOfContents(accessor, DEFAULT_INSERT_VALUE, title);
}
if (params?.mode !== 'custom') {
return false;
}
const doc = accessor.get(IUniverInstanceService)
.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!doc) {
return false;
}
const confirmService = accessor.get(IConfirmService);
const localeService = accessor.get(LocaleService);
const state: Pick<IInsertTableOfContentsDialogProps, 'options'> = { options: { ...DEFAULT_INSERT_VALUE } };
await confirmService.open({
id: INSERT_TABLE_OF_CONTENTS_DIALOG_ID,
width: 520,
title: { title: localeService.t<LocaleKey>('docs-toc-ui.tableOfContents.customTitle') },
children: {
label: {
name: INSERT_TABLE_OF_CONTENTS_DIALOG_COMPONENT,
props: {
options: state.options,
onChange: (value: IInsertTableOfContentsDialogValue) => {
state.options = value;
},
},
},
},
onConfirm: async () => {
const inserted = await insertTableOfContents(accessor, state.options);
if (inserted) {
confirmService.close(INSERT_TABLE_OF_CONTENTS_DIALOG_ID);
}
},
onClose: () => confirmService.close(INSERT_TABLE_OF_CONTENTS_DIALOG_ID),
});
return true;
},
};
export const OpenTableOfContentsDialogOperation: ICommand = {
id: 'doc.operation.open-table-of-contents-dialog',
type: CommandType.OPERATION,
async handler(accessor: IAccessor) {
const doc = accessor.get(IUniverInstanceService)
.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const selection = accessor.get(DocSelectionManagerService).getActiveTextRange();
const toc = findTableOfContentsAtOffset(doc?.getBody(), selection?.startOffset);
if (!doc || !toc || toc.properties?.locked === BooleanNumber.TRUE) {
return false;
}
const confirmService = accessor.get(IConfirmService);
const commandService = accessor.get(ICommandService);
const localeService = accessor.get(LocaleService);
const state: Pick<ITableOfContentsDialogProps, 'mode'> = { mode: 'pageNumbersOnly' };
await confirmService.open({
id: TABLE_OF_CONTENTS_DIALOG_ID,
width: 420,
title: { title: localeService.t<LocaleKey>('docs-toc-ui.tableOfContents.updateTitle') },
children: {
label: {
name: TABLE_OF_CONTENTS_DIALOG_COMPONENT,
props: {
mode: state.mode,
onModeChange: (mode: ITableOfContentsDialogProps['mode']) => {
state.mode = mode;
},
},
},
},
onConfirm: async () => {
try {
await commandService.executeCommand(UpdateTableOfContentsCommand.id, {
unitId: doc.getUnitId(),
rangeId: toc.rangeId,
mode: state.mode,
});
} finally {
confirmService.close(TABLE_OF_CONTENTS_DIALOG_ID);
}
},
onClose: () => confirmService.close(TABLE_OF_CONTENTS_DIALOG_ID),
});
return true;
},
};
+27
View File
@@ -0,0 +1,27 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MenuConfig } from '@univerjs/ui';
export const DOCS_TOC_UI_PLUGIN_CONFIG_KEY = 'docs-toc-ui.config';
export const configSymbol = Symbol(DOCS_TOC_UI_PLUGIN_CONFIG_KEY);
export interface IUniverDocsTocUIConfig {
menu?: MenuConfig;
}
export const defaultPluginConfig: IUniverDocsTocUIConfig = {};
@@ -0,0 +1,90 @@
/**
* 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, IDocumentData } from '@univerjs/core';
import { CustomRangeType, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, UniverDocsPlugin } from '@univerjs/docs';
import { DesktopRibbonService, IMenuManagerService, IRibbonService, MenuManagerService } from '@univerjs/ui';
import { afterEach, describe, expect, it } from 'vitest';
import { DOC_TABLE_OF_CONTENTS_RIBBON_TAB, tableOfContentsRibbonMenuSchema } from '../../menu/schema';
import { TableOfContentsRibbonController } from '../table-of-contents-ribbon.controller';
describe('TableOfContentsRibbonController', () => {
let univer: Univer;
afterEach(() => univer?.dispose());
function setup() {
univer = new Univer();
univer.registerPlugin(UniverDocsPlugin);
const injector = univer.__getInjector();
injector.add([IMenuManagerService, { useClass: MenuManagerService }]);
injector.add([IRibbonService, { useClass: DesktopRibbonService }]);
injector.add([TableOfContentsRibbonController]);
univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'doc-1',
body: {
dataStream: 'Before\r\u001FContents\u001E\rAfter\r\n',
paragraphs: [6, 17, 23].map((startIndex) => ({ startIndex, paragraphId: `p-${startIndex}` })),
sectionBreaks: [{ startIndex: 24, sectionId: 's-1' }],
customRanges: [{
rangeId: 'toc-1',
rangeType: CustomRangeType.FIELD,
startIndex: 7,
endIndex: 16,
properties: { fieldType: 'TOC' },
}],
},
documentStyle: {},
});
injector.get(IUniverInstanceService).focusUnit('doc-1');
const selection = injector.get(DocSelectionManagerService);
selection.__TEST_ONLY_add([{ startOffset: 0, endOffset: 0, collapsed: true, isActive: true }]);
const select = (offset: number) => selection.__replaceTextRangesWithNoRefresh({
...selection.getSelectionInfo()!,
textRanges: [{ startOffset: offset, endOffset: offset, collapsed: true, isActive: true }],
}, { unitId: 'doc-1', subUnitId: 'doc-1' });
const ribbon = injector.get(IRibbonService);
injector.get(IMenuManagerService).appendRootMenu(tableOfContentsRibbonMenuSchema);
const tabs: string[][] = [];
const active: string[] = [];
const tabSubscription = ribbon.ribbon$.subscribe((items) => tabs.push(items.map((item) => item.key)));
const activeSubscription = ribbon.activatedTab$.subscribe((tab) => active.push(tab));
const controller = injector.get(TableOfContentsRibbonController);
controller.disposeWithMe(tabSubscription);
controller.disposeWithMe(activeSubscription);
return { controller, select, tabs, active };
}
it('activates the contextual tab once on entry and hides it on exit', () => {
const { controller, select, tabs, active } = setup();
select(8);
select(10);
expect(active.filter((tab) => tab === DOC_TABLE_OF_CONTENTS_RIBBON_TAB)).toHaveLength(1);
expect(tabs[tabs.length - 1]).toContain(DOC_TABLE_OF_CONTENTS_RIBBON_TAB);
select(20);
expect(tabs[tabs.length - 1]).not.toContain(DOC_TABLE_OF_CONTENTS_RIBBON_TAB);
controller.dispose();
});
it('hides an active contextual tab when disposed', () => {
const { controller, select, tabs } = setup();
select(8);
expect(tabs[tabs.length - 1]).toContain(DOC_TABLE_OF_CONTENTS_RIBBON_TAB);
controller.dispose();
expect(tabs[tabs.length - 1]).not.toContain(DOC_TABLE_OF_CONTENTS_RIBBON_TAB);
});
});
@@ -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 { Disposable, Inject } from '@univerjs/core';
import { ComponentManager } from '@univerjs/ui';
import { INSERT_TABLE_OF_CONTENTS_DIALOG_COMPONENT, InsertTableOfContentsDialog } from '../views/InsertTableOfContentsDialog';
import { TABLE_OF_CONTENTS_DIALOG_COMPONENT, TableOfContentsDialog } from '../views/TableOfContentsDialog';
export class ComponentsController extends Disposable {
constructor(@Inject(ComponentManager) componentManager: ComponentManager) {
super();
this.disposeWithMe(componentManager.register(TABLE_OF_CONTENTS_DIALOG_COMPONENT, TableOfContentsDialog));
this.disposeWithMe(componentManager.register(INSERT_TABLE_OF_CONTENTS_DIALOG_COMPONENT, InsertTableOfContentsDialog));
}
}
@@ -0,0 +1,141 @@
/**
* 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 } from '@univerjs/core';
import type { Documents, IPoint, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import { ColorKit, generateRandomId, Inject, IUniverInstanceService, RxDisposable, ThemeService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { findTableOfContentsAtOffset } from '@univerjs/docs-toc';
import { NodePositionConvertToCursor, TEXT_RANGE_LAYER_INDEX } from '@univerjs/docs-ui';
import { Rect, RegularPolygon } from '@univerjs/engine-render';
import { takeUntil } from 'rxjs';
const RANGE_PADDING = 4;
export class TableOfContentsHighlightRenderController extends RxDisposable implements IRenderModule {
private _range: RegularPolygon | null = null;
private _outline: Rect | null = null;
private _rangeId: string | null = null;
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService,
@Inject(DocSelectionManagerService) private readonly _selectionManager: DocSelectionManagerService,
@Inject(DocSkeletonManagerService) private readonly _skeletonManager: DocSkeletonManagerService,
@Inject(ThemeService) private readonly _themeService: ThemeService
) {
super();
this._selectionManager.textSelection$.pipe(takeUntil(this.dispose$)).subscribe(() => this._update());
this._skeletonManager.currentSkeleton$.pipe(takeUntil(this.dispose$)).subscribe(() => this._update(true));
this._instanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)
.pipe(takeUntil(this.dispose$))
.subscribe(() => this._update());
this._update();
}
private _update(force = false): void {
const currentDoc = this._instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const selection = this._selectionManager.getActiveTextRange();
const toc = currentDoc?.getUnitId() === this._context.unitId && !selection?.segmentId
? findTableOfContentsAtOffset(this._context.unit.getBody(), selection?.startOffset)
: undefined;
if (!toc) {
this._clear();
return;
}
if (!force && toc.rangeId === this._rangeId) {
return;
}
this._clear();
const document = this._context.mainComponent as Documents | undefined;
const endOffset = Math.max(toc.startIndex + 1, toc.endIndex - 1);
if (!document) {
return;
}
const primary = this._themeService.getColorFromTheme('primary.600');
const skeleton = this._skeletonManager.getSkeleton();
const startPosition = skeleton.findNodePositionByCharIndex(toc.startIndex + 1, true, '', -1);
const endPosition = skeleton.findNodePositionByCharIndex(endOffset, true, '', -1);
if (!startPosition || !endPosition) {
return;
}
const offsetConfig = document.getOffsetConfig();
const { borderBoxPointGroup } = new NodePositionConvertToCursor(offsetConfig, skeleton)
.getRangePointData(startPosition, endPosition);
const bounds = getPointGroupBounds(borderBoxPointGroup);
if (!bounds) {
return;
}
const range = new RegularPolygon(`__DocTocFill__${generateRandomId()}`, {
pointsGroup: borderBoxPointGroup,
left: offsetConfig.docsLeft,
top: offsetConfig.docsTop,
fill: new ColorKit(primary).setAlpha(0.08).toRgbString(),
evented: false,
debounceParentDirty: false,
});
const outline = new Rect(`__DocTocRange__${toc.rangeId}`, {
left: bounds.left + offsetConfig.docsLeft - RANGE_PADDING,
top: bounds.top + offsetConfig.docsTop - RANGE_PADDING,
width: bounds.width + RANGE_PADDING * 2,
height: bounds.height + RANGE_PADDING * 2,
radius: 2,
fill: 'rgba(0, 0, 0, 0)',
stroke: new ColorKit(primary).setAlpha(0.8).toRgbString(),
strokeWidth: 1,
evented: false,
debounceParentDirty: false,
});
this._context.scene.addObject(range, TEXT_RANGE_LAYER_INDEX);
this._context.scene.addObject(outline, TEXT_RANGE_LAYER_INDEX);
this._range = range;
this._outline = outline;
this._rangeId = toc.rangeId;
this._context.scene.makeDirty();
}
private _clear(): void {
if (!this._range && !this._outline) {
this._rangeId = null;
return;
}
this._range?.dispose();
this._outline?.dispose();
this._range = null;
this._outline = null;
this._rangeId = null;
this._context.scene.makeDirty();
}
override dispose(): void {
this._clear();
super.dispose();
}
}
function getPointGroupBounds(pointsGroup: IPoint[][]): { left: number; top: number; width: number; height: number } | null {
const points = pointsGroup.flat();
if (!points.length) {
return null;
}
const left = Math.min(...points.map((point) => point.x));
const top = Math.min(...points.map((point) => point.y));
const right = Math.max(...points.map((point) => point.x));
const bottom = Math.max(...points.map((point) => point.y));
return { left, top, width: right - left, height: bottom - top };
}
@@ -0,0 +1,69 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel } from '@univerjs/core';
import { Disposable, Inject, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { findTableOfContentsAtOffset } from '@univerjs/docs-toc';
import { IRibbonService } from '@univerjs/ui';
import { DOC_TABLE_OF_CONTENTS_RIBBON_TAB } from '../menu/schema';
export class TableOfContentsRibbonController extends Disposable {
private _visible = false;
constructor(
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService,
@Inject(DocSelectionManagerService) private readonly _selectionManager: DocSelectionManagerService,
@IRibbonService private readonly _ribbonService: IRibbonService
) {
super();
this.disposeWithMe(this._selectionManager.textSelection$.subscribe(() => this._update()));
this.disposeWithMe(
this._instanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)
.subscribe(() => this._update())
);
this._update();
}
private _update(): void {
const doc = this._instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const selection = this._selectionManager.getActiveTextRange();
const visible = Boolean(
doc &&
selection &&
!selection.segmentId &&
findTableOfContentsAtOffset(doc.getBody(), selection.startOffset)
);
if (visible === this._visible) {
return;
}
this._visible = visible;
if (visible) {
this._ribbonService.showContextualTab(DOC_TABLE_OF_CONTENTS_RIBBON_TAB, { activate: true });
} else {
this._ribbonService.hideContextualTab(DOC_TABLE_OF_CONTENTS_RIBBON_TAB);
}
}
override dispose(): void {
if (this._visible) {
this._ribbonService.hideContextualTab(DOC_TABLE_OF_CONTENTS_RIBBON_TAB);
}
super.dispose();
}
}
@@ -0,0 +1,33 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, ICommandService } from '@univerjs/core';
import { IMenuManagerService } from '@univerjs/ui';
import { InsertTableOfContentsOperation, OpenTableOfContentsDialogOperation } from '../commands/table-of-contents-dialog.operation';
import { DocsTocUIMenuSchema, tableOfContentsRibbonMenuSchema } from '../menu/schema';
export class DocsTocUIController extends Disposable {
constructor(
@ICommandService commandService: ICommandService,
@IMenuManagerService menuManagerService: IMenuManagerService
) {
super();
this.disposeWithMe(commandService.registerCommand(OpenTableOfContentsDialogOperation));
this.disposeWithMe(commandService.registerCommand(InsertTableOfContentsOperation));
menuManagerService.appendRootMenu(tableOfContentsRibbonMenuSchema);
menuManagerService.mergeMenu(DocsTocUIMenuSchema);
}
}
+2
View File
@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;
+20
View File
@@ -0,0 +1,20 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './global.css';
export { DocsTocUIMenuSchema } from './menu/schema';
export { UniverDocsTocUIPlugin } from './plugin';
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'جدول المحتويات',
insertTitle: 'جدول المحتويات',
automaticTitle: 'جدول تلقائي',
customTitle: 'جدول محتويات مخصص…',
contentsTitle: 'المحتويات',
levels: 'إظهار المستويات',
showPageNumbers: 'إظهار أرقام الصفحات',
rightAlignPageNumbers: 'محاذاة أرقام الصفحات إلى اليمين',
tabLeader: 'حرف تعبئة علامات الجدولة',
leaderNone: 'بلا',
leaderDots: 'نقاط',
leaderDashes: 'شرطات',
leaderUnderline: 'تسطير',
format: 'التنسيقات',
formatFromTemplate: 'من القالب',
formatClassic: 'كلاسيكي',
formatModern: 'حديث',
formatSimple: 'بسيط',
preview: 'معاينة الطباعة',
previewHeading: 'عنوان',
noHeadings: 'لم يتم العثور على عناوين. طبّق أنماط العنوان 1–3 أو اختر المستويات المطابقة.',
updateTitle: 'تحديث جدول المحتويات',
removeTitle: 'إزالة جدول المحتويات',
updatePageNumbersOnly: 'تحديث أرقام الصفحات فقط',
updateEntireTable: 'تحديث الجدول بالكامل',
updateHint: 'اختر كيفية تحديث جدول المحتويات هذا.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'Taula de continguts',
insertTitle: 'Taula de continguts',
automaticTitle: 'Taula automàtica',
customTitle: 'Taula de continguts personalitzada…',
contentsTitle: 'Continguts',
levels: 'Mostra els nivells',
showPageNumbers: 'Mostra els números de pàgina',
rightAlignPageNumbers: 'Alinea els números de pàgina a la dreta',
tabLeader: 'Caràcter de farciment',
leaderNone: 'Cap',
leaderDots: 'Punts',
leaderDashes: 'Guions',
leaderUnderline: 'Subratllat',
format: 'Formats',
formatFromTemplate: 'De la plantilla',
formatClassic: 'Clàssic',
formatModern: 'Modern',
formatSimple: 'Simple',
preview: 'Visualització prèvia d\'impressió',
previewHeading: 'Títol',
noHeadings: 'No s\'ha trobat cap títol. Apliqueu els estils Títol 1–3 o trieu els nivells corresponents.',
updateTitle: 'Actualitza la taula de continguts',
removeTitle: 'Elimina la taula de continguts',
updatePageNumbersOnly: 'Actualitza només els números de pàgina',
updateEntireTable: 'Actualitza tota la taula',
updateHint: 'Trieu com voleu actualitzar aquesta taula de continguts.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'Inhaltsverzeichnis',
insertTitle: 'Inhaltsverzeichnis',
automaticTitle: 'Automatisches Verzeichnis',
customTitle: 'Benutzerdefiniertes Inhaltsverzeichnis…',
contentsTitle: 'Inhalt',
levels: 'Ebenen anzeigen',
showPageNumbers: 'Seitenzahlen anzeigen',
rightAlignPageNumbers: 'Seitenzahlen rechtsbündig',
tabLeader: 'Füllzeichen',
leaderNone: 'Keine',
leaderDots: 'Punkte',
leaderDashes: 'Striche',
leaderUnderline: 'Unterstrich',
format: 'Formate',
formatFromTemplate: 'Von Vorlage',
formatClassic: 'Klassisch',
formatModern: 'Modern',
formatSimple: 'Einfach',
preview: 'Druckvorschau',
previewHeading: 'Überschrift',
noHeadings: 'Keine Überschriften gefunden. Wenden Sie Überschrift 1–3 an oder wählen Sie passende Ebenen.',
updateTitle: 'Inhaltsverzeichnis aktualisieren',
removeTitle: 'Inhaltsverzeichnis entfernen',
updatePageNumbersOnly: 'Nur Seitenzahlen aktualisieren',
updateEntireTable: 'Gesamtes Verzeichnis aktualisieren',
updateHint: 'Wählen Sie die Aktualisierungsmethode.',
},
},
};
export default locale;
+50
View File
@@ -0,0 +1,50 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const locale = {
'docs-toc-ui': {
tableOfContents: {
title: 'Table of contents',
insertTitle: 'Table of contents',
automaticTitle: 'Automatic table',
customTitle: 'Custom table of contents…',
contentsTitle: 'Contents',
levels: 'Show levels',
showPageNumbers: 'Show page numbers',
rightAlignPageNumbers: 'Right align page numbers',
tabLeader: 'Tab leader',
leaderNone: 'None',
leaderDots: 'Dots',
leaderDashes: 'Dashes',
leaderUnderline: 'Underline',
format: 'Formats',
formatFromTemplate: 'From template',
formatClassic: 'Classic',
formatModern: 'Modern',
formatSimple: 'Simple',
preview: 'Print preview',
previewHeading: 'Heading',
noHeadings: 'No headings were found. Apply Heading 1–3 styles first, or choose matching levels.',
updateTitle: 'Update table of contents',
removeTitle: 'Remove table of contents',
updatePageNumbersOnly: 'Update page numbers only',
updateEntireTable: 'Update entire table',
updateHint: 'Choose how Univer should refresh this table of contents.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'Tabla de contenido',
insertTitle: 'Tabla de contenido',
automaticTitle: 'Tabla automática',
customTitle: 'Tabla de contenido personalizada…',
contentsTitle: 'Contenido',
levels: 'Mostrar niveles',
showPageNumbers: 'Mostrar números de página',
rightAlignPageNumbers: 'Alinear números de página a la derecha',
tabLeader: 'Carácter de relleno',
leaderNone: 'Ninguno',
leaderDots: 'Puntos',
leaderDashes: 'Guiones',
leaderUnderline: 'Subrayado',
format: 'Formatos',
formatFromTemplate: 'De la plantilla',
formatClassic: 'Clásico',
formatModern: 'Moderno',
formatSimple: 'Sencillo',
preview: 'Vista previa de impresión',
previewHeading: 'Título',
noHeadings: 'No se encontraron títulos. Aplique los estilos Título 1–3 o elija los niveles correspondientes.',
updateTitle: 'Actualizar tabla de contenido',
removeTitle: 'Quitar tabla de contenido',
updatePageNumbersOnly: 'Actualizar solo los números de página',
updateEntireTable: 'Actualizar toda la tabla',
updateHint: 'Elija cómo actualizar esta tabla de contenido.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'فهرست مطالب',
insertTitle: 'فهرست مطالب',
automaticTitle: 'فهرست خودکار',
customTitle: 'فهرست مطالب سفارشی…',
contentsTitle: 'مطالب',
levels: 'نمایش سطوح',
showPageNumbers: 'نمایش شماره صفحات',
rightAlignPageNumbers: 'تراز شماره صفحات به راست',
tabLeader: 'نویسهٔ راهنمای تب',
leaderNone: 'هیچ‌کدام',
leaderDots: 'نقطه',
leaderDashes: 'خط تیره',
leaderUnderline: 'زیرخط',
format: 'قالب‌ها',
formatFromTemplate: 'از الگو',
formatClassic: 'کلاسیک',
formatModern: 'مدرن',
formatSimple: 'ساده',
preview: 'پیش‌نمایش چاپ',
previewHeading: 'عنوان',
noHeadings: 'عنوانی یافت نشد. سبک‌های عنوان ۱ تا ۳ را اعمال کنید یا سطوح منطبق را انتخاب کنید.',
updateTitle: 'به‌روزرسانی فهرست مطالب',
removeTitle: 'حذف فهرست مطالب',
updatePageNumbersOnly: 'فقط به‌روزرسانی شماره صفحات',
updateEntireTable: 'به‌روزرسانی کل فهرست',
updateHint: 'روش به‌روزرسانی این فهرست مطالب را انتخاب کنید.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'Table des matières',
insertTitle: 'Table des matières',
automaticTitle: 'Table automatique',
customTitle: 'Table des matières personnalisée…',
contentsTitle: 'Sommaire',
levels: 'Afficher les niveaux',
showPageNumbers: 'Afficher les numéros de page',
rightAlignPageNumbers: 'Aligner les numéros de page à droite',
tabLeader: 'Caractères de suite',
leaderNone: 'Aucun',
leaderDots: 'Points',
leaderDashes: 'Tirets',
leaderUnderline: 'Soulignement',
format: 'Formats',
formatFromTemplate: 'Depuis le modèle',
formatClassic: 'Classique',
formatModern: 'Moderne',
formatSimple: 'Simple',
preview: 'Aperçu avant impression',
previewHeading: 'Titre',
noHeadings: 'Aucun titre trouvé. Appliquez les styles Titre 1–3 ou choisissez les niveaux correspondants.',
updateTitle: 'Mettre à jour la table des matières',
removeTitle: 'Supprimer la table des matières',
updatePageNumbersOnly: 'Mettre à jour uniquement les numéros de page',
updateEntireTable: 'Mettre à jour toute la table',
updateHint: 'Choisissez comment mettre à jour cette table des matières.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'Daftar isi',
insertTitle: 'Daftar isi',
automaticTitle: 'Daftar otomatis',
customTitle: 'Daftar isi kustom…',
contentsTitle: 'Daftar isi',
levels: 'Tampilkan tingkat',
showPageNumbers: 'Tampilkan nomor halaman',
rightAlignPageNumbers: 'Ratakan nomor halaman ke kanan',
tabLeader: 'Pengisi tab',
leaderNone: 'Tidak ada',
leaderDots: 'Titik',
leaderDashes: 'Garis putus-putus',
leaderUnderline: 'Garis bawah',
format: 'Format',
formatFromTemplate: 'Dari templat',
formatClassic: 'Klasik',
formatModern: 'Modern',
formatSimple: 'Sederhana',
preview: 'Pratinjau cetak',
previewHeading: 'Judul',
noHeadings: 'Tidak ada judul yang ditemukan. Terapkan gaya Judul 1–3 atau pilih tingkat yang sesuai.',
updateTitle: 'Perbarui daftar isi',
removeTitle: 'Hapus daftar isi',
updatePageNumbersOnly: 'Perbarui nomor halaman saja',
updateEntireTable: 'Perbarui seluruh daftar',
updateHint: 'Pilih cara memperbarui daftar isi ini.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'Sommario',
insertTitle: 'Sommario',
automaticTitle: 'Sommario automatico',
customTitle: 'Sommario personalizzato…',
contentsTitle: 'Sommario',
levels: 'Mostra livelli',
showPageNumbers: 'Mostra numeri di pagina',
rightAlignPageNumbers: 'Allinea i numeri di pagina a destra',
tabLeader: 'Carattere di riempimento',
leaderNone: 'Nessuno',
leaderDots: 'Punti',
leaderDashes: 'Trattini',
leaderUnderline: 'Sottolineatura',
format: 'Formati',
formatFromTemplate: 'Da modello',
formatClassic: 'Classico',
formatModern: 'Moderno',
formatSimple: 'Semplice',
preview: 'Anteprima di stampa',
previewHeading: 'Titolo',
noHeadings: 'Nessun titolo trovato. Applica gli stili Titolo 1–3 o scegli i livelli corrispondenti.',
updateTitle: 'Aggiorna sommario',
removeTitle: 'Rimuovi sommario',
updatePageNumbersOnly: 'Aggiorna solo i numeri di pagina',
updateEntireTable: 'Aggiorna intero sommario',
updateHint: 'Scegli come aggiornare questo sommario.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: '目次',
insertTitle: '目次',
automaticTitle: '自動作成の目次',
customTitle: 'ユーザー設定の目次…',
contentsTitle: '目次',
levels: '表示するレベル',
showPageNumbers: 'ページ番号を表示する',
rightAlignPageNumbers: 'ページ番号を右揃えにする',
tabLeader: 'タブリーダー',
leaderNone: 'なし',
leaderDots: '点線',
leaderDashes: '破線',
leaderUnderline: '下線',
format: '書式',
formatFromTemplate: 'テンプレートから',
formatClassic: 'クラシック',
formatModern: 'モダン',
formatSimple: 'シンプル',
preview: '印刷プレビュー',
previewHeading: '見出し',
noHeadings: '見出しが見つかりません。見出し 1~3 のスタイルを適用するか、対応するレベルを選択してください。',
updateTitle: '目次の更新',
removeTitle: '目次の削除',
updatePageNumbersOnly: 'ページ番号だけを更新する',
updateEntireTable: '目次をすべて更新する',
updateHint: 'この目次の更新方法を選択してください。',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: '목차',
insertTitle: '목차',
automaticTitle: '자동 목차',
customTitle: '사용자 지정 목차…',
contentsTitle: '목차',
levels: '표시할 수준',
showPageNumbers: '페이지 번호 표시',
rightAlignPageNumbers: '페이지 번호 오른쪽 맞춤',
tabLeader: '탭 채움선',
leaderNone: '없음',
leaderDots: '점선',
leaderDashes: '파선',
leaderUnderline: '밑줄',
format: '서식',
formatFromTemplate: '서식 파일에서',
formatClassic: '클래식',
formatModern: '모던',
formatSimple: '간단',
preview: '인쇄 미리 보기',
previewHeading: '제목',
noHeadings: '제목을 찾을 수 없습니다. 제목 1–3 스타일을 적용하거나 해당 수준을 선택하세요.',
updateTitle: '목차 업데이트',
removeTitle: '목차 제거',
updatePageNumbersOnly: '페이지 번호만 업데이트',
updateEntireTable: '목차 전체 업데이트',
updateHint: '이 목차를 업데이트할 방법을 선택하세요.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'Spis treści',
insertTitle: 'Spis treści',
automaticTitle: 'Automatyczny spis treści',
customTitle: 'Niestandardowy spis treści…',
contentsTitle: 'Spis treści',
levels: 'Pokaż poziomy',
showPageNumbers: 'Pokaż numery stron',
rightAlignPageNumbers: 'Wyrównaj numery stron do prawej',
tabLeader: 'Znak wiodący',
leaderNone: 'Brak',
leaderDots: 'Kropki',
leaderDashes: 'Kreski',
leaderUnderline: 'Podkreślenie',
format: 'Formaty',
formatFromTemplate: 'Z szablonu',
formatClassic: 'Klasyczny',
formatModern: 'Nowoczesny',
formatSimple: 'Prosty',
preview: 'Podgląd wydruku',
previewHeading: 'Nagłówek',
noHeadings: 'Nie znaleziono nagłówków. Zastosuj style Nagłówek 1–3 lub wybierz odpowiednie poziomy.',
updateTitle: 'Aktualizuj spis treści',
removeTitle: 'Usuń spis treści',
updatePageNumbersOnly: 'Aktualizuj tylko numery stron',
updateEntireTable: 'Aktualizuj cały spis',
updateHint: 'Wybierz sposób aktualizacji tego spisu treści.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'Sumário',
insertTitle: 'Sumário',
automaticTitle: 'Sumário automático',
customTitle: 'Sumário personalizado…',
contentsTitle: 'Sumário',
levels: 'Mostrar níveis',
showPageNumbers: 'Mostrar números de página',
rightAlignPageNumbers: 'Alinhar números de página à direita',
tabLeader: 'Preenchimento de tabulação',
leaderNone: 'Nenhum',
leaderDots: 'Pontos',
leaderDashes: 'Traços',
leaderUnderline: 'Sublinhado',
format: 'Formatos',
formatFromTemplate: 'Do modelo',
formatClassic: 'Clássico',
formatModern: 'Moderno',
formatSimple: 'Simples',
preview: 'Visualização de impressão',
previewHeading: 'Título',
noHeadings: 'Nenhum título encontrado. Aplique os estilos Título 1–3 ou escolha os níveis correspondentes.',
updateTitle: 'Atualizar sumário',
removeTitle: 'Remover sumário',
updatePageNumbersOnly: 'Atualizar apenas os números de página',
updateEntireTable: 'Atualizar o sumário inteiro',
updateHint: 'Escolha como atualizar este sumário.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'Оглавление',
insertTitle: 'Оглавление',
automaticTitle: 'Автособираемое оглавление',
customTitle: 'Настраиваемое оглавление…',
contentsTitle: 'Содержание',
levels: 'Показать уровни',
showPageNumbers: 'Показать номера страниц',
rightAlignPageNumbers: 'Номера страниц по правому краю',
tabLeader: 'Заполнитель',
leaderNone: 'Нет',
leaderDots: 'Точки',
leaderDashes: 'Тире',
leaderUnderline: 'Подчёркивание',
format: 'Форматы',
formatFromTemplate: 'Из шаблона',
formatClassic: 'Классический',
formatModern: 'Современный',
formatSimple: 'Простой',
preview: 'Предварительный просмотр',
previewHeading: 'Заголовок',
noHeadings: 'Заголовки не найдены. Примените стили «Заголовок 1–3» или выберите соответствующие уровни.',
updateTitle: 'Обновить оглавление',
removeTitle: 'Удалить оглавление',
updatePageNumbersOnly: 'Обновить только номера страниц',
updateEntireTable: 'Обновить целиком',
updateHint: 'Выберите способ обновления оглавления.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'Obsah',
insertTitle: 'Obsah',
automaticTitle: 'Automatický obsah',
customTitle: 'Vlastný obsah…',
contentsTitle: 'Obsah',
levels: 'Zobraziť úrovne',
showPageNumbers: 'Zobraziť čísla strán',
rightAlignPageNumbers: 'Zarovnať čísla strán doprava',
tabLeader: 'Vodiaci znak',
leaderNone: 'Žiadny',
leaderDots: 'Bodky',
leaderDashes: 'Pomlčky',
leaderUnderline: 'Podčiarknutie',
format: 'Formáty',
formatFromTemplate: 'Zo šablóny',
formatClassic: 'Klasický',
formatModern: 'Moderný',
formatSimple: 'Jednoduchý',
preview: 'Ukážka pred tlačou',
previewHeading: 'Nadpis',
noHeadings: 'Nenašli sa žiadne nadpisy. Použite štýly Nadpis 1–3 alebo vyberte zodpovedajúce úrovne.',
updateTitle: 'Aktualizovať obsah',
removeTitle: 'Odstrániť obsah',
updatePageNumbersOnly: 'Aktualizovať iba čísla strán',
updateEntireTable: 'Aktualizovať celý obsah',
updateHint: 'Vyberte spôsob aktualizácie tohto obsahu.',
},
},
};
export default locale;
+20
View File
@@ -0,0 +1,20 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleLeafKeys } from '@univerjs/core';
import type enUS from './en-US';
export type LocaleKey = LocaleLeafKeys<typeof enUS>;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: 'Mục lục',
insertTitle: 'Mục lục',
automaticTitle: 'Mục lục tự động',
customTitle: 'Mục lục tùy chỉnh…',
contentsTitle: 'Mục lục',
levels: 'Hiển thị cấp độ',
showPageNumbers: 'Hiển thị số trang',
rightAlignPageNumbers: 'Căn phải số trang',
tabLeader: 'Ký tự dẫn tab',
leaderNone: 'Không có',
leaderDots: 'Dấu chấm',
leaderDashes: 'Dấu gạch ngang',
leaderUnderline: 'Gạch dưới',
format: 'Định dạng',
formatFromTemplate: 'Từ mẫu',
formatClassic: 'Cổ điển',
formatModern: 'Hiện đại',
formatSimple: 'Đơn giản',
preview: 'Xem trước khi in',
previewHeading: 'Tiêu đề',
noHeadings: 'Không tìm thấy tiêu đề. Hãy áp dụng kiểu Tiêu đề 1–3 hoặc chọn cấp độ phù hợp.',
updateTitle: 'Cập nhật mục lục',
removeTitle: 'Xóa mục lục',
updatePageNumbersOnly: 'Chỉ cập nhật số trang',
updateEntireTable: 'Cập nhật toàn bộ mục lục',
updateHint: 'Chọn cách cập nhật mục lục này.',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: '目录',
insertTitle: '插入目录',
automaticTitle: '自动目录',
customTitle: '自定义目录…',
contentsTitle: '目录',
levels: '显示级别',
showPageNumbers: '显示页码',
rightAlignPageNumbers: '页码右对齐',
tabLeader: '制表符前导符',
leaderNone: '无',
leaderDots: '点线',
leaderDashes: '短划线',
leaderUnderline: '下划线',
format: '格式',
formatFromTemplate: '来自模板',
formatClassic: '经典',
formatModern: '现代',
formatSimple: '简洁',
preview: '打印预览',
previewHeading: '标题',
noHeadings: '未找到标题。请先应用标题 1–3 样式,或选择匹配的标题级别。',
updateTitle: '更新目录',
removeTitle: '删除目录',
updatePageNumbersOnly: '只更新页码',
updateEntireTable: '更新整个目录',
updateHint: '请选择 Univer 更新此目录的方式。',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: '目錄',
insertTitle: '插入目錄',
automaticTitle: '自動目錄',
customTitle: '自訂目錄…',
contentsTitle: '目錄',
levels: '顯示級別',
showPageNumbers: '顯示頁碼',
rightAlignPageNumbers: '頁碼靠右對齊',
tabLeader: '定位點前置字元',
leaderNone: '無',
leaderDots: '點線',
leaderDashes: '短劃線',
leaderUnderline: '底線',
format: '格式',
formatFromTemplate: '來自範本',
formatClassic: '傳統',
formatModern: '現代',
formatSimple: '簡潔',
preview: '預覽列印',
previewHeading: '標題',
noHeadings: '找不到標題。請先套用標題 1–3 樣式,或選擇相符的標題級別。',
updateTitle: '更新目錄',
removeTitle: '刪除目錄',
updatePageNumbersOnly: '只更新頁碼',
updateEntireTable: '更新整個目錄',
updateHint: '請選擇 Univer 更新此目錄的方式。',
},
},
};
export default locale;
+52
View File
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-toc-ui': {
tableOfContents: {
title: '目錄',
insertTitle: '目錄',
automaticTitle: '自動目錄',
customTitle: '自訂目錄…',
contentsTitle: '目錄',
levels: '顯示階層',
showPageNumbers: '顯示頁碼',
rightAlignPageNumbers: '頁碼靠右對齊',
tabLeader: '定位點前置字元',
leaderNone: '無',
leaderDots: '點線',
leaderDashes: '虛線',
leaderUnderline: '底線',
format: '格式',
formatFromTemplate: '來自範本',
formatClassic: '傳統',
formatModern: '現代',
formatSimple: '簡單',
preview: '預覽列印',
previewHeading: '標題',
noHeadings: '找不到標題。請先套用標題 1–3 樣式,或選擇相符的階層。',
updateTitle: '更新目錄',
removeTitle: '移除目錄',
updatePageNumbersOnly: '只更新頁碼',
updateEntireTable: '更新整個目錄',
updateHint: '選擇此目錄的更新方式。',
},
},
};
export default locale;
@@ -0,0 +1,89 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import { BooleanNumber, CustomRangeType, ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, UniverDocsPlugin } from '@univerjs/docs';
import { MenuItemType } from '@univerjs/ui';
import { firstValueFrom } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { OpenTableOfContentsDialogOperation } from '../../commands/table-of-contents-dialog.operation';
import { DeleteTableOfContentsMenuFactory, InsertTableOfContentsMenuFactory, UpdateTableOfContentsMenuFactory } from '../menu';
describe('table of contents menu factories', () => {
let univer: Univer;
afterEach(() => univer?.dispose());
it.each([undefined, BooleanNumber.FALSE, BooleanNumber.TRUE])('switches insert and manage actions with field lock %s', async (locked) => {
univer = new Univer();
univer.registerPlugin(UniverDocsPlugin);
const accessor = univer.__getInjector();
univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'doc-1',
body: {
dataStream: '\u001FContents\u001E\rBody\r\n',
paragraphs: [{ startIndex: 10, paragraphId: 'toc-p' }, { startIndex: 15, paragraphId: 'body-p' }],
sectionBreaks: [{ startIndex: 16, sectionId: 's-1' }],
customRanges: [{
rangeId: 'toc',
rangeType: CustomRangeType.FIELD,
startIndex: 0,
endIndex: 9,
properties: { fieldType: 'TOC', locked },
}],
},
documentStyle: {},
});
accessor.get(IUniverInstanceService).focusUnit('doc-1');
const selection = accessor.get(DocSelectionManagerService);
selection.__TEST_ONLY_add([{ startOffset: 5, endOffset: 5, collapsed: true, isActive: true }]);
const select = (startOffset: number, endOffset = startOffset) => selection.__replaceTextRangesWithNoRefresh({
...selection.getSelectionInfo()!,
textRanges: [{ startOffset, endOffset, collapsed: startOffset === endOffset, isActive: true }],
}, { unitId: 'doc-1', subUnitId: 'doc-1' });
const manageHidden = [
UpdateTableOfContentsMenuFactory(accessor).hidden$,
DeleteTableOfContentsMenuFactory(accessor).hidden$,
];
const insertMenu = InsertTableOfContentsMenuFactory(accessor);
expect(insertMenu.icon).toBe('UnorderIcon');
expect(insertMenu.type).toBe(MenuItemType.SUBITEMS);
expect(Array.isArray(insertMenu.selections) ? insertMenu.selections.map((selection) => selection.value) : []).toEqual(['automatic', 'custom']);
expect(UpdateTableOfContentsMenuFactory(accessor).icon).toBe('UnorderIcon');
if (manageHidden.some((state) => !state)) {
throw new Error('TOC menus must expose hidden state.');
}
expect(await Promise.all(manageHidden.map((state) => firstValueFrom(state!)))).toEqual([false, false]);
expect(await firstValueFrom(UpdateTableOfContentsMenuFactory(accessor).disabled$!)).toBe(locked === BooleanNumber.TRUE);
expect(await firstValueFrom(DeleteTableOfContentsMenuFactory(accessor).disabled$!)).toBe(false);
if (locked === BooleanNumber.TRUE) {
const commands = accessor.get(ICommandService);
commands.registerCommand(OpenTableOfContentsDialogOperation);
expect(await commands.executeCommand(OpenTableOfContentsDialogOperation.id)).toBe(false);
}
expect(await firstValueFrom(insertMenu.hidden$!)).toBe(false);
expect(await firstValueFrom(insertMenu.disabled$!)).toBe(true);
select(11);
expect(await Promise.all(manageHidden.map((state) => firstValueFrom(state!)))).toEqual([true, true]);
expect(await firstValueFrom(insertMenu.hidden$!)).toBe(false);
expect(await firstValueFrom(insertMenu.disabled$!)).toBe(false);
select(11, 12);
expect(await firstValueFrom(insertMenu.hidden$!)).toBe(false);
expect(await firstValueFrom(insertMenu.disabled$!)).toBe(true);
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICustomRange } from '@univerjs/core';
import type { IMenuButtonItem, IMenuSelectorItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { BooleanNumber, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocStateChangeManagerService } from '@univerjs/docs';
import { DeleteTableOfContentsCommand, findTableOfContentsAtOffset } from '@univerjs/docs-toc';
import { disableMenuWithoutDocumentUnitPermission } from '@univerjs/docs-ui';
import { UnitAction } from '@univerjs/protocol';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { combineLatest, map, Observable } from 'rxjs';
import {
InsertTableOfContentsOperation,
OpenTableOfContentsDialogOperation,
} from '../commands/table-of-contents-dialog.operation';
export const INSERT_TABLE_OF_CONTENTS_MENU_ID = 'doc.menu.insert-table-of-contents';
function getActiveTableOfContentsObservable(accessor: IAccessor): Observable<ICustomRange | undefined> {
const selectionManager = accessor.get(DocSelectionManagerService);
const instanceService = accessor.get(IUniverInstanceService);
return new Observable((subscriber) => {
const emit = () => {
const doc = instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const selection = selectionManager.getActiveTextRange();
subscriber.next(selection?.segmentId ? undefined : findTableOfContentsAtOffset(doc?.getBody(), selection?.startOffset));
};
emit();
const selectionSubscription = selectionManager.textSelection$.subscribe(emit);
const unitSubscription = instanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_DOC).subscribe(emit);
const documentSubscription = accessor.get(DocStateChangeManagerService).docStateChange$.subscribe(emit);
return () => {
selectionSubscription.unsubscribe();
unitSubscription.unsubscribe();
documentSubscription.unsubscribe();
};
});
}
function getInsertTableOfContentsDisabledObservable(accessor: IAccessor): Observable<boolean> {
const selectionManager = accessor.get(DocSelectionManagerService);
const instanceService = accessor.get(IUniverInstanceService);
return new Observable((subscriber) => {
const emit = () => {
const doc = instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const selection = selectionManager.getActiveTextRange();
subscriber.next(!selection || selection.startOffset !== selection.endOffset || Boolean(selection.segmentId) ||
Boolean(findTableOfContentsAtOffset(doc?.getBody(), selection.startOffset)));
};
emit();
const selectionSubscription = selectionManager.textSelection$.subscribe(emit);
const unitSubscription = instanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_DOC).subscribe(emit);
return () => {
selectionSubscription.unsubscribe();
unitSubscription.unsubscribe();
};
});
}
export function InsertTableOfContentsMenuFactory(accessor: IAccessor): IMenuSelectorItem<LocaleKey> {
const permissionDisabled$ = disableMenuWithoutDocumentUnitPermission(accessor, UnitAction.Edit);
return {
id: INSERT_TABLE_OF_CONTENTS_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'UnorderIcon',
title: 'docs-toc-ui.tableOfContents.insertTitle',
selectionsCommandId: InsertTableOfContentsOperation.id,
selections: [{
value: 'automatic',
label: 'docs-toc-ui.tableOfContents.automaticTitle',
icon: 'UnorderIcon',
params: { mode: 'automatic' },
}, {
value: 'custom',
label: 'docs-toc-ui.tableOfContents.customTitle',
icon: 'DocSettingIcon',
params: { mode: 'custom' },
}],
disabled$: combineLatest([
permissionDisabled$,
getInsertTableOfContentsDisabledObservable(accessor),
]).pipe(map(([permissionDisabled, unavailable]) => permissionDisabled || unavailable)),
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function UpdateTableOfContentsMenuFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: OpenTableOfContentsDialogOperation.id,
commandId: OpenTableOfContentsDialogOperation.id,
type: MenuItemType.BUTTON,
icon: 'UnorderIcon',
title: 'docs-toc-ui.tableOfContents.updateTitle',
disabled$: combineLatest([
disableMenuWithoutDocumentUnitPermission(accessor, UnitAction.Edit),
getActiveTableOfContentsObservable(accessor),
]).pipe(map(([permissionDisabled, toc]) => permissionDisabled || !toc || toc.properties?.locked === BooleanNumber.TRUE)),
hidden$: combineLatest([
getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
getActiveTableOfContentsObservable(accessor),
]).pipe(map(([hidden, toc]) => hidden || !toc)),
};
}
export function DeleteTableOfContentsMenuFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: DeleteTableOfContentsCommand.id,
commandId: DeleteTableOfContentsCommand.id,
type: MenuItemType.BUTTON,
icon: 'DeleteIcon',
title: 'docs-toc-ui.tableOfContents.removeTitle',
disabled$: disableMenuWithoutDocumentUnitPermission(accessor, UnitAction.Edit),
hidden$: combineLatest([
getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
getActiveTableOfContentsObservable(accessor),
]).pipe(map(([hidden, toc]) => hidden || !toc)),
};
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MenuSchemaType } from '@univerjs/ui';
import { DeleteTableOfContentsCommand } from '@univerjs/docs-toc';
import { ContextMenuGroup, ContextMenuPosition, MenuManagerPosition, RibbonInsertGroup } from '@univerjs/ui';
import { OpenTableOfContentsDialogOperation } from '../commands/table-of-contents-dialog.operation';
import { DeleteTableOfContentsMenuFactory, INSERT_TABLE_OF_CONTENTS_MENU_ID, InsertTableOfContentsMenuFactory, UpdateTableOfContentsMenuFactory } from './menu';
export const DOC_TABLE_OF_CONTENTS_RIBBON_TAB = 'ribbon.table-of-contents';
const DOC_TABLE_OF_CONTENTS_RIBBON_GROUP = 'ribbon.table-of-contents.manage';
export const tableOfContentsRibbonMenuSchema: MenuSchemaType = {
[MenuManagerPosition.RIBBON]: {
[DOC_TABLE_OF_CONTENTS_RIBBON_TAB]: {
order: 100,
title: 'docs-toc-ui.tableOfContents.title',
contextual: true,
[DOC_TABLE_OF_CONTENTS_RIBBON_GROUP]: {
order: 0,
[OpenTableOfContentsDialogOperation.id]: {
order: 0,
gridLayout: { row: 1, column: 1, rowSpan: 2, showLabel: true },
menuItemFactory: UpdateTableOfContentsMenuFactory,
},
[DeleteTableOfContentsCommand.id]: {
order: 1,
gridLayout: { row: 1, column: 2, rowSpan: 2, showLabel: true },
menuItemFactory: DeleteTableOfContentsMenuFactory,
},
},
},
},
};
export const DocsTocUIMenuSchema: MenuSchemaType = {
[RibbonInsertGroup.CELL]: {
[INSERT_TABLE_OF_CONTENTS_MENU_ID]: {
order: 7,
gridLayout: { row: 1, column: 1, showLabel: true },
menuItemFactory: InsertTableOfContentsMenuFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.LAYOUT]: {
[OpenTableOfContentsDialogOperation.id]: {
order: -1,
menuItemFactory: UpdateTableOfContentsMenuFactory,
},
[DeleteTableOfContentsCommand.id]: {
order: -0.5,
menuItemFactory: DeleteTableOfContentsMenuFactory,
},
},
},
};
+72
View File
@@ -0,0 +1,72 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverDocsTocUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsTocPlugin } from '@univerjs/docs-toc';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { defaultPluginConfig, DOCS_TOC_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { TableOfContentsHighlightRenderController } from './controllers/table-of-contents-highlight.render-controller';
import { TableOfContentsRibbonController } from './controllers/table-of-contents-ribbon.controller';
import { DocsTocUIController } from './controllers/ui.controller';
@DependentOn(UniverDocsPlugin, UniverRenderEnginePlugin, UniverDocsTocPlugin, UniverDocsUIPlugin)
export class UniverDocsTocUIPlugin extends Plugin {
static override pluginName = 'DOCS_TOC_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_DOC;
constructor(
private readonly _config: IUniverDocsTocUIConfig = defaultPluginConfig,
@Inject(Injector) protected override _injector: Injector,
@IConfigService private readonly _configService: IConfigService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService
) {
super();
const { menu, ...rest } = merge({}, defaultPluginConfig, this._config);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(DOCS_TOC_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
([
[ComponentsController],
[DocsTocUIController],
[TableOfContentsRibbonController],
] as Dependency[]).forEach((dependency) => this._injector.add(dependency));
this._injector.get(ComponentsController);
this._injector.get(DocsTocUIController);
}
override onReady(): void {
this._injector.get(TableOfContentsRibbonController);
}
override onRendered(): void {
this.disposeWithMe(this._renderManagerService.registerRenderModule(
UniverInstanceType.UNIVER_DOC,
[TableOfContentsHighlightRenderController]
));
}
}
@@ -0,0 +1,168 @@
/**
* 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 { TableOfContentsFormat, TableOfContentsTabLeader } from '@univerjs/docs-toc';
import type { LocaleKey } from '../locale/types';
import { LocaleService } from '@univerjs/core';
import { useDependency } from '@univerjs/ui';
import { useState } from 'react';
export const INSERT_TABLE_OF_CONTENTS_DIALOG_COMPONENT = 'doc.insert-table-of-contents-dialog';
export interface IInsertTableOfContentsDialogValue {
levels: number;
showPageNumbers: boolean;
rightAlignPageNumbers: boolean;
tabLeader: TableOfContentsTabLeader;
format: TableOfContentsFormat;
}
export interface IInsertTableOfContentsDialogProps {
options: IInsertTableOfContentsDialogValue;
onChange: (value: IInsertTableOfContentsDialogValue) => void;
}
const FORMAT_OPTIONS: Array<[TableOfContentsFormat, LocaleKey]> = [
['fromTemplate', 'docs-toc-ui.tableOfContents.formatFromTemplate'],
['classic', 'docs-toc-ui.tableOfContents.formatClassic'],
['modern', 'docs-toc-ui.tableOfContents.formatModern'],
['simple', 'docs-toc-ui.tableOfContents.formatSimple'],
];
const LEADER_OPTIONS: Array<[TableOfContentsTabLeader, LocaleKey]> = [
['none', 'docs-toc-ui.tableOfContents.leaderNone'],
['dots', 'docs-toc-ui.tableOfContents.leaderDots'],
['dashes', 'docs-toc-ui.tableOfContents.leaderDashes'],
['underline', 'docs-toc-ui.tableOfContents.leaderUnderline'],
];
export function InsertTableOfContentsDialog(props: IInsertTableOfContentsDialogProps) {
const localeService = useDependency(LocaleService);
const [value, setValue] = useState(props.options);
const update = (patch: Partial<IInsertTableOfContentsDialogValue>) => {
const next = { ...value, ...patch };
setValue(next);
props.onChange(next);
};
const leader = value.tabLeader === 'dots' ? '··········' : value.tabLeader === 'dashes' ? '----------' : value.tabLeader === 'underline' ? '__________' : '';
return (
<div className="univer-grid univer-gap-4 univer-py-2" data-testid="table-of-contents-insert-dialog">
<div className="univer-grid univer-grid-cols-2 univer-gap-3">
<label className="univer-grid univer-gap-1 univer-text-sm">
<span>{localeService.t<LocaleKey>('docs-toc-ui.tableOfContents.levels')}</span>
<select
className="univer-h-8 univer-rounded-md univer-border univer-bg-transparent univer-px-2"
value={value.levels}
onChange={(event) => update({ levels: Number(event.target.value) })}
>
{Array.from({ length: 9 }, (_, index) => index + 1).map((level) => (
<option key={level} value={level}>{level}</option>
))}
</select>
</label>
<label className="univer-grid univer-gap-1 univer-text-sm">
<span>{localeService.t<LocaleKey>('docs-toc-ui.tableOfContents.format')}</span>
<select
className="univer-h-8 univer-rounded-md univer-border univer-bg-transparent univer-px-2"
value={value.format}
onChange={(event) => update({ format: event.target.value as TableOfContentsFormat })}
>
{FORMAT_OPTIONS.map(([format, label]) => (
<option key={format} value={format}>{localeService.t<LocaleKey>(label)}</option>
))}
</select>
</label>
</div>
<div className="univer-grid univer-gap-2 univer-text-sm">
<label className="univer-flex univer-items-center univer-gap-2">
<input
type="checkbox"
checked={value.showPageNumbers}
onChange={(event) => update({
showPageNumbers: event.target.checked,
rightAlignPageNumbers: event.target.checked && value.rightAlignPageNumbers,
})}
/>
<span>{localeService.t<LocaleKey>('docs-toc-ui.tableOfContents.showPageNumbers')}</span>
</label>
<label className="univer-flex univer-items-center univer-gap-2">
<input
type="checkbox"
checked={value.rightAlignPageNumbers}
disabled={!value.showPageNumbers}
onChange={(event) => update({ rightAlignPageNumbers: event.target.checked })}
/>
<span>{localeService.t<LocaleKey>('docs-toc-ui.tableOfContents.rightAlignPageNumbers')}</span>
</label>
<label className="univer-flex univer-items-center univer-justify-between univer-gap-3">
<span>{localeService.t<LocaleKey>('docs-toc-ui.tableOfContents.tabLeader')}</span>
<select
className="
univer-h-8 univer-min-w-40 univer-rounded-md univer-border univer-bg-transparent univer-px-2
"
value={value.tabLeader}
disabled={!value.showPageNumbers || !value.rightAlignPageNumbers}
onChange={(event) => update({ tabLeader: event.target.value as TableOfContentsTabLeader })}
>
{LEADER_OPTIONS.map(([tabLeader, label]) => (
<option key={tabLeader} value={tabLeader}>{localeService.t<LocaleKey>(label)}</option>
))}
</select>
</label>
</div>
<section
className="
univer-rounded-lg univer-border univer-bg-gray-50 univer-p-4
dark:univer-bg-gray-900
"
>
<div className="univer-mb-3 univer-text-xs univer-font-medium univer-text-gray-500">
{localeService.t<LocaleKey>('docs-toc-ui.tableOfContents.preview')}
</div>
{Array.from({ length: Math.min(value.levels, 3) }, (_, index) => index).map((level) => (
<div
key={level}
className={`
univer-flex univer-items-baseline univer-text-sm
${value.format === 'modern'
? 'univer-mb-2'
: 'univer-mb-1'}
`}
style={{ marginInlineStart: `${level * (value.format === 'simple' ? 12 : value.format === 'classic' ? 14 : value.format === 'modern' ? 20 : 18)}px` }}
>
<span>
{localeService.t<LocaleKey>('docs-toc-ui.tableOfContents.previewHeading')}
{' '}
{level + 1}
</span>
{value.showPageNumbers && value.rightAlignPageNumbers && (
<span
className="
univer-mx-2 univer-flex-1 univer-overflow-hidden univer-whitespace-nowrap
univer-text-gray-400
"
>
{leader}
</span>
)}
{value.showPageNumbers && <span className={value.rightAlignPageNumbers ? '' : 'univer-ml-2'}>{level + 1}</span>}
</div>
))}
</section>
</div>
);
}
@@ -0,0 +1,66 @@
/**
* 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 { LocaleKey } from '../locale/types';
import { LocaleService } from '@univerjs/core';
import { useDependency } from '@univerjs/ui';
import { useState } from 'react';
export const TABLE_OF_CONTENTS_DIALOG_COMPONENT = 'doc.table-of-contents-dialog';
export interface ITableOfContentsDialogProps {
mode: 'pageNumbersOnly' | 'entireTable';
onModeChange: (mode: ITableOfContentsDialogProps['mode']) => void;
}
export function TableOfContentsDialog(props: ITableOfContentsDialogProps) {
const localeService = useDependency(LocaleService);
const [mode, setMode] = useState(props.mode);
const options = [
['pageNumbersOnly', 'docs-toc-ui.tableOfContents.updatePageNumbersOnly'],
['entireTable', 'docs-toc-ui.tableOfContents.updateEntireTable'],
] as const;
return (
<div className="univer-grid univer-gap-4 univer-py-2" data-testid="table-of-contents-update-dialog">
<p className="univer-m-0 univer-text-sm">
{localeService.t<LocaleKey>('docs-toc-ui.tableOfContents.updateHint')}
</p>
<fieldset className="univer-grid univer-gap-3 univer-border-0 univer-p-0">
{options.map(([value, label]) => (
<label
key={value}
className="univer-flex univer-gap-3 univer-rounded-lg univer-border univer-p-3"
>
<input
type="radio"
name="toc-update-mode"
value={value}
checked={mode === value}
onChange={() => {
setMode(value);
props.onModeChange(value);
}}
/>
<span className="univer-text-sm">
{localeService.t<LocaleKey>(label)}
</span>
</label>
))}
</fieldset>
</div>
);
}
@@ -0,0 +1,121 @@
/**
* 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 { Root } from 'react-dom/client';
import { Injector, LocaleService, LocaleType } from '@univerjs/core';
import { RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InsertTableOfContentsDialog } from '../InsertTableOfContentsDialog';
import { TableOfContentsDialog } from '../TableOfContentsDialog';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe('TableOfContentsDialog', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
});
it('defaults to page-only update and allows selecting a full update', async () => {
const injector = new Injector();
injector.add([LocaleService, { useClass: LocaleService }]);
injector.get(LocaleService).load({
[LocaleType.EN_US]: {
'docs-toc-ui': {
tableOfContents: {
updatePageNumbersOnly: 'Update page numbers only',
updateEntireTable: 'Update entire table',
updateHint: 'Choose an update mode.',
},
},
},
});
const onModeChange = vi.fn();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => root?.render(
<RediContext.Provider value={{ injector }}>
<TableOfContentsDialog mode="pageNumbersOnly" onModeChange={onModeChange} />
</RediContext.Provider>
));
const radios = [...container.querySelectorAll<HTMLInputElement>('input[type="radio"]')];
expect(radios.map((radio) => radio.checked)).toEqual([true, false]);
await act(async () => radios[1].click());
expect(radios.map((radio) => radio.checked)).toEqual([false, true]);
expect(onModeChange).toHaveBeenCalledWith('entireTable');
injector.dispose();
});
it('edits custom TOC options and updates the preview', async () => {
const injector = new Injector();
injector.add([LocaleService, { useClass: LocaleService }]);
injector.get(LocaleService).load({
[LocaleType.EN_US]: {
'docs-toc-ui': {
tableOfContents: {
levels: 'Show levels',
format: 'Formats',
formatFromTemplate: 'From template',
formatClassic: 'Classic',
formatModern: 'Modern',
formatSimple: 'Simple',
showPageNumbers: 'Show page numbers',
rightAlignPageNumbers: 'Right align page numbers',
tabLeader: 'Tab leader',
leaderNone: 'None',
leaderDots: 'Dots',
leaderDashes: 'Dashes',
leaderUnderline: 'Underline',
preview: 'Print preview',
previewHeading: 'Heading',
},
},
},
});
const onChange = vi.fn();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => root?.render(
<RediContext.Provider value={{ injector }}>
<InsertTableOfContentsDialog
options={{ levels: 3, showPageNumbers: true, rightAlignPageNumbers: true, tabLeader: 'dots', format: 'fromTemplate' }}
onChange={onChange}
/>
</RediContext.Provider>
));
const checkboxes = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')];
const selects = [...container.querySelectorAll<HTMLSelectElement>('select')];
expect(container.textContent).toContain('··········');
await act(async () => {
checkboxes[0].click();
});
expect(checkboxes.map((checkbox) => checkbox.checked)).toEqual([false, false]);
expect(selects[2].disabled).toBe(true);
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ showPageNumbers: false, rightAlignPageNumbers: false }));
injector.dispose();
});
});
+27
View File
@@ -0,0 +1,27 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/node",
"compilerOptions": {
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "lib/types"
},
"include": [
"src"
],
"exclude": [
"src/**/__tests__/**",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}
+3
View File
@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();
+28
View File
@@ -0,0 +1,28 @@
# @univerjs/docs-toc
`@univerjs/docs-toc` provides table-of-contents insertion, update, deletion, and FIELD-range lookup for Univer Docs.
## Installation
```sh
pnpm add @univerjs/docs-toc
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import { UniverDocsTocPlugin } from '@univerjs/docs-toc';
univer.registerPlugin(UniverDocsTocPlugin);
```
Use this package with `@univerjs/docs-toc-ui` when users need Ribbon, context-menu, dialog, and active-range feedback.
The updater currently evaluates outline-based TOC instructions (`\\o`). Other Word TOC switches are retained in FIELD metadata but are not all evaluated.
## Resources
- [Documentation](https://docs.univer.ai)
- [GitHub repository](https://github.com/dream-num/univer)
+80
View File
@@ -0,0 +1,80 @@
{
"name": "@univerjs/docs-toc",
"version": "1.0.0-rc.0",
"private": false,
"description": "Table of contents commands for Univer Docs.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"docs",
"table-of-contents",
"toc",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"peerDependencies": {
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/docs": "workspace:*",
"@univerjs/engine-render": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"rxjs": "^7.8.2",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}
@@ -0,0 +1,463 @@
/**
* 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, ICustomRange, IDocumentBody, IDocumentData } from '@univerjs/core';
import {
BooleanNumber,
CustomRangeType,
getBodySlice,
ICommandService,
NamedStyleType,
SliceBodyType,
TabStopAlignment,
TabStopLeader,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { describe, expect, it } from 'vitest';
import {
buildPageNumberOnlyTableOfContentsBody,
buildTableOfContentsBody,
buildTableOfContentsInstruction,
countTableOfContentsHeadings,
findTableOfContentsAtOffset,
UpdateTableOfContentsCommand,
} from '../table-of-contents.command';
function createBody(): { body: IDocumentBody; toc: ICustomRange } {
const dataStream = '\u001FOld\t\u001F1\u001E\r\u001EHeading One\rSubheading\r\n';
const tocEnd = dataStream.indexOf('\u001E', dataStream.indexOf('\u001E') + 1);
const firstHeadingStart = tocEnd + 1;
const firstHeadingEnd = dataStream.indexOf('\r', firstHeadingStart);
const secondHeadingStart = firstHeadingEnd + 1;
const secondHeadingEnd = dataStream.indexOf('\r', secondHeadingStart);
const toc: ICustomRange = {
startIndex: 0,
endIndex: tocEnd,
rangeId: 'toc',
rangeType: CustomRangeType.FIELD,
wholeEntity: false,
properties: { fieldType: 'TOC', instruction: 'TOC \\o "1-2" \\h \\z \\u' },
};
return {
toc,
body: {
dataStream,
paragraphs: [{
startIndex: dataStream.indexOf('\r'),
paragraphId: 'toc-1',
styleId: 'TOC1',
}, {
startIndex: firstHeadingEnd,
paragraphId: 'heading-1-paragraph',
paragraphStyle: {
headingId: 'heading-1',
namedStyleType: NamedStyleType.HEADING_1,
outlineLevel: 0,
},
}, {
startIndex: secondHeadingEnd,
paragraphId: 'heading-2-paragraph',
paragraphStyle: {
headingId: 'heading-2',
namedStyleType: NamedStyleType.HEADING_2,
outlineLevel: 1,
},
}],
customRanges: [toc, {
startIndex: firstHeadingStart,
endIndex: firstHeadingEnd - 1,
rangeId: 'bookmark-1',
rangeType: CustomRangeType.BOOKMARK,
properties: { bookmarkId: '_Toc1' },
}],
},
};
}
describe('table of contents body builder', () => {
it('rejects a locked update before acquiring the layout renderer or modifying the document', async () => {
const univer = new Univer();
try {
univer.registerPlugin(UniverDocsPlugin);
const { body, toc } = createBody();
toc.properties!.locked = BooleanNumber.TRUE;
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'locked-toc-doc',
body,
documentStyle: {},
});
const commands = univer.__getInjector().get(ICommandService);
commands.registerCommand(UpdateTableOfContentsCommand);
const before = JSON.stringify(doc.getBody());
expect(await commands.executeCommand(UpdateTableOfContentsCommand.id, {
unitId: doc.getUnitId(),
rangeId: toc.rangeId,
mode: 'entireTable',
})).toBe(false);
expect(JSON.stringify(doc.getBody())).toBe(before);
} finally {
univer.dispose();
}
});
it('does not recalculate a locked TOC in either update mode', () => {
const { body, toc } = createBody();
toc.properties!.locked = BooleanNumber.TRUE;
const before = JSON.stringify(body);
const resolvePageNumber = () => {
throw new Error('Locked fields must not resolve page numbers');
};
expect(buildTableOfContentsBody(body, toc, resolvePageNumber)).toBeNull();
expect(buildPageNumberOnlyTableOfContentsBody(body, toc, resolvePageNumber)).toBeNull();
expect(JSON.stringify(body)).toBe(before);
});
it('encodes page-number options in Word TOC switches for OOXML round trips', () => {
expect(buildTableOfContentsInstruction()).toBe('TOC \\o "1-3" \\h \\z \\u');
expect(buildTableOfContentsInstruction({ levels: 5, showPageNumbers: false })).toBe('TOC \\o "1-5" \\h \\z \\u \\n');
expect(buildTableOfContentsInstruction({ levels: 2, rightAlignPageNumbers: false })).toBe('TOC \\o "1-2" \\h \\z \\u \\p " "');
});
it('rebuilds an outline TOC with nested PAGEREF fields and internal links', () => {
const { body, toc } = createBody();
let id = 0;
const result = buildTableOfContentsBody(
body,
toc,
(offset) => offset < body.dataStream.indexOf('Subheading') ? 6 : 12,
() => `generated-${++id}`
)!;
expect(result.dataStream).toBe('\u001FHeading One\t\u001F6\u001E\rSubheading\t\u001F12\u001E\r\u001E');
expect(result.paragraphs).toEqual([
expect.objectContaining({ startIndex: 16, styleId: 'TOC1' }),
expect.objectContaining({ startIndex: 32, styleId: 'TOC1' }),
]);
expect(result.customRanges).toEqual(expect.arrayContaining([
expect.objectContaining({
rangeId: 'toc',
rangeType: CustomRangeType.FIELD,
startIndex: 0,
endIndex: 33,
properties: expect.objectContaining({
fieldType: 'TOC',
cachedResult: 'Heading One\t6\rSubheading\t12\r',
}),
}),
expect.objectContaining({
rangeType: CustomRangeType.HYPERLINK,
properties: { bookmarkId: '_Toc1', textStyleMode: 'text' },
}),
expect.objectContaining({
rangeType: CustomRangeType.HYPERLINK,
properties: { headingId: 'heading-2', textStyleMode: 'text' },
}),
expect.objectContaining({
rangeType: CustomRangeType.FIELD,
properties: expect.objectContaining({ fieldType: 'PAGEREF', cachedResult: '12' }),
}),
]));
});
it('keeps distinct, stable paragraph identities when entries share a formatting template', () => {
const { body, toc } = createBody();
body.paragraphs![0].paragraphId = 'para_toc-entry';
toc.properties!.title = 'Contents';
let id = 0;
const generateId = () => `identity-${++id}`;
const first = buildTableOfContentsBody(body, toc, () => 1, generateId)!;
const firstIds = first.paragraphs!.map((paragraph) => paragraph.paragraphId);
expect(new Set(firstIds).size).toBe(3);
expect(firstIds[1]).toBe('para_toc-entry');
expect(firstIds.every((paragraphId) => paragraphId.startsWith('para_'))).toBe(true);
const delta = first.dataStream.length - toc.endIndex - 1;
const updatedBody: IDocumentBody = {
dataStream: first.dataStream + body.dataStream.slice(toc.endIndex + 1),
paragraphs: [
...first.paragraphs!,
...body.paragraphs!.filter((paragraph) => paragraph.startIndex > toc.endIndex)
.map((paragraph) => ({ ...paragraph, startIndex: paragraph.startIndex + delta })),
],
customRanges: first.customRanges,
};
const updatedToc = updatedBody.customRanges!.find((range) => range.rangeId === toc.rangeId)!;
const second = buildTableOfContentsBody(updatedBody, updatedToc, () => 2, generateId)!;
expect(second.paragraphs!.map((paragraph) => paragraph.paragraphId)).toEqual(firstIds);
expect(second.paragraphs!.slice(1).every((paragraph) => paragraph.styleId === 'TOC1')).toBe(true);
});
it('leaves caption tables of contents unchanged until SEQ fields are supported', () => {
const { body, toc } = createBody();
toc.properties = { fieldType: 'TOC', instruction: 'TOC \\h \\z \\c "图"' };
expect(buildTableOfContentsBody(body, toc, () => 1)).toBeNull();
});
it('creates default TOC indentation and a right-aligned dot leader when inserting', () => {
const body: IDocumentBody = {
dataStream: 'Heading One\rSubheading\r\n',
paragraphs: [{
startIndex: 11,
paragraphId: 'heading-1-paragraph',
paragraphStyle: { headingId: 'heading-1', outlineLevel: 0 },
}, {
startIndex: 22,
paragraphId: 'heading-2-paragraph',
paragraphStyle: { headingId: 'heading-2', outlineLevel: 1 },
}],
};
const toc: ICustomRange = {
startIndex: 0,
endIndex: 0,
rangeId: 'new-toc',
rangeType: CustomRangeType.FIELD,
properties: { fieldType: 'TOC', instruction: 'TOC \\o "1-2" \\h' },
};
const result = buildTableOfContentsBody(body, toc, () => 1, () => 'generated', { tabStopOffset: 414 })!;
expect(result.paragraphs?.map((paragraph) => paragraph.paragraphStyle)).toEqual([{
indentStart: { v: 0 },
tabStops: [{ offset: 414, alignment: TabStopAlignment.END, leader: TabStopLeader.DOT }],
}, {
indentStart: { v: 18 },
tabStops: [{ offset: 414, alignment: TabStopAlignment.END, leader: TabStopLeader.DOT }],
}]);
});
it('does not create an empty field when no matching headings exist', () => {
const body: IDocumentBody = {
dataStream: 'Body text\r\n',
paragraphs: [{ startIndex: 9, paragraphId: 'body' }],
};
const toc: ICustomRange = {
startIndex: 0,
endIndex: 0,
rangeId: 'new-toc',
rangeType: CustomRangeType.FIELD,
properties: { fieldType: 'TOC', instruction: 'TOC \\o "1-3" \\h' },
};
expect(countTableOfContentsHeadings(body, 3)).toBe(0);
expect(buildTableOfContentsBody(body, toc, () => 1)).toBeNull();
});
it('supports a localized title and a TOC without page numbers', () => {
const body: IDocumentBody = {
dataStream: 'Heading One\r\n',
paragraphs: [{
startIndex: 11,
paragraphId: 'heading',
paragraphStyle: { headingId: 'heading-1', outlineLevel: 0 },
}],
};
const toc: ICustomRange = {
startIndex: 0,
endIndex: 0,
rangeId: 'new-toc',
rangeType: CustomRangeType.FIELD,
properties: {
fieldType: 'TOC',
instruction: 'TOC \\o "1-3" \\h \\n',
title: 'Contents',
showPageNumbers: false,
},
};
const result = buildTableOfContentsBody(body, toc, () => undefined, () => 'generated')!;
expect(result.dataStream).toBe('\u001FContents\rHeading One\r\u001E');
expect(result.paragraphs?.[0]).toEqual(expect.objectContaining({ styleId: 'TOCHeading' }));
expect(result.customRanges?.some((range) => range.properties?.fieldType === 'PAGEREF')).toBe(false);
expect(result.customRanges?.find((range) => range.rangeId === 'new-toc')?.properties?.cachedResult).toBe('Contents\rHeading One\r');
});
it('preserves an imported TOCHeading title when OOXML has no Univer title property', () => {
const dataStream = '\u001FContents\rOld\t\u001F1\u001E\r\u001EHeading One\r\n';
const outerEnd = dataStream.indexOf('\u001E', dataStream.indexOf('\u001E') + 1);
const headingStart = outerEnd + 1;
const headingEnd = dataStream.indexOf('\r', headingStart);
const toc: ICustomRange = {
startIndex: 0,
endIndex: outerEnd,
rangeId: 'toc',
rangeType: CustomRangeType.FIELD,
properties: { fieldType: 'TOC', instruction: 'TOC \\o "1-3" \\h' },
};
const body: IDocumentBody = {
dataStream,
paragraphs: [{
startIndex: 9,
paragraphId: 'toc-title',
styleId: 'TOCHeading',
}, {
startIndex: outerEnd - 1,
paragraphId: 'toc-entry',
styleId: 'TOC1',
}, {
startIndex: headingEnd,
paragraphId: 'heading',
paragraphStyle: { headingId: 'heading-1', outlineLevel: 0 },
}],
customRanges: [toc],
};
const result = buildTableOfContentsBody(body, toc, () => 2, () => 'generated')!;
expect(result.dataStream).toBe('\u001FContents\rHeading One\t\u001F2\u001E\r\u001E');
expect(result.paragraphs?.[0].styleId).toBe('TOCHeading');
});
it.each([
['none', TabStopLeader.NONE, 'simple', 12],
['dashes', TabStopLeader.HYPHEN, 'classic', 14],
['underline', TabStopLeader.UNDERSCORE, 'modern', 20],
] as const)('applies the %s leader and %s format', (leader, expectedLeader, format, expectedIndent) => {
const body: IDocumentBody = {
dataStream: 'Subheading\r\n',
paragraphs: [{
startIndex: 10,
paragraphId: 'heading',
paragraphStyle: { headingId: 'heading-2', outlineLevel: 1 },
}],
};
const toc: ICustomRange = {
startIndex: 0,
endIndex: 0,
rangeId: 'new-toc',
rangeType: CustomRangeType.FIELD,
properties: {
fieldType: 'TOC',
instruction: 'TOC \\o "1-3" \\h',
tabLeader: leader,
format,
},
};
const result = buildTableOfContentsBody(body, toc, () => 2, () => 'generated', { tabStopOffset: 414 })!;
expect(result.paragraphs?.[0].paragraphStyle?.indentStart?.v).toBe(expectedIndent);
expect(result.paragraphs?.[0].paragraphStyle?.tabStops?.[0].leader).toBe(expectedLeader);
});
it('places page numbers next to entry text when right alignment is disabled', () => {
const body: IDocumentBody = {
dataStream: 'Heading One\r\n',
paragraphs: [{
startIndex: 11,
paragraphId: 'heading',
paragraphStyle: { headingId: 'heading-1', outlineLevel: 0 },
}],
};
const toc: ICustomRange = {
startIndex: 0,
endIndex: 0,
rangeId: 'new-toc',
rangeType: CustomRangeType.FIELD,
properties: {
fieldType: 'TOC',
instruction: 'TOC \\o "1-3" \\h \\p " "',
rightAlignPageNumbers: false,
},
};
const result = buildTableOfContentsBody(body, toc, () => 4, () => 'generated', { tabStopOffset: 414 })!;
expect(result.dataStream).toBe('\u001FHeading One \u001F4\u001E\r\u001E');
expect(result.paragraphs?.[0].paragraphStyle?.tabStops).toBeUndefined();
});
it.each([undefined, BooleanNumber.FALSE, BooleanNumber.TRUE].flatMap((locked) =>
[6, 9, 42].map((pageNumber) => ({ locked, pageNumber }))
))('preserves manual text and detects unchanged page fields (lock $locked, page $pageNumber)', ({ locked, pageNumber }) => {
const dataStream = '\u001FManual title\t\u001F6\u001E\r\u001EHeading One\r\n';
const tocEnd = dataStream.indexOf('\u001E', dataStream.indexOf('\u001E') + 1);
const pageStart = dataStream.indexOf('\u001F', 1);
const pageEnd = dataStream.indexOf('\u001E');
const headingStart = tocEnd + 1;
const headingEnd = dataStream.indexOf('\r', headingStart);
const toc: ICustomRange = {
startIndex: 0,
endIndex: tocEnd,
rangeId: 'toc',
rangeType: CustomRangeType.FIELD,
properties: { fieldType: 'TOC', instruction: 'TOC \\o "1-3" \\h', cachedResult: 'Manual title\t6\r' },
};
const body: IDocumentBody = {
dataStream,
paragraphs: [
{ startIndex: tocEnd - 1, paragraphId: 'toc-paragraph' },
{ startIndex: headingEnd, paragraphId: 'heading-paragraph', paragraphStyle: { headingId: 'heading-1', outlineLevel: 0 } },
],
customRanges: [toc, {
startIndex: pageStart,
endIndex: pageEnd,
rangeId: 'page',
rangeType: CustomRangeType.FIELD,
properties: { fieldType: 'PAGEREF', instruction: 'PAGEREF _Toc1 \\h', cachedResult: '6', locked },
}, {
startIndex: 1,
endIndex: pageEnd,
rangeId: 'link',
rangeType: CustomRangeType.HYPERLINK,
properties: { bookmarkId: '_Toc1' },
}, {
startIndex: headingStart,
endIndex: headingEnd - 1,
rangeId: 'bookmark',
rangeType: CustomRangeType.BOOKMARK,
properties: { bookmarkId: '_Toc1' },
}],
};
const before = JSON.stringify(body);
const result = buildPageNumberOnlyTableOfContentsBody(body, toc, () => locked === BooleanNumber.TRUE ? undefined : pageNumber)!;
const expectedPage = locked === BooleanNumber.TRUE ? '6' : String(pageNumber);
const expectedEnd = pageEnd + expectedPage.length - 1;
expect(result.dataStream).toBe(`\u001FManual title\t\u001F${expectedPage}\u001E\r\u001E`);
expect(result.customRanges?.find((range) => range.rangeId === 'page')).toEqual(expect.objectContaining({
endIndex: expectedEnd,
properties: expect.objectContaining({ cachedResult: expectedPage, locked }),
}));
expect(result.customRanges?.find((range) => range.rangeId === 'link')?.endIndex).toBe(expectedEnd);
expect(findTableOfContentsAtOffset(body, 5)?.rangeId).toBe('toc');
const current = getBodySlice(body, toc.startIndex, toc.endIndex + 1, true, SliceBodyType.cut);
if (expectedPage === '6') {
expect(result).toEqual(current);
toc.properties!.cachedResult = 'Stale cache';
expect(buildPageNumberOnlyTableOfContentsBody(body, toc, () => pageNumber))
.not
.toEqual(getBodySlice(body, toc.startIndex, toc.endIndex + 1, true, SliceBodyType.cut));
toc.properties!.cachedResult = 'Manual title\t6\r';
if (locked !== BooleanNumber.TRUE) {
const pageField = body.customRanges!.find((range) => range.rangeId === 'page')!;
pageField.properties!.cachedResult = 'Stale page cache';
expect(buildPageNumberOnlyTableOfContentsBody(body, toc, () => pageNumber)).toEqual(result);
expect(pageField.properties!.cachedResult).toBe('Stale page cache');
pageField.properties!.cachedResult = '6';
}
} else {
expect(result).not.toEqual(current);
}
expect(JSON.stringify(body)).toBe(before);
});
});
@@ -0,0 +1,702 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, ICustomRange, IDocumentBody, IParagraph, ITextRange, ITextRun } from '@univerjs/core';
import type { DocumentSkeleton } from '@univerjs/engine-render';
import {
BooleanNumber,
BuildTextUtils,
cloneParagraphWithId,
CommandType,
createParagraphId,
CustomRangeType,
generateRandomId,
getBodySlice,
getPlainText,
getRichTextEditPath,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
JSONX,
NamedStyleType,
SliceBodyType,
TabStopAlignment,
TabStopLeader,
TextX,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService, DocStateChangeManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { filter, firstValueFrom, take, timeout } from 'rxjs';
const FIELD_START = '\u001F';
const FIELD_END = '\u001E';
async function replaceBodyRange(
accessor: IAccessor,
doc: DocumentDataModel,
unitId: string,
selection: ITextRange,
body: IDocumentBody,
historyAction: string,
historyGroupId = generateRandomId()
): Promise<boolean> {
const textX = new TextX();
textX.push(...BuildTextUtils.selection.delete([selection], doc.getBody()!, 0, body));
const caretOffset = selection.startOffset + body.dataStream.length;
accessor.get(DocStateChangeManagerService).flushPendingChanges(unitId);
const group = accessor.get(IUndoRedoService).beginUndoRedoGroup(unitId, historyGroupId, 'append');
try {
return Boolean(accessor.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
unitId,
historyAction,
actions: JSONX.getInstance().editOp(textX.serialize(), getRichTextEditPath(doc)),
textRanges: [{
startOffset: caretOffset,
endOffset: caretOffset,
collapsed: true,
}],
debounce: false,
trigger: historyAction,
}));
} finally {
group.dispose();
}
}
export interface IUpdateTableOfContentsCommandParams {
unitId?: string;
rangeId?: string;
mode?: 'pageNumbersOnly' | 'entireTable';
}
export interface IInsertTableOfContentsCommandParams {
unitId?: string;
levels?: number;
showPageNumbers?: boolean;
rightAlignPageNumbers?: boolean;
tabLeader?: TableOfContentsTabLeader;
format?: TableOfContentsFormat;
title?: string;
}
export type TableOfContentsTabLeader = 'none' | 'dots' | 'dashes' | 'underline';
export type TableOfContentsFormat = 'fromTemplate' | 'classic' | 'modern' | 'simple';
export interface IDeleteTableOfContentsCommandParams {
unitId?: string;
rangeId?: string;
}
interface ITableOfContentsHeading {
bookmarkId?: string;
headingId: string;
level: number;
paragraph: IParagraph;
startOffset: number;
text: string;
}
export const UpdateTableOfContentsCommand: ICommand<IUpdateTableOfContentsCommandParams> = {
id: 'doc.command.update-table-of-contents',
type: CommandType.COMMAND,
handler: (accessor, params) => updateTableOfContents(accessor, params),
};
async function updateTableOfContents(
accessor: IAccessor,
params?: IUpdateTableOfContentsCommandParams,
historyGroupId = generateRandomId()
): Promise<boolean> {
const instanceService = accessor.get(IUniverInstanceService);
const doc = params?.unitId
? instanceService.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC)
: instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const body = doc?.getBody();
const selection = accessor.get(DocSelectionManagerService).getActiveTextRange();
const toc = params?.rangeId
? body?.customRanges?.find((range) => range.rangeId === params.rangeId && isTableOfContentsRange(range))
: findTableOfContentsAtOffset(body, selection?.startOffset);
const unitId = doc?.getUnitId();
if (!doc || !body || !toc || toc.properties?.locked === BooleanNumber.TRUE || !unitId) {
return false;
}
const render = accessor.get(IRenderManagerService).getRenderUnitById(unitId);
const renderInjector = render?.getInjector?.();
if (!render || !renderInjector?.has(DocSkeletonManagerService)) {
return false;
}
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
if (!await waitForCompleteLayout(skeleton)) {
return false;
}
let updated = false;
for (let iteration = 0; iteration < 3; iteration += 1) {
const currentBody = doc.getBody();
const currentToc = currentBody?.customRanges?.find((range) => range.rangeId === toc.rangeId && isTableOfContentsRange(range));
if (!currentBody || !currentToc || currentToc.properties?.locked === BooleanNumber.TRUE) {
return false;
}
const resolvePageNumber = (offset: number) => {
const pageIndex = skeleton.findBodyPageIndexByCharIndex(offset);
return skeleton.getSkeletonData()?.pages[pageIndex]?.pageNumber;
};
const replacement = params?.mode === 'pageNumbersOnly'
? buildPageNumberOnlyTableOfContentsBody(currentBody, currentToc, resolvePageNumber)
: buildTableOfContentsBody(currentBody, currentToc, resolvePageNumber);
if (!replacement) {
return false;
}
// An accurate page-only update must not create history or restart pagination.
// Compare metadata too: identical visible text can still have stale field caches.
if (params?.mode === 'pageNumbersOnly' && Tools.diffValue(
getBodySlice(currentBody, currentToc.startIndex, currentToc.endIndex + 1, true, SliceBodyType.cut),
replacement
)) {
return true;
}
if (updated && currentBody.dataStream.slice(currentToc.startIndex, currentToc.endIndex + 1) === replacement.dataStream) {
return true;
}
const generation = skeleton.getLayoutProgress()?.generation ?? -1;
updated = await replaceBodyRange(accessor, doc, unitId, {
startOffset: currentToc.startIndex,
endOffset: currentToc.endIndex + 1,
collapsed: false,
}, replacement, UpdateTableOfContentsCommand.id, historyGroupId);
if (!updated || iteration === 2) {
return updated;
}
if (!await waitForNextCompleteLayout(skeleton, generation)) {
return false;
}
}
return updated;
}
export const DeleteTableOfContentsCommand: ICommand<IDeleteTableOfContentsCommandParams> = {
id: 'doc.command.delete-table-of-contents',
type: CommandType.COMMAND,
async handler(accessor, params) {
const instanceService = accessor.get(IUniverInstanceService);
const doc = params?.unitId
? instanceService.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC)
: instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const body = doc?.getBody();
const selection = accessor.get(DocSelectionManagerService).getActiveTextRange();
const toc = params?.rangeId
? body?.customRanges?.find((range) => range.rangeId === params.rangeId && isTableOfContentsRange(range))
: findTableOfContentsAtOffset(body, selection?.startOffset);
const unitId = doc?.getUnitId();
if (!toc || !unitId) {
return false;
}
return replaceBodyRange(accessor, doc!, unitId, {
startOffset: toc.startIndex,
endOffset: toc.endIndex + 1,
collapsed: false,
}, { dataStream: '' }, DeleteTableOfContentsCommand.id);
},
};
export const InsertTableOfContentsCommand: ICommand<IInsertTableOfContentsCommandParams> = {
id: 'doc.command.insert-table-of-contents',
type: CommandType.COMMAND,
async handler(accessor, params) {
const instanceService = accessor.get(IUniverInstanceService);
const doc = params?.unitId
? instanceService.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC)
: instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const selection = accessor.get(DocSelectionManagerService).getActiveTextRange();
const body = doc?.getBody();
const unitId = doc?.getUnitId();
const render = unitId ? accessor.get(IRenderManagerService).getRenderUnitById(unitId) : undefined;
const renderInjector = render?.getInjector?.();
if (!doc || !body || !unitId || !selection || selection.segmentId || selection.startOffset !== selection.endOffset ||
findTableOfContentsAtOffset(body, selection.startOffset) || !render || !renderInjector?.has(DocSkeletonManagerService)) {
return false;
}
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
if (!await waitForCompleteLayout(skeleton)) {
return false;
}
const levels = Math.min(9, Math.max(1, Math.trunc(params?.levels ?? 3)));
const showPageNumbers = params?.showPageNumbers ?? true;
const rightAlignPageNumbers = showPageNumbers && (params?.rightAlignPageNumbers ?? true);
const tabLeader = params?.tabLeader ?? 'dots';
const format = params?.format ?? 'fromTemplate';
const rangeId = generateRandomId();
const toc: ICustomRange = {
rangeId,
rangeType: CustomRangeType.FIELD,
startIndex: selection.startOffset,
endIndex: selection.startOffset,
wholeEntity: false,
properties: {
fieldType: 'TOC',
instruction: buildTableOfContentsInstruction({ levels, showPageNumbers, rightAlignPageNumbers }),
sourceKind: 'complex',
showPageNumbers,
rightAlignPageNumbers,
tabLeader,
format,
title: params?.title,
},
};
const { pageSize, marginLeft = 0, marginRight = 0 } = doc.getDocumentStyle();
const replacement = buildTableOfContentsBody(body, toc, (offset) => {
const pageIndex = skeleton.findBodyPageIndexByCharIndex(offset);
return skeleton.getSkeletonData()?.pages[pageIndex]?.pageNumber;
}, generateRandomId, { tabStopOffset: Math.max(72, (pageSize?.width ?? 594.3) - marginLeft - marginRight) });
if (!replacement) {
return false;
}
const historyGroupId = generateRandomId();
const inserted = await replaceBodyRange(accessor, doc, unitId, {
startOffset: selection.startOffset,
endOffset: selection.startOffset,
collapsed: true,
}, replacement, InsertTableOfContentsCommand.id, historyGroupId);
if (!inserted) {
return false;
}
await updateTableOfContents(accessor, {
unitId,
rangeId,
mode: 'entireTable',
}, historyGroupId);
return true;
},
};
export function buildTableOfContentsInstruction(
options: Pick<IInsertTableOfContentsCommandParams, 'levels' | 'showPageNumbers' | 'rightAlignPageNumbers'> = {}
): string {
const levels = Math.min(9, Math.max(1, Math.trunc(options.levels ?? 3)));
const showPageNumbers = options.showPageNumbers ?? true;
const rightAlignPageNumbers = showPageNumbers && (options.rightAlignPageNumbers ?? true);
const switches = [
`\\o "1-${levels}"`,
'\\h',
'\\z',
'\\u',
...(!showPageNumbers ? ['\\n'] : []),
...(showPageNumbers && !rightAlignPageNumbers ? ['\\p " "'] : []),
];
return `TOC ${switches.join(' ')}`;
}
function isTableOfContentsRange(range: ICustomRange): boolean {
return range.rangeType === CustomRangeType.FIELD && range.properties?.fieldType === 'TOC';
}
export function findTableOfContentsAtOffset(body: IDocumentBody | null | undefined, offset: number | undefined): ICustomRange | undefined {
if (offset == null) {
return undefined;
}
return body?.customRanges
?.filter((range) => isTableOfContentsRange(range) && range.startIndex <= offset && offset <= range.endIndex)
.sort((left, right) => left.endIndex - left.startIndex - (right.endIndex - right.startIndex))[0];
}
async function waitForCompleteLayout(skeleton: DocumentSkeleton): Promise<boolean> {
const progress = skeleton.getLayoutProgress();
if (progress == null || progress.complete) {
return true;
}
try {
await firstValueFrom(skeleton.layoutProgress$.pipe(
filter((next) => next.complete),
take(1),
timeout({ first: 30_000 })
));
return true;
} catch {
return false;
}
}
async function waitForNextCompleteLayout(skeleton: DocumentSkeleton, generation: number): Promise<boolean> {
try {
await firstValueFrom(skeleton.layoutProgress$.pipe(
filter((next) => next.generation > generation && next.complete),
take(1),
timeout({ first: 30_000 })
));
return true;
} catch {
return false;
}
}
export function buildPageNumberOnlyTableOfContentsBody(
body: IDocumentBody,
toc: ICustomRange,
resolvePageNumber: (offset: number) => number | undefined
): IDocumentBody | null {
if (toc.properties?.locked === BooleanNumber.TRUE) {
return null;
}
// Slice metadata may share nested range properties with the source. Build the
// replacement in isolation so history captures the unmodified field caches.
const result = Tools.deepClone(getBodySlice(body, toc.startIndex, toc.endIndex + 1, true, SliceBodyType.cut));
const pageFields = result.customRanges
?.filter((range) => range.rangeType === CustomRangeType.FIELD && range.properties?.fieldType === 'PAGEREF' &&
range.properties?.locked !== BooleanNumber.TRUE)
.sort((left, right) => right.startIndex - left.startIndex) ?? [];
for (const pageField of pageFields) {
const originalField = body.customRanges?.find((range) => range.rangeId === pageField.rangeId);
const targetOffset = originalField && resolvePageReferenceOffset(body, originalField);
const pageNumber = targetOffset == null ? undefined : resolvePageNumber(targetOffset);
if (pageNumber == null) {
return null;
}
replaceFieldResult(result, pageField, String(pageNumber));
}
const outer = result.customRanges?.find((range) => range.rangeId === toc.rangeId);
if (outer?.properties) {
outer.properties.cachedResult = getPlainText(result.dataStream.slice(1, -1))
.replaceAll(FIELD_START, '')
.replaceAll(FIELD_END, '');
}
return result;
}
function resolvePageReferenceOffset(body: IDocumentBody, pageField: ICustomRange): number | undefined {
const targetId = String(pageField.properties?.instruction ?? '').match(/PAGEREF\s+"?([^"\s\\]+)"?/i)?.[1];
if (targetId) {
const bookmark = body.customRanges?.find((range) =>
range.rangeType === CustomRangeType.BOOKMARK && range.properties?.bookmarkId === targetId
);
if (bookmark) {
return bookmark.startIndex;
}
}
const link = body.customRanges?.find((range) =>
range.rangeType === CustomRangeType.HYPERLINK &&
range.startIndex <= pageField.startIndex && pageField.endIndex <= range.endIndex
);
const headingId = link?.properties?.headingId;
return body.paragraphs?.find((paragraph) => paragraph.paragraphStyle?.headingId === headingId)?.startIndex;
}
function replaceFieldResult(body: IDocumentBody, field: ICustomRange, value: string): void {
const start = field.startIndex + 1;
const end = field.endIndex;
if (field.properties) {
field.properties.cachedResult = value;
}
// Unchanged text can still need a cache repair, but cannot shift any ranges.
if (body.dataStream.slice(start, end) === value) {
return;
}
const delta = value.length - (end - start);
body.dataStream = `${body.dataStream.slice(0, start)}${value}${body.dataStream.slice(end)}`;
shiftRuns(body.textRuns, start, end, delta);
body.paragraphs?.forEach((paragraph) => {
if (paragraph.startIndex >= end) {
paragraph.startIndex += delta;
}
});
body.customRanges?.forEach((range) => {
if (range.startIndex >= end) {
range.startIndex += delta;
range.endIndex += delta;
} else if (range.endIndex >= end) {
range.endIndex += delta;
}
});
}
function shiftRuns(runs: ITextRun[] | undefined, start: number, end: number, delta: number): void {
runs?.forEach((run) => {
if (run.st >= end) {
run.st += delta;
run.ed += delta;
} else if (run.ed > start) {
run.ed += delta;
}
});
}
export function buildTableOfContentsBody(
body: IDocumentBody,
toc: ICustomRange,
resolvePageNumber: (offset: number) => number | undefined,
idFactory: () => string = generateRandomId,
options?: { tabStopOffset?: number }
): IDocumentBody | null {
if (toc.properties?.locked === BooleanNumber.TRUE) {
return null;
}
const instruction = String(toc.properties?.instruction ?? '');
if (/\\c\s+/i.test(instruction)) {
return null;
}
const [minLevel, maxLevel] = parseOutlineLevelRange(instruction);
const headings = collectHeadings(body, toc, minLevel, maxLevel);
if (headings.length === 0) {
return null;
}
const showPageNumbers = toc.properties?.showPageNumbers !== false && !/\\n(?:\s|$)/i.test(instruction);
const rightAlignPageNumbers = showPageNumbers && toc.properties?.rightAlignPageNumbers !== false && !/\\p\s+/i.test(instruction);
const tabLeader = normalizeTabLeader(toc.properties?.tabLeader);
const format = normalizeFormat(toc.properties?.format);
const isExistingToc = body.customRanges?.some((range) => range.rangeId === toc.rangeId) ?? false;
const oldParagraphs = isExistingToc
? body.paragraphs?.filter((paragraph) =>
paragraph.startIndex >= toc.startIndex && paragraph.startIndex <= toc.endIndex
) ?? []
: [];
const title = typeof toc.properties?.title === 'string'
? toc.properties.title.trim()
: getExistingTableOfContentsTitle(body, oldParagraphs);
const result: IDocumentBody = { dataStream: FIELD_START, paragraphs: [], customRanges: [] };
const ranges = result.customRanges!;
const oldTitleParagraph = oldParagraphs.find((paragraph) => paragraph.styleId?.toLowerCase() === 'tocheading');
const oldEntryParagraphs = oldParagraphs.filter((paragraph) => paragraph !== oldTitleParagraph);
const usedParagraphIds = new Set<string>();
let cachedResult = '';
if (title) {
result.dataStream += `${title}\r`;
cachedResult += `${title}\r`;
result.paragraphs!.push(cloneParagraphWithId({
startIndex: result.dataStream.length - 1,
paragraphId: oldTitleParagraph?.paragraphId ?? '',
styleId: 'TOCHeading',
}, usedParagraphIds));
}
for (const [index, heading] of headings.entries()) {
const pageNumber = showPageNumbers ? resolvePageNumber(heading.startOffset) : undefined;
if (showPageNumbers && pageNumber == null) {
return null;
}
const entryStart = result.dataStream.length;
const separator = showPageNumbers ? (rightAlignPageNumbers ? '\t' : ' ') : '';
const visibleEntry = `${heading.text}${separator}${pageNumber ?? ''}`;
result.dataStream += `${heading.text}${separator}`;
const targetId = heading.bookmarkId ?? heading.headingId;
const link: ICustomRange = {
startIndex: entryStart,
endIndex: result.dataStream.length - 1,
rangeId: idFactory(),
rangeType: CustomRangeType.HYPERLINK,
wholeEntity: false,
properties: {
...(heading.bookmarkId ? { bookmarkId: targetId } : { headingId: targetId }),
textStyleMode: 'text',
},
};
ranges.push(link);
if (showPageNumbers) {
const pageFieldStart = result.dataStream.length;
result.dataStream += `${FIELD_START}${pageNumber}${FIELD_END}`;
const pageFieldEnd = result.dataStream.length - 1;
link.endIndex = pageFieldEnd;
ranges.push({
startIndex: pageFieldStart,
endIndex: pageFieldEnd,
rangeId: idFactory(),
rangeType: CustomRangeType.FIELD,
wholeEntity: false,
properties: {
fieldType: 'PAGEREF',
instruction: `PAGEREF ${targetId} \\h`,
cachedResult: String(pageNumber),
sourceKind: 'complex',
},
});
}
result.dataStream += '\r';
cachedResult += `${visibleEntry}\r`;
result.paragraphs!.push(copyTocParagraph(
oldEntryParagraphs,
index,
heading.level,
result.dataStream.length - 1,
usedParagraphIds,
rightAlignPageNumbers ? options?.tabStopOffset : undefined,
tabLeader,
format
));
}
result.dataStream += FIELD_END;
ranges.push({
startIndex: 0,
endIndex: result.dataStream.length - 1,
rangeId: toc.rangeId,
rangeType: CustomRangeType.FIELD,
wholeEntity: false,
properties: {
...Tools.deepClone(toc.properties ?? {}),
fieldType: 'TOC',
instruction,
cachedResult,
},
});
ranges.sort((left, right) => left.startIndex - right.startIndex || right.endIndex - left.endIndex);
return result;
}
function getExistingTableOfContentsTitle(body: IDocumentBody, paragraphs: IParagraph[]): string {
const titleParagraph = paragraphs.find((paragraph) => paragraph.styleId?.toLowerCase() === 'tocheading');
if (!titleParagraph) {
return '';
}
const allParagraphs = [...(body.paragraphs ?? [])].sort((left, right) => left.startIndex - right.startIndex);
const index = allParagraphs.findIndex((paragraph) => paragraph === titleParagraph || paragraph.paragraphId === titleParagraph.paragraphId);
let start = index > 0 ? allParagraphs[index - 1].startIndex + 1 : 0;
while (body.dataStream[start] === FIELD_START || body.dataStream[start] === FIELD_END) {
start += 1;
}
return getPlainText(body.dataStream.slice(start, titleParagraph.startIndex)).trim();
}
export function countTableOfContentsHeadings(body: IDocumentBody | null | undefined, levels = 3): number {
if (!body) {
return 0;
}
const maxLevel = Math.min(9, Math.max(1, Math.trunc(levels)));
return collectHeadings(body, undefined, 1, maxLevel).length;
}
function parseOutlineLevelRange(instruction: string): [number, number] {
const match = instruction.match(/\\o\s+"(\d+)\s*-\s*(\d+)"/i);
const min = Math.max(1, Number(match?.[1] ?? 1));
const max = Math.min(9, Math.max(min, Number(match?.[2] ?? 3)));
return [min, max];
}
function collectHeadings(body: IDocumentBody, toc: ICustomRange | undefined, minLevel: number, maxLevel: number): ITableOfContentsHeading[] {
const paragraphs = [...(body.paragraphs ?? [])].sort((left, right) => left.startIndex - right.startIndex);
let paragraphStart = 0;
return paragraphs.flatMap((paragraph) => {
let startOffset = paragraphStart;
while (body.dataStream[startOffset] === FIELD_START || body.dataStream[startOffset] === FIELD_END) {
startOffset += 1;
}
paragraphStart = paragraph.startIndex + 1;
const level = getOutlineLevel(paragraph);
const headingId = paragraph.paragraphStyle?.headingId;
if (!headingId || level == null || level < minLevel || level > maxLevel ||
(toc && startOffset >= toc.startIndex && paragraph.startIndex <= toc.endIndex)) {
return [];
}
const text = getPlainText(body.dataStream.slice(startOffset, paragraph.startIndex)).trim();
if (!text) {
return [];
}
const bookmark = body.customRanges?.find((range) =>
range.rangeType === CustomRangeType.BOOKMARK &&
range.startIndex <= paragraph.startIndex &&
range.endIndex >= startOffset &&
range.properties?.bookmarkId
);
return [{
bookmarkId: bookmark?.properties?.bookmarkId,
headingId,
level,
paragraph,
startOffset,
text,
}];
});
}
function getOutlineLevel(paragraph: IParagraph): number | undefined {
const outlineLevel = paragraph.paragraphStyle?.outlineLevel;
if (outlineLevel != null && outlineLevel >= 0 && outlineLevel <= 8) {
return outlineLevel + 1;
}
const namedStyle = paragraph.paragraphStyle?.namedStyleType;
if (namedStyle != null && namedStyle >= NamedStyleType.HEADING_1 && namedStyle <= NamedStyleType.HEADING_5) {
return namedStyle - NamedStyleType.HEADING_1 + 1;
}
}
function copyTocParagraph(
oldParagraphs: IParagraph[],
index: number,
level: number,
startIndex: number,
usedParagraphIds: Set<string>,
tabStopOffset?: number,
tabLeader: TableOfContentsTabLeader = 'dots',
format: TableOfContentsFormat = 'fromTemplate'
): IParagraph {
const byStyleLevel = oldParagraphs.find((paragraph) => paragraph.styleId?.match(/TOC(\d+)/i)?.[1] === String(level));
const source = byStyleLevel ?? oldParagraphs[Math.min(index, oldParagraphs.length - 1)];
// A same-level paragraph is a formatting template, not the identity of every
// entry. Keep distinct existing row IDs and allocate only for additional rows.
if (source) {
return cloneParagraphWithId({
...source,
startIndex,
paragraphId: oldParagraphs[index]?.paragraphId ?? '',
}, usedParagraphIds);
}
return {
startIndex,
paragraphId: createParagraphId(usedParagraphIds),
styleId: `TOC${level}`,
paragraphStyle: {
indentStart: { v: Math.max(0, level - 1) * getFormatIndent(format) },
spaceBelow: format === 'modern' ? { v: 4 } : format === 'classic' ? { v: 2 } : undefined,
tabStops: tabStopOffset == null
? undefined
: [{
offset: tabStopOffset,
alignment: TabStopAlignment.END,
leader: getTabStopLeader(tabLeader),
}],
},
};
}
function normalizeTabLeader(value: unknown): TableOfContentsTabLeader {
return value === 'none' || value === 'dashes' || value === 'underline' ? value : 'dots';
}
function getTabStopLeader(value: TableOfContentsTabLeader): TabStopLeader {
return {
none: TabStopLeader.NONE,
dots: TabStopLeader.DOT,
dashes: TabStopLeader.HYPHEN,
underline: TabStopLeader.UNDERSCORE,
}[value];
}
function normalizeFormat(value: unknown): TableOfContentsFormat {
return value === 'classic' || value === 'modern' || value === 'simple' ? value : 'fromTemplate';
}
function getFormatIndent(format: TableOfContentsFormat): number {
return {
fromTemplate: 18,
classic: 14,
modern: 20,
simple: 12,
}[format];
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const DOCS_TOC_PLUGIN_CONFIG_KEY = 'docs-toc.config';
export const configSymbol = Symbol(DOCS_TOC_PLUGIN_CONFIG_KEY);
export interface IUniverDocsTocConfig {
}
export const defaultPluginConfig: IUniverDocsTocConfig = {};
+34
View File
@@ -0,0 +1,34 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
buildPageNumberOnlyTableOfContentsBody,
buildTableOfContentsBody,
buildTableOfContentsInstruction,
countTableOfContentsHeadings,
DeleteTableOfContentsCommand,
findTableOfContentsAtOffset,
InsertTableOfContentsCommand,
UpdateTableOfContentsCommand,
} from './commands/table-of-contents.command';
export type {
IDeleteTableOfContentsCommandParams,
IInsertTableOfContentsCommandParams,
IUpdateTableOfContentsCommandParams,
TableOfContentsFormat,
TableOfContentsTabLeader,
} from './commands/table-of-contents.command';
export { UniverDocsTocPlugin } from './plugin';
+47
View File
@@ -0,0 +1,47 @@
/**
* 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 { IUniverDocsTocConfig } from './config/config';
import { DependentOn, ICommandService, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { DeleteTableOfContentsCommand, InsertTableOfContentsCommand, UpdateTableOfContentsCommand } from './commands/table-of-contents.command';
import { defaultPluginConfig, DOCS_TOC_PLUGIN_CONFIG_KEY } from './config/config';
@DependentOn(UniverDocsPlugin, UniverRenderEnginePlugin)
export class UniverDocsTocPlugin extends Plugin {
static override pluginName = 'DOCS_TOC_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_DOC;
constructor(
private readonly _config: IUniverDocsTocConfig = defaultPluginConfig,
@Inject(Injector) protected override _injector: Injector,
@IConfigService private readonly _configService: IConfigService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
const { ...rest } = merge({}, defaultPluginConfig, this._config);
this._configService.setConfig(DOCS_TOC_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
[InsertTableOfContentsCommand, UpdateTableOfContentsCommand, DeleteTableOfContentsCommand]
.forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

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