feat(editor): edgeless toolbar widget package (#11064)

This commit is contained in:
Saul-Mirone
2025-03-21 11:45:32 +00:00
parent 51d89edb02
commit 6777c16683
25 changed files with 167 additions and 35 deletions

View File

@@ -0,0 +1,41 @@
{
"name": "@blocksuite/affine-widget-edgeless-toolbar",
"description": "Affine edgeless toolbar widget.",
"type": "module",
"scripts": {
"build": "tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.6",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
"rxjs": "^7.8.1"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.20.0"
}

View File

@@ -0,0 +1,192 @@
import type { Placement } from '@floating-ui/dom';
import type { TemplateResult } from 'lit';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { cache } from 'lit/directives/cache.js';
import { styleMap } from 'lit/directives/style-map.js';
export class EdgelessToolIconButton extends LitElement {
static override styles = css`
.icon-container {
position: relative;
display: flex;
align-items: center;
padding: var(--icon-container-padding);
color: var(--affine-icon-color);
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
box-sizing: border-box;
width: var(--icon-container-width, unset);
justify-content: var(--justify, unset);
}
.icon-container.active-mode-color[active] {
color: var(--affine-primary-color);
}
.icon-container.active-mode-background[active] {
background: var(--affine-hover-color);
}
.icon-container[disabled] {
pointer-events: none;
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
.icon-container[coming] {
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
::slotted(svg) {
flex-shrink: 0;
width: var(--icon-size, unset);
height: var(--icon-size, unset);
}
::slotted(.label) {
flex: 1;
padding: 0 4px;
overflow: hidden;
white-space: nowrap;
line-height: var(--label-height, inherit);
}
::slotted(.label.padding0) {
padding: 0;
}
::slotted(.label.ellipsis) {
text-overflow: ellipsis;
}
::slotted(.label.medium) {
font-weight: 500;
}
.icon-container[with-hover]::before {
content: '';
display: block;
background: var(--affine-hover-color);
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
border-radius: 4px;
}
`;
constructor() {
super();
this.addEventListener(
'click',
event => {
if (this.disabled) {
event.stopPropagation();
event.preventDefault();
}
},
{ capture: true }
);
}
override connectedCallback() {
super.connectedCallback();
this.role = 'button';
}
override render() {
const tooltip = this.coming ? '(Coming soon)' : this.tooltip;
const classnames = `icon-container active-mode-${this.activeMode} ${this.hoverState ? 'hovered' : ''}`;
const padding = this.iconContainerPadding;
const iconContainerStyles = styleMap({
'--icon-container-width': this.iconContainerWidth,
'--icon-container-padding': Array.isArray(padding)
? padding.map(v => `${v}px`).join(' ')
: `${padding}px`,
'--icon-size': this.iconSize,
'--justify': this.justify,
'--label-height': this.labelHeight,
});
return html`
<style>
.icon-container:hover,
.icon-container.hovered {
background: ${this.hover ? `var(--affine-hover-color)` : 'inherit'};
}
</style>
<div
class=${classnames}
style=${iconContainerStyles}
?with-hover=${this.withHover}
?disabled=${this.disabled}
?active=${this.active}
>
<slot></slot>
${cache(
this.showTooltip && tooltip
? html`<affine-tooltip
tip-position=${this.tipPosition}
.arrow=${this.arrow}
.offset=${this.tooltipOffset}
>${tooltip}</affine-tooltip
>`
: nothing
)}
</div>
`;
}
@property({ attribute: false })
accessor active = false;
@property({ attribute: false })
accessor activeMode: 'color' | 'background' = 'color';
@property({ attribute: false })
accessor arrow = true;
@property({ attribute: false })
accessor coming = false;
@property({ attribute: false })
accessor disabled = false;
@property({ attribute: false })
accessor hover = true;
@property({ attribute: false })
accessor hoverState = false;
@property({ attribute: false })
accessor iconContainerPadding: number | number[] = 2;
@property({ attribute: false })
accessor iconContainerWidth: string | undefined = undefined;
@property({ attribute: false })
accessor iconSize: string | undefined = undefined;
@property({ attribute: false })
accessor justify: string | undefined = undefined;
@property({ attribute: false })
accessor labelHeight: string | undefined = undefined;
@property({ type: Boolean })
accessor showTooltip = true;
@property({ attribute: false })
accessor tipPosition: Placement = 'top';
@property({ attribute: false })
accessor tooltip!: string | TemplateResult<1>;
@property({ attribute: false })
accessor tooltipOffset = 8;
@property({ attribute: false })
accessor withHover: boolean | undefined = undefined;
}

View File

@@ -0,0 +1,39 @@
import { css, html } from 'lit';
import { EdgelessToolIconButton } from './tool-icon-button.js';
export class EdgelessToolbarButton extends EdgelessToolIconButton {
static override styles = css`
.icon-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--affine-icon-color);
cursor: pointer;
}
.icon-container.active-mode-color[active] {
color: var(--affine-primary-color);
}
.icon-container.active-mode-background[active] {
background: var(--affine-hover-color);
}
.icon-container[disabled] {
pointer-events: none;
cursor: not-allowed;
}
.icon-container[coming] {
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
`;
override render() {
return html` ${super.render()} `;
}
}

View File

@@ -0,0 +1,21 @@
import type { ColorScheme } from '@blocksuite/affine-model';
import { createContext } from '@lit/context';
import type { Subject } from 'rxjs';
import type { EdgelessToolbarWidget } from './edgeless-toolbar.js';
export interface EdgelessToolbarSlots {
resize: Subject<{ w: number; h: number }>;
}
export const edgelessToolbarSlotsContext = createContext<EdgelessToolbarSlots>(
Symbol('edgelessToolbarSlotsContext')
);
export const edgelessToolbarThemeContext = createContext<ColorScheme>(
Symbol('edgelessToolbarThemeContext')
);
export const edgelessToolbarContext = createContext<EdgelessToolbarWidget>(
Symbol('edgelessToolbarContext')
);

View File

@@ -0,0 +1,103 @@
import { BlockSuiteError } from '@blocksuite/global/exceptions';
// more than 100% due to the shadow
const leaveToPercent = `calc(100% + 10px)`;
export interface MenuPopper<T extends HTMLElement> {
element: T;
dispose: () => void;
cancel?: () => void;
}
// store active poppers
const popMap = new WeakMap<HTMLElement, Map<string, MenuPopper<HTMLElement>>>();
function animateEnter(el: HTMLElement) {
el.style.transform = 'translateY(0)';
}
function animateLeave(el: HTMLElement) {
el.style.transform = `translateY(${leaveToPercent})`;
}
export function createPopper<T extends keyof HTMLElementTagNameMap>(
tagName: T,
reference: HTMLElement,
options?: {
/** transition duration in ms */
duration?: number;
onDispose?: () => void;
setProps?: (ele: HTMLElementTagNameMap[T]) => void;
}
): MenuPopper<HTMLElementTagNameMap[T]> {
const duration = options?.duration ?? 230;
if (!popMap.has(reference)) popMap.set(reference, new Map());
const elMap = popMap.get(reference);
// if there is already a popper, cancel leave transition and apply enter transition
if (elMap && elMap.has(tagName)) {
const popper = elMap.get(tagName);
if (popper) {
popper.cancel?.();
requestAnimationFrame(() => animateEnter(popper.element));
return popper as MenuPopper<HTMLElementTagNameMap[T]>;
}
}
const clipWrapper = document.createElement('div');
const menu = document.createElement(tagName);
options?.setProps?.(menu);
clipWrapper.append(menu);
if (!reference.shadowRoot) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.ValueNotExists,
'reference must be a shadow root'
);
}
reference.shadowRoot.append(clipWrapper);
// apply enter transition
menu.style.transition = `all ${duration}ms ease`;
animateLeave(menu);
requestAnimationFrame(() => animateEnter(menu));
Object.assign(clipWrapper.style, {
height: '100px',
pointerEvents: 'none',
position: 'absolute',
overflow: 'hidden',
width: '100%',
maxWidth: '100%',
boxSizing: 'border-box',
left: '0px',
bottom: '100%',
display: 'flex',
alignItems: 'end',
});
Object.assign(menu.style, {
width: '100%',
marginLeft: '30px',
maxWidth: 'calc(100% - 60px)',
bottom: '0%',
pointerEvents: 'auto',
});
const remove = () => {
clipWrapper.remove();
menu.remove();
popMap.get(reference)?.delete(tagName);
options?.onDispose?.();
};
const popper: MenuPopper<HTMLElementTagNameMap[T]> = {
element: menu,
dispose: () => {
// apply leave transition
animateLeave(menu);
menu.addEventListener('transitionend', remove, { once: true });
popper.cancel = () => menu.removeEventListener('transitionend', remove);
},
};
popMap.get(reference)?.set(tagName, popper);
return popper;
}

