refactor(editor): image toolbar config extension (#11329)

Closes: [BS-2378](https://linear.app/affine-design/issue/BS-2378/image-toolbar-迁移)
This commit is contained in:
fundon
2025-04-01 10:04:28 +00:00
parent 032244ae61
commit 7af5e53838
26 changed files with 267 additions and 785 deletions

View File

@@ -1,19 +1,121 @@
import { ImageBlockModel } from '@blocksuite/affine-model'; import { ImageBlockModel } from '@blocksuite/affine-model';
import { import {
ActionPlacement,
type ToolbarModuleConfig, type ToolbarModuleConfig,
ToolbarModuleExtension, ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services'; } from '@blocksuite/affine-shared/services';
import { CaptionIcon, DownloadIcon } from '@blocksuite/icons/lit'; import {
BookmarkIcon,
CaptionIcon,
CopyIcon,
DeleteIcon,
DownloadIcon,
DuplicateIcon,
} from '@blocksuite/icons/lit';
import { BlockFlavourIdentifier } from '@blocksuite/std'; import { BlockFlavourIdentifier } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store'; import type { ExtensionType } from '@blocksuite/store';
import { ImageBlockComponent } from '../image-block';
import { ImageEdgelessBlockComponent } from '../image-edgeless-block'; import { ImageEdgelessBlockComponent } from '../image-edgeless-block';
import { duplicate } from '../utils';
const trackBaseProps = { const trackBaseProps = {
category: 'image', category: 'image',
type: 'card view', type: 'card view',
}; };
const builtinToolbarConfig = {
actions: [
{
id: 'a.download',
tooltip: 'Download',
icon: DownloadIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
block?.download();
},
},
{
id: 'b.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
block?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
},
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'a.copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
block?.copy();
},
},
{
id: 'b.duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
if (!block) return;
duplicate(block);
},
},
],
},
{
placement: ActionPlacement.More,
id: 'b.conversions',
actions: [
{
id: 'a.turn-into-card-view',
label: 'Turn into card view',
icon: BookmarkIcon(),
when(ctx) {
const supported =
ctx.store.schema.flavourSchemaMap.has('affine:attachment');
if (!supported) return false;
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
return Boolean(block?.blob);
},
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
block?.convertToCardView();
},
},
],
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
if (!block) return;
ctx.store.deleteBlock(block.model);
},
},
],
placement: 'inner',
} as const satisfies ToolbarModuleConfig;
const builtinSurfaceToolbarConfig = { const builtinSurfaceToolbarConfig = {
actions: [ actions: [
{ {
@@ -50,6 +152,11 @@ export const createBuiltinToolbarConfigExtension = (
const name = flavour.split(':').pop(); const name = flavour.split(':').pop();
return [ return [
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
ToolbarModuleExtension({ ToolbarModuleExtension({
id: BlockFlavourIdentifier(`affine:surface:${name}`), id: BlockFlavourIdentifier(`affine:surface:${name}`),
config: builtinSurfaceToolbarConfig, config: builtinSurfaceToolbarConfig,

View File

@@ -1,6 +1,8 @@
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import { whenHover } from '@blocksuite/affine-components/hover';
import { Peekable } from '@blocksuite/affine-components/peek'; import { Peekable } from '@blocksuite/affine-components/peek';
import type { ImageBlockModel } from '@blocksuite/affine-model'; import type { ImageBlockModel } from '@blocksuite/affine-model';
import { ToolbarRegistryIdentifier } from '@blocksuite/affine-shared/services';
import { IS_MOBILE } from '@blocksuite/global/env'; import { IS_MOBILE } from '@blocksuite/global/env';
import { BlockSelection } from '@blocksuite/std'; import { BlockSelection } from '@blocksuite/std';
import { html } from 'lit'; import { html } from 'lit';
@@ -54,9 +56,33 @@ export class ImageBlockComponent extends CaptionedBlockComponent<ImageBlockModel
selectionManager.setGroup('note', [blockSelection]); selectionManager.setGroup('note', [blockSelection]);
} }
private _initHover() {
const { setReference, setFloating, dispose } = whenHover(
hovered => {
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
if (hovered) {
message$.value = {
flavour: this.model.flavour,
element: this,
setFloating,
};
return;
}
// Clears previous bindings
message$.value = null;
setFloating();
},
{ enterDelay: 500 }
);
setReference(this);
this._disposables.add(dispose);
}
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this._initHover();
this.refreshData(); this.refreshData();
this.contentEditable = 'false'; this.contentEditable = 'false';
this._disposables.add( this._disposables.add(

View File

@@ -1,10 +1,6 @@
import { ImageBlockSchema } from '@blocksuite/affine-model'; import { ImageBlockSchema } from '@blocksuite/affine-model';
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu'; import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
import { import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
BlockViewExtension,
FlavourExtension,
WidgetViewExtension,
} from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store'; import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js'; import { literal } from 'lit/static-html.js';
@@ -16,12 +12,6 @@ import { ImageDropOption } from './image-service';
const flavour = ImageBlockSchema.model.flavour; const flavour = ImageBlockSchema.model.flavour;
export const imageToolbarWidget = WidgetViewExtension(
flavour,
'imageToolbar',
literal`affine-image-toolbar-widget`
);
export const ImageBlockSpec: ExtensionType[] = [ export const ImageBlockSpec: ExtensionType[] = [
FlavourExtension(flavour), FlavourExtension(flavour),
BlockViewExtension(flavour, model => { BlockViewExtension(flavour, model => {
@@ -33,7 +23,6 @@ export const ImageBlockSpec: ExtensionType[] = [
return literal`affine-image`; return literal`affine-image`;
}), }),
imageToolbarWidget,
ImageDropOption, ImageDropOption,
ImageBlockAdapterExtensions, ImageBlockAdapterExtensions,
createBuiltinToolbarConfigExtension(flavour), createBuiltinToolbarConfigExtension(flavour),

View File

@@ -11,13 +11,19 @@ import {
} from '@blocksuite/affine-shared/services'; } from '@blocksuite/affine-shared/services';
import { import {
downloadBlob, downloadBlob,
getBlockProps,
humanFileSize, humanFileSize,
isInsidePageEditor,
readImageSize, readImageSize,
transformModel, transformModel,
withTempBlobData, withTempBlobData,
} from '@blocksuite/affine-shared/utils'; } from '@blocksuite/affine-shared/utils';
import { Bound, type IVec, Point, Vec } from '@blocksuite/global/gfx'; import { Bound, type IVec, Point, Vec } from '@blocksuite/global/gfx';
import type { BlockStdScope, EditorHost } from '@blocksuite/std'; import {
BlockSelection,
type BlockStdScope,
type EditorHost,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx'; import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store'; import type { BlockModel } from '@blocksuite/store';
@@ -552,3 +558,49 @@ export function calcBoundByOrigin(
? new Bound(point[0], point[1], width, height) ? new Bound(point[0], point[1], width, height)
: Bound.fromCenter(point, width, height); : Bound.fromCenter(point, width, height);
} }
export function duplicate(block: ImageBlockComponent) {
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);
if (!parent) {
console.error(`Parent not found for block(${model.flavour}) ${model.id}`);
return;
}
const index = parent?.children.indexOf(model);
const duplicateId = doc.addBlock(
model.flavour,
duplicateProps,
parent,
index + 1
);
const editorHost = block.host;
editorHost.updateComplete
.then(() => {
const { selection } = editorHost;
selection.setGroup('note', [
selection.create(BlockSelection, {
blockId: duplicateId,
}),
]);
if (isInsidePageEditor(editorHost)) {
const duplicateElement = editorHost.view.getBlock(duplicateId);
if (duplicateElement) {
duplicateElement.scrollIntoView(true);
}
}
})
.catch(console.error);
}

View File

@@ -31,7 +31,6 @@ import { AffineTemplateLoading } from './edgeless/components/toolbar/template/te
import { EdgelessTemplatePanel } from './edgeless/components/toolbar/template/template-panel.js'; import { EdgelessTemplatePanel } from './edgeless/components/toolbar/template/template-panel.js';
import { EdgelessTemplateButton } from './edgeless/components/toolbar/template/template-tool-button.js'; import { EdgelessTemplateButton } from './edgeless/components/toolbar/template/template-tool-button.js';
import { import {
AffineImageToolbarWidget,
AffineModalWidget, AffineModalWidget,
EdgelessRootBlockComponent, EdgelessRootBlockComponent,
EdgelessRootPreviewBlockComponent, EdgelessRootPreviewBlockComponent,
@@ -48,8 +47,6 @@ import {
} from './widgets/edgeless-zoom-toolbar/index.js'; } from './widgets/edgeless-zoom-toolbar/index.js';
import { ZoomBarToggleButton } from './widgets/edgeless-zoom-toolbar/zoom-bar-toggle-button.js'; import { ZoomBarToggleButton } from './widgets/edgeless-zoom-toolbar/zoom-bar-toggle-button.js';
import { EdgelessZoomToolbar } from './widgets/edgeless-zoom-toolbar/zoom-toolbar.js'; import { EdgelessZoomToolbar } from './widgets/edgeless-zoom-toolbar/zoom-toolbar.js';
import { AffineImageToolbar } from './widgets/image-toolbar/components/image-toolbar.js';
import { AFFINE_IMAGE_TOOLBAR_WIDGET } from './widgets/image-toolbar/index.js';
import { import {
AFFINE_INNER_MODAL_WIDGET, AFFINE_INNER_MODAL_WIDGET,
AffineInnerModalWidget, AffineInnerModalWidget,
@@ -109,7 +106,6 @@ function registerWidgets() {
AFFINE_PAGE_DRAGGING_AREA_WIDGET, AFFINE_PAGE_DRAGGING_AREA_WIDGET,
AffinePageDraggingAreaWidget AffinePageDraggingAreaWidget
); );
customElements.define(AFFINE_IMAGE_TOOLBAR_WIDGET, AffineImageToolbarWidget);
customElements.define( customElements.define(
AFFINE_VIEWPORT_OVERLAY_WIDGET, AFFINE_VIEWPORT_OVERLAY_WIDGET,
AffineViewportOverlayWidget AffineViewportOverlayWidget
@@ -146,7 +142,6 @@ function registerMiscComponents() {
customElements.define('affine-template-loading', AffineTemplateLoading); customElements.define('affine-template-loading', AffineTemplateLoading);
// Toolbar and UI components // Toolbar and UI components
customElements.define('affine-image-toolbar', AffineImageToolbar);
customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar); customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar);
customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton); customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton);
customElements.define('overlay-scrollbar', OverlayScrollbar); customElements.define('overlay-scrollbar', OverlayScrollbar);
@@ -200,10 +195,8 @@ declare global {
'affine-page-root': PageRootBlockComponent; 'affine-page-root': PageRootBlockComponent;
'zoom-bar-toggle-button': ZoomBarToggleButton; 'zoom-bar-toggle-button': ZoomBarToggleButton;
'edgeless-zoom-toolbar': EdgelessZoomToolbar; 'edgeless-zoom-toolbar': EdgelessZoomToolbar;
'affine-image-toolbar': AffineImageToolbar;
[AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET]: AffineEdgelessZoomToolbarWidget; [AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET]: AffineEdgelessZoomToolbarWidget;
[AFFINE_IMAGE_TOOLBAR_WIDGET]: AffineImageToolbarWidget;
[AFFINE_INNER_MODAL_WIDGET]: AffineInnerModalWidget; [AFFINE_INNER_MODAL_WIDGET]: AffineInnerModalWidget;
} }
} }

View File

@@ -1,138 +0,0 @@
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 } from '@blocksuite/global/utils';
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
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;
if (!this._moreButton) {
return;
}
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}
.iconSize=${'20px'}
@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>[];
}

View File

@@ -1,144 +0,0 @@
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DownloadIcon,
} from '@blocksuite/affine-components/icons';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { BookmarkIcon, DuplicateIcon } from '@blocksuite/icons/lit';
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,
];

View File

@@ -1,43 +0,0 @@
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
import { MenuContext } from '@blocksuite/affine-components/toolbar';
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;
}
}

View File

@@ -1,173 +0,0 @@
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
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 { ImageBlockModel } from '@blocksuite/affine-model';
import { PAGE_HEADER_HEIGHT } from '@blocksuite/affine-shared/consts';
import {
BlockSelection,
TextSelection,
WidgetComponent,
} from '@blocksuite/std';
import { limitShift, shift } from '@floating-ui/dom';
import { html } from 'lit';
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 readonly _setHoverController = () => {
this._hoverController = null;
this._hoverController = new HoverController(
this,
({ abortController }) => {
const imageBlock = this.block;
if (!imageBlock) {
return null;
}
const selection = this.host.selection;
const textSelection = selection.find(TextSelection);
if (
!!textSelection &&
(!!textSelection.to || !!textSelection.from.length)
) {
return null;
}
const blockSelections = selection.filter(BlockSelection);
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;
if (!imageBlock) {
return;
}
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();
}
}

View File

@@ -1,24 +0,0 @@
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;
}
`;

View File

@@ -1,56 +0,0 @@
import type { ImageBlockComponent } from '@blocksuite/affine-block-image';
import {
getBlockProps,
isInsidePageEditor,
} from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/std';
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);
if (!parent) {
console.error(`Parent not found for block(${model.flavour}) ${model.id}`);
return;
}
const index = parent?.children.indexOf(model);
const duplicateId = doc.addBlock(
model.flavour,
duplicateProps,
parent,
index + 1
);
abortController?.abort();
const editorHost = block.host;
editorHost.updateComplete
.then(() => {
const { selection } = editorHost;
selection.setGroup('note', [
selection.create(BlockSelection, {
blockId: duplicateId,
}),
]);
if (isInsidePageEditor(editorHost)) {
const duplicateElement = editorHost.view.getBlock(duplicateId);
if (duplicateElement) {
duplicateElement.scrollIntoView(true);
}
}
})
.catch(console.error);
}

View File

@@ -1,5 +1,4 @@
export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js'; export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js';
export { AffineImageToolbarWidget } from './image-toolbar/index.js';
export { AffineInnerModalWidget } from './inner-modal/inner-modal.js'; export { AffineInnerModalWidget } from './inner-modal/inner-modal.js';
export * from './keyboard-toolbar/index.js'; export * from './keyboard-toolbar/index.js';
export { export {

View File

@@ -192,11 +192,8 @@ abstract class ToolbarContextBase {
}; };
const getFromMessage = () => { const getFromMessage = () => {
const msgEle = this.message$.peek()?.element; const block = this.message$.peek()?.element;
if (msgEle instanceof BlockComponent) { return block instanceof BlockComponent ? block : null;
return msgEle;
}
return null;
}; };
return getFromSelection() ?? getFromMessage(); return getFromSelection() ?? getFromMessage();
@@ -234,11 +231,8 @@ abstract class ToolbarContextBase {
}; };
const getFromMessage = () => { const getFromMessage = () => {
const msgEle = this.message$.peek()?.element; const block = this.message$.peek()?.element;
if (msgEle instanceof BlockComponent) { return block instanceof BlockComponent ? block.model : null;
return msgEle.model;
}
return null;
}; };
return getFromSelection() ?? getFromMessage(); return getFromSelection() ?? getFromMessage();

View File

@@ -88,6 +88,7 @@ export class AffineToolbarWidget extends WidgetComponent {
box-sizing: border-box; box-sizing: border-box;
gap: 4px; gap: 4px;
.inner-button,
editor-icon-button, editor-icon-button,
editor-menu-button { editor-menu-button {
background: ${unsafeCSSVarV2('button/iconButtonSolid')}; background: ${unsafeCSSVarV2('button/iconButtonSolid')};

View File

@@ -92,7 +92,7 @@ export class AskAIButton extends WithDisposable(LitElement) {
return; return;
} }
if (this._abortController) { if (this._abortController && !this._abortController.signal.aborted) {
this._clearAbortController(); this._clearAbortController();
return; return;
} }

View File

@@ -1,7 +1,11 @@
import '../../components/ask-ai-button'; import '../../components/ask-ai-button';
import type { AffineImageToolbarWidget } from '@blocksuite/affine/blocks/root'; import { ImageBlockComponent } from '@blocksuite/affine/blocks/image';
import { ImageSelection } from '@blocksuite/affine/shared/selection'; import {
ActionPlacement,
type ToolbarModuleConfig,
} from '@blocksuite/affine/shared/services';
import { BlockSelection } from '@blocksuite/affine/std';
import { html } from 'lit'; import { html } from 'lit';
import { buildAIImageItemGroups } from '../../_common/config'; import { buildAIImageItemGroups } from '../../_common/config';
@@ -14,40 +18,34 @@ const buttonOptions: AskAIButtonOptions = {
panelWidth: 300, panelWidth: 300,
}; };
export function setupImageToolbarAIEntry( export function imageToolbarAIEntryConfig(): ToolbarModuleConfig {
imageToolbar: AffineImageToolbarWidget
) {
imageToolbar.addPrimaryItems(
[
{
type: 'ask-ai',
when: ({ doc }) => !doc.readonly,
generate: ({ host, blockComponent }) => {
return { return {
action: () => { actions: [
const { selection } = host; {
selection.setGroup('note', [ placement: ActionPlacement.Start,
selection.create(ImageSelection, { id: 'A.ai',
blockId: blockComponent.blockId, score: -1,
}), content: ctx => {
]); const block = ctx.getCurrentBlockByType(ImageBlockComponent);
}, if (!block) return null;
render: item =>
html`<ask-ai-button return html`<ask-ai-button
class="image-toolbar-button ask-ai" class="ask-ai inner-button"
.host=${host} .host=${ctx.host}
.actionGroups=${AIImageItemGroups} .actionGroups=${AIImageItemGroups}
.toggleType=${'click'} .toggleType="${'click'}"
.options=${buttonOptions} .options=${buttonOptions}
@click=${(e: MouseEvent) => { @click=${(e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
item.action(); ctx.selection.update(() => [
ctx.selection.create(BlockSelection, {
blockId: block.blockId,
}),
]);
}} }}
></ask-ai-button>`, ></ask-ai-button>`;
};
}, },
}, },
], ],
0 };
);
} }

View File

@@ -1,29 +1,14 @@
import { ImageBlockSpec } from '@blocksuite/affine/blocks/image'; import { ImageBlockSpec } from '@blocksuite/affine/blocks/image';
import { AffineImageToolbarWidget } from '@blocksuite/affine/blocks/root'; import { ToolbarModuleExtension } from '@blocksuite/affine/shared/services';
import { LifeCycleWatcher } from '@blocksuite/affine/std'; import { BlockFlavourIdentifier } from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store'; import type { ExtensionType } from '@blocksuite/affine/store';
import { setupImageToolbarAIEntry } from '../entries/image-toolbar/setup-image-toolbar'; import { imageToolbarAIEntryConfig } from '../entries/image-toolbar/setup-image-toolbar';
class AIImageBlockWatcher extends LifeCycleWatcher {
static override key = 'ai-image-block-watcher';
override mounted() {
super.mounted();
const { view } = this.std;
view.viewUpdated.subscribe(payload => {
if (payload.type !== 'widget' || payload.method !== 'add') {
return;
}
const component = payload.view;
if (component instanceof AffineImageToolbarWidget) {
setupImageToolbarAIEntry(component);
}
});
}
}
export const AIImageBlockSpec: ExtensionType[] = [ export const AIImageBlockSpec: ExtensionType[] = [
...ImageBlockSpec, ...ImageBlockSpec,
AIImageBlockWatcher, ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:image'),
config: imageToolbarAIEntryConfig(),
}),
]; ];

View File

@@ -3,7 +3,6 @@ import {
CodeBlockConfigExtension, CodeBlockConfigExtension,
codeToolbarWidget, codeToolbarWidget,
} from '@blocksuite/affine/blocks/code'; } from '@blocksuite/affine/blocks/code';
import { imageToolbarWidget } from '@blocksuite/affine/blocks/image';
import { ParagraphBlockConfigExtension } from '@blocksuite/affine/blocks/paragraph'; import { ParagraphBlockConfigExtension } from '@blocksuite/affine/blocks/paragraph';
import type { import type {
Container, Container,
@@ -134,7 +133,6 @@ export function enableMobileExtension(
framework: FrameworkProvider framework: FrameworkProvider
): void { ): void {
specBuilder.omit(codeToolbarWidget); specBuilder.omit(codeToolbarWidget);
specBuilder.omit(imageToolbarWidget);
specBuilder.omit(toolbarWidget); specBuilder.omit(toolbarWidget);
specBuilder.omit(SlashMenuExtension); specBuilder.omit(SlashMenuExtension);
specBuilder.extend([ specBuilder.extend([

View File

@@ -35,12 +35,13 @@ test('should paste it as PNG after copying SVG', async ({ page }) => {
await svg.hover(); await svg.hover();
await page.waitForTimeout(500); await page.waitForTimeout(500);
const imageToolbar = page.locator('affine-image-toolbar'); const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
await expect(imageToolbar).toBeVisible(); await expect(toolbar).toBeVisible();
await imageToolbar.getByRole('button', { name: 'More' }).click();
const moveMenu = page.locator('.image-more-popup-menu'); const moreMenu = toolbar.getByLabel('More menu');
await moveMenu.getByRole('button', { name: /^Copy$/ }).click(); await moreMenu.click();
await moreMenu.getByRole('button', { name: /^Copy$/ }).click();
await svg.click(); await svg.click();

View File

@@ -1,4 +1,4 @@
/* eslint-disable unicorn/prefer-dom-node-dataset */ /* oxlint-disable unicorn/prefer-dom-node-dataset */
import fs from 'node:fs'; import fs from 'node:fs';
import { test } from '@affine-test/kit/playwright'; import { test } from '@affine-test/kit/playwright';
@@ -723,9 +723,8 @@ test('caption should be visible and different styles were applied if image zoome
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await importImage(page, 'large-image.png'); await importImage(page, 'large-image.png');
await page.locator('affine-page-image').first().hover(); await page.locator('affine-page-image').first().hover();
await page const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
.locator('.affine-image-toolbar-container .image-toolbar-button.caption') await toolbar.getByLabel('Caption').click();
.click();
await page.getByPlaceholder('Write a caption').fill(sampleCaption); await page.getByPlaceholder('Write a caption').fill(sampleCaption);
await page.locator('affine-page-image').first().dblclick(); await page.locator('affine-page-image').first().dblclick();
const locator = page.getByTestId('image-preview-modal'); const locator = page.getByTestId('image-preview-modal');

View File

@@ -9,6 +9,7 @@ import {
dragEmbedResizeByTopLeft, dragEmbedResizeByTopLeft,
dragEmbedResizeByTopRight, dragEmbedResizeByTopRight,
enterPlaygroundRoom, enterPlaygroundRoom,
getEditorHostLocator,
initImageState, initImageState,
moveToImage, moveToImage,
pasteByKeyboard, pasteByKeyboard,
@@ -21,7 +22,6 @@ import {
waitNextFrame, waitNextFrame,
} from '../utils/actions/index.js'; } from '../utils/actions/index.js';
import { import {
assertImageOption,
assertImageSize, assertImageSize,
assertRichDragButton, assertRichDragButton,
assertRichImage, assertRichImage,
@@ -31,9 +31,8 @@ import {
import { test } from '../utils/playwright.js'; import { test } from '../utils/playwright.js';
async function focusCaption(page: Page) { async function focusCaption(page: Page) {
await page.click( const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
'.affine-image-toolbar-container .image-toolbar-button.caption' await toolbar.getByLabel('Caption').click();
);
} }
test('can drag resize image by left menu', async ({ page }) => { test('can drag resize image by left menu', async ({ page }) => {
@@ -115,11 +114,12 @@ test('enter shortcut on focusing embed block and its caption', async ({
await initImageState(page); await initImageState(page);
await assertRichImage(page, 1); await assertRichImage(page, 1);
await getEditorHostLocator(page).focus();
await moveToImage(page); await moveToImage(page);
await assertImageOption(page);
await focusCaption(page);
const caption = page.locator('affine-image block-caption-editor textarea'); const caption = page.locator('affine-image block-caption-editor textarea');
await focusCaption(page);
await type(page, '123'); await type(page, '123');
test.info().annotations.push({ test.info().annotations.push({
@@ -139,11 +139,12 @@ test('should support the enter key of image caption', async ({ page }) => {
await initImageState(page); await initImageState(page);
await assertRichImage(page, 1); await assertRichImage(page, 1);
await getEditorHostLocator(page).focus();
await moveToImage(page); await moveToImage(page);
await assertImageOption(page);
await focusCaption(page);
const caption = page.locator('affine-image block-caption-editor textarea'); const caption = page.locator('affine-image block-caption-editor textarea');
await focusCaption(page);
await type(page, 'abc123'); await type(page, 'abc123');
await pressArrowLeft(page, 3); await pressArrowLeft(page, 3);
await pressEnter(page); await pressEnter(page);

View File

@@ -1,70 +1,13 @@
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import { import {
activeEmbed,
dragBetweenCoords, dragBetweenCoords,
enterPlaygroundRoom, enterPlaygroundRoom,
initImageState, initImageState,
insertThreeLevelLists,
pressEnter,
scrollToTop,
} from '../utils/actions/index.js'; } from '../utils/actions/index.js';
import { assertRichImage } from '../utils/asserts.js'; import { assertRichImage } from '../utils/asserts.js';
import { test } from '../utils/playwright.js'; import { test } from '../utils/playwright.js';
// FIXME(@fundon): This behavior is not meeting the design spec
test.skip('popup menu should follow position of image when scrolling', async ({
page,
}) => {
await enterPlaygroundRoom(page);
await initImageState(page);
await activeEmbed(page);
await pressEnter(page);
await insertThreeLevelLists(page, 0);
await pressEnter(page);
await insertThreeLevelLists(page, 3);
await pressEnter(page);
await insertThreeLevelLists(page, 6);
await pressEnter(page);
await insertThreeLevelLists(page, 9);
await pressEnter(page);
await insertThreeLevelLists(page, 12);
await scrollToTop(page);
const rect = await page.locator('.affine-image-container img').boundingBox();
if (!rect) throw new Error('image not found');
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
await page.waitForTimeout(150);
const menu = page.locator('.affine-image-toolbar-container');
await expect(menu).toBeVisible();
await page.evaluate(
([rect]) => {
const viewport = document.querySelector('.affine-page-viewport');
if (!viewport) {
throw new Error();
}
// const distance = viewport.scrollHeight - viewport.clientHeight;
viewport.scrollTo(0, (rect.height + rect.y) / 2);
},
[rect]
);
await page.waitForTimeout(150);
const image = page.locator('.affine-image-container img');
const imageRect = await image.boundingBox();
const menuRect = await menu.boundingBox();
if (!imageRect) throw new Error('image not found');
if (!menuRect) throw new Error('menu not found');
expect(imageRect.y).toBeCloseTo((rect.y - rect.height) / 2, 172);
expect(menuRect.y).toBeCloseTo(65, -0.325);
});
test('select image should not show format bar', async ({ page }) => { test('select image should not show format bar', async ({ page }) => {
await enterPlaygroundRoom(page); await enterPlaygroundRoom(page);
await initImageState(page); await initImageState(page);

View File

@@ -1132,14 +1132,14 @@ test('should blur rich-text first on starting block selection', async ({
await expect(page.locator('*:focus')).toHaveCount(0); await expect(page.locator('*:focus')).toHaveCount(0);
}); });
test('should not show option menu of image on block selection', async ({ test('should show toolbar of image on block selection', async ({ page }) => {
page,
}) => {
await enterPlaygroundRoom(page); await enterPlaygroundRoom(page);
await initImageState(page); await initImageState(page);
await activeEmbed(page); await activeEmbed(page);
await expect(page.locator('.affine-image-toolbar-container')).toHaveCount(1); const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
await expect(toolbar).toBeHidden();
await pressEnter(page); await pressEnter(page);
@@ -1162,7 +1162,7 @@ test('should not show option menu of image on block selection', async ({
await page.waitForTimeout(50); await page.waitForTimeout(50);
await expect(page.locator('.affine-image-toolbar-container')).toHaveCount(0); await expect(toolbar).toBeVisible();
await expect( await expect(
page.locator('affine-block-selection').locator('visible=true') page.locator('affine-block-selection').locator('visible=true')
).toHaveCount(1); ).toHaveCount(1);

View File

@@ -1338,14 +1338,16 @@ test('should keep native range selection when scrolling forward with the scroll
assertClipItems(page, 'text/plain', '123456789'); assertClipItems(page, 'text/plain', '123456789');
}); });
test('should not show option menu of image on native selection', async ({ test('should not show toolbar of image on native selection', async ({
page, page,
}) => { }) => {
await enterPlaygroundRoom(page); await enterPlaygroundRoom(page);
await initImageState(page); await initImageState(page);
await activeEmbed(page); await activeEmbed(page);
await expect(page.locator('.affine-image-toolbar-container')).toHaveCount(1); const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
await expect(toolbar).toBeVisible();
await pressEscape(page); await pressEscape(page);
await pressEnter(page); await pressEnter(page);
@@ -1381,7 +1383,7 @@ test('should not show option menu of image on native selection', async ({
await copyByKeyboard(page); await copyByKeyboard(page);
assertClipItems(page, 'text/plain', '123'); assertClipItems(page, 'text/plain', '123');
await expect(page.locator('.affine-image-toolbar-container')).toHaveCount(0); await expect(toolbar).toBeHidden();
}); });
test('should select with shift-click', async ({ page }) => { test('should select with shift-click', async ({ page }) => {

View File

@@ -1,6 +1,5 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { assertImageOption } from '../asserts.js';
import { getIndexCoordinate, waitNextFrame } from './misc.js'; import { getIndexCoordinate, waitNextFrame } from './misc.js';
export async function dragBetweenCoords( export async function dragBetweenCoords(
@@ -201,42 +200,22 @@ export async function dragBlockToPoint(
} }
export async function moveToImage(page: Page) { export async function moveToImage(page: Page) {
const { x, y } = await page.evaluate(() => { await page.locator('affine-image').hover({ timeout: 500 });
const bottomRightButton = document.querySelector(
'affine-image img'
) as HTMLElement;
const imageClient = bottomRightButton.getBoundingClientRect();
const y = imageClient.top;
return {
x: imageClient.left + 30,
y: y + 30,
};
});
await page.mouse.move(x, y);
} }
export async function popImageMoreMenu(page: Page) { export async function popImageMoreMenu(page: Page) {
await moveToImage(page); await moveToImage(page);
await assertImageOption(page); const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
const moreButton = page.locator('.image-toolbar-button.more'); const menu = toolbar.getByLabel('More menu');
await moreButton.click(); await menu.click();
const menu = page.locator('.image-more-popup-menu');
const turnIntoCardButton = page.locator('editor-menu-action', { const turnIntoCardButton = menu.getByLabel('Turn into card view');
hasText: 'Turn into card view',
});
const copyButton = page.locator('editor-menu-action', { const copyButton = menu.getByLabel('Copy');
hasText: 'Copy',
});
const duplicateButton = page.locator('editor-menu-action', { const duplicateButton = page.getByLabel('Duplicate');
hasText: 'Duplicate',
});
const deleteButton = page.locator('editor-menu-action', { const deleteButton = page.getByLabel('Delete');
hasText: 'Delete',
});
return { return {
menu, menu,

View File

@@ -217,13 +217,6 @@ export async function assertImageSize(
}); });
} }
export async function assertImageOption(page: Page) {
// const actual = await page.locator('.embed-editing-state').count();
// expect(actual).toEqual(1);
const locator = page.locator('.affine-image-toolbar-container');
await expect(locator).toBeVisible();
}
export async function assertDocTitleFocus(page: Page) { export async function assertDocTitleFocus(page: Page) {
const locator = page.locator('doc-title .inline-editor').nth(0); const locator = page.locator('doc-title .inline-editor').nth(0);
await expect(locator).toBeFocused(); await expect(locator).toBeFocused();