feat(editor): add toolbar registry extension (#9572)

### What's Changed!

#### Added
Manage various types of toolbars uniformly in one place.

* `affine-toolbar-widget`
* `ToolbarRegistryExtension`

The toolbar currently supports and handles several scenarios:

1.  Select blocks: `BlockSelection`
2. Select text: `TextSelection` or `NativeSelection`
3. Hover a link: `affine-link` and `affine-reference`

#### Removed
Remove redundant toolbar implementations.

* `attachment` toolbar
* `bookmark` toolbar
* `embed` toolbar
* `formatting` toolbar
* `affine-link` toolbar
* `affine-reference` toolbar

### How to migrate?

Here is an example that can help us migrate some unrefactored toolbars:

Check out the more detailed types of [`ToolbarModuleConfig`](c178debf2d/blocksuite/affine/shared/src/services/toolbar-service/config.ts).

1.  Add toolbar configuration file to a block type, such as bookmark block: [`config.ts`](c178debf2d/blocksuite/affine/block-bookmark/src/configs/toolbar.ts)

```ts
export const builtinToolbarConfig = {
  actions: [
    {
      id: 'a.preview',
      content(ctx) {
        const model = ctx.getCurrentModelBy(BlockSelection, BookmarkBlockModel);
        if (!model) return null;

        const { url } = model;

        return html`<affine-link-preview .url=${url}></affine-link-preview>`;
      },
    },
    {
      id: 'b.conversions',
      actions: [
        {
          id: 'inline',
          label: 'Inline view',
          run(ctx) {
          },
        },
        {
          id: 'card',
          label: 'Card view',
          disabled: true,
        },
        {
          id: 'embed',
          label: 'Embed view',
          disabled(ctx) {
          },
          run(ctx) {
          },
        },
      ],
      content(ctx) {
      },
    } satisfies ToolbarActionGroup<ToolbarAction>,
    {
      id: 'c.style',
      actions: [
        {
          id: 'horizontal',
          label: 'Large horizontal style',
        },
        {
          id: 'list',
          label: 'Small horizontal style',
        },
      ],
      content(ctx) {
      },
    } satisfies ToolbarActionGroup<ToolbarAction>,
    {
      id: 'd.caption',
      tooltip: 'Caption',
      icon: CaptionIcon(),
      run(ctx) {
      },
    },
    {
      placement: ActionPlacement.More,
      id: 'a.clipboard',
      actions: [
        {
          id: 'copy',
          label: 'Copy',
          icon: CopyIcon(),
          run(ctx) {
          },
        },
        {
          id: 'duplicate',
          label: 'Duplicate',
          icon: DuplicateIcon(),
          run(ctx) {
          },
        },
      ],
    },
    {
      placement: ActionPlacement.More,
      id: 'b.refresh',
      label: 'Reload',
      icon: ResetIcon(),
      run(ctx) {
      },
    },
    {
      placement: ActionPlacement.More,
      id: 'c.delete',
      label: 'Delete',
      icon: DeleteIcon(),
      variant: 'destructive',
      run(ctx) {
      },
    },
  ],
} as const satisfies ToolbarModuleConfig;
```

2. Add configuration extension to a block spec: [bookmark's spec](c178debf2d/blocksuite/affine/block-bookmark/src/bookmark-spec.ts)

```ts
const flavour = BookmarkBlockSchema.model.flavour;

export const BookmarkBlockSpec: ExtensionType[] = [
  ...,
  ToolbarModuleExtension({
    id: BlockFlavourIdentifier(flavour),
    config: builtinToolbarConfig,
  }),
].flat();
```

3. If the bock type already has a toolbar configuration built in, we can customize it in the following ways:

Check out the [editor's config](c178debf2d/packages/frontend/core/src/blocksuite/extensions/editor-config/index.ts (L51C4-L54C8)) file.

```ts
// Defines a toolbar configuration for the bookmark block type
const customBookmarkToolbarConfig = {
  actions: [
    ...
  ]
} as const satisfies ToolbarModuleConfig;

// Adds it into the editor's config
 ToolbarModuleExtension({
    id: BlockFlavourIdentifier('custom:affine:bookmark'),
    config: customBookmarkToolbarConfig,
 }),
```

4. If we want to extend the global:

```ts
// Defines a toolbar configuration
const customWildcardToolbarConfig = {
  actions: [
    ...
  ]
} as const satisfies ToolbarModuleConfig;

// Adds it into the editor's config
 ToolbarModuleExtension({
    id: BlockFlavourIdentifier('custom:affine:*'),
    config: customWildcardToolbarConfig,
 }),
```

Currently, only most toolbars in page mode have been refactored. Next is edgeless mode.
This commit is contained in:
fundon
2025-03-06 06:46:03 +00:00
parent 06e4bd9aed
commit ec9bd1f383
147 changed files with 6389 additions and 5156 deletions

View File

@@ -36,6 +36,7 @@
"@blocksuite/affine-widget-frame-title": "workspace:*",
"@blocksuite/affine-widget-remote-selection": "workspace:*",
"@blocksuite/affine-widget-scroll-anchoring": "workspace:*",
"@blocksuite/affine-widget-toolbar": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/data-view": "workspace:*",
"@blocksuite/global": "workspace:*",

View File

@@ -5,17 +5,17 @@ import {
EmbedOptionService,
PageViewportServiceExtension,
ThemeService,
ToolbarRegistryExtension,
} from '@blocksuite/affine-shared/services';
import { dragHandleWidget } from '@blocksuite/affine-widget-drag-handle';
import { docRemoteSelectionWidget } from '@blocksuite/affine-widget-remote-selection';
import { scrollAnchoringWidget } from '@blocksuite/affine-widget-scroll-anchoring';
import { toolbarWidget } from '@blocksuite/affine-widget-toolbar';
import { FlavourExtension } from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { RootBlockAdapterExtensions } from '../adapters/extension';
import {
embedCardToolbarWidget,
formatBarWidget,
innerModalWidget,
linkedDocWidget,
modalWidget,
@@ -31,6 +31,7 @@ export const CommonSpecs: ExtensionType[] = [
PageViewportServiceExtension,
DNDAPIExtension,
FileDropExtension,
ToolbarRegistryExtension,
...RootBlockAdapterExtensions,
modalWidget,
@@ -38,11 +39,10 @@ export const CommonSpecs: ExtensionType[] = [
slashMenuWidget,
linkedDocWidget,
dragHandleWidget,
embedCardToolbarWidget,
formatBarWidget,
docRemoteSelectionWidget,
viewportOverlayWidget,
scrollAnchoringWidget,
toolbarWidget,
];
export * from './widgets';

View File

@@ -1,8 +1,6 @@
import { WidgetViewExtension } from '@blocksuite/block-std';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from '../widgets/embed-card-toolbar/embed-card-toolbar.js';
import { AFFINE_FORMAT_BAR_WIDGET } from '../widgets/format-bar/format-bar.js';
import { AFFINE_INNER_MODAL_WIDGET } from '../widgets/inner-modal/inner-modal.js';
import { AFFINE_LINKED_DOC_WIDGET } from '../widgets/linked-doc/config.js';
import { AFFINE_MODAL_WIDGET } from '../widgets/modal/modal.js';
@@ -29,16 +27,6 @@ export const linkedDocWidget = WidgetViewExtension(
AFFINE_LINKED_DOC_WIDGET,
literal`${unsafeStatic(AFFINE_LINKED_DOC_WIDGET)}`
);
export const embedCardToolbarWidget = WidgetViewExtension(
'affine:page',
AFFINE_EMBED_CARD_TOOLBAR_WIDGET,
literal`${unsafeStatic(AFFINE_EMBED_CARD_TOOLBAR_WIDGET)}`
);
export const formatBarWidget = WidgetViewExtension(
'affine:page',
AFFINE_FORMAT_BAR_WIDGET,
literal`${unsafeStatic(AFFINE_FORMAT_BAR_WIDGET)}`
);
export const viewportOverlayWidget = WidgetViewExtension(
'affine:page',
AFFINE_VIEWPORT_OVERLAY_WIDGET,

View File

@@ -75,15 +75,11 @@ import { EdgelessTemplatePanel } from './edgeless/components/toolbar/template/te
import { EdgelessTemplateButton } from './edgeless/components/toolbar/template/template-tool-button.js';
import { EdgelessTextMenu } from './edgeless/components/toolbar/text/text-menu.js';
import {
AFFINE_EMBED_CARD_TOOLBAR_WIDGET,
AFFINE_FORMAT_BAR_WIDGET,
AffineFormatBarWidget,
AffineImageToolbarWidget,
AffineModalWidget,
EDGELESS_TOOLBAR_WIDGET,
EdgelessRootBlockComponent,
EdgelessRootPreviewBlockComponent,
EmbedCardToolbar,
FramePreview,
PageRootBlockComponent,
PreviewRootBlockComponent,
@@ -153,7 +149,6 @@ function registerRootComponents() {
}
function registerWidgets() {
customElements.define(AFFINE_EMBED_CARD_TOOLBAR_WIDGET, EmbedCardToolbar);
customElements.define(AFFINE_INNER_MODAL_WIDGET, AffineInnerModalWidget);
customElements.define(AFFINE_MODAL_WIDGET, AffineModalWidget);
customElements.define(
@@ -171,7 +166,6 @@ function registerWidgets() {
AffineEdgelessZoomToolbarWidget
);
customElements.define(AFFINE_SURFACE_REF_TOOLBAR, AffineSurfaceRefToolbar);
customElements.define(AFFINE_FORMAT_BAR_WIDGET, AffineFormatBarWidget);
}
function registerEdgelessToolbarComponents() {

View File

@@ -9,8 +9,6 @@ import type { EdgelessRootBlockComponent } from './edgeless/edgeless-root-block.
import type { PageRootBlockComponent } from './page/page-root-block.js';
import type { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from './widgets/edgeless-zoom-toolbar/index.js';
import type { EDGELESS_ELEMENT_TOOLBAR_WIDGET } from './widgets/element-toolbar/index.js';
import type { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from './widgets/embed-card-toolbar/embed-card-toolbar.js';
import type { AFFINE_FORMAT_BAR_WIDGET } from './widgets/format-bar/format-bar.js';
import type { AFFINE_KEYBOARD_TOOLBAR_WIDGET } from './widgets/index.js';
import type { AFFINE_INNER_MODAL_WIDGET } from './widgets/inner-modal/inner-modal.js';
import type { AFFINE_LINKED_DOC_WIDGET } from './widgets/linked-doc/config.js';
@@ -27,8 +25,6 @@ export type PageRootBlockWidgetName =
| typeof AFFINE_LINKED_DOC_WIDGET
| typeof AFFINE_PAGE_DRAGGING_AREA_WIDGET
| typeof AFFINE_DRAG_HANDLE_WIDGET
| typeof AFFINE_EMBED_CARD_TOOLBAR_WIDGET
| typeof AFFINE_FORMAT_BAR_WIDGET
| typeof AFFINE_DOC_REMOTE_SELECTION_WIDGET
| typeof AFFINE_VIEWPORT_OVERLAY_WIDGET;
@@ -38,8 +34,6 @@ export type EdgelessRootBlockWidgetName =
| typeof AFFINE_SLASH_MENU_WIDGET
| typeof AFFINE_LINKED_DOC_WIDGET
| typeof AFFINE_DRAG_HANDLE_WIDGET
| typeof AFFINE_EMBED_CARD_TOOLBAR_WIDGET
| typeof AFFINE_FORMAT_BAR_WIDGET
| typeof AFFINE_DOC_REMOTE_SELECTION_WIDGET
| typeof AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET
| typeof AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET

View File

@@ -7,25 +7,12 @@ import {
EmbedLoomBlockComponent,
EmbedSyncedDocBlockComponent,
EmbedYoutubeBlockComponent,
type LinkableEmbedBlockComponent,
} from '@blocksuite/affine-block-embed';
import type { BlockComponent } from '@blocksuite/block-std';
export type ExternalEmbedBlockComponent =
| BookmarkBlockComponent
| EmbedFigmaBlockComponent
| EmbedGithubBlockComponent
| EmbedLoomBlockComponent
| EmbedYoutubeBlockComponent;
export type InternalEmbedBlockComponent =
| EmbedLinkedDocBlockComponent
| EmbedSyncedDocBlockComponent;
export type LinkableEmbedBlockComponent =
| ExternalEmbedBlockComponent
| InternalEmbedBlockComponent;
export type BuiltInEmbedBlockComponent =
| BookmarkBlockComponent
| LinkableEmbedBlockComponent
| EmbedHtmlBlockComponent;

View File

@@ -1,6 +1,6 @@
import {
type AttachmentBlockComponent,
attachmentViewToggleMenu,
attachmentViewDropdownMenu,
} from '@blocksuite/affine-block-attachment';
import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
import {
@@ -17,7 +17,10 @@ import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import {
ThemeProvider,
ToolbarContext,
} from '@blocksuite/affine-shared/services';
import { Bound } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/utils';
import type { TemplateResult } from 'lit';
@@ -79,17 +82,6 @@ export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) {
return this.edgeless.std;
}
get viewToggleMenu() {
const block = this._block;
const model = this.model;
if (!block || !model) return nothing;
return attachmentViewToggleMenu({
block,
callback: () => this.requestUpdate(),
});
}
override render() {
return join(
[
@@ -115,7 +107,10 @@ export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) {
</card-style-panel>
</editor-menu-button>
`,
this.viewToggleMenu,
// TODO(@fundon): should remove it when refactoring the element toolbar
attachmentViewDropdownMenu(new ToolbarContext(this.std)),
html`
<editor-icon-button
aria-label="Download"
@@ -137,7 +132,7 @@ export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) {
${CaptionIcon}
</editor-icon-button>
`,
].filter(button => button !== nothing && button),
].filter(button => button !== null),
renderToolbarSeparator
);
}

View File

@@ -1,97 +0,0 @@
import {
CopyIcon,
DeleteIcon,
DuplicateIcon,
RefreshIcon,
} from '@blocksuite/affine-components/icons';
import { toast } from '@blocksuite/affine-components/toast';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import { Slice } from '@blocksuite/store';
import {
isAttachmentBlock,
isBookmarkBlock,
isEmbeddedLinkBlock,
isImageBlock,
} from '../../edgeless/utils/query.js';
import type { EmbedCardToolbarContext } from './context.js';
export const BUILT_IN_GROUPS: MenuItemGroup<EmbedCardToolbarContext>[] = [
{
type: 'clipboard',
items: [
{
type: 'copy',
label: 'Copy',
icon: CopyIcon,
disabled: ({ doc }) => doc.readonly,
action: async ({ host, doc, std, blockComponent, close }) => {
const slice = Slice.fromModels(doc, [blockComponent.model]);
await std.clipboard.copySlice(slice);
toast(host, 'Copied link to clipboard');
close();
},
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ doc, blockComponent, close }) => {
const model = blockComponent.model;
const blockProps = getBlockProps(model);
const {
width: _width,
height: _height,
xywh: _xywh,
rotate: _rotate,
zIndex: _zIndex,
...duplicateProps
} = blockProps;
const parent = doc.getParent(model);
const index = parent?.children.indexOf(model);
doc.addBlock(model.flavour, duplicateProps, parent, index);
close();
},
},
{
type: 'reload',
label: 'Reload',
icon: RefreshIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ blockComponent, close }) => {
blockComponent?.refreshData();
close();
},
when: ({ blockComponent }) => {
const model = blockComponent.model;
return (
!!model &&
(isImageBlock(model) ||
isBookmarkBlock(model) ||
isAttachmentBlock(model) ||
isEmbeddedLinkBlock(model))
);
},
},
],
},
{
type: 'delete',
items: [
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ doc, blockComponent, close }) => {
doc.deleteBlock(blockComponent.model);
close();
},
},
],
},
];

View File

@@ -1,45 +0,0 @@
import { MenuContext } from '@blocksuite/affine-components/toolbar';
import type { BuiltInEmbedBlockComponent } from '../../utils';
export class EmbedCardToolbarContext extends MenuContext {
override close = () => {
this.abortController.abort();
};
get doc() {
return this.blockComponent.doc;
}
get host() {
return this.blockComponent.host;
}
get selectedBlockModels() {
if (this.blockComponent.model) return [this.blockComponent.model];
return [];
}
get std() {
return this.host.std;
}
constructor(
public blockComponent: BuiltInEmbedBlockComponent,
public abortController: AbortController
) {
super();
}
isEmpty() {
return false;
}
isMultiple() {
return false;
}
isSingle() {
return true;
}
}

View File

@@ -1,923 +0,0 @@
import {
EmbedLinkedDocBlockComponent,
EmbedSyncedDocBlockComponent,
getDocContentWithMaxLength,
getEmbedCardIcons,
} from '@blocksuite/affine-block-embed';
import {
toggleEmbedCardCaptionEditModal,
toggleEmbedCardEditModal,
} from '@blocksuite/affine-components/embed-card-modal';
import {
CaptionIcon,
CopyIcon,
EditIcon,
OpenIcon,
PaletteIcon,
} from '@blocksuite/affine-components/icons';
import {
notifyLinkedDocClearedAliases,
notifyLinkedDocSwitchedToCard,
notifyLinkedDocSwitchedToEmbed,
} from '@blocksuite/affine-components/notification';
import { isPeekable, peek } from '@blocksuite/affine-components/peek';
import { toast } from '@blocksuite/affine-components/toast';
import {
cloneGroups,
getMoreMenuConfig,
type MenuItem,
type MenuItemGroup,
renderGroups,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar';
import {
type AliasInfo,
type BookmarkBlockModel,
BookmarkStyles,
type BuiltInEmbedModel,
type EmbedCardStyle,
type EmbedGithubModel,
type EmbedLinkedDocModel,
isInternalEmbedModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import {
EmbedOptionProvider,
type EmbedOptions,
GenerateDocUrlProvider,
type GenerateDocUrlService,
type LinkEventType,
OpenDocExtensionIdentifier,
type TelemetryEvent,
TelemetryProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { getHostName, referenceToNode } from '@blocksuite/affine-shared/utils';
import {
BlockSelection,
type BlockStdScope,
TextSelection,
WidgetComponent,
} from '@blocksuite/block-std';
import { ArrowDownSmallIcon, MoreVerticalIcon } from '@blocksuite/icons/lit';
import { type BlockModel, Text } from '@blocksuite/store';
import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom';
import { html, nothing, type TemplateResult } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import * as Y from 'yjs';
import {
isBookmarkBlock,
isEmbedGithubBlock,
isEmbedHtmlBlock,
isEmbedLinkedDocBlock,
isEmbedSyncedDocBlock,
} from '../../edgeless/utils/query.js';
import type { RootBlockComponent } from '../../types.js';
import {
type BuiltInEmbedBlockComponent,
isEmbedCardBlockComponent,
} from '../../utils/types';
import { BUILT_IN_GROUPS } from './config.js';
import { EmbedCardToolbarContext } from './context.js';
import { embedCardToolbarStyle } from './styles.js';
export const AFFINE_EMBED_CARD_TOOLBAR_WIDGET = 'affine-embed-card-toolbar';
export class EmbedCardToolbar extends WidgetComponent<
RootBlockModel,
RootBlockComponent
> {
static override styles = embedCardToolbarStyle;
private _abortController = new AbortController();
private readonly _copyUrl = () => {
const model = this.focusModel;
if (!model) return;
let url!: ReturnType<GenerateDocUrlService['generateDocUrl']>;
const isInternal = isInternalEmbedModel(model);
if ('url' in model) {
url = model.url;
} else if (isInternal) {
url = this.std
.getOptional(GenerateDocUrlProvider)
?.generateDocUrl(model.pageId, model.params);
}
if (!url) return;
navigator.clipboard.writeText(url).catch(console.error);
toast(this.std.host, 'Copied link to clipboard');
track(this.std, model, this._viewType, 'CopiedLink', {
control: 'copy link',
});
};
private _embedOptions: EmbedOptions | null = null;
private readonly _openEditPopup = (e: MouseEvent) => {
e.stopPropagation();
const model = this.focusModel;
if (!model || isEmbedHtmlBlock(model)) return;
const originalDocInfo = this._originalDocInfo;
this._hide();
toggleEmbedCardEditModal(
this.host,
model,
this._viewType,
originalDocInfo,
(std, component) => {
if (
isEmbedLinkedDocBlock(model) &&
component instanceof EmbedLinkedDocBlockComponent
) {
component.refreshData();
notifyLinkedDocClearedAliases(std);
}
},
(std, component, props) => {
if (
isEmbedSyncedDocBlock(model) &&
component instanceof EmbedSyncedDocBlockComponent
) {
component.convertToCard(props);
notifyLinkedDocSwitchedToCard(std);
} else {
this.model.doc.updateBlock(model, props);
component.requestUpdate();
}
}
);
track(this.std, model, this._viewType, 'OpenedAliasPopup', {
control: 'edit',
});
};
private readonly _resetAbortController = () => {
this._abortController.abort();
this._abortController = new AbortController();
};
private readonly _showCaption = () => {
const focusBlock = this.focusBlock;
if (!focusBlock) {
return;
}
try {
focusBlock.captionEditor?.show();
} catch {
toggleEmbedCardCaptionEditModal(focusBlock);
}
this._resetAbortController();
const model = this.focusModel;
if (!model) return;
track(this.std, model, this._viewType, 'OpenedCaptionEditor', {
control: 'add caption',
});
};
private readonly _toggleCardStyleSelector = (e: Event) => {
const opened = (e as CustomEvent<boolean>).detail;
if (!opened) return;
const model = this.focusModel;
if (!model) return;
track(this.std, model, this._viewType, 'OpenedCardStyleSelector', {
control: 'switch card style',
});
};
private readonly _toggleViewSelector = (e: Event) => {
const opened = (e as CustomEvent<boolean>).detail;
if (!opened) return;
const model = this.focusModel;
if (!model) return;
track(this.std, model, this._viewType, 'OpenedViewSelector', {
control: 'switch view',
});
};
private readonly _trackViewSelected = (type: string) => {
const model = this.focusModel;
if (!model) return;
track(this.std, model, this._viewType, 'SelectedView', {
control: 'selected view',
type: `${type} view`,
});
};
/*
* Caches the more menu items.
* Currently only supports configuring more menu.
*/
moreGroups: MenuItemGroup<EmbedCardToolbarContext>[] =
cloneGroups(BUILT_IN_GROUPS);
private get _canConvertToEmbedView() {
if (!this.focusBlock) return false;
return (
'convertToEmbed' in this.focusBlock ||
this._embedOptions?.viewType === 'embed'
);
}
private get _canShowUrlOptions() {
return this.focusModel && 'url' in this.focusModel && this._isCardView;
}
private get _embedViewButtonDisabled() {
if (this.doc.readonly) {
return true;
}
return (
this.focusModel &&
this.focusBlock &&
isEmbedLinkedDocBlock(this.focusModel) &&
(referenceToNode(this.focusModel) ||
!!this.focusBlock.closest('affine-embed-synced-doc-block') ||
this.focusModel.pageId === this.doc.id)
);
}
private get _isCardView() {
return (
this.focusModel &&
(isBookmarkBlock(this.focusModel) ||
isEmbedLinkedDocBlock(this.focusModel) ||
this._embedOptions?.viewType === 'card')
);
}
private get _isEmbedView() {
return (
this.focusModel &&
!isBookmarkBlock(this.focusModel) &&
(isEmbedSyncedDocBlock(this.focusModel) ||
this._embedOptions?.viewType === 'embed')
);
}
get _openButtonDisabled() {
return (
this.focusModel &&
isEmbedLinkedDocBlock(this.focusModel) &&
this.focusModel.pageId === this.doc.id
);
}
get _originalDocInfo(): AliasInfo | undefined {
const model = this.focusModel;
if (!model) return undefined;
const doc = isInternalEmbedModel(model)
? this.std.workspace.getDoc(model.pageId)
: null;
if (doc) {
const title = doc.meta?.title;
const description = isEmbedLinkedDocBlock(model)
? getDocContentWithMaxLength(doc)
: undefined;
return { title, description };
}
return undefined;
}
get _originalDocTitle() {
const model = this.focusModel;
if (!model) return undefined;
const doc = isInternalEmbedModel(model)
? this.std.workspace.getDoc(model.pageId)
: null;
return doc?.meta?.title || 'Untitled';
}
private get _selection() {
return this.host.selection;
}
private get _viewType(): 'inline' | 'embed' | 'card' {
if (this._isCardView) {
return 'card';
}
if (this._isEmbedView) {
return 'embed';
}
return 'inline';
}
get focusModel(): BuiltInEmbedModel | undefined {
return this.focusBlock?.model;
}
private _canShowCardStylePanel(
model: BlockModel
): model is BookmarkBlockModel | EmbedGithubModel | EmbedLinkedDocModel {
return (
isBookmarkBlock(model) ||
isEmbedGithubBlock(model) ||
isEmbedLinkedDocBlock(model)
);
}
private _cardStyleSelector() {
const model = this.focusModel;
if (!model) return nothing;
if (!this._canShowCardStylePanel(model)) return nothing;
const theme = this.std.get(ThemeProvider).theme;
const { EmbedCardHorizontalIcon, EmbedCardListIcon } =
getEmbedCardIcons(theme);
const buttons = [
{
type: 'horizontal',
label: 'Large horizontal style',
icon: EmbedCardHorizontalIcon,
},
{
type: 'list',
label: 'Small horizontal style',
icon: EmbedCardListIcon,
},
] as {
type: EmbedCardStyle;
label: string;
icon: TemplateResult<1>;
}[];
return html`
<editor-menu-button
class="card-style-select"
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button aria-label="Card style" .tooltip=${'Card style'}>
${PaletteIcon}
</editor-icon-button>
`}
@toggle=${this._toggleCardStyleSelector}
>
<div>
${repeat(
buttons,
button => button.type,
({ type, label, icon }) => html`
<icon-button
width="76px"
height="76px"
aria-label=${label}
class=${classMap({
selected: model.style === type,
})}
@click=${() => this._setEmbedCardStyle(type)}
>
${icon}
<affine-tooltip .offset=${4}>${label}</affine-tooltip>
</icon-button>
`
)}
</div>
</editor-menu-button>
`;
}
private _convertToCardView() {
if (this._isCardView) {
return;
}
if (!this.focusBlock) {
return;
}
if ('convertToCard' in this.focusBlock) {
this.focusBlock.convertToCard();
return;
}
if (!this.focusModel || !('url' in this.focusModel)) {
return;
}
const targetModel = this.focusModel;
const { doc, url, style, caption } = targetModel;
let targetFlavour = 'affine:bookmark',
targetStyle = style;
if (this._embedOptions && this._embedOptions.viewType === 'card') {
const { flavour, styles } = this._embedOptions;
targetFlavour = flavour;
targetStyle = styles.includes(style) ? style : styles[0];
} else {
targetStyle = BookmarkStyles.includes(style)
? style
: BookmarkStyles.filter(
style => style !== 'vertical' && style !== 'cube'
)[0];
}
const parent = doc.getParent(targetModel);
if (!parent) return;
const index = parent.children.indexOf(targetModel);
doc.addBlock(
targetFlavour as never,
{ url, style: targetStyle, caption },
parent,
index
);
this.std.selection.setGroup('note', []);
doc.deleteBlock(targetModel);
}
private _convertToEmbedView() {
if (this._isEmbedView) {
return;
}
if (!this.focusBlock) {
return;
}
if ('convertToEmbed' in this.focusBlock) {
const referenceInfo = this.focusBlock.referenceInfo$.peek();
this.focusBlock.convertToEmbed();
if (referenceInfo.title || referenceInfo.description) {
notifyLinkedDocSwitchedToEmbed(this.std);
}
return;
}
if (!this.focusModel || !('url' in this.focusModel)) {
return;
}
const targetModel = this.focusModel;
const { doc, url, style, caption } = targetModel;
if (!this._embedOptions || this._embedOptions.viewType !== 'embed') {
return;
}
const { flavour, styles } = this._embedOptions;
const targetStyle = styles.includes(style)
? style
: styles.filter(style => style !== 'vertical' && style !== 'cube')[0];
const parent = doc.getParent(targetModel);
if (!parent) return;
const index = parent.children.indexOf(targetModel);
doc.addBlock(
flavour as never,
{ url, style: targetStyle, caption },
parent,
index
);
this.std.selection.setGroup('note', []);
doc.deleteBlock(targetModel);
}
private _hide() {
this._resetAbortController();
this.focusBlock = null;
this.hide = true;
}
private _moreActions() {
if (!this.focusBlock) return nothing;
const context = new EmbedCardToolbarContext(
this.focusBlock,
this._abortController
);
return renderGroups(this.moreGroups, context);
}
private _openMenuButton() {
const openDocConfig = this.std.get(OpenDocExtensionIdentifier);
const element = this.focusBlock;
const buttons: MenuItem[] = openDocConfig.items
.map(item => {
if (
item.type === 'open-in-center-peek' &&
element &&
!isPeekable(element)
) {
return null;
}
if (
!(
this.focusModel &&
(isEmbedLinkedDocBlock(this.focusModel) ||
isEmbedSyncedDocBlock(this.focusModel))
)
) {
return null;
}
return {
label: item.label,
type: item.type,
icon: item.icon,
action: () => {
if (item.type === 'open-in-center-peek') {
element && peek(element);
} else {
this.focusBlock?.open({ openMode: item.type });
}
},
};
})
.filter(item => item !== null);
if (buttons.length === 0) {
return nothing;
}
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Open"
.justify=${'space-between'}
.labelHeight=${'20px'}
>
${OpenIcon}${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
</editor-icon-button>
`}
>
<div data-size="small" data-orientation="vertical">
${repeat(
buttons,
button => button.label,
({ label, icon, action, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${disabled}
@click=${action}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
private _setEmbedCardStyle(style: EmbedCardStyle) {
const model = this.focusModel;
if (!model) return;
model.doc.updateBlock(model, { style });
this.requestUpdate();
this._abortController.abort();
track(this.std, model, this._viewType, 'SelectedCardStyle', {
control: 'select card style',
type: style,
});
}
private _show() {
if (!this.focusBlock) {
return;
}
this.hide = false;
this._abortController.signal.addEventListener(
'abort',
autoUpdate(this.focusBlock, this, () => {
if (!this.focusBlock) {
return;
}
computePosition(this.focusBlock, this, {
placement: 'top-start',
middleware: [flip(), offset(8)],
})
.then(({ x, y }) => {
this.style.left = `${x}px`;
this.style.top = `${y}px`;
})
.catch(console.error);
})
);
}
private _turnIntoInlineView() {
if (this.focusBlock && 'covertToInline' in this.focusBlock) {
this.focusBlock.covertToInline();
return;
}
if (!this.focusModel || !('url' in this.focusModel)) {
return;
}
const targetModel = this.focusModel;
const { doc, title, caption, url } = targetModel;
const parent = doc.getParent(targetModel);
const index = parent?.children.indexOf(targetModel);
const yText = new Y.Text();
const insert = title || caption || url;
yText.insert(0, insert);
yText.format(0, insert.length, { link: url });
const text = new Text(yText);
doc.addBlock(
'affine:paragraph',
{
text,
},
parent,
index
);
doc.deleteBlock(targetModel);
}
private _viewSelector() {
const buttons = [];
buttons.push({
type: 'inline',
label: 'Inline view',
action: () => this._turnIntoInlineView(),
disabled: this.doc.readonly,
});
buttons.push({
type: 'card',
label: 'Card view',
action: () => this._convertToCardView(),
disabled: this.doc.readonly,
});
if (this._canConvertToEmbedView || this._isEmbedView) {
buttons.push({
type: 'embed',
label: 'Embed view',
action: () => this._convertToEmbedView(),
disabled: this.doc.readonly || this._embedViewButtonDisabled,
});
}
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Switch view"
.justify=${'space-between'}
.labelHeight=${'20px'}
.iconContainerWidth=${'110px'}
>
<div class="label">
<span style="text-transform: capitalize">${this._viewType}</span>
view
</div>
${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
</editor-icon-button>
`}
@toggle=${this._toggleViewSelector}
>
<div data-size="small" data-orientation="vertical">
${repeat(
buttons,
button => button.type,
({ type, label, action, disabled }) => html`
<editor-menu-action
data-testid=${`link-to-${type}`}
aria-label=${ifDefined(label)}
?data-selected=${this._viewType === type}
?disabled=${disabled || this._viewType === type}
@click=${() => {
action();
this._trackViewSelected(type);
this._hide();
}}
>
${label}
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
override connectedCallback() {
super.connectedCallback();
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
this.disposables.add(
this._selection.slots.changed.on(() => {
const hasTextSelection = this._selection.find(TextSelection);
if (hasTextSelection) {
this._hide();
return;
}
const blockSelections = this._selection.filter(BlockSelection);
if (!blockSelections || blockSelections.length !== 1) {
this._hide();
return;
}
const block = this.std.view.getBlock(blockSelections[0].blockId);
if (!block || !isEmbedCardBlockComponent(block)) {
this._hide();
return;
}
this.focusBlock = block as BuiltInEmbedBlockComponent;
this._show();
})
);
}
override render() {
if (this.hide) return nothing;
const model = this.focusModel;
if (!model) return nothing;
this._embedOptions =
'url' in model
? this.std.get(EmbedOptionProvider).getEmbedBlockOptions(model.url)
: null;
const hasUrl = this._canShowUrlOptions && 'url' in model;
const buttons = [
this._openMenuButton(),
hasUrl
? html`
<a
class="affine-link-preview"
href=${model.url}
rel="noopener noreferrer"
target="_blank"
>
<span>${getHostName(model.url)}</span>
</a>
`
: nothing,
// internal embed model
isEmbedLinkedDocBlock(model) && model.title
? html`
<editor-icon-button
class="doc-title"
aria-label="Doc title"
.hover=${false}
.labelHeight=${'20px'}
.tooltip=${this._originalDocTitle}
@click=${this.focusBlock?.open}
>
<span class="label">${this._originalDocTitle}</span>
</editor-icon-button>
`
: nothing,
isEmbedHtmlBlock(model)
? nothing
: html`
<editor-icon-button
aria-label="Copy link"
data-testid="copy-link"
.tooltip=${'Copy link'}
@click=${this._copyUrl}
>
${CopyIcon}
</editor-icon-button>
<editor-icon-button
aria-label="Edit"
data-testid="edit"
.tooltip=${'Edit'}
?disabled=${this.doc.readonly}
@click=${this._openEditPopup}
>
${EditIcon}
</editor-icon-button>
`,
this._viewSelector(),
this._cardStyleSelector(),
html`
<editor-icon-button
aria-label="Caption"
.tooltip=${'Add Caption'}
?disabled=${this.doc.readonly}
@click=${this._showCaption}
>
${CaptionIcon}
</editor-icon-button>
`,
html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="More"
.tooltip=${'More'}
.iconSize=${'20px'}
>
${MoreVerticalIcon()}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${this._moreActions()}
</div>
</editor-menu-button>
`,
];
return html`
<editor-toolbar class="embed-card-toolbar">
${join(
buttons.filter(button => button !== nothing),
renderToolbarSeparator
)}
</editor-toolbar>
`;
}
@query('.embed-card-toolbar-button.card-style')
accessor cardStyleButton: HTMLElement | null = null;
@query('.embed-card-toolbar')
accessor embedCardToolbarElement!: HTMLElement;
@state()
accessor focusBlock: BuiltInEmbedBlockComponent | null = null;
@state()
accessor hide: boolean = true;
@query('.embed-card-toolbar-button.more-button')
accessor moreButton: HTMLElement | null = null;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_EMBED_CARD_TOOLBAR_WIDGET]: EmbedCardToolbar;
}
}
function track(
std: BlockStdScope,
model: BuiltInEmbedModel,
viewType: string,
event: LinkEventType,
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'toolbar',
page: 'doc editor',
module: 'embed card toolbar',
type: `${viewType} view`,
category: isInternalEmbedModel(model) ? 'linked doc' : 'link',
...props,
});
}

View File

@@ -1,67 +0,0 @@
import { css } from 'lit';
export const embedCardToolbarStyle = css`
:host {
position: absolute;
top: 0;
left: 0;
z-index: var(--affine-z-index-popover);
}
.affine-link-preview {
display: flex;
justify-content: flex-start;
min-width: 60px;
max-width: 140px;
padding: var(--1, 0px);
border-radius: var(--1, 0px);
opacity: var(--add, 1);
user-select: none;
cursor: pointer;
color: var(--affine-link-color);
font-feature-settings:
'clig' off,
'liga' off;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
text-decoration: none;
text-wrap: nowrap;
}
.affine-link-preview > span {
display: inline-block;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
overflow: hidden;
opacity: var(--add, 1);
}
.card-style-select icon-button.selected {
border: 1px solid var(--affine-brand-color);
}
editor-icon-button.doc-title .label {
max-width: 110px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
cursor: pointer;
color: var(--affine-link-color);
font-feature-settings:
'clig' off,
'liga' off;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
text-decoration: none;
text-wrap: nowrap;
}
`;

View File

@@ -1,95 +0,0 @@
import { isFormatSupported } from '@blocksuite/affine-components/rich-text';
import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar';
import { html, type TemplateResult } from 'lit';
import type { AffineFormatBarWidget } from '../format-bar.js';
import { HighlightButton } from './highlight/highlight-button.js';
import { ParagraphButton } from './paragraph-button.js';
export function ConfigRenderer(formatBar: AffineFormatBarWidget) {
return (
formatBar.configItems
.filter(item => {
if (item.type === 'paragraph-action') {
return false;
}
if (item.type === 'highlighter-dropdown') {
const [supported] = isFormatSupported(
formatBar.std.command.chain()
).run();
return supported;
}
if (item.type === 'inline-action') {
return item.showWhen(formatBar.std.command.chain(), formatBar);
}
return true;
})
.map(item => {
let template: TemplateResult | null = null;
switch (item.type) {
case 'divider':
template = renderToolbarSeparator();
break;
case 'highlighter-dropdown': {
template = HighlightButton(formatBar);
break;
}
case 'paragraph-dropdown':
template = ParagraphButton(formatBar);
break;
case 'inline-action': {
template = html`
<editor-icon-button
data-testid=${item.id}
?active=${item.isActive(
formatBar.std.command.chain(),
formatBar
)}
.tooltip=${item.name}
@click=${() => {
item.action(formatBar.std.command.chain(), formatBar);
formatBar.requestUpdate();
}}
>
${typeof item.icon === 'function' ? item.icon() : item.icon}
</editor-icon-button>
`;
break;
}
case 'custom': {
template = item.render(formatBar);
break;
}
default:
template = null;
}
return [template, item] as const;
})
.filter(([template]) => template !== null && template !== undefined)
// 1. delete the redundant dividers in the middle
.filter(([_, item], index, list) => {
if (
item.type === 'divider' &&
index + 1 < list.length &&
list[index + 1][1].type === 'divider'
) {
return false;
}
return true;
})
// 2. delete the redundant dividers at the head and tail
.filter(([_, item], index, list) => {
if (item.type === 'divider') {
if (index === 0) {
return false;
}
if (index === list.length - 1) {
return false;
}
}
return true;
})
.map(([template]) => template)
);
}

View File

@@ -1,42 +0,0 @@
interface HighlightConfig {
name: string;
color: string | null;
hotkey: string | null;
}
const colors = [
'red',
'orange',
'yellow',
'green',
'teal',
'blue',
'purple',
'grey',
];
export const backgroundConfig: HighlightConfig[] = [
{
name: 'Default Background',
color: null,
hotkey: null,
},
...colors.map(color => ({
name: `${color[0].toUpperCase()}${color.slice(1)} Background`,
color: `var(--affine-text-highlight-${color})`,
hotkey: null,
})),
];
export const foregroundConfig: HighlightConfig[] = [
{
name: 'Default Color',
color: null,
hotkey: null,
},
...colors.map(color => ({
name: `${color[0].toUpperCase()}${color.slice(1)}`,
color: `var(--affine-text-highlight-foreground-${color})`,
hotkey: null,
})),
];

View File

@@ -1,166 +0,0 @@
import { whenHover } from '@blocksuite/affine-components/hover';
import {
ArrowDownIcon,
HighLightDuotoneIcon,
TextBackgroundDuotoneIcon,
TextForegroundDuotoneIcon,
} from '@blocksuite/affine-components/icons';
import {
formatBlockCommand,
formatNativeCommand,
formatTextCommand,
} from '@blocksuite/affine-components/rich-text';
import {
getBlockSelectionsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { EditorHost } from '@blocksuite/block-std';
import { computePosition, flip, offset, shift } from '@floating-ui/dom';
import { html } from 'lit';
import { ref, type RefOrCallback } from 'lit/directives/ref.js';
import type { AffineFormatBarWidget } from '../../format-bar.js';
import { backgroundConfig, foregroundConfig } from './consts.js';
enum HighlightType {
Color = 'color',
Background = 'background',
}
let lastUsedColor: string | null = null;
let lastUsedHighlightType: HighlightType = HighlightType.Background;
const updateHighlight = (
host: EditorHost,
color: string | null,
highlightType: HighlightType
) => {
lastUsedColor = color;
lastUsedHighlightType = highlightType;
const payload: {
styles: AffineTextAttributes;
} = {
styles: {
[`${highlightType}`]: color,
},
};
host.std.command
.chain()
.try(chain => [
chain.pipe(getTextSelectionCommand).pipe(formatTextCommand, payload),
chain.pipe(getBlockSelectionsCommand).pipe(formatBlockCommand, payload),
chain.pipe(formatNativeCommand, payload),
])
.run();
};
const HighlightPanel = (
formatBar: AffineFormatBarWidget,
containerRef?: RefOrCallback
) => {
return html`
<editor-menu-content class="highlight-panel" data-show ${ref(containerRef)}>
<div data-orientation="vertical">
<!-- Text Color Highlight -->
<div class="highligh-panel-heading">Color</div>
${foregroundConfig.map(
({ name, color }) => html`
<editor-menu-action
data-testid="${color ?? 'unset'}"
@click="${() => {
updateHighlight(formatBar.host, color, HighlightType.Color);
formatBar.requestUpdate();
}}"
>
<span style="display: flex; color: ${color}">
${TextForegroundDuotoneIcon}
</span>
${name}
</editor-menu-action>
`
)}
<!-- Text Background Highlight -->
<div class="highligh-panel-heading">Background</div>
${backgroundConfig.map(
({ name, color }) => html`
<editor-menu-action
data-testid="${color ?? 'transparent'}"
@click="${() => {
updateHighlight(
formatBar.host,
color,
HighlightType.Background
);
formatBar.requestUpdate();
}}"
>
<span style="display: flex; color: ${color ?? 'transparent'}">
${TextBackgroundDuotoneIcon}
</span>
${name}
</editor-menu-action>
`
)}
</div>
</editor-menu-content>
`;
};
export const HighlightButton = (formatBar: AffineFormatBarWidget) => {
const editorHost = formatBar.host;
const { setFloating, setReference } = whenHover(isHover => {
if (!isHover) {
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-panel');
if (!panel) return;
panel.style.display = 'none';
return;
}
const button =
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-button');
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-panel');
if (!button || !panel) {
return;
}
panel.style.display = 'flex';
computePosition(button, panel, {
placement: 'bottom',
middleware: [
flip(),
offset(6),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
})
.catch(console.error);
});
const highlightPanel = HighlightPanel(formatBar, setFloating);
return html`
<div class="highlight-button" ${ref(setReference)}>
<editor-icon-button
class="highlight-icon"
data-last-used="${lastUsedColor ?? 'unset'}"
@click="${() =>
updateHighlight(editorHost, lastUsedColor, lastUsedHighlightType)}"
>
<span style="display: flex; color: ${lastUsedColor}">
${HighLightDuotoneIcon}
</span>
${ArrowDownIcon}
</editor-icon-button>
${highlightPanel}
</div>
`;
};

View File

@@ -1,124 +0,0 @@
import { whenHover } from '@blocksuite/affine-components/hover';
import { ArrowDownIcon } from '@blocksuite/affine-components/icons';
import { textConversionConfigs } from '@blocksuite/affine-components/rich-text';
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import type { EditorHost } from '@blocksuite/block-std';
import { computePosition, flip, offset, shift } from '@floating-ui/dom';
import { html } from 'lit';
import { ref, type RefOrCallback } from 'lit/directives/ref.js';
import { repeat } from 'lit/directives/repeat.js';
import type { ParagraphActionConfigItem } from '../config.js';
import type { AffineFormatBarWidget } from '../format-bar.js';
interface ParagraphPanelProps {
host: EditorHost;
formatBar: AffineFormatBarWidget;
ref?: RefOrCallback;
}
const ParagraphPanel = ({
formatBar,
host,
ref: containerRef,
}: ParagraphPanelProps) => {
const config = formatBar.configItems
.filter(
(item): item is ParagraphActionConfigItem =>
item.type === 'paragraph-action'
)
.filter(({ flavour }) => host.doc.schema.flavourSchemaMap.has(flavour));
const renderedConfig = repeat(
config,
item => html`
<editor-menu-action
data-testid="${item.id}"
@click="${() => item.action(formatBar.std.command.chain(), formatBar)}"
>
${typeof item.icon === 'function' ? item.icon() : item.icon}
${item.name}
</editor-menu-action>
`
);
return html`
<editor-menu-content class="paragraph-panel" data-show ${ref(containerRef)}>
<div data-orientation="vertical">${renderedConfig}</div>
</editor-menu-content>
`;
};
export const ParagraphButton = (formatBar: AffineFormatBarWidget) => {
if (formatBar.displayType !== 'text' && formatBar.displayType !== 'block') {
return null;
}
const selectedBlocks = formatBar.selectedBlocks;
// only support model with text
if (selectedBlocks.some(el => !el.model.text)) {
return null;
}
const paragraphIcon =
selectedBlocks.length < 1
? textConversionConfigs[0].icon
: (textConversionConfigs.find(
({ flavour, type }) =>
selectedBlocks[0].flavour === flavour &&
(selectedBlocks[0].model as ParagraphBlockModel).type === type
)?.icon ?? textConversionConfigs[0].icon);
const rootComponent = formatBar.block;
if (rootComponent.model.flavour !== 'affine:page') {
console.error('paragraph button host is not a page component');
return null;
}
const { setFloating, setReference } = whenHover(isHover => {
if (!isHover) {
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-panel');
if (!panel) return;
panel.style.display = 'none';
return;
}
const formatQuickBarElement = formatBar.formatBarElement;
const panel =
formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-panel');
if (!panel || !formatQuickBarElement) {
return;
}
panel.style.display = 'flex';
computePosition(formatQuickBarElement, panel, {
placement: 'top-start',
middleware: [
flip(),
offset(6),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
})
.catch(console.error);
});
const paragraphPanel = ParagraphPanel({
formatBar,
host: formatBar.host,
ref: setFloating,
});
return html`
<div class="paragraph-button" ${ref(setReference)}>
<editor-icon-button class="paragraph-button-icon">
${paragraphIcon} ${ArrowDownIcon}
</editor-icon-button>
${paragraphPanel}
</div>
`;
};

View File

@@ -1,492 +0,0 @@
import {
convertToDatabase,
DATABASE_CONVERT_WHITE_LIST,
} from '@blocksuite/affine-block-database';
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import {
BoldIcon,
BulletedListIcon,
CheckBoxIcon,
CodeIcon,
CopyIcon,
DatabaseTableViewIcon20,
DeleteIcon,
DuplicateIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
ItalicIcon,
LinkedDocIcon,
LinkIcon,
NumberedListIcon,
QuoteIcon,
StrikethroughIcon,
TextIcon,
UnderlineIcon,
} from '@blocksuite/affine-components/icons';
import {
deleteTextCommand,
toggleBold,
toggleCode,
toggleItalic,
toggleLink,
toggleStrike,
toggleUnderline,
} from '@blocksuite/affine-components/rich-text';
import { toast } from '@blocksuite/affine-components/toast';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { renderGroups } from '@blocksuite/affine-components/toolbar';
import {
copySelectedModelsCommand,
deleteSelectedModelsCommand,
draftSelectedModelsCommand,
getBlockIndexCommand,
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedBlocksCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type {
BlockComponent,
Chain,
InitCommandCtx,
} from '@blocksuite/block-std';
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
import { Slice, toDraftModel } from '@blocksuite/store';
import { html, type TemplateResult } from 'lit';
import { FormatBarContext } from './context.js';
import type { AffineFormatBarWidget } from './format-bar.js';
export type DividerConfigItem = {
type: 'divider';
};
export type HighlighterDropdownConfigItem = {
type: 'highlighter-dropdown';
};
export type ParagraphDropdownConfigItem = {
type: 'paragraph-dropdown';
};
export type InlineActionConfigItem = {
id: string;
name: string;
type: 'inline-action';
action: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => void;
icon: TemplateResult | (() => HTMLElement);
isActive: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => boolean;
showWhen: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => boolean;
};
export type ParagraphActionConfigItem = {
id: string;
type: 'paragraph-action';
name: string;
action: (
chain: Chain<InitCommandCtx>,
formatBar: AffineFormatBarWidget
) => void;
icon: TemplateResult | (() => HTMLElement);
flavour: string;
};
export type CustomConfigItem = {
type: 'custom';
render: (formatBar: AffineFormatBarWidget) => TemplateResult | null;
};
export type FormatBarConfigItem =
| DividerConfigItem
| HighlighterDropdownConfigItem
| ParagraphDropdownConfigItem
| ParagraphActionConfigItem
| InlineActionConfigItem
| CustomConfigItem;
export function toolbarDefaultConfig(toolbar: AffineFormatBarWidget) {
toolbar
.clearConfig()
.addParagraphDropdown()
.addDivider()
.addTextStyleToggle({
key: 'bold',
action: chain => chain.pipe(toggleBold).run(),
icon: BoldIcon,
})
.addTextStyleToggle({
key: 'italic',
action: chain => chain.pipe(toggleItalic).run(),
icon: ItalicIcon,
})
.addTextStyleToggle({
key: 'underline',
action: chain => chain.pipe(toggleUnderline).run(),
icon: UnderlineIcon,
})
.addTextStyleToggle({
key: 'strike',
action: chain => chain.pipe(toggleStrike).run(),
icon: StrikethroughIcon,
})
.addTextStyleToggle({
key: 'code',
action: chain => chain.pipe(toggleCode).run(),
icon: CodeIcon,
})
.addTextStyleToggle({
key: 'link',
action: chain => chain.pipe(toggleLink).run(),
icon: LinkIcon,
})
.addDivider()
.addHighlighterDropdown()
.addDivider()
.addInlineAction({
id: 'convert-to-database',
name: 'Create Table',
icon: DatabaseTableViewIcon20,
isActive: () => false,
action: () => {
convertToDatabase(toolbar.host, tableViewMeta.type);
},
showWhen: chain => {
const middleware = (count = 0) => {
return (
ctx: { selectedBlocks: BlockComponent[] },
next: () => void
) => {
const { selectedBlocks } = ctx;
if (!selectedBlocks || selectedBlocks.length === count) return;
const allowed = selectedBlocks.every(block =>
DATABASE_CONVERT_WHITE_LIST.includes(block.flavour)
);
if (!allowed) return;
next();
};
};
let [result] = chain
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
})
.pipe(middleware(1))
.run();
if (result) return true;
[result] = chain
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedBlocksCommand, {
types: ['block', 'image'],
})
.pipe(middleware(0))
.run();
return result;
},
})
.addDivider()
.addInlineAction({
id: 'convert-to-linked-doc',
name: 'Create Linked Doc',
icon: LinkedDocIcon,
isActive: () => false,
action: (chain, formatBar) => {
const [_, ctx] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'flat',
})
.pipe(draftSelectedModelsCommand)
.run();
const { draftedModels, selectedModels, std } = ctx;
if (!selectedModels?.length || !draftedModels) return;
const host = formatBar.host;
host.selection.clear();
const doc = host.doc;
const autofill = getTitleFromSelectedModels(
selectedModels.map(toDraftModel)
);
promptDocTitle(std, autofill)
.then(async title => {
if (title === null) return;
await convertSelectedBlocksToLinkedDoc(
std,
doc,
draftedModels,
title
);
notifyDocCreated(std, doc);
host.std.getOptional(TelemetryProvider)?.track('DocCreated', {
control: 'create linked doc',
page: 'doc editor',
module: 'format toolbar',
type: 'embed-linked-doc',
});
host.std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
control: 'create linked doc',
page: 'doc editor',
module: 'format toolbar',
type: 'embed-linked-doc',
});
})
.catch(console.error);
},
showWhen: chain => {
const [_, ctx] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'highest',
})
.run();
const { selectedModels } = ctx;
return !!selectedModels && selectedModels.length > 0;
},
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'text',
name: 'Text',
icon: TextIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h1',
name: 'Heading 1',
icon: Heading1Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h2',
name: 'Heading 2',
icon: Heading2Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h3',
name: 'Heading 3',
icon: Heading3Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h4',
name: 'Heading 4',
icon: Heading4Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h5',
name: 'Heading 5',
icon: Heading5Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'h6',
name: 'Heading 6',
icon: Heading6Icon,
})
.addBlockTypeSwitch({
flavour: 'affine:list',
type: 'bulleted',
name: 'Bulleted List',
icon: BulletedListIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:list',
type: 'numbered',
name: 'Numbered List',
icon: NumberedListIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:list',
type: 'todo',
name: 'To-do List',
icon: CheckBoxIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:code',
name: 'Code Block',
icon: CodeIcon,
})
.addBlockTypeSwitch({
flavour: 'affine:paragraph',
type: 'quote',
name: 'Quote',
icon: QuoteIcon,
});
}
export const BUILT_IN_GROUPS: MenuItemGroup<FormatBarContext>[] = [
{
type: 'clipboard',
items: [
{
type: 'copy',
label: 'Copy',
icon: CopyIcon,
disabled: c => c.doc.readonly,
action: c => {
c.std.command
.chain()
.pipe(getSelectedModelsCommand)
.with({
onCopy: () => {
toast(c.host, 'Copied to clipboard');
},
})
.pipe(draftSelectedModelsCommand)
.pipe(copySelectedModelsCommand)
.run();
},
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
disabled: c => c.doc.readonly,
action: c => {
c.doc.captureSync();
c.std.command
.chain()
.try<{ currentSelectionPath: string }>(cmd => [
cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => {
const textSelection = ctx.currentTextSelection;
if (!textSelection) {
return;
}
const end = textSelection.to ?? textSelection.from;
next({ currentSelectionPath: end.blockId });
}),
cmd.pipe(getBlockSelectionsCommand).pipe((ctx, next) => {
const currentBlockSelections = ctx.currentBlockSelections;
if (!currentBlockSelections) {
return;
}
const blockSelection = currentBlockSelections.at(-1);
if (!blockSelection) {
return;
}
next({ currentSelectionPath: blockSelection.blockId });
}),
])
.pipe(getBlockIndexCommand)
.pipe(getSelectedModelsCommand)
.pipe(draftSelectedModelsCommand)
.pipe((ctx, next) => {
ctx.draftedModels
.then(models => {
const slice = Slice.fromModels(ctx.std.store, models);
return ctx.std.clipboard.duplicateSlice(
slice,
ctx.std.store,
ctx.parentBlock?.model.id,
ctx.blockIndex ? ctx.blockIndex + 1 : 1
);
})
.catch(console.error);
return next();
})
.run();
},
},
],
},
{
type: 'delete',
items: [
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
disabled: c => c.doc.readonly,
action: c => {
// remove text
const [result] = c.std.command
.chain()
.pipe(getTextSelectionCommand)
.pipe(deleteTextCommand)
.run();
if (result) {
return;
}
// remove blocks
c.std.command
.chain()
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedModelsCommand)
.pipe(deleteSelectedModelsCommand)
.run();
c.toolbar.reset();
},
},
],
},
];
export function toolbarMoreButton(toolbar: AffineFormatBarWidget) {
const richText = getRichText();
if (richText?.dataset.disableAskAi !== undefined) return null;
const context = new FormatBarContext(toolbar);
const actions = renderGroups(toolbar.moreGroups, context);
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button="${html`
<editor-icon-button
aria-label="More"
.tooltip=${'More'}
.iconSize=${'20px'}
>
${MoreVerticalIcon()}
</editor-icon-button>
`}"
>
<div data-size="large" data-orientation="vertical">${actions}</div>
</editor-menu-button>
`;
}
const getRichText = () => {
const selection = getSelection();
if (!selection) return null;
if (selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const commonAncestorContainer =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
if (!commonAncestorContainer) return null;
return commonAncestorContainer.closest('rich-text');
};

View File

@@ -1,72 +0,0 @@
import { MenuContext } from '@blocksuite/affine-components/toolbar';
import {
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type { AffineFormatBarWidget } from './format-bar.js';
export class FormatBarContext extends MenuContext {
get doc() {
return this.toolbar.host.doc;
}
get host() {
return this.toolbar.host;
}
get selectedBlockModels() {
const [success, result] = this.std.command
.chain()
.tryAll(chain => [
chain.pipe(getTextSelectionCommand),
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedModelsCommand, {
mode: 'highest',
})
.run();
if (!success) {
return [];
}
// should return an empty array if `to` of the range is null
if (
result.currentTextSelection &&
!result.currentTextSelection.to &&
result.currentTextSelection.from.length === 0
) {
return [];
}
if (result.selectedModels?.length) {
return result.selectedModels;
}
return [];
}
get std() {
return this.toolbar.std;
}
constructor(public toolbar: AffineFormatBarWidget) {
super();
}
isEmpty() {
return this.selectedBlockModels.length === 0;
}
isMultiple() {
return this.selectedBlockModels.length > 1;
}
isSingle() {
return this.selectedBlockModels.length === 1;
}
}

View File

@@ -1,614 +0,0 @@
import { updateBlockType } from '@blocksuite/affine-block-note';
import { HoverController } from '@blocksuite/affine-components/hover';
import {
isFormatSupported,
isTextStyleActive,
type RichText,
} from '@blocksuite/affine-components/rich-text';
import {
cloneGroups,
getMoreMenuConfig,
type MenuItemGroup,
} from '@blocksuite/affine-components/toolbar';
import {
CodeBlockModel,
ImageBlockModel,
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import {
getSelectedBlocksCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { matchModels } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockSelection,
CursorSelection,
TextSelection,
WidgetComponent,
} from '@blocksuite/block-std';
import { DisposableGroup, nextTick } from '@blocksuite/global/utils';
import type { BaseSelection } from '@blocksuite/store';
import {
autoUpdate,
computePosition,
inline,
offset,
type Placement,
type ReferenceElement,
shift,
} from '@floating-ui/dom';
import { html, nothing } from 'lit';
import { query, state } from 'lit/decorators.js';
import { ConfigRenderer } from './components/config-renderer.js';
import {
BUILT_IN_GROUPS,
type FormatBarConfigItem,
type InlineActionConfigItem,
type ParagraphActionConfigItem,
toolbarDefaultConfig,
toolbarMoreButton,
} from './config.js';
import type { FormatBarContext } from './context.js';
import { formatBarStyle } from './styles.js';
export const AFFINE_FORMAT_BAR_WIDGET = 'affine-format-bar-widget';
export class AffineFormatBarWidget extends WidgetComponent {
static override styles = formatBarStyle;
private _abortController = new AbortController();
private _floatDisposables: DisposableGroup | null = null;
private _lastCursor: CursorSelection | undefined = undefined;
private _placement: Placement = 'top';
/*
* Caches the more menu items.
* Currently only supports configuring more menu.
*/
moreGroups: MenuItemGroup<FormatBarContext>[] = cloneGroups(BUILT_IN_GROUPS);
private get _selectionManager() {
return this.host.selection;
}
get displayType() {
return this._displayType;
}
get nativeRange() {
const sl = document.getSelection();
if (!sl || sl.rangeCount === 0) return null;
return sl.getRangeAt(0);
}
get selectedBlocks() {
return this._selectedBlocks;
}
private _calculatePlacement() {
const rootComponent = this.block;
this.handleEvent('dragStart', () => {
this._dragging = true;
});
this.handleEvent('dragEnd', () => {
this._dragging = false;
});
// calculate placement
this.disposables.add(
this.host.event.add('pointerUp', ctx => {
let targetRect: DOMRect | null = null;
if (this.displayType === 'text' || this.displayType === 'native') {
const range = this.nativeRange;
if (!range) {
this.reset();
return;
}
targetRect = range.getBoundingClientRect();
} else if (this.displayType === 'block') {
const block = this._selectedBlocks[0];
if (!block) return;
targetRect = block.getBoundingClientRect();
} else {
return;
}
const { top: editorHostTop, bottom: editorHostBottom } =
this.host.getBoundingClientRect();
const e = ctx.get('pointerState');
if (editorHostBottom - targetRect.bottom < 50) {
this._placement = 'top';
} else if (targetRect.top - Math.max(editorHostTop, 0) < 50) {
this._placement = 'bottom';
} else if (e.raw.y < targetRect.top + targetRect.height / 2) {
this._placement = 'top';
} else {
this._placement = 'bottom';
}
})
);
// listen to selection change
this.disposables.add(
this._selectionManager.slots.changed.on(() => {
const update = async () => {
const textSelection = rootComponent.selection.find(TextSelection);
const blockSelections =
rootComponent.selection.filter(BlockSelection);
// Should not re-render format bar when only cursor selection changed in edgeless
const cursorSelection = rootComponent.selection.find(CursorSelection);
if (cursorSelection) {
if (!this._lastCursor) {
this._lastCursor = cursorSelection;
return;
}
if (!this._selectionEqual(cursorSelection, this._lastCursor)) {
this._lastCursor = cursorSelection;
return;
}
}
// We cannot use `host.getUpdateComplete()` here
// because it would cause excessive DOM queries, leading to UI jamming.
await nextTick();
if (textSelection) {
const block = this.host.view.getBlock(textSelection.blockId);
if (
!textSelection.isCollapsed() &&
block &&
block.model.role === 'content'
) {
this._displayType = 'text';
if (!rootComponent.std.range) return;
this.host.std.command
.chain()
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
})
.pipe(ctx => {
const { selectedBlocks } = ctx;
if (!selectedBlocks) return;
this._selectedBlocks = selectedBlocks;
})
.run();
return;
}
this.reset();
return;
}
if (this.block && blockSelections.length > 0) {
this._displayType = 'block';
const selectedBlocks = blockSelections
.map(selection => {
const path = selection.blockId;
return this.block.host.view.getBlock(path);
})
.filter((el): el is BlockComponent => !!el);
this._selectedBlocks = selectedBlocks;
return;
}
this.reset();
};
update().catch(console.error);
})
);
this.disposables.addFromEvent(document, 'selectionchange', () => {
if (!this.host.event.active) return;
const reset = () => {
this.reset();
this.requestUpdate();
};
const range = this.nativeRange;
if (!range) return;
const container =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
if (!container) return;
const notBlockText = container.closest('rich-text')?.dataset.notBlockText;
if (notBlockText == null) return;
if (range.collapsed) return reset();
this._displayType = 'native';
this.requestUpdate();
});
}
private _listenFloatingElement() {
const formatQuickBarElement = this.formatBarElement;
if (!formatQuickBarElement) {
return;
}
const listenFloatingElement = (
getElement: () => ReferenceElement | void
) => {
const initialElement = getElement();
if (!initialElement) {
return;
}
if (!this._floatDisposables) {
return;
}
HoverController.globalAbortController?.abort();
this._floatDisposables.add(
autoUpdate(
initialElement,
formatQuickBarElement,
() => {
const element = getElement();
if (!element) return;
computePosition(element, formatQuickBarElement, {
placement: this._placement,
middleware: [
offset(10),
inline(),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
formatQuickBarElement.style.display = 'flex';
formatQuickBarElement.style.top = `${y}px`;
formatQuickBarElement.style.left = `${x}px`;
})
.catch(console.error);
},
{
// follow edgeless viewport update
animationFrame: true,
}
)
);
};
const getReferenceElementFromBlock = () => {
const firstBlock = this._selectedBlocks[0];
let rect = firstBlock?.getBoundingClientRect();
if (!rect) return;
this._selectedBlocks.forEach(el => {
const elRect = el.getBoundingClientRect();
if (elRect.top < rect.top) {
rect = new DOMRect(rect.left, elRect.top, rect.width, rect.bottom);
}
if (elRect.bottom > rect.bottom) {
rect = new DOMRect(rect.left, rect.top, rect.width, elRect.bottom);
}
if (elRect.left < rect.left) {
rect = new DOMRect(elRect.left, rect.top, rect.right, rect.bottom);
}
if (elRect.right > rect.right) {
rect = new DOMRect(rect.left, rect.top, elRect.right, rect.bottom);
}
});
return {
getBoundingClientRect: () => rect,
getClientRects: () =>
this._selectedBlocks.map(el => el.getBoundingClientRect()),
};
};
const getReferenceElementFromText = () => {
const range = this.nativeRange;
if (!range) {
return;
}
return {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects(),
};
};
switch (this.displayType) {
case 'text':
case 'native':
return listenFloatingElement(getReferenceElementFromText);
case 'block':
return listenFloatingElement(getReferenceElementFromBlock);
default:
return;
}
}
private _selectionEqual(
target: BaseSelection | undefined,
current: BaseSelection | undefined
) {
if (target === current || (target && current && target.equals(current))) {
return true;
}
return false;
}
private _shouldDisplay() {
const readonly = this.doc.readonly;
const active = this.host.event.active;
if (readonly || !active) return false;
if (
this.displayType === 'block' &&
this._selectedBlocks?.[0]?.flavour === 'affine:surface-ref'
) {
return false;
}
if (this.displayType === 'block' && this._selectedBlocks.length === 1) {
const selectedBlock = this._selectedBlocks[0];
if (
!matchModels(selectedBlock.model, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
ImageBlockModel,
])
) {
return false;
}
}
if (this.displayType === 'none' || this._dragging) {
return false;
}
// if the selection is on an embed (ex. linked page), we should not display the format bar
if (this.displayType === 'text' && this._selectedBlocks.length === 1) {
const isEmbed = () => {
const [element] = this._selectedBlocks;
const richText = element.querySelector<RichText>('rich-text');
const inline = richText?.inlineEditor;
if (!richText || !inline) {
return false;
}
const range = inline.getInlineRange();
if (!range || range.length > 1) {
return false;
}
const deltas = inline.getDeltasByInlineRange(range);
if (deltas.length > 2) {
return false;
}
const delta = deltas?.[1]?.[0];
if (!delta) {
return false;
}
return inline.isEmbed(delta);
};
if (isEmbed()) {
return false;
}
}
// todo: refactor later that ai panel & format bar should not depend on each other
// do not display if AI panel is open
const rootBlockId = this.host.doc.root?.id;
const aiPanel = rootBlockId
? this.host.view.getWidget('affine-ai-panel-widget', rootBlockId)
: null;
// @ts-expect-error FIXME: ts error
if (aiPanel && aiPanel?.state !== 'hidden') {
return false;
}
return true;
}
addBlockTypeSwitch(config: {
flavour: string;
icon: ParagraphActionConfigItem['icon'];
type?: string;
name?: string;
}) {
const { flavour, type, icon } = config;
return this.addParagraphAction({
id: `${flavour}/${type ?? ''}`,
icon,
flavour,
name: config.name ?? camelCaseToWords(type ?? flavour),
action: chain => {
chain
.pipe(updateBlockType, {
flavour,
props: type != null ? { type } : undefined,
})
.run();
},
});
}
addDivider() {
this.configItems.push({ type: 'divider' });
return this;
}
addHighlighterDropdown() {
this.configItems.push({ type: 'highlighter-dropdown' });
return this;
}
addInlineAction(config: Omit<InlineActionConfigItem, 'type'>) {
this.configItems.push({ ...config, type: 'inline-action' });
return this;
}
addParagraphAction(config: Omit<ParagraphActionConfigItem, 'type'>) {
this.configItems.push({ ...config, type: 'paragraph-action' });
return this;
}
addParagraphDropdown() {
this.configItems.push({ type: 'paragraph-dropdown' });
return this;
}
addRawConfigItems(configItems: FormatBarConfigItem[], index?: number) {
if (index === undefined) {
this.configItems.push(...configItems);
} else {
this.configItems.splice(index, 0, ...configItems);
}
return this;
}
addTextStyleToggle(config: {
icon: InlineActionConfigItem['icon'];
key: Exclude<
keyof AffineTextAttributes,
'color' | 'background' | 'reference'
>;
action: InlineActionConfigItem['action'];
}) {
const { key } = config;
return this.addInlineAction({
id: key,
name: camelCaseToWords(key),
icon: config.icon,
isActive: chain => {
const [result] = chain.pipe(isTextStyleActive, { key }).run();
return result;
},
action: config.action,
showWhen: chain => {
const [result] = isFormatSupported(chain).run();
return result;
},
});
}
clearConfig() {
this.configItems = [];
return this;
}
override connectedCallback() {
super.connectedCallback();
this._abortController = new AbortController();
const rootComponent = this.block;
if (!rootComponent) {
return;
}
const widgets = rootComponent.widgets;
// check if the host use the format bar widget
if (!Object.hasOwn(widgets, AFFINE_FORMAT_BAR_WIDGET)) {
return;
}
// check if format bar widget support the host
if (rootComponent.model.flavour !== 'affine:page') {
console.error(
`format bar not support rootComponent: ${rootComponent.constructor.name} but its widgets has format bar`
);
return;
}
this._calculatePlacement();
if (this.configItems.length === 0) {
toolbarDefaultConfig(this);
}
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._abortController.abort();
this.reset();
this._lastCursor = undefined;
}
override render() {
if (!this._shouldDisplay()) {
return nothing;
}
const items = ConfigRenderer(this);
const moreButton = toolbarMoreButton(this);
return html`
<editor-toolbar class="${AFFINE_FORMAT_BAR_WIDGET}">
${items}
${moreButton
? html`
<editor-toolbar-separator></editor-toolbar-separator>
${moreButton}
`
: nothing}
</editor-toolbar>
`;
}
reset() {
this._displayType = 'none';
this._selectedBlocks = [];
}
override updated() {
if (this._floatDisposables) {
this._floatDisposables.dispose();
this._floatDisposables = null;
}
if (!this._shouldDisplay()) {
return;
}
this._floatDisposables = new DisposableGroup();
this._listenFloatingElement();
}
@state()
private accessor _displayType: 'text' | 'block' | 'native' | 'none' = 'none';
@state()
private accessor _dragging = false;
@state()
private accessor _selectedBlocks: BlockComponent[] = [];
@state()
accessor configItems: FormatBarConfigItem[] = [];
@query(`.${AFFINE_FORMAT_BAR_WIDGET}`)
accessor formatBarElement: HTMLElement | null = null;
}
function camelCaseToWords(s: string) {
const result = s.replace(/([A-Z])/g, ' $1');
return result.charAt(0).toUpperCase() + result.slice(1);
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_FORMAT_BAR_WIDGET]: AffineFormatBarWidget;
}
}

