fix(ui): preserve scoped formatting and virtualized document selections (#7756)

This commit is contained in:
Univer
2026-09-27 12:55:50 -07:00
committed by GitHub
parent cfb245a2f9
commit b0be43d135
8 changed files with 103 additions and 7 deletions
@@ -201,6 +201,10 @@ export class DocMoveCursorController extends Disposable {
return;
}
if (this._deferUnmaterializedCursorMove(docDataModel, skeleton, nextOffset, normalizedSegmentId, () => this._handleShiftMoveSelection(direction, granularity))) {
return;
}
const normalizedNextOffset = this._normalizeRenderableCursorOffset(
skeleton,
body.dataStream,
@@ -1493,6 +1493,10 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
Tools.now() - hydrationStartedAt
);
}
this._textSelectionManagerService.refreshSelection(
{ unitId: this._context.unitId, subUnitId: this._context.unitId },
false
);
this._markDocumentRenderDirty();
}
}
@@ -400,6 +400,7 @@ describe('selection utils', () => {
it('does not create ranges when a character boundary cannot be resolved', () => {
const skeleton = {
getSkeletonData: () => undefined,
findNodePositionByCharIndex: vi.fn(() => undefined),
};
const document = createDocument();
@@ -29,7 +29,11 @@ import type {
} from '@univerjs/engine-render';
import type { IDocRange } from './range-interface';
import { DataStreamTreeNodeType, RANGE_DIRECTION, Tools } from '@univerjs/core';
import { DocumentSkeletonPageType, getDocumentSkeletonColumnPagePathInfo, getOffsetRectForDom } from '@univerjs/engine-render';
import {
DocumentSkeletonPageType,
getDocumentSkeletonColumnPagePathInfo,
getOffsetRectForDom,
} from '@univerjs/engine-render';
import { isInSameTableCell, isInSameTableCellData, isValidRectRange } from './convert-rect-range';
import { compareNodePosition } from './convert-text-range';
import { convertPositionsToRectRanges, RectRange } from './rect-range';
@@ -138,6 +142,16 @@ export function getTextRangeFromCharIndex(
const endNodePosition = skeleton.findNodePositionByCharIndex(endOffset, endIsBack, segmentId, segmentPage);
if (startNodePosition == null || endNodePosition == null) {
const pages = skeleton.getSkeletonData()?.pages;
const startPage = pages?.[skeleton.findBodyPageIndexByCharIndex(startOffset)];
const endPage = pages?.[skeleton.findBodyPageIndexByCharIndex(endOffset)];
if (segmentId === '' && startOffset !== endOffset && startPage && endPage &&
(startPage.isMaterializationPlaceholder || endPage.isMaterializationPlaceholder)) {
return new TextRange(scene, document, skeleton, undefined, undefined, style, segmentId, segmentPage, {
startOffset: startIsBack ? startOffset : startOffset + 1,
endOffset: endIsBack ? endOffset : endOffset + 1,
});
}
return;
}
@@ -178,6 +192,10 @@ export function getRangeListFromCharIndex(
const endNodePosition = skeleton.findNodePositionByCharIndex(endOffset, true, segmentId, segmentPage);
if (startNodePosition == null || endNodePosition == null) {
const textRange = getTextRangeFromCharIndex(startOffset, endOffset, scene, document, skeleton, style, segmentId, segmentPage);
if (textRange) {
return { textRanges: [textRange], rectRanges: [] };
}
return;
}
@@ -26,11 +26,26 @@ import type {
Scene,
} from '@univerjs/engine-render';
import type { IDocRange } from './range-interface';
import { BooleanNumber, ColorKit, COLORS, DOC_RANGE_TYPE, generateRandomId, getColorStyle, RANGE_DIRECTION } from '@univerjs/core';
import { DocumentSkeletonPageType, getColor, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, Rect, RegularPolygon } from '@univerjs/engine-render';
import {
BooleanNumber,
ColorKit,
COLORS,
DOC_RANGE_TYPE,
generateRandomId,
getColorStyle,
RANGE_DIRECTION,
} from '@univerjs/core';
import {
DocumentSkeletonPageType,
getColor,
NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
Rect,
RegularPolygon,
} from '@univerjs/engine-render';
import {
compareNodePosition,
compareNodePositionLogic,
findDocRangeNodePositions,
getOneTextSelectionRange,
NodePositionConvertToCursor,
NodePositionMap,
@@ -131,7 +146,8 @@ export class TextRange implements IDocRange {
public focusNodePosition?: Nullable<INodePosition>,
public style: ITextSelectionStyle = NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
private _segmentId: string = '',
private _segmentPage: number = -1
private _segmentPage: number = -1,
private _logicalRange?: { startOffset: number; endOffset: number }
) {
this._anchorBlink();
@@ -162,6 +178,9 @@ export class TextRange implements IDocRange {
// The start position of the range
get startOffset() {
if (this._logicalRange) {
return Math.min(this._logicalRange.startOffset, this._logicalRange.endOffset);
}
const { startOffset } = getOneTextSelectionRange(this._cursorList) ?? {};
const body = this._docSkeleton
.getViewModel()
@@ -180,6 +199,9 @@ export class TextRange implements IDocRange {
// The end position of the range
get endOffset() {
if (this._logicalRange) {
return Math.max(this._logicalRange.startOffset, this._logicalRange.endOffset);
}
const { endOffset } = getOneTextSelectionRange(this._cursorList) ?? {};
const body = this._docSkeleton
.getViewModel()
@@ -231,6 +253,11 @@ export class TextRange implements IDocRange {
}
get direction() {
if (this._logicalRange) {
return this._logicalRange.startOffset <= this._logicalRange.endOffset
? RANGE_DIRECTION.FORWARD
: RANGE_DIRECTION.BACKWARD;
}
const { collapsed, anchorNodePosition, focusNodePosition } = this;
if (collapsed || anchorNodePosition == null || focusNodePosition == null) {
@@ -359,6 +386,7 @@ export class TextRange implements IDocRange {
// render cursor and selection.
refresh() {
this._refreshLogicalRangePositions();
const { _document, _docSkeleton } = this;
const anchor = this.anchorNodePosition;
const focus = this.focusNodePosition;
@@ -398,6 +426,38 @@ export class TextRange implements IDocRange {
}
}
private _refreshLogicalRangePositions(): void {
const range = this._logicalRange;
if (!range) {
return;
}
// Keep the full selection in document offsets; only its visible geometry
// depends on the bounded page window retained by the layout worker.
const start = Math.min(range.startOffset, range.endOffset);
const end = Math.max(range.startOffset, range.endOffset);
let first: Nullable<INodePosition>;
let last: Nullable<INodePosition>;
for (const page of this._docSkeleton.getSkeletonData()?.pages ?? []) {
if (page.isLayoutPlaceholder || page.isMaterializationPlaceholder || page.ed < start || page.st >= end) {
continue;
}
const positions = findDocRangeNodePositions(
this._docSkeleton,
Math.max(start, page.st),
Math.min(end - 1, page.ed),
this._segmentId,
this._segmentPage
);
if (positions) {
first ??= positions.startPosition;
last = positions.endPosition;
}
}
const forward = range.startOffset <= range.endOffset;
this.anchorNodePosition = forward ? first : last;
this.focusNodePosition = forward ? last : first;
}
private _isEmpty() {
return this.anchorNodePosition == null && this.focusNodePosition == null;
}
@@ -29,13 +29,14 @@ export function focusFormulaEditor(
return;
}
// Blurring the previous editor can publish its stale rendered selection.
const selections = [...editor.getSelectionRanges()];
editorService.focus(editor.getEditorId());
focusFormulaEditorElement(editor);
if (editor.docSelectionRenderService.isOnPointerEvent) {
return;
}
const selections = [...editor.getSelectionRanges()];
if (Tools.isDefine(offset)) {
editor.setSelectionRanges([{ startOffset: offset, endOffset: offset }]);
} else if (!selections.length) {
@@ -35,6 +35,8 @@ import {
SetRangeValuesCommand,
SetRangeValuesMutation,
SetSelectionsOperation,
WorkbookPermissionService,
WorksheetPermissionService,
} from '@univerjs/sheets';
import { FormatPainterSessionService } from '@univerjs/ui';
import { BehaviorSubject } from 'rxjs';
@@ -228,6 +230,8 @@ describe('Test format painter rules in controller', () => {
const newTheme = set(theme, 'gray.1000', '#35322b');
themeService.setTheme(newTheme);
get(WorkbookPermissionService);
get(WorksheetPermissionService);
get(FormatPainterController);
commandService.registerCommand(SetFormatPainterOperation);
commandService.registerCommand(SetInfiniteFormatPainterCommand);
@@ -48,8 +48,10 @@ import {
SheetsSelectionsService,
} from '@univerjs/sheets';
import { FormatPainterSessionService } from '@univerjs/ui';
import { tap } from 'rxjs';
import { ApplyFormatPainterCommand } from '../../commands/commands/set-format-painter.command';
import { checkCellContentInRanges } from '../../common/utils';
import { ClearSelectionFormatMenuItemFactory } from '../../menu/clear.menu';
import { FormatPainterStatus, IFormatPainterService } from '../../services/format-painter/format-painter.service';
export class FormatPainterController extends Disposable {
@@ -70,13 +72,15 @@ export class FormatPainterController extends Disposable {
private _initialize() {
this._addDefaultHook();
const session = this._injector.get(FormatPainterSessionService);
let disabled = true;
const disabled$ = this._injector.invoke(ClearSelectionFormatMenuItemFactory).disabled$!;
this.disposeWithMe(session.register({
id: 'sheet-cells',
priority: 0,
clear: () => this._commandService.executeCommand(ClearSelectionFormatCommand.id),
changes$: this._selectionManagerService.selectionMoveEnd$,
changes$: disabled$.pipe(tap((value) => { disabled = value; })),
isActive: () => this._univerInstanceService.getFocusedUnit()?.type === UniverInstanceType.UNIVER_SHEET,
canStart: () => !!this._selectionManagerService.getCurrentLastSelection(),
canStart: () => !disabled && !!this._selectionManagerService.getCurrentLastSelection(),
capture: () => {
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const format = this._collectSelectionRangeFormat();