From 07a64eb00410edd051c6c718fced8d9ef475be69 Mon Sep 17 00:00:00 2001 From: fundon Date: Thu, 20 Mar 2025 02:08:16 +0000 Subject: [PATCH] refactor(editor): edgeless toolbar lock and unlock actions (#10878) --- .../src/edgeless/configs/toolbar/index.ts | 9 +- .../src/edgeless/configs/toolbar/misc.ts | 171 +++++++++++++++++- .../src/services/toolbar-service/context.ts | 8 +- .../widgets/widget-toolbar/src/toolbar.ts | 166 +++++++++-------- .../extensions/editor-config/toolbar/index.ts | 2 + 5 files changed, 277 insertions(+), 79 deletions(-) diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/index.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/index.ts index c2babcd36..b1e485ae4 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/index.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/index.ts @@ -9,7 +9,7 @@ import { builtinEdgelessTextToolbarConfig } from './edgeless-text'; import { createFrameToolbarConfig } from './frame'; import { builtinGroupToolbarConfig } from './group'; import { builtinMindmapToolbarConfig } from './mindmap'; -import { builtinMiscToolbarConfig } from './misc'; +import { builtinLockedToolbarConfig, builtinMiscToolbarConfig } from './misc'; import { builtinShapeToolbarConfig } from './shape'; import { builtinTextToolbarConfig } from './text'; @@ -55,4 +55,11 @@ export const EdgelessElementToolbarExtension: ExtensionType[] = [ id: BlockFlavourIdentifier('affine:surface:*'), config: builtinMiscToolbarConfig, }), + + // Special Scenarios + // Only display the `unlock` button when the selection includes a locked element. + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('affine:surface:locked'), + config: builtinLockedToolbarConfig, + }), ]; 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 54127d129..cdcfc5805 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 @@ -1,19 +1,28 @@ +import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface'; import { ConnectorElementModel, DEFAULT_CONNECTOR_MODE, + GroupElementModel, + MindmapElementModel, } from '@blocksuite/affine-model'; import { ActionPlacement, + type ElementLockEvent, type ToolbarAction, + type ToolbarContext, type ToolbarModuleConfig, } from '@blocksuite/affine-shared/services'; +import type { GfxModel } from '@blocksuite/block-std/gfx'; import { ConnectorCIcon, LockIcon, ReleaseFromGroupIcon, + UnlockIcon, } from '@blocksuite/icons/lit'; import { html } from 'lit'; +import { EdgelessRootBlockComponent } from '../..'; + export const builtinMiscToolbarConfig = { actions: [ { @@ -37,7 +46,6 @@ export const builtinMiscToolbarConfig = { when(ctx) { const models = ctx.getSurfaceModels(); if (models.length !== 1) return false; - if (models[0].isLocked()) return false; return !ctx.matchModel(models[0], ConnectorElementModel); }, content(ctx) { @@ -74,9 +82,166 @@ export const builtinMiscToolbarConfig = { { placement: ActionPlacement.End, id: 'b.lock', - icon: LockIcon(), tooltip: 'Lock', - run() {}, + icon: LockIcon(), + run(ctx) { + 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; + } + + // get most top selected elements(*) from tree, like in a tree below + // G0 + // / \ + // E1* G1 + // / \ + // E2* E3* + // + // (*) selected elements, [E1, E2, E3] + // return [E1] + + const elements = Array.from( + new Set( + models.map(model => + ctx.matchModel(model.group, MindmapElementModel) + ? model.group + : model + ) + ) + ); + + const levels = elements.map(element => element.groups.length); + const topElement = elements[levels.indexOf(Math.min(...levels))]; + const otherElements = elements.filter( + element => element !== topElement + ); + + ctx.store.captureSync(); + + // release other elements from their groups and group with top element + otherElements.forEach(element => { + // oxlint-disable-next-line unicorn/prefer-dom-node-remove + element.group?.removeChild(element); + topElement.group?.addChild(element); + }); + + if (otherElements.length === 0) { + topElement.lock(); + + ctx.gfx.selection.set({ + editing: false, + elements: [topElement.id], + }); + + track(ctx, topElement, 'lock'); + return; + } + + const groupId = edgeless.service.createGroup([ + topElement, + ...otherElements, + ]); + + if (groupId) { + const element = ctx.std + .get(EdgelessCRUDIdentifier) + .getElementById(groupId); + + if (element) { + element.lock(); + ctx.gfx.selection.set({ + editing: false, + elements: [groupId], + }); + + track(ctx, element, 'group-lock'); + return; + } + } + + for (const element of elements) { + element.lock(); + + track(ctx, element, 'lock'); + } + + ctx.gfx.selection.set({ + editing: false, + elements: elements.map(e => e.id), + }); + }, }, ], + when(ctx) { + const models = ctx.getSurfaceModels(); + return models.length > 0 && !models.some(model => model.isLocked()); + }, } as const satisfies ToolbarModuleConfig; + +export const builtinLockedToolbarConfig = { + actions: [ + { + placement: ActionPlacement.End, + id: 'b.unlock', + label: 'Click to unlock', + icon: UnlockIcon(), + run(ctx) { + 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 elements = new Set( + models.map(model => + ctx.matchModel(model.group, MindmapElementModel) + ? model.group + : model + ) + ); + + ctx.store.captureSync(); + + for (const element of elements) { + if (element instanceof GroupElementModel) { + edgeless.service.ungroup(element); + } else { + element.lockedBySelf = false; + } + + track(ctx, element, 'unlock'); + } + }, + }, + ], + when: ctx => ctx.getSurfaceModels().some(model => model.isLocked()), +} as const satisfies ToolbarModuleConfig; + +function track( + ctx: ToolbarContext, + element: GfxModel, + control: ElementLockEvent['control'] +) { + ctx.track('EdgelessElementLocked', { + control, + type: + 'flavour' in element + ? (element.flavour.split(':')[1] ?? element.flavour) + : element.type, + }); +} diff --git a/blocksuite/affine/shared/src/services/toolbar-service/context.ts b/blocksuite/affine/shared/src/services/toolbar-service/context.ts index 4698b2855..617c1c13a 100644 --- a/blocksuite/affine/shared/src/services/toolbar-service/context.ts +++ b/blocksuite/affine/shared/src/services/toolbar-service/context.ts @@ -139,7 +139,13 @@ abstract class ToolbarContextBase { getSurfaceModels() { if (this.hasSelectedSurfaceModels) { - const elements = this.elementsMap$.peek().get(this.flavour$.peek()); + const flavour = this.flavour$.peek(); + const elementsMap = this.elementsMap$.peek(); + const elements = ['affine:surface', 'affine:surface:locked'].includes( + flavour + ) + ? Array.from(elementsMap.values()).flat() + : elementsMap.get(flavour); return elements ?? []; } return []; diff --git a/blocksuite/affine/widgets/widget-toolbar/src/toolbar.ts b/blocksuite/affine/widgets/widget-toolbar/src/toolbar.ts index 545986180..181dcc564 100644 --- a/blocksuite/affine/widgets/widget-toolbar/src/toolbar.ts +++ b/blocksuite/affine/widgets/widget-toolbar/src/toolbar.ts @@ -128,6 +128,82 @@ export class AffineToolbarWidget extends WidgetComponent { : null; } + updateWithSurface( + ctx: ToolbarContext, + activated: boolean, + elementIds: string[] + ) { + const gfx = ctx.gfx; + const surface = gfx.surface; + let flavour = 'affine:surface'; + let elements: GfxModel[] = []; + let hasLocked = false; + let sideOptions = null; + let paired: [string, GfxModel[]][] = []; + + if (activated && surface) { + elements = elementIds + .map(id => gfx.getElementById(id)) + .filter(model => model !== null) as GfxModel[]; + + hasLocked = elements.some(e => e.isLocked()); + + const grouped = groupBy( + elements.map(model => { + let flavour = surface.flavour; + + if (model instanceof GfxBlockElementModel) { + flavour += `:${model.flavour.split(':').pop()}`; + } else if (model instanceof GfxPrimitiveElementModel) { + flavour += `:${model.type}`; + } + + return { model, flavour }; + }), + e => e.flavour + ); + + paired = toPairs(grouped).map(([flavour, items]) => [ + flavour, + items.map(({ model }) => model), + ]); + + if (hasLocked) { + flavour = 'affine:surface:locked'; + } else { + if (paired.length === 1) { + flavour = paired[0][0]; + if (flavour === 'affine:surface:shape' && paired[0][1].length === 1) { + sideOptions = sideMap.get(flavour) ?? null; + } + } + } + if (!sideOptions) { + const flavours = new Set(paired.map(([f]) => f)); + if (flavours.has('affine:surface:frame')) { + sideOptions = sideMap.get('affine:surface:frame') ?? null; + } else if (flavours.has('affine:surface:group')) { + sideOptions = sideMap.get('affine:surface:group') ?? null; + } + } + } + + batch(() => { + ctx.flags.toggle(Flag.Surface, activated); + + ctx.elementsMap$.value = new Map(paired); + + if (!activated || !flavour) return; + + this.setReferenceElementWithElements(gfx, elements); + + this.sideOptions$.value = sideOptions; + ctx.flavour$.value = flavour; + this.placement$.value = hasLocked ? 'top' : 'top-start'; + ctx.flags.refresh(Flag.Surface); + }); + } + toolbar = new EditorToolbar(); get toolbarRegistry() { @@ -147,7 +223,7 @@ export class AffineToolbarWidget extends WidgetComponent { host, std, } = this; - const { flags, flavour$, message$, elementsMap$ } = toolbarRegistry; + const { flags, flavour$, message$ } = toolbarRegistry; const context = new ToolbarContext(std); // TODO(@fundon): fix toolbar position shaking when the wheel scrolls @@ -293,76 +369,9 @@ export class AffineToolbarWidget extends WidgetComponent { .map(s => (s.editing || s.inoperable ? [] : s.elements)) .flat(); const count = elementIds.length; - const gfx = context.gfx; - const surface = gfx.surface; - const activated = - context.activated && Boolean(surface) && Boolean(count); - let flavour = 'affine:surface'; - let elements: GfxModel[] = []; - let hasLocked = false; - let sideOptions = null; - let paired: [string, GfxModel[]][] = []; + const activated = context.activated && Boolean(count); - if (activated && surface) { - elements = elementIds - .map(id => gfx.getElementById(id)) - .filter(model => model !== null) as GfxModel[]; - - hasLocked = elements.some(e => e.isLocked()); - - const grouped = groupBy( - elements.map(model => { - let flavour = surface.flavour; - - if (model instanceof GfxBlockElementModel) { - flavour += `:${model.flavour.split(':').pop()}`; - } else if (model instanceof GfxPrimitiveElementModel) { - flavour += `:${model.type}`; - } - - return { model, flavour }; - }), - e => e.flavour - ); - - paired = toPairs(grouped).map(([flavour, items]) => [ - flavour, - items.map(({ model }) => model), - ]); - - if (paired.length === 1) { - flavour = paired[0][0]; - if ( - flavour === 'affine:surface:shape' && - paired[0][1].length === 1 - ) { - sideOptions = sideMap.get(flavour) ?? null; - } - } - if (!sideOptions) { - const flavours = new Set(paired.map(([f]) => f)); - if (flavours.has('affine:surface:frame')) { - sideOptions = sideMap.get('affine:surface:frame') ?? null; - } else if (flavours.has('affine:surface:group')) { - sideOptions = sideMap.get('affine:surface:group') ?? null; - } - } - } - - batch(() => { - flags.toggle(Flag.Surface, activated); - - elementsMap$.value = new Map(paired); - - if (!activated || !flavour) return; - - this.setReferenceElementWithElements(gfx, elements); - - sideOptions$.value = sideOptions; - flavour$.value = flavour; - placement$.value = hasLocked ? 'top' : 'top-start'; - flags.refresh(Flag.Surface); - }); + this.updateWithSurface(context, activated, elementIds); }) ); @@ -452,7 +461,12 @@ export class AffineToolbarWidget extends WidgetComponent { } if (flags.isSurface()) { - flags.refresh(Flag.Surface); + const elementIds = context.gfx.selection.selectedIds; + this.updateWithSurface( + context, + context.activated && Boolean(elementIds.length), + elementIds + ); return; } }) @@ -460,14 +474,18 @@ export class AffineToolbarWidget extends WidgetComponent { // Handles elemets when updating disposables.add( - effect(() => { - const surface = context.gfx.surface$.value; + context.gfx.surface$.subscribe(surface => { if (!surface) return; const subscription = surface.elementUpdated.subscribe(() => { if (!flags.isSurface()) return; - flags.refresh(Flag.Surface); + const elementIds = context.gfx.selection.selectedIds; + this.updateWithSurface( + context, + context.activated && Boolean(elementIds.length), + elementIds + ); }); return () => { diff --git a/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts index 52af85520..71d915e5a 100644 --- a/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts +++ b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts @@ -395,6 +395,8 @@ function createToolbarMoreMenuConfigV2(baseUrl?: string) { ], }, ], + + when: ctx => !ctx.getSurfaceModels().some(model => model.isLocked()), } as const satisfies ToolbarModuleConfig; }