mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +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 { createFrameToolbarConfig } from './frame';
|
||||||
import { builtinGroupToolbarConfig } from './group';
|
import { builtinGroupToolbarConfig } from './group';
|
||||||
import { builtinMindmapToolbarConfig } from './mindmap';
|
import { builtinMindmapToolbarConfig } from './mindmap';
|
||||||
import { builtinMiscToolbarConfig } from './misc';
|
import { builtinLockedToolbarConfig, builtinMiscToolbarConfig } from './misc';
|
||||||
import { builtinShapeToolbarConfig } from './shape';
|
import { builtinShapeToolbarConfig } from './shape';
|
||||||
import { builtinTextToolbarConfig } from './text';
|
import { builtinTextToolbarConfig } from './text';
|
||||||
|
|
||||||
@@ -55,4 +55,11 @@ export const EdgelessElementToolbarExtension: ExtensionType[] = [
|
|||||||
id: BlockFlavourIdentifier('affine:surface:*'),
|
id: BlockFlavourIdentifier('affine:surface:*'),
|
||||||
config: builtinMiscToolbarConfig,
|
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 {
|
import {
|
||||||
ConnectorElementModel,
|
ConnectorElementModel,
|
||||||
DEFAULT_CONNECTOR_MODE,
|
DEFAULT_CONNECTOR_MODE,
|
||||||
|
GroupElementModel,
|
||||||
|
MindmapElementModel,
|
||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
import {
|
import {
|
||||||
ActionPlacement,
|
ActionPlacement,
|
||||||
|
type ElementLockEvent,
|
||||||
type ToolbarAction,
|
type ToolbarAction,
|
||||||
|
type ToolbarContext,
|
||||||
type ToolbarModuleConfig,
|
type ToolbarModuleConfig,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
|
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
||||||
import {
|
import {
|
||||||
ConnectorCIcon,
|
ConnectorCIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
ReleaseFromGroupIcon,
|
ReleaseFromGroupIcon,
|
||||||
|
UnlockIcon,
|
||||||
} from '@blocksuite/icons/lit';
|
} from '@blocksuite/icons/lit';
|
||||||
import { html } from 'lit';
|
import { html } from 'lit';
|
||||||
|
|
||||||
|
import { EdgelessRootBlockComponent } from '../..';
|
||||||
|
|
||||||
export const builtinMiscToolbarConfig = {
|
export const builtinMiscToolbarConfig = {
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
@@ -37,7 +46,6 @@ export const builtinMiscToolbarConfig = {
|
|||||||
when(ctx) {
|
when(ctx) {
|
||||||
const models = ctx.getSurfaceModels();
|
const models = ctx.getSurfaceModels();
|
||||||
if (models.length !== 1) return false;
|
if (models.length !== 1) return false;
|
||||||
if (models[0].isLocked()) return false;
|
|
||||||
return !ctx.matchModel(models[0], ConnectorElementModel);
|
return !ctx.matchModel(models[0], ConnectorElementModel);
|
||||||
},
|
},
|
||||||
content(ctx) {
|
content(ctx) {
|
||||||
@@ -74,9 +82,166 @@ export const builtinMiscToolbarConfig = {
|
|||||||
{
|
{
|
||||||
placement: ActionPlacement.End,
|
placement: ActionPlacement.End,
|
||||||
id: 'b.lock',
|
id: 'b.lock',
|
||||||
icon: LockIcon(),
|
|
||||||
tooltip: 'Lock',
|
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;
|
} 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() {
|
getSurfaceModels() {
|
||||||
if (this.hasSelectedSurfaceModels) {
|
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 elements ?? [];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -128,6 +128,82 @@ export class AffineToolbarWidget extends WidgetComponent {
|
|||||||
: null;
|
: 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();
|
toolbar = new EditorToolbar();
|
||||||
|
|
||||||
get toolbarRegistry() {
|
get toolbarRegistry() {
|
||||||
@@ -147,7 +223,7 @@ export class AffineToolbarWidget extends WidgetComponent {
|
|||||||
host,
|
host,
|
||||||
std,
|
std,
|
||||||
} = this;
|
} = this;
|
||||||
const { flags, flavour$, message$, elementsMap$ } = toolbarRegistry;
|
const { flags, flavour$, message$ } = toolbarRegistry;
|
||||||
const context = new ToolbarContext(std);
|
const context = new ToolbarContext(std);
|
||||||
|
|
||||||
// TODO(@fundon): fix toolbar position shaking when the wheel scrolls
|
// 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))
|
.map(s => (s.editing || s.inoperable ? [] : s.elements))
|
||||||
.flat();
|
.flat();
|
||||||
const count = elementIds.length;
|
const count = elementIds.length;
|
||||||
const gfx = context.gfx;
|
const activated = context.activated && Boolean(count);
|
||||||
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[]][] = [];
|
|
||||||
|
|
||||||
if (activated && surface) {
|
this.updateWithSurface(context, activated, elementIds);
|
||||||
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);
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -452,7 +461,12 @@ export class AffineToolbarWidget extends WidgetComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (flags.isSurface()) {
|
if (flags.isSurface()) {
|
||||||
flags.refresh(Flag.Surface);
|
const elementIds = context.gfx.selection.selectedIds;
|
||||||
|
this.updateWithSurface(
|
||||||
|
context,
|
||||||
|
context.activated && Boolean(elementIds.length),
|
||||||
|
elementIds
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -460,14 +474,18 @@ export class AffineToolbarWidget extends WidgetComponent {
|
|||||||
|
|
||||||
// Handles elemets when updating
|
// Handles elemets when updating
|
||||||
disposables.add(
|
disposables.add(
|
||||||
effect(() => {
|
context.gfx.surface$.subscribe(surface => {
|
||||||
const surface = context.gfx.surface$.value;
|
|
||||||
if (!surface) return;
|
if (!surface) return;
|
||||||
|
|
||||||
const subscription = surface.elementUpdated.subscribe(() => {
|
const subscription = surface.elementUpdated.subscribe(() => {
|
||||||
if (!flags.isSurface()) return;
|
if (!flags.isSurface()) return;
|
||||||
|
|
||||||
flags.refresh(Flag.Surface);
|
const elementIds = context.gfx.selection.selectedIds;
|
||||||
|
this.updateWithSurface(
|
||||||
|
context,
|
||||||
|
context.activated && Boolean(elementIds.length),
|
||||||
|
elementIds
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -395,6 +395,8 @@ function createToolbarMoreMenuConfigV2(baseUrl?: string) {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
when: ctx => !ctx.getSurfaceModels().some(model => model.isLocked()),
|
||||||
} as const satisfies ToolbarModuleConfig;
|
} as const satisfies ToolbarModuleConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user