chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,141 @@
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 { assertExists, noop } from '@blocksuite/global/utils';
import { flip, offset } from '@floating-ui/dom';
import { html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { ImageToolbarContext } from '../context.js';
import { styles } from '../styles.js';
export class AffineImageToolbar extends LitElement {
static override styles = styles;
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 _clearPopMenu() {
if (this._popMenuAbortController) {
this._popMenuAbortController.abort();
this._popMenuAbortController = null;
}
}
private _toggleMoreMenu() {
// If the menu we're trying to open is already open, return
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;
assertExists(this._moreButton);
createLitPortal({
template: html`
<editor-menu-content
data-show
class="image-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>
`,
container: this.context.host,
// stacking-context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
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();
this._clearPopMenu();
}
override render() {
return html`
<editor-toolbar class="affine-image-toolbar-container" data-without-bg>
${renderGroups(this.primaryGroups, this.context)}
<editor-icon-button
class="image-toolbar-button more"
aria-label="More"
.tooltip=${'More'}
.tooltipOffset=${4}
.showTooltip=${!this._moreMenuOpen}
@click=${() => this._toggleMoreMenu()}
>
${MoreVerticalIcon}
</editor-icon-button>
</editor-toolbar>
`;
}
@query('editor-icon-button.more')
private accessor _moreButton!: EditorIconButton;
@state()
private accessor _moreMenuOpen = false;
@property({ attribute: false })
accessor context!: ImageToolbarContext;
@property({ attribute: false })
accessor moreGroups!: MenuItemGroup<ImageToolbarContext>[];
@property({ attribute: false })
accessor onActiveStatusChange: (active: boolean) => void = noop;
@property({ attribute: false })
accessor primaryGroups!: MenuItemGroup<ImageToolbarContext>[];
}
declare global {
interface HTMLElementTagNameMap {
'affine-image-toolbar': AffineImageToolbar;
}
}
@@ -0,0 +1,145 @@
import {
BookmarkIcon,
CaptionIcon,
CopyIcon,
DeleteIcon,
DownloadIcon,
DuplicateIcon,
} from '@blocksuite/affine-components/icons';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import type { ImageToolbarContext } from './context.js';
import { duplicate } from './utils.js';
export const PRIMARY_GROUPS: MenuItemGroup<ImageToolbarContext>[] = [
{
type: 'primary',
items: [
{
type: 'download',
label: 'Download',
icon: DownloadIcon,
generate: ({ blockComponent }) => {
return {
action: () => {
blockComponent.download();
},
render: item => html`
<editor-icon-button
class="image-toolbar-button download"
aria-label=${ifDefined(item.label)}
.tooltip=${item.label}
.tooltipOffset=${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="image-toolbar-button caption"
aria-label=${ifDefined(item.label)}
.tooltip=${item.label}
.tooltipOffset=${4}
@click=${(e: MouseEvent) => {
e.stopPropagation();
item.action();
}}
>
${item.icon}
</editor-icon-button>
`,
};
},
},
],
},
];
// Clipboard Group
export const clipboardGroup: MenuItemGroup<ImageToolbarContext> = {
type: 'clipboard',
items: [
{
type: 'copy',
label: 'Copy',
icon: CopyIcon,
action: ({ blockComponent, close }) => {
blockComponent.copy();
close();
},
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
when: ({ doc }) => !doc.readonly,
action: ({ blockComponent, abortController }) => {
duplicate(blockComponent, abortController);
},
},
],
};
// Conversions Group
export const conversionsGroup: MenuItemGroup<ImageToolbarContext> = {
type: 'conversions',
items: [
{
label: 'Turn into card view',
type: 'turn-into-card-view',
icon: BookmarkIcon,
when: ({ doc, blockComponent }) => {
const supportAttachment =
doc.schema.flavourSchemaMap.has('affine:attachment');
const readonly = doc.readonly;
return supportAttachment && !readonly && !!blockComponent.blob;
},
action: ({ blockComponent, close }) => {
blockComponent.convertToCardView();
close();
},
},
],
};
// Delete Group
export const deleteGroup: MenuItemGroup<ImageToolbarContext> = {
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<ImageToolbarContext>[] = [
clipboardGroup,
conversionsGroup,
deleteGroup,
];
@@ -0,0 +1,43 @@
import type { ImageBlockComponent } from '../../../image-block/image-block.js';
import { MenuContext } from '../../configs/toolbar.js';
export class ImageToolbarContext extends MenuContext {
override close = () => {
this.abortController.abort();
};
get doc() {
return this.blockComponent.doc;
}
get host() {
return this.blockComponent.host;
}
get selectedBlockModels() {
return [this.blockComponent.model];
}
get std() {
return this.blockComponent.std;
}
constructor(
public blockComponent: ImageBlockComponent,
public abortController: AbortController
) {
super();
}
isEmpty() {
return false;
}
isMultiple() {
return false;
}
isSingle() {
return true;
}
}
@@ -0,0 +1,167 @@
import { HoverController } from '@blocksuite/affine-components/hover';
import type {
AdvancedMenuItem,
MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import { cloneGroups } from '@blocksuite/affine-components/toolbar';
import type { ImageBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent } from '@blocksuite/block-std';
import { limitShift, shift } from '@floating-ui/dom';
import { html } from 'lit';
import { PAGE_HEADER_HEIGHT } from '../../../_common/consts.js';
import type { ImageBlockComponent } from '../../../image-block/image-block.js';
import { getMoreMenuConfig } from '../../configs/toolbar.js';
import { MORE_GROUPS, PRIMARY_GROUPS } from './config.js';
import { ImageToolbarContext } from './context.js';
export const AFFINE_IMAGE_TOOLBAR_WIDGET = 'affine-image-toolbar-widget';
export class AffineImageToolbarWidget extends WidgetComponent<
ImageBlockModel,
ImageBlockComponent
> {
private _hoverController: HoverController | null = null;
private _isActivated = false;
private _setHoverController = () => {
this._hoverController = null;
this._hoverController = new HoverController(
this,
({ abortController }) => {
const imageBlock = 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 !== imageBlock.blockId)
) {
return null;
}
const imageContainer =
imageBlock.resizableImg ?? imageBlock.fallbackCard;
if (!imageContainer) {
return null;
}
const context = new ImageToolbarContext(imageBlock, abortController);
return {
template: html`<affine-image-toolbar
.context=${context}
.primaryGroups=${this.primaryGroups}
.moreGroups=${this.moreGroups}
.onActiveStatusChange=${(active: boolean) => {
this._isActivated = active;
if (!active && !this._hoverController?.isHovering) {
this._hoverController?.abort();
}
}}
></affine-image-toolbar>`,
container: this.block,
// stacking-context(editor-host)
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
computePosition: {
referenceElement: imageContainer,
placement: 'right-start',
middleware: [
shift({
crossAxis: true,
padding: {
top: PAGE_HEADER_HEIGHT + 12,
bottom: 12,
right: 12,
},
limiter: limitShift(),
}),
],
autoUpdate: true,
},
};
},
{ allowMultiple: true }
);
const imageBlock = this.block;
this._hoverController.setReference(imageBlock);
this._hoverController.onAbort = () => {
// If the more menu is opened, don't close it.
if (this._isActivated) return;
this._hoverController?.abort();
return;
};
};
addMoreItems = (
items: AdvancedMenuItem<ImageToolbarContext>[],
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<ImageToolbarContext>[],
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.
*/
moreGroups: MenuItemGroup<ImageToolbarContext>[] = cloneGroups(MORE_GROUPS);
primaryGroups: MenuItemGroup<ImageToolbarContext>[] =
cloneGroups(PRIMARY_GROUPS);
override firstUpdated() {
if (this.doc.getParent(this.model.id)?.flavour === 'affine:surface') {
return;
}
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
this._setHoverController();
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_IMAGE_TOOLBAR_WIDGET]: AffineImageToolbarWidget;
}
}
@@ -0,0 +1,24 @@
import { css } from 'lit';
export const styles = css`
:host {
position: absolute;
top: 0;
right: 0;
z-index: var(--affine-z-index-popover);
}
.affine-image-toolbar-container {
height: 24px;
gap: 4px;
padding: 4px;
margin: 0;
}
.image-toolbar-button {
color: var(--affine-icon-color);
background-color: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1);
border-radius: 4px;
}
`;
@@ -0,0 +1,54 @@
import {
getBlockProps,
isInsidePageEditor,
} from '@blocksuite/affine-shared/utils';
import { assertExists } from '@blocksuite/global/utils';
import type { ImageBlockComponent } from '../../../image-block/image-block.js';
export function duplicate(
block: ImageBlockComponent,
abortController?: AbortController
) {
const model = block.model;
const blockProps = getBlockProps(model);
const {
width: _width,
height: _height,
xywh: _xywh,
rotate: _rotate,
zIndex: _zIndex,
...duplicateProps
} = blockProps;
const { doc } = model;
const parent = doc.getParent(model);
assertExists(parent, 'Parent not found');
const index = parent?.children.indexOf(model);
const duplicateId = doc.addBlock(
model.flavour as BlockSuite.Flavour,
duplicateProps,
parent,
index + 1
);
abortController?.abort();
const editorHost = block.host;
editorHost.updateComplete
.then(() => {
const { selection } = editorHost;
selection.setGroup('note', [
selection.create('block', {
blockId: duplicateId,
}),
]);
if (isInsidePageEditor(editorHost)) {
const duplicateElement = editorHost.view.getBlock(duplicateId);
if (duplicateElement) {
duplicateElement.scrollIntoView(true);
}
}
})
.catch(console.error);
}