refactor(editor): unify directories naming (#11516)

**Directory Structure Changes**

- Renamed multiple block-related directories by removing the "block-" prefix:
  - `block-attachment` → `attachment`
  - `block-bookmark` → `bookmark`
  - `block-callout` → `callout`
  - `block-code` → `code`
  - `block-data-view` → `data-view`
  - `block-database` → `database`
  - `block-divider` → `divider`
  - `block-edgeless-text` → `edgeless-text`
  - `block-embed` → `embed`
This commit is contained in:
Saul-Mirone
2025-04-07 12:34:40 +00:00
parent e1bd2047c4
commit 1f45cc5dec
893 changed files with 439 additions and 460 deletions

View File

@@ -0,0 +1,184 @@
import { toast } from '@blocksuite/affine-components/toast';
import type {
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { insertContent } from '@blocksuite/affine-rich-text';
import {
ArrowDownBigIcon,
ArrowUpBigIcon,
CopyIcon,
DeleteIcon,
DualLinkIcon,
NowIcon,
TodayIcon,
TomorrowIcon,
YesterdayIcon,
} from '@blocksuite/icons/lit';
import { type DeltaInsert, Slice, Text } from '@blocksuite/store';
import { slashMenuToolTips } from './tooltips';
import type { SlashMenuConfig } from './types';
import { formatDate, formatTime } from './utils';
export const defaultSlashMenuConfig: SlashMenuConfig = {
items: () => {
const now = new Date();
const tomorrow = new Date();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
tomorrow.setDate(tomorrow.getDate() + 1);
return [
{
name: 'Today',
icon: TodayIcon(),
tooltip: slashMenuToolTips['Today'],
description: formatDate(now),
group: '6_Date@0',
action: ({ std, model }) => {
insertContent(std, model, formatDate(now));
},
},
{
name: 'Tomorrow',
icon: TomorrowIcon(),
tooltip: slashMenuToolTips['Tomorrow'],
description: formatDate(tomorrow),
group: '6_Date@1',
action: ({ std, model }) => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
insertContent(std, model, formatDate(tomorrow));
},
},
{
name: 'Yesterday',
icon: YesterdayIcon(),
tooltip: slashMenuToolTips['Yesterday'],
description: formatDate(yesterday),
group: '6_Date@2',
action: ({ std, model }) => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
insertContent(std, model, formatDate(yesterday));
},
},
{
name: 'Now',
icon: NowIcon(),
tooltip: slashMenuToolTips['Now'],
description: formatTime(now),
group: '6_Date@3',
action: ({ std, model }) => {
insertContent(std, model, formatTime(now));
},
},
{
name: 'Move Up',
description: 'Shift this line up.',
icon: ArrowUpBigIcon(),
tooltip: slashMenuToolTips['Move Up'],
group: '8_Actions@0',
action: ({ std, model }) => {
const { host } = std;
const previousSiblingModel = host.doc.getPrev(model);
if (!previousSiblingModel) return;
const parentModel = host.doc.getParent(previousSiblingModel);
if (!parentModel) return;
host.doc.moveBlocks([model], parentModel, previousSiblingModel, true);
},
},
{
name: 'Move Down',
description: 'Shift this line down.',
icon: ArrowDownBigIcon(),
tooltip: slashMenuToolTips['Move Down'],
group: '8_Actions@1',
action: ({ std, model }) => {
const { host } = std;
const nextSiblingModel = host.doc.getNext(model);
if (!nextSiblingModel) return;
const parentModel = host.doc.getParent(nextSiblingModel);
if (!parentModel) return;
host.doc.moveBlocks([model], parentModel, nextSiblingModel, false);
},
},
{
name: 'Copy',
description: 'Copy this line to clipboard.',
icon: CopyIcon(),
tooltip: slashMenuToolTips['Copy'],
group: '8_Actions@2',
action: ({ std, model }) => {
const slice = Slice.fromModels(std.store, [model]);
std.clipboard
.copy(slice)
.then(() => {
toast(std.host, 'Copied to clipboard');
})
.catch(e => {
console.error(e);
});
},
},
{
name: 'Duplicate',
description: 'Create a duplicate of this line.',
icon: DualLinkIcon(),
tooltip: slashMenuToolTips['Copy'],
group: '8_Actions@3',
action: ({ std, model }) => {
if (!model.text || !(model.text instanceof Text)) {
console.error("Can't duplicate a block without text");
return;
}
const { host } = std;
const parent = host.doc.getParent(model);
if (!parent) {
console.error(
'Failed to duplicate block! Parent not found: ' +
model.id +
'|' +
model.flavour
);
return;
}
const index = parent.children.indexOf(model);
// FIXME: this clone is not correct
host.doc.addBlock(
model.flavour,
{
type: (model as ParagraphBlockModel).props.type,
text: new Text(
(
model as ParagraphBlockModel
).props.text.toDelta() as DeltaInsert[]
),
checked: (model as ListBlockModel).props.checked,
},
host.doc.getParent(model),
index
);
},
},
{
name: 'Delete',
description: 'Remove this line permanently.',
searchAlias: ['remove'],
icon: DeleteIcon(),
tooltip: slashMenuToolTips['Delete'],
group: '8_Actions@4',
action: ({ std, model }) => {
std.host.doc.deleteBlock(model);
},
},
];
},
};

