Skip to content

Commit

Permalink
feat(ui): change floating position (#2698)
Browse files Browse the repository at this point in the history
  • Loading branch information
wzhudev authored Jul 8, 2024
1 parent d523db9 commit f2fbd3b
Show file tree
Hide file tree
Showing 9 changed files with 263 additions and 21 deletions.
18 changes: 6 additions & 12 deletions packages/design/src/components/popup/RectPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function calcPopupPosition(layout: IPopupLayoutInfo): { top: number; left: numbe
if (direction === 'vertical' || direction === 'top' || direction === 'bottom') {
const { left: startX, top: startY, right: endX, bottom: endY } = position;
const verticalStyle = direction === 'top'
// const verticalStyle = ((endY + height) > containerHeight || direction === 'top')
// const verticalStyle = ((endY + height) > containerHeight || direction === 'top')
? { top: Math.max(startY - height, PUSHING_MINIMUM_GAP) }
: { top: Math.min(endY, containerHeight - height - PUSHING_MINIMUM_GAP) };

Expand Down Expand Up @@ -108,10 +108,9 @@ function RectPopup(props: IRectPopupProps) {
if (!nodeRef.current) return;

const { clientWidth, clientHeight } = nodeRef.current;
const parent = nodeRef.current.parentElement;
if (!parent) return;
const innerWidth = window.innerWidth;
const innerHeight = window.innerHeight;

const { clientWidth: innerWidth, clientHeight: innerHeight } = parent;
setPosition(calcPopupPosition(
{
position: anchorRect,
Expand Down Expand Up @@ -157,21 +156,16 @@ function RectPopup(props: IRectPopupProps) {
clickOtherFn(e);
};

window.addEventListener('click', handleClickOther);

return () => {
window.removeEventListener('click', handleClickOther);
};
window.addEventListener('pointerdown', handleClickOther);
return () => window.removeEventListener('pointerdown', handleClickOther);
}, [anchorRect, anchorRect.bottom, anchorRect.left, anchorRect.right, anchorRect.top, clickOtherFn, excludeOutside]);

return (
<section
ref={nodeRef}
style={style}
className={styles.popupAbsolute}
onClick={(e) => {
e.stopPropagation();
}}
onPointerDown={(e) => e.stopPropagation()}
>
<RectPopupContext.Provider value={anchorRect}>
{children}
Expand Down
2 changes: 2 additions & 0 deletions packages/engine-render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
"rxjs": ">=7.0.0"
},
"dependencies": {
"@floating-ui/dom": "^1.6.7",
"@floating-ui/utils": "^0.2.4",
"cjk-regex": "^3.1.0",
"franc-min": "^6.2.0",
"opentype.js": "^1.3.4"
Expand Down
20 changes: 19 additions & 1 deletion packages/engine-render/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import type { Nullable } from '@univerjs/core';
import { toDisposable } from '@univerjs/core';

import { Subject } from 'rxjs';
import { Observable, shareReplay, Subject } from 'rxjs';
import type { CURSOR_TYPE } from './basics/const';
import type { IKeyboardEvent, IPointerEvent } from './basics/i-events';
import { DeviceType, PointerInput } from './basics/i-events';
Expand All @@ -27,15 +27,33 @@ import { getPointerPrefix, getSizeForDom, IsSafari, requestNewFrame } from './ba
import { Canvas, CanvasRenderMode } from './canvas';
import type { Scene } from './scene';
import { ThinEngine } from './thin-engine';
import { observeClientRect } from './floating/util';

export class Engine extends ThinEngine<Scene> {
renderEvenInBackground = true;

private readonly _beginFrame$ = new Subject<void>();
readonly beginFrame$ = this._beginFrame$.asObservable();

private readonly _endFrame$ = new Subject<void>();
readonly endFrame$ = this._endFrame$.asObservable();

private _rect$: Nullable<Observable<void>> = null;
public get clientRect$(): Observable<void> {
return this._rect$ || (this._rect$ = new Observable((subscriber) => {
if (!this._container) {
throw new Error('[Engine]: cannot subscribe to rect changes when container is not set!');
}

const sub = observeClientRect(this._container).subscribe(() => subscriber.next());

return () => {
sub.unsubscribe();
this._rect$ = null;
};
})).pipe(shareReplay(1));
}

private _container: Nullable<HTMLElement>;

private _canvas: Nullable<Canvas>;
Expand Down
187 changes: 187 additions & 0 deletions packages/engine-render/src/floating/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* 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 { getOverflowAncestors } from '@floating-ui/dom';
import { floor, max, min } from '@floating-ui/utils';
import { getDocumentElement } from '@floating-ui/utils/dom';
import { Observable } from 'rxjs';

export function observeClientRect(containerElement: HTMLElement): Observable<void> {
return new Observable<void>((observer) => {
const disposable = autoClientRect(containerElement, () => observer.next());
return () => disposable();
});
}

/// The following methods are copied from floating-ui's `autoUpdate` function. Though it does not have the floating
/// element but only reports that client rect of the reference element changes.

// https://samthor.au/2021/observing-dom/
function observeMove(element: Element, onMove: () => void) {
let io: IntersectionObserver | null = null;
let timeoutId: NodeJS.Timeout;

const root = getDocumentElement(element);

function cleanup() {
clearTimeout(timeoutId);
io?.disconnect();
io = null;
}

function refresh(skip = false, threshold = 1) {
cleanup();

const { left, top, width, height } = element.getBoundingClientRect();

if (!skip) {
onMove();
}

if (!width || !height) {
return;
}

const insetTop = floor(top);
const insetRight = floor(root.clientWidth - (left + width));
const insetBottom = floor(root.clientHeight - (top + height));
const insetLeft = floor(left);
const rootMargin = `${-insetTop}px ${-insetRight}px ${-insetBottom}px ${-insetLeft}px`;

const options = {
rootMargin,
threshold: max(0, min(1, threshold)) || 1,
};

let isFirstUpdate = true;

function handleObserve(entries: IntersectionObserverEntry[]) {
const ratio = entries[0].intersectionRatio;

if (ratio !== threshold) {
if (!isFirstUpdate) {
return refresh();
}

if (!ratio) {
// If the reference is clipped, the ratio is 0. Throttle the refresh
// to prevent an infinite loop of updates.
timeoutId = setTimeout(() => {
refresh(false, 1e-7);
}, 1000);
} else {
refresh(false, ratio);
}
}

isFirstUpdate = false;
}

// Older browsers don't support a `document` as the root and will throw an
// error.
try {
io = new IntersectionObserver(handleObserve, {
...options,
// Handle <iframe>s
root: root.ownerDocument,
});
} catch (e) {
io = new IntersectionObserver(handleObserve, options);
}

io.observe(element);
}

refresh(true);

return cleanup;
}

// This implementation is very simple compared to the original implementation in floating-ui.
// Maybe some bugs.
function getBoundingClientRect(reference: Element) {
return reference.getBoundingClientRect();
}

function autoClientRect(
reference: Element,
update: () => void
) {
const ancestorScroll = true;
const ancestorResize = true;
const layoutShift = true;
const animationFrame = false;

const referenceEl = reference;

const ancestors =
ancestorScroll || ancestorResize
? [
...(referenceEl ? getOverflowAncestors(referenceEl) : []),
]
: [];

ancestors.forEach((ancestor) => {
ancestorScroll &&
ancestor.addEventListener('scroll', update, { passive: true });
ancestorResize && ancestor.addEventListener('resize', update);
});

const cleanupIo =
referenceEl && layoutShift ? observeMove(referenceEl, update) : null;

let resizeObserver: ResizeObserver | null = null;

let frameId: number;
let prevRefRect = animationFrame ? getBoundingClientRect(reference) : null;

if (animationFrame) {
frameLoop();
}

function frameLoop() {
const nextRefRect = getBoundingClientRect(reference);

if (prevRefRect &&
(nextRefRect.x !== prevRefRect.x ||
nextRefRect.y !== prevRefRect.y ||
nextRefRect.width !== prevRefRect.width ||
nextRefRect.height !== prevRefRect.height)
) {
update();
}

prevRefRect = nextRefRect;
frameId = requestAnimationFrame(frameLoop);
}

update();

return () => {
ancestors.forEach((ancestor) => {
ancestorScroll && ancestor.removeEventListener('scroll', update);
ancestorResize && ancestor.removeEventListener('resize', update);
});

cleanupIo?.();
resizeObserver?.disconnect();
resizeObserver = null;

if (animationFrame) {
cancelAnimationFrame(frameId);
}
};
}
14 changes: 9 additions & 5 deletions packages/sheets-ui/src/services/canvas-pop-manager.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export class SheetCanvasPopManagerService extends Disposable {
const updatePosition = () => position$.next(this._calcCellPositionByCell(row, col, currentRender, skeleton, activeViewport));

const disposable = new DisposableCollection();
disposable.add(currentRender.engine.clientRect$.subscribe(() => updatePosition()));
disposable.add(this._commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === SetWorksheetRowAutoHeightMutation.id) {
const params = commandInfo.params as ISetWorksheetRowAutoHeightMutationParams;
Expand Down Expand Up @@ -265,7 +266,7 @@ export class SheetCanvasPopManagerService extends Disposable {
skeleton: SpreadsheetSkeleton,
activeViewport: Viewport
): IBoundRectNoAngle {
const { scene } = currentRender;
const { scene, engine } = currentRender;

const primaryWithCoord = skeleton.getCellByIndex(row, col);
const cellInfo = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord;
Expand All @@ -276,11 +277,14 @@ export class SheetCanvasPopManagerService extends Disposable {
y: activeViewport.viewportScrollY,
};

const canvasClientRect = engine.getCanvasElement().getBoundingClientRect();
const { top, left } = canvasClientRect;

return {
left: ((cellInfo.startX - scrollXY.x) * scaleX),
right: (cellInfo.endX - scrollXY.x) * scaleX,
top: ((cellInfo.startY - scrollXY.y) * scaleY),
bottom: ((cellInfo.endY - scrollXY.y) * scaleY),
left: ((cellInfo.startX - scrollXY.x) * scaleX) + left,
right: (cellInfo.endX - scrollXY.x) * scaleX + left,
top: ((cellInfo.startY - scrollXY.y) * scaleY) + top,
bottom: ((cellInfo.endY - scrollXY.y) * scaleY) + top,
};
}

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/controllers/ui/ui-desktop.controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export class DesktopUIController extends Disposable {
}

private _initBuiltinComponents() {
this.disposeWithMe(this._uiPartsService.registerComponent(BuiltInUIPart.CONTENT, () => connectInjector(CanvasPopup, this._injector)));
this.disposeWithMe(this._uiPartsService.registerComponent(BuiltInUIPart.FLOATING, () => connectInjector(CanvasPopup, this._injector)));
this.disposeWithMe(this._uiPartsService.registerComponent(BuiltInUIPart.CONTENT, () => connectInjector(FloatDom, this._injector)));
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/services/parts/parts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export enum BuiltInUIPart {
CONTENT = 'content',
FOOTER = 'footer',
LEFT_SIDEBAR = 'left-sidebar',
FLOATING = 'floating',
}

export interface IUIPartsService {
Expand Down
14 changes: 12 additions & 2 deletions packages/ui/src/views/DesktopApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@

import { LocaleService, ThemeService } from '@univerjs/core';
import type { ILocale } from '@univerjs/design';
import { ConfigProvider, defaultTheme, themeInstance } from '@univerjs/design';
import { ConfigContext, ConfigProvider, defaultTheme, themeInstance } from '@univerjs/design';
import { useDependency } from '@wendellhu/redi/react-bindings';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';

import type { IWorkbenchOptions } from '../controllers/ui/ui.controller';
import { IMessageService } from '../services/message/message.service';
import { BuiltInUIPart } from '../services/parts/parts.service';
Expand Down Expand Up @@ -154,6 +156,14 @@ export function DesktopApp(props: IUniverAppProps) {
<ComponentContainer key="global" components={globalComponents} />
<ComponentContainer key="built-in-global" components={builtInGlobalComponents} />
{contextMenu && <DesktopContextMenu />}
<FloatingContainer />
</ConfigProvider>
);
}

function FloatingContainer() {
const { mountContainer } = useContext(ConfigContext);
const floatingComponents = useComponentsOfPart(BuiltInUIPart.FLOATING);

return createPortal(<ComponentContainer key="floating" components={floatingComponents} />, mountContainer!);
}
Loading

0 comments on commit f2fbd3b

Please sign in to comment.