mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 14:27:02 +08:00
refactor(editor): inner toolbar surface-ref block with extension (#11246)
This PR refactor `surface-ref` toolbar with `ToolbarExtension`
This commit is contained in:
@@ -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 })]);
|
||||
};
|
||||
Reference in New Issue
Block a user