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

@@ -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);
}