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

@@ -46,12 +46,8 @@ export class EdgelessTemplatePanel extends WithDisposable(LitElement) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.edgeless-templates-panel[data-app-theme='light'] { ${unsafeCSS(lightToolbarStyles('.edgeless-templates-panel'))}
${unsafeCSS(lightToolbarStyles.join('\n'))} ${unsafeCSS(darkToolbarStyles('.edgeless-templates-panel'))}
}
.edgeless-templates-panel[data-app-theme='dark'] {
${unsafeCSS(darkToolbarStyles.join('\n'))}
}
.search-bar { .search-bar {
padding: 21px 24px; padding: 21px 24px;

View File

@@ -21,10 +21,18 @@ const toolbarColorKeys: Array<keyof AffineCssVariables> = [
'--affine-hover-color-filled', '--affine-hover-color-filled',
]; ];
export const lightToolbarStyles = toolbarColorKeys.map( export const lightToolbarStyles = (selector: string) => `
key => `${key}: ${unsafeCSS(combinedLightCssVariables[key])};` ${selector}[data-app-theme='light'] {
); ${toolbarColorKeys
.map(key => `${key}: ${unsafeCSS(combinedLightCssVariables[key])};`)
.join('\n')}
}
`;
export const darkToolbarStyles = toolbarColorKeys.map( export const darkToolbarStyles = (selector: string) => `
key => `${key}: ${unsafeCSS(combinedDarkCssVariables[key])};` ${selector}[data-app-theme='dark'] {
); ${toolbarColorKeys
.map(key => `${key}: ${unsafeCSS(combinedDarkCssVariables[key])};`)
.join('\n')}
}
`;

View File

@@ -3,10 +3,14 @@ import type { Placement } from '@floating-ui/dom';
import type { ToolbarActions } from './action'; import type { ToolbarActions } from './action';
import type { ToolbarContext } from './context'; import type { ToolbarContext } from './context';
export type ToolbarPlacement =
| Extract<Placement, 'top' | 'top-start'>
| 'inner';
export type ToolbarModuleConfig = { export type ToolbarModuleConfig = {
actions: ToolbarActions; actions: ToolbarActions;
when?: ((ctx: ToolbarContext) => boolean) | boolean; when?: ((ctx: ToolbarContext) => boolean) | boolean;
placement?: Extract<Placement, 'top' | 'top-start'>; placement?: ToolbarPlacement;
}; };

View File

@@ -123,6 +123,10 @@ abstract class ToolbarContextBase {
return this.toolbarRegistry.flavour$; return this.toolbarRegistry.flavour$;
} }
get placement$() {
return this.toolbarRegistry.placement$;
}
get message$() { get message$() {
return this.toolbarRegistry.message$; return this.toolbarRegistry.message$;
} }

View File

@@ -4,6 +4,7 @@ import { type Container, createIdentifier } from '@blocksuite/global/di';
import { Extension, type ExtensionType } from '@blocksuite/store'; import { Extension, type ExtensionType } from '@blocksuite/store';
import { signal } from '@preact/signals-core'; import { signal } from '@preact/signals-core';
import type { ToolbarPlacement } from './config';
import { Flags } from './flags'; import { Flags } from './flags';
import type { ToolbarModule } from './module'; import type { ToolbarModule } from './module';
@@ -33,6 +34,8 @@ export class ToolbarRegistryExtension extends Extension {
setFloating: (element?: Element) => void; setFloating: (element?: Element) => void;
} | null>(null); } | null>(null);
placement$ = signal<ToolbarPlacement>('top');
flags = new Flags(); flags = new Flags();
constructor(readonly std: BlockStdScope) { constructor(readonly std: BlockStdScope) {
@@ -43,6 +46,14 @@ export class ToolbarRegistryExtension extends Extension {
return this.std.provider.getAll(ToolbarModuleIdentifier); return this.std.provider.getAll(ToolbarModuleIdentifier);
} }
getModulePlacement(flavour: string, fallback: ToolbarPlacement = 'top') {
return (
this.modules.get(`custom:${flavour}`)?.config.placement ??
this.modules.get(flavour)?.config.placement ??
fallback
);
}
static override setup(di: Container) { static override setup(di: Container) {
di.addImpl(ToolbarRegistryIdentifier, this, [StdIdentifier]); di.addImpl(ToolbarRegistryIdentifier, this, [StdIdentifier]);
} }

View File

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

View File

@@ -17,6 +17,7 @@ import {
ToolbarFlag as Flag, ToolbarFlag as Flag,
ToolbarRegistryIdentifier, ToolbarRegistryIdentifier,
} from '@blocksuite/affine-shared/services'; } from '@blocksuite/affine-shared/services';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { matchModels } from '@blocksuite/affine-shared/utils'; import { matchModels } from '@blocksuite/affine-shared/utils';
import { import {
type BlockComponent, type BlockComponent,
@@ -36,7 +37,7 @@ import {
getCommonBoundWithRotation, getCommonBoundWithRotation,
} from '@blocksuite/global/gfx'; } from '@blocksuite/global/gfx';
import { nextTick } from '@blocksuite/global/utils'; 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 { batch, effect, signal } from '@preact/signals-core';
import { css, unsafeCSS } from 'lit'; import { css, unsafeCSS } from 'lit';
import groupBy from 'lodash-es/groupBy'; import groupBy from 'lodash-es/groupBy';
@@ -76,15 +77,32 @@ export class AffineToolbarWidget extends WidgetComponent {
} }
} }
editor-toolbar[data-app-theme='dark'] { editor-toolbar[data-placement='inner'] {
${unsafeCSS(darkToolbarStyles.join('\n'))} background-color: unset;
} box-shadow: unset;
editor-toolbar[data-app-theme='light'] { height: fit-content;
${unsafeCSS(lightToolbarStyles.join('\n'))} 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); sideOptions$ = signal<Partial<SideObject> | null>(null);
@@ -213,7 +231,7 @@ export class AffineToolbarWidget extends WidgetComponent {
this.sideOptions$.value = sideOptions; this.sideOptions$.value = sideOptions;
ctx.flavour$.value = flavour; ctx.flavour$.value = flavour;
this.placement$.value = hasLocked ? 'top' : 'top-start'; ctx.placement$.value = hasLocked ? 'top' : 'top-start';
ctx.flags.refresh(Flag.Surface); ctx.flags.refresh(Flag.Surface);
}); });
} }
@@ -228,7 +246,6 @@ export class AffineToolbarWidget extends WidgetComponent {
super.connectedCallback(); super.connectedCallback();
const { const {
placement$,
sideOptions$, sideOptions$,
referenceElement$, referenceElement$,
disposables, disposables,
@@ -237,7 +254,7 @@ export class AffineToolbarWidget extends WidgetComponent {
host, host,
std, std,
} = this; } = this;
const { flags, flavour$, message$ } = toolbarRegistry; const { flags, flavour$, message$, placement$ } = 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
@@ -266,7 +283,7 @@ export class AffineToolbarWidget extends WidgetComponent {
sideOptions$.value = null; sideOptions$.value = null;
flavour$.value = 'affine:note'; flavour$.value = 'affine:note';
placement$.value = 'top'; placement$.value = toolbarRegistry.getModulePlacement('affine:note');
flags.refresh(Flag.Text); flags.refresh(Flag.Text);
}); });
}) })
@@ -317,7 +334,7 @@ export class AffineToolbarWidget extends WidgetComponent {
sideOptions$.value = null; sideOptions$.value = null;
flavour$.value = 'affine:note'; flavour$.value = 'affine:note';
placement$.value = 'top'; placement$.value = toolbarRegistry.getModulePlacement('affine:note');
flags.refresh(Flag.Native); flags.refresh(Flag.Native);
}); });
}); });
@@ -338,7 +355,7 @@ export class AffineToolbarWidget extends WidgetComponent {
if (block) { if (block) {
const modelFlavour = block.model.flavour; const modelFlavour = block.model.flavour;
const existed = const existed =
toolbarRegistry.modules.has(modelFlavour) ?? toolbarRegistry.modules.has(modelFlavour) ||
toolbarRegistry.modules.has(`custom:${modelFlavour}`); toolbarRegistry.modules.has(`custom:${modelFlavour}`);
if (existed) { if (existed) {
flavour = modelFlavour; flavour = modelFlavour;
@@ -366,7 +383,10 @@ export class AffineToolbarWidget extends WidgetComponent {
sideOptions$.value = null; sideOptions$.value = null;
flavour$.value = flavour; 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); flags.refresh(Flag.Block);
}); });
}) })
@@ -494,7 +514,7 @@ export class AffineToolbarWidget extends WidgetComponent {
}) })
); );
// Handles elemets when updating // Handles elements when updating
disposables.add( disposables.add(
context.gfx.surface$.subscribe(surface => { context.gfx.surface$.subscribe(surface => {
if (!surface) return; if (!surface) return;
@@ -580,7 +600,7 @@ export class AffineToolbarWidget extends WidgetComponent {
sideOptions$.value = null; sideOptions$.value = null;
flavour$.value = flavour; flavour$.value = flavour;
placement$.value = 'top'; placement$.value = toolbarRegistry.getModulePlacement(flavour);
flags.refresh(Flag.Hovering); 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( disposables.add(
effect(() => { effect(() => {
const value = flags.value$.value; const value = flags.value$.value;

View File

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