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:
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>[];
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
];
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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')};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
return {
|
||||||
) {
|
actions: [
|
||||||
imageToolbar.addPrimaryItems(
|
|
||||||
[
|
|
||||||
{
|
{
|
||||||
type: 'ask-ai',
|
placement: ActionPlacement.Start,
|
||||||
when: ({ doc }) => !doc.readonly,
|
id: 'A.ai',
|
||||||
generate: ({ host, blockComponent }) => {
|
score: -1,
|
||||||
return {
|
content: ctx => {
|
||||||
action: () => {
|
const block = ctx.getCurrentBlockByType(ImageBlockComponent);
|
||||||
const { selection } = host;
|
if (!block) return null;
|
||||||
selection.setGroup('note', [
|
|
||||||
selection.create(ImageSelection, {
|
return html`<ask-ai-button
|
||||||
blockId: blockComponent.blockId,
|
class="ask-ai inner-button"
|
||||||
|
.host=${ctx.host}
|
||||||
|
.actionGroups=${AIImageItemGroups}
|
||||||
|
.toggleType="${'click'}"
|
||||||
|
.options=${buttonOptions}
|
||||||
|
@click=${(e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
ctx.selection.update(() => [
|
||||||
|
ctx.selection.create(BlockSelection, {
|
||||||
|
blockId: block.blockId,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
}}
|
||||||
render: item =>
|
></ask-ai-button>`;
|
||||||
html`<ask-ai-button
|
|
||||||
class="image-toolbar-button ask-ai"
|
|
||||||
.host=${host}
|
|
||||||
.actionGroups=${AIImageItemGroups}
|
|
||||||
.toggleType=${'click'}
|
|
||||||
.options=${buttonOptions}
|
|
||||||
@click=${(e: MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
item.action();
|
|
||||||
}}
|
|
||||||
></ask-ai-button>`,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
0
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user