mirror of
https://github.com/dream-num/univer.git
synced 2026-09-19 02:18:42 +08:00
fix(sheets-formula): preserve formula reference highlights (#7235)
This commit is contained in:
+115
-1
@@ -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);
|
||||
|
||||
+19
-2
@@ -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>) => {
|
||||
|
||||
+34
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user