chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,282 @@
import { IS_MOBILE } from '@blocksuite/global/env';
import {
CheckBoxCkeckSolidIcon,
CheckBoxUnIcon,
DoneIcon,
} from '@blocksuite/icons/lit';
import type { ReadonlySignal } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, type TemplateResult, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { keyed } from 'lit/directives/keyed.js';
import type { ClassInfo } from 'lit-html/directives/class-map.js';
import { MenuFocusable } from './focusable.js';
import type { Menu } from './menu.js';
import type { MenuClass, MenuItemRender } from './types.js';
export type MenuButtonData = {
content: () => TemplateResult;
class: ClassInfo;
select: (ele: HTMLElement) => void | false;
onHover?: (hover: boolean) => void;
};
export class MenuButton extends MenuFocusable {
static override styles = css`
.affine-menu-button {
display: flex;
width: 100%;
font-size: 20px;
cursor: pointer;
align-items: center;
padding: 4px;
gap: 8px;
border-radius: 4px;
color: var(--affine-icon-color);
}
.affine-menu-button:hover,
affine-menu-button.active .affine-menu-button {
background-color: var(--affine-hover-color);
}
.affine-menu-button .affine-menu-action-text {
flex: 1;
font-size: 14px;
line-height: 22px;
color: var(--affine-text-primary-color);
}
.affine-menu-button.focused {
outline: 1px solid ${unsafeCSS(cssVarV2.layer.insideBorder.primaryBorder)};
}
.affine-menu-button.delete-item:hover {
background-color: var(--affine-background-error-color);
color: var(--affine-error-color);
}
.affine-menu-button.delete-item:hover .affine-menu-action-text {
color: var(--affine-error-color);
}
`;
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'mouseenter', () => {
this.data.onHover?.(true);
this.menu.closeSubMenu();
});
this.disposables.addFromEvent(this, 'mouseleave', () => {
this.data.onHover?.(false);
});
this.disposables.addFromEvent(this, 'click', this.onClick);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.data.onHover?.(false);
}
onClick() {
if (this.data.select(this) !== false) {
this.menu.options.onComplete?.();
this.menu.close();
}
}
override onPressEnter() {
this.onClick();
}
protected override render(): unknown {
const classString = classMap({
'affine-menu-button': true,
focused: this.isFocused$.value,
...this.data.class,
});
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
accessor data!: MenuButtonData;
}
export class MobileMenuButton extends MenuFocusable {
static override styles = css`
.mobile-menu-button {
display: flex;
width: 100%;
cursor: pointer;
align-items: center;
font-size: 20px;
padding: 11px 8px;
gap: 8px;
border-radius: 4px;
color: var(--affine-icon-color);
}
.mobile-menu-button .affine-menu-action-text {
flex: 1;
color: var(--affine-text-primary-color);
font-size: 17px;
line-height: 22px;
}
.mobile-menu-button.delete-item {
color: var(--affine-error-color);
}
.mobile-menu-button.delete-item .mobile-menu-action-text {
color: var(--affine-error-color);
}
`;
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', this.onClick);
}
// eslint-disable-next-line sonarjs/no-identical-functions
onClick() {
if (this.data.select(this) !== false) {
this.menu.options.onComplete?.();
this.menu.close();
}
}
override onPressEnter() {
this.onClick();
}
protected override render(): unknown {
const classString = classMap({
'mobile-menu-button': true,
focused: this.isFocused$.value,
...this.data.class,
});
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
accessor data!: MenuButtonData;
}
const renderButton = (data: MenuButtonData, menu: Menu) => {
if (IS_MOBILE) {
return html`<mobile-menu-button
.data="${data}"
.menu="${menu}"
></mobile-menu-button>`;
}
return html`<affine-menu-button
.data="${data}"
.menu="${menu}"
></affine-menu-button>`;
};
export const menuButtonItems = {
action:
(config: {
name: string;
label?: () => TemplateResult;
prefix?: TemplateResult;
postfix?: TemplateResult;
isSelected?: boolean;
select: (ele: HTMLElement) => void | false;
onHover?: (hover: boolean) => void;
class?: MenuClass;
hide?: () => boolean;
}) =>
menu => {
if (config.hide?.() || !menu.search(config.name)) {
return;
}
const data: MenuButtonData = {
content: () => {
return html`
${config.prefix}
<div class="affine-menu-action-text">
${config.label?.() ?? config.name}
</div>
${config.postfix ?? (config.isSelected ? DoneIcon() : undefined)}
`;
},
onHover: config.onHover,
select: config.select,
class: {
'selected-item': config.isSelected ?? false,
...config.class,
},
};
return renderButton(data, menu);
},
checkbox:
(config: {
name: string;
checked: ReadonlySignal<boolean>;
postfix?: TemplateResult;
label?: () => TemplateResult;
select: (checked: boolean) => boolean;
class?: ClassInfo;
}) =>
menu => {
if (!menu.search(config.name)) {
return;
}
const data: MenuButtonData = {
content: () => html`
${config.checked.value
? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` })
: CheckBoxUnIcon()}
<div class="affine-menu-action-text">
${config.label?.() ?? config.name}
</div>
${config.postfix}
`,
select: () => {
config.select(config.checked.value);
return false;
},
class: config.class ?? {},
};
return html`${keyed(config.name, renderButton(data, menu))}`;
},
toggleSwitch:
(config: {
name: string;
on: boolean;
postfix?: TemplateResult;
label?: () => TemplateResult;
onChange: (on: boolean) => void;
class?: ClassInfo;
}) =>
menu => {
if (!menu.search(config.name)) {
return;
}
const onChange = (on: boolean) => {
config.onChange(on);
};
const data: MenuButtonData = {
content: () => html`
<div class="affine-menu-action-text">
${config.label?.() ?? config.name}
</div>
<toggle-switch
.on="${config.on}"
.onChange="${onChange}"
></toggle-switch>
${config.postfix}
`,
select: () => {
config.onChange(config.on);
return false;
},
class: config.class ?? {},
};
return html`${keyed(config.name, renderButton(data, menu))}`;
},
} satisfies Record<string, MenuItemRender<never>>;
@@ -0,0 +1,15 @@
import { html, type TemplateResult } from 'lit';
import type { MenuConfig } from './menu.js';
import type { MenuItemRender } from './types.js';
export const menuDynamicItems = {
dynamic: (config: () => MenuConfig[]) => menu => {
const items = menu.renderItems(config());
if (!items.length) {
return;
}
const result: TemplateResult = html`${items}`;
return result;
},
} satisfies Record<string, MenuItemRender<never>>;
@@ -0,0 +1,18 @@
import { computed } from '@preact/signals-core';
import { MenuItem } from './item.js';
export abstract class MenuFocusable extends MenuItem {
isFocused$ = computed(() => this.menu.currentFocused$.value === this);
override connectedCallback() {
super.connectedCallback();
this.dataset.focusable = 'true';
}
override focus() {
this.menu.focusTo(this);
}
abstract onPressEnter(): void;
}
@@ -0,0 +1,35 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { IS_MOBILE } from '@blocksuite/global/env';
import { html, type TemplateResult } from 'lit';
import type { MenuConfig } from './menu.js';
import type { MenuItemRender } from './types.js';
export const menuGroupItems = {
group: (config: { name?: string; items: MenuConfig[] }) => (menu, index) => {
const items = menu.renderItems(config.items);
if (!items.length) {
return;
}
if (IS_MOBILE) {
return html` <div
style="
display: flex;
flex-direction: column;
background-color: ${unsafeCSSVarV2('layer/background/primary')};
padding: 4px;
border-radius: 12px;
"
>
${items}
</div>`;
}
const result: TemplateResult = html` ${index === 0
? ''
: html` <div
style="height: 0.5px;background-color: var(--affine-divider-color);margin: 4px 0"
></div>`}
<div style="display: flex;flex-direction: column;gap:4px">${items}</div>`;
return result;
},
} satisfies Record<string, MenuItemRender<never>>;
@@ -0,0 +1,26 @@
import { MenuButton, MobileMenuButton } from './button.js';
import { MenuInput, MobileMenuInput } from './input.js';
import { MenuComponent, MobileMenuComponent } from './menu-renderer.js';
import { MenuSubMenu, MobileSubMenu } from './sub-menu.js';
export * from './button.js';
export * from './focusable.js';
export * from './group.js';
export * from './input.js';
export * from './item.js';
export * from './menu.js';
export * from './menu-renderer.js';
export * from './sub-menu.js';
export function effects() {
customElements.define('affine-menu', MenuComponent);
customElements.define('mobile-menu', MobileMenuComponent);
customElements.define('affine-menu-button', MenuButton);
customElements.define('mobile-menu-button', MobileMenuButton);
customElements.define('affine-menu-input', MenuInput);
customElements.define('mobile-menu-input', MobileMenuInput);
customElements.define('affine-menu-sub-menu', MenuSubMenu);
customElements.define('mobile-sub-menu', MobileSubMenu);
}
export * from './types.js';
@@ -0,0 +1,252 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { IS_MOBILE } from '@blocksuite/global/env';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, type TemplateResult } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { StyleInfo } from 'lit-html/directives/style-map.js';
import { MenuFocusable } from './focusable.js';
import type { Menu } from './menu.js';
import type { MenuItemRender } from './types.js';
export type MenuInputData = {
placeholder?: string;
initialValue?: string;
class?: string;
onComplete?: (value: string) => void;
onChange?: (value: string) => void;
disableAutoFocus?: boolean;
};
export class MenuInput extends MenuFocusable {
static override styles = css`
.affine-menu-input {
flex: 1;
outline: none;
border-radius: 4px;
font-size: 14px;
line-height: 22px;
padding: 4px 6px;
border: 1px solid var(--affine-border-color);
width: 100%;
color: ${unsafeCSSVarV2('text/primary')};
background-color: transparent;
}
.affine-menu-input.focused {
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/primaryBorder')};
}
.affine-menu-input:focus {
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/primaryBorder')};
box-shadow: 0px 0px 0px 2px rgba(28, 158, 228, 0.3);
}
`;
private onCompositionEnd = () => {
this.data.onChange?.(this.inputRef.value);
};
private onInput = (e: InputEvent) => {
e.stopPropagation();
if (e.isComposing) return;
this.data.onChange?.(this.inputRef.value);
};
private onKeydown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.isComposing) return;
if (e.key === 'Escape') {
this.complete();
this.inputRef.blur();
this.menu.focusTo(this);
return;
}
if (e.key === 'Enter') {
this.complete();
this.menu.close();
return;
}
};
private stopPropagation = (e: Event) => {
e.stopPropagation();
};
complete() {
this.data.onComplete?.(this.inputRef.value);
}
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', e => {
e.stopPropagation();
});
this.disposables.addFromEvent(this, 'mouseenter', () => {
this.menu.closeSubMenu();
});
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.inputRef.select();
});
});
}
override onPressEnter() {
this.inputRef.focus();
}
protected override render(): unknown {
const classString = classMap({
[this.data.class ?? '']: true,
'affine-menu-input': true,
focused: this.isFocused$.value,
});
return html`<input
@focus="${() => {
this.menu.setFocusOnly(this);
}}"
@input="${this.onInput}"
@keydown="${this.onKeydown}"
@copy="${this.stopPropagation}"
@paste="${this.stopPropagation}"
@compositionend="${this.onCompositionEnd}"
class="${classString}"
value="${this.data.initialValue ?? ''}"
type="text"
/>`;
}
@property({ attribute: false })
accessor data!: MenuInputData;
@query('input')
accessor inputRef!: HTMLInputElement;
}
export class MobileMenuInput extends MenuFocusable {
static override styles = css`
.mobile-menu-input {
flex: 1;
outline: none;
font-size: 17px;
line-height: 22px;
border: none;
width: 100%;
color: ${unsafeCSSVarV2('text/primary')};
}
`;
private onCompositionEnd = () => {
this.data.onChange?.(this.inputRef.value);
};
private onInput = (e: InputEvent) => {
e.stopPropagation();
if (e.isComposing) return;
this.data.onChange?.(this.inputRef.value);
};
private stopPropagation = (e: Event) => {
e.stopPropagation();
};
complete() {
this.data.onComplete?.(this.inputRef.value);
}
override onPressEnter() {
this.inputRef.focus();
}
protected override render(): unknown {
const classString = classMap({
[this.data.class ?? '']: true,
'mobile-menu-input': true,
focused: this.isFocused$.value,
});
return html`<input
@focus="${() => {
this.menu.setFocusOnly(this);
}}"
@input="${this.onInput}"
@copy="${this.stopPropagation}"
@paste="${this.stopPropagation}"
@compositionend="${this.onCompositionEnd}"
class="${classString}"
value="${this.data.initialValue ?? ''}"
type="text"
/>`;
}
@property({ attribute: false })
accessor data!: MenuInputData;
@query('input')
accessor inputRef!: HTMLInputElement;
}
const renderInput = (data: MenuInputData, menu: Menu) => {
if (IS_MOBILE) {
return html` <mobile-menu-input
style="flex:1"
.data="${data}"
.menu="${menu}"
></mobile-menu-input>`;
}
return html` <affine-menu-input
style="flex:1"
.data="${data}"
.menu="${menu}"
></affine-menu-input>`;
};
export const menuInputItems = {
input:
(config: {
placeholder?: string;
initialValue?: string;
postfix?: TemplateResult;
prefix?: TemplateResult;
onComplete?: (value: string) => void;
onChange?: (value: string) => void;
class?: string;
style?: Readonly<StyleInfo>;
}) =>
menu => {
if (menu.showSearch$.value) {
return;
}
const data: MenuInputData = {
placeholder: config.placeholder,
initialValue: config.initialValue,
class: config.class,
onComplete: config.onComplete,
onChange: config.onChange,
};
const style = styleMap({
display: 'flex',
alignItems: 'center',
...(IS_MOBILE
? {
borderRadius: '12px',
backgroundColor: cssVarV2('layer/background/primary'),
padding: '12px',
gap: '8px',
}
: {
marginBottom: '8px',
gap: '4px',
}),
...config.style,
});
return html`
<div style="${style}">
${config.prefix} ${renderInput(data, menu)} ${config.postfix}
</div>
`;
},
} satisfies Record<string, MenuItemRender<never>>;
@@ -0,0 +1,12 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { property } from 'lit/decorators.js';
import type { Menu } from './menu.js';
export abstract class MenuItem extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
@property({ attribute: false })
accessor menu!: Menu;
}
@@ -0,0 +1,571 @@
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/block-std';
import { IS_MOBILE } from '@blocksuite/global/env';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import {
ArrowLeftBigIcon,
ArrowLeftSmallIcon,
CloseIcon,
SearchIcon,
} from '@blocksuite/icons/lit';
import {
autoPlacement,
autoUpdate,
computePosition,
type Middleware,
offset,
type ReferenceElement,
shift,
} from '@floating-ui/dom';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { createRef, ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { MenuFocusable } from './focusable.js';
import { Menu, type MenuConfig, type MenuOptions } from './menu.js';
import type { MenuComponentInterface } from './types.js';
export class MenuComponent
extends SignalWatcher(WithDisposable(ShadowlessElement))
implements MenuComponentInterface
{
static override styles = css`
affine-menu {
font-family: var(--affine-font-family);
display: flex;
flex-direction: column;
user-select: none;
min-width: 276px;
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
border-radius: 4px;
background-color: ${unsafeCSSVarV2('layer/background/overlayPanel')};
padding: 8px;
position: absolute;
z-index: 999;
gap: 8px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
color: ${unsafeCSSVarV2('text/primary')};
}
.affine-menu-search-container {
border-radius: 4px;
display: flex;
align-items: center;
padding: 4px 10px;
gap: 8px;
border: 1px solid ${unsafeCSSVarV2('input/border/default')};
}
.affine-menu-search {
flex: 1;
outline: none;
font-size: 14px;
line-height: 22px;
border: none;
background-color: transparent;
}
.affine-menu-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.no-results {
font-size: 12px;
line-height: 20px;
color: var(--affine-text-secondary-color);
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
}
`;
private _clickContainer = (e: MouseEvent) => {
e.stopPropagation();
this.focusInput();
this.menu.closeSubMenu();
};
private searchRef = createRef<HTMLInputElement>();
override firstUpdated() {
const input = this.searchRef.value;
if (input) {
requestAnimationFrame(() => {
this.focusInput();
});
const length = input.value.length;
input.setSelectionRange(length, length);
this.disposables.addFromEvent(input, 'keydown', e => {
e.stopPropagation();
if (e.key === 'Escape') {
this.menu.close();
return;
}
const onBack = this.menu.options.title?.onBack;
if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) {
this.menu.close();
onBack(this.menu);
return;
}
if (e.key === 'Enter' && !e.isComposing) {
this.menu.pressEnter();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
this.menu.focusPrev();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
this.menu.focusNext();
return;
}
});
this.disposables.addFromEvent(input, 'copy', e => {
e.stopPropagation();
});
this.disposables.addFromEvent(input, 'cut', e => {
e.stopPropagation();
});
this.disposables.addFromEvent(this, 'click', this._clickContainer);
}
}
focusInput() {
this.searchRef.value?.focus();
}
focusTo(ele?: MenuFocusable) {
this.menu.setFocusOnly(ele);
this.focusInput();
}
getFirstFocusableElement(): HTMLElement | null {
return this.querySelector('[data-focusable="true"]');
}
getFocusableElements(): HTMLElement[] {
return Array.from(this.querySelectorAll('[data-focusable="true"]'));
}
override render() {
const result = this.menu.renderItems(this.menu.options.items);
return html`
${this.renderTitle()} ${this.renderSearch()}
<div class="affine-menu-body">
${result.length === 0 && this.menu.enableSearch
? html` <div class="no-results">No Results</div>`
: ''}
${result}
</div>
`;
}
renderSearch() {
const config = this.menu.options.search;
const showSearch = this.menu.showSearch$.value || config?.placeholder;
const searchStyle = styleMap({
opacity: showSearch ? '1' : '0',
height: showSearch ? undefined : '0',
overflow: showSearch ? undefined : 'hidden',
position: showSearch ? undefined : 'absolute',
pointerEvents: showSearch ? undefined : 'none',
});
return html` <div style=${searchStyle} class="affine-menu-search-container">
<div
style="font-size:20px;display:flex;align-items:center;color: var(--affine-text-secondary-color)"
>
${SearchIcon()}
</div>
<input
autocomplete="off"
class="affine-menu-search"
placeholder="${config?.placeholder ?? ''}"
data-1p-ignore
${ref(this.searchRef)}
type="text"
value="${this.menu.searchName$.value}"
@input="${(e: Event) =>
(this.menu.searchName$.value = (e.target as HTMLInputElement).value)}"
/>
</div>`;
}
renderTitle() {
const title = this.menu.options.title;
if (!title) {
return;
}
return html`
<div
style="display:flex;align-items:center;gap: 4px;min-width: 300px;padding:3px 4px 3px 2px"
@mouseenter="${() => this.menu.closeSubMenu()}"
>
${title.onBack
? html` <div
@click="${() => {
title.onBack?.(this.menu);
this.menu.close();
}}"
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
style="display:flex;"
>
${ArrowLeftBigIcon()}
</div>`
: nothing}
<div
style="flex:1;font-weight:500;font-size: 14px;line-height: 22px;color: var(--affine-text-primary-color)"
>
${title.text}
</div>
${title.postfix?.()}
${title.onClose
? html` <div
@click="${title.onClose}"
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
style="display:flex;"
>
${CloseIcon()}
</div>`
: nothing}
</div>
`;
}
@property({ attribute: false })
accessor menu!: Menu;
}
declare global {
interface HTMLElementTagNameMap {
'affine-menu': MenuComponent;
}
}
export class MobileMenuComponent
extends SignalWatcher(WithDisposable(ShadowlessElement))
implements MenuComponentInterface
{
static override styles = css`
mobile-menu {
height: 100%;
font-family: var(--affine-font-family);
display: flex;
flex-direction: column;
user-select: none;
width: 100%;
background-color: ${unsafeCSSVarV2('layer/background/secondary')};
padding: calc(8px + env(safe-area-inset-top, 0px)) 8px
calc(8px + env(safe-area-inset-bottom, 0px)) 8px;
position: absolute;
z-index: 999;
color: ${unsafeCSSVarV2('text/primary')};
}
.mobile-menu-body {
display: flex;
flex-direction: column;
padding: 24px 16px;
gap: 16px;
flex: 1;
overflow-y: auto;
}
`;
onClose = () => {
const close = this.menu.options.title?.onClose;
if (close) {
close();
} else {
this.menu.close();
}
};
focusTo(ele?: MenuFocusable) {
this.menu.setFocusOnly(ele);
}
getFirstFocusableElement(): HTMLElement | null {
return this.querySelector('[data-focusable="true"]');
}
getFocusableElements(): HTMLElement[] {
return Array.from(this.querySelectorAll('[data-focusable="true"]'));
}
override render() {
const result = this.menu.renderItems(this.menu.options.items);
return html`
${this.renderTitle()}
<div class="mobile-menu-body">${result}</div>
`;
}
renderTitle() {
const title = this.menu.options.title;
return html`
<div
style="display:flex;align-items:center;height: 44px;"
@mouseenter="${() => this.menu.closeSubMenu()}"
>
<div style="width: 50px;flex-shrink: 0;margin-left: 10px;">
${title?.onBack
? html` <div
@click="${() => {
title.onBack?.(this.menu);
this.menu.close();
}}"
style="
display:flex;
font-size: 24px;
align-items:center;
"
>
${ArrowLeftSmallIcon()}
</div>`
: nothing}
</div>
<div
style="
flex:1;
font-size: 17px;
font-style: normal;
font-weight: 500;
line-height: 22px;
color: var(--affine-text-primary-color);
display: flex;
justify-content: center;
"
>
${title?.text}
</div>
<div
@click="${this.onClose}"
style="
display:flex;
font-weight: 500;
font-size: 17px;
color: ${unsafeCSSVarV2('button/primary')};
width: 50px;
flex-shrink: 0;
margin-right: 10px;
"
>
Done
</div>
</div>
`;
}
@property({ attribute: false })
accessor menu!: Menu;
}
declare global {
interface HTMLElementTagNameMap {
'affine-menu-mobile': MobileMenuComponent;
}
}
export const getDefaultModalRoot = (ele: HTMLElement) => {
const host: HTMLElement | null =
ele.closest('editor-host') ?? ele.closest('.data-view-popup-container');
if (host) {
return host;
}
return document.body;
};
export const createModal = (container: HTMLElement = document.body) => {
const div = document.createElement('div');
div.style.pointerEvents = 'auto';
div.style.position = 'absolute';
div.style.left = '0';
div.style.top = '0';
div.style.width = '100%';
div.style.height = '100%';
div.style.zIndex = '1001';
div.style.fontFamily = 'var(--affine-font-family)';
container.append(div);
return div;
};
export type PopupTarget = {
targetRect: ReferenceElement;
root: HTMLElement;
popupStart: () => () => void;
};
export const popupTargetFromElement = (element: HTMLElement): PopupTarget => {
let rect = element.getBoundingClientRect();
let count = 0;
let isActive = false;
return {
targetRect: {
getBoundingClientRect: () => {
if (element.isConnected) {
return (rect = element.getBoundingClientRect());
}
return rect;
},
},
root: getDefaultModalRoot(element),
popupStart: () => {
if (!count) {
isActive = element.classList.contains('active');
if (!isActive) {
element.classList.add('active');
}
}
count++;
return () => {
count--;
if (!count && !isActive) {
element.classList.remove('active');
}
};
},
};
};
export const createPopup = (
target: PopupTarget,
content: HTMLElement,
options?: {
onClose?: () => void;
middleware?: Array<Middleware | null | undefined | false>;
container?: HTMLElement;
}
) => {
const close = () => {
modal.remove();
options?.onClose?.();
};
const modal = createModal(target.root);
autoUpdate(target.targetRect, content, () => {
computePosition(target.targetRect, content, {
middleware: options?.middleware ?? [shift({ crossAxis: true })],
})
.then(({ x, y }) => {
Object.assign(content.style, {
left: `${x}px`,
top: `${y}px`,
});
})
.catch(console.error);
});
modal.append(content);
modal.onpointerdown = ev => {
if (ev.target === modal) {
close();
}
};
modal.onmousedown = ev => {
if (ev.target === modal) {
close();
}
};
modal.oncontextmenu = ev => {
ev.preventDefault();
if (ev.target === modal) {
close();
}
};
return close;
};
export type MenuHandler = {
close: () => void;
menu: Menu;
reopen: () => void;
};
const popMobileMenu = (options: MenuOptions): MenuHandler => {
const model = createModal(document.body);
const menu = new Menu({
...options,
onClose: () => {
closePopup();
},
});
model.append(menu.menuElement);
const closePopup = () => {
model.remove();
options.onClose?.();
};
return {
close: () => {
menu.close();
},
menu,
reopen: () => {
menu.close();
popMobileMenu(options);
},
};
};
export const popMenu = (
target: PopupTarget,
props: {
options: MenuOptions;
middleware?: Array<Middleware | null | undefined | false>;
container?: HTMLElement;
}
): MenuHandler => {
if (IS_MOBILE) {
return popMobileMenu(props.options);
}
const popupEnd = target.popupStart();
const onClose = () => {
props.options.onClose?.();
popupEnd();
closePopup();
};
const menu = new Menu({
...props.options,
onClose: onClose,
});
const closePopup = createPopup(target, menu.menuElement, {
onClose: () => {
menu.close();
},
middleware: props.middleware ?? [
autoPlacement({
allowedPlacements: [
'bottom-start',
'bottom-end',
'top-start',
'top-end',
],
}),
offset(4),
],
container: props.container,
});
return {
close: closePopup,
menu,
reopen: () => {
popMenu(target, props);
},
};
};
export const popFilterableSimpleMenu = (
target: PopupTarget,
options: MenuConfig[],
onClose?: () => void
) => {
popMenu(target, {
options: {
items: options,
onClose,
},
});
};
@@ -0,0 +1,180 @@
import { IS_MOBILE } from '@blocksuite/global/env';
import { computed, signal } from '@preact/signals-core';
import type { TemplateResult } from 'lit';
import { menuButtonItems } from './button.js';
import { menuDynamicItems } from './dynamic.js';
import { MenuFocusable } from './focusable.js';
import { menuGroupItems } from './group.js';
import { menuInputItems } from './input.js';
// eslint-disable-next-line
import { MenuComponent, MobileMenuComponent } from './menu-renderer.js';
import { subMenuItems } from './sub-menu.js';
import type { MenuComponentInterface, MenuItemRender } from './types.js';
export const menu = {
...menuButtonItems,
...subMenuItems,
...menuInputItems,
...menuGroupItems,
...menuDynamicItems,
} satisfies Record<string, MenuItemRender<never>>;
export type MenuConfig = (
menu: Menu,
index: number
) => TemplateResult | undefined;
export type MenuOptions = {
onComplete?: () => void;
onClose?: () => void;
title?: {
text: string;
onBack?: (menu: Menu) => void;
onClose?: () => void;
postfix?: () => TemplateResult;
};
search?: {
placeholder?: string;
};
items: MenuConfig[];
};
// Global menu open listener type
type MenuOpenListener = (menu: Menu) => (() => void) | void;
// Global menu open listeners
const menuOpenListeners = new Set<MenuOpenListener>();
// Add global menu open listener
export function onMenuOpen(listener: MenuOpenListener) {
menuOpenListeners.add(listener);
// Return cleanup function
return () => {
menuOpenListeners.delete(listener);
};
}
export class Menu {
private _cleanupFns: Array<() => void> = [];
private _currentFocused$ = signal<MenuFocusable>();
private _subMenu$ = signal<Menu>();
closed = false;
readonly currentFocused$ = computed(() => this._currentFocused$.value);
menuElement: MenuComponentInterface;
searchName$ = signal('');
searchResult$ = computed(() => {
return this.renderItems(this.options.items);
});
showSearch$ = computed(() => {
return this.enableSearch && this.searchName$.value.length > 0;
});
get enableSearch() {
return true;
}
constructor(public options: MenuOptions) {
this.menuElement = IS_MOBILE
? new MobileMenuComponent()
: new MenuComponent();
this.menuElement.menu = this;
// Call global menu open listeners
menuOpenListeners.forEach(listener => {
const cleanup = listener(this);
if (cleanup) {
this._cleanupFns.push(cleanup);
}
});
}
close() {
if (this.closed) {
return;
}
this.closed = true;
// Execute cleanup functions
this._cleanupFns.forEach(cleanup => cleanup());
this._cleanupFns = [];
this.menuElement.remove();
this.options.onClose?.();
}
closeSubMenu() {
this._subMenu$.value?.close();
this._subMenu$.value = undefined;
}
focusNext() {
if (!this._currentFocused$.value) {
const ele = this.menuElement.getFirstFocusableElement();
if (ele instanceof MenuFocusable) {
ele.focus();
}
return;
}
const list = this.menuElement
.getFocusableElements()
.filter(ele => ele instanceof MenuFocusable);
const index = list.indexOf(this._currentFocused$.value);
list[index + 1]?.focus();
}
focusPrev() {
if (!this._currentFocused$.value) {
return;
}
const list = this.menuElement
.getFocusableElements()
.filter(ele => ele instanceof MenuFocusable);
const index = list.indexOf(this._currentFocused$.value);
if (index === 0) {
this._currentFocused$.value = undefined;
return;
}
list[index - 1]?.focus();
}
focusTo(ele?: MenuFocusable) {
this.menuElement.focusTo(ele);
}
openSubMenu(menu: Menu) {
this.closeSubMenu();
this._subMenu$.value = menu;
}
pressEnter() {
this._currentFocused$.value?.onPressEnter();
}
renderItems(items: MenuConfig[]) {
const result = [];
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < items.length; i++) {
const item = items[i];
const template = item(this, result.length);
if (template != null) {
result.push(template);
}
}
return result;
}
search(name: string) {
return name.toLowerCase().includes(this.searchName$.value.toLowerCase());
}
setFocusOnly(ele?: MenuFocusable) {
this._currentFocused$.value = ele;
}
}
@@ -0,0 +1,194 @@
import { IS_MOBILE } from '@blocksuite/global/env';
import { ArrowRightSmallIcon } from '@blocksuite/icons/lit';
import {
autoPlacement,
autoUpdate,
computePosition,
offset,
} from '@floating-ui/dom';
import { html, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { MenuFocusable } from './focusable.js';
import { Menu, type MenuOptions } from './menu.js';
import { popMenu, popupTargetFromElement } from './menu-renderer.js';
import type { MenuItemRender } from './types.js';
export type MenuSubMenuData = {
content: () => TemplateResult;
options: MenuOptions;
select?: () => void;
class?: string;
};
export const subMenuOffset = offset({
mainAxis: 16,
crossAxis: -8.5,
});
export const subMenuPlacements = autoPlacement({
allowedPlacements: ['right-start', 'left-start', 'right-end', 'left-end'],
});
export const subMenuMiddleware = [subMenuOffset, subMenuPlacements];
export class MenuSubMenu extends MenuFocusable {
createTime = 0;
override connectedCallback() {
super.connectedCallback();
this.createTime = Date.now();
this.disposables.addFromEvent(this, 'mouseenter', this.onMouseEnter);
this.disposables.addFromEvent(this, 'click', e => {
e.preventDefault();
e.stopPropagation();
if (this.data.select) {
this.data.select();
this.menu.close();
} else {
this.openSubMenu();
}
});
}
onMouseEnter() {
if (Date.now() - this.createTime > 100) {
this.openSubMenu();
}
}
override onPressEnter() {
this.onMouseEnter();
}
openSubMenu() {
const focus = this.menu.currentFocused$.value;
const menu = new Menu({
...this.data.options,
onComplete: () => {
this.menu.close();
},
onClose: () => {
menu.menuElement.remove();
this.menu.focusTo(focus);
this.data.options.onClose?.();
unsub();
},
});
this.menu.menuElement.parentElement?.append(menu.menuElement);
const unsub = autoUpdate(this, menu.menuElement, () => {
computePosition(this, menu.menuElement, {
middleware: subMenuMiddleware,
})
.then(({ x, y }) => {
menu.menuElement.style.left = `${x}px`;
menu.menuElement.style.top = `${y}px`;
})
.catch(err => console.error(err));
});
this.menu.openSubMenu(menu);
}
protected override render(): unknown {
const classString = classMap({
[this.data.class ?? '']: true,
'affine-menu-button': true,
focused: this.isFocused$.value,
});
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
accessor data!: MenuSubMenuData;
}
export class MobileSubMenu extends MenuFocusable {
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', e => {
e.preventDefault();
e.stopPropagation();
this.openSubMenu();
});
}
onMouseEnter() {
this.openSubMenu();
}
override onPressEnter() {
this.onMouseEnter();
}
openSubMenu() {
const { menu } = popMenu(popupTargetFromElement(this), {
options: {
...this.data.options,
onComplete: () => {
this.menu.close();
},
onClose: () => {
menu.menuElement.remove();
this.data.options.onClose?.();
},
},
});
this.menu.openSubMenu(menu);
}
protected override render(): unknown {
const classString = classMap({
[this.data.class ?? '']: true,
'mobile-menu-button': true,
focused: this.isFocused$.value,
});
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
accessor data!: MenuSubMenuData;
}
export const renderSubMenu = (data: MenuSubMenuData, menu: Menu) => {
if (IS_MOBILE) {
return html` <mobile-sub-menu
.data="${data}"
.menu="${menu}"
></mobile-sub-menu>`;
}
return html` <affine-menu-sub-menu
.data="${data}"
.menu="${menu}"
></affine-menu-sub-menu>`;
};
export const subMenuItems = {
subMenu:
(config: {
name: string;
label?: () => TemplateResult;
select?: () => void;
isSelected?: boolean;
postfix?: TemplateResult;
prefix?: TemplateResult;
class?: string;
options: MenuOptions;
disableArrow?: boolean;
hide?: () => boolean;
}) =>
menu => {
if (config.hide?.() || !menu.search(config.name)) {
return;
}
const data: MenuSubMenuData = {
content: () =>
html`${config.prefix}
<div class="affine-menu-action-text">
${config.label?.() ?? config.name}
</div>
${config.postfix}
${config.disableArrow ? nothing : ArrowRightSmallIcon()} `,
class: config.class,
options: config.options,
};
return renderSubMenu(data, menu);
},
} satisfies Record<string, MenuItemRender<never>>;
@@ -0,0 +1,21 @@
import type { ClassInfo } from 'lit-html/directives/class-map.js';
import type { MenuFocusable } from './focusable.js';
import type { Menu, MenuConfig } from './menu.js';
export type MenuClass = ClassInfo & {
'delete-item'?: boolean;
};
export type MenuItemRender<Props> = (props: Props) => MenuConfig;
export interface MenuComponentInterface extends HTMLElement {
menu: Menu;
remove(): void;
getFocusableElements(): HTMLElement[];
getFirstFocusableElement(): HTMLElement | null;
focusTo(ele?: MenuFocusable): void;
}