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;
}

View File

@@ -26,7 +26,7 @@ import {
offset,
shift,
} from '@floating-ui/dom';
import { html, render, type TemplateResult } from 'lit';
import { html, render } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { keyed } from 'lit/directives/keyed.js';
@@ -223,32 +223,31 @@ export function renderToolbar(
context,
renderMenuActionItem
);
// if (moreMenuItems.length) {
// TODO(@fundon): edgeless case needs to be considered
const key = `${context.getCurrentModel()?.id}`;
if (moreMenuItems.length) {
const key = `${context.getCurrentModel()?.id}`;
primaryActionGroup.push({
id: 'more',
content: html`${keyed(
`${flavour}:${key}`,
html`
<editor-menu-button
class="more-menu"
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="More" .tooltip="${'More'}">
${MoreVerticalIcon()}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${join(moreMenuItems, renderToolbarSeparator('horizontal'))}
</div>
</editor-menu-button>
`
)}`,
});
// }
primaryActionGroup.push({
id: 'more',
content: html`${keyed(
`${flavour}:${key}`,
html`
<editor-menu-button
class="more-menu"
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="More" .tooltip="${'More'}">
${MoreVerticalIcon()}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${join(moreMenuItems, renderToolbarSeparator('horizontal'))}
</div>
</editor-menu-button>
`
)}`,
});
}
}
render(
@@ -264,20 +263,18 @@ function renderActions(
) {
return actions
.map(action => {
let content: TemplateResult | null = null;
if ('content' in action) {
if (typeof action.content === 'function') {
content = action.content(context);
return action.content(context);
} else {
content = action.content ?? null;
return action.content ?? null;
}
return content;
}
if ('actions' in action && action.actions.length) {
const combined = combine(action.actions, context);
if (!combined.length) return content;
if (!combined.length) return null;
const ordered = orderBy(combined, ['id', 'score'], ['asc', 'asc']);
@@ -301,7 +298,7 @@ function renderActions(
return render(action, context);
}
return content;
return null;
})
.filter(action => action !== null);
}