refactor(editor): inner toolbar surface-ref block with extension (#11246)

This PR refactor `surface-ref` toolbar with `ToolbarExtension`
This commit is contained in:
L-Sun
2025-03-28 05:48:24 +00:00
parent 69f393fe2f
commit af91a0217f
22 changed files with 551 additions and 542 deletions
@@ -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:*",
@@ -0,0 +1 @@
export { SurfaceRefToolbarTitle } from './surface-ref-toolbar-title';
@@ -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}<span>${title}</span>`;
}
}
@@ -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`<surface-ref-toolbar-title
.referenceModel=${surfaceRefBlock.referenceModel}
></surface-ref-toolbar-title>`;
},
},
{
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',
};
@@ -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;
}
}
@@ -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<SurfaceRefBlockMode
this._previewSpec.extend([SurfaceRefViewportWatcher]);
}
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);
}
private _renderRefContent(referencedModel: GfxModel) {
const [, , w, h] = deserializeXYWH(referencedModel.xywh);
const _previewSpec = this._previewSpec.value;
@@ -468,6 +495,28 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
</div>`;
}
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<SurfaceRefBlockMode
this._initViewport();
this._initReferencedModel();
this._initSelection();
this._initHover();
}
override render() {
@@ -1,30 +1,33 @@
import { SurfaceRefBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockFlavourIdentifier,
BlockViewExtension,
FlavourExtension,
WidgetViewExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { SurfaceRefSlashMenuConfigExtension } from './configs/slash-menu';
export const surfaceRefToolbarWidget = WidgetViewExtension(
'affine:surface-ref',
'surfaceToolbar',
literal`affine-surface-ref-toolbar`
);
import { surfaceRefToolbarModuleConfig } from './configs/toolbar';
export const PageSurfaceRefBlockSpec: ExtensionType[] = [
FlavourExtension('affine:surface-ref'),
BlockViewExtension('affine:surface-ref', literal`affine-surface-ref`),
surfaceRefToolbarWidget,
FlavourExtension(SurfaceRefBlockSchema.model.flavour),
BlockViewExtension(
SurfaceRefBlockSchema.model.flavour,
literal`affine-surface-ref`
),
ToolbarModuleExtension({
id: BlockFlavourIdentifier(SurfaceRefBlockSchema.model.flavour),
config: surfaceRefToolbarModuleConfig,
}),
SurfaceRefSlashMenuConfigExtension,
];
export const EdgelessSurfaceRefBlockSpec: ExtensionType[] = [
FlavourExtension('affine:surface-ref'),
FlavourExtension(SurfaceRefBlockSchema.model.flavour),
BlockViewExtension(
'affine:surface-ref',
SurfaceRefBlockSchema.model.flavour,
literal`affine-edgeless-surface-ref`
),
SurfaceRefSlashMenuConfigExtension,
@@ -1,3 +1,10 @@
import type { SurfaceBlockComponent } from '@blocksuite/affine-block-surface';
import { ExportManager } from '@blocksuite/affine-block-surface';
import type { SurfaceRefBlockComponent } from '@blocksuite/affine-block-surface-ref';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { Bound } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import { html } from 'lit';
export const noContentPlaceholder = html`
@@ -97,3 +104,43 @@ export const noContentPlaceholder = html`
/>
</svg>
`;
export const surfaceRefToBlob = async (
surfaceRefBlock: SurfaceRefBlockComponent
): Promise<Blob | null> => {
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<SurfaceBlockComponent>(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 })]);
};
@@ -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<SurfaceRefToolbarContext>[] = [
{
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();
},
},
],
},
];
@@ -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;
}
}
@@ -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<ButtonPopperOptions> = {
mainAxis: 4,
stateUpdated: ({ display }) => {
this.dataset.openMenuDisplay = display;
},
};
/*
* Caches the more menu items.
* Currently only supports configuring more menu.
*/
moreGroups: MenuItemGroup<SurfaceRefToolbarContext>[] =
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`<div class="surface-ref-toolbar-title">
${icon}
<span>${title}</span>
</div>`;
}
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`<editor-menu-button
data-show
aria-label="Open"
style=${styleMap({
'--content-padding': '8px',
})}
.button=${html`
<editor-icon-button .iconContainerPadding=${4} .iconSize=${'16px'}>
${OpenInNewIcon()} ${ArrowDownSmallIcon()}
</editor-icon-button>
`}
.popperOptions=${this._popoverOptions}
>
<div data-orientation="vertical">
${repeat(
openMenuActions,
button => button.label,
({ label, icon, action, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${disabled}
@click=${action}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>`;
}
private _moreButton() {
if (!this.block) return nothing;
const moreMenuActions = renderGroups(
this.moreGroups,
new SurfaceRefToolbarContext(this.block, new AbortController())
);
return html`<editor-menu-button
data-show
style=${styleMap({
'--content-padding': '8px',
})}
.button=${html`
<editor-icon-button .iconContainerPadding=${4} .iconSize=${'16px'}>
${MoreVerticalIcon()}
</editor-icon-button>
`}
.popperOptions=${this._popoverOptions}
>
<div data-orientation="vertical">${moreMenuActions}</div>
</editor-menu-button>`;
}
private _renderButtons() {
if (!this.block) return nothing;
const readonly = this.block.model.doc.readonly;
const buttons = [
this._renderOpenButton(),
when(
!readonly,
() =>
html`<editor-icon-button
.iconContainerPadding=${4}
.iconSize=${'16px'}
@click=${() => {
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()}
</editor-icon-button>`
),
when(
!readonly,
() =>
html`<editor-icon-button
.iconContainerPadding=${4}
.iconSize=${'16px'}
@click=${() => {
if (!this.block) return;
this.block.captionElement.show();
}}
>
${CaptionIcon()}
</editor-icon-button>`
),
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;
}
}
@@ -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<Blob> => {
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 })]);
};
@@ -10,6 +10,7 @@
{ "path": "../block-frame" },
{ "path": "../block-surface" },
{ "path": "../../components" },
{ "path": "../../inlines/reference" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../widgets/widget-slash-menu" },
@@ -1,5 +1,5 @@
import {
type BlockComponent,
BlockComponent,
BlockSelection,
type BlockStdScope,
SurfaceSelection,
@@ -177,17 +177,29 @@ abstract class ToolbarContextBase {
}
getCurrentBlockBy<T extends SelectionConstructor>(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<T extends SelectionConstructor>(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 {
@@ -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`<editor-menu-button
aria-label="Open"
.contentPadding=${'8px'}
.button=${html`<editor-icon-button
.iconSize=${'16px'}
.iconContainerPadding=${4}
>
${OpenInNewIcon()} ${ArrowDownSmallIcon()}
</editor-icon-button>`}
>
<div data-orientation="vertical" style=${styles}>
${repeat(
actions,
action => action.id,
({ label, icon, run, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${disabled}
@click=${() => {
run?.(ctx);
}}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>`
)}`;
},
},
{
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),
}),
];
};
@@ -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([
@@ -204,6 +204,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
workbench.openDoc(
{
docId: pageId,
mode: params?.mode,
blockIds: params?.blockIds,
elementIds: params?.elementIds,
},
+4
View File
@@ -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`
*/
@@ -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",
+2 -2
View File
@@ -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();
+1
View File
@@ -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',
+1
View File
@@ -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:*"