View File

@@ -0,0 +1,4 @@
export const AFFINE_SLASH_MENU_WIDGET = 'affine-slash-menu-widget';
export const AFFINE_SLASH_MENU_TRIGGER_KEY = '/';
export const AFFINE_SLASH_MENU_TOOLTIP_TIMEOUT = 800;
export const AFFINE_SLASH_MENU_MAX_HEIGHT = 390;

View File

@@ -0,0 +1,15 @@
import { AFFINE_SLASH_MENU_WIDGET } from './consts';
import { InnerSlashMenu, SlashMenu } from './slash-menu-popover';
import { AffineSlashMenuWidget } from './widget';
export function effects() {
customElements.define(AFFINE_SLASH_MENU_WIDGET, AffineSlashMenuWidget);
customElements.define('affine-slash-menu', SlashMenu);
customElements.define('inner-slash-menu', InnerSlashMenu);
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_SLASH_MENU_WIDGET]: AffineSlashMenuWidget;
}
}

View File

@@ -0,0 +1,51 @@
import { type Container, createIdentifier } from '@blocksuite/global/di';
import {
type BlockStdScope,
StdIdentifier,
WidgetViewExtension,
} from '@blocksuite/std';
import { Extension, type ExtensionType } from '@blocksuite/store';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { defaultSlashMenuConfig } from './config';
import { AFFINE_SLASH_MENU_WIDGET } from './consts';
import type { SlashMenuConfig } from './types';
import { mergeSlashMenuConfigs } from './utils';
export class SlashMenuExtension extends Extension {
config: SlashMenuConfig;
static override setup(di: Container) {
WidgetViewExtension(
'affine:page',
AFFINE_SLASH_MENU_WIDGET,
literal`${unsafeStatic(AFFINE_SLASH_MENU_WIDGET)}`
).setup(di);
di.add(this, [StdIdentifier]);
SlashMenuConfigExtension('default', defaultSlashMenuConfig).setup(di);
}
constructor(readonly std: BlockStdScope) {
super();
this.config = mergeSlashMenuConfigs(
this.std.provider.getAll(SlashMenuConfigIdentifier)
);
}
}
export const SlashMenuConfigIdentifier = createIdentifier<SlashMenuConfig>(
`${AFFINE_SLASH_MENU_WIDGET}-config`
);
export function SlashMenuConfigExtension(
id: string,
config: SlashMenuConfig
): ExtensionType {
return {
setup: di => {
di.addImpl(SlashMenuConfigIdentifier(id), config);
},
};
}

View File

@@ -0,0 +1,3 @@
export { AFFINE_SLASH_MENU_WIDGET } from './consts';
export * from './extensions';
export * from './types';

View File

