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

### What's Changed!

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

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

The toolbar currently supports and handles several scenarios:

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

#### Removed
Remove redundant toolbar implementations.

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

### How to migrate?

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

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

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

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

        const { url } = model;

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

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

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

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

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

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

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

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

4. If we want to extend the global:

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

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

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

View File

@@ -1,6 +1,5 @@
import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
import { HoverController } from '@blocksuite/affine-components/hover';
import {
AttachmentIcon16,
getAttachmentFileIcon,
@@ -16,19 +15,16 @@ import {
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { humanFileSize } from '@blocksuite/affine-shared/utils';
import { BlockSelection, TextSelection } from '@blocksuite/block-std';
import { BlockSelection } from '@blocksuite/block-std';
import { Slice } from '@blocksuite/store';
import { flip, offset } from '@floating-ui/dom';
import { html, nothing } from 'lit';
import { html } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import { AttachmentOptionsTemplate } from './components/options.js';
import { AttachmentEmbedProvider } from './embed.js';
import { styles } from './styles.js';
import { checkAttachmentBlob, downloadAttachmentBlob } from './utils.js';
import { AttachmentEmbedProvider } from './embed';
import { styles } from './styles';
import { checkAttachmentBlob, downloadAttachmentBlob } from './utils';
@Peekable({
enableOn: ({ model }: AttachmentBlockComponent) => {
@@ -42,43 +38,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
protected _isResizing = false;
protected _whenHover: HoverController | null = new HoverController(
this,
({ abortController }) => {
const selection = this.host.selection;
const textSelection = selection.find(TextSelection);
if (
!!textSelection &&
(!!textSelection.to || !!textSelection.from.length)
) {
return null;
}
const blockSelections = selection.filter(BlockSelection);
if (
blockSelections.length > 1 ||
(blockSelections.length === 1 &&
blockSelections[0].blockId !== this.blockId)
) {
return null;
}
return {
template: AttachmentOptionsTemplate({
block: this,
model: this.model,
abortController,
}),
computePosition: {
referenceElement: this,
placement: 'top-start',
middleware: [flip(), offset(4)],
autoUpdate: true,
},
};
}
);
blockDraggable = true;
protected containerStyleMap = styleMap({
@@ -227,11 +186,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
const embedView = this.embedView;
return html`
<div
${this._whenHover ? ref(this._whenHover.setReference) : nothing}
class="affine-attachment-container"
style=${this.containerStyleMap}
>
<div class="affine-attachment-container" style=${this.containerStyleMap}>
${embedView
? html`<div class="affine-attachment-embed-container">
${embedView}

View File

@@ -1,5 +1,4 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import type { HoverController } from '@blocksuite/affine-components/hover';
import { AttachmentBlockStyles } from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
@@ -13,8 +12,6 @@ import { AttachmentBlockComponent } from './attachment-block.js';
export class AttachmentEdgelessBlockComponent extends toGfxBlockComponent(
AttachmentBlockComponent
) {
protected override _whenHover: HoverController | null = null;
override blockDraggable = false;
get slots() {

View File

@@ -1,17 +1,26 @@
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
import { AttachmentBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockFlavourIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { AttachmentBlockNotionHtmlAdapterExtension } from './adapters/notion-html.js';
import { AttachmentDropOption } from './attachment-service.js';
import { builtinToolbarConfig } from './configs/toolbar';
import {
AttachmentEmbedConfigExtension,
AttachmentEmbedService,
} from './embed.js';
} from './embed';
const flavour = AttachmentBlockSchema.model.flavour;
export const AttachmentBlockSpec: ExtensionType[] = [
FlavourExtension('affine:attachment'),
BlockViewExtension('affine:attachment', model => {
FlavourExtension(flavour),
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-edgeless-attachment`
: literal`affine-attachment`;
@@ -20,4 +29,8 @@ export const AttachmentBlockSpec: ExtensionType[] = [
AttachmentEmbedConfigExtension(),
AttachmentEmbedService,
AttachmentBlockNotionHtmlAdapterExtension,
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
];

View File

@@ -1,77 +0,0 @@
import {
CopyIcon,
DeleteIcon,
DownloadIcon,
DuplicateIcon,
RefreshIcon,
} from '@blocksuite/affine-components/icons';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
import { cloneAttachmentProperties } from '../utils.js';
import type { AttachmentToolbarMoreMenuContext } from './context.js';
export const BUILT_IN_GROUPS: MenuItemGroup<AttachmentToolbarMoreMenuContext>[] =
[
{
type: 'clipboard',
items: [
{
type: 'copy',
label: 'Copy',
icon: CopyIcon,
disabled: ({ doc }) => doc.readonly,
action: ctx => ctx.blockComponent.copy(),
},
{
type: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ doc, blockComponent, close }) => {
const model = blockComponent.model;
const prop: { flavour: 'affine:attachment' } = {
flavour: 'affine:attachment',
...cloneAttachmentProperties(model),
};
doc.addSiblingBlocks(model, [prop]);
close();
},
},
{
type: 'reload',
label: 'Reload',
icon: RefreshIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ blockComponent, close }) => {
blockComponent.refreshData();
close();
},
},
{
type: 'download',
label: 'Download',
icon: DownloadIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ blockComponent, close }) => {
blockComponent.download();
close();
},
},
],
},
{
type: 'delete',
items: [
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
disabled: ({ doc }) => doc.readonly,
action: ({ doc, blockComponent, close }) => {
doc.deleteBlock(blockComponent.model);
close();
},
},
],
},
];

View File

@@ -1,45 +0,0 @@
import { MenuContext } from '@blocksuite/affine-components/toolbar';
import type { AttachmentBlockComponent } from '../attachment-block.js';
export class AttachmentToolbarMoreMenuContext 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.blockComponent.std;
}
constructor(
public blockComponent: AttachmentBlockComponent,
public abortController: AbortController
) {
super();
}
isEmpty() {
return false;
}
isMultiple() {
return false;
}
isSingle() {
return true;
}
}

View File

@@ -1,229 +0,0 @@
import {
CaptionIcon,
DownloadIcon,
EditIcon,
} from '@blocksuite/affine-components/icons';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import {
cloneGroups,
getMoreMenuConfig,
renderGroups,
renderToolbarSeparator,
} from '@blocksuite/affine-components/toolbar';
import {
type AttachmentBlockModel,
defaultAttachmentProps,
} from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import { Bound } from '@blocksuite/global/gfx';
import { ArrowDownSmallIcon, MoreVerticalIcon } from '@blocksuite/icons/lit';
import { flip, offset } from '@floating-ui/dom';
import { html, nothing } from 'lit';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import type { AttachmentBlockComponent } from '../attachment-block.js';
import { BUILT_IN_GROUPS } from './config.js';
import { AttachmentToolbarMoreMenuContext } from './context.js';
import { RenameModal } from './rename-model.js';
import { styles } from './styles.js';
export function attachmentViewToggleMenu({
block,
callback,
}: {
block: AttachmentBlockComponent;
callback?: () => void;
}) {
const model = block.model;
const readonly = model.doc.readonly;
const embedded = model.embed;
const viewType = embedded ? 'embed' : 'card';
const viewActions = [
{
type: 'card',
label: 'Card view',
disabled: readonly || !embedded,
action: () => {
const style = defaultAttachmentProps.style!;
const width = EMBED_CARD_WIDTH[style];
const height = EMBED_CARD_HEIGHT[style];
const bound = Bound.deserialize(model.xywh);
bound.w = width;
bound.h = height;
model.doc.updateBlock(model, {
style,
embed: false,
xywh: bound.serialize(),
});
callback?.();
},
},
{
type: 'embed',
label: 'Embed view',
disabled: readonly || embedded || !block.embedded(),
action: () => {
block.convertTo();
callback?.();
},
},
];
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">${viewType}</span>
view
</div>
${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
</editor-icon-button>
`}
>
<div data-size="small" data-orientation="vertical">
${repeat(
viewActions,
button => button.type,
({ type, label, action, disabled }) => html`
<editor-menu-action
aria-label=${label}
data-testid=${`link-to-${type}`}
?data-selected=${type === viewType}
?disabled=${disabled}
@click=${action}
>
${label}
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
export function AttachmentOptionsTemplate({
block,
model,
abortController,
}: {
block: AttachmentBlockComponent;
model: AttachmentBlockModel;
abortController: AbortController;
}) {
const std = block.std;
const editorHost = block.host;
const readonly = model.doc.readonly;
const context = new AttachmentToolbarMoreMenuContext(block, abortController);
const groups = getMoreMenuConfig(std).configure(cloneGroups(BUILT_IN_GROUPS));
const moreMenuActions = renderGroups(groups, context);
const buttons = [
// preview
// html`
// <editor-icon-button aria-label="Preview" .tooltip=${'Preview'}>
// ${ViewIcon}
// </editor-icon-button>
// `,
readonly
? nothing
: html`
<editor-icon-button
aria-label="Rename"
.tooltip=${'Rename'}
@click=${() => {
abortController.abort();
const renameAbortController = new AbortController();
createLitPortal({
template: RenameModal({
model,
editorHost,
abortController: renameAbortController,
}),
computePosition: {
referenceElement: block,
placement: 'top-start',
middleware: [flip(), offset(4)],
// It has a overlay mask, so we don't need to update the position.
// autoUpdate: true,
},
abortController: renameAbortController,
});
}}
>
${EditIcon}
</editor-icon-button>
`,
attachmentViewToggleMenu({
block,
callback: () => abortController.abort(),
}),
readonly
? nothing
: html`
<editor-icon-button
aria-label="Download"
.tooltip=${'Download'}
@click=${() => block.download()}
>
${DownloadIcon}
</editor-icon-button>
`,
readonly
? nothing
: html`
<editor-icon-button
aria-label="Caption"
.tooltip=${'Caption'}
@click=${() => block.captionEditor?.show()}
>
${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">
${moreMenuActions}
</div>
</editor-menu-button>
`,
];
return html`
<style>
${styles}
</style>
<editor-toolbar class="affine-attachment-toolbar">
${join(
buttons.filter(button => button !== nothing),
renderToolbarSeparator
)}
</editor-toolbar>
`;
}

View File

@@ -5,7 +5,7 @@ import type { EditorHost } from '@blocksuite/block-std';
import { html } from 'lit';
import { createRef, ref } from 'lit/directives/ref.js';
import { renameStyles } from './styles.js';
import { renameStyles } from './styles';
export const RenameModal = ({
editorHost,
@@ -34,6 +34,7 @@ export const RenameModal = ({
let fileName = includeExtension ? nameWithoutExtension : originalName;
const extension = includeExtension ? originalExtension : '';
const abort = () => abortController.abort();
const onConfirm = () => {
const newFileName = fileName + extension;
if (!newFileName) {
@@ -43,7 +44,7 @@ export const RenameModal = ({
model.doc.updateBlock(model, {
name: newFileName,
});
abortController.abort();
abort();
};
const onInput = (e: InputEvent) => {
fileName = (e.target as HTMLInputElement).value;
@@ -52,7 +53,7 @@ export const RenameModal = ({
e.stopPropagation();
if (e.key === 'Escape' && !e.isComposing) {
abortController.abort();
abort();
return;
}
if (e.key === 'Enter' && !e.isComposing) {
@@ -65,10 +66,7 @@ export const RenameModal = ({
<style>
${renameStyles}
</style>
<div
class="affine-attachment-rename-overlay-mask"
@click="${() => abortController.abort()}"
></div>
<div class="affine-attachment-rename-overlay-mask" @click="${abort}"></div>
<div class="affine-attachment-rename-container">
<div class="affine-attachment-rename-input-wrapper">
<input

View File

@@ -57,43 +57,6 @@ export const renameStyles = css`
}
`;
export const moreMenuStyles = css`
.affine-attachment-options-more {
box-sizing: border-box;
padding-bottom: 4px;
}
.affine-attachment-options-more-container {
display: flex;
flex-direction: column;
align-items: center;
color: var(--affine-text-primary-color);
border-radius: 8px;
padding: 8px;
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
}
.affine-attachment-options-more-container > icon-button {
display: flex;
align-items: center;
padding: 8px;
gap: 8px;
}
.affine-attachment-options-more-container > icon-button[hidden] {
display: none;
}
.affine-attachment-options-more-container > icon-button:hover.danger {
background: var(--affine-background-error-color);
color: var(--affine-error-color);
}
.affine-attachment-options-more-container > icon-button:hover.danger > svg {
color: var(--affine-error-color);
}
`;
export const styles = css`
:host {
z-index: 1;

View File

@@ -0,0 +1,278 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import {
AttachmentBlockModel,
defaultAttachmentProps,
} from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
ActionPlacement,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarContext,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { BlockSelection, SurfaceSelection } from '@blocksuite/block-std';
import { Bound } from '@blocksuite/global/gfx';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DownloadIcon,
DuplicateIcon,
EditIcon,
ResetIcon,
} from '@blocksuite/icons/lit';
import type { SelectionConstructor } from '@blocksuite/store';
import { flip, offset } from '@floating-ui/dom';
import { computed } from '@preact/signals-core';
import { html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
import { AttachmentBlockComponent } from '../attachment-block';
import { RenameModal } from '../components/rename-model';
import { AttachmentEmbedProvider } from '../embed';
import { cloneAttachmentProperties } from '../utils';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'attachment',
type: 'card view',
};
const createAttachmentViewDropdownMenuWith = <T extends SelectionConstructor>(
t: T
) => {
return {
id: 'b.conversions',
actions: [
{
id: 'card',
label: 'Card view',
run(ctx) {
const model = ctx.getCurrentModelByType(t, AttachmentBlockModel);
if (!model) return;
const style = defaultAttachmentProps.style!;
const width = EMBED_CARD_WIDTH[style];
const height = EMBED_CARD_HEIGHT[style];
const bound = Bound.deserialize(model.xywh);
bound.w = width;
bound.h = height;
ctx.store.updateBlock(model, {
style,
embed: false,
xywh: bound.serialize(),
});
},
},
{
id: 'embed',
label: 'Embed view',
run(ctx) {
const model = ctx.getCurrentModelByType(t, AttachmentBlockModel);
if (!model) return;
// Clears
ctx.reset();
ctx.select('note');
ctx.std.get(AttachmentEmbedProvider).convertTo(model);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(t, AttachmentBlockModel);
if (!model) return null;
const embedProvider = ctx.std.get(AttachmentEmbedProvider);
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = computed(() => {
const [cardAction, embedAction] = actions;
const embed = model.embed$.value ?? false;
cardAction.disabled = !embed;
embedAction.disabled = embed && embedProvider.embedded(model);
return embed ? embedAction.label : cardAction.label;
});
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
model,
html`<affine-view-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.viewType$=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>;
};
export const builtinToolbarConfig = {
actions: [
{
id: 'a.rename',
content(cx) {
const component = cx.getCurrentBlockComponentBy(
BlockSelection,
AttachmentBlockComponent
);
if (!component) return null;
const abortController = new AbortController();
abortController.signal.onabort = () => cx.show();
return html`
<editor-icon-button
aria-label="Rename"
.tooltip="${'Rename'}"
@click=${() => {
cx.hide();
createLitPortal({
template: RenameModal({
model: component.model,
editorHost: cx.host,
abortController,
}),
computePosition: {
referenceElement: component,
placement: 'top-start',
middleware: [flip(), offset(4)],
},
abortController,
});
}}
>
${EditIcon()}
</editor-icon-button>
`;
},
},
createAttachmentViewDropdownMenuWith(BlockSelection),
{
id: 'c.download',
tooltip: 'Download',
icon: DownloadIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
AttachmentBlockComponent
);
component?.download();
},
},
{
id: 'd.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
AttachmentBlockComponent
);
component?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
},
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
// TODO(@fundon): unify `clone` method
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
AttachmentBlockComponent
);
component?.copy();
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const model = ctx.getCurrentBlockComponentBy(
BlockSelection,
AttachmentBlockComponent
)?.model;
if (!model) return;
// TODO(@fundon): unify `duplicate` method
ctx.store.addSiblingBlocks(model, [
{
flavour: model.flavour,
...cloneAttachmentProperties(model),
},
]);
},
},
],
},
{
placement: ActionPlacement.More,
id: 'b.refresh',
label: 'Reload',
icon: ResetIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
AttachmentBlockComponent
);
component?.refreshData();
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;
export const attachmentViewDropdownMenu = (ctx: ToolbarContext) => {
return createAttachmentViewDropdownMenuWith(SurfaceSelection).content(ctx);
};

View File

@@ -2,7 +2,7 @@ export * from './adapters/notion-html';
export * from './attachment-block';
export * from './attachment-service';
export * from './attachment-spec';
export { attachmentViewToggleMenu } from './components/options';
export { attachmentViewDropdownMenu } from './configs/toolbar';
export {
type AttachmentEmbedConfig,
AttachmentEmbedConfigIdentifier,

View File

@@ -22,12 +22,12 @@
"@blocksuite/icons": "^2.2.1",
"@blocksuite/inline": "workspace:*",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"yjs": "^13.6.23",
"zod": "^3.23.8"
},
"exports": {

View File

@@ -1,15 +1,28 @@
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockFlavourIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { BookmarkBlockAdapterExtensions } from './adapters/extension.js';
import { BookmarkBlockAdapterExtensions } from './adapters/extension';
import { builtinToolbarConfig } from './configs/toolbar';
const flavour = BookmarkBlockSchema.model.flavour;
export const BookmarkBlockSpec: ExtensionType[] = [
FlavourExtension('affine:bookmark'),
BlockViewExtension('affine:bookmark', model => {
FlavourExtension(flavour),
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-edgeless-bookmark`
: literal`affine-bookmark`;
}),
BookmarkBlockAdapterExtensions,
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
].flat();

View File

@@ -0,0 +1,324 @@
import { toast } from '@blocksuite/affine-components/toast';
import { BookmarkBlockModel } from '@blocksuite/affine-model';
import {
ActionPlacement,
EmbedOptionProvider,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ResetIcon,
} from '@blocksuite/icons/lit';
import { Slice, Text } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
import * as Y from 'yjs';
import { BookmarkBlockComponent } from '../bookmark-block';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'bookmark',
type: 'card view',
};
export const builtinToolbarConfig = {
actions: [
{
id: 'a.preview',
content(ctx) {
const model = ctx.getCurrentModelByType(
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) {
const model = ctx.getCurrentModelByType(
BlockSelection,
BookmarkBlockModel
);
if (!model) return;
const { title, caption, url, parent } = model;
const index = parent?.children.indexOf(model);
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);
ctx.store.addBlock('affine:paragraph', { text }, parent, index);
ctx.store.deleteBlock(model);
// Clears
ctx.reset();
ctx.select('note');
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'inline view',
});
},
},
{
id: 'card',
label: 'Card view',
disabled: true,
},
{
id: 'embed',
label: 'Embed view',
disabled(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
BookmarkBlockModel
);
if (!model) return true;
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(model.url);
return options?.viewType !== 'embed';
},
run(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
BookmarkBlockModel
);
if (!model) return;
const { caption, url, style, parent } = model;
const index = parent?.children.indexOf(model);
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (!options) return;
const { flavour, styles } = options;
const newStyle = styles.includes(style)
? style
: styles.find(s => s !== 'vertical' && s !== 'cube');
const blockId = ctx.store.addBlock(
flavour,
{
url,
caption,
style: newStyle,
},
parent,
index
);
ctx.store.deleteBlock(model);
// Selects new block
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
BookmarkBlockModel
);
if (!model) return null;
const actions = this.actions.map(action => ({ ...action }));
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
model,
html`<affine-view-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.viewType$=${signal(actions[1].label)}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
BookmarkBlockModel
);
if (!model) return null;
const actions = this.actions.map(action => ({
...action,
run: ({ store }) => {
store.updateBlock(model, { style: action.id });
ctx.track('SelectedCardStyle', {
...trackBaseProps,
control: 'select card style',
type: action.id,
});
},
})) satisfies ToolbarAction[];
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedCardStyleSelector', {
...trackBaseProps,
control: 'switch card style',
});
};
return html`${keyed(
model,
html`<affine-card-style-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.style$=${model.style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'd.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
BookmarkBlockComponent
);
component?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
},
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model) return;
const slice = Slice.fromModels(ctx.store, [model]);
ctx.clipboard
.copySlice(slice)
.then(() => toast(ctx.host, 'Copied to clipboard'))
.catch(console.error);
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model) return;
const { flavour, parent } = model;
const props = getBlockProps(model);
const index = parent?.children.indexOf(model);
ctx.store.addBlock(flavour, props, parent, index);
},
},
],
},
{
placement: ActionPlacement.More,
id: 'b.refresh',
label: 'Reload',
icon: ResetIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
BookmarkBlockComponent
);
component?.refreshData();
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;

View File

@@ -0,0 +1,404 @@
import { toast } from '@blocksuite/affine-components/toast';
import {
BookmarkStyles,
EmbedGithubModel,
isExternalEmbedModel,
} from '@blocksuite/affine-model';
import {
ActionPlacement,
EmbedOptionProvider,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ResetIcon,
} from '@blocksuite/icons/lit';
import { Slice, Text } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
import * as Y from 'yjs';
import type { EmbedFigmaBlockComponent } from '../embed-figma-block';
import type { EmbedGithubBlockComponent } from '../embed-github-block';
import type { EmbedLoomBlockComponent } from '../embed-loom-block';
import type { EmbedYoutubeBlockComponent } from '../embed-youtube-block';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'link',
type: 'card view',
};
// External embed blocks
export function createBuiltinToolbarConfigForExternal(
klass:
| typeof EmbedGithubBlockComponent
| typeof EmbedFigmaBlockComponent
| typeof EmbedLoomBlockComponent
| typeof EmbedYoutubeBlockComponent
) {
return {
actions: [
{
id: 'a.preview',
content(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return null;
const { url } = model;
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (options?.viewType !== 'card') return null;
return html`<affine-link-preview .url=${url}></affine-link-preview>`;
},
},
{
id: 'b.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return;
const { title, caption, url: link, parent } = model;
const index = parent?.children.indexOf(model);
const yText = new Y.Text();
const insert = title || caption || link;
yText.insert(0, insert);
yText.format(0, insert.length, { link });
const text = new Text(yText);
ctx.store.addBlock('affine:paragraph', { text }, parent, index);
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'inline view',
});
},
},
{
id: 'card',
label: 'Card view',
disabled(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return true;
const { url } = model;
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return options?.viewType === 'card';
},
run(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return;
const { url, caption, parent } = model;
const index = parent?.children.indexOf(model);
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
let { style } = model;
let flavour = 'affine:bookmark';
if (options?.viewType === 'card') {
flavour = options.flavour;
if (!options.styles.includes(style)) {
style = options.styles[0];
}
} else {
style =
BookmarkStyles.find(s => s !== 'vertical' && s !== 'cube') ??
BookmarkStyles[1];
}
const blockId = ctx.store.addBlock(
flavour,
{ url, caption, style },
parent,
index
);
ctx.store.deleteBlock(model);
// Selects new block
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'card view',
});
},
},
{
id: 'embed',
label: 'Embed view',
disabled(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return false;
const { url } = model;
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return options?.viewType === 'embed';
},
when(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return false;
const { url } = model;
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return options?.viewType === 'embed';
},
run(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return;
const { url, caption, parent } = model;
const index = parent?.children.indexOf(model);
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (options?.viewType !== 'embed') return;
const { flavour, styles } = options;
let { style } = model;
if (!styles.includes(style)) {
style =
styles.find(s => s !== 'vertical' && s !== 'cube') ??
styles[0];
}
const blockId = ctx.store.addBlock(
flavour,
{ url, caption, style },
parent,
index
);
ctx.store.deleteBlock(model);
// Selects new block
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return null;
const { url } = model;
const viewType =
ctx.std.get(EmbedOptionProvider).getEmbedBlockOptions(url)
?.viewType ?? 'card';
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal(
`${viewType === 'card' ? 'Card' : 'Embed'} view`
);
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
model,
html`<affine-view-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.viewType$=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedGithubModel
);
if (!model) return null;
const actions = this.actions.map(action => ({
...action,
run: ({ store }) => {
store.updateBlock(model, { style: action.id });
ctx.track('SelectedCardStyle', {
...trackBaseProps,
control: 'select card style',
type: action.id,
});
},
})) satisfies ToolbarAction[];
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedCardStyleSelector', {
...trackBaseProps,
control: 'switch card style',
});
};
return html`${keyed(
model,
html`<affine-card-style-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.style$=${model.style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'd.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
klass
);
if (!component) return;
component.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
},
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model || !isExternalEmbedModel(model)) return;
const slice = Slice.fromModels(ctx.store, [model]);
ctx.clipboard
.copySlice(slice)
.then(() => toast(ctx.host, 'Copied to clipboard'))
.catch(console.error);
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model || !isExternalEmbedModel(model)) return;
const { flavour, parent } = model;
const props = getBlockProps(model);
const index = parent?.children.indexOf(model);
ctx.store.addBlock(flavour, props, parent, index);
},
},
],
},
{
placement: ActionPlacement.More,
id: 'b.reload',
label: 'Reload',
icon: ResetIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
klass
);
component?.refreshData();
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model || !isExternalEmbedModel(model)) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;
}

View File

@@ -1,17 +1,31 @@
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockServiceIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedFigmaBlockAdapterExtensions } from './adapters/extension.js';
import { EmbedFigmaBlockOptionConfig } from './embed-figma-service.js';
import { createBuiltinToolbarConfigForExternal } from '../configs/toolbar';
import { EmbedFigmaBlockAdapterExtensions } from './adapters/extension';
import { EmbedFigmaBlockComponent } from './embed-figma-block';
import { EmbedFigmaBlockOptionConfig } from './embed-figma-service';
const flavour = EmbedFigmaBlockSchema.model.flavour;
export const EmbedFigmaBlockSpec: ExtensionType[] = [
FlavourExtension('affine:embed-figma'),
BlockViewExtension('affine:embed-figma', model => {
FlavourExtension(flavour),
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-figma-block`
: literal`affine-embed-figma-block`;
}),
EmbedFigmaBlockAdapterExtensions,
EmbedFigmaBlockOptionConfig,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: createBuiltinToolbarConfigForExternal(EmbedFigmaBlockComponent),
}),
].flat();

View File

@@ -1,21 +1,35 @@
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
import { EmbedGithubBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockServiceIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedGithubBlockAdapterExtensions } from './adapters/extension.js';
import { createBuiltinToolbarConfigForExternal } from '../configs/toolbar';
import { EmbedGithubBlockAdapterExtensions } from './adapters/extension';
import { EmbedGithubBlockComponent } from './embed-github-block';
import {
EmbedGithubBlockOptionConfig,
EmbedGithubBlockService,
} from './embed-github-service.js';
} from './embed-github-service';
const flavour = EmbedGithubBlockSchema.model.flavour;
export const EmbedGithubBlockSpec: ExtensionType[] = [
FlavourExtension('affine:embed-github'),
FlavourExtension(flavour),
EmbedGithubBlockService,
BlockViewExtension('affine:embed-github', model => {
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-github-block`
: literal`affine-embed-github-block`;
}),
EmbedGithubBlockAdapterExtensions,
EmbedGithubBlockOptionConfig,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: createBuiltinToolbarConfigForExternal(EmbedGithubBlockComponent),
}),
].flat();

View File

@@ -0,0 +1,167 @@
import { toast } from '@blocksuite/affine-components/toast';
import { EmbedHtmlModel } from '@blocksuite/affine-model';
import {
ActionPlacement,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ExpandFullIcon,
} from '@blocksuite/icons/lit';
import { Slice } from '@blocksuite/store';
import { html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
import { EmbedHtmlBlockComponent } from '../embed-html-block';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'html',
type: 'card view',
};
export const builtinToolbarConfig = {
actions: [
{
id: 'a.open-doc',
icon: ExpandFullIcon(),
tooltip: 'Open this doc',
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedHtmlBlockComponent
);
component?.open();
},
},
{
id: 'b.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(BlockSelection, EmbedHtmlModel);
if (!model) return null;
const actions = this.actions.map<ToolbarAction>(action => ({
...action,
run: ({ store }) => {
store.updateBlock(model, { style: action.id });
ctx.track('SelectedCardStyle', {
...trackBaseProps,
control: 'select card style',
type: action.id,
});
},
}));
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedCardStyleSelector', {
...trackBaseProps,
control: 'switch card style',
});
};
return html`${keyed(
model,
html`<affine-card-style-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.style$=${model.style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedHtmlBlockComponent
);
component?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
},
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
const slice = Slice.fromModels(ctx.store, [model]);
ctx.clipboard
.copySlice(slice)
.then(() => toast(ctx.host, 'Copied to clipboard'))
.catch(console.error);
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
const { flavour, parent } = model;
const props = getBlockProps(model);
const index = parent?.children.indexOf(model);
ctx.store.addBlock(flavour, props, parent, index);
},
},
],
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;

View File

@@ -1,11 +1,24 @@
import { BlockViewExtension } from '@blocksuite/block-std';
import { EmbedHtmlBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockFlavourIdentifier,
BlockViewExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { builtinToolbarConfig } from './configs/toolbar';
const flavour = EmbedHtmlBlockSchema.model.flavour;
export const EmbedHtmlBlockSpec: ExtensionType[] = [
BlockViewExtension('affine:embed-html', model => {
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-html-block`
: literal`affine-embed-html-block`;
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
];

View File

@@ -0,0 +1,276 @@
import { toast } from '@blocksuite/affine-components/toast';
import { EmbedLinkedDocModel } from '@blocksuite/affine-model';
import {
ActionPlacement,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import {
getBlockProps,
referenceToNode,
} from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
} from '@blocksuite/icons/lit';
import { Slice } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
import { EmbedLinkedDocBlockComponent } from '../embed-linked-doc-block';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'card view',
};
export const builtinToolbarConfig = {
actions: [
{
id: 'a.doc-title',
content(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
if (!component) return null;
const model = component.model;
if (!model.title) return null;
const originalTitle =
ctx.workspace.getDoc(model.pageId)?.meta?.title || 'Untitled';
return html`<affine-linked-doc-title
.title=${originalTitle}
.open=${(event: MouseEvent) => component.open({ event })}
></affine-linked-doc-title>`;
},
},
{
id: 'b.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
component?.covertToInline();
// Clears
ctx.select('note');
ctx.reset();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'inline view',
});
},
},
{
id: 'card',
label: 'Card view',
disabled: true,
},
{
id: 'embed',
label: 'Embed view',
disabled(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
if (!component) return true;
if (component.closest('affine-embed-synced-doc-block')) return true;
const model = component.model;
// same doc
if (model.pageId === ctx.store.id) return true;
// linking to block
if (referenceToNode(model)) return true;
return false;
},
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
component?.convertToEmbed();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedLinkedDocModel
);
if (!model) return null;
const actions = this.actions.map(action => ({ ...action }));
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
model,
html`<affine-view-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.viewType$=${signal(actions[1].label)}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedLinkedDocModel
);
if (!model) return null;
const actions = this.actions.map(action => ({
...action,
run: ({ store }) => {
store.updateBlock(model, { style: action.id });
ctx.track('SelectedCardStyle', {
...trackBaseProps,
control: 'select card style',
type: action.id,
});
},
})) satisfies ToolbarAction[];
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedCardStyleSelector', {
...trackBaseProps,
control: 'switch card style',
});
};
return html`${keyed(
model,
html`<affine-card-style-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.style$=${model.style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'd.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
component?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
},
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
const slice = Slice.fromModels(ctx.store, [model]);
ctx.clipboard
.copySlice(slice)
.then(() => toast(ctx.host, 'Copied to clipboard'))
.catch(console.error);
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
const { flavour, parent } = model;
const props = getBlockProps(model);
const index = parent?.children.indexOf(model);
ctx.store.addBlock(flavour, props, parent, index);
},
},
],
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;

View File

@@ -109,7 +109,7 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
};
private readonly _selectBlock = () => {
const selectionManager = this.host.selection;
const selectionManager = this.std.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
@@ -129,15 +129,10 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
convertToEmbed = () => {
if (this._referenceToNode) return;
const { doc, caption } = this.model;
const { doc, caption, parent } = this.model;
const index = parent?.children.indexOf(this.model);
const parent = doc.getParent(this.model);
if (!parent) {
return;
}
const index = parent.children.indexOf(this.model);
doc.addBlock(
const blockId = doc.addBlock(
'affine:embed-synced-doc',
{
caption,
@@ -147,8 +142,11 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
index
);
this.std.selection.setGroup('note', []);
doc.deleteBlock(this.model);
this.std.selection.setGroup('note', [
this.std.selection.create(BlockSelection, { blockId }),
]);
};
covertToInline = () => {

View File

@@ -1,14 +1,26 @@
import { BlockViewExtension } from '@blocksuite/block-std';
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockServiceIdentifier,
BlockViewExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension.js';
import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension';
import { builtinToolbarConfig } from './configs/toolbar';
const flavour = EmbedLinkedDocBlockSchema.model.flavour;
export const EmbedLinkedDocBlockSpec: ExtensionType[] = [
BlockViewExtension('affine:embed-linked-doc', model => {
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-linked-doc-block`
: literal`affine-embed-linked-doc-block`;
}),
EmbedLinkedDocBlockAdapterExtensions,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: builtinToolbarConfig,
}),
].flat();

View File

@@ -1,21 +1,35 @@
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
import { EmbedLoomBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockServiceIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedLoomBlockAdapterExtensions } from './adapters/extension.js';
import { createBuiltinToolbarConfigForExternal } from '../configs/toolbar';
import { EmbedLoomBlockAdapterExtensions } from './adapters/extension';
import { EmbedLoomBlockComponent } from './embed-loom-block';
import {
EmbedLoomBlockOptionConfig,
EmbedLoomBlockService,
} from './embed-loom-service.js';
} from './embed-loom-service';
const flavour = EmbedLoomBlockSchema.model.flavour;
export const EmbedLoomBlockSpec: ExtensionType[] = [
FlavourExtension('affine:embed-loom'),
FlavourExtension(flavour),
EmbedLoomBlockService,
BlockViewExtension('affine:embed-loom', model => {
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-loom-block`
: literal`affine-embed-loom-block`;
}),
EmbedLoomBlockAdapterExtensions,
EmbedLoomBlockOptionConfig,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: createBuiltinToolbarConfigForExternal(EmbedLoomBlockComponent),
}),
].flat();

View File

@@ -0,0 +1,259 @@
import { toast } from '@blocksuite/affine-components/toast';
import { EmbedSyncedDocModel } from '@blocksuite/affine-model';
import {
ActionPlacement,
type OpenDocMode,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarContext,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import {
ArrowDownSmallIcon,
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ExpandFullIcon,
OpenInNewIcon,
} from '@blocksuite/icons/lit';
import { Slice } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { keyed } from 'lit/directives/keyed.js';
import { repeat } from 'lit/directives/repeat.js';
import { EmbedSyncedDocBlockComponent } from '../embed-synced-doc-block';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'embed view',
};
export const builtinToolbarConfig = {
actions: [
{
placement: ActionPlacement.Start,
id: 'A.open-doc',
actions: [
{
id: 'open-in-active-view',
label: 'Open this doc',
icon: ExpandFullIcon(),
},
],
content(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedSyncedDocBlockComponent
);
if (!component) return null;
const actions = this.actions
.map<ToolbarAction>(action => {
const shouldOpenInActiveView = action.id === 'open-in-active-view';
const allowed =
typeof action.when === 'function'
? action.when(ctx)
: (action.when ?? true);
return {
...action,
disabled: shouldOpenInActiveView
? component.model.pageId === ctx.store.id
: false,
when: allowed,
run: (_ctx: ToolbarContext) =>
component.open({
openMode: action.id as OpenDocMode,
}),
};
})
.filter(action => {
if (typeof action.when === 'function') return action.when(ctx);
return action.when ?? true;
});
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="Open doc" .tooltip=${'Open doc'}>
${OpenInNewIcon()} ${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
<div data-size="small" data-orientation="vertical">
${repeat(
actions,
action => action.id,
({ label, icon, run, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${ifDefined(
typeof disabled === 'function' ? disabled(ctx) : disabled
)}
@click=${() => run?.(ctx)}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'a.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedSyncedDocBlockComponent
);
component?.covertToInline();
// Clears
ctx.reset();
ctx.select('note');
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'inline view',
});
},
},
{
id: 'card',
label: 'Card view',
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedSyncedDocBlockComponent
);
component?.convertToCard();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'card view',
});
},
},
{
id: 'embed',
label: 'Embed view',
disabled: true,
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedSyncedDocModel
);
if (!model) return null;
const actions = this.actions.map(action => ({ ...action }));
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
model,
html`<affine-view-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.viewType$=${signal(actions[2].label)}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'b.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedSyncedDocBlockComponent
);
component?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
},
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
const slice = Slice.fromModels(ctx.store, [model]);
ctx.clipboard
.copySlice(slice)
.then(() => toast(ctx.host, 'Copied to clipboard'))
.catch(console.error);
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
const { flavour, parent } = model;
const props = getBlockProps(model);
const index = parent?.children.indexOf(model);
ctx.store.addBlock(flavour, props, parent, index);
},
},
],
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;

View File

@@ -291,15 +291,18 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSynce
}
const index = parent.children.indexOf(this.model);
doc.addBlock(
const blockId = doc.addBlock(
'affine:embed-linked-doc',
{ caption, ...this.referenceInfo, ...aliasInfo },
parent,
index
);
this.std.selection.setGroup('note', []);
doc.deleteBlock(this.model);
this.std.selection.setGroup('note', [
this.std.selection.create(BlockSelection, { blockId }),
]);
};
covertToInline = () => {
@@ -469,7 +472,7 @@ export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSynce
}
private _selectBlock() {
const selectionManager = this.host.selection;
const selectionManager = this.std.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});

