Files
AFFiNE-Mirror/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-toolbar.ts
DarkSky 728e02cab7 feat: bump eslint & oxlint (#14452)
#### PR Dependency Tree


* **PR #14452** 👈

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

* **Bug Fixes**
* Improved null-safety, dependency tracking, upload validation, and
error logging for more reliable uploads, clipboard, calendar linking,
telemetry, PDF/theme printing, and preview/zoom behavior.
* Tightened handling of all-day calendar events (missing date now
reported).

* **Deprecations**
  * Removed deprecated RadioButton and RadioButtonGroup; use RadioGroup.

* **Chores**
* Unified and upgraded linting/config, reorganized imports, and
standardized binary handling for more consistent builds and tooling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-16 13:52:08 +08:00

397 lines
11 KiB
TypeScript

import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
import { type VirtualKeyboardProviderWithAction } from '@blocksuite/affine-shared/services';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ArrowLeftBigIcon, KeyboardIcon } from '@blocksuite/icons/lit';
import {
BlockComponent,
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/std';
import { effect, type Signal, signal } from '@preact/signals-core';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import type {
KeyboardIconType,
KeyboardToolbarConfig,
KeyboardToolbarContext,
KeyboardToolbarItem,
KeyboardToolPanelConfig,
} from './config';
import { keyboardToolbarStyles } from './styles';
import {
isKeyboardSubToolBarConfig,
isKeyboardToolBarActionItem,
isKeyboardToolPanelConfig,
} from './utils';
export const AFFINE_KEYBOARD_TOOLBAR = 'affine-keyboard-toolbar';
@requiredProperties({
config: PropTypes.object,
rootComponent: PropTypes.instanceOf(BlockComponent),
})
export class AffineKeyboardToolbar extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = keyboardToolbarStyles;
private readonly _expanded$ = signal(false);
get std() {
return this.rootComponent.std;
}
get panelOpened() {
return this._currentPanelIndex$.value !== -1;
}
private get panelHeight() {
return this._expanded$.value
? `${
this.keyboard.staticHeight$.value !== 0
? this.keyboard.staticHeight$.value
: 330
}px`
: this.keyboard.appTabSafeArea$.value;
}
/**
* Prevent flickering during keyboard opening
*/
private _resetPanelIndexTimeoutId: ReturnType<typeof setTimeout> | null =
null;
private readonly _closeToolPanel = () => {
if (!this.keyboard.visible$.peek()) this.keyboard.show();
if (this._resetPanelIndexTimeoutId) {
clearTimeout(this._resetPanelIndexTimeoutId);
this._resetPanelIndexTimeoutId = null;
}
this._resetPanelIndexTimeoutId = setTimeout(() => {
this._currentPanelIndex$.value = -1;
}, 100);
};
private readonly _currentPanelIndex$ = signal(-1);
private readonly _goPrevToolbar = () => {
if (!this._isSubToolbarOpened) return;
if (this.panelOpened) this._closeToolPanel();
this._path$.value = this._path$.value.slice(0, -1);
};
private readonly _handleItemClick = (
item: KeyboardToolbarItem,
index: number
) => {
if (isKeyboardToolBarActionItem(item)) {
item.action &&
Promise.resolve(item.action(this._context)).catch(console.error);
} else if (isKeyboardSubToolBarConfig(item)) {
this._closeToolPanel();
this._path$.value = [...this._path$.value, index];
} else if (isKeyboardToolPanelConfig(item)) {
if (this._currentPanelIndex$.value === index) {
this._closeToolPanel();
} else {
if (this._resetPanelIndexTimeoutId) {
clearTimeout(this._resetPanelIndexTimeoutId);
this._resetPanelIndexTimeoutId = null;
}
this._currentPanelIndex$.value = index;
this.keyboard.hide();
this._scrollCurrentBlockIntoView();
}
}
this._lastActiveItem$.value = item;
};
private readonly _lastActiveItem$ = signal<KeyboardToolbarItem | null>(null);
private readonly _path$ = signal<number[]>([]);
private readonly _scrollCurrentBlockIntoView = () => {
this.std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(({ selectedModels }) => {
if (!selectedModels?.length) return;
const block = this.std.view.getBlock(selectedModels[0].id);
if (!block) return;
const { y: y1 } = this.getBoundingClientRect();
const { bottom: y2 } = block.getBoundingClientRect();
const gap = 8;
if (y2 < y1 + gap) return;
scrollTo({
top: window.scrollY + y2 - y1 + gap,
behavior: 'instant',
});
})
.run();
};
private get _context(): KeyboardToolbarContext {
return {
std: this.std,
rootComponent: this.rootComponent,
closeToolPanel: () => {
this._closeToolPanel();
},
};
}
private get _currentPanelConfig(): KeyboardToolPanelConfig | null {
if (!this.panelOpened) return null;
const result = this._currentToolbarItems[this._currentPanelIndex$.value];
return isKeyboardToolPanelConfig(result) ? result : null;
}
private get _currentToolbarItems(): KeyboardToolbarItem[] {
let items = this.config.items;
for (const index of this._path$.value) {
if (isKeyboardSubToolBarConfig(items[index])) {
items = items[index].items;
} else {
break;
}
}
return items.filter(item =>
isKeyboardToolBarActionItem(item)
? (item.showWhen?.(this._context) ?? true)
: true
);
}
private get _isSubToolbarOpened() {
return this._path$.value.length > 0;
}
private _renderIcon(icon: KeyboardIconType) {
return typeof icon === 'function' ? icon(this._context) : icon;
}
private _renderItem(item: KeyboardToolbarItem, index: number) {
let icon = item.icon;
let style = styleMap({});
const disabled =
('disableWhen' in item && item.disableWhen?.(this._context)) ?? false;
if (isKeyboardToolBarActionItem(item)) {
const background =
typeof item.background === 'function'
? item.background(this._context)
: item.background;
if (background)
style = styleMap({
background: background,
});
} else if (isKeyboardToolPanelConfig(item)) {
const { activeIcon, activeBackground } = item;
const active = this._currentPanelIndex$.value === index;
if (active && activeIcon) icon = activeIcon;
if (active && activeBackground)
style = styleMap({ background: activeBackground });
}
return html`<icon-button
size="36px"
style=${style}
?disabled=${disabled}
@click=${() => {
this._handleItemClick(item, index);
}}
>
${this._renderIcon(icon)}
</icon-button>`;
}
private _renderItems() {
if (!this.std.event.active$.value)
return html`<div class="item-container"></div>`;
const goPrevToolbarAction = when(
this._isSubToolbarOpened,
() =>
html`<icon-button size="36px" @click=${this._goPrevToolbar}>
${ArrowLeftBigIcon()}
</icon-button>`
);
return html`<div class="item-container">
${goPrevToolbarAction}
${repeat(this._currentToolbarItems, (item, index) =>
this._renderItem(item, index)
)}
</div>`;
}
private _renderKeyboardButton() {
return html`<div class="keyboard-container">
<icon-button
size="36px"
@click=${() => {
if (this.keyboard.staticHeight$.value === 0) {
this._closeToolPanel();
return;
}
if (this.keyboard.visible$.peek()) {
this.keyboard.hide();
} else {
this.keyboard.show();
}
}}
>
${KeyboardIcon()}
</icon-button>
</div>`;
}
override connectedCallback() {
super.connectedCallback();
// There are two cases that `_expanded$` will be true:
// 1. when virtual keyboard is opened, the panel need to be expanded and overlapped by the keyboard,
// so that the toolbar will be on the top of the keyboard.
// 2. the panel is opened, whether the keyboard is closed or not exists (e.g. a physical keyboard connected)
//
// There is one case that `_expanded$` will be false:
// 1. the panel is closed, and the keyboard is closed, the toolbar will be rendered at the bottom of the viewport
this._disposables.add(
effect(() => {
if (this.keyboard.visible$.value || this.panelOpened) {
this._expanded$.value = true;
} else {
this._expanded$.value = false;
}
})
);
// prevent editor blur when click item in toolbar
this.disposables.addFromEvent(this, 'pointerdown', e => {
e.preventDefault();
});
this.disposables.add(
effect(() => {
const std = this.rootComponent.std;
void std.selection.value;
// wait cursor updated
requestAnimationFrame(() => {
this._scrollCurrentBlockIntoView();
});
})
);
this.disposables.add(
effect(() => {
// sometime the keyboard will auto show when user click into different paragraph in Android,
// so we need to close the tool panel explicitly when the keyboard is visible
if (this.keyboard.visible$.value) {
this._closeToolPanel();
}
})
);
this._watchAutoShow();
this.disposables.add(() => {
if (this._resetPanelIndexTimeoutId) {
clearTimeout(this._resetPanelIndexTimeoutId);
this._resetPanelIndexTimeoutId = null;
}
});
}
private _watchAutoShow() {
const autoShowSubToolbars: { path: number[]; signal: Signal<boolean> }[] =
[];
const traverse = (item: KeyboardToolbarItem, path: number[]) => {
if (isKeyboardSubToolBarConfig(item) && item.autoShow) {
autoShowSubToolbars.push({
path,
signal: item.autoShow(this._context),
});
item.items.forEach((subItem, index) => {
traverse(subItem, [...path, index]);
});
}
};
this.config.items.forEach((item, index) => {
traverse(item, [index]);
});
const samePath = (a: number[], b: number[]) =>
a.length === b.length && a.every((v, i) => v === b[i]);
let prevPath = this._path$.peek();
this.disposables.add(
effect(() => {
autoShowSubToolbars.forEach(({ path, signal }) => {
if (signal.value) {
if (samePath(this._path$.peek(), path)) return;
prevPath = this._path$.peek();
this._path$.value = path;
} else {
this._path$.value = prevPath;
}
});
})
);
}
override firstUpdated() {
// workaround for the virtual keyboard showing transition animation
const timeoutId = setTimeout(() => {
this._scrollCurrentBlockIntoView();
}, 700);
this.disposables.add(() => {
clearTimeout(timeoutId);
});
}
override render() {
return html`
<div class="keyboard-toolbar">
${this._renderItems()}
<div class="divider"></div>
${this._renderKeyboardButton()}
</div>
<affine-keyboard-tool-panel
.config=${this._currentPanelConfig}
.context=${this._context}
style=${styleMap({
height: this.panelHeight,
paddingBottom: this.keyboard.appTabSafeArea$.value,
})}
></affine-keyboard-tool-panel>
`;
}
@property({ attribute: false })
accessor keyboard!: VirtualKeyboardProviderWithAction;
@property({ attribute: false })
accessor config!: KeyboardToolbarConfig;
@property({ attribute: false })
accessor rootComponent!: BlockComponent;
}