diff --git a/blocksuite/affine/blocks/block-image/src/configs/toolbar.ts b/blocksuite/affine/blocks/block-image/src/configs/toolbar.ts index 2fd97b7e1..b513cc3fb 100644 --- a/blocksuite/affine/blocks/block-image/src/configs/toolbar.ts +++ b/blocksuite/affine/blocks/block-image/src/configs/toolbar.ts @@ -1,19 +1,121 @@ import { ImageBlockModel } from '@blocksuite/affine-model'; import { + ActionPlacement, type ToolbarModuleConfig, ToolbarModuleExtension, } 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 type { ExtensionType } from '@blocksuite/store'; +import { ImageBlockComponent } from '../image-block'; import { ImageEdgelessBlockComponent } from '../image-edgeless-block'; +import { duplicate } from '../utils'; const trackBaseProps = { category: 'image', 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 = { actions: [ { @@ -50,6 +152,11 @@ export const createBuiltinToolbarConfigExtension = ( const name = flavour.split(':').pop(); return [ + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), + ToolbarModuleExtension({ id: BlockFlavourIdentifier(`affine:surface:${name}`), config: builtinSurfaceToolbarConfig, diff --git a/blocksuite/affine/blocks/block-image/src/image-block.ts b/blocksuite/affine/blocks/block-image/src/image-block.ts index 230a857d8..ac2aae2ad 100644 --- a/blocksuite/affine/blocks/block-image/src/image-block.ts +++ b/blocksuite/affine/blocks/block-image/src/image-block.ts @@ -1,6 +1,8 @@ import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { whenHover } from '@blocksuite/affine-components/hover'; import { Peekable } from '@blocksuite/affine-components/peek'; import type { ImageBlockModel } from '@blocksuite/affine-model'; +import { ToolbarRegistryIdentifier } from '@blocksuite/affine-shared/services'; import { IS_MOBILE } from '@blocksuite/global/env'; import { BlockSelection } from '@blocksuite/std'; import { html } from 'lit'; @@ -54,9 +56,33 @@ export class ImageBlockComponent extends CaptionedBlockComponent { + 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() { super.connectedCallback(); + this._initHover(); this.refreshData(); this.contentEditable = 'false'; this._disposables.add( diff --git a/blocksuite/affine/blocks/block-image/src/image-spec.ts b/blocksuite/affine/blocks/block-image/src/image-spec.ts index f0d692cca..c83ff28dc 100644 --- a/blocksuite/affine/blocks/block-image/src/image-spec.ts +++ b/blocksuite/affine/blocks/block-image/src/image-spec.ts @@ -1,10 +1,6 @@ import { ImageBlockSchema } from '@blocksuite/affine-model'; import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu'; -import { - BlockViewExtension, - FlavourExtension, - WidgetViewExtension, -} from '@blocksuite/std'; +import { BlockViewExtension, FlavourExtension } from '@blocksuite/std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; @@ -16,12 +12,6 @@ import { ImageDropOption } from './image-service'; const flavour = ImageBlockSchema.model.flavour; -export const imageToolbarWidget = WidgetViewExtension( - flavour, - 'imageToolbar', - literal`affine-image-toolbar-widget` -); - export const ImageBlockSpec: ExtensionType[] = [ FlavourExtension(flavour), BlockViewExtension(flavour, model => { @@ -33,7 +23,6 @@ export const ImageBlockSpec: ExtensionType[] = [ return literal`affine-image`; }), - imageToolbarWidget, ImageDropOption, ImageBlockAdapterExtensions, createBuiltinToolbarConfigExtension(flavour), diff --git a/blocksuite/affine/blocks/block-image/src/utils.ts b/blocksuite/affine/blocks/block-image/src/utils.ts index 5d0324aa4..69f273b16 100644 --- a/blocksuite/affine/blocks/block-image/src/utils.ts +++ b/blocksuite/affine/blocks/block-image/src/utils.ts @@ -11,13 +11,19 @@ import { } from '@blocksuite/affine-shared/services'; import { downloadBlob, + getBlockProps, humanFileSize, + isInsidePageEditor, readImageSize, transformModel, withTempBlobData, } from '@blocksuite/affine-shared/utils'; 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 type { BlockModel } from '@blocksuite/store'; @@ -552,3 +558,49 @@ export function calcBoundByOrigin( ? new Bound(point[0], point[1], 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); +} diff --git a/blocksuite/affine/blocks/block-root/src/effects.ts b/blocksuite/affine/blocks/block-root/src/effects.ts index ad93ff20c..9e4e9a128 100644 --- a/blocksuite/affine/blocks/block-root/src/effects.ts +++ b/blocksuite/affine/blocks/block-root/src/effects.ts @@ -31,7 +31,6 @@ import { AffineTemplateLoading } from './edgeless/components/toolbar/template/te import { EdgelessTemplatePanel } from './edgeless/components/toolbar/template/template-panel.js'; import { EdgelessTemplateButton } from './edgeless/components/toolbar/template/template-tool-button.js'; import { - AffineImageToolbarWidget, AffineModalWidget, EdgelessRootBlockComponent, EdgelessRootPreviewBlockComponent, @@ -48,8 +47,6 @@ import { } from './widgets/edgeless-zoom-toolbar/index.js'; import { ZoomBarToggleButton } from './widgets/edgeless-zoom-toolbar/zoom-bar-toggle-button.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 { AFFINE_INNER_MODAL_WIDGET, AffineInnerModalWidget, @@ -109,7 +106,6 @@ function registerWidgets() { AFFINE_PAGE_DRAGGING_AREA_WIDGET, AffinePageDraggingAreaWidget ); - customElements.define(AFFINE_IMAGE_TOOLBAR_WIDGET, AffineImageToolbarWidget); customElements.define( AFFINE_VIEWPORT_OVERLAY_WIDGET, AffineViewportOverlayWidget @@ -146,7 +142,6 @@ function registerMiscComponents() { customElements.define('affine-template-loading', AffineTemplateLoading); // Toolbar and UI components - customElements.define('affine-image-toolbar', AffineImageToolbar); customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar); customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton); customElements.define('overlay-scrollbar', OverlayScrollbar); @@ -200,10 +195,8 @@ declare global { 'affine-page-root': PageRootBlockComponent; 'zoom-bar-toggle-button': ZoomBarToggleButton; 'edgeless-zoom-toolbar': EdgelessZoomToolbar; - 'affine-image-toolbar': AffineImageToolbar; [AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET]: AffineEdgelessZoomToolbarWidget; - [AFFINE_IMAGE_TOOLBAR_WIDGET]: AffineImageToolbarWidget; [AFFINE_INNER_MODAL_WIDGET]: AffineInnerModalWidget; } } diff --git a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/components/image-toolbar.ts b/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/components/image-toolbar.ts deleted file mode 100644 index 82fe148c4..000000000 --- a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/components/image-toolbar.ts +++ /dev/null @@ -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` - -
- ${renderGroups(this.moreGroups, this.context)} -
-
- `, - 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` - - ${renderGroups(this.primaryGroups, this.context)} - this._toggleMoreMenu()} - > - ${MoreVerticalIcon()} - - - `; - } - - @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[]; - - @property({ attribute: false }) - accessor onActiveStatusChange: (active: boolean) => void = noop; - - @property({ attribute: false }) - accessor primaryGroups!: MenuItemGroup[]; -} diff --git a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/config.ts b/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/config.ts deleted file mode 100644 index c86f97e83..000000000 --- a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/config.ts +++ /dev/null @@ -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[] = [ - { - type: 'primary', - items: [ - { - type: 'download', - label: 'Download', - icon: DownloadIcon, - generate: ({ blockComponent }) => { - return { - action: () => { - blockComponent.download(); - }, - render: item => html` - { - e.stopPropagation(); - item.action(); - }} - > - ${item.icon} - - `, - }; - }, - }, - { - type: 'caption', - label: 'Caption', - icon: CaptionIcon, - when: ({ doc }) => !doc.readonly, - generate: ({ blockComponent }) => { - return { - action: () => { - blockComponent.captionEditor?.show(); - }, - render: item => html` - { - e.stopPropagation(); - item.action(); - }} - > - ${item.icon} - - `, - }; - }, - }, - ], - }, -]; - -// Clipboard Group -export const clipboardGroup: MenuItemGroup = { - 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 = { - 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 = { - 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[] = [ - clipboardGroup, - conversionsGroup, - deleteGroup, -]; diff --git a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/context.ts b/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/context.ts deleted file mode 100644 index 763938d5e..000000000 --- a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/context.ts +++ /dev/null @@ -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; - } -} diff --git a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/index.ts b/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/index.ts deleted file mode 100644 index f7abdc301..000000000 --- a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/index.ts +++ /dev/null @@ -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` { - this._isActivated = active; - if (!active && !this._hoverController?.isHovering) { - this._hoverController?.abort(); - } - }} - >`, - 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[], - 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[], - 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[] = cloneGroups(MORE_GROUPS); - - primaryGroups: MenuItemGroup[] = - 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(); - } -} diff --git a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/styles.ts b/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/styles.ts deleted file mode 100644 index 9fee84817..000000000 --- a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/styles.ts +++ /dev/null @@ -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; - } -`; diff --git a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/utils.ts b/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/utils.ts deleted file mode 100644 index 9b7e14bd8..000000000 --- a/blocksuite/affine/blocks/block-root/src/widgets/image-toolbar/utils.ts +++ /dev/null @@ -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); -} diff --git a/blocksuite/affine/blocks/block-root/src/widgets/index.ts b/blocksuite/affine/blocks/block-root/src/widgets/index.ts index ade6ba704..286eee9df 100644 --- a/blocksuite/affine/blocks/block-root/src/widgets/index.ts +++ b/blocksuite/affine/blocks/block-root/src/widgets/index.ts @@ -1,5 +1,4 @@ export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js'; -export { AffineImageToolbarWidget } from './image-toolbar/index.js'; export { AffineInnerModalWidget } from './inner-modal/inner-modal.js'; export * from './keyboard-toolbar/index.js'; export { diff --git a/blocksuite/affine/shared/src/services/toolbar-service/context.ts b/blocksuite/affine/shared/src/services/toolbar-service/context.ts index 44f4a48de..42182f413 100644 --- a/blocksuite/affine/shared/src/services/toolbar-service/context.ts +++ b/blocksuite/affine/shared/src/services/toolbar-service/context.ts @@ -192,11 +192,8 @@ abstract class ToolbarContextBase { }; const getFromMessage = () => { - const msgEle = this.message$.peek()?.element; - if (msgEle instanceof BlockComponent) { - return msgEle; - } - return null; + const block = this.message$.peek()?.element; + return block instanceof BlockComponent ? block : null; }; return getFromSelection() ?? getFromMessage(); @@ -234,11 +231,8 @@ abstract class ToolbarContextBase { }; const getFromMessage = () => { - const msgEle = this.message$.peek()?.element; - if (msgEle instanceof BlockComponent) { - return msgEle.model; - } - return null; + const block = this.message$.peek()?.element; + return block instanceof BlockComponent ? block.model : null; }; return getFromSelection() ?? getFromMessage(); diff --git a/blocksuite/affine/widgets/widget-toolbar/src/toolbar.ts b/blocksuite/affine/widgets/widget-toolbar/src/toolbar.ts index b422966e0..c8e8a73ee 100644 --- a/blocksuite/affine/widgets/widget-toolbar/src/toolbar.ts +++ b/blocksuite/affine/widgets/widget-toolbar/src/toolbar.ts @@ -88,6 +88,7 @@ export class AffineToolbarWidget extends WidgetComponent { box-sizing: border-box; gap: 4px; + .inner-button, editor-icon-button, editor-menu-button { background: ${unsafeCSSVarV2('button/iconButtonSolid')}; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ask-ai-button.ts b/packages/frontend/core/src/blocksuite/ai/components/ask-ai-button.ts index 448898cc0..9a414d244 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ask-ai-button.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ask-ai-button.ts @@ -92,7 +92,7 @@ export class AskAIButton extends WithDisposable(LitElement) { return; } - if (this._abortController) { + if (this._abortController && !this._abortController.signal.aborted) { this._clearAbortController(); return; } diff --git a/packages/frontend/core/src/blocksuite/ai/entries/image-toolbar/setup-image-toolbar.ts b/packages/frontend/core/src/blocksuite/ai/entries/image-toolbar/setup-image-toolbar.ts index 4fe2c721b..cfffcdb8d 100644 --- a/packages/frontend/core/src/blocksuite/ai/entries/image-toolbar/setup-image-toolbar.ts +++ b/packages/frontend/core/src/blocksuite/ai/entries/image-toolbar/setup-image-toolbar.ts @@ -1,7 +1,11 @@ import '../../components/ask-ai-button'; -import type { AffineImageToolbarWidget } from '@blocksuite/affine/blocks/root'; -import { ImageSelection } from '@blocksuite/affine/shared/selection'; +import { ImageBlockComponent } from '@blocksuite/affine/blocks/image'; +import { + ActionPlacement, + type ToolbarModuleConfig, +} from '@blocksuite/affine/shared/services'; +import { BlockSelection } from '@blocksuite/affine/std'; import { html } from 'lit'; import { buildAIImageItemGroups } from '../../_common/config'; @@ -14,40 +18,34 @@ const buttonOptions: AskAIButtonOptions = { panelWidth: 300, }; -export function setupImageToolbarAIEntry( - imageToolbar: AffineImageToolbarWidget -) { - imageToolbar.addPrimaryItems( - [ +export function imageToolbarAIEntryConfig(): ToolbarModuleConfig { + return { + actions: [ { - type: 'ask-ai', - when: ({ doc }) => !doc.readonly, - generate: ({ host, blockComponent }) => { - return { - action: () => { - const { selection } = host; - selection.setGroup('note', [ - selection.create(ImageSelection, { - blockId: blockComponent.blockId, + placement: ActionPlacement.Start, + id: 'A.ai', + score: -1, + content: ctx => { + const block = ctx.getCurrentBlockByType(ImageBlockComponent); + if (!block) return null; + + return html` { + e.stopPropagation(); + ctx.selection.update(() => [ + ctx.selection.create(BlockSelection, { + blockId: block.blockId, }), ]); - }, - render: item => - html` { - e.stopPropagation(); - item.action(); - }} - >`, - }; + }} + >`; }, }, ], - 0 - ); + }; } diff --git a/packages/frontend/core/src/blocksuite/ai/extensions/ai-image.ts b/packages/frontend/core/src/blocksuite/ai/extensions/ai-image.ts index a3f0b65d3..912fcf6a0 100644 --- a/packages/frontend/core/src/blocksuite/ai/extensions/ai-image.ts +++ b/packages/frontend/core/src/blocksuite/ai/extensions/ai-image.ts @@ -1,29 +1,14 @@ import { ImageBlockSpec } from '@blocksuite/affine/blocks/image'; -import { AffineImageToolbarWidget } from '@blocksuite/affine/blocks/root'; -import { LifeCycleWatcher } from '@blocksuite/affine/std'; +import { ToolbarModuleExtension } from '@blocksuite/affine/shared/services'; +import { BlockFlavourIdentifier } from '@blocksuite/affine/std'; import type { ExtensionType } from '@blocksuite/affine/store'; -import { setupImageToolbarAIEntry } 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); - } - }); - } -} +import { imageToolbarAIEntryConfig } from '../entries/image-toolbar/setup-image-toolbar'; export const AIImageBlockSpec: ExtensionType[] = [ ...ImageBlockSpec, - AIImageBlockWatcher, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('custom:affine:image'), + config: imageToolbarAIEntryConfig(), + }), ]; diff --git a/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts b/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts index 5dad9bc14..6b30103ec 100644 --- a/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts +++ b/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts @@ -3,7 +3,6 @@ import { CodeBlockConfigExtension, codeToolbarWidget, } from '@blocksuite/affine/blocks/code'; -import { imageToolbarWidget } from '@blocksuite/affine/blocks/image'; import { ParagraphBlockConfigExtension } from '@blocksuite/affine/blocks/paragraph'; import type { Container, @@ -134,7 +133,6 @@ export function enableMobileExtension( framework: FrameworkProvider ): void { specBuilder.omit(codeToolbarWidget); - specBuilder.omit(imageToolbarWidget); specBuilder.omit(toolbarWidget); specBuilder.omit(SlashMenuExtension); specBuilder.extend([ diff --git a/tests/affine-desktop/e2e/image.spec.ts b/tests/affine-desktop/e2e/image.spec.ts index 657d283d6..9a12b8a4a 100644 --- a/tests/affine-desktop/e2e/image.spec.ts +++ b/tests/affine-desktop/e2e/image.spec.ts @@ -35,12 +35,13 @@ test('should paste it as PNG after copying SVG', async ({ page }) => { await svg.hover(); await page.waitForTimeout(500); - const imageToolbar = page.locator('affine-image-toolbar'); - await expect(imageToolbar).toBeVisible(); - await imageToolbar.getByRole('button', { name: 'More' }).click(); + const toolbar = page.locator('affine-toolbar-widget editor-toolbar'); + await expect(toolbar).toBeVisible(); - const moveMenu = page.locator('.image-more-popup-menu'); - await moveMenu.getByRole('button', { name: /^Copy$/ }).click(); + const moreMenu = toolbar.getByLabel('More menu'); + await moreMenu.click(); + + await moreMenu.getByRole('button', { name: /^Copy$/ }).click(); await svg.click(); diff --git a/tests/affine-local/e2e/image-preview.spec.ts b/tests/affine-local/e2e/image-preview.spec.ts index c4eee2475..0bded9e1a 100644 --- a/tests/affine-local/e2e/image-preview.spec.ts +++ b/tests/affine-local/e2e/image-preview.spec.ts @@ -1,4 +1,4 @@ -/* eslint-disable unicorn/prefer-dom-node-dataset */ +/* oxlint-disable unicorn/prefer-dom-node-dataset */ import fs from 'node:fs'; 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 importImage(page, 'large-image.png'); await page.locator('affine-page-image').first().hover(); - await page - .locator('.affine-image-toolbar-container .image-toolbar-button.caption') - .click(); + const toolbar = page.locator('affine-toolbar-widget editor-toolbar'); + await toolbar.getByLabel('Caption').click(); await page.getByPlaceholder('Write a caption').fill(sampleCaption); await page.locator('affine-page-image').first().dblclick(); const locator = page.getByTestId('image-preview-modal'); diff --git a/tests/blocksuite/e2e/image/image.spec.ts b/tests/blocksuite/e2e/image/image.spec.ts index de51aaac5..fe0e41220 100644 --- a/tests/blocksuite/e2e/image/image.spec.ts +++ b/tests/blocksuite/e2e/image/image.spec.ts @@ -9,6 +9,7 @@ import { dragEmbedResizeByTopLeft, dragEmbedResizeByTopRight, enterPlaygroundRoom, + getEditorHostLocator, initImageState, moveToImage, pasteByKeyboard, @@ -21,7 +22,6 @@ import { waitNextFrame, } from '../utils/actions/index.js'; import { - assertImageOption, assertImageSize, assertRichDragButton, assertRichImage, @@ -31,9 +31,8 @@ import { import { test } from '../utils/playwright.js'; async function focusCaption(page: Page) { - await page.click( - '.affine-image-toolbar-container .image-toolbar-button.caption' - ); + const toolbar = page.locator('affine-toolbar-widget editor-toolbar'); + await toolbar.getByLabel('Caption').click(); } 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 assertRichImage(page, 1); + await getEditorHostLocator(page).focus(); await moveToImage(page); - await assertImageOption(page); + + await focusCaption(page); const caption = page.locator('affine-image block-caption-editor textarea'); - await focusCaption(page); await type(page, '123'); test.info().annotations.push({ @@ -139,11 +139,12 @@ test('should support the enter key of image caption', async ({ page }) => { await initImageState(page); await assertRichImage(page, 1); + await getEditorHostLocator(page).focus(); await moveToImage(page); - await assertImageOption(page); + + await focusCaption(page); const caption = page.locator('affine-image block-caption-editor textarea'); - await focusCaption(page); await type(page, 'abc123'); await pressArrowLeft(page, 3); await pressEnter(page); diff --git a/tests/blocksuite/e2e/image/menu.spec.ts b/tests/blocksuite/e2e/image/menu.spec.ts index e62cf9632..2a9cee1a3 100644 --- a/tests/blocksuite/e2e/image/menu.spec.ts +++ b/tests/blocksuite/e2e/image/menu.spec.ts @@ -1,70 +1,13 @@ import { expect } from '@playwright/test'; import { - activeEmbed, dragBetweenCoords, enterPlaygroundRoom, initImageState, - insertThreeLevelLists, - pressEnter, - scrollToTop, } from '../utils/actions/index.js'; import { assertRichImage } from '../utils/asserts.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 }) => { await enterPlaygroundRoom(page); await initImageState(page); diff --git a/tests/blocksuite/e2e/selection/block.spec.ts b/tests/blocksuite/e2e/selection/block.spec.ts index 6138e281d..8f90ba630 100644 --- a/tests/blocksuite/e2e/selection/block.spec.ts +++ b/tests/blocksuite/e2e/selection/block.spec.ts @@ -1132,14 +1132,14 @@ test('should blur rich-text first on starting block selection', async ({ await expect(page.locator('*:focus')).toHaveCount(0); }); -test('should not show option menu of image on block selection', async ({ - page, -}) => { +test('should show toolbar of image on block selection', async ({ page }) => { await enterPlaygroundRoom(page); await initImageState(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); @@ -1162,7 +1162,7 @@ test('should not show option menu of image on block selection', async ({ await page.waitForTimeout(50); - await expect(page.locator('.affine-image-toolbar-container')).toHaveCount(0); + await expect(toolbar).toBeVisible(); await expect( page.locator('affine-block-selection').locator('visible=true') ).toHaveCount(1); diff --git a/tests/blocksuite/e2e/selection/native.spec.ts b/tests/blocksuite/e2e/selection/native.spec.ts index f1ab91d50..3d3a94e1f 100644 --- a/tests/blocksuite/e2e/selection/native.spec.ts +++ b/tests/blocksuite/e2e/selection/native.spec.ts @@ -1338,14 +1338,16 @@ test('should keep native range selection when scrolling forward with the scroll 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, }) => { await enterPlaygroundRoom(page); await initImageState(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 pressEnter(page); @@ -1381,7 +1383,7 @@ test('should not show option menu of image on native selection', async ({ await copyByKeyboard(page); 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 }) => { diff --git a/tests/blocksuite/e2e/utils/actions/drag.ts b/tests/blocksuite/e2e/utils/actions/drag.ts index 67b4b1dae..97b335a86 100644 --- a/tests/blocksuite/e2e/utils/actions/drag.ts +++ b/tests/blocksuite/e2e/utils/actions/drag.ts @@ -1,6 +1,5 @@ import type { Page } from '@playwright/test'; -import { assertImageOption } from '../asserts.js'; import { getIndexCoordinate, waitNextFrame } from './misc.js'; export async function dragBetweenCoords( @@ -201,42 +200,22 @@ export async function dragBlockToPoint( } export async function moveToImage(page: Page) { - const { x, y } = await page.evaluate(() => { - 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); + await page.locator('affine-image').hover({ timeout: 500 }); } export async function popImageMoreMenu(page: Page) { await moveToImage(page); - await assertImageOption(page); - const moreButton = page.locator('.image-toolbar-button.more'); - await moreButton.click(); - const menu = page.locator('.image-more-popup-menu'); + const toolbar = page.locator('affine-toolbar-widget editor-toolbar'); + const menu = toolbar.getByLabel('More menu'); + await menu.click(); - const turnIntoCardButton = page.locator('editor-menu-action', { - hasText: 'Turn into card view', - }); + const turnIntoCardButton = menu.getByLabel('Turn into card view'); - const copyButton = page.locator('editor-menu-action', { - hasText: 'Copy', - }); + const copyButton = menu.getByLabel('Copy'); - const duplicateButton = page.locator('editor-menu-action', { - hasText: 'Duplicate', - }); + const duplicateButton = page.getByLabel('Duplicate'); - const deleteButton = page.locator('editor-menu-action', { - hasText: 'Delete', - }); + const deleteButton = page.getByLabel('Delete'); return { menu, diff --git a/tests/blocksuite/e2e/utils/asserts.ts b/tests/blocksuite/e2e/utils/asserts.ts index 4c02d35b1..8b112e2cb 100644 --- a/tests/blocksuite/e2e/utils/asserts.ts +++ b/tests/blocksuite/e2e/utils/asserts.ts @@ -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) { const locator = page.locator('doc-title .inline-editor').nth(0); await expect(locator).toBeFocused();