mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-16 22:07:09 +08:00
refactor(editor): edgeless toolbar alignment actions (#10881)
This commit is contained in:
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user