View File

@@ -1,17 +1,30 @@
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockServiceIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedSyncedDocBlockAdapterExtensions } from './adapters/extension.js';
import { EmbedSyncedDocBlockService } from './embed-synced-doc-service.js';
import { EmbedSyncedDocBlockAdapterExtensions } from './adapters/extension';
import { builtinToolbarConfig } from './configs/toolbar';
import { EmbedSyncedDocBlockService } from './embed-synced-doc-service';
const flavour = EmbedSyncedDocBlockSchema.model.flavour;
export const EmbedSyncedDocBlockSpec: ExtensionType[] = [
FlavourExtension('affine:embed-synced-doc'),
FlavourExtension(flavour),
EmbedSyncedDocBlockService,
BlockViewExtension('affine:embed-synced-doc', model => {
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-synced-doc-block`
: literal`affine-embed-synced-doc-block`;
}),
EmbedSyncedDocBlockAdapterExtensions,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: builtinToolbarConfig,
}),
].flat();

View File

@@ -1,21 +1,35 @@
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
import { EmbedYoutubeBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockServiceIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedYoutubeBlockAdapterExtensions } from './adapters/extension.js';
import { createBuiltinToolbarConfigForExternal } from '../configs/toolbar';
import { EmbedYoutubeBlockAdapterExtensions } from './adapters/extension';
import { EmbedYoutubeBlockComponent } from './embed-youtube-block';
import {
EmbedYoutubeBlockOptionConfig,
EmbedYoutubeBlockService,
} from './embed-youtube-service.js';
} from './embed-youtube-service';
const flavour = EmbedYoutubeBlockSchema.model.flavour;
export const EmbedYoutubeBlockSpec: ExtensionType[] = [
FlavourExtension('affine:embed-youtube'),
FlavourExtension(flavour),
EmbedYoutubeBlockService,
BlockViewExtension('affine:embed-youtube', model => {
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-youtube-block`
: literal`affine-embed-youtube-block`;
}),
EmbedYoutubeBlockAdapterExtensions,
EmbedYoutubeBlockOptionConfig,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: createBuiltinToolbarConfigForExternal(EmbedYoutubeBlockComponent),
}),
].flat();

