mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 21:38:44 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user