mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-12 07:36:42 +08:00
0f5778ac89
fix #13663 #### PR Dependency Tree * **PR #14984** 👈 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 * **New Features** * Calendar view for database blocks (month layout, entry cards, external-source support) * Workspace calendar integration and new slash-menu "Calendar View" * **Improvements** * Create/manage database rows from calendar UI; preserve durations when moving/resizing ranges * Drag-and-drop, drop-preview, and hit-testing support for calendar and docs * Redesigned in-menu View settings with multi-page navigation * Context-menu input autofocus toggle and conditional back-navigation * **Tests** * New unit and E2E suites covering calendar layout, interactions, sources, and slash-menu integration <!-- end of auto-generated comment: release notes by coderabbit.ai -->
171 lines
4.0 KiB
TypeScript
171 lines
4.0 KiB
TypeScript
import { IS_MOBILE } from '@blocksuite/global/env';
|
|
import { computed, signal } from '@preact/signals-core';
|
|
import type { TemplateResult } from 'lit';
|
|
|
|
import { MenuFocusable } from './focusable.js';
|
|
import type { MenuComponentInterface } from './types.js';
|
|
|
|
export type MenuConfig = (
|
|
menu: Menu,
|
|
index: number
|
|
) => TemplateResult | undefined;
|
|
|
|
export type MenuOptions = {
|
|
onComplete?: () => void;
|
|
onClose?: () => void;
|
|
title?: {
|
|
text: string;
|
|
onBack?: (menu: Menu) => boolean | void;
|
|
onClose?: () => void;
|
|
postfix?: () => TemplateResult;
|
|
};
|
|
search?: {
|
|
placeholder?: string;
|
|
};
|
|
items: MenuConfig[];
|
|
testId?: string;
|
|
};
|
|
|
|
// 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 readonly _currentFocused$ = signal<MenuFocusable>();
|
|
|
|
private readonly _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
|
|
? document.createElement('mobile-menu')
|
|
: document.createElement('affine-menu');
|
|
this.menuElement.menu = this;
|
|
if (this.options.testId) {
|
|
this.menuElement.dataset.testid = this.options.testId;
|
|
}
|
|
|
|
// 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 = [];
|
|
// oxlint-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;
|
|
}
|
|
}
|