From af91a0217f485e8d3794c3f866f0d533b6b86ceb Mon Sep 17 00:00:00 2001 From: L-Sun Date: Fri, 28 Mar 2025 05:48:24 +0000 Subject: [PATCH] refactor(editor): inner toolbar surface-ref block with extension (#11246) This PR refactor `surface-ref` toolbar with `ToolbarExtension` --- .../blocks/block-surface-ref/package.json | 1 + .../block-surface-ref/src/components/index.ts | 1 + .../components/surface-ref-toolbar-title.ts | 68 +++++ .../block-surface-ref/src/configs/toolbar.ts | 125 ++++++++ .../blocks/block-surface-ref/src/effects.ts | 21 +- .../src/surface-ref-block.ts | 50 ++++ .../block-surface-ref/src/surface-ref-spec.ts | 27 +- .../blocks/block-surface-ref/src/utils.ts | 47 +++ .../block-surface-ref/src/widgets/config.ts | 122 -------- .../block-surface-ref/src/widgets/context.ts | 44 --- .../src/widgets/surface-ref-toolbar.ts | 280 ------------------ .../block-surface-ref/src/widgets/utils.ts | 48 --- .../blocks/block-surface-ref/tsconfig.json | 1 + .../src/services/toolbar-service/context.ts | 60 ++-- .../extensions/editor-config/toolbar/index.ts | 184 +++++++++++- .../extensions/entry/enable-mobile.ts | 2 - .../workspace/detail-page/detail-page.tsx | 1 + packages/frontend/i18n/src/i18n.gen.ts | 4 + packages/frontend/i18n/src/resources/en.json | 1 + tests/affine-local/e2e/peek-view.spec.ts | 4 +- tools/utils/src/workspace.gen.ts | 1 + yarn.lock | 1 + 22 files changed, 551 insertions(+), 542 deletions(-) create mode 100644 blocksuite/affine/blocks/block-surface-ref/src/components/index.ts create mode 100644 blocksuite/affine/blocks/block-surface-ref/src/components/surface-ref-toolbar-title.ts create mode 100644 blocksuite/affine/blocks/block-surface-ref/src/configs/toolbar.ts delete mode 100644 blocksuite/affine/blocks/block-surface-ref/src/widgets/config.ts delete mode 100644 blocksuite/affine/blocks/block-surface-ref/src/widgets/context.ts delete mode 100644 blocksuite/affine/blocks/block-surface-ref/src/widgets/surface-ref-toolbar.ts delete mode 100644 blocksuite/affine/blocks/block-surface-ref/src/widgets/utils.ts diff --git a/blocksuite/affine/blocks/block-surface-ref/package.json b/blocksuite/affine/blocks/block-surface-ref/package.json index e8f06e1b8d..a0664d90fe 100644 --- a/blocksuite/affine/blocks/block-surface-ref/package.json +++ b/blocksuite/affine/blocks/block-surface-ref/package.json @@ -13,6 +13,7 @@ "@blocksuite/affine-block-frame": "workspace:*", "@blocksuite/affine-block-surface": "workspace:*", "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-inline-reference": "workspace:*", "@blocksuite/affine-model": "workspace:*", "@blocksuite/affine-shared": "workspace:*", "@blocksuite/affine-widget-slash-menu": "workspace:*", diff --git a/blocksuite/affine/blocks/block-surface-ref/src/components/index.ts b/blocksuite/affine/blocks/block-surface-ref/src/components/index.ts new file mode 100644 index 0000000000..284bdca8b4 --- /dev/null +++ b/blocksuite/affine/blocks/block-surface-ref/src/components/index.ts @@ -0,0 +1 @@ +export { SurfaceRefToolbarTitle } from './surface-ref-toolbar-title'; diff --git a/blocksuite/affine/blocks/block-surface-ref/src/components/surface-ref-toolbar-title.ts b/blocksuite/affine/blocks/block-surface-ref/src/components/surface-ref-toolbar-title.ts new file mode 100644 index 0000000000..40c704069a --- /dev/null +++ b/blocksuite/affine/blocks/block-surface-ref/src/components/surface-ref-toolbar-title.ts @@ -0,0 +1,68 @@ +import { + FrameBlockModel, + GroupElementModel, + MindmapElementModel, + ShapeElementModel, +} from '@blocksuite/affine-model'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import type { GfxModel } from '@blocksuite/block-std/gfx'; +import { + EdgelessIcon, + FrameIcon, + GroupIcon, + MindmapIcon, +} from '@blocksuite/icons/lit'; +import { css, html, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +export class SurfaceRefToolbarTitle extends ShadowlessElement { + static override styles = css` + surface-ref-toolbar-title { + display: flex; + padding: 2px 4px; + margin-right: auto; + align-items: center; + gap: 4px; + border-radius: 2px; + background: ${unsafeCSSVarV2('button/iconButtonSolid')}; + + svg { + color: ${unsafeCSSVarV2('icon/primary')}; + width: 16px; + height: 16px; + } + + span { + color: ${unsafeCSSVarV2('text/primary')}; + font-size: 12px; + font-weight: 500; + line-height: 20px; + } + } + `; + + @property({ attribute: false }) + accessor referenceModel: GfxModel | null = null; + + override render() { + const { referenceModel } = this; + let title = ''; + let icon: TemplateResult = EdgelessIcon(); + if (referenceModel instanceof GroupElementModel) { + title = referenceModel.title.toString(); + icon = GroupIcon(); + } else if (referenceModel instanceof FrameBlockModel) { + title = referenceModel.props.title.toString(); + icon = FrameIcon(); + } else if (referenceModel instanceof MindmapElementModel) { + const rootElement = referenceModel.tree.element; + if (rootElement instanceof ShapeElementModel) { + title = rootElement.text?.toString() ?? ''; + } + icon = MindmapIcon(); + } + + return html`${icon}${title}`; + } +} diff --git a/blocksuite/affine/blocks/block-surface-ref/src/configs/toolbar.ts b/blocksuite/affine/blocks/block-surface-ref/src/configs/toolbar.ts new file mode 100644 index 0000000000..b890208af0 --- /dev/null +++ b/blocksuite/affine/blocks/block-surface-ref/src/configs/toolbar.ts @@ -0,0 +1,125 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import { + copySelectedModelsCommand, + draftSelectedModelsCommand, +} from '@blocksuite/affine-shared/commands'; +import { + ActionPlacement, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { CaptionIcon, CopyIcon, DeleteIcon } from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +import { SurfaceRefBlockComponent } from '../surface-ref-block'; +import { surfaceRefToBlob, writeImageBlobToClipboard } from '../utils'; + +export const surfaceRefToolbarModuleConfig: ToolbarModuleConfig = { + actions: [ + { + id: 'a.surface-ref-title', + content: ctx => { + const surfaceRefBlock = ctx.getCurrentBlockByType( + SurfaceRefBlockComponent + ); + if (!surfaceRefBlock) return null; + + return html``; + }, + }, + { + id: 'c.copy-surface-ref', + label: 'Copy', + icon: CopyIcon(), + run: ctx => { + const surfaceRefBlock = ctx.getCurrentBlockByType( + SurfaceRefBlockComponent + ); + if (!surfaceRefBlock) return; + + ctx.chain + .pipe(draftSelectedModelsCommand, { + selectedModels: [surfaceRefBlock.model], + }) + .pipe(copySelectedModelsCommand) + .run(); + + toast(surfaceRefBlock.std.host, 'Copied to clipboard'); + }, + }, + { + id: 'd.surface-ref-caption', + icon: CaptionIcon(), + run: ctx => { + const surfaceRefBlock = ctx.getCurrentBlockByType( + SurfaceRefBlockComponent + ); + if (!surfaceRefBlock) return; + + surfaceRefBlock.captionElement.show(); + }, + }, + { + id: 'a.clipboard', + placement: ActionPlacement.More, + when: ctx => { + const surfaceRefBlock = ctx.getCurrentBlock(); + if (!(surfaceRefBlock instanceof SurfaceRefBlockComponent)) + return false; + + return !!surfaceRefBlock.referenceModel; + }, + actions: [ + { + id: 'a.surface-ref-copy-as-image', + label: 'Copy as Image', + icon: CopyIcon(), + placement: ActionPlacement.More, + when: ctx => { + const surfaceRefBlock = ctx.getCurrentBlockByType( + SurfaceRefBlockComponent + ); + if (!surfaceRefBlock) return false; + + return !!surfaceRefBlock.referenceModel; + }, + run: ctx => { + const surfaceRefBlock = ctx.getCurrentBlockByType( + SurfaceRefBlockComponent + ); + if (!surfaceRefBlock) return; + + surfaceRefToBlob(surfaceRefBlock) + .then(async blob => { + if (!blob) { + toast(ctx.host, 'Failed to render surface-ref to image'); + } else { + await writeImageBlobToClipboard(blob); + toast(ctx.host, 'Copied image to clipboard'); + } + }) + .catch(console.error); + }, + }, + // TODO(@L-Sun): add duplicate action after refactoring root-block/edgeless + ], + }, + { + id: 'g.surface-ref-deletion', + label: 'Delete', + icon: DeleteIcon(), + placement: ActionPlacement.More, + variant: 'destructive', + run: ctx => { + const surfaceRefBlock = ctx.getCurrentBlockByType( + SurfaceRefBlockComponent + ); + if (!surfaceRefBlock) return; + + ctx.store.deleteBlock(surfaceRefBlock.model); + }, + }, + ], + placement: 'inner', +}; diff --git a/blocksuite/affine/blocks/block-surface-ref/src/effects.ts b/blocksuite/affine/blocks/block-surface-ref/src/effects.ts index 5b4de41f76..27bf90ea87 100644 --- a/blocksuite/affine/blocks/block-surface-ref/src/effects.ts +++ b/blocksuite/affine/blocks/block-surface-ref/src/effects.ts @@ -1,11 +1,8 @@ -import { SurfaceRefGenericBlockPortal } from './portal/generic-block.js'; -import { SurfaceRefNotePortal } from './portal/note.js'; -import { SurfaceRefBlockComponent } from './surface-ref-block.js'; -import { EdgelessSurfaceRefBlockComponent } from './surface-ref-block-edgeless.js'; -import { - AFFINE_SURFACE_REF_TOOLBAR, - AffineSurfaceRefToolbar, -} from './widgets/surface-ref-toolbar.js'; +import { SurfaceRefToolbarTitle } from './components'; +import { SurfaceRefGenericBlockPortal } from './portal/generic-block'; +import { SurfaceRefNotePortal } from './portal/note'; +import { SurfaceRefBlockComponent } from './surface-ref-block'; +import { EdgelessSurfaceRefBlockComponent } from './surface-ref-block-edgeless'; export function effects() { customElements.define( @@ -18,5 +15,11 @@ export function effects() { EdgelessSurfaceRefBlockComponent ); customElements.define('surface-ref-note-portal', SurfaceRefNotePortal); - customElements.define(AFFINE_SURFACE_REF_TOOLBAR, AffineSurfaceRefToolbar); + customElements.define('surface-ref-toolbar-title', SurfaceRefToolbarTitle); +} + +declare global { + interface HTMLElementTagNameMap { + 'surface-ref-toolbar-title': SurfaceRefToolbarTitle; + } } diff --git a/blocksuite/affine/blocks/block-surface-ref/src/surface-ref-block.ts b/blocksuite/affine/blocks/block-surface-ref/src/surface-ref-block.ts index e93ff0ff6b..2340e0cc09 100644 --- a/blocksuite/affine/blocks/block-surface-ref/src/surface-ref-block.ts +++ b/blocksuite/affine/blocks/block-surface-ref/src/surface-ref-block.ts @@ -4,7 +4,9 @@ import { getSurfaceBlock, } from '@blocksuite/affine-block-surface'; import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption'; +import { whenHover } from '@blocksuite/affine-components/hover'; import { Peekable } from '@blocksuite/affine-components/peek'; +import { RefNodeSlotsProvider } from '@blocksuite/affine-inline-reference'; import { FrameBlockModel, type SurfaceRefBlockModel, @@ -12,7 +14,9 @@ import { import { DocModeProvider, EditPropsStore, + type OpenDocMode, ThemeProvider, + ToolbarRegistryIdentifier, ViewportElementExtension, } from '@blocksuite/affine-shared/services'; import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; @@ -424,6 +428,29 @@ export class SurfaceRefBlockComponent extends BlockComponent { + 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); + } + private _renderRefContent(referencedModel: GfxModel) { const [, , w, h] = deserializeXYWH(referencedModel.xywh); const _previewSpec = this._previewSpec.value; @@ -468,6 +495,28 @@ export class SurfaceRefBlockComponent extends BlockComponent`; } + readonly open = ({ + openMode, + event, + }: { + openMode?: OpenDocMode; + event?: MouseEvent; + } = {}) => { + const pageId = this.referenceModel?.surface?.doc.id; + if (!pageId) return; + + this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.next({ + pageId: pageId, + params: { + mode: 'edgeless', + elementIds: [this.model.props.reference], + }, + openMode, + event, + host: this.host, + }); + }; + override connectedCallback() { super.connectedCallback(); @@ -479,6 +528,7 @@ export class SurfaceRefBlockComponent extends BlockComponent `; + +export const surfaceRefToBlob = async ( + surfaceRefBlock: SurfaceRefBlockComponent +): Promise => { + const { referenceModel, previewEditor } = surfaceRefBlock; + if (!referenceModel || !previewEditor) return null; + + const exportManager = previewEditor.std.get(ExportManager); + const gfx = previewEditor.std.get(GfxControllerIdentifier); + + const { surface } = gfx; + if (!surface) return null; + const surfaceBlock = previewEditor.std.view.getBlock(surface.id); + if (!surfaceBlock) return null; + assertType(surfaceBlock); + + const canvas = await exportManager.edgelessToCanvas( + surfaceBlock.renderer, + Bound.deserialize(referenceModel.xywh), + gfx, + undefined, + undefined, + { zoom: surfaceBlock.renderer.viewport.zoom } + ); + + if (!canvas) { + throw new BlockSuiteError( + BlockSuiteError.ErrorCode.ValueNotExists, + 'Failed to export edgeless to canvas' + ); + } + + return new Promise((resolve, reject) => { + canvas.toBlob(blob => (blob ? resolve(blob) : reject(null)), 'image/png'); + }); +}; + +export const writeImageBlobToClipboard = async (blob: Blob) => { + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); +}; diff --git a/blocksuite/affine/blocks/block-surface-ref/src/widgets/config.ts b/blocksuite/affine/blocks/block-surface-ref/src/widgets/config.ts deleted file mode 100644 index 2365477797..0000000000 --- a/blocksuite/affine/blocks/block-surface-ref/src/widgets/config.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { type SurfaceBlockComponent } from '@blocksuite/affine-block-surface'; -import { - CopyIcon, - DeleteIcon, - DownloadIcon, -} from '@blocksuite/affine-components/icons'; -import { toast } from '@blocksuite/affine-components/toast'; -import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; -import { downloadBlob } from '@blocksuite/affine-shared/utils'; -import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; - -import type { SurfaceRefToolbarContext } from './context.js'; -import { edgelessToBlob, writeImageBlobToClipboard } from './utils.js'; - -export const BUILT_IN_GROUPS: MenuItemGroup[] = [ - { - type: 'clipboard', - when: ctx => !!(ctx.blockComponent.referenceModel && ctx.doc.root), - items: [ - { - type: 'copy', - label: 'Copy as Image', - icon: CopyIcon, - action: ctx => { - if (!(ctx.blockComponent.referenceModel && ctx.doc.root?.id)) { - ctx.close(); - return; - } - - const referencedModel = ctx.blockComponent.referenceModel; - const editor = ctx.blockComponent.previewEditor; - const surfaceModel = editor?.std.get(GfxControllerIdentifier).surface; - if (!surfaceModel) { - ctx.close(); - return; - } - const surfaceBlock = editor?.std.view.getBlock( - surfaceModel.id - ) as SurfaceBlockComponent; - const surfaceRenderer = surfaceBlock.renderer; - - if (!surfaceRenderer) { - ctx.close(); - return; - } - - edgelessToBlob(editor, { - surfaceRefBlock: ctx.blockComponent, - surfaceRenderer, - edgelessElement: referencedModel, - }) - .then(blob => writeImageBlobToClipboard(blob)) - .then(() => toast(ctx.host, 'Copied image to clipboard')) - .catch(console.error); - - ctx.close(); - }, - }, - // TODO(@L-Sun): add duplicate action after refactoring toolbar - { - type: 'download', - label: 'Download', - icon: DownloadIcon, - action: ctx => { - if (!(ctx.blockComponent.referenceModel && ctx.doc.root?.id)) { - ctx.close(); - return; - } - - const referencedModel = ctx.blockComponent.referenceModel; - const editor = ctx.blockComponent.previewEditor; - const surfaceModel = editor?.std.get(GfxControllerIdentifier).surface; - if (!surfaceModel) { - ctx.close(); - return; - } - const surfaceBlock = editor?.std.view.getBlock( - surfaceModel.id - ) as SurfaceBlockComponent; - const surfaceRenderer = surfaceBlock.renderer; - - if (!surfaceRenderer) { - ctx.close(); - return; - } - - edgelessToBlob(editor, { - surfaceRefBlock: ctx.blockComponent, - surfaceRenderer, - edgelessElement: referencedModel, - }) - .then(blob => { - const fileName = - 'title' in referencedModel - ? (referencedModel.title?.toString() ?? 'Edgeless Content') - : 'Edgeless Content'; - - downloadBlob(blob, fileName); - }) - .catch(console.error); - - ctx.close(); - }, - }, - ], - }, - { - type: 'delete', - items: [ - { - type: 'delete', - label: 'Delete', - icon: DeleteIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ blockComponent, doc, close }) => { - doc.deleteBlock(blockComponent.model); - close(); - }, - }, - ], - }, -]; diff --git a/blocksuite/affine/blocks/block-surface-ref/src/widgets/context.ts b/blocksuite/affine/blocks/block-surface-ref/src/widgets/context.ts deleted file mode 100644 index 5c67e3d493..0000000000 --- a/blocksuite/affine/blocks/block-surface-ref/src/widgets/context.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { SurfaceRefBlockComponent } from '@blocksuite/affine-block-surface-ref'; -import { MenuContext } from '@blocksuite/affine-components/toolbar'; - -export class SurfaceRefToolbarContext extends MenuContext { - override close = () => { - this.abortController.abort(); - }; - - get doc() { - return this.blockComponent.doc; - } - - get host() { - return this.blockComponent.host; - } - - get selectedBlockModels() { - if (this.blockComponent) return [this.blockComponent.model]; - return []; - } - - get std() { - return this.host.std; - } - - constructor( - public blockComponent: SurfaceRefBlockComponent, - public abortController: AbortController - ) { - super(); - } - - isEmpty() { - return !this.blockComponent; - } - - isMultiple() { - return false; - } - - isSingle() { - return true; - } -} diff --git a/blocksuite/affine/blocks/block-surface-ref/src/widgets/surface-ref-toolbar.ts b/blocksuite/affine/blocks/block-surface-ref/src/widgets/surface-ref-toolbar.ts deleted file mode 100644 index f14189afc8..0000000000 --- a/blocksuite/affine/blocks/block-surface-ref/src/widgets/surface-ref-toolbar.ts +++ /dev/null @@ -1,280 +0,0 @@ -import type { SurfaceRefBlockComponent } from '@blocksuite/affine-block-surface-ref'; -import { peek } from '@blocksuite/affine-components/peek'; -import { toast } from '@blocksuite/affine-components/toast'; -import { - cloneGroups, - getMoreMenuConfig, - type MenuItem, - type MenuItemGroup, - renderGroups, -} from '@blocksuite/affine-components/toolbar'; -import { - FrameBlockModel, - GroupElementModel, - MindmapElementModel, - ShapeElementModel, - type SurfaceRefBlockModel, -} from '@blocksuite/affine-model'; -import { - copySelectedModelsCommand, - draftSelectedModelsCommand, -} from '@blocksuite/affine-shared/commands'; -import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; -import type { ButtonPopperOptions } from '@blocksuite/affine-shared/utils'; -import { WidgetComponent } from '@blocksuite/block-std'; -import { - ArrowDownSmallIcon, - CaptionIcon, - CenterPeekIcon, - CopyIcon, - EdgelessIcon, - FrameIcon, - GroupIcon, - MindmapIcon, - MoreVerticalIcon, - OpenInNewIcon, -} from '@blocksuite/icons/lit'; -import { css, html, nothing, type TemplateResult } from 'lit'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { repeat } from 'lit/directives/repeat.js'; -import { styleMap } from 'lit/directives/style-map.js'; -import { when } from 'lit/directives/when.js'; - -import { BUILT_IN_GROUPS } from './config.js'; -import { SurfaceRefToolbarContext } from './context.js'; - -export const AFFINE_SURFACE_REF_TOOLBAR = 'affine-surface-ref-toolbar'; - -export class AffineSurfaceRefToolbar extends WidgetComponent< - SurfaceRefBlockModel, - SurfaceRefBlockComponent -> { - static override styles = css` - :host { - position: absolute; - top: 0; - left: 0; - - width: 100%; - gap: 4px; - padding: 4px; - margin: 0; - display: flex; - justify-content: flex-end; - - editor-icon-button, - editor-menu-button { - background: ${unsafeCSSVarV2('button/iconButtonSolid')}; - color: ${unsafeCSSVarV2('text/primary')}; - box-shadow: ${unsafeCSSVar('shadow1')}; - border-radius: 4px; - } - } - - .surface-ref-toolbar-title { - display: flex; - padding: 2px 4px; - margin-right: auto; - align-items: center; - gap: 4px; - border-radius: 2px; - background: ${unsafeCSSVarV2('button/iconButtonSolid')}; - - svg { - color: ${unsafeCSSVarV2('icon/primary')}; - width: 16px; - height: 16px; - } - - span { - color: ${unsafeCSSVarV2('text/primary')}; - font-size: 12px; - font-weight: 500; - line-height: 20px; - } - } - `; - - private readonly _popoverOptions: Partial = { - mainAxis: 4, - stateUpdated: ({ display }) => { - this.dataset.openMenuDisplay = display; - }, - }; - - /* - * Caches the more menu items. - * Currently only supports configuring more menu. - */ - moreGroups: MenuItemGroup[] = - cloneGroups(BUILT_IN_GROUPS); - - override connectedCallback() { - super.connectedCallback(); - this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups); - - this.disposables.addFromEvent(this, 'dblclick', e => { - e.stopPropagation(); - }); - } - - private _renderTitle() { - if (!this.block) return nothing; - const { referenceModel } = this.block; - if (!referenceModel) return nothing; - - let title = ''; - let icon: TemplateResult<1> | null = null; - if (referenceModel instanceof GroupElementModel) { - title = referenceModel.title.toString(); - icon = GroupIcon(); - } else if (referenceModel instanceof FrameBlockModel) { - title = referenceModel.props.title.toString(); - icon = FrameIcon(); - } else if (referenceModel instanceof MindmapElementModel) { - const rootElement = referenceModel.tree.element; - if (rootElement instanceof ShapeElementModel) { - title = rootElement.text?.toString() ?? ''; - } - icon = MindmapIcon(); - } - - return html`
- ${icon} - ${title} -
`; - } - - private _renderOpenButton() { - const referenceModel = this.block?.referenceModel; - if (!referenceModel) return nothing; - - const openMenuActions: MenuItem[] = [ - { - type: 'open-in-edgeless', - label: 'Open in Edgeless', - icon: EdgelessIcon(), - action: () => this.block?.viewInEdgeless(), - disabled: this.block.model.doc.readonly, - }, - { - type: 'open-in-center-peek', - label: 'Open in center peek', - icon: CenterPeekIcon(), - action: () => this.block && peek(this.block), - }, - // TODO(@L-Sun): add split view and new tab - ]; - - return html` - ${OpenInNewIcon()} ${ArrowDownSmallIcon()} - - `} - .popperOptions=${this._popoverOptions} - > -
- ${repeat( - openMenuActions, - button => button.label, - ({ label, icon, action, disabled }) => html` - - ${icon}${label} - - ` - )} -
-
`; - } - - private _moreButton() { - if (!this.block) return nothing; - - const moreMenuActions = renderGroups( - this.moreGroups, - new SurfaceRefToolbarContext(this.block, new AbortController()) - ); - - return html` - ${MoreVerticalIcon()} - - `} - .popperOptions=${this._popoverOptions} - > -
${moreMenuActions}
-
`; - } - - private _renderButtons() { - if (!this.block) return nothing; - const readonly = this.block.model.doc.readonly; - const buttons = [ - this._renderOpenButton(), - when( - !readonly, - () => - html` { - if (!this.block) return; - this.std.command - .chain() - .pipe(draftSelectedModelsCommand, { - selectedModels: [this.block.model], - }) - .pipe(copySelectedModelsCommand) - .run(); - toast(this.block.std.host, 'Copied to clipboard'); - }} - > - ${CopyIcon()} - ` - ), - when( - !readonly, - () => - html` { - if (!this.block) return; - this.block.captionElement.show(); - }} - > - ${CaptionIcon()} - ` - ), - this._moreButton(), - ]; - - return buttons; - } - - override render() { - if (!this.block) return nothing; - return html`${this._renderTitle()} ${this._renderButtons()}`; - } -} - -declare global { - interface HTMLElementTagNameMap { - [AFFINE_SURFACE_REF_TOOLBAR]: AffineSurfaceRefToolbar; - } -} diff --git a/blocksuite/affine/blocks/block-surface-ref/src/widgets/utils.ts b/blocksuite/affine/blocks/block-surface-ref/src/widgets/utils.ts deleted file mode 100644 index 7be9fe4edd..0000000000 --- a/blocksuite/affine/blocks/block-surface-ref/src/widgets/utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { CanvasRenderer } from '@blocksuite/affine-block-surface'; -import { ExportManager } from '@blocksuite/affine-block-surface'; -import type { SurfaceRefBlockComponent } from '@blocksuite/affine-block-surface-ref'; -import type { EditorHost } from '@blocksuite/block-std'; -import { - GfxControllerIdentifier, - type GfxModel, -} from '@blocksuite/block-std/gfx'; -import { BlockSuiteError } from '@blocksuite/global/exceptions'; -import { Bound } from '@blocksuite/global/gfx'; - -export const edgelessToBlob = async ( - host: EditorHost, - options: { - surfaceRefBlock: SurfaceRefBlockComponent; - surfaceRenderer: CanvasRenderer; - edgelessElement: GfxModel; - } -): Promise => { - const { edgelessElement } = options; - const exportManager = host.std.get(ExportManager); - const bound = Bound.deserialize(edgelessElement.xywh); - const gfx = host.std.get(GfxControllerIdentifier); - - const canvas = await exportManager.edgelessToCanvas( - options.surfaceRenderer, - bound, - gfx, - undefined, - undefined, - { zoom: options.surfaceRenderer.viewport.zoom } - ); - - if (!canvas) { - throw new BlockSuiteError( - BlockSuiteError.ErrorCode.ValueNotExists, - 'Failed to export edgeless to canvas' - ); - } - - return new Promise((resolve, reject) => { - canvas.toBlob(blob => (blob ? resolve(blob) : reject(null)), 'image/png'); - }); -}; - -export const writeImageBlobToClipboard = async (blob: Blob) => { - await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); -}; diff --git a/blocksuite/affine/blocks/block-surface-ref/tsconfig.json b/blocksuite/affine/blocks/block-surface-ref/tsconfig.json index 1f079a4057..6472f3c17a 100644 --- a/blocksuite/affine/blocks/block-surface-ref/tsconfig.json +++ b/blocksuite/affine/blocks/block-surface-ref/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../block-frame" }, { "path": "../block-surface" }, { "path": "../../components" }, + { "path": "../../inlines/reference" }, { "path": "../../model" }, { "path": "../../shared" }, { "path": "../../widgets/widget-slash-menu" }, diff --git a/blocksuite/affine/shared/src/services/toolbar-service/context.ts b/blocksuite/affine/shared/src/services/toolbar-service/context.ts index bf64d18eb3..b417342df9 100644 --- a/blocksuite/affine/shared/src/services/toolbar-service/context.ts +++ b/blocksuite/affine/shared/src/services/toolbar-service/context.ts @@ -1,5 +1,5 @@ import { - type BlockComponent, + BlockComponent, BlockSelection, type BlockStdScope, SurfaceSelection, @@ -177,17 +177,29 @@ abstract class ToolbarContextBase { } getCurrentBlockBy(type: T) { - const selection = this.selection.find(type); - if (!selection) return null; - if (selection.is(SurfaceSelection)) { - const elementId = selection.elements[0]; - const model = this.gfx.getElementById(elementId); + const getFromSelection = () => { + const selection = this.selection.find(type); + if (!selection) return null; + if (selection.is(SurfaceSelection)) { + const elementId = selection.elements[0]; + const model = this.gfx.getElementById(elementId); + if (!model) return null; + return this.gfx.view.get(model.id) ?? null; + } + const model = this.store.getBlock(selection.blockId); if (!model) return null; - return this.gfx.view.get(model.id) ?? null; - } - const model = this.store.getBlock(selection.blockId); - if (!model) return null; - return this.view.getBlock(model.id); + return this.view.getBlock(model.id); + }; + + const getFromMessage = () => { + const msgEle = this.message$.peek()?.element; + if (msgEle instanceof BlockComponent) { + return msgEle; + } + return null; + }; + + return getFromSelection() ?? getFromMessage(); } getCurrentBlock() { @@ -211,13 +223,25 @@ abstract class ToolbarContextBase { } getCurrentModelBy(type: T) { - const selection = this.selection.find(type); - if (!selection) return null; - if (selection.is(SurfaceSelection)) { - const elementId = selection.elements[0]; - return elementId ? this.gfx.getElementById(elementId) : null; - } - return this.store.getBlock(selection.blockId)?.model ?? null; + const getFromSelection = () => { + const selection = this.selection.find(type); + if (!selection) return null; + if (selection.is(SurfaceSelection)) { + const elementId = selection.elements[0]; + return elementId ? this.gfx.getElementById(elementId) : null; + } + return this.store.getBlock(selection.blockId)?.model ?? null; + }; + + const getFromMessage = () => { + const msgEle = this.message$.peek()?.element; + if (msgEle instanceof BlockComponent) { + return msgEle.model; + } + return null; + }; + + return getFromSelection() ?? getFromMessage(); } getCurrentModel(): GfxModel | BlockModel | null { diff --git a/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts index c11779ebcc..640846d79a 100644 --- a/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts +++ b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts @@ -29,6 +29,7 @@ import { EmbedYoutubeBlockComponent, getDocContentWithMaxLength, } from '@blocksuite/affine/blocks/embed'; +import { SurfaceRefBlockComponent } from '@blocksuite/affine/blocks/surface-ref'; import { toggleEmbedCardEditModal } from '@blocksuite/affine/components/embed-card-modal'; import { notifyLinkedDocClearedAliases, @@ -51,6 +52,7 @@ import { EmbedIframeBlockModel, EmbedLinkedDocModel, EmbedSyncedDocModel, + SurfaceRefBlockSchema, } from '@blocksuite/affine/model'; import { getSelectedModelsCommand } from '@blocksuite/affine/shared/commands'; import { ImageSelection } from '@blocksuite/affine/shared/selection'; @@ -70,6 +72,7 @@ import { import { matchModels } from '@blocksuite/affine/shared/utils'; import type { ExtensionType } from '@blocksuite/affine/store'; import { + ArrowDownSmallIcon, CopyAsImgaeIcon, CopyIcon, EditIcon, @@ -481,7 +484,8 @@ function createOpenDocActions( target: | EmbedLinkedDocBlockComponent | EmbedSyncedDocBlockComponent - | AffineReference, + | AffineReference + | SurfaceRefBlockComponent, isSameDoc: boolean, actions = openDocActions.map( ({ type: mode, label, icon, enabled: when }, i) => ({ @@ -569,6 +573,131 @@ function createEdgelessOpenDocActionGroup( }; } +function createSurfaceRefToolbarConfig(baseUrl?: string): ToolbarModuleConfig { + return { + actions: [ + { + id: 'b.open-surface-ref', + when: ctx => + !!ctx.getCurrentBlockByType(SurfaceRefBlockComponent)?.referenceModel, + content: ctx => { + const surfaceRefBlock = ctx.getCurrentBlockByType( + SurfaceRefBlockComponent + ); + if (!surfaceRefBlock) return null; + + const actions = createOpenDocActions(ctx, surfaceRefBlock, false) + .map(action => ({ + ...action, + ...action.generate(ctx), + })) + .map(action => { + if (action.id.endsWith('open-in-active-view')) { + action.label = + I18n['com.affine.peek-view-controls.open-doc-in-edgeless'](); + } + return action; + }); + if (!actions.length) return null; + + const styles = styleMap({ + gap: 4, + }); + + return html`${keyed( + surfaceRefBlock, + html` + ${OpenInNewIcon()} ${ArrowDownSmallIcon()} + `} + > +
+ ${repeat( + actions, + action => action.id, + ({ label, icon, run, disabled }) => html` + { + run?.(ctx); + }} + > + ${icon}${label} + + ` + )} +
+
` + )}`; + }, + }, + { + id: 'a.clipboard', + placement: ActionPlacement.More, + actions: [ + { + id: 'copy-link-to-surface-ref', + label: 'Copy original link', + icon: LinkIcon(), + when: ctx => + !!ctx.getCurrentBlockByType(SurfaceRefBlockComponent) + ?.referenceModel, + run: ctx => { + const surfaceRefBlock = ctx.getCurrentBlockByType( + SurfaceRefBlockComponent + ); + if (!surfaceRefBlock) return; + + const refModel = surfaceRefBlock.referenceModel; + if (!refModel) return; + + const { store, workspace, std } = ctx; + const pageId = store.doc.id; + const workspaceId = workspace.id; + const options: UseSharingUrl = { + workspaceId, + pageId, + mode: 'edgeless', + }; + + let type = ''; + if (refModel instanceof GfxPrimitiveElementModel) { + options.elementIds = [refModel.id]; + type = refModel.type; + } else if (refModel instanceof GfxBlockElementModel) { + options.blockIds = [refModel.id]; + type = refModel.flavour; + } + + const str = generateUrl({ + ...options, + baseUrl: baseUrl ?? location.origin, + }); + if (!str) return; + + copyLinkToBlockStdScopeClipboard(str, std.clipboard) + .then(ok => { + if (!ok) return; + + notify.success({ title: I18n['Copied link to clipboard']() }); + }) + .catch(console.error); + + track.doc.editor.toolbar.copyBlockToLink({ type }); + }, + }, + ], + }, + ], + }; +} + function renderOpenDocMenu( settings: EditorSettingExt, ctx: ToolbarContext, @@ -877,11 +1006,11 @@ const embedIframeToolbarConfig = { }, actions: [ { - id: 'b.copy-link', + id: 'a.copy-link-and-edit', actions: [ { id: 'copy-link', - tooltip: 'Copy original link', + tooltip: 'Copy link', icon: CopyIcon(), run(ctx) { const model = ctx.getCurrentBlockByType( @@ -895,14 +1024,52 @@ const embedIframeToolbarConfig = { toast(ctx.host, 'Copied link to clipboard'); ctx.track('CopiedLink', { - category: matchModels(model, [EmbedIframeBlockModel]) - ? 'embed iframe block' + category: matchModels(model, [BookmarkBlockModel]) + ? 'bookmark' : 'link', type: 'card view', control: 'copy link', }); }, }, + { + id: 'edit', + tooltip: 'Edit', + icon: EditIcon(), + run(ctx) { + const component = ctx.getCurrentBlockByType( + EmbedIframeBlockComponent + ); + if (!component) return; + + ctx.hide(); + + const model = component.model; + const abortController = new AbortController(); + abortController.signal.onabort = () => ctx.show(); + + toggleEmbedCardEditModal( + ctx.host, + model, + 'card', + undefined, + undefined, + (_std, _component, props) => { + ctx.store.updateBlock(model, props); + component.requestUpdate(); + }, + abortController + ); + + ctx.track('OpenedAliasPopup', { + category: matchModels(model, [BookmarkBlockModel]) + ? 'bookmark' + : 'link', + type: 'card view', + control: 'edit', + }); + }, + }, ], }, ], @@ -1052,5 +1219,12 @@ export const createCustomToolbarExtension = ( when: ctx => ctx.getSurfaceModels().length === 1, }, }), + + ToolbarModuleExtension({ + id: BlockFlavourIdentifier( + `custom:${SurfaceRefBlockSchema.model.flavour}` + ), + config: createSurfaceRefToolbarConfig(baseUrl), + }), ]; }; 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 0905302201..36d9799c1c 100644 --- a/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts +++ b/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts @@ -10,7 +10,6 @@ import { } from '@blocksuite/affine/blocks/code'; import { imageToolbarWidget } from '@blocksuite/affine/blocks/image'; import { ParagraphBlockConfigExtension } from '@blocksuite/affine/blocks/paragraph'; -import { surfaceRefToolbarWidget } from '@blocksuite/affine/blocks/surface-ref'; import type { Container, ServiceIdentifier, @@ -136,7 +135,6 @@ export function enableMobileExtension( ): void { specBuilder.omit(codeToolbarWidget); specBuilder.omit(imageToolbarWidget); - specBuilder.omit(surfaceRefToolbarWidget); specBuilder.omit(toolbarWidget); specBuilder.omit(SlashMenuExtension); specBuilder.extend([ diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx index 399b614aea..79248d11bb 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx @@ -204,6 +204,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { workbench.openDoc( { docId: pageId, + mode: params?.mode, blockIds: params?.blockIds, elementIds: params?.elementIds, }, diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index cfce617cf3..51be910a89 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -4351,6 +4351,10 @@ export function useAFFiNEI18N(): { * `Open this doc` */ ["com.affine.peek-view-controls.open-doc"](): string; + /** + * `Open in edgeless` + */ + ["com.affine.peek-view-controls.open-doc-in-edgeless"](): string; /** * `Open in new tab` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 66dcaa5b99..3783463017 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1079,6 +1079,7 @@ "com.affine.payment.license-success.copy": "Copied key to clipboard", "com.affine.peek-view-controls.close": "Close", "com.affine.peek-view-controls.open-doc": "Open this doc", + "com.affine.peek-view-controls.open-doc-in-edgeless": "Open in edgeless", "com.affine.peek-view-controls.open-doc-in-new-tab": "Open in new tab", "com.affine.peek-view-controls.open-doc-in-split-view": "Open in split view", "com.affine.peek-view-controls.open-info": "Open doc info", diff --git a/tests/affine-local/e2e/peek-view.spec.ts b/tests/affine-local/e2e/peek-view.spec.ts index 5888efb671..4124a5f3a0 100644 --- a/tests/affine-local/e2e/peek-view.spec.ts +++ b/tests/affine-local/e2e/peek-view.spec.ts @@ -134,11 +134,11 @@ test('can open peek view for embedded frames', async ({ page }) => { await expect(surfaceRef).toBeVisible(); await surfaceRef.hover(); await page - .locator('affine-surface-ref-toolbar editor-menu-button[aria-label="Open"]') + .locator('affine-toolbar-widget editor-menu-button[aria-label="Open"]') .click(); await page .locator( - 'affine-surface-ref-toolbar editor-menu-action[aria-label="Open in center peek"]' + 'affine-toolbar-widget editor-menu-action[aria-label="Open in center peek"]' ) .click(); diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index d56a190407..df89a694c0 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -361,6 +361,7 @@ export const PackageList = [ 'blocksuite/affine/blocks/block-frame', 'blocksuite/affine/blocks/block-surface', 'blocksuite/affine/components', + 'blocksuite/affine/inlines/reference', 'blocksuite/affine/model', 'blocksuite/affine/shared', 'blocksuite/affine/widgets/widget-slash-menu', diff --git a/yarn.lock b/yarn.lock index ec24728f06..0c3b060b1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2792,6 +2792,7 @@ __metadata: "@blocksuite/affine-block-frame": "workspace:*" "@blocksuite/affine-block-surface": "workspace:*" "@blocksuite/affine-components": "workspace:*" + "@blocksuite/affine-inline-reference": "workspace:*" "@blocksuite/affine-model": "workspace:*" "@blocksuite/affine-shared": "workspace:*" "@blocksuite/affine-widget-slash-menu": "workspace:*"