@@ -0,0 +1,668 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import {
cleanSpecifiedTail,
getInlineEditorByModel,
getTextContentFromInlineRange,
} from '@blocksuite/affine-rich-text';
import {
DocModeProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import {
createKeydownObserver,
getCurrentNativeRange,
getPopperPosition,
isControlledKeyboardEvent,
isFuzzyMatch,
substringMatchScore,
} from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
import { autoPlacement, offset } from '@floating-ui/dom';
import { html, LitElement, nothing, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import groupBy from 'lodash-es/groupBy';
import throttle from 'lodash-es/throttle';
import {
AFFINE_SLASH_MENU_MAX_HEIGHT,
AFFINE_SLASH_MENU_TOOLTIP_TIMEOUT,
AFFINE_SLASH_MENU_TRIGGER_KEY,
} from './consts.js';
import { slashItemToolTipStyle, styles } from './styles.js';
import type {
SlashMenuActionItem,
SlashMenuContext,
SlashMenuItem,
SlashMenuSubMenu,
} from './types.js';
import {
isActionItem,
isSubMenuItem,
parseGroup,
slashItemClassName,
} from './utils.js';
type InnerSlashMenuContext = SlashMenuContext & {
onClickItem: (item: SlashMenuActionItem) => void;
searching: boolean;
};
export class SlashMenu extends WithDisposable(LitElement) {
static override styles = styles;
private get _telemetry() {
return this.context.std.getOptional(TelemetryProvider);
}
private get _editorMode() {
return this.context.std.get(DocModeProvider).getEditorMode();
}
private readonly _handleClickItem = (item: SlashMenuActionItem) => {
// Need to remove the search string
// We must to do clean the slash string before we do the action
// Otherwise, the action may change the model and cause the slash string to be changed
cleanSpecifiedTail(
this.context.std,
this.context.model,
AFFINE_SLASH_MENU_TRIGGER_KEY + (this._query || '')
);
this.inlineEditor
.waitForUpdate()
.then(() => {
item.action(this.context);
this._telemetry?.track('SelectSlashMenuItem', {
page: this._editorMode ?? undefined,
segment:
this.context.model.flavour === 'affine:edgeless-text'
? 'edgeless-text'
: 'doc',
module: 'slash menu',
control: item.name,
});
this.abortController.abort();
})
.catch(console.error);
};
private readonly _initItemPathMap = () => {
const traverse = (item: SlashMenuItem, path: number[]) => {
this._itemPathMap.set(item, [...path]);
if (isSubMenuItem(item)) {
item.subMenu.forEach((subItem, index) =>
traverse(subItem, [...path, index])
);
}
};
this.items.forEach((item, index) => traverse(item, [index]));
};
private _innerSlashMenuContext!: InnerSlashMenuContext;
private readonly _itemPathMap = new Map<SlashMenuItem, number[]>();
private _queryState: 'off' | 'on' | 'no_result' = 'off';
private readonly _startRange = this.inlineEditor.getInlineRange();
private readonly _updateFilteredItems = () => {
const query = this._query;
if (query === null) {
this.abortController.abort();
return;
}
this._filteredItems = [];
const searchStr = query.toLowerCase();
if (searchStr === '' || searchStr.endsWith(' ')) {
this._queryState = searchStr === '' ? 'off' : 'no_result';
this._innerSlashMenuContext.searching = false;
return;
}
// Layer order traversal
let depth = 0;
let queue = this.items;
while (queue.length !== 0) {
// remove the sub menu item from the previous layer result
this._filteredItems = this._filteredItems.filter(
item => !isSubMenuItem(item)
);
this._filteredItems = this._filteredItems.concat(
queue.filter(({ name, searchAlias = [] }) =>
[name, ...searchAlias].some(str => isFuzzyMatch(str, searchStr))
)
);
// We search first and second layer
if (this._filteredItems.length !== 0 && depth >= 1) break;
queue = queue
.map<typeof queue>(item => {
if (isSubMenuItem(item)) {
return item.subMenu;
} else {
return [];
}
})
.flat();
depth++;
}
this._filteredItems.sort((a, b) => {
return -(
substringMatchScore(a.name, searchStr) -
substringMatchScore(b.name, searchStr)
);
});
this._queryState = this._filteredItems.length === 0 ? 'no_result' : 'on';
this._innerSlashMenuContext.searching = true;
};
private get _query() {
return getTextContentFromInlineRange(this.inlineEditor, this._startRange);
}
get host() {
return this.context.std.host;
}
constructor(
private readonly inlineEditor: AffineInlineEditor,
private readonly abortController = new AbortController()
) {
super();
}
override connectedCallback() {
super.connectedCallback();
this._innerSlashMenuContext = {
...this.context,
onClickItem: this._handleClickItem,
searching: false,
};
this._initItemPathMap();
this._disposables.addFromEvent(this, 'mousedown', e => {
// Prevent input from losing focus
e.preventDefault();
});
const inlineEditor = this.inlineEditor;
if (!inlineEditor || !inlineEditor.eventSource) {
console.error('inlineEditor or eventSource is not found');
return;
}
/**
* Handle arrow key
*
* The slash menu will be closed in the following keyboard cases:
* - Press the space key
* - Press the backspace key and the search string is empty
* - Press the escape key
* - When the search item is empty, the slash menu will be hidden temporarily,
* and if the following key is not the backspace key, the slash menu will be closed
*/
createKeydownObserver({
target: inlineEditor.eventSource,
signal: this.abortController.signal,
interceptor: (event, next) => {
const { key, isComposing, code } = event;
if (key === AFFINE_SLASH_MENU_TRIGGER_KEY) {
// Can not stopPropagation here,
// otherwise the rich text will not be able to trigger a new the slash menu
return;
}
if (key === 'Process' && !isComposing && code === 'Slash') {
// The IME case of above
return;
}
if (key !== 'Backspace' && this._queryState === 'no_result') {
// if the following key is not the backspace key,
// the slash menu will be closed
this.abortController.abort();
return;
}
if (key === 'Escape') {
this.abortController.abort();
event.preventDefault();
event.stopPropagation();
return;
}
if (key === 'ArrowRight' || key === 'ArrowLeft') {
return;
}
next();
},
onInput: isComposition => {
if (isComposition) {
this._updateFilteredItems();
} else {
const subscription = this.inlineEditor.slots.renderComplete.subscribe(
() => {
subscription.unsubscribe();
this._updateFilteredItems();
}
);
}
},
onPaste: () => {
setTimeout(() => {
this._updateFilteredItems();
}, 50);
},
onDelete: () => {
const curRange = this.inlineEditor.getInlineRange();
if (!this._startRange || !curRange) {
return;
}
if (curRange.index < this._startRange.index) {
this.abortController.abort();
}
const subscription = this.inlineEditor.slots.renderComplete.subscribe(
() => {
subscription.unsubscribe();
this._updateFilteredItems();
}
);
},
onAbort: () => this.abortController.abort(),
});
this._telemetry?.track('OpenSlashMenu', {
page: this._editorMode ?? undefined,
type: this.context.model.flavour.split(':').pop(),
module: 'slash menu',
});
}
protected override willUpdate() {
if (!this.hasUpdated) {
const currRage = getCurrentNativeRange();
if (!currRage) {
this.abortController.abort();
return;
}
// Handle position
const updatePosition = throttle(() => {
this._position = getPopperPosition(this, currRage);
}, 10);
this.disposables.addFromEvent(window, 'resize', updatePosition);
updatePosition();
}
}
override render() {
const slashMenuStyles = this._position
? {
transform: `translate(${this._position.x}, ${this._position.y})`,
maxHeight: `${Math.min(this._position.height, AFFINE_SLASH_MENU_MAX_HEIGHT)}px`,
}
: {
visibility: 'hidden',
};
return html`${this._queryState !== 'no_result'
? html` <div
class="overlay-mask"
@click="${() => this.abortController.abort()}"
></div>`
: nothing}
<inner-slash-menu
.context=${this._innerSlashMenuContext}
.menu=${this._queryState === 'off' ? this.items : this._filteredItems}
.mainMenuStyle=${slashMenuStyles}
.abortController=${this.abortController}
>
</inner-slash-menu>`;
}
@state()
private accessor _filteredItems: (SlashMenuActionItem | SlashMenuSubMenu)[] =
[];
@state()
private accessor _position: {
x: string;
y: string;
height: number;
} | null = null;
@property({ attribute: false })
accessor items!: SlashMenuItem[];
@property({ attribute: false })
accessor context!: SlashMenuContext;
}
export class InnerSlashMenu extends WithDisposable(LitElement) {
static override styles = styles;
private readonly _closeSubMenu = () => {
this._subMenuAbortController?.abort();
this._subMenuAbortController = null;
this._currentSubMenu = null;
};
private _currentSubMenu: SlashMenuSubMenu | null = null;
private readonly _openSubMenu = (item: SlashMenuSubMenu) => {
if (item === this._currentSubMenu) return;
const itemElement = this.shadowRoot?.querySelector(
`.${slashItemClassName(item)}`
);
if (!itemElement) return;
this._closeSubMenu();
this._currentSubMenu = item;
this._subMenuAbortController = new AbortController();
this._subMenuAbortController.signal.addEventListener('abort', () => {
this._closeSubMenu();
});
const subMenuElement = createLitPortal({
shadowDom: false,
template: html`<inner-slash-menu
.context=${this.context}
.menu=${item.subMenu}
.depth=${this.depth + 1}
.abortController=${this._subMenuAbortController}
>
${item.subMenu.map(this._renderItem)}
</inner-slash-menu>`,
computePosition: {
referenceElement: itemElement,
autoUpdate: true,
middleware: [
offset(12),
autoPlacement({
allowedPlacements: ['right-start', 'right-end'],
}),
],
},
abortController: this._subMenuAbortController,
});
subMenuElement.style.zIndex = `calc(var(--affine-z-index-popover) + ${this.depth})`;
subMenuElement.focus();
};
private readonly _renderActionItem = (item: SlashMenuActionItem) => {
const { name, icon, description, tooltip } = item;
const hover = item === this._activeItem;
return html`<icon-button
class="slash-menu-item ${slashItemClassName(item)}"
width="100%"
height="44px"
text=${name}
subText=${ifDefined(description)}
data-testid="${name}"
hover=${hover}
@mousemove=${() => {
this._activeItem = item;
this._closeSubMenu();
}}
@click=${() => this.context.onClickItem(item)}
>
${icon && html`<div class="slash-menu-item-icon">${icon}</div>`}
${tooltip &&
html`<affine-tooltip
tip-position="right"
.offset=${22}
.tooltipStyle=${slashItemToolTipStyle}
.hoverOptions=${{
enterDelay: AFFINE_SLASH_MENU_TOOLTIP_TIMEOUT,
allowMultiple: false,
}}
>
<div class="tooltip-figure">${tooltip.figure}</div>
<div class="tooltip-caption">${tooltip.caption}</div>
</affine-tooltip>`}
</icon-button>`;
};
private readonly _renderGroup = (
groupName: string,
items: SlashMenuItem[]
) => {
return html`<div class="slash-menu-group">
${when(
!this.context.searching,
() => html`<div class="slash-menu-group-name">${groupName}</div>`
)}
${items.map(this._renderItem)}
</div>`;
};
private readonly _renderItem = (item: SlashMenuItem) => {
if (isActionItem(item)) return this._renderActionItem(item);
if (isSubMenuItem(item)) return this._renderSubMenuItem(item);
return nothing;
};
private readonly _renderSubMenuItem = (item: SlashMenuSubMenu) => {
const { name, icon, description } = item;
const hover = item === this._activeItem;
return html`<icon-button
class="slash-menu-item ${slashItemClassName(item)}"
width="100%"
height="44px"
text=${name}
subText=${ifDefined(description)}
data-testid="${name}"
hover=${hover}
@mousemove=${() => {
this._activeItem = item;
this._openSubMenu(item);
}}
@touchstart=${() => {
isSubMenuItem(item) &&
(this._currentSubMenu === item
? this._closeSubMenu()
: this._openSubMenu(item));
}}
>
${icon && html`<div class="slash-menu-item-icon">${icon}</div>`}
<div slot="suffix" style="transform: rotate(-90deg);">
${ArrowDownSmallIcon()}
</div>
</icon-button>`;
};
private _subMenuAbortController: AbortController | null = null;
private _scrollToItem(item: SlashMenuItem) {
const shadowRoot = this.shadowRoot;
if (!shadowRoot) {
return;
}
const ele = shadowRoot.querySelector(`icon-button[text="${item.name}"]`);
if (!ele) {
return;
}
ele.scrollIntoView({
block: 'nearest',
});
}
override connectedCallback() {
super.connectedCallback();
// close all sub menus
this.abortController?.signal?.addEventListener('abort', () => {
this._subMenuAbortController?.abort();
});
this.addEventListener('wheel', event => {
if (this._currentSubMenu) {
event.preventDefault();
}
});
const inlineEditor = getInlineEditorByModel(
this.context.std,
this.context.model
);
if (!inlineEditor || !inlineEditor.eventSource) {
console.error('inlineEditor or eventSource is not found');
return;
}
inlineEditor.eventSource.addEventListener(
'keydown',
event => {
if (this._currentSubMenu) return;
if (event.isComposing) return;
const { key, ctrlKey, metaKey, altKey, shiftKey } = event;
const onlyCmd = (ctrlKey || metaKey) && !altKey && !shiftKey;
const onlyShift = shiftKey && !isControlledKeyboardEvent(event);
const notControlShift = !(ctrlKey || metaKey || altKey || shiftKey);
let moveStep = 0;
if (
(key === 'ArrowUp' && notControlShift) ||
(key === 'Tab' && onlyShift) ||
(key === 'P' && onlyCmd) ||
(key === 'p' && onlyCmd)
) {
moveStep = -1;
}
if (
(key === 'ArrowDown' && notControlShift) ||
(key === 'Tab' && notControlShift) ||
(key === 'n' && onlyCmd) ||
(key === 'N' && onlyCmd)
) {
moveStep = 1;
}
if (moveStep !== 0) {
const activeItemIndex = this.menu.indexOf(this._activeItem);
const itemIndex =
(activeItemIndex + moveStep + this.menu.length) % this.menu.length;
this._activeItem = this.menu[itemIndex] as typeof this._activeItem;
this._scrollToItem(this._activeItem);
event.preventDefault();
event.stopPropagation();
}
if (key === 'ArrowRight' && notControlShift) {
if (isSubMenuItem(this._activeItem)) {
this._openSubMenu(this._activeItem);
}
event.preventDefault();
event.stopPropagation();
}
if (key === 'ArrowLeft' && notControlShift) {
if (this.depth != 0) this.abortController.abort();
event.preventDefault();
event.stopPropagation();
}
if (key === 'Escape' && notControlShift) {
this.abortController.abort();
event.preventDefault();
event.stopPropagation();
}
if (key === 'Enter' && notControlShift) {
if (isSubMenuItem(this._activeItem)) {
this._openSubMenu(this._activeItem);
} else if (isActionItem(this._activeItem)) {
this.context.onClickItem(this._activeItem);
}
event.preventDefault();
event.stopPropagation();
}
},
{
capture: true,
signal: this.abortController.signal,
}
);
}
override disconnectedCallback() {
this.abortController.abort();
}
override render() {
if (this.menu.length === 0) return nothing;
const style = styleMap(this.mainMenuStyle ?? { position: 'relative' });
const groups = groupBy(this.menu, ({ group }) =>
group && !this.context.searching ? parseGroup(group)[1] : ''
);
return html`<div
class="slash-menu"
style=${style}
data-testid=${`sub-menu-${this.depth}`}
>
${Object.entries(groups).map(([groupName, items]) =>
this._renderGroup(groupName, items)
)}
</div>`;
}
override willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has('menu') && this.menu.length !== 0) {
this._activeItem = this.menu[0];
// this case happen on query updated
this._subMenuAbortController?.abort();
}
}
@state()
private accessor _activeItem!: SlashMenuActionItem | SlashMenuSubMenu;
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor context!: InnerSlashMenuContext;
@property({ attribute: false })
accessor depth: number = 0;
@property({ attribute: false })
accessor mainMenuStyle: Parameters<typeof styleMap>[0] | null = null;
@property({ attribute: false })
accessor menu!: SlashMenuItem[];
}

