### What's Changed! #### Added Manage various types of toolbars uniformly in one place. * `affine-toolbar-widget` * `ToolbarRegistryExtension` The toolbar currently supports and handles several scenarios: 1. Select blocks: `BlockSelection` 2. Select text: `TextSelection` or `NativeSelection` 3. Hover a link: `affine-link` and `affine-reference` #### Removed Remove redundant toolbar implementations. * `attachment` toolbar * `bookmark` toolbar * `embed` toolbar * `formatting` toolbar * `affine-link` toolbar * `affine-reference` toolbar ### How to migrate? Here is an example that can help us migrate some unrefactored toolbars: Check out the more detailed types of [`ToolbarModuleConfig`](c178debf2d/blocksuite/affine/shared/src/services/toolbar-service/config.ts). 1. Add toolbar configuration file to a block type, such as bookmark block: [`config.ts`](c178debf2d/blocksuite/affine/block-bookmark/src/configs/toolbar.ts) ```ts export const builtinToolbarConfig = { actions: [ { id: 'a.preview', content(ctx) { const model = ctx.getCurrentModelBy(BlockSelection, BookmarkBlockModel); if (!model) return null; const { url } = model; return html`<affine-link-preview .url=${url}></affine-link-preview>`; }, }, { id: 'b.conversions', actions: [ { id: 'inline', label: 'Inline view', run(ctx) { }, }, { id: 'card', label: 'Card view', disabled: true, }, { id: 'embed', label: 'Embed view', disabled(ctx) { }, run(ctx) { }, }, ], content(ctx) { }, } satisfies ToolbarActionGroup<ToolbarAction>, { id: 'c.style', actions: [ { id: 'horizontal', label: 'Large horizontal style', }, { id: 'list', label: 'Small horizontal style', }, ], content(ctx) { }, } satisfies ToolbarActionGroup<ToolbarAction>, { id: 'd.caption', tooltip: 'Caption', icon: CaptionIcon(), run(ctx) { }, }, { placement: ActionPlacement.More, id: 'a.clipboard', actions: [ { id: 'copy', label: 'Copy', icon: CopyIcon(), run(ctx) { }, }, { id: 'duplicate', label: 'Duplicate', icon: DuplicateIcon(), run(ctx) { }, }, ], }, { placement: ActionPlacement.More, id: 'b.refresh', label: 'Reload', icon: ResetIcon(), run(ctx) { }, }, { placement: ActionPlacement.More, id: 'c.delete', label: 'Delete', icon: DeleteIcon(), variant: 'destructive', run(ctx) { }, }, ], } as const satisfies ToolbarModuleConfig; ``` 2. Add configuration extension to a block spec: [bookmark's spec](c178debf2d/blocksuite/affine/block-bookmark/src/bookmark-spec.ts) ```ts const flavour = BookmarkBlockSchema.model.flavour; export const BookmarkBlockSpec: ExtensionType[] = [ ..., ToolbarModuleExtension({ id: BlockFlavourIdentifier(flavour), config: builtinToolbarConfig, }), ].flat(); ``` 3. If the bock type already has a toolbar configuration built in, we can customize it in the following ways: Check out the [editor's config](c178debf2d/packages/frontend/core/src/blocksuite/extensions/editor-config/index.ts (L51C4-L54C8)) file. ```ts // Defines a toolbar configuration for the bookmark block type const customBookmarkToolbarConfig = { actions: [ ... ] } as const satisfies ToolbarModuleConfig; // Adds it into the editor's config ToolbarModuleExtension({ id: BlockFlavourIdentifier('custom:affine:bookmark'), config: customBookmarkToolbarConfig, }), ``` 4. If we want to extend the global: ```ts // Defines a toolbar configuration const customWildcardToolbarConfig = { actions: [ ... ] } as const satisfies ToolbarModuleConfig; // Adds it into the editor's config ToolbarModuleExtension({ id: BlockFlavourIdentifier('custom:affine:*'), config: customWildcardToolbarConfig, }), ``` Currently, only most toolbars in page mode have been refactored. Next is edgeless mode.
145 lines
4.5 KiB
TypeScript
145 lines
4.5 KiB
TypeScript
import { dedupe, delayHide, delayShow } from './middlewares/basic.js';
|
|
import { safeBridge, safeTriangle } from './middlewares/safe-area.js';
|
|
import type { HoverMiddleware, WhenHoverOptions } from './types.js';
|
|
|
|
/**
|
|
* Call the `whenHoverChange` callback when the element is hovered.
|
|
*
|
|
* After the mouse leaves the element, there is a 300ms delay by default.
|
|
*
|
|
* Note: The callback may be called multiple times when the mouse is hovering or hovering out.
|
|
*
|
|
* See also https://floating-ui.com/docs/useHover
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* private _setReference: RefOrCallback;
|
|
*
|
|
* connectedCallback() {
|
|
* let hoverTip: HTMLElement | null = null;
|
|
* const { setReference, setFloating } = whenHover(isHover => {
|
|
* if (!isHover) {
|
|
* hoverTips?.remove();
|
|
* return;
|
|
* }
|
|
* hoverTip = document.createElement('div');
|
|
* document.body.append(hoverTip);
|
|
* setFloating(hoverTip);
|
|
* }, { hoverDelay: 500 });
|
|
* this._setReference = setReference;
|
|
* }
|
|
*
|
|
* render() {
|
|
* return html`
|
|
* <div ref=${this._setReference}></div>
|
|
* `;
|
|
* }
|
|
* ```
|
|
*/
|
|
export const whenHover = (
|
|
whenHoverChange: (isHover: boolean, event?: Event) => void,
|
|
{
|
|
enterDelay = 0,
|
|
leaveDelay = 250,
|
|
alwayRunWhenNoFloating = true,
|
|
safeTriangle: triangleOptions = false,
|
|
safeBridge: bridgeOptions = true,
|
|
}: WhenHoverOptions = {}
|
|
) => {
|
|
/**
|
|
* The event listener will be removed when the signal is aborted.
|
|
*/
|
|
const abortController = new AbortController();
|
|
let referenceElement: Element | undefined;
|
|
let floatingElement: Element | undefined;
|
|
|
|
const middlewares: HoverMiddleware[] = [
|
|
dedupe(alwayRunWhenNoFloating),
|
|
triangleOptions &&
|
|
safeTriangle(
|
|
typeof triangleOptions === 'boolean' ? undefined : triangleOptions
|
|
),
|
|
bridgeOptions &&
|
|
safeBridge(
|
|
typeof bridgeOptions === 'boolean' ? undefined : bridgeOptions
|
|
),
|
|
delayShow(enterDelay),
|
|
delayHide(leaveDelay),
|
|
].filter(v => typeof v !== 'boolean') as HoverMiddleware[];
|
|
|
|
let currentEvent: Event | null = null;
|
|
const onHoverChange = (async (e: Event) => {
|
|
currentEvent = e;
|
|
for (const middleware of middlewares) {
|
|
const go = await middleware({
|
|
event: e,
|
|
floatingElement,
|
|
referenceElement,
|
|
});
|
|
if (!go) return;
|
|
}
|
|
// ignore expired event
|
|
if (e !== currentEvent) return;
|
|
const isHover = e.type === 'mouseenter' ? true : false;
|
|
whenHoverChange(isHover, e);
|
|
}) as (e: Event) => void;
|
|
|
|
const addHoverListener = (element?: Element) => {
|
|
if (!element) return;
|
|
// see https://stackoverflow.com/questions/14795099/pure-javascript-to-check-if-something-has-hover-without-setting-on-mouseover-ou
|
|
const alreadyHover = element.matches(':hover');
|
|
if (alreadyHover && !abortController.signal.aborted) {
|
|
// When the element is already hovered, we need to trigger the callback manually
|
|
onHoverChange(new MouseEvent('mouseenter'));
|
|
}
|
|
element.addEventListener('mouseenter', onHoverChange, {
|
|
capture: true,
|
|
signal: abortController.signal,
|
|
});
|
|
element.addEventListener('mouseleave', onHoverChange, {
|
|
// Please refrain use `capture: true` here.
|
|
// It will cause the `mouseleave` trigger incorrectly when the pointer is still within the element.
|
|
// The issue is detailed in https://github.com/toeverything/blocksuite/issues/6241
|
|
//
|
|
// The `mouseleave` does not **bubble**.
|
|
// This means that `mouseleave` is fired when the pointer has exited the element and all of its descendants,
|
|
// If `capture` is used, all `mouseleave` events will be received when the pointer leaves the element or leaves one of the element's descendants (even if the pointer is still within the element).
|
|
//
|
|
// capture: true,
|
|
signal: abortController.signal,
|
|
});
|
|
};
|
|
|
|
const removeHoverListener = (element?: Element) => {
|
|
if (!element) return;
|
|
element.removeEventListener('mouseenter', onHoverChange, {
|
|
capture: true,
|
|
});
|
|
element.removeEventListener('mouseleave', onHoverChange);
|
|
};
|
|
|
|
const setReference = (element?: Element) => {
|
|
// Clean previous listeners
|
|
removeHoverListener(referenceElement);
|
|
addHoverListener(element);
|
|
referenceElement = element;
|
|
};
|
|
|
|
const setFloating = (element?: Element) => {
|
|
// Clean previous listeners
|
|
removeHoverListener(floatingElement);
|
|
addHoverListener(element);
|
|
floatingElement = element;
|
|
};
|
|
|
|
return {
|
|
setReference,
|
|
setFloating,
|
|
dispose: () => {
|
|
abortController.abort();
|
|
},
|
|
};
|
|
};
|
|
|
|
export type { WhenHoverOptions };
|