Files
AFFiNE-Mirror/blocksuite/affine/components/src/context-menu/menu.ts
T
DarkSky 0f5778ac89 feat(editor): calendar view for database block (#14984)
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 -->
2026-05-17 20:40:36 +08:00

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;
}
}