mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 12:36:24 +08:00
feat(editor): extract keyboard toolbar widget (#11707)
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js';
|
||||
export * from './keyboard-toolbar/index.js';
|
||||
export { AffineModalWidget } from './modal/modal.js';
|
||||
export { AffinePageDraggingAreaWidget } from './page-dragging-area/page-dragging-area.js';
|
||||
export * from './viewport-overlay/viewport-overlay.js';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
import {
|
||||
AFFINE_KEYBOARD_TOOLBAR_WIDGET,
|
||||
AffineKeyboardToolbarWidget,
|
||||
} from './index.js';
|
||||
import {
|
||||
AFFINE_KEYBOARD_TOOL_PANEL,
|
||||
AffineKeyboardToolPanel,
|
||||
} from './keyboard-tool-panel.js';
|
||||
import {
|
||||
AFFINE_KEYBOARD_TOOLBAR,
|
||||
AffineKeyboardToolbar,
|
||||
} from './keyboard-toolbar.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
AFFINE_KEYBOARD_TOOLBAR_WIDGET,
|
||||
AffineKeyboardToolbarWidget
|
||||
);
|
||||
customElements.define(AFFINE_KEYBOARD_TOOLBAR, AffineKeyboardToolbar);
|
||||
customElements.define(AFFINE_KEYBOARD_TOOL_PANEL, AffineKeyboardToolPanel);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_KEYBOARD_TOOLBAR]: AffineKeyboardToolbar;
|
||||
[AFFINE_KEYBOARD_TOOL_PANEL]: AffineKeyboardToolPanel;
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import {
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { html } from 'lit';
|
||||
|
||||
export function HeadingIcon(i: 1 | 2 | 3 | 4 | 5 | 6) {
|
||||
switch (i) {
|
||||
case 1:
|
||||
return Heading1Icon();
|
||||
case 2:
|
||||
return Heading2Icon();
|
||||
case 3:
|
||||
return Heading3Icon();
|
||||
case 4:
|
||||
return Heading4Icon();
|
||||
case 5:
|
||||
return Heading5Icon();
|
||||
case 6:
|
||||
return Heading6Icon();
|
||||
default:
|
||||
return Heading1Icon();
|
||||
}
|
||||
}
|
||||
|
||||
export const HighLightDuotoneIcon = (color: string) =>
|
||||
html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M5.8291 16.441L7.91757 18.5295L6.57811 19.8689C6.53119 19.9158 6.46406 19.9364 6.3989 19.9239L3.37036 19.3412C3.21285 19.3109 3.15331 19.1168 3.26673 19.0034L5.8291 16.441Z"
|
||||
fill="${color}"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M19.0095 3.63759C17.9526 2.58067 16.26 2.516 15.1255 3.48919L7.32135 10.1837C6.35438 11.0132 6.05275 12.3823 6.58163 13.5414L6.73501 13.8775L5.67697 14.9356C5.30169 15.3108 5.30169 15.9193 5.67697 16.2946L8.06379 18.6814C8.43907 19.0567 9.04752 19.0567 9.4228 18.6814L10.4808 17.6234L10.8171 17.7768C11.9761 18.3057 13.3452 18.0041 14.1747 17.0371L20.8692 9.23294C21.8424 8.09846 21.7778 6.40588 20.7208 5.34896L19.0095 3.63759ZM16.1021 4.62769C16.6415 4.16498 17.4463 4.19572 17.9488 4.69825L19.6602 6.40962C20.1627 6.91215 20.1935 7.7169 19.7307 8.25631L14.6424 14.188L10.1704 9.71604L16.1021 4.62769ZM9.02857 10.6955L8.29798 11.3222C7.83822 11.7166 7.6948 12.3676 7.94627 12.9187L8.29785 13.6892C8.4348 13.9893 8.37947 14.3544 8.13372 14.6001L7.11878 15.6151L8.74329 17.2396L9.75812 16.2247C10.004 15.9789 10.3691 15.9236 10.6693 16.0606L11.4398 16.4122C11.9908 16.6636 12.6418 16.5202 13.0362 16.0605L13.6629 15.3299L9.02857 10.6955Z"
|
||||
fill="${cssVarV2('icon/primary')}"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const TextColorIcon = (color: string) =>
|
||||
html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.0627 6.16255C14.385 5.30291 15.2068 4.7334 16.1249 4.7334C17.043 4.7334 17.8648 5.30291 18.1872 6.16255L23.7279 20.9378C23.9219 21.455 23.6599 22.0314 23.1427 22.2253C22.6256 22.4192 22.0492 22.1572 21.8553 21.6401L20.2289 17.3031H12.021L10.3946 21.6401C10.2007 22.1572 9.62428 22.4192 9.10716 22.2253C8.59004 22.0314 8.32803 21.455 8.52195 20.9378L14.0627 6.16255ZM12.771 15.3031H19.4789L16.3146 6.8648C16.2849 6.78576 16.2094 6.7334 16.1249 6.7334C16.0405 6.7334 15.965 6.78576 15.9353 6.8648L12.771 15.3031Z"
|
||||
fill="${cssVarV2('icon/primary')}"
|
||||
/>
|
||||
<rect
|
||||
x="5.45837"
|
||||
y="24"
|
||||
width="21.3333"
|
||||
height="3.33333"
|
||||
rx="1"
|
||||
fill=${color}
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const TextBackgroundDuotoneIcon = (color: string) =>
|
||||
html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.57507 7.33336C4.57507 5.60287 5.97791 4.20003 7.70841 4.20003H25.0417C26.7722 4.20003 28.1751 5.60287 28.1751 7.33336V24.6667C28.1751 26.3972 26.7722 27.8 25.0417 27.8H7.70841C5.97791 27.8 4.57507 26.3972 4.57507 24.6667V7.33336Z"
|
||||
fill="${color}"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.57495 7.33333C4.57495 5.60284 5.97779 4.2 7.70828 4.2H25.0416C26.7721 4.2 28.175 5.60284 28.175 7.33333V24.6667C28.175 26.3972 26.7721 27.8 25.0416 27.8H7.70828C5.97779 27.8 4.57495 26.3972 4.57495 24.6667V7.33333ZM7.70828 5.13333C6.49326 5.13333 5.50828 6.1183 5.50828 7.33333V24.6667C5.50828 25.8817 6.49326 26.8667 7.70828 26.8667H25.0416C26.2566 26.8667 27.2416 25.8817 27.2416 24.6667V7.33333C27.2416 6.1183 26.2566 5.13333 25.0416 5.13333H7.70828Z"
|
||||
fill="black"
|
||||
fill-opacity="0.22"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.5379 10.0064C14.8251 9.24064 15.5571 8.73332 16.375 8.73332C17.1928 8.73332 17.9249 9.24064 18.2121 10.0064L22.6446 21.8266C22.8386 22.3438 22.5766 22.9202 22.0594 23.1141C21.5423 23.308 20.9659 23.046 20.772 22.5289L19.5196 19.1891H13.2304L11.978 22.5289C11.7841 23.046 11.2076 23.308 10.6905 23.1141C10.1734 22.9202 9.9114 22.3438 10.1053 21.8266L14.5379 10.0064ZM13.9804 17.1891H18.7696L16.375 10.8035L13.9804 17.1891Z"
|
||||
fill="${cssVarV2('text/primary')}"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export const FigmaDuotoneIcon = html`<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g id="Figma_Duotone">
|
||||
<path
|
||||
id="Vector"
|
||||
d="M8.41842 22.5027C10.3047 22.5027 11.8356 20.9719 11.8356 19.0856V15.6685H8.41842C6.53216 15.6685 5.00128 17.1993 5.00128 19.0856C5.00128 20.9719 6.53216 22.5027 8.41842 22.5027Z"
|
||||
fill="#0ACF83"
|
||||
/>
|
||||
<path
|
||||
id="Vector_2"
|
||||
d="M5.00128 12.2514C5.00128 10.3651 6.53216 8.83423 8.41842 8.83423H11.8356V15.6685H8.41842C6.53216 15.6685 5.00128 14.1376 5.00128 12.2514Z"
|
||||
fill="#A259FF"
|
||||
/>
|
||||
<path
|
||||
id="Vector_3"
|
||||
d="M5.00146 5.41714C5.00146 3.53088 6.53234 2 8.4186 2H11.8357V8.83428H8.4186C6.53234 8.83428 5.00146 7.3034 5.00146 5.41714Z"
|
||||
fill="#F24E1E"
|
||||
/>
|
||||
<path
|
||||
id="Vector_4"
|
||||
d="M11.8356 2H15.2527C17.139 2 18.6699 3.53088 18.6699 5.41714C18.6699 7.3034 17.139 8.83428 15.2527 8.83428H11.8356V2Z"
|
||||
fill="#FF7262"
|
||||
/>
|
||||
<path
|
||||
id="Vector_5"
|
||||
d="M18.6699 12.2514C18.6699 14.1376 17.139 15.6685 15.2527 15.6685C13.3665 15.6685 11.8356 14.1376 11.8356 12.2514C11.8356 10.3651 13.3665 8.83423 15.2527 8.83423C17.139 8.83423 18.6699 10.3651 18.6699 12.2514Z"
|
||||
fill="#1ABCFE"
|
||||
/>
|
||||
</g>
|
||||
</svg> `;
|
||||
@@ -1,141 +0,0 @@
|
||||
import { getDocTitleByEditorHost } from '@blocksuite/affine-fragment-doc-title';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
isVirtualKeyboardProviderWithAction,
|
||||
VirtualKeyboardProvider,
|
||||
type VirtualKeyboardProviderWithAction,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { WidgetComponent } from '@blocksuite/std';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
|
||||
import { RootBlockConfigExtension } from '../../root-config.js';
|
||||
import { defaultKeyboardToolbarConfig } from './config.js';
|
||||
|
||||
export * from './config.js';
|
||||
|
||||
export const AFFINE_KEYBOARD_TOOLBAR_WIDGET = 'affine-keyboard-toolbar-widget';
|
||||
|
||||
export class AffineKeyboardToolbarWidget extends WidgetComponent<RootBlockModel> {
|
||||
private readonly _close = (blur: boolean) => {
|
||||
if (blur) {
|
||||
if (document.activeElement === this._docTitle?.inlineEditorContainer) {
|
||||
this._docTitle?.inlineEditor?.setInlineRange(null);
|
||||
this._docTitle?.inlineEditor?.eventSource?.blur();
|
||||
} else if (document.activeElement === this.block?.rootComponent) {
|
||||
this.std.selection.clear();
|
||||
}
|
||||
}
|
||||
this._show$.value = false;
|
||||
};
|
||||
|
||||
private readonly _show$ = signal(false);
|
||||
|
||||
private _initialInputMode: string = '';
|
||||
|
||||
get keyboard(): VirtualKeyboardProviderWithAction & { fallback?: boolean } {
|
||||
const provider = this.std.get(VirtualKeyboardProvider);
|
||||
if (isVirtualKeyboardProviderWithAction(provider)) return provider;
|
||||
|
||||
return {
|
||||
// fallback keyboard actions
|
||||
show: () => {
|
||||
const rootComponent = this.block?.rootComponent;
|
||||
if (rootComponent && rootComponent === document.activeElement) {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
}
|
||||
},
|
||||
hide: () => {
|
||||
const rootComponent = this.block?.rootComponent;
|
||||
if (rootComponent && rootComponent === document.activeElement) {
|
||||
rootComponent.inputMode = 'none';
|
||||
}
|
||||
},
|
||||
...provider,
|
||||
};
|
||||
}
|
||||
|
||||
private get _docTitle() {
|
||||
return getDocTitleByEditorHost(this.std.host);
|
||||
}
|
||||
|
||||
get config() {
|
||||
return {
|
||||
...defaultKeyboardToolbarConfig,
|
||||
...this.std.getOptional(RootBlockConfigExtension.identifier)
|
||||
?.keyboardToolbar,
|
||||
};
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
const rootComponent = this.block?.rootComponent;
|
||||
if (rootComponent) {
|
||||
this.disposables.addFromEvent(rootComponent, 'focus', () => {
|
||||
this._show$.value = true;
|
||||
});
|
||||
this.disposables.addFromEvent(rootComponent, 'blur', () => {
|
||||
this._show$.value = false;
|
||||
});
|
||||
|
||||
if (this.keyboard.fallback) {
|
||||
this._initialInputMode = rootComponent.inputMode;
|
||||
this.disposables.add(() => {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
});
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
// recover input mode when keyboard toolbar is hidden
|
||||
if (!this._show$.value) {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this._docTitle) {
|
||||
const { inlineEditorContainer } = this._docTitle;
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'focus', () => {
|
||||
this._show$.value = true;
|
||||
});
|
||||
this.disposables.addFromEvent(inlineEditorContainer, 'blur', () => {
|
||||
this._show$.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (
|
||||
this.doc.readonly ||
|
||||
!IS_MOBILE ||
|
||||
!this.doc
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_mobile_keyboard_toolbar')
|
||||
)
|
||||
return nothing;
|
||||
|
||||
if (!this._show$.value) return nothing;
|
||||
|
||||
if (!this.block?.rootComponent) return nothing;
|
||||
|
||||
return html`<blocksuite-portal
|
||||
.shadowDom=${false}
|
||||
.template=${html`<affine-keyboard-toolbar
|
||||
.keyboard=${this.keyboard}
|
||||
.config=${this.config}
|
||||
.rootComponent=${this.block.rootComponent}
|
||||
.close=${this._close}
|
||||
></affine-keyboard-toolbar>`}
|
||||
></blocksuite-portal>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_KEYBOARD_TOOLBAR_WIDGET]: AffineKeyboardToolbarWidget;
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/std';
|
||||
import { html, nothing, type PropertyValues } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type {
|
||||
KeyboardIconType,
|
||||
KeyboardToolbarActionItem,
|
||||
KeyboardToolbarContext,
|
||||
KeyboardToolPanelConfig,
|
||||
KeyboardToolPanelGroup,
|
||||
} from './config.js';
|
||||
import { keyboardToolPanelStyles } from './styles.js';
|
||||
|
||||
export const AFFINE_KEYBOARD_TOOL_PANEL = 'affine-keyboard-tool-panel';
|
||||
|
||||
@requiredProperties({
|
||||
context: PropTypes.object,
|
||||
})
|
||||
export class AffineKeyboardToolPanel extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = keyboardToolPanelStyles;
|
||||
|
||||
private readonly _handleItemClick = (item: KeyboardToolbarActionItem) => {
|
||||
if (item.disableWhen && item.disableWhen(this.context)) return;
|
||||
if (item.action) {
|
||||
Promise.resolve(item.action(this.context)).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
private _renderGroup(group: KeyboardToolPanelGroup) {
|
||||
const items = group.items.filter(
|
||||
item => item.showWhen?.(this.context) ?? true
|
||||
);
|
||||
|
||||
return html`<div class="keyboard-tool-panel-group">
|
||||
<div class="keyboard-tool-panel-group-header">${group.name}</div>
|
||||
<div class="keyboard-tool-panel-group-item-container">
|
||||
${repeat(
|
||||
items,
|
||||
item => item.name,
|
||||
item => this._renderItem(item)
|
||||
)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderIcon(icon: KeyboardIconType) {
|
||||
return typeof icon === 'function' ? icon(this.context) : icon;
|
||||
}
|
||||
|
||||
private _renderItem(item: KeyboardToolbarActionItem) {
|
||||
return html`<div class="keyboard-tool-panel-item">
|
||||
<button @click=${() => this._handleItemClick(item)}>
|
||||
${this._renderIcon(item.icon)}
|
||||
</button>
|
||||
<span>${item.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.config) return nothing;
|
||||
|
||||
const groups = this.config.groups
|
||||
.map(group => (typeof group === 'function' ? group(this.context) : group))
|
||||
.filter((group): group is KeyboardToolPanelGroup => group !== null);
|
||||
|
||||
return repeat(
|
||||
groups,
|
||||
group => group.name,
|
||||
group => this._renderGroup(group)
|
||||
);
|
||||
}
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has('height')) {
|
||||
this.style.height = `${this.height}px`;
|
||||
if (this.height === 0) {
|
||||
this.style.padding = '0';
|
||||
} else {
|
||||
this.style.padding = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config: KeyboardToolPanelConfig | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: KeyboardToolbarContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor height = 0;
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
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 {
|
||||
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 { PageRootBlockComponent } from '../../page/page-root-block';
|
||||
import type {
|
||||
KeyboardIconType,
|
||||
KeyboardToolbarConfig,
|
||||
KeyboardToolbarContext,
|
||||
KeyboardToolbarItem,
|
||||
KeyboardToolPanelConfig,
|
||||
} from './config';
|
||||
import { PositionController } from './position-controller';
|
||||
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(PageRootBlockComponent),
|
||||
})
|
||||
export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = keyboardToolbarStyles;
|
||||
|
||||
/** This field records the panel static height same as the virtual keyboard height */
|
||||
panelHeight$ = signal(0);
|
||||
|
||||
positionController = new PositionController(this);
|
||||
|
||||
get std() {
|
||||
return this.rootComponent.std;
|
||||
}
|
||||
|
||||
get panelOpened() {
|
||||
return this._currentPanelIndex$.value !== -1;
|
||||
}
|
||||
|
||||
private readonly _closeToolPanel = () => {
|
||||
this._currentPanelIndex$.value = -1;
|
||||
if (!this.keyboard.visible$.peek()) this.keyboard.show();
|
||||
};
|
||||
|
||||
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 {
|
||||
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,
|
||||
closeToolbar: (blur = false) => {
|
||||
this.close(blur);
|
||||
},
|
||||
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 (document.activeElement !== this.rootComponent)
|
||||
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=${() => {
|
||||
this.close(true);
|
||||
}}
|
||||
>
|
||||
${KeyboardIcon()}
|
||||
</icon-button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// 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;
|
||||
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();
|
||||
}
|
||||
|
||||
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
|
||||
setTimeout(() => {
|
||||
this._scrollCurrentBlockIntoView();
|
||||
}, 700);
|
||||
}
|
||||
|
||||
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}
|
||||
.height=${this.panelHeight$.value}
|
||||
></affine-keyboard-tool-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor keyboard!: VirtualKeyboardProviderWithAction;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor close: (blur: boolean) => void = () => {};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config!: KeyboardToolbarConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor rootComponent!: PageRootBlockComponent;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { type VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import type { BlockStdScope, ShadowlessElement } from '@blocksuite/std';
|
||||
import { effect, type Signal } from '@preact/signals-core';
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
|
||||
/**
|
||||
* This controller is used to control the keyboard toolbar position
|
||||
*/
|
||||
export class PositionController implements ReactiveController {
|
||||
private readonly _disposables = new DisposableGroup();
|
||||
|
||||
host: ReactiveControllerHost &
|
||||
ShadowlessElement & {
|
||||
std: BlockStdScope;
|
||||
panelHeight$: Signal<number>;
|
||||
keyboard: VirtualKeyboardProvider;
|
||||
panelOpened: boolean;
|
||||
};
|
||||
|
||||
constructor(host: PositionController['host']) {
|
||||
(this.host = host).addController(this);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
const { keyboard } = this.host;
|
||||
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
if (keyboard.visible$.value) {
|
||||
this.host.panelHeight$.value = keyboard.height$.value;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.host.style.bottom = '0px';
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { css } from 'lit';
|
||||
|
||||
export const keyboardToolbarStyles = css`
|
||||
affine-keyboard-toolbar {
|
||||
position: fixed;
|
||||
display: block;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.keyboard-toolbar {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0px 8px;
|
||||
box-sizing: border-box;
|
||||
gap: 8px;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
|
||||
background-color: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
box-shadow: 0px -4px 10px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
> div {
|
||||
padding-top: 4px;
|
||||
}
|
||||
> div:not(.item-container) {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
icon-button svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 8px;
|
||||
padding-bottom: 0px;
|
||||
|
||||
icon-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.item-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 24px;
|
||||
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
}
|
||||
`;
|
||||
|
||||
export const keyboardToolPanelStyles = css`
|
||||
affine-keyboard-tool-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
padding: 16px 4px 8px 8px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
background-color: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
}
|
||||
|
||||
${scrollbarStyle('affine-keyboard-tool-panel')}
|
||||
|
||||
.keyboard-tool-panel-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.keyboard-tool-panel-group-header {
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
|
||||
/* Footnote/Emphasized */
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 590;
|
||||
line-height: 18px; /* 138.462% */
|
||||
}
|
||||
|
||||
.keyboard-tool-panel-group-item-container {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
column-gap: 12px;
|
||||
row-gap: 12px;
|
||||
}
|
||||
|
||||
.keyboard-tool-panel-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
align-self: stretch;
|
||||
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
background: ${unsafeCSSVarV2('layer/background/secondary')};
|
||||
}
|
||||
|
||||
button:active {
|
||||
background: #00000012;
|
||||
}
|
||||
|
||||
button svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-x: hidden;
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,43 +0,0 @@
|
||||
import type {
|
||||
KeyboardSubToolbarConfig,
|
||||
KeyboardToolbarActionItem,
|
||||
KeyboardToolbarItem,
|
||||
KeyboardToolPanelConfig,
|
||||
} from './config.js';
|
||||
|
||||
export function isKeyboardToolBarActionItem(
|
||||
item: KeyboardToolbarItem
|
||||
): item is KeyboardToolbarActionItem {
|
||||
return 'action' in item;
|
||||
}
|
||||
|
||||
export function isKeyboardSubToolBarConfig(
|
||||
item: KeyboardToolbarItem
|
||||
): item is KeyboardSubToolbarConfig {
|
||||
return 'items' in item;
|
||||
}
|
||||
|
||||
export function isKeyboardToolPanelConfig(
|
||||
item: KeyboardToolbarItem
|
||||
): item is KeyboardToolPanelConfig {
|
||||
return 'groups' in item;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date) {
|
||||
// yyyy-mm-dd
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const strTime = `${year}-${month}-${day}`;
|
||||
return strTime;
|
||||
}
|
||||
|
||||
export function formatTime(date: Date) {
|
||||
// mm-dd hh:mm
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const strTime = `${month}-${day} ${hours}:${minutes}`;
|
||||
return strTime;
|
||||
}
|
||||
Reference in New Issue
Block a user