feat(editor): inner toolbar layout for block (#11243)

Close [BS-2808](https://linear.app/affine-design/issue/BS-2808/组件内-toolbar-重构)
This commit is contained in:
L-Sun
2025-03-28 03:47:37 +00:00
parent 7193393a06
commit e2c752d56f
8 changed files with 150 additions and 62 deletions

View File

@@ -77,12 +77,9 @@ export class EdgelessToolbarWidget extends WidgetComponent<RootBlockModel> {
display: flex;
justify-content: center;
}
.edgeless-toolbar-wrapper[data-app-theme='light'] {
${unsafeCSS(lightToolbarStyles.join('\n'))}
}
.edgeless-toolbar-wrapper[data-app-theme='dark'] {
${unsafeCSS(darkToolbarStyles.join('\n'))}
}
${unsafeCSS(lightToolbarStyles('.edgeless-toolbar-wrapper'))}
${unsafeCSS(darkToolbarStyles('.edgeless-toolbar-wrapper'))}
.edgeless-toolbar-toggle-control {
pointer-events: auto;
padding-bottom: 16px;

View File

@@ -17,6 +17,7 @@ import {
ToolbarFlag as Flag,
ToolbarRegistryIdentifier,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { matchModels } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
@@ -36,7 +37,7 @@ import {
getCommonBoundWithRotation,
} from '@blocksuite/global/gfx';
import { nextTick } from '@blocksuite/global/utils';
import type { Placement, ReferenceElement, SideObject } from '@floating-ui/dom';
import type { ReferenceElement, SideObject } from '@floating-ui/dom';
import { batch, effect, signal } from '@preact/signals-core';
import { css, unsafeCSS } from 'lit';
import groupBy from 'lodash-es/groupBy';
@@ -76,15 +77,32 @@ export class AffineToolbarWidget extends WidgetComponent {
}
}
editor-toolbar[data-app-theme='dark'] {
${unsafeCSS(darkToolbarStyles.join('\n'))}
}
editor-toolbar[data-app-theme='light'] {
${unsafeCSS(lightToolbarStyles.join('\n'))}
}
`;
editor-toolbar[data-placement='inner'] {
background-color: unset;
box-shadow: unset;
height: fit-content;
padding-top: 4px;
border-radius: 0;
border: unset;
justify-content: flex-end;
box-sizing: border-box;
gap: 4px;
placement$ = signal<Placement>('top');
editor-icon-button,
editor-menu-button {
background: ${unsafeCSSVarV2('button/iconButtonSolid')};
color: ${unsafeCSSVarV2('text/primary')};
box-shadow: ${unsafeCSSVar('buttonShadow')};
border-radius: 4px;
}
editor-menu-button > div {
gap: 4px;
}
}
${unsafeCSS(darkToolbarStyles('editor-toolbar'))}
${unsafeCSS(lightToolbarStyles('editor-toolbar'))}
`;
sideOptions$ = signal<Partial<SideObject> | null>(null);
@@ -213,7 +231,7 @@ export class AffineToolbarWidget extends WidgetComponent {
this.sideOptions$.value = sideOptions;
ctx.flavour$.value = flavour;
this.placement$.value = hasLocked ? 'top' : 'top-start';
ctx.placement$.value = hasLocked ? 'top' : 'top-start';
ctx.flags.refresh(Flag.Surface);
});
}
@@ -228,7 +246,6 @@ export class AffineToolbarWidget extends WidgetComponent {
super.connectedCallback();
const {
placement$,
sideOptions$,
referenceElement$,
disposables,
@@ -237,7 +254,7 @@ export class AffineToolbarWidget extends WidgetComponent {
host,
std,
} = this;
const { flags, flavour$, message$ } = toolbarRegistry;
const { flags, flavour$, message$, placement$ } = toolbarRegistry;
const context = new ToolbarContext(std);
// TODO(@fundon): fix toolbar position shaking when the wheel scrolls
@@ -266,7 +283,7 @@ export class AffineToolbarWidget extends WidgetComponent {
sideOptions$.value = null;
flavour$.value = 'affine:note';
placement$.value = 'top';
placement$.value = toolbarRegistry.getModulePlacement('affine:note');
flags.refresh(Flag.Text);
});
})
@@ -317,7 +334,7 @@ export class AffineToolbarWidget extends WidgetComponent {
sideOptions$.value = null;
flavour$.value = 'affine:note';
placement$.value = 'top';
placement$.value = toolbarRegistry.getModulePlacement('affine:note');
flags.refresh(Flag.Native);
});
});
@@ -338,7 +355,7 @@ export class AffineToolbarWidget extends WidgetComponent {
if (block) {
const modelFlavour = block.model.flavour;
const existed =
toolbarRegistry.modules.has(modelFlavour) ??
toolbarRegistry.modules.has(modelFlavour) ||
toolbarRegistry.modules.has(`custom:${modelFlavour}`);
if (existed) {
flavour = modelFlavour;
@@ -366,7 +383,10 @@ export class AffineToolbarWidget extends WidgetComponent {
sideOptions$.value = null;
flavour$.value = flavour;
placement$.value = flavour === 'affine:note' ? 'top' : 'top-start';
placement$.value = toolbarRegistry.getModulePlacement(
flavour,
flavour === 'affine:note' ? 'top' : 'top-start'
);
flags.refresh(Flag.Block);
});
})
@@ -494,7 +514,7 @@ export class AffineToolbarWidget extends WidgetComponent {
})
);
// Handles elemets when updating
// Handles elements when updating
disposables.add(
context.gfx.surface$.subscribe(surface => {
if (!surface) return;
@@ -580,7 +600,7 @@ export class AffineToolbarWidget extends WidgetComponent {
sideOptions$.value = null;
flavour$.value = flavour;
placement$.value = 'top';
placement$.value = toolbarRegistry.getModulePlacement(flavour);
flags.refresh(Flag.Hovering);
});
})
@@ -593,6 +613,13 @@ export class AffineToolbarWidget extends WidgetComponent {
})
);
// Update layout when placement changing to `inner`
disposables.add(
effect(() => {
toolbar.dataset.placement = placement$.value;
})
);
disposables.add(
effect(() => {
const value = flags.value$.value;

View File

@@ -7,12 +7,13 @@ import {
type ToolbarAction,
type ToolbarActions,
type ToolbarContext,
type ToolbarPlacement,
} from '@blocksuite/affine-shared/services';
import { nextTick } from '@blocksuite/global/utils';
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
import type {
AutoUpdateOptions,
Placement,
ComputePositionConfig,
ReferenceElement,
SideObject,
} from '@floating-ui/dom';
@@ -25,8 +26,9 @@ import {
limitShift,
offset,
shift,
size,
} from '@floating-ui/dom';
import { html, render } from 'lit';
import { html, nothing, render } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { keyed } from 'lit/directives/keyed.js';
@@ -51,34 +53,54 @@ export function autoUpdatePosition(
toolbar: EditorToolbar,
referenceElement: ReferenceElement,
flavour: string,
placement: Placement,
placement: ToolbarPlacement,
sideOptions: Partial<SideObject> | null,
options: AutoUpdateOptions = { elementResize: false, animationFrame: true }
) {
const isInline = flavour === 'affine:note';
const hasSurfaceScope = flavour.includes('surface');
const isInner = placement === 'inner';
const offsetTop = sideOptions?.top ?? 0;
const offsetBottom = sideOptions?.bottom ?? 0;
const offsetY = offsetTop + (hasSurfaceScope ? 2 : 0);
const config = {
placement,
middleware: [
offset(10 + offsetY),
isInline ? inline() : undefined,
shift(state => ({
padding: {
top: 10,
right: 10,
bottom: 150,
left: 10,
},
crossAxis: state.placement.includes('bottom'),
limiter: limitShift(),
})),
flip({ padding: 10 }),
hide(),
],
};
const config: Partial<ComputePositionConfig> = isInner
? {
placement: 'top-start',
middleware: [
offset(({ rects }) => -rects.floating.height),
size({
apply: ({ elements }) => {
elements.floating.style.width = `${
elements.reference.getBoundingClientRect().width
}px`;
},
}),
],
}
: {
placement,
middleware: [
offset(10 + offsetY),
size({
apply: ({ elements }) => {
elements.floating.style.width = 'fit-content';
},
}),
isInline ? inline() : undefined,
shift(state => ({
padding: {
top: 10,
right: 10,
bottom: 150,
left: 10,
},
crossAxis: state.placement.includes('bottom'),
limiter: limitShift(),
})),
flip({ padding: 10 }),
hide(),
],
};
const update = async () => {
await Promise.race([
new Promise(resolve => {
@@ -217,6 +239,8 @@ export function renderToolbar(
a => a.placement === ActionPlacement.More
);
const innerToolbar = context.placement$.value === 'inner';
if (moreActionGroup.length) {
const moreMenuItems = renderActions(
moreActionGroup,
@@ -235,12 +259,20 @@ export function renderToolbar(
aria-label="More menu"
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="More" .tooltip="${'More'}">
<editor-icon-button
aria-label="More"
.tooltip="${'More'}"
.iconContainerPadding=${innerToolbar ? 4 : 2}
.iconSize=${innerToolbar ? '16px' : undefined}
>
${MoreVerticalIcon()}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
<div
data-size=${innerToolbar ? '' : 'large'}
data-orientation="vertical"
>
${join(moreMenuItems, renderToolbarSeparator('horizontal'))}
</div>
</editor-menu-button>
@@ -251,7 +283,10 @@ export function renderToolbar(
}
render(
join(renderActions(primaryActionGroup, context), renderToolbarSeparator()),
join(
renderActions(primaryActionGroup, context),
innerToolbar ? nothing : renderToolbarSeparator()
),
toolbar
);
}
@@ -305,6 +340,7 @@ function renderActions(
// TODO(@fundon): supports templates
function renderActionItem(action: ToolbarAction, context: ToolbarContext) {
const innerToolbar = context.placement$.value === 'inner';
const ids = action.id.split('.');
const id = ids[ids.length - 1];
return html`
@@ -316,6 +352,8 @@ function renderActionItem(action: ToolbarAction, context: ToolbarContext) {
: action.active}
?disabled=${action.disabled}
.tooltip=${action.tooltip}
.iconContainerPadding=${innerToolbar ? 4 : 2}
.iconSize=${innerToolbar ? '16px' : undefined}
@click=${() => action.run?.(context)}
>
${action.icon}
@@ -327,6 +365,7 @@ function renderActionItem(action: ToolbarAction, context: ToolbarContext) {
}
function renderMenuActionItem(action: ToolbarAction, context: ToolbarContext) {
const innerToolbar = context.placement$.value === 'inner';
const ids = action.id.split('.');
const id = ids[ids.length - 1];
return html`
@@ -341,6 +380,8 @@ function renderMenuActionItem(action: ToolbarAction, context: ToolbarContext) {
: action.active}
?disabled=${action.disabled}
.tooltip=${ifDefined(action.tooltip)}
.iconContainerPadding=${innerToolbar ? 4 : 2}
.iconSize=${innerToolbar ? '16px' : undefined}
@click=${() => action.run?.(context)}
>
${action.icon}