diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/frame.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/frame.ts index 63260d696d..94d4f81592 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/frame.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/frame.ts @@ -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(); diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/group.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/group.ts index ae7b19f3d3..2f40e3c475 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/group.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/group.ts @@ -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); diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/misc.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/misc.ts index 1ed1158b52..4066fc53a1 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/misc.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/misc.ts @@ -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 => diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/more.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/more.ts new file mode 100644 index 0000000000..540fa7522e --- /dev/null +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/more.ts @@ -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)) + ); +} diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/shape.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/shape.ts index d1452fda2e..f8453b5f84 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/shape.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/shape.ts @@ -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); }, diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/utils.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/utils.ts index 662f8fa25e..e44138cf50 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/utils.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/utils.ts @@ -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>( @@ -60,3 +62,17 @@ export function renderMenuItems( ` ); } + +// 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; +} diff --git a/blocksuite/affine/widgets/widget-toolbar/src/utils.ts b/blocksuite/affine/widgets/widget-toolbar/src/utils.ts index 7833b1f217..ed94224b97 100644 --- a/blocksuite/affine/widgets/widget-toolbar/src/utils.ts +++ b/blocksuite/affine/widgets/widget-toolbar/src/utils.ts @@ -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` - - ${MoreVerticalIcon()} - - `} - > -
- ${join(moreMenuItems, renderToolbarSeparator('horizontal'))} -
-
- ` - )}`, - }); - // } + primaryActionGroup.push({ + id: 'more', + content: html`${keyed( + `${flavour}:${key}`, + html` + + ${MoreVerticalIcon()} + + `} + > +
+ ${join(moreMenuItems, renderToolbarSeparator('horizontal'))} +
+
+ ` + )}`, + }); + } } 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); }