refactor(editor): edgeless toolbar alignment actions (#10881)

This commit is contained in:
fundon
2025-03-20 02:08:18 +00:00
parent e4b8b367ce
commit 39704aac66
2 changed files with 325 additions and 6 deletions

View File

@@ -0,0 +1,290 @@
import {
autoArrangeElementsCommand,
autoResizeElementsCommand,
EdgelessCRUDIdentifier,
updateXYWH,
} from '@blocksuite/affine-block-surface';
import type { ToolbarContext } from '@blocksuite/affine-shared/services';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import { Bound } from '@blocksuite/global/gfx';
import {
AlignBottomIcon,
AlignHorizontalCenterIcon,
AlignLeftIcon,
AlignRightIcon,
AlignTopIcon,
AlignVerticalCenterIcon,
ArrowDownSmallIcon,
AutoTidyUpIcon,
DistributeHorizontalIcon,
DistributeVerticalIcon,
ResizeTidyUpIcon,
} from '@blocksuite/icons/lit';
import { html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import type { Menu, MenuItem } from './types';
import { renderMenuItems } from './utils';
enum Alignment {
None,
AutoArrange,
AutoResize,
Bottom,
DistributeHorizontally,
DistributeVertically,
Horizontally,
Left,
Right,
Top,
Vertically,
}
type AlignmentMap = Record<
Alignment,
(ctx: ToolbarContext, elements: GfxModel[]) => void
>;
const HORIZONTAL_ALIGNMENT = [
{
key: 'Align left',
value: Alignment.Left,
icon: AlignLeftIcon(),
},
{
key: 'Align horizontally',
value: Alignment.Horizontally,
icon: AlignHorizontalCenterIcon(),
},
{
key: 'Align right',
value: Alignment.Right,
icon: AlignRightIcon(),
},
{
key: 'Distribute horizontally',
value: Alignment.DistributeHorizontally,
icon: DistributeHorizontalIcon(),
},
] as const satisfies MenuItem<Alignment>[];
const VERTICAL_ALIGNMENT = [
{
key: 'Align top',
value: Alignment.Top,
icon: AlignTopIcon(),
},
{
key: 'Align vertically',
value: Alignment.Vertically,
icon: AlignVerticalCenterIcon(),
},
{
key: 'Align bottom',
value: Alignment.Bottom,
icon: AlignBottomIcon(),
},
{
key: 'Distribute vertically',
value: Alignment.DistributeVertically,
icon: DistributeVerticalIcon(),
},
] as const satisfies MenuItem<Alignment>[];
const AUTO_ALIGNMENT = [
{
key: 'Auto arrange',
value: Alignment.AutoArrange,
icon: AutoTidyUpIcon(),
},
{
key: 'Resize & Align',
value: Alignment.AutoResize,
icon: ResizeTidyUpIcon(),
},
] as const satisfies MenuItem<Alignment>[];
const alignment = {
// None: do nothing
[Alignment.None]() {},
// Horizontal
[Alignment.Left](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const left = Math.min(...bounds.map(b => b.minX));
for (const [index, element] of elements.entries()) {
const elementBound = bounds[index];
const bound = Bound.deserialize(element.xywh);
const offset = bound.minX - elementBound.minX;
bound.x = left + offset;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.Horizontally](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const left = Math.min(...bounds.map(b => b.minX));
const right = Math.max(...bounds.map(b => b.maxX));
const centerX = (left + right) / 2;
for (const element of elements) {
const bound = Bound.deserialize(element.xywh);
bound.x = centerX - bound.w / 2;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.Right](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const right = Math.max(...bounds.map(b => b.maxX));
for (const [i, element] of elements.entries()) {
const elementBound = bounds[i];
const bound = Bound.deserialize(element.xywh);
const offset = bound.maxX - elementBound.maxX;
bound.x = right - bound.w + offset;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.DistributeHorizontally](
ctx: ToolbarContext,
elements: GfxModel[]
) {
elements.sort((a, b) => a.elementBound.minX - b.elementBound.minX);
const bounds = elements.map(a => a.elementBound);
const left = bounds[0].minX;
const right = bounds[bounds.length - 1].maxX;
const totalWidth = right - left;
const totalGap =
totalWidth - elements.reduce((prev, ele) => prev + ele.elementBound.w, 0);
const gap = totalGap / (elements.length - 1);
let next = bounds[0].maxX + gap;
for (let i = 1; i < elements.length - 1; i++) {
const bound = Bound.deserialize(elements[i].xywh);
bound.x = next + bounds[i].w / 2 - bound.w / 2;
next += gap + bounds[i].w;
updateXYWHWith(ctx, elements[i], bound);
}
},
// Vertical
[Alignment.Top](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const top = Math.min(...bounds.map(b => b.minY));
for (const [i, element] of elements.entries()) {
const elementBound = bounds[i];
const bound = Bound.deserialize(element.xywh);
const offset = bound.minY - elementBound.minY;
bound.y = top + offset;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.Vertically](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const top = Math.min(...bounds.map(b => b.minY));
const bottom = Math.max(...bounds.map(b => b.maxY));
const centerY = (top + bottom) / 2;
for (const element of elements) {
const bound = Bound.deserialize(element.xywh);
bound.y = centerY - bound.h / 2;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.Bottom](ctx: ToolbarContext, elements: GfxModel[]) {
const bounds = elements.map(a => a.elementBound);
const bottom = Math.max(...bounds.map(b => b.maxY));
for (const [i, element] of elements.entries()) {
const elementBound = bounds[i];
const bound = Bound.deserialize(element.xywh);
const offset = bound.maxY - elementBound.maxY;
bound.y = bottom - bound.h + offset;
updateXYWHWith(ctx, element, bound);
}
},
[Alignment.DistributeVertically](ctx: ToolbarContext, elements: GfxModel[]) {
elements.sort((a, b) => a.elementBound.minY - b.elementBound.minY);
const bounds = elements.map(a => a.elementBound);
const top = bounds[0].minY;
const bottom = bounds[bounds.length - 1].maxY;
const totalHeight = bottom - top;
const totalGap =
totalHeight -
elements.reduce((prev, ele) => prev + ele.elementBound.h, 0);
const gap = totalGap / (elements.length - 1);
let next = bounds[0].maxY + gap;
for (let i = 1; i < elements.length - 1; i++) {
const bound = Bound.deserialize(elements[i].xywh);
bound.y = next + bounds[i].h / 2 - bound.h / 2;
next += gap + bounds[i].h;
updateXYWHWith(ctx, elements[i], bound);
}
},
// Auto
[Alignment.AutoArrange](ctx: ToolbarContext) {
ctx.command.exec(autoArrangeElementsCommand);
},
[Alignment.AutoResize](ctx: ToolbarContext) {
ctx.command.exec(autoResizeElementsCommand);
},
} as const satisfies AlignmentMap;
const updateXYWHWith = (ctx: ToolbarContext, model: GfxModel, bound: Bound) => {
updateXYWH(
model,
bound,
ctx.std.get(EdgelessCRUDIdentifier).updateElement,
ctx.store.updateBlock
);
};
export function renderAlignmentMenu(
ctx: ToolbarContext,
models: GfxModel[],
{ label, tooltip, icon }: Pick<Menu<Alignment>, 'label' | 'tooltip' | 'icon'>,
onPick = (type: Alignment) => alignment[type](ctx, models)
) {
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="${label}"
.tooltip="${tooltip ?? label}"
>
${icon} ${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
<div data-orientation="vertical">
<div style=${styleMap({ display: 'grid', gridGap: '8px', gridTemplateColumns: 'repeat(4, 1fr)' })}>
${renderMenuItems(HORIZONTAL_ALIGNMENT, Alignment.None, onPick)}
${renderMenuItems(VERTICAL_ALIGNMENT, Alignment.None, onPick)}
</div>
<editor-toolbar-separator data-orientation="horizontal"></editor-toolbar-separator>
<div style=${styleMap({ display: 'grid', gridGap: '8px', gridTemplateColumns: 'repeat(4, 1fr)' })}>
${renderMenuItems(AUTO_ALIGNMENT, Alignment.None, onPick)}
</div>
</editor-menu-button>
`;
}

View File

@@ -15,6 +15,7 @@ import {
import type { GfxModel } from '@blocksuite/block-std/gfx';
import { Bound } from '@blocksuite/global/gfx';
import {
AlignLeftIcon,
ConnectorCIcon,
FrameIcon,
GroupingIcon,
@@ -25,6 +26,7 @@ import {
import { html } from 'lit';
import { EdgelessRootBlockComponent } from '../..';
import { renderAlignmentMenu } from './alignment';
export const builtinMiscToolbarConfig = {
actions: [
@@ -72,6 +74,11 @@ export const builtinMiscToolbarConfig = {
models.some(model => ctx.matchModel(model.group, MindmapElementModel))
)
return false;
if (
models.length ===
models.filter(model => model instanceof ConnectorElementModel).length
)
return false;
return true;
},
@@ -144,9 +151,31 @@ export const builtinMiscToolbarConfig = {
},
{
placement: ActionPlacement.Start,
id: 'a.misc',
label: 'Misc',
run() {},
id: 'd.alignment',
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 (
models.length ===
models.filter(model => model instanceof ConnectorElementModel).length
)
return false;
return true;
},
content(ctx) {
const models = ctx.getSurfaceModels();
if (models.length < 2) return null;
return renderAlignmentMenu(ctx, models, {
icon: AlignLeftIcon(),
label: 'Align objects',
tooltip: 'Align objects',
});
},
},
{
placement: ActionPlacement.End,
@@ -350,8 +379,8 @@ function track(
ctx.track('EdgelessElementLocked', {
control,
type:
'flavour' in element
? (element.flavour.split(':')[1] ?? element.flavour)
: element.type,
'type' in element
? element.type
: (element.flavour.split(':')[1] ?? element.flavour),
});
}