feat(editor): edgeless zoom toolbar widget extension (#12037)

Closes: BS-3363
This commit is contained in:
Saul-Mirone
2025-04-28 14:38:26 +00:00
parent 4e201ede17
commit 468db9f3eb
18 changed files with 150 additions and 27 deletions
@@ -9,7 +9,6 @@ import type { ExtensionType } from '@blocksuite/store';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { CommonSpecs } from '../common-specs/index.js';
import { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from '../widgets/edgeless-zoom-toolbar/index.js';
import { EdgelessClipboardController } from './clipboard/clipboard.js';
import { NOTE_SLICER_WIDGET } from './components/note-slicer/index.js';
import { EDGELESS_DRAGGING_AREA_WIDGET } from './components/rects/edgeless-dragging-area-rect.js';
@@ -17,11 +16,6 @@ import { EDGELESS_SELECTED_RECT_WIDGET } from './components/rects/edgeless-selec
import { quickTools } from './components/toolbar/tools.js';
import { EdgelessRootService } from './edgeless-root-service.js';
export const edgelessZoomToolbarWidget = WidgetViewExtension(
'affine:page',
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
literal`${unsafeStatic(AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET)}`
);
export const edgelessDraggingAreaWidget = WidgetViewExtension(
'affine:page',
EDGELESS_DRAGGING_AREA_WIDGET,
@@ -57,7 +51,6 @@ const EdgelessCommonExtension: ExtensionType[] = [
export const EdgelessRootBlockSpec: ExtensionType[] = [
...EdgelessCommonExtension,
BlockViewExtension('affine:page', literal`affine-edgeless-root`),
edgelessZoomToolbarWidget,
edgelessDraggingAreaWidget,
noteSlicerWidget,
edgelessSelectedRectWidget,
@@ -21,12 +21,6 @@ import {
PageRootBlockComponent,
PreviewRootBlockComponent,
} from './index.js';
import {
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
AffineEdgelessZoomToolbarWidget,
} from './widgets/edgeless-zoom-toolbar/index.js';
import { ZoomBarToggleButton } from './widgets/edgeless-zoom-toolbar/zoom-bar-toggle-button.js';
import { EdgelessZoomToolbar } from './widgets/edgeless-zoom-toolbar/zoom-toolbar.js';
import {
AFFINE_PAGE_DRAGGING_AREA_WIDGET,
AffinePageDraggingAreaWidget,
@@ -55,10 +49,6 @@ function registerWidgets() {
AFFINE_PAGE_DRAGGING_AREA_WIDGET,
AffinePageDraggingAreaWidget
);
customElements.define(
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
AffineEdgelessZoomToolbarWidget
);
}
function registerEdgelessToolbarComponents() {
@@ -73,10 +63,6 @@ function registerEdgelessToolbarComponents() {
}
function registerMiscComponents() {
// Toolbar and UI components
customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar);
customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton);
// Auto-complete components
customElements.define(
'edgeless-auto-complete-panel',
@@ -111,9 +97,5 @@ declare global {
'toolbar-arrow-up-icon': ToolbarArrowUpIcon;
'edgeless-link-tool-button': EdgelessLinkToolButton;
'affine-page-root': PageRootBlockComponent;
'zoom-bar-toggle-button': ZoomBarToggleButton;
'edgeless-zoom-toolbar': EdgelessZoomToolbar;
[AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET]: AffineEdgelessZoomToolbarWidget;
}
}
@@ -1,87 +0,0 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { WidgetComponent } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { state } from 'lit/decorators.js';
export const AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET =
'affine-edgeless-zoom-toolbar-widget';
export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<RootBlockModel> {
static override styles = css`
:host {
position: absolute;
bottom: 20px;
left: 12px;
z-index: var(--affine-z-index-popover);
display: flex;
justify-content: center;
-webkit-user-select: none;
user-select: none;
}
@container viewport (width <= 1200px) {
edgeless-zoom-toolbar {
display: none;
}
}
@container viewport (width > 1200px) {
zoom-bar-toggle-button {
display: none;
}
}
`;
get edgeless() {
return this.block;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
effect(() => {
const currentTool = this.gfx.tool.currentToolName$.value;
if (currentTool !== 'frameNavigator') {
this._hide = false;
}
this.requestUpdate();
})
);
}
override firstUpdated() {
const { disposables, std } = this;
const slots = std.get(EdgelessLegacySlotIdentifier);
disposables.add(
slots.navigatorSettingUpdated.subscribe(({ hideToolbar }) => {
if (hideToolbar !== undefined) {
this._hide = hideToolbar;
}
})
);
}
override render() {
if (this._hide || !this.edgeless) {
return nothing;
}
return html`
<edgeless-zoom-toolbar .std=${this.std}></edgeless-zoom-toolbar>
<zoom-bar-toggle-button .std=${this.std}></zoom-bar-toggle-button>
`;
}
@state()
private accessor _hide = false;
}
@@ -1,113 +0,0 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
import type { BlockStdScope } from '@blocksuite/std';
import { offset } from '@floating-ui/dom';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
export class ZoomBarToggleButton extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
}
.toggle-button {
display: flex;
position: relative;
}
edgeless-zoom-toolbar {
position: absolute;
bottom: initial;
}
`;
private _abortController: AbortController | null = null;
private _closeZoomMenu() {
if (this._abortController && !this._abortController.signal.aborted) {
this._abortController.abort();
this._abortController = null;
this._showPopper = false;
}
}
private get _slots() {
return this.std.get(EdgelessLegacySlotIdentifier);
}
private _toggleZoomMenu() {
if (this._abortController && !this._abortController.signal.aborted) {
this._closeZoomMenu();
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this._showPopper = false;
});
createLitPortal({
template: html`<edgeless-zoom-toolbar
.std=${this.std}
.layout=${'vertical'}
></edgeless-zoom-toolbar>`,
container: this._toggleButton,
computePosition: {
referenceElement: this._toggleButton,
placement: 'top',
middleware: [offset(4)],
autoUpdate: true,
},
abortController: this._abortController,
closeOnClickAway: true,
});
this._showPopper = true;
}
override disconnectedCallback() {
super.disconnectedCallback();
this._closeZoomMenu();
}
override firstUpdated() {
const { disposables } = this;
disposables.add(
this._slots.readonlyUpdated.subscribe(() => {
this.requestUpdate();
})
);
}
override render() {
if (this.std.store.readonly) {
return nothing;
}
return html`
<div class="toggle-button" @pointerdown=${stopPropagation}>
<edgeless-tool-icon-button
.tooltip=${'Toggle Zoom Tool Bar'}
.tipPosition=${'right'}
.active=${this._showPopper}
.arrow=${false}
.activeMode=${'background'}
.iconContainerPadding=${6}
.iconSize=${'24px'}
@click=${() => this._toggleZoomMenu()}
>
${MoreHorizontalIcon()}
</edgeless-tool-icon-button>
</div>
`;
}
@state()
private accessor _showPopper = false;
@query('.toggle-button')
private accessor _toggleButton!: HTMLElement;
@property({ attribute: false })
accessor std!: BlockStdScope;
}
@@ -1,213 +0,0 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { MinusIcon, PlusIcon, ViewBarIcon } from '@blocksuite/icons/lit';
import type { BlockStdScope } from '@blocksuite/std';
import {
GfxControllerIdentifier,
ZOOM_MAX,
ZOOM_MIN,
ZOOM_STEP,
} from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import clamp from 'lodash-es/clamp';
export class EdgelessZoomToolbar extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
}
.edgeless-zoom-toolbar-container {
display: flex;
align-items: center;
background: transparent;
border-radius: 8px;
fill: currentcolor;
padding: 4px;
}
.edgeless-zoom-toolbar-container.horizantal {
flex-direction: row;
}
.edgeless-zoom-toolbar-container.vertical {
flex-direction: column;
width: 40px;
background-color: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
border: 1px solid var(--affine-border-color);
border-radius: 8px;
}
.edgeless-zoom-toolbar-container[level='second'] {
position: absolute;
bottom: 8px;
transform: translateY(-100%);
}
.edgeless-zoom-toolbar-container[hidden] {
display: none;
}
.zoom-percent {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 32px;
border: none;
box-sizing: border-box;
padding: 4px;
color: var(--affine-icon-color);
background-color: transparent;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
font-size: 12px;
font-weight: 500;
text-align: center;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.zoom-percent:hover {
color: var(--affine-primary-color);
background-color: var(--affine-hover-color);
}
.zoom-percent[disabled] {
pointer-events: none;
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
`;
get slots() {
return this.std.get(EdgelessLegacySlotIdentifier);
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get edgelessTool() {
return this.gfx.tool.currentToolOption$.peek();
}
get locked() {
return this.viewport.locked;
}
get viewport() {
return this.gfx.viewport;
}
setZoomByStep = (step: number) => {
this.viewport.smoothZoom(clamp(this.zoom + step, ZOOM_MIN, ZOOM_MAX));
};
get zoom() {
if (!this.viewport) {
console.error('Something went wrong, viewport is not available');
return 1;
}
return this.viewport.zoom;
}
private _isVerticalBar() {
return this.layout === 'vertical';
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
effect(() => {
this.gfx.tool.currentToolName$.value;
this.requestUpdate();
})
);
}
override firstUpdated() {
const { disposables } = this;
disposables.add(
this.viewport.viewportUpdated.subscribe(() => this.requestUpdate())
);
disposables.add(
this.slots.readonlyUpdated.subscribe(() => {
this.requestUpdate();
})
);
}
override render() {
if (this.std.store.readonly) {
return nothing;
}
const formattedZoom = `${Math.round(this.zoom * 100)}%`;
const classes = `edgeless-zoom-toolbar-container ${this.layout}`;
const locked = this.locked;
return html`
<div
class=${classes}
@dblclick=${stopPropagation}
@mousedown=${stopPropagation}
@mouseup=${stopPropagation}
@pointerdown=${stopPropagation}
>
<edgeless-tool-icon-button
.tooltip=${'Fit to screen'}
.tipPosition=${this._isVerticalBar() ? 'right' : 'top-end'}
.arrow=${!this._isVerticalBar()}
@click=${() => this.gfx.fitToScreen()}
.iconContainerPadding=${4}
.iconSize=${'24px'}
.disabled=${locked}
>
${ViewBarIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.tooltip=${'Zoom out'}
.tipPosition=${this._isVerticalBar() ? 'right' : 'top'}
.arrow=${!this._isVerticalBar()}
@click=${() => this.setZoomByStep(-ZOOM_STEP)}
.iconContainerPadding=${4}
.iconSize=${'24px'}
.disabled=${locked}
>
${MinusIcon()}
</edgeless-tool-icon-button>
<button
class="zoom-percent"
@click=${() => this.viewport.smoothZoom(1)}
.disabled=${locked}
>
${formattedZoom}
</button>
<edgeless-tool-icon-button
.tooltip=${'Zoom in'}
.tipPosition=${this._isVerticalBar() ? 'right' : 'top'}
.arrow=${!this._isVerticalBar()}
@click=${() => this.setZoomByStep(ZOOM_STEP)}
.iconContainerPadding=${4}
.iconSize=${'24px'}
.disabled=${locked}
>
${PlusIcon()}
</edgeless-tool-icon-button>
</div>
`;
}
@property({ attribute: false })
accessor layout: 'horizontal' | 'vertical' = 'horizontal';
@property({ attribute: false })
accessor std!: BlockStdScope;
}
@@ -1,2 +1 @@
export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js';
export { AffinePageDraggingAreaWidget } from './page-dragging-area/page-dragging-area.js';