View File

@@ -9,11 +9,14 @@ import { EmbedSyncedDocBlockSpec } from './embed-synced-doc-block';
import { EmbedYoutubeBlockSpec } from './embed-youtube-block';
export const EmbedExtensions: ExtensionType[] = [
// External embed blocks
EmbedFigmaBlockSpec,
EmbedGithubBlockSpec,
EmbedHtmlBlockSpec,
EmbedLoomBlockSpec,
EmbedYoutubeBlockSpec,
// Internal embed blocks
EmbedHtmlBlockSpec,
EmbedLinkedDocBlockSpec,
EmbedSyncedDocBlockSpec,
].flat();
@@ -22,7 +25,7 @@ export { createEmbedBlockHtmlAdapterMatcher } from './common/adapters/html';
export { createEmbedBlockMarkdownAdapterMatcher } from './common/adapters/markdown';
export { createEmbedBlockPlainTextAdapterMatcher } from './common/adapters/plain-text';
export { EmbedBlockComponent } from './common/embed-block-element';
export { insertEmbedCard } from './common/insert-embed-card.js';
export { insertEmbedCard } from './common/insert-embed-card';
export * from './common/render-linked-doc';
export { toEdgelessEmbedBlock } from './common/to-edgeless-embed-block';
export * from './common/utils';
@@ -33,3 +36,4 @@ export * from './embed-linked-doc-block';
export * from './embed-loom-block';
export * from './embed-synced-doc-block';
export * from './embed-youtube-block';
export * from './types';

View File

@@ -0,0 +1,33 @@
import type { BlockComponent } from '@blocksuite/block-std';
import { EmbedFigmaBlockComponent } from './embed-figma-block';
import { EmbedGithubBlockComponent } from './embed-github-block';
import type { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block';
import { EmbedLoomBlockComponent } from './embed-loom-block';
import type { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block';
import { EmbedYoutubeBlockComponent } from './embed-youtube-block';
export type ExternalEmbedBlockComponent =
| EmbedFigmaBlockComponent
| EmbedGithubBlockComponent
| EmbedLoomBlockComponent
| EmbedYoutubeBlockComponent;
export type InternalEmbedBlockComponent =
| EmbedLinkedDocBlockComponent
| EmbedSyncedDocBlockComponent;
export type LinkableEmbedBlockComponent =
| ExternalEmbedBlockComponent
| InternalEmbedBlockComponent;
export function isExternalEmbedBlockComponent(
block: BlockComponent
): block is ExternalEmbedBlockComponent {
return (
block instanceof EmbedFigmaBlockComponent ||
block instanceof EmbedGithubBlockComponent ||
block instanceof EmbedLoomBlockComponent ||
block instanceof EmbedYoutubeBlockComponent
);
}

View File

@@ -6,9 +6,11 @@ import {
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { ImageBlockAdapterExtensions } from './adapters/extension.js';
import { ImageProxyService } from './image-proxy-service.js';
import { ImageBlockService, ImageDropOption } from './image-service.js';
import { ImageBlockAdapterExtensions } from './adapters/extension';
import { ImageProxyService } from './image-proxy-service';
import { ImageBlockService, ImageDropOption } from './image-service';
const flavour = 'affine:image';
export const imageToolbarWidget = WidgetViewExtension(
'affine:image',
@@ -17,9 +19,9 @@ export const imageToolbarWidget = WidgetViewExtension(
);
export const ImageBlockSpec: ExtensionType[] = [
FlavourExtension('affine:image'),
FlavourExtension(flavour),
ImageBlockService,
BlockViewExtension('affine:image', model => {
BlockViewExtension(flavour, model => {
const parent = model.doc.getParent(model.id);
if (parent?.flavour === 'affine:surface') {

View File

@@ -13,17 +13,18 @@
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-database": "workspace:*",
"@blocksuite/affine-block-embed": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/data-view": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.1",
"@blocksuite/inline": "workspace:*",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.12",

View File

@@ -0,0 +1,374 @@
import {
convertToDatabase,
DATABASE_CONVERT_WHITE_LIST,
} from '@blocksuite/affine-block-database';
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import {
deleteTextCommand,
formatBlockCommand,
formatNativeCommand,
formatTextCommand,
isFormatSupported,
textConversionConfigs,
textFormatConfigs,
} from '@blocksuite/affine-components/rich-text';
import { toast } from '@blocksuite/affine-components/toast';
import {
copySelectedModelsCommand,
deleteSelectedModelsCommand,
draftSelectedModelsCommand,
duplicateSelectedModelsCommand,
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedBlocksCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type {
ToolbarAction,
ToolbarActionGenerator,
ToolbarActionGroup,
ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { ActionPlacement } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { type BlockComponent, BlockSelection } from '@blocksuite/block-std';
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
import {
ArrowDownSmallIcon,
CopyIcon,
DatabaseTableViewIcon,
DeleteIcon,
DuplicateIcon,
LinkedPageIcon,
} from '@blocksuite/icons/lit';
import { toDraftModel } from '@blocksuite/store';
import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { updateBlockType } from '../commands';
const conversionsActionGroup = {
id: 'a.conversions',
when: ({ chain }) => isFormatSupported(chain).run()[0],
generate({ chain }) {
const [ok, { selectedModels = [] }] = chain
.tryAll(chain => [
chain.pipe(getTextSelectionCommand),
chain.pipe(getBlockSelectionsCommand),
])
.pipe(getSelectedModelsCommand, { types: ['text', 'block'] })
.run();
// only support model with text
// TODO(@fundon): displays only in a single paragraph, `length === 1`.
const allowed = ok && selectedModels.filter(model => model.text).length > 0;
if (!allowed) return null;
const model = selectedModels[0];
const conversion =
textConversionConfigs.find(
({ flavour, type }) =>
flavour === model.flavour &&
(type ? 'type' in model && type === model.type : true)
) ?? textConversionConfigs[0];
const update = (flavour: string, type?: string) => {
chain
.pipe(updateBlockType, {
flavour,
...(type && { props: { type } }),
})
.run();
};
return {
content: html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="Conversions"
.tooltip="${'Turn Into'}"
>
${conversion.icon} ${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${repeat(
textConversionConfigs.filter(c => c.flavour !== 'affine:divider'),
item => item.name,
({ flavour, type, name, icon }) => html`
<editor-menu-action
aria-label=${name}
?data-selected=${conversion.name === name}
@click=${() => update(flavour, type)}
>
${icon}<span class="label">${name}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`,
};
},
} as const satisfies ToolbarActionGenerator;
const inlineTextActionGroup = {
id: 'b.inline-text',
when: ({ chain }) => isFormatSupported(chain).run()[0],
actions: textFormatConfigs.map(
({ id, name, action, activeWhen, icon }, score) => {
return {
id,
icon,
score,
tooltip: name,
run: ({ host }) => action(host),
active: ({ host }) => activeWhen(host),
};
}
),
} as const satisfies ToolbarActionGroup;
const highlightActionGroup = {
id: 'c.highlight',
when: ({ chain }) => isFormatSupported(chain).run()[0],
content({ chain }) {
const updateHighlight = (styles: AffineTextAttributes) => {
const payload = { styles };
chain
.try(chain => [
chain.pipe(getTextSelectionCommand).pipe(formatTextCommand, payload),
chain
.pipe(getBlockSelectionsCommand)
.pipe(formatBlockCommand, payload),
chain.pipe(formatNativeCommand, payload),
])
.run();
};
return html`
<affine-highlight-dropdown-menu
.updateHighlight=${updateHighlight}
></affine-highlight-dropdown-menu>
`;
},
} as const satisfies ToolbarAction;
export const turnIntoDatabase = {
id: 'd.convert-to-database',
tooltip: 'Create Table',
icon: DatabaseTableViewIcon(),
when({ 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 [ok] = chain
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
})
.pipe(middleware(1))
.run();
if (ok) return true;
[ok] = chain
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedBlocksCommand, {
types: ['block', 'image'],
})
.pipe(middleware(0))
.run();
return ok;
},
run({ host }) {
convertToDatabase(host, tableViewMeta.type);
},
} as const satisfies ToolbarAction;
export const turnIntoLinkedDoc = {
id: 'e.convert-to-linked-doc',
tooltip: 'Create Linked Doc',
icon: LinkedPageIcon(),
when({ chain }) {
const [ok, { selectedModels }] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'flat',
})
.run();
return ok && Boolean(selectedModels?.length);
},
run({ chain, store, selection, std, track }) {
const [ok, { draftedModels, selectedModels }] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'flat',
})
.pipe(draftSelectedModelsCommand)
.run();
if (!ok || !draftedModels || !selectedModels?.length) return;
selection.clear();
const autofill = getTitleFromSelectedModels(
selectedModels.map(toDraftModel)
);
promptDocTitle(std, autofill)
.then(async title => {
if (title === null) return;
await convertSelectedBlocksToLinkedDoc(
std,
store,
draftedModels,
title
);
notifyDocCreated(std, store);
track('DocCreated', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
control: 'create linked doc',
type: 'embed-linked-doc',
});
track('LinkedDocCreated', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
control: 'create linked doc',
type: 'embed-linked-doc',
});
})
.catch(console.error);
},
} as const satisfies ToolbarAction;
export const builtinToolbarConfig = {
actions: [
conversionsActionGroup,
inlineTextActionGroup,
highlightActionGroup,
turnIntoDatabase,
turnIntoLinkedDoc,
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run({ chain, host }) {
const [ok] = chain
.pipe(getSelectedModelsCommand)
.pipe(draftSelectedModelsCommand)
.pipe(copySelectedModelsCommand)
.run();
if (!ok) return;
toast(host, 'Copied to clipboard');
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run({ chain, store, selection }) {
store.captureSync();
const [ok, { selectedBlocks = [] }] = chain
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
mode: 'highest',
})
.run();
// If text selection exists, convert to block selection
if (ok && selectedBlocks.length) {
selection.setGroup(
'note',
selectedBlocks.map(block =>
selection.create(BlockSelection, {
blockId: block.model.id,
})
)
);
}
chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'image'],
mode: 'highest',
})
.pipe(draftSelectedModelsCommand)
.pipe(duplicateSelectedModelsCommand)
.run();
},
},
],
when(ctx) {
return !ctx.flags.isNative();
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
actions: [
{
id: 'delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run({ chain }) {
// removes text
const [ok] = chain
.pipe(getTextSelectionCommand)
.pipe(deleteTextCommand)
.run();
if (ok) return;
// removes blocks
chain
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedModelsCommand)
.pipe(deleteSelectedModelsCommand)
.run();
},
},
],
when(ctx) {
return !ctx.flags.isNative();
},
},
],
} as const satisfies ToolbarModuleConfig;