View File

@@ -0,0 +1,732 @@
/* oxlint-disable @typescript-eslint/no-non-null-assertion */
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import {
type MenuHandler,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import {
darkToolbarStyles,
lightToolbarStyles,
} from '@blocksuite/affine-components/toolbar';
import { ColorScheme, type RootBlockModel } from '@blocksuite/affine-model';
import {
EditPropsStore,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import {
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
MoreHorizontalIcon,
} from '@blocksuite/icons/lit';
import { autoPlacement, offset } from '@floating-ui/dom';
import { ContextProvider } from '@lit/context';
import { computed } from '@preact/signals-core';
import { baseTheme, cssVar } from '@toeverything/theme';
import { css, html, nothing, unsafeCSS } from 'lit';
import { query, state } from 'lit/decorators.js';
import { cache } from 'lit/directives/cache.js';
import { literal, unsafeStatic } from 'lit/static-html.js';
import debounce from 'lodash-es/debounce';
import { Subject } from 'rxjs';
import {
edgelessToolbarContext,
type EdgelessToolbarSlots,
edgelessToolbarSlotsContext,
edgelessToolbarThemeContext,
} from './context.js';
import type { MenuPopper } from './create-popper.js';
import {
QuickToolIdentifier,
SeniorToolIdentifier,
} from './extension/index.js';
const TOOLBAR_PADDING_X = 12;
const TOOLBAR_HEIGHT = 64;
const QUICK_TOOLS_GAP = 10;
const QUICK_TOOL_SIZE = 36;
const QUICK_TOOL_MORE_SIZE = 20;
const SENIOR_TOOLS_GAP = 0;
const SENIOR_TOOL_WIDTH = 96;
const SENIOR_TOOL_NAV_SIZE = 20;
const DIVIDER_WIDTH = 8;
const DIVIDER_SPACE = 8;
const SAFE_AREA_WIDTH = 64;
export const EDGELESS_TOOLBAR_WIDGET = 'edgeless-toolbar-widget';
export class EdgelessToolbarWidget extends WidgetComponent<RootBlockModel> {
static override styles = css`
:host {
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
position: absolute;
z-index: 1;
left: calc(50%);
transform: translateX(-50%);
bottom: 0;
-webkit-user-select: none;
user-select: none;
width: 100%;
pointer-events: none;
}
.edgeless-toolbar-wrapper {
width: 100%;
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'))}
}
.edgeless-toolbar-toggle-control {
pointer-events: auto;
padding-bottom: 16px;
width: fit-content;
max-width: calc(100% - ${unsafeCSS(SAFE_AREA_WIDTH)}px * 2);
min-width: 264px;
}
.edgeless-toolbar-toggle-control[data-enable='true'] {
transition: 0.23s ease;
padding-top: 100px;
transform: translateY(100px);
}
.edgeless-toolbar-toggle-control[data-enable='true']:hover {
padding-top: 0;
transform: translateY(0);
}
.edgeless-toolbar-smooth-corner {
display: block;
width: fit-content;
max-width: 100%;
}
.edgeless-toolbar-container {
position: relative;
display: flex;
align-items: center;
padding: 0 ${unsafeCSS(TOOLBAR_PADDING_X)}px;
height: ${unsafeCSS(TOOLBAR_HEIGHT)}px;
}
:host([disabled]) .edgeless-toolbar-container {
pointer-events: none;
}
.edgeless-toolbar-container[level='second'] {
position: absolute;
bottom: 8px;
transform: translateY(-100%);
}
.edgeless-toolbar-container[hidden] {
display: none;
}
.quick-tools {
display: flex;
align-items: center;
justify-content: center;
gap: ${unsafeCSS(QUICK_TOOLS_GAP)}px;
}
.full-divider {
width: ${unsafeCSS(DIVIDER_WIDTH)}px;
height: 100%;
margin: 0 ${unsafeCSS(DIVIDER_SPACE)}px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.full-divider::after {
content: '';
display: block;
width: 1px;
height: 100%;
background-color: var(--affine-border-color);
}
.brush-and-eraser {
display: flex;
height: 100%;
gap: 4px;
justify-content: center;
}
.senior-tools {
display: flex;
align-items: center;
justify-content: flex-start;
gap: ${unsafeCSS(SENIOR_TOOLS_GAP)}px;
height: 100%;
min-width: ${unsafeCSS(SENIOR_TOOL_WIDTH)}px;
}
.quick-tool-item {
width: ${unsafeCSS(QUICK_TOOL_SIZE)}px;
height: ${unsafeCSS(QUICK_TOOL_SIZE)}px;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
}
.quick-tool-more {
width: 0;
height: ${unsafeCSS(QUICK_TOOL_SIZE)}px;
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.23s ease;
overflow: hidden;
}
[data-dense-quick='true'] .quick-tool-more {
width: ${unsafeCSS(QUICK_TOOL_MORE_SIZE)}px;
margin-left: ${unsafeCSS(DIVIDER_SPACE)}px;
}
.quick-tool-more-button {
padding: 0;
}
.senior-tool-item {
width: ${unsafeCSS(SENIOR_TOOL_WIDTH)}px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
}
.senior-nav-button-wrapper {
flex-shrink: 0;
width: 0px;
height: ${unsafeCSS(SENIOR_TOOL_NAV_SIZE)}px;
transition: width 0.23s ease;
overflow: hidden;
}
.senior-nav-button {
padding: 0;
}
.senior-nav-button svg {
width: 20px;
height: 20px;
}
[data-dense-senior='true'] .senior-nav-button-wrapper {
width: ${unsafeCSS(SENIOR_TOOL_NAV_SIZE)}px;
}
[data-dense-senior='true'] .senior-nav-button-wrapper.prev {
margin-right: ${unsafeCSS(DIVIDER_SPACE)}px;
}
[data-dense-senior='true'] .senior-nav-button-wrapper.next {
margin-left: ${unsafeCSS(DIVIDER_SPACE)}px;
}
.transform-button svg {
transition: 0.3s ease-in-out;
}
.transform-button:hover svg {
transform: scale(1.15);
}
`;
private readonly _appTheme$ = computed(() => {
return this.std.get(ThemeProvider).app$.value;
});
private _moreQuickToolsMenu: MenuHandler | null = null;
private _moreQuickToolsMenuRef: HTMLElement | null = null;
@state()
accessor containerWidth = 1920;
private readonly _onContainerResize = debounce(
({ w }: { w: number }) => {
if (!this.isConnected) return;
this.slots.resize.next({ w, h: TOOLBAR_HEIGHT });
this.containerWidth = w;
if (this._denseSeniorTools) {
this.scrollSeniorToolIndex = Math.min(
this._seniorTools.length - this.scrollSeniorToolSize,
this.scrollSeniorToolIndex
);
} else {
this.scrollSeniorToolIndex = 0;
}
if (
this._denseQuickTools &&
this._moreQuickToolsMenu &&
this._moreQuickToolsMenuRef
) {
this._moreQuickToolsMenu.close();
this._openMoreQuickToolsMenu({
currentTarget: this._moreQuickToolsMenuRef,
});
}
if (!this._denseQuickTools && this._moreQuickToolsMenu) {
this._moreQuickToolsMenu.close();
this._moreQuickToolsMenu = null;
}
},
300,
{ leading: true }
);
private _resizeObserver: ResizeObserver | null = null;
private readonly _slotsProvider = new ContextProvider(this, {
context: edgelessToolbarSlotsContext,
initialValue: { resize: new Subject() } satisfies EdgelessToolbarSlots,
});
private readonly _themeProvider = new ContextProvider(this, {
context: edgelessToolbarThemeContext,
initialValue: ColorScheme.Light,
});
private readonly _toolbarProvider = new ContextProvider(this, {
context: edgelessToolbarContext,
initialValue: this,
});
activePopper: MenuPopper<HTMLElement> | null = null;
// calculate all the width manually
private get _availableWidth() {
return this.containerWidth - 2 * SAFE_AREA_WIDTH;
}
private get _cachedPresentHideToolbar() {
return !!this.std.get(EditPropsStore).getStorage('presentHideToolbar');
}
private get _denseQuickTools() {
return (
this._availableWidth -
this._seniorToolNavWidth -
1 * SENIOR_TOOL_WIDTH -
2 * TOOLBAR_PADDING_X <
this._quickToolsWidthTotal
);
}
private get _denseSeniorTools() {
return (
this._availableWidth -
this._quickToolsWidthTotal -
this._spaceWidthTotal <
this._seniorToolsWidthTotal
);
}
/**
* When enabled, the toolbar will auto-hide when the mouse is not over it.
*/
private get _enableAutoHide() {
return (
this.isPresentMode &&
this._cachedPresentHideToolbar &&
!this.presentSettingMenuShow &&
!this.presentFrameMenuShow
);
}
private get _hiddenQuickTools() {
return this._quickTools
.slice(this._visibleQuickToolSize)
.filter(tool => !!tool.menu);
}
private get _quickTools() {
const block = this.block;
if (!block) {
return [];
}
const quickTools = Array.from(
this.std.provider.getAll(QuickToolIdentifier).values()
);
const gfx = this.std.get(GfxControllerIdentifier);
return quickTools
.map(tool =>
tool({ block, gfx, toolbarContainer: this.toolbarContainer })
)
.filter(({ enable = true }) => enable);
}
private get _quickToolsWidthTotal() {
return (
this._quickTools.length * (QUICK_TOOL_SIZE + QUICK_TOOLS_GAP) -
QUICK_TOOLS_GAP
);
}
private get _seniorNextTooltip() {
if (this._seniorScrollNextDisabled) return '';
const nextTool =
this._seniorTools[this.scrollSeniorToolIndex + this.scrollSeniorToolSize];
return nextTool?.name ?? '';
}
private get _seniorPrevTooltip() {
if (this._seniorScrollPrevDisabled) return '';
const prevTool = this._seniorTools[this.scrollSeniorToolIndex - 1];
return prevTool?.name ?? '';
}
private get _seniorScrollNextDisabled() {
return (
this.scrollSeniorToolIndex + this.scrollSeniorToolSize >=
this._seniorTools.length
);
}
private get _seniorScrollPrevDisabled() {
return this.scrollSeniorToolIndex === 0;
}
private get _seniorToolNavWidth() {
return this._denseSeniorTools
? (SENIOR_TOOL_NAV_SIZE + DIVIDER_SPACE) * 2
: 0;
}
private get _seniorTools() {
const block = this.block;
if (!block) {
return [];
}
const seniorTools = Array.from(
this.std.provider.getAll(SeniorToolIdentifier).values()
);
const gfx = this.std.get(GfxControllerIdentifier);
return seniorTools
.map(tool =>
tool({ block, gfx, toolbarContainer: this.toolbarContainer })
)
.filter(({ enable = true }) => enable);
}
private get _seniorToolsWidthTotal() {
return (
this._seniorTools.length * (SENIOR_TOOL_WIDTH + SENIOR_TOOLS_GAP) -
SENIOR_TOOLS_GAP
);
}
private get _spaceWidthTotal() {
return DIVIDER_WIDTH + DIVIDER_SPACE * 2 + TOOLBAR_PADDING_X * 2;
}
private get _visibleQuickToolSize() {
if (!this._denseQuickTools) return this._quickTools.length;
const availableWidth =
this._availableWidth -
this._seniorToolNavWidth -
this._spaceWidthTotal -
SENIOR_TOOL_WIDTH;
return Math.max(
1,
Math.floor(
(availableWidth - QUICK_TOOL_MORE_SIZE - DIVIDER_SPACE) /
(QUICK_TOOL_SIZE + QUICK_TOOLS_GAP)
)
);
}
get edgelessTool() {
// FIXME: maybe we need to fix this type
return this.gfx.tool.currentToolOption$.value as { type: string };
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get isPresentMode() {
return this.edgelessTool.type === 'frameNavigator';
}
get scrollSeniorToolSize() {
if (this._denseQuickTools) return 1;
const seniorAvailableWidth =
this._availableWidth - this._quickToolsWidthTotal - this._spaceWidthTotal;
if (seniorAvailableWidth >= this._seniorToolsWidthTotal)
return this._seniorTools.length;
return (
Math.floor(
(seniorAvailableWidth - (SENIOR_TOOL_NAV_SIZE + DIVIDER_SPACE) * 2) /
SENIOR_TOOL_WIDTH
) || 1
);
}
get slots() {
return this._slotsProvider.value;
}
constructor() {
super();
}
private _onSeniorNavNext() {
if (this._seniorScrollNextDisabled) return;
this.scrollSeniorToolIndex = Math.min(
this._seniorTools.length - this.scrollSeniorToolSize,
this.scrollSeniorToolIndex + this.scrollSeniorToolSize
);
}
private _onSeniorNavPrev() {
if (this._seniorScrollPrevDisabled) return;
this.scrollSeniorToolIndex = Math.max(
0,
this.scrollSeniorToolIndex - this.scrollSeniorToolSize
);
}
private _openMoreQuickToolsMenu(e: { currentTarget: HTMLElement }) {
if (!this._hiddenQuickTools.length) return;
this._moreQuickToolsMenuRef = e.currentTarget;
this._moreQuickToolsMenu = popMenu(
popupTargetFromElement(e.currentTarget as HTMLElement),
{
middleware: [
autoPlacement({
allowedPlacements: ['top'],
}),
offset({
mainAxis: (TOOLBAR_HEIGHT - QUICK_TOOL_MORE_SIZE) / 2 + 8,
}),
],
options: {
onClose: () => {
this._moreQuickToolsMenu = null;
this._moreQuickToolsMenuRef = null;
},
items: this._hiddenQuickTools.map(tool => tool.menu!),
},
}
);
}
private _renderContent() {
return html`
<div class="quick-tools">
${this._quickTools
.slice(0, this._visibleQuickToolSize)
.map(
tool => html`<div class="quick-tool-item">${tool.content}</div>`
)}
</div>
<div class="quick-tool-more">
<icon-button
?disabled=${!this._denseQuickTools}
.size=${20}
class="quick-tool-more-button"
@click=${this._openMoreQuickToolsMenu}
?active=${this._quickTools
.slice(this._visibleQuickToolSize)
.some(tool => tool.type === this.edgelessTool?.type)}
>
${MoreHorizontalIcon({ width: '20px', height: '20px' })}
<affine-tooltip tip-position="top" .offset=${25}>
More Tools
</affine-tooltip>
</icon-button>
</div>
<div class="full-divider"></div>
<div class="senior-nav-button-wrapper prev">
<icon-button
.size=${20}
class="senior-nav-button"
?disabled=${this._seniorScrollPrevDisabled}
@click=${this._onSeniorNavPrev}
>
${ArrowLeftSmallIcon({ width: '20px', height: '20px' })}
${cache(
this._seniorPrevTooltip
? html` <affine-tooltip tip-position="top" .offset=${4}>
${this._seniorPrevTooltip}
</affine-tooltip>`
: nothing
)}
</icon-button>
</div>
<div class="senior-tools">
${this._seniorTools
.slice(
this.scrollSeniorToolIndex,
this.scrollSeniorToolIndex + this.scrollSeniorToolSize
)
.map(
tool => html`<div class="senior-tool-item">${tool.content}</div>`
)}
</div>
<div class="senior-nav-button-wrapper next">
<icon-button
.size=${20}
class="senior-nav-button"
?disabled=${this._seniorScrollNextDisabled}
@click=${this._onSeniorNavNext}
>
${ArrowRightSmallIcon({ width: '20px', height: '20px' })}
${cache(
this._seniorNextTooltip
? html` <affine-tooltip tip-position="top" .offset=${4}>
${this._seniorNextTooltip}
</affine-tooltip>`
: nothing
)}
</icon-button>
</div>
`;
}
override connectedCallback() {
super.connectedCallback();
this._toolbarProvider.setValue(this);
this._resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
const { width } = entry.contentRect;
this._onContainerResize({ w: width });
}
});
this._resizeObserver.observe(this);
this.disposables.add(
this.std
.get(ThemeProvider)
.theme$.subscribe(mode => this._themeProvider.setValue(mode))
);
if (!this.block) {
return;
}
this._disposables.add(
this.block.bindHotKey(
{
Escape: () => {
if (this.gfx.selection.editing) return;
if (this.edgelessTool.type === 'frameNavigator') return;
if (this.edgelessTool.type === 'default') {
if (this.activePopper) {
this.activePopper.dispose();
this.activePopper = null;
}
return;
}
// @ts-expect-error FIXME: resolve after gfx tool refactor
this.gfx.tool.setTool('default');
},
},
{ global: true }
)
);
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
}
override firstUpdated() {
const { _disposables, block, gfx } = this;
if (!block) return;
const slots = this.std.get(EdgelessLegacySlotIdentifier);
const editPropsStore = this.std.get(EditPropsStore);
_disposables.add(
gfx.viewport.viewportUpdated.subscribe(() => this.requestUpdate())
);
_disposables.add(
slots.readonlyUpdated.subscribe(() => {
this.requestUpdate();
})
);
_disposables.add(
slots.toolbarLocked.subscribe(disabled => {
this.toggleAttribute('disabled', disabled);
})
);
// This state from `editPropsStore` is not reactive,
// if the value is updated outside of this component, it will not be reflected.
_disposables.add(
editPropsStore.slots.storageUpdated.subscribe(({ key }) => {
if (key === 'presentHideToolbar') {
this.requestUpdate();
}
})
);
}
override render() {
const { type } = this.edgelessTool || {};
if (this.doc.readonly && type !== 'frameNavigator') {
return nothing;
}
return html`
<div
class="edgeless-toolbar-wrapper"
data-app-theme=${this._appTheme$.value}
>
<div
class="edgeless-toolbar-toggle-control"
data-enable=${this._enableAutoHide}
>
<smooth-corner
class="edgeless-toolbar-smooth-corner"
.borderRadius=${16}
.smooth=${0.7}
.borderWidth=${1}
.bgColor=${'var(--affine-background-overlay-panel-color)'}
.borderColor=${'var(--affine-border-color)'}
style="filter: drop-shadow(${cssVar('toolbarShadow')})"
>
<div
class="edgeless-toolbar-container"
data-dense-quick=${this._denseQuickTools &&
this._hiddenQuickTools.length > 0}
data-dense-senior=${this._denseSeniorTools}
@dblclick=${stopPropagation}
@mousedown=${stopPropagation}
@pointerdown=${stopPropagation}
>
${this.isPresentMode
? html`<presentation-toolbar
.edgeless=${this.block}
.settingMenuShow=${this.presentSettingMenuShow}
.frameMenuShow=${this.presentFrameMenuShow}
.setSettingMenuShow=${(show: boolean) =>
(this.presentSettingMenuShow = show)}
.setFrameMenuShow=${(show: boolean) =>
(this.presentFrameMenuShow = show)}
.containerWidth=${this.containerWidth}
></presentation-toolbar>`
: nothing}
${this.isPresentMode ? nothing : this._renderContent()}
</div>
</smooth-corner>
</div>
</div>
`;
}
@state()
accessor presentFrameMenuShow = false;
@state()
accessor presentSettingMenuShow = false;
@state()
accessor scrollSeniorToolIndex = 0;
@query('.edgeless-toolbar-container')
accessor toolbarContainer!: HTMLElement;
}
export const edgelessToolbarWidget = WidgetViewExtension(
'affine:page',
EDGELESS_TOOLBAR_WIDGET,
literal`${unsafeStatic(EDGELESS_TOOLBAR_WIDGET)}`
);

