mirror of
https://github.com/dream-num/univer.git
synced 2026-08-30 17:21:11 +08:00
fix(docs): improve hyperlink styling and popup interactions (#7530)
This commit is contained in:
@@ -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: '',
|
||||
}],
|
||||
|
||||
+11
-18
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+77
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+22
-9
@@ -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,
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
+30
-1
@@ -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);
|
||||
|
||||
+14
-4
@@ -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 } },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -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()!;
|
||||
|
||||
Reference in New Issue
Block a user