refactor(editor): edgeless toolbar more actions (#10882)

This commit is contained in:
fundon
2025-03-20 02:08:18 +00:00
parent 39704aac66
commit a004a2cfab
7 changed files with 491 additions and 119 deletions

View File

@@ -30,8 +30,8 @@ import { EditIcon, PageIcon, UngroupIcon } from '@blocksuite/icons/lit';
import type { ExtensionType } from '@blocksuite/store';
import { html } from 'lit';
import { EdgelessRootBlockComponent } from '../..';
import { mountFrameTitleEditor } from '../../utils/text';
import { getEdgelessWith } from './utils';
const builtinSurfaceToolbarConfig = {
actions: [
@@ -87,15 +87,8 @@ const builtinSurfaceToolbarConfig = {
const model = ctx.getCurrentModelByType(FrameBlockModel);
if (!model) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
// TODO(@fundon): it should be simple
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
mountFrameTitleEditor(model, edgeless);
},
@@ -108,15 +101,8 @@ const builtinSurfaceToolbarConfig = {
const models = ctx.getSurfaceModelsByType(FrameBlockModel);
if (!models.length) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
// TODO(@fundon): it should be simple
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
ctx.store.captureSync();

View File

@@ -12,8 +12,8 @@ import { matchModels } from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import { EditIcon, PageIcon, UngroupIcon } from '@blocksuite/icons/lit';
import { EdgelessRootBlockComponent } from '../..';
import { mountGroupTitleEditor } from '../../utils/text';
import { getEdgelessWith } from './utils';
export const builtinGroupToolbarConfig = {
actions: [
@@ -69,15 +69,8 @@ export const builtinGroupToolbarConfig = {
const model = ctx.getCurrentModelByType(GroupElementModel);
if (!model) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
// TODO(@fundon): it should be simple
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
mountGroupTitleEditor(model, edgeless);
},
@@ -90,15 +83,8 @@ export const builtinGroupToolbarConfig = {
const models = ctx.getSurfaceModelsByType(GroupElementModel);
if (!models.length) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
// TODO(@fundon): it should be simple
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
for (const model of models) {
edgeless.service.ungroup(model);

View File

@@ -25,8 +25,9 @@ import {
} from '@blocksuite/icons/lit';
import { html } from 'lit';
import { EdgelessRootBlockComponent } from '../..';
import { renderAlignmentMenu } from './alignment';
import { moreActions } from './more';
import { getEdgelessWith } from './utils';
export const builtinMiscToolbarConfig = {
actions: [
@@ -86,15 +87,8 @@ export const builtinMiscToolbarConfig = {
const models = ctx.getSurfaceModels();
if (models.length < 2) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
// TODO(@fundon): it should be simple
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
const frame = edgeless.service.frame.createFrameOnSelected();
if (!frame) return;
@@ -135,15 +129,8 @@ export const builtinMiscToolbarConfig = {
const models = ctx.getSurfaceModels();
if (models.length < 2) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
// TODO(@fundon): it should be simple
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
// TODO(@fundon): should be a command
edgeless.service.createGroupFromSelected();
@@ -155,7 +142,6 @@ export const builtinMiscToolbarConfig = {
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length < 2) return false;
if (models.some(model => model.isLocked())) return false;
if (models.some(model => model.group instanceof MindmapElementModel))
return false;
if (
@@ -227,15 +213,8 @@ export const builtinMiscToolbarConfig = {
const models = ctx.getSurfaceModels();
if (!models.length) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
// TODO(@fundon): it should be simple
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
// get most top selected elements(*) from tree, like in a tree below
// G0
@@ -318,6 +297,12 @@ export const builtinMiscToolbarConfig = {
});
},
},
// More actions
...moreActions.map(action => ({
...action,
placement: ActionPlacement.More,
})),
],
when(ctx) {
const models = ctx.getSurfaceModels();
@@ -336,15 +321,8 @@ export const builtinLockedToolbarConfig = {
const models = ctx.getSurfaceModels();
if (!models.length) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
// TODO(@fundon): it should be simple
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
const elements = new Set(
models.map(model =>

View File

@@ -0,0 +1,416 @@
import { AttachmentBlockComponent } from '@blocksuite/affine-block-attachment';
import { BookmarkBlockComponent } from '@blocksuite/affine-block-bookmark';
import {
isExternalEmbedBlockComponent,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import { ImageBlockComponent } from '@blocksuite/affine-block-image';
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import {
AttachmentBlockModel,
BookmarkBlockModel,
EmbedLinkedDocBlockSchema,
EmbedLinkedDocModel,
EmbedSyncedDocBlockSchema,
EmbedSyncedDocModel,
FrameBlockModel,
ImageBlockModel,
isExternalEmbedModel,
NoteBlockModel,
} from '@blocksuite/affine-model';
import type {
ToolbarActions,
ToolbarContext,
} from '@blocksuite/affine-shared/services';
import { type ReorderingType } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { GfxBlockElementModel, type GfxModel } from '@blocksuite/block-std/gfx';
import { Bound, getCommonBoundWithRotation } from '@blocksuite/global/gfx';
import {
ArrowDownBigBottomIcon,
ArrowDownBigIcon,
ArrowUpBigIcon,
ArrowUpBigTopIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
FrameIcon,
GroupIcon,
LinkedPageIcon,
ResetIcon,
} from '@blocksuite/icons/lit';
import {
createLinkedDocFromEdgelessElements,
createLinkedDocFromNote,
} from '../../../widgets/element-toolbar/more-menu/render-linked-doc';
import { duplicate } from '../../utils/clipboard-utils';
import { getSortedCloneElements } from '../../utils/clone-utils';
import { moveConnectors } from '../../utils/connector';
import { deleteElements } from '../../utils/crud';
import { getEdgelessWith } from './utils';
export const moreActions = [
// Selection Group: frame & group
{
id: 'Z.a.selection',
actions: [
{
id: 'a.create-frame',
label: 'Frame section',
icon: FrameIcon(),
run(ctx) {
const frame = ctx.std
.get(EdgelessFrameManagerIdentifier)
.createFrameOnSelected();
if (!frame) return;
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
edgeless.surface.fitToViewport(Bound.deserialize(frame.xywh));
ctx.track('CanvasElementAdded', {
control: 'context-menu',
type: 'frame',
});
},
},
{
id: 'b.create-group',
label: 'Group section',
icon: GroupIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length === 0) return false;
return !models.some(model => ctx.matchModel(model, FrameBlockModel));
},
run(ctx) {
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
edgeless.service.createGroupFromSelected();
},
},
],
},
// Reordering Group
{
id: 'Z.b.reordering',
actions: [
{
id: 'a.bring-to-front',
label: 'Bring to Front',
icon: ArrowUpBigTopIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
reorderElements(ctx, models, 'front');
},
},
{
id: 'b.bring-forward',
label: 'Bring Forward',
icon: ArrowUpBigIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
reorderElements(ctx, models, 'forward');
},
},
{
id: 'c.send-backward',
label: 'Send Backward',
icon: ArrowDownBigIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
reorderElements(ctx, models, 'backward');
},
},
{
id: 'c.send-to-back',
label: 'Send to Back',
icon: ArrowDownBigBottomIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
reorderElements(ctx, models, 'back');
},
},
],
},
// Clipboard Group
// Uses the same `ID` for both page and edgeless modes.
{
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
if (!models.length) return;
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
edgeless.clipboardController.copy();
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const models = ctx.getSurfaceModels();
if (!models.length) return;
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
duplicate(edgeless, models).catch(console.error);
},
},
{
id: 'reload',
label: 'Reload',
icon: ResetIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length === 0) return false;
return models.every(isRefreshableModel);
},
run(ctx) {
const blocks = ctx
.getSurfaceModels()
.map(model => ctx.view.getBlock(model.id))
.filter(isRefreshableBlock);
if (!blocks.length) return;
for (const block of blocks) {
block.refreshData();
}
},
},
],
},
// Conversions Group
{
id: 'd.conversions',
actions: [
{
id: 'a.turn-into-linked-doc',
label: 'Turn into linked doc',
icon: LinkedPageIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length !== 1) return false;
return ctx.matchModel(models[0], NoteBlockModel);
},
run(ctx) {
const model = ctx.getCurrentModelByType(NoteBlockModel);
if (!model) return;
const create = async () => {
const title = await promptDocTitle(ctx.std);
if (title === null) return;
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
const surfaceId = edgeless.surfaceBlockModel.id;
if (!surfaceId) return;
const linkedDoc = createLinkedDocFromNote(ctx.store, model, title);
// Inserts linked doc card
const cardId = ctx.std.get(EdgelessCRUDIdentifier).addBlock(
EmbedSyncedDocBlockSchema.model.flavour,
{
xywh: model.xywh,
style: 'syncedDoc',
pageId: linkedDoc.id,
index: model.index,
},
surfaceId
);
ctx.track('CanvasElementAdded', {
control: 'context-menu',
type: 'embed-synced-doc',
});
ctx.track('DocCreated', {
control: 'turn into linked doc',
type: 'embed-linked-doc',
});
ctx.track('LinkedDocCreated', {
control: 'turn into linked doc',
type: 'embed-linked-doc',
other: 'new doc',
});
moveConnectors(model.id, cardId, edgeless.service);
// Deletes selected note
ctx.store.transact(() => {
ctx.store.deleteBlock(model);
});
ctx.gfx.selection.set({
elements: [cardId],
editing: false,
});
};
create().catch(console.error);
},
},
{
id: 'b.create-linked-doc',
label: 'Create linked doc',
icon: LinkedPageIcon(),
when(ctx) {
const models = ctx.getSurfaceModels();
if (models.length === 0) return false;
if (models.length === 1) {
return ![
NoteBlockModel,
EmbedLinkedDocModel,
EmbedSyncedDocModel,
].some(k => ctx.matchModel(models[0], k));
}
return true;
},
run(ctx) {
const models = ctx.getSurfaceModels();
if (!models.length) return;
const create = async () => {
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
const surfaceId = edgeless.surfaceBlockModel.id;
if (!surfaceId) return;
const title = await promptDocTitle(ctx.std);
if (title === null) return;
const clonedModels = getSortedCloneElements(models);
const linkedDoc = createLinkedDocFromEdgelessElements(
ctx.host,
clonedModels,
title
);
ctx.store.transact(() => {
deleteElements(edgeless, clonedModels);
});
// Inserts linked doc card
const width = 364;
const height = 390;
const bound = getCommonBoundWithRotation(clonedModels);
const cardId = ctx.std.get(EdgelessCRUDIdentifier).addBlock(
EmbedLinkedDocBlockSchema.model.flavour,
{
xywh: `[${bound.center[0] - width / 2}, ${bound.center[1] - height / 2}, ${width}, ${height}]`,
style: 'vertical',
pageId: linkedDoc.id,
},
surfaceId
);
ctx.gfx.selection.set({
elements: [cardId],
editing: false,
});
ctx.track('CanvasElementAdded', {
control: 'context-menu',
type: 'embed-linked-doc',
});
ctx.track('DocCreated', {
control: 'create linked doc',
type: 'embed-linked-doc',
});
ctx.track('LinkedDocCreated', {
control: 'create linked doc',
type: 'embed-linked-doc',
other: 'new doc',
});
notifyDocCreated(ctx.std, ctx.store);
};
create().catch(console.error);
},
},
],
},
// Deleting Group
{
id: 'e.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const models = ctx.getSurfaceModels();
if (!models.length) return;
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
ctx.store.captureSync();
deleteElements(edgeless, models);
// Clears
ctx.select('surface');
ctx.reset();
},
},
] as const satisfies ToolbarActions;
function reorderElements(
ctx: ToolbarContext,
models: GfxModel[],
type: ReorderingType
) {
if (!models.length) return;
for (const model of models) {
const index = ctx.gfx.layer.getReorderedIndex(model, type);
// block should be updated in transaction
if (model instanceof GfxBlockElementModel) {
ctx.store.transact(() => {
model.index = index;
});
} else {
model.index = index;
}
}
}
function isRefreshableModel(model: GfxModel) {
return (
model instanceof AttachmentBlockModel ||
model instanceof BookmarkBlockModel ||
model instanceof ImageBlockModel ||
isExternalEmbedModel(model)
);
}
function isRefreshableBlock(block: BlockComponent | null) {
return (
!!block &&
(block instanceof AttachmentBlockComponent ||
block instanceof BookmarkBlockComponent ||
block instanceof ImageBlockComponent ||
isExternalEmbedBlockComponent(block))
);
}

View File

@@ -35,7 +35,7 @@ import { AddTextIcon, ShapeIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
import isEqual from 'lodash-es/isEqual';
import { EdgelessRootBlockComponent, type ShapeToolOption } from '../..';
import type { ShapeToolOption } from '../..';
import { ShapeComponentConfig } from '../../components/toolbar/shape/shape-menu-config';
import { mountShapeTextEditor } from '../../utils/text';
import { LINE_STYLE_LIST } from './consts';
@@ -44,7 +44,7 @@ import {
createMindmapStyleActionMenu,
} from './mindmap';
import { createTextActions } from './text-common';
import { renderMenu } from './utils';
import { getEdgelessWith, renderMenu } from './utils';
export const builtinShapeToolbarConfig = {
actions: [
@@ -318,15 +318,8 @@ export const builtinShapeToolbarConfig = {
const model = ctx.getCurrentModelByType(ShapeElementModel);
if (!model) return;
const rootModel = ctx.store.root;
if (!rootModel) return;
// TODO(@fundon): it should be simple
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
const edgeless = getEdgelessWith(ctx);
if (!edgeless) return;
mountShapeTextEditor(model, edgeless);
},

View File

@@ -1,8 +1,10 @@
import type { ToolbarContext } from '@blocksuite/affine-shared/services';
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { repeat } from 'lit/directives/repeat.js';
import { EdgelessRootBlockComponent } from '../..';
import type { Menu, MenuItem } from './types';
export function renderCurrentMenuItemWith<T, F extends keyof MenuItem<T>>(
@@ -60,3 +62,17 @@ export function renderMenuItems<T>(
`
);
}
// TODO(@fundon): it should be simple
export function getEdgelessWith(ctx: ToolbarContext) {
const rootModel = ctx.store.root;
if (!rootModel) return;
const edgeless = ctx.view.getBlock(rootModel.id);
if (!ctx.matchBlock(edgeless, EdgelessRootBlockComponent)) {
console.error('edgeless view is not found.');
return;
}
return edgeless;
}