mirror of
https://github.com/dream-num/univer.git
synced 2026-10-02 04:24:34 +08:00
feat(docs): integrate table of contents and Office layout fidelity (#7670)
This commit is contained in:
@@ -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: '',
|
||||
|
||||
+56
-1
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -391,7 +391,7 @@ describe('DocDrawingTransformerController business methods', () => {
|
||||
hostPage: bodyPage,
|
||||
page: headerPage,
|
||||
width: 80,
|
||||
})).toBe(false);
|
||||
})).toBe(true);
|
||||
expect(shouldUseDocsDrawingOuterPageOrigin({
|
||||
drawing: overlayDrawing,
|
||||
height: 1055,
|
||||
|
||||
+171
-4
@@ -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: {
|
||||
|
||||
+2
-2
@@ -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({});
|
||||
|
||||
+90
-34
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-1
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
+58
-1
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
+78
@@ -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,
|
||||
|
||||
+59
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
-12
@@ -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;
|
||||
|
||||
+39
@@ -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);
|
||||
});
|
||||
|
||||
+10
-2
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from '@univerjs-infra/shared/postcss';
|
||||
+92
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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 = {};
|
||||
+90
@@ -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));
|
||||
}
|
||||
}
|
||||
+141
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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';
|
||||
@@ -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,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,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,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;
|
||||
@@ -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;
|
||||
@@ -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,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;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import createConfig from '@univerjs-infra/shared/vitest';
|
||||
|
||||
export default createConfig();
|
||||
@@ -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)
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 = {};
|
||||
@@ -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';
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user