fix(sheets-formula): preserve formula reference highlights (#7235)

This commit is contained in:
Univer
2026-07-11 17:03:38 +08:00
committed by GitHub
parent 6853617003
commit e0b727ee32
6 changed files with 189 additions and 128 deletions
@@ -15,7 +15,7 @@
*/
import type { IDisposable } from '@univerjs/core';
import { createIdentifier } from '@univerjs/core';
import { createIdentifier, DisposableCollection, toDisposable } from '@univerjs/core';
export const FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE = 'data-embed-interaction-boundary-owner';
export const FORMULA_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE = 'data-embed-runtime-focus-role';
@@ -54,6 +54,120 @@ export interface IFormulaEmbedInteractionBoundaryService {
export const IFormulaEmbedInteractionBoundaryService = createIdentifier<IFormulaEmbedInteractionBoundaryService>('sheets-formula-ui.embed-interaction-boundary.service');
interface IRegisterFormulaEditorRuntimePortalOptions {
embedId: string;
editorId: string;
ownerDocument?: Document;
interactionBoundaryService?: IFormulaEmbedInteractionBoundaryService;
focusCoordinator?: IFormulaEmbedRuntimeFocusCoordinator;
}
export function registerFormulaEditorRuntimePortal(options: IRegisterFormulaEditorRuntimePortalOptions): IDisposable {
const ownerDocument = options.ownerDocument ?? (typeof document === 'undefined' ? undefined : document);
if (!ownerDocument) {
return toDisposable(() => {});
}
const collection = new DisposableCollection();
const view = ownerDocument.defaultView;
const frameHandles: number[] = [];
let observer: MutationObserver | undefined;
let portalRegistration: IDisposable | undefined;
let registeredPortalRoot: HTMLElement | null = null;
let disposed = false;
const tryRegister = () => {
if (disposed) {
return;
}
const portalRoot = resolveFormulaEditorPortalRoot(options.editorId, ownerDocument);
if (portalRoot === registeredPortalRoot) {
return;
}
portalRegistration?.dispose();
portalRegistration = undefined;
registeredPortalRoot = null;
if (!portalRoot) {
return;
}
registeredPortalRoot = portalRoot;
portalRegistration = registerFormulaEditorPortalRoot(options, ownerDocument, portalRoot);
};
const scheduleRetry = (remaining: number) => {
if (remaining <= 0 || !view?.requestAnimationFrame) {
return;
}
const handle = view.requestAnimationFrame(() => {
const index = frameHandles.indexOf(handle);
if (index >= 0) {
frameHandles.splice(index, 1);
}
tryRegister();
if (!registeredPortalRoot) {
scheduleRetry(remaining - 1);
}
});
frameHandles.push(handle);
};
tryRegister();
if (!registeredPortalRoot) {
scheduleRetry(2);
}
if (view?.MutationObserver && ownerDocument.body) {
observer = new view.MutationObserver(() => tryRegister());
observer.observe(ownerDocument.body, { childList: true, subtree: true });
}
collection.add(toDisposable(() => {
disposed = true;
frameHandles.forEach((handle) => view?.cancelAnimationFrame?.(handle));
frameHandles.length = 0;
observer?.disconnect();
observer = undefined;
portalRegistration?.dispose();
portalRegistration = undefined;
registeredPortalRoot = null;
}));
return collection;
}
function resolveFormulaEditorPortalRoot(editorId: string, ownerDocument: Document): HTMLElement | null {
return (ownerDocument.getElementById(`univer-doc-selection-container-${editorId}`) as HTMLElement | null)
?? (ownerDocument.getElementById(`__editor_${editorId}`) as HTMLElement | null);
}
function registerFormulaEditorPortalRoot(
options: IRegisterFormulaEditorRuntimePortalOptions,
ownerDocument: Document,
portalRoot: HTMLElement
): IDisposable {
const collection = new DisposableCollection();
const editorElement = ownerDocument.getElementById(`__editor_${options.editorId}`) as HTMLElement | null;
const elements = editorElement && editorElement !== portalRoot ? [portalRoot, editorElement] : [portalRoot];
elements.forEach((element) => {
if (options.interactionBoundaryService) {
collection.add(options.interactionBoundaryService.registerOwnedElement(options.embedId, element));
}
if (options.focusCoordinator) {
collection.add(options.focusCoordinator.registerElement({
embedId: options.embedId,
role: 'child-editor',
element,
}));
}
});
return collection;
}
export function resolveFormulaEmbedRuntimeDomScope(root: HTMLElement | null | undefined): IFormulaEmbedRuntimeDomScope | undefined {
const scopeElement = root?.closest<HTMLElement>(`[${FORMULA_EMBED_ID_ATTRIBUTE}]`);
const embedId = scopeElement?.getAttribute(FORMULA_EMBED_ID_ATTRIBUTE);
@@ -23,11 +23,11 @@ import {
FORMULA_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE,
FORMULA_EMBED_RUNTIME_FOCUS_ROLE_ATTRIBUTE,
isEventTargetInSameFormulaEmbedInteractionBoundary,
registerFormulaEditorRuntimePortal,
} from '../../formula-embed-integration.service';
import { registerFormulaEditorRuntimePortal } from '../..';
import { focusFormulaEditor, hasActiveFormulaEmbedInteraction, shouldRefocusFormulaEditorOnMouseUp, shouldSkipFormulaEditorMouseUpFocus } from '../use-focus';
import { FormulaSelectingType, resolveFormulaSelectingIntent, resolveFormulaSelectionCursorIndex, resolveFormulaSelectionDataStream, resolveFormulaSelectionWorkbook, shouldSkipReferenceEditingByPointer } from '../use-formula-selection';
import { buildTextRuns, calcHighlightRanges, getFormulaHighlightDataStream } from '../use-highlight';
import { buildTextRuns, calcHighlightRanges, createFormulaHighlightBody, getFormulaHighlightDataStream } from '../use-highlight';
import { isFormulaEditorInteractionOwner, shouldMoveFormulaSelectionFromCurrentSelection } from '../use-left-and-right-arrow';
import { createSelectionChangeDuplicateEndGuard, createSelectionChangeHandler, getInitialFormulaReferenceSelectionCount, getLastFormulaSelection, getSelectionsForFormulaRefUpdate, getSequenceNodeCharAtOffset, getSharedSelectionChangeDuplicateEndGuard, insertFormulaReferenceText, isFormulaReferenceAddingContext, isFormulaReferenceAddingTextContext, isSameFormulaSelection, prepareSelectionChangeContext, replaceFormulaControlSelection, shouldSkipFormulaReferenceUpdate } from '../use-sheet-selection-change';
@@ -568,6 +568,23 @@ describe('formula selection update helpers', () => {
});
describe('formula highlight helpers', () => {
it('does not copy stale paragraph metadata into a formula text replacement', () => {
const body = createFormulaHighlightBody('=F40', [
{ st: 0, ed: 1, ts: { fs: 11 } },
{ st: 1, ed: 4, ts: { fs: 11 } },
]);
expect(body).toEqual({
dataStream: '=F40',
textRuns: [
{ st: 0, ed: 1, ts: { fs: 11 } },
{ st: 1, ed: 4, ts: { fs: 11 } },
],
});
expect(body.paragraphs).toBeUndefined();
expect(body.sectionBreaks).toBeUndefined();
});
it('preserves incomplete formula editor text while applying token highlights', () => {
expect(getFormulaHighlightDataStream('=', [
'SUM(',
@@ -14,12 +14,12 @@
* limitations under the License.
*/
import type { IRange, ITextRange, ITextRun, Workbook } from '@univerjs/core';
import type { IDocumentBody, IRange, ITextRange, ITextRun, Workbook } from '@univerjs/core';
import type { Editor } from '@univerjs/docs-ui';
import type { ISequenceNode } from '@univerjs/engine-formula';
import type { ISelectionWithStyle, SheetsSelectionsService } from '@univerjs/sheets';
import type { INode } from './use-formula-token';
import { getBodySlice, ICommandService, IUniverInstanceService, ThemeService, UniverInstanceType } from '@univerjs/core';
import { ICommandService, IUniverInstanceService, ThemeService, UniverInstanceType } from '@univerjs/core';
import { ReplaceTextRunsCommand } from '@univerjs/docs-ui';
import { deserializeRangeWithSheet, sequenceNodeType } from '@univerjs/engine-formula';
import { IRenderManagerService } from '@univerjs/engine-render';
@@ -250,13 +250,11 @@ export function useDocHight(_leadingCharacter: string = '') {
return [];
}
const str = body.dataStream.slice(0, body.dataStream.length - 2);
const cloneBody = { dataStream: '', ...data.body };
if (!str.startsWith(_leadingCharacter)) return [];
if (sequenceNodes == null || sequenceNodes.length === 0) {
cloneBody.textRuns = [];
commandService.syncExecuteCommand(ReplaceTextRunsCommand.id, {
unitId: editorId,
body: getBodySlice(cloneBody, 0, cloneBody.dataStream.length - 2),
body: createFormulaHighlightBody(str, []),
});
return [];
} else {
@@ -268,14 +266,14 @@ export function useDocHight(_leadingCharacter: string = '') {
});
}
cloneBody.textRuns = [{ st: 0, ed: 1, ts: { fs: 11 } }, ...textRuns];
cloneBody.dataStream = getFormulaHighlightDataStream(_leadingCharacter, sequenceNodes, sourceText);
const highlightDataStream = getFormulaHighlightDataStream(_leadingCharacter, sequenceNodes, sourceText);
const highlightTextRuns = [{ st: 0, ed: 1, ts: { fs: 11 } }, ...textRuns];
let selections;
if (isNeedResetSelection) {
// Switching between uppercase and lowercase will trigger a reflow, causing the cursor to be misplaced. Let's refresh the cursor position here.
selections = editor.getSelectionRanges();
// After 'buildTextRuns' , the content changes, most of it is deleted, and the cursor position needs to be corrected
const maxOffset = cloneBody.dataStream.length - 2 + leadingCharacterLength;
const maxOffset = highlightDataStream.length - 2 + leadingCharacterLength;
selections.forEach((selection) => {
selection.startOffset = Math.max(0, Math.min(selection.startOffset, maxOffset));
selection.endOffset = Math.max(0, Math.min(selection.endOffset, maxOffset));
@@ -283,7 +281,7 @@ export function useDocHight(_leadingCharacter: string = '') {
}
commandService.syncExecuteCommand(ReplaceTextRunsCommand.id, {
unitId: editorId,
body: getBodySlice(cloneBody, 0, cloneBody.dataStream.length - 2),
body: createFormulaHighlightBody(highlightDataStream.slice(0, -2), highlightTextRuns),
textRanges: newSelections ?? selections,
});
return refSelections;
@@ -292,6 +290,15 @@ export function useDocHight(_leadingCharacter: string = '') {
return highlightDoc;
}
/**
* ReplaceTextRunsCommand shifts the editor's existing structural metadata when text changes.
* Its replacement body must therefore contain only inline formula data; carrying paragraphs
* or section breaks copied from the old snapshot would leave their indexes stale.
*/
export function createFormulaHighlightBody(dataStream: string, textRuns: ITextRun[]): IDocumentBody {
return { dataStream, textRuns };
}
export function getFormulaHighlightDataStream(leadingCharacter: string, sequenceNodes: Array<ISequenceNode | string>, sourceText?: string): string {
const text = sourceText ?? sequenceNodes.reduce((pre, cur) => {
if (typeof cur === 'string') {
@@ -35,7 +35,6 @@ import {
Injector,
IUniverInstanceService,
noop,
toDisposable,
UniverInstanceType,
VerticalAlign,
} from '@univerjs/core';
@@ -57,6 +56,7 @@ import { findIndexFromSequenceNodes, findRefSequenceIndex } from '../range-selec
import {
IFormulaEmbedInteractionBoundaryService,
IFormulaEmbedRuntimeFocusCoordinator,
registerFormulaEditorRuntimePortal,
resolveActiveFormulaEmbedRuntimeDomScope,
resolveFormulaEmbedRuntimeDomScope,
} from './formula-embed-integration.service';
@@ -147,118 +147,6 @@ export function syncCounterpartFormulaEditorSelection(
editorService.getEditor(syncEditorId)?.setSelectionRanges(selections, false);
}
export function registerFormulaEditorRuntimePortal(options: {
embedId: string;
editorId: string;
ownerDocument?: Document;
interactionBoundaryService?: IFormulaEmbedInteractionBoundaryService;
focusCoordinator?: IFormulaEmbedRuntimeFocusCoordinator;
}): IDisposable {
const ownerDocument = options.ownerDocument ?? (typeof document === 'undefined' ? undefined : document);
if (!ownerDocument) {
return toDisposable(() => {});
}
const collection = new DisposableCollection();
const view = ownerDocument.defaultView;
const frameHandles: number[] = [];
let observer: MutationObserver | undefined;
let portalRegistration: IDisposable | undefined;
let registeredPortalRoot: HTMLElement | null = null;
let disposed = false;
const tryRegister = () => {
if (disposed) {
return;
}
const portalRoot = resolveFormulaEditorPortalRoot(options.editorId, ownerDocument);
if (portalRoot === registeredPortalRoot) {
return;
}
portalRegistration?.dispose();
portalRegistration = undefined;
registeredPortalRoot = null;
if (!portalRoot) {
return;
}
const rootRegistration = new DisposableCollection();
registeredPortalRoot = portalRoot;
if (options.interactionBoundaryService) {
rootRegistration.add(options.interactionBoundaryService.registerOwnedElement(options.embedId, portalRoot));
const editorElement = ownerDocument.getElementById(`__editor_${options.editorId}`) as HTMLElement | null;
if (editorElement && editorElement !== portalRoot) {
rootRegistration.add(options.interactionBoundaryService.registerOwnedElement(options.embedId, editorElement));
}
}
if (options.focusCoordinator) {
rootRegistration.add(options.focusCoordinator.registerElement({
embedId: options.embedId,
role: 'child-editor',
element: portalRoot,
}));
const editorElement = ownerDocument.getElementById(`__editor_${options.editorId}`) as HTMLElement | null;
if (editorElement && editorElement !== portalRoot) {
rootRegistration.add(options.focusCoordinator.registerElement({
embedId: options.embedId,
role: 'child-editor',
element: editorElement,
}));
}
}
portalRegistration = rootRegistration;
};
const scheduleRetry = (remaining: number) => {
if (remaining <= 0 || !view?.requestAnimationFrame) {
return;
}
const handle = view.requestAnimationFrame(() => {
const index = frameHandles.indexOf(handle);
if (index >= 0) {
frameHandles.splice(index, 1);
}
tryRegister();
if (!registeredPortalRoot) {
scheduleRetry(remaining - 1);
}
});
frameHandles.push(handle);
};
tryRegister();
if (!registeredPortalRoot) {
scheduleRetry(2);
}
if (view?.MutationObserver && ownerDocument.body) {
observer = new view.MutationObserver(() => tryRegister());
observer.observe(ownerDocument.body, { childList: true, subtree: true });
}
collection.add(toDisposable(() => {
disposed = true;
frameHandles.forEach((handle) => view?.cancelAnimationFrame?.(handle));
frameHandles.length = 0;
observer?.disconnect();
observer = undefined;
portalRegistration?.dispose();
portalRegistration = undefined;
registeredPortalRoot = null;
}));
return collection;
}
function resolveFormulaEditorPortalRoot(editorId: string, ownerDocument: Document): HTMLElement | null {
return (ownerDocument.getElementById(`univer-doc-selection-container-${editorId}`) as HTMLElement | null)
?? (ownerDocument.getElementById(`__editor_${editorId}`) as HTMLElement | null);
}
export { getSelectionAfterLaggingFormulaInput } from './hooks/use-formula-selection';
export const FormulaEditor = forwardRef((props: IFormulaEditorProps, ref: Ref<IFormulaEditorRef>) => {
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import type { IDocumentBody } from '@univerjs/core';
import {
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
@@ -35,7 +36,39 @@ import {
MoveSelectionCommand,
MoveSelectionEnterAndTabCommand,
} from '../../../commands/commands/set-selection.command';
import { EditingRenderController } from '../editing.render-controller';
import { EditingRenderController, emptyBody } from '../editing.render-controller';
describe('emptyBody', () => {
it('keeps an empty text-run collection initialized', () => {
const body: IDocumentBody = { dataStream: 'value\r\n', textRuns: [] };
emptyBody(body);
expect(body.textRuns).toEqual([]);
});
it('keeps one inherited style when resetting the editor', () => {
const body: IDocumentBody = {
dataStream: 'value\r\n',
textRuns: [{ st: 0, ed: 5, ts: { fs: 11 } }],
};
emptyBody(body);
expect(body.textRuns).toEqual([{ st: 0, ed: 1, ts: { fs: 11 } }]);
});
it('removes an inherited style when style removal is requested', () => {
const body: IDocumentBody = {
dataStream: 'value\r\n',
textRuns: [{ st: 0, ed: 5, ts: { fs: 11 } }],
};
emptyBody(body, true);
expect(body.textRuns).toBeUndefined();
});
});
function createController() {
const worksheet = {
@@ -1039,10 +1039,12 @@ export function getCellStyleBySnapshot(snapshot: IDocumentData): Nullable<IStyle
return null;
}
function emptyBody(body: IDocumentBody, removeStyle = false) {
export function emptyBody(body: IDocumentBody, removeStyle = false) {
body.dataStream = DEFAULT_EMPTY_DOCUMENT_VALUE;
if (body.textRuns != null) {
// Keep an empty collection initialized so the first formula highlight can be
// applied without a Core TextX special case.
if (body.textRuns?.length) {
if (body.textRuns.length === 1 && !removeStyle) {
body.textRuns[0].st = 0;
body.textRuns[0].ed = 1;