View File

@@ -1,2 +0,0 @@
export * from './config.js';
export { AffineFormatBarWidget } from './format-bar.js';

View File

@@ -1,55 +0,0 @@
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { css } from 'lit';
const paragraphButtonStyle = css`
.paragraph-button-icon > svg:nth-child(2) {
transition-duration: 0.3s;
}
.paragraph-button-icon:is(:hover, :focus-visible, :active)
> svg:nth-child(2) {
transform: rotate(180deg);
}
.highlight-icon > svg:nth-child(2) {
transition-duration: 0.3s;
}
.highlight-icon:is(:hover, :focus-visible, :active) > svg:nth-child(2) {
transform: rotate(180deg);
}
.highlight-panel {
max-height: 380px;
}
.highligh-panel-heading {
display: flex;
color: var(--affine-text-secondary-color);
padding: 4px;
}
editor-menu-content {
display: none;
position: absolute;
padding: 0;
z-index: var(--affine-z-index-popover);
--packed-height: 6px;
}
editor-menu-content > div[data-orientation='vertical'] {
padding: 8px;
overflow-y: auto;
}
${scrollbarStyle('editor-menu-content > div[data-orientation="vertical"]')}
`;
export const formatBarStyle = css`
.affine-format-bar-widget {
position: absolute;
display: none;
z-index: var(--affine-z-index-popover);
user-select: none;
}
${paragraphButtonStyle}
`;

View File

@@ -4,15 +4,6 @@ export {
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
EdgelessElementToolbarWidget,
} from './element-toolbar/index.js';
export {
AFFINE_EMBED_CARD_TOOLBAR_WIDGET,
EmbedCardToolbar,
} from './embed-card-toolbar/embed-card-toolbar.js';
export { toolbarDefaultConfig } from './format-bar/config.js';
export {
AFFINE_FORMAT_BAR_WIDGET,
AffineFormatBarWidget,
} from './format-bar/format-bar.js';
export { AffineImageToolbarWidget } from './image-toolbar/index.js';
export { AffineInnerModalWidget } from './inner-modal/inner-modal.js';
export * from './keyboard-toolbar/index.js';