mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 02:42:25 +08:00
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:
@@ -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:*",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
];
|
||||
@@ -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>
|
||||
`;
|
||||
};
|
||||
@@ -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>
|
||||
`;
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './config.js';
|
||||
export { AffineFormatBarWidget } from './format-bar.js';
|
||||
@@ -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}
|
||||
`;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user