diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/alignment.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/alignment.ts new file mode 100644 index 000000000..fac7743a8 --- /dev/null +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/alignment.ts @@ -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[]; + +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[]; + +const AUTO_ALIGNMENT = [ + { + key: 'Auto arrange', + value: Alignment.AutoArrange, + icon: AutoTidyUpIcon(), + }, + { + key: 'Resize & Align', + value: Alignment.AutoResize, + icon: ResizeTidyUpIcon(), + }, +] as const satisfies MenuItem[]; + +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, 'label' | 'tooltip' | 'icon'>, + onPick = (type: Alignment) => alignment[type](ctx, models) +) { + return html` + + ${icon} ${ArrowDownSmallIcon()} + + `} + > +
+
+ ${renderMenuItems(HORIZONTAL_ALIGNMENT, Alignment.None, onPick)} + ${renderMenuItems(VERTICAL_ALIGNMENT, Alignment.None, onPick)} +
+ +
+ ${renderMenuItems(AUTO_ALIGNMENT, Alignment.None, onPick)} +
+ + `; +} diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/misc.ts b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/misc.ts index 1835ced0a..1ed1158b5 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/misc.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/configs/toolbar/misc.ts @@ -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), }); }