View File

@@ -1,4 +1,10 @@
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
import { NoteBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockFlavourIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
@@ -6,18 +12,29 @@ import {
DocNoteBlockAdapterExtensions,
EdgelessNoteBlockAdapterExtensions,
} from './adapters/index.js';
import { builtinToolbarConfig } from './configs/toolbar.js';
import { NoteBlockService } from './note-service.js';
const flavour = NoteBlockSchema.model.flavour;
export const NoteBlockSpec: ExtensionType[] = [
FlavourExtension('affine:note'),
FlavourExtension(flavour),
NoteBlockService,
BlockViewExtension('affine:note', literal`affine-note`),
BlockViewExtension(flavour, literal`affine-note`),
DocNoteBlockAdapterExtensions,
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
].flat();
export const EdgelessNoteBlockSpec: ExtensionType[] = [
FlavourExtension('affine:note'),
FlavourExtension(flavour),
NoteBlockService,
BlockViewExtension('affine:note', literal`affine-edgeless-note`),
BlockViewExtension(flavour, literal`affine-edgeless-note`),
EdgelessNoteBlockAdapterExtensions,
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
].flat();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,7 +63,12 @@
"./block-zero-width": "./src/block-zero-width/index.ts",
"./block-selection": "./src/block-selection/index.ts",
"./doc-title": "./src/doc-title/index.ts",
"./embed-card-modal": "./src/embed-card-modal/index.ts"
"./embed-card-modal": "./src/embed-card-modal/index.ts",
"./link-preview": "./src/link-preview/index.ts",
"./linked-doc-title": "./src/linked-doc-title/index.ts",
"./view-dropdown-menu": "./src/view-dropdown-menu/index.ts",
"./card-style-dropdown-menu": "./src/card-style-dropdown-menu/index.ts",
"./highlight-dropdown-menu": "./src/highlight-dropdown-menu/index.ts"
},
"files": [
"src",

View File

@@ -0,0 +1,115 @@
import type { ColorScheme } from '@blocksuite/affine-model';
import {
type ToolbarAction,
ToolbarContext,
} from '@blocksuite/affine-shared/services';
import {
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/block-std';
import { SignalWatcher } from '@blocksuite/global/utils';
import { PaletteIcon } from '@blocksuite/icons/lit';
import {
computed,
type ReadonlySignal,
type Signal,
} from '@preact/signals-core';
import { property } from 'lit/decorators.js';
import { html, type TemplateResult } from 'lit-html';
import { ifDefined } from 'lit-html/directives/if-defined.js';
import { repeat } from 'lit-html/directives/repeat.js';
import {
EmbedCardDarkHorizontalIcon,
EmbedCardDarkListIcon,
EmbedCardLightHorizontalIcon,
EmbedCardLightListIcon,
} from '../icons';
const cardStyleMap: Record<ColorScheme, Record<string, TemplateResult>> = {
light: {
horizontal: EmbedCardLightHorizontalIcon,
list: EmbedCardLightListIcon,
},
dark: {
horizontal: EmbedCardDarkHorizontalIcon,
list: EmbedCardDarkListIcon,
},
};
@requiredProperties({
actions: PropTypes.array,
context: PropTypes.instanceOf(ToolbarContext),
style$: PropTypes.object,
})
export class CardStyleDropdownMenu extends SignalWatcher(ShadowlessElement) {
@property({ attribute: false })
accessor actions!: ToolbarAction[];
@property({ attribute: false })
accessor context!: ToolbarContext;
@property({ attribute: false })
accessor style$!: Signal<string> | ReadonlySignal<string>;
@property({ attribute: false })
accessor toggle: ((e: CustomEvent<boolean>) => void) | undefined;
icons$ = computed(
() => cardStyleMap[this.context.themeProvider.theme$.value]
);
override render() {
const {
actions,
context,
toggle,
style$: { value: style },
icons$: { value: icons },
} = this;
return html`
<editor-menu-button
@toggle=${toggle}
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="Card style"
.tooltip="${'Card style'}"
>
${PaletteIcon()}
</editor-icon-button>
`}
>
<div>
${repeat(
actions,
action => action.id,
({ id, label, icon, disabled, run }) => html`
<editor-icon-button
aria-label="${label}"
data-testid="${id}"
.tooltip="${label}"
.activeMode="${'border'}"
.iconContainerWidth="${'76px'}"
.iconContainerHeight="${'76px'}"
?active="${id === style}"
?disabled="${ifDefined(disabled)}"
@click=${() => run?.(context)}
>
${icon || icons[id]}
</editor-icon-button>
`
)}
</div>
</editor-menu-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-card-style-dropdown-menu': CardStyleDropdownMenu;
}
}

View File

@@ -0,0 +1,10 @@
import { CardStyleDropdownMenu } from './dropdown-menu';
export * from './dropdown-menu';
export function effects() {
customElements.define(
'affine-card-style-dropdown-menu',
CardStyleDropdownMenu
);
}

View File

@@ -20,7 +20,11 @@ import type {
BlockStdScope,
EditorHost,
} from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import {
nextTick,
SignalWatcher,
WithDisposable,
} from '@blocksuite/global/utils';
import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom';
import { computed, signal } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
@@ -137,6 +141,7 @@ export class EmbedCardEditModal extends SignalWatcher(
private readonly _hide = () => {
this.remove();
this.abortController?.abort();
};
private readonly _onKeydown = (e: KeyboardEvent) => {
@@ -146,7 +151,7 @@ export class EmbedCardEditModal extends SignalWatcher(
}
if (e.key === 'Escape') {
e.preventDefault();
this.remove();
this._hide();
}
};
@@ -154,7 +159,7 @@ export class EmbedCardEditModal extends SignalWatcher(
const blockComponent = this._blockComponent;
if (!blockComponent) {
this.remove();
this._hide();
return;
}
@@ -168,14 +173,14 @@ export class EmbedCardEditModal extends SignalWatcher(
track(std, this.model, this.viewType, 'ResetedAlias', { control: 'reset' });
this.remove();
this._hide();
};
private readonly _onSave = () => {
const blockComponent = this._blockComponent;
if (!blockComponent) {
this.remove();
this._hide();
return;
}
@@ -196,7 +201,7 @@ export class EmbedCardEditModal extends SignalWatcher(
track(std, this.model, this.viewType, 'SavedAlias', { control: 'save' });
this.remove();
this._hide();
};
private readonly _updateDescription = (e: InputEvent) => {
@@ -278,7 +283,10 @@ export class EmbedCardEditModal extends SignalWatcher(
})
);
this.disposables.add(listenClickAway(this, this._hide));
// Resolves the click event is triggered after the first rendering.
nextTick()
.then(() => this.disposables.add(listenClickAway(this, this._hide)))
.catch(console.error);
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this.disposables.addFromEvent(this, 'pointerdown', stopPropagation);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
@@ -401,6 +409,9 @@ export class EmbedCardEditModal extends SignalWatcher(
@property({ attribute: false })
accessor viewType!: string;
@property({ attribute: false })
accessor abortController: AbortController | undefined = undefined;
}
export function toggleEmbedCardEditModal(
@@ -413,7 +424,8 @@ export function toggleEmbedCardEditModal(
std: BlockStdScope,
component: BlockComponent,
props: AliasInfo
) => void
) => void,
abortController?: AbortController
) {
document.body.querySelector('embed-card-edit-modal')?.remove();
@@ -424,6 +436,7 @@ export function toggleEmbedCardEditModal(
embedCardEditModal.originalDocInfo = originalDocInfo;
embedCardEditModal.onReset = onReset;
embedCardEditModal.onSave = onSave;
embedCardEditModal.abortController = abortController;
document.body.append(embedCardEditModal);
}

View File

@@ -0,0 +1,120 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { PropTypes, requiredProperties } from '@blocksuite/block-std';
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
import { LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit-html';
import { repeat } from 'lit-html/directives/repeat.js';
const colors = [
'default',
'red',
'orange',
'yellow',
'green',
'teal',
'blue',
'purple',
'grey',
] as const;
type HighlightType = 'color' | 'background';
// TODO(@fundon): these recent settings should be added to the dropdown menu
// blocksuite/tests-legacy/e2e/format-bar.spec.ts#253
//
// let latestHighlightColor: string | null = null;
// let latestHighlightType: HighlightType = 'background';
@requiredProperties({
updateHighlight: PropTypes.instanceOf(Function),
})
export class HighlightDropdownMenu extends LitElement {
@property({ attribute: false })
accessor updateHighlight!: (styles: AffineTextAttributes) => void;
private readonly _update = (value: string | null, type: HighlightType) => {
// latestHighlightColor = value;
// latestHighlightType = type;
this.updateHighlight({ [`${type}`]: value });
};
override render() {
const prefix = '--affine-text-highlight';
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="highlight" .tooltip="${'Highlight'}">
<affine-highlight-duotone-icon
style=${styleMap({
'--color':
// latestHighlightColor ?? 'var(--affine-text-primary-color)',
'var(--affine-text-primary-color)',
})}
></affine-highlight-duotone-icon>
${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
<div class="highlight-heading">Color</div>
${repeat(colors, color => {
const isDefault = color === 'default';
const value = isDefault
? null
: `var(${prefix}-foreground-${color})`;
return html`
<editor-menu-action
data-testid="foreground-${color}"
@click=${() => this._update(value, 'color')}
>
<affine-text-duotone-icon
style=${styleMap({
'--color': value ?? 'var(--affine-text-primary-color)',
})}
></affine-text-duotone-icon>
<span class="label capitalize"
>${isDefault ? `${color} color` : color}</span
>
</editor-menu-action>
`;
})}
<div class="highlight-heading">Background</div>
${repeat(colors, color => {
const isDefault = color === 'default';
const value = isDefault ? null : `var(${prefix}-${color})`;
return html`
<editor-menu-action
data-testid="background-${color}"
@click=${() => this._update(value, 'background')}
>
<affine-text-duotone-icon
style=${styleMap({
'--color': 'var(--affine-text-primary-color)',
'--background': value ?? 'transparent',
})}
></affine-text-duotone-icon>
<span class="label capitalize"
>${isDefault ? `${color} background` : color}</span
>
</editor-menu-action>
`;
})}
</div>
</editor-menu-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-highlight-dropdown-menu': HighlightDropdownMenu;
}
}

View File

@@ -0,0 +1,23 @@
import { HighLightDuotoneIcon } from '@blocksuite/icons/lit';
import { css, LitElement } from 'lit';
export class HighlightDuotoneIcon extends LitElement {
static override styles = css`
svg {
display: flex;
font-size: 20px;
}
svg > path:nth-child(1) {
fill: var(--color, unset);
}
`;
override render() {
return HighLightDuotoneIcon();
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-highlight-duotone-icon': HighlightDuotoneIcon;
}
}

View File

@@ -0,0 +1,16 @@
import { HighlightDropdownMenu } from './dropdown-menu';
import { HighlightDuotoneIcon } from './highlight-duotone-icon';
import { TextDuotoneIcon } from './text-duotone-icon';
export * from './dropdown-menu';
export * from './highlight-duotone-icon';
export * from './text-duotone-icon';
export function effects() {
customElements.define(
'affine-highlight-dropdown-menu',
HighlightDropdownMenu
);
customElements.define('affine-highlight-duotone-icon', HighlightDuotoneIcon);
customElements.define('affine-text-duotone-icon', TextDuotoneIcon);
}

View File

@@ -0,0 +1,26 @@
import { TextBackgroundDuotoneIcon } from '@blocksuite/icons/lit';
import { css, LitElement } from 'lit';
export class TextDuotoneIcon extends LitElement {
static override styles = css`
svg {
display: flex;
font-size: 20px;
}
svg > path:nth-child(1) {
fill: var(--background, unset);
}
svg > path:nth-child(3) {
fill: var(--color, unset);
}
`;
override render() {
return TextBackgroundDuotoneIcon();
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-text-duotone-icon': TextDuotoneIcon;
}
}

View File

@@ -12,7 +12,7 @@ export const dedupe = (keepWhenFloatingNotReady = true): HoverMiddleware => {
let hoverState = false;
return ({ event, floatingElement }) => {
const curState = hoverState;
if (event.type === 'mouseover') {
if (event.type === 'mouseenter') {
// hover in
hoverState = true;
if (curState !== hoverState)
@@ -55,7 +55,7 @@ export const delayShow = (delay: number): HoverMiddleware => {
abortController.abort();
const newAbortController = new AbortController();
abortController = newAbortController;
if (event.type !== 'mouseover') return true;
if (event.type !== 'mouseenter') return true;
if (delay <= 0) return true;
await sleep(delay, newAbortController.signal);
return !newAbortController.signal.aborted;

View File

@@ -80,7 +80,7 @@ export const whenHover = (
}
// ignore expired event
if (e !== currentEvent) return;
const isHover = e.type === 'mouseover' ? true : false;
const isHover = e.type === 'mouseenter' ? true : false;
whenHoverChange(isHover, e);
}) as (e: Event) => void;
@@ -90,9 +90,9 @@ export const whenHover = (
const alreadyHover = element.matches(':hover');
if (alreadyHover && !abortController.signal.aborted) {
// When the element is already hovered, we need to trigger the callback manually
onHoverChange(new MouseEvent('mouseover'));
onHoverChange(new MouseEvent('mouseenter'));
}
element.addEventListener('mouseover', onHoverChange, {
element.addEventListener('mouseenter', onHoverChange, {
capture: true,
signal: abortController.signal,
});
@@ -112,7 +112,9 @@ export const whenHover = (
const removeHoverListener = (element?: Element) => {
if (!element) return;
element.removeEventListener('mouseover', onHoverChange);
element.removeEventListener('mouseenter', onHoverChange, {
capture: true,
});
element.removeEventListener('mouseleave', onHoverChange);
};

View File

@@ -0,0 +1,7 @@
import { LinkPreview } from './link';
export * from './link';
export function effects() {
customElements.define('affine-link-preview', LinkPreview);
}

View File

@@ -0,0 +1,73 @@
import { getHostName } from '@blocksuite/affine-shared/utils';
import {
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/block-std';
import { css } from 'lit';
import { property } from 'lit/decorators.js';
import { html } from 'lit-html';
@requiredProperties({
url: PropTypes.string,
})
export class LinkPreview extends ShadowlessElement {
static override styles = css`
.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);
}
`;
@property({ attribute: false })
accessor url!: string;
override render() {
const { url } = this;
return html`
<a
class="affine-link-preview"
rel="noopener noreferrer"
target="_blank"
href=${url}
>
<span>${getHostName(url)}</span>
</a>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-link-preview': LinkPreview;
}
}

View File

@@ -0,0 +1,65 @@
import {
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/block-std';
import { css } from 'lit';
import { property } from 'lit/decorators.js';
import { html } from 'lit-html';
@requiredProperties({
title: PropTypes.string,
open: PropTypes.instanceOf(Function),
})
export class DocTitle extends ShadowlessElement {
static override styles = css`
editor-icon-button .label {
min-width: 60px;
max-width: 140px;
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;
}
`;
@property({ attribute: false })
override accessor title!: string;
@property({ attribute: false })
accessor open!: (event: MouseEvent) => void;
override render() {
const { title, open } = this;
return html`
<editor-icon-button
aria-label="Doc title"
.hover=${false}
.labelHeight="${'20px'}"
.tooltip=${title}
@click=${(event: MouseEvent) => open(event)}
>
<span class="label">${title}</span>
</editor-icon-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-linked-doc-title': DocTitle;
}
}

View File

@@ -0,0 +1,7 @@
import { DocTitle } from './doc-title';
export * from './doc-title';
export function effects() {
customElements.define('affine-linked-doc-title', DocTitle);
}

View File

@@ -10,8 +10,7 @@ import { LatexEditorMenu } from './inline/presets/nodes/latex-node/latex-editor-
import { LatexEditorUnit } from './inline/presets/nodes/latex-node/latex-editor-unit.js';
import { AffineLatexNode } from './inline/presets/nodes/latex-node/latex-node.js';
import { LinkPopup } from './inline/presets/nodes/link-node/link-popup/link-popup.js';
import { ReferenceAliasPopup } from './inline/presets/nodes/reference-node/reference-alias-popup.js';
import { ReferencePopup } from './inline/presets/nodes/reference-node/reference-popup.js';
import { ReferencePopup } from './inline/presets/nodes/reference-node/reference-popup/reference-popup.js';
import { RichText } from './rich-text.js';
export function effects() {
@@ -23,7 +22,6 @@ export function effects() {
customElements.define('link-popup', LinkPopup);
customElements.define('affine-link', AffineLink);
customElements.define('reference-popup', ReferencePopup);
customElements.define('reference-alias-popup', ReferenceAliasPopup);
customElements.define('affine-reference', AffineReference);
customElements.define('affine-footnote-node', AffineFootnoteNode);
customElements.define('footnote-popup', FootNotePopup);
@@ -41,7 +39,6 @@ declare global {
'affine-text': AffineText;
'rich-text': RichText;
'reference-popup': ReferencePopup;
'reference-alias-popup': ReferenceAliasPopup;
'latex-editor-unit': LatexEditorUnit;
'latex-editor-menu': LatexEditorMenu;
'link-popup': LinkPopup;

View File

@@ -68,7 +68,7 @@ export const toggleUnderline = toggleTextStyleCommandWrapper('underline');
export const toggleStrike = toggleTextStyleCommandWrapper('strike');
export const toggleCode = toggleTextStyleCommandWrapper('code');
export const toggleLink: Command = (_ctx, next) => {
export const toggleLink: Command = (ctx, next) => {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) return false;
@@ -92,8 +92,9 @@ export const toggleLink: Command = (_ctx, next) => {
const abortController = new AbortController();
const popup = toggleLinkPopup(
inlineEditor,
ctx.std,
'create',
inlineEditor,
targetInlineRange,
abortController
);

View File

@@ -1,12 +1,15 @@
import { FootNoteSchema, ReferenceInfoSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { StdIdentifier } from '@blocksuite/block-std';
import { BlockFlavourIdentifier, StdIdentifier } from '@blocksuite/block-std';
import type { InlineEditor, InlineRootElement } from '@blocksuite/inline';
import { html } from 'lit';
import { z } from 'zod';
import { InlineSpecExtension } from '../../extension/index.js';
import { FootNoteNodeConfigIdentifier } from './nodes/footnote-node/footnote-config.js';
import { builtinInlineLinkToolbarConfig } from './nodes/link-node/configs/toolbar.js';
import { builtinInlineReferenceToolbarConfig } from './nodes/reference-node/configs/toolbar.js';
import {
ReferenceNodeConfigIdentifier,
ReferenceNodeConfigProvider,
@@ -220,4 +223,14 @@ export const InlineSpecExtensions = [
LinkInlineSpecExtension,
LatexEditorUnitSpecExtension,
FootNoteInlineSpecExtension,
ToolbarModuleExtension({
id: BlockFlavourIdentifier('affine:reference'),
config: builtinInlineReferenceToolbarConfig,
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('affine:link'),
config: builtinInlineLinkToolbarConfig,
}),
];

View File

@@ -2,9 +2,4 @@ export { affineTextStyles } from './affine-text.js';
export * from './footnote-node/footnote-config.js';
export { AffineFootnoteNode } from './footnote-node/footnote-node.js';
export { AffineLink, toggleLinkPopup } from './link-node/index.js';
export * from './reference-node/reference-config.js';
export { AffineReference } from './reference-node/reference-node.js';
export type {
DocLinkClickedEvent,
RefNodeSlots,
} from './reference-node/types.js';
export * from './reference-node/index.js';

View File

@@ -1,13 +1,12 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import { ParseDocUrlProvider } from '@blocksuite/affine-shared/services';
import {
ParseDocUrlProvider,
ToolbarRegistryIdentifier,
} from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { BlockComponent, BlockStdScope } from '@blocksuite/block-std';
import {
BLOCK_ID_ATTR,
BlockSelection,
ShadowlessElement,
TextSelection,
} from '@blocksuite/block-std';
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import {
type DeltaInsert,
INLINE_ROOT_ATTR,
@@ -16,15 +15,13 @@ import {
} from '@blocksuite/inline';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { ref } from 'lit/directives/ref.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { HoverController } from '../../../../../hover/index.js';
import { RefNodeSlotsProvider } from '../../../../extension/index.js';
import { affineTextStyles } from '../affine-text.js';
import { toggleLinkPopup } from './link-popup/toggle-link-popup.js';
import { whenHover } from '../../../../../hover/index';
import { RefNodeSlotsProvider } from '../../../../extension/index';
import { affineTextStyles } from '../affine-text';
export class AffineLink extends ShadowlessElement {
export class AffineLink extends WithDisposable(ShadowlessElement) {
static override styles = css`
affine-link a:hover [data-v-text='true'] {
text-decoration: underline;
@@ -66,43 +63,41 @@ export class AffineLink extends ShadowlessElement {
});
};
private readonly _whenHover = new HoverController(
this,
({ abortController }) => {
if (this.block?.doc.readonly) {
return null;
}
if (!this.inlineEditor || !this.selfInlineRange) {
return null;
_whenHover = whenHover(
hovered => {
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
if (hovered) {
message$.value = {
flavour: 'affine:link',
element: this,
setFloating: this._whenHover.setFloating,
};
return;
}
const selection = this.std.selection;
const textSelection = selection?.find(TextSelection);
if (!!textSelection && !textSelection.isCollapsed()) {
return null;
}
const blockSelections = selection?.filter(BlockSelection);
if (blockSelections?.length) {
return null;
}
return {
template: toggleLinkPopup(
this.inlineEditor,
'view',
this.selfInlineRange,
abortController,
(e?: MouseEvent) => {
this.openLink(e);
abortController.abort();
}
),
};
// Clears previous bindings
message$.value = null;
this._whenHover.setFloating();
},
{ enterDelay: 500 }
);
override connectedCallback() {
super.connectedCallback();
this._whenHover.setReference(this);
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
this._disposables.add(() => {
if (message$?.value) {
message$.value = null;
}
this._whenHover.dispose();
});
}
// Workaround for links not working in contenteditable div
// see also https://stackoverflow.com/questions/12059211/how-to-make-clickable-anchor-in-contenteditable-div
//
@@ -149,7 +144,6 @@ export class AffineLink extends ShadowlessElement {
private _renderLink(style: StyleInfo) {
return html`<a
${ref(this._whenHover.setReference)}
href=${this.link}
rel="noopener noreferrer"
target="_blank"

View File

@@ -0,0 +1,351 @@
import {
ActionPlacement,
EmbedOptionProvider,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { BlockSelection } from '@blocksuite/block-std';
import {
CopyIcon,
DeleteIcon,
EditIcon,
UnlinkIcon,
} from '@blocksuite/icons/lit';
import { signal } from '@preact/signals-core';
import { html } from 'lit-html';
import { keyed } from 'lit-html/directives/keyed.js';
import { toast } from '../../../../../../toast';
import { AffineLink } from '../affine-link';
import { toggleLinkPopup } from '../link-popup/toggle-link-popup';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'link',
type: 'inline view',
};
export const builtinInlineLinkToolbarConfig = {
actions: [
{
id: 'a.preview',
content(cx) {
const target = cx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return null;
const { link } = target;
return html`<affine-link-preview .url=${link}></affine-link-preview>`;
},
},
{
id: 'b.copy-link-and-edit',
actions: [
{
id: 'copy-link',
tooltip: 'Copy link',
icon: CopyIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { link } = target;
if (!link) return;
// Clears
ctx.reset();
navigator.clipboard.writeText(link).catch(console.error);
toast(ctx.host, 'Copied link to clipboard');
ctx.track('CopiedLink', {
...trackBaseProps,
control: 'copy link',
});
},
},
{
id: 'edit',
tooltip: 'Edit',
icon: EditIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
const abortController = new AbortController();
const popover = toggleLinkPopup(
ctx.std,
'edit',
inlineEditor,
selfInlineRange,
abortController
);
abortController.signal.onabort = () => popover.remove();
ctx.track('OpenedAliasPopup', {
...trackBaseProps,
control: 'edit',
});
},
},
],
},
{
id: 'c.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
disabled: true,
},
{
id: 'card',
label: 'Card view',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
if (!target.block) return;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
const url = inlineEditor.getFormat(selfInlineRange).link;
if (!url) return;
// Clears
ctx.reset();
const title = inlineEditor.yTextString.slice(
selfInlineRange.index,
selfInlineRange.index + selfInlineRange.length
);
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
const flavour =
options?.viewType === 'card'
? options.flavour
: 'affine:bookmark';
const index = parent.children.indexOf(model);
const props = {
url,
title: title === url ? '' : title,
};
const blockId = ctx.store.addBlock(
flavour,
props,
parent,
index + 1
);
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.formatText(selfInlineRange, { link: null });
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'card view',
});
},
},
{
id: 'embed',
label: 'Embed view',
when(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return false;
if (!target.block) return false;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return false;
const url = inlineEditor.getFormat(selfInlineRange).link;
if (!url) return false;
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return options?.viewType === 'embed';
},
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
if (!target.block) return;
const {
block: { model },
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
const url = inlineEditor.getFormat(selfInlineRange).link;
if (!url) return;
// Clears
ctx.reset();
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (options?.viewType !== 'embed') return;
const flavour = options.flavour;
const index = parent.children.indexOf(model);
const props = { url };
const blockId = ctx.store.addBlock(
flavour,
props,
parent,
index + 1
);
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.formatText(selfInlineRange, { link: null });
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return null;
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal(actions[0].label);
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
target,
html`<affine-view-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.viewType$=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
when(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return false;
if (!target.block) return false;
if (ctx.flags.isNative()) return false;
if (
target.block.closest('affine-database') ||
target.block.closest('affine-table')
)
return false;
const { model } = target.block;
const parent = model.parent;
if (!parent) return false;
const schema = ctx.store.schema;
const bookmarkSchema = schema.flavourSchemaMap.get('affine:bookmark');
if (!bookmarkSchema) return false;
const parentSchema = schema.flavourSchemaMap.get(parent.flavour);
if (!parentSchema) return false;
try {
schema.validateSchema(bookmarkSchema, parentSchema);
} catch {
return false;
}
return true;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
placement: ActionPlacement.More,
id: 'b.remove-link',
label: 'Remove link',
icon: UnlinkIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
inlineEditor.formatText(selfInlineRange, { link: null });
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineLink)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
inlineEditor.deleteText(selfInlineRange);
},
},
],
} as const satisfies ToolbarModuleConfig;

View File

@@ -1,48 +1,20 @@
import {
EmbedOptionProvider,
type LinkEventType,
type TelemetryEvent,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { EmbedOptions } from '@blocksuite/affine-shared/types';
import {
getHostName,
isValidUrl,
normalizeUrl,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type BlockStdScope,
TextSelection,
} from '@blocksuite/block-std';
import { type BlockStdScope, TextSelection } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { ArrowDownSmallIcon, MoreVerticalIcon } from '@blocksuite/icons/lit';
import { DoneIcon } from '@blocksuite/icons/lit';
import type { InlineRange } from '@blocksuite/inline/types';
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
import { html, LitElement, nothing } from 'lit';
import { html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import {
ConfirmIcon,
CopyIcon,
DeleteIcon,
EditIcon,
OpenIcon,
UnlinkIcon,
} from '../../../../../../icons/index.js';
import { toast } from '../../../../../../toast/index.js';
import type { EditorIconButton } from '../../../../../../toolbar/index.js';
import {
renderActions,
renderToolbarSeparator,
} from '../../../../../../toolbar/index.js';
import type { AffineInlineEditor } from '../../../affine-inline-specs.js';
import { linkPopupStyle } from './styles.js';
import type { EditorIconButton } from '../../../../../../toolbar/index';
import type { AffineInlineEditor } from '../../../affine-inline-specs';
import { linkPopupStyle } from './styles';
export class LinkPopup extends WithDisposable(LitElement) {
static override styles = linkPopupStyle;
@@ -74,21 +46,6 @@ export class LinkPopup extends WithDisposable(LitElement) {
`;
};
private readonly _delete = () => {
if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
this.inlineEditor.deleteText(this.targetInlineRange);
}
this.abortController.abort();
};
private readonly _edit = () => {
if (!this.host) return;
this.type = 'edit';
track(this.host.std, 'OpenedAliasPopup', { control: 'edit' });
};
private readonly _editTemplate = () => {
this.updateComplete
.then(() => {
@@ -137,154 +94,6 @@ export class LinkPopup extends WithDisposable(LitElement) {
`;
};
private _embedOptions: EmbedOptions | null = null;
private readonly _openLink = () => {
if (this.openLink) {
this.openLink();
return;
}
let link = this.currentLink;
if (!link) return;
if (!link.match(/^[a-zA-Z]+:\/\//)) {
link = 'https://' + link;
}
window.open(link, '_blank');
this.abortController.abort();
};
private readonly _removeLink = () => {
if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
this.inlineEditor.formatText(this.targetInlineRange, {
link: null,
});
}
this.abortController.abort();
};
private readonly _toggleViewSelector = (e: Event) => {
if (!this.host) return;
const opened = (e as CustomEvent<boolean>).detail;
if (!opened) return;
track(this.host.std, 'OpenedViewSelector', { control: 'switch view' });
};
private readonly _trackViewSelected = (type: string) => {
if (!this.host) return;
track(this.host.std, 'SelectedView', {
control: 'select view',
type: `${type} view`,
});
};
private readonly _viewTemplate = () => {
if (!this.currentLink) return;
this._embedOptions =
this.std
?.get(EmbedOptionProvider)
.getEmbedBlockOptions(this.currentLink) ?? null;
const buttons = [
html`
<a
class="affine-link-preview"
href=${this.currentLink}
rel="noopener noreferrer"
target="_blank"
@click=${(e: MouseEvent) => this.openLink?.(e)}
>
<span>${getHostName(this.currentLink)}</span>
</a>
<editor-icon-button
aria-label="Copy"
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'}
@click=${this._edit}
>
${EditIcon}
</editor-icon-button>
`,
this._viewSelector(),
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="affine-link-popover view">
${join(
buttons.filter(button => button !== nothing),
renderToolbarSeparator
)}
</editor-toolbar>
`;
};
private get _canConvertToEmbedView() {
return this._embedOptions?.viewType === 'embed';
}
private get _isBookmarkAllowed() {
const block = this.block;
if (!block) return false;
const schema = block.doc.schema;
const parent = block.doc.getParent(block.model);
if (!parent) return false;
const bookmarkSchema = schema.flavourSchemaMap.get('affine:bookmark');
if (!bookmarkSchema) return false;
const parentSchema = schema.flavourSchemaMap.get(parent.flavour);
if (!parentSchema) return false;
try {
schema.validateSchema(bookmarkSchema, parentSchema);
} catch {
return false;
}
return true;
}
get block() {
const { rootElement } = this.inlineEditor;
if (!rootElement) return null;
const block = rootElement.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
if (!block) return null;
return block;
}
get currentLink() {
return this.inlineEditor.getFormat(this.targetInlineRange).link;
}
@@ -296,137 +105,19 @@ export class LinkPopup extends WithDisposable(LitElement) {
);
}
get host() {
return this.block?.host;
}
get std() {
return this.block?.std;
}
private _confirmBtnTemplate() {
return html`
<editor-icon-button
class="affine-confirm-button"
.iconSize=${'24px'}
.iconSize="${'24px'}"
.disabled=${true}
@click=${this._onConfirm}
>
${ConfirmIcon}
${DoneIcon()}
</editor-icon-button>
`;
}
private _convertToCardView() {
if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
return;
}
let targetFlavour = 'affine:bookmark';
if (this._embedOptions && this._embedOptions.viewType === 'card') {
targetFlavour = this._embedOptions.flavour;
}
const block = this.block;
if (!block) return;
const url = this.currentLink;
const title = this.currentText;
const props = {
url,
title: title === url ? '' : title,
};
const doc = block.doc;
const parent = doc.getParent(block.model);
if (!parent) return;
const index = parent.children.indexOf(block.model);
doc.addBlock(targetFlavour as never, props, parent, index + 1);
const totalTextLength = this.inlineEditor.yTextLength;
const inlineTextLength = this.targetInlineRange.length;
if (totalTextLength === inlineTextLength) {
doc.deleteBlock(block.model);
} else {
this.inlineEditor.formatText(this.targetInlineRange, { link: null });
}
this.abortController.abort();
}
private _convertToEmbedView() {
if (!this._embedOptions || this._embedOptions.viewType !== 'embed') {
return;
}
const { flavour } = this._embedOptions;
const url = this.currentLink;
const block = this.block;
if (!block) return;
const doc = block.doc;
const parent = doc.getParent(block.model);
if (!parent) return;
const index = parent.children.indexOf(block.model);
doc.addBlock(flavour as never, { url }, parent, index + 1);
const totalTextLength = this.inlineEditor.yTextLength;
const inlineTextLength = this.targetInlineRange.length;
if (totalTextLength === inlineTextLength) {
doc.deleteBlock(block.model);
} else {
this.inlineEditor.formatText(this.targetInlineRange, { link: null });
}
this.abortController.abort();
}
private _copyUrl() {
if (!this.currentLink) return;
navigator.clipboard.writeText(this.currentLink).catch(console.error);
if (!this.host) return;
toast(this.host, 'Copied link to clipboard');
this.abortController.abort();
track(this.host.std, 'CopiedLink', { control: 'copy link' });
}
private _moreActions() {
return renderActions([
[
{
label: 'Open',
type: 'open',
icon: OpenIcon,
action: this._openLink,
},
{
label: 'Copy',
type: 'copy',
icon: CopyIcon,
action: this._copyUrl,
},
{
label: 'Remove link',
type: 'remove-link',
icon: UnlinkIcon,
action: this._removeLink,
},
],
[
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
action: this._delete,
},
],
]);
}
private _onConfirm() {
if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) return;
if (!this.linkInput) return;
@@ -442,10 +133,6 @@ export class LinkPopup extends WithDisposable(LitElement) {
reference: null,
});
this.inlineEditor.setInlineRange(this.targetInlineRange);
const textSelection = this.host?.selection.find(TextSelection);
if (!textSelection) return;
this.std?.range.syncTextSelectionToRange(textSelection);
} else if (this.type === 'edit') {
const text = this.textInput?.value ?? link;
this.inlineEditor.insertText(this.targetInlineRange, text, {
@@ -456,10 +143,11 @@ export class LinkPopup extends WithDisposable(LitElement) {
index: this.targetInlineRange.index,
length: text.length,
});
const textSelection = this.host?.selection.find(TextSelection);
if (!textSelection) return;
}
this.std?.range.syncTextSelectionToRange(textSelection);
const textSelection = this.std.host.selection.find(TextSelection);
if (textSelection) {
this.std.range.syncTextSelectionToRange(textSelection);
}
this.abortController.abort();
@@ -467,9 +155,17 @@ export class LinkPopup extends WithDisposable(LitElement) {
private _onKeydown(e: KeyboardEvent) {
e.stopPropagation();
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
this._onConfirm();
if (!e.isComposing) {
if (e.key === 'Escape') {
e.preventDefault();
this.abortController.abort();
this.std.host.selection.clear();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
this._onConfirm();
}
}
}
@@ -484,70 +180,6 @@ export class LinkPopup extends WithDisposable(LitElement) {
this.confirmButton.requestUpdate();
}
private _viewSelector() {
if (!this._isBookmarkAllowed) return nothing;
const buttons = [];
buttons.push({
type: 'inline',
label: 'Inline view',
});
buttons.push({
type: 'card',
label: 'Card view',
action: () => this._convertToCardView(),
});
if (this._canConvertToEmbedView) {
buttons.push({
type: 'embed',
label: 'Embed view',
action: () => this._convertToEmbedView(),
});
}
return html`
<editor-menu-button
.contentPadding=${'8px'}
.button=${html`
<editor-icon-button
aria-label="Switch view"
.justify=${'space-between'}
.labelHeight=${'20px'}
.iconContainerWidth=${'110px'}
.iconSize=${'16px'}
>
<div class="label">Inline view</div>
${ArrowDownSmallIcon()}
</editor-icon-button>
`}
@toggle=${this._toggleViewSelector}
>
<div data-size="small" data-orientation="vertical">
${repeat(
buttons,
button => button.type,
({ type, label, action }) => html`
<editor-menu-action
data-testid=${`link-to-${type}`}
?data-selected=${type === 'inline'}
?disabled=${type === 'inline'}
@click=${() => {
action?.();
this._trackViewSelected(type);
}}
>
${label}
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
override connectedCallback() {
super.connectedCallback();
@@ -555,45 +187,38 @@ export class LinkPopup extends WithDisposable(LitElement) {
return;
}
if (this.type === 'edit' || this.type === 'create') {
// disable body scroll
this._bodyOverflowStyle = document.body.style.overflow;
document.body.style.overflow = 'hidden';
this.disposables.add({
dispose: () => {
document.body.style.overflow = this._bodyOverflowStyle;
},
});
}
// disable body scroll
this._bodyOverflowStyle = document.body.style.overflow;
document.body.style.overflow = 'hidden';
this.disposables.add({
dispose: () => {
document.body.style.overflow = this._bodyOverflowStyle;
},
});
}
protected override firstUpdated() {
if (!this.linkInput) return;
override firstUpdated() {
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this._disposables.addFromEvent(this.linkInput, 'copy', stopPropagation);
this._disposables.addFromEvent(this.linkInput, 'cut', stopPropagation);
this._disposables.addFromEvent(this.linkInput, 'paste', stopPropagation);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
this.disposables.addFromEvent(this.overlayMask, 'click', e => {
e.stopPropagation();
this.std.host.selection.setGroup('note', []);
this.abortController.abort();
});
}
override render() {
return html`
<div class="overlay-root">
${this.type === 'view'
? nothing
: html`
<div
class="affine-link-popover-overlay-mask"
@click=${() => {
this.abortController.abort();
this.host?.selection.clear();
}}
></div>
`}
<div class="affine-link-popover-container" @keydown=${this._onKeydown}>
<div class="overlay-mask"></div>
<div class="popover-container">
${choose(this.type, [
['create', this._createTemplate],
['edit', this._editTemplate],
['view', this._viewTemplate],
])}
</div>
<div class="mock-selection-container"></div>
@@ -607,29 +232,29 @@ export class LinkPopup extends WithDisposable(LitElement) {
return;
}
if (this.type !== 'view') {
const domRects = range.getClientRects();
const domRects = range.getClientRects();
Object.values(domRects).forEach(domRect => {
if (!this.mockSelectionContainer) {
return;
}
const mockSelection = document.createElement('div');
mockSelection.classList.add('mock-selection');
mockSelection.style.left = `${domRect.left}px`;
mockSelection.style.top = `${domRect.top}px`;
mockSelection.style.width = `${domRect.width}px`;
mockSelection.style.height = `${domRect.height}px`;
Object.values(domRects).forEach(domRect => {
if (!this.mockSelectionContainer) {
return;
}
const mockSelection = document.createElement('div');
mockSelection.classList.add('mock-selection');
mockSelection.style.left = `${domRect.left}px`;
mockSelection.style.top = `${domRect.top}px`;
mockSelection.style.width = `${domRect.width}px`;
mockSelection.style.height = `${domRect.height}px`;
this.mockSelectionContainer.append(mockSelection);
});
}
this.mockSelectionContainer.append(mockSelection);
});
const visualElement = {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects(),
};
computePosition(visualElement, this.popupContainer, {
const popover = this.popoverContainer;
computePosition(visualElement, popover, {
middleware: [
offset(10),
inline(),
@@ -639,10 +264,8 @@ export class LinkPopup extends WithDisposable(LitElement) {
],
})
.then(({ x, y }) => {
const popupContainer = this.popupContainer;
if (!popupContainer) return;
popupContainer.style.left = `${x}px`;
popupContainer.style.top = `${y}px`;
popover.style.left = `${x}px`;
popover.style.top = `${y}px`;
})
.catch(console.error);
}
@@ -662,11 +285,11 @@ export class LinkPopup extends WithDisposable(LitElement) {
@query('.mock-selection-container')
accessor mockSelectionContainer!: HTMLDivElement;
@property({ attribute: false })
accessor openLink: ((e?: MouseEvent) => void) | null = null;
@query('.overlay-mask')
accessor overlayMask!: HTMLDivElement;
@query('.affine-link-popover-container')
accessor popupContainer!: HTMLDivElement;
@query('.popover-container')
accessor popoverContainer!: HTMLDivElement;
@property({ attribute: false })
accessor targetInlineRange!: InlineRange;
@@ -675,20 +298,8 @@ export class LinkPopup extends WithDisposable(LitElement) {
accessor textInput: HTMLInputElement | null = null;
@property()
accessor type: 'create' | 'edit' | 'view' = 'create';
}
accessor type: 'create' | 'edit' = 'create';
function track(
std: BlockStdScope,
event: LinkEventType,
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'toolbar',
page: 'doc editor',
module: 'link toolbar',
type: 'inline view',
category: 'link',
...props,
});
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -99,7 +99,7 @@ export const linkPopupStyle = css`
background-color: rgba(35, 131, 226, 0.28);
}
.affine-link-popover-container {
.popover-container {
z-index: var(--affine-z-index-popover);
animation: affine-popover-fade-in 0.2s ease;
position: absolute;
@@ -116,7 +116,7 @@ export const linkPopupStyle = css`
}
}
.affine-link-popover-overlay-mask {
.overlay-mask {
position: fixed;
top: 0;
left: 0;
@@ -125,39 +125,6 @@ export const linkPopupStyle = css`
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);
}
.affine-link-popover.create {
${PANEL_BASE};
gap: 12px;

View File

@@ -1,20 +1,21 @@
import type { BlockStdScope } from '@blocksuite/block-std';
import type { InlineRange } from '@blocksuite/inline';
import type { AffineInlineEditor } from '../../../affine-inline-specs.js';
import { LinkPopup } from './link-popup.js';
import type { AffineInlineEditor } from '../../../affine-inline-specs';
import { LinkPopup } from './link-popup';
export function toggleLinkPopup(
inlineEditor: AffineInlineEditor,
std: BlockStdScope,
type: LinkPopup['type'],
inlineEditor: AffineInlineEditor,
targetInlineRange: InlineRange,
abortController: AbortController,
openLink: ((e?: MouseEvent) => void) | null = null
abortController: AbortController
): LinkPopup {
const popup = new LinkPopup();
popup.inlineEditor = inlineEditor;
popup.std = std;
popup.type = type;
popup.inlineEditor = inlineEditor;
popup.targetInlineRange = targetInlineRange;
popup.openLink = openLink;
popup.abortController = abortController;
document.body.append(popup);

View File

@@ -0,0 +1,241 @@
import {
ActionPlacement,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import {
cloneReferenceInfoWithoutAliases,
isInsideBlockByFlavour,
} from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import { DeleteIcon } from '@blocksuite/icons/lit';
import { signal } from '@preact/signals-core';
import { html } from 'lit-html';
import { keyed } from 'lit-html/directives/keyed.js';
import { notifyLinkedDocSwitchedToEmbed } from '../../../../../../notification';
import { AffineReference } from '../reference-node';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'inline view',
};
export const builtinInlineReferenceToolbarConfig = {
actions: [
{
id: 'a.doc-title',
content(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return null;
if (!target.referenceInfo.title) return null;
return html`<affine-linked-doc-title
.title=${target.docTitle}
.open=${(event: MouseEvent) => target.open({ event })}
></affine-linked-doc-title>`;
},
},
{
id: 'c.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
disabled: true,
},
{
id: 'card',
label: 'Card view',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return;
if (!target.block) return;
const {
block: { model },
referenceInfo,
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
// Clears
ctx.reset();
const index = parent.children.indexOf(model);
const blockId = ctx.store.addBlock(
'affine:embed-linked-doc',
referenceInfo,
parent,
index + 1
);
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.insertText(selfInlineRange, target.docTitle);
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'card view',
});
},
},
{
id: 'embed',
label: 'Embed view',
disabled(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return true;
if (!target.block) return true;
if (
isInsideBlockByFlavour(
ctx.store,
target.block.model,
'affine:edgeless-text'
)
)
return true;
// nesting is not supported
if (target.closest('affine-embed-synced-doc-block')) return true;
// same doc
if (target.referenceInfo.pageId === ctx.store.id) return true;
// linking to block
if (target.referenceToNode()) return true;
return false;
},
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return;
if (!target.block) return;
const {
block: { model },
referenceInfo,
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
// Clears
ctx.reset();
const index = parent.children.indexOf(model);
const blockId = ctx.store.addBlock(
'affine:embed-synced-doc',
cloneReferenceInfoWithoutAliases(referenceInfo),
parent,
index + 1
);
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.insertText(selfInlineRange, target.docTitle);
}
const hasTitleAlias = Boolean(referenceInfo.title);
if (hasTitleAlias) {
notifyLinkedDocSwitchedToEmbed(ctx.std);
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return null;
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal(actions[0].label);
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
target,
html`<affine-view-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.viewType$=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
when(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return false;
if (!target.block) return false;
if (ctx.flags.isNative()) return false;
if (
target.block.closest('affine-database') ||
target.block.closest('affine-table')
)
return false;
return true;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
inlineEditor.deleteText(selfInlineRange);
},
},
],
} as const satisfies ToolbarModuleConfig;

View File

@@ -0,0 +1,4 @@
export * from './reference-config';
export { AffineReference } from './reference-node';
export { toggleReferencePopup } from './reference-popup/toggle-reference-popup';
export type { DocLinkClickedEvent, RefNodeSlots } from './types';

View File

@@ -3,7 +3,7 @@ import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { TemplateResult } from 'lit';
import type { AffineReference } from './reference-node.js';
import type { AffineReference } from './reference-node';
export interface ReferenceNodeConfig {
customContent?: (reference: AffineReference) => TemplateResult;

View File

@@ -3,20 +3,17 @@ import {
DEFAULT_DOC_NAME,
REFERENCE_NODE,
} from '@blocksuite/affine-shared/consts';
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
import {
DocDisplayMetaProvider,
ToolbarRegistryIdentifier,
} from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
cloneReferenceInfo,
referenceToNode,
} from '@blocksuite/affine-shared/utils';
import {
BLOCK_ID_ATTR,
type BlockComponent,
BlockSelection,
type BlockStdScope,
ShadowlessElement,
TextSelection,
} from '@blocksuite/block-std';
import type { BlockComponent, BlockStdScope } from '@blocksuite/block-std';
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
import {
@@ -31,15 +28,14 @@ import { css, html, nothing } from 'lit';
import { property, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import { HoverController } from '../../../../../hover/index.js';
import { Peekable } from '../../../../../peek/index.js';
import { RefNodeSlotsProvider } from '../../../../extension/index.js';
import { affineTextStyles } from '../affine-text.js';
import type { ReferenceNodeConfigProvider } from './reference-config.js';
import { toggleReferencePopup } from './reference-popup.js';
import { whenHover } from '../../../../../hover/index';
import { Peekable } from '../../../../../peek/index';
import { RefNodeSlotsProvider } from '../../../../extension/index';
import { affineTextStyles } from '../affine-text';
import type { ReferenceNodeConfigProvider } from './reference-config';
import type { DocLinkClickedEvent } from './types';
@Peekable({ action: false })
export class AffineReference extends WithDisposable(ShadowlessElement) {
@@ -73,6 +69,10 @@ export class AffineReference extends WithDisposable(ShadowlessElement) {
}
`;
get docTitle() {
return this.refMeta?.title ?? DEFAULT_DOC_NAME;
}
private readonly _updateRefMeta = (doc: Store) => {
const refAttribute = this.delta.attributes?.reference;
if (!refAttribute) {
@@ -93,48 +93,6 @@ export class AffineReference extends WithDisposable(ShadowlessElement) {
@state()
accessor refMeta: DocMeta | undefined = undefined;
private readonly _whenHover: HoverController = new HoverController(
this,
({ abortController }) => {
if (
this.config.hidePopup ||
this.doc?.readonly ||
this.closest('.prevent-reference-popup') ||
!this.selfInlineRange ||
!this.inlineEditor
) {
return null;
}
const selection = this.std.selection;
if (!selection) {
return null;
}
const textSelection = selection.find(TextSelection);
if (!!textSelection && !textSelection.isCollapsed()) {
return null;
}
const blockSelections = selection.filter(BlockSelection);
if (blockSelections.length) {
return null;
}
return {
template: toggleReferencePopup(
this,
this.referenceToNode(),
this.referenceInfo,
this.inlineEditor,
this.selfInlineRange,
this.refMeta?.title ?? DEFAULT_DOC_NAME,
abortController
),
};
},
{ enterDelay: 500 }
);
get _icon() {
const { pageId, params, title } = this.referenceInfo;
return this.std
@@ -187,17 +145,52 @@ export class AffineReference extends WithDisposable(ShadowlessElement) {
return selfInlineRange;
}
private _onClick() {
readonly open = (event?: Partial<DocLinkClickedEvent>) => {
if (!this.config.interactable) return;
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit({
...this.referenceInfo,
...event,
host: this.std.host,
});
}
};
_whenHover = whenHover(
hovered => {
if (!this.config.interactable) return;
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
if (hovered) {
message$.value = {
flavour: 'affine:reference',
element: this,
setFloating: this._whenHover.setFloating,
};
return;
}
// Clears previous bindings
message$.value = null;
this._whenHover.setFloating();
},
{ enterDelay: 500 }
);
override connectedCallback() {
super.connectedCallback();
this._whenHover.setReference(this);
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
this._disposables.add(() => {
if (message$?.value) {
message$.value = null;
}
this._whenHover.dispose();
});
if (!this.config) {
console.error('`reference-node` need `ReferenceNodeConfig`.');
return;
@@ -281,11 +274,10 @@ export class AffineReference extends WithDisposable(ShadowlessElement) {
// we need to add `<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text>` in an
// embed element to make sure inline range calculation is correct
return html`<span
${this.config.interactable ? ref(this._whenHover.setReference) : ''}
data-selected=${this.selected}
class="affine-reference"
style=${styleMap(style)}
@click=${this._onClick}
@click=${(event: MouseEvent) => this.open({ event })}
>${content}<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text
></span>`;
}

View File

@@ -1,607 +0,0 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import {
GenerateDocUrlProvider,
type LinkEventType,
OpenDocExtensionIdentifier,
type OpenDocMode,
type TelemetryEvent,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
cloneReferenceInfoWithoutAliases,
isInsideBlockByFlavour,
} from '@blocksuite/affine-shared/utils';
import {
BLOCK_ID_ATTR,
type BlockComponent,
type BlockStdScope,
} from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { ArrowDownSmallIcon, MoreVerticalIcon } from '@blocksuite/icons/lit';
import type { InlineRange } from '@blocksuite/inline';
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
import { effect } from '@preact/signals-core';
import { html, LitElement, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { join } from 'lit/directives/join.js';
import { repeat } from 'lit/directives/repeat.js';
import {
CopyIcon,
DeleteIcon,
EditIcon,
OpenIcon,
} from '../../../../../icons/index.js';
import { notifyLinkedDocSwitchedToEmbed } from '../../../../../notification/index.js';
import { isPeekable, peek } from '../../../../../peek/index.js';
import { toast } from '../../../../../toast/toast.js';
import {
type MenuItem,
renderActions,
renderToolbarSeparator,
} from '../../../../../toolbar/index.js';
import { RefNodeSlotsProvider } from '../../../../extension/index.js';
import type { AffineInlineEditor } from '../../affine-inline-specs.js';
import { ReferenceAliasPopup } from './reference-alias-popup.js';
import { styles } from './styles.js';
import type { DocLinkClickedEvent } from './types.js';
export class ReferencePopup extends WithDisposable(LitElement) {
static override styles = styles;
private readonly _copyLink = () => {
if (!this.std) {
console.error('`std` is not found');
return;
}
const url = this.std
.getOptional(GenerateDocUrlProvider)
?.generateDocUrl(this.referenceInfo.pageId, this.referenceInfo.params);
if (url) {
navigator.clipboard.writeText(url).catch(console.error);
toast(this.std.host, 'Copied link to clipboard');
}
this.abortController.abort();
track(this.std, 'CopiedLink', { control: 'copy link' });
};
private readonly _openDoc = (event?: Partial<DocLinkClickedEvent>) => {
if (!this.std) {
console.error('`std` is not found');
return;
}
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit({
...this.referenceInfo,
...event,
host: this.std.host,
});
};
private readonly _openEditPopup = (e: MouseEvent) => {
e.stopPropagation();
if (document.body.querySelector('reference-alias-popup')) {
return;
}
const {
std,
docTitle,
referenceInfo,
inlineEditor,
targetInlineRange,
abortController,
} = this;
if (!std) {
console.error('`std` is not found');
return;
}
const aliasPopup = new ReferenceAliasPopup();
aliasPopup.std = std;
aliasPopup.docTitle = docTitle;
aliasPopup.referenceInfo = referenceInfo;
aliasPopup.inlineEditor = inlineEditor;
aliasPopup.inlineRange = targetInlineRange;
document.body.append(aliasPopup);
abortController.abort();
track(std, 'OpenedAliasPopup', { control: 'edit' });
};
private readonly _toggleViewSelector = (e: Event) => {
if (!this.std) {
console.error('`std` is not found');
return;
}
const opened = (e as CustomEvent<boolean>).detail;
if (!opened) return;
track(this.std, 'OpenedViewSelector', { control: 'switch view' });
};
private readonly _trackViewSelected = (type: string) => {
if (!this.std) {
console.error('`std` is not found');
return;
}
track(this.std, 'SelectedView', {
control: 'select view',
type: `${type} view`,
});
};
get _embedViewButtonDisabled() {
if (!this.block) {
console.error('`block` is not found');
return true;
}
if (
this.block.doc.readonly ||
isInsideBlockByFlavour(
this.block.doc,
this.block.model,
'affine:edgeless-text'
)
) {
return true;
}
return (
!!this.block.closest('affine-embed-synced-doc-block') ||
this.referenceDocId === this.block.doc.id
);
}
_openButtonDisabled(openMode?: OpenDocMode) {
if (openMode === 'open-in-active-view') {
return this.referenceDocId === this.doc?.id;
}
return false;
}
get block() {
const block = this.inlineEditor.rootElement?.closest<BlockComponent>(
`[${BLOCK_ID_ATTR}]`
);
return block;
}
get doc() {
const doc = this.block?.doc;
return doc;
}
get referenceDocId() {
const docId = this.inlineEditor.getFormat(this.targetInlineRange).reference
?.pageId;
return docId;
}
get std() {
const std = this.block?.std;
return std;
}
private _convertToCardView() {
const block = this.block;
if (!block) return;
const doc = block.host.doc;
const parent = doc.getParent(block.model);
if (!parent) return;
const index = parent.children.indexOf(block.model);
doc.addBlock(
'affine:embed-linked-doc',
this.referenceInfo,
parent,
index + 1
);
const totalTextLength = this.inlineEditor.yTextLength;
const inlineTextLength = this.targetInlineRange.length;
if (totalTextLength === inlineTextLength) {
doc.deleteBlock(block.model);
} else {
this.inlineEditor.insertText(this.targetInlineRange, this.docTitle);
}
this.abortController.abort();
}
private _convertToEmbedView() {
const block = this.block;
const std = block?.std;
if (!std || !block) {
console.error('`std` or `block` is not found');
return;
}
const doc = block.host.doc;
const parent = doc.getParent(block.model);
if (!parent) {
console.error('`parent` is not found');
return;
}
const index = parent.children.indexOf(block.model);
const referenceInfo = this.referenceInfo;
const hasTitleAlias = Boolean(referenceInfo.title);
doc.addBlock(
'affine:embed-synced-doc',
cloneReferenceInfoWithoutAliases(referenceInfo),
parent,
index + 1
);
const totalTextLength = this.inlineEditor.yTextLength;
const inlineTextLength = this.targetInlineRange.length;
if (totalTextLength === inlineTextLength) {
doc.deleteBlock(block.model);
} else {
this.inlineEditor.insertText(this.targetInlineRange, this.docTitle);
}
if (hasTitleAlias) {
notifyLinkedDocSwitchedToEmbed(std);
}
this.abortController.abort();
}
private _delete() {
if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
this.inlineEditor.deleteText(this.targetInlineRange);
}
this.abortController.abort();
}
private _moreActions() {
return renderActions([
[
{
type: 'delete',
label: 'Delete',
icon: DeleteIcon,
disabled: this.doc?.readonly,
action: () => this._delete(),
},
],
]);
}
private _openMenuButton() {
if (!this.std) {
console.error('`std` is not found');
return nothing;
}
const openDocConfig = this.std.get(OpenDocExtensionIdentifier);
const buttons: MenuItem[] = openDocConfig.items
.map(item => {
if (
(item.type === 'open-in-center-peek' && !isPeekable(this.target)) ||
!openDocConfig?.isAllowed(item.type)
) {
return null;
}
return {
label: item.label,
type: item.type,
icon: item.icon,
action: () => {
if (item.type === 'open-in-center-peek') {
peek(this.target);
} else {
this._openDoc({ openMode: item.type });
}
},
disabled: this._openButtonDisabled(item.type),
when: () => {
if (item.type === 'open-in-center-peek') {
return isPeekable(this.target);
}
return openDocConfig?.isAllowed(item.type) ?? true;
},
};
})
.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 doc"
.justify=${'space-between'}
.labelHeight=${'20px'}
>
${OpenIcon} ${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
</editor-icon-button>
`}
>
<div data-size="large" 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 _viewSelector() {
const buttons = [];
buttons.push({
type: 'inline',
label: 'Inline view',
});
buttons.push({
type: 'card',
label: 'Card view',
action: () => this._convertToCardView(),
disabled: this.doc?.readonly,
});
buttons.push({
type: 'embed',
label: 'Embed view',
action: () => this._convertToEmbedView(),
disabled:
this.doc?.readonly ||
this.isLinkedNode ||
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'}
>
<span class="label">Inline view</span>
${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
aria-label=${label}
data-testid=${`link-to-${type}`}
?data-selected=${type === 'inline'}
?disabled=${disabled || type === 'inline'}
@click=${() => {
action?.();
this._trackViewSelected(type);
}}
>
${label}
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
override connectedCallback() {
super.connectedCallback();
if (this.targetInlineRange.length === 0) {
return;
}
if (!this.block) return;
const parent = this.block.host.doc.getParent(this.block.model);
if (!parent) return;
this.disposables.add(
effect(() => {
if (!this.block) return;
const children = parent.children;
if (children.includes(this.block.model)) return;
this.abortController.abort();
})
);
}
override render() {
const titleButton = this.referenceInfo.title
? html`
<editor-icon-button
class="doc-title"
aria-label="Doc title"
.hover=${false}
.labelHeight=${'20px'}
.tooltip=${this.docTitle}
@click=${this._openDoc}
>
<span class="label">${this.docTitle}</span>
</editor-icon-button>
`
: nothing;
const buttons = [
this._openMenuButton(),
html`
${titleButton}
<editor-icon-button
aria-label="Copy link"
data-testid="copy-link"
.tooltip=${'Copy link'}
@click=${this._copyLink}
>
${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(),
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`
<div class="overlay-root">
<div class="affine-reference-popover-container">
<editor-toolbar class="affine-reference-popover view">
${join(
buttons.filter(button => button !== nothing),
renderToolbarSeparator
)}
</editor-toolbar>
</div>
</div>
`;
}
override updated() {
const range = this.inlineEditor.toDomRange(this.targetInlineRange);
if (!range) return;
const visualElement = {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects(),
};
computePosition(visualElement, this.popupContainer, {
middleware: [
offset(10),
inline(),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
const popupContainer = this.popupContainer;
if (!popupContainer) return;
popupContainer.style.left = `${x}px`;
popupContainer.style.top = `${y}px`;
})
.catch(console.error);
}
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor docTitle!: string;
@property({ attribute: false })
accessor inlineEditor!: AffineInlineEditor;
@property({ attribute: false })
accessor isLinkedNode!: boolean;
@query('.affine-reference-popover-container')
accessor popupContainer!: HTMLDivElement;
@property({ type: Object })
accessor referenceInfo!: ReferenceInfo;
@property({ attribute: false })
accessor target!: LitElement;
@property({ attribute: false })
accessor targetInlineRange!: InlineRange;
}
export function toggleReferencePopup(
target: LitElement,
isLinkedNode: boolean,
referenceInfo: ReferenceInfo,
inlineEditor: AffineInlineEditor,
targetInlineRange: InlineRange,
docTitle: string,
abortController: AbortController
): ReferencePopup {
const popup = new ReferencePopup();
popup.target = target;
popup.isLinkedNode = isLinkedNode;
popup.referenceInfo = referenceInfo;
popup.inlineEditor = inlineEditor;
popup.targetInlineRange = targetInlineRange;
popup.docTitle = docTitle;
popup.abortController = abortController;
document.body.append(popup);
return popup;
}
function track(
std: BlockStdScope,
event: LinkEventType,
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'toolbar',
page: 'doc editor',
module: 'reference toolbar',
type: 'inline view',
category: 'linked doc',
...props,
});
}

View File

@@ -7,20 +7,21 @@ import {
} from '@blocksuite/affine-shared/services';
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { DoneIcon, ResetIcon } from '@blocksuite/icons/lit';
import type { DeltaInsert, InlineRange } from '@blocksuite/inline';
import type { InlineRange } from '@blocksuite/inline';
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
import { signal } from '@preact/signals-core';
import { css, html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js';
import type { EditorIconButton } from '../../../../../toolbar/index.js';
import type { AffineInlineEditor } from '../../affine-inline-specs.js';
import type { EditorIconButton } from '../../../../../../toolbar/index';
import type { AffineInlineEditor } from '../../../affine-inline-specs';
export class ReferenceAliasPopup extends SignalWatcher(
export class ReferencePopup extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
@@ -37,7 +38,7 @@ export class ReferenceAliasPopup extends SignalWatcher(
height: 100vh;
}
.alias-form-popup {
.popover-container {
${PANEL_BASE};
position: absolute;
display: flex;
@@ -159,11 +160,16 @@ export class ReferenceAliasPopup extends SignalWatcher(
}
override firstUpdated() {
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
this.disposables.addFromEvent(this.overlayMask, 'click', e => {
e.stopPropagation();
this.remove();
});
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this.inputElement.focus();
this.inputElement.select();
@@ -173,7 +179,7 @@ export class ReferenceAliasPopup extends SignalWatcher(
return html`
<div class="overlay-root">
<div class="overlay-mask"></div>
<div class="alias-form-popup">
<div class="popover-container">
<input
id="alias-title"
type="text"
@@ -207,15 +213,15 @@ export class ReferenceAliasPopup extends SignalWatcher(
override updated() {
const range = this.inlineEditor.toDomRange(this.inlineRange);
if (!range) {
return;
}
if (!range) return;
const visualElement = {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects(),
};
computePosition(visualElement, this.popupContainer, {
const popover = this.popoverContainer;
computePosition(visualElement, popover, {
middleware: [
offset(10),
inline(),
@@ -225,16 +231,14 @@ export class ReferenceAliasPopup extends SignalWatcher(
],
})
.then(({ x, y }) => {
const popupContainer = this.popupContainer;
if (!popupContainer) return;
popupContainer.style.left = `${x}px`;
popupContainer.style.top = `${y}px`;
popover.style.left = `${x}px`;
popover.style.top = `${y}px`;
})
.catch(console.error);
}
@property({ type: Object })
accessor delta!: DeltaInsert<AffineTextAttributes>;
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor docTitle!: string;
@@ -251,8 +255,8 @@ export class ReferenceAliasPopup extends SignalWatcher(
@query('.overlay-mask')
accessor overlayMask!: HTMLDivElement;
@query('.alias-form-popup')
accessor popupContainer!: HTMLDivElement;
@query('.popover-container')
accessor popoverContainer!: HTMLDivElement;
@property({ type: Object })
accessor referenceInfo!: ReferenceInfo;
@@ -272,11 +276,11 @@ function track(
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'toolbar',
segment: 'doc',
page: 'doc editor',
module: 'reference edit popup',
type: 'inline view',
module: 'toolbar',
category: 'linked doc',
type: 'inline view',
...props,
});
}

View File

@@ -0,0 +1,27 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import type { BlockStdScope } from '@blocksuite/block-std';
import type { InlineRange } from '@blocksuite/inline';
import type { AffineInlineEditor } from '../../../affine-inline-specs';
import { ReferencePopup } from './reference-popup';
export function toggleReferencePopup(
std: BlockStdScope,
docTitle: string,
referenceInfo: ReferenceInfo,
inlineEditor: AffineInlineEditor,
inlineRange: InlineRange,
abortController: AbortController
): ReferencePopup {
const popup = new ReferencePopup();
popup.std = std;
popup.docTitle = docTitle;
popup.referenceInfo = referenceInfo;
popup.inlineEditor = inlineEditor;
popup.inlineRange = inlineRange;
popup.abortController = abortController;
document.body.append(popup);
return popup;
}

View File

@@ -1,44 +0,0 @@
import { css } from 'lit';
export const styles = css`
:host {
box-sizing: border-box;
}
.affine-reference-popover-container {
z-index: var(--affine-z-index-popover);
animation: affine-popover-fade-in 0.2s ease;
position: absolute;
}
@keyframes affine-popover-fade-in {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
editor-icon-button.doc-title .label {
max-width: 110px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
cursor: pointer;
color: var(--affine-link-color);
font-feature-settings:
'clig' off,
'liga' off;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
text-decoration: none;
text-wrap: nowrap;
}
`;

View File

@@ -25,6 +25,7 @@ export class EditorIconButton extends LitElement {
white-space: nowrap;
box-sizing: border-box;
width: var(--icon-container-width, unset);
height: var(--icon-container-height, unset);
justify-content: var(--justify, unset);
user-select: none;
}
@@ -33,6 +34,10 @@ export class EditorIconButton extends LitElement {
color: var(--affine-primary-color);
}
:host([active]) .icon-container.active-mode-border {
border: 1px solid var(--affine-brand-color);
}
:host([active]) .icon-container.active-mode-background {
background: var(--affine-hover-color);
}
@@ -44,8 +49,7 @@ export class EditorIconButton extends LitElement {
::slotted(svg) {
flex-shrink: 0;
width: var(--icon-size, unset);
height: var(--icon-size, unset);
font-size: var(--icon-size, 20px);
}
::slotted(.label) {
@@ -116,6 +120,7 @@ export class EditorIconButton extends LitElement {
const padding = this.iconContainerPadding;
const iconContainerStyles = styleMap({
'--icon-container-width': this.iconContainerWidth,
'--icon-container-height': this.iconContainerHeight,
'--icon-container-padding': Array.isArray(padding)
? padding.map(v => `${v}px`).join(' ')
: `${padding}px`,
@@ -156,7 +161,7 @@ export class EditorIconButton extends LitElement {
accessor active = false;
@property({ attribute: false })
accessor activeMode: 'color' | 'background' = 'color';
accessor activeMode: 'color' | 'border' | 'background' = 'color';
@property({ attribute: false })
accessor arrow = true;
@@ -179,6 +184,9 @@ export class EditorIconButton extends LitElement {
@property({ attribute: false })
accessor iconContainerWidth: string | undefined = undefined;
@property({ attribute: false })
accessor iconContainerHeight: string | undefined = undefined;
@property({ attribute: false })
accessor iconSize: string | undefined = undefined;

View File

@@ -44,6 +44,7 @@ export class EditorMenuButton extends WithDisposable(LitElement) {
{
mainAxis: 12,
ignoreShift: true,
offsetHeight: 6 * 4,
}
);
this._disposables.addFromEvent(this, 'keydown', (e: KeyboardEvent) => {
@@ -54,9 +55,6 @@ export class EditorMenuButton extends WithDisposable(LitElement) {
});
this._disposables.addFromEvent(this._trigger, 'click', (_: MouseEvent) => {
this._popper.toggle();
if (this._popper.state === 'show') {
this._content.focus({ preventScroll: true });
}
});
this._disposables.add(this._popper);
}
@@ -91,7 +89,7 @@ export class EditorMenuButton extends WithDisposable(LitElement) {
private accessor _trigger!: EditorIconButton;
@property({ attribute: false })
accessor button!: string | TemplateResult<1>;
accessor button!: TemplateResult;
@property({ attribute: false })
accessor contentPadding: string | undefined = undefined;
@@ -104,6 +102,7 @@ export class EditorMenuContent extends LitElement {
--offset-height: calc(-1 * var(--packed-height));
display: none;
outline: none;
overscroll-behavior: contain;
}
:host::before,
@@ -154,6 +153,7 @@ export class EditorMenuContent extends LitElement {
align-items: stretch;
gap: unset;
min-height: unset;
overflow-y: auto;
}
`;
@@ -206,6 +206,13 @@ export class EditorMenuAction extends LitElement {
color: var(--affine-icon-color);
font-size: 20px;
}
::slotted(.label) {
color: inherit !important;
}
::slotted(.label.capitalize) {
text-transform: capitalize !important;
}
`;
override connectedCallback() {

View File

@@ -106,8 +106,10 @@ export function renderGroups<T>(groups: MenuItemGroup<T>[], context: T) {
return renderActions(groupsToActions(groups, context));
}
export function renderToolbarSeparator() {
return html`<editor-toolbar-separator></editor-toolbar-separator>`;
export function renderToolbarSeparator(orientation?: 'horizontal') {
return html`<editor-toolbar-separator
data-orientation=${ifDefined(orientation)}
></editor-toolbar-separator>`;
}
export function getMoreMenuConfig(std: BlockStdScope): ToolbarMoreMenuConfig {

View File

@@ -0,0 +1,92 @@
import {
type ToolbarAction,
ToolbarContext,
} from '@blocksuite/affine-shared/services';
import {
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/block-std';
import { SignalWatcher } from '@blocksuite/global/utils';
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
import type { ReadonlySignal, Signal } from '@preact/signals-core';
import { property } from 'lit/decorators.js';
import { html } from 'lit-html';
import { ifDefined } from 'lit-html/directives/if-defined.js';
import { repeat } from 'lit-html/directives/repeat.js';
@requiredProperties({
actions: PropTypes.array,
context: PropTypes.instanceOf(ToolbarContext),
viewType$: PropTypes.object,
})
export class ViewDropdownMenu extends SignalWatcher(ShadowlessElement) {
@property({ attribute: false })
accessor actions!: ToolbarAction[];
@property({ attribute: false })
accessor context!: ToolbarContext;
@property({ attribute: false })
accessor viewType$!: Signal<string> | ReadonlySignal<string>;
@property({ attribute: false })
accessor toggle: ((e: CustomEvent<boolean>) => void) | undefined;
override render() {
const {
actions,
context,
toggle,
viewType$: { value: viewType },
} = this;
return html`
<editor-menu-button
@toggle=${toggle}
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="Switch view"
.justify="${'space-between'}"
.labelHeight="${'20px'}"
.iconContainerWidth="${'110px'}"
>
<span class="label">${viewType}</span>
${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
<div data-size="small" data-orientation="vertical">
${repeat(
actions.filter(action => {
if (typeof action.when === 'function')
return action.when(context);
return action.when ?? true;
}),
action => action.id,
({ id, label, disabled, run }) => html`
<editor-menu-action
aria-label="${label}"
data-testid="${`link-to-${id}`}"
?data-selected="${label === viewType}"
?disabled="${ifDefined(
typeof disabled === 'function' ? disabled(context) : disabled
)}"
@click=${() => run?.(context)}
>
${label}
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-view-dropdown-menu': ViewDropdownMenu;
}
}

View File

@@ -0,0 +1,7 @@
import { ViewDropdownMenu } from './dropdown-menu';
export * from './dropdown-menu';
export function effects() {
customElements.define('affine-view-dropdown-menu', ViewDropdownMenu);
}

View File

@@ -16,6 +16,7 @@ import {
type TableViewSelectionWithType,
} from '../selection';
import type { TableColumn } from '../table-view-manager.js';
import type { TableGroup } from './group.js';
export class DatabaseCellContainer extends SignalWatcher(
WithDisposable(ShadowlessElement)
@@ -85,7 +86,7 @@ export class DatabaseCellContainer extends SignalWatcher(
}
private get groupKey() {
return this.closest('affine-data-view-table-group')?.group?.key;
return this.closest<TableGroup>('affine-data-view-table-group')?.group?.key;
}
private get readonly() {

View File

@@ -19,6 +19,7 @@ import {
type TableViewSelectionWithType,
} from '../../selection';
import type { DatabaseCellContainer } from '../cell.js';
import type { TableGroup } from '../group.js';
import type { TableRow } from '../row/row.js';
import type { DataViewTable } from '../table-view.js';
import {
@@ -812,7 +813,8 @@ export class TableSelectionController implements ReactiveController {
cell: DatabaseCellContainer,
fillValues?: boolean
) {
const groupKey = cell.closest('affine-data-view-table-group')?.group?.key;
const groupKey = cell.closest<TableGroup>('affine-data-view-table-group')
?.group?.key;
const table = this.tableContainer;
const scrollContainer = table?.parentElement;
if (!table || !scrollContainer) {

View File

@@ -14,6 +14,7 @@ import {
type TableViewSelection,
} from '../../selection';
import type { TableSingleView } from '../../table-view-manager.js';
import type { TableGroup } from '../group.js';
import { openDetail, popRowMenu } from '../menu.js';
export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) {
@@ -156,7 +157,7 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) {
};
get groupKey() {
return this.closest('affine-data-view-table-group')?.group?.key;
return this.closest<TableGroup>('affine-data-view-table-group')?.group?.key;
}
get selectionController() {

View File

@@ -1,3 +1,5 @@
import type { BlockModel } from '@blocksuite/store';
import { EmbedFigmaModel } from './figma';
import { EmbedGithubModel } from './github';
import type { EmbedHtmlModel } from './html';
@@ -6,21 +8,31 @@ import { EmbedLoomModel } from './loom';
import { EmbedSyncedDocModel } from './synced-doc';
import { EmbedYoutubeModel } from './youtube';
export type ExternalEmbedModel =
| EmbedFigmaModel
| EmbedGithubModel
| EmbedLoomModel
| EmbedYoutubeModel;
export const ExternalEmbedModels = [
EmbedFigmaModel,
EmbedGithubModel,
EmbedLoomModel,
EmbedYoutubeModel,
] as const;
export type InternalEmbedModel = EmbedLinkedDocModel | EmbedSyncedDocModel;
export const InternalEmbedModels = [
EmbedLinkedDocModel,
EmbedSyncedDocModel,
] as const;
export type LinkableEmbedModel = ExternalEmbedModel | InternalEmbedModel;
export type ExternalEmbedModel = (typeof ExternalEmbedModels)[number];
export type InternalEmbedModel = (typeof InternalEmbedModels)[number];
export type LinkableEmbedModel = InstanceType<
ExternalEmbedModel | InternalEmbedModel
>;
export type BuiltInEmbedModel = LinkableEmbedModel | EmbedHtmlModel;
export function isExternalEmbedModel(
model: BuiltInEmbedModel
): model is ExternalEmbedModel {
model: BlockModel
): model is InstanceType<ExternalEmbedModel> {
return (
model instanceof EmbedFigmaModel ||
model instanceof EmbedGithubModel ||
@@ -30,8 +42,8 @@ export function isExternalEmbedModel(
}
export function isInternalEmbedModel(
model: BuiltInEmbedModel
): model is InternalEmbedModel {
model: BlockModel
): model is InstanceType<InternalEmbedModel> {
return (
model instanceof EmbedLinkedDocModel || model instanceof EmbedSyncedDocModel
);

View File

@@ -21,15 +21,13 @@ export const getBlockIndexCommand: Command<
const parentModel = ctx.std.store.getParent(path);
if (!parentModel) return;
const parent = ctx.std.view.getBlock(parentModel.id);
if (!parent) return;
const parentBlock = ctx.std.view.getBlock(parentModel.id);
if (!parentBlock) return;
const index = parent.childBlocks.findIndex(x => {
return x.blockId === path;
});
const blockIndex = parentBlock.childBlocks.findIndex(x => x.blockId === path);
next({
blockIndex: index,
parentBlock: parent as BlockComponent,
blockIndex,
parentBlock,
});
};

View File

@@ -78,7 +78,7 @@ export const DEFAULT_LINK_PREVIEW_ENDPOINT =
export const CANVAS_EXPORT_IGNORE_TAGS = [
'EDGELESS-TOOLBAR-WIDGET',
'AFFINE-DRAG-HANDLE-WIDGET',
'AFFINE-FORMAT-BAR-WIDGET',
'AFFINE-TOOLBAR-WIDGET',
'AFFINE-BLOCK-SELECTION',
];

View File

@@ -19,5 +19,6 @@ export * from './quick-search-service';
export * from './sidebar-service';
export * from './telemetry-service';
export * from './theme-service';
export * from './toolbar-service';
export * from './user-service';
export * from './virtual-keyboard-service';

View File

@@ -0,0 +1,56 @@
import type { TemplateResult } from 'lit';
import type { ToolbarContext } from './context';
export enum ActionPlacement {
Start = 0,
End = 1 << 1,
More = 1 << 2,
}
type ActionBase = {
id: string;
score?: number;
when?: ((cx: ToolbarContext) => boolean) | boolean;
active?: ((cx: ToolbarContext) => boolean) | boolean;
placement?: ActionPlacement;
};
export type ToolbarAction = ActionBase & {
label?: string;
icon?: TemplateResult;
tooltip?: string;
variant?: 'destructive';
disabled?: ((cx: ToolbarContext) => boolean) | boolean;
content?:
| ((cx: ToolbarContext) => TemplateResult | null)
| (TemplateResult | null);
run?: (cx: ToolbarContext) => void;
};
// Generates an action at runtime
export type ToolbarActionGenerator = ActionBase & {
generate: (cx: ToolbarContext) => Omit<ToolbarAction, 'id'> | null;
};
export type ToolbarActionGroup<
T extends ActionBase = ToolbarAction | ToolbarActionGenerator,
> = ActionBase & {
actions: T[];
content?:
| ((cx: ToolbarContext) => TemplateResult | null)
| (TemplateResult | null);
};
// Generates an action group at runtime
export type ToolbarActionGroupGenerator = ActionBase & {
generate: (cx: ToolbarContext) => Omit<ToolbarActionGroup, 'id'> | null;
};
export type ToolbarGenericAction =
| ToolbarAction
| ToolbarActionGenerator
| ToolbarActionGroup
| ToolbarActionGroupGenerator;
export type ToolbarActions<T extends ActionBase = ToolbarGenericAction> = T[];

View File

@@ -0,0 +1,9 @@
import type { Placement } from '@floating-ui/dom';
import type { ToolbarActions } from './action';
export type ToolbarModuleConfig = {
actions: ToolbarActions;
placement?: Extract<Placement, 'top' | 'top-start'>;
};

View File

@@ -0,0 +1,172 @@
import {
type BlockComponent,
BlockSelection,
type BlockStdScope,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { nextTick } from '@blocksuite/global/utils';
import type {
BaseSelection,
Block,
SelectionConstructor,
} from '@blocksuite/store';
import { matchModels } from '../../utils';
import { DocModeProvider } from '../doc-mode-service';
import { TelemetryProvider, type TelemetryService } from '../telemetry-service';
import { ThemeProvider } from '../theme-service';
import { ToolbarRegistryIdentifier } from './registry';
abstract class ToolbarContextBase {
constructor(readonly std: BlockStdScope) {}
get command() {
return this.std.command;
}
get chain() {
return this.command.chain();
}
get doc() {
return this.store.doc;
}
get workspace() {
return this.std.workspace;
}
get host() {
return this.std.host;
}
get clipboard() {
return this.std.clipboard;
}
get selection() {
return this.std.selection;
}
get store() {
return this.std.store;
}
get view() {
return this.std.view;
}
get activated() {
if (this.readonly) return false;
if (this.flags.accept()) return true;
if (this.host.event.active) return true;
// Selects `embed-synced-doc-block`
return this.host.contains(document.activeElement);
}
get readonly() {
return this.store.readonly;
}
get docModeProvider() {
return this.std.get(DocModeProvider);
}
get editorMode() {
return this.docModeProvider.getEditorMode() ?? 'page';
}
get isPageMode() {
return this.editorMode === 'page';
}
get isEdgelessMode() {
return this.editorMode === 'edgeless';
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get themeProvider() {
return this.std.get(ThemeProvider);
}
get theme() {
return this.themeProvider.theme;
}
get toolbarRegistry() {
return this.std.get(ToolbarRegistryIdentifier);
}
get flags() {
return this.toolbarRegistry.flags;
}
get message$() {
return this.toolbarRegistry.message$;
}
getCurrentBlockBy<T extends SelectionConstructor>(type?: T): Block | null {
const selection = this.selection.find(type ?? BlockSelection);
return (selection && this.store.getBlock(selection.blockId)) ?? null;
}
getCurrentModelBy<T extends SelectionConstructor>(type: T) {
return this.getCurrentBlockBy<T>(type)?.model ?? null;
}
getCurrentModelByType<
T extends SelectionConstructor,
M extends Parameters<typeof matchModels>[1][number],
>(type: T, klass: M) {
const model = this.getCurrentModelBy(type);
return matchModels(model, [klass]) ? model : null;
}
getCurrentBlockComponentBy<
T extends SelectionConstructor,
K extends abstract new (...args: any) => any,
>(type: T, klass: K): InstanceType<K> | null {
const block = this.getCurrentBlockBy<T>(type);
const component = block && this.view.getBlock(block.id);
return this.blockComponentIs(component, klass) ? component : null;
}
blockComponentIs<K extends abstract new (...args: any) => any>(
component: BlockComponent | null,
...classes: K[]
): component is InstanceType<K> {
return classes.some(k => component instanceof k);
}
select(group: string, selections: BaseSelection[] = []) {
nextTick()
.then(() => this.selection.setGroup(group, selections))
.catch(console.error);
}
show() {
this.flags.show();
}
hide() {
this.flags.hide();
}
reset() {
this.flags.reset();
this.message$.value = null;
}
get telemetryProvider() {
return this.std.getOptional(TelemetryProvider);
}
track = (...args: Parameters<TelemetryService['track']>) => {
this.telemetryProvider?.track(...args);
};
}
export class ToolbarContext extends ToolbarContextBase {}

View File

@@ -0,0 +1,80 @@
import { batch, signal } from '@preact/signals-core';
export enum Flag {
None = 0b0,
Surface = 0b1,
Block = 0b10,
Text = 0b100,
Native = 0b1000,
// Hovering something, e.g. inline links
Hovering = 0b10000,
// Dragging something or opening modal, e.g. drag handle, drag resources from outside, bookmark rename modal
Hiding = 0b100000,
// When the editor is inactive and the toolbar is hidden, we still want to accept the message
Accepting = 0b1000000,
}
export class Flags {
value$ = signal(Flag.None);
get value() {
return this.value$.peek();
}
toggle(flag: Flag, activated: boolean) {
if (activated) {
this.value$.value |= flag;
return;
}
this.value$.value &= ~flag;
}
check(flag: Flag, value = this.value) {
return (flag & value) === flag;
}
contains(flag: number, value = this.value) {
return (flag & value) !== Flag.None;
}
refresh(flag: Flag) {
batch(() => {
this.toggle(flag, false);
this.toggle(flag, true);
});
}
reset() {
this.value$.value = Flag.None;
}
hide() {
batch(() => {
this.toggle(Flag.Accepting, true);
this.toggle(Flag.Hiding, true);
});
}
show() {
batch(() => {
this.toggle(Flag.Hiding, false);
this.toggle(Flag.Accepting, false);
});
}
isText() {
return this.check(Flag.Text);
}
isBlock() {
return this.check(Flag.Block);
}
isNative() {
return this.check(Flag.Native);
}
accept() {
return this.check(Flag.Accepting);
}
}

Some files were not shown because too many files have changed in this diff Show More