feat(editor): gfx link extension (#12046)

Closes: BS-3368

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Introduced a new link tool extension, enabling enhanced link-related functionality within the edgeless workspace.
  - Added a new view extension for link tools, improving integration and usability in edgeless mode.

- **Chores**
  - Added a new package for link tool functionality with appropriate dependencies and exports.
  - Registered new custom elements for edgeless toolbars and link tools to support modular UI components.
  - Updated project configurations and workspace dependencies to include the new link tool module.

- **Refactor**
  - Removed unused quick tool exports and toolbar component registrations to streamline the edgeless extension codebase.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Saul-Mirone
2025-04-29 03:19:37 +00:00
parent be28038e94
commit 4c84e6bac7
21 changed files with 181 additions and 28 deletions

View File

@@ -1,183 +0,0 @@
import {
type EdgelessToolbarSlots,
edgelessToolbarSlotsContext,
} from '@blocksuite/affine-widget-edgeless-toolbar';
import { WithDisposable } from '@blocksuite/global/lit';
import { ArrowRightSmallIcon } from '@blocksuite/icons/lit';
import { consume } from '@lit/context';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
export class EdgelessSlideMenu extends WithDisposable(LitElement) {
static override styles = css`
:host {
max-width: 100%;
}
::-webkit-scrollbar {
display: none;
}
.slide-menu-wrapper {
position: relative;
}
.menu-container {
background: var(--affine-background-overlay-panel-color);
border-radius: 8px 8px 0 0;
border: 1px solid var(--affine-border-color);
border-bottom: none;
display: flex;
align-items: center;
width: fit-content;
max-width: 100%;
position: relative;
height: calc(var(--menu-height) + 1px);
box-sizing: border-box;
padding-left: 10px;
scroll-snap-type: x mandatory;
}
.menu-container-scrollable {
overflow-x: auto;
overscroll-behavior: none;
scrollbar-width: none;
height: 100%;
padding-right: 10px;
}
.slide-menu-content {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
transition: left 0.5s ease-in-out;
width: fit-content;
}
.next-slide-button,
.previous-slide-button {
align-items: center;
justify-content: center;
position: absolute;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--affine-border-color);
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
color: var(--affine-icon-color);
transition:
transform 0.3s ease-in-out,
opacity 0.5s ease-in-out;
z-index: 12;
}
.next-slide-button {
opacity: 0;
display: flex;
top: 50%;
right: 0;
transform: translate(50%, -50%) scale(0.5);
}
.next-slide-button:hover {
cursor: pointer;
transform: translate(50%, -50%) scale(1);
}
.previous-slide-button {
opacity: 0;
top: 50%;
left: 0;
transform: translate(-50%, -50%) scale(0.5);
}
.previous-slide-button:hover {
cursor: pointer;
transform: translate(-50%, -50%) scale(1);
}
.previous-slide-button svg {
transform: rotate(180deg);
}
`;
private _handleSlideButtonClick(direction: 'left' | 'right') {
const totalWidth = this._slideMenuContent.clientWidth;
const currentScrollLeft = this._menuContainer.scrollLeft;
const menuWidth = this._menuContainer.clientWidth;
const newLeft =
currentScrollLeft + (direction === 'left' ? -menuWidth : menuWidth);
this._menuContainer.scrollTo({
left: Math.max(0, Math.min(newLeft, totalWidth)),
behavior: 'smooth',
});
}
private _handleWheel(event: WheelEvent) {
event.stopPropagation();
}
private _toggleSlideButton() {
const scrollLeft = this._menuContainer.scrollLeft;
const menuWidth = this._menuContainer.clientWidth;
const leftMin = 0;
const leftMax = this._slideMenuContent.clientWidth - menuWidth;
this.showPrevious = scrollLeft > leftMin;
this.showNext = scrollLeft < leftMax;
}
override firstUpdated() {
setTimeout(this._toggleSlideButton.bind(this), 0);
this._disposables.addFromEvent(this._menuContainer, 'scrollend', () => {
this._toggleSlideButton();
});
this._disposables.add(
this.toolbarSlots.resize.subscribe(() => this._toggleSlideButton())
);
}
override render() {
const iconSize = { width: '32px', height: '32px' };
return html`
<div class="slide-menu-wrapper">
<div
class="previous-slide-button"
@click=${() => this._handleSlideButtonClick('left')}
style=${styleMap({ opacity: this.showPrevious ? '1' : '0' })}
>
${ArrowRightSmallIcon(iconSize)}
</div>
<div
class="menu-container"
style=${styleMap({ '--menu-height': this.height })}
>
<slot name="prefix"></slot>
<div class="menu-container-scrollable">
<div class="slide-menu-content" @wheel=${this._handleWheel}>
<slot></slot>
</div>
</div>
</div>
<div
style=${styleMap({ opacity: this.showNext ? '1' : '0' })}
class="next-slide-button"
@click=${() => this._handleSlideButtonClick('right')}
>
${ArrowRightSmallIcon(iconSize)}
</div>
</div>
`;
}
@query('.menu-container-scrollable')
private accessor _menuContainer!: HTMLDivElement;
@query('.slide-menu-content')
private accessor _slideMenuContent!: HTMLDivElement;
@property({ attribute: false })
accessor height = '40px';
@property({ attribute: false })
accessor showNext = false;
@property({ attribute: false })
accessor showPrevious = false;
@consume({ context: edgelessToolbarSlotsContext })
accessor toolbarSlots!: EdgelessToolbarSlots;
}

View File

@@ -1,17 +0,0 @@
import { ArrowUpSmallIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html } from 'lit';
export class ToolbarArrowUpIcon extends ShadowlessElement {
static override styles = css`
.arrow-up-icon {
position: absolute;
top: -2px;
right: -2px;
}
`;
override render() {
return html`<span class="arrow-up-icon"> ${ArrowUpSmallIcon()} </span>`;
}
}

View File

@@ -1,32 +0,0 @@
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
import { menu } from '@blocksuite/affine-components/context-menu';
import { LinkIcon } from '@blocksuite/affine-components/icons';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type { DenseMenuBuilder } from '@blocksuite/affine-widget-edgeless-toolbar';
export const buildLinkDenseMenu: DenseMenuBuilder = edgeless =>
menu.action({
name: 'Link',
prefix: LinkIcon,
select: () => {
const [_, { insertedLinkType }] = edgeless.std.command.exec(
insertLinkByQuickSearchCommand
);
insertedLinkType
?.then(type => {
const flavour = type?.flavour;
if (!flavour) return;
edgeless.std
.getOptional(TelemetryProvider)
?.track('CanvasElementAdded', {
control: 'toolbar:general',
page: 'whiteboard editor',
module: 'toolbar',
type: flavour.split(':')[1],
});
})
.catch(console.error);
},
});

View File

@@ -1,88 +0,0 @@
import { insertLinkByQuickSearchCommand } from '@blocksuite/affine-block-bookmark';
import { insertEmbedCard } from '@blocksuite/affine-block-embed';
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
import { LinkIcon } from '@blocksuite/affine-components/icons';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import { QuickToolMixin } from '@blocksuite/affine-widget-edgeless-toolbar';
import { css, html, LitElement } from 'lit';
export class EdgelessLinkToolButton extends QuickToolMixin(LitElement) {
static override styles = css`
.link-icon,
.link-icon > svg {
width: 24px;
height: 24px;
}
`;
override type = 'default' as const;
private _onClick() {
const [success, { insertedLinkType }] = this.edgeless.std.command.exec(
insertLinkByQuickSearchCommand
);
if (!success) {
// fallback to create a bookmark block with input modal
toggleEmbedCardCreateModal(
this.edgeless.host,
'Links',
'The added link will be displayed as a card view.',
{
mode: 'edgeless',
onSave: url => {
insertEmbedCard(this.edgeless.std, {
flavour: 'affine:bookmark',
targetStyle: 'vertical',
props: { url },
});
},
}
).catch(console.error);
return;
}
insertedLinkType
?.then(type => {
const flavour = type?.flavour;
if (!flavour) return;
this.edgeless.std
.getOptional(TelemetryProvider)
?.track('CanvasElementAdded', {
control: 'toolbar:general',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: flavour.split(':')[1],
});
this.edgeless.std
.getOptional(TelemetryProvider)
?.track('LinkedDocCreated', {
control: 'links',
page: 'whiteboard editor',
module: 'edgeless toolbar',
segment: 'whiteboard',
type: flavour.split(':')[1],
other: 'existing doc',
});
})
.catch(console.error);
}
override render() {
return html`<edgeless-tool-icon-button
.iconContainerPadding="${6}"
.tooltip="${html`<affine-tooltip-content-with-shortcut
data-tip="${'Link'}"
data-shortcut="${'@'}"
></affine-tooltip-content-with-shortcut>`}"
.tooltipOffset=${17}
class="edgeless-link-tool-button"
@click=${this._onClick}
>
<span class="link-icon">${LinkIcon}</span>
</edgeless-tool-icon-button>`;
}
}

View File

@@ -1,15 +0,0 @@
import { QuickToolExtension } from '@blocksuite/affine-widget-edgeless-toolbar';
import { html } from 'lit';
import { buildLinkDenseMenu } from './link/link-dense-menu.js';
const linkQuickTool = QuickToolExtension('link', ({ block, gfx }) => {
return {
content: html`<edgeless-link-tool-button
.edgeless=${block}
></edgeless-link-tool-button>`,
menu: buildLinkDenseMenu(block, gfx),
};
});
export const quickTools = [linkQuickTool];

View File

@@ -13,7 +13,6 @@ 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';
import { EDGELESS_SELECTED_RECT_WIDGET } from './components/rects/edgeless-selected-rect.js';
import { quickTools } from './components/toolbar/tools.js';
import { EdgelessRootService } from './edgeless-root-service.js';
export const edgelessDraggingAreaWidget = WidgetViewExtension(
@@ -45,7 +44,6 @@ const EdgelessCommonExtension: ExtensionType[] = [
CommonSpecs,
EdgelessRootService,
ViewportElementExtension('.affine-edgeless-viewport'),
...quickTools,
].flat();
export const EdgelessRootBlockSpec: ExtensionType[] = [

View File

@@ -12,9 +12,6 @@ import {
EDGELESS_SELECTED_RECT_WIDGET,
EdgelessSelectedRectWidget,
} from './edgeless/components/rects/edgeless-selected-rect.js';
import { EdgelessSlideMenu } from './edgeless/components/toolbar/common/slide-menu.js';
import { ToolbarArrowUpIcon } from './edgeless/components/toolbar/common/toolbar-arrow-up-icon.js';
import { EdgelessLinkToolButton } from './edgeless/components/toolbar/link/link-tool-button.js';
import {
EdgelessRootBlockComponent,
EdgelessRootPreviewBlockComponent,
@@ -25,7 +22,6 @@ import {
export function effects() {
// Register components by category
registerRootComponents();
registerEdgelessToolbarComponents();
registerMiscComponents();
}
@@ -39,17 +35,6 @@ function registerRootComponents() {
);
}
function registerEdgelessToolbarComponents() {
// Tool buttons
customElements.define('edgeless-link-tool-button', EdgelessLinkToolButton);
// Menus
customElements.define('edgeless-slide-menu', EdgelessSlideMenu);
// Toolbar components
customElements.define('toolbar-arrow-up-icon', ToolbarArrowUpIcon);
}
function registerMiscComponents() {
// Auto-complete components
customElements.define(
@@ -81,9 +66,6 @@ declare global {
'note-slicer': NoteSlicer;
'edgeless-dragging-area-rect': EdgelessDraggingAreaRectWidget;
'edgeless-selected-rect': EdgelessSelectedRectWidget;
'edgeless-slide-menu': EdgelessSlideMenu;
'toolbar-arrow-up-icon': ToolbarArrowUpIcon;
'edgeless-link-tool-button': EdgelessLinkToolButton;
'affine-page-root': PageRootBlockComponent;
}
}