View File

@@ -0,0 +1,109 @@
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { baseTheme } from '@toeverything/theme';
import { css, unsafeCSS } from 'lit';
export const styles = css`
.overlay-mask {
pointer-events: auto;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: var(--affine-z-index-popover);
}
.slash-menu {
position: fixed;
left: 0;
top: 0;
box-sizing: border-box;
padding: 8px 4px 8px 8px;
width: 280px;
overflow-y: auto;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
border-radius: 8px;
z-index: var(--affine-z-index-popover);
user-select: none;
/* transition: max-height 0.2s ease-in-out; */
}
${scrollbarStyle('.slash-menu')}
.slash-menu-group-name {
box-sizing: border-box;
padding: 2px 8px;
font-size: var(--affine-font-xs);
font-weight: 500;
line-height: var(--affine-line-height);
text-align: left;
color: var(
--light-textColor-textSecondaryColor,
var(--textColor-textSecondaryColor, #8e8d91)
);
}
.slash-menu-item {
padding: 2px 8px 2px 8px;
justify-content: flex-start;
gap: 10px;
}
.slash-menu-item-icon {
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 4px;
border: 1px solid var(--affine-border-color, #e3e2e4);
border-radius: 4px;
color: var(--affine-icon-color);
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
display: flex;
justify-content: center;
align-items: center;
}
.slash-menu-item-icon svg {
display: block;
width: 100%;
height: 100%;
}
.slash-menu-item.ask-ai {
color: var(--affine-brand-color);
}
.slash-menu-item.github .github-icon {
color: var(--affine-black);
}
`;
export const slashItemToolTipStyle = css`
.affine-tooltip {
display: flex;
padding: 4px 4px 2px 4px;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.tooltip-figure svg {
display: block;
}
.tooltip-caption {
padding-left: 4px;
color: var(
--light-textColor-textSecondaryColor,
var(--textColor-textSecondaryColor, #8e8d91)
);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
line-height: var(--affine-line-height);
}
`;

View File

@@ -0,0 +1,13 @@
import { html } from 'lit';
// prettier-ignore
export const CopyTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1240" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1240)">
<path d="M4 7C4 5.89543 4.89543 5 6 5H172V32H6C4.89543 32 4 31.1046 4 30V7Z" fill="#F4F4F5"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
</g>
</svg>
`;

View File

@@ -0,0 +1,14 @@
import { html } from 'lit';
// prettier-ignore
export const DeleteTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1246" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1246)">
<path d="M4 7C4 5.89543 4.89543 5 6 5H172V32H6C4.89543 32 4 31.1046 4 30V7Z" fill="#FDECEB"/>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
<text fill="#EB4335" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
</g>
</svg>
`;

View File

@@ -0,0 +1,11 @@
import { html } from 'lit';
// prettier-ignore
export const EmptyTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_864" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_864)">
</g>
</svg>
`;

View File

@@ -0,0 +1,51 @@
import type { SlashMenuTooltip } from '../types';
import { CopyTooltip } from './copy';
import { DeleteTooltip } from './delete';
import { MoveDownTooltip } from './move-down';
import { MoveUpTooltip } from './move-up';
import { NowTooltip } from './now';
import { TodayTooltip } from './today';
import { TomorrowTooltip } from './tomorrow';
import { YesterdayTooltip } from './yesterday';
export const slashMenuToolTips: Record<string, SlashMenuTooltip> = {
Today: {
figure: TodayTooltip,
caption: 'Today',
},
Tomorrow: {
figure: TomorrowTooltip,
caption: 'Tomorrow',
},
Yesterday: {
figure: YesterdayTooltip,
caption: 'Yesterday',
},
Now: {
figure: NowTooltip,
caption: 'Now',
},
'Move Up': {
figure: MoveUpTooltip,
caption: 'Move Up',
},
'Move Down': {
figure: MoveDownTooltip,
caption: 'Move Down',
},
Copy: {
figure: CopyTooltip,
caption: 'Copy / Duplicate',
},
Delete: {
figure: DeleteTooltip,
caption: 'Delete',
},
};

View File

@@ -0,0 +1,21 @@
import { html } from 'lit';
// prettier-ignore
export const MoveDownTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1234" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1234)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
<text fill="#A9A9AD" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
</g>
<g clip-path="url(#clip0_16460_1234)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.5073 51.0032C25.2022 50.723 24.7278 50.7432 24.4476 51.0483L20.75 55.0745L20.75 43C20.75 42.5858 20.4142 42.25 20 42.25C19.5858 42.25 19.25 42.5858 19.25 43L19.25 55.0745L15.5524 51.0483C15.2722 50.7432 14.7978 50.723 14.4927 51.0032C14.1876 51.2833 14.1674 51.7578 14.4476 52.0629L19.4476 57.5073C19.5896 57.662 19.79 57.75 20 57.75C20.21 57.75 20.4104 57.662 20.5524 57.5073L25.5524 52.0629C25.8326 51.7578 25.8124 51.2833 25.5073 51.0032Z" fill="#121212"/>
</g>
<defs>
<clipPath id="clip0_16460_1234">
<rect width="24" height="24" fill="white" transform="translate(8 38)"/>
</clipPath>
</defs>
</svg>
`;

View File

@@ -0,0 +1,21 @@
import { html } from 'lit';
// prettier-ignore
export const MoveUpTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1228" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1228)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text>
<text fill="#A9A9AD" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text>
</g>
<g clip-path="url(#clip0_16460_1228)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.5073 16.9968C25.2022 17.277 24.7278 17.2568 24.4476 16.9517L20.75 12.9255L20.75 25C20.75 25.4142 20.4142 25.75 20 25.75C19.5858 25.75 19.25 25.4142 19.25 25L19.25 12.9255L15.5524 16.9517C15.2722 17.2568 14.7978 17.277 14.4927 16.9968C14.1876 16.7167 14.1674 16.2422 14.4476 15.9371L19.4476 10.4927C19.5896 10.338 19.79 10.25 20 10.25C20.21 10.25 20.4104 10.338 20.5524 10.4927L25.5524 15.9371C25.8326 16.2422 25.8124 16.7167 25.5073 16.9968Z" fill="#121212"/>
</g>
<defs>
<clipPath id="clip0_16460_1228">
<rect width="24" height="24" fill="white" transform="translate(8 6)"/>
</clipPath>
</defs>
</svg>
`;

View File

@@ -0,0 +1,14 @@
import { html } from 'lit';
// prettier-ignore
export const NowTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1143" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1143)">
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="38.0488" y="16.6364">now</tspan><tspan x="81.8574" y="16.6364"> and time.&#10;</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert </tspan><tspan x="57.8047" y="16.6364"> date</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">11:45:14 Wed 3 Aug, 2022</tspan></text>
</g>
</svg>
`;

View File

@@ -0,0 +1,14 @@
import { html } from 'lit';
// prettier-ignore
export const TodayTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1128" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1128)">
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="95.5098" y="16.6364">.&#10;</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">Wed 3 Aug, 2022</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert today&#x2019;s date</tspan></text>
</g>
</svg>
`;

View File

@@ -0,0 +1,14 @@
import { html } from 'lit';
// prettier-ignore
export const TomorrowTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1133" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1133)">
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="38.0488" y="16.6364">tomorrow&#x2019;s</tspan><tspan x="114.211" y="16.6364">.&#10;</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">Wed 3 Aug, 2022</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert </tspan><tspan x="90.1582" y="16.6364"> date</tspan></text>
</g>
</svg>
`;

View File

@@ -0,0 +1,14 @@
import { html } from 'lit';
// prettier-ignore
export const YesterdayTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1138" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1138)">
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="38.0488" y="16.6364">yesterday&#x2019;s</tspan><tspan x="115.334" y="16.6364">.&#10;</tspan></text>
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">Wed 3 Aug, 2022</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert </tspan><tspan x="91.2812" y="16.6364"> date</tspan></text>
</g>
</svg>
`;

View File

@@ -0,0 +1,59 @@
import type { BlockStdScope } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import type { TemplateResult } from 'lit';
export type SlashMenuContext = {
std: BlockStdScope;
model: BlockModel;
};
export type SlashMenuTooltip = {
figure: TemplateResult;
caption: string;
};
type SlashMenuItemBase = {
name: string;
description?: string;
icon?: TemplateResult;
/**
* This field defines sorting and grouping of menu items like VSCode.
* The first number indicates the group index, the second number indicates the item index in the group.
* The group name is the string between `_` and `@`.
* You can find an example figure in https://code.visualstudio.com/api/references/contribution-points#menu-example
*/
group?: `${number}_${string}@${number}`;
searchAlias?: string[];
/**
* The condition to show the menu item.
*/
when?: (ctx: SlashMenuContext) => boolean;
};
export type SlashMenuActionItem = SlashMenuItemBase & {
action: (ctx: SlashMenuContext) => void;
tooltip?: SlashMenuTooltip;
/**
* The alias of the menu item for search.
*/
searchAlias?: string[];
};
export type SlashMenuSubMenu = SlashMenuItemBase & {
subMenu: SlashMenuItem[];
};
export type SlashMenuItem = SlashMenuActionItem | SlashMenuSubMenu;
export type SlashMenuConfig = {
/**
* The items in the slash menu. It can be generated dynamically with the context.
*/
items: SlashMenuItem[] | ((ctx: SlashMenuContext) => SlashMenuItem[]);
/**
* Slash menu will not be triggered when the condition is true.
*/
disableWhen?: (ctx: SlashMenuContext) => boolean;
};

View File

@@ -0,0 +1,105 @@
import type {
SlashMenuActionItem,
SlashMenuConfig,
SlashMenuContext,
SlashMenuItem,
SlashMenuSubMenu,
} from './types';
export function isActionItem(item: SlashMenuItem): item is SlashMenuActionItem {
return 'action' in item;
}
export function isSubMenuItem(item: SlashMenuItem): item is SlashMenuSubMenu {
return 'subMenu' in item;
}
export function slashItemClassName({ name }: SlashMenuItem) {
return name.split(' ').join('-').toLocaleLowerCase();
}
export function parseGroup(group: NonNullable<SlashMenuItem['group']>) {
return [
parseInt(group.split('_')[0]),
group.split('_')[1].split('@')[0],
parseInt(group.split('@')[1]),
] as const;
}
function itemCompareFn(a: SlashMenuItem, b: SlashMenuItem) {
if (a.group === undefined && b.group === undefined) return 0;
if (a.group === undefined) return -1;
if (b.group === undefined) return 1;
const [aGroupIndex, aGroupName, aItemIndex] = parseGroup(a.group);
const [bGroupIndex, bGroupName, bItemIndex] = parseGroup(b.group);
if (isNaN(aGroupIndex)) return -1;
if (isNaN(bGroupIndex)) return 1;
if (aGroupIndex < bGroupIndex) return -1;
if (aGroupIndex > bGroupIndex) return 1;
if (aGroupName !== bGroupName) return aGroupName.localeCompare(bGroupName);
if (isNaN(aItemIndex)) return -1;
if (isNaN(bItemIndex)) return 1;
return aItemIndex - bItemIndex;
}
export function buildSlashMenuItems(
items: SlashMenuItem[],
context: SlashMenuContext,
transform?: (item: SlashMenuItem) => SlashMenuItem
): SlashMenuItem[] {
if (transform) items = items.map(transform);
const result = items
.filter(item => (item.when ? item.when(context) : true))
.sort(itemCompareFn)
.map(item => {
if (isSubMenuItem(item)) {
return {
...item,
subMenu: buildSlashMenuItems(item.subMenu, context),
};
} else {
return { ...item };
}
});
return result;
}
export function mergeSlashMenuConfigs(
configs: Map<string, SlashMenuConfig>
): SlashMenuConfig {
return {
items: ctx =>
Array.from(configs.values()).flatMap(({ items }) =>
typeof items === 'function' ? items(ctx) : items
),
disableWhen: ctx =>
configs
.values()
.map(({ disableWhen }) => disableWhen?.(ctx) ?? false)
.some(Boolean),
};
}
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;
}

View File

@@ -0,0 +1,184 @@
import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { UIEventStateContext } from '@blocksuite/std';
import { TextSelection, WidgetComponent } from '@blocksuite/std';
import { InlineEditor } from '@blocksuite/std/inline';
import debounce from 'lodash-es/debounce';
import { AFFINE_SLASH_MENU_TRIGGER_KEY } from './consts';
import { SlashMenuExtension } from './extensions';
import { SlashMenu } from './slash-menu-popover';
import type { SlashMenuConfig, SlashMenuContext, SlashMenuItem } from './types';
import { buildSlashMenuItems } from './utils';
let globalAbortController = new AbortController();
function closeSlashMenu() {
globalAbortController.abort();
}
const showSlashMenu = debounce(
({
context,
config,
container = document.body,
abortController = new AbortController(),
configItemTransform,
}: {
context: SlashMenuContext;
config: SlashMenuConfig;
container?: HTMLElement;
abortController?: AbortController;
configItemTransform: (item: SlashMenuItem) => SlashMenuItem;
}) => {
globalAbortController = abortController;
const disposables = new DisposableGroup();
abortController.signal.addEventListener('abort', () =>
disposables.dispose()
);
const inlineEditor = getInlineEditorByModel(context.std, context.model);
if (!inlineEditor) return;
const slashMenu = new SlashMenu(inlineEditor, abortController);
disposables.add(() => slashMenu.remove());
slashMenu.context = context;
slashMenu.items = buildSlashMenuItems(
typeof config.items === 'function' ? config.items(context) : config.items,
context,
configItemTransform
);
// FIXME(Flrande): It is not a best practice,
// but merely a temporary measure for reusing previous components.
// Mount
container.append(slashMenu);
return slashMenu;
},
100,
{ leading: true }
);
export class AffineSlashMenuWidget extends WidgetComponent {
private readonly _getInlineEditor = (
evt: KeyboardEvent | CompositionEvent
) => {
if (evt.target instanceof HTMLElement) {
const editor = (
evt.target.closest('.inline-editor') as {
inlineEditor?: AffineInlineEditor;
}
)?.inlineEditor;
if (editor instanceof InlineEditor) {
return editor;
}
}
const textSelection = this.host.selection.find(TextSelection);
if (!textSelection) return;
const model = this.host.doc.getBlock(textSelection.blockId)?.model;
if (!model) return;
return getInlineEditorByModel(this.std, model);
};
private readonly _handleInput = (
inlineEditor: InlineEditor,
isCompositionEnd: boolean
) => {
const inlineRangeApplyCallback = (callback: () => void) => {
// the inline ranged updated in compositionEnd event before this event callback
if (isCompositionEnd) {
callback();
} else {
const subscription = inlineEditor.slots.inlineRangeSync.subscribe(
() => {
subscription.unsubscribe();
callback();
}
);
}
};
if (this.block?.model.flavour !== 'affine:page') {
console.error('SlashMenuWidget should be used in RootBlock');
return;
}
inlineRangeApplyCallback(() => {
const textSelection = this.host.selection.find(TextSelection);
if (!textSelection) return;
const block = this.host.view.getBlock(textSelection.blockId);
if (!block) return;
const model = block.model;
if (this.config.disableWhen?.({ model, std: this.std })) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const textPoint = inlineEditor.getTextPoint(inlineRange.index);
if (!textPoint) return;
const [leafStart, offsetStart] = textPoint;
const text = leafStart.textContent
? leafStart.textContent.slice(0, offsetStart)
: '';
if (!text.endsWith(AFFINE_SLASH_MENU_TRIGGER_KEY)) return;
closeSlashMenu();
showSlashMenu({
context: {
model,
std: this.std,
},
config: this.config,
configItemTransform: this.configItemTransform,
});
});
};
private readonly _onCompositionEnd = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event as CompositionEvent;
if (event.data !== AFFINE_SLASH_MENU_TRIGGER_KEY) return;
const inlineEditor = this._getInlineEditor(event);
if (!inlineEditor) return;
this._handleInput(inlineEditor, true);
};
private readonly _onKeyDown = (ctx: UIEventStateContext) => {
const eventState = ctx.get('keyboardState');
const event = eventState.raw;
const key = event.key;
if (event.isComposing || key !== AFFINE_SLASH_MENU_TRIGGER_KEY) return;
const inlineEditor = this._getInlineEditor(event);
if (!inlineEditor) return;
this._handleInput(inlineEditor, false);
};
get config() {
return this.std.get(SlashMenuExtension).config;
}
// TODO(@L-Sun): Remove this when moving each config item to corresponding blocks
// This is a temporary way for patching the slash menu config
configItemTransform: (item: SlashMenuItem) => SlashMenuItem = item => item;
override connectedCallback() {
super.connectedCallback();
// this.handleEvent('beforeInput', this._onBeforeInput);
this.handleEvent('keyDown', this._onKeyDown);
this.handleEvent('compositionEnd', this._onCompositionEnd);
}
}