refactor(editor): edgeless toolbar lock and unlock actions (#10878)

This commit is contained in:
fundon
2025-03-20 02:08:16 +00:00
parent bd9b78f7d2
commit 07a64eb004
5 changed files with 277 additions and 79 deletions

View File

@@ -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,
}),
];

View File

@@ -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,
});
}

View File

@@ -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 [];

View File

@@ -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 () => {