fix(docs): improve hyperlink styling and popup interactions (#7530)

This commit is contained in:
Univer
2026-08-17 00:38:46 +08:00
committed by GitHub
parent f24306c359
commit 07d42e36e1
16 changed files with 352 additions and 48 deletions
@@ -46,6 +46,11 @@ function createDocData(): IDocumentData {
properties: {
url: 'https://before.invalid',
},
}, {
startIndex: 0,
endIndex: 1,
rangeId: 'custom-1',
rangeType: CustomRangeType.CUSTOM,
}],
},
documentStyle: {
@@ -119,7 +124,7 @@ describe('docs-hyper-link-ui integration', () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_add([{
startOffset: 0,
startOffset: 2,
endOffset: 5,
collapsed: false,
isActive: true,
@@ -216,6 +221,7 @@ describe('docs-hyper-link-ui integration', () => {
segmentPage: undefined,
}, expect.objectContaining({
componentKey: 'univer.doc.link-info-popup',
offset: [0, 10],
}), 'test-doc');
expect(await commandService.executeCommand(SetTextSelectionsOperation.id, {
@@ -225,8 +231,8 @@ describe('docs-hyper-link-ui integration', () => {
isEditing: false,
style: null,
ranges: [{
startOffset: 0,
endOffset: 0,
startOffset: 1,
endOffset: 1,
collapsed: true,
segmentId: '',
}],
@@ -16,7 +16,7 @@
import type { DocumentDataModel } from '@univerjs/core';
import type { ISetTextSelectionsOperationParams } from '@univerjs/docs';
import { Disposable, ICommandService, Inject, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { CustomRangeType, Disposable, ICommandService, Inject, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { SetTextSelectionsOperation } from '@univerjs/docs';
import { DocHyperLinkPopupService } from '../services/hyper-link-popup.service';
@@ -40,23 +40,16 @@ export class DocHyperLinkSelectionController extends Disposable {
const doc = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const primary = ranges[0];
if (primary && doc) {
const { startOffset, endOffset, collapsed, segmentPage } = primary;
const customRanges = doc.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.customRanges;
if (collapsed) {
// cursor
const index = customRanges?.findIndex((value) => (value.startIndex) < startOffset && value.endIndex > endOffset - 1) ?? -1;
if (index > -1) {
const customRange = customRanges![index];
this._docHyperLinkService.showInfoPopup({ unitId, linkId: customRange.rangeId, segmentId, segmentPage, startIndex: customRange.startIndex, endIndex: customRange.endIndex });
return;
}
} else {
// range
const range = customRanges?.find((value) => value.startIndex <= startOffset && value.endIndex >= (endOffset - 1));
if (range) {
return;
}
if (primary?.collapsed && doc) {
const { startOffset, endOffset, segmentPage } = primary;
const customRange = doc.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.customRanges?.find((value) => (
value.rangeType === CustomRangeType.HYPERLINK &&
value.startIndex < startOffset &&
value.endIndex > endOffset - 1
));
if (customRange) {
this._docHyperLinkService.showInfoPopup({ unitId, linkId: customRange.rangeId, segmentId, segmentPage, startIndex: customRange.startIndex, endIndex: customRange.endIndex });
return;
}
}
@@ -14,14 +14,17 @@
* limitations under the License.
*/
import { CustomRangeType } from '@univerjs/core';
import { config, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { ClickDocHyperLinkOperation } from '../../../commands/operations/popup.operation';
import { DocHyperLinkEventRenderController } from '../hyper-link-event.render-controller';
describe('DocHyperLinkEventRenderController', () => {
it('ignores hover ranges when the current selection has no text ranges', async () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const onUnhandledError = vi.fn();
const previousUnhandledError = config.onUnhandledError;
const commandService = {
@@ -32,7 +35,7 @@ describe('DocHyperLinkEventRenderController', () => {
try {
const controller = new DocHyperLinkEventRenderController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$ } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
{ showing: false } as never,
{ getSkeleton: vi.fn() } as never,
@@ -50,4 +53,77 @@ describe('DocHyperLinkEventRenderController', () => {
config.onUnhandledError = previousUnhandledError;
}
});
it('opens document hyperlinks only with Ctrl or Command click', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const commandService = {
executeCommand: vi.fn(),
};
const controller = new DocHyperLinkEventRenderController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
{ showing: false } as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [] } as never
);
const clickedRange = {
range: {
rangeId: 'link-1',
rangeType: CustomRangeType.HYPERLINK,
},
segmentId: 'header-1',
segmentPageIndex: 0,
rects: [],
};
clickCustomRanges$.next({ ...clickedRange, ctrlKey: false, metaKey: false });
expect(commandService.executeCommand).not.toHaveBeenCalled();
clickCustomRanges$.next({ ...clickedRange, ctrlKey: true, metaKey: false });
expect(commandService.executeCommand).toHaveBeenLastCalledWith(ClickDocHyperLinkOperation.id, {
unitId: 'doc-unit',
linkId: 'link-1',
segmentId: 'header-1',
});
commandService.executeCommand.mockClear();
clickCustomRanges$.next({ ...clickedRange, ctrlKey: false, metaKey: true });
expect(commandService.executeCommand).toHaveBeenCalledWith(ClickDocHyperLinkOperation.id, {
unitId: 'doc-unit',
linkId: 'link-1',
segmentId: 'header-1',
});
controller.dispose();
});
it('hides the hyperlink popup immediately when pointer down is outside a hyperlink', () => {
const hoverCustomRanges$ = new Subject<unknown[]>();
const clickCustomRanges$ = new Subject<unknown>();
const pointerDownCustomRanges$ = new Subject<unknown[]>();
const commandService = {
executeCommand: vi.fn(),
};
const hyperLinkPopupService = {
showing: { linkId: 'link-1' },
hideInfoPopupOnPointerDown: vi.fn(),
};
const controller = new DocHyperLinkEventRenderController(
{ unitId: 'doc-unit' } as never,
{ hoverCustomRanges$, clickCustomRanges$, pointerDownCustomRanges$ } as never,
commandService as never,
hyperLinkPopupService as never,
{ getSkeleton: vi.fn() } as never,
{ getTextRanges: () => [] } as never
);
pointerDownCustomRanges$.next([]);
expect(hyperLinkPopupService.hideInfoPopupOnPointerDown).toHaveBeenCalledTimes(1);
controller.dispose();
});
});
@@ -41,6 +41,7 @@ export class DocHyperLinkEventRenderController extends Disposable implements IRe
return;
}
this._initPointerDown();
this._initHover();
this._initClick();
}
@@ -53,6 +54,16 @@ export class DocHyperLinkEventRenderController extends Disposable implements IRe
}
}
private _initPointerDown() {
this.disposeWithMe(
this._docEventManagerService.pointerDownCustomRanges$.subscribe((ranges) => {
if (!ranges.some((range) => range.range.rangeType === CustomRangeType.HYPERLINK)) {
this._hyperLinkPopupService.hideInfoPopupOnPointerDown();
}
})
);
}
private _initHover() {
this.disposeWithMe(
this._docEventManagerService.hoverCustomRanges$.subscribe((ranges) => {
@@ -88,16 +99,18 @@ export class DocHyperLinkEventRenderController extends Disposable implements IRe
this.disposeWithMe(
this._docEventManagerService.clickCustomRanges$.subscribe((range) => {
const link = range.range;
if (link) {
this._commandService.executeCommand(
ClickDocHyperLinkOperation.id,
{
unitId: this._context.unitId,
linkId: link.rangeId,
segmentId: range.segmentId,
}
);
if (link.rangeType !== CustomRangeType.HYPERLINK || (!range.ctrlKey && !range.metaKey)) {
return;
}
this._commandService.executeCommand(
ClickDocHyperLinkOperation.id,
{
unitId: this._context.unitId,
linkId: link.rangeId,
segmentId: range.segmentId,
}
);
})
);
}
@@ -85,12 +85,17 @@ describe('DocHyperLinkPopupService', () => {
service.showEditPopup('doc-1', link);
expect(service.editing).toEqual(link);
expect(refreshes.at(-1)).toMatchObject({ docRanges: [{ startOffset: 4, endOffset: 9 }] });
expect(attached).toMatchObject([{ range: { startOffset: 4, endOffset: 9, collapsed: false }, unitId: 'doc-1' }]);
expect(attached).toMatchObject([{
range: { startOffset: 4, endOffset: 9, collapsed: false },
popup: { offset: [0, 10] },
unitId: 'doc-1',
}]);
service.showInfoPopup(link);
expect(service.showing).toEqual(link);
expect(attached.at(-1)).toMatchObject({
range: { startOffset: 4, endOffset: 9, collapsed: false },
popup: { offset: [0, 10] },
unitId: 'doc-1',
});
@@ -184,4 +189,28 @@ describe('DocHyperLinkPopupService', () => {
expect(service.showing).toBeNull();
expect(disposed).toEqual([1]);
});
it('prevents a stale selection refresh from restoring a popup closed on pointer down', async () => {
const { service, attached, disposed } = createService();
const link = {
unitId: 'doc-1',
linkId: 'link-1',
startIndex: 4,
endIndex: 8,
};
service.showInfoPopup(link);
service.hideInfoPopupOnPointerDown();
service.showInfoPopup(link);
expect(service.showing).toBeNull();
expect(attached).toHaveLength(1);
expect(disposed).toEqual([1]);
await new Promise((resolve) => setTimeout(resolve, 0));
service.showInfoPopup(link);
expect(service.showing).toEqual(link);
expect(attached).toHaveLength(2);
});
});
@@ -39,6 +39,8 @@ export class DocHyperLinkPopupService extends Disposable {
private _editPopup: Nullable<IDisposable> = null;
private _infoPopup: Nullable<IDisposable> = null;
private _infoPopupSuppressed = false;
private _infoPopupSuppressionTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
@Inject(DocCanvasPopManagerService) private readonly _docCanvasPopupManagerService: DocCanvasPopManagerService,
@@ -48,6 +50,9 @@ export class DocHyperLinkPopupService extends Disposable {
super();
this.disposeWithMe(() => {
if (this._infoPopupSuppressionTimer !== null) {
clearTimeout(this._infoPopupSuppressionTimer);
}
this._editingLink$.complete();
this._showingLink$.complete();
});
@@ -91,6 +96,7 @@ export class DocHyperLinkPopupService extends Disposable {
{
componentKey: DocHyperLinkEdit.componentKey,
direction: 'bottom',
offset: [0, 10],
},
unitId
);
@@ -106,6 +112,10 @@ export class DocHyperLinkPopupService extends Disposable {
}
showInfoPopup(info: ILinkInfo): Nullable<IDisposable> {
if (this._infoPopupSuppressed) {
return;
}
const { linkId, unitId, segmentId, segmentPage, startIndex, endIndex } = info;
if (
this.showing?.linkId === linkId &&
@@ -139,6 +149,7 @@ export class DocHyperLinkPopupService extends Disposable {
componentKey: DocLinkPopup.componentKey,
direction: 'top-center',
multipleDirection: 'top',
offset: [0, 10],
onClickOutside: () => {
this.hideInfoPopup();
},
@@ -152,4 +163,17 @@ export class DocHyperLinkPopupService extends Disposable {
this._showingLink$.next(null);
this._infoPopup?.dispose();
}
hideInfoPopupOnPointerDown() {
this._infoPopupSuppressed = true;
if (this._infoPopupSuppressionTimer !== null) {
clearTimeout(this._infoPopupSuppressionTimer);
}
this.hideInfoPopup();
this._infoPopupSuppressionTimer = setTimeout(() => {
this._infoPopupSuppressed = false;
this._infoPopupSuppressionTimer = null;
}, 0);
}
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { EventState, EventSubject, Injector, PresetListType } from '@univerjs/core';
import { CustomRangeType, EventState, EventSubject, Injector, PresetListType } from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { setDocsTableRenderViewportProvider, TRANSFORM_CHANGE_OBSERVABLE_TYPE } from '@univerjs/engine-render';
import { afterEach, describe, expect, it } from 'vitest';
@@ -583,6 +583,72 @@ describe('DocEventManagerService list marker helpers', () => {
service.dispose();
});
it('includes Ctrl and Command state in custom-range click events', () => {
const { context, documents, service } = createDocEventManagerService();
const target = {};
const clicks: unknown[] = [];
const pointerDowns: unknown[] = [];
const internalService = service as unknown as {
_customRangeDirty: boolean;
_customRangeBounds: Array<{
customRange: {
rangeId: string;
startIndex: number;
endIndex: number;
rangeType: CustomRangeType;
};
rects: Array<{ top: number; bottom: number; left: number; right: number }>;
segmentPageIndex: number;
}>;
};
internalService._customRangeDirty = false;
internalService._customRangeBounds = [{
customRange: {
rangeId: 'link-1',
startIndex: 12,
endIndex: 15,
rangeType: CustomRangeType.HYPERLINK,
},
rects: [{ top: 40, bottom: 60, left: 20, right: 80 }],
segmentPageIndex: -1,
}];
const subscription = service.clickCustomRanges$.subscribe((event) => clicks.push(event));
const pointerDownSubscription = service.pointerDownCustomRanges$.subscribe((ranges) => pointerDowns.push(ranges));
documents.onPointerDown$.emitEvent({
button: 0,
ctrlKey: false,
metaKey: true,
offsetX: 25,
offsetY: 45,
target,
timeStamp: 100,
});
expect(pointerDowns).toEqual([[
expect.objectContaining({ range: expect.objectContaining({ rangeId: 'link-1' }) }),
]]);
expect(clicks).toEqual([]);
context.scene.onPointerUp$.emitEvent({
button: 0,
offsetX: 25,
offsetY: 45,
target,
timeStamp: 180,
});
expect(clicks).toEqual([expect.objectContaining({
ctrlKey: false,
metaKey: true,
range: expect.objectContaining({ rangeId: 'link-1' }),
})]);
subscription.unsubscribe();
pointerDownSubscription.unsubscribe();
service.dispose();
});
it('publishes paragraph and table hover state from pointer movement', async () => {
const { context, service } = createDocEventManagerService();
const hoveredParagraphs: Array<number | null> = [];
@@ -56,11 +56,13 @@ class InertDocSelectionRenderService {
class RecordingDocCanvasPopManagerService {
readonly ranges: string[] = [];
readonly directions: string[] = [];
readonly offsets: Array<[number, number] | undefined> = [];
disposedCount = 0;
attachPopupToRange(range: { startOffset: number; endOffset: number }, options: { direction: string }) {
attachPopupToRange(range: { startOffset: number; endOffset: number }, options: { direction: string; offset?: [number, number] }) {
this.ranges.push(`${range.startOffset}:${range.endOffset}`);
this.directions.push(options.direction);
this.offsets.push(options.offset);
return {
dispose: () => {
@@ -187,6 +189,7 @@ describe('DocFloatMenuService', () => {
const popupService = injector.get(DocCanvasPopManagerService) as unknown as RecordingDocCanvasPopManagerService;
expect(popupService.ranges).toEqual(['0:5']);
expect(popupService.offsets).toEqual([[0, 10]]);
expect(service.floatMenu).toMatchObject({ start: 0, end: 5 });
const selectionRenderService = injector.get(DocSelectionRenderService) as unknown as ActiveDocSelectionRenderService;
@@ -30,7 +30,7 @@ import type {
import { Disposable, fromEventSubject, Inject, PresetListType } from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { CURSOR_TYPE, documentSkeletonLineIterator, documentSkeletonTableIterator, getDocsTableRenderViewport, getTableIdAndSliceIndex, TRANSFORM_CHANGE_OBSERVABLE_TYPE } from '@univerjs/engine-render';
import { BehaviorSubject, distinctUntilChanged, filter, map, Subject, switchMap, take, throttleTime } from 'rxjs';
import { BehaviorSubject, distinctUntilChanged, filter, map, Subject, switchMap, take, tap, throttleTime } from 'rxjs';
import { DOC_VERTICAL_PADDING } from '../types/const/padding';
import { transformOffset2Bound } from './doc-popup-manager.service';
import { NodePositionConvertToCursor } from './selection/convert-text-range';
@@ -277,6 +277,11 @@ interface ICustomRangeActive {
rects: IBoundRectNoAngle[];
}
interface ICustomRangeClickEvent extends ICustomRangeActive {
ctrlKey: boolean;
metaKey: boolean;
}
interface IBulletActive {
paragraph: IParagraph;
segmentId?: string;
@@ -394,9 +399,12 @@ export class DocEventManagerService extends Disposable implements IRenderModule
private readonly _hoverCustomRanges$ = new BehaviorSubject<ICustomRangeActive[]>([]);
readonly hoverCustomRanges$ = this._hoverCustomRanges$.pipe(distinctUntilChanged((pre, aft) => pre.length === aft.length && pre.every((item, i) => aft[i].range.rangeId === item.range.rangeId && aft[i].segmentId === item.segmentId && aft[i].segmentPageIndex === item.segmentPageIndex && aft[i].range.startIndex === item.range.startIndex)));
private readonly _clickCustomRanges$ = new Subject<ICustomRangeActive>();
private readonly _clickCustomRanges$ = new Subject<ICustomRangeClickEvent>();
readonly clickCustomRanges$ = this._clickCustomRanges$.asObservable();
private readonly _pointerDownCustomRanges$ = new Subject<ICustomRangeActive[]>();
readonly pointerDownCustomRanges$ = this._pointerDownCustomRanges$.asObservable();
private readonly _hoverBullet$ = new Subject<Nullable<IBulletActive>>();
readonly hoverBullet$ = this._hoverBullet$.pipe(distinctUntilChanged((pre, aft) => pre?.paragraph.startIndex === aft?.paragraph.startIndex && pre?.segmentId === aft?.segmentId && pre?.segmentPageIndex === aft?.segmentPageIndex));
@@ -479,6 +487,7 @@ export class DocEventManagerService extends Disposable implements IRenderModule
override dispose() {
this._hoverCustomRanges$.complete();
this._clickCustomRanges$.complete();
this._pointerDownCustomRanges$.complete();
super.dispose();
}
@@ -557,6 +566,14 @@ export class DocEventManagerService extends Disposable implements IRenderModule
const onPointerDown$ = fromEventSubject(this._context.mainComponent!.onPointerDown$);
const onPointerUp$ = fromEventSubject(this._context.scene!.onPointerUp$);
this.disposeWithMe(onPointerDown$.pipe(
tap((down) => {
if (down.button !== 0) {
return;
}
const point = transformOffset2Bound(down.offsetX, down.offsetY, this._context.scene);
this._pointerDownCustomRanges$.next(this._calcActiveRanges(point));
}),
switchMap((down) => onPointerUp$.pipe(take(1), map((up) => ({ down, up })))),
filter(({ down, up }) => down.target === up.target && (down.button === 2 || up.timeStamp - down.timeStamp < 300))
).subscribe(({ down }) => {
@@ -577,7 +594,11 @@ export class DocEventManagerService extends Disposable implements IRenderModule
const ranges = this._calcActiveRanges(point);
if (ranges.length) {
this._clickCustomRanges$.next(ranges.pop()!);
this._clickCustomRanges$.next({
...ranges.pop()!,
ctrlKey: !!down.ctrlKey,
metaKey: !!down.metaKey,
});
}
const bullet = this._calcActiveBullet(point);
@@ -152,7 +152,7 @@ export class DocFloatMenuService extends Disposable implements IRenderModule {
{
componentKey: FLOAT_MENU_COMPONENT_KEY,
direction: range.direction === 'backward' || isInSameLine((range as ITextRangeWithStyle).startNodePosition, (range as ITextRangeWithStyle).endNodePosition) ? 'top-center' : 'bottom-center',
offset: [0, 4],
offset: [0, 10],
},
unitId
),
@@ -19,7 +19,7 @@ import type { IBoundRectNoAngle } from '../../../basics';
import type { IDocumentSkeletonGlyph } from '../../../basics/i-document-skeleton-cached';
import type { UniverRenderingContext } from '../../../context';
import type { IDrawInfo } from '../../extension';
import { BaselineOffset, getColorStyle } from '@univerjs/core';
import { BaselineOffset } from '@univerjs/core';
import { GlyphType } from '../../../basics';
import { cjk } from '../../../basics/cjk-regexp';
import { COLOR_BLACK_RGB } from '../../../basics/const';
@@ -28,6 +28,7 @@ import { Vector2 } from '../../../basics/vector2';
import { CheckboxShape, isCheckboxGlyph } from '../../../shape/checkbox';
import { DocumentsSpanAndLineExtensionRegistry } from '../../extension';
import { docExtension } from '../doc-extension';
import { getColorStyleForCanvas } from '../layout/style/color';
const UNIQUE_KEY = 'DefaultDocsFontAndBaseLineExtension';
@@ -99,7 +100,7 @@ export class FontAndBaseLine extends docExtension {
}
const { cl: colorStyle, va: baselineOffset, textFill, glow, outerShadow } = textStyle;
const fontColor = getColorStyle(colorStyle) || COLOR_BLACK_RGB;
const fontColor = getColorStyleForCanvas(colorStyle) || COLOR_BLACK_RGB;
if (baselineOffset === BaselineOffset.SUPERSCRIPT) {
spanPointWithFont.y += -bBox.spo;
@@ -19,13 +19,14 @@ import type { IDocumentSkeletonGlyph } from '../../../basics/i-document-skeleton
import type { IBoundRectNoAngle } from '../../../basics/vector2';
import type { UniverRenderingContext } from '../../../context';
import type { IDrawInfo } from '../../extension';
import { BaselineOffset, BooleanNumber, getColorStyle, TextDecoration } from '@univerjs/core';
import { BaselineOffset, BooleanNumber, TextDecoration } from '@univerjs/core';
import { COLOR_BLACK_RGB, DEFAULT_OFFSET_SPACING } from '../../../basics/const';
import { calculateRectRotate } from '../../../basics/draw';
import { degToRad, getScale } from '../../../basics/tools';
import { Vector2 } from '../../../basics/vector2';
import { DocumentsSpanAndLineExtensionRegistry } from '../../extension';
import { docExtension } from '../doc-extension';
import { getColorStyleForCanvas } from '../layout/style/color';
const UNIQUE_KEY = 'DefaultDocsLineExtension';
@@ -140,7 +141,7 @@ export class Line extends docExtension {
// ctx.translateWithPrecision(FIX_ONE_PIXEL_BLUR_OFFSET, FIX_ONE_PIXEL_BLUR_OFFSET);
const color =
(c === BooleanNumber.TRUE ? getColorStyle(glyph.ts?.cl) : getColorStyle(colorStyle)) || COLOR_BLACK_RGB;
(c === BooleanNumber.TRUE ? getColorStyleForCanvas(glyph.ts?.cl) : getColorStyleForCanvas(colorStyle)) || COLOR_BLACK_RGB;
ctx.strokeStyle = color;
this._setLineType(ctx, lineType ?? TextDecoration.SINGLE, lineWidth);
@@ -16,23 +16,28 @@
import { BooleanNumber, CustomRangeType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getColorStyleForCanvas } from '../color';
import { getCustomRangeStyle } from '../custom-range';
describe('custom range style', () => {
it('renders active hyperlink-like ranges as blue underlined text', () => {
it('renders hyperlink-like ranges with the theme blue token and an underline', () => {
expect(getCustomRangeStyle({ rangeType: CustomRangeType.HYPERLINK } as never)).toEqual({
ul: { s: BooleanNumber.TRUE },
cl: { rgb: '#274fee' },
cl: { rgb: 'blue.600' },
});
expect(getCustomRangeStyle({ rangeType: CustomRangeType.HYPERLINK, active: false } as never)).toEqual({
ul: { s: BooleanNumber.TRUE },
cl: { rgb: 'blue.600' },
});
expect(getCustomRangeStyle({ rangeType: CustomRangeType.MENTION, active: true } as never)).toEqual({
ul: { s: BooleanNumber.TRUE },
cl: { rgb: '#274fee' },
cl: { rgb: 'blue.600' },
});
});
it('keeps the link color but drops underline for inactive custom ranges', () => {
expect(getCustomRangeStyle({ rangeType: CustomRangeType.CUSTOM, active: false } as never)).toEqual({
cl: { rgb: '#274fee' },
cl: { rgb: 'blue.600' },
});
});
@@ -48,4 +53,9 @@ describe('custom range style', () => {
it('does not style unsupported custom range types', () => {
expect(getCustomRangeStyle({ rangeType: CustomRangeType.COMMENT } as never)).toBeNull();
});
it('keeps palette tokens for the canvas theme color service', () => {
expect(getColorStyleForCanvas({ rgb: 'blue.600' })).toBe('blue.600');
expect(getColorStyleForCanvas({ rgb: 'rgb(39, 79, 238)' })).toBe('#274fee');
});
});
@@ -0,0 +1,29 @@
/**
* 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 { IColorStyle, Nullable } from '@univerjs/core';
import { getColorStyle } from '@univerjs/core';
const THEME_COLOR_TOKEN_PATTERN = /^[a-z][a-z0-9-]*\.\d+$/i;
export function getColorStyleForCanvas(color: Nullable<IColorStyle>): Nullable<string> {
const rgb = color?.rgb;
if (rgb && THEME_COLOR_TOKEN_PATTERN.test(rgb)) {
return rgb;
}
return getColorStyle(color);
}
@@ -17,6 +17,8 @@
import type { ICustomRangeForInterceptor, ITextStyle, Nullable } from '@univerjs/core';
import { BooleanNumber, CustomRangeType } from '@univerjs/core';
const CUSTOM_RANGE_COLOR_TOKEN = 'blue.600';
export function getCustomRangeStyle(customRange: ICustomRangeForInterceptor): Nullable<ITextStyle> {
if (
customRange.rangeType === CustomRangeType.HYPERLINK ||
@@ -24,9 +26,10 @@ export function getCustomRangeStyle(customRange: ICustomRangeForInterceptor): Nu
customRange.rangeType === CustomRangeType.CUSTOM
) {
const preserveTextColor = customRange.properties?.textColorMode === 'text';
const showUnderline = customRange.rangeType === CustomRangeType.HYPERLINK || (customRange.active ?? true);
return {
...(customRange.active ?? true) ? { ul: { s: BooleanNumber.TRUE } } : null,
...preserveTextColor ? null : { cl: { rgb: '#274fee' } },
...showUnderline ? { ul: { s: BooleanNumber.TRUE } } : null,
...preserveTextColor ? null : { cl: { rgb: CUSTOM_RANGE_COLOR_TOKEN } },
};
}
@@ -20,6 +20,7 @@ import {
BooleanNumber,
BorderStyleTypes,
createSheetGapTestConfig,
CustomRangeType,
HorizontalAlign,
ILogService,
@@ -52,6 +53,7 @@ import {
SpreadsheetSkeleton,
} from '../sheet.render-skeleton';
import { Spreadsheet } from '../spreadsheet';
import { createDocumentModelWithStyle } from '../util';
const workbookDataFactory = (): IWorkbookData => ({
id: 'sheet-render-workbook',
@@ -283,6 +285,33 @@ describe('spreadsheet integration', () => {
expect(document.body?.textRuns?.[0].ts?.fs).toBe(20);
});
it('applies document hyperlink styling to rich-text cells', () => {
const { skeleton, workbook } = fixture;
const worksheet = workbook.getActiveSheet()!;
const document = createDocumentModelWithStyle('Univer', {}).getSnapshot();
document.body!.customRanges = [{
startIndex: 0,
endIndex: 5,
rangeId: 'sheet-link',
rangeType: CustomRangeType.HYPERLINK,
}];
const cell = { p: document };
const style = worksheet.getComposedCellStyleByCellData(0, 6, cell)!;
skeleton._setFontStylesCache(0, 6, cell, style);
const pages = skeleton.getFont(0, 6)!.documentSkeleton!.getSkeletonData()!.pages;
const sections = pages.flatMap((page) => page.sections);
const columns = sections.flatMap((section) => section.columns);
const lines = columns.flatMap((column) => column.lines);
const divides = lines.flatMap((line) => line.divides);
const glyphs = divides.flatMap((divide) => divide.glyphGroup);
const linkGlyph = glyphs.find((glyph) => glyph.content === 'U');
expect(linkGlyph?.ts?.cl?.rgb).toBe('blue.600');
expect(linkGlyph?.ts?.ul?.s).toBe(BooleanNumber.TRUE);
});
it('applies shrink to fit while building the font cache', () => {
const { skeleton, workbook } = fixture;
const worksheet = workbook.getActiveSheet()!;