View File

@@ -0,0 +1,20 @@
import { EdgelessToolIconButton } from './button/tool-icon-button';
import { EdgelessToolbarButton } from './button/toolbar-button';
import {
EDGELESS_TOOLBAR_WIDGET,
EdgelessToolbarWidget,
} from './edgeless-toolbar';
export function effects() {
customElements.define(EDGELESS_TOOLBAR_WIDGET, EdgelessToolbarWidget);
customElements.define('edgeless-toolbar-button', EdgelessToolbarButton);
customElements.define('edgeless-tool-icon-button', EdgelessToolIconButton);
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-tool-icon-button': EdgelessToolIconButton;
'edgeless-toolbar-button': EdgelessToolbarButton;
'edgeless-toolbar-widget': EdgelessToolbarWidget;
}
}

View File

@@ -0,0 +1,60 @@
import type { MenuConfig } from '@blocksuite/affine-components/context-menu';
import type { BlockComponent } from '@blocksuite/block-std';
import type { GfxController, GfxToolsMap } from '@blocksuite/block-std/gfx';
import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import { type TemplateResult } from 'lit';
export interface QuickTool {
type?: keyof GfxToolsMap;
enable?: boolean;
content: TemplateResult;
/**
* if not configured, the tool will not be shown in dense mode
*/
menu?: MenuConfig;
}
export interface SeniorTool {
/**
* Used to show in nav-button's tooltip
*/
name: string;
content: TemplateResult;
enable?: boolean;
}
export type ToolBuilder<T> = (options: {
block: BlockComponent;
gfx: GfxController;
toolbarContainer: HTMLElement;
}) => T;
export const QuickToolIdentifier = createIdentifier<ToolBuilder<QuickTool>>(
'edgeless-quick-tool'
);
export const SeniorToolIdentifier = createIdentifier<ToolBuilder<SeniorTool>>(
'edgeless-senior-tool'
);
export const QuickToolExtension = (
id: string,
builder: ToolBuilder<QuickTool>
): ExtensionType => {
return {
setup: di => {
di.addImpl(QuickToolIdentifier(id), () => builder);
},
};
};
export const SeniorToolExtension = (
id: string,
builder: ToolBuilder<SeniorTool>
): ExtensionType => {
return {
setup: di => {
di.addImpl(SeniorToolIdentifier(id), () => builder);
},
};
};

View File

@@ -0,0 +1,4 @@
export * from './context';
export * from './create-popper';
export * from './edgeless-toolbar';
export * from './extension';

View File

@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/block-surface" },
{ "path": "../../components" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },
{ "path": "../../../framework/block-std" },
{ "path": "../../../framework/global" }
]
}