mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-16 05:47:09 +08:00
refactor(editor): edgeless toolbar lock and unlock actions (#10878)
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user