refactor(editor): extract code block (#9397)

This commit is contained in:
Saul-Mirone
2024-12-27 14:45:11 +00:00
parent 5e1d936c2e
commit 6ebefbbf2b
42 changed files with 177 additions and 52 deletions

View File

@@ -1,154 +0,0 @@
import { MoreVerticalIcon } from '@blocksuite/affine-components/icons';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import type {
EditorIconButton,
MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import { renderGroups } from '@blocksuite/affine-components/toolbar';
import { noop, WithDisposable } from '@blocksuite/global/utils';
import { flip, offset } from '@floating-ui/dom';
import { css, html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { CodeBlockToolbarContext } from '../context.js';
export class AffineCodeToolbar extends WithDisposable(LitElement) {
static override styles = css`
:host {
position: absolute;
top: 0;
right: 0;
}
.code-toolbar-container {
height: 24px;
gap: 4px;
padding: 4px;
margin: 0;
}
.code-toolbar-button {
color: var(--affine-icon-color);
background-color: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1);
border-radius: 4px;
}
`;
private _currentOpenMenu: AbortController | null = null;
private _popMenuAbortController: AbortController | null = null;
closeCurrentMenu = () => {
if (this._currentOpenMenu && !this._currentOpenMenu.signal.aborted) {
this._currentOpenMenu.abort();
this._currentOpenMenu = null;
}
};
private _toggleMoreMenu() {
if (
this._currentOpenMenu &&
!this._currentOpenMenu.signal.aborted &&
this._currentOpenMenu === this._popMenuAbortController
) {
this.closeCurrentMenu();
this._moreMenuOpen = false;
return;
}
this.closeCurrentMenu();
this._popMenuAbortController = new AbortController();
this._popMenuAbortController.signal.addEventListener('abort', () => {
this._moreMenuOpen = false;
this.onActiveStatusChange(false);
});
this.onActiveStatusChange(true);
this._currentOpenMenu = this._popMenuAbortController;
if (!this._moreButton) {
console.error(
'Failed to open more menu in code toolbar! Unexpected missing more button'
);
return;
}
createLitPortal({
template: html`
<editor-menu-content
data-show
class="more-popup-menu"
style=${styleMap({
'--content-padding': '8px',
'--packed-height': '4px',
})}
>
<div data-size="large" data-orientation="vertical">
${renderGroups(this.moreGroups, this.context)}
</div>
</editor-menu-content>
`,
// should be greater than block-selection z-index as selection and popover wil share the same stacking context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: this.context.host,
computePosition: {
referenceElement: this._moreButton,
placement: 'bottom-start',
middleware: [flip(), offset(4)],
autoUpdate: { animationFrame: true },
},
abortController: this._popMenuAbortController,
closeOnClickAway: true,
});
this._moreMenuOpen = true;
}
override disconnectedCallback() {
super.disconnectedCallback();
this.closeCurrentMenu();
}
override render() {
return html`
<editor-toolbar class="code-toolbar-container" data-without-bg>
${renderGroups(this.primaryGroups, this.context)}
<editor-icon-button
class="code-toolbar-button more"
data-testid="more"
aria-label="More"
.tooltip=${'More'}
.tooltipOffset=${4}
.iconSize=${'16px'}
.iconContainerPadding=${4}
.showTooltip=${!this._moreMenuOpen}
?disabled=${this.context.doc.readonly}
@click=${() => this._toggleMoreMenu()}
>
${MoreVerticalIcon}
</editor-icon-button>
</editor-toolbar>
`;
}
@query('.code-toolbar-button.more')
private accessor _moreButton!: EditorIconButton;
@state()
private accessor _moreMenuOpen = false;
@property({ attribute: false })
accessor context!: CodeBlockToolbarContext;
@property({ attribute: false })
accessor moreGroups!: MenuItemGroup<CodeBlockToolbarContext>[];
@property({ attribute: false })
accessor onActiveStatusChange: (active: boolean) => void = noop;
@property({ attribute: false })
accessor primaryGroups!: MenuItemGroup<CodeBlockToolbarContext>[];
}

View File

@@ -1,154 +0,0 @@
import {
type FilterableListItem,
type FilterableListOptions,
showPopFilterableList,
} from '@blocksuite/affine-components/filterable-list';
import { ArrowDownIcon } from '@blocksuite/affine-components/icons';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { css, LitElement, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit/static-html.js';
import type { CodeBlockComponent } from '../../../../code-block/code-block.js';
export class LanguageListButton extends WithDisposable(
SignalWatcher(LitElement)
) {
static override styles = css`
.lang-button {
background-color: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1);
display: flex;
gap: 4px;
padding: 2px 4px;
}
.lang-button:hover {
background: var(--affine-hover-color-filled);
}
.lang-button[hover] {
background: var(--affine-hover-color-filled);
}
.lang-button-icon {
display: flex;
align-items: center;
color: ${unsafeCSSVarV2('icon/primary')};
svg {
height: 16px;
width: 16px;
}
}
`;
private _abortController?: AbortController;
private readonly _clickLangBtn = () => {
if (this.blockComponent.doc.readonly) return;
if (this._abortController) {
// Close the language list if it's already opened.
this._abortController.abort();
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this.onActiveStatusChange(false);
this._abortController = undefined;
});
this.onActiveStatusChange(true);
const options: FilterableListOptions = {
placeholder: 'Search for a language',
onSelect: item => {
const sortedBundledLanguages = this._sortedBundledLanguages;
const index = sortedBundledLanguages.indexOf(item);
if (index !== -1) {
sortedBundledLanguages.splice(index, 1);
sortedBundledLanguages.unshift(item);
}
this.blockComponent.doc.transact(() => {
this.blockComponent.model.language$.value = item.name;
});
},
active: item => item.name === this.blockComponent.model.language,
items: this._sortedBundledLanguages,
};
showPopFilterableList({
options,
referenceElement: this._langButton,
container: this.blockComponent.host,
abortController: this._abortController,
// stacking-context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
});
};
private _sortedBundledLanguages: FilterableListItem[] = [];
override connectedCallback(): void {
super.connectedCallback();
const langList = localStorage.getItem('blocksuite:code-block:lang-list');
if (langList) {
this._sortedBundledLanguages = JSON.parse(langList);
} else {
this._sortedBundledLanguages = this.blockComponent.service.langs.map(
lang => ({
label: lang.name,
name: lang.id,
aliases: lang.aliases,
})
);
}
this.disposables.add(() => {
localStorage.setItem(
'blocksuite:code-block:lang-list',
JSON.stringify(this._sortedBundledLanguages)
);
});
}
override render() {
const textStyles = styleMap({
fontFamily: 'Inter',
fontSize: 'var(--affine-font-xs)',
fontStyle: 'normal',
fontWeight: '500',
lineHeight: '20px',
padding: '0 4px',
});
return html`<icon-button
class="lang-button"
data-testid="lang-button"
width="auto"
.text=${html`<div style=${textStyles}>
${this.blockComponent.languageName$.value}
</div>`}
height="24px"
@click=${this._clickLangBtn}
?disabled=${this.blockComponent.doc.readonly}
>
<span class="lang-button-icon" slot="suffix">
${!this.blockComponent.doc.readonly ? ArrowDownIcon : nothing}
</span>
</icon-button> `;
}
@query('.lang-button')
private accessor _langButton!: HTMLElement;
@property({ attribute: false })
accessor blockComponent!: CodeBlockComponent;
@property({ attribute: false })
accessor onActiveStatusChange: (active: boolean) => void = noop;
}

View File

@@ -1,177 +0,0 @@
import {
CancelWrapIcon,
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
WrapIcon,
} from '@blocksuite/affine-components/icons';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { isInsidePageEditor } from '@blocksuite/affine-shared/utils';
import { noop, sleep } from '@blocksuite/global/utils';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import type { CodeBlockToolbarContext } from './context.js';
import { duplicateCodeBlock } from './utils.js';
export const PRIMARY_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [
{
type: 'primary',
items: [
{
type: 'change-lang',
generate: ({ blockComponent, setActive }) => {
const state = { active: false };
return {
action: noop,
render: () =>
html`<language-list-button
.blockComponent=${blockComponent}
.onActiveStatusChange=${async (active: boolean) => {
state.active = active;
if (!active) {
await sleep(1000);
if (state.active) return;
}
setActive(active);
}}
>
</language-list-button>`,
};
},
},
{
type: 'copy-code',
label: 'Copy code',
icon: CopyIcon,
generate: ({ blockComponent }) => {
return {
action: () => {
blockComponent.copyCode();
},
render: item => html`
<editor-icon-button
class="code-toolbar-button copy-code"
aria-label=${ifDefined(item.label)}
.tooltip=${item.label}
.tooltipOffset=${4}
.iconSize=${'16px'}
.iconContainerPadding=${4}
@click=${(e: MouseEvent) => {
e.stopPropagation();
item.action();
}}
>
${item.icon}
</editor-icon-button>
`,
};
},
},
{
type: 'caption',
label: 'Caption',
icon: CaptionIcon,
when: ({ doc }) => !doc.readonly,
generate: ({ blockComponent }) => {
return {
action: () => {
blockComponent.captionEditor?.show();
},
render: item => html`
<editor-icon-button
class="code-toolbar-button caption"
aria-label=${ifDefined(item.label)}
.tooltip=${item.label}
.tooltipOffset=${4}
.iconSize=${'16px'}
.iconContainerPadding=${4}
@click=${(e: MouseEvent) => {
e.stopPropagation();
item.action();
}}
>
${item.icon}
</editor-icon-button>
`,
};
},
},
],
},
];
// Clipboard Group
export const clipboardGroup: MenuItemGroup<CodeBlockToolbarContext> = {
type: 'clipboard',
items: [
{
type: 'wrap',
generate: ({ blockComponent, close }) => {
const wrapped = blockComponent.model.wrap;
const label = wrapped ? 'Cancel wrap' : 'Wrap';
const icon = wrapped ? CancelWrapIcon : WrapIcon;
return {
label,
icon,
action: () => {
blockComponent.setWrap(!wrapped);
close();
},
};
},
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
when: ({ doc }) => !doc.readonly,
action: ({ host, blockComponent, close }) => {
const codeId = duplicateCodeBlock(blockComponent.model);
host.updateComplete
.then(() => {
host.selection.setGroup('note', [
host.selection.create('block', {
blockId: codeId,
}),
]);
if (isInsidePageEditor(host)) {
const duplicateElement = host.view.getBlock(codeId);
if (duplicateElement) {
duplicateElement.scrollIntoView({ block: 'nearest' });
}
}
})
.catch(console.error);
close();
},
},
],
};
// Delete Group
export const deleteGroup: MenuItemGroup<CodeBlockToolbarContext> = {
type: 'delete',
items: [
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
when: ({ doc }) => !doc.readonly,
action: ({ doc, blockComponent, close }) => {
doc.deleteBlock(blockComponent.model);
close();
},
},
],
};
export const MORE_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [
clipboardGroup,
deleteGroup,
];

View File

@@ -1,46 +0,0 @@
import { MenuContext } from '@blocksuite/affine-components/toolbar';
import type { CodeBlockComponent } from '../../../code-block/code-block.js';
export class CodeBlockToolbarContext extends MenuContext {
override close = () => {
this.abortController.abort();
};
get doc() {
return this.blockComponent.doc;
}
get host() {
return this.blockComponent.host;
}
get selectedBlockModels() {
if (this.blockComponent.model) return [this.blockComponent.model];
return [];
}
get std() {
return this.blockComponent.std;
}
constructor(
public blockComponent: CodeBlockComponent,
public abortController: AbortController,
public setActive: (active: boolean) => void
) {
super();
}
isEmpty() {
return false;
}
isMultiple() {
return false;
}
isSingle() {
return true;
}
}

View File

@@ -1,20 +0,0 @@
import { AffineCodeToolbar } from './components/code-toolbar.js';
import { LanguageListButton } from './components/lang-button.js';
import {
AFFINE_CODE_TOOLBAR_WIDGET,
AffineCodeToolbarWidget,
} from './index.js';
export function effects() {
customElements.define('language-list-button', LanguageListButton);
customElements.define('affine-code-toolbar', AffineCodeToolbar);
customElements.define(AFFINE_CODE_TOOLBAR_WIDGET, AffineCodeToolbarWidget);
}
declare global {
interface HTMLElementTagNameMap {
'language-list-button': LanguageListButton;
'affine-code-toolbar': AffineCodeToolbar;
[AFFINE_CODE_TOOLBAR_WIDGET]: AffineCodeToolbarWidget;
}
}

View File

@@ -1,159 +0,0 @@
import { HoverController } from '@blocksuite/affine-components/hover';
import type {
AdvancedMenuItem,
MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import {
cloneGroups,
getMoreMenuConfig,
} from '@blocksuite/affine-components/toolbar';
import type { CodeBlockModel } from '@blocksuite/affine-model';
import { PAGE_HEADER_HEIGHT } from '@blocksuite/affine-shared/consts';
import { WidgetComponent } from '@blocksuite/block-std';
import { limitShift, shift } from '@floating-ui/dom';
import { html } from 'lit';
import type { CodeBlockComponent } from '../../../code-block/code-block.js';
import { MORE_GROUPS, PRIMARY_GROUPS } from './config.js';
import { CodeBlockToolbarContext } from './context.js';
export const AFFINE_CODE_TOOLBAR_WIDGET = 'affine-code-toolbar-widget';
export class AffineCodeToolbarWidget extends WidgetComponent<
CodeBlockModel,
CodeBlockComponent
> {
private _hoverController: HoverController | null = null;
private _isActivated = false;
private readonly _setHoverController = () => {
this._hoverController = null;
this._hoverController = new HoverController(
this,
({ abortController }) => {
const codeBlock = this.block;
const selection = this.host.selection;
const textSelection = selection.find('text');
if (
!!textSelection &&
(!!textSelection.to || !!textSelection.from.length)
) {
return null;
}
const blockSelections = selection.filter('block');
if (
blockSelections.length > 1 ||
(blockSelections.length === 1 &&
blockSelections[0].blockId !== codeBlock.blockId)
) {
return null;
}
const setActive = (active: boolean) => {
this._isActivated = active;
if (!active && !this._hoverController?.isHovering) {
this._hoverController?.abort();
}
};
const context = new CodeBlockToolbarContext(
codeBlock,
abortController,
setActive
);
return {
template: html`<affine-code-toolbar
.context=${context}
.primaryGroups=${this.primaryGroups}
.moreGroups=${this.moreGroups}
.onActiveStatusChange=${setActive}
></affine-code-toolbar>`,
container: this.block,
// stacking-context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
computePosition: {
referenceElement: codeBlock,
placement: 'right-start',
middleware: [
shift({
crossAxis: true,
padding: {
top: PAGE_HEADER_HEIGHT + 12,
bottom: 12,
right: 12,
},
limiter: limitShift(),
}),
],
autoUpdate: true,
},
};
},
{ allowMultiple: true }
);
const codeBlock = this.block;
this._hoverController.setReference(codeBlock);
this._hoverController.onAbort = () => {
// If the more menu is opened, don't close it.
if (this._isActivated) return;
this._hoverController?.abort();
return;
};
};
addMoretems = (
items: AdvancedMenuItem<CodeBlockToolbarContext>[],
index?: number,
type?: string
) => {
let group;
if (type) {
group = this.moreGroups.find(g => g.type === type);
}
if (!group) {
group = this.moreGroups[0];
}
if (index === undefined) {
group.items.push(...items);
return this;
}
group.items.splice(index, 0, ...items);
return this;
};
addPrimaryItems = (
items: AdvancedMenuItem<CodeBlockToolbarContext>[],
index?: number
) => {
if (index === undefined) {
this.primaryGroups[0].items.push(...items);
return this;
}
this.primaryGroups[0].items.splice(index, 0, ...items);
return this;
};
/*
* Caches the more menu items.
* Currently only supports configuring more menu.
*/
protected moreGroups: MenuItemGroup<CodeBlockToolbarContext>[] =
cloneGroups(MORE_GROUPS);
protected primaryGroups: MenuItemGroup<CodeBlockToolbarContext>[] =
cloneGroups(PRIMARY_GROUPS);
override firstUpdated() {
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
this._setHoverController();
}
}

View File

@@ -1,16 +0,0 @@
import type { CodeBlockModel } from '@blocksuite/affine-model';
export const duplicateCodeBlock = (model: CodeBlockModel) => {
const keys = model.keys as (keyof typeof model)[];
const values = keys.map(key => model[key]);
const blockProps = Object.fromEntries(keys.map((key, i) => [key, values[i]]));
const { text: _text, ...duplicateProps } = blockProps;
const newProps = {
flavour: model.flavour,
text: model.text.clone(),
...duplicateProps,
};
return model.doc.addSiblingBlocks(model, [newProps])[0];
};

View File

@@ -6,7 +6,6 @@ export {
type AffineAIPanelState,
type AffineAIPanelWidgetConfig,
} from './ai-panel/type.js';
export { AffineCodeToolbarWidget } from './code-toolbar/index.js';
export { AffineDocRemoteSelectionWidget } from './doc-remote-selection/doc-remote-selection.js';
export { AffineDragHandleWidget } from './drag-handle/drag-handle.js';
export {