Files
AFFiNE-Mirror/blocksuite/affine/components/src/hover/controller.ts
T
L-Sun 339bfdae0f fix(editor): centerize iframe modal in mobile (#13073)
Close
[BS-3160](https://linear.app/affine-design/issue/BS-3160/新的-embed-输入的-sheet-没有弹起来)

#### PR Dependency Tree


* **PR #13073** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved popup positioning and responsiveness when using virtual
keyboards, ensuring popups remain visible and correctly placed above the
keyboard.
* Standardized how popups and overlays are created and referenced
throughout the app, reducing inconsistencies and potential display
issues.
* Enhanced stability of date picker, AI panels, and slash menu popovers
by refining how their elements are managed and updated.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-07 11:17:57 +00:00

202 lines
4.8 KiB
TypeScript

import { DisposableGroup } from '@blocksuite/global/disposable';
import type { ReactiveController, ReactiveElement } from 'lit';
import {
type AdvancedPortalOptions,
createLitPortal,
} from '../portal/index.js';
import type { HoverOptions } from './types.js';
import { whenHover } from './when-hover.js';
type OptionsParams = Omit<
ReturnType<typeof whenHover>,
'setFloating' | 'dispose'
> & {
abortController: AbortController;
};
type HoverPortalOptions = Omit<AdvancedPortalOptions, 'abortController'>;
const DEFAULT_HOVER_OPTIONS: HoverOptions = {
transition: {
duration: 100,
in: {
opacity: '1',
transition: 'opacity 0.1s ease-in-out',
},
out: {
opacity: '0',
transition: 'opacity 0.1s ease-in-out',
},
},
setPortalAsFloating: true,
allowMultiple: false,
};
const abortHoverPortal = ({
portal,
hoverOptions,
abortController,
}: {
portal: HTMLDivElement | undefined;
hoverOptions: HoverOptions;
abortController: AbortController;
}) => {
if (!portal || !hoverOptions.transition) {
abortController.abort();
return;
}
// Transition out
Object.assign(portal.style, hoverOptions.transition.out);
portal.addEventListener(
'transitionend',
() => {
abortController.abort();
},
{ signal: abortController.signal }
);
portal.addEventListener(
'transitioncancel',
() => {
abortController.abort();
},
{ signal: abortController.signal }
);
// Make sure the portal is aborted after the transition ends
setTimeout(() => abortController.abort(), hoverOptions.transition.duration);
};
export class HoverController implements ReactiveController {
static globalAbortController?: AbortController;
private _abortController?: AbortController;
private readonly _hoverOptions: HoverOptions;
private _isHovering = false;
private readonly _onHover: (
options: OptionsParams
) => HoverPortalOptions | null;
private _portal?: HTMLDivElement;
private _setReference: (element?: Element | undefined) => void = () => {
console.error('setReference is not ready');
};
protected _disposables = new DisposableGroup();
host: ReactiveElement;
/**
* Callback when the portal needs to be aborted.
*/
onAbort = () => {
this.abort();
};
/**
* Whether the host is currently hovering.
*
* This property is unreliable when the floating element disconnect from the DOM suddenly.
*/
get isHovering() {
return this._isHovering;
}
get portal() {
return this._portal;
}
get setReference() {
return this._setReference;
}
constructor(
host: ReactiveElement,
onHover: (options: OptionsParams) => HoverPortalOptions | null,
hoverOptions?: Partial<HoverOptions>
) {
this._hoverOptions = { ...DEFAULT_HOVER_OPTIONS, ...hoverOptions };
(this.host = host).addController(this);
this._onHover = onHover;
}
abort(force = false) {
if (!this._abortController) return;
if (force) {
this._abortController.abort();
return;
}
abortHoverPortal({
portal: this._portal,
hoverOptions: this._hoverOptions,
abortController: this._abortController,
});
}
hostConnected() {
if (this._disposables.disposed) {
this._disposables = new DisposableGroup();
}
// Start a timer when the host is connected
const { setReference, setFloating, dispose } = whenHover(isHover => {
if (!this.host.isConnected) {
return;
}
this._isHovering = isHover;
if (!isHover) {
this.onAbort();
return;
}
if (this._abortController) {
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this._abortController = undefined;
});
if (!this._hoverOptions.allowMultiple) {
HoverController.globalAbortController?.abort();
HoverController.globalAbortController = this._abortController;
}
const portalOptions = this._onHover({
setReference,
abortController: this._abortController,
});
if (!portalOptions) {
// Sometimes the portal is not ready to show
this._abortController.abort();
return;
}
this._portal = createLitPortal({
...portalOptions,
abortController: this._abortController,
}).portal;
const transition = this._hoverOptions.transition;
if (transition) {
Object.assign(this._portal.style, transition.in);
}
if (this._hoverOptions.setPortalAsFloating) {
setFloating(this._portal);
}
}, this._hoverOptions);
this._setReference = setReference;
this._disposables.add(dispose);
}
hostDisconnected() {
this._abortController?.abort();
this._disposables.dispose();
}
}