mirror of
https://github.com/dream-num/univer.git
synced 2026-10-02 04:24:34 +08:00
feat(docs): support paginated footnotes and endnotes (#7645)
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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, DocStyleType } from '../../../types/interfaces/i-document-data';
|
||||
import { DocumentDataModel } from '../document-data-model';
|
||||
import { JSONX } from '../json-x/json-x';
|
||||
import { validateDocumentStructure } from '../text-x/structure-validator';
|
||||
import { TextX } from '../text-x/text-x';
|
||||
import { getRichTextEditPath } from '../text-x/utils';
|
||||
|
||||
function body(text: string): IDocumentBody {
|
||||
return {
|
||||
dataStream: `${text}\r\n`,
|
||||
customBlocks: [],
|
||||
customRanges: [],
|
||||
customDecorations: [],
|
||||
paragraphs: [{ startIndex: text.length, paragraphId: 'paragraph' }],
|
||||
sectionBreaks: [{ startIndex: text.length + 1, sectionId: 'section' }],
|
||||
};
|
||||
}
|
||||
|
||||
function createDocument(): DocumentDataModel {
|
||||
return new DocumentDataModel({
|
||||
id: 'doc',
|
||||
body: {
|
||||
...body('A\uFFFCB'),
|
||||
customRanges: [{ startIndex: 1, endIndex: 1, rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, wholeEntity: true, properties: { noteId: 'note' } }],
|
||||
},
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: body('Explanation') } },
|
||||
noteSettings: { footnote: { position: 'pageBottom', restart: 'eachSect', numberFormat: 'lowerRoman', startNumber: 3 } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('document footnote segments', () => {
|
||||
it('inherits document styles and refreshes them without persisting duplicate styles in each note', () => {
|
||||
const document = createDocument();
|
||||
const jsonX = JSONX.getInstance();
|
||||
try {
|
||||
document.apply(jsonX.insertOp(['styles'], { FootnoteText: { name: 'Footnote Text', type: DocStyleType.paragraph, textStyle: { fs: 10 } } }));
|
||||
const note = document.noteModelMap.get('note');
|
||||
expect(note?.getSnapshot().styles?.FootnoteText.textStyle?.fs).toBe(10);
|
||||
document.apply(jsonX.replaceOp(['styles', 'FootnoteText', 'textStyle', 'fs'], 10, 12));
|
||||
expect(document.noteModelMap.get('note')).not.toBe(note);
|
||||
expect(document.noteModelMap.get('note')?.getSnapshot().styles?.FootnoteText.textStyle?.fs).toBe(12);
|
||||
document.updateDocumentStyle({ textStyle: { fs: 14 }, marginLeft: 40 });
|
||||
expect(document.noteModelMap.get('note')?.getDocumentStyle()).toMatchObject({
|
||||
textStyle: { fs: 14 },
|
||||
marginLeft: 40,
|
||||
});
|
||||
expect(Object.keys(document.getSnapshot().notes!.note)).toEqual(['type', 'noteId', 'body']);
|
||||
} finally {
|
||||
document.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('edits note text through the rich-text path and restores it through undo', () => {
|
||||
const document = createDocument();
|
||||
const before = structuredClone(document.getSnapshot());
|
||||
const path = getRichTextEditPath(document, 'note');
|
||||
const edit = JSONX.getInstance().editOp(new TextX().insert(4, { dataStream: 'New ' }).serialize(), path);
|
||||
const undo = JSONX.invertWithDoc(edit, before);
|
||||
document.apply(edit);
|
||||
expect(document.getSelfOrHeaderFooterModel('note')?.getBody()?.dataStream).toBe('New Explanation\r\n');
|
||||
expect(document.getBody()).toEqual(before.body);
|
||||
document.apply(undo);
|
||||
expect(document.getSnapshot()).toEqual(before);
|
||||
expect(document.getSelfOrHeaderFooterModel('note')?.getBody()?.dataStream).toBe('Explanation\r\n');
|
||||
document.dispose();
|
||||
});
|
||||
|
||||
it('preserves the note identity when text before its reference is edited', () => {
|
||||
const document = createDocument();
|
||||
const edit = JSONX.getInstance().editOp(new TextX().insert(2, { dataStream: '前文' }).serialize(), ['body']);
|
||||
document.apply(edit);
|
||||
expect(document.getBody()?.customRanges?.[0]).toMatchObject({ startIndex: 3, endIndex: 3, properties: { noteId: 'note' } });
|
||||
const restored = new DocumentDataModel(document.getSnapshot());
|
||||
expect(restored.getSelfOrHeaderFooterModel('note')?.getBody()).toEqual(document.getSnapshot().notes?.note.body);
|
||||
expect(restored.getSnapshot().noteSettings?.footnote).toEqual(document.getSnapshot().noteSettings?.footnote);
|
||||
expect(validateDocumentStructure(restored.getSnapshot())).toEqual([]);
|
||||
restored.dispose();
|
||||
document.dispose();
|
||||
});
|
||||
|
||||
it('keeps unrelated note models when one note is edited', () => {
|
||||
const document = createDocument();
|
||||
const jsonX = JSONX.getInstance();
|
||||
document.apply(jsonX.insertOp(['notes', 'other'], { type: 'footnote' as const, noteId: 'other', body: body('Other explanation') }));
|
||||
const other = document.noteModelMap.get('other');
|
||||
const edit = jsonX.editOp(new TextX().insert(4, { dataStream: 'New ' }).serialize(), ['notes', 'note', 'body']);
|
||||
document.apply(edit);
|
||||
expect(document.noteModelMap.get('other')).toBe(other);
|
||||
expect(document.noteModelMap.get('note')?.getBody()?.dataStream).toBe('New Explanation\r\n');
|
||||
document.dispose();
|
||||
});
|
||||
|
||||
it('removes a disposed note segment from the model after a persisted deletion', () => {
|
||||
const document = createDocument();
|
||||
const edit = JSONX.getInstance().removeOp(['notes', 'note'], document.getSnapshot().notes?.note);
|
||||
document.apply(edit);
|
||||
expect(document.getSelfOrHeaderFooterModel('note')).toBeNull();
|
||||
expect(validateDocumentStructure(document.getSnapshot()).map((issue) => issue.code)).toContain('missing-note');
|
||||
document.dispose();
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ import { Tools } from '../../shared/tools';
|
||||
import { createSectionId } from '../section-break-id';
|
||||
import { calculateDocumentStatistics } from './document-statistics';
|
||||
import { getEmptySnapshot } from './empty-snapshot';
|
||||
import { JSONX } from './json-x/json-x';
|
||||
import { JSON1, JSONX } from './json-x/json-x';
|
||||
import { PRESET_LIST_TYPE } from './preset-list-type';
|
||||
import { getPlainText } from './text-x/build-utils/parse';
|
||||
import { getBodySlice, SliceBodyType } from './text-x/utils';
|
||||
@@ -337,6 +337,7 @@ export class DocumentDataModel extends DocumentDataModelSimple {
|
||||
headerModelMap: Map<string, DocumentDataModel> = new Map();
|
||||
|
||||
footerModelMap: Map<string, DocumentDataModel> = new Map();
|
||||
noteModelMap: Map<string, DocumentDataModel> = new Map();
|
||||
change$ = new BehaviorSubject<number>(0);
|
||||
|
||||
constructor(snapshot: Partial<IDocumentData>) {
|
||||
@@ -359,6 +360,8 @@ export class DocumentDataModel extends DocumentDataModelSimple {
|
||||
this.footerModelMap.forEach((footer) => {
|
||||
footer.dispose();
|
||||
});
|
||||
this.noteModelMap.forEach((note) => note.dispose());
|
||||
this.noteModelMap.clear();
|
||||
|
||||
this._name$.complete();
|
||||
}
|
||||
@@ -408,6 +411,10 @@ export class DocumentDataModel extends DocumentDataModelSimple {
|
||||
return this.footerModelMap.get(segmentId)!;
|
||||
}
|
||||
|
||||
if (this.noteModelMap.has(segmentId)) {
|
||||
return this.noteModelMap.get(segmentId)!;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -420,8 +427,24 @@ export class DocumentDataModel extends DocumentDataModelSimple {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousNotes = this.snapshot.notes;
|
||||
const changedNotes = new Set<string>();
|
||||
let changedInheritedNoteStyles = false;
|
||||
const cursor = JSON1.type.readCursor(actions);
|
||||
cursor.traverse(null, () => {
|
||||
const path = cursor.getPath();
|
||||
if (path[0] === 'notes' && typeof path[1] === 'string') {
|
||||
changedNotes.add(path[1]);
|
||||
}
|
||||
if (path[0] === 'styles' || path[0] === 'documentStyle') {
|
||||
changedInheritedNoteStyles = true;
|
||||
}
|
||||
});
|
||||
this.snapshot = JSONX.apply(this.snapshot, actions) as unknown as IDocumentData;
|
||||
this._markMutation();
|
||||
if (changedInheritedNoteStyles || previousNotes !== this.snapshot.notes || changedNotes.size > 0) {
|
||||
this._initializeNoteModels(changedInheritedNoteStyles ? undefined : previousNotes, changedNotes);
|
||||
}
|
||||
|
||||
// FIXME: @JOCS, ANY better solution to find action that create or delete header/footer?
|
||||
if (actions?.some((a) => Array.isArray(a) && (a?.[0] === 'headers' || a?.[0] === 'footers'))) {
|
||||
@@ -445,6 +468,7 @@ export class DocumentDataModel extends DocumentDataModelSimple {
|
||||
}
|
||||
|
||||
private _initializeHeaderFooterModel() {
|
||||
this._initializeNoteModels();
|
||||
const { headers, footers } = this.getSnapshot();
|
||||
|
||||
if (headers) {
|
||||
@@ -464,6 +488,32 @@ export class DocumentDataModel extends DocumentDataModelSimple {
|
||||
}
|
||||
}
|
||||
|
||||
private _initializeNoteModels(previousNotes?: IDocumentData['notes'], changedNotes?: ReadonlySet<string>): void {
|
||||
for (const [id, model] of this.noteModelMap) {
|
||||
if (changedNotes?.has(id) || previousNotes?.[id] !== this.snapshot.notes?.[id] || !previousNotes?.[id]) {
|
||||
model.dispose();
|
||||
this.noteModelMap.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id, note] of Object.entries(this.snapshot.notes ?? {})) {
|
||||
if (this.noteModelMap.has(id)) {
|
||||
continue;
|
||||
}
|
||||
const model = new DocumentDataModel({
|
||||
...note,
|
||||
id: this.getUnitId(),
|
||||
documentStyle: this.snapshot.documentStyle,
|
||||
styles: this.snapshot.styles,
|
||||
});
|
||||
this.noteModelMap.set(id, model);
|
||||
}
|
||||
}
|
||||
|
||||
override updateDocumentStyle(config: IDocumentStyle) {
|
||||
super.updateDocumentStyle(config);
|
||||
this._initializeNoteModels();
|
||||
}
|
||||
|
||||
override updateDocumentId(unitId: string) {
|
||||
super.updateDocumentId(unitId);
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ export function getEmptySnapshot(
|
||||
drawingsOrder: [],
|
||||
headers: {},
|
||||
footers: {},
|
||||
// Stable containers let concurrent first-note mutations address distinct child IDs.
|
||||
notes: {},
|
||||
noteSettings: {},
|
||||
body: {
|
||||
dataStream: '\r\n',
|
||||
textRuns: [],
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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, IDocumentNote } from '../../../../types/interfaces/i-document-data';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CustomRangeType } from '../../../../types/interfaces/i-document-data';
|
||||
import { validateDocumentStructure } from '../structure-validator';
|
||||
|
||||
function noteBody(text: string): IDocumentBody {
|
||||
return {
|
||||
dataStream: `${text}\r\n`,
|
||||
paragraphs: [{ paragraphId: 'paragraph', startIndex: text.length }],
|
||||
sectionBreaks: [{ sectionId: 'section', startIndex: text.length + 1 }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('validateDocumentStructure notes', () => {
|
||||
it.each(['footnote', 'endnote'] as const)('rejects independent page setup in a %s while accepting a normal rich note body', (type) => {
|
||||
const note: IDocumentNote = { type, noteId: 'note', body: noteBody('Explanation') };
|
||||
note.body.textRuns = [{ st: 0, ed: 3, ts: { bl: 1 } }];
|
||||
expect(validateDocumentStructure({ notes: { note } })).toEqual([]);
|
||||
note.body.sectionBreaks![0].marginTop = 50;
|
||||
expect(validateDocumentStructure({ notes: { note } }).map((issue) => issue.code)).toEqual(['invalid-note-body']);
|
||||
note.body = {
|
||||
dataStream: 'A\r\nB\r\n',
|
||||
paragraphs: [{ paragraphId: 'a', startIndex: 1 }, { paragraphId: 'b', startIndex: 4 }],
|
||||
sectionBreaks: [{ sectionId: 'a', startIndex: 2 }, { sectionId: 'b', startIndex: 5 }],
|
||||
};
|
||||
expect(validateDocumentStructure({ notes: { note } }).map((issue) => issue.code)).toContain('invalid-note-body');
|
||||
});
|
||||
|
||||
it('rejects duplicate references and mismatched segment identities', () => {
|
||||
const body = noteBody('\uFFFC\uFFFC');
|
||||
body.customRanges = [0, 1].map((index) => ({
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
rangeId: `reference-${index}`,
|
||||
startIndex: index,
|
||||
endIndex: index,
|
||||
wholeEntity: true,
|
||||
properties: { noteId: 'note' },
|
||||
}));
|
||||
const notes = { note: { type: 'footnote' as const, noteId: 'note', body: noteBody('Explanation') } };
|
||||
expect(validateDocumentStructure({ body, notes }).map((issue) => issue.code)).toEqual(['duplicate-note-reference']);
|
||||
notes.note.noteId = 'other';
|
||||
expect(validateDocumentStructure({ notes }).map((issue) => issue.code)).toEqual(['invalid-note-id']);
|
||||
});
|
||||
|
||||
it('rejects nested references even when the referenced note exists', () => {
|
||||
const body = noteBody('\uFFFC');
|
||||
body.customRanges = [{ rangeType: CustomRangeType.FOOTNOTE, rangeId: 'nested', startIndex: 0, endIndex: 0, wholeEntity: true, properties: { noteId: 'second' } }];
|
||||
expect(validateDocumentStructure({ notes: {
|
||||
first: { type: 'footnote' as const, noteId: 'first', body },
|
||||
second: { type: 'footnote' as const, noteId: 'second', body: noteBody('Second') },
|
||||
} }).map((issue) => issue.code)).toEqual(['nested-note']);
|
||||
});
|
||||
|
||||
it.each(['headers', 'footers'] as const)('rejects references in %s and ambiguous note segment identities', (kind) => {
|
||||
const body = noteBody('\uFFFC');
|
||||
body.customRanges = [{ rangeType: CustomRangeType.FOOTNOTE, rangeId: 'reference', startIndex: 0, endIndex: 0, wholeEntity: true, properties: { noteId: 'note' } }];
|
||||
const notes = { note: { type: 'footnote' as const, noteId: 'note', body: noteBody('Explanation') } };
|
||||
const running = { headerId: 'running', footerId: 'running', body };
|
||||
expect(validateDocumentStructure({ [kind]: { running }, notes })).toEqual([
|
||||
expect.objectContaining({ code: 'invalid-note-reference', segmentId: 'running', index: 0 }),
|
||||
]);
|
||||
const colliding = { headerId: 'note', footerId: 'note', body: noteBody('Running text') };
|
||||
expect(validateDocumentStructure({ [kind]: { note: colliding }, notes })).toEqual([
|
||||
expect.objectContaining({ code: 'invalid-note-id', segmentType: 'note', segmentId: 'note' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -254,6 +254,68 @@ describe('drawing build utils', () => {
|
||||
expect(doc.getBody()?.dataStream).toBe('A\bB\r\n');
|
||||
});
|
||||
|
||||
it('keeps inserted and replaced footnote images scoped through undo and redo', () => {
|
||||
const doc = new DocumentDataModel({
|
||||
id: 'doc-note-images',
|
||||
body: { dataStream: 'Main\r\n' },
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: { dataStream: 'Note\r\n' } } },
|
||||
});
|
||||
const before = doc.getSnapshot();
|
||||
const drawings = ['image-1', 'image-2'].map((drawingId) => ({
|
||||
drawingId,
|
||||
unitId: 'doc-note-images',
|
||||
subUnitId: 'doc-note-images',
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
layoutType: PositionedObjectLayoutType.INLINE,
|
||||
docTransform: {
|
||||
size: { width: 20, height: 20 },
|
||||
positionH: { relativeFrom: 0, posOffset: 0 },
|
||||
positionV: { relativeFrom: 0, posOffset: 0 },
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const insert = addDrawing({
|
||||
selection: { startOffset: 4, endOffset: 4, collapsed: true, segmentId: 'note' },
|
||||
documentDataModel: doc,
|
||||
drawings,
|
||||
});
|
||||
if (!insert) {
|
||||
throw new Error('Expected footnote drawing insertion');
|
||||
}
|
||||
const undoInsert = JSONX.invertWithDoc(insert, before);
|
||||
doc.apply(insert);
|
||||
const inserted = doc.getSnapshot();
|
||||
expect(inserted.notes?.note.body.dataStream).toBe('Note\b\b\r\n');
|
||||
expect(inserted.notes?.note.drawingsOrder).toEqual(['image-1', 'image-2']);
|
||||
expect(inserted.notes?.note.drawings).toMatchObject({ 'image-1': drawings[0], 'image-2': drawings[1] });
|
||||
expect(inserted.body).toEqual(before.body);
|
||||
expect(inserted.drawings).toEqual(before.drawings);
|
||||
expect(inserted.drawingsOrder).toEqual(before.drawingsOrder);
|
||||
|
||||
const replace = addDrawing({
|
||||
selection: { startOffset: 4, endOffset: 6, collapsed: false, segmentId: 'note' },
|
||||
documentDataModel: doc,
|
||||
drawings: [{ ...drawings[0], drawingId: 'replacement' }],
|
||||
});
|
||||
if (!replace) {
|
||||
throw new Error('Expected footnote drawing replacement');
|
||||
}
|
||||
const undoReplace = JSONX.invertWithDoc(replace, inserted);
|
||||
doc.apply(replace);
|
||||
expect(doc.getSnapshot().notes?.note.drawingsOrder).toEqual(['replacement']);
|
||||
expect(Object.keys(doc.getSnapshot().notes?.note.drawings ?? {})).toEqual(['replacement']);
|
||||
expect(doc.getSnapshot().notes?.note.body.dataStream).toBe('Note\b\r\n');
|
||||
doc.apply(undoReplace);
|
||||
expect(doc.getSnapshot()).toEqual(inserted);
|
||||
doc.apply(undoInsert);
|
||||
expect(doc.getSnapshot()).toEqual(before);
|
||||
doc.apply(insert);
|
||||
expect(doc.getSnapshot()).toEqual(inserted);
|
||||
} finally {
|
||||
doc.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false when drawing insertion targets a missing body', () => {
|
||||
const documentDataModel = new DocumentDataModel({ id: 'doc-without-body' });
|
||||
delete documentDataModel.getSnapshot().body;
|
||||
|
||||
@@ -56,16 +56,20 @@ export function getCustomBlockIdsInSelections(body: IDocumentBody, selections: I
|
||||
}
|
||||
|
||||
export function removeDrawingReferences(
|
||||
documentData: Pick<IDocumentData, 'body' | 'drawings' | 'drawingsOrder'>,
|
||||
documentData: Pick<IDocumentData, 'body' | 'drawings' | 'drawingsOrder' | 'notes'>,
|
||||
selections: ITextRange[],
|
||||
body: IDocumentBody | undefined = documentData.body
|
||||
body: IDocumentBody | undefined = documentData.body,
|
||||
segmentId = ''
|
||||
): JSONXActions[] {
|
||||
if (!body) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const drawings = documentData.drawings ?? {};
|
||||
const drawingOrder = documentData.drawingsOrder ?? [];
|
||||
const footnote = documentData.notes?.[segmentId];
|
||||
const source = footnote ?? documentData;
|
||||
const prefix = footnote ? ['notes', segmentId] : [];
|
||||
const drawings = source.drawings ?? {};
|
||||
const drawingOrder = source.drawingsOrder ?? [];
|
||||
const blockIds = [...new Set(getCustomBlockIdsInSelections(body, selections))]
|
||||
.sort((left, right) => drawingOrder.indexOf(right) - drawingOrder.indexOf(left));
|
||||
const jsonX = JSONX.getInstance();
|
||||
@@ -74,7 +78,7 @@ export function removeDrawingReferences(
|
||||
for (const blockId of blockIds) {
|
||||
const drawing = drawings[blockId];
|
||||
if (drawing != null) {
|
||||
const removeDrawingAction = jsonX.removeOp(['drawings', blockId], drawing);
|
||||
const removeDrawingAction = jsonX.removeOp([...prefix, 'drawings', blockId], drawing);
|
||||
if (removeDrawingAction) {
|
||||
actions.push(removeDrawingAction);
|
||||
}
|
||||
@@ -82,7 +86,7 @@ export function removeDrawingReferences(
|
||||
|
||||
const drawingIndex = drawingOrder.indexOf(blockId);
|
||||
if (drawingIndex >= 0) {
|
||||
const removeDrawingOrderAction = jsonX.removeOp(['drawingsOrder', drawingIndex], blockId);
|
||||
const removeDrawingOrderAction = jsonX.removeOp([...prefix, 'drawingsOrder', drawingIndex], blockId);
|
||||
if (removeDrawingOrderAction) {
|
||||
actions.push(removeDrawingOrderAction);
|
||||
}
|
||||
@@ -92,7 +96,6 @@ export function removeDrawingReferences(
|
||||
return actions;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
export const addDrawing = (param: IAddDrawingParam) => {
|
||||
const { selection, documentDataModel, drawings } = param;
|
||||
const { collapsed, startOffset, segmentId } = selection;
|
||||
@@ -105,11 +108,21 @@ export const addDrawing = (param: IAddDrawingParam) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const drawingOrderLength = documentDataModel.getSnapshot().drawingsOrder?.length ?? 0;
|
||||
let removeDrawingLen = 0;
|
||||
const snapshot = documentDataModel.getSnapshot();
|
||||
const footnote = snapshot.notes?.[segmentId ?? ''];
|
||||
const source = footnote ?? snapshot;
|
||||
const prefix = footnote ? ['notes', segmentId!] : [];
|
||||
const drawingOrder = source.drawingsOrder ?? [];
|
||||
let insertDrawingIndex = drawingOrder.length;
|
||||
if (source.drawings == null) {
|
||||
rawActions.push(jsonX.insertOp([...prefix, 'drawings'], {})!);
|
||||
}
|
||||
if (source.drawingsOrder == null) {
|
||||
rawActions.push(jsonX.insertOp([...prefix, 'drawingsOrder'], [])!);
|
||||
}
|
||||
const insertOffset = collapsed ? normalizeDrawingInsertOffset(body, startOffset ?? 0) : (startOffset ?? 0);
|
||||
|
||||
// Step 1: Insert placeholder `\b` in dataStream and add drawing to customBlocks.
|
||||
// Step 1: Insert placeholder `\b` in dataStream and add drawing to customBlocks.
|
||||
if (collapsed) {
|
||||
if (insertOffset > 0) {
|
||||
textX.push({
|
||||
@@ -121,34 +134,11 @@ export const addDrawing = (param: IAddDrawingParam) => {
|
||||
const dos = deleteSelectionTextX([selection], body, 0, null, false);
|
||||
textX.push(...dos);
|
||||
|
||||
const removedCustomBlockIds = getCustomBlockIdsInSelections(body, [selection]);
|
||||
const drawings = documentDataModel.getDrawings() ?? {};
|
||||
const drawingOrder = documentDataModel.getDrawingsOrder() ?? [];
|
||||
const sortedRemovedCustomBlockIds = removedCustomBlockIds.sort((a, b) => {
|
||||
if (drawingOrder.indexOf(a) > drawingOrder.indexOf(b)) {
|
||||
return -1;
|
||||
} else if (drawingOrder.indexOf(a) < drawingOrder.indexOf(b)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (sortedRemovedCustomBlockIds.length > 0) {
|
||||
for (const blockId of sortedRemovedCustomBlockIds) {
|
||||
const drawing = drawings[blockId];
|
||||
const drawingIndex = drawingOrder.indexOf(blockId);
|
||||
if (drawing == null || drawingIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const removeDrawingAction = jsonX.removeOp(['drawings', blockId], drawing);
|
||||
const removeDrawingOrderAction = jsonX.removeOp(['drawingsOrder', drawingIndex], blockId);
|
||||
|
||||
rawActions.push(removeDrawingAction!);
|
||||
rawActions.push(removeDrawingOrderAction!);
|
||||
|
||||
removeDrawingLen++;
|
||||
const removedIds = new Set(getCustomBlockIdsInSelections(body, [selection]));
|
||||
insertDrawingIndex -= drawingOrder.filter((id) => removedIds.has(id)).length;
|
||||
for (const action of removeDrawingReferences(snapshot, [selection], body, segmentId)) {
|
||||
if (action) {
|
||||
rawActions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,11 +155,11 @@ export const addDrawing = (param: IAddDrawingParam) => {
|
||||
|
||||
rawActions.push(placeHolderAction!);
|
||||
|
||||
// Step 2: add drawing to drawings and drawingsOrder fields.
|
||||
// Step 2: add drawing to drawings and drawingsOrder fields.
|
||||
for (const drawing of drawings) {
|
||||
const { drawingId } = drawing;
|
||||
const addDrawingAction = jsonX.insertOp(['drawings', drawingId], drawing);
|
||||
const addDrawingOrderAction = jsonX.insertOp(['drawingsOrder', drawingOrderLength - removeDrawingLen], drawingId);
|
||||
const addDrawingAction = jsonX.insertOp([...prefix, 'drawings', drawingId], drawing);
|
||||
const addDrawingOrderAction = jsonX.insertOp([...prefix, 'drawingsOrder', insertDrawingIndex++], drawingId);
|
||||
|
||||
rawActions.push(addDrawingAction!);
|
||||
rawActions.push(addDrawingOrderAction!);
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ICustomTable, IDocumentBody, IDocumentData } from '../../../types/interfaces/i-document-data';
|
||||
import type { 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,
|
||||
@@ -24,6 +25,12 @@ import {
|
||||
} from './build-utils/range-interval';
|
||||
|
||||
export type DocStructureIssueCode =
|
||||
| 'missing-note'
|
||||
| 'invalid-note-reference'
|
||||
| 'nested-note'
|
||||
| 'invalid-note-body'
|
||||
| 'invalid-note-id'
|
||||
| 'duplicate-note-reference'
|
||||
| 'missing-root-paragraph'
|
||||
| 'missing-root-section-break'
|
||||
| 'paragraph-token-mismatch'
|
||||
@@ -54,7 +61,7 @@ export type DocStructureIssueCode =
|
||||
|
||||
export interface IDocStructureIssue {
|
||||
code: DocStructureIssueCode;
|
||||
segmentType: 'body' | 'header' | 'footer';
|
||||
segmentType: 'body' | 'header' | 'footer' | 'note';
|
||||
segmentId?: string;
|
||||
index?: number;
|
||||
message: string;
|
||||
@@ -546,14 +553,61 @@ export function validateDocBodyStructure(
|
||||
validateCustomBlockMetadata(body, scan, issues, context);
|
||||
validateStructuralContainers(body, issues, context);
|
||||
|
||||
if (context.segmentType === 'header' || context.segmentType === 'footer') {
|
||||
for (const range of body.customRanges ?? []) {
|
||||
if ((range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE)) {
|
||||
issues.push(createIssue(context, 'invalid-note-reference', 'Note references are only supported in the document body.', range.startIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateDocumentStructure(snapshot: Pick<IDocumentData, 'body' | 'headers' | 'footers'>): IDocStructureIssue[] {
|
||||
function validateNoteBody(noteId: string, note: IDocumentNote): IDocStructureIssue[] {
|
||||
const context: IValidationContext = { segmentType: 'note', segmentId: noteId };
|
||||
const issues = validateDocBodyStructure(note.body, context);
|
||||
if (note.noteId !== noteId || noteId.length === 0 || !['footnote', 'endnote'].includes(note.type)) {
|
||||
issues.push({ ...context, code: 'invalid-note-id', message: 'Note identity must match its segment key.' });
|
||||
}
|
||||
if (note.body.customRanges?.some((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE))) {
|
||||
issues.push({ ...context, code: 'nested-note', message: 'A note cannot contain another note reference.' });
|
||||
}
|
||||
const rootSections = note.body.sectionBreaks?.filter((section) =>
|
||||
!note.body.tables?.some((table) => section.startIndex >= table.startIndex && section.startIndex < table.endIndex)) ?? [];
|
||||
const hasPageSetup = rootSections.some((section) => Object.keys(section).some((key) => key !== 'sectionId' && key !== 'startIndex'));
|
||||
const hasFloatingDrawings = Object.values(note.drawings ?? {}).some((drawing) => drawing.layoutType !== PositionedObjectLayoutType.INLINE);
|
||||
const hasFloatingTables = Object.values(note.tableSource ?? {}).some((table) => table.textWrap === TableTextWrapType.WRAP);
|
||||
if (rootSections.length > 1 || hasPageSetup || note.body.columnGroups?.length || hasFloatingDrawings || hasFloatingTables) {
|
||||
issues.push({ ...context, code: 'invalid-note-body', message: 'Notes support paragraphs, inline drawings and flow tables without independent sections or columns.' });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateDocumentStructure(snapshot: Pick<IDocumentData, 'body' | 'headers' | 'footers' | 'notes'>): IDocStructureIssue[] {
|
||||
const issues: IDocStructureIssue[] = [];
|
||||
|
||||
if (snapshot.body) {
|
||||
issues.push(...validateDocBodyStructure(snapshot.body, { segmentType: 'body' }));
|
||||
const referencedNotes = new Set<string>();
|
||||
for (const range of snapshot.body.customRanges ?? []) {
|
||||
if ((range.rangeType !== CustomRangeType.FOOTNOTE && range.rangeType !== CustomRangeType.ENDNOTE)) {
|
||||
continue;
|
||||
}
|
||||
const noteId = range.properties?.noteId;
|
||||
if (typeof noteId !== 'string' || snapshot.notes?.[noteId] == null) {
|
||||
issues.push({ code: 'missing-note', segmentType: 'body', index: range.startIndex, message: 'Note reference must resolve to a note segment.' });
|
||||
} else if (snapshot.notes[noteId].type !== (range.rangeType === CustomRangeType.ENDNOTE ? 'endnote' : 'footnote')) {
|
||||
issues.push({ code: 'invalid-note-reference', segmentType: 'body', index: range.startIndex, message: 'Note reference kind must match its target.' });
|
||||
} else if (referencedNotes.has(noteId)) {
|
||||
issues.push({ code: 'duplicate-note-reference', segmentType: 'body', index: range.startIndex, message: 'Each note must have its own reference identity.' });
|
||||
} else {
|
||||
referencedNotes.add(noteId);
|
||||
}
|
||||
if (range.startIndex !== range.endIndex || snapshot.body.dataStream[range.startIndex] !== '\uFFFC' || !range.wholeEntity) {
|
||||
issues.push({ code: 'invalid-note-reference', segmentType: 'body', index: range.startIndex, message: 'Note reference must cover one whole-entity reference character.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [headerId, header] of Object.entries(snapshot.headers ?? {})) {
|
||||
@@ -564,5 +618,12 @@ export function validateDocumentStructure(snapshot: Pick<IDocumentData, 'body' |
|
||||
issues.push(...validateDocBodyStructure(footer.body, { segmentType: 'footer', segmentId: footerId }));
|
||||
}
|
||||
|
||||
for (const [noteId, note] of Object.entries(snapshot.notes ?? {})) {
|
||||
issues.push(...validateNoteBody(noteId, note));
|
||||
if (snapshot.headers?.[noteId] || snapshot.footers?.[noteId]) {
|
||||
issues.push({ code: 'invalid-note-id', segmentType: 'note', segmentId: noteId, message: 'Notes must not share a segment identity with a header or footer.' });
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -770,6 +770,9 @@ export function getRichTextEditPath(docDataModel: DocumentDataModel, segmentId =
|
||||
}
|
||||
|
||||
const { headers, footers } = docDataModel.getSnapshot();
|
||||
if (docDataModel.getSnapshot().notes?.[segmentId] != null) {
|
||||
return ['notes', segmentId, 'body'];
|
||||
}
|
||||
|
||||
if (headers == null && footers == null) {
|
||||
throw new Error('Document data model must have headers or footers when update by segment id');
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface IDocumentData extends IReferenceSource {
|
||||
locale?: LocaleType;
|
||||
title?: string;
|
||||
body?: IDocumentBody; // Rich text.
|
||||
/** Independent rich-text segments addressed by footnote or endnote references in the main body. */
|
||||
notes?: Record<string, IDocumentNote>;
|
||||
noteSettings?: INoteSettings;
|
||||
documentStyle: IDocumentStyle;
|
||||
/** OOXML-compatible named document styles keyed by stable style id. */
|
||||
styles?: IDocStyles;
|
||||
@@ -57,6 +60,63 @@ export interface IDocumentSettings {
|
||||
zoomRatio?: number;
|
||||
}
|
||||
|
||||
/** Settings inherit from the document into each section; absent values do not reset inheritance. */
|
||||
export interface IFootnoteProperties {
|
||||
position?: 'pageBottom' | 'beneathText';
|
||||
/** OOXML ST_NumberFormat name, shared by the import/export and numbering engines. */
|
||||
numberFormat?: string;
|
||||
startNumber?: number;
|
||||
restart?: 'continuous' | 'eachSect' | 'eachPage';
|
||||
/** Zero matches the body section's column count. */
|
||||
columnCount?: number;
|
||||
}
|
||||
|
||||
export interface IFootnoteSettings extends IFootnoteProperties {
|
||||
separator?: IDocumentBody;
|
||||
continuationSeparator?: IDocumentBody;
|
||||
continuationNotice?: IDocumentBody;
|
||||
}
|
||||
|
||||
export type DocumentNoteType = 'footnote' | 'endnote';
|
||||
|
||||
export interface IEndnoteProperties extends Omit<IFootnoteProperties, 'position' | 'restart' | 'columnCount'> {
|
||||
position?: 'docEnd' | 'sectEnd';
|
||||
restart?: 'continuous' | 'eachSect';
|
||||
}
|
||||
|
||||
export interface IEndnoteSettings extends IEndnoteProperties {
|
||||
separator?: IDocumentBody;
|
||||
continuationSeparator?: IDocumentBody;
|
||||
continuationNotice?: IDocumentBody;
|
||||
}
|
||||
|
||||
export interface INoteSettings {
|
||||
footnote?: IFootnoteSettings;
|
||||
endnote?: IEndnoteSettings;
|
||||
}
|
||||
|
||||
export interface INoteProperties {
|
||||
footnote?: IFootnoteProperties;
|
||||
endnote?: IEndnoteProperties;
|
||||
}
|
||||
|
||||
/** Notes have editable content, but no independent page setup or nested notes. */
|
||||
export interface IDocumentNote {
|
||||
noteId: string;
|
||||
type: DocumentNoteType;
|
||||
body: IDocumentBody;
|
||||
tableSource?: ITables;
|
||||
drawings?: IDrawings;
|
||||
drawingsOrder?: string[];
|
||||
lists?: ILists;
|
||||
/** Custom marks do not consume a number in the automatic sequence. */
|
||||
customMark?: string;
|
||||
/** Style of the generated note marker; it does not affect text inserted at body offset zero. */
|
||||
referenceTextStyle?: ITextStyle;
|
||||
}
|
||||
|
||||
export type IFootnoteCustomRange = ICustomRange<{ noteId: string }>;
|
||||
|
||||
/**
|
||||
* Set of headers
|
||||
*/
|
||||
@@ -437,6 +497,8 @@ export enum CustomRangeType {
|
||||
CUSTOM,
|
||||
MENTION,
|
||||
UNI_FORMULA,
|
||||
FOOTNOTE,
|
||||
ENDNOTE,
|
||||
|
||||
DELTED = 9999,
|
||||
}
|
||||
@@ -538,6 +600,8 @@ export interface IDocumentLayout {
|
||||
characterSpacingControl?: characterSpacingControlType; // characterSpacingControl 17.18.7 ST_CharacterSpacing (Character-Level Whitespace Compression Settings),default compressPunctuation
|
||||
/** Use the legacy East Asian Word layout rules stored as OOXML `useFELayout`. */
|
||||
useFELayout?: BooleanNumber;
|
||||
/** OOXML `splitPgBreakAndParaMark`: move a trailing paragraph mark after a manual page break. */
|
||||
splitPageBreakAndParagraphMark?: BooleanNumber;
|
||||
/** Align automatic line height inside tables to the active document line grid. */
|
||||
adjustLineHeightInTable?: BooleanNumber;
|
||||
paragraphLineGapDefault?: number; // paragraphLineGapDefault default line spacing
|
||||
@@ -597,6 +661,9 @@ export interface IDocumentRenderConfig {
|
||||
}
|
||||
|
||||
export interface ISectionBreakBase {
|
||||
noteProperties?: INoteProperties;
|
||||
/** Defer section-end notes to the next section that does not suppress them. */
|
||||
suppressEndnotes?: boolean;
|
||||
// docGrid (Document Grid), open xml $17.6.5
|
||||
charSpace?: number; // charSpace
|
||||
linePitch?: number; // linePitch
|
||||
|
||||
+42
@@ -19,6 +19,7 @@ import type { IDocDrawing } from '@univerjs/docs-drawing';
|
||||
import type { RenderUnit } from '@univerjs/engine-render';
|
||||
import {
|
||||
BooleanNumber,
|
||||
CustomRangeType,
|
||||
DocumentFlavor,
|
||||
DrawingTypeEnum,
|
||||
ICommandService,
|
||||
@@ -242,6 +243,47 @@ describe('DocDrawingAddRemoveController with real commands and services', () =>
|
||||
removeSubscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('registers scoped note images and removes them when their note is deleted', () => {
|
||||
const body = model.getBody()!;
|
||||
const referencedBody = {
|
||||
...body,
|
||||
dataStream: `\uFFFC${body.dataStream}`,
|
||||
paragraphs: body.paragraphs?.map((paragraph) => ({ ...paragraph, startIndex: paragraph.startIndex + 1 })),
|
||||
sectionBreaks: body.sectionBreaks?.map((section) => ({ ...section, startIndex: section.startIndex + 1 })),
|
||||
customBlocks: body.customBlocks?.map((block) => ({ ...block, startIndex: block.startIndex + 1 })),
|
||||
customRanges: [{
|
||||
rangeId: 'ref',
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
startIndex: 0,
|
||||
endIndex: 0,
|
||||
wholeEntity: true,
|
||||
properties: { noteId: 'note' },
|
||||
}],
|
||||
};
|
||||
const note = { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: '\b\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'np' }],
|
||||
customBlocks: [{ startIndex: 0, blockId: 'note-image' }],
|
||||
}, drawings: { 'note-image': { ...drawing('note-image'), layoutType: PositionedObjectLayoutType.INLINE } }, drawingsOrder: ['note-image'] };
|
||||
const jsonX = JSONX.getInstance();
|
||||
mutate(JSONX.compose(jsonX.replaceOp(['body'], body, referencedBody), jsonX.insertOp(['notes', 'note'], note)));
|
||||
const search = { unitId, subUnitId: unitId, drawingId: 'note-image' };
|
||||
expect(manager.getDrawingByParam(search)?.drawingId).toBe('note-image');
|
||||
expect(docDrawings.getDrawingOrder(unitId, unitId)).toEqual(['a', 'b', 'note-image']);
|
||||
expect(commands.syncExecuteCommand(UpdateDrawingDocTransformCommand.id, {
|
||||
unitId,
|
||||
subUnitId: unitId,
|
||||
drawings: [{ drawingId: 'note-image', key: 'size', value: { width: 70, height: 35 } }],
|
||||
})).toBe(true);
|
||||
expect(manager.getDrawingByParam(search)).toMatchObject({ docTransform: { size: { width: 70, height: 35 } } });
|
||||
expect(model.getDrawings()).not.toHaveProperty('note-image');
|
||||
mutate(jsonX.replaceOp(['body'], model.getBody(), body));
|
||||
expect(model.getSnapshot().notes?.note).toBeUndefined();
|
||||
expect(manager.getDrawingByParam(search)).toBeUndefined();
|
||||
expect(docDrawings.getDrawingByParam(search)).toBeUndefined();
|
||||
expect(manager.getDrawingByParam({ ...search, drawingId: 'a' })).toBeDefined();
|
||||
});
|
||||
|
||||
it('updates persisted order and keeps behind-text drawings below foreground drawings', () => {
|
||||
mutate(JSONX.getInstance().moveOp(['drawingsOrder', 0], ['drawingsOrder', 1]));
|
||||
expect(docDrawings.getDrawingOrder(unitId, unitId)).toEqual(['b', 'a']);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ICommandService,
|
||||
Inject,
|
||||
IUniverInstanceService,
|
||||
JSON1,
|
||||
JSONX,
|
||||
PositionedObjectLayoutType,
|
||||
RedoCommand,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
} from '@univerjs/core';
|
||||
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
|
||||
import {
|
||||
collectDocDrawings,
|
||||
getDocDrawingRenderOrder,
|
||||
IDocDrawingService,
|
||||
TextWrappingStyle,
|
||||
@@ -224,6 +226,16 @@ export class DocDrawingAddRemoveController extends Disposable {
|
||||
|
||||
const params = command.params as IRichTextEditingMutationParams;
|
||||
const { unitId, actions } = params;
|
||||
let changedNotes = false;
|
||||
if (!JSONX.isNoop(actions)) {
|
||||
const cursor = JSON1.type.readCursor(actions);
|
||||
cursor.traverse(null, () => {
|
||||
changedNotes ||= cursor.getPath()[0] === 'notes';
|
||||
});
|
||||
}
|
||||
if (changedNotes) {
|
||||
this._syncNoteDrawings(unitId);
|
||||
}
|
||||
const reOrderedDrawings = getReOrderedDrawings(actions);
|
||||
|
||||
if (reOrderedDrawings.length > 0) {
|
||||
@@ -413,7 +425,7 @@ export class DocDrawingAddRemoveController extends Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
const { drawings, drawingsOrder } = documentDataModel.getSnapshot();
|
||||
const { drawings, drawingsOrder } = collectDocDrawings(documentDataModel.getSnapshot());
|
||||
|
||||
if (drawingsOrder == null) {
|
||||
return;
|
||||
@@ -441,6 +453,27 @@ export class DocDrawingAddRemoveController extends Disposable {
|
||||
});
|
||||
}
|
||||
|
||||
private _syncNoteDrawings(unitId: string): void {
|
||||
const model = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
const { drawings } = collectDocDrawings(model.getSnapshot());
|
||||
const previous = this._docDrawingService.getDrawingData(unitId, unitId) ?? {};
|
||||
const added = Object.keys(drawings).filter((id) => !previous[id]);
|
||||
const removed = Object.keys(previous).filter((id) => !drawings[id]);
|
||||
const changed = Object.keys(drawings).filter((id) => drawings[id] !== previous[id]);
|
||||
if (added.length > 0) {
|
||||
this._addDrawings(unitId, added.map((id) => ({ ...drawings[id], unitId, subUnitId: unitId })));
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
this._removeDrawings(unitId, removed);
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
this._syncDrawingDataFromSnapshot(unitId, changed);
|
||||
}
|
||||
}
|
||||
|
||||
private _syncDrawingDataFromSnapshot(unitId: string, drawingIds: string[]) {
|
||||
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
|
||||
|
||||
@@ -448,7 +481,7 @@ export class DocDrawingAddRemoveController extends Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
const { drawings = {}, drawingsOrder = [] } = documentDataModel.getSnapshot();
|
||||
const { drawings, drawingsOrder } = collectDocDrawings(documentDataModel.getSnapshot());
|
||||
const drawingData = drawings as IDrawingMapItemData<IDocDrawing>;
|
||||
const previousDrawings = this._docDrawingService.getDrawingData(unitId, unitId);
|
||||
const orderChanged = drawingsOrder !== this._docDrawingService.getDrawingOrder(unitId, unitId) ||
|
||||
|
||||
+2
-3
@@ -33,7 +33,7 @@ import {
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { DocSkeletonManagerService } from '@univerjs/docs';
|
||||
import { UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
|
||||
import { findDocDrawing, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
|
||||
import { DocSelectionRenderService, getAnchorBounding, getOneTextSelectionRange, neoGetDocObject, NodePositionConvertToCursor, TEXT_RANGE_LAYER_INDEX } from '@univerjs/docs-ui';
|
||||
import { IDrawingManagerService } from '@univerjs/drawing';
|
||||
import { DocumentSkeletonPageType, getColor, IRenderManagerService, Liquid, PageLayoutType, Rect, Vector2 } from '@univerjs/engine-render';
|
||||
@@ -162,7 +162,6 @@ export class DocDrawingTransformerController extends Disposable {
|
||||
|
||||
// Only handle one drawing transformer change.
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
private _listenTransformerChange(unitId: string): void {
|
||||
const transformer = this._getSceneAndTransformerByDrawingSearch(unitId)?.transformer;
|
||||
|
||||
@@ -184,7 +183,7 @@ export class DocDrawingTransformerController extends Disposable {
|
||||
}
|
||||
|
||||
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(drawing.unitId, UniverInstanceType.UNIVER_DOC);
|
||||
const drawingData = documentDataModel?.getSnapshot().drawings?.[drawing.drawingId];
|
||||
const drawingData = documentDataModel ? findDocDrawing(documentDataModel.getSnapshot(), drawing.drawingId)?.drawing : undefined;
|
||||
|
||||
if (drawingData?.layoutType === PositionedObjectLayoutType.INLINE) {
|
||||
try {
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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 { IDocDrawing } from '@univerjs/docs-drawing';
|
||||
import type { IImageData } from '@univerjs/drawing';
|
||||
import { DrawingTypeEnum, ObjectRelativeFromH, ObjectRelativeFromV, PositionedObjectLayoutType } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getDocImageCropUpdates } from '../doc-drawing-crop';
|
||||
|
||||
function drawing(): IDocDrawing {
|
||||
return {
|
||||
unitId: 'doc',
|
||||
subUnitId: 'doc',
|
||||
drawingId: 'note-image',
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
layoutType: PositionedObjectLayoutType.INLINE,
|
||||
transform: { left: 40, top: 600, width: 100, height: 40 },
|
||||
docTransform: {
|
||||
angle: 0,
|
||||
size: { width: 150, height: 60 },
|
||||
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 40 },
|
||||
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 600 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('document image crop updates', () => {
|
||||
it('does not turn an uncropped transformer notification into a second size mutation', () => {
|
||||
const image = drawing();
|
||||
const notification = { ...image, srcRect: undefined } as IImageData;
|
||||
expect(getDocImageCropUpdates(notification, image, notification)).toEqual([]);
|
||||
expect(image.docTransform.size).toEqual({ width: 150, height: 60 });
|
||||
});
|
||||
|
||||
it('persists a real crop and permits an explicit reset without moving an inline anchor', () => {
|
||||
const image = drawing();
|
||||
const renderImage = image as IImageData;
|
||||
const crop = { left: 10, right: 10, top: 4, bottom: 4 };
|
||||
expect(getDocImageCropUpdates({
|
||||
...renderImage,
|
||||
srcRect: crop,
|
||||
transform: { left: 50, top: 604, width: 80, height: 32 },
|
||||
}, image, renderImage)).toEqual([
|
||||
{ drawingId: 'note-image', key: 'srcRect', value: crop },
|
||||
{ drawingId: 'note-image', key: 'size', value: { width: 80, height: 32 } },
|
||||
]);
|
||||
expect(getDocImageCropUpdates({ ...renderImage, srcRect: null }, image, renderImage)).toEqual([
|
||||
{ drawingId: 'note-image', key: 'srcRect', value: null },
|
||||
{ drawingId: 'note-image', key: 'size', value: { width: 100, height: 40 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+7
-4
@@ -235,7 +235,7 @@ describe('DocDrawingTransformUpdateController', () => {
|
||||
expect(tracker.shouldRefresh(skeleton, progress(2))).toBe(true);
|
||||
});
|
||||
|
||||
it('refreshes when a published column group contains drawings', () => {
|
||||
it.each(['column', 'footnote'])('refreshes when a published %s contains drawings', (scope) => {
|
||||
const nestedPage = {
|
||||
skeDrawings: new Map([['column-drawing', {}]]),
|
||||
skeTables: new Map(),
|
||||
@@ -247,9 +247,12 @@ describe('DocDrawingTransformUpdateController', () => {
|
||||
pageWidth: 100,
|
||||
skeDrawings: new Map(),
|
||||
skeTables: new Map(),
|
||||
skeColumnGroups: new Map([['column-group', {
|
||||
columns: [{ page: nestedPage }],
|
||||
}]]),
|
||||
skeColumnGroups: new Map(scope === 'column'
|
||||
? [['column-group', {
|
||||
columns: [{ page: nestedPage }],
|
||||
}]]
|
||||
: []),
|
||||
notes: scope === 'footnote' ? [{ page: nestedPage }] : [],
|
||||
}];
|
||||
const skeleton = {
|
||||
getSkeletonData: () => ({
|
||||
|
||||
+2
-1
@@ -384,12 +384,13 @@ describe('DocDrawingUpdateRenderController', () => {
|
||||
focus$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'body-drawing' }]);
|
||||
expect(contextService.setContextValue).toHaveBeenCalledWith(FOCUSING_COMMON_DRAWINGS, true);
|
||||
expect(docDrawingService.focusDrawing).toHaveBeenLastCalledWith([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'body-drawing' }]);
|
||||
expect(docSelectionManagerService.replaceDocRanges).toHaveBeenCalledWith([{ startOffset: 4, endOffset: 5 }]);
|
||||
expect(docSelectionManagerService.replaceDocRanges).toHaveBeenCalledWith([{ startOffset: 4, endOffset: 5, segmentId: '' }]);
|
||||
expect(docSelectionRenderService.setSegment).not.toHaveBeenCalled();
|
||||
expect(transformer.resetProps).toHaveBeenLastCalledWith({ zeroTop: 18, zeroLeft: 12 });
|
||||
|
||||
focus$.next([{ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'header-drawing' }]);
|
||||
expect(docSelectionRenderService.setSegment).toHaveBeenCalledWith('header-1');
|
||||
expect(docSelectionManagerService.replaceDocRanges).toHaveBeenLastCalledWith([{ startOffset: 2, endOffset: 3, segmentId: 'header-1' }]);
|
||||
});
|
||||
|
||||
it('refreshes doc selection after transformer changes finish', async () => {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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 { IDocDrawing, IDrawingDocTransform } from '@univerjs/docs-drawing';
|
||||
import type { IImageData } from '@univerjs/drawing';
|
||||
import { BooleanNumber, DrawingTypeEnum, PositionedObjectLayoutType } from '@univerjs/core';
|
||||
|
||||
export function getDocImageCropUpdates(
|
||||
param: IImageData,
|
||||
docDrawing: IDocDrawing | undefined,
|
||||
renderDrawing: IImageData | undefined | null
|
||||
): IDrawingDocTransform[] {
|
||||
const { drawingId, transform } = param;
|
||||
// Transformer updates also carry an undefined srcRect for uncropped images.
|
||||
// Their size is already persisted by the document transformer controller.
|
||||
if (transform == null || param.srcRect === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (docDrawing?.drawingType !== DrawingTypeEnum.DRAWING_IMAGE || renderDrawing?.transform == null) {
|
||||
return [];
|
||||
}
|
||||
const nextTransform = { ...renderDrawing.transform, ...transform };
|
||||
const drawings: IDrawingDocTransform[] = [{ drawingId, key: 'srcRect', value: param.srcRect }];
|
||||
|
||||
if (nextTransform.width != null && nextTransform.height != null) {
|
||||
drawings.push({
|
||||
drawingId,
|
||||
key: 'size',
|
||||
value: { width: nextTransform.width, height: nextTransform.height },
|
||||
});
|
||||
}
|
||||
|
||||
if (docDrawing.layoutType === PositionedObjectLayoutType.INLINE || renderDrawing.isMultiTransform === BooleanNumber.TRUE) {
|
||||
return drawings;
|
||||
}
|
||||
|
||||
const horizontalDelta = (nextTransform.left ?? 0) - (renderDrawing.transform.left ?? 0);
|
||||
const verticalDelta = (nextTransform.top ?? 0) - (renderDrawing.transform.top ?? 0);
|
||||
const { positionH, positionV } = docDrawing.docTransform;
|
||||
|
||||
if (horizontalDelta !== 0 && positionH.posOffset != null) {
|
||||
drawings.push({
|
||||
drawingId,
|
||||
key: 'positionH',
|
||||
value: { relativeFrom: positionH.relativeFrom, posOffset: positionH.posOffset + horizontalDelta },
|
||||
});
|
||||
}
|
||||
if (verticalDelta !== 0 && positionV.posOffset != null) {
|
||||
drawings.push({
|
||||
drawingId,
|
||||
key: 'positionV',
|
||||
value: { relativeFrom: positionV.relativeFrom, posOffset: positionV.posOffset + verticalDelta },
|
||||
});
|
||||
}
|
||||
|
||||
return drawings;
|
||||
}
|
||||
@@ -37,14 +37,15 @@ export function getDocMutationAffectedDrawingIds(actions: JSONXActions): DocMuta
|
||||
sawComponent = true;
|
||||
const path = cursor.getPath();
|
||||
const root = String(path[0]);
|
||||
if (root === 'drawings') {
|
||||
const drawingId = path[1];
|
||||
const drawingIndex = root === 'notes' ? 2 : 0;
|
||||
if (path[drawingIndex] === 'drawings') {
|
||||
const drawingId = path[drawingIndex + 1];
|
||||
if (typeof drawingId === 'string' && drawingId.length > 0) {
|
||||
drawingIds.add(drawingId);
|
||||
} else {
|
||||
needsFullRefresh = true;
|
||||
}
|
||||
} else if (root === 'drawingsOrder' || path.includes('customBlocks')) {
|
||||
} else if (path[drawingIndex] === 'drawingsOrder' || (root === 'notes' && path.length <= 2) || path.includes('customBlocks')) {
|
||||
needsFullRefresh = true;
|
||||
}
|
||||
});
|
||||
|
||||
+23
-3
@@ -161,6 +161,7 @@ export type DocumentDrawingPublicationProgress = Pick<
|
||||
>;
|
||||
|
||||
interface IDocumentDrawingPublicationNestedPage {
|
||||
notes?: Array<{ page: IDocumentDrawingPublicationNestedPage }>;
|
||||
skeDrawings: ReadonlyMap<string, unknown>;
|
||||
skeTables?: ReadonlyMap<string, {
|
||||
rows: Array<{ cells: IDocumentDrawingPublicationNestedPage[] }>;
|
||||
@@ -393,7 +394,7 @@ function hasHorizontalTableViewport(viewport: IDocsTableRenderViewport | null |
|
||||
}
|
||||
|
||||
function hasSkeletonPageDrawings(page: IDocumentDrawingPublicationNestedPage): boolean {
|
||||
if (page.skeDrawings.size > 0) {
|
||||
if (page.skeDrawings.size > 0 || page.notes?.some((note) => hasSkeletonPageDrawings(note.page))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -420,6 +421,9 @@ function hasSkeletonPageDrawings(page: IDocumentDrawingPublicationNestedPage): b
|
||||
|
||||
function countSkeletonPageDrawings(page: IDocumentDrawingPublicationNestedPage): number {
|
||||
let count = page.skeDrawings.size;
|
||||
page.notes?.forEach((note) => {
|
||||
count += countSkeletonPageDrawings(note.page);
|
||||
});
|
||||
page.skeTables?.forEach((table) => {
|
||||
table.rows.forEach((row) => {
|
||||
row.cells.forEach((cell) => {
|
||||
@@ -783,6 +787,20 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
|
||||
undefined,
|
||||
selectable
|
||||
);
|
||||
for (const note of page.notes ?? []) {
|
||||
this._collectSegmentDrawingPositions(
|
||||
unitId,
|
||||
note.page,
|
||||
docsLeft,
|
||||
docsTop,
|
||||
updateDrawingMap,
|
||||
note.top,
|
||||
note.left,
|
||||
undefined,
|
||||
selectable,
|
||||
{ top: note.top, left: note.left }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _collectSegmentDrawingPositions(
|
||||
@@ -794,7 +812,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
|
||||
marginTop: number,
|
||||
marginLeft: number,
|
||||
hostPage: IDocumentSkeletonPage | undefined,
|
||||
selectable: boolean
|
||||
selectable: boolean,
|
||||
clipOffset?: { left: number; top: number }
|
||||
): void {
|
||||
this._calculateDrawingPosition(
|
||||
unitId,
|
||||
@@ -805,7 +824,8 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
|
||||
marginTop,
|
||||
marginLeft,
|
||||
hostPage,
|
||||
selectable
|
||||
selectable,
|
||||
clipOffset
|
||||
);
|
||||
this._calculateTableCellDrawingPositions(
|
||||
unitId,
|
||||
|
||||
+16
-57
@@ -48,7 +48,7 @@ import {
|
||||
getDocumentEntityPermissionObjectId,
|
||||
RichTextEditingMutation,
|
||||
} from '@univerjs/docs';
|
||||
import { IDocDrawingService, InsertDocDrawingCommand, SetDocDrawingArrangeCommand, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
|
||||
import { collectDocDrawings, findDocDrawing, IDocDrawingService, InsertDocDrawingCommand, SetDocDrawingArrangeCommand, UpdateDrawingDocTransformCommand } from '@univerjs/docs-drawing';
|
||||
import { DocSelectionRenderService } from '@univerjs/docs-ui';
|
||||
import {
|
||||
DRAWING_IMAGE_ALLOW_IMAGE_LIST,
|
||||
@@ -66,6 +66,7 @@ import { debounceTime } from 'rxjs';
|
||||
import { GroupDocDrawingCommand } from '../../commands/commands/group-doc-drawing.command';
|
||||
import { UngroupDocDrawingCommand } from '../../commands/commands/ungroup-doc-drawing.command';
|
||||
import { DocRefreshDrawingsService } from '../../services/doc-refresh-drawings.service';
|
||||
import { getDocImageCropUpdates } from './doc-drawing-crop';
|
||||
import { getDocMutationAffectedDrawingIds } from './doc-drawing-mutation';
|
||||
|
||||
interface IImageInsertPosition {
|
||||
@@ -345,58 +346,19 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
}
|
||||
|
||||
private _getImageCropUpdates(param: IImageData): IDrawingDocTransform[] {
|
||||
const { unitId, subUnitId, drawingId, transform } = param;
|
||||
const { unitId, subUnitId, drawingId } = param;
|
||||
if ([unitId, subUnitId].some((id) => id !== this._context.unitId)) {
|
||||
return [];
|
||||
}
|
||||
if (transform == null || !Object.prototype.hasOwnProperty.call(param, 'srcRect')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const docDrawing = this._getDocDrawing(drawingId);
|
||||
const renderDrawing = this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId });
|
||||
if (docDrawing?.drawingType !== DrawingTypeEnum.DRAWING_IMAGE || renderDrawing?.transform == null) {
|
||||
return [];
|
||||
}
|
||||
const nextTransform = { ...renderDrawing.transform, ...transform };
|
||||
const drawings: IDrawingDocTransform[] = [{ drawingId, key: 'srcRect', value: param.srcRect }];
|
||||
|
||||
if (nextTransform.width != null && nextTransform.height != null) {
|
||||
drawings.push({
|
||||
drawingId,
|
||||
key: 'size',
|
||||
value: { width: nextTransform.width, height: nextTransform.height },
|
||||
});
|
||||
}
|
||||
|
||||
if (docDrawing.layoutType === PositionedObjectLayoutType.INLINE || renderDrawing.isMultiTransform === BooleanNumber.TRUE) {
|
||||
return drawings;
|
||||
}
|
||||
|
||||
const horizontalDelta = (nextTransform.left ?? 0) - (renderDrawing.transform.left ?? 0);
|
||||
const verticalDelta = (nextTransform.top ?? 0) - (renderDrawing.transform.top ?? 0);
|
||||
const { positionH, positionV } = docDrawing.docTransform;
|
||||
|
||||
if (horizontalDelta !== 0 && positionH.posOffset != null) {
|
||||
drawings.push({
|
||||
drawingId,
|
||||
key: 'positionH',
|
||||
value: { relativeFrom: positionH.relativeFrom, posOffset: positionH.posOffset + horizontalDelta },
|
||||
});
|
||||
}
|
||||
if (verticalDelta !== 0 && positionV.posOffset != null) {
|
||||
drawings.push({
|
||||
drawingId,
|
||||
key: 'positionV',
|
||||
value: { relativeFrom: positionV.relativeFrom, posOffset: positionV.posOffset + verticalDelta },
|
||||
});
|
||||
}
|
||||
|
||||
return drawings;
|
||||
return getDocImageCropUpdates(
|
||||
param,
|
||||
this._getDocDrawing(drawingId),
|
||||
this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId }) as IImageData | undefined
|
||||
);
|
||||
}
|
||||
|
||||
private _getDocDrawing(drawingId: string): IDocDrawing | undefined {
|
||||
return this._context.unit.getDrawings()?.[drawingId] as IDocDrawing | undefined;
|
||||
return findDocDrawing(this._context.unit.getSnapshot(), drawingId)?.drawing as IDocDrawing | undefined;
|
||||
}
|
||||
|
||||
private _groupDrawingListener() {
|
||||
@@ -491,7 +453,7 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
}
|
||||
|
||||
const snapshot = docDataModel.getSnapshot();
|
||||
const { drawings = {} } = snapshot;
|
||||
const { drawings } = collectDocDrawings(snapshot);
|
||||
const isEditBody = viewModel.getEditArea() === DocumentEditArea.BODY;
|
||||
const isDocInteractionFocusing = this._docSelectionRenderService.isFocusing
|
||||
|| this._drawingManagerService.getFocusDrawings().some((drawing) => drawing.unitId === unitId);
|
||||
@@ -719,15 +681,12 @@ export class DocDrawingUpdateRenderController extends Disposable implements IRen
|
||||
|
||||
private _setDrawingSelections(params: IDrawingParam[]) {
|
||||
const { unit } = this._context;
|
||||
const customBlocks = unit.getSnapshot().body?.customBlocks ?? [];
|
||||
const ranges = params.map((item) => {
|
||||
const id = item.drawingId;
|
||||
const block = customBlocks.find((b) => b.blockId === id);
|
||||
if (block) {
|
||||
return block.startIndex;
|
||||
}
|
||||
return null;
|
||||
}).filter((e) => e !== null).map((offset) => ({ startOffset: offset, endOffset: offset + 1 }));
|
||||
const ranges = params.flatMap((item) => {
|
||||
const segmentId = getDocumentDrawingSegmentId(unit, item.drawingId);
|
||||
const body = unit.getSelfOrHeaderFooterModel(segmentId)?.getBody();
|
||||
const block = body?.customBlocks?.find((block) => block.blockId === item.drawingId);
|
||||
return block ? [{ startOffset: block.startIndex, endOffset: block.startIndex + 1, segmentId }] : [];
|
||||
});
|
||||
this._docSelectionManagerService.replaceDocRanges(ranges);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +323,9 @@ export class DocDrawingPopupMenuController extends RxDisposable {
|
||||
object,
|
||||
{
|
||||
componentKey: COMPONENT_IMAGE_POPUP_MENU,
|
||||
// Object anchors follow transform updates. Keeping this toolbar
|
||||
// open must not suspend publication after a resize or move.
|
||||
requiresStableLayout: false,
|
||||
direction: isImage || isChart ? 'top-center' : 'horizontal',
|
||||
offset: isImage || isChart ? [0, 8] : [2, 0],
|
||||
extraProps: {
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
BooleanNumber,
|
||||
BuildTextUtils,
|
||||
CommandType,
|
||||
DrawingTypeEnum,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
PositionedObjectLayoutType,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
@@ -74,14 +76,21 @@ export const InsertDocDrawingCommand: ICommand = {
|
||||
}
|
||||
|
||||
const snapshot = documentDataModel.getSnapshot();
|
||||
const isFootnote = !!snapshot.notes?.[segmentId];
|
||||
if (isFootnote && drawings.some((drawing) => drawing.drawingType !== DrawingTypeEnum.DRAWING_IMAGE)) {
|
||||
return false;
|
||||
}
|
||||
const isHeaderFooter = !!snapshot.headers?.[segmentId] || !!snapshot.footers?.[segmentId];
|
||||
const targetDrawings = isHeaderFooter
|
||||
let targetDrawings = isHeaderFooter
|
||||
? drawings.map((drawing) => ({
|
||||
...drawing,
|
||||
isMultiTransform: BooleanNumber.TRUE,
|
||||
transforms: drawing.transforms ?? (drawing.transform ? [drawing.transform] : null),
|
||||
}))
|
||||
: drawings;
|
||||
if (isFootnote) {
|
||||
targetDrawings = drawings.map((drawing) => ({ ...drawing, layoutType: PositionedObjectLayoutType.INLINE }));
|
||||
}
|
||||
const actions = BuildTextUtils.drawing.add({
|
||||
selection: resolvedTextRange,
|
||||
documentDataModel,
|
||||
@@ -96,6 +105,7 @@ export const InsertDocDrawingCommand: ICommand = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId,
|
||||
segmentId,
|
||||
actions: [],
|
||||
textRanges: [],
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '@univerjs/core';
|
||||
import { DocHistoryAction, DocSelectionManagerService, getContentInsertRange, normalizeTextRange, RichTextEditingMutation } from '@univerjs/docs';
|
||||
import { IDocDrawingAdapterService } from '../../services/doc-drawing-adapter.service';
|
||||
import { findDocDrawing } from '../../services/doc-drawing-source';
|
||||
|
||||
export interface IRemoveDocDrawingCommandParam {
|
||||
unitId: string;
|
||||
@@ -49,7 +50,6 @@ export interface IRemoveDocDrawingCommandParams {
|
||||
export const RemoveDocDrawingCommand: ICommand = {
|
||||
id: 'doc.command.remove-doc-image',
|
||||
type: CommandType.COMMAND,
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
handler: (accessor: IAccessor, params?: IRemoveDocDrawingCommandParams) => {
|
||||
if (!params) {
|
||||
return false;
|
||||
@@ -70,7 +70,15 @@ export const RemoveDocDrawingCommand: ICommand = {
|
||||
const activeTextRange = docSelectionManagerService.getActiveTextRange();
|
||||
const explicitTextRange = !textRange ? null : normalizeTextRange(textRange);
|
||||
const contentInsertRange = explicitTextRange ?? getContentInsertRange(accessor, unitId);
|
||||
const segmentId = contentInsertRange?.segmentId ?? activeTextRange?.segmentId ?? '';
|
||||
const snapshot = documentDataModel.getSnapshot();
|
||||
const noteSegments = removeDrawings.map(({ drawingId }) => findDocDrawing(snapshot, drawingId)?.segmentId ?? '');
|
||||
if (noteSegments.some((id) => id !== noteSegments[0])) {
|
||||
return false;
|
||||
}
|
||||
const segmentId = noteSegments[0] || (contentInsertRange?.segmentId ?? activeTextRange?.segmentId ?? '');
|
||||
const footnote = snapshot.notes?.[segmentId];
|
||||
const source = footnote ?? snapshot;
|
||||
const prefix = footnote ? ['notes', segmentId] : [];
|
||||
|
||||
const textX = new TextX();
|
||||
const jsonX = JSONX.getInstance();
|
||||
@@ -84,7 +92,7 @@ export const RemoveDocDrawingCommand: ICommand = {
|
||||
return false;
|
||||
}
|
||||
|
||||
const drawings = documentDataModel.getDrawings() ?? {};
|
||||
const drawings = source.drawings ?? {};
|
||||
const removeDrawingParamById = new Map(removeDrawings.map((drawing) => [drawing.drawingId, drawing]));
|
||||
const removeDrawingSnapshots = removeCustomBlocks
|
||||
.map((block) => drawings[block!.blockId] as IDocDrawing | undefined)
|
||||
@@ -140,7 +148,7 @@ export const RemoveDocDrawingCommand: ICommand = {
|
||||
const historyActions = getHistoryActions(removeDrawings);
|
||||
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: { unitId, actions: [], textRanges, historyActions },
|
||||
params: { unitId, segmentId, actions: [], textRanges, historyActions },
|
||||
};
|
||||
const rawActions: JSONXActions = [];
|
||||
|
||||
@@ -155,11 +163,19 @@ export const RemoveDocDrawingCommand: ICommand = {
|
||||
|
||||
rawActions.push(jsonX.editOp(textX.serialize(), getRichTextEditPath(documentDataModel, segmentId))!);
|
||||
|
||||
for (const block of removeCustomBlocks) {
|
||||
const drawingOrder = source.drawingsOrder ?? [];
|
||||
const resourceBlocks = [...removeCustomBlocks].sort((left, right) =>
|
||||
drawingOrder.indexOf(right!.blockId) - drawingOrder.indexOf(left!.blockId)
|
||||
);
|
||||
for (const block of resourceBlocks) {
|
||||
const { blockId } = block!;
|
||||
const drawingIndex = documentDataModel.getDrawingsOrder()!.indexOf(blockId);
|
||||
rawActions.push(jsonX.removeOp(['drawings', blockId], drawings[blockId])!);
|
||||
rawActions.push(jsonX.removeOp(['drawingsOrder', drawingIndex], blockId)!);
|
||||
const drawingIndex = drawingOrder.indexOf(blockId);
|
||||
if (drawings[blockId]) {
|
||||
rawActions.push(jsonX.removeOp([...prefix, 'drawings', blockId], drawings[blockId])!);
|
||||
}
|
||||
if (drawingIndex >= 0) {
|
||||
rawActions.push(jsonX.removeOp([...prefix, 'drawingsOrder', drawingIndex], blockId)!);
|
||||
}
|
||||
}
|
||||
|
||||
doMutation.params.actions = rawActions.reduce((acc, cur) => JSONX.compose(acc, cur as JSONXActions), null as JSONXActions);
|
||||
|
||||
+11
-5
@@ -28,6 +28,8 @@ import {
|
||||
} from '@univerjs/core';
|
||||
import { DocHistoryAction, RichTextEditingMutation } from '@univerjs/docs';
|
||||
|
||||
import { findDocDrawing } from '../../services/doc-drawing-source';
|
||||
|
||||
export interface IDrawingDocTransform {
|
||||
drawingId: string;
|
||||
key: 'size' | 'angle' | 'positionH' | 'positionV' | 'flipX' | 'flipY' | 'srcRect';
|
||||
@@ -57,9 +59,9 @@ export const UpdateDrawingDocTransformCommand: ICommand = {
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldDrawings = documentDataModel.getSnapshot().drawings ?? {};
|
||||
const snapshot = documentDataModel.getSnapshot();
|
||||
const historyAction = drawings.length > 0 && drawings.every(({ drawingId }) =>
|
||||
oldDrawings[drawingId]?.drawingType === DrawingTypeEnum.DRAWING_IMAGE
|
||||
findDocDrawing(snapshot, drawingId)?.drawing.drawingType === DrawingTypeEnum.DRAWING_IMAGE
|
||||
)
|
||||
? DocHistoryAction.UpdateImage
|
||||
: undefined;
|
||||
@@ -67,14 +69,18 @@ export const UpdateDrawingDocTransformCommand: ICommand = {
|
||||
const actions: JSONXActions = [];
|
||||
|
||||
for (const { drawingId, key, value } of drawings) {
|
||||
const oldDrawing = oldDrawings[drawingId];
|
||||
const source = findDocDrawing(snapshot, drawingId);
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
const oldDrawing = source.drawing;
|
||||
const oldValue = key === 'srcRect'
|
||||
? (oldDrawing as IDocImage | undefined)?.srcRect
|
||||
: oldDrawing?.docTransform?.[key];
|
||||
if (!Tools.diffValue(oldValue, value)) {
|
||||
const path = key === 'srcRect'
|
||||
? ['drawings', drawingId, key]
|
||||
: ['drawings', drawingId, 'docTransform', key];
|
||||
? [...source.path, key]
|
||||
: [...source.path, 'docTransform', key];
|
||||
// Optional transform fields such as flips do not exist in older documents.
|
||||
actions.push(oldValue === undefined
|
||||
? jsonX.insertOp(path, value)!
|
||||
|
||||
+13
-7
@@ -36,6 +36,8 @@ import { DocHistoryAction, RichTextEditingMutation } from '@univerjs/docs';
|
||||
* `INLINE`, `WRAP_SQUARE`, and `WRAP_TOP_AND_BOTTOM` participate in text layout and can cause text to reflow.
|
||||
* `BEHIND_TEXT` and `IN_FRONT_OF_TEXT` are overlay styles and do not cause text to reflow.
|
||||
*/
|
||||
import { findDocDrawing } from '../../services/doc-drawing-source';
|
||||
|
||||
export enum TextWrappingStyle {
|
||||
/**
|
||||
* Places the drawing in the text flow like a character.
|
||||
@@ -106,9 +108,12 @@ export const UpdateDocDrawingWrappingStyleCommand: ICommand = {
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldDrawings = documentDataModel.getDrawings() ?? {};
|
||||
const snapshot = documentDataModel.getSnapshot();
|
||||
if (wrappingStyle !== TextWrappingStyle.INLINE && drawings.some(({ drawingId }) => findDocDrawing(snapshot, drawingId)?.segmentId)) {
|
||||
return false;
|
||||
}
|
||||
const historyAction = drawings.length > 0 && drawings.every(({ drawingId }) =>
|
||||
oldDrawings[drawingId]?.drawingType === DrawingTypeEnum.DRAWING_IMAGE
|
||||
findDocDrawing(snapshot, drawingId)?.drawing.drawingType === DrawingTypeEnum.DRAWING_IMAGE
|
||||
)
|
||||
? DocHistoryAction.UpdateImage
|
||||
: undefined;
|
||||
@@ -116,20 +121,21 @@ export const UpdateDocDrawingWrappingStyleCommand: ICommand = {
|
||||
const rawActions: JSONXActions = [];
|
||||
|
||||
for (const drawing of drawings) {
|
||||
const oldDrawing = oldDrawings[drawing.drawingId] as IDocDrawing | undefined;
|
||||
if (!oldDrawing) {
|
||||
const source = findDocDrawing(snapshot, drawing.drawingId);
|
||||
const oldDrawing = source?.drawing as IDocDrawing | undefined;
|
||||
if (!oldDrawing || !source) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const layoutType = WRAPPING_STYLE_TO_LAYOUT_TYPE[wrappingStyle];
|
||||
if (oldDrawing.layoutType !== layoutType) {
|
||||
rawActions.push(jsonX.replaceOp(['drawings', drawing.drawingId, 'layoutType'], oldDrawing.layoutType, layoutType)!);
|
||||
rawActions.push(jsonX.replaceOp([...source.path, 'layoutType'], oldDrawing.layoutType, layoutType)!);
|
||||
}
|
||||
|
||||
if (wrappingStyle === TextWrappingStyle.BEHIND_TEXT || wrappingStyle === TextWrappingStyle.IN_FRONT_OF_TEXT) {
|
||||
const behindDoc = wrappingStyle === TextWrappingStyle.BEHIND_TEXT ? BooleanNumber.TRUE : BooleanNumber.FALSE;
|
||||
if (oldDrawing.behindDoc !== behindDoc) {
|
||||
rawActions.push(jsonX.replaceOp(['drawings', drawing.drawingId, 'behindDoc'], oldDrawing.behindDoc, behindDoc)!);
|
||||
rawActions.push(jsonX.replaceOp([...source.path, 'behindDoc'], oldDrawing.behindDoc, behindDoc)!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +144,7 @@ export const UpdateDocDrawingWrappingStyleCommand: ICommand = {
|
||||
const value = drawing.docTransform?.[key];
|
||||
const oldValue = oldDrawing.docTransform[key];
|
||||
if (value && !Tools.diffValue(oldValue, value)) {
|
||||
rawActions.push(jsonX.replaceOp(['drawings', drawing.drawingId, 'docTransform', key], oldValue, value)!);
|
||||
rawActions.push(jsonX.replaceOp([...source.path, 'docTransform', key], oldValue, value)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,182 +14,88 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BooleanNumber, PositionedObjectLayoutType, UniverInstanceType } from '@univerjs/core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IDocDrawing } from '../../services/doc-drawing.service';
|
||||
import { BooleanNumber, DrawingTypeEnum, IResourceManagerService, PositionedObjectLayoutType, UniverInstanceType } from '@univerjs/core';
|
||||
import { IDrawingManagerService } from '@univerjs/drawing';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
|
||||
import { IDocDrawingService } from '../../services/doc-drawing.service';
|
||||
import { DocDrawingController, DOCS_DRAWING_PLUGIN } from '../doc-drawing.controller';
|
||||
|
||||
function drawing(drawingId: string): IDocDrawing {
|
||||
return {
|
||||
drawingId,
|
||||
unitId: 'test-doc',
|
||||
subUnitId: 'stale-import-unit',
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
layoutType: PositionedObjectLayoutType.WRAP_NONE,
|
||||
docTransform: { size: { width: 320, height: 180 }, angle: 15, positionH: { relativeFrom: 0, posOffset: 12 }, positionV: { relativeFrom: 0, posOffset: 34 } },
|
||||
};
|
||||
}
|
||||
|
||||
describe('DocDrawingController', () => {
|
||||
it('should serialize snapshot and load resources into drawing services', () => {
|
||||
const registerDrawingData = vi.fn();
|
||||
const registerDrawingDataForManager = vi.fn();
|
||||
const removeDrawingDataForUnit = vi.fn();
|
||||
const removeDrawingDataForUnitFromManager = vi.fn();
|
||||
|
||||
let snapshot: any = {
|
||||
drawings: { d1: { id: 'd1', drawingType: 'image' } },
|
||||
drawingsOrder: ['d1'],
|
||||
};
|
||||
|
||||
const doc = {
|
||||
getSnapshot: () => snapshot,
|
||||
resetDrawing: (data: any, order: any) => {
|
||||
snapshot = { ...snapshot, drawings: data, drawingsOrder: order };
|
||||
},
|
||||
getDrawings: () => snapshot.drawings,
|
||||
getDrawingsOrder: () => snapshot.drawingsOrder,
|
||||
};
|
||||
|
||||
const univerInstanceService = {
|
||||
getUnit: vi.fn((_unitId: string, _type?: UniverInstanceType) => doc),
|
||||
};
|
||||
|
||||
let capturedResource: any;
|
||||
const resourceManagerService = {
|
||||
registerPluginResource: vi.fn((resource: any) => {
|
||||
capturedResource = resource;
|
||||
return { dispose: vi.fn() };
|
||||
}),
|
||||
};
|
||||
const controller = new DocDrawingController(
|
||||
{ registerDrawingData, removeDrawingDataForUnit } as any,
|
||||
{
|
||||
registerDrawingData: registerDrawingDataForManager,
|
||||
removeDrawingDataForUnit: removeDrawingDataForUnitFromManager,
|
||||
} as any,
|
||||
resourceManagerService as any,
|
||||
univerInstanceService as any,
|
||||
{
|
||||
registerCommand: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
} as any
|
||||
);
|
||||
|
||||
expect(resourceManagerService.registerPluginResource).toHaveBeenCalledTimes(1);
|
||||
expect(capturedResource.pluginName).toBe(DOCS_DRAWING_PLUGIN);
|
||||
expect(capturedResource.businesses).toEqual([UniverInstanceType.UNIVER_DOC]);
|
||||
|
||||
const json = capturedResource.toJson('doc-1');
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.data).toEqual(snapshot.drawings);
|
||||
expect(parsed.order).toEqual(snapshot.drawingsOrder);
|
||||
|
||||
expect(capturedResource.parseJson('')).toEqual({ data: {}, order: [] });
|
||||
expect(capturedResource.parseJson('{bad json')).toEqual({ data: {}, order: [] });
|
||||
|
||||
capturedResource.onLoad('doc-1', {
|
||||
data: {
|
||||
mask: {
|
||||
id: 'mask',
|
||||
layoutType: PositionedObjectLayoutType.WRAP_NONE,
|
||||
behindDoc: BooleanNumber.FALSE,
|
||||
},
|
||||
photo: {
|
||||
id: 'photo',
|
||||
layoutType: PositionedObjectLayoutType.WRAP_NONE,
|
||||
behindDoc: BooleanNumber.TRUE,
|
||||
},
|
||||
d2: {
|
||||
id: 'd2',
|
||||
docTransform: {
|
||||
size: { width: 320, height: 180 },
|
||||
positionH: { posOffset: 12 },
|
||||
positionV: { posOffset: 34 },
|
||||
angle: 15,
|
||||
},
|
||||
},
|
||||
},
|
||||
order: ['mask', 'photo', 'd2'],
|
||||
});
|
||||
expect(registerDrawingData).toHaveBeenCalledWith('doc-1', expect.any(Object));
|
||||
expect(registerDrawingDataForManager).toHaveBeenCalledWith('doc-1', expect.any(Object));
|
||||
const loadedDrawingData = registerDrawingDataForManager.mock.calls.at(-1)?.[1];
|
||||
expect(loadedDrawingData).toMatchObject({
|
||||
'doc-1': {
|
||||
unitId: 'doc-1',
|
||||
subUnitId: 'doc-1',
|
||||
it('serializes snapshots and loads and unloads resources through drawing services', () => {
|
||||
const bed = createFacadeTestBed();
|
||||
try {
|
||||
const model = bed.documentDataModel;
|
||||
model.resetDrawing({ d1: drawing('d1') }, ['d1']);
|
||||
const resources = bed.injector.get(IResourceManagerService);
|
||||
const hook = resources.getAllResourceHooks().find((hook) => hook.pluginName === DOCS_DRAWING_PLUGIN)!;
|
||||
expect(hook.businesses).toEqual([UniverInstanceType.UNIVER_DOC]);
|
||||
expect(JSON.parse(hook.toJson('test-doc'))).toEqual({ data: model.getDrawings(), order: ['d1'] });
|
||||
expect(hook.parseJson('')).toEqual({ data: {}, order: [] });
|
||||
expect(hook.parseJson('{bad json')).toEqual({ data: {}, order: [] });
|
||||
hook.onLoad('test-doc', {
|
||||
data: {
|
||||
mask: expect.objectContaining({ hidden: true }),
|
||||
photo: expect.objectContaining({ hidden: true }),
|
||||
d2: expect.objectContaining({
|
||||
hidden: true,
|
||||
docTransform: {
|
||||
size: { width: 320, height: 180 },
|
||||
positionH: { posOffset: 12 },
|
||||
positionV: { posOffset: 34 },
|
||||
angle: 15,
|
||||
},
|
||||
}),
|
||||
mask: { ...drawing('mask'), behindDoc: BooleanNumber.FALSE },
|
||||
photo: { ...drawing('photo'), behindDoc: BooleanNumber.TRUE },
|
||||
d2: drawing('d2'),
|
||||
},
|
||||
order: ['photo', 'mask', 'd2'],
|
||||
},
|
||||
order: ['mask', 'photo', 'd2'],
|
||||
});
|
||||
const manager = bed.injector.get(IDrawingManagerService);
|
||||
const docDrawings = bed.injector.get(IDocDrawingService);
|
||||
expect(manager.getDrawingOrder('test-doc', 'test-doc')).toEqual(['photo', 'mask', 'd2']);
|
||||
expect(docDrawings.getDrawingOrder('test-doc', 'test-doc')).toEqual(['mask', 'photo', 'd2']);
|
||||
expect(manager.getDrawingData('test-doc', 'test-doc').d2).toMatchObject({
|
||||
hidden: true,
|
||||
docTransform: drawing('d2').docTransform,
|
||||
});
|
||||
expect(manager.getDrawingData('test-doc', 'test-doc').d2).not.toHaveProperty('transform');
|
||||
expect(docDrawings.getDrawingData('test-doc', 'test-doc').d2).not.toHaveProperty('hidden');
|
||||
hook.onUnLoad('test-doc');
|
||||
expect(model.getDrawings()).toEqual({});
|
||||
expect(model.getDrawingsOrder()).toEqual([]);
|
||||
expect(manager.getDrawingData('test-doc', 'test-doc')).toEqual({});
|
||||
expect(docDrawings.getDrawingData('test-doc', 'test-doc')).toEqual({});
|
||||
} finally {
|
||||
bed.univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes imported body and footnote drawing identities without flattening persisted resources', () => {
|
||||
const bed = createFacadeTestBed({
|
||||
id: 'test-doc',
|
||||
documentStyle: {},
|
||||
body: { dataStream: '\r\n' },
|
||||
drawings: { image: drawing('image') },
|
||||
drawingsOrder: ['image'],
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: { dataStream: '\b\r\n' }, drawings: { 'note-image': { ...drawing('note-image'), layoutType: PositionedObjectLayoutType.INLINE } }, drawingsOrder: ['note-image'] } },
|
||||
});
|
||||
const loadedDocDrawingData = registerDrawingData.mock.calls.at(-1)?.[1];
|
||||
expect(loadedDocDrawingData?.['doc-1']?.order).toEqual(['mask', 'photo', 'd2']);
|
||||
expect(loadedDocDrawingData?.['doc-1']?.data?.d2).not.toHaveProperty('hidden');
|
||||
expect(loadedDrawingData?.['doc-1']?.data?.d2).not.toHaveProperty('transform');
|
||||
|
||||
capturedResource.onUnLoad('doc-1');
|
||||
expect(snapshot.drawings).toEqual({});
|
||||
expect(snapshot.drawingsOrder).toEqual([]);
|
||||
expect(removeDrawingDataForUnit).toHaveBeenCalledWith('doc-1');
|
||||
expect(removeDrawingDataForUnitFromManager).toHaveBeenCalledWith('doc-1');
|
||||
|
||||
controller.dispose();
|
||||
});
|
||||
});
|
||||
describe('DocDrawingController imported drawing identity regression', () => {
|
||||
it('normalizes drawing unit identity when registering imported snapshot data', () => {
|
||||
const registerDocDrawingData = vi.fn();
|
||||
const registerRenderDrawingData = vi.fn();
|
||||
const snapshot = {
|
||||
drawings: {
|
||||
shape1: {
|
||||
unitId: 'doc-1',
|
||||
subUnitId: 'stale-import-unit',
|
||||
drawingId: 'shape1',
|
||||
drawingType: 1,
|
||||
},
|
||||
},
|
||||
drawingsOrder: ['shape1'],
|
||||
};
|
||||
const documentDataModel = {
|
||||
getDrawings: () => snapshot.drawings,
|
||||
getDrawingsOrder: () => snapshot.drawingsOrder,
|
||||
};
|
||||
const resourceManagerService = {
|
||||
registerPluginResource: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
};
|
||||
const controller = new DocDrawingController(
|
||||
{ registerDrawingData: registerDocDrawingData } as never,
|
||||
{ registerDrawingData: registerRenderDrawingData } as never,
|
||||
resourceManagerService as never,
|
||||
{
|
||||
getUnit: vi.fn((_unitId: string, _type?: UniverInstanceType) => documentDataModel),
|
||||
} as never,
|
||||
{ registerCommand: vi.fn(() => ({ dispose: vi.fn() })) } as never
|
||||
);
|
||||
|
||||
expect(controller.loadDrawingDataForUnit('doc-1')).toBe(true);
|
||||
expect(registerDocDrawingData).toHaveBeenCalledWith('doc-1', expect.objectContaining({
|
||||
'doc-1': expect.objectContaining({
|
||||
data: {
|
||||
shape1: expect.objectContaining({
|
||||
unitId: 'doc-1',
|
||||
subUnitId: 'doc-1',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
expect(registerRenderDrawingData).toHaveBeenCalledWith('doc-1', expect.objectContaining({
|
||||
'doc-1': expect.objectContaining({
|
||||
data: {
|
||||
shape1: expect.objectContaining({
|
||||
unitId: 'doc-1',
|
||||
subUnitId: 'doc-1',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
controller.dispose();
|
||||
try {
|
||||
const controller = bed.injector.get(DocDrawingController);
|
||||
expect(controller.loadDrawingDataForUnit('test-doc')).toBe(true);
|
||||
for (const service of [bed.injector.get(IDocDrawingService), bed.injector.get(IDrawingManagerService)]) {
|
||||
expect(service.getDrawingOrder('test-doc', 'test-doc')).toEqual(['image', 'note-image']);
|
||||
expect(service.getDrawingData('test-doc', 'test-doc')).toMatchObject({
|
||||
image: { unitId: 'test-doc', subUnitId: 'test-doc' },
|
||||
'note-image': { unitId: 'test-doc', subUnitId: 'test-doc' },
|
||||
});
|
||||
}
|
||||
expect(bed.documentDataModel.getDrawings()).not.toHaveProperty('note-image');
|
||||
expect(bed.documentDataModel.getSnapshot().notes?.note.drawings?.['note-image'].subUnitId).toBe('stale-import-unit');
|
||||
} finally {
|
||||
bed.univer.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import { RemoveDocDrawingCommand } from '../commands/commands/remove-doc-drawing
|
||||
import { SetDocDrawingArrangeCommand } from '../commands/commands/set-drawing-arrange.command';
|
||||
import { UpdateDrawingDocTransformCommand } from '../commands/commands/update-doc-drawing-transform.command';
|
||||
import { UpdateDocDrawingWrappingStyleCommand } from '../commands/commands/update-doc-drawing-wrapping-style.command';
|
||||
import { collectDocDrawings } from '../services/doc-drawing-source';
|
||||
import { IDocDrawingService } from '../services/doc-drawing.service';
|
||||
|
||||
export const DOCS_DRAWING_PLUGIN = 'DOC_DRAWING_PLUGIN';
|
||||
@@ -140,8 +141,7 @@ export class DocDrawingController extends Disposable {
|
||||
|
||||
const subUnitId = unitId;
|
||||
|
||||
const drawingDataModels = dataModel.getDrawings();
|
||||
const drawingOrderModel = dataModel.getDrawingsOrder();
|
||||
const { drawings: drawingDataModels, drawingsOrder: drawingOrderModel } = collectDocDrawings(dataModel.getSnapshot());
|
||||
|
||||
if (!drawingDataModels || !drawingOrderModel) {
|
||||
return false;
|
||||
|
||||
@@ -18,7 +18,9 @@ import type { IDocumentData, IObjectPositionH, IObjectPositionV } from '@univerj
|
||||
import {
|
||||
ArrangeTypeEnum,
|
||||
BooleanNumber,
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentFlavor,
|
||||
ICommandService,
|
||||
ImageSourceType,
|
||||
ObjectRelativeFromH,
|
||||
@@ -110,6 +112,60 @@ describe('FDocument image facade', () => {
|
||||
expect(testBed.document.getImages().map((item) => item.getId())).toEqual([image!.getId()]);
|
||||
});
|
||||
|
||||
it('inserts, resizes and removes an inline footnote image without changing body resources', async () => {
|
||||
testBed.univer.dispose();
|
||||
testBed = createFacadeTestBed({
|
||||
id: 'test-doc',
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: {
|
||||
dataStream: 'A\uFFFC\r\n',
|
||||
paragraphs: [{ startIndex: 2, paragraphId: 'p' }],
|
||||
sectionBreaks: [{ startIndex: 3, sectionId: 's' }],
|
||||
customRanges: [{
|
||||
rangeId: 'reference',
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
startIndex: 1,
|
||||
endIndex: 1,
|
||||
wholeEntity: true,
|
||||
properties: { noteId: 'note' },
|
||||
}],
|
||||
},
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: 'Note\r\n',
|
||||
paragraphs: [{ startIndex: 4, paragraphId: 'np' }],
|
||||
} } },
|
||||
});
|
||||
const before = testBed.document.save();
|
||||
const image = await testBed.document.insertImage({
|
||||
source: 'data:image/png;base64,image',
|
||||
imageSourceType: ImageSourceType.BASE64,
|
||||
width: 20,
|
||||
height: 10,
|
||||
wrappingStyle: TextWrappingStyle.WRAP_SQUARE,
|
||||
textRange: { startOffset: 4, endOffset: 4, collapsed: true, segmentId: 'note' },
|
||||
});
|
||||
if (!image) {
|
||||
throw new Error('Expected footnote image insertion');
|
||||
}
|
||||
expect(image.getImageData()?.layoutType).toBe(PositionedObjectLayoutType.INLINE);
|
||||
expect(testBed.document.getImage(image.getId())?.getSource()).toBe('data:image/png;base64,image');
|
||||
expect(testBed.document.getImages().map((item) => item.getId())).toEqual([image.getId()]);
|
||||
expect(image.setSize(30, 15)).toBe(true);
|
||||
expect(image.getSize()).toEqual({ width: 30, height: 15 });
|
||||
expect(image.setWrappingStyle(TextWrappingStyle.WRAP_SQUARE)).toBe(false);
|
||||
const inserted = testBed.document.save();
|
||||
expect(inserted.notes?.note.body.dataStream).toBe('Note\b\r\n');
|
||||
expect(inserted.notes?.note.drawingsOrder).toEqual([image.getId()]);
|
||||
expect(inserted.drawings).toEqual(before.drawings);
|
||||
expect(inserted.body).toEqual(before.body);
|
||||
expect(image.remove()).toBe(true);
|
||||
expect(testBed.document.save().notes?.note.body.dataStream).toBe('Note\r\n');
|
||||
expect(testBed.document.save().notes?.note.drawingsOrder).toEqual([]);
|
||||
expect(testBed.document.getImage(image.getId())).toBeNull();
|
||||
expect(testBed.document.undo()).toBe(true);
|
||||
expect(testBed.document.save().notes?.note).toEqual(inserted.notes?.note);
|
||||
});
|
||||
|
||||
it('resolves the insertion range only once', async () => {
|
||||
const selectionManager = testBed.injector.get(DocSelectionManagerService);
|
||||
const getActiveTextRange = vi.spyOn(selectionManager, 'getActiveTextRange');
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '@univerjs/core';
|
||||
import { FBase } from '@univerjs/core/facade';
|
||||
import {
|
||||
findDocDrawing,
|
||||
RemoveDocDrawingCommand,
|
||||
SetDocDrawingArrangeCommand,
|
||||
UpdateDocDrawingWrappingStyleCommand,
|
||||
@@ -193,7 +194,7 @@ export class FDocumentImage extends FBase {
|
||||
* ```
|
||||
*/
|
||||
getImageData(): IDocImage | null {
|
||||
const drawing = this._document.getDocumentDataModel().getDrawings()?.[this._imageId];
|
||||
const drawing = findDocDrawing(this._document.getDocumentDataModel().getSnapshot(), this._imageId)?.drawing;
|
||||
if (!drawing || drawing.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
|
||||
return null;
|
||||
}
|
||||
@@ -453,11 +454,12 @@ export class FDocumentImage extends FBase {
|
||||
|
||||
private _getTextRange(): ITextRangeParam | null {
|
||||
const snapshot = this._document.getDocumentDataModel().getSnapshot();
|
||||
const { body, headers = {}, footers = {} } = snapshot;
|
||||
const { body, headers = {}, footers = {}, notes = {} } = snapshot;
|
||||
const segments = [
|
||||
{ segmentId: '', body },
|
||||
...Object.entries(headers).map(([segmentId, header]) => ({ segmentId, body: header.body })),
|
||||
...Object.entries(footers).map(([segmentId, footer]) => ({ segmentId, body: footer.body })),
|
||||
...Object.entries(notes).map(([segmentId, note]) => ({ segmentId, body: note.body })),
|
||||
];
|
||||
|
||||
for (const { segmentId, body } of segments) {
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
} from '@univerjs/core';
|
||||
import { buildDocTransform, docDrawingPositionToTransform } from '@univerjs/docs';
|
||||
import {
|
||||
collectDocDrawings,
|
||||
findDocDrawing,
|
||||
InsertDocDrawingCommand,
|
||||
TextWrappingStyle,
|
||||
WRAPPING_STYLE_TO_LAYOUT_TYPE,
|
||||
@@ -195,7 +197,7 @@ export class FDocumentImageMixin extends FDocument implements IFDocumentImageMix
|
||||
}
|
||||
|
||||
override getImage(imageId: string): FDocumentImage | null {
|
||||
const drawing = this.getDocumentDataModel().getDrawings()?.[imageId];
|
||||
const drawing = findDocDrawing(this.getDocumentDataModel().getSnapshot(), imageId)?.drawing;
|
||||
if (!drawing || drawing.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
|
||||
return null;
|
||||
}
|
||||
@@ -204,8 +206,7 @@ export class FDocumentImageMixin extends FDocument implements IFDocumentImageMix
|
||||
|
||||
override getImages(): FDocumentImage[] {
|
||||
const documentDataModel = this.getDocumentDataModel();
|
||||
const drawings = documentDataModel.getDrawings() ?? {};
|
||||
const drawingIds = documentDataModel.getDrawingsOrder() ?? Object.keys(drawings);
|
||||
const { drawings, drawingsOrder: drawingIds } = collectDocDrawings(documentDataModel.getSnapshot());
|
||||
return drawingIds
|
||||
.filter((drawingId) => drawings[drawingId]?.drawingType === DrawingTypeEnum.DRAWING_IMAGE)
|
||||
.map((drawingId) => this._injector.createInstance(FDocumentImage, this, drawingId, this._injector));
|
||||
|
||||
@@ -35,6 +35,8 @@ export type {
|
||||
IDocDrawingMutationInfos,
|
||||
IDocDrawingRemoveMutationInfoParams,
|
||||
} from './services/doc-drawing-adapter.service';
|
||||
export { collectDocDrawings, findDocDrawing } from './services/doc-drawing-source';
|
||||
export { DocDrawingService, IDocDrawingService } from './services/doc-drawing.service';
|
||||
export type { IDocDrawing, IDocImage } from './services/doc-drawing.service';
|
||||
|
||||
export { type IDocFloatDom } from './services/doc-drawing.service';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentData, IDrawings } from '@univerjs/core';
|
||||
|
||||
/** Resolve persisted drawing ownership independently of the current text selection. */
|
||||
export function findDocDrawing(snapshot: IDocumentData, drawingId: string): {
|
||||
drawing: IDrawings[string];
|
||||
path: string[];
|
||||
segmentId: string;
|
||||
} | undefined {
|
||||
const drawing = snapshot.drawings?.[drawingId];
|
||||
if (drawing) {
|
||||
return { drawing, path: ['drawings', drawingId], segmentId: '' };
|
||||
}
|
||||
for (const [segmentId, note] of Object.entries(snapshot.notes ?? {})) {
|
||||
const drawing = note.drawings?.[drawingId];
|
||||
if (drawing) {
|
||||
return { drawing, path: ['notes', segmentId, 'drawings', drawingId], segmentId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The render registry is flat; persisted note resources retain their own scope. */
|
||||
export function collectDocDrawings(snapshot: IDocumentData): { drawings: IDrawings; drawingsOrder: string[] } {
|
||||
let drawings = snapshot.drawings ?? {};
|
||||
let drawingsOrder = snapshot.drawingsOrder ?? Object.keys(drawings);
|
||||
let copied = false;
|
||||
for (const note of Object.values(snapshot.notes ?? {})) {
|
||||
if (!note.drawings || Object.keys(note.drawings).length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (!copied) {
|
||||
drawings = { ...drawings };
|
||||
drawingsOrder = [...drawingsOrder];
|
||||
copied = true;
|
||||
}
|
||||
Object.assign(drawings, note.drawings);
|
||||
drawingsOrder.push(...(note.drawingsOrder ?? Object.keys(note.drawings)));
|
||||
}
|
||||
return { drawings, drawingsOrder };
|
||||
}
|
||||
@@ -14,13 +14,68 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { DocumentDataModel, ICommand, IDocumentData, Injector, Univer } from '@univerjs/core';
|
||||
import { awaitTime, DataStreamTreeTokenType, DocumentBlockRangeType, DocumentFlavor, ICommandService, IUniverInstanceService, NamedStyleType, PresetListType, UniverInstanceType } from '@univerjs/core';
|
||||
import { DocSelectionManagerService, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
|
||||
import type { DocumentDataModel, ICommand, IDocumentData, Injector } from '@univerjs/core';
|
||||
import { awaitTime, CustomRangeType, DataStreamTreeTokenType, DocumentBlockRangeType, DocumentFlavor, ICommandService, IUniverInstanceService, NamedStyleType, PresetListType, Univer, UniverInstanceType } from '@univerjs/core';
|
||||
import { DocSelectionManagerService, DocStateChangeManagerService, DocStateEmitService, InsertTextCommand, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
|
||||
import { IRenderManagerService, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, RenderManagerService } from '@univerjs/engine-render';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { DocMenuStyleService } from '../../../services/doc-menu-style.service';
|
||||
import { BreakLineCommand, BreakLineInsertionMode } from '../break-line.command';
|
||||
import { createCommandTestBed } from './create-command-test-bed';
|
||||
|
||||
it('keeps typing in the footnote after inserting a paragraph', async () => {
|
||||
const univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocStateEmitService]);
|
||||
injector.add([DocStateChangeManagerService]);
|
||||
injector.add([DocMenuStyleService]);
|
||||
injector.get(DocStateChangeManagerService);
|
||||
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'note-input',
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: { dataStream: 'Body\uFFFC\r\n', paragraphs: [{ paragraphId: 'p', startIndex: 5 }], sectionBreaks: [{ sectionId: 's', startIndex: 6 }], customRanges: [{ rangeId: 'reference', rangeType: CustomRangeType.FOOTNOTE, startIndex: 4, endIndex: 4, properties: { noteId: 'note' } }] },
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: { dataStream: '\r\n', paragraphs: [{ paragraphId: 'np', startIndex: 0 }] } } },
|
||||
});
|
||||
injector.get(IUniverInstanceService).focusUnit(doc.getUnitId());
|
||||
const commands = injector.get(ICommandService);
|
||||
[RichTextEditingMutation, InsertTextCommand, BreakLineCommand]
|
||||
.forEach((command) => commands.registerCommand(command));
|
||||
const selections = injector.get(DocSelectionManagerService);
|
||||
selections.__TEST_ONLY_setCurrentSelection({ unitId: 'note-input', subUnitId: 'note-input' });
|
||||
selections.replaceSelectionInfoWithoutRefresh({
|
||||
textRanges: [{ startOffset: 0, endOffset: 0, collapsed: true, isActive: true, segmentId: 'note' }],
|
||||
rectRanges: [],
|
||||
segmentId: '',
|
||||
segmentPage: -1,
|
||||
isEditing: true,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
});
|
||||
try {
|
||||
const noteId = Object.keys(doc.getSnapshot().notes!)[0];
|
||||
for (const text of ['First', ' ', 'line', '\r', 'Next', ' ', 'line']) {
|
||||
const range = selections.getActiveTextRange()!;
|
||||
expect(range.segmentId).toBe(noteId);
|
||||
if (text === '\r') {
|
||||
expect(commands.syncExecuteCommand(BreakLineCommand.id)).toBe(true);
|
||||
} else {
|
||||
expect(commands.syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: doc.getUnitId(),
|
||||
range,
|
||||
body: { dataStream: text },
|
||||
})).toBe(true);
|
||||
}
|
||||
await Promise.resolve();
|
||||
}
|
||||
expect(selections.getActiveTextRange()).toMatchObject({ segmentId: noteId, startOffset: 20 });
|
||||
expect(doc.getBody()?.dataStream).toBe('Body\uFFFC\r\n');
|
||||
expect(doc.getSnapshot().notes?.[noteId].body.dataStream).toBe('First line\rNext line\r\n');
|
||||
} finally {
|
||||
univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
function getDocumentData(): IDocumentData {
|
||||
return {
|
||||
id: 'test-doc',
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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, DocumentFlavor, ICommandService, IUniverInstanceService, RedoCommand, UndoCommand, Univer, UniverInstanceType, validateDocumentStructure } from '@univerjs/core';
|
||||
import { DocSelectionManagerService, DocStateChangeManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
|
||||
import { IRenderManagerService, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, RenderManagerService } from '@univerjs/engine-render';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createInternalClipboardDocData } from '../../../services/clipboard/internal-fragment';
|
||||
import { InnerPasteCommand } from '../clipboard.inner.command';
|
||||
|
||||
const SOURCE: IDocumentData = {
|
||||
id: 'source',
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: {
|
||||
dataStream: 'A\uFFFCB',
|
||||
textRuns: [{ st: 2, ed: 3, ts: { bl: 1 } }],
|
||||
customRanges: [{ startIndex: 1, endIndex: 1, wholeEntity: true, rangeId: 'reference', rangeType: CustomRangeType.FOOTNOTE, properties: { noteId: 'note' } }],
|
||||
},
|
||||
notes: {
|
||||
note: { type: 'footnote' as const, noteId: 'note', customMark: '†', referenceTextStyle: { fs: 8, va: 3 }, body: {
|
||||
dataStream: 'Explanation\r\n',
|
||||
paragraphs: [{ startIndex: 11, paragraphId: 'source-paragraph' }],
|
||||
sectionBreaks: [{ startIndex: 12, sectionId: 'source-section' }],
|
||||
textRuns: [{ st: 0, ed: 11, ts: { fs: 9.5 } }],
|
||||
} },
|
||||
},
|
||||
};
|
||||
|
||||
describe('InnerPasteCommand notes', () => {
|
||||
let univer: Univer;
|
||||
let document: DocumentDataModel;
|
||||
let commands: ICommandService;
|
||||
let selections: DocSelectionManagerService;
|
||||
beforeEach(() => {
|
||||
univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocStateEmitService]);
|
||||
injector.add([DocStateChangeManagerService]);
|
||||
injector.get(DocStateChangeManagerService);
|
||||
commands = injector.get(ICommandService);
|
||||
[InnerPasteCommand, RichTextEditingMutation].forEach((command) => commands.registerCommand(command));
|
||||
document = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'target',
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: { dataStream: '\r\n', customRanges: [], customDecorations: [], customBlocks: [], textRuns: [], paragraphs: [{ startIndex: 0, paragraphId: 'target-paragraph' }], sectionBreaks: [{ startIndex: 1, sectionId: 'target-section' }] },
|
||||
});
|
||||
injector.get(IUniverInstanceService).focusUnit(document.getUnitId());
|
||||
selections = injector.get(DocSelectionManagerService);
|
||||
selections.__TEST_ONLY_setCurrentSelection({ unitId: 'target', subUnitId: 'target' });
|
||||
});
|
||||
afterEach(() => univer.dispose());
|
||||
|
||||
async function paste(offset: number, segmentId = '') {
|
||||
selections.replaceSelectionInfoWithoutRefresh({
|
||||
textRanges: [{ startOffset: offset, endOffset: offset, segmentId, collapsed: true, isActive: true }],
|
||||
rectRanges: [],
|
||||
segmentId,
|
||||
segmentPage: -1,
|
||||
isEditing: true,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
});
|
||||
return commands.executeCommand(InnerPasteCommand.id, {
|
||||
doc: createInternalClipboardDocData(SOURCE),
|
||||
segmentId,
|
||||
textRanges: [{ startOffset: offset + 3, endOffset: offset + 3, collapsed: true, segmentId }],
|
||||
});
|
||||
}
|
||||
|
||||
it('copies independent rich notes and restores their identities through undo and redo', async () => {
|
||||
const before = structuredClone(document.getSnapshot());
|
||||
expect(await paste(0)).toBe(true);
|
||||
const first = structuredClone(document.getSnapshot());
|
||||
const firstId = Object.keys(first.notes!)[0];
|
||||
expect(firstId).not.toBe('note');
|
||||
expect(first.notes![firstId]).toMatchObject({ customMark: '†', referenceTextStyle: { fs: 8, va: 3 } });
|
||||
expect(first.notes![firstId].body.textRuns).toEqual(SOURCE.notes!.note.body.textRuns);
|
||||
commands.syncExecuteCommand(UndoCommand.id);
|
||||
expect(document.getSnapshot()).toEqual(before);
|
||||
commands.syncExecuteCommand(RedoCommand.id);
|
||||
expect(document.getSnapshot()).toEqual(first);
|
||||
expect(await paste(3)).toBe(true);
|
||||
const second = document.getSnapshot();
|
||||
const ids = second.body!.customRanges!.map((range) => range.properties!.noteId);
|
||||
expect(new Set(ids).size).toBe(2);
|
||||
expect(Object.keys(second.notes!)).toHaveLength(2);
|
||||
expect(validateDocumentStructure(second)).toEqual([]);
|
||||
expect(SOURCE.notes!.note.body.paragraphs![0].paragraphId).toBe('source-paragraph');
|
||||
});
|
||||
|
||||
it('drops references in modern mode and adjusts following text styles', async () => {
|
||||
document.updateDocumentStyle({ documentFlavor: DocumentFlavor.MODERN });
|
||||
expect(await paste(0)).toBe(true);
|
||||
expect(document.getBody()!.dataStream).toBe('AB\r\n');
|
||||
expect(document.getBody()!.textRuns).toContainEqual(expect.objectContaining({ st: 1, ed: 2, ts: { bl: 1 } }));
|
||||
expect(document.getSnapshot().notes).toEqual({});
|
||||
expect(validateDocumentStructure(document.getSnapshot())).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects nested references without leaving orphan note resources', async () => {
|
||||
await paste(0);
|
||||
const before = structuredClone(document.getSnapshot());
|
||||
const id = Object.keys(before.notes!)[0];
|
||||
expect(await paste(0, id)).toBe(false);
|
||||
expect(document.getSnapshot()).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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 { DeleteDirection, DocumentFlavor, ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
|
||||
import { DocSelectionManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
|
||||
import { IRenderManagerService, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, RenderManagerService } from '@univerjs/engine-render';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MergeTwoParagraphCommand } from '../doc-delete.command';
|
||||
|
||||
describe('MergeTwoParagraphCommand segment selection', () => {
|
||||
it.each([DeleteDirection.LEFT, DeleteDirection.RIGHT])('keeps the footnote caret after merging in direction %s', async (direction) => {
|
||||
const univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocStateEmitService]);
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'note-merge',
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: { dataStream: 'Body\r\n', paragraphs: [{ startIndex: 4, paragraphId: 'body' }] },
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: 'First\rSecond\r\n',
|
||||
paragraphs: [{ startIndex: 5, paragraphId: 'first' }, { startIndex: 12, paragraphId: 'second' }],
|
||||
} } },
|
||||
});
|
||||
try {
|
||||
injector.get(IUniverInstanceService).focusUnit('note-merge');
|
||||
const commands = injector.get(ICommandService);
|
||||
commands.registerCommand(MergeTwoParagraphCommand);
|
||||
commands.registerCommand(RichTextEditingMutation);
|
||||
const selections = injector.get(DocSelectionManagerService);
|
||||
selections.__TEST_ONLY_setCurrentSelection({ unitId: 'note-merge', subUnitId: 'note-merge' });
|
||||
const offset = direction === DeleteDirection.LEFT ? 6 : 5;
|
||||
const range = { startOffset: offset, endOffset: offset, collapsed: true, isActive: true, segmentId: 'note' };
|
||||
selections.replaceSelectionInfoWithoutRefresh({
|
||||
textRanges: [range],
|
||||
rectRanges: [],
|
||||
segmentId: 'note',
|
||||
segmentPage: 2,
|
||||
isEditing: true,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
});
|
||||
expect(await commands.executeCommand(MergeTwoParagraphCommand.id, { direction, range })).toBe(true);
|
||||
expect(model.getSnapshot().notes!.note.body.dataStream).toBe('FirstSecond\r\n');
|
||||
expect(model.getSnapshot().body!.dataStream).toBe('Body\r\n');
|
||||
expect(selections.getActiveTextRange()).toMatchObject({ segmentId: 'note', startOffset: 5, endOffset: 5 });
|
||||
} finally {
|
||||
univer.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -220,9 +220,11 @@ export const BreakLineCommand: ICommand<IBreakLineCommandParams> = {
|
||||
startOffset: startOffset + cursorMove,
|
||||
endOffset: startOffset + cursorMove,
|
||||
collapsed,
|
||||
segmentId,
|
||||
}];
|
||||
|
||||
const path = getRichTextEditPath(docDataModel, segmentId);
|
||||
doMutation.params.segmentId = segmentId;
|
||||
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
|
||||
const result = commandService.syncExecuteCommand<
|
||||
IRichTextEditingMutationParams,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
CommandType,
|
||||
createParagraphId,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentFlavor,
|
||||
generateRandomId,
|
||||
getCustomBlockIdsInSelections,
|
||||
getRichTextEditPath,
|
||||
@@ -43,6 +44,7 @@ import { getCustomDecorationAtPosition, getCustomRangeAtPosition } from '../../b
|
||||
import {
|
||||
IDocClipboardPasteAdapterService,
|
||||
} from '../../services/clipboard/doc-paste-mutation-adapter.service';
|
||||
import { cloneClipboardNotes, omitClipboardNotes } from '../../services/clipboard/internal-fragment';
|
||||
import { getCommandSkeleton } from '../util';
|
||||
import { getDeleteRowContentActionParams, getDeleteRowsActionsParams, getDeleteTableActionParams } from './table/table';
|
||||
|
||||
@@ -83,7 +85,8 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
|
||||
const selections = docSelectionManagerService.getTextRanges() ?? [];
|
||||
const rectRanges = docSelectionManagerService.getRectRanges() ?? [];
|
||||
const selectionInfo = docSelectionManagerService.getSelectionInfo();
|
||||
const { body, tableSource, drawings } = doc;
|
||||
const { body: sourceBody, tableSource, drawings } = doc;
|
||||
let body = sourceBody;
|
||||
if ((selections.length === 0 && rectRanges.length === 0) || body == null) {
|
||||
return false;
|
||||
}
|
||||
@@ -94,6 +97,9 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
|
||||
return false;
|
||||
}
|
||||
const unitId = docDataModel.getUnitId();
|
||||
if (docDataModel.getDocumentStyle().documentFlavor !== DocumentFlavor.TRADITIONAL) {
|
||||
body = omitClipboardNotes(body);
|
||||
}
|
||||
|
||||
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
@@ -112,6 +118,7 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
|
||||
const textX = new TextX();
|
||||
const jsonX = JSONX.getInstance();
|
||||
const rawActions: JSONXActions = [];
|
||||
const pastedNotes: NonNullable<IDocumentData['notes']> = {};
|
||||
const resourceRedoMutations: IMutationInfo[] = [];
|
||||
const resourceUndoMutations: IMutationInfo[] = [];
|
||||
const resourceMutationGroups: Array<{ redoMutations: IMutationInfo[]; undoMutations: IMutationInfo[] }> = [];
|
||||
@@ -179,15 +186,18 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
|
||||
const cloneBody = Tools.deepClone(body);
|
||||
const blockRangeMappings: IDocClipboardPasteBlockRangeMapping[] = [];
|
||||
const customBlockMappings: IDocClipboardPasteCustomBlockMapping[] = [];
|
||||
const selectionCustomRangeMappings = customRangeMappings.map(({ sourceRange }) => ({
|
||||
sourceRange,
|
||||
targetRange: BuildTextUtils.customRange.copyCustomRange(sourceRange),
|
||||
const selectionCustomRangeMappings = (body.customRanges ?? []).map((range) => ({
|
||||
sourceRange: customRangeMappings.find(({ targetRange }) => targetRange.rangeId === range.rangeId)?.sourceRange ?? range,
|
||||
targetRange: BuildTextUtils.customRange.copyCustomRange(range),
|
||||
}));
|
||||
if (selectionCustomRangeMappings.length > 0) {
|
||||
cloneBody.customRanges = selectionCustomRangeMappings.map(
|
||||
({ targetRange }) => targetRange
|
||||
);
|
||||
}
|
||||
if (docDataModel.getDocumentStyle().documentFlavor === DocumentFlavor.TRADITIONAL) {
|
||||
Object.assign(pastedNotes, cloneClipboardNotes(cloneBody, doc.notes));
|
||||
}
|
||||
|
||||
if (hasBlockRange) {
|
||||
cloneBody.blockRanges!.forEach((targetBlockRange, index) => {
|
||||
@@ -307,6 +317,20 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
|
||||
|
||||
const path = getRichTextEditPath(docDataModel, segmentId);
|
||||
|
||||
if (Object.keys(pastedNotes).length > 0) {
|
||||
if (segmentId) {
|
||||
return false;
|
||||
}
|
||||
const existing = docDataModel.getSnapshot().notes;
|
||||
if (existing == null) {
|
||||
rawActions.push(jsonX.insertOp(['notes'], pastedNotes)!);
|
||||
} else {
|
||||
for (const [id, note] of Object.entries(pastedNotes)) {
|
||||
rawActions.push(jsonX.insertOp(['notes', id], note)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
|
||||
|
||||
const pasteActions = rawActions.reduce((acc, cur) => {
|
||||
|
||||
@@ -210,6 +210,7 @@ export const MergeTwoParagraphCommand: ICommand<IMergeTwoParagraphParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId,
|
||||
segmentId,
|
||||
actions: [],
|
||||
textRanges,
|
||||
prevTextRanges: [range],
|
||||
|
||||
@@ -15,11 +15,32 @@
|
||||
*/
|
||||
|
||||
import type { IDocumentData } from '@univerjs/core';
|
||||
import { JSONX } from '@univerjs/core';
|
||||
import { CustomRangeType, JSONX, TextX } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getDocumentMutationLayoutImpact } from '../render-controllers/doc-mutation-layout';
|
||||
|
||||
describe('document mutation layout range index', () => {
|
||||
it.each(['first-note', 'another-note', 'edit-note'])('starts %s layout at the body reference instead of the start of the document', (operation) => {
|
||||
const note = { type: 'footnote' as const, noteId: 'note', body: { dataStream: 'Explanation\r\n' } };
|
||||
const jsonX = JSONX.getInstance();
|
||||
let actions = jsonX.insertOp(['notes'], { note });
|
||||
if (operation === 'another-note') {
|
||||
actions = jsonX.insertOp(['notes', 'note'], note);
|
||||
} else if (operation === 'edit-note') {
|
||||
actions = jsonX.editOp(new TextX().insert(1, { dataStream: 'X' }).serialize(), ['notes', 'note', 'body']);
|
||||
}
|
||||
const impact = getDocumentMutationLayoutImpact(actions, {
|
||||
body: { dataStream: '', customRanges: [{
|
||||
rangeId: 'ref',
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
startIndex: 90_000,
|
||||
endIndex: 90_000,
|
||||
properties: { noteId: 'note' },
|
||||
}] },
|
||||
});
|
||||
expect(impact).toEqual({ global: false, range: { start: 90_000, end: 90_001 }, unresolvedLocal: false });
|
||||
});
|
||||
|
||||
it('indexes table ranges once for a composed table mutation', () => {
|
||||
const tableCount = 1_000;
|
||||
let indexedReads = 0;
|
||||
|
||||
+13
@@ -203,4 +203,17 @@ describe('document layout progress', () => {
|
||||
}))).toBe(25);
|
||||
expect(resolveDocLayoutProgressPercent(createProgress({ complete: true }))).toBe(100);
|
||||
});
|
||||
|
||||
it('does not report the initial interaction window as a completed document', () => {
|
||||
const handoff = createProgress({
|
||||
processedBlockCount: 0,
|
||||
totalBlockCount: 6_264,
|
||||
pageCount: 2,
|
||||
publishedPageCount: 2,
|
||||
estimatedPageCount: 2,
|
||||
});
|
||||
expect(resolveDocLayoutProgressPercent(handoff)).toBe(0);
|
||||
expect(resolveDocLayoutProgressPercent({ ...handoff, processedBlockCount: 64, estimatedPageCount: 314 })).toBe(1);
|
||||
expect(resolveDocLayoutProgressPercent({ ...handoff, processedBlockCount: 3_132, publishedPageCount: 140, estimatedPageCount: 314 })).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
+67
-2
@@ -21,6 +21,7 @@ import type { IDocLayoutExecutor } from '@univerjs/docs';
|
||||
import type { Documents, IPointerEvent, RenderUnit } from '@univerjs/engine-render';
|
||||
import {
|
||||
BooleanNumber,
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentFlavor,
|
||||
DrawingTypeEnum,
|
||||
@@ -69,7 +70,7 @@ import { DocInputController } from '../doc-input.controller';
|
||||
import { DocSelectionRenderController } from '../doc-selection-render.controller';
|
||||
import { DocRenderController } from '../doc.render-controller';
|
||||
|
||||
function createEditor(paragraphCount = 8, withDrawing = true, workerBeforeLayout = false, documentFlavor = DocumentFlavor.TRADITIONAL) {
|
||||
function createEditor(paragraphCount = 8, withDrawing = true, workerBeforeLayout = false, documentFlavor = DocumentFlavor.TRADITIONAL, withFootnote = false) {
|
||||
const univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
const root = document.createElement('div');
|
||||
@@ -100,8 +101,9 @@ function createEditor(paragraphCount = 8, withDrawing = true, workerBeforeLayout
|
||||
.forEach((command) => commands.registerCommand(command));
|
||||
const firstParagraph = 'Hello world';
|
||||
const drawingToken = withDrawing ? DataStreamTreeTokenType.CUSTOM_BLOCK : '';
|
||||
const noteStream = `${'Footnote continuation with several words.\r'.repeat(40)}\n`;
|
||||
const separator = documentFlavor === DocumentFlavor.MODERN ? '\r\r' : '\r';
|
||||
const dataStream = `${firstParagraph}\r${drawingToken}${Array.from({ length: paragraphCount }, (_, i) => `Paragraph ${i} has enough words to wrap across several lines.${separator}`).join('')}\n`;
|
||||
const dataStream = `${firstParagraph}\r${drawingToken}${withFootnote ? '\uFFFC' : ''}${Array.from({ length: paragraphCount }, (_, i) => `Paragraph ${i} has enough words to wrap across several lines.${separator}`).join('')}\n`;
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'bounded-caret-test',
|
||||
// A drawing on the edited page deliberately keeps the conservative
|
||||
@@ -123,8 +125,24 @@ function createEditor(paragraphCount = 8, withDrawing = true, workerBeforeLayout
|
||||
},
|
||||
}
|
||||
: {},
|
||||
notes: withFootnote
|
||||
? { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: noteStream,
|
||||
paragraphs: [...noteStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `note-${index}` })),
|
||||
} } }
|
||||
: undefined,
|
||||
body: {
|
||||
dataStream,
|
||||
customRanges: withFootnote
|
||||
? [{
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
rangeId: 'note-ref',
|
||||
wholeEntity: true,
|
||||
startIndex: 12,
|
||||
endIndex: 12,
|
||||
properties: { noteId: 'note' },
|
||||
}]
|
||||
: [],
|
||||
customBlocks: withDrawing ? [{ blockId: 'inline-drawing', startIndex: firstParagraph.length + 1 }] : [],
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({
|
||||
startIndex: match.index!,
|
||||
@@ -220,6 +238,53 @@ describe('DocRenderController bounded input publication', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps typing at the logical footnote caret after Enter moves it to a continuation page', async () => {
|
||||
const editor = createEditor(8, false, false, DocumentFlavor.TRADITIONAL, true);
|
||||
try {
|
||||
await vi.dynamicImportSettled();
|
||||
const pages = editor.skeleton.getSkeletonData()!.pages;
|
||||
const oldPage = pages.findIndex((page) => page.notes?.some((note) => note.noteId === 'note'));
|
||||
const boundary = pages[oldPage].notes![0].page.ed;
|
||||
editor.selectionManager.replaceDocRanges([{
|
||||
startOffset: boundary,
|
||||
endOffset: boundary,
|
||||
segmentId: 'note',
|
||||
segmentPage: oldPage,
|
||||
}], { unitId: editor.unitId, subUnitId: editor.unitId }, true);
|
||||
expect(await editor.commands.executeCommand(BreakLineCommand.id)).toBe(true);
|
||||
for (const text of ['A', ' ', '中', 'B']) {
|
||||
editor.input.textContent = text;
|
||||
editor.input.dispatchEvent(new InputEvent('input', { data: text, inputType: 'insertText' }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
const offset = boundary + 5;
|
||||
expect(editor.model.getSnapshot().notes!.note.body.dataStream.slice(boundary, offset)).toBe('\rA 中B');
|
||||
expect(editor.selectionManager.getActiveTextRange()?.endOffset).toBe(offset);
|
||||
const position = editor.skeleton.findNodePositionByCharIndex(offset, true, 'note');
|
||||
expect(position?.page).toBeGreaterThan(oldPage);
|
||||
expect(editor.selection.getActiveTextRange()?.endOffset).toBe(offset);
|
||||
expect(editor.selection.getSegmentPage()).toBe(position?.page);
|
||||
expect(editor.selection.hasPendingSelection).toBe(false);
|
||||
editor.selectionManager.replaceDocRanges([{
|
||||
startOffset: 5,
|
||||
endOffset: 5,
|
||||
segmentId: '',
|
||||
segmentPage: -1,
|
||||
}], { unitId: editor.unitId, subUnitId: editor.unitId }, true);
|
||||
expect(editor.selection.getSegment()).toBe('');
|
||||
editor.input.textContent = 'Z';
|
||||
editor.input.dispatchEvent(new InputEvent('input', { data: 'Z', inputType: 'insertText' }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(editor.model.getBody()!.dataStream.startsWith('HelloZ world')).toBe(true);
|
||||
expect(editor.model.getSnapshot().notes!.note.body.dataStream.slice(boundary, offset)).toBe('\rA 中B');
|
||||
} finally {
|
||||
editor.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps short Modern edits and spaces complete while Worker initialization is pending', async () => {
|
||||
const editor = createEditor(3, false, false, DocumentFlavor.MODERN);
|
||||
try {
|
||||
|
||||
+6
-1
@@ -39,7 +39,12 @@ export function resolveDocLayoutProgressPercent(progress: IDocumentLayoutProgres
|
||||
const pageProgress = progress.mode === 'paginated' && estimatedPageCount > 0
|
||||
? progress.publishedPageCount / estimatedPageCount
|
||||
: 0;
|
||||
return Math.min(99, Math.max(0, Math.floor(Math.max(blockProgress, pageProgress) * 100)));
|
||||
// Before the first Worker slice, the page estimate only contains the Main
|
||||
// interaction window. Publishing that window does not complete initial layout.
|
||||
const completedFraction = progress.reason === 'initial' && progress.totalBlockCount > 0
|
||||
? blockProgress
|
||||
: Math.max(blockProgress, pageProgress);
|
||||
return Math.min(99, Math.max(0, Math.floor(completedFraction * 100)));
|
||||
}
|
||||
|
||||
export class DocLayoutProgressRenderController extends RxDisposable implements IRenderModule {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import type { IDocStyles, IDocumentBody, IDocumentData, JSONXActions } from '@univerjs/core';
|
||||
import type { IDocumentLayoutInvalidation } from '@univerjs/engine-render';
|
||||
import { JSON1, TextX } from '@univerjs/core';
|
||||
import { CustomRangeType, JSON1, TextX } from '@univerjs/core';
|
||||
|
||||
interface IDocumentLayoutRange {
|
||||
end: number;
|
||||
@@ -226,6 +226,21 @@ function getComponentLayoutImpact(
|
||||
if (root === 'body') {
|
||||
return getBodyComponentLayoutImpact(path, component, body);
|
||||
}
|
||||
if (root === 'notes') {
|
||||
const ids = getChangedRecordKeys(path, component);
|
||||
let range: IDocumentLayoutRange | undefined;
|
||||
for (const reference of body?.customRanges ?? []) {
|
||||
const id = reference.properties?.noteId;
|
||||
if ((reference.rangeType === CustomRangeType.FOOTNOTE || reference.rangeType === CustomRangeType.ENDNOTE) && typeof id === 'string' && ids.has(id)) {
|
||||
range = mergeLayoutRange(range, { start: reference.startIndex, end: reference.endIndex + 1 });
|
||||
}
|
||||
}
|
||||
return {
|
||||
global: false,
|
||||
range,
|
||||
unresolvedLocal: range == null,
|
||||
};
|
||||
}
|
||||
if (root === 'tableSource') {
|
||||
const tableId = path[1];
|
||||
const range = typeof tableId === 'string' ? index.getTableRange(tableId) : undefined;
|
||||
|
||||
@@ -997,11 +997,14 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
|
||||
);
|
||||
} else if (isInitialLayout) {
|
||||
this._refreshPagePosition();
|
||||
} else if (progress.didPublishAnchor && (
|
||||
} else if ((progress.didPublishAnchor && (
|
||||
refreshIncompleteAnchorSelection || this._docSelectionRenderService.hasPendingSelection
|
||||
)) {
|
||||
)) || (publication != null &&
|
||||
this._context.unit.getSnapshot().notes?.[this._getActiveRange(unitId)?.segmentId ?? ''] != null)) {
|
||||
// The foreground pass replaces edited line and glyph objects. Rebuild
|
||||
// the caret from stable document offsets without moving the viewport.
|
||||
// A footnote can continue beyond the body anchor, so later Worker
|
||||
// publications must also resolve its current segment offset.
|
||||
this._textSelectionManagerService.refreshSelection(
|
||||
{ unitId, subUnitId: unitId },
|
||||
this._getActiveEditingRange(unitId) != null
|
||||
@@ -1728,7 +1731,7 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
|
||||
if (!doesDocMutationRequireLayout(params.actions, snapshot.drawings)) {
|
||||
return;
|
||||
}
|
||||
const bodyRanges = textRanges?.filter((range) => !range.segmentId) ?? [];
|
||||
const bodyRanges = textRanges?.filter((range) => !(range.segmentId ?? params.segmentId)) ?? [];
|
||||
const textInvalidation = getBodyMutationInvalidation(params.actions, params.segmentId);
|
||||
const mutationLayoutImpact = getDocumentMutationLayoutImpact(
|
||||
params.actions,
|
||||
@@ -1754,7 +1757,8 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
|
||||
? undefined
|
||||
: bodyRanges.find((range) => range.isActive) ??
|
||||
(bodyRanges.length === 1 ? bodyRanges[0] : undefined);
|
||||
const priorityAnchor = mutationActiveRange?.endOffset ?? activeRange?.endOffset;
|
||||
const priorityAnchor = mutationActiveRange?.endOffset ??
|
||||
(activeRange?.segmentId ? undefined : activeRange?.endOffset);
|
||||
// RichTextEditingMutation preserves the original Main input
|
||||
// contract by refreshing its local post-edit range in a microtask,
|
||||
// once the synchronous layout prefix has finished. Do not publish
|
||||
|
||||
@@ -119,7 +119,6 @@ import {
|
||||
DOC_SECTION_BREAK_NEXT_COLUMN_MENU_ID,
|
||||
DOC_SECTION_BREAK_NEXT_PAGE_MENU_ID,
|
||||
DOC_SECTION_BREAK_ODD_PAGE_MENU_ID,
|
||||
// DocSwitchModeMenuItemFactory,
|
||||
EmojiPickerMenuItemFactory,
|
||||
FLOAT_TEXT_STYLE_MENU_ID,
|
||||
FLOAT_TOOLBAR_MENU_POSITION,
|
||||
|
||||
@@ -34,9 +34,10 @@ import {
|
||||
Documents,
|
||||
ICanvasColorService,
|
||||
IRenderManagerService,
|
||||
Rect,
|
||||
RenderManagerService,
|
||||
} from '@univerjs/engine-render';
|
||||
import { ICanvasPopupService } from '@univerjs/ui';
|
||||
import { CanvasPopupService, ICanvasPopupService } from '@univerjs/ui';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SetDocZoomRatioOperation } from '../../commands/operations/set-doc-zoom-ratio.operation';
|
||||
import {
|
||||
@@ -54,6 +55,60 @@ import {
|
||||
import { NodePositionConvertToCursor } from '../selection/convert-text-range';
|
||||
|
||||
describe('popup anchors with real document layout', () => {
|
||||
it('lets an object toolbar follow layout transforms without holding a document layout lock', () => {
|
||||
const context = new Proxy({}, { get: () => () => {} });
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as never);
|
||||
const univer = new Univer();
|
||||
try {
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
|
||||
injector.add([ICanvasPopupService, { useClass: CanvasPopupService }]);
|
||||
injector.add([DocCanvasPopManagerService]);
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'object-popup-test',
|
||||
body: { dataStream: '\r\n' },
|
||||
});
|
||||
const render = injector.get(IRenderManagerService).createRender(model.getUnitId()) as RenderUnit;
|
||||
render.deactivate();
|
||||
render.engine.resizeBySize(300, 400);
|
||||
const canvas = render.engine.getCanvasElement()!;
|
||||
canvas.style.width = '300px';
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 300, 400));
|
||||
render.addRenderDependencies([[DocLayoutInteractionService], [DocCanvasPopupLayoutInteractionController]]);
|
||||
render.with(DocCanvasPopupLayoutInteractionController);
|
||||
const interaction = render.with(DocLayoutInteractionService);
|
||||
const shape = new Rect('note-image', { left: 20, top: 300, width: 100, height: 40 });
|
||||
render.scene.addObject(shape);
|
||||
const popupManager = injector.get(DocCanvasPopManagerService);
|
||||
const popup = popupManager.attachPopupToObject(shape, {
|
||||
componentKey: 'object-toolbar',
|
||||
requiresStableLayout: false,
|
||||
}, model.getUnitId());
|
||||
const popupService = injector.get(ICanvasPopupService);
|
||||
const anchors: unknown[] = [];
|
||||
let completed = false;
|
||||
const subscription = popupService.popups[0][1].anchorRect$.subscribe({
|
||||
next: (anchor) => anchors.push(anchor),
|
||||
complete: () => { completed = true; },
|
||||
});
|
||||
expect(interaction.isActive).toBe(false);
|
||||
shape.transformByState({ top: 280, width: 150, height: 60 });
|
||||
expect(anchors[anchors.length - 1]).toMatchObject({ left: 20, right: 170, top: 280, bottom: 340 });
|
||||
expect(interaction.isActive).toBe(false);
|
||||
popup.dispose();
|
||||
expect(completed).toBe(true);
|
||||
const count = anchors.length;
|
||||
shape.transformByState({ top: 260 });
|
||||
expect(anchors).toHaveLength(count);
|
||||
expect(popupService.popups).toHaveLength(0);
|
||||
subscription.unsubscribe();
|
||||
} finally {
|
||||
univer.dispose();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([0, 1, 3])('covers a %i-character range through its trailing edge', (length) => {
|
||||
const context = new Proxy({
|
||||
font: '',
|
||||
@@ -467,15 +522,14 @@ describe('DocCanvasPopManagerService', () => {
|
||||
|
||||
it('updates object anchored popups after zoom commands and removes popup on dispose', () => {
|
||||
const { service, popupService, commandService } = createService();
|
||||
const targetObject = { left: 30, top: 40, width: 50, height: 60 };
|
||||
const targetObject = new Rect('popup-target', { left: 30, top: 40, width: 50, height: 60 });
|
||||
|
||||
const disposable = service.attachPopupToObject(targetObject as never, { componentKey: 'object-menu' }, 'doc-1');
|
||||
const disposable = service.attachPopupToObject(targetObject, { componentKey: 'object-menu' }, 'doc-1');
|
||||
const popup = popupService.popups.get('popup-1');
|
||||
expect(popup?.anchorRect).toEqual({ left: 40, right: 90, top: 60, bottom: 120 });
|
||||
expect(disposable.canDispose()).toBe(false);
|
||||
|
||||
targetObject.left = 60;
|
||||
targetObject.top = 80;
|
||||
targetObject.transformByState({ left: 60, top: 80 });
|
||||
commandService.emit(SetDocZoomRatioOperation.id);
|
||||
const anchorRect$ = popup?.anchorRect$ as { value?: unknown } | undefined;
|
||||
expect(anchorRect$?.value).toEqual({ left: 70, right: 120, top: 100, bottom: 160 });
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Injector } from '@univerjs/core';
|
||||
import { CustomRangeType, DocumentDataModel, DocumentFlavor, DrawingTypeEnum, Injector, LocaleService, PositionedObjectLayoutType } from '@univerjs/core';
|
||||
import { DocumentSkeleton, DocumentViewModel } from '@univerjs/engine-render';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DocPrintInterceptorService } from '../doc-print-interceptor.service';
|
||||
|
||||
@@ -37,6 +38,55 @@ describe('DocPrintInterceptorService', () => {
|
||||
expect(service.interceptor.fetchThroughInterceptors(interceptPoints.PRINTING_DOM_COLLECT)(domCollection as never, { unitId: 'doc-1' } as never)).toBe(domCollection);
|
||||
});
|
||||
|
||||
it('prints an inline footnote image only on the page containing its note', () => {
|
||||
const injector = new Injector([[DocPrintInterceptorService], [LocaleService]]);
|
||||
const model = new DocumentDataModel({
|
||||
id: 'note-print',
|
||||
documentStyle: {
|
||||
documentFlavor: DocumentFlavor.TRADITIONAL,
|
||||
pageSize: { width: 300, height: 400 },
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
marginLeft: 20,
|
||||
marginRight: 20,
|
||||
},
|
||||
body: {
|
||||
dataStream: 'A\uFFFC\rB\r\n',
|
||||
paragraphs: [{ startIndex: 2, paragraphId: 'p1' }, {
|
||||
startIndex: 4,
|
||||
paragraphId: 'p2',
|
||||
paragraphStyle: { pageBreakBefore: 1 },
|
||||
}],
|
||||
sectionBreaks: [{ startIndex: 5, sectionId: 's' }],
|
||||
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: 1, endIndex: 1, wholeEntity: true, properties: { noteId: 'note' } }],
|
||||
},
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: '\b\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'np' }],
|
||||
customBlocks: [{ startIndex: 0, blockId: 'note-image' }],
|
||||
}, drawings: { 'note-image': {
|
||||
drawingId: 'note-image',
|
||||
unitId: 'note-print',
|
||||
subUnitId: 'note-print',
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
layoutType: PositionedObjectLayoutType.INLINE,
|
||||
docTransform: { angle: 0, size: { width: 40, height: 20 }, positionH: { relativeFrom: 0, posOffset: 0 }, positionV: { relativeFrom: 0, posOffset: 0 } },
|
||||
} }, drawingsOrder: ['note-image'] } },
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(new DocumentViewModel(model), injector.get(LocaleService));
|
||||
try {
|
||||
skeleton.calculate();
|
||||
expect(skeleton.getSkeletonData()?.pages).toHaveLength(2);
|
||||
const service = injector.get(DocPrintInterceptorService);
|
||||
expect([...service.getPageDrawingIds(skeleton, 0)!]).toEqual(['note-image']);
|
||||
expect([...service.getPageDrawingIds(skeleton, 1)!]).toEqual([]);
|
||||
} finally {
|
||||
skeleton.dispose();
|
||||
model.dispose();
|
||||
injector.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('waits for registered print preparation handlers', async () => {
|
||||
const service = createService();
|
||||
const handler = vi.fn(async () => undefined);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Disposable,
|
||||
DOC_RANGE_TYPE,
|
||||
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
|
||||
DocumentFlavor,
|
||||
DrawingTypeEnum,
|
||||
ErrorService,
|
||||
generateRandomId,
|
||||
@@ -77,6 +78,7 @@ import {
|
||||
DOC_INTERNAL_FRAGMENT_MIME,
|
||||
embedInternalClipboardFragment,
|
||||
extractInternalClipboardFragmentFromHtml,
|
||||
omitClipboardNotes,
|
||||
parseInternalClipboardFragment,
|
||||
wrapClipboardHtml,
|
||||
} from './internal-fragment';
|
||||
@@ -479,6 +481,10 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentDocument.getDocumentStyle().documentFlavor !== DocumentFlavor.TRADITIONAL) {
|
||||
body = omitClipboardNotes(body);
|
||||
}
|
||||
|
||||
this._clipboardHooks.forEach((hook) => {
|
||||
if (hook.onBeforePaste) {
|
||||
body = hook.onBeforePaste(body, {
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentData } from '@univerjs/core';
|
||||
import { generateRandomId, Tools } from '@univerjs/core';
|
||||
import type { IDocumentBody, IDocumentData, IDocumentNote } from '@univerjs/core';
|
||||
import { BuildTextUtils, createParagraphId, createSectionId, CustomRangeType, generateRandomId, TextX, Tools } from '@univerjs/core';
|
||||
|
||||
export const DOC_INTERNAL_FRAGMENT_MIME = 'application/x-doc-fragment+json';
|
||||
export const DOC_INTERNAL_FRAGMENT_COMMENT_PREFIX = 'univer-doc-fragment:';
|
||||
@@ -51,10 +51,27 @@ export function parseInternalClipboardFragment(value?: string): Partial<IDocumen
|
||||
return null;
|
||||
}
|
||||
|
||||
function copyReferencedNotes(doc: IDocumentData): IDocumentData['notes'] {
|
||||
let copied: IDocumentData['notes'];
|
||||
for (const range of doc.body?.customRanges ?? []) {
|
||||
const id = (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE) ? range.properties?.noteId : undefined;
|
||||
if (typeof id === 'string' && doc.notes?.[id]) {
|
||||
copied ??= {};
|
||||
copied[id] = Tools.deepClone(doc.notes[id]);
|
||||
}
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
export function createInternalClipboardDocData(doc: IDocumentData): Partial<IDocumentData> {
|
||||
const body = Tools.deepClone(doc.body);
|
||||
const internalDocData: Partial<IDocumentData> = { body };
|
||||
|
||||
const notes = copyReferencedNotes(doc);
|
||||
if (notes) {
|
||||
internalDocData.notes = notes;
|
||||
}
|
||||
|
||||
if (body?.tables?.length && doc.tableSource) {
|
||||
internalDocData.tableSource = {};
|
||||
body.tables.forEach((tableRange) => {
|
||||
@@ -138,11 +155,85 @@ export function createInternalClipboardDocDataList(docs: IDocumentData[]): Parti
|
||||
merged.drawings ??= {};
|
||||
Object.assign(merged.drawings, part.drawings);
|
||||
}
|
||||
if (part.notes) {
|
||||
merged.notes ??= {};
|
||||
Object.assign(merged.notes, part.notes);
|
||||
}
|
||||
}
|
||||
|
||||
return merged.body?.dataStream ? merged : null;
|
||||
}
|
||||
|
||||
/** Removes reference characters through TextX so every following clipboard offset stays valid. */
|
||||
export function omitClipboardNotes(body: IDocumentBody): IDocumentBody {
|
||||
const references = body.customRanges?.filter((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE)) ?? [];
|
||||
if (references.length === 0) {
|
||||
return body;
|
||||
}
|
||||
const textX = new TextX();
|
||||
let cursor = 0;
|
||||
for (const reference of references) {
|
||||
textX.retain(reference.startIndex - cursor).delete(reference.endIndex - reference.startIndex + 1);
|
||||
cursor = reference.endIndex + 1;
|
||||
}
|
||||
return TextX.apply(Tools.deepClone(body), textX.serialize());
|
||||
}
|
||||
|
||||
/** Each pasted reference owns a new rich segment, including its drawing and table identities. */
|
||||
export function cloneClipboardNotes(body: IDocumentBody, notes: IDocumentData['notes']): Record<string, IDocumentNote> {
|
||||
const copied: Record<string, IDocumentNote> = {};
|
||||
for (const range of body.customRanges ?? []) {
|
||||
if ((range.rangeType !== CustomRangeType.FOOTNOTE && range.rangeType !== CustomRangeType.ENDNOTE)) {
|
||||
continue;
|
||||
}
|
||||
const sourceId = range.properties?.noteId;
|
||||
const source = typeof sourceId === 'string' ? notes?.[sourceId] : undefined;
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const note = Tools.deepClone(source);
|
||||
note.noteId = generateRandomId(12);
|
||||
range.rangeId = generateRandomId(12);
|
||||
range.properties = { ...range.properties, noteId: note.noteId };
|
||||
const paragraphIds = new Set<string>();
|
||||
const sectionIds = new Set<string>();
|
||||
note.body.paragraphs?.forEach((paragraph) => {
|
||||
paragraph.paragraphId = createParagraphId(paragraphIds);
|
||||
});
|
||||
note.body.sectionBreaks?.forEach((section) => {
|
||||
section.sectionId = createSectionId(sectionIds);
|
||||
});
|
||||
note.body.customRanges = note.body.customRanges?.map((item) => BuildTextUtils.customRange.copyCustomRange(item));
|
||||
resetNoteResourceIds(note);
|
||||
copied[note.noteId] = note;
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
function resetNoteResourceIds(note: IDocumentNote): void {
|
||||
for (const range of note.body.tables ?? []) {
|
||||
const previousId = range.tableId;
|
||||
const table = note.tableSource?.[previousId];
|
||||
if (table && note.tableSource) {
|
||||
range.tableId = generateRandomId(12);
|
||||
table.tableId = range.tableId;
|
||||
note.tableSource[range.tableId] = table;
|
||||
delete note.tableSource[previousId];
|
||||
}
|
||||
}
|
||||
for (const block of note.body.customBlocks ?? []) {
|
||||
const previousId = block.blockId;
|
||||
const drawing = note.drawings?.[previousId];
|
||||
if (drawing && note.drawings) {
|
||||
block.blockId = generateRandomId(12);
|
||||
drawing.drawingId = block.blockId;
|
||||
note.drawings[block.blockId] = drawing;
|
||||
delete note.drawings[previousId];
|
||||
note.drawingsOrder = note.drawingsOrder?.map((id) => id === previousId ? block.blockId : id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function embedInternalClipboardFragment(html: string, fragmentJson: string): string {
|
||||
return `<!--${DOC_INTERNAL_FRAGMENT_COMMENT_PREFIX}${encodeBase64(fragmentJson)}-->${html}`;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ export class DocCanvasPopManagerService extends Disposable {
|
||||
position,
|
||||
position$,
|
||||
disposable,
|
||||
updatePosition,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,7 +274,9 @@ export class DocCanvasPopManagerService extends Disposable {
|
||||
return bound;
|
||||
};
|
||||
|
||||
return this._createRectPositionObserver(getBound, currentRender);
|
||||
const observer = this._createRectPositionObserver(getBound, currentRender);
|
||||
observer.disposable.add(targetObject.onTransformChange$.subscribeEvent(observer.updatePosition));
|
||||
return observer;
|
||||
}
|
||||
|
||||
private _createRangePositionObserver(range: ITextRangeParam, currentRender: IRender) {
|
||||
|
||||
@@ -125,6 +125,14 @@ function collectPageDrawingIds(page: IDocumentSkeletonPage | IDocumentSkeletonHe
|
||||
for (const drawingId of page.skeDrawings.keys()) {
|
||||
drawingIds.add(drawingId);
|
||||
}
|
||||
for (const note of page.notes ?? []) {
|
||||
collectPageDrawingIds(note.page, drawingIds);
|
||||
}
|
||||
for (const group of page.skeColumnGroups?.values() ?? []) {
|
||||
for (const column of group.columns) {
|
||||
collectPageDrawingIds(column.page, drawingIds);
|
||||
}
|
||||
}
|
||||
// Cell drawings belong to their cell skeleton, not the host page's map.
|
||||
for (const table of page.skeTables.values()) {
|
||||
for (const row of table.rows) {
|
||||
|
||||
+62
-4
@@ -16,17 +16,19 @@
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { IDisposable, IDocumentData } from '@univerjs/core';
|
||||
import type { IDisposable, IDocumentData, ITextRangeParam } from '@univerjs/core';
|
||||
import type { Mock } from 'vitest';
|
||||
import {
|
||||
DataStreamTreeTokenType,
|
||||
DOC_RANGE_TYPE,
|
||||
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
|
||||
DocumentDataModel,
|
||||
DocumentFlavor,
|
||||
Univer,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
|
||||
import { GlyphType, RenderUnit } from '@univerjs/engine-render';
|
||||
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
|
||||
import { GlyphType, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, RenderUnit } from '@univerjs/engine-render';
|
||||
import { ILayoutService } from '@univerjs/ui';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
@@ -193,6 +195,7 @@ function createService() {
|
||||
scene,
|
||||
mainComponent,
|
||||
unitId: 'unit-1',
|
||||
unit: new DocumentDataModel({ id: 'unit-1' }),
|
||||
},
|
||||
_container: {
|
||||
style: {},
|
||||
@@ -306,6 +309,7 @@ class TestRenderEvent<T> {
|
||||
}
|
||||
|
||||
function createRealSelectionRenderService(options: {
|
||||
documentData?: IDocumentData;
|
||||
embedInteractionBoundaryService?: Partial<EmbedInteractionBoundaryService>;
|
||||
embedRuntimeFocusCoordinator?: EmbedRuntimeFocusCoordinator;
|
||||
mainComponent?: unknown;
|
||||
@@ -323,7 +327,7 @@ function createRealSelectionRenderService(options: {
|
||||
if (options.embedRuntimeFocusCoordinator) {
|
||||
injector.add([EmbedRuntimeFocusCoordinator, { useValue: options.embedRuntimeFocusCoordinator }]);
|
||||
}
|
||||
const documentData: IDocumentData = {
|
||||
const documentData: IDocumentData = options.documentData ?? {
|
||||
id: 'selection-render-doc',
|
||||
body: {
|
||||
dataStream: 'Hello\r\n',
|
||||
@@ -895,6 +899,60 @@ describe('doc selection render service internals', () => {
|
||||
});
|
||||
|
||||
describe('DocSelectionRenderService', () => {
|
||||
it.each(['', 'note'])('uses the logical caret in segment "%s" while replacement layout is pending', (segmentId) => {
|
||||
TestLayoutService.reset();
|
||||
const univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.add([DocLayoutExecutorService]);
|
||||
injector.add([ILayoutService, { useClass: TestLayoutService as never }]);
|
||||
const doc = univer.createUnit(UniverInstanceType.UNIVER_DOC, {
|
||||
id: 'pending-input',
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: { dataStream: 'Body\r\n', paragraphs: [{ paragraphId: 'p', startIndex: 4 }] },
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: 'Note\r\n',
|
||||
paragraphs: [{ paragraphId: 'np', startIndex: 4 }],
|
||||
} } },
|
||||
});
|
||||
const render = injector.createInstance(RenderUnit, {
|
||||
engine: {} as never,
|
||||
scene: { getViewports: () => [], getEngine: () => null, enableObjectsEvent: () => {} } as never,
|
||||
isMainScene: true,
|
||||
unit: doc,
|
||||
});
|
||||
render.addRenderDependencies([[DocSkeletonManagerService], [DocSelectionRenderService]]);
|
||||
try {
|
||||
const service = render.with(DocSelectionRenderService);
|
||||
render.with(DocSkeletonManagerService).getSkeleton().beginExternalLayout({ reason: 'edit' });
|
||||
const manager = injector.get(DocSelectionManagerService);
|
||||
manager.__TEST_ONLY_setCurrentSelection({ unitId: 'pending-input', subUnitId: 'pending-input' });
|
||||
manager.replaceSelectionInfoWithoutRefresh({
|
||||
textRanges: [{ startOffset: 3, endOffset: 3, collapsed: true, isActive: true, segmentId }],
|
||||
rectRanges: [],
|
||||
segmentId,
|
||||
segmentPage: -1,
|
||||
isEditing: true,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
});
|
||||
expect(service.replaceDocRanges(manager.getSelectionInfo()!.textRanges)).toBe(false);
|
||||
expect(service.hasPendingSelection).toBe(true);
|
||||
const received: Array<ITextRangeParam | null> = [];
|
||||
const subscription = service.onInput$.subscribe((event) => received.push(event.activeRange ?? null));
|
||||
try {
|
||||
const input = document.getElementById('__editor_pending-input')!;
|
||||
input.textContent = 'X';
|
||||
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: 'X' }));
|
||||
expect(received).toEqual([expect.objectContaining({ startOffset: 3, endOffset: 3, segmentId })]);
|
||||
} finally {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
} finally {
|
||||
render.dispose();
|
||||
univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels an active pointer selection and disposes its cached ranges', () => {
|
||||
const { scene, service } = createService();
|
||||
const liveTextRange = createTextRange();
|
||||
|
||||
@@ -432,6 +432,11 @@ export class NodePositionConvertToCursor {
|
||||
if (startPageType === DocumentSkeletonPageType.HEADER || startPageType === DocumentSkeletonPageType.FOOTER) {
|
||||
return startPage === endPage;
|
||||
}
|
||||
if (startPageType === DocumentSkeletonPageType.NOTE) {
|
||||
const skeleton = this._docSkeleton.getSkeletonData();
|
||||
return skeleton != null && getPageFromPath(skeleton, startOrigin.path)?.segmentId ===
|
||||
getPageFromPath(skeleton, endOrigin.path)?.segmentId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -609,6 +614,9 @@ export class NodePositionConvertToCursor {
|
||||
segmentPage = skeHeaders.get(headerId)?.get(pageWidth);
|
||||
} else if (pageType === DocumentSkeletonPageType.FOOTER) {
|
||||
segmentPage = skeFooters.get(footerId)?.get(pageWidth);
|
||||
} else if (pageType === DocumentSkeletonPageType.NOTE) {
|
||||
const noteId = getPageFromPath(skeletonData, path)?.segmentId;
|
||||
segmentPage = page.notes?.find((note) => note.noteId === noteId)?.page;
|
||||
} else if (pageType === DocumentSkeletonPageType.CELL) {
|
||||
segmentPage = path[0] === 'pages'
|
||||
? getPageFromPath(skeletonData, path)
|
||||
@@ -627,13 +635,18 @@ export class NodePositionConvertToCursor {
|
||||
startPosition,
|
||||
endPosition,
|
||||
sections.length - 1,
|
||||
pageType === DocumentSkeletonPageType.BODY || pageType === DocumentSkeletonPageType.CELL ? p : 0
|
||||
pageType === DocumentSkeletonPageType.BODY || pageType === DocumentSkeletonPageType.CELL || pageType === DocumentSkeletonPageType.NOTE ? p : 0
|
||||
);
|
||||
this._liquid.translateSave();
|
||||
const previousHorizontalClip = this._horizontalClip;
|
||||
this._horizontalClip = null;
|
||||
|
||||
switch (pageType) {
|
||||
case DocumentSkeletonPageType.NOTE: {
|
||||
const note = page.notes?.find((fragment) => fragment.page === segmentPage);
|
||||
this._liquid.translate(note?.left ?? page.marginLeft, note?.top ?? page.marginTop);
|
||||
break;
|
||||
}
|
||||
case DocumentSkeletonPageType.HEADER:
|
||||
this._liquid.translatePagePadding({
|
||||
...segmentPage,
|
||||
@@ -674,6 +687,9 @@ export class NodePositionConvertToCursor {
|
||||
} else if (tablePage?.type === DocumentSkeletonPageType.FOOTER) {
|
||||
const footerTop = page.pageHeight - tablePage.height - tablePage.marginBottom;
|
||||
this._liquid.translate(page.marginLeft, footerTop);
|
||||
} else if (tablePage?.type === DocumentSkeletonPageType.NOTE) {
|
||||
const note = page.notes?.find((fragment) => fragment.page === tablePage);
|
||||
this._liquid.translate(note?.left ?? page.marginLeft, note?.top ?? page.marginTop);
|
||||
} else {
|
||||
this._liquid.translatePagePadding(page);
|
||||
}
|
||||
|
||||
@@ -283,25 +283,51 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
|
||||
this._pendingSelection = null;
|
||||
return false;
|
||||
}
|
||||
if (this._currentSegmentId === '' && ranges.length > 0) {
|
||||
const progress = this._docSkeletonManagerService.getSkeleton().getLayoutProgress();
|
||||
if (progress?.reason === 'edit' && !progress.anchorReady && !progress.complete && !progress.cancelled) {
|
||||
const { unitId } = this._context;
|
||||
const selection = this._docSelectionManagerService.getSelectionInfo({ unitId, subUnitId: unitId });
|
||||
// Only a refresh of the current logical selection belongs to this
|
||||
// publication. A new pointer/programmatic selection takes over.
|
||||
if (
|
||||
selection != null &&
|
||||
ranges.length === selection.textRanges.length &&
|
||||
ranges.every((range, index) => range === selection.textRanges[index])
|
||||
) {
|
||||
this._pendingSelection = selection;
|
||||
this._syncNoteSegment(ranges[0]);
|
||||
const isBodyOrNote = this._currentSegmentId === '' ||
|
||||
this._context.unit.getSnapshot().notes?.[this._currentSegmentId] != null;
|
||||
let currentLogicalSelection: Nullable<IDocSelectionInnerParam> = null;
|
||||
if (isBodyOrNote && ranges.length > 0) {
|
||||
const { unitId } = this._context;
|
||||
const selection = this._docSelectionManagerService.getSelectionInfo({ unitId, subUnitId: unitId });
|
||||
// Only a refresh of the current logical selection belongs to this
|
||||
// publication. A new pointer/programmatic selection takes over.
|
||||
if (selection != null && ranges.length === selection.textRanges.length &&
|
||||
ranges.every((range, index) => range === selection.textRanges[index])) {
|
||||
currentLogicalSelection = selection;
|
||||
const progress = this._docSkeletonManagerService.getSkeleton().getLayoutProgress();
|
||||
if (progress?.reason === 'edit' && !progress.anchorReady && !progress.complete && !progress.cancelled) {
|
||||
this._pendingSelection = currentLogicalSelection;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._pendingSelection = null;
|
||||
return this.addDocRanges(ranges, isEditing, options, true);
|
||||
const replaced = this.addDocRanges(ranges, isEditing, options, true);
|
||||
if (!replaced) {
|
||||
this._pendingSelection = currentLogicalSelection;
|
||||
}
|
||||
return replaced;
|
||||
}
|
||||
|
||||
private _syncNoteSegment(range: ISuccinctDocRangeParam | undefined): void {
|
||||
const targetSegment = range?.segmentId;
|
||||
if (targetSegment == null) {
|
||||
return;
|
||||
}
|
||||
const notes = this._context.unit.getSnapshot().notes;
|
||||
if (targetSegment !== this._currentSegmentId &&
|
||||
(notes?.[targetSegment] || notes?.[this._currentSegmentId])) {
|
||||
this.setSegment(targetSegment);
|
||||
this.setSegmentPage(range?.segmentPage ?? -1);
|
||||
}
|
||||
if (notes?.[targetSegment] && range != null) {
|
||||
const position = this._docSkeletonManagerService.getSkeleton()
|
||||
.findNodePositionByCharIndex(range.endOffset, true, targetSegment);
|
||||
if (position != null && position.page !== this._currentSegmentPage) {
|
||||
this.setSegmentPage(position.page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getPendingSelection(): Nullable<IDocSelectionInnerParam> {
|
||||
@@ -697,7 +723,9 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
|
||||
this._pendingSelection = null;
|
||||
const { segmentId, segmentPage } = startNode;
|
||||
|
||||
if (segmentId && this._currentSegmentId && segmentId !== this._currentSegmentId) {
|
||||
const notes = this._context.unit.getSnapshot().notes;
|
||||
if (segmentId !== this._currentSegmentId &&
|
||||
((segmentId && this._currentSegmentId) || notes?.[segmentId] || notes?.[this._currentSegmentId])) {
|
||||
this.setSegment(segmentId);
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ function buildInsertTextActions(
|
||||
const textActions = JSONX.getInstance().editOp(textX.serialize(), getRichTextEditPath(documentDataModel, segmentId));
|
||||
return collapsed
|
||||
? textActions
|
||||
: appendRemovedDrawingActions(textActions, documentDataModel, originBody, startOffset, endOffset);
|
||||
: appendRemovedDrawingActions(textActions, documentDataModel, originBody, startOffset, endOffset, segmentId);
|
||||
}
|
||||
|
||||
function appendRemovedDrawingActions(
|
||||
@@ -142,13 +142,14 @@ function appendRemovedDrawingActions(
|
||||
documentDataModel: DocumentDataModel,
|
||||
body: IDocumentBody,
|
||||
startOffset: number,
|
||||
endOffset: number
|
||||
endOffset: number,
|
||||
segmentId?: string
|
||||
): IRichTextEditingMutationParams['actions'] {
|
||||
const drawingActions = BuildTextUtils.drawing.remove(documentDataModel.getSnapshot(), [{
|
||||
startOffset,
|
||||
endOffset,
|
||||
collapsed: false,
|
||||
}], body);
|
||||
}], body, segmentId);
|
||||
const rawActions = [textActions, ...drawingActions];
|
||||
|
||||
return rawActions.reduce((accumulator, action) => JSONX.compose(accumulator, action));
|
||||
@@ -221,7 +222,8 @@ export const DeleteTextCommand: ICommand<IDeleteTextCommandParams> = {
|
||||
docDataModel,
|
||||
body,
|
||||
start,
|
||||
end + 1
|
||||
end + 1,
|
||||
segmentId
|
||||
);
|
||||
|
||||
const result = commandService.syncExecuteCommand<
|
||||
|
||||
@@ -14,10 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ICommandService, JSONX } from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from '../core-editing.mutation';
|
||||
import { CustomRangeType, DocumentFlavor, ICommandService, JSONX, TextX } from '@univerjs/core';
|
||||
import { NORMAL_TEXT_SELECTION_PLUGIN_STYLE } from '@univerjs/engine-render';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createTestBed } from '../../../facade/__tests__/create-test-bed';
|
||||
import { createDocumentData, createTestBed } from '../../../facade/__tests__/create-test-bed';
|
||||
import { DocSelectionManagerService } from '../../../services/doc-selection-manager.service';
|
||||
import { InsertTextCommand } from '../../commands/core-editing.command';
|
||||
import { RichTextEditingMutation, transformDocumentTextRanges } from '../core-editing.mutation';
|
||||
|
||||
describe('transformDocumentTextRanges', () => {
|
||||
@@ -76,6 +79,132 @@ describe('transformDocumentTextRanges', () => {
|
||||
});
|
||||
|
||||
describe('RichTextEditingMutation selection scheduling', () => {
|
||||
it('rejects a remote footnote whose segment identity belongs to a header', () => {
|
||||
const snapshot = createDocumentData('note-collision', {
|
||||
dataStream: 'Body\r\n',
|
||||
paragraphs: [{ paragraphId: 'p', startIndex: 4 }],
|
||||
sectionBreaks: [{ sectionId: 's', startIndex: 5 }],
|
||||
});
|
||||
snapshot.headers = { shared: { headerId: 'shared', body: {
|
||||
dataStream: 'Header\r\n',
|
||||
paragraphs: [{ paragraphId: 'hp', startIndex: 6 }],
|
||||
sectionBreaks: [{ sectionId: 'hs', startIndex: 7 }],
|
||||
} } };
|
||||
const bed = createTestBed(snapshot);
|
||||
try {
|
||||
const before = structuredClone(bed.doc.getSnapshot());
|
||||
expect(() => bed.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: snapshot.id,
|
||||
segmentId: '',
|
||||
textRanges: null,
|
||||
actions: JSONX.getInstance().insertOp(['notes', 'shared'], { type: 'footnote' as const, noteId: 'shared', body: { dataStream: '\r\n', paragraphs: [{ paragraphId: 'np', startIndex: 0 }], sectionBreaks: [{ sectionId: 'ns', startIndex: 1 }] } }),
|
||||
}, { fromCollab: true })).toThrow('invalid-note-id');
|
||||
expect(bed.doc.getSnapshot()).toEqual(before);
|
||||
expect(bed.doc.getSelfOrHeaderFooterModel('shared')?.getBody()?.dataStream).toBe('Header\r\n');
|
||||
} finally {
|
||||
bed.univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps consecutive text, spaces and paragraphs in the active footnote', async () => {
|
||||
const snapshot = createDocumentData('note-input', {
|
||||
dataStream: 'A\uFFFCB\r\n',
|
||||
paragraphs: [{ paragraphId: 'p', startIndex: 3 }],
|
||||
sectionBreaks: [{ sectionId: 's', startIndex: 4 }],
|
||||
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: 1, endIndex: 1, wholeEntity: true, properties: { noteId: 'note' } }],
|
||||
});
|
||||
snapshot.documentStyle = { ...snapshot.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
snapshot.notes = { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: '\r\n',
|
||||
paragraphs: [{ paragraphId: 'np', startIndex: 0 }],
|
||||
sectionBreaks: [{ sectionId: 'ns', startIndex: 1 }],
|
||||
} } };
|
||||
const bed = createTestBed(snapshot);
|
||||
const selectionManager = bed.get(DocSelectionManagerService);
|
||||
const target = { unitId: 'note-input', subUnitId: 'note-input' };
|
||||
selectionManager.__TEST_ONLY_setCurrentSelection(target);
|
||||
selectionManager.replaceSelectionInfoWithoutRefresh({
|
||||
textRanges: [{ startOffset: 0, endOffset: 0, collapsed: true, isActive: true, segmentId: 'note' }],
|
||||
rectRanges: [],
|
||||
segmentId: 'note',
|
||||
segmentPage: 0,
|
||||
isEditing: true,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
}, target);
|
||||
try {
|
||||
for (const text of ['First', ' ', 'line', '\r', 'Next', ' ', 'line']) {
|
||||
const range = selectionManager.getActiveTextRange()!;
|
||||
expect(range.segmentId).toBe('note');
|
||||
bed.get(ICommandService).syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: target.unitId,
|
||||
range,
|
||||
body: text === '\r'
|
||||
? { dataStream: text, paragraphs: [{ paragraphId: 'next-p', startIndex: 0 }] }
|
||||
: { dataStream: text },
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(selectionManager.getSelectionInfo()?.segmentId).toBe('note');
|
||||
}
|
||||
expect(bed.doc.getSnapshot().notes?.note.body.dataStream).toBe('First line\rNext line\r\n');
|
||||
expect(bed.doc.getBody()?.dataStream).toBe('A\uFFFCB\r\n');
|
||||
} finally {
|
||||
bed.univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([1, 2])('publishes deletion at offset %i and its collaborative undo atomically', (offset) => {
|
||||
const snapshot = createDocumentData('note-delete', {
|
||||
dataStream: 'A\uFFFCB\r\n',
|
||||
customDecorations: [],
|
||||
paragraphs: [{ paragraphId: 'p', startIndex: 3 }],
|
||||
sectionBreaks: [{ sectionId: 's', startIndex: 4 }],
|
||||
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: 1, endIndex: 1, wholeEntity: true, properties: { noteId: 'note' } }],
|
||||
});
|
||||
snapshot.documentStyle = { ...snapshot.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
snapshot.notes = { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: 'Explanation\r\n',
|
||||
paragraphs: [{ paragraphId: 'np', startIndex: 11 }],
|
||||
sectionBreaks: [{ sectionId: 'ns', startIndex: 12 }],
|
||||
} } };
|
||||
const bed = createTestBed(structuredClone(snapshot));
|
||||
const peer = createTestBed(structuredClone(snapshot));
|
||||
const params: IRichTextEditingMutationParams = {
|
||||
unitId: 'note-delete',
|
||||
segmentId: '',
|
||||
textRanges: null,
|
||||
actions: JSONX.getInstance().editOp(new TextX().retain(offset).delete(1).serialize(), ['body']),
|
||||
};
|
||||
try {
|
||||
const before = structuredClone(bed.doc.getSnapshot());
|
||||
const expectedText = offset === 1 ? 'AB\r\n' : 'A\uFFFC\r\n';
|
||||
const noteExists = offset !== 1;
|
||||
const observed: Array<{ text?: string; noteExists: boolean }> = [];
|
||||
const subscription = bed.doc.change$.subscribe(() => observed.push({
|
||||
text: bed.doc.getBody()?.dataStream,
|
||||
noteExists: bed.doc.getSnapshot().notes?.note != null,
|
||||
}));
|
||||
observed.length = 0;
|
||||
const apply = vi.spyOn(JSONX, 'apply');
|
||||
const undo = bed.get(ICommandService).syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(RichTextEditingMutation.id, params);
|
||||
expect(apply).toHaveBeenCalledTimes(offset === 1 ? 2 : 1);
|
||||
apply.mockRestore();
|
||||
expect(bed.doc.getBody()?.dataStream).toBe(expectedText);
|
||||
expect(bed.doc.getSnapshot().notes?.note != null).toBe(noteExists);
|
||||
expect(observed).toEqual([{ text: expectedText, noteExists }]);
|
||||
peer.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, params, { fromCollab: true });
|
||||
expect(peer.doc.getSnapshot()).toEqual(bed.doc.getSnapshot());
|
||||
bed.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, undo);
|
||||
peer.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, undo, { fromCollab: true });
|
||||
expect(bed.doc.getSnapshot()).toEqual(before);
|
||||
expect(peer.doc.getSnapshot()).toEqual(before);
|
||||
expect(observed).toEqual([{ text: expectedText, noteExists }, { text: 'A\uFFFCB\r\n', noteExists: true }]);
|
||||
subscription.unsubscribe();
|
||||
} finally {
|
||||
bed.univer.dispose();
|
||||
peer.univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not refresh selection for metadata changes without ranges', async () => {
|
||||
const testBed = createTestBed();
|
||||
const selectionManager = testBed.get(DocSelectionManagerService);
|
||||
|
||||
@@ -16,21 +16,27 @@
|
||||
|
||||
import type {
|
||||
DocumentDataModel,
|
||||
ICustomRange,
|
||||
IDocumentData,
|
||||
IExecutionOptions,
|
||||
IMutation,
|
||||
IMutationCommonParams,
|
||||
JSONXActions,
|
||||
Nullable,
|
||||
TextXAction,
|
||||
TPriority,
|
||||
} from '@univerjs/core';
|
||||
import type { DocumentViewModel, ITextRangeWithStyle } from '@univerjs/engine-render';
|
||||
import type { IDocStateChangeInfo } from '../../services/doc-state-emit.service';
|
||||
import {
|
||||
CommandType,
|
||||
|
||||
CustomRangeType,
|
||||
IUniverInstanceService,
|
||||
JSON1,
|
||||
JSONX,
|
||||
|
||||
TextX,
|
||||
TextXActionType,
|
||||
Tools,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { IRenderManagerService } from '@univerjs/engine-render';
|
||||
@@ -124,17 +130,78 @@ export function transformDocumentTextRanges(
|
||||
});
|
||||
}
|
||||
|
||||
function canRemoveFootnoteReference(actions: JSONXActions, references: ICustomRange[]): boolean {
|
||||
let canRemove = false;
|
||||
const cursor = JSON1.type.readCursor(actions);
|
||||
cursor.traverse(null, (component) => {
|
||||
const path = cursor.getPath();
|
||||
if (path.length === 0) {
|
||||
canRemove = true;
|
||||
}
|
||||
if (canRemove || path[0] !== 'body') {
|
||||
return;
|
||||
}
|
||||
if (path.length === 1 && component.et === TextX.id && Array.isArray(component.e)) {
|
||||
let offset = 0;
|
||||
for (const action of component.e as TextXAction[]) {
|
||||
if (action.t === TextXActionType.DELETE && references.some((reference) =>
|
||||
offset <= reference.endIndex && offset + action.len > reference.startIndex
|
||||
)) {
|
||||
canRemove = true;
|
||||
return;
|
||||
}
|
||||
if (action.t === TextXActionType.RETAIN && action.body?.customRanges != null) {
|
||||
canRemove = true;
|
||||
return;
|
||||
}
|
||||
if (action.t !== TextXActionType.INSERT) {
|
||||
offset += action.len;
|
||||
}
|
||||
}
|
||||
} else if (path.length === 1 || path[1] === 'customRanges' || path[1] === 'dataStream') {
|
||||
canRemove = true;
|
||||
}
|
||||
});
|
||||
return canRemove;
|
||||
}
|
||||
|
||||
function includeFootnoteCleanup(before: IDocumentData, actions: JSONXActions): JSONXActions {
|
||||
if (!before.notes) {
|
||||
return actions;
|
||||
}
|
||||
const references = before.body?.customRanges?.filter((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE)) ?? [];
|
||||
if (references.length === 0 || !canRemoveFootnoteReference(actions, references)) {
|
||||
return actions;
|
||||
}
|
||||
// TextX edits mutate their body. Isolate the cleanup preview, and only run it
|
||||
// for edits that can remove a reference; ordinary typing keeps the fast path.
|
||||
const after = JSONX.apply(Tools.deepClone(before), actions) as unknown as IDocumentData;
|
||||
const remaining = new Set(after.body?.customRanges?.filter((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE))
|
||||
.map((range) => range.properties?.noteId));
|
||||
let cleanup: JSONXActions = null;
|
||||
const previousIds = new Set(references.map((reference) => reference.properties?.noteId));
|
||||
for (const id of previousIds) {
|
||||
if (typeof id === 'string' && !remaining.has(id) && after.notes?.[id]) {
|
||||
cleanup = JSONX.compose(cleanup, JSONX.getInstance().removeOp(['notes', id], after.notes[id]));
|
||||
}
|
||||
}
|
||||
return JSONX.isNoop(cleanup) ? actions : JSONX.compose(actions, cleanup);
|
||||
}
|
||||
|
||||
function applyValidatedDocumentActions(
|
||||
documentDataModel: DocumentDataModel,
|
||||
segmentId: string,
|
||||
actions: JSONXActions
|
||||
): { undoActions: JSONXActions; preservesStructure: boolean } {
|
||||
const undoActions = JSONX.invertWithDoc(actions, documentDataModel.getSnapshot());
|
||||
documentDataModel.apply(actions);
|
||||
): { actions: JSONXActions; undoActions: JSONXActions; preservesStructure: boolean } {
|
||||
const before = documentDataModel.getSnapshot();
|
||||
const appliedActions = includeFootnoteCleanup(before, actions);
|
||||
const undoActions = JSONX.invertWithDoc(appliedActions, before);
|
||||
documentDataModel.apply(appliedActions);
|
||||
try {
|
||||
return {
|
||||
actions: appliedActions,
|
||||
undoActions,
|
||||
preservesStructure: validateDocStructureMutation(documentDataModel, segmentId, actions, undoActions),
|
||||
preservesStructure: validateDocStructureMutation(documentDataModel, segmentId, appliedActions, undoActions),
|
||||
};
|
||||
} catch (error) {
|
||||
documentDataModel.apply(undoActions);
|
||||
@@ -172,17 +239,18 @@ function scheduleDocumentSelectionUpdate(
|
||||
}
|
||||
const selectionTarget = { unitId, subUnitId: unitId };
|
||||
const currentSelection = selectionManager.getSelectionInfo(selectionTarget);
|
||||
const logicalTextRanges = textRanges.map((textRange, index) => ({
|
||||
...textRange,
|
||||
segmentId: textRange.segmentId ?? params.segmentId ?? '',
|
||||
collapsed: textRange.startOffset === textRange.endOffset,
|
||||
isActive: index === textRanges.length - 1,
|
||||
}));
|
||||
if (currentSelection != null) {
|
||||
const logicalTextRanges = textRanges.map((textRange, index) => ({
|
||||
...textRange,
|
||||
collapsed: textRange.startOffset === textRange.endOffset,
|
||||
isActive: index === textRanges.length - 1,
|
||||
}));
|
||||
|
||||
// Advance logical intent with the mutation. Only its visual refresh is
|
||||
// deferred; a later input or pointer selection must supersede this one.
|
||||
selectionManager.replaceSelectionInfoWithoutRefresh({
|
||||
...currentSelection,
|
||||
segmentId: logicalTextRanges[logicalTextRanges.length - 1].segmentId,
|
||||
textRanges: logicalTextRanges,
|
||||
rectRanges: [],
|
||||
isEditing,
|
||||
@@ -195,7 +263,7 @@ function scheduleDocumentSelectionUpdate(
|
||||
return;
|
||||
}
|
||||
if (updatedSelection == null) {
|
||||
selectionManager.replaceDocRanges(textRanges, selectionTarget, isEditing, params.options);
|
||||
selectionManager.replaceDocRanges(logicalTextRanges, selectionTarget, isEditing, params.options);
|
||||
} else {
|
||||
selectionManager.refreshSelection(selectionTarget, isEditing);
|
||||
}
|
||||
@@ -255,13 +323,15 @@ export const RichTextEditingMutation: IMutation<IRichTextEditingMutationParams,
|
||||
};
|
||||
}
|
||||
|
||||
const { undoActions, preservesStructure } = applyValidatedDocumentActions(
|
||||
const { actions: appliedActions, undoActions, preservesStructure } = applyValidatedDocumentActions(
|
||||
documentDataModel,
|
||||
segmentId,
|
||||
actions
|
||||
);
|
||||
|
||||
resetDocumentViewModel(documentViewModel, documentDataModel, segmentId, actions, preservesStructure);
|
||||
// Publish reference deletion and note cleanup in the same deterministic mutation.
|
||||
params.actions = appliedActions;
|
||||
resetDocumentViewModel(documentViewModel, documentDataModel, segmentId, appliedActions, preservesStructure);
|
||||
scheduleDocumentSelectionUpdate(docSelectionManagerService, params, isSync);
|
||||
|
||||
// Step 4: Emit state change event.
|
||||
@@ -273,7 +343,7 @@ export const RichTextEditingMutation: IMutation<IRichTextEditingMutationParams,
|
||||
noHistory,
|
||||
debounce,
|
||||
redoState: {
|
||||
actions,
|
||||
actions: appliedActions,
|
||||
textRanges,
|
||||
options: params.options,
|
||||
isEditing,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import type { DocumentDataModel, IDocumentBody, JSONXActions, JSONXPath } from '@univerjs/core';
|
||||
import {
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
|
||||
getRichTextEditPath,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
TextX,
|
||||
TextXActionType,
|
||||
validateDocBodyStructure,
|
||||
validateDocumentStructure,
|
||||
} from '@univerjs/core';
|
||||
|
||||
const STRUCTURAL_BODY_FIELDS = [
|
||||
@@ -155,7 +157,7 @@ function isStructurePreservingJSONXEdit(actions: JSONXActions, expectedPath: JSO
|
||||
return hasComponent && isStructurePreserving;
|
||||
}
|
||||
|
||||
function getSegmentType(documentDataModel: DocumentDataModel, segmentId: string): 'body' | 'header' | 'footer' {
|
||||
function getSegmentType(documentDataModel: DocumentDataModel, segmentId: string): 'body' | 'header' | 'footer' | 'note' {
|
||||
if (!segmentId) {
|
||||
return 'body';
|
||||
}
|
||||
@@ -169,6 +171,10 @@ function getSegmentType(documentDataModel: DocumentDataModel, segmentId: string)
|
||||
return 'footer';
|
||||
}
|
||||
|
||||
if (documentDataModel.getSnapshot().notes?.[segmentId]) {
|
||||
return 'note';
|
||||
}
|
||||
|
||||
return 'body';
|
||||
}
|
||||
|
||||
@@ -180,7 +186,10 @@ function assertValidDocBodyStructure(documentDataModel: DocumentDataModel, segme
|
||||
}
|
||||
|
||||
const segmentType = getSegmentType(documentDataModel, segmentId);
|
||||
const issues = validateDocBodyStructure(body, { segmentType, segmentId: segmentId || undefined });
|
||||
const note = documentDataModel.getSnapshot().notes?.[segmentId];
|
||||
const issues = note
|
||||
? validateDocumentStructure({ notes: { [segmentId]: note } })
|
||||
: validateDocBodyStructure(body, { segmentType, segmentId: segmentId || undefined });
|
||||
if (!issues.length) {
|
||||
return;
|
||||
}
|
||||
@@ -201,8 +210,29 @@ export function validateDocStructureMutation(
|
||||
isStructurePreservingJSONXEdit(actions, editPath) &&
|
||||
isStructurePreservingJSONXEdit(undoActions, editPath);
|
||||
|
||||
if (!preservesStructure) {
|
||||
let changesFootnoteStructure = false;
|
||||
const cursor = JSON1.type.readCursor(actions);
|
||||
cursor.traverse(null, (component) => {
|
||||
const path = cursor.getPath();
|
||||
if ((path[0] === 'notes' && (path.length <= 2 || path[2] !== 'body')) || path.includes('customRanges')) {
|
||||
changesFootnoteStructure = true;
|
||||
}
|
||||
if (component.et === TextX.id && Array.isArray(component.e)) {
|
||||
for (const action of component.e) {
|
||||
const ranges: unknown = isRecord(action) && isRecord(action.body) ? action.body.customRanges : undefined;
|
||||
if (Array.isArray(ranges) && ranges.some((range) => isRecord(range) && (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE))) {
|
||||
changesFootnoteStructure = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (changesFootnoteStructure) {
|
||||
const issues = validateDocumentStructure(documentDataModel.getSnapshot());
|
||||
if (issues.length > 0) {
|
||||
throw new Error(`[DocStructure] ${issues.map((issue) => `${issue.code}@${issue.index ?? issue.segmentId ?? ''}`).join(', ')}`);
|
||||
}
|
||||
} else if (!preservesStructure) {
|
||||
assertValidDocBodyStructure(documentDataModel, segmentId);
|
||||
}
|
||||
return preservesStructure;
|
||||
return preservesStructure && !changesFootnoteStructure;
|
||||
}
|
||||
|
||||
@@ -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 { JSONX } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collectDocumentFontFamilies } from '../document-font-metrics';
|
||||
|
||||
describe('document font metrics', () => {
|
||||
it('collects inherited, header and footnote families and font changes without inspecting text content', () => {
|
||||
const families = new Set<string>();
|
||||
collectDocumentFontFamilies({
|
||||
documentStyle: { textStyle: { ff: 'Body Font' } },
|
||||
headers: { first: { body: { textRuns: [{ ts: { ff: 'Header Font' } }] } } },
|
||||
notes: { first: { body: { textRuns: [{ ts: { ff: 'Note Font' } }] } } },
|
||||
body: { dataStream: '{"ff":"Not a font"}', textRuns: [{ ts: { ff: 'Body Font' } }] },
|
||||
}, families);
|
||||
collectDocumentFontFamilies(JSONX.getInstance().replaceOp(
|
||||
['documentStyle', 'textStyle', 'ff'],
|
||||
'Body Font',
|
||||
'Replacement Font'
|
||||
), families);
|
||||
collectDocumentFontFamilies([{ actions: ['body', {
|
||||
et: 'textX',
|
||||
e: [{ t: 'retain', len: 1, body: { textRuns: [{ ts: { ff: 'Pasted Font' } }] } }],
|
||||
}] }], families);
|
||||
expect([...families].sort()).toEqual([
|
||||
'Body Font',
|
||||
'Header Font',
|
||||
'Note Font',
|
||||
'Pasted Font',
|
||||
'Replacement Font',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -18,11 +18,13 @@ import {
|
||||
createDocumentModelWithStyle,
|
||||
CustomRangeType,
|
||||
DocumentFlavor,
|
||||
JSONX,
|
||||
LocaleType,
|
||||
ObjectRelativeFromH,
|
||||
ObjectRelativeFromV,
|
||||
PositionedObjectLayoutType,
|
||||
} from '@univerjs/core';
|
||||
import { getFontStyleString, invalidateDocumentFontMetrics } from '@univerjs/engine-render';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocLayoutSessionStatus } from '../../services/doc-layout-executor.service';
|
||||
import { DocsLayoutWorkerPerformanceTracker } from '../performance-tracker';
|
||||
@@ -50,9 +52,74 @@ function stubOffscreenCanvas(): void {
|
||||
|
||||
describe('DocsLayoutWorkerRuntime', () => {
|
||||
afterEach(() => {
|
||||
invalidateDocumentFontMetrics(() => true);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses browser normal font spacing for initial layout and a newly introduced font during editing', async () => {
|
||||
stubOffscreenCanvas();
|
||||
vi.stubGlobal('document', undefined);
|
||||
const source = createDocumentModelWithStyle('Normal spacing', { ff: 'Initial Font', fs: 12 });
|
||||
source.updateDocumentStyle({ documentFlavor: DocumentFlavor.TRADITIONAL });
|
||||
source.updateDocumentDataPageSize(300, 300);
|
||||
source.updateDocumentDataMargin({ t: 0, r: 0, b: 0, l: 0 });
|
||||
const snapshot = structuredClone(source.getSnapshot());
|
||||
const runtime = new DocsLayoutWorkerRuntime();
|
||||
const identity = { unitId: source.getUnitId(), mountId: 'font-mount', mountEpoch: 1, viewportEpoch: 1 };
|
||||
const fontKey = (ff: string) => getFontStyleString({ ff, fs: 1000 }).fontString.trim();
|
||||
invalidateDocumentFontMetrics(() => true);
|
||||
await runtime.createSession({
|
||||
unitId: identity.unitId,
|
||||
sessionEpoch: 1,
|
||||
snapshot,
|
||||
modelRevision: 0,
|
||||
locale: LocaleType.EN_US,
|
||||
direction: 'ltr',
|
||||
normalFontLineHeights: { [fontKey('Initial Font')]: 1.5 },
|
||||
});
|
||||
try {
|
||||
for (const revision of [0, 1]) {
|
||||
const start = await runtime.startLayout({
|
||||
...identity,
|
||||
metricsRevision: revision + 1,
|
||||
baseRevision: 0,
|
||||
modelRevision: revision,
|
||||
mutations: revision === 0
|
||||
? []
|
||||
: [{
|
||||
baseRevision: 0,
|
||||
modelRevision: 1,
|
||||
actions: JSONX.getInstance().replaceOp(
|
||||
['body', 'textRuns', 0, 'ts', 'ff'],
|
||||
'Initial Font',
|
||||
'Edited Font'
|
||||
),
|
||||
}],
|
||||
normalFontLineHeights: revision === 0 ? undefined : { [fontKey('Edited Font')]: 1.75 },
|
||||
reason: revision === 0 ? 'initial' : 'edit',
|
||||
budgetMs: 8,
|
||||
});
|
||||
if (start.status !== DocLayoutSessionStatus.ACCEPTED) {
|
||||
throw new Error('Expected layout to accept the font measurement update.');
|
||||
}
|
||||
let step = start.step;
|
||||
await expect.poll(async () => {
|
||||
if (!step.progress.complete) {
|
||||
step = await runtime.stepLayout({ ...identity, generation: step.progress.generation, budgetMs: 8 });
|
||||
}
|
||||
return step.progress.complete;
|
||||
}).toBe(true);
|
||||
const result = await runtime.getLayoutPage({ ...identity, pageIndex: 0 });
|
||||
const line = result.page?.page.sections[0].columns[0].lines[0];
|
||||
expect(line?.lineHeight).toBeCloseTo(revision === 0 ? 18 : 21);
|
||||
expect(line?.divides.flatMap((divide) => divide.glyphGroup).map((glyph) => glyph.raw).join(''))
|
||||
.toBe(snapshot.body!.dataStream);
|
||||
}
|
||||
} finally {
|
||||
await runtime.disposeSession({ unitId: identity.unitId, sessionEpoch: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it('applies custom range presentations to the matching body, header, and footer segments', async () => {
|
||||
stubOffscreenCanvas();
|
||||
const sourceModel = createDocumentModelWithStyle('Body\r', {});
|
||||
@@ -213,7 +280,7 @@ describe('DocsLayoutWorkerRuntime', () => {
|
||||
});
|
||||
|
||||
await expect(runtime.getCapabilities()).resolves.toMatchObject({
|
||||
protocolVersion: 4,
|
||||
protocolVersion: 5,
|
||||
executor: 'worker',
|
||||
offscreenCanvas: true,
|
||||
structuredClone: true,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 { BooleanNumber } from '@univerjs/core';
|
||||
import { FontCache, getFontStyleString } from '@univerjs/engine-render';
|
||||
|
||||
/** Collect once from the snapshot, then only from mutation payloads (including JSON1 font replacements). */
|
||||
export function collectDocumentFontFamilies(value: unknown, families: Set<string>): void {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const item = value[index];
|
||||
if (value[index - 1] === 'ff' && item != null && typeof item === 'object' && typeof item.i === 'string') {
|
||||
families.add(item.i);
|
||||
}
|
||||
collectDocumentFontFamilies(item, families);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (key === 'ff' && typeof item === 'string') {
|
||||
families.add(item);
|
||||
} else {
|
||||
collectDocumentFontFamilies(item, families);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function measureDocumentFontFamilies(families: Set<string>): Record<string, number> {
|
||||
FontCache.getNormalLineHeight(getFontStyleString());
|
||||
// Bold/italic may be inherited separately from the family in paragraph or run styles.
|
||||
for (const ff of ['', ...families]) {
|
||||
for (const bl of [BooleanNumber.FALSE, BooleanNumber.TRUE]) {
|
||||
for (const it of [BooleanNumber.FALSE, BooleanNumber.TRUE]) {
|
||||
FontCache.getNormalLineHeight(getFontStyleString({ ff, bl, it }));
|
||||
}
|
||||
}
|
||||
}
|
||||
return FontCache.getNormalLineHeightCache();
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import pkg from '../../package.json';
|
||||
import { UniverDocsPlugin } from '../plugin';
|
||||
import { DocLayoutExecutorService, DocLayoutExecutorType } from '../services/doc-layout-executor.service';
|
||||
import { DEFAULT_DOCS_LAYOUT_WORKER_REQUEST_TIMEOUT_MS, defaultPluginDocsLayoutWorkerConfig, DOCS_LAYOUT_WORKER_PLUGIN_CONFIG_KEY } from './config/config';
|
||||
import { collectDocumentFontFamilies, measureDocumentFontFamilies } from './document-font-metrics';
|
||||
import { DocsLayoutWorkerPerformanceTracker } from './performance-tracker';
|
||||
import { DOCS_LAYOUT_WORKER_CHANNEL, DOCS_LAYOUT_WORKER_PROTOCOL_VERSION } from './protocol';
|
||||
import { startDocsLayoutWorker } from './worker';
|
||||
@@ -65,6 +66,7 @@ export class DocsLayoutWorkerClientService extends Disposable implements IDocLay
|
||||
private _initialization: Promise<void>;
|
||||
private _initialized = false;
|
||||
private readonly _performanceTracker = new DocsLayoutWorkerPerformanceTracker();
|
||||
private readonly _fontFamilies = new Map<string, Set<string>>();
|
||||
|
||||
constructor(
|
||||
private readonly _workerFactory: () => Worker,
|
||||
@@ -88,18 +90,29 @@ export class DocsLayoutWorkerClientService extends Disposable implements IDocLay
|
||||
}
|
||||
|
||||
createSession(request: IDocLayoutCreateSessionRequest): Promise<void> {
|
||||
const families = new Set<string>();
|
||||
collectDocumentFontFamilies(request.snapshot, families);
|
||||
this._fontFamilies.set(request.unitId, families);
|
||||
const measuredRequest = { ...request, normalFontLineHeights: measureDocumentFontFamilies(families) };
|
||||
if (this._initialized) {
|
||||
return this._withTimeout(this._getRuntime().createSession(request), 'create session');
|
||||
return this._withTimeout(this._getRuntime().createSession(measuredRequest), 'create session');
|
||||
}
|
||||
|
||||
const capturedRequest = Tools.deepClone(request);
|
||||
const capturedRequest = Tools.deepClone(measuredRequest);
|
||||
return this._initialization.then(() =>
|
||||
this._withTimeout(this._getRuntime().createSession(capturedRequest), 'create session'));
|
||||
}
|
||||
|
||||
async startLayout(request: IDocLayoutStartRequest): Promise<IDocLayoutStartResult> {
|
||||
await this._initialization;
|
||||
return this._withTimeout(this._getRuntime().startLayout(request), 'start layout');
|
||||
const families = this._fontFamilies.get(request.unitId);
|
||||
if (families != null) {
|
||||
collectDocumentFontFamilies(request.mutations, families);
|
||||
}
|
||||
const measuredRequest = families == null
|
||||
? request
|
||||
: { ...request, normalFontLineHeights: measureDocumentFontFamilies(families) };
|
||||
return this._withTimeout(this._getRuntime().startLayout(measuredRequest), 'start layout');
|
||||
}
|
||||
|
||||
async stepLayout(request: IDocLayoutStepRequest): Promise<IDocLayoutStepResult> {
|
||||
@@ -156,6 +169,7 @@ export class DocsLayoutWorkerClientService extends Disposable implements IDocLay
|
||||
await this._initialization;
|
||||
await this._withTimeout(this._getRuntime().disposeSession(request), 'dispose session');
|
||||
this._performanceTracker.reset(request.unitId);
|
||||
this._fontFamilies.delete(request.unitId);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
@@ -178,6 +192,7 @@ export class DocsLayoutWorkerClientService extends Disposable implements IDocLay
|
||||
}
|
||||
|
||||
private _disposeRuntime(): void {
|
||||
this._fontFamilies.clear();
|
||||
this._initialized = false;
|
||||
this._channelService?.dispose();
|
||||
this._channelService = null;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import type { DocLayoutExecutorType, IDocLayoutExecutor } from '../services/doc-layout-executor.service';
|
||||
|
||||
export const DOCS_LAYOUT_WORKER_CHANNEL = 'univer.docs-layout-worker';
|
||||
export const DOCS_LAYOUT_WORKER_PROTOCOL_VERSION = 4;
|
||||
export const DOCS_LAYOUT_WORKER_PROTOCOL_VERSION = 5;
|
||||
|
||||
export interface IDocsLayoutWorkerFontProbe {
|
||||
content: string;
|
||||
|
||||
@@ -142,6 +142,7 @@ export class DocsLayoutWorkerRuntime implements IDocsLayoutWorkerRuntime {
|
||||
}
|
||||
this._disposeSession(request.unitId);
|
||||
|
||||
FontCache.setNormalLineHeightCache(request.normalFontLineHeights ?? {});
|
||||
const dataModel = new DocumentDataModel(request.snapshot);
|
||||
const localeService = new LocaleService();
|
||||
if (request.localeData != null) {
|
||||
@@ -179,6 +180,8 @@ export class DocsLayoutWorkerRuntime implements IDocsLayoutWorkerRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
FontCache.setNormalLineHeightCache(request.normalFontLineHeights ?? {});
|
||||
|
||||
this._updateCustomRangePresentations(session, request);
|
||||
this._applyCustomRangePresentations(session.dataModel, Array.from(session.customRangePresentations.values()));
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface IDocLayoutPerformanceMetrics {
|
||||
}
|
||||
|
||||
export interface IDocLayoutCreateSessionRequest {
|
||||
normalFontLineHeights?: Record<string, number>;
|
||||
unitId: string;
|
||||
sessionEpoch: number;
|
||||
snapshot: IDocumentData;
|
||||
@@ -118,6 +119,7 @@ export interface IDocLayoutMountIdentity {
|
||||
}
|
||||
|
||||
export interface IDocLayoutStartRequest extends IDocLayoutMountIdentity {
|
||||
normalFontLineHeights?: Record<string, number>;
|
||||
metricsRevision: number;
|
||||
baseRevision: number;
|
||||
modelRevision: number;
|
||||
|
||||
+45
-2
@@ -14,12 +14,55 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { DataStreamTreeTokenType, DocumentDataModel } from '@univerjs/core';
|
||||
import { CustomRangeType, DataStreamTreeTokenType, DocumentDataModel, DrawingTypeEnum, ObjectRelativeFromH, ObjectRelativeFromV, PositionedObjectLayoutType } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getDocumentParagraphPermissionObjectId } from '../document-permission';
|
||||
import { getDocumentEntityPermissionObjectId, getDocumentParagraphPermissionObjectId, getDocumentSectionPermissionObjectId } from '../document-permission';
|
||||
import { getDocumentEditTargetObjectIds } from '../document-permission-resolver';
|
||||
|
||||
describe('document permission resolver', () => {
|
||||
it('uses note-owned drawing permissions while retaining the body reference restrictions', () => {
|
||||
const document = new DocumentDataModel({
|
||||
id: 'note-permission',
|
||||
body: {
|
||||
dataStream: 'A\uFFFC\r\n',
|
||||
paragraphs: [{ startIndex: 2, paragraphId: 'body-paragraph' }],
|
||||
sectionBreaks: [{ startIndex: 3, sectionId: 'body-section' }],
|
||||
customRanges: [{ startIndex: 1, endIndex: 1, rangeId: 'note-ref', rangeType: CustomRangeType.FOOTNOTE, properties: { noteId: 'note' } }],
|
||||
},
|
||||
notes: {
|
||||
note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: '\b\r\n',
|
||||
customBlocks: [{ startIndex: 0, blockId: 'image' }],
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'note-paragraph' }],
|
||||
sectionBreaks: [{ startIndex: 2, sectionId: 'note-section' }],
|
||||
}, drawings: {
|
||||
image: {
|
||||
unitId: 'note-permission',
|
||||
subUnitId: 'note-permission',
|
||||
drawingId: 'image',
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
layoutType: PositionedObjectLayoutType.INLINE,
|
||||
docTransform: {
|
||||
size: { width: 100, height: 40 },
|
||||
angle: 0,
|
||||
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 0 },
|
||||
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 0 },
|
||||
},
|
||||
},
|
||||
} },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const targets = getDocumentEditTargetObjectIds(document, 'note', { startOffset: 0, endOffset: 1 });
|
||||
expect(targets).toContain(getDocumentEntityPermissionObjectId('note', 'drawing', 'image'));
|
||||
expect(targets).not.toContain(getDocumentEntityPermissionObjectId('note', 'custom-block', 'image'));
|
||||
expect(targets).toContain(getDocumentSectionPermissionObjectId('', 'body-section'));
|
||||
expect(targets).toContain(getDocumentParagraphPermissionObjectId('', 'body-paragraph'));
|
||||
} finally {
|
||||
document.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('indexes paragraph content starts once per document revision', () => {
|
||||
const paragraphCount = 1_000;
|
||||
const paragraphs = Array.from({ length: paragraphCount }, (_, index) => ({
|
||||
|
||||
@@ -23,8 +23,10 @@ import type {
|
||||
TextXAction,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
CustomRangeType,
|
||||
getParagraphContentStartOffset,
|
||||
getParagraphContentStartOffsets,
|
||||
JSON1,
|
||||
resolveSectionHeaderFooterReference,
|
||||
TextX,
|
||||
TextXActionType,
|
||||
@@ -60,6 +62,7 @@ interface IDocumentPermissionSegmentIndex {
|
||||
|
||||
interface IDocumentPermissionResolverIndex {
|
||||
drawingSegmentIds: Map<string, string>;
|
||||
footnoteReferenceRanges: Map<string, IDocumentPermissionRange>;
|
||||
mutationRevision: number;
|
||||
segments: Map<string, IDocumentPermissionSegmentIndex>;
|
||||
topLevelSections: ISectionBreak[];
|
||||
@@ -93,9 +96,20 @@ function getDocumentPermissionResolverIndex(documentDataModel: DocumentDataModel
|
||||
addDrawingSegments(snapshot.body, '');
|
||||
Object.entries(snapshot.headers ?? {}).forEach(([segmentId, header]) => addDrawingSegments(header.body, segmentId));
|
||||
Object.entries(snapshot.footers ?? {}).forEach(([segmentId, footer]) => addDrawingSegments(footer.body, segmentId));
|
||||
Object.entries(snapshot.notes ?? {}).forEach(([segmentId, footnote]) => addDrawingSegments(footnote.body, segmentId));
|
||||
const footnoteReferenceRanges = new Map<string, IDocumentPermissionRange>();
|
||||
for (const reference of snapshot.body?.customRanges ?? []) {
|
||||
if ((reference.rangeType === CustomRangeType.FOOTNOTE || reference.rangeType === CustomRangeType.ENDNOTE) && typeof reference.properties?.noteId === 'string') {
|
||||
footnoteReferenceRanges.set(reference.properties.noteId, {
|
||||
startOffset: reference.startIndex,
|
||||
endOffset: reference.endIndex + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const index: IDocumentPermissionResolverIndex = {
|
||||
drawingSegmentIds,
|
||||
footnoteReferenceRanges,
|
||||
mutationRevision,
|
||||
segments: new Map(),
|
||||
topLevelSections,
|
||||
@@ -115,7 +129,9 @@ function getDocumentPermissionSegmentIndex(
|
||||
}
|
||||
|
||||
const body = documentDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody() ?? null;
|
||||
const drawings = documentDataModel.getSnapshot().drawings ?? {};
|
||||
const snapshot = documentDataModel.getSnapshot();
|
||||
const note = snapshot.notes?.[segmentId];
|
||||
const drawings = (note ? note.drawings : snapshot.drawings) ?? {};
|
||||
const entities: IDocumentPermissionEntityRange[] = body == null
|
||||
? []
|
||||
: [
|
||||
@@ -179,12 +195,39 @@ export function getDocumentEditTargetObjectIdsFromActions(
|
||||
segmentId: string,
|
||||
actions: JSONXActions
|
||||
): string[] {
|
||||
const textActions = getTextXActions(actions);
|
||||
const result = new Set<string>();
|
||||
if (textActions) {
|
||||
if (actions == null) {
|
||||
return [];
|
||||
}
|
||||
const cursor = JSON1.type.readCursor(actions);
|
||||
cursor.traverse(null, () => {
|
||||
const path = cursor.getPath();
|
||||
const edit = cursor.getComponent();
|
||||
const scopedSegment = ['headers', 'footers', 'notes'].includes(String(path[0])) && typeof path[1] === 'string'
|
||||
? path[1]
|
||||
: segmentId;
|
||||
if (path[0] === 'notes' && typeof path[1] === 'string') {
|
||||
getSectionPermissionObjectIds(documentDataModel, path[1], { startOffset: 0, endOffset: 0 })
|
||||
.forEach((objectId) => result.add(objectId));
|
||||
}
|
||||
if (path[0] === 'noteSettings') {
|
||||
getDocumentPermissionResolverIndex(documentDataModel).topLevelSections.forEach((section) => {
|
||||
result.add(getDocumentSectionPermissionObjectId('', section.sectionId));
|
||||
});
|
||||
}
|
||||
if (path[0] === 'body' && path[1] === 'sectionBreaks' && typeof path[2] === 'number' && (path[3] === 'noteProperties' || path[3] === 'suppressEndnotes')) {
|
||||
const sectionId = documentDataModel.getBody()?.sectionBreaks?.[path[2]]?.sectionId;
|
||||
if (sectionId) {
|
||||
result.add(getDocumentSectionPermissionObjectId('', sectionId));
|
||||
}
|
||||
}
|
||||
if (path[path.length - 1] !== 'body' || edit?.et !== TextX.id || !Array.isArray(edit.e) || !edit.e.every(isTextXAction)) {
|
||||
return;
|
||||
}
|
||||
const textActions = edit.e;
|
||||
let offset = 0;
|
||||
const addRange = (startOffset: number, endOffset: number): void => {
|
||||
getDocumentEditTargetObjectIds(documentDataModel, segmentId, { startOffset, endOffset })
|
||||
getDocumentEditTargetObjectIds(documentDataModel, scopedSegment, { startOffset, endOffset })
|
||||
.forEach((objectId) => result.add(objectId));
|
||||
};
|
||||
|
||||
@@ -203,7 +246,7 @@ export function getDocumentEditTargetObjectIdsFromActions(
|
||||
}
|
||||
offset += action.len;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
getDrawingIdsFromActions(actions).forEach((drawingId) => {
|
||||
result.add(getDocumentEntityPermissionObjectId(segmentId, 'drawing', drawingId));
|
||||
@@ -241,14 +284,6 @@ function getDrawingIdsFromActions(actions: JSONXActions): string[] {
|
||||
return [...result];
|
||||
}
|
||||
|
||||
function getTextXActions(actions: JSONXActions): TextXAction[] | null {
|
||||
if (!Array.isArray(actions) || actions.length !== 2 || actions[0] !== 'body' || !isRecord(actions[1])) {
|
||||
return null;
|
||||
}
|
||||
const edit = actions[1];
|
||||
return edit.et === TextX.id && Array.isArray(edit.e) && edit.e.every(isTextXAction) ? edit.e : null;
|
||||
}
|
||||
|
||||
function isTextXAction(value: unknown): value is TextXAction {
|
||||
if (!isRecord(value) || typeof value.len !== 'number') {
|
||||
return false;
|
||||
@@ -313,6 +348,12 @@ function getSectionPermissionObjectIds(
|
||||
resolverIndex = getDocumentPermissionResolverIndex(documentDataModel)
|
||||
): string[] {
|
||||
if (segmentId) {
|
||||
const referenceRange = resolverIndex.footnoteReferenceRanges.get(segmentId);
|
||||
if (referenceRange) {
|
||||
// A note belongs to its reference, including the enclosing paragraph,
|
||||
// table and section. Moving the reference moves those restrictions too.
|
||||
return getDocumentEditTargetObjectIds(documentDataModel, '', referenceRange);
|
||||
}
|
||||
return getHeaderFooterOwnerSectionIds(documentDataModel, segmentId, resolverIndex.topLevelSections)
|
||||
.map((sectionId) => getDocumentSectionPermissionObjectId('', sectionId));
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export enum DocumentSkeletonPageType {
|
||||
HEADER,
|
||||
FOOTER,
|
||||
CELL,
|
||||
NOTE,
|
||||
};
|
||||
|
||||
export interface IDocumentSkeletonPage {
|
||||
@@ -125,6 +126,12 @@ export interface IDocumentSkeletonPage {
|
||||
isExplicitPageBreak?: boolean;
|
||||
/** Internal layout provenance for a page opened because content exhausted the previous page. */
|
||||
isNaturalPageOverflow?: boolean;
|
||||
/** Note fragments use their own segment offsets, independent of body character indices. */
|
||||
notes?: IDocumentSkeletonNote[];
|
||||
/** Space reserved on this body page for footnotes, including separators and continuation notices. */
|
||||
footnoteHeight?: number;
|
||||
/** Positioned separators and continuation notices for both footnotes and endnotes. */
|
||||
noteDecorations?: IDocumentSkeletonNoteDecoration[];
|
||||
st: number; // startIndex
|
||||
ed: number; // endIndex
|
||||
/** Whether this cell page is only a layout placeholder covered by a merged cell. */
|
||||
@@ -135,7 +142,26 @@ export interface IDocumentSkeletonPage {
|
||||
segmentId: string; // header/footer id if header/footer, empty string if body page
|
||||
type: DocumentSkeletonPageType; // page type: header, footer, body, or cell
|
||||
renderConfig?: IDocumentRenderConfig;
|
||||
parent?: IDocumentSkeletonCached | IDocumentSkeletonRow | IDocumentSkeletonColumnGroupColumn;
|
||||
parent?: IDocumentSkeletonCached | IDocumentSkeletonRow | IDocumentSkeletonColumnGroupColumn | IDocumentSkeletonNote;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonNoteDecoration {
|
||||
noteType?: 'footnote' | 'endnote';
|
||||
kind: 'separator' | 'continuationSeparator' | 'continuationNotice';
|
||||
left: number;
|
||||
top: number;
|
||||
page: IDocumentSkeletonPage;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonNote {
|
||||
noteType?: 'footnote' | 'endnote';
|
||||
noteId: string;
|
||||
referenceIndex: number;
|
||||
continued: boolean;
|
||||
left: number;
|
||||
top: number;
|
||||
page: IDocumentSkeletonPage;
|
||||
parent?: IDocumentSkeletonPage;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonHeaderFooter extends IDocumentSkeletonPage {}
|
||||
@@ -297,6 +323,8 @@ export interface IDocumentSkeletonGlyph {
|
||||
url?: string; // image url
|
||||
featureId?: string; // support interaction for feature ,eg. hyperLine person
|
||||
drawingId?: string; // drawing.drawingId
|
||||
noteId?: string;
|
||||
noteSeparator?: boolean;
|
||||
fauxBoldStrokeWidth?: number;
|
||||
tabLeader?: TabStopLeader;
|
||||
}
|
||||
@@ -359,7 +387,7 @@ export interface IDocumentSkeletonBoundingBox {
|
||||
width: number; // width
|
||||
ba: number; // boundingBoxAscent
|
||||
bd: number; // boundingBoxDescent
|
||||
normalLineHeight?: number; // Canvas font bounding-box height, used as the base for Word AUTO spacing
|
||||
normalLineHeight?: number; // Normal font spacing, including leading when available, for Word AUTO spacing.
|
||||
aba: number; // actualBoundingBoxAscent
|
||||
abd: number; // actualBoundingBoxDescent
|
||||
sp: number; // strikeoutPosition
|
||||
|
||||
@@ -113,6 +113,9 @@ export interface IFooterIds {
|
||||
|
||||
export interface ISectionBreakConfig extends IDocStyleBase, ISectionBreakBase, IDocsConfig {
|
||||
sectionId: string;
|
||||
/** Cell continuation pages retain their segment and physical row-slice budgets. */
|
||||
cellTableId?: string;
|
||||
cellPageHeights?: readonly number[];
|
||||
headerIds?: IHeaderIds;
|
||||
footerIds?: IFooterIds;
|
||||
useFirstPageHeaderFooter?: BooleanNumber;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ColumnSeparatorType, DashStyleType, DocumentFlavor } from '@univerjs/core';
|
||||
import { ColumnSeparatorType, CustomRangeType, DashStyleType, DocumentFlavor } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setupRenderTestEnv } from '../../../__tests__/render-test-utils';
|
||||
import {
|
||||
@@ -34,6 +34,8 @@ import { DocBackground } from '../doc-background';
|
||||
import { DOCS_EXTENSION_TYPE } from '../doc-extension';
|
||||
import { Documents, drawSectionColumnSeparators, resolveHeaderFooterFieldGlyph } from '../document';
|
||||
import { getDocumentCompatibilityPolicy } from '../document-compatibility';
|
||||
import { createParagraphLayoutTestBed } from '../layout/block/paragraph/__tests__/create-paragraph-layout-test-bed';
|
||||
import { DocumentSkeleton } from '../layout/doc-skeleton';
|
||||
import { setDocsTableRenderViewportProvider } from '../table-render-viewport';
|
||||
|
||||
function createGlyph(content: string, left: number, width = 16, backgroundColor?: string) {
|
||||
@@ -370,6 +372,60 @@ describe('documents render', () => {
|
||||
setDocsTableRenderViewportProvider(null);
|
||||
});
|
||||
|
||||
it.each(['separator', 'continuationSeparator', 'continuationNotice'] as const)('renders each note kind with its own %s fields', (kind) => {
|
||||
const fieldBody = (fieldType: string) => ({
|
||||
dataStream: '\uFFFC\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'field' }],
|
||||
sectionBreaks: [{ startIndex: 2, sectionId: 'field-section' }],
|
||||
customRanges: [{ rangeId: fieldType, rangeType: CustomRangeType.FIELD, startIndex: 0, endIndex: 0, properties: { fieldType } }],
|
||||
});
|
||||
const footnoteBody = fieldBody('PAGE');
|
||||
const endnoteBody = fieldBody('NUMPAGES');
|
||||
const bed = createParagraphLayoutTestBed('Reference\uFFFC\uFFFC', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: {
|
||||
customRanges: ['footnote', 'endnote'].map((type, index) => ({
|
||||
rangeId: type,
|
||||
rangeType: index === 0 ? CustomRangeType.FOOTNOTE : CustomRangeType.ENDNOTE,
|
||||
startIndex: 9 + index,
|
||||
endIndex: 9 + index,
|
||||
wholeEntity: true,
|
||||
properties: { noteId: type },
|
||||
})),
|
||||
},
|
||||
notes: Object.fromEntries(['footnote', 'endnote'].map((type) => [type, { noteId: type, type, body: fieldBody('PAGE') }])),
|
||||
noteSettings: { footnote: { [kind]: footnoteBody }, endnote: { [kind]: endnoteBody } },
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
const documents = new Documents('note-fields', skeleton);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const page = skeleton.getSkeletonData()!.pages[0];
|
||||
const fragments = page.notes!;
|
||||
// Render both kinds on the same body page, including continuation decorations.
|
||||
page.noteDecorations = fragments.map((note) => ({
|
||||
noteType: bed.dataModel.getSnapshot().notes![note.noteId].type,
|
||||
kind,
|
||||
left: note.left,
|
||||
top: note.top,
|
||||
page: note.page,
|
||||
}));
|
||||
const render = vi.spyOn(documents as unknown as { _drawHeaderFooter: (...args: unknown[]) => void }, '_drawHeaderFooter');
|
||||
documents.draw(canvas.getContext());
|
||||
for (const decoration of page.noteDecorations) {
|
||||
const call = render.mock.calls.find((args) => args[0] === decoration.page);
|
||||
expect(call).toBeDefined();
|
||||
expect(call![14]).toEqual(decoration.noteType === 'endnote' ? endnoteBody.customRanges : footnoteBody.customRanges);
|
||||
}
|
||||
expect(page.noteDecorations.map((note) => note.noteType).sort()).toEqual(['endnote', 'footnote']);
|
||||
} finally {
|
||||
documents.dispose();
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('hit tests table content and controls that overflow the document bounds', () => {
|
||||
const bodyPage = createPage(DocumentSkeletonPageType.BODY, '');
|
||||
attachTable(bodyPage);
|
||||
|
||||
@@ -41,11 +41,19 @@ import type { ComponentExtension, IDrawInfo, IExtensionConfig } from '../extensi
|
||||
import type { IDocumentsConfig, IPageMarginLayout } from './doc-component';
|
||||
import type { DocumentSkeleton } from './layout/doc-skeleton';
|
||||
import type { IDocsTableRenderViewport } from './table-render-viewport';
|
||||
import { CellValueType, ColumnSeparatorType, DashStyleType, DocumentFlavor, HorizontalAlign, VerticalAlign, WrapStrategy } from '@univerjs/core';
|
||||
import {
|
||||
CellValueType,
|
||||
ColumnSeparatorType,
|
||||
DashStyleType,
|
||||
DocumentFlavor,
|
||||
HorizontalAlign,
|
||||
VerticalAlign,
|
||||
WrapStrategy,
|
||||
} from '@univerjs/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { BORDER_TYPE, COLOR_BLACK_RGB, drawLineByBorderType } from '../../basics';
|
||||
import { calculateRectRotate, getRotateOffsetAndFarthestHypotenuse } from '../../basics/draw';
|
||||
import { LineType } from '../../basics/i-document-skeleton-cached';
|
||||
import { DocumentSkeletonPageType, LineType } from '../../basics/i-document-skeleton-cached';
|
||||
import { VERTICAL_ROTATE_ANGLE } from '../../basics/text-rotation';
|
||||
import { degToRad, fixLineWidthByScale } from '../../basics/tools';
|
||||
import { Vector2 } from '../../basics/vector2';
|
||||
@@ -58,7 +66,11 @@ import { getTableIdAndSliceIndex } from './layout/block/table';
|
||||
import { getColorStyleForCanvas } from './layout/style/color';
|
||||
import { documentSkeletonTableIterator } from './layout/tools';
|
||||
import { Liquid } from './liquid';
|
||||
import { getDocsTableRenderViewport, getDocsTableViewportLeft, hasDocsTableHorizontalViewport } from './table-render-viewport';
|
||||
import {
|
||||
getDocsTableRenderViewport,
|
||||
getDocsTableViewportLeft,
|
||||
hasDocsTableHorizontalViewport,
|
||||
} from './table-render-viewport';
|
||||
import './extensions';
|
||||
|
||||
const DEFAULT_BORDER_COLOR: ITableCellBorder = {
|
||||
@@ -671,6 +683,28 @@ export class Documents extends DocComponent {
|
||||
|
||||
this._resetRotation(ctx, finalAngle);
|
||||
|
||||
for (const note of [...page.noteDecorations ?? [], ...page.notes ?? []]) {
|
||||
this._drawHeaderFooter(
|
||||
note.page,
|
||||
ctx,
|
||||
extensions,
|
||||
backgroundExtension,
|
||||
preTextBackgroundExtensions,
|
||||
glyphExtensionsExcludeBackground,
|
||||
Vector2.create(horizontalOffsetNoAngle + note.left - page.marginLeft, note.top),
|
||||
centerAngle,
|
||||
vertexAngle,
|
||||
renderConfig,
|
||||
parentScale,
|
||||
page,
|
||||
false,
|
||||
pages.length,
|
||||
'kind' in note
|
||||
? this.getSkeleton()?.getViewModel().getSnapshot().noteSettings?.[note.noteType ?? 'footnote']?.[note.kind]?.customRanges ?? []
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
const footerSkeletonPage = skeFooters.get(footerId)?.get(pageWidth);
|
||||
|
||||
if (footerSkeletonPage) {
|
||||
@@ -1492,7 +1526,8 @@ export class Documents extends DocComponent {
|
||||
parentScale: IScale,
|
||||
parentPage: IDocumentSkeletonPage,
|
||||
isHeader = true,
|
||||
pageCount = 1
|
||||
pageCount = 1,
|
||||
customRangesOverride?: ICustomRange[]
|
||||
) {
|
||||
if (this._drawLiquid == null) {
|
||||
return;
|
||||
@@ -1500,13 +1535,13 @@ export class Documents extends DocComponent {
|
||||
const { sections, skeTables } = page;
|
||||
const { y: originY } = this._drawLiquid;
|
||||
const skeleton = this.getSkeleton();
|
||||
const customRanges = typeof skeleton?.getViewModel === 'function'
|
||||
const customRanges = customRangesOverride ?? (typeof skeleton?.getViewModel === 'function'
|
||||
? skeleton
|
||||
.getViewModel()
|
||||
.getSelfOrHeaderFooterViewModel(page.segmentId)
|
||||
.getBody()
|
||||
?.customRanges ?? []
|
||||
: [];
|
||||
: []);
|
||||
|
||||
if (skeTables.size > 0) {
|
||||
const tablePage = {
|
||||
@@ -1572,7 +1607,7 @@ export class Documents extends DocComponent {
|
||||
this._drawLiquid.translateLine(line, true, true);
|
||||
const { y } = this._drawLiquid;
|
||||
|
||||
if (!isHeader) {
|
||||
if (!isHeader && page.type !== DocumentSkeletonPageType.NOTE) {
|
||||
if ((y - originY + alignOffset.y + lineHeight) < (parentPage.pageHeight - 100) / 2 + 100) {
|
||||
this._drawLiquid.translateRestore();
|
||||
continue;
|
||||
|
||||
+19
-1
@@ -23,7 +23,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { COLOR_BLACK_RGB } from '../../../../basics/const';
|
||||
import { GlyphType } from '../../../../basics/i-document-skeleton-cached';
|
||||
import { Vector2 } from '../../../../basics/vector2';
|
||||
import { CheckboxShape } from '../../../../shape';
|
||||
import { CheckboxShape } from '../../../../shape/checkbox';
|
||||
import { FontAndBaseLine } from '../font-and-base-line';
|
||||
|
||||
type MockRenderContext = UniverRenderingContext & {
|
||||
@@ -158,6 +158,24 @@ describe('docs font and baseline extension', () => {
|
||||
expect(ScreenContext.fillStyle).toBe('#000000');
|
||||
});
|
||||
|
||||
it('draws footnote separator marks with their measured width and text color', () => {
|
||||
const extension = new FontAndBaseLine();
|
||||
const SeparatorContext = Object.assign(createContext(), {
|
||||
beginPath: vi.fn(),
|
||||
moveToByPrecision: vi.fn(),
|
||||
lineToByPrecision: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
});
|
||||
extension.extensionOffset = { spanPointWithFont: Vector2.create(12, 20) };
|
||||
extension.draw(SeparatorContext, DEFAULT_SCALE, createGlyph(' ', { noteSeparator: true, width: 192 }));
|
||||
expect(SeparatorContext.moveToByPrecision).toHaveBeenCalledWith(12, 20);
|
||||
expect(SeparatorContext.lineToByPrecision).toHaveBeenCalledWith(204, 20);
|
||||
expect(SeparatorContext.strokeStyle).toBe('#223344');
|
||||
expect(SeparatorContext.stroke).toHaveBeenCalledOnce();
|
||||
expect(SeparatorContext.fillText).not.toHaveBeenCalled();
|
||||
expect(SeparatorContext.restore).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders public glow and outer shadow behind the source text without canvas filters', () => {
|
||||
const extension = new FontAndBaseLine();
|
||||
const TestContext = createContext();
|
||||
|
||||
@@ -108,6 +108,18 @@ export class FontAndBaseLine extends docExtension {
|
||||
spanPointWithFont.y += bBox.sbo;
|
||||
}
|
||||
|
||||
if (glyph.noteSeparator) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = fontColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveToByPrecision(spanPointWithFont.x, spanPointWithFont.y);
|
||||
ctx.lineToByPrecision(spanPointWithFont.x + glyph.width, spanPointWithFont.y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
const drawText = () => {
|
||||
if (this._fillTextWithTextFill(ctx, glyph, spanPointWithFont, textFill, fontColor)) {
|
||||
return;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { IDocumentData, IParagraph } from '@univerjs/core';
|
||||
import type { IDocumentLayoutPageGeometryPublication } from '../document-layout-publication';
|
||||
import {
|
||||
BooleanNumber,
|
||||
ColumnSeparatorType,
|
||||
@@ -54,6 +55,7 @@ import { setDocsCustomBlockRenderViewportProvider } from '../../custom-block-ren
|
||||
import { setDocsTableRenderViewportProvider } from '../../table-render-viewport';
|
||||
import { DocumentViewModel } from '../../view-model/document-view-model';
|
||||
import { DocumentSkeleton } from '../doc-skeleton';
|
||||
import { serializeDocumentSkeletonPage } from '../document-layout-page-patch';
|
||||
import { Hyphen } from '../hyphenation/hyphen';
|
||||
import { Lang } from '../hyphenation/lang';
|
||||
import { PATTERN_LOADERS } from '../hyphenation/pattern-loaders.gen';
|
||||
@@ -219,6 +221,261 @@ function createPage(type: DocumentSkeletonPageType, st: number, tableId = '') {
|
||||
}
|
||||
|
||||
describe('doc skeleton', () => {
|
||||
it.each([2, 6].flatMap((paragraphCount) => [TableRowHeightRule.AUTO, TableRowHeightRule.AT_LEAST, TableRowHeightRule.EXACT].map((hRule) => ({ paragraphCount, hRule }))))('sizes vertically merged cells against the whole row span ($paragraphCount paragraphs, rule $hRule)', ({ paragraphCount, hRule }) => {
|
||||
const measure = vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
|
||||
width: text.length * 5,
|
||||
fontBoundingBoxAscent: 8,
|
||||
fontBoundingBoxDescent: 2,
|
||||
actualBoundingBoxAscent: 8,
|
||||
actualBoundingBoxDescent: 2,
|
||||
}) as TextMetrics);
|
||||
const univer = new Univer();
|
||||
const T = DataStreamTreeTokenType;
|
||||
const cell = (text: string) => `${T.TABLE_CELL_START}${text}\n${T.TABLE_CELL_END}`;
|
||||
const row = (left: string, right: string) => `${T.TABLE_ROW_START}${cell(left)}${cell(right)}${T.TABLE_ROW_END}`;
|
||||
const tableStream = `${T.TABLE_START}${row('Merged\r'.repeat(paragraphCount), 'One\r')}${row('\r', 'Two\r')}${T.TABLE_END}`;
|
||||
const dataStream = `${tableStream}\rAfter\r\n`;
|
||||
const snapshot: IDocumentData = {
|
||||
id: 'merged-cell-height',
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].filter((match) => match.index !== tableStream.length).map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index })),
|
||||
sectionBreaks: [...dataStream.matchAll(/\n/g)].map((match, index) => ({ sectionId: `s-${index}`, startIndex: match.index })),
|
||||
tables: [{ tableId: 'table', startIndex: 0, endIndex: tableStream.length }],
|
||||
},
|
||||
documentStyle: {
|
||||
documentFlavor: DocumentFlavor.TRADITIONAL,
|
||||
pageSize: { width: 400, height: 400 },
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
},
|
||||
tableSource: { table: {
|
||||
tableId: 'table',
|
||||
align: TableAlignmentType.START,
|
||||
indent: { v: 0 },
|
||||
textWrap: TableTextWrapType.NONE,
|
||||
size: { type: TableSizeType.SPECIFIED, width: { v: 200 } },
|
||||
position: {
|
||||
positionH: { relativeFrom: ObjectRelativeFromH.PAGE },
|
||||
positionV: { relativeFrom: ObjectRelativeFromV.PAGE },
|
||||
},
|
||||
dist: { distT: 0, distB: 0, distL: 0, distR: 0 },
|
||||
cellMargin: { top: { v: 0 }, bottom: { v: 0 }, start: { v: 0 }, end: { v: 0 } },
|
||||
tableColumns: [{ size: { type: TableSizeType.SPECIFIED, width: { v: 100 } } }, { size: { type: TableSizeType.SPECIFIED, width: { v: 100 } } }],
|
||||
tableRows: [0, 1].map((index) => ({
|
||||
trHeight: { hRule, val: { v: 25 } },
|
||||
tableCells: [{ rowSpan: index === 0 ? 2 : 0, vAlign: VerticalAlignmentType.CENTER }, {}],
|
||||
})),
|
||||
} },
|
||||
};
|
||||
const viewModel = new DocumentViewModel(new DocumentDataModel(snapshot));
|
||||
const skeleton = DocumentSkeleton.create(viewModel, univer.__getInjector().get(LocaleService));
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages).toHaveLength(1);
|
||||
const table = pages[0].skeTables.get('table')!;
|
||||
const mergedCell = table.rows[0].cells[0];
|
||||
const otherCellsHeight = table.rows.reduce((height, row) => height + row.cells[1].height, 0);
|
||||
let expectedHeight = Math.max(mergedCell.height, otherCellsHeight);
|
||||
if (hRule === TableRowHeightRule.EXACT) {
|
||||
expectedHeight = 50;
|
||||
} else if (hRule === TableRowHeightRule.AT_LEAST) {
|
||||
expectedHeight = Math.max(expectedHeight, 50);
|
||||
}
|
||||
expect(table.height).toBeCloseTo(expectedHeight);
|
||||
expect(mergedCell.pageHeight).toBeCloseTo(table.height);
|
||||
expect(mergedCell.marginTop).toBeCloseTo(Math.max(0, (table.height - mergedCell.height) / 2));
|
||||
expect(table.rows[1].top).toBeCloseTo(table.rows[0].height);
|
||||
expectIncrementalSkeletonToEqualSynchronous(snapshot, univer.__getInjector().get(LocaleService));
|
||||
} finally {
|
||||
skeleton.dispose();
|
||||
viewModel.dispose();
|
||||
univer.dispose();
|
||||
measure.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reserves footnote space and carries long notes through synchronous and sliced pagination', () => {
|
||||
const measure = vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
|
||||
width: text.length * 5,
|
||||
fontBoundingBoxAscent: 8,
|
||||
fontBoundingBoxDescent: 2,
|
||||
actualBoundingBoxAscent: 8,
|
||||
actualBoundingBoxDescent: 2,
|
||||
}) as TextMetrics);
|
||||
const univer = new Univer();
|
||||
const dataStream = `${'Intro\r'.repeat(30)}Text with reference\uFFFC and more text.\r${'Later body paragraph.\r'.repeat(10)}\n`;
|
||||
const noteStream = `${'Long explanation that continues onto the following page.\r'.repeat(10)}\n`;
|
||||
const referenceIndex = dataStream.indexOf('\uFFFC');
|
||||
const snapshot: IDocumentData = {
|
||||
id: 'footnote-pagination',
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, i) => ({ startIndex: match.index!, paragraphId: `p-${i}` })),
|
||||
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: 'main' }],
|
||||
customRanges: [{
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
rangeId: 'ref',
|
||||
wholeEntity: true,
|
||||
startIndex: referenceIndex,
|
||||
endIndex: referenceIndex,
|
||||
properties: { noteId: 'note' },
|
||||
}],
|
||||
},
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: noteStream,
|
||||
paragraphs: [...noteStream.matchAll(/\r/g)].map((match, i) => ({ startIndex: match.index!, paragraphId: `n-${i}` })),
|
||||
sectionBreaks: [{ startIndex: noteStream.length - 1, sectionId: 'note' }],
|
||||
} } },
|
||||
documentStyle: {
|
||||
documentFlavor: DocumentFlavor.TRADITIONAL,
|
||||
pageSize: { width: 220, height: 160 },
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
},
|
||||
};
|
||||
const skeleton = DocumentSkeleton.create(new DocumentViewModel(new DocumentDataModel(snapshot)), univer.__getInjector().get(LocaleService));
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const fragments = pages.flatMap((page) => page.notes ?? []);
|
||||
expect(fragments.length).toBeGreaterThan(1);
|
||||
const referencePage = pages.find((page) => page.st <= referenceIndex && page.ed >= referenceIndex)!;
|
||||
expect(referencePage.notes?.[0]).toMatchObject({ noteId: 'note', continued: false });
|
||||
expect(fragments.slice(1).every((fragment) => fragment.continued)).toBe(true);
|
||||
const firstPosition = skeleton.findNodePositionByCharIndex(0, true, 'note');
|
||||
expect(firstPosition?.pageType).toBe(DocumentSkeletonPageType.NOTE);
|
||||
expect(firstPosition?.path).toContain('notes');
|
||||
expect(skeleton.findCharIndexByPosition(firstPosition!)).toBe(0);
|
||||
expect(skeleton.findGlyphByPosition(firstPosition)).toBe(skeleton.findNodeByCharIndex(0, 'note'));
|
||||
const referencePageIndex = pages.indexOf(referencePage);
|
||||
const continuation = fragments[1];
|
||||
const continuationPosition = skeleton.findNodePositionByCharIndex(continuation.page.st, true, 'note', referencePageIndex);
|
||||
expect(continuationPosition?.page).toBeGreaterThan(referencePageIndex);
|
||||
expect(skeleton.findCharIndexByPosition(continuationPosition!)).toBe(continuation.page.st);
|
||||
const pageTop = pages.slice(0, referencePageIndex).reduce((top, page) => top + page.pageHeight, 0);
|
||||
const firstFragment = referencePage.notes![0];
|
||||
const hit = skeleton.findNodeByCoord(Vector2.create(firstFragment.left + 20, pageTop + firstFragment.top + 5), PageLayoutType.VERTICAL, 0, 0);
|
||||
expect(hit?.segmentId).toBe('note');
|
||||
expect(hit?.segmentPage).toBe(referencePageIndex);
|
||||
for (const page of pages) {
|
||||
const firstNote = page.notes?.[0];
|
||||
if (!firstNote) {
|
||||
continue;
|
||||
}
|
||||
expect(page.height + page.marginTop).toBeLessThanOrEqual(firstNote.top);
|
||||
expect(firstNote.top + firstNote.page.height).toBeLessThanOrEqual(page.pageHeight - page.marginBottom + 2);
|
||||
}
|
||||
expect(fragments.map((fragment) => fragment.page.sections.flatMap((section) => section.columns.flatMap(
|
||||
(column) => column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.map((glyph) => glyph.raw)))
|
||||
)).join('')).join('')).toBe(noteStream);
|
||||
expectIncrementalSkeletonToEqualSynchronous(snapshot, univer.__getInjector().get(LocaleService));
|
||||
const edited = structuredClone(snapshot);
|
||||
const added = 'Expanded note content.\r'.repeat(3);
|
||||
const editedBody = edited.notes!.note.body;
|
||||
editedBody.dataStream = added + noteStream;
|
||||
editedBody.paragraphs = [...editedBody.dataStream.matchAll(/\r/g)].map((match, index) => ({
|
||||
paragraphId: `edited-note-${index}`,
|
||||
startIndex: match.index!,
|
||||
}));
|
||||
editedBody.sectionBreaks![0].startIndex = editedBody.dataStream.length - 1;
|
||||
skeleton.getViewModel().reset(new DocumentDataModel(edited));
|
||||
const generation = skeleton.startIncrementalLayout({
|
||||
reason: 'edit',
|
||||
anchor: referenceIndex,
|
||||
invalidation: { oldStart: referenceIndex, oldEnd: referenceIndex + 1, newEnd: referenceIndex + 1 },
|
||||
});
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let step = 0; !progress.complete && step < 1_000; step++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress.complete).toBe(true);
|
||||
const expected = DocumentSkeleton.create(new DocumentViewModel(new DocumentDataModel(edited)), univer.__getInjector().get(LocaleService));
|
||||
try {
|
||||
expected.calculate();
|
||||
expect(normalizeSkeleton(skeleton.getSkeletonData())).toEqual(normalizeSkeleton(expected.getSkeletonData()));
|
||||
const data = skeleton.getSkeletonData()!;
|
||||
const notePageIndex = data.pages.findIndex((page) => page.notes?.length);
|
||||
const protectedPage = data.pages[notePageIndex];
|
||||
const publication: IDocumentLayoutPageGeometryPublication = {
|
||||
kind: 'page',
|
||||
left: data.left,
|
||||
top: data.top,
|
||||
st: data.st,
|
||||
resources: { reset: false, skeHeaders: [], skeFooters: [], skeListLevel: null, drawingAnchor: null },
|
||||
pages: data.pages.map((page, pageIndex) => ({ pageIndex, page: serializeDocumentSkeletonPage(page) })),
|
||||
};
|
||||
skeleton.beginExternalLayout({
|
||||
reason: 'edit',
|
||||
protectedRange: { mode: 'paginated', startPageIndex: notePageIndex, endPageIndex: notePageIndex },
|
||||
});
|
||||
// A Worker can agree on body pagination but move a footnote line
|
||||
// to the next page. That boundary must take part in the merge.
|
||||
publication.pages[notePageIndex].page.notes![0].page.ed -= 1;
|
||||
const partial = { ...progress, complete: false };
|
||||
expect(skeleton.applyLayoutPublication(publication, partial).didReplaceProtectedPages).toBe(false);
|
||||
expect(skeleton.getSkeletonData()!.pages[notePageIndex]).toBe(protectedPage);
|
||||
expect(skeleton.applyLayoutPublication({ ...publication, pages: [] }, progress).didReplaceProtectedPages).toBe(true);
|
||||
expect(skeleton.getSkeletonData()!.pages[notePageIndex].notes![0].page.ed)
|
||||
.toBe(protectedPage.notes![0].page.ed - 1);
|
||||
} finally {
|
||||
expected.dispose();
|
||||
}
|
||||
} finally {
|
||||
measure.mockRestore();
|
||||
skeleton.dispose();
|
||||
univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('shapes a multi-character footnote number as one editable reference in both layout paths', () => {
|
||||
const univer = new Univer();
|
||||
const snapshot: Partial<IDocumentData> = {
|
||||
id: 'footnote-caret',
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: 500 } },
|
||||
body: {
|
||||
dataStream: 'A\uFFFCB\r\n',
|
||||
paragraphs: [{ startIndex: 3, paragraphId: 'paragraph' }],
|
||||
sectionBreaks: [{ startIndex: 4, sectionId: 'section' }],
|
||||
customRanges: [{
|
||||
rangeId: 'reference',
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
startIndex: 1,
|
||||
endIndex: 1,
|
||||
wholeEntity: true,
|
||||
properties: { noteId: 'note' },
|
||||
}],
|
||||
},
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: 'Explanation\r\n',
|
||||
paragraphs: [{ startIndex: 11, paragraphId: 'note-paragraph' }],
|
||||
sectionBreaks: [{ startIndex: 12, sectionId: 'note-section' }],
|
||||
} } },
|
||||
noteSettings: { footnote: { startNumber: 8, numberFormat: 'upperRoman' } },
|
||||
};
|
||||
const model = new DocumentDataModel(snapshot);
|
||||
const viewModel = new DocumentViewModel(model);
|
||||
const localeService = univer.__getInjector().get(LocaleService);
|
||||
const skeleton = DocumentSkeleton.create(viewModel, localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
expect(skeleton.findNodeByCharIndex(1)).toMatchObject({ content: 'VIII', raw: '\uFFFC', count: 1 });
|
||||
expect(skeleton.findNodeByCharIndex(2)).toMatchObject({ content: 'B' });
|
||||
expect(skeleton.findNodePositionByCharIndex(2)?.page).toBe(0);
|
||||
expectIncrementalSkeletonToEqualSynchronous(snapshot, localeService);
|
||||
} finally {
|
||||
skeleton.dispose();
|
||||
viewModel.dispose();
|
||||
model.dispose();
|
||||
univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['ready', 'cancel', 'failure', 'header', 'footer'])('waits for cold hyphenation rules and matches a warm executor (%s)', async (scenario) => {
|
||||
const univer = new Univer();
|
||||
const content = scenario === 'header' || scenario === 'footer'
|
||||
@@ -975,19 +1232,10 @@ describe('doc skeleton', () => {
|
||||
const header = createPage(DocumentSkeletonPageType.HEADER, 200);
|
||||
const footer = createPage(DocumentSkeletonPageType.FOOTER, 300);
|
||||
|
||||
const docViewModel = {
|
||||
getDataModel: () => ({
|
||||
documentStyle: {
|
||||
pageSize: { width: 210, height: 297 },
|
||||
},
|
||||
}),
|
||||
getHeaderFooterTreeMap: () => ({
|
||||
headerTreeMap: new Map(),
|
||||
footerTreeMap: new Map(),
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
} as any;
|
||||
const skeleton = new DocumentSkeleton(docViewModel, {} as any);
|
||||
const univer = new Univer();
|
||||
const model = createDocumentModelWithStyle('ABC', {});
|
||||
model.updateDocumentStyle({ pageSize: { width: 210, height: 297 } });
|
||||
const skeleton = DocumentSkeleton.create(new DocumentViewModel(model), univer.__getInjector().get(LocaleService));
|
||||
(skeleton as any)._skeletonData = {
|
||||
pages: [body.page],
|
||||
skeHeaders: new Map([['header-seg', new Map([[body.page.pageWidth, header.page]])]]),
|
||||
@@ -1058,6 +1306,9 @@ describe('doc skeleton', () => {
|
||||
|
||||
(skeleton as any)._translatePage(body.page, PageLayoutType.VERTICAL, 1, 2);
|
||||
expect((skeleton as any)._findLiquid.x).toBeGreaterThanOrEqual(0);
|
||||
skeleton.dispose();
|
||||
model.dispose();
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('finds nodes by coordinate inside column group columns', () => {
|
||||
@@ -1446,12 +1697,12 @@ describe('doc skeleton', () => {
|
||||
const localeService = univer.__getInjector().get(LocaleService);
|
||||
const viewModel = new DocumentViewModel(documentModel);
|
||||
const skeleton = DocumentSkeleton.create(viewModel, localeService);
|
||||
const createSkeletonSpy = vi.spyOn(skeleton as any, '_createSkeleton');
|
||||
const createSkeletonPassSpy = vi.spyOn(skeleton as any, '_createSkeletonPass');
|
||||
const restoreContinuousSectionSpy = vi.spyOn(skeleton as any, '_restoreContinuousSection');
|
||||
|
||||
skeleton.calculate();
|
||||
|
||||
expect(createSkeletonSpy.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(createSkeletonPassSpy.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(restoreContinuousSectionSpy).not.toHaveBeenCalled();
|
||||
const firstPageSections = skeleton.getSkeletonData()?.pages[0]?.sections ?? [];
|
||||
expect(firstPageSections).toHaveLength(2);
|
||||
@@ -1542,11 +1793,11 @@ describe('doc skeleton', () => {
|
||||
const univer = new Univer();
|
||||
const localeService = univer.__getInjector().get(LocaleService);
|
||||
const skeleton = DocumentSkeleton.create(new DocumentViewModel(documentModel), localeService);
|
||||
const createSkeletonSpy = vi.spyOn(skeleton as any, '_createSkeleton');
|
||||
const createSkeletonPassSpy = vi.spyOn(skeleton as any, '_createSkeletonPass');
|
||||
|
||||
skeleton.calculate();
|
||||
|
||||
expect(createSkeletonSpy.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(createSkeletonPassSpy.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(skeleton.getSkeletonData()?.pages.map(({ pageNumber }) => pageNumber)).toEqual([1, 2]);
|
||||
|
||||
const incremental = DocumentSkeleton.create(new DocumentViewModel(documentModel), localeService);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentData, IDocumentNote } from '@univerjs/core';
|
||||
import { CustomRangeType, DocumentFlavor } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createParagraphLayoutTestBed } from '../block/paragraph/__tests__/create-paragraph-layout-test-bed';
|
||||
import { DocumentSkeleton } from '../doc-skeleton';
|
||||
import { hydrateDocumentSkeletonPage, serializeDocumentSkeletonPage } from '../document-layout-page-patch';
|
||||
import { resolveNoteReferences } from '../note-numbering';
|
||||
import { FontCache } from '../shaping-engine/font-cache';
|
||||
|
||||
function body(text: string) {
|
||||
const dataStream = `${text}\r\n`;
|
||||
return { dataStream, paragraphs: [...dataStream.matchAll(/\r/g)].map((match, i) => ({ startIndex: match.index!, paragraphId: `p-${i}` })), sectionBreaks: [...dataStream.matchAll(/\n/g)].map((match, i) => ({ startIndex: match.index!, sectionId: `s-${i}` })) };
|
||||
}
|
||||
|
||||
function snapshot(position: 'docEnd' | 'sectEnd', long = false) {
|
||||
const main = body('First \uFFFC foot \uFFFC\r\nSecond \uFFFC');
|
||||
const types = ['endnote', 'footnote', 'endnote'] as const;
|
||||
const notes: Record<string, IDocumentNote> = {};
|
||||
const customRanges = [...main.dataStream.matchAll(/\uFFFC/g)].map((match, i) => {
|
||||
const noteId = `note-${i}`;
|
||||
notes[noteId] = { noteId, type: types[i], body: body(long && i === 0 ? Array.from({ length: 60 }, (_, i) => `Long note paragraph ${i}`).join('\r') : `Note ${i}`) };
|
||||
return { rangeId: `ref-${i}`, rangeType: types[i] === 'endnote' ? CustomRangeType.ENDNOTE : CustomRangeType.FOOTNOTE, startIndex: match.index!, endIndex: match.index!, wholeEntity: true, properties: { noteId } };
|
||||
});
|
||||
return { documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 300, height: 180 }, marginTop: 10, marginBottom: 10, marginLeft: 10, marginRight: 10 }, body: { ...main, customRanges }, notes, noteSettings: { endnote: { position, numberFormat: 'lowerRoman' }, footnote: { startNumber: 3 } } } satisfies Partial<IDocumentData>;
|
||||
}
|
||||
|
||||
describe('endnote pagination', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({ width: text.length * 5, fontBoundingBoxAscent: 8, fontBoundingBoxDescent: 2, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }) as TextMetrics);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('numbers footnotes and endnotes independently', () => {
|
||||
const refs = [...resolveNoteReferences(snapshot('docEnd')).values()];
|
||||
expect(refs.map((ref) => ref.label)).toEqual(['i', '3', 'ii']);
|
||||
});
|
||||
|
||||
it.each([false, true])('places section endnotes before the next section and document endnotes after it (incremental: %s)', (incremental) => {
|
||||
for (const position of ['sectEnd', 'docEnd'] as const) {
|
||||
const bed = createParagraphLayoutTestBed('', snapshot(position));
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
if (incremental) {
|
||||
const generation = skeleton.startIncrementalLayout();
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let i = 0; i < 1000 && !progress.complete; i++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress.complete).toBe(true);
|
||||
} else {
|
||||
skeleton.calculate();
|
||||
}
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const firstNote = pages.flatMap((page, pageIndex) => (page.notes ?? []).map((note) => ({ ...note, pageIndex }))).find((note) => note.noteId === 'note-0')!;
|
||||
const secondSectionPage = pages.findIndex((page) => page.sections.some((section) => section.columns.some((column) => column.lines.some((line) => line.paragraphIndex > 16))));
|
||||
expect(firstNote).toBeDefined();
|
||||
expect(pages.flatMap((page) => page.notes ?? []).filter((note) => !note.continued).map((note) => note.noteId).sort()).toEqual(['note-0', 'note-1', 'note-2']);
|
||||
if (position === 'sectEnd') {
|
||||
expect(firstNote.pageIndex).toBeLessThan(secondSectionPage);
|
||||
} else {
|
||||
expect(firstNote.pageIndex).toBeGreaterThanOrEqual(secondSectionPage);
|
||||
}
|
||||
for (const page of pages) {
|
||||
const restored = hydrateDocumentSkeletonPage(serializeDocumentSkeletonPage(page));
|
||||
expect(restored.notes?.map((note) => note.noteId)).toEqual(page.notes?.map((note) => note.noteId));
|
||||
}
|
||||
} finally {
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('defers suppressed section endnotes while retaining their numbering and reference order', () => {
|
||||
const data = snapshot('sectEnd');
|
||||
Object.assign(data.body.sectionBreaks[0], { suppressEndnotes: true });
|
||||
const bed = createParagraphLayoutTestBed('', data);
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const endnotes = pages.flatMap((page, pageIndex) => (page.notes ?? [])
|
||||
.filter((note) => note.noteType === 'endnote')
|
||||
.map((note) => ({ noteId: note.noteId, pageIndex })));
|
||||
expect(endnotes.map((note) => note.noteId)).toEqual(['note-0', 'note-2']);
|
||||
expect(endnotes[0].pageIndex).toBe(endnotes[1].pageIndex);
|
||||
expect(endnotes[0].pageIndex).toBeGreaterThan(0);
|
||||
} finally {
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])('splits long endnotes and keeps all content above the footer (incremental: %s)', (incremental) => {
|
||||
const bed = createParagraphLayoutTestBed('', snapshot('docEnd', true));
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
if (incremental) {
|
||||
const generation = skeleton.startIncrementalLayout();
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let i = 0; i < 1000 && !progress.complete; i++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress.complete).toBe(true);
|
||||
} else {
|
||||
skeleton.calculate();
|
||||
}
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const notes = pages.flatMap((page) => page.notes ?? []).filter((note) => note.noteId === 'note-0');
|
||||
expect(notes.length).toBeGreaterThan(1);
|
||||
const text = notes.flatMap((note) => note.page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.map((glyph) => glyph.raw ?? glyph.content)))))).join('');
|
||||
for (let i = 0; i < 60; i++) {
|
||||
expect(text).toContain(`Long note paragraph ${i}`);
|
||||
}
|
||||
expect(notes[0].continued).toBe(false);
|
||||
expect(notes.slice(1).every((note) => note.continued)).toBe(true);
|
||||
for (const page of pages) {
|
||||
for (const note of page.notes ?? []) {
|
||||
expect(note.top + note.page.height).toBeLessThanOrEqual(page.pageHeight - page.marginBottom + 0.1);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* 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 { IDocumentNote } from '@univerjs/core';
|
||||
import type { IDocumentSkeletonPage } from '../../../../basics/i-document-skeleton-cached';
|
||||
import {
|
||||
BaselineOffset,
|
||||
BooleanNumber,
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentFlavor,
|
||||
} from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocumentSkeletonPageType, PageLayoutType } from '../../../../basics/i-document-skeleton-cached';
|
||||
import { Vector2 } from '../../../../basics/vector2';
|
||||
import { createParagraphLayoutTestBed } from '../block/paragraph/__tests__/create-paragraph-layout-test-bed';
|
||||
import { DocumentSkeleton } from '../doc-skeleton';
|
||||
import { hydrateDocumentSkeletonPage, serializeDocumentSkeletonPage } from '../document-layout-page-patch';
|
||||
import { layoutNoteBody } from '../footnote-layout';
|
||||
import { resolveNoteReferences } from '../note-numbering';
|
||||
import { FontCache } from '../shaping-engine/font-cache';
|
||||
|
||||
function createNoteTestBed(pageHeight = 600, noteOverrides: Partial<IDocumentNote> = {}) {
|
||||
const dataStream = `${Array.from({ length: 30 }, (_, i) => `Explanation ${i + 1} with enough text to wrap onto another line.\r`).join('')}\n`;
|
||||
const paragraphs = [...dataStream.matchAll(/\r/g)].map((match, i) => ({
|
||||
paragraphId: `note-paragraph-${i}`,
|
||||
startIndex: match.index!,
|
||||
}));
|
||||
return createParagraphLayoutTestBed('Reference\uFFFC', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: pageHeight } },
|
||||
body: {
|
||||
customRanges: [{
|
||||
rangeId: 'reference',
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
startIndex: 9,
|
||||
endIndex: 9,
|
||||
wholeEntity: true,
|
||||
properties: { noteId: 'note' },
|
||||
}],
|
||||
},
|
||||
noteSettings: { footnote: { numberFormat: 'upperRoman', startNumber: 8 } },
|
||||
notes: {
|
||||
note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream,
|
||||
paragraphs,
|
||||
sectionBreaks: [{ sectionId: 'note-body', startIndex: dataStream.length - 1 }],
|
||||
}, ...noteOverrides },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getPageGlyphs(page: IDocumentSkeletonPage) {
|
||||
return page.sections.flatMap((section) => section.columns.flatMap((column) =>
|
||||
column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup))));
|
||||
}
|
||||
|
||||
describe('footnote body layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
|
||||
width: text.length * 5,
|
||||
fontBoundingBoxAscent: 8,
|
||||
fontBoundingBoxDescent: 2,
|
||||
actualBoundingBoxAscent: 8,
|
||||
actualBoundingBoxDescent: 2,
|
||||
}) as TextMetrics);
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it.each([false, true])('fills the remaining body space with table rows after an earlier footnote (incremental: %s)', (incremental) => {
|
||||
const T = DataStreamTreeTokenType;
|
||||
const prefix = `Reference\uFFFC\r${'Filler\r'.repeat(7)}`;
|
||||
const tableStream = `${T.TABLE_START}${Array.from({ length: 10 }, (_, index) => `${T.TABLE_ROW_START}${T.TABLE_CELL_START}Row ${index}\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}`).join('')}${T.TABLE_END}`;
|
||||
const dataStream = `${prefix}${tableStream}\rAfter\r\n`;
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: 160 }, marginTop: 10, marginBottom: 10, marginLeft: 10, marginRight: 10 },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].filter((match) => match.index !== prefix.length + tableStream.length).map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index })),
|
||||
sectionBreaks: [...dataStream.matchAll(/\n/g)].map((match, index) => ({ sectionId: `s-${index}`, startIndex: match.index })),
|
||||
tables: [{ tableId: 'table', startIndex: prefix.length, endIndex: prefix.length + tableStream.length }],
|
||||
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: 9, endIndex: 9, wholeEntity: true, properties: { noteId: 'note' } }],
|
||||
},
|
||||
tableSource: { table: {
|
||||
tableId: 'table',
|
||||
align: 0,
|
||||
indent: { v: 0 },
|
||||
textWrap: 0,
|
||||
size: { type: 0, width: { v: 200 } },
|
||||
cellMargin: { top: { v: 0 }, bottom: { v: 0 }, start: { v: 0 }, end: { v: 0 } },
|
||||
tableColumns: [{ size: { type: 0, width: { v: 200 } } }],
|
||||
tableRows: Array.from({ length: 10 }, () => ({ tableCells: [{}], trHeight: { val: { v: 0 }, hRule: 0 } })),
|
||||
} },
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: { dataStream: 'Explanation\r\n', paragraphs: [{ paragraphId: 'note-p', startIndex: 11 }], sectionBreaks: [{ sectionId: 'note-section', startIndex: 12 }] } } },
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
if (incremental) {
|
||||
const generation = skeleton.startIncrementalLayout();
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let step = 0; step < 1000 && !progress.complete; step++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress.complete).toBe(true);
|
||||
} else {
|
||||
skeleton.calculate();
|
||||
}
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages[0].notes?.map((note) => note.noteId)).toEqual(['note']);
|
||||
const firstTable = [...pages[0].skeTables.values()][0];
|
||||
expect(firstTable).toBeDefined();
|
||||
expect(firstTable.rows[0].index).toBe(0);
|
||||
expect(firstTable.rows.length).toBeLessThan(10);
|
||||
expect(pages[0].marginTop + firstTable.top + firstTable.height).toBeLessThanOrEqual(pages[0].noteDecorations![0].top + 0.01);
|
||||
const rows = pages.flatMap((page) => [...page.skeTables.values()].flatMap((table) => table.rows));
|
||||
expect(rows.map((row) => row.index)).toEqual(Array.from({ length: 10 }, (_, index) => index));
|
||||
} finally {
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([1, 32].flatMap((repeatCount) => [false, true].flatMap((incremental) => ['keepLines', 'keepNext', 'widowControl'].map((constraint) => ({ repeatCount, incremental, constraint })))))('reflows page-local note markers for $constraint across $repeatCount paragraphs (incremental: $incremental)', ({ repeatCount, incremental, constraint }) => {
|
||||
const prefix = constraint === 'keepNext' ? 'Filler filler filler filler\rHeading \uFFFC\r' : 'Filler \uFFFC filler filler filler\r';
|
||||
const kept = constraint !== 'widowControl' ? 'One \uFFFC two three four five six seven eight nine ten.\r' : 'One two three four five six seven eight nine \uFFFC ten eleven twelve thirteen.\r';
|
||||
const dataStream = `${`${prefix}${kept}`.repeat(repeatCount)}\n`;
|
||||
const references = [...dataStream.matchAll(/\uFFFC/g)].map((match, index) => ({ index: match.index!, id: `note-${index}` }));
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 120, height: constraint === 'widowControl' ? 128 : 120 }, marginTop: 10, marginBottom: 10, marginLeft: 10, marginRight: 10 },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `p-${index}`, paragraphStyle: constraint === 'keepNext'
|
||||
? { keepNext: index % 3 === 1 ? BooleanNumber.TRUE : BooleanNumber.FALSE, keepLines: index % 3 === 2 ? BooleanNumber.TRUE : BooleanNumber.FALSE }
|
||||
: { [constraint]: index % 2 === 1 ? BooleanNumber.TRUE : BooleanNumber.FALSE } })),
|
||||
sectionBreaks: [{ sectionId: 'main', startIndex: dataStream.length - 1 }],
|
||||
customRanges: references.map(({ index, id }) => ({ rangeId: `ref-${id}`, rangeType: CustomRangeType.FOOTNOTE, startIndex: index, endIndex: index, wholeEntity: true, properties: { noteId: id } })),
|
||||
},
|
||||
noteSettings: { footnote: { restart: 'eachPage', startNumber: 9 } },
|
||||
notes: Object.fromEntries(references.map(({ id }) => [id, { type: 'footnote' as const, noteId: id, body: { dataStream: 'Note\r\n', paragraphs: [{ startIndex: 4, paragraphId: 'note-text' }], sectionBreaks: [{ startIndex: 5, sectionId: 'note-section' }] } }])),
|
||||
});
|
||||
try {
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
if (incremental) {
|
||||
const generation = skeleton.startIncrementalLayout();
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let step = 0; step < 20_000 && !progress.complete; step++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress).toMatchObject({ complete: true, cancelled: false });
|
||||
} else {
|
||||
skeleton.calculate();
|
||||
}
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages.flatMap(getPageGlyphs).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(dataStream);
|
||||
const referenceWidth = pages.flatMap(getPageGlyphs).find((glyph) => glyph.noteId === 'note-0')!.bBox.width;
|
||||
for (const page of pages) {
|
||||
const refs = getPageGlyphs(page).filter((glyph) => glyph.noteId);
|
||||
expect(refs.map((glyph) => glyph.content)).toEqual(refs.map((_, index) => String(9 + index)));
|
||||
for (const glyph of refs) {
|
||||
if (glyph.content === '9') {
|
||||
expect(glyph.bBox.width).toBeCloseTo(referenceWidth);
|
||||
}
|
||||
const note = page.notes?.find((fragment) => fragment.noteId === glyph.noteId && !fragment.continued);
|
||||
expect(note).toBeDefined();
|
||||
expect(getPageGlyphs(note!.page).find((item) => item.count === 0)?.content).toBe(glyph.content);
|
||||
}
|
||||
for (const section of page.sections) {
|
||||
for (const column of section.columns) {
|
||||
for (const line of column.lines) {
|
||||
for (const divide of line.divides) {
|
||||
for (let index = 1; index < divide.glyphGroup.length; index++) {
|
||||
const previous = divide.glyphGroup[index - 1];
|
||||
expect(divide.glyphGroup[index].left).toBeCloseTo(previous.left + previous.width);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (constraint === 'keepNext') {
|
||||
for (let index = 0; index < references.length; index += 2) {
|
||||
const headingPage = pages.find((page) => getPageGlyphs(page).some((glyph) => glyph.noteId === references[index].id));
|
||||
const followingPage = pages.find((page) => getPageGlyphs(page).some((glyph) => glyph.noteId === references[index + 1].id));
|
||||
expect(headingPage).toBe(followingPage);
|
||||
}
|
||||
}
|
||||
const paragraphEnds = [...dataStream.matchAll(/\r/g)].filter((_, index) => constraint === 'keepNext' ? index % 3 === 2 : index % 2 === 1).map((match) => match.index!);
|
||||
for (const end of paragraphEnds) {
|
||||
const counts = pages.map((page) => page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines)).filter((line) => line.paragraphIndex === end).length).filter((count) => count > 0);
|
||||
if (constraint !== 'widowControl') {
|
||||
expect(counts).toHaveLength(1);
|
||||
} else {
|
||||
expect(counts.every((count) => count >= 2)).toBe(true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('places a table-first footnote marker in its first cell without consuming an editable character', () => {
|
||||
const T = DataStreamTreeTokenType;
|
||||
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}First\r\n${T.TABLE_CELL_END}${T.TABLE_CELL_START}Second\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
|
||||
const dataStream = `${tableStream}After\r\n`;
|
||||
const bed = createNoteTestBed(600, {
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `note-${index}` })),
|
||||
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: 'note-section' }],
|
||||
tables: [{ tableId: 'note-table', startIndex: 0, endIndex: tableStream.length }],
|
||||
},
|
||||
tableSource: {
|
||||
'note-table': {
|
||||
tableId: 'note-table',
|
||||
align: 0,
|
||||
indent: { v: 0 },
|
||||
textWrap: 0,
|
||||
position: { positionH: { relativeFrom: 0 }, positionV: { relativeFrom: 0 } },
|
||||
dist: { distT: 0, distB: 0, distL: 0, distR: 0 },
|
||||
size: { type: 0, width: { v: 300 } },
|
||||
tableRows: [{ tableCells: [{}, {}], trHeight: { val: { v: 0 }, hRule: 0 } }],
|
||||
tableColumns: [{ size: { type: 0, width: { v: 150 } } }, { size: { type: 0, width: { v: 150 } } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const pages = layoutNoteBody(bed.ctx, resolveNoteReferences(bed.dataModel.getSnapshot()).get(9)!, bed.sectionBreakConfig, {
|
||||
width: 320,
|
||||
firstPageHeight: 200,
|
||||
continuationPageHeight: 200,
|
||||
});
|
||||
const cells = pages.flatMap((page) => [...page.skeTables.values()].flatMap((table) => table.rows.flatMap((row) => row.cells)));
|
||||
expect(cells).toHaveLength(2);
|
||||
const first = getPageGlyphs(cells[0]);
|
||||
expect(first[0]).toMatchObject({ content: 'VIII', raw: '', count: 0 });
|
||||
expect(first.slice(1).map((glyph) => glyph.raw).join('')).toBe('First\r\n');
|
||||
expect(first[1].left).toBeCloseTo(first[0].left + first[0].width, 5);
|
||||
expect(getPageGlyphs(cells[1]).map((glyph) => glyph.raw).join('')).toBe('Second\r\n');
|
||||
expect([...pages.flatMap(getPageGlyphs), ...cells.flatMap(getPageGlyphs)].filter((glyph) => glyph.content === 'VIII')).toHaveLength(1);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the reference font separate from editable footnote text', () => {
|
||||
const bed = createNoteTestBed(600, {
|
||||
referenceTextStyle: { fs: 8, va: BaselineOffset.SUPERSCRIPT },
|
||||
body: {
|
||||
dataStream: 'Explanation\r\n',
|
||||
paragraphs: [{ startIndex: 11, paragraphId: 'note-text' }],
|
||||
sectionBreaks: [{ startIndex: 12, sectionId: 'note-section' }],
|
||||
textRuns: [{ st: 0, ed: 11, ts: { fs: 9.5 } }],
|
||||
},
|
||||
});
|
||||
const pages = layoutNoteBody(bed.ctx, resolveNoteReferences(bed.dataModel.getSnapshot()).get(9)!, bed.sectionBreakConfig, {
|
||||
width: 320,
|
||||
columnCount: 1,
|
||||
firstPageHeight: 100,
|
||||
continuationPageHeight: 100,
|
||||
});
|
||||
try {
|
||||
const [marker, text] = getPageGlyphs(pages[0]);
|
||||
expect(marker).toMatchObject({ count: 0, ts: { fs: 8, va: BaselineOffset.SUPERSCRIPT } });
|
||||
expect(marker.fontStyle?.originFontSize).toBe(8);
|
||||
expect(marker.fontStyle?.fontSize).toBeLessThan(8);
|
||||
expect(text.ts?.fs).toBe(9.5);
|
||||
expect(text.ts?.va).not.toBe(BaselineOffset.SUPERSCRIPT);
|
||||
expect(text.fontStyle?.fontSize).toBe(9.5);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([1, 100])('lays out two notes in shared columns with a %i-length first note', (repeatCount) => {
|
||||
const noteText = 'Explanation with several words to wrap across lines.';
|
||||
const body = {
|
||||
dataStream: `${noteText}\r\n`,
|
||||
paragraphs: [{ paragraphId: 'p', startIndex: noteText.length }],
|
||||
sectionBreaks: [{ sectionId: 's', startIndex: noteText.length + 1 }],
|
||||
};
|
||||
const firstText = noteText.repeat(repeatCount);
|
||||
const firstBody = { dataStream: `${firstText}\r\n`, paragraphs: [{ paragraphId: 'first', startIndex: firstText.length }], sectionBreaks: [{ sectionId: 'first-section', startIndex: firstText.length + 1 }] };
|
||||
const bed = createParagraphLayoutTestBed('\uFFFC\uFFFC', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
noteSettings: { footnote: { columnCount: 2 } },
|
||||
body: { customRanges: [0, 1].map((index) => ({
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
rangeId: `ref-${index}`,
|
||||
startIndex: index,
|
||||
endIndex: index,
|
||||
wholeEntity: true,
|
||||
properties: { noteId: `note-${index}` },
|
||||
})) },
|
||||
notes: {
|
||||
'note-0': { type: 'footnote' as const, noteId: 'note-0', body: firstBody },
|
||||
'note-1': { type: 'footnote' as const, noteId: 'note-1', body: structuredClone(body) },
|
||||
},
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const notes = pages[0].notes!;
|
||||
if (repeatCount > 1) {
|
||||
const allNotes = pages.flatMap((page) => page.notes ?? []);
|
||||
expect(allNotes.length).toBeGreaterThan(2);
|
||||
expect(allNotes[allNotes.length - 1].noteId).toBe('note-1');
|
||||
expect(allNotes[allNotes.length - 1].continued).toBe(false);
|
||||
for (const [id, expected] of [['note-0', firstBody.dataStream], ['note-1', body.dataStream]]) {
|
||||
const raw = allNotes.filter((note) => note.noteId === id).flatMap((note) =>
|
||||
note.page.sections.flatMap((section) => section.columns.flatMap((column) =>
|
||||
column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw)))))).join('');
|
||||
expect(raw).toBe(expected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
expect(notes).toHaveLength(2);
|
||||
expect(notes[0].page.sections[0].columns[0].lines.length).toBeGreaterThan(0);
|
||||
expect(notes[1].page.sections[0].columns[1].lines.length).toBeGreaterThan(0);
|
||||
expect(notes[0].top).toBe(notes[1].top);
|
||||
const column = notes[1].page.sections[0].columns[1];
|
||||
const line = column.lines[0];
|
||||
const divide = line.divides[0];
|
||||
const glyph = divide.glyphGroup.find((item) => item.count > 0)!;
|
||||
const point = Vector2.create(notes[1].left + column.left + divide.left + divide.paddingLeft + glyph.left + 1, notes[1].top + line.top + 1);
|
||||
expect(skeleton.findNodeByCoord(point, PageLayoutType.VERTICAL, 0, 0)?.segmentId).toBe('note-1');
|
||||
for (const note of notes) {
|
||||
const glyphs = note.page.sections.flatMap((section) => section.columns.flatMap((column) =>
|
||||
column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup))));
|
||||
expect(glyphs.filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(body.dataStream);
|
||||
expect(note.page.st).toBe(0);
|
||||
}
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4])('continues rich paragraphs through %i columns without changing note character offsets', (columnCount) => {
|
||||
const bed = createNoteTestBed();
|
||||
const snapshot = bed.dataModel.getSnapshot();
|
||||
const reference = resolveNoteReferences(snapshot).get(9)!;
|
||||
const pages = layoutNoteBody(bed.ctx, reference, bed.sectionBreakConfig, {
|
||||
width: 320,
|
||||
columnCount,
|
||||
firstPageHeight: 45,
|
||||
continuationPageHeight: 100,
|
||||
});
|
||||
expect(pages.length).toBeGreaterThan(2);
|
||||
expect(pages.every((page) => page.sections[0].columns.length === columnCount)).toBe(true);
|
||||
expect(pages.every((page) => page.type === DocumentSkeletonPageType.NOTE && page.segmentId === 'note')).toBe(true);
|
||||
expect(pages[0].height).toBeLessThanOrEqual(47);
|
||||
expect(pages.slice(1).every((page) => page.height <= 102)).toBe(true);
|
||||
const glyphs = pages.flatMap((page) => page.sections.flatMap((section) => section.columns.flatMap(
|
||||
(column) => column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup))
|
||||
)));
|
||||
expect(glyphs[0]).toMatchObject({ content: 'VIII', raw: '', count: 0 });
|
||||
expect(glyphs.filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(snapshot.notes!.note.body.dataStream);
|
||||
expect(pages[0].st).toBe(0);
|
||||
for (let index = 1; index < pages.length; index++) {
|
||||
expect(pages[index].st).toBe(pages[index - 1].ed + 1);
|
||||
}
|
||||
expect(snapshot.notes!.note.body.dataStream.startsWith('Explanation')).toBe(true);
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0 },
|
||||
{ prefixCount: 11, rowCount: 1, referenceRow: 0 },
|
||||
{ prefixCount: 14, rowCount: 1, referenceRow: 0 },
|
||||
{ prefixCount: 0, rowCount: 20, referenceRow: 0 },
|
||||
{ prefixCount: 0, rowCount: 20, referenceRow: 5 },
|
||||
{ prefixCount: 0, rowCount: 20, referenceRow: 19 },
|
||||
{ prefixCount: 0, rowCount: 20, referenceRow: 0, noteParagraphs: 30 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraph: 10 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraph: 19 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, columnCount: 2, referenceColumn: 1 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, noteParagraphs: 30 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19] },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19], restartEachPage: true },
|
||||
{ prefixCount: 0, rowCount: 20, referenceRow: 0, referenceRows: [0, 4, 8, 12, 16, 19], restartEachPage: true, cantSplit: true, noteParagraphs: 1 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19], restartEachPage: true, startNumber: 9 },
|
||||
{ prefixCount: 7, rowCount: 12, referenceRow: 0, referenceRows: [0, 3, 6, 9, 11], restartEachPage: true, cantSplit: true, noteParagraphs: 1, exactRowHeight: 30 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19], referenceColumns: [0, 1], columnCount: 2, restartEachPage: true, noteParagraphs: 1 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, columnCount: 2, referenceColumns: [0, 1] },
|
||||
].flatMap((sample, caseIndex) => [false, true].map((incremental) => ({ ...sample, incremental, caseIndex }))))('keeps table references with their notes (case $caseIndex, incremental: $incremental)', ({ prefixCount, rowCount, referenceRow, incremental, noteParagraphs = 4, cellParagraphs = 1, referenceParagraph = 0, columnCount = 1, referenceColumn = 0, referenceParagraphs = [referenceParagraph], referenceColumns = [referenceColumn], restartEachPage = false, startNumber = 1, cantSplit = false, referenceRows = [referenceRow], exactRowHeight = 0 }) => {
|
||||
const T = DataStreamTreeTokenType;
|
||||
const cellText = (row: number, column: number) => `${Array.from({ length: cellParagraphs }, (_, paragraph) =>
|
||||
`Cell${referenceRows.includes(row) && referenceColumns.includes(column) && referenceParagraphs.includes(paragraph) ? '\uFFFC' : ''}${T.PARAGRAPH}`).join('')}${T.SECTION_BREAK}`;
|
||||
const tableStream = `${T.TABLE_START}${Array.from({ length: rowCount }, (_, index) => `${T.TABLE_ROW_START}${Array.from({ length: columnCount }, (_, column) => `${T.TABLE_CELL_START}${cellText(index, column)}${T.TABLE_CELL_END}`).join('')}${T.TABLE_ROW_END}`).join('')}${T.TABLE_END}`;
|
||||
const prefix = 'Intro\r'.repeat(prefixCount);
|
||||
const dataStream = `${prefix}${tableStream}\r\n`;
|
||||
const noteStream = `${'Explanation\r'.repeat(noteParagraphs)}\n`;
|
||||
const references = [...dataStream.matchAll(/\uFFFC/g)].map((match, index) => ({ index: match.index!, id: `note-${index}` }));
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: 200 } },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `p-${index}` })),
|
||||
sectionBreaks: [{ sectionId: 'main', startIndex: dataStream.length - 1 }],
|
||||
tables: [{ tableId: 'table', startIndex: prefix.length, endIndex: prefix.length + tableStream.length }],
|
||||
customRanges: references.map(({ index, id }) => ({ rangeId: `ref-${id}`, rangeType: CustomRangeType.FOOTNOTE, startIndex: index, endIndex: index, wholeEntity: true, properties: { noteId: id } })),
|
||||
},
|
||||
tableSource: {
|
||||
table: {
|
||||
tableId: 'table',
|
||||
align: 0,
|
||||
indent: { v: 0 },
|
||||
textWrap: 0,
|
||||
size: { type: 0, width: { v: 300 } },
|
||||
tableRows: Array.from({ length: rowCount }, () => ({ cantSplit: cantSplit ? 1 : 0, tableCells: Array.from({ length: columnCount }, () => ({ size: { type: 0, width: { v: 300 / columnCount } } })), trHeight: { val: { v: exactRowHeight }, hRule: exactRowHeight > 0 ? 2 : 0 } })),
|
||||
tableColumns: Array.from({ length: columnCount }, () => ({ size: { type: 0, width: { v: 300 / columnCount } } })),
|
||||
},
|
||||
},
|
||||
noteSettings: { footnote: restartEachPage ? { restart: 'eachPage', startNumber } : undefined },
|
||||
notes: Object.fromEntries(references.map(({ id }) => [id, { type: 'footnote' as const, noteId: id, body: {
|
||||
dataStream: noteStream,
|
||||
paragraphs: [...noteStream.matchAll(/\r/g)].map((match, index) => ({ paragraphId: `note-${index}`, startIndex: match.index! })),
|
||||
sectionBreaks: [{ sectionId: 'note-section', startIndex: noteStream.length - 1 }],
|
||||
} }])),
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
if (incremental) {
|
||||
const generation = skeleton.startIncrementalLayout();
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let step = 0; step < 20_000 && !progress.complete; step++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress).toMatchObject({ complete: true, cancelled: false });
|
||||
} else {
|
||||
skeleton.calculate();
|
||||
}
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
if (prefixCount === 0 && rowCount === 1 && cellParagraphs === 1) {
|
||||
expect(pages).toHaveLength(1);
|
||||
} else {
|
||||
expect(pages.length).toBeGreaterThan(1);
|
||||
}
|
||||
const tablePages = pages.filter((page) => page.skeTables.size > 0);
|
||||
if (restartEachPage) {
|
||||
const markerWidths = new Map<string, number>();
|
||||
for (const page of tablePages) {
|
||||
const refs = [...page.skeTables.values()].flatMap((table) => table.rows.flatMap((row) => row.cells.flatMap(getPageGlyphs))).filter((glyph) => glyph.noteId);
|
||||
expect(refs.map((glyph) => glyph.content)).toEqual(refs.map((_, index) => String(index + startNumber)));
|
||||
for (const glyph of refs) {
|
||||
const previousWidth = markerWidths.get(glyph.content);
|
||||
if (previousWidth != null) {
|
||||
expect(glyph.width).toBeCloseTo(previousWidth);
|
||||
}
|
||||
markerWidths.set(glyph.content, glyph.width);
|
||||
const note = page.notes?.find((fragment) => fragment.noteId === glyph.noteId && !fragment.continued);
|
||||
expect(note).toBeDefined();
|
||||
expect(getPageGlyphs(note!.page).find((item) => item.count === 0)?.content).toBe(glyph.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = tablePages.flatMap((page) => [...page.skeTables.values()].flatMap((table) => table.rows));
|
||||
expect(new Set(rows.map((row) => row.index)).size).toBe(rowCount);
|
||||
for (let column = 0; column < columnCount; column++) {
|
||||
const text = rows.flatMap((row) => getPageGlyphs(row.cells[column])).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('');
|
||||
expect(text).toBe(Array.from({ length: rowCount }, (_, row) => cellText(row, column)).join(''));
|
||||
}
|
||||
const page = tablePages.find((page) => [...page.skeTables.values()].some((table) =>
|
||||
table.rows.some((row) => row.cells.some((cell) => getPageGlyphs(cell).some((glyph) => glyph.noteId === 'note-0')))))!;
|
||||
expect(page.notes!.some((note) => note.noteId === 'note-0')).toBe(true);
|
||||
const fragments = pages.flatMap((page) => page.notes ?? []);
|
||||
expect(fragments[0].continued).toBe(false);
|
||||
for (const { id } of references) {
|
||||
const noteFragments = fragments.filter((fragment) => fragment.noteId === id);
|
||||
expect(noteFragments[0].continued).toBe(false);
|
||||
expect(noteFragments.slice(1).every((fragment) => fragment.continued)).toBe(true);
|
||||
expect(noteFragments.flatMap((fragment) => getPageGlyphs(fragment.page)).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(noteStream);
|
||||
}
|
||||
const table = [...page.skeTables.values()][0];
|
||||
expect(page.marginTop + table.top + table.height).toBeLessThanOrEqual(page.noteDecorations![0].top + 0.01);
|
||||
for (const tablePage of tablePages) {
|
||||
if (tablePage.footnoteHeight) {
|
||||
const bottom = Math.max(...[...tablePage.skeTables.values()].map((table) => table.top + table.height));
|
||||
expect(tablePage.marginTop + bottom).toBeLessThanOrEqual(tablePage.noteDecorations![0].top + 0.01);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['pageBottom', 'beneathText'] as const)('positions a short note at %s without changing its text', (position) => {
|
||||
const body = {
|
||||
dataStream: 'Explanation\r\n',
|
||||
paragraphs: [{ paragraphId: 'np', startIndex: 11 }],
|
||||
sectionBreaks: [{ sectionId: 'ns', startIndex: 12 }],
|
||||
};
|
||||
const bed = createNoteTestBed(600, { body });
|
||||
bed.dataModel.getSnapshot().noteSettings = { footnote: { position } };
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages).toHaveLength(1);
|
||||
const page = pages[0];
|
||||
const note = page.notes![0];
|
||||
const separator = page.noteDecorations![0];
|
||||
const bodyLine = page.sections[0].columns[0].lines[0];
|
||||
const bodyBottom = page.marginTop + bodyLine.top + bodyLine.lineHeight;
|
||||
expect(note.top).toBeCloseTo(separator.top + separator.page.height);
|
||||
if (position === 'beneathText') {
|
||||
expect(separator.top).toBeCloseTo(bodyBottom);
|
||||
expect(note.top + note.page.height).toBeLessThan(page.pageHeight - page.marginBottom - 100);
|
||||
} else {
|
||||
expect(note.top + note.page.height).toBeCloseTo(page.pageHeight - page.marginBottom);
|
||||
expect(separator.top).toBeGreaterThan(bodyBottom + 100);
|
||||
}
|
||||
expect(getPageGlyphs(note.page).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(body.dataStream);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('reserves custom separator and continuation notice paragraphs and publishes them to the Worker consumer', () => {
|
||||
const bed = createNoteTestBed(200);
|
||||
const makeBody = (text: string) => ({
|
||||
dataStream: `${text}\r\n`,
|
||||
paragraphs: [{ paragraphId: 'decoration', startIndex: text.length, paragraphStyle: { spaceBelow: { v: 17 } } }],
|
||||
sectionBreaks: [{ sectionId: 'decoration', startIndex: text.length + 1 }],
|
||||
});
|
||||
bed.dataModel.getSnapshot().noteSettings = { footnote: {
|
||||
separator: makeBody('Notes'),
|
||||
continuationSeparator: makeBody('Notes continued'),
|
||||
continuationNotice: makeBody('Continued on the following page'),
|
||||
} };
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages.length).toBeGreaterThan(1);
|
||||
for (let index = 0; index < pages.length; index++) {
|
||||
const page = pages[index];
|
||||
const decorations = page.noteDecorations!;
|
||||
const separator = decorations[0];
|
||||
expect(separator.kind).toBe(index === 0 ? 'separator' : 'continuationSeparator');
|
||||
expect(getPageGlyphs(separator.page).map((glyph) => glyph.raw).join('')).toBe(index === 0 ? 'Notes\r\n' : 'Notes continued\r\n');
|
||||
expect(page.notes![0].top).toBeCloseTo(separator.top + separator.page.height);
|
||||
const lastLine = separator.page.sections[0].columns[0].lines.slice(-1)[0];
|
||||
expect(separator.page.height).toBeCloseTo(lastLine.top + lastLine.lineHeight + 17);
|
||||
const notice = decorations.find((decoration) => decoration.kind === 'continuationNotice');
|
||||
expect(Boolean(notice)).toBe(index < pages.length - 1);
|
||||
if (notice) {
|
||||
const noteBottom = Math.max(...page.notes!.map((note) => note.top + note.page.height));
|
||||
expect(notice.top).toBeCloseTo(noteBottom);
|
||||
expect(notice.top + notice.page.height).toBeLessThanOrEqual(page.pageHeight - page.marginBottom + 0.01);
|
||||
}
|
||||
const patch = serializeDocumentSkeletonPage(page, true);
|
||||
expect(() => JSON.stringify(patch)).not.toThrow();
|
||||
const restored = hydrateDocumentSkeletonPage(patch, undefined, bed.dataModel.getSnapshot());
|
||||
const restoredPage = restored.noteDecorations![0].page;
|
||||
expect(restoredPage.sections[0].parent).toBe(restoredPage);
|
||||
expect(getPageGlyphs(restoredPage).map((glyph) => glyph.raw)).toEqual(getPageGlyphs(separator.page).map((glyph) => glyph.raw));
|
||||
}
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([10, 11, 12, 13, 14, 15])('does not carry a speculative note when its reference line moves after %i paragraphs', (prefixCount) => {
|
||||
const prefix = 'Intro\r'.repeat(prefixCount);
|
||||
const dataStream = `${prefix}\uFFFC ${'A paragraph that wraps over several lines. '.repeat(8)}\r\n`;
|
||||
const noteStream = 'A short explanation.\r\n';
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: 200 } },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `p-${index}` })),
|
||||
sectionBreaks: [{ sectionId: 'main', startIndex: dataStream.length - 1 }],
|
||||
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: prefix.length, endIndex: prefix.length, wholeEntity: true, properties: { noteId: 'note' } }],
|
||||
},
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: noteStream,
|
||||
paragraphs: [{ paragraphId: 'note-text', startIndex: noteStream.length - 2 }],
|
||||
sectionBreaks: [{ sectionId: 'note-section', startIndex: noteStream.length - 1 }],
|
||||
} } },
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const fragments = pages.flatMap((page) => page.notes ?? []);
|
||||
expect(fragments).toHaveLength(1);
|
||||
expect(fragments.flatMap((fragment) => getPageGlyphs(fragment.page)).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(noteStream);
|
||||
expect(pages.find((page) => getPageGlyphs(page).some((glyph) => glyph.noteId === 'note'))?.notes).toHaveLength(1);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses short and full-width default separator marks on first and continued pages', () => {
|
||||
const bed = createNoteTestBed(200);
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages.length).toBeGreaterThan(1);
|
||||
for (const [index, page] of pages.entries()) {
|
||||
const separator = page.noteDecorations![0];
|
||||
const marker = getPageGlyphs(separator.page).find((glyph) => glyph.noteSeparator)!;
|
||||
expect(marker.raw).toBe('\uFFFC');
|
||||
expect(marker.count).toBe(1);
|
||||
expect(marker.width).toBe(index === 0 ? 192 : page.pageWidth - page.marginLeft - page.marginRight);
|
||||
}
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('publishes note fragments without parent cycles and restores their local hit-test hierarchy', () => {
|
||||
const bed = createNoteTestBed();
|
||||
const reference = resolveNoteReferences(bed.dataModel.getSnapshot()).get(9)!;
|
||||
const pages = layoutNoteBody(bed.ctx, reference, bed.sectionBreakConfig, {
|
||||
width: 180,
|
||||
firstPageHeight: 100,
|
||||
continuationPageHeight: 100,
|
||||
});
|
||||
bed.curPage.notes = [{
|
||||
noteId: 'note',
|
||||
referenceIndex: 9,
|
||||
continued: false,
|
||||
left: 20,
|
||||
top: 400,
|
||||
page: pages[0],
|
||||
}];
|
||||
const patch = serializeDocumentSkeletonPage(bed.curPage, true);
|
||||
expect(() => JSON.stringify(patch)).not.toThrow();
|
||||
const restored = hydrateDocumentSkeletonPage(patch, undefined, bed.dataModel.getSnapshot());
|
||||
const notePage = restored.notes![0].page;
|
||||
const section = notePage.sections[0];
|
||||
const column = section.columns[0];
|
||||
const line = column.lines[0];
|
||||
const divide = line.divides[0];
|
||||
expect(section.parent).toBe(notePage);
|
||||
expect(column.parent).toBe(section);
|
||||
expect(line.parent).toBe(column);
|
||||
expect(divide.glyphGroup[0].parent).toBe(divide);
|
||||
expect(notePage.st).toBe(0);
|
||||
expect(restored.notes![0].top).toBe(400);
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentData, IDocumentNote, IFootnoteProperties } from '@univerjs/core';
|
||||
import { CustomRangeType } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatNoteNumber, resolveNoteReferences } from '../note-numbering';
|
||||
|
||||
function createSnapshot(restart: IFootnoteProperties['restart']): Pick<IDocumentData, 'body' | 'notes' | 'noteSettings'> {
|
||||
const notes: Record<string, IDocumentNote> = {};
|
||||
for (let index = 0; index < 5; index++) {
|
||||
const noteId = `note-${index}`;
|
||||
notes[noteId] = { type: 'footnote' as const, noteId, body: { dataStream: 'Explanation\r\n' } };
|
||||
}
|
||||
notes['note-1'].customMark = '†';
|
||||
return {
|
||||
body: {
|
||||
dataStream: '\uFFFC\uFFFC\uFFFC\r\n\uFFFC\uFFFC\r\n',
|
||||
customRanges: [0, 1, 2, 5, 6].map((offset, index) => ({
|
||||
rangeId: `reference-${index}`,
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
startIndex: offset,
|
||||
endIndex: offset,
|
||||
wholeEntity: true,
|
||||
properties: { noteId: `note-${index}` },
|
||||
})),
|
||||
sectionBreaks: [
|
||||
{ sectionId: 'section-1', startIndex: 4 },
|
||||
{ sectionId: 'section-2', startIndex: 8 },
|
||||
],
|
||||
},
|
||||
notes,
|
||||
noteSettings: { footnote: { restart, startNumber: 3, numberFormat: 'lowerRoman' } },
|
||||
};
|
||||
}
|
||||
|
||||
describe('footnote numbering', () => {
|
||||
it('keeps custom marks out of the automatic sequence without changing persisted text', () => {
|
||||
const snapshot = createSnapshot('continuous');
|
||||
const original = JSON.stringify(snapshot);
|
||||
const references = resolveNoteReferences(snapshot);
|
||||
expect([...references.values()].map((reference) => reference.label)).toEqual(['iii', '†', 'iv', 'v', 'vi']);
|
||||
expect(JSON.stringify(snapshot)).toBe(original);
|
||||
});
|
||||
|
||||
it('restarts at section boundaries and inherits the document number format', () => {
|
||||
const snapshot = createSnapshot('eachSect');
|
||||
snapshot.body!.sectionBreaks![1].noteProperties = { footnote: { startNumber: 9 } };
|
||||
expect([...resolveNoteReferences(snapshot).values()].map((reference) => reference.label))
|
||||
.toEqual(['iii', '†', 'iv', 'ix', 'x']);
|
||||
});
|
||||
|
||||
it('renumbers after a reference moves to a new physical page without rewriting the reference', () => {
|
||||
const snapshot = createSnapshot('eachPage');
|
||||
const pages = new Map([['note-0', 0], ['note-1', 1], ['note-2', 1], ['note-3', 2], ['note-4', 2]]);
|
||||
expect([...resolveNoteReferences(snapshot, pages).values()].map((reference) => reference.label))
|
||||
.toEqual(['iii', '†', 'iii', 'iii', 'iv']);
|
||||
pages.set('note-4', 3);
|
||||
const references = resolveNoteReferences(snapshot, pages);
|
||||
expect(references.get(6)).toMatchObject({ noteId: 'note-4', label: 'iii' });
|
||||
expect(snapshot.body!.customRanges![4].startIndex).toBe(6);
|
||||
});
|
||||
|
||||
it('extends the symbol cycle and supports enclosed and full-width numbers', () => {
|
||||
expect([1, 6, 7, 13].map((number) => formatNoteNumber(number, 'chicago'))).toEqual(['*', '¶', '**', '***']);
|
||||
expect([1, 20, 21, 35, 36, 50].map((number) => formatNoteNumber(number, 'decimalEnclosedCircle')))
|
||||
.toEqual(['①', '⑳', '㉑', '㉟', '㊱', '㊿']);
|
||||
expect(formatNoteNumber(108, 'decimalFullWidth')).toBe('108');
|
||||
});
|
||||
});
|
||||
+144
@@ -17,11 +17,13 @@
|
||||
import type { IDocumentSkeletonDivide, IDocumentSkeletonGlyph } from '../../../../../../basics/i-document-skeleton-cached';
|
||||
import type { IParagraphConfig } from '../../../../../../basics/interfaces';
|
||||
import {
|
||||
AlignTypeH,
|
||||
BooleanNumber,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentFlavor,
|
||||
DrawingTypeEnum,
|
||||
GridType,
|
||||
ObjectRelativeFromH,
|
||||
ObjectRelativeFromV,
|
||||
PositionedObjectLayoutType,
|
||||
SpacingRule,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { GlyphType, LineType } from '../../../../../../basics/i-document-skeleton-cached';
|
||||
import { setDocsCustomBlockRenderViewportProvider } from '../../../../custom-block-render-viewport';
|
||||
import { DocumentSkeleton } from '../../../doc-skeleton';
|
||||
import { Lang } from '../../../hyphenation/lang';
|
||||
import { BreakPointType } from '../../../line-breaker/break';
|
||||
import { createSkeletonCustomBlockGlyph } from '../../../model/glyph';
|
||||
@@ -60,6 +63,146 @@ describe('layout-ruler', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([DocumentFlavor.TRADITIONAL, DocumentFlavor.MODERN].flatMap((documentFlavor) =>
|
||||
[false, true].map((incremental) => ({ documentFlavor, incremental }))))('preserves table terminators without adding traditional blank lines (flavor: $documentFlavor, incremental: $incremental)', ({ documentFlavor, incremental }) => {
|
||||
const T = DataStreamTreeTokenType;
|
||||
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
|
||||
const dataStream = `${tableStream}\r\rAfter\r\n`;
|
||||
const anchorIndex = tableStream.length;
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].filter((match) => match.index !== anchorIndex).map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index })),
|
||||
sectionBreaks: [{ sectionId: 'cell', startIndex: dataStream.indexOf('\n') }, { sectionId: 'body', startIndex: dataStream.length - 1 }],
|
||||
tables: [{ tableId: 'table', startIndex: 0, endIndex: anchorIndex }],
|
||||
},
|
||||
tableSource: { table: {
|
||||
tableId: 'table',
|
||||
align: 0,
|
||||
indent: { v: 0 },
|
||||
textWrap: 0,
|
||||
size: { type: 0, width: { v: 200 } },
|
||||
tableRows: [{ tableCells: [{ size: { type: 0, width: { v: 200 } } }], trHeight: { val: { v: 0 }, hRule: 0 } }],
|
||||
tableColumns: [{ size: { type: 0, width: { v: 200 } } }],
|
||||
} },
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
if (incremental) {
|
||||
const generation = skeleton.startIncrementalLayout();
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let step = 0; step < 20 && !progress.complete; step++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress.complete).toBe(true);
|
||||
} else {
|
||||
skeleton.calculate();
|
||||
}
|
||||
const page = skeleton.getSkeletonData()!.pages[0];
|
||||
const table = page.skeTables.get('table')!;
|
||||
const lines = page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines));
|
||||
const anchor = lines.find((line) => line.paragraphIndex === anchorIndex)!;
|
||||
const emptyParagraph = lines.find((line) => line.paragraphIndex === anchorIndex + 1)!;
|
||||
if (documentFlavor === DocumentFlavor.TRADITIONAL) {
|
||||
expect(anchor.lineHeight).toBe(0);
|
||||
} else {
|
||||
expect(anchor.lineHeight).toBeGreaterThan(0);
|
||||
}
|
||||
expect(emptyParagraph.lineHeight).toBeGreaterThan(0);
|
||||
expect(emptyParagraph.top).toBeCloseTo(table.top + table.height + anchor.lineHeight);
|
||||
expect(bed.dataModel.getBody()?.dataStream).toBe(dataStream);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ relativeFrom: ObjectRelativeFromH.MARGIN, align: AlignTypeH.CENTER, expectedLeft: 10 },
|
||||
{ relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 60, expectedLeft: 20 },
|
||||
{ relativeFrom: ObjectRelativeFromH.COLUMN, posOffset: 20, expectedLeft: 20 },
|
||||
])('positions floating tables in body coordinates for anchor $relativeFrom', ({ expectedLeft, ...positionH }) => {
|
||||
const T = DataStreamTreeTokenType;
|
||||
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
|
||||
const dataStream = `${tableStream}\rAfter\r\n`;
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, marginLeft: 40, marginRight: 20 },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index })),
|
||||
sectionBreaks: [{ sectionId: 'cell', startIndex: dataStream.indexOf('\n') }, { sectionId: 'body', startIndex: dataStream.length - 1 }],
|
||||
tables: [{ tableId: 'table', startIndex: 0, endIndex: tableStream.length }],
|
||||
},
|
||||
tableSource: { table: {
|
||||
tableId: 'table',
|
||||
align: 0,
|
||||
indent: { v: 0 },
|
||||
textWrap: TableTextWrapType.WRAP,
|
||||
dist: { distT: 0, distB: 0, distL: 0, distR: 0 },
|
||||
position: { positionH, positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 10 } },
|
||||
size: { type: 0, width: { v: 320 } },
|
||||
tableRows: [{ tableCells: [{ size: { type: 0, width: { v: 320 } } }], trHeight: { val: { v: 0 }, hRule: 0 } }],
|
||||
tableColumns: [{ size: { type: 0, width: { v: 320 } } }],
|
||||
} },
|
||||
});
|
||||
try {
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
skeleton.calculate();
|
||||
const tables = skeleton.getSkeletonData()!.pages.flatMap((page) => [...page.skeTables.values()]);
|
||||
expect(tables).toHaveLength(1);
|
||||
const table = tables[0];
|
||||
expect(table.left).toBeCloseTo(expectedLeft);
|
||||
expect(table.top).toBeCloseTo(10);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps words intact below floating tables while retaining usable side wraps', () => {
|
||||
const T = DataStreamTreeTokenType;
|
||||
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
|
||||
const dataStream = `${tableStream}\rEn las Quintas columnas\r\n`;
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index })),
|
||||
sectionBreaks: [{ sectionId: 'cell', startIndex: dataStream.indexOf('\n') }, { sectionId: 'body', startIndex: dataStream.length - 1 }],
|
||||
tables: [{ tableId: 'table', startIndex: 0, endIndex: tableStream.length }],
|
||||
},
|
||||
tableSource: { table: {
|
||||
tableId: 'table',
|
||||
align: 0,
|
||||
indent: { v: 0 },
|
||||
textWrap: TableTextWrapType.WRAP,
|
||||
dist: { distT: 0, distB: 0, distL: 0, distR: 0 },
|
||||
position: {
|
||||
positionH: { relativeFrom: ObjectRelativeFromH.COLUMN, posOffset: 30 },
|
||||
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 0 },
|
||||
},
|
||||
size: { type: 0, width: { v: 300 } },
|
||||
tableRows: [{ tableCells: [{ size: { type: 0, width: { v: 300 } } }], trHeight: { val: { v: 120 }, hRule: 0 } }],
|
||||
tableColumns: [{ size: { type: 0, width: { v: 300 } } }],
|
||||
} },
|
||||
});
|
||||
try {
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
skeleton.calculate();
|
||||
const page = skeleton.getSkeletonData()!.pages[0];
|
||||
const table = [...page.skeTables.values()][0];
|
||||
const lines = page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines));
|
||||
const text = (line: typeof lines[number]) => line.divides.map((divide) => divide.glyphGroup.map((glyph) => glyph.content).join('')).join('');
|
||||
expect(lines.map(text).join('')).toContain('En las Quintas columnas');
|
||||
expect(lines.find((line) => text(line).includes('En'))!.top).toBeLessThan(table.top + table.height);
|
||||
expect(lines.find((line) => text(line).includes('Quintas'))!.top).toBeGreaterThanOrEqual(table.top + table.height);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
function createGlyph(content: string, width: number): IDocumentSkeletonGlyph {
|
||||
return {
|
||||
content,
|
||||
@@ -981,6 +1124,7 @@ describe('layout-ruler', () => {
|
||||
0,
|
||||
14,
|
||||
page,
|
||||
[page],
|
||||
column,
|
||||
section,
|
||||
cache,
|
||||
|
||||
+167
@@ -34,6 +34,9 @@ import {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { GlyphType } from '../../../../../../basics/i-document-skeleton-cached';
|
||||
import { setDocsCustomBlockRenderViewportProvider } from '../../../../custom-block-render-viewport';
|
||||
import { getDocumentCompatibilityPolicy } from '../../../../document-compatibility';
|
||||
import { DocumentSkeleton } from '../../../doc-skeleton';
|
||||
import { FontCache } from '../../../shaping-engine/font-cache';
|
||||
import { updateInlineDrawingCoordsAndBorder } from '../../../tools';
|
||||
import { lineBreaking } from '../linebreaking';
|
||||
import { shaping } from '../shaping';
|
||||
@@ -105,6 +108,7 @@ describe('linebreaking', () => {
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setDocsCustomBlockRenderViewportProvider(null);
|
||||
});
|
||||
|
||||
@@ -355,6 +359,91 @@ describe('linebreaking', () => {
|
||||
expect(paragraphLines(result[0], secondParagraph.endIndex).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps the first cell paragraph height when its leading page break is redundant', () => {
|
||||
const heights: number[] = [];
|
||||
for (const breakText of ['', '\f']) {
|
||||
const T = DataStreamTreeTokenType;
|
||||
const cellText = `${breakText}\rEquation\r\r\n`;
|
||||
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}${cellText}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
|
||||
const dataStream = `${tableStream}\r\n`;
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...cellText.matchAll(/\r/g)].map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index + 3 })),
|
||||
sectionBreaks: [{ sectionId: 'cell', startIndex: cellText.length + 2 }, { sectionId: 'body', startIndex: dataStream.length - 1 }],
|
||||
tables: [{ tableId: 'table', startIndex: 0, endIndex: tableStream.length }],
|
||||
},
|
||||
tableSource: { table: {
|
||||
tableId: 'table',
|
||||
align: 0,
|
||||
indent: { v: 0 },
|
||||
textWrap: 0,
|
||||
size: { type: 0, width: { v: 200 } },
|
||||
tableRows: [{ tableCells: [{ size: { type: 0, width: { v: 200 } } }], trHeight: { val: { v: 0 }, hRule: 0 } }],
|
||||
tableColumns: [{ size: { type: 0, width: { v: 200 } } }],
|
||||
} },
|
||||
});
|
||||
try {
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages).toHaveLength(1);
|
||||
const table = pages[0].skeTables.get('table')!;
|
||||
const lines = table.rows[0].cells[0].sections[0].columns[0].lines;
|
||||
expect(lines[0].lineHeight).toBeGreaterThan(0);
|
||||
expect(lines.flatMap((line) => line.divides).flatMap((divide) => divide.glyphGroup).map((glyph) => glyph.raw).join('')).toBe(cellText);
|
||||
heights.push(table.height);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
}
|
||||
expect(heights[1]).toBeCloseTo(heights[0]);
|
||||
});
|
||||
|
||||
it.each([undefined, BooleanNumber.FALSE, BooleanNumber.TRUE])(
|
||||
'keeps a trailing paragraph mark on the manual break line unless split compatibility is enabled (%s)',
|
||||
(splitPageBreakAndParagraphMark) => {
|
||||
const testBed = createSectionLayoutTestBed(['Before\f', '', 'Chapter'], {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, splitPageBreakAndParagraphMark },
|
||||
});
|
||||
const pages = [testBed.curPage];
|
||||
for (const paragraph of testBed.sectionNode.children) {
|
||||
const result = lineBreaking(
|
||||
testBed.ctx,
|
||||
testBed.viewModel,
|
||||
shaping(testBed.ctx, paragraph.content!, testBed.viewModel, paragraph, testBed.sectionBreakConfig),
|
||||
pages[pages.length - 1],
|
||||
paragraph,
|
||||
testBed.sectionBreakConfig,
|
||||
null
|
||||
);
|
||||
pages.push(...result.slice(1));
|
||||
}
|
||||
const pageGlyphs = pages.map((page) => page.sections.flatMap((section) =>
|
||||
section.columns.flatMap((column) => column.lines.flatMap((line) =>
|
||||
line.divides.flatMap((divide) => divide.glyphGroup)
|
||||
))
|
||||
));
|
||||
expect(pages).toHaveLength(2);
|
||||
expect(pageGlyphs.flat().map((glyph) => glyph.raw).join('')).toBe(testBed.dataModel.getBody()!.dataStream);
|
||||
expect(pageGlyphs[0].map((glyph) => glyph.raw).join('')).toBe(
|
||||
splitPageBreakAndParagraphMark === BooleanNumber.TRUE ? 'Before\f' : 'Before\f\r'
|
||||
);
|
||||
const nextPageLines = pages[1].sections[0].columns[0].lines;
|
||||
// The separately authored empty paragraph remains, even when the break's own terminator stays behind.
|
||||
expect(nextPageLines.filter((line) => line.divides.flatMap((divide) => divide.glyphGroup)
|
||||
.map((glyph) => glyph.raw)
|
||||
.join('') === '\r')).toHaveLength(
|
||||
splitPageBreakAndParagraphMark === BooleanNumber.TRUE ? 2 : 1
|
||||
);
|
||||
for (const glyph of pageGlyphs.flat()) {
|
||||
expect(glyph.parent?.glyphGroup).toContain(glyph);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('does not create a blank page for a manual page break at the top of an empty page', () => {
|
||||
const testBed = createParagraphLayoutTestBed(DataStreamTreeTokenType.PAGE_BREAK, {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
@@ -567,6 +656,48 @@ describe('linebreaking', () => {
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([0, 19])('an obsolete rendered page marker at offset %s does not change wrapping or disable widow control', (offset) => {
|
||||
vi.spyOn(FontCache, 'getNormalLineHeight').mockReturnValue(14.8);
|
||||
const text = 'One two three four five six seven eight nine ten eleven twelve';
|
||||
const layout = (marker: boolean) => {
|
||||
const content = marker ? `${text.slice(0, offset)}\f${text.slice(offset)}` : text;
|
||||
const bed = createParagraphLayoutTestBed(content, {
|
||||
documentStyle: {
|
||||
documentFlavor: DocumentFlavor.TRADITIONAL,
|
||||
pageSize: { width: 120, height: 85 },
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
marginLeft: 20,
|
||||
marginRight: 20,
|
||||
},
|
||||
body: { renderedPageBreaks: marker ? [offset] : [] },
|
||||
});
|
||||
bed.sectionBreakConfig.documentCompatibilityPolicy = getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL);
|
||||
bed.curPage.pageNumber = 5;
|
||||
const pages = lineBreaking(
|
||||
bed.ctx,
|
||||
bed.viewModel,
|
||||
shaping(bed.ctx, bed.paragraphNode.content!, bed.viewModel, bed.paragraphNode, bed.sectionBreakConfig),
|
||||
bed.curPage,
|
||||
bed.paragraphNode,
|
||||
bed.sectionBreakConfig,
|
||||
null
|
||||
);
|
||||
const lines = pages.map((page) => paragraphLines(page, bed.paragraphNode.endIndex));
|
||||
expect(lines.flatMap((pageLines) => pageLines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup)))
|
||||
.map((glyph) => glyph.raw).join('')).toBe(bed.dataModel.getBody()!.dataStream);
|
||||
return lines.map((pageLines) => pageLines.map((line) => ({
|
||||
top: line.top,
|
||||
height: line.lineHeight,
|
||||
text: line.divides.flatMap((divide) => divide.glyphGroup).map((glyph) => glyph.raw).join('').replace(/\f/g, ''),
|
||||
})));
|
||||
};
|
||||
const expected = layout(false);
|
||||
expect(expected[0][0].height).toBeCloseTo(14.8);
|
||||
expect(expected.map((lines) => lines.length).filter(Boolean)).not.toContain(1);
|
||||
expect(layout(true)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('preserves a rendered page break before a fitting inline drawing', () => {
|
||||
const beforeBreak = 'Intro';
|
||||
const content = `${beforeBreak}${DataStreamTreeTokenType.PAGE_BREAK}${DataStreamTreeTokenType.CUSTOM_BLOCK}`;
|
||||
@@ -1193,6 +1324,42 @@ describe('linebreaking', () => {
|
||||
expect(testBed.ctx.paginationMetrics?.retryCount).toBeLessThanOrEqual(contents.length);
|
||||
});
|
||||
|
||||
it.each([2, 3])('keeps a short %s-line paragraph together instead of leaving a single widow or orphan', (lineCount) => {
|
||||
const layout = (widowControl: BooleanNumber) => {
|
||||
const contents = [...Array.from({ length: 4 - lineCount }, () => 'Prefix'), new Array(lineCount).fill('abcdefg').join(' ')];
|
||||
let offset = 0;
|
||||
const paragraphs = contents.map((content) => {
|
||||
offset += content.length;
|
||||
return { startIndex: offset++, paragraphStyle: { widowControl } };
|
||||
});
|
||||
const bed = createSectionLayoutTestBed(contents, {
|
||||
documentStyle: {
|
||||
documentFlavor: DocumentFlavor.TRADITIONAL,
|
||||
pageSize: { width: 120, height: 85 },
|
||||
},
|
||||
body: { paragraphs },
|
||||
});
|
||||
let pages = [bed.curPage];
|
||||
for (const paragraph of bed.sectionNode.children) {
|
||||
const next = lineBreaking(bed.ctx, bed.viewModel, shaping(bed.ctx, paragraph.content!, bed.viewModel, paragraph, bed.sectionBreakConfig), pages[pages.length - 1], paragraph, bed.sectionBreakConfig, null);
|
||||
pages = [...pages.slice(0, -1), ...next];
|
||||
}
|
||||
const targetIndex = paragraphs[paragraphs.length - 1].startIndex;
|
||||
const counts = pages.map((page) => paragraphLines(page, targetIndex).length);
|
||||
const text = pages.flatMap((page) => page.sections)
|
||||
.flatMap((section) => section.columns)
|
||||
.flatMap((column) => column.lines)
|
||||
.flatMap((line) => line.divides)
|
||||
.flatMap((divide) => divide.glyphGroup)
|
||||
.map((glyph) => glyph.raw)
|
||||
.join('');
|
||||
expect(text).toBe(bed.dataModel.getBody()!.dataStream);
|
||||
return counts;
|
||||
};
|
||||
expect(layout(BooleanNumber.FALSE)).toEqual([lineCount - 1, 1]);
|
||||
expect(layout(BooleanNumber.TRUE)).toEqual([0, lineCount]);
|
||||
});
|
||||
|
||||
it('DOCX golden e2e avoids a single natural widow line and lets oversized keepLines paragraphs terminate', () => {
|
||||
const content = 'One two three four five six seven eight nine ten eleven twelve';
|
||||
const widowBed = createParagraphLayoutTestBed(content, {
|
||||
|
||||
+268
-89
@@ -44,7 +44,7 @@ import {
|
||||
TabStopAlignment,
|
||||
WrapStrategy,
|
||||
} from '@univerjs/core';
|
||||
import { GlyphType, LineType } from '../../../../../basics/i-document-skeleton-cached';
|
||||
import { DocumentSkeletonPageType, GlyphType, LineType } from '../../../../../basics/i-document-skeleton-cached';
|
||||
import { isCjkLeftAlignedPunctuation } from '../../../../../basics/tools';
|
||||
import { getDocsCustomBlockRenderViewport } from '../../../custom-block-render-viewport';
|
||||
import { isTraditionalDocumentCompatibility } from '../../../document-compatibility';
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
createAndUpdateBlockAnchor,
|
||||
createSkeletonLine,
|
||||
setLineMarginBottom,
|
||||
TRADITIONAL_TABLE_WRAP_MIN_WIDTH,
|
||||
updateDivideInfo,
|
||||
} from '../../model/line';
|
||||
import { createSkeletonPage } from '../../model/page';
|
||||
@@ -188,7 +189,8 @@ function isGlyphGroupEndWithWhiteSpaces(glyphGroup: IDocumentSkeletonGlyph[]) {
|
||||
}
|
||||
|
||||
if (isInWhiteSpace &&
|
||||
g.content !== DataStreamTreeTokenType.SPACE && g.content !== DataStreamTreeTokenType.PARAGRAPH && g.streamType !== DataStreamTreeTokenType.SECTION_BREAK) {
|
||||
g.content !== DataStreamTreeTokenType.SPACE && g.content !== DataStreamTreeTokenType.PARAGRAPH &&
|
||||
g.streamType !== DataStreamTreeTokenType.SECTION_BREAK && g.streamType !== DataStreamTreeTokenType.PAGE_BREAK) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -291,6 +293,7 @@ function _divideOperator(
|
||||
) {
|
||||
const lastPage = getLastPage(pages);
|
||||
const divideInfo = getLastNotFullDivideInfo(lastPage); // Get the first divide in the latest line that is not full.
|
||||
ctx.footnoteLayout?.updateReferenceGlyphs(lastPage, glyphGroup, sectionBreakConfig, paragraphConfig);
|
||||
if (divideInfo) {
|
||||
const { divide, isLast } = divideInfo;
|
||||
_adjustExplicitTabStop(divide, glyphGroup, paragraphConfig);
|
||||
@@ -364,6 +367,20 @@ function _divideOperator(
|
||||
defaultSpanMetrics
|
||||
);
|
||||
} else if (divide?.glyphGroup.length === 0) {
|
||||
const line = divide.parent!;
|
||||
const column = line.parent!;
|
||||
const section = column.parent!;
|
||||
const wordWidth = __getGlyphGroupWidth(glyphGroup);
|
||||
if (
|
||||
isTraditionalDocumentCompatibility(paragraphConfig.documentCompatibilityPolicy!) &&
|
||||
wordWidth < column.width &&
|
||||
calculateLineTopByDrawings(line.lineHeight, line.top, lastPage, null, null, column.left, column.width, section.top, wordWidth - 0.001) > line.top
|
||||
) {
|
||||
// Preserve the shaped word when the obstacle, rather than the
|
||||
// column, is too narrow. Normal long-word breaking still applies.
|
||||
_lineOperator(ctx, glyphGroup, pages, sectionBreakConfig, paragraphConfig, isParagraphFirstShapedText, breakPointType, defaultSpanMetrics);
|
||||
return;
|
||||
}
|
||||
const sliceGlyphGroup: IDocumentSkeletonGlyph[] = [];
|
||||
|
||||
while (glyphGroup.length) {
|
||||
@@ -457,8 +474,33 @@ function _divideOperator(
|
||||
} else {
|
||||
// w does not exceed divide width, add it to divide
|
||||
const currentLine = divide.parent;
|
||||
if (currentLine?.parent?.parent && ctx.footnoteLayout && glyphGroup.some((glyph) => glyph.noteId)) {
|
||||
const bodyBottom = currentLine.parent.parent.top + currentLine.top + currentLine.lineHeight;
|
||||
const bodyLimit = ctx.footnoteLayout.getBodyLimit(lastPage, pages, sectionBreakConfig, bodyBottom, glyphGroup);
|
||||
if (bodyBottom > bodyLimit + LINE_LAYOUT_OVERFLOW_TOLERANCE) {
|
||||
const column = currentLine.parent;
|
||||
const cachedGlyphs = __getGlyphGroupByLine(currentLine);
|
||||
column.lines.pop();
|
||||
const previousLine = column.lines[column.lines.length - 1];
|
||||
const previousBottom = column.parent!.top + (previousLine == null ? 0 : previousLine.top + previousLine.lineHeight);
|
||||
ctx.footnoteLayout.getBodyLimit(lastPage, pages, sectionBreakConfig, previousBottom);
|
||||
_pageOperator(ctx, [...cachedGlyphs, ...glyphGroup], pages, sectionBreakConfig, paragraphConfig, currentLine.paragraphStart, breakPointType, defaultSpanMetrics);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const maxBox = __maxFontBoundingBoxByGlyphGroup(glyphGroup);
|
||||
|
||||
if (currentLine?.parent && __isNullLine(currentLine) && __hasFlowGlyph(glyphGroup)) {
|
||||
const cachedGlyphs = __getGlyphGroupByLine(currentLine);
|
||||
if (cachedGlyphs.length > 0 && cachedGlyphs.every((glyph) => glyph.streamType === DataStreamTreeTokenType.PAGE_BREAK)) {
|
||||
// A skipped rendered-page hint has no font metrics. Let the first real text
|
||||
// establish its line, including normal font leading and available page height.
|
||||
currentLine.parent.lines.pop();
|
||||
_lineOperator(ctx, [...cachedGlyphs, ...glyphGroup], pages, sectionBreakConfig, paragraphConfig, currentLine.paragraphStart, breakPointType, defaultSpanMetrics);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentLine &&
|
||||
__isPositionedCustomBlockOnlyLine(__getGlyphGroupByLine(currentLine), paragraphConfig.paragraphNonInlineSkeDrawings) &&
|
||||
@@ -623,51 +665,17 @@ function _adjustExplicitTabStop(
|
||||
tabGlyph.tabLeader = tabStop.leader;
|
||||
}
|
||||
|
||||
function _lineOperator(
|
||||
function _getParagraphLineMetrics(
|
||||
ctx: ILayoutContext,
|
||||
glyphGroup: IDocumentSkeletonGlyph[],
|
||||
pages: IDocumentSkeletonPage[],
|
||||
lastPage: IDocumentSkeletonPage,
|
||||
column: IDocumentSkeletonColumn,
|
||||
sectionBreakConfig: ISectionBreakConfig,
|
||||
paragraphConfig: IParagraphConfig,
|
||||
isParagraphFirstShapedText: boolean,
|
||||
breakPointType: BreakPointType = BreakPointType.Normal,
|
||||
defaultSpanMetrics?: IDefaultSpanMetrics
|
||||
) {
|
||||
let lastPage = getLastPage(pages);
|
||||
let columnInfo = getLastNotFullColumnInfo(lastPage);
|
||||
if (!columnInfo || !columnInfo.column) {
|
||||
const lastSection = getLastSection(lastPage);
|
||||
const lastColumnIndex = lastSection.columns.length - 1;
|
||||
const lastColumn = lastSection.columns[lastColumnIndex];
|
||||
if (lastColumn && isBlankColumn(lastColumn)) {
|
||||
setColumnFullState(lastColumn, false);
|
||||
columnInfo = {
|
||||
column: lastColumn,
|
||||
index: lastColumnIndex,
|
||||
isLast: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!columnInfo || !columnInfo.column) {
|
||||
// If the column does not exist, use a fallback strategy and add a new page.
|
||||
_pageOperator(ctx, glyphGroup, pages, sectionBreakConfig, paragraphConfig, true, breakPointType);
|
||||
lastPage = getLastPage(pages);
|
||||
columnInfo = getLastNotFullColumnInfo(lastPage);
|
||||
}
|
||||
// Todo: columnInfo does not exist when demo4 is imported, return first
|
||||
if (!columnInfo) return;
|
||||
|
||||
const column = columnInfo!.column;
|
||||
|
||||
// If the page width < marginLeft + marginRight, will trigger infinity loop, so return it first.
|
||||
// The best solution is to do data checks and data repairs, and pass the correct data to the render layer.
|
||||
if (column.width <= 0) {
|
||||
console.error('The column width is less than 0, need to adjust page width to make it great than 0');
|
||||
return;
|
||||
}
|
||||
|
||||
const preLine = getLastLineByColumn(column);
|
||||
|
||||
const ascent = Math.max(...glyphGroup.map((glyph) => glyph.bBox.ba));
|
||||
const descent = Math.max(...glyphGroup.map((glyph) => glyph.bBox.bd));
|
||||
const glyphLineHeight = defaultSpanMetrics?.lineHeight || (ascent + descent);
|
||||
@@ -677,12 +685,13 @@ function _lineOperator(
|
||||
paragraphStyle: originParagraphStyle = {},
|
||||
paragraphNonInlineSkeDrawings,
|
||||
skeTablesInParagraph,
|
||||
skeHeaders,
|
||||
skeFooters,
|
||||
pDrawingAnchor,
|
||||
paragraphIndex,
|
||||
} = paragraphConfig;
|
||||
const isZeroWidthNonFlowFloatingAnchorLine = __isZeroWidthNonFlowFloatingAnchorLine(glyphGroup, paragraphNonInlineSkeDrawings);
|
||||
const isSyntheticTableAnchorLine = isTraditionalDocumentCompatibility(paragraphConfig.documentCompatibilityPolicy!) &&
|
||||
skeTablesInParagraph?.some(({ table }) => table.tableSource.textWrap === TableTextWrapType.NONE) === true &&
|
||||
ctx.viewModel.getSelfOrHeaderFooterViewModel(lastPage.segmentId).getParagraph(paragraphIndex) == null &&
|
||||
glyphGroup.every((glyph) => glyph.streamType === DataStreamTreeTokenType.PARAGRAPH || glyph.streamType === DataStreamTreeTokenType.SECTION_BREAK);
|
||||
const { namedStyleType } = originParagraphStyle;
|
||||
const namedStyle = namedStyleType !== undefined ? NAMED_STYLE_SPACE_MAP[namedStyleType] : null;
|
||||
const paragraphStyle = {
|
||||
@@ -692,13 +701,8 @@ function _lineOperator(
|
||||
};
|
||||
|
||||
const {
|
||||
// direction,
|
||||
spaceAbove,
|
||||
spaceBelow,
|
||||
indentFirstLine,
|
||||
hanging,
|
||||
indentStart,
|
||||
indentEnd,
|
||||
} = paragraphStyle;
|
||||
|
||||
const {
|
||||
@@ -733,9 +737,6 @@ function _lineOperator(
|
||||
|
||||
return glyph.streamType === DataStreamTreeTokenType.PARAGRAPH || glyph.raw === DataStreamTreeTokenType.PARAGRAPH;
|
||||
});
|
||||
const glyphGroupCustomBlockIds = new Set(glyphGroup
|
||||
.filter((glyph) => glyph.streamType === DataStreamTreeTokenType.CUSTOM_BLOCK && glyph.drawingId != null)
|
||||
.map((glyph) => glyph.drawingId!));
|
||||
let { paddingTop, paddingBottom, contentHeight, lineSpacingApply } = getLineHeightMetrics(
|
||||
glyphLineHeight,
|
||||
paragraphLineGapDefault,
|
||||
@@ -802,7 +803,9 @@ function _lineOperator(
|
||||
spaceBelowApply = 0;
|
||||
}
|
||||
|
||||
if (isZeroWidthNonFlowFloatingAnchorLine) {
|
||||
// A table's structural terminator has no Word paragraph. Keep its glyph and
|
||||
// character position without adding a blank line after the table.
|
||||
if (isZeroWidthNonFlowFloatingAnchorLine || isSyntheticTableAnchorLine) {
|
||||
paddingTop = 0;
|
||||
paddingBottom = 0;
|
||||
contentHeight = 0;
|
||||
@@ -811,26 +814,38 @@ function _lineOperator(
|
||||
spaceBelowApply = 0;
|
||||
}
|
||||
|
||||
const lineHeight = marginTop + paddingTop + contentHeight + paddingBottom;
|
||||
const { charSpace, defaultTabStop } = getCharSpaceConfig(sectionBreakConfig, paragraphConfig);
|
||||
const charSpaceApply = getCharSpaceApply(charSpace, defaultTabStop, gridType, snapToGrid);
|
||||
const paragraphAnchorLeft = __getParagraphAnchorLeft(sectionBreakConfig, paragraphConfig, indentStart);
|
||||
|
||||
let section = column.parent;
|
||||
if (!section) {
|
||||
// Fallback, point to the last section of the current page
|
||||
section = getLastSection(lastPage);
|
||||
}
|
||||
const preLineHeight = preLine?.lineHeight || 0;
|
||||
const preTop = preLine?.top || 0;
|
||||
const lineTop = preLineHeight + preTop;
|
||||
|
||||
const { pageWidth, headerId, footerId, segmentId } = lastPage;
|
||||
const headerPage = skeHeaders?.get(headerId)?.get(pageWidth);
|
||||
const footerPage = skeFooters?.get(footerId)?.get(pageWidth);
|
||||
|
||||
let needOpenNewPageByTableLayout = false;
|
||||
return {
|
||||
paragraphStyle,
|
||||
gridType,
|
||||
snapToGrid,
|
||||
hasInlineCustomBlock,
|
||||
positionedCustomBlockOnly,
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
contentHeight,
|
||||
marginTop,
|
||||
spaceBelowApply,
|
||||
};
|
||||
}
|
||||
|
||||
function _positionLineDrawings(
|
||||
ctx: ILayoutContext,
|
||||
glyphGroup: IDocumentSkeletonGlyph[],
|
||||
column: IDocumentSkeletonColumn,
|
||||
lastPage: IDocumentSkeletonPage,
|
||||
paragraphConfig: IParagraphConfig,
|
||||
lineTop: number,
|
||||
lineHeight: number,
|
||||
paragraphAnchorLeft: number,
|
||||
hasInlineCustomBlock: boolean,
|
||||
isParagraphFirstShapedText: boolean
|
||||
) {
|
||||
const preLine = getLastLineByColumn(column);
|
||||
const { segmentId } = lastPage;
|
||||
const { paragraphNonInlineSkeDrawings, pDrawingAnchor, paragraphIndex } = paragraphConfig;
|
||||
const glyphGroupCustomBlockIds = new Set(glyphGroup
|
||||
.filter((glyph) => glyph.streamType === DataStreamTreeTokenType.CUSTOM_BLOCK && glyph.drawingId != null)
|
||||
.map((glyph) => glyph.drawingId!));
|
||||
// Handle float object relative to line.
|
||||
// FIXME: @jocs, it will not update the last line's drawings.
|
||||
if (preLine) {
|
||||
@@ -872,8 +887,153 @@ function _lineOperator(
|
||||
__updateAndPositionDrawings(ctx, lineTop, lineHeight, column, targetDrawings, paragraphConfig.paragraphIndex, isParagraphFirstShapedText, pDrawingAnchor?.get(paragraphIndex)?.top, paragraphAnchorLeft, false, deferredTopBottomAnchorDrawings.length > 0);
|
||||
}
|
||||
|
||||
return { deferredInlineGroupAnchorDrawings, deferredTopBottomAnchorDrawings };
|
||||
}
|
||||
|
||||
function _getOrCreateLineColumn(
|
||||
ctx: ILayoutContext,
|
||||
glyphGroup: IDocumentSkeletonGlyph[],
|
||||
pages: IDocumentSkeletonPage[],
|
||||
sectionBreakConfig: ISectionBreakConfig,
|
||||
paragraphConfig: IParagraphConfig,
|
||||
breakPointType: BreakPointType
|
||||
) {
|
||||
let lastPage = getLastPage(pages);
|
||||
let columnInfo = getLastNotFullColumnInfo(lastPage);
|
||||
if (!columnInfo || !columnInfo.column) {
|
||||
const lastSection = getLastSection(lastPage);
|
||||
const lastColumnIndex = lastSection.columns.length - 1;
|
||||
const lastColumn = lastSection.columns[lastColumnIndex];
|
||||
if (lastColumn && isBlankColumn(lastColumn)) {
|
||||
setColumnFullState(lastColumn, false);
|
||||
columnInfo = {
|
||||
column: lastColumn,
|
||||
index: lastColumnIndex,
|
||||
isLast: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!columnInfo || !columnInfo.column) {
|
||||
// If the column does not exist, use a fallback strategy and add a new page.
|
||||
_pageOperator(ctx, glyphGroup, pages, sectionBreakConfig, paragraphConfig, true, breakPointType);
|
||||
lastPage = getLastPage(pages);
|
||||
columnInfo = getLastNotFullColumnInfo(lastPage);
|
||||
}
|
||||
return columnInfo;
|
||||
}
|
||||
|
||||
function _clearOverflowedParagraphDrawings(
|
||||
ctx: ILayoutContext,
|
||||
lastPage: IDocumentSkeletonPage,
|
||||
paragraphNonInlineSkeDrawings: IParagraphConfig['paragraphNonInlineSkeDrawings'],
|
||||
isParagraphFirstShapedText: boolean
|
||||
): void {
|
||||
const { segmentId } = lastPage;
|
||||
if (isParagraphFirstShapedText && paragraphNonInlineSkeDrawings && paragraphNonInlineSkeDrawings.size > 0) {
|
||||
for (const drawing of paragraphNonInlineSkeDrawings.values()) {
|
||||
if (lastPage.skeDrawings.has(drawing.drawingId)) {
|
||||
lastPage.skeDrawings.delete(drawing.drawingId);
|
||||
}
|
||||
|
||||
if (ctx.floatObjectsCache.has(drawing.drawingId)) {
|
||||
ctx.floatObjectsCache.delete(drawing.drawingId);
|
||||
ctx.isDirty = false;
|
||||
ctx.layoutStartPointer[segmentId] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _lineOperator(
|
||||
ctx: ILayoutContext,
|
||||
glyphGroup: IDocumentSkeletonGlyph[],
|
||||
pages: IDocumentSkeletonPage[],
|
||||
sectionBreakConfig: ISectionBreakConfig,
|
||||
paragraphConfig: IParagraphConfig,
|
||||
isParagraphFirstShapedText: boolean,
|
||||
breakPointType: BreakPointType = BreakPointType.Normal,
|
||||
defaultSpanMetrics?: IDefaultSpanMetrics
|
||||
) {
|
||||
let lastPage = getLastPage(pages);
|
||||
ctx.footnoteLayout?.updateReferenceGlyphs(lastPage, glyphGroup, sectionBreakConfig, paragraphConfig);
|
||||
const columnInfo = _getOrCreateLineColumn(ctx, glyphGroup, pages, sectionBreakConfig, paragraphConfig, breakPointType);
|
||||
lastPage = getLastPage(pages);
|
||||
// Todo: columnInfo does not exist when demo4 is imported, return first
|
||||
if (!columnInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const column = columnInfo.column;
|
||||
|
||||
// If the page width < marginLeft + marginRight, will trigger infinity loop, so return it first.
|
||||
// The best solution is to do data checks and data repairs, and pass the correct data to the render layer.
|
||||
if (column.width <= 0) {
|
||||
console.error('The column width is less than 0, need to adjust page width to make it great than 0');
|
||||
return;
|
||||
}
|
||||
|
||||
const preLine = getLastLineByColumn(column);
|
||||
|
||||
const {
|
||||
paragraphNonInlineSkeDrawings,
|
||||
skeTablesInParagraph,
|
||||
skeHeaders,
|
||||
skeFooters,
|
||||
pDrawingAnchor,
|
||||
paragraphIndex,
|
||||
} = paragraphConfig;
|
||||
const {
|
||||
paragraphStyle,
|
||||
gridType,
|
||||
snapToGrid,
|
||||
hasInlineCustomBlock,
|
||||
positionedCustomBlockOnly,
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
contentHeight,
|
||||
marginTop,
|
||||
spaceBelowApply,
|
||||
} = _getParagraphLineMetrics(ctx, glyphGroup, lastPage, column, sectionBreakConfig, paragraphConfig, isParagraphFirstShapedText, defaultSpanMetrics);
|
||||
const { indentFirstLine, hanging, indentStart, indentEnd } = paragraphStyle;
|
||||
|
||||
const lineHeight = marginTop + paddingTop + contentHeight + paddingBottom;
|
||||
const { charSpace, defaultTabStop } = getCharSpaceConfig(sectionBreakConfig, paragraphConfig);
|
||||
const charSpaceApply = getCharSpaceApply(charSpace, defaultTabStop, gridType, snapToGrid);
|
||||
const paragraphAnchorLeft = __getParagraphAnchorLeft(sectionBreakConfig, paragraphConfig, indentStart);
|
||||
|
||||
let section = column.parent;
|
||||
if (!section) {
|
||||
// Fallback, point to the last section of the current page
|
||||
section = getLastSection(lastPage);
|
||||
}
|
||||
const preLineHeight = preLine?.lineHeight || 0;
|
||||
const initialFootnoteTop = lastPage.type === DocumentSkeletonPageType.NOTE && lastPage.pageNumber === 1 && columnInfo.index === ctx.footnoteFirstColumn?.index
|
||||
? ctx.footnoteFirstColumn.top
|
||||
: 0;
|
||||
const preTop = preLine?.top ?? initialFootnoteTop;
|
||||
const lineTop = preLineHeight + preTop;
|
||||
|
||||
const { pageWidth, headerId, footerId } = lastPage;
|
||||
const headerPage = skeHeaders?.get(headerId)?.get(pageWidth);
|
||||
const footerPage = skeFooters?.get(footerId)?.get(pageWidth);
|
||||
|
||||
let needOpenNewPageByTableLayout = false;
|
||||
|
||||
const { deferredInlineGroupAnchorDrawings, deferredTopBottomAnchorDrawings } = _positionLineDrawings(
|
||||
ctx,
|
||||
glyphGroup,
|
||||
column,
|
||||
lastPage,
|
||||
paragraphConfig,
|
||||
lineTop,
|
||||
lineHeight,
|
||||
paragraphAnchorLeft,
|
||||
hasInlineCustomBlock,
|
||||
isParagraphFirstShapedText
|
||||
);
|
||||
|
||||
if (skeTablesInParagraph != null && skeTablesInParagraph.length > 0) {
|
||||
needOpenNewPageByTableLayout = _updateAndPositionTable(ctx, lineTop, lineHeight, lastPage, column, section, skeTablesInParagraph, paragraphConfig.paragraphIndex, sectionBreakConfig, pDrawingAnchor?.get(paragraphIndex)?.top);
|
||||
needOpenNewPageByTableLayout = _updateAndPositionTable(ctx, lineTop, lineHeight, lastPage, pages, column, section, skeTablesInParagraph, paragraphConfig.paragraphIndex, sectionBreakConfig, pDrawingAnchor?.get(paragraphIndex)?.top);
|
||||
}
|
||||
|
||||
const hasSameParagraphTopBottomDrawingWithInline = hasInlineCustomBlock &&
|
||||
@@ -891,7 +1051,10 @@ function _lineOperator(
|
||||
footerPage,
|
||||
column.left,
|
||||
column.width,
|
||||
section.top
|
||||
section.top,
|
||||
isTraditionalDocumentCompatibility(paragraphConfig.documentCompatibilityPolicy!)
|
||||
? Math.max(TRADITIONAL_TABLE_WRAP_MIN_WIDTH, Math.min(__getGlyphGroupWidth(glyphGroup) - 0.001, column.width - 1))
|
||||
: 0
|
||||
); // WRAP_TOP_AND_BOTTOM drawing and WRAP NONE table will change the starting top of the line
|
||||
const previousTopBottomCustomBlockFlowBottom = deferredTopBottomAnchorDrawings.length > 0
|
||||
? paragraphConfig.topBottomCustomBlockFlowBottom
|
||||
@@ -903,12 +1066,20 @@ function _lineOperator(
|
||||
// Word keeps an inline drawing below a top-bottom floating drawing from the
|
||||
// same paragraph and clips the inline drawing at the physical page bottom.
|
||||
const clipsSameParagraphInlineDrawing = hasSameParagraphTopBottomDrawingWithInline && newLineTop < section.height;
|
||||
const footnoteBodyLimit = ctx.footnoteLayout?.getBodyLimit(
|
||||
lastPage,
|
||||
pages,
|
||||
sectionBreakConfig,
|
||||
section.top + newLineTop + lineHeight,
|
||||
glyphGroup
|
||||
);
|
||||
const availableSectionHeight = footnoteBodyLimit == null ? section.height : Math.min(section.height, footnoteBodyLimit - section.top);
|
||||
const lineOverflowsSection = !clipsSameParagraphInlineDrawing &&
|
||||
lineHeight + newLineTop - section.height > LINE_LAYOUT_OVERFLOW_TOLERANCE;
|
||||
lineHeight + newLineTop - availableSectionHeight > LINE_LAYOUT_OVERFLOW_TOLERANCE;
|
||||
|
||||
if (
|
||||
(lineOverflowsSection &&
|
||||
(column.lines.length > 0 || section.top > 0) &&
|
||||
(column.lines.length > 0 || initialFootnoteTop > 0 || section.top > 0 || (lastPage.footnoteHeight ?? 0) > 0 || footnoteBodyLimit === -1) &&
|
||||
lastPage.sections.length > 0) ||
|
||||
needOpenNewPageByTableLayout
|
||||
) {
|
||||
@@ -926,19 +1097,7 @@ function _lineOperator(
|
||||
defaultSpanMetrics
|
||||
);
|
||||
|
||||
if (isParagraphFirstShapedText && paragraphNonInlineSkeDrawings && paragraphNonInlineSkeDrawings.size > 0) {
|
||||
for (const drawing of paragraphNonInlineSkeDrawings.values()) {
|
||||
if (lastPage.skeDrawings.has(drawing.drawingId)) {
|
||||
lastPage.skeDrawings.delete(drawing.drawingId);
|
||||
}
|
||||
|
||||
if (ctx.floatObjectsCache.has(drawing.drawingId)) {
|
||||
ctx.floatObjectsCache.delete(drawing.drawingId);
|
||||
ctx.isDirty = false;
|
||||
ctx.layoutStartPointer[segmentId] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
_clearOverflowedParagraphDrawings(ctx, lastPage, paragraphNonInlineSkeDrawings, isParagraphFirstShapedText);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1139,7 +1298,10 @@ function __getWrapTablePosition(
|
||||
const { tableSource, width, height } = table;
|
||||
const { positionH, positionV } = tableSource.position;
|
||||
|
||||
const left = getPositionHorizon(positionH, column, page, width, isPageBreak) ?? 0;
|
||||
const horizontalPosition = getPositionHorizon(positionH, column, page, width, isPageBreak);
|
||||
// Tables render from the body origin; page/margin anchors return page coordinates.
|
||||
const pageRelative = positionH.relativeFrom === ObjectRelativeFromH.PAGE || positionH.relativeFrom === ObjectRelativeFromH.MARGIN;
|
||||
const left = horizontalPosition == null ? 0 : horizontalPosition - (pageRelative ? page.marginLeft : 0);
|
||||
const top = getPositionVertical(
|
||||
positionV,
|
||||
page,
|
||||
@@ -1196,6 +1358,7 @@ function _updateAndPositionTable(
|
||||
lineTop: number,
|
||||
lineHeight: number,
|
||||
page: IDocumentSkeletonPage,
|
||||
pages: IDocumentSkeletonPage[],
|
||||
column: IDocumentSkeletonColumn,
|
||||
section: IDocumentSkeletonSection,
|
||||
skeTablesInParagraph: IParagraphTableCache[],
|
||||
@@ -1244,8 +1407,22 @@ function _updateAndPositionTable(
|
||||
}
|
||||
|
||||
const { top, left, height } = table;
|
||||
|
||||
const localTop = top - section.top;
|
||||
|
||||
// A short table and its note must move together. Waiting until the trailing
|
||||
// paragraph overflows leaves the table on the old page without its note.
|
||||
// Tables that already exceed the remaining body space still use row pagination below.
|
||||
if (ctx.footnoteLayout && localTop + height <= section.height && (column.lines.length > 0 || firstUnPositionedTable.isSlideTable) &&
|
||||
tableSource.textWrap === TableTextWrapType.NONE) {
|
||||
const bodyLimit = ctx.footnoteLayout.getTableBodyLimit(page, pages, sectionBreakConfig, table);
|
||||
if (top + height > bodyLimit + LINE_LAYOUT_OVERFLOW_TOLERANCE) {
|
||||
const previousLine = column.lines[column.lines.length - 1];
|
||||
const bodyBottom = previousLine ? section.top + previousLine.top + previousLine.lineHeight : section.top;
|
||||
ctx.footnoteLayout.getBodyLimit(page, pages, sectionBreakConfig, bodyBottom);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(localTop + height > section.height || table.hasPageBreak === true) &&
|
||||
firstUnPositionedTable.isSlideTable === false
|
||||
@@ -1282,6 +1459,7 @@ function _updateAndPositionTable(
|
||||
|
||||
page.skeTables.set(firstTable.tableId, firstTable);
|
||||
firstTable.parent = page;
|
||||
ctx.footnoteLayout?.getTableBodyLimit(page, pages, sectionBreakConfig, firstTable);
|
||||
skeTablesInParagraph.push({
|
||||
table: firstTable,
|
||||
tableId: firstTable.tableId,
|
||||
@@ -1306,6 +1484,7 @@ function _updateAndPositionTable(
|
||||
} else {
|
||||
page.skeTables.set(tableId, table);
|
||||
table.parent = page;
|
||||
ctx.footnoteLayout?.getTableBodyLimit(page, pages, sectionBreakConfig, table);
|
||||
firstUnPositionedTable.hasPositioned = true;
|
||||
|
||||
const isLastTable = firstUnPositionedTable === skeTablesInParagraph[skeTablesInParagraph.length - 1];
|
||||
|
||||
+134
-32
@@ -52,6 +52,7 @@ import {
|
||||
} from '@univerjs/core';
|
||||
import { BreakType, GlyphType } from '../../../../../basics/i-document-skeleton-cached';
|
||||
import { getDocumentCompatibilityPolicy, isTraditionalDocumentCompatibility } from '../../../document-compatibility';
|
||||
import { BreakPointType } from '../../line-breaker/break';
|
||||
import { createSkeletonPage } from '../../model/page';
|
||||
import { setColumnFullState } from '../../model/section';
|
||||
import { getLastNotFullColumnInfo, getLastSection, hasSameParagraphBorderSet, isBlankColumn } from '../../tools';
|
||||
@@ -537,6 +538,8 @@ function _hasOnlyExplicitPageBoundaryMarkers(page: IDocumentSkeletonPage): boole
|
||||
divide.glyphGroup.every(({ raw, streamType }) =>
|
||||
raw === DataStreamTreeTokenType.PARAGRAPH ||
|
||||
streamType === DataStreamTreeTokenType.PARAGRAPH ||
|
||||
raw === DataStreamTreeTokenType.SECTION_BREAK ||
|
||||
streamType === DataStreamTreeTokenType.SECTION_BREAK ||
|
||||
raw === DataStreamTreeTokenType.PAGE_BREAK ||
|
||||
streamType === DataStreamTreeTokenType.PAGE_BREAK
|
||||
)
|
||||
@@ -570,7 +573,8 @@ function _reindexColumnLines(column: IDocumentSkeletonColumn): void {
|
||||
function _prependLines(
|
||||
sourceColumn: IDocumentSkeletonColumn,
|
||||
targetColumn: IDocumentSkeletonColumn,
|
||||
lines: IParagraphLineRef['line'][]
|
||||
lines: IParagraphLineRef['line'][],
|
||||
validateMove?: () => boolean
|
||||
): number {
|
||||
if (lines.length === 0 || lines.some((line) => !sourceColumn.lines.includes(line))) {
|
||||
return 0;
|
||||
@@ -581,6 +585,10 @@ function _prependLines(
|
||||
return 0;
|
||||
}
|
||||
|
||||
const sourceLines = sourceColumn.lines.slice();
|
||||
const targetLines = targetColumn.lines.slice();
|
||||
const originalTops = validateMove ? new Map([...sourceLines, ...targetLines].map((line) => [line, line.top])) : undefined;
|
||||
const sourceWasFull = sourceColumn.isFull;
|
||||
const firstTop = Math.min(...lines.map((line) => line.top));
|
||||
const moved = lines.map((line) => {
|
||||
line.top -= firstTop;
|
||||
@@ -595,20 +603,35 @@ function _prependLines(
|
||||
sourceColumn.isFull = false;
|
||||
_reindexColumnLines(sourceColumn);
|
||||
_reindexColumnLines(targetColumn);
|
||||
if (validateMove && !validateMove()) {
|
||||
sourceColumn.lines = sourceLines;
|
||||
targetColumn.lines = targetLines;
|
||||
sourceColumn.isFull = sourceWasFull;
|
||||
originalTops?.forEach((top, line) => {
|
||||
line.top = top;
|
||||
});
|
||||
_reindexColumnLines(sourceColumn);
|
||||
_reindexColumnLines(targetColumn);
|
||||
validateMove();
|
||||
return 0;
|
||||
}
|
||||
return moved.length;
|
||||
}
|
||||
|
||||
function _hasExplicitStructuralBreak(shapedTextList: IShapedText[]): boolean {
|
||||
return shapedTextList.some(({ text, glyphs }) =>
|
||||
_endsWithToken(text, glyphs, DataStreamTreeTokenType.PAGE_BREAK) ||
|
||||
_endsWithToken(text, glyphs, DataStreamTreeTokenType.COLUMN_BREAK)
|
||||
);
|
||||
function _hasExplicitStructuralBreak(shapedTextList: IShapedText[], viewModel: DocumentViewModel, startIndex: number): boolean {
|
||||
let offset = startIndex;
|
||||
return shapedTextList.some(({ text, glyphs }) => {
|
||||
offset += _glyphCount(glyphs);
|
||||
return (_endsWithToken(text, glyphs, DataStreamTreeTokenType.PAGE_BREAK) && !_isRenderedPageBreak(viewModel, offset - 1)) ||
|
||||
_endsWithToken(text, glyphs, DataStreamTreeTokenType.COLUMN_BREAK);
|
||||
});
|
||||
}
|
||||
|
||||
function _applyKeepLines(
|
||||
pages: IDocumentSkeletonPage[],
|
||||
paragraphIndex: number,
|
||||
metrics: ILayoutContext['paginationMetrics']
|
||||
metrics: ILayoutContext['paginationMetrics'],
|
||||
validateMove?: () => boolean
|
||||
): number {
|
||||
const refs = _getParagraphLineRefs(pages, paragraphIndex);
|
||||
if (metrics) {
|
||||
@@ -631,7 +654,8 @@ function _applyKeepLines(
|
||||
const movedLineCount = _prependLines(
|
||||
sourceColumns[0],
|
||||
targetColumns[0],
|
||||
sourceRefs.map(({ line }) => line)
|
||||
sourceRefs.map(({ line }) => line),
|
||||
validateMove
|
||||
);
|
||||
if (metrics && movedLineCount > 0) {
|
||||
metrics.retryCount += 1;
|
||||
@@ -645,7 +669,8 @@ function _applyKeepNext(
|
||||
originalPage: IDocumentSkeletonPage,
|
||||
paragraphIndex: number,
|
||||
paragraphConfigCache: Map<number, IParagraphConfig>,
|
||||
metrics: ILayoutContext['paginationMetrics']
|
||||
metrics: ILayoutContext['paginationMetrics'],
|
||||
validateMove?: () => boolean
|
||||
): number {
|
||||
const refs = _getParagraphLineRefs(pages, paragraphIndex);
|
||||
if (metrics) {
|
||||
@@ -698,7 +723,7 @@ function _applyKeepNext(
|
||||
if (start === sourceColumn.lines.length) {
|
||||
return 0;
|
||||
}
|
||||
const movedLineCount = _prependLines(sourceColumn, targetColumn, sourceColumn.lines.slice(start));
|
||||
const movedLineCount = _prependLines(sourceColumn, targetColumn, sourceColumn.lines.slice(start), validateMove);
|
||||
if (metrics && movedLineCount > 0) {
|
||||
metrics.retryCount += 1;
|
||||
metrics.movedLineCount += movedLineCount;
|
||||
@@ -709,7 +734,8 @@ function _applyKeepNext(
|
||||
function _applyWidowControl(
|
||||
pages: IDocumentSkeletonPage[],
|
||||
paragraphIndex: number,
|
||||
metrics: ILayoutContext['paginationMetrics']
|
||||
metrics: ILayoutContext['paginationMetrics'],
|
||||
validateMove?: () => boolean
|
||||
): number {
|
||||
const refs = _getParagraphLineRefs(pages, paragraphIndex);
|
||||
if (metrics) {
|
||||
@@ -726,17 +752,18 @@ function _applyWidowControl(
|
||||
const targetPage = paragraphPages[paragraphPages.length - 1];
|
||||
const sourceRefs = refs.filter(({ page }) => page === sourcePage);
|
||||
const targetRefs = refs.filter(({ page }) => page === targetPage);
|
||||
if (sourceRefs.length === 1 && targetRefs.length >= 2) {
|
||||
const movedLineCount = _prependLines(sourceRefs[0].column, targetRefs[0].column, [sourceRefs[0].line]);
|
||||
if (sourceRefs.length === 1 && targetRefs.length >= 1) {
|
||||
const movedLineCount = _prependLines(sourceRefs[0].column, targetRefs[0].column, [sourceRefs[0].line], validateMove);
|
||||
if (metrics && movedLineCount > 0) {
|
||||
metrics.retryCount += 1;
|
||||
metrics.movedLineCount += movedLineCount;
|
||||
}
|
||||
return movedLineCount;
|
||||
}
|
||||
if (targetRefs.length === 1 && sourceRefs.length >= 3) {
|
||||
const line = sourceRefs[sourceRefs.length - 1];
|
||||
const movedLineCount = _prependLines(line.column, targetRefs[0].column, [line.line]);
|
||||
if (targetRefs.length === 1 && sourceRefs.length >= 2) {
|
||||
// A three-line paragraph must move together; moving only its second line creates an orphan.
|
||||
const movedRefs = sourceRefs.slice(sourceRefs.length === 2 ? 0 : -1);
|
||||
const movedLineCount = _prependLines(movedRefs[0].column, targetRefs[0].column, movedRefs.map(({ line }) => line), validateMove);
|
||||
if (metrics && movedLineCount > 0) {
|
||||
metrics.retryCount += 1;
|
||||
metrics.movedLineCount += movedLineCount;
|
||||
@@ -944,6 +971,28 @@ interface ILayoutShapedTextsParams {
|
||||
paragraphInlineSkeDrawingsByBlockId: Map<string, IDocumentSkeletonDrawing>;
|
||||
}
|
||||
|
||||
function _splitAtReferencePageBreaks(shapedTexts: IShapedText[], offsets: ReadonlySet<number> | undefined): IShapedText[] {
|
||||
if (!offsets?.size) {
|
||||
return shapedTexts;
|
||||
}
|
||||
const result: IShapedText[] = [];
|
||||
let offset = 0;
|
||||
for (const shaped of shapedTexts) {
|
||||
let start = 0;
|
||||
for (let index = 0; index < shaped.glyphs.length; index++) {
|
||||
if (index > start && offsets.has(offset)) {
|
||||
const glyphs = shaped.glyphs.slice(start, index);
|
||||
result.push({ ...shaped, glyphs, text: glyphs.map((glyph) => glyph.raw).join(''), breakPointType: BreakPointType.Normal });
|
||||
start = index;
|
||||
}
|
||||
offset += shaped.glyphs[index].count;
|
||||
}
|
||||
const glyphs = shaped.glyphs.slice(start);
|
||||
result.push(start === 0 ? shaped : { ...shaped, glyphs, text: glyphs.map((glyph) => glyph.raw).join('') });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function _layoutShapedTexts({
|
||||
ctx,
|
||||
viewModel,
|
||||
@@ -966,19 +1015,32 @@ function _layoutShapedTexts({
|
||||
let renderParagraphBullet = true;
|
||||
let shapedTextOffset = 0;
|
||||
let renderedPageBreakAnchorPage = curPage;
|
||||
const mergedShapedTextList = _mergeAdjacentCustomBlockShapedTexts(shapedTextList, paragraphNonInlineSkeDrawingsByBlockId);
|
||||
const retainedBreaks = ctx.footnoteLayout?.getReferencePageBreaks(paragraphNode.endIndex);
|
||||
const referencePageBreaks = retainedBreaks
|
||||
? new Set(retainedBreaks.map((offset) => offset - paragraphNode.startIndex))
|
||||
: undefined;
|
||||
const mergedShapedTextList = _mergeAdjacentCustomBlockShapedTexts(
|
||||
_splitAtReferencePageBreaks(shapedTextList, referencePageBreaks),
|
||||
paragraphNonInlineSkeDrawingsByBlockId
|
||||
);
|
||||
|
||||
for (const { text, glyphs, breakPointType } of mergedShapedTextList) {
|
||||
for (let shapedIndex = 0; shapedIndex < mergedShapedTextList.length; shapedIndex++) {
|
||||
const { text, glyphs, breakPointType } = mergedShapedTextList[shapedIndex];
|
||||
let layoutGlyphs = glyphs;
|
||||
const textStartIndex = paragraphNode.startIndex + shapedTextOffset;
|
||||
const lastPage = allPages[allPages.length - 1];
|
||||
if (referencePageBreaks?.has(shapedTextOffset) && _hasPageContent(lastPage)) {
|
||||
allPages.push(createSkeletonPage(ctx, sectionBreakConfig, skeletonResourceReference, _getNextPageNumber(lastPage)));
|
||||
}
|
||||
const textGlyphCount = _glyphCount(glyphs);
|
||||
const textEndIndex = textStartIndex + textGlyphCount;
|
||||
const pushPending = () => {
|
||||
if (glyphs.length === 0) {
|
||||
const pushPending = (layoutBreakPointType = breakPointType) => {
|
||||
if (layoutGlyphs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncActiveParagraphDrawings(
|
||||
glyphs,
|
||||
layoutGlyphs,
|
||||
paragraphNonInlineSkeDrawings,
|
||||
paragraphInlineSkeDrawings,
|
||||
paragraphNonInlineSkeDrawingsByBlockId,
|
||||
@@ -987,12 +1049,12 @@ function _layoutShapedTexts({
|
||||
|
||||
allPages = layoutParagraph(
|
||||
ctx,
|
||||
glyphs,
|
||||
layoutGlyphs,
|
||||
allPages,
|
||||
sectionBreakConfig,
|
||||
paragraphConfig,
|
||||
isParagraphFirstShapedText || hasOnlyFloatingCustomBlockGlyphs(glyphs, paragraphNonInlineSkeDrawingsByBlockId),
|
||||
breakPointType,
|
||||
layoutBreakPointType,
|
||||
renderParagraphBullet
|
||||
);
|
||||
|
||||
@@ -1001,10 +1063,38 @@ function _layoutShapedTexts({
|
||||
};
|
||||
|
||||
if (_endsWithToken(text, glyphs, DataStreamTreeTokenType.PAGE_BREAK)) {
|
||||
pushPending();
|
||||
const currentPage = allPages[allPages.length - 1];
|
||||
const isRenderedPageBreak =
|
||||
traditionalPagination && _isRenderedPageBreak(viewModel, textEndIndex - 1);
|
||||
if (
|
||||
traditionalPagination && !isRenderedPageBreak &&
|
||||
viewModel.getDataModel().documentStyle.splitPageBreakAndParagraphMark !== BooleanNumber.TRUE
|
||||
) {
|
||||
const trailingGlyphs = mergedShapedTextList.slice(shapedIndex + 1).flatMap((item) => item.glyphs);
|
||||
if (
|
||||
trailingGlyphs.some((glyph) => glyph.raw === DataStreamTreeTokenType.PARAGRAPH) &&
|
||||
trailingGlyphs.every((glyph) =>
|
||||
glyph.raw === DataStreamTreeTokenType.PARAGRAPH || glyph.raw === DataStreamTreeTokenType.SECTION_BREAK
|
||||
)
|
||||
) {
|
||||
// Word keeps the paragraph terminator on the break's line when no run content follows it.
|
||||
// Retain its source offset and font style without making an empty line on the next page.
|
||||
// A leading cell break does not advance a page, so its real paragraph still occupies height.
|
||||
const retainParagraphHeight = _isInsideFlowTable(viewModel, textEndIndex - 1) &&
|
||||
!_hasPageContent(allPages[allPages.length - 1]) &&
|
||||
glyphs.every((glyph) => glyph.raw === DataStreamTreeTokenType.PAGE_BREAK);
|
||||
layoutGlyphs = [...glyphs, ...trailingGlyphs.map((glyph) => retainParagraphHeight
|
||||
? glyph
|
||||
: ({
|
||||
...glyph,
|
||||
width: 0,
|
||||
bBox: { ...glyph.bBox, width: 0, ba: 0, bd: 0, aba: 0, abd: 0, normalLineHeight: 0 },
|
||||
}))];
|
||||
shapedTextOffset += _glyphCount(trailingGlyphs);
|
||||
shapedIndex = mergedShapedTextList.length - 1;
|
||||
}
|
||||
}
|
||||
pushPending(isRenderedPageBreak ? BreakPointType.Normal : breakPointType);
|
||||
const currentPage = allPages[allPages.length - 1];
|
||||
const naturallyAdvancedInsideTable =
|
||||
isRenderedPageBreak &&
|
||||
_isInsideFlowTable(viewModel, textEndIndex - 1) &&
|
||||
@@ -1033,7 +1123,7 @@ function _layoutShapedTexts({
|
||||
}
|
||||
renderedPageBreakAnchorPage = allPages[allPages.length - 1];
|
||||
paragraphNonInlineSkeDrawings.clear();
|
||||
isParagraphFirstShapedText = true;
|
||||
isParagraphFirstShapedText = allPages[allPages.length - 1] !== currentPage || !isRenderedPageBreak;
|
||||
shapedTextOffset += textGlyphCount;
|
||||
continue;
|
||||
} else if (
|
||||
@@ -1103,6 +1193,7 @@ interface IApplyPaginationConstraintsParams {
|
||||
resolvedParagraphStyle: IParagraphStyle;
|
||||
segmentParagraphCache: Map<number, IParagraphConfig>;
|
||||
traditionalPagination: boolean;
|
||||
sectionBreakConfig: ISectionBreakConfig;
|
||||
}
|
||||
|
||||
function _applyPaginationConstraints({
|
||||
@@ -1117,7 +1208,10 @@ function _applyPaginationConstraints({
|
||||
resolvedParagraphStyle,
|
||||
segmentParagraphCache,
|
||||
traditionalPagination,
|
||||
sectionBreakConfig,
|
||||
}: IApplyPaginationConstraintsParams): void {
|
||||
let moved = false;
|
||||
const validateMove = ctx.footnoteLayout ? () => ctx.footnoteLayout!.reconcilePages(allPages, sectionBreakConfig) : undefined;
|
||||
const needsPaginationCheckpoint =
|
||||
forcePageBreakBefore ||
|
||||
(
|
||||
@@ -1151,13 +1245,15 @@ function _applyPaginationConstraints({
|
||||
// Apply Word-compatible soft constraints from strongest to weakest.
|
||||
// Each helper only relocates already-shaped local lines and remains bounded.
|
||||
if (resolvedParagraphStyle.keepLines === BooleanNumber.TRUE &&
|
||||
_applyKeepLines(allPages, endIndex, ctx.paginationMetrics) > 0) {
|
||||
_applyKeepLines(allPages, endIndex, ctx.paginationMetrics, validateMove) > 0) {
|
||||
moved = true;
|
||||
ctx.paragraphsOpenNewPage.add(endIndex);
|
||||
}
|
||||
const keepNextMoved = allPages.length > 1
|
||||
? _applyKeepNext(allPages, curPage, endIndex, segmentParagraphCache, ctx.paginationMetrics)
|
||||
? _applyKeepNext(allPages, curPage, endIndex, segmentParagraphCache, ctx.paginationMetrics, validateMove)
|
||||
: 0;
|
||||
if (keepNextMoved > 0) {
|
||||
moved = true;
|
||||
ctx.paragraphsOpenNewPage.add(endIndex);
|
||||
}
|
||||
let widowMoved = 0;
|
||||
@@ -1165,16 +1261,21 @@ function _applyPaginationConstraints({
|
||||
allPages.length > 1 &&
|
||||
resolvedParagraphStyle.widowControl === BooleanNumber.TRUE
|
||||
) {
|
||||
widowMoved = _applyWidowControl(allPages, endIndex, ctx.paginationMetrics);
|
||||
widowMoved = _applyWidowControl(allPages, endIndex, ctx.paginationMetrics, validateMove);
|
||||
}
|
||||
moved ||= widowMoved > 0;
|
||||
// A widow adjustment must not orphan a stronger keep-next predecessor.
|
||||
// Re-check only when the first keep-next pass made no move.
|
||||
if (widowMoved > 0 &&
|
||||
keepNextMoved === 0 &&
|
||||
_applyKeepNext(allPages, curPage, endIndex, segmentParagraphCache, ctx.paginationMetrics) > 0) {
|
||||
_applyKeepNext(allPages, curPage, endIndex, segmentParagraphCache, ctx.paginationMetrics, validateMove) > 0) {
|
||||
moved = true;
|
||||
ctx.paragraphsOpenNewPage.add(endIndex);
|
||||
}
|
||||
}
|
||||
if (moved) {
|
||||
ctx.footnoteLayout?.queueReferenceRelayout(allPages);
|
||||
}
|
||||
}
|
||||
|
||||
export function lineBreaking(
|
||||
@@ -1208,7 +1309,7 @@ export function lineBreaking(
|
||||
nextParagraphNode
|
||||
);
|
||||
const traditionalPagination = isTraditionalDocumentCompatibility(documentCompatibilityPolicy);
|
||||
const explicitStructuralBreak = _hasExplicitStructuralBreak(shapedTextList);
|
||||
const explicitStructuralBreak = _hasExplicitStructuralBreak(shapedTextList, viewModel, paragraphNode.startIndex);
|
||||
const forcePageBreakBefore =
|
||||
traditionalPagination &&
|
||||
(resolvedParagraphStyle.pageBreakBefore === BooleanNumber.TRUE || tablePageBreakBefore) &&
|
||||
@@ -1250,13 +1351,14 @@ export function lineBreaking(
|
||||
allPages,
|
||||
curPage,
|
||||
endIndex,
|
||||
explicitStructuralBreak,
|
||||
explicitStructuralBreak: explicitStructuralBreak || allPages.slice(1).some((page) => page.isExplicitPageBreak),
|
||||
forcePageBreakBefore,
|
||||
tableSkeleton,
|
||||
paragraphNonInlineSkeDrawingsByBlockId,
|
||||
resolvedParagraphStyle,
|
||||
segmentParagraphCache,
|
||||
traditionalPagination,
|
||||
sectionBreakConfig,
|
||||
});
|
||||
|
||||
return allPages;
|
||||
|
||||
@@ -21,10 +21,17 @@ import type { DataStreamTreeNode } from '../../../view-model/data-stream-tree-no
|
||||
import type { DocumentViewModel } from '../../../view-model/document-view-model';
|
||||
import type { IBreakPoints } from '../../line-breaker/line-breaker';
|
||||
import type { ILayoutContext } from '../../tools';
|
||||
import { BooleanNumber, DataStreamTreeTokenType, GridType, PositionedObjectLayoutType } from '@univerjs/core';
|
||||
import {
|
||||
BaselineOffset,
|
||||
BooleanNumber,
|
||||
DataStreamTreeTokenType,
|
||||
GridType,
|
||||
PositionedObjectLayoutType,
|
||||
} from '@univerjs/core';
|
||||
import { cjk } from '../../../../../basics/cjk-regexp';
|
||||
import { GlyphType } from '../../../../../basics/i-document-skeleton-cached';
|
||||
import {
|
||||
getFontStyleString,
|
||||
hasArabic,
|
||||
hasThai,
|
||||
hasTibetan,
|
||||
@@ -267,6 +274,20 @@ export function shaping(
|
||||
const word = content.slice(last, bk.position);
|
||||
const shapedGlyphs: IDocumentSkeletonGlyph[] = [];
|
||||
|
||||
if (last === 0 && ctx.noteLabel != null && viewModel === ctx.viewModel &&
|
||||
paragraphNode.endIndex === viewModel.getBody()?.paragraphs?.[0]?.startIndex) {
|
||||
const config = getFontCreateConfig(0, viewModel, paragraphNode, sectionBreakConfig, paragraph);
|
||||
const textStyle = { ...config.textStyle, va: BaselineOffset.SUPERSCRIPT, ...ctx.noteReferenceTextStyle };
|
||||
const marker = createSkeletonLetterGlyph(ctx.noteLabel, {
|
||||
...config,
|
||||
textStyle,
|
||||
fontStyle: getFontStyleString({ ...textStyle, ff: [textStyle.ff, textStyle.eastAsiaFontFamily].filter(Boolean).join(', ') }),
|
||||
});
|
||||
marker.raw = '';
|
||||
marker.count = 0;
|
||||
shapedGlyphs.push(marker);
|
||||
}
|
||||
|
||||
let src = word;
|
||||
let i = last;
|
||||
while (src.length > 0) {
|
||||
@@ -304,7 +325,34 @@ export function shaping(
|
||||
measuredWholeEntityRangeIndex++;
|
||||
}
|
||||
|
||||
if (char === DataStreamTreeTokenType.CUSTOM_BLOCK) {
|
||||
const fieldType = char === '\uFFFC'
|
||||
? viewModel.getCustomRangeRaw(paragraphNode.startIndex + i)?.properties?.fieldType
|
||||
: undefined;
|
||||
const isSeparator = fieldType === 'FOOTNOTE_SEPARATOR' || fieldType === 'FOOTNOTE_CONTINUATION_SEPARATOR';
|
||||
const note = viewModel === ctx.viewModel && char === '\uFFFC'
|
||||
? ctx.noteReferences?.get(paragraphNode.startIndex + i)
|
||||
: undefined;
|
||||
if (isSeparator) {
|
||||
const config = getFontCreateConfig(i, viewModel, paragraphNode, sectionBreakConfig, paragraph);
|
||||
const textWidth = Math.max(0, (sectionBreakConfig.pageSize?.width ?? 192) -
|
||||
(sectionBreakConfig.marginLeft ?? 0) - (sectionBreakConfig.marginRight ?? 0));
|
||||
const width = fieldType === 'FOOTNOTE_SEPARATOR' ? Math.min(192, textWidth) : textWidth;
|
||||
const glyph = createSkeletonLetterGlyph(' ', config, width);
|
||||
glyph.raw = '\uFFFC';
|
||||
glyph.noteSeparator = true;
|
||||
shapedGlyphs.push(glyph);
|
||||
i++;
|
||||
src = src.substring(1);
|
||||
} else if (note) {
|
||||
const config = getFontCreateConfig(i, viewModel, paragraphNode, sectionBreakConfig, paragraph);
|
||||
const glyph = createSkeletonLetterGlyph(note.label, config);
|
||||
glyph.raw = '\uFFFC';
|
||||
glyph.count = 1;
|
||||
glyph.noteId = note.noteId;
|
||||
shapedGlyphs.push(glyph);
|
||||
i++;
|
||||
src = src.substring(1);
|
||||
} else if (char === DataStreamTreeTokenType.CUSTOM_BLOCK) {
|
||||
const config = getFontCreateConfig(i, viewModel, paragraphNode, sectionBreakConfig, paragraph);
|
||||
let newGlyph: Nullable<IDocumentSkeletonGlyph> = null;
|
||||
const customBlock = viewModel.getCustomBlockWithoutSetCurrentIndex(paragraphNode.startIndex + i);
|
||||
|
||||
@@ -24,11 +24,12 @@ import type {
|
||||
import type { ISectionBreakConfig } from '../../../../basics/interfaces';
|
||||
import type { DataStreamTreeNode } from '../../view-model/data-stream-tree-node';
|
||||
import type { DocumentViewModel } from '../../view-model/document-view-model';
|
||||
import type { DocumentFootnoteLayout } from '../footnote-layout';
|
||||
import type { ICellSkeletonBuildState } from '../model/page';
|
||||
import type { ILayoutContext } from '../tools';
|
||||
import { BooleanNumber, TableAlignmentType, TableRowHeightRule, VerticalAlignmentType } from '@univerjs/core';
|
||||
import { DocumentSkeletonPageType } from '../../../../basics/i-document-skeleton-cached';
|
||||
import { getDocumentCompatibilityPolicy } from '../../document-compatibility';
|
||||
import { getDocumentCompatibilityPolicy, isTraditionalDocumentCompatibility } from '../../document-compatibility';
|
||||
import {
|
||||
createNullCellPage,
|
||||
createSkeletonCellPages,
|
||||
@@ -281,7 +282,14 @@ function _finishTableSkeletonBuild(state: ITableSkeletonBuildState): void {
|
||||
const { curPage, table, tableSkeleton } = state;
|
||||
tableSkeleton.width = state.tableWidth;
|
||||
tableSkeleton.height = state.rowTop;
|
||||
const mergedRowHeightsChanged = isTraditionalDocumentCompatibility(state.sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy()) &&
|
||||
resolveMergedRowHeights(tableSkeleton);
|
||||
applyMergedCellSpanHeights(tableSkeleton);
|
||||
if (mergedRowHeightsChanged) {
|
||||
for (const row of tableSkeleton.rows) {
|
||||
_verticalAlignInCell(row, table.tableRows[row.index]);
|
||||
}
|
||||
}
|
||||
|
||||
const { pageWidth, marginLeft = 0, marginRight = 0 } = curPage;
|
||||
tableSkeleton.left = getTableLeft(
|
||||
@@ -337,6 +345,7 @@ export interface ISlicedTableSkeletonBuildState {
|
||||
rowIndex: number;
|
||||
columnIndex: number;
|
||||
preparedCellPages: Map<number, IDocumentSkeletonPage[]>;
|
||||
cellPageHeights?: number[];
|
||||
pendingCellBuild: Nullable<ICellSkeletonBuildState>;
|
||||
complete: boolean;
|
||||
result: Nullable<ISlicedTableSkeletonParams>;
|
||||
@@ -348,6 +357,8 @@ interface ICreateTableCache {
|
||||
remainHeight: number;
|
||||
repeatRows: DataStreamTreeNode[];
|
||||
repeatRowsHeight: number;
|
||||
notes?: ReturnType<DocumentFootnoteLayout['createTablePagination']>;
|
||||
fromCurrentPage?: boolean;
|
||||
}
|
||||
|
||||
export function startTableSkeletonsBuild(
|
||||
@@ -380,6 +391,7 @@ export function startTableSkeletonsBuild(
|
||||
remainHeight: availableHeight,
|
||||
repeatRows: getLeadingRepeatHeaderRows(table, rowNodes),
|
||||
repeatRowsHeight: 0,
|
||||
notes: ctx.footnoteLayout?.createTablePagination(curPage, sectionBreakConfig, availableHeight),
|
||||
},
|
||||
rowIndex: 0,
|
||||
columnIndex: 0,
|
||||
@@ -407,7 +419,7 @@ export function stepTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState):
|
||||
const precomputedRow = precomputedTableSkeletons.get(state.ctx)?.get(state.tableNode.startIndex)?.rows[state.rowIndex];
|
||||
// Pagination uses the same current-generation measurements as the synchronous
|
||||
// path. Only cells in rows that actually need splitting must be laid out again.
|
||||
const reusePrecomputedRow = canReusePrecomputedTableRow(state.curPage, state.createCache, rowSource, precomputedRow);
|
||||
const reusePrecomputedRow = state.cellPageHeights == null && canReusePrecomputedTableRow(state.curPage, state.createCache, rowSource, precomputedRow);
|
||||
if (!reusePrecomputedRow && cellNode != null && !isCoveredTableCell(cellConfig)) {
|
||||
const pageContentHeight = getAvailableHeight(state.curPage, state.createCache, false);
|
||||
const availableHeight = getAvailableHeight(state.curPage, state.createCache, true);
|
||||
@@ -427,7 +439,8 @@ export function stepTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState):
|
||||
state.rowIndex,
|
||||
state.columnIndex,
|
||||
firstCellPageHeight,
|
||||
pageContentHeight
|
||||
pageContentHeight,
|
||||
state.cellPageHeights
|
||||
);
|
||||
if (!stepSkeletonCellPagesBuild(state.pendingCellBuild)) {
|
||||
return false;
|
||||
@@ -442,7 +455,8 @@ export function stepTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState):
|
||||
state.rowIndex,
|
||||
state.columnIndex,
|
||||
firstCellPageHeight,
|
||||
pageContentHeight
|
||||
pageContentHeight,
|
||||
state.cellPageHeights
|
||||
)
|
||||
: state.pendingCellBuild.pages;
|
||||
state.pendingCellBuild = null;
|
||||
@@ -454,7 +468,7 @@ export function stepTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState):
|
||||
return false;
|
||||
}
|
||||
|
||||
dealWithTableRow(
|
||||
const nextHeights = dealWithTableRow(
|
||||
state.ctx,
|
||||
state.curPage,
|
||||
state.skeTables,
|
||||
@@ -466,9 +480,17 @@ export function stepTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState):
|
||||
state.createCache,
|
||||
false,
|
||||
precomputedRow,
|
||||
state.preparedCellPages
|
||||
state.preparedCellPages,
|
||||
state.cellPageHeights
|
||||
);
|
||||
if (nextHeights) {
|
||||
state.cellPageHeights = nextHeights;
|
||||
state.columnIndex = 0;
|
||||
state.preparedCellPages = new Map();
|
||||
return false;
|
||||
}
|
||||
state.rowIndex++;
|
||||
state.cellPageHeights = undefined;
|
||||
state.columnIndex = 0;
|
||||
state.preparedCellPages = new Map();
|
||||
state.pendingCellBuild = null;
|
||||
@@ -500,6 +522,7 @@ function _finishTableSkeletonsBuild(state: ISlicedTableSkeletonBuildState): void
|
||||
state.result = {
|
||||
skeTables: state.skeTables,
|
||||
fromCurrentPage:
|
||||
state.createCache.fromCurrentPage !== false &&
|
||||
state.skeTables[0].height <=
|
||||
state.availableHeight + policy.table.currentPageOverflowTolerance,
|
||||
};
|
||||
@@ -563,6 +586,7 @@ export function createTableSkeletons(
|
||||
remainHeight: availableHeight,
|
||||
repeatRows,
|
||||
repeatRowsHeight: 0,
|
||||
notes: ctx.footnoteLayout?.createTablePagination(curPage, sectionBreakConfig, availableHeight),
|
||||
};
|
||||
|
||||
skeTables.push(curTableSkeleton);
|
||||
@@ -570,25 +594,31 @@ export function createTableSkeletons(
|
||||
for (const rowNode of rowNodes) {
|
||||
const row = rowNodes.indexOf(rowNode);
|
||||
|
||||
dealWithTableRow(
|
||||
ctx,
|
||||
curPage,
|
||||
skeTables,
|
||||
viewModel,
|
||||
sectionBreakConfig,
|
||||
rowNode,
|
||||
row,
|
||||
table,
|
||||
createCache,
|
||||
false,
|
||||
precomputedTable?.rows[row]
|
||||
);
|
||||
let cellPageHeights: number[] | undefined;
|
||||
do {
|
||||
cellPageHeights = dealWithTableRow(
|
||||
ctx,
|
||||
curPage,
|
||||
skeTables,
|
||||
viewModel,
|
||||
sectionBreakConfig,
|
||||
rowNode,
|
||||
row,
|
||||
table,
|
||||
createCache,
|
||||
false,
|
||||
precomputedTable?.rows[row],
|
||||
undefined,
|
||||
cellPageHeights
|
||||
);
|
||||
} while (cellPageHeights);
|
||||
}
|
||||
|
||||
updateTableSkeletonsPosition(createCache, curPage, skeTables, table);
|
||||
|
||||
const documentCompatibilityPolicy = sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy();
|
||||
const fromCurrentPage =
|
||||
createCache.fromCurrentPage !== false &&
|
||||
skeTables[0].height <= availableHeight + documentCompatibilityPolicy.table.currentPageOverflowTolerance;
|
||||
|
||||
return {
|
||||
@@ -666,8 +696,9 @@ function dealWithTableRow(
|
||||
cache: ICreateTableCache,
|
||||
isRepeatRow = false,
|
||||
precomputedRow?: IDocumentSkeletonRow,
|
||||
preparedCellPages?: Map<number, IDocumentSkeletonPage[]>
|
||||
) {
|
||||
preparedCellPages?: Map<number, IDocumentSkeletonPage[]>,
|
||||
cellPageHeights?: readonly number[]
|
||||
): number[] | undefined {
|
||||
const pageContentHeight = getAvailableHeight(curPage, cache, false);
|
||||
const availableHeight = getAvailableHeight(curPage, cache, true);
|
||||
const documentCompatibilityPolicy = sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy();
|
||||
@@ -677,7 +708,7 @@ function dealWithTableRow(
|
||||
const { hRule, val } = trHeight;
|
||||
const canRowSplit = cantSplit !== BooleanNumber.TRUE && trHeight.hRule !== TableRowHeightRule.EXACT;
|
||||
const needOpenNewTable = cache.remainHeight <= 0;
|
||||
const precomputedRowFits = !isRepeatRow && canReusePrecomputedTableRow(curPage, cache, rowSource, precomputedRow);
|
||||
const precomputedRowFits = !isRepeatRow && cellPageHeights == null && canReusePrecomputedTableRow(curPage, cache, rowSource, precomputedRow);
|
||||
const rowSkeletons: IDocumentSkeletonRow[] = precomputedRowFits ? [precomputedRow] : [];
|
||||
let curTableSkeleton = getCurTableSkeleton(skeTables);
|
||||
|
||||
@@ -713,7 +744,8 @@ function dealWithTableRow(
|
||||
row,
|
||||
col,
|
||||
canRowSplit && !needOpenNewTable ? cache.remainHeight : availableHeight,
|
||||
pageContentHeight
|
||||
pageContentHeight,
|
||||
cellPageHeights
|
||||
);
|
||||
while (rowSkeletons.length < cellPageSkeletons.length) {
|
||||
rowSkeletons.push(createNullRowSkeletonWithCells(
|
||||
@@ -786,6 +818,22 @@ function dealWithTableRow(
|
||||
rowSke.height = rowHeights[rowIndex];
|
||||
}
|
||||
|
||||
if (!isRepeatRow && cache.notes) {
|
||||
let nextHeights: number[] | undefined;
|
||||
if (canRowSplit) {
|
||||
nextHeights = cache.notes.measureRow(curTableSkeleton, rowSkeletons, cellPageHeights);
|
||||
} else if (cache.notes.renumberWholeRow(curTableSkeleton, rowSkeletons, cache.remainHeight)) {
|
||||
nextHeights = [];
|
||||
}
|
||||
if (nextHeights) {
|
||||
const lists = ctx.skeletonResourceReference.skeListLevel;
|
||||
if (lists) {
|
||||
rollbackListCache(lists, rowNode);
|
||||
}
|
||||
return nextHeights;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRepeatRow && row < cache.repeatRows.length) {
|
||||
cache.repeatRowsHeight += rowHeights.reduce((total, height) => total + height, 0);
|
||||
}
|
||||
@@ -800,13 +848,19 @@ function dealWithTableRow(
|
||||
const rowSkeleton = rowSkeletons.shift()!;
|
||||
const lastRow = curTableSkeleton.rows[curTableSkeleton.rows.length - 1];
|
||||
const rowOverflowHeight = rowSkeleton.height - cache.remainHeight;
|
||||
const shouldOpenNewTable =
|
||||
const geometryOverflow =
|
||||
cache.remainHeight <= 0 ||
|
||||
forcedPageBreakRows.has(rowSkeleton) ||
|
||||
rowOverflowHeight > documentCompatibilityPolicy.table.rowOverflowTolerance;
|
||||
let remainingWithNotes = geometryOverflow ? undefined : cache.notes?.append(curTableSkeleton, rowSkeleton);
|
||||
const shouldOpenNewTable = geometryOverflow || (cache.notes != null && remainingWithNotes == null);
|
||||
|
||||
if (shouldOpenNewTable) {
|
||||
cache.remainHeight = getAvailableHeight(curPage, cache, row !== 0 && rowSkeleton.index !== lastRow.index);
|
||||
cache.notes?.nextPage();
|
||||
if (cache.notes && skeTables.length === 1 && curTableSkeleton.rows.length === 0) {
|
||||
cache.fromCurrentPage = false;
|
||||
}
|
||||
cache.remainHeight = getAvailableHeight(curPage, cache, row !== 0 && rowSkeleton.index !== lastRow?.index);
|
||||
cache.rowTop = 0;
|
||||
|
||||
if (curTableSkeleton.rows.length > 0) {
|
||||
@@ -833,6 +887,11 @@ function dealWithTableRow(
|
||||
});
|
||||
}
|
||||
}
|
||||
remainingWithNotes = cache.notes?.append(getCurTableSkeleton(skeTables), rowSkeleton);
|
||||
while (remainingWithNotes == null && cache.notes?.hasContinuation()) {
|
||||
cache.notes.nextPage();
|
||||
remainingWithNotes = cache.notes.append(getCurTableSkeleton(skeTables), rowSkeleton);
|
||||
}
|
||||
}
|
||||
|
||||
curTableSkeleton = getCurTableSkeleton(skeTables);
|
||||
@@ -842,7 +901,7 @@ function dealWithTableRow(
|
||||
|
||||
curTableSkeleton.rows.push(rowSkeleton);
|
||||
rowSkeleton.parent = curTableSkeleton;
|
||||
cache.remainHeight -= rowSkeleton.height;
|
||||
cache.remainHeight = remainingWithNotes ?? cache.remainHeight - rowSkeleton.height;
|
||||
|
||||
cache.rowTop += rowSkeleton.height;
|
||||
}
|
||||
@@ -975,6 +1034,60 @@ function applyMergedCellSpanHeights(tableSkeleton: IDocumentSkeletonTable): void
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMergedRowHeights(tableSkeleton: IDocumentSkeletonTable): boolean {
|
||||
const rows = tableSkeleton.rows;
|
||||
const sources = tableSkeleton.tableSource.tableRows;
|
||||
if (!sources.some((row) => row.tableCells.some((cell) => (cell.rowSpan ?? 1) > 1))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A merged cell constrains the combined height of its rows, not just the first row.
|
||||
// First measure independent cells and explicit row minima, then satisfy each span.
|
||||
const cellHeight = (cell: IDocumentSkeletonPage) =>
|
||||
cell.height + (cell.originMarginTop ?? 0) + (cell.originMarginBottom ?? 0);
|
||||
for (const row of rows) {
|
||||
const source = sources[row.index];
|
||||
const { hRule, val } = source.trHeight;
|
||||
const contentHeight = row.cells.reduce((height, cell, column) =>
|
||||
(source.tableCells[column].rowSpan ?? 1) > 1 || isCoveredTableCell(source.tableCells[column])
|
||||
? height
|
||||
: Math.max(height, cellHeight(cell)), 0);
|
||||
row.height = contentHeight;
|
||||
if (hRule === TableRowHeightRule.EXACT) {
|
||||
row.height = val.v;
|
||||
} else if (hRule === TableRowHeightRule.AT_LEAST) {
|
||||
row.height = Math.max(contentHeight, val.v);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
sources[row.index].tableCells.forEach((source, column) => {
|
||||
if ((source.rowSpan ?? 1) <= 1) {
|
||||
return;
|
||||
}
|
||||
const span = rows.slice(row.index, row.index + source.rowSpan!);
|
||||
const extraHeight = cellHeight(row.cells[column]) - span.reduce((height, item) => height + item.height, 0);
|
||||
const expandable = span.filter((item) => sources[item.index].trHeight.hRule !== TableRowHeightRule.EXACT);
|
||||
if (extraHeight > 0 && expandable.length > 0) {
|
||||
for (const item of expandable) {
|
||||
item.height += extraHeight / expandable.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let top = 0;
|
||||
for (const row of rows) {
|
||||
row.top = top;
|
||||
top += row.height;
|
||||
for (const cell of row.cells) {
|
||||
cell.pageHeight = row.height;
|
||||
}
|
||||
}
|
||||
tableSkeleton.height = top;
|
||||
return true;
|
||||
}
|
||||
|
||||
function createMergedAwareNullCellPage(
|
||||
ctx: ILayoutContext,
|
||||
sectionBreakConfig: ISectionBreakConfig,
|
||||
|
||||
@@ -103,11 +103,14 @@ import {
|
||||
hydrateDocumentSkeletonPageMaterializationPlaceholder,
|
||||
serializeDocumentSkeletonPage,
|
||||
} from './document-layout-page-patch';
|
||||
import { DocumentEndnoteLayout, getEndnoteFlowBottom } from './endnote-layout';
|
||||
import { DocumentFootnoteLayout } from './footnote-layout';
|
||||
import { Hyphen } from './hyphenation/hyphen';
|
||||
import { Lang } from './hyphenation/lang';
|
||||
import { LanguageDetector } from './hyphenation/language-detector';
|
||||
import { createSkeletonPage } from './model/page';
|
||||
import { createSkeletonSection } from './model/section';
|
||||
import { resolveNoteReferences } from './note-numbering';
|
||||
import {
|
||||
getLastNotFullColumnInfo,
|
||||
getLastPage,
|
||||
@@ -361,7 +364,7 @@ interface IIncrementalLayoutState {
|
||||
allowMetadataOnlyStructuralTailReuse: boolean;
|
||||
reusedTail: boolean;
|
||||
tailConvergencePageCount: number;
|
||||
dirtyRetryCount: number;
|
||||
dirtyRetries: Map<number, number>;
|
||||
preparedHyphenationNodes: WeakSet<DataStreamTreeNode>;
|
||||
pendingHyphenation: Promise<void> | null;
|
||||
hyphenationError: Error | null;
|
||||
@@ -591,6 +594,9 @@ function copyPageBoundaryMetadata(
|
||||
}
|
||||
|
||||
function shiftPageCharacterOffsets(page: IDocumentSkeletonPage, delta: number): void {
|
||||
for (const note of page.notes ?? []) {
|
||||
note.referenceIndex += delta;
|
||||
}
|
||||
if (delta === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -755,6 +761,9 @@ function hasReusablePaginatedTailBoundary(
|
||||
invalidation: IDocumentLayoutInvalidation,
|
||||
allowMetadataOnlyStructuralTailReuse: boolean
|
||||
): boolean {
|
||||
if (previousPage.notes?.length || currentPage.notes?.length) {
|
||||
return false;
|
||||
}
|
||||
const mutationDelta = invalidation.newEnd - invalidation.oldEnd;
|
||||
const hasStructuralContainers = previousPage.skeTables.size > 0 ||
|
||||
previousPage.skeColumnGroups.size > 0 ||
|
||||
@@ -851,6 +860,15 @@ function getPagePath(page: IDocumentSkeletonPage) {
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
let parent: any = skeNode.parent;
|
||||
while (parent) {
|
||||
if (parent.page === skeNode && parent.noteId && parent.parent?.notes) {
|
||||
const index = parent.parent.notes.indexOf(parent);
|
||||
if (index !== -1) {
|
||||
path.unshift('notes', index, 'page');
|
||||
}
|
||||
skeNode = parent.parent;
|
||||
parent = skeNode.parent;
|
||||
continue;
|
||||
}
|
||||
if (parent.page === skeNode && parent.parent?.columns) {
|
||||
const index = parent.parent.columns.indexOf(parent);
|
||||
|
||||
@@ -997,6 +1015,12 @@ function serializePaginatedContinuationCheckpoint(page: IDocumentSkeletonPagePat
|
||||
// protected range are complete, self-contained page publications. The
|
||||
// checkpoint only needs to prove that the next page starts at the same
|
||||
// logical offset and with the same paginated section configuration.
|
||||
notes: page.notes?.map((note) => ({
|
||||
noteId: note.noteId,
|
||||
referenceIndex: note.referenceIndex,
|
||||
continued: note.continued,
|
||||
page: serializePaginatedContinuationCheckpoint(note.page),
|
||||
})),
|
||||
tables: page.skeTables.map(([tableId, table]) => ({
|
||||
tableId,
|
||||
st: table.st,
|
||||
@@ -1140,8 +1164,6 @@ export class DocumentSkeleton extends Skeleton {
|
||||
|
||||
private _languageDetector = LanguageDetector.getInstance();
|
||||
|
||||
private _iteratorCount = 0;
|
||||
|
||||
private _initialWidth = 0;
|
||||
|
||||
private _paginationMetrics: Nullable<IDocumentPaginationMetrics> = null;
|
||||
@@ -1354,7 +1376,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
allowMetadataOnlyStructuralTailReuse: options?.allowMetadataOnlyStructuralTailReuse === true,
|
||||
reusedTail: false,
|
||||
tailConvergencePageCount: incrementalStart.ctx.skeleton.pages.length,
|
||||
dirtyRetryCount: 0,
|
||||
dirtyRetries: new Map(),
|
||||
preparedHyphenationNodes: new WeakSet(),
|
||||
pendingHyphenation: null,
|
||||
hyphenationError: null,
|
||||
@@ -1442,7 +1464,8 @@ export class DocumentSkeleton extends Skeleton {
|
||||
blocks[anchorBlockIndex].block.children.length === 0;
|
||||
// A Modern page can contain the entire document. Paginated tail reuse
|
||||
// deep-clones that page in one atomic step, bypassing the slice budget.
|
||||
if (mode === 'paginated' && reuseInteractionPagePrefix && canResumePlainParagraph) {
|
||||
if (mode === 'paginated' && reuseInteractionPagePrefix && canResumePlainParagraph &&
|
||||
!previousSkeleton.pages[anchorPageIndex]?.notes?.length) {
|
||||
const interactionPreviousAnchor = mapCurrentOffsetToPrevious(anchor, invalidation);
|
||||
const interactionPageIndex = interactionSkeleton == null
|
||||
? -1
|
||||
@@ -1494,7 +1517,8 @@ export class DocumentSkeleton extends Skeleton {
|
||||
// Include page zero: an explicit page-break paragraph can rewind there
|
||||
// while still pointing past the cover content that must also be rebuilt.
|
||||
while (startPageIndex >= 0) {
|
||||
const pageStart = getFirstBodyFlowCharIndex(previousSkeleton.pages[startPageIndex]);
|
||||
const previousPage = previousSkeleton.pages[startPageIndex];
|
||||
const pageStart = Math.min(getFirstBodyFlowCharIndex(previousPage), ...(previousPage.notes ?? []).map((note) => note.referenceIndex));
|
||||
const currentPageStart = mapPreviousOffsetToCurrent(pageStart, invalidation);
|
||||
const firstPageBlockIndex = blocks.findIndex(({ block }) => blockContainsOffset(block, currentPageStart));
|
||||
if (firstPageBlockIndex < 0 || firstPageBlockIndex >= startBlockIndex) {
|
||||
@@ -2970,6 +2994,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
break;
|
||||
}
|
||||
|
||||
case DocumentSkeletonPageType.NOTE:
|
||||
case DocumentSkeletonPageType.CELL: {
|
||||
pageIndex = typeof path[1] === 'number' ? path[1] : segmentPage;
|
||||
break;
|
||||
@@ -3049,6 +3074,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
break;
|
||||
}
|
||||
|
||||
case DocumentSkeletonPageType.NOTE:
|
||||
case DocumentSkeletonPageType.CELL: {
|
||||
pageIndex = typeof path[1] === 'number' ? path[1] : segmentPageIndex;
|
||||
break;
|
||||
@@ -3276,6 +3302,32 @@ export class DocumentSkeleton extends Skeleton {
|
||||
}
|
||||
const pageLength = pages.length;
|
||||
|
||||
this._findLiquid.reset();
|
||||
if (restrictions == null || restrictions.strict === false) {
|
||||
for (let pi = 0; pi < pageLength; pi++) {
|
||||
const page = pages[pi];
|
||||
let insideNoteArea = false;
|
||||
for (const note of page.notes ?? []) {
|
||||
const left = this._findLiquid.x + note.left;
|
||||
const top = this._findLiquid.y + note.top;
|
||||
if (x < left || x > left + note.page.pageWidth || y < top || y > top + note.page.height) {
|
||||
continue;
|
||||
}
|
||||
insideNoteArea = true;
|
||||
const noteCache: INearestCache = { nearestNodeList: [], nearestNodeDistanceList: [] };
|
||||
const match = this._collectNearestNode(note.page, DocumentSkeletonPageType.NOTE, page, note.noteId, pi, noteCache, x, y, pageLength);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
cache.nearestNodeList.push(...noteCache.nearestNodeList);
|
||||
cache.nearestNodeDistanceList.push(...noteCache.nearestNodeDistanceList);
|
||||
}
|
||||
if (insideNoteArea) {
|
||||
return this._getNearestNode(cache.nearestNodeList, cache.nearestNodeDistanceList);
|
||||
}
|
||||
this._translatePage(page, pageLayoutType, pageMarginLeft, pageMarginTop);
|
||||
}
|
||||
}
|
||||
this._findLiquid.reset();
|
||||
if (restrictions == null) {
|
||||
for (let pi = 0; pi < pageLength; pi++) {
|
||||
@@ -3341,12 +3393,25 @@ export class DocumentSkeleton extends Skeleton {
|
||||
const { segmentId, segmentPage, strict } = restrictions;
|
||||
let exactMatch = null;
|
||||
|
||||
if (strict && this._docViewModel.getNoteTreeMap().has(segmentId)) {
|
||||
for (let pi = 0; pi < pageLength; pi++) {
|
||||
const page = pages[pi];
|
||||
for (const note of page.notes ?? []) {
|
||||
if (note.noteId === segmentId) {
|
||||
exactMatch = this._collectNearestNode(note.page, DocumentSkeletonPageType.NOTE, page, segmentId, pi, cache, x, y, pageLength);
|
||||
}
|
||||
}
|
||||
this._translatePage(page, pageLayoutType, pageMarginLeft, pageMarginTop);
|
||||
}
|
||||
return exactMatch ?? this._getNearestNode(cache.nearestNodeList, cache.nearestNodeDistanceList);
|
||||
}
|
||||
|
||||
if (strict === false) {
|
||||
for (let pi = 0; pi < pageLength; pi++) {
|
||||
const page = pages[pi];
|
||||
const { headerId, footerId, pageWidth } = page;
|
||||
|
||||
if (segmentId !== '') {
|
||||
if (segmentId !== '' && !this._docViewModel.getNoteTreeMap().has(segmentId)) {
|
||||
const headerSke = skeHeaders.get(headerId)?.get(pageWidth) as IDocumentSkeletonPage;
|
||||
|
||||
if (headerSke) {
|
||||
@@ -3468,10 +3533,13 @@ export class DocumentSkeleton extends Skeleton {
|
||||
const { sections, skeTables, skeColumnGroups = new Map() } = segmentPage;
|
||||
this._findLiquid.translateSave();
|
||||
|
||||
const pageLeft = this._findLiquid.x;
|
||||
const pageRight = pageLeft + page.pageWidth; // Use page.pageWidth instead of segmentPage.pageWidth, because the segmentPage not include the margin left and right.
|
||||
const pageTop = this._findLiquid.y + (pageType === DocumentSkeletonPageType.FOOTER ? page.pageHeight - segmentPage.pageHeight : 0);
|
||||
const pageBottom = pageTop + segmentPage.pageHeight;
|
||||
const note = pageType === DocumentSkeletonPageType.NOTE
|
||||
? page.notes?.find((fragment) => fragment.page === segmentPage)
|
||||
: undefined;
|
||||
const pageLeft = this._findLiquid.x + (note?.left ?? 0);
|
||||
const pageRight = pageLeft + (note ? segmentPage.pageWidth : page.pageWidth);
|
||||
const pageTop = this._findLiquid.y + (note?.top ?? (pageType === DocumentSkeletonPageType.FOOTER ? page.pageHeight - segmentPage.pageHeight : 0));
|
||||
const pageBottom = pageTop + (note ? segmentPage.height : segmentPage.pageHeight);
|
||||
|
||||
let pointInPage = x >= pageLeft
|
||||
&& x <= pageRight
|
||||
@@ -3498,6 +3566,10 @@ export class DocumentSkeleton extends Skeleton {
|
||||
}
|
||||
|
||||
switch (pageType) {
|
||||
case DocumentSkeletonPageType.NOTE: {
|
||||
this._findLiquid.translate(note?.left ?? page.marginLeft, note?.top ?? page.marginTop);
|
||||
break;
|
||||
}
|
||||
case DocumentSkeletonPageType.HEADER: {
|
||||
this._findLiquid.translatePagePadding({
|
||||
...segmentPage,
|
||||
@@ -4440,6 +4512,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
}
|
||||
|
||||
if (result.complete) {
|
||||
ctx.endnoteLayout?.finishSection(ctx.skeleton.pages, state.sectionIndex, sectionBreakConfig);
|
||||
const nextColumnProperties = state.sectionIndex + 1 < sections.length
|
||||
? prepareSectionBreakConfig(ctx, state.sectionIndex + 1).columnProperties ?? []
|
||||
: [];
|
||||
@@ -4740,13 +4813,13 @@ export class DocumentSkeleton extends Skeleton {
|
||||
// incremental scheduler. A wrapped floating object can invalidate lines that
|
||||
// precede its anchor; completing that retry synchronously would reintroduce the
|
||||
// exact long main-thread task that incremental pagination is meant to remove.
|
||||
if (layoutAnchor == null || state.dirtyRetryCount >= 10) {
|
||||
if (layoutAnchor == null || (state.dirtyRetries.get(layoutAnchor) ?? 0) >= 10) {
|
||||
resetContext(ctx);
|
||||
this._finishIncrementalLayout(state);
|
||||
return;
|
||||
}
|
||||
|
||||
state.dirtyRetryCount++;
|
||||
state.dirtyRetries.set(layoutAnchor, (state.dirtyRetries.get(layoutAnchor) ?? 0) + 1);
|
||||
resetContext(ctx);
|
||||
ctx.layoutStartPointer[''] = null;
|
||||
|
||||
@@ -4814,6 +4887,10 @@ export class DocumentSkeleton extends Skeleton {
|
||||
}
|
||||
removeDupPages(ctx);
|
||||
mergeContinuousDuplicatePages(skeleton.pages, state.mode === 'continuous');
|
||||
if (ctx.footnoteLayout) {
|
||||
ctx.footnoteLayout.finish(skeleton.pages, prepareSectionBreakConfig(ctx, ctx.viewModel.getChildren().length - 1));
|
||||
updateBlockIndex(skeleton.pages, -1, ctx.docsConfig.documentCompatibilityPolicy);
|
||||
}
|
||||
if (state.mode === 'continuous') {
|
||||
updateBlockIndex(skeleton.pages, -1, ctx.docsConfig.documentCompatibilityPolicy);
|
||||
updateInlineDrawingCoordsAndBorder(ctx, skeleton.pages);
|
||||
@@ -4831,7 +4908,6 @@ export class DocumentSkeleton extends Skeleton {
|
||||
setPageParent(skeleton.pages, skeleton);
|
||||
state.complete = true;
|
||||
state.stableLaidOutThrough = state.laidOutThrough;
|
||||
this._iteratorCount = 0;
|
||||
}
|
||||
|
||||
private _prepareLayoutContext(): ILayoutContext {
|
||||
@@ -4875,9 +4951,12 @@ export class DocumentSkeleton extends Skeleton {
|
||||
drawingAnchor,
|
||||
};
|
||||
|
||||
return {
|
||||
const ctx: ILayoutContext = {
|
||||
viewModel,
|
||||
dataModel,
|
||||
noteReferences: documentStyle.documentFlavor === DocumentFlavor.TRADITIONAL
|
||||
? resolveNoteReferences(dataModel.getSnapshot())
|
||||
: undefined,
|
||||
skeleton,
|
||||
skeletonResourceReference,
|
||||
docsConfig,
|
||||
@@ -4901,6 +4980,11 @@ export class DocumentSkeleton extends Skeleton {
|
||||
hyphen: this._hyphen,
|
||||
languageDetector: this._languageDetector,
|
||||
};
|
||||
if (ctx.noteReferences?.size) {
|
||||
ctx.footnoteLayout = new DocumentFootnoteLayout(ctx);
|
||||
ctx.endnoteLayout = new DocumentEndnoteLayout(ctx);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4932,7 +5016,45 @@ export class DocumentSkeleton extends Skeleton {
|
||||
*/
|
||||
|
||||
private _createSkeleton(ctx: ILayoutContext, _bounds?: IViewportInfo): IDocumentSkeletonCached {
|
||||
// console.log('createSkeleton: iterate ', this._iteratorCount, 'times');
|
||||
const retries = new Map<number, number>();
|
||||
while (true) {
|
||||
this._createSkeletonPass(ctx);
|
||||
const anchor = ctx.layoutStartPointer[''];
|
||||
if (!ctx.isDirty || anchor == null || (retries.get(anchor) ?? 0) >= 10) {
|
||||
break;
|
||||
}
|
||||
// Bound retries at the same paragraph, not across independent reflows
|
||||
// throughout a long document. Iterate instead of growing the call stack.
|
||||
retries.set(anchor, (retries.get(anchor) ?? 0) + 1);
|
||||
resetContext(ctx);
|
||||
}
|
||||
const { skeleton, viewModel } = ctx;
|
||||
// Calculate page and section position information
|
||||
removeDupPages(ctx);
|
||||
ctx.footnoteLayout?.finish(skeleton.pages, prepareSectionBreakConfig(ctx, viewModel.getChildren().length - 1));
|
||||
updateBlockIndex(skeleton.pages, -1, ctx.docsConfig.documentCompatibilityPolicy);
|
||||
mergeContinuousDuplicatePages(
|
||||
skeleton.pages,
|
||||
ctx.dataModel.documentStyle.documentFlavor === DocumentFlavor.MODERN
|
||||
);
|
||||
// Calculate inline drawing position and update.
|
||||
updateInlineDrawingCoordsAndBorder(ctx, skeleton.pages);
|
||||
for (const hSkeMap of skeleton.skeHeaders.values()) {
|
||||
for (const page of hSkeMap.values()) {
|
||||
updateInlineDrawingCoordsAndBorder(ctx, [page]);
|
||||
}
|
||||
}
|
||||
for (const fSkeMap of skeleton.skeFooters.values()) {
|
||||
for (const page of fSkeMap.values()) {
|
||||
updateInlineDrawingCoordsAndBorder(ctx, [page]);
|
||||
}
|
||||
}
|
||||
setPageParent(skeleton.pages, skeleton);
|
||||
|
||||
return skeleton;
|
||||
}
|
||||
|
||||
private _createSkeletonPass(ctx: ILayoutContext): void {
|
||||
const { viewModel, skeleton, skeletonResourceReference } = ctx;
|
||||
|
||||
const allSkeletonPages = skeleton.pages;
|
||||
@@ -5090,43 +5212,13 @@ export class DocumentSkeleton extends Skeleton {
|
||||
}
|
||||
|
||||
allSkeletonPages.push(...pages);
|
||||
ctx.endnoteLayout?.finishSection(allSkeletonPages, i, sectionBreakConfig);
|
||||
|
||||
// The page needs to be reflowed due to floating objects.
|
||||
if (ctx.isDirty) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 10 is too small?
|
||||
if (ctx.isDirty && this._iteratorCount < 10) {
|
||||
this._iteratorCount++;
|
||||
resetContext(ctx);
|
||||
return this._createSkeleton(ctx, _bounds);
|
||||
} else {
|
||||
// Calculate page and section position information
|
||||
this._iteratorCount = 0;
|
||||
removeDupPages(ctx);
|
||||
updateBlockIndex(skeleton.pages, -1, ctx.docsConfig.documentCompatibilityPolicy);
|
||||
mergeContinuousDuplicatePages(
|
||||
skeleton.pages,
|
||||
ctx.dataModel.documentStyle.documentFlavor === DocumentFlavor.MODERN
|
||||
);
|
||||
// Calculate inline drawing position and update.
|
||||
updateInlineDrawingCoordsAndBorder(ctx, skeleton.pages);
|
||||
for (const hSkeMap of skeleton.skeHeaders.values()) {
|
||||
for (const page of hSkeMap.values()) {
|
||||
updateInlineDrawingCoordsAndBorder(ctx, [page]);
|
||||
}
|
||||
}
|
||||
for (const fSkeMap of skeleton.skeFooters.values()) {
|
||||
for (const page of fSkeMap.values()) {
|
||||
updateInlineDrawingCoordsAndBorder(ctx, [page]);
|
||||
}
|
||||
}
|
||||
setPageParent(skeleton.pages, skeleton);
|
||||
|
||||
return skeleton;
|
||||
}
|
||||
}
|
||||
|
||||
// A page with multiple sections only occurs in SectionType.CONTINUOUS
|
||||
@@ -5147,7 +5239,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
} = curSkeletonPage;
|
||||
const pageContentWidth = pageWidth - curPageML - curPageMR;
|
||||
const pageContentHeight = pageHeight - curPageMT - curPageMB;
|
||||
const lastSectionBottom = (lastSection?.top || 0) + (lastSection?.height || 0);
|
||||
const lastSectionBottom = Math.max((lastSection?.top || 0) + (lastSection?.height || 0), getEndnoteFlowBottom(curSkeletonPage));
|
||||
const newSection = createSkeletonSection(
|
||||
columnProperties,
|
||||
columnSeparatorType,
|
||||
@@ -5234,10 +5326,13 @@ export class DocumentSkeleton extends Skeleton {
|
||||
}
|
||||
|
||||
const { pages, skeFooters, skeHeaders } = skeletonData;
|
||||
// Unlike repeated headers/footers, a note offset identifies one
|
||||
// fragment. Its previous physical page is only a presentation hint.
|
||||
const isRepeatedSegment = segmentId !== '' && (skeHeaders.has(segmentId) || skeFooters.has(segmentId));
|
||||
|
||||
for (const page of pages) {
|
||||
const curPageIndex = pages.indexOf(page);
|
||||
if (segmentId && curPageIndex !== segmentPageIndex) {
|
||||
if (isRepeatedSegment && segmentPageIndex >= 0 && curPageIndex !== segmentPageIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -5247,7 +5342,11 @@ export class DocumentSkeleton extends Skeleton {
|
||||
if (segmentId) {
|
||||
const maybeHeaderSke = skeHeaders.get(segmentId)?.get(pageWidth);
|
||||
const maybeFooterSke = skeFooters.get(segmentId)?.get(pageWidth);
|
||||
if (maybeHeaderSke) {
|
||||
const note = page.notes?.find((fragment) => fragment.noteId === segmentId &&
|
||||
fragment.page.st <= charIndex && charIndex <= fragment.page.ed);
|
||||
if (note) {
|
||||
segmentPage = note.page;
|
||||
} else if (maybeHeaderSke) {
|
||||
segmentPage = maybeHeaderSke;
|
||||
} else if (maybeFooterSke) {
|
||||
segmentPage = maybeFooterSke;
|
||||
@@ -5327,7 +5426,7 @@ export class DocumentSkeleton extends Skeleton {
|
||||
line,
|
||||
divide,
|
||||
glyph,
|
||||
segmentPageIndex,
|
||||
segmentPageIndex: segmentId ? curPageIndex : segmentPageIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
IDocumentSkeletonDrawing,
|
||||
IDocumentSkeletonGlyph,
|
||||
IDocumentSkeletonLine,
|
||||
IDocumentSkeletonNote,
|
||||
IDocumentSkeletonNoteDecoration,
|
||||
IDocumentSkeletonPage,
|
||||
IDocumentSkeletonRow,
|
||||
IDocumentSkeletonSection,
|
||||
@@ -74,17 +76,19 @@ export interface IDocumentSkeletonColumnGroupPatch extends Omit<IDocumentSkeleto
|
||||
|
||||
export interface IDocumentSkeletonPagePatch extends Omit<
|
||||
IDocumentSkeletonPage,
|
||||
'sections' | 'skeDrawings' | 'skeTables' | 'skeColumnGroups' | 'parent'
|
||||
'sections' | 'skeDrawings' | 'skeTables' | 'skeColumnGroups' | 'notes' | 'noteDecorations' | 'parent'
|
||||
> {
|
||||
sections: IDocumentSkeletonSectionPatch[];
|
||||
skeDrawings: Array<[string, IDocumentSkeletonDrawingPatch]>;
|
||||
skeTables: Array<[string, IDocumentSkeletonTablePatch]>;
|
||||
skeColumnGroups: Array<[string, IDocumentSkeletonColumnGroupPatch]>;
|
||||
noteDecorations?: Array<Omit<IDocumentSkeletonNoteDecoration, 'page'> & { page: IDocumentSkeletonPagePatch }>;
|
||||
notes?: Array<Omit<IDocumentSkeletonNote, 'page' | 'parent'> & { page: IDocumentSkeletonPagePatch }>;
|
||||
}
|
||||
|
||||
export type IDocumentSkeletonPageGeometryPatch = Omit<
|
||||
IDocumentSkeletonPagePatch,
|
||||
'sections' | 'skeDrawings' | 'skeTables' | 'skeColumnGroups'
|
||||
'sections' | 'skeDrawings' | 'skeTables' | 'skeColumnGroups' | 'notes' | 'noteDecorations'
|
||||
>;
|
||||
|
||||
export type IDocumentSkeletonSectionGeometryPatch = Omit<IDocumentSkeletonSectionPatch, 'columns'>;
|
||||
@@ -206,9 +210,16 @@ export function serializeDocumentSkeletonPage(
|
||||
source: IDocumentSkeletonPage,
|
||||
omitResourceSources = false
|
||||
): IDocumentSkeletonPagePatch {
|
||||
const { notes, noteDecorations, ...geometry } = withoutParent(source);
|
||||
return {
|
||||
...withoutParent(source),
|
||||
...geometry,
|
||||
sections: source.sections.map(serializeSection),
|
||||
noteDecorations: noteDecorations?.map((decoration) => ({ ...decoration, page: serializeDocumentSkeletonPage(decoration.page, omitResourceSources) })),
|
||||
...(notes == null
|
||||
? {}
|
||||
: {
|
||||
notes: notes.map((note) => ({ ...withoutParent(note), page: serializeDocumentSkeletonPage(note.page, omitResourceSources) })),
|
||||
}),
|
||||
skeDrawings: [...source.skeDrawings].map(([drawingId, drawing]) => [
|
||||
drawingId,
|
||||
serializeDrawing(drawing, omitResourceSources),
|
||||
@@ -271,7 +282,7 @@ function getDocumentReferenceSource(
|
||||
snapshot: IDocumentData,
|
||||
segmentId: string
|
||||
): IReferenceSource {
|
||||
return snapshot.headers?.[segmentId] ?? snapshot.footers?.[segmentId] ?? snapshot;
|
||||
return snapshot.headers?.[segmentId] ?? snapshot.footers?.[segmentId] ?? snapshot.notes?.[segmentId] ?? snapshot;
|
||||
}
|
||||
|
||||
function requireDrawingOrigin(
|
||||
@@ -409,6 +420,8 @@ export function hydrateDocumentSkeletonPagePlaceholder(
|
||||
): IDocumentSkeletonPage {
|
||||
const {
|
||||
sections: _sections,
|
||||
notes: _notes,
|
||||
noteDecorations: _noteDecorations,
|
||||
skeDrawings: _skeDrawings,
|
||||
skeTables: _skeTables,
|
||||
skeColumnGroups: _skeColumnGroups,
|
||||
@@ -448,6 +461,14 @@ function hydrateDocumentSkeletonPageInternal(
|
||||
const page: IDocumentSkeletonPage = {
|
||||
...source,
|
||||
sections: [],
|
||||
noteDecorations: source.noteDecorations?.map((decoration) => ({
|
||||
...decoration,
|
||||
page: hydrateDocumentSkeletonPageInternal(decoration.page, undefined, snapshot, decoration.page.segmentId),
|
||||
})),
|
||||
notes: source.notes?.map((note) => ({
|
||||
...note,
|
||||
page: hydrateDocumentSkeletonPageInternal(note.page, undefined, snapshot, note.noteId),
|
||||
})),
|
||||
skeDrawings: new Map(source.skeDrawings.map(([drawingId, drawing]) => [
|
||||
drawingId,
|
||||
hydrateDrawing(drawing, snapshot, resourceSegmentId),
|
||||
@@ -457,6 +478,10 @@ function hydrateDocumentSkeletonPageInternal(
|
||||
parent,
|
||||
};
|
||||
page.sections = source.sections.map((section) => hydrateSection(section, page));
|
||||
for (const note of page.notes ?? []) {
|
||||
note.parent = page;
|
||||
note.page.parent = note;
|
||||
}
|
||||
page.skeTables = new Map(source.skeTables.map(([tableId, table]) => [
|
||||
tableId,
|
||||
hydrateTable(table, page, snapshot, resourceSegmentId),
|
||||
@@ -549,6 +574,8 @@ function serializePageGeometry(source: IDocumentSkeletonPage): IDocumentSkeleton
|
||||
const serialized = withoutParent(source);
|
||||
const {
|
||||
sections: _sections,
|
||||
notes: _notes,
|
||||
noteDecorations: _noteDecorations,
|
||||
skeDrawings: _skeDrawings,
|
||||
skeTables: _skeTables,
|
||||
skeColumnGroups: _skeColumnGroups,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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 { IDocumentSkeletonPage } from '../../../basics/i-document-skeleton-cached';
|
||||
import type { ISectionBreakConfig } from '../../../basics/interfaces';
|
||||
import type { ILayoutContext } from './tools';
|
||||
import { DocumentDataModel } from '@univerjs/core';
|
||||
import { DocumentViewModel } from '../view-model/document-view-model';
|
||||
import { defaultSeparatorBody, layoutNoteBody, layoutNoteSegment } from './footnote-layout';
|
||||
import { createSkeletonPage } from './model/page';
|
||||
import { getNoteSections } from './note-numbering';
|
||||
|
||||
/** Endnotes occupy document flow after a section or the final body section. */
|
||||
export class DocumentEndnoteLayout {
|
||||
constructor(private readonly _ctx: ILayoutContext) {}
|
||||
|
||||
finishSection(pages: IDocumentSkeletonPage[], sectionIndex: number, config: ISectionBreakConfig): void {
|
||||
const snapshot = this._ctx.dataModel.getSnapshot();
|
||||
const sections = getNoteSections(snapshot);
|
||||
const section = sections[sectionIndex];
|
||||
const isLast = sectionIndex >= sections.length - 1;
|
||||
const placed = new Set(pages.flatMap((page) => (page.notes ?? []).map((note) => note.noteId)));
|
||||
const references = [...this._ctx.noteReferences?.values() ?? []].filter((reference) => {
|
||||
if (reference.type !== 'endnote' || placed.has(reference.noteId)) {
|
||||
return false;
|
||||
}
|
||||
if (isLast) {
|
||||
return true;
|
||||
}
|
||||
return reference.properties.position === 'sectEnd' && !section?.suppressEndnotes &&
|
||||
reference.referenceIndex <= (section?.startIndex ?? -1);
|
||||
});
|
||||
if (references.length === 0 || pages.length === 0) {
|
||||
return;
|
||||
}
|
||||
this._ctx.footnoteLayout?.finish(pages, config);
|
||||
let page = pages[pages.length - 1];
|
||||
let top = getEndnoteFlowBottom(page);
|
||||
let decoration = this._layoutDecoration(page, config, 'separator');
|
||||
let needsSeparator = true;
|
||||
for (const reference of references) {
|
||||
const contentHeight = page.pageHeight - page.marginTop - page.marginBottom - (page.footnoteHeight ?? 0);
|
||||
const separatorHeight = needsSeparator ? decoration?.height ?? 0 : 0;
|
||||
const continuation = this._layoutDecoration(page, config, 'continuationSeparator');
|
||||
const notice = this._layoutDecoration(page, config, 'continuationNotice');
|
||||
const options = {
|
||||
width: page.pageWidth - page.marginLeft - page.marginRight,
|
||||
firstPageHeight: Math.max(1, contentHeight - top - separatorHeight),
|
||||
continuationPageHeight: Math.max(1, page.pageHeight - page.marginTop - page.marginBottom - (continuation?.height ?? 0) - (notice?.height ?? 0)),
|
||||
columnCount: 1,
|
||||
};
|
||||
let notePages = layoutNoteBody(this._ctx, reference, config, options);
|
||||
if (notice && notePages.length > 1) {
|
||||
notePages = layoutNoteBody(this._ctx, reference, config, { ...options, firstPageHeight: Math.max(1, options.firstPageHeight - notice.height) });
|
||||
}
|
||||
let continued = false;
|
||||
for (let index = 0; index < notePages.length; index++) {
|
||||
const fragment = notePages[index];
|
||||
if (index > 0) {
|
||||
page = createSkeletonPage(this._ctx, config, this._ctx.skeletonResourceReference, page.pageNumber + 1);
|
||||
pages.push(page);
|
||||
top = 0;
|
||||
needsSeparator = true;
|
||||
decoration = continued ? continuation : this._layoutDecoration(page, config, 'separator');
|
||||
}
|
||||
if (!hasNoteContent(fragment)) {
|
||||
continue;
|
||||
}
|
||||
if (needsSeparator && decoration) {
|
||||
page.noteDecorations ??= [];
|
||||
page.noteDecorations.push({
|
||||
noteType: 'endnote',
|
||||
kind: continued ? 'continuationSeparator' : 'separator',
|
||||
left: page.marginLeft,
|
||||
top: page.marginTop + top,
|
||||
page: decoration,
|
||||
});
|
||||
top += decoration.height;
|
||||
}
|
||||
needsSeparator = false;
|
||||
page.notes ??= [];
|
||||
page.notes.push({ noteType: 'endnote', noteId: reference.noteId, referenceIndex: reference.referenceIndex, continued, left: page.marginLeft, top: page.marginTop + top, page: fragment });
|
||||
top += fragment.height;
|
||||
continued = true;
|
||||
if (notice && index < notePages.length - 1) {
|
||||
page.noteDecorations ??= [];
|
||||
page.noteDecorations.push({ noteType: 'endnote', kind: 'continuationNotice', left: page.marginLeft, top: page.marginTop + top, page: notice });
|
||||
top += notice.height;
|
||||
}
|
||||
}
|
||||
placed.add(reference.noteId);
|
||||
}
|
||||
}
|
||||
|
||||
private _layoutDecoration(
|
||||
page: IDocumentSkeletonPage,
|
||||
config: ISectionBreakConfig,
|
||||
kind: 'separator' | 'continuationSeparator' | 'continuationNotice'
|
||||
): IDocumentSkeletonPage | undefined {
|
||||
const snapshot = this._ctx.dataModel.getSnapshot();
|
||||
const body = snapshot.noteSettings?.endnote?.[kind] ?? (kind === 'continuationNotice' ? undefined : defaultSeparatorBody(kind === 'continuationSeparator'));
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
const model = new DocumentDataModel({ id: `endnote-${kind}`, body, documentStyle: snapshot.documentStyle, styles: snapshot.styles });
|
||||
const viewModel = new DocumentViewModel(model);
|
||||
try {
|
||||
return layoutNoteSegment(this._ctx, viewModel, { noteId: model.getUnitId(), sectionId: config.sectionId }, config, {
|
||||
width: page.pageWidth - page.marginLeft - page.marginRight,
|
||||
firstPageHeight: Number.POSITIVE_INFINITY,
|
||||
continuationPageHeight: Number.POSITIVE_INFINITY,
|
||||
})[0];
|
||||
} finally {
|
||||
viewModel.dispose();
|
||||
model.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasNoteContent(page: IDocumentSkeletonPage): boolean {
|
||||
return page.sections.some((section) => section.columns.some((column) => column.lines.length > 0));
|
||||
}
|
||||
|
||||
export function getEndnoteFlowBottom(page: IDocumentSkeletonPage): number {
|
||||
const bodyBottom = page.sections.reduce((bottom, section) => Math.max(bottom, section.top, ...section.columns.map((column) => {
|
||||
const line = column.lines[column.lines.length - 1];
|
||||
return line ? section.top + line.top + line.lineHeight : section.top;
|
||||
})), 0);
|
||||
return Math.max(bodyBottom, ...page.notes?.filter((note) => note.noteType === 'endnote')
|
||||
.map((note) => note.top - page.marginTop + note.page.height) ?? []);
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
/**
|
||||
* 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 '@univerjs/core';
|
||||
import type {
|
||||
IDocumentSkeletonGlyph,
|
||||
IDocumentSkeletonNote,
|
||||
IDocumentSkeletonNoteDecoration,
|
||||
IDocumentSkeletonPage,
|
||||
IDocumentSkeletonTable,
|
||||
} from '../../../basics/i-document-skeleton-cached';
|
||||
import type { IParagraphConfig, ISectionBreakConfig } from '../../../basics/interfaces';
|
||||
import type { INoteReferenceLayout } from './note-numbering';
|
||||
import type { ILayoutContext } from './tools';
|
||||
import { ColumnSeparatorType, CustomRangeType, DocumentDataModel, GridType } from '@univerjs/core';
|
||||
import { DocumentSkeletonPageType } from '../../../basics/i-document-skeleton-cached';
|
||||
import { getDocumentCompatibilityPolicy } from '../document-compatibility';
|
||||
import { DocumentViewModel } from '../view-model/document-view-model';
|
||||
import { dealWithSection } from './block/section';
|
||||
import { createSkeletonLetterGlyph } from './model/glyph';
|
||||
import {
|
||||
createSkeletonPage,
|
||||
expandCellPageHeightForFlowTables,
|
||||
expandCellPageHeightForInlineDrawings,
|
||||
} from './model/page';
|
||||
import { formatNoteNumber } from './note-numbering';
|
||||
import {
|
||||
clearFontCreateConfigCache,
|
||||
getFontConfigFromLastGlyph,
|
||||
getNullSkeleton,
|
||||
updateBlockIndex,
|
||||
updateInlineDrawingCoordsAndBorder,
|
||||
} from './tools';
|
||||
|
||||
export interface INoteLayoutConstraints {
|
||||
width: number;
|
||||
firstPageHeight: number;
|
||||
continuationPageHeight: number;
|
||||
columnCount?: number;
|
||||
firstColumn?: { index: number; top: number };
|
||||
}
|
||||
|
||||
function createFootnoteColumns(config: ISectionBreakConfig, width: number, count: number) {
|
||||
if (count <= 1) {
|
||||
return [];
|
||||
}
|
||||
if (config.columnProperties?.length === count) {
|
||||
return config.columnProperties;
|
||||
}
|
||||
const gap = Math.min(48, width / (count * 2));
|
||||
const columnWidth = Math.max(1, (width - gap * (count - 1)) / count);
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
width: columnWidth,
|
||||
paddingEnd: index === count - 1 ? 0 : gap,
|
||||
}));
|
||||
}
|
||||
|
||||
function lastFootnoteColumn(fragments: IDocumentSkeletonNote[]) {
|
||||
const page = fragments[fragments.length - 1]?.page;
|
||||
const columns = page?.sections[page.sections.length - 1]?.columns ?? [];
|
||||
for (let index = columns.length - 1; index >= 0; index--) {
|
||||
const line = columns[index].lines[columns[index].lines.length - 1];
|
||||
if (line) {
|
||||
return { index, top: line.top + line.lineHeight };
|
||||
}
|
||||
}
|
||||
return { index: 0, top: 0 };
|
||||
}
|
||||
|
||||
/** Uses the body layout pipeline, including table row splitting and inline drawing metrics. */
|
||||
export function layoutNoteBody(
|
||||
parentContext: ILayoutContext,
|
||||
reference: INoteReferenceLayout,
|
||||
sectionConfig: ISectionBreakConfig,
|
||||
constraints: INoteLayoutConstraints
|
||||
): IDocumentSkeletonPage[] {
|
||||
const viewModel = parentContext.viewModel.getNoteTreeMap().get(reference.noteId);
|
||||
if (!viewModel) {
|
||||
return [];
|
||||
}
|
||||
return layoutNoteSegment(parentContext, viewModel, reference, sectionConfig, constraints);
|
||||
}
|
||||
|
||||
export function layoutNoteSegment(
|
||||
parentContext: ILayoutContext,
|
||||
viewModel: DocumentViewModel,
|
||||
reference: Pick<INoteReferenceLayout, 'noteId' | 'sectionId'> & { label?: string },
|
||||
sectionConfig: ISectionBreakConfig,
|
||||
constraints: INoteLayoutConstraints
|
||||
): IDocumentSkeletonPage[] {
|
||||
const skeleton = getNullSkeleton();
|
||||
const { skeHeaders, skeFooters, skeListLevel, drawingAnchor } = skeleton;
|
||||
const dataModel = viewModel.getDataModel();
|
||||
const config: ISectionBreakConfig = {
|
||||
...sectionConfig,
|
||||
documentCompatibilityPolicy: sectionConfig.documentCompatibilityPolicy ??
|
||||
getDocumentCompatibilityPolicy(parentContext.dataModel.documentStyle.documentFlavor),
|
||||
sectionId: reference.sectionId,
|
||||
pageSize: { width: constraints.width, height: constraints.continuationPageHeight },
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
marginHeader: 0,
|
||||
marginFooter: 0,
|
||||
headerIds: {},
|
||||
footerIds: {},
|
||||
headerTreeMap: new Map(),
|
||||
footerTreeMap: new Map(),
|
||||
columnProperties: createFootnoteColumns(sectionConfig, constraints.width, constraints.columnCount ?? 1),
|
||||
columnSeparatorType: ColumnSeparatorType.NONE,
|
||||
gridType: GridType.DEFAULT,
|
||||
drawings: dataModel.drawings,
|
||||
lists: { ...sectionConfig.lists, ...dataModel.lists },
|
||||
documentTextStyle: reference.label == null ? sectionConfig.documentTextStyle : { ...sectionConfig.documentTextStyle, fs: 10 },
|
||||
};
|
||||
const ctx: ILayoutContext = {
|
||||
...parentContext,
|
||||
viewModel,
|
||||
dataModel,
|
||||
skeleton,
|
||||
skeletonResourceReference: { skeHeaders, skeFooters, skeListLevel, drawingAnchor },
|
||||
noteReferences: undefined,
|
||||
footnoteLayout: undefined,
|
||||
endnoteLayout: undefined,
|
||||
noteLabel: reference.label,
|
||||
noteReferenceTextStyle: parentContext.dataModel.getSnapshot().notes?.[reference.noteId]?.referenceTextStyle,
|
||||
noteSegmentId: reference.noteId,
|
||||
footnoteFirstColumn: constraints.firstColumn,
|
||||
deferSlicedTableLayout: undefined,
|
||||
layoutStartPointer: { [reference.noteId]: null },
|
||||
isDirty: false,
|
||||
floatObjectsCache: new Map(),
|
||||
paragraphConfigCache: new Map(),
|
||||
sectionBreakConfigCache: new Map(),
|
||||
paragraphsOpenNewPage: new Set(),
|
||||
};
|
||||
const firstPage = createSkeletonPage(ctx, {
|
||||
...config,
|
||||
pageSize: { width: constraints.width, height: Math.max(1, constraints.firstPageHeight) },
|
||||
}, ctx.skeletonResourceReference);
|
||||
firstPage.type = DocumentSkeletonPageType.NOTE;
|
||||
firstPage.segmentId = reference.noteId;
|
||||
for (let index = 0; index < (constraints.firstColumn?.index ?? 0); index++) {
|
||||
firstPage.sections[0].columns[index].isFull = true;
|
||||
}
|
||||
try {
|
||||
const pages = dealWithSection(ctx, viewModel, viewModel.getChildren()[0], firstPage, config, null).pages;
|
||||
for (const page of pages) {
|
||||
page.type = DocumentSkeletonPageType.NOTE;
|
||||
page.segmentId = reference.noteId;
|
||||
}
|
||||
updateBlockIndex(pages, -1, config.documentCompatibilityPolicy);
|
||||
updateInlineDrawingCoordsAndBorder(ctx, pages);
|
||||
expandCellPageHeightForInlineDrawings(pages);
|
||||
expandCellPageHeightForFlowTables(pages);
|
||||
return pages;
|
||||
} finally {
|
||||
// Text run offsets are local to a segment. Never reuse a note's font cache for body text.
|
||||
clearFontCreateConfigCache();
|
||||
}
|
||||
}
|
||||
|
||||
interface IPageFootnoteLayout {
|
||||
fragments: IDocumentSkeletonNote[];
|
||||
following: IDocumentSkeletonNote[][];
|
||||
referenceIds: string[];
|
||||
height: number;
|
||||
separator?: IDocumentSkeletonPage;
|
||||
notice?: IDocumentSkeletonPage;
|
||||
}
|
||||
|
||||
type FootnoteDecorationKind = IDocumentSkeletonNoteDecoration['kind'];
|
||||
|
||||
export function defaultSeparatorBody(continued: boolean): IDocumentBody {
|
||||
return {
|
||||
dataStream: '\uFFFC\r\n',
|
||||
paragraphs: [{ paragraphId: 'separator', startIndex: 1, paragraphStyle: { spaceAbove: { v: 0 }, spaceBelow: { v: 0 } } }],
|
||||
sectionBreaks: [{ sectionId: 'separator', startIndex: 2 }],
|
||||
customRanges: [{
|
||||
rangeId: 'separator',
|
||||
rangeType: CustomRangeType.FIELD,
|
||||
startIndex: 0,
|
||||
endIndex: 0,
|
||||
wholeEntity: true,
|
||||
properties: { fieldType: continued ? 'FOOTNOTE_CONTINUATION_SEPARATOR' : 'FOOTNOTE_SEPARATOR' },
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function footnoteContentHeight(fragments: IDocumentSkeletonNote[]): number {
|
||||
return fragments.reduce((height, fragment) => Math.max(height, fragment.page.height), 0);
|
||||
}
|
||||
|
||||
function collectPageReferenceGlyphs(page: IDocumentSkeletonPage): IDocumentSkeletonGlyph[] {
|
||||
const result: IDocumentSkeletonGlyph[] = [];
|
||||
for (const section of page.sections) {
|
||||
for (const column of section.columns) {
|
||||
for (const line of column.lines) {
|
||||
for (const divide of line.divides) {
|
||||
result.push(...divide.glyphGroup.filter((glyph) => glyph.noteId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const table of page.skeTables.values()) {
|
||||
result.push(...table.rows.flatMap((row) => row.cells.flatMap(collectPageReferenceGlyphs)));
|
||||
}
|
||||
for (const group of page.skeColumnGroups.values()) {
|
||||
result.push(...group.columns.flatMap((column) => collectPageReferenceGlyphs(column.page)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectPageReferenceIds(page: IDocumentSkeletonPage): string[] {
|
||||
return [...new Set(collectPageReferenceGlyphs(page).map((glyph) => glyph.noteId!))];
|
||||
}
|
||||
|
||||
function collectCellReferenceLines(page: IDocumentSkeletonPage, offset = 0): { top: number; bottom: number; glyphs: IDocumentSkeletonGlyph[] }[] {
|
||||
const lines = page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines.flatMap((line) => {
|
||||
const glyphs = line.divides.flatMap((divide) => divide.glyphGroup.filter((glyph) => glyph.noteId));
|
||||
const top = offset + page.marginTop + section.top + line.top;
|
||||
return glyphs.length > 0 ? [{ top, bottom: top + line.lineHeight + page.marginBottom, glyphs }] : [];
|
||||
})));
|
||||
for (const table of page.skeTables.values()) {
|
||||
for (const row of table.rows) {
|
||||
lines.push(...row.cells.flatMap((cell) => collectCellReferenceLines(cell, offset + page.marginTop + table.top + row.top)));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Per-layout state, shared by synchronous and sliced body pagination. */
|
||||
export class DocumentFootnoteLayout {
|
||||
private readonly _pages = new WeakMap<IDocumentSkeletonPage, IPageFootnoteLayout>();
|
||||
private readonly _inherited = new WeakMap<IDocumentSkeletonPage, IPageFootnoteLayout>();
|
||||
private readonly _references = new Map<string, INoteReferenceLayout>();
|
||||
private readonly _referencePageBreaks = new Map<number, Map<number, number>>();
|
||||
private readonly _decorations = new Map<string, IDocumentSkeletonPage | undefined>();
|
||||
|
||||
constructor(private readonly _ctx: ILayoutContext) {
|
||||
for (const reference of _ctx.noteReferences?.values() ?? []) {
|
||||
if (reference.type === 'footnote') {
|
||||
this._references.set(reference.noteId, reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateReferenceGlyphs(
|
||||
page: IDocumentSkeletonPage,
|
||||
glyphs: IDocumentSkeletonGlyph[],
|
||||
config: ISectionBreakConfig,
|
||||
paragraph: IParagraphConfig
|
||||
): void {
|
||||
if (!glyphs.some((glyph) => glyph.noteId) || page.type !== DocumentSkeletonPageType.BODY || page.segmentId !== '') {
|
||||
return;
|
||||
}
|
||||
this._updateReferenceLabels(page, glyphs);
|
||||
for (const glyph of glyphs) {
|
||||
const reference = glyph.noteId ? this._references.get(glyph.noteId) : undefined;
|
||||
if (!reference || reference.properties.restart !== 'eachPage' || reference.number == null) {
|
||||
continue;
|
||||
}
|
||||
const label = reference.label;
|
||||
if (glyph.content !== label) {
|
||||
const fontConfig = getFontConfigFromLastGlyph(glyph, config, paragraph.paragraphStyle ?? {});
|
||||
Object.assign(glyph, createSkeletonLetterGlyph(label, fontConfig), {
|
||||
raw: '\uFFFC',
|
||||
count: 1,
|
||||
noteId: reference.noteId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _updateReferenceLabels(page: IDocumentSkeletonPage, incoming: IDocumentSkeletonGlyph[]): boolean {
|
||||
const ids = [...new Set([...collectPageReferenceIds(page), ...incoming.map((glyph) => glyph.noteId!)])]
|
||||
.filter((id) => this._references.has(id))
|
||||
.sort((left, right) => this._references.get(left)!.referenceIndex - this._references.get(right)!.referenceIndex);
|
||||
let precedingCount = 0;
|
||||
let changed = false;
|
||||
for (const id of ids) {
|
||||
const reference = this._references.get(id)!;
|
||||
if (reference.number == null) {
|
||||
continue;
|
||||
}
|
||||
if (reference.properties.restart === 'eachPage') {
|
||||
const number = (reference.properties.startNumber ?? 1) + precedingCount;
|
||||
const label = formatNoteNumber(number, reference.properties.numberFormat);
|
||||
changed ||= reference.label !== label;
|
||||
reference.label = label;
|
||||
}
|
||||
precedingCount++;
|
||||
}
|
||||
if (changed) {
|
||||
this._pages.delete(page);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
getTableBodyLimit(
|
||||
page: IDocumentSkeletonPage,
|
||||
currentPages: IDocumentSkeletonPage[],
|
||||
config: ISectionBreakConfig,
|
||||
table: IDocumentSkeletonTable
|
||||
): number {
|
||||
const references = table.rows.flatMap((row) => row.cells.flatMap(collectPageReferenceGlyphs));
|
||||
return this.getBodyLimit(page, currentPages, config, table.top + table.height, references);
|
||||
}
|
||||
|
||||
/** Measure table slices without publishing speculative notes on body pages. */
|
||||
createTablePagination(page: IDocumentSkeletonPage, config: ISectionBreakConfig, availableHeight: number) {
|
||||
if (page.type !== DocumentSkeletonPageType.BODY || page.segmentId !== '') {
|
||||
return undefined;
|
||||
}
|
||||
let current = { ...page, skeTables: new Map(page.skeTables) };
|
||||
const pages = [current];
|
||||
this._inherited.set(current, this._getInherited(page, this._ctx.skeleton.pages, config));
|
||||
let top = page.pageHeight - page.marginTop - page.marginBottom - availableHeight;
|
||||
return {
|
||||
renumberWholeRow: (table: IDocumentSkeletonTable, rows: IDocumentSkeletonTable['rows'], remainingHeight: number): boolean => {
|
||||
if (!rows.some((row) => row.cells.some((cell) => collectPageReferenceGlyphs(cell)
|
||||
.some((glyph) => this._references.get(glyph.noteId!)?.properties.restart === 'eachPage')))) {
|
||||
return false;
|
||||
}
|
||||
let target = { ...current, skeTables: new Map(current.skeTables) };
|
||||
const targetPages = [target];
|
||||
this._inherited.set(target, this._getInherited(current, pages, config));
|
||||
const accepted = this._pages.get(current);
|
||||
if (accepted) {
|
||||
this._pages.set(target, accepted);
|
||||
}
|
||||
let offset = top + table.height;
|
||||
let preceding = table.rows.flatMap((row) => row.cells.flatMap(collectPageReferenceGlyphs));
|
||||
let changed = false;
|
||||
for (const row of rows) {
|
||||
const glyphs = row.cells.flatMap(collectPageReferenceGlyphs);
|
||||
let previousPlan = this._pages.get(target) ?? this._getInherited(target, targetPages, config);
|
||||
this._updateReferenceLabels(target, [...preceding, ...glyphs]);
|
||||
let limit = this.getBodyLimit(target, targetPages, config, offset + row.height, [...preceding, ...glyphs]);
|
||||
const tolerance = (config.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy()).table.rowOverflowTolerance;
|
||||
const requiresNewPage = row !== rows[0] || remainingHeight <= 0 || row.height > remainingHeight + tolerance;
|
||||
if (requiresNewPage || offset + row.height > limit + 0.01) {
|
||||
do {
|
||||
this._pages.set(target, previousPlan);
|
||||
target = createSkeletonPage(this._ctx, config, this._ctx.skeletonResourceReference, target.pageNumber + 1);
|
||||
targetPages.push(target);
|
||||
offset = 0;
|
||||
preceding = [];
|
||||
previousPlan = this._getInherited(target, targetPages, config);
|
||||
this._updateReferenceLabels(target, glyphs);
|
||||
limit = this.getBodyLimit(target, targetPages, config, row.height, glyphs);
|
||||
} while (row.height > limit + 0.01 && this._getInherited(target, targetPages, config).fragments.length > 0);
|
||||
}
|
||||
changed ||= glyphs.some((glyph) => glyph.content !== this._references.get(glyph.noteId!)?.label);
|
||||
offset += row.height;
|
||||
preceding.push(...glyphs);
|
||||
}
|
||||
return changed;
|
||||
},
|
||||
measureRow: (table: IDocumentSkeletonTable, rows: IDocumentSkeletonTable['rows'], previousHeights?: readonly number[]): number[] | undefined => {
|
||||
let measurePage = { ...current, skeTables: new Map(current.skeTables) };
|
||||
const measurePages = [measurePage];
|
||||
this._inherited.set(measurePage, this._getInherited(current, pages, config));
|
||||
const accepted = this._pages.get(current);
|
||||
if (accepted) {
|
||||
this._pages.set(measurePage, accepted);
|
||||
}
|
||||
const heights = [...(previousHeights ?? [])];
|
||||
let changed = false;
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = rows[index];
|
||||
const offset = index === 0 ? top + table.height : 0;
|
||||
const contentHeight = measurePage.pageHeight - measurePage.marginTop - measurePage.marginBottom;
|
||||
const available = heights[index] ?? contentHeight - offset;
|
||||
const lines = row.cells
|
||||
.flatMap((cell) => collectCellReferenceLines(cell))
|
||||
.filter((line) => line.bottom <= available)
|
||||
.sort((left, right) => left.bottom - right.bottom);
|
||||
const incoming = lines.flatMap((line) => line.glyphs);
|
||||
if (index === 0) {
|
||||
incoming.push(...table.rows.flatMap((item) => item.cells.flatMap(collectPageReferenceGlyphs)));
|
||||
}
|
||||
// A cell is shaped before its physical page is known. Rebuild the
|
||||
// row when its page-local marker changes, including marker width.
|
||||
changed = this._updateReferenceLabels(measurePage, incoming) || changed;
|
||||
const lastReference = lines[lines.length - 1];
|
||||
const minimumBottom = lastReference?.bottom ?? Math.min(row.height, available);
|
||||
const limit = this.getBodyLimit(measurePage, measurePages, config, offset + minimumBottom, incoming) - offset;
|
||||
const deferredIds = new Set(this._pages.get(measurePage)?.following.flatMap((fragments) => fragments.filter((fragment) => !fragment.continued).map((fragment) => fragment.noteId)));
|
||||
const deferredLine = lines.find((line) => line.top > 0 && line.glyphs.some((glyph) => deferredIds.has(glyph.noteId!)));
|
||||
// A continuing note can postpone later notes. Split before a later
|
||||
// reference line so its first note fragment follows it to the next page.
|
||||
const capacity = Math.max(1, Math.min(
|
||||
limit < minimumBottom && lastReference ? lastReference.top : limit,
|
||||
deferredLine?.top ?? Number.POSITIVE_INFINITY
|
||||
));
|
||||
if (row.height > capacity + 0.01 && capacity + 0.01 < available) {
|
||||
heights[index] = capacity;
|
||||
changed = true;
|
||||
} else {
|
||||
heights[index] = available;
|
||||
}
|
||||
measurePage = createSkeletonPage(this._ctx, config, this._ctx.skeletonResourceReference, measurePage.pageNumber + 1);
|
||||
measurePages.push(measurePage);
|
||||
}
|
||||
return changed ? heights : undefined;
|
||||
},
|
||||
append: (table: IDocumentSkeletonTable, row: IDocumentSkeletonTable['rows'][number]): number | undefined => {
|
||||
const previous = this._pages.get(current);
|
||||
const incoming = [...table.rows, row].flatMap((item) => item.cells.flatMap(collectPageReferenceGlyphs));
|
||||
const bottom = top + table.height + row.height;
|
||||
const limit = this.getBodyLimit(current, pages, config, bottom, incoming);
|
||||
if (bottom <= limit + 0.01) {
|
||||
return Math.max(0, limit - bottom);
|
||||
}
|
||||
if (previous) {
|
||||
this._pages.set(current, previous);
|
||||
} else {
|
||||
this._pages.set(current, this._getInherited(current, pages, config));
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
hasContinuation: () => this._getInherited(current, pages, config).fragments.length > 0,
|
||||
nextPage: () => {
|
||||
current = createSkeletonPage(this._ctx, config, this._ctx.skeletonResourceReference, current.pageNumber + 1);
|
||||
pages.push(current);
|
||||
top = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getBodyLimit(
|
||||
page: IDocumentSkeletonPage,
|
||||
currentPages: IDocumentSkeletonPage[],
|
||||
config: ISectionBreakConfig,
|
||||
bodyBottom: number,
|
||||
incoming: IDocumentSkeletonGlyph[] = []
|
||||
): number {
|
||||
const contentHeight = page.pageHeight - page.marginTop - page.marginBottom;
|
||||
if (page.type !== DocumentSkeletonPageType.BODY || page.segmentId !== '' || !Number.isFinite(contentHeight)) {
|
||||
return contentHeight;
|
||||
}
|
||||
const inherited = this._getInherited(page, currentPages, config);
|
||||
const ids = new Set(collectPageReferenceIds(page));
|
||||
for (const glyph of incoming) {
|
||||
if (glyph.noteId) {
|
||||
ids.add(glyph.noteId);
|
||||
}
|
||||
}
|
||||
const referenceIds = [...ids].filter((id) => this._references.has(id)).sort((left, right) =>
|
||||
(this._references.get(left)?.referenceIndex ?? 0) - (this._references.get(right)?.referenceIndex ?? 0));
|
||||
const previous = this._pages.get(page);
|
||||
if (previous && previous.referenceIds.length === referenceIds.length &&
|
||||
previous.referenceIds.every((id, index) => id === referenceIds[index])) {
|
||||
this._position(page, previous, bodyBottom);
|
||||
return contentHeight - previous.height;
|
||||
}
|
||||
|
||||
const firstReference = this._references.get(inherited.fragments[0]?.noteId ?? referenceIds[0]);
|
||||
const requestedCount = firstReference?.properties.columnCount ?? 0;
|
||||
const columnCount = requestedCount === 0 ? Math.max(1, config.columnProperties?.length ?? 1) : requestedCount;
|
||||
const separator = this._layoutDecoration(inherited.fragments.some((fragment) => fragment.continued) ? 'continuationSeparator' : 'separator', page, config);
|
||||
const available = Math.max(1, contentHeight - bodyBottom - (separator?.height ?? 0));
|
||||
let plan = this._layoutPageNotes(page, config, referenceIds, inherited, available, columnCount, separator);
|
||||
if (!plan) {
|
||||
return -1;
|
||||
}
|
||||
// Balance all notes on this page together; a continuing note already uses
|
||||
// the full available height and must retain its continuation boundary.
|
||||
if (columnCount > 1 && plan.following.length === 0 && inherited.fragments.length === 0 && plan.fragments.length > 0) {
|
||||
let low = 1;
|
||||
let high = Math.max(1, footnoteContentHeight(plan.fragments));
|
||||
for (let attempt = 0; attempt < 12 && high - low > 0.25; attempt++) {
|
||||
const middle = (low + high) / 2;
|
||||
const candidate = this._layoutPageNotes(page, config, referenceIds, inherited, middle, columnCount, separator);
|
||||
if (candidate && candidate.following.length === 0) {
|
||||
plan = candidate;
|
||||
high = middle;
|
||||
} else {
|
||||
low = middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._pages.set(page, plan);
|
||||
this._position(page, plan, bodyBottom);
|
||||
return contentHeight - plan.height;
|
||||
}
|
||||
|
||||
finish(pages: IDocumentSkeletonPage[], config: ISectionBreakConfig): void {
|
||||
let previous = pages[pages.length - 1];
|
||||
if (!previous) {
|
||||
return;
|
||||
}
|
||||
while (this._pages.get(previous)?.following.length) {
|
||||
const page = createSkeletonPage(this._ctx, config, this._ctx.skeletonResourceReference, previous.pageNumber + 1);
|
||||
pages.push(page);
|
||||
const inherited = this._getInherited(page, pages, config);
|
||||
this._pages.set(page, inherited);
|
||||
this._position(page, inherited, 0);
|
||||
previous = page;
|
||||
}
|
||||
}
|
||||
|
||||
getReferencePageBreaks(paragraphIndex: number): readonly number[] | undefined {
|
||||
const boundaries = this._referencePageBreaks.get(paragraphIndex);
|
||||
return boundaries ? [...boundaries.values()] : undefined;
|
||||
}
|
||||
|
||||
/** Re-shape moved text while retaining the page boundary chosen by keep/widow control. */
|
||||
queueReferenceRelayout(pages: IDocumentSkeletonPage[]): void {
|
||||
let anchor = Number.POSITIVE_INFINITY;
|
||||
for (const page of pages) {
|
||||
if (page.type !== DocumentSkeletonPageType.BODY || page.segmentId !== '') {
|
||||
continue;
|
||||
}
|
||||
this._updateReferenceLabels(page, []);
|
||||
for (const section of page.sections) {
|
||||
for (const column of section.columns) {
|
||||
const offsets = new Map<number, number>();
|
||||
for (const line of column.lines) {
|
||||
let offset = offsets.get(line.paragraphIndex) ?? 0;
|
||||
for (const glyph of line.divides.flatMap((divide) => divide.glyphGroup)) {
|
||||
const reference = this._references.get(glyph.noteId ?? '');
|
||||
if (reference?.properties.restart === 'eachPage' && glyph.content !== reference.label) {
|
||||
const boundaries = this._referencePageBreaks.get(line.paragraphIndex) ?? new Map<number, number>();
|
||||
boundaries.set(page.pageNumber, reference.referenceIndex - offset);
|
||||
this._referencePageBreaks.set(line.paragraphIndex, boundaries);
|
||||
anchor = Math.min(anchor, line.paragraphIndex);
|
||||
}
|
||||
offset += glyph.count;
|
||||
}
|
||||
offsets.set(line.paragraphIndex, offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(anchor)) {
|
||||
return;
|
||||
}
|
||||
// The retained physical-page boundary also covers keep-next predecessors.
|
||||
// A second whole-paragraph break would separate them again during the retry.
|
||||
for (const paragraph of this._ctx.paragraphsOpenNewPage) {
|
||||
if (paragraph >= anchor) {
|
||||
this._ctx.paragraphsOpenNewPage.delete(paragraph);
|
||||
}
|
||||
}
|
||||
this._ctx.isDirty = true;
|
||||
this._ctx.layoutStartPointer[''] = Math.min(anchor, this._ctx.layoutStartPointer[''] ?? Number.POSITIVE_INFINITY);
|
||||
}
|
||||
|
||||
/** Rebuild reservations after keep-lines or widow control relocates shaped lines. */
|
||||
reconcilePages(pages: IDocumentSkeletonPage[], config: ISectionBreakConfig): boolean {
|
||||
let fits = true;
|
||||
for (const page of pages) {
|
||||
this._inherited.delete(page);
|
||||
this._pages.delete(page);
|
||||
this._updateReferenceLabels(page, []);
|
||||
const bodyBottom = page.sections.reduce((bottom, section) => Math.max(
|
||||
bottom,
|
||||
...section.columns.map((column) => {
|
||||
const line = column.lines[column.lines.length - 1];
|
||||
return line ? section.top + line.top + line.lineHeight : 0;
|
||||
})
|
||||
), 0);
|
||||
if (bodyBottom > this.getBodyLimit(page, pages, config, bodyBottom) + 2) {
|
||||
fits = false;
|
||||
}
|
||||
}
|
||||
return fits;
|
||||
}
|
||||
|
||||
private _layoutPageNotes(
|
||||
page: IDocumentSkeletonPage,
|
||||
config: ISectionBreakConfig,
|
||||
referenceIds: string[],
|
||||
inherited: IPageFootnoteLayout,
|
||||
available: number,
|
||||
columnCount: number,
|
||||
separator: IDocumentSkeletonPage | undefined,
|
||||
reserveNotice = false
|
||||
): IPageFootnoteLayout | undefined {
|
||||
const plan: IPageFootnoteLayout = {
|
||||
fragments: [...inherited.fragments],
|
||||
following: inherited.following.map((fragments) => [...fragments]),
|
||||
referenceIds,
|
||||
height: inherited.height,
|
||||
separator,
|
||||
};
|
||||
const width = page.pageWidth - page.marginLeft - page.marginRight;
|
||||
const continuationSeparator = this._layoutDecoration('continuationSeparator', page, config);
|
||||
const notice = this._layoutDecoration('continuationNotice', page, config);
|
||||
const continuationHeight = page.pageHeight - page.marginTop - page.marginBottom -
|
||||
(continuationSeparator?.height ?? 0) - (notice?.height ?? 0);
|
||||
for (const id of referenceIds) {
|
||||
const reference = this._references.get(id);
|
||||
if (!reference) {
|
||||
continue;
|
||||
}
|
||||
const delayed = plan.following.length > 0;
|
||||
const target = delayed ? plan.following[plan.following.length - 1] : plan.fragments;
|
||||
const notePages = layoutNoteBody(this._ctx, reference, config, {
|
||||
width,
|
||||
firstPageHeight: delayed ? continuationHeight : Math.max(1, available - (reserveNotice ? notice?.height ?? 0 : 0)),
|
||||
continuationPageHeight: Math.max(1, continuationHeight),
|
||||
columnCount,
|
||||
firstColumn: lastFootnoteColumn(target),
|
||||
});
|
||||
const first = notePages[0];
|
||||
if (!first) {
|
||||
continue;
|
||||
}
|
||||
if (first.sections.every((section) => section.columns.every((column) => column.lines.length === 0))) {
|
||||
// Keep a new reference with the first line of its note. Otherwise
|
||||
// a later paragraph retry can inherit a note whose reference moved.
|
||||
if (!delayed && (plan.fragments.length > 0 ||
|
||||
page.sections.some((section) => section.columns.some((column) => column.lines.length > 0)))) {
|
||||
return undefined;
|
||||
}
|
||||
this._appendFollowing(plan.following, notePages.slice(1), reference, false);
|
||||
continue;
|
||||
}
|
||||
if (delayed) {
|
||||
target.push({ noteId: id, referenceIndex: reference.referenceIndex, continued: false, left: page.marginLeft, top: 0, page: first });
|
||||
this._appendFollowing(plan.following, notePages.slice(1), reference, true);
|
||||
continue;
|
||||
}
|
||||
if (first.height > available + 2) {
|
||||
if (page.sections.some((section) => section.columns.some((column) => column.lines.length > 0)) ||
|
||||
plan.fragments.length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
this._appendFollowing(plan.following, notePages, reference, false);
|
||||
continue;
|
||||
}
|
||||
plan.fragments.push({
|
||||
noteId: id,
|
||||
referenceIndex: reference.referenceIndex,
|
||||
continued: false,
|
||||
left: page.marginLeft,
|
||||
top: 0,
|
||||
page: first,
|
||||
});
|
||||
this._appendFollowing(plan.following, notePages.slice(1), reference, true);
|
||||
}
|
||||
if (!reserveNotice && notice && plan.following.length > 0) {
|
||||
return this._layoutPageNotes(page, config, referenceIds, inherited, available, columnCount, separator, true);
|
||||
}
|
||||
plan.notice = plan.following.length > 0 ? notice : undefined;
|
||||
plan.height = plan.fragments.length === 0
|
||||
? 0
|
||||
: (separator?.height ?? 0) +
|
||||
footnoteContentHeight(plan.fragments) + (plan.notice?.height ?? 0);
|
||||
return plan;
|
||||
}
|
||||
|
||||
private _layoutDecoration(kind: FootnoteDecorationKind, page: IDocumentSkeletonPage, config: ISectionBreakConfig): IDocumentSkeletonPage | undefined {
|
||||
const width = page.pageWidth - page.marginLeft - page.marginRight;
|
||||
const key = `${kind}:${config.sectionId}:${width}`;
|
||||
if (this._decorations.has(key)) {
|
||||
return this._decorations.get(key);
|
||||
}
|
||||
const snapshot = this._ctx.dataModel.getSnapshot();
|
||||
const body = snapshot.noteSettings?.footnote?.[kind] ?? (kind === 'continuationNotice' ? undefined : defaultSeparatorBody(kind === 'continuationSeparator'));
|
||||
if (!body) {
|
||||
this._decorations.set(key, undefined);
|
||||
return undefined;
|
||||
}
|
||||
const model = new DocumentDataModel({ id: `footnote-${kind}`, body, documentStyle: snapshot.documentStyle, styles: snapshot.styles });
|
||||
const viewModel = new DocumentViewModel(model);
|
||||
try {
|
||||
const pages = layoutNoteSegment(this._ctx, viewModel, { noteId: model.getUnitId(), sectionId: config.sectionId }, config, {
|
||||
width,
|
||||
firstPageHeight: Number.POSITIVE_INFINITY,
|
||||
continuationPageHeight: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
const decoration = pages[0];
|
||||
if (decoration) {
|
||||
for (const section of decoration.sections) {
|
||||
for (const column of section.columns) {
|
||||
const lastLine = column.lines[column.lines.length - 1];
|
||||
if (lastLine) {
|
||||
decoration.height = Math.max(decoration.height, section.top + lastLine.top + lastLine.lineHeight + Math.max(0, lastLine.spaceBelowApply ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this._decorations.set(key, decoration);
|
||||
return decoration;
|
||||
} finally {
|
||||
viewModel.dispose();
|
||||
model.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private _appendFollowing(
|
||||
following: IDocumentSkeletonNote[][],
|
||||
pages: IDocumentSkeletonPage[],
|
||||
reference: INoteReferenceLayout,
|
||||
continued: boolean
|
||||
): void {
|
||||
for (let index = 0; index < pages.length; index++) {
|
||||
following.push([{
|
||||
noteId: reference.noteId,
|
||||
referenceIndex: reference.referenceIndex,
|
||||
continued: continued || index > 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
page: pages[index],
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
private _getInherited(page: IDocumentSkeletonPage, currentPages: IDocumentSkeletonPage[], config: ISectionBreakConfig): IPageFootnoteLayout {
|
||||
const existing = this._inherited.get(page);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
let pageIndex = currentPages.indexOf(page);
|
||||
let previous = pageIndex > 0 ? currentPages[pageIndex - 1] : undefined;
|
||||
if (!previous) {
|
||||
const pages = this._ctx.skeleton.pages;
|
||||
pageIndex = pages.indexOf(page);
|
||||
previous = pageIndex > 0 ? pages[pageIndex - 1] : pages[pages.length - 1];
|
||||
if (previous === page) {
|
||||
previous = undefined;
|
||||
}
|
||||
}
|
||||
const previousPlan = previous == null ? undefined : this._pages.get(previous);
|
||||
const fragments = previousPlan?.following[0] ?? [];
|
||||
const separator = fragments.length > 0
|
||||
? this._layoutDecoration(fragments.some((fragment) => fragment.continued) ? 'continuationSeparator' : 'separator', page, config)
|
||||
: undefined;
|
||||
const notice = (previousPlan?.following.length ?? 0) > 1 ? this._layoutDecoration('continuationNotice', page, config) : undefined;
|
||||
const plan: IPageFootnoteLayout = {
|
||||
fragments: fragments.map((fragment) => ({ ...fragment, left: page.marginLeft })),
|
||||
following: previousPlan?.following.slice(1) ?? [],
|
||||
referenceIds: [],
|
||||
height: fragments.length === 0
|
||||
? 0
|
||||
: (separator?.height ?? 0) + footnoteContentHeight(fragments) + (notice?.height ?? 0),
|
||||
separator,
|
||||
notice,
|
||||
};
|
||||
this._inherited.set(page, plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
private _position(page: IDocumentSkeletonPage, plan: IPageFootnoteLayout, bodyBottom: number): void {
|
||||
if (plan.fragments.length === 0) {
|
||||
page.notes = page.notes?.filter((note) => this._ctx.dataModel.getSnapshot().notes?.[note.noteId]?.type === 'endnote');
|
||||
delete page.footnoteHeight;
|
||||
page.noteDecorations = page.noteDecorations?.filter((decoration) => decoration.noteType === 'endnote');
|
||||
return;
|
||||
}
|
||||
const firstReference = this._references.get(plan.fragments[0].noteId);
|
||||
const separatorHeight = plan.separator?.height ?? 0;
|
||||
const top = firstReference?.properties.position === 'beneathText'
|
||||
? page.marginTop + bodyBottom + separatorHeight
|
||||
: page.pageHeight - page.marginBottom - plan.height + separatorHeight;
|
||||
const endnotes = page.notes?.filter((note) => this._ctx.dataModel.getSnapshot().notes?.[note.noteId]?.type === 'endnote') ?? [];
|
||||
page.notes = [...endnotes, ...plan.fragments.map((fragment) => {
|
||||
const positioned = { ...fragment, top, left: page.marginLeft };
|
||||
return positioned;
|
||||
})];
|
||||
page.footnoteHeight = plan.height;
|
||||
const decorations: IDocumentSkeletonNoteDecoration[] = [];
|
||||
if (plan.separator) {
|
||||
decorations.push({ kind: plan.fragments.some((fragment) => fragment.continued) ? 'continuationSeparator' : 'separator', left: page.marginLeft, top: top - separatorHeight, page: plan.separator });
|
||||
}
|
||||
if (plan.notice) {
|
||||
decorations.push({ kind: 'continuationNotice', left: page.marginLeft, top: top + footnoteContentHeight(plan.fragments), page: plan.notice });
|
||||
}
|
||||
page.noteDecorations = [...page.noteDecorations?.filter((decoration) => decoration.noteType === 'endnote') ?? [], ...decorations];
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentSkeletonPage } from '../../../../../basics/i-document-skeleton-cached';
|
||||
import { BooleanNumber, PositionedObjectLayoutType, TableTextWrapType, WrapTextType } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LineType } from '../../../../../basics/i-document-skeleton-cached';
|
||||
@@ -43,6 +44,27 @@ function createTopBottomDrawing(top: number, height: number, angle = 0) {
|
||||
}
|
||||
|
||||
describe('line model', () => {
|
||||
it.each([
|
||||
{ gap: 24, minimumWidth: 24, expectedTop: 130 },
|
||||
{ gap: 25, minimumWidth: 24, expectedTop: 20 },
|
||||
{ gap: 30, minimumWidth: 56, expectedTop: 130 },
|
||||
{ gap: 80, minimumWidth: 56, expectedTop: 20 },
|
||||
])('clears floating tables when side gaps cannot accommodate text ($gap, $minimumWidth)', ({ gap, minimumWidth, expectedTop }) => {
|
||||
const page = {
|
||||
skeDrawings: new Map(),
|
||||
skeTables: new Map([['table', {
|
||||
left: gap,
|
||||
top: 10,
|
||||
width: 300 - gap * 2,
|
||||
height: 120,
|
||||
tableSource: { textWrap: TableTextWrapType.WRAP, dist: {} },
|
||||
}]]),
|
||||
} as unknown as IDocumentSkeletonPage;
|
||||
expect(calculateLineTopByDrawings(16, 20, page, null, null, 0, 300, 0, minimumWidth)).toBe(expectedTop);
|
||||
expect(calculateLineTopByDrawings(16, 140, page, null, null, 0, 300, 0, minimumWidth)).toBe(140);
|
||||
expect(calculateLineTopByDrawings(16, 20, page, null, null, 400, 300, 0, minimumWidth)).toBe(20);
|
||||
});
|
||||
|
||||
it('creates line skeleton and divides with drawing/table layout data', () => {
|
||||
const page = {
|
||||
marginTop: 8,
|
||||
|
||||
@@ -256,7 +256,7 @@ export function _createSkeletonWordOrLetter(
|
||||
let xOffset = 0;
|
||||
|
||||
const documentCompatibilityPolicy = config.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy();
|
||||
bBox = FontCache.getTextSize(content, fontStyle);
|
||||
bBox = FontCache.getTextSize(content, fontStyle, isTraditionalDocumentCompatibility(documentCompatibilityPolicy));
|
||||
bBox = applyFontMetricCompatibility(
|
||||
content,
|
||||
fontStyle,
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import type { IDistFromText, Nullable } from '@univerjs/core';
|
||||
import type { IParagraphConfig } from '../../../../basics';
|
||||
import type {
|
||||
IDocumentSkeletonDivide,
|
||||
IDocumentSkeletonDrawing,
|
||||
@@ -25,11 +24,17 @@ import type {
|
||||
IDocumentSkeletonTable,
|
||||
LineType,
|
||||
} from '../../../../basics/i-document-skeleton-cached';
|
||||
import type { IParagraphConfig } from '../../../../basics/interfaces';
|
||||
import type { IFloatObject } from '../tools';
|
||||
import { PositionedObjectLayoutType, TableTextWrapType, WrapTextType } from '@univerjs/core';
|
||||
import { Path2 } from '../../../../basics/path2';
|
||||
import { Transform } from '../../../../basics/transform';
|
||||
import { Vector2 } from '../../../../basics/vector2';
|
||||
import { isTraditionalDocumentCompatibility } from '../../document-compatibility';
|
||||
|
||||
// Word reserves at least 18pt for text beside a floating table. Narrower gaps
|
||||
// move the line below the table, including empty paragraphs.
|
||||
export const TRADITIONAL_TABLE_WRAP_MIN_WIDTH = 24;
|
||||
|
||||
interface IDrawingsSplit {
|
||||
left: number;
|
||||
@@ -131,7 +136,10 @@ export function createSkeletonLine(
|
||||
affectSkeDrawings,
|
||||
headersDrawings,
|
||||
footersDrawings,
|
||||
wrapTypeTables
|
||||
wrapTypeTables,
|
||||
paragraphConfig.documentCompatibilityPolicy && isTraditionalDocumentCompatibility(paragraphConfig.documentCompatibilityPolicy)
|
||||
? TRADITIONAL_TABLE_WRAP_MIN_WIDTH
|
||||
: 0
|
||||
);
|
||||
|
||||
for (const divide of lineSke.divides) {
|
||||
@@ -149,7 +157,8 @@ export function calculateLineTopByDrawings(
|
||||
footerPage: Nullable<IDocumentSkeletonPage>,
|
||||
columnLeft: number = 0,
|
||||
columnWidth: number = 0,
|
||||
sectionTop: number = 0
|
||||
sectionTop: number = 0,
|
||||
minimumTableWrapWidth: number = 0
|
||||
) {
|
||||
const absoluteLineTop = sectionTop + lineTop;
|
||||
let maxTop = absoluteLineTop;
|
||||
@@ -208,9 +217,50 @@ export function calculateLineTopByDrawings(
|
||||
}
|
||||
});
|
||||
|
||||
// A later table can occupy the line reached after clearing an earlier one.
|
||||
for (let pass = 0; pass < page.skeTables.size; pass++) {
|
||||
const previousTop = maxTop;
|
||||
for (const table of page.skeTables.values()) {
|
||||
if (table.tableSource.textWrap !== TableTextWrapType.WRAP || columnWidth <= 0) {
|
||||
continue;
|
||||
}
|
||||
const split = _getTableWrapSplit(table, maxTop, lineHeight, columnLeft, columnWidth, minimumTableWrapWidth);
|
||||
if (split && split.left <= 0 && split.left + split.width >= columnWidth) {
|
||||
maxTop = Math.max(maxTop, table.top + table.height + (table.tableSource.dist?.distB ?? 0));
|
||||
}
|
||||
}
|
||||
if (maxTop === previousTop) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return maxTop - sectionTop;
|
||||
}
|
||||
|
||||
function _getTableWrapSplit(
|
||||
table: IDocumentSkeletonTable,
|
||||
lineTop: number,
|
||||
lineHeight: number,
|
||||
columnLeft: number,
|
||||
columnWidth: number,
|
||||
minimumWidth: number
|
||||
) {
|
||||
const { left, top, width, height, tableSource } = table;
|
||||
const split = __getSplitWidthNoAngle(top, height, left - columnLeft, width, lineTop, lineHeight, columnWidth, tableSource.dist ?? {});
|
||||
if (!split || split.left >= columnWidth || split.left + split.width <= 0) {
|
||||
return;
|
||||
}
|
||||
let right = split.left + split.width;
|
||||
if (split.left <= minimumWidth) {
|
||||
split.left = 0;
|
||||
}
|
||||
if (columnWidth - right <= minimumWidth) {
|
||||
right = columnWidth;
|
||||
}
|
||||
split.width = right - split.left;
|
||||
return split;
|
||||
}
|
||||
|
||||
function _getLineTopWithFullColumnWrap(
|
||||
drawing: IDocumentSkeletonDrawing,
|
||||
lineHeight: number,
|
||||
@@ -363,7 +413,8 @@ function _calculateDividesByDrawings(
|
||||
paragraphNonInlineSkeDrawings?: Map<string, IDocumentSkeletonDrawing>,
|
||||
headersDrawings?: Map<string, IDocumentSkeletonDrawing>,
|
||||
footersDrawings?: Map<string, IDocumentSkeletonDrawing>,
|
||||
wrapTypeTables?: Map<string, IDocumentSkeletonTable>
|
||||
wrapTypeTables?: Map<string, IDocumentSkeletonTable>,
|
||||
minimumTableWrapWidth: number = 0
|
||||
): IDocumentSkeletonDivide[] {
|
||||
const drawingsMix: IDrawingsSplit[] = []; // Mixed text and graphics case
|
||||
// Insert indent placeholder
|
||||
@@ -410,9 +461,7 @@ function _calculateDividesByDrawings(
|
||||
|
||||
if (wrapTypeTables && wrapTypeTables.size > 0) {
|
||||
wrapTypeTables.forEach((table) => {
|
||||
const { left, top, width, height, tableSource } = table;
|
||||
const { dist } = tableSource;
|
||||
const split = __getSplitWidthNoAngle(top, height, left - columnLeft, width, lineTop, lineHeight, columnWidth, dist);
|
||||
const split = _getTableWrapSplit(table, lineTop, lineHeight, columnLeft, columnWidth, minimumTableWrapWidth);
|
||||
|
||||
if (split) {
|
||||
drawingsMix.push(split);
|
||||
|
||||
@@ -24,11 +24,23 @@ import type { ISectionBreakConfig } from '../../../../basics/interfaces';
|
||||
import type { DataStreamTreeNode } from '../../view-model/data-stream-tree-node';
|
||||
import type { DocumentViewModel } from '../../view-model/document-view-model';
|
||||
import type { ILayoutContext } from '../tools';
|
||||
import { BooleanNumber, GridType, PageOrientType, PositionedObjectLayoutType, SpacingRule, TableTextWrapType } from '@univerjs/core';
|
||||
import {
|
||||
BooleanNumber,
|
||||
GridType,
|
||||
PageOrientType,
|
||||
PositionedObjectLayoutType,
|
||||
SpacingRule,
|
||||
TableTextWrapType,
|
||||
} from '@univerjs/core';
|
||||
import { BreakType, DocumentSkeletonPageType } from '../../../../basics/i-document-skeleton-cached';
|
||||
import { getDocumentCompatibilityPolicy, isTraditionalDocumentCompatibility } from '../../document-compatibility';
|
||||
import { dealWithSection } from '../block/section';
|
||||
import { reachesNextDocumentGridLine, resetContext, updateBlockIndex, updateInlineDrawingCoordsAndBorder } from '../tools';
|
||||
import {
|
||||
reachesNextDocumentGridLine,
|
||||
resetContext,
|
||||
updateBlockIndex,
|
||||
updateInlineDrawingCoordsAndBorder,
|
||||
} from '../tools';
|
||||
import { createSkeletonSection } from './section';
|
||||
|
||||
function getHeaderFooterMaxHeight(pageHeight: number) {
|
||||
@@ -44,7 +56,14 @@ export function createSkeletonPage(
|
||||
pageNumber = 1,
|
||||
breakType = BreakType.SECTION
|
||||
): IDocumentSkeletonPage {
|
||||
const page: IDocumentSkeletonPage = _getNullPage();
|
||||
let page: IDocumentSkeletonPage;
|
||||
if (sectionBreakConfig.cellTableId) {
|
||||
page = _getNullPage(DocumentSkeletonPageType.CELL, sectionBreakConfig.cellTableId);
|
||||
} else if (ctx.noteSegmentId) {
|
||||
page = _getNullPage(DocumentSkeletonPageType.NOTE, ctx.noteSegmentId);
|
||||
} else {
|
||||
page = _getNullPage();
|
||||
}
|
||||
|
||||
const {
|
||||
sectionId,
|
||||
@@ -70,7 +89,8 @@ export function createSkeletonPage(
|
||||
|
||||
const { skeHeaders, skeFooters } = skeletonResourceReference;
|
||||
|
||||
const { width: pageWidth = Number.POSITIVE_INFINITY, height: pageHeight = Number.POSITIVE_INFINITY } = pageSize;
|
||||
const { width: pageWidth = Number.POSITIVE_INFINITY, height: configuredHeight = Number.POSITIVE_INFINITY } = pageSize;
|
||||
const pageHeight = sectionBreakConfig.cellPageHeights?.[pageNumber - 1] ?? configuredHeight;
|
||||
|
||||
page.pageNumber = pageNumber;
|
||||
page.sectionId = sectionId;
|
||||
@@ -310,7 +330,8 @@ export function createNullCellPage(
|
||||
availableHeight: number = Number.POSITIVE_INFINITY,
|
||||
maxCellPageHeight: number = Number.POSITIVE_INFINITY,
|
||||
inheritDocumentLinePitch = true,
|
||||
enableDocumentTableLineGrid = true
|
||||
enableDocumentTableLineGrid = true,
|
||||
cellPageHeights?: readonly number[]
|
||||
) {
|
||||
const {
|
||||
sectionId,
|
||||
@@ -365,6 +386,8 @@ export function createNullCellPage(
|
||||
|
||||
const cellSectionBreakConfig: ISectionBreakConfig = {
|
||||
sectionId,
|
||||
cellTableId: tableId,
|
||||
cellPageHeights,
|
||||
lists,
|
||||
footerTreeMap,
|
||||
headerTreeMap,
|
||||
@@ -527,7 +550,8 @@ export function createSkeletonCellPages(
|
||||
row: number,
|
||||
col: number,
|
||||
availableHeight: number = Number.POSITIVE_INFINITY,
|
||||
maxCellPageHeight: number = Number.POSITIVE_INFINITY
|
||||
maxCellPageHeight: number = Number.POSITIVE_INFINITY,
|
||||
cellPageHeights?: readonly number[]
|
||||
) {
|
||||
// Table cell only has one section.
|
||||
const sectionNode = cellNode.children[0];
|
||||
@@ -547,7 +571,8 @@ export function createSkeletonCellPages(
|
||||
availableHeight,
|
||||
maxCellPageHeight,
|
||||
inheritDocumentLinePitch,
|
||||
enableDocumentTableLineGrid
|
||||
enableDocumentTableLineGrid,
|
||||
cellPageHeights
|
||||
);
|
||||
|
||||
const segmentId = tableConfig.tableId;
|
||||
@@ -628,7 +653,8 @@ export function startSkeletonCellPagesBuild(
|
||||
row: number,
|
||||
col: number,
|
||||
availableHeight: number = Number.POSITIVE_INFINITY,
|
||||
maxCellPageHeight: number = Number.POSITIVE_INFINITY
|
||||
maxCellPageHeight: number = Number.POSITIVE_INFINITY,
|
||||
cellPageHeights?: readonly number[]
|
||||
): ICellSkeletonBuildState {
|
||||
const sectionNode = cellNode.children[0];
|
||||
const { enableDocumentTableLineGrid, inheritDocumentLinePitch } = getCellLineGridOptions(
|
||||
@@ -645,7 +671,8 @@ export function startSkeletonCellPagesBuild(
|
||||
availableHeight,
|
||||
maxCellPageHeight,
|
||||
inheritDocumentLinePitch,
|
||||
enableDocumentTableLineGrid
|
||||
enableDocumentTableLineGrid,
|
||||
cellPageHeights
|
||||
);
|
||||
page.type = DocumentSkeletonPageType.CELL;
|
||||
page.segmentId = tableConfig.tableId;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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 { DocumentNoteType, IDocumentData, IFootnoteProperties, ISectionBreak } from '@univerjs/core';
|
||||
import { CustomRangeType, ListGlyphType } from '@univerjs/core';
|
||||
import { getBulletOrderedSymbol } from './block/paragraph/bullet-ruler';
|
||||
|
||||
const NUMBER_FORMATS: Record<string, ListGlyphType> = {
|
||||
decimal: ListGlyphType.DECIMAL,
|
||||
decimalZero: ListGlyphType.DECIMAL_ZERO,
|
||||
upperLetter: ListGlyphType.UPPER_LETTER,
|
||||
lowerLetter: ListGlyphType.LOWER_LETTER,
|
||||
upperRoman: ListGlyphType.UPPER_ROMAN,
|
||||
lowerRoman: ListGlyphType.LOWER_ROMAN,
|
||||
chineseCounting: ListGlyphType.CHINESE_COUNTING,
|
||||
};
|
||||
|
||||
export interface INoteLayoutProperties extends Omit<IFootnoteProperties, 'position'> {
|
||||
position?: 'pageBottom' | 'beneathText' | 'docEnd' | 'sectEnd';
|
||||
}
|
||||
|
||||
export interface INoteReferenceLayout {
|
||||
noteId: string;
|
||||
type: DocumentNoteType;
|
||||
referenceIndex: number;
|
||||
sectionId: string;
|
||||
label: string;
|
||||
number?: number;
|
||||
properties: INoteLayoutProperties;
|
||||
}
|
||||
|
||||
export function formatNoteNumber(number: number, format = 'decimal'): string {
|
||||
if (format === 'none') {
|
||||
return '';
|
||||
}
|
||||
if (format === 'chicago') {
|
||||
const symbols = ['*', '†', '‡', '§', '‖', '¶'];
|
||||
const index = Math.max(0, number - 1);
|
||||
return symbols[index % symbols.length].repeat(Math.floor(index / symbols.length) + 1);
|
||||
}
|
||||
if (format === 'decimalEnclosedCircle') {
|
||||
if (number === 0) {
|
||||
return '⓪';
|
||||
}
|
||||
if (number >= 1 && number <= 20) {
|
||||
return String.fromCodePoint(0x2460 + number - 1);
|
||||
}
|
||||
if (number >= 21 && number <= 35) {
|
||||
return String.fromCodePoint(0x3251 + number - 21);
|
||||
}
|
||||
if (number >= 36 && number <= 50) {
|
||||
return String.fromCodePoint(0x32B1 + number - 36);
|
||||
}
|
||||
}
|
||||
if (format === 'ideographEnclosedCircle' && number >= 1 && number <= 10) {
|
||||
return String.fromCodePoint(0x3220 + number - 1);
|
||||
}
|
||||
if (format === 'ideographTraditional' || format === 'ideographZodiac') {
|
||||
const alphabet = format === 'ideographTraditional' ? '甲乙丙丁戊己庚辛壬癸' : '子丑寅卯辰巳午未申酉戌亥';
|
||||
if (number >= 1 && number <= alphabet.length) {
|
||||
return alphabet[number - 1];
|
||||
}
|
||||
}
|
||||
if (format === 'chineseLegalSimplified') {
|
||||
const standard = getBulletOrderedSymbol(0, number, ListGlyphType.CHINESE_COUNTING);
|
||||
const digits = '零一二三四五六七八九十百千';
|
||||
const legal = '零壹贰叁肆伍陆柒捌玖拾佰仟';
|
||||
const result = standard.replace(/[零一二三四五六七八九十百千]/g, (digit) => legal[digits.indexOf(digit)]);
|
||||
return result.startsWith('拾') ? `壹${result}` : result;
|
||||
}
|
||||
if (format === 'decimalFullWidth' || format === 'decimalFullWidth2') {
|
||||
return String(number).replace(/\d/g, (digit) => String.fromCharCode(0xFF10 + Number(digit)));
|
||||
}
|
||||
if (format === 'ideographDigital' || format === 'taiwaneseDigital') {
|
||||
return String(number).replace(/\d/g, (digit) => '〇一二三四五六七八九'[Number(digit)]);
|
||||
}
|
||||
return getBulletOrderedSymbol(0, number, NUMBER_FORMATS[format] ?? ListGlyphType.DECIMAL);
|
||||
}
|
||||
|
||||
/** Page keys are physical page indices, independent of the printed page-number format. */
|
||||
/** Cell section sentinels cannot own document-level note numbering or placement. */
|
||||
export function getNoteSections(snapshot: Pick<IDocumentData, 'body'>): ISectionBreak[] {
|
||||
const sections = snapshot.body?.sectionBreaks ?? [];
|
||||
const tables = [...snapshot.body?.tables ?? []].sort((a, b) => a.startIndex - b.startIndex);
|
||||
let tableIndex = 0;
|
||||
let tableEnd = -1;
|
||||
return sections.filter((section) => {
|
||||
while (tableIndex < tables.length && tables[tableIndex].startIndex <= section.startIndex) {
|
||||
tableEnd = Math.max(tableEnd, tables[tableIndex++].endIndex);
|
||||
}
|
||||
return section.startIndex > tableEnd;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNoteReferences(
|
||||
snapshot: Pick<IDocumentData, 'body' | 'notes' | 'noteSettings'>,
|
||||
referencePages: ReadonlyMap<string, number> = new Map()
|
||||
): Map<number, INoteReferenceLayout> {
|
||||
const references = snapshot.body?.customRanges?.filter((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE)) ?? [];
|
||||
const sections = getNoteSections(snapshot);
|
||||
const result = new Map<number, INoteReferenceLayout>();
|
||||
let sectionIndex = 0;
|
||||
const counters = new Map<DocumentNoteType, { sectionId: string; page?: number; nextNumber: number }>();
|
||||
for (const reference of references) {
|
||||
const noteId = reference.properties?.noteId;
|
||||
if (typeof noteId !== 'string' || !snapshot.notes?.[noteId]) {
|
||||
continue;
|
||||
}
|
||||
while (sectionIndex < sections.length - 1 && sections[sectionIndex].startIndex < reference.startIndex) {
|
||||
sectionIndex++;
|
||||
}
|
||||
const section = sections[sectionIndex];
|
||||
const note = snapshot.notes[noteId];
|
||||
const type = note.type;
|
||||
const properties: INoteLayoutProperties = { ...snapshot.noteSettings?.[type], ...section?.noteProperties?.[type] };
|
||||
const sectionId = section?.sectionId ?? '';
|
||||
const page = referencePages.get(noteId);
|
||||
const previous = counters.get(type);
|
||||
let nextNumber = previous?.nextNumber;
|
||||
if (nextNumber == null || (properties.restart === 'eachSect' && previous?.sectionId !== sectionId) ||
|
||||
(properties.restart === 'eachPage' && page != null && previous?.page !== page)) {
|
||||
nextNumber = properties.startNumber ?? 1;
|
||||
}
|
||||
const number = note.customMark == null ? nextNumber : undefined;
|
||||
const label = note.customMark ?? formatNoteNumber(nextNumber, properties.numberFormat ?? (type === 'endnote' ? 'lowerRoman' : 'decimal'));
|
||||
result.set(reference.startIndex, { noteId, type, referenceIndex: reference.startIndex, sectionId, properties, number, label });
|
||||
if (number != null) {
|
||||
nextNumber++;
|
||||
}
|
||||
counters.set(type, { sectionId, page, nextNumber });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+47
@@ -19,6 +19,7 @@ import { FontCache, invalidateDocumentFontMetrics } from '../font-cache';
|
||||
|
||||
describe('font cache', () => {
|
||||
beforeEach(() => {
|
||||
invalidateDocumentFontMetrics(() => true);
|
||||
(FontCache as any)._globalFontMeasureCache = new Map();
|
||||
(FontCache as any)._fontDataMap = new Map();
|
||||
(FontCache as any)._getTextHeightCache = {};
|
||||
@@ -26,6 +27,7 @@ describe('font cache', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
invalidateDocumentFontMetrics(() => true);
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -79,6 +81,51 @@ describe('font cache', () => {
|
||||
expect(FontCache.getMeasureText('Agent', '14px FontChange')).toEqual(next);
|
||||
});
|
||||
|
||||
it('preserves fractional normal spacing across font sizes and transfers it to a DOM-free Worker', () => {
|
||||
let normalHeight = 3066;
|
||||
const measureRect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({
|
||||
height: normalHeight,
|
||||
} as DOMRect));
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
font: '',
|
||||
measureText: () => ({
|
||||
width: 8,
|
||||
fontBoundingBoxAscent: 14,
|
||||
fontBoundingBoxDescent: 3,
|
||||
actualBoundingBoxAscent: 11,
|
||||
actualBoundingBoxDescent: 2,
|
||||
}),
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
const style = {
|
||||
fontString: '12pt FixtureFont',
|
||||
fontCache: '12pt FixtureFont',
|
||||
fontFamily: 'FixtureFont',
|
||||
fontSize: 12,
|
||||
originFontSize: 12,
|
||||
};
|
||||
const originalChildCount = document.body.childElementCount;
|
||||
const main = FontCache.getTextSize('A', style, true);
|
||||
expect(main.normalLineHeight).toBeCloseTo(18.396);
|
||||
expect(FontCache.getTextSize('A', style).normalLineHeight).toBe(17);
|
||||
expect(FontCache.getNormalLineHeight({ ...style, originFontSize: 10 })).toBeCloseTo(15.33);
|
||||
expect(measureRect).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.childElementCount).toBe(originalChildCount);
|
||||
|
||||
const transferred = FontCache.getNormalLineHeightCache();
|
||||
// Superscript keeps the paragraph's original font size for normal spacing.
|
||||
expect(FontCache.getNormalLineHeight({ ...style, fontSize: 7.2, fontString: '7.2pt FixtureFont' }))
|
||||
.toBeCloseTo(main.normalLineHeight!);
|
||||
invalidateDocumentFontMetrics((font) => font === style.fontString);
|
||||
normalHeight = 3200;
|
||||
expect(FontCache.getTextSize('A', style, true).normalLineHeight).toBeCloseTo(19.2);
|
||||
|
||||
vi.stubGlobal('document', undefined);
|
||||
invalidateDocumentFontMetrics(() => true);
|
||||
FontCache.setNormalLineHeightCache(transferred);
|
||||
expect(FontCache.getTextSize('A', style, true)).toEqual(main);
|
||||
expect(measureRect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('measures text with OffscreenCanvas when the DOM is unavailable', () => {
|
||||
const measureText = vi.fn(() => ({
|
||||
width: 16,
|
||||
|
||||
@@ -37,6 +37,10 @@ const getDefaultBaselineOffset = (fontSize: number) => ({
|
||||
spo: fontSize,
|
||||
});
|
||||
|
||||
function getNormalFontKey(font: string): string {
|
||||
return font.replace(/\d+(?:\.\d+)?(?:pt|px)/, '1000pt').trim();
|
||||
}
|
||||
|
||||
interface IFontData {
|
||||
notDefWidth: number;
|
||||
ascender: number;
|
||||
@@ -64,6 +68,8 @@ export function invalidateDocumentFontMetrics(matches: (fontStyle: string) => bo
|
||||
}
|
||||
|
||||
export class FontCache {
|
||||
private static _normalLineHeightCache = new Map<string, number>();
|
||||
|
||||
private static _getTextHeightCache: { [key: string]: { width: number; height: number } } = {};
|
||||
|
||||
private static _context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
|
||||
@@ -78,11 +84,17 @@ export class FontCache {
|
||||
|
||||
static invalidateMetrics(matches: (fontStyle: string) => boolean): boolean {
|
||||
let changed = false;
|
||||
const keys = new Set([...this._globalFontMeasureCache.keys(), ...Object.keys(this._getTextHeightCache)]);
|
||||
const keys = new Set([
|
||||
...this._globalFontMeasureCache.keys(),
|
||||
...Object.keys(this._getTextHeightCache),
|
||||
...this._normalLineHeightCache.keys(),
|
||||
]);
|
||||
for (const key of keys) {
|
||||
if (matches(key)) {
|
||||
this._globalFontMeasureCache.delete(key);
|
||||
delete this._getTextHeightCache[key];
|
||||
this._normalLineHeightCache.delete(key);
|
||||
this._normalLineHeightCache.delete(getNormalFontKey(key));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -209,7 +221,55 @@ export class FontCache {
|
||||
return result;
|
||||
}
|
||||
|
||||
static getTextSize(content: string, fontStyle: IDocumentSkeletonFontStyle): IDocumentSkeletonBoundingBox {
|
||||
/** CSS normal spacing includes font leading that Canvas bounding boxes omit. */
|
||||
static getNormalLineHeight(fontStyle: IDocumentSkeletonFontStyle): number | undefined {
|
||||
const key = getNormalFontKey(fontStyle.fontString);
|
||||
let ratio = this._normalLineHeightCache.get(key);
|
||||
if (ratio == null && typeof document !== 'undefined' && document.body != null) {
|
||||
const element = document.createElement('div');
|
||||
element.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;white-space:pre;margin:0;padding:0;border:0;';
|
||||
element.style.font = key;
|
||||
element.style.lineHeight = 'normal';
|
||||
element.textContent = 'Hg\nHg';
|
||||
document.body.appendChild(element);
|
||||
try {
|
||||
// Measuring large text preserves subpixel font metrics at ordinary document sizes.
|
||||
const height = element.getBoundingClientRect().height / 2000;
|
||||
if (Number.isFinite(height) && height > 0) {
|
||||
ratio = height;
|
||||
if (this._normalLineHeightCache.size >= 1024) {
|
||||
this._normalLineHeightCache.clear();
|
||||
}
|
||||
this._normalLineHeightCache.set(key, ratio);
|
||||
}
|
||||
} finally {
|
||||
element.remove();
|
||||
}
|
||||
}
|
||||
return ratio == null ? undefined : ratio * fontStyle.originFontSize;
|
||||
}
|
||||
|
||||
/** Transfers browser-only normal spacing measurements to the document layout Worker. */
|
||||
static getNormalLineHeightCache(): Record<string, number> {
|
||||
return Object.fromEntries(this._normalLineHeightCache);
|
||||
}
|
||||
|
||||
static setNormalLineHeightCache(metrics: Record<string, number>): void {
|
||||
for (const [font, ratio] of Object.entries(metrics)) {
|
||||
if (Number.isFinite(ratio) && ratio > 0) {
|
||||
if (this._normalLineHeightCache.size >= 1024 && !this._normalLineHeightCache.has(font)) {
|
||||
this._normalLineHeightCache.clear();
|
||||
}
|
||||
this._normalLineHeightCache.set(font, ratio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static getTextSize(
|
||||
content: string,
|
||||
fontStyle: IDocumentSkeletonFontStyle,
|
||||
includeNormalFontLeading = false
|
||||
): IDocumentSkeletonBoundingBox {
|
||||
const { fontString, fontSize, fontFamily } = fontStyle;
|
||||
|
||||
let bBox = this._getBoundingBoxByFont(fontFamily, fontSize);
|
||||
@@ -224,7 +284,7 @@ export class FontCache {
|
||||
|
||||
return {
|
||||
...bBox,
|
||||
normalLineHeight: bBox.ba + bBox.bd,
|
||||
normalLineHeight: (includeNormalFontLeading ? this.getNormalLineHeight(fontStyle) : undefined) ?? bBox.ba + bBox.bd,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,14 @@
|
||||
*/
|
||||
|
||||
import type { ICustomRangeForInterceptor, ITextStyle, Nullable } from '@univerjs/core';
|
||||
import { BooleanNumber, CustomRangeType } from '@univerjs/core';
|
||||
import { BaselineOffset, BooleanNumber, CustomRangeType } from '@univerjs/core';
|
||||
|
||||
const CUSTOM_RANGE_COLOR_TOKEN = 'blue.600';
|
||||
|
||||
export function getCustomRangeStyle(customRange: ICustomRangeForInterceptor): Nullable<ITextStyle> {
|
||||
if ((customRange.rangeType === CustomRangeType.FOOTNOTE || customRange.rangeType === CustomRangeType.ENDNOTE)) {
|
||||
return { va: BaselineOffset.SUPERSCRIPT };
|
||||
}
|
||||
if (
|
||||
customRange.rangeType === CustomRangeType.HYPERLINK ||
|
||||
customRange.rangeType === CustomRangeType.MENTION ||
|
||||
|
||||
@@ -49,8 +49,11 @@ import type { IBoundRectNoAngle } from '../../../basics/vector2';
|
||||
import type { IDocumentCompatibilityPolicy } from '../document-compatibility';
|
||||
import type { DataStreamTreeNode } from '../view-model/data-stream-tree-node';
|
||||
import type { DocumentViewModel } from '../view-model/document-view-model';
|
||||
import type { DocumentEndnoteLayout } from './endnote-layout';
|
||||
import type { DocumentFootnoteLayout } from './footnote-layout';
|
||||
import type { Hyphen } from './hyphenation/hyphen';
|
||||
import type { LanguageDetector } from './hyphenation/language-detector';
|
||||
import type { INoteReferenceLayout } from './note-numbering';
|
||||
import {
|
||||
AlignTypeH,
|
||||
AlignTypeV,
|
||||
@@ -1591,6 +1594,7 @@ export function getGlyphGroupWidth(divide: IDocumentSkeletonDivide) {
|
||||
}
|
||||
|
||||
interface IFontCreateConfig {
|
||||
documentCompatibilityPolicy?: IDocumentCompatibilityPolicy;
|
||||
fontStyle: IDocumentSkeletonFontStyle;
|
||||
textStyle: ITextStyle;
|
||||
charSpace: number;
|
||||
@@ -1625,6 +1629,7 @@ export function getFontConfigFromLastGlyph(
|
||||
const pageWidth = pageSize.width || Number.POSITIVE_INFINITY - marginLeft - marginRight;
|
||||
|
||||
const result = {
|
||||
documentCompatibilityPolicy: sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy(),
|
||||
fontStyle: fontStyle!,
|
||||
textStyle: ts!,
|
||||
charSpace,
|
||||
@@ -1813,6 +1818,10 @@ export function getNullSkeleton(): IDocumentSkeletonCached {
|
||||
export function setPageParent(pages: IDocumentSkeletonPage[], parent: IDocumentSkeletonCached) {
|
||||
for (const page of pages) {
|
||||
page.parent = parent;
|
||||
for (const note of page.notes ?? []) {
|
||||
note.parent = page;
|
||||
note.page.parent = note;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1854,6 +1863,15 @@ export interface IDocumentPaginationMetrics {
|
||||
}
|
||||
|
||||
export interface ILayoutContext {
|
||||
noteReferences?: ReadonlyMap<number, INoteReferenceLayout>;
|
||||
footnoteLayout?: DocumentFootnoteLayout;
|
||||
endnoteLayout?: DocumentEndnoteLayout;
|
||||
/** Virtual marker in a note body; it never consumes a persisted character. */
|
||||
noteLabel?: string;
|
||||
noteReferenceTextStyle?: ITextStyle;
|
||||
/** Preserve the local note segment when the paragraph/table pipeline opens a continuation page. */
|
||||
noteSegmentId?: string;
|
||||
footnoteFirstColumn?: { index: number; top: number };
|
||||
// The view model of current layout document.
|
||||
viewModel: DocumentViewModel;
|
||||
// The data model of current layout document.
|
||||
@@ -2156,6 +2174,13 @@ export function getPageFromPath(skeletonData: IDocumentSkeletonCached, path: (st
|
||||
const cellIndex = pathCopy.shift() as number;
|
||||
|
||||
page = page.skeTables?.get(tableId)?.rows[rowIndex]?.cells[cellIndex];
|
||||
} else if (field === 'notes') {
|
||||
if (page == null) {
|
||||
return null;
|
||||
}
|
||||
const footnoteIndex = pathCopy.shift() as number;
|
||||
pathCopy.shift(); // page
|
||||
page = page.notes?.[footnoteIndex]?.page;
|
||||
} else if (field === 'skeColumnGroups') {
|
||||
if (page == null) {
|
||||
return null;
|
||||
|
||||
+30
@@ -84,6 +84,7 @@ function createDocumentDataModel(overrides?: {
|
||||
getSnapshot: vi.fn(() => snapshot),
|
||||
headerModelMap: overrides?.headerModelMap ?? new Map(),
|
||||
footerModelMap: overrides?.footerModelMap ?? new Map(),
|
||||
noteModelMap: new Map(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
@@ -98,6 +99,35 @@ function findFirstNodeByType(node: any, type: DataStreamTreeNodeType): any | nul
|
||||
}
|
||||
|
||||
describe('DocumentViewModel', () => {
|
||||
it('rebuilds an edited footnote tree without rebuilding unrelated note trees', () => {
|
||||
const noteBody = (text: string) => ({
|
||||
dataStream: `${text}\r\n`,
|
||||
paragraphs: [{ paragraphId: `${text}-p`, startIndex: text.length }],
|
||||
sectionBreaks: [{ sectionId: `${text}-s`, startIndex: text.length + 1 }],
|
||||
});
|
||||
const model = new DocumentDataModel({
|
||||
id: 'notes',
|
||||
body: noteBody('Body'),
|
||||
notes: {
|
||||
first: { type: 'footnote' as const, noteId: 'first', body: noteBody('First') },
|
||||
second: { type: 'footnote' as const, noteId: 'second', body: noteBody('Second') },
|
||||
},
|
||||
});
|
||||
const view = new DocumentViewModel(model);
|
||||
const first = view.getNoteTreeMap().get('first');
|
||||
const second = view.getNoteTreeMap().get('second');
|
||||
try {
|
||||
model.apply(JSONX.getInstance().editOp(new TextX().insert(4, { dataStream: 'New ' }).serialize(), ['notes', 'first', 'body']));
|
||||
view.reset(model);
|
||||
expect(view.getNoteTreeMap().get('first')).not.toBe(first);
|
||||
expect(view.getNoteTreeMap().get('second')).toBe(second);
|
||||
expect(view.getNoteTreeMap().get('first')?.getChildren()[0].children[0].content).toBe('New First\r\n');
|
||||
} finally {
|
||||
view.dispose();
|
||||
model.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
describe('parseDataStreamToTree', () => {
|
||||
it('should handle empty section correctly', () => {
|
||||
const dataStream = DataStreamTreeTokenType.SECTION_BREAK;
|
||||
|
||||
@@ -14,7 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { DocumentDataModel, IColumnGroup, ICustomBlock, ICustomColumnGroup, ICustomDecorationForInterceptor, ICustomRangeForInterceptor, ICustomTable, IDisposable, IParagraph, ISectionBreak, ITable, ITextRun, JSONXActions, JSONXPath, Nullable } from '@univerjs/core';
|
||||
import type {
|
||||
DocumentDataModel,
|
||||
IColumnGroup,
|
||||
ICustomBlock,
|
||||
ICustomColumnGroup,
|
||||
ICustomDecorationForInterceptor,
|
||||
ICustomRangeForInterceptor,
|
||||
ICustomTable,
|
||||
IDisposable,
|
||||
IParagraph,
|
||||
ISectionBreak,
|
||||
ITable,
|
||||
ITextRun,
|
||||
JSONXActions,
|
||||
JSONXPath,
|
||||
Nullable,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
DataStreamTreeNodeType,
|
||||
DataStreamTreeTokenType,
|
||||
@@ -540,6 +556,7 @@ export class DocumentViewModel implements IDisposable {
|
||||
|
||||
private _headerTreeMap: Map<string, DocumentViewModel> = new Map();
|
||||
private _footerTreeMap: Map<string, DocumentViewModel> = new Map();
|
||||
private _noteTreeMap: Map<string, DocumentViewModel> = new Map();
|
||||
|
||||
private readonly _segmentViewModels$ = new BehaviorSubject<DocumentViewModel[]>([]);
|
||||
readonly segmentViewModels$ = this._segmentViewModels$.asObservable();
|
||||
@@ -583,7 +600,8 @@ export class DocumentViewModel implements IDisposable {
|
||||
this._plainTopLevelParagraphNodes = [];
|
||||
this._lastTextRun = null;
|
||||
// this._headerTreeMap.clear();
|
||||
// this._footerTreeMap.clear();
|
||||
this._noteTreeMap.forEach((viewModel) => viewModel.dispose());
|
||||
this._noteTreeMap.clear();
|
||||
this._segmentViewModels$.complete();
|
||||
this._editAreaChange$.complete();
|
||||
}
|
||||
@@ -595,6 +613,10 @@ export class DocumentViewModel implements IDisposable {
|
||||
};
|
||||
}
|
||||
|
||||
getNoteTreeMap(): ReadonlyMap<string, DocumentViewModel> {
|
||||
return this._noteTreeMap;
|
||||
}
|
||||
|
||||
getEditArea() {
|
||||
return this._editArea;
|
||||
}
|
||||
@@ -635,6 +657,10 @@ export class DocumentViewModel implements IDisposable {
|
||||
return this._footerTreeMap.get(segmentId)!;
|
||||
}
|
||||
|
||||
if (this._noteTreeMap.has(segmentId)) {
|
||||
return this._noteTreeMap.get(segmentId)!;
|
||||
}
|
||||
|
||||
return this as DocumentViewModel;
|
||||
}
|
||||
|
||||
@@ -1138,6 +1164,12 @@ export class DocumentViewModel implements IDisposable {
|
||||
}
|
||||
|
||||
private _buildHeaderFooterViewModel() {
|
||||
for (const [noteId, viewModel] of this._noteTreeMap) {
|
||||
if (viewModel.getDataModel() !== this._documentDataModel.noteModelMap.get(noteId)) {
|
||||
viewModel.dispose();
|
||||
this._noteTreeMap.delete(noteId);
|
||||
}
|
||||
}
|
||||
const { headerModelMap, footerModelMap } = this._documentDataModel;
|
||||
const viewModels = [];
|
||||
const rootTableSource = this.getSnapshot().tableSource;
|
||||
@@ -1157,6 +1189,12 @@ export class DocumentViewModel implements IDisposable {
|
||||
viewModels.push(this._footerTreeMap.get(footerId)!);
|
||||
}
|
||||
|
||||
for (const [noteId, model] of this._documentDataModel.noteModelMap) {
|
||||
const viewModel = this._noteTreeMap.get(noteId)
|
||||
?? new DocumentViewModel(model, { ...rootTableSource, ...model.getSnapshot().tableSource });
|
||||
this._noteTreeMap.set(noteId, viewModel);
|
||||
viewModels.push(viewModel);
|
||||
}
|
||||
this._segmentViewModels$.next(viewModels);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user