feat(editor): support embed iframe block (#10740)

To close:
[BS-2660](https://linear.app/affine-design/issue/BS-2660/slash-menu-支持-iframe-embed)
[BS-2661](https://linear.app/affine-design/issue/BS-2661/iframe-embed-block-model-and-block-component)
[BS-2662](https://linear.app/affine-design/issue/BS-2662/iframe-embed-block-toolbar)
[BS-2768](https://linear.app/affine-design/issue/BS-2768/iframe-embed-block-loading-和-error-态)
[BS-2670](https://linear.app/affine-design/issue/BS-2670/iframe-embed-block-导出)

# PR Description

# Add Embed Iframe Block Support

## Overview

This PR introduces a new `EmbedIframeBlock` to enhance content embedding capabilities within our editor. This block allows users to seamlessly embed external content from various providers (Google Drive, Spotify, etc.) directly into their docs.

## New Blocks

### EmbedIframeBlock

The core block that renders embedded iframe content. This block:

* Displays external content within a secure iframe
* Handles loading states with visual feedback
* Provides error handling with edit and retry options
* Supports customization of width, height, and other iframe attributes

### Supporting Components

* **EmbedIframeCreateModal**: Modal interface for creating new iframe embeds
* **EmbedIframeLinkEditPopup**: UI for editing existing embed links
* **EmbedIframeLoadingCard**: Visual feedback during content loading
* **EmbedIframeErrorCard**: Error handling with retry functionality

## New Store Extensions

### EmbedIframeConfigExtension

This extension provides configuration for different embed providers:

```typescript
/**
 * The options for the iframe
 * @example
 * {
 *   defaultWidth: '100%',
 *   defaultHeight: '152px',
 *   style: 'border-radius: 8px;',
 *   allow: 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
 * }
 * =>
 * <iframe
 *   width="100%"
 *   height="152px"
 *   style="border-radius: 8px;"
 *   allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
 * ></iframe>
 */
export type IframeOptions = {
  defaultWidth?: string;
  defaultHeight?: string;
  style?: string;
  referrerpolicy?: string;
  scrolling?: boolean;
  allow?: string;
  allowFullscreen?: boolean;
};

/**
 * Define the config of an embed iframe block provider
 */
export type EmbedIframeConfig = {
  /**
   * The name of the embed iframe block provider
   */
  name: string;
  /**
   * The function to match the url
   */
  match: (url: string) => boolean;
  /**
   * The function to build the oEmbed URL for fetching embed data
   */
  buildOEmbedUrl: (url: string) => string | undefined;
  /**
   * Use oEmbed URL directly as iframe src without fetching oEmbed data
   */
  useOEmbedUrlDirectly: boolean;
  /**
   * The options for the iframe
   */
  options?: IframeOptions;
};

export const EmbedIframeConfigIdentifier =
  createIdentifier<EmbedIframeConfig>('EmbedIframeConfig');

export function EmbedIframeConfigExtension(
  config: EmbedIframeConfig
): ExtensionType & {
  identifier: ServiceIdentifier<EmbedIframeConfig>;
} {
  const identifier = EmbedIframeConfigIdentifier(config.name);
  return {
    setup: di => {
      di.addImpl(identifier, () => config);
    },
    identifier,
  };
}
```

**example:**

```typescript
//  blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/providers/spotify.ts

const SPOTIFY_DEFAULT_WIDTH = '100%';
const SPOTIFY_DEFAULT_HEIGHT = '152px';

// https://developer.spotify.com/documentation/embeds/reference/oembed
const spotifyEndpoint = 'https://open.spotify.com/oembed';

const spotifyUrlValidationOptions: EmbedIframeUrlValidationOptions = {
  protocols: ['https:'],
  hostnames: ['open.spotify.com', 'spotify.link'],
};

const spotifyConfig = {
  name: 'spotify',
  match: (url: string) =>
    validateEmbedIframeUrl(url, spotifyUrlValidationOptions),
  buildOEmbedUrl: (url: string) => {
    const match = validateEmbedIframeUrl(url, spotifyUrlValidationOptions);
    if (!match) {
      return undefined;
    }
    const encodedUrl = encodeURIComponent(url);
    const oEmbedUrl = `${spotifyEndpoint}?url=${encodedUrl}`;
    return oEmbedUrl;
  },
  useOEmbedUrlDirectly: false,
  options: {
    defaultWidth: SPOTIFY_DEFAULT_WIDTH,
    defaultHeight: SPOTIFY_DEFAULT_HEIGHT,
    allow:
      'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
    style: 'border-radius: 12px;',
    allowFullscreen: true,
  },
};

// add the config extension to store
export const SpotifyEmbedConfig = EmbedIframeConfigExtension(spotifyConfig);
```

**Key features:**

* Provider registration and discovery
* URL pattern matching
* Provider-specific embed options (width, height, features)

### EmbedIframeService

This service provides abilities to handle URL validation, data fetching, and block creation

**Type:**

```typescript
/**
 * Service for handling embeddable URLs
 */
export interface EmbedIframeProvider {
  /**
   * Check if a URL can be embedded
   * @param url URL to check
   * @returns true if the URL can be embedded, false otherwise
   */
  canEmbed: (url: string) => boolean;

  /**
   * Build a API URL for fetching embed data
   * @param url URL to build API URL
   * @returns API URL if the URL can be embedded, undefined otherwise
   */
  buildOEmbedUrl: (url: string) => string | undefined;

  /**
   * Get the embed iframe config
   * @param url URL to get embed iframe config
   * @returns Embed iframe config if the URL can be embedded, undefined otherwise
   */
  getConfig: (url: string) => EmbedIframeConfig | undefined;

  /**
   * Get embed iframe data
   * @param url URL to get embed iframe data
   * @returns Embed iframe data if the URL can be embedded, undefined otherwise
   */
  getEmbedIframeData: (url: string) => Promise<EmbedIframeData | null>;

  /**
   * Parse an embeddable URL and add an EmbedIframeBlock to doc
   * @param url Original url to embed
   * @param parentId Parent block ID
   * @param index Optional index to insert at
   * @returns Created block id if successful, undefined if the URL cannot be embedded
   */
  addEmbedIframeBlock: (
    props: Partial<EmbedIframeBlockProps>,
    parentId: string,
    index?: number
  ) => string | undefined;
}
```

**Implemetation:**

```typescript
export class EmbedIframeService
  extends StoreExtension
  implements EmbedIframeProvider
{
  static override key = 'embed-iframe-service';

  private readonly _configs: EmbedIframeConfig[];

  constructor(store: Store) {
    super(store);
    this._configs = Array.from(
      store.provider.getAll(EmbedIframeConfigIdentifier).values()
    );
  }

  canEmbed = (url: string): boolean => {
    return this._configs.some(config => config.match(url));
  };

  buildOEmbedUrl = (url: string): string | undefined => {
    return this._configs.find(config => config.match(url))?.buildOEmbedUrl(url);
  };

  getConfig = (url: string): EmbedIframeConfig | undefined => {
    return this._configs.find(config => config.match(url));
  };

  getEmbedIframeData = async (
    url: string,
    signal?: AbortSignal
  ): Promise<EmbedIframeData | null> => {
    try {
      const config = this._configs.find(config => config.match(url));
      if (!config) {
        return null;
      }

      const oEmbedUrl = config.buildOEmbedUrl(url);
      if (!oEmbedUrl) {
        return null;
      }

      // if the config useOEmbedUrlDirectly is true, return the url directly as iframe_url
      if (config.useOEmbedUrlDirectly) {
        return {
          iframe_url: oEmbedUrl,
        };
      }

      // otherwise, fetch the oEmbed data
      const response = await fetch(oEmbedUrl, { signal });
      if (!response.ok) {
        console.warn(
          `Failed to fetch oEmbed data: ${response.status} ${response.statusText}`
        );
        return null;
      }

      const data = await response.json();
      return data as EmbedIframeData;
    } catch (error) {
      if (error instanceof Error && error.name !== 'AbortError') {
        console.error('Error fetching embed iframe data:', error);
      }
      return null;
    }
  };

  addEmbedIframeBlock = (
    props: Partial<EmbedIframeBlockProps>,
    parentId: string,
    index?: number
  ): string | undefined => {
    const blockId = this.store.addBlock(
      'affine:embed-iframe',
      props,
      parentId,
      index
    );
    return blockId;
  };
}
```

**Usage:**

```typescript
// Usage example
const embedIframeService = this.std.get(EmbedIframeService);

// Check if a URL can be embedded
const canEmbed = embedIframeService.canEmbed(url);

// Get embed data for a URL
const embedData = await embedIframeService.getEmbedIframeData(url);

// Add an embed iframe block to the document
const block = embedIframeService.addEmbedIframeBlock({
  url,
  iframeUrl: embedData.iframe_url,
  title: embedData.title,
  description: embedData.description
}, parentId, index);
```

**Key features:**

* URL validation and transformation
* Provider-specific data fetching
* Block creation and management

## Adaptations

### Toolbar Integration

Added toolbar actions for embedded content:

* Copy link
* Edit embed title and description
* Toggle between inline/card views
* Add caption
* And more

### Slash Menu Integration

Added a new slash menu option for embedding content:

* Embed item for inserting embed iframe block
* Conditional rendering based on feature flags

### Adapters

Implemented adapters for various formats:

* **HTML Adapter**: Exports embed original urls as html links
* **Markdown Adapter**: Exports embed original urls as markdown links
* **Plain Text Adapter**: Exports embed original urls as link text

## To Be Continued:
- [ ] **UI Optimization**
- [ ] **Edgeless Mode Support**
- [ ] **Mobile Support**
This commit is contained in:
donteatfriedrice
2025-03-13 04:11:45 +00:00
parent 98a3cf8516
commit d2c62602a4
39 changed files with 2450 additions and 2 deletions

View File

@@ -5,6 +5,11 @@ import { EmbedEdgelessGithubBlockComponent } from './embed-github-block/embed-ed
import { EmbedHtmlBlockComponent } from './embed-html-block';
import { EmbedHtmlFullscreenToolbar } from './embed-html-block/components/fullscreen-toolbar';
import { EmbedEdgelessHtmlBlockComponent } from './embed-html-block/embed-edgeless-html-block';
import { EmbedIframeCreateModal } from './embed-iframe-block/components/embed-iframe-create-modal';
import { EmbedIframeErrorCard } from './embed-iframe-block/components/embed-iframe-error-card';
import { EmbedIframeLinkEditPopup } from './embed-iframe-block/components/embed-iframe-link-edit-popup';
import { EmbedIframeLoadingCard } from './embed-iframe-block/components/embed-iframe-loading-card';
import { EmbedIframeBlockComponent } from './embed-iframe-block/embed-iframe-block';
import { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block';
import { EmbedEdgelessLinkedDocBlockComponent } from './embed-linked-doc-block/embed-edgeless-linked-doc-block';
import { EmbedLoomBlockComponent } from './embed-loom-block';
@@ -72,6 +77,18 @@ export function effects() {
'affine-embed-synced-doc-block',
EmbedSyncedDocBlockComponent
);
customElements.define('affine-embed-iframe-block', EmbedIframeBlockComponent);
customElements.define(
'affine-embed-iframe-create-modal',
EmbedIframeCreateModal
);
customElements.define('embed-iframe-loading-card', EmbedIframeLoadingCard);
customElements.define('embed-iframe-error-card', EmbedIframeErrorCard);
customElements.define(
'embed-iframe-link-edit-popup',
EmbedIframeLinkEditPopup
);
}
declare global {
@@ -92,5 +109,10 @@ declare global {
'affine-embed-edgeless-synced-doc-block': EmbedEdgelessSyncedDocBlockComponent;
'affine-embed-linked-doc-block': EmbedLinkedDocBlockComponent;
'affine-embed-edgeless-linked-doc-block': EmbedEdgelessLinkedDocBlockComponent;
'affine-embed-iframe-block': EmbedIframeBlockComponent;
'affine-embed-iframe-create-modal': EmbedIframeCreateModal;
'embed-iframe-loading-card': EmbedIframeLoadingCard;
'embed-iframe-error-card': EmbedIframeErrorCard;
'embed-iframe-link-edit-popup': EmbedIframeLinkEditPopup;
}
}

View File

@@ -0,0 +1,55 @@
import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html';
export const embedIframeBlockHtmlAdapterMatcher =
createEmbedBlockHtmlAdapterMatcher(EmbedIframeBlockSchema.model.flavour, {
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext } = context;
// Parse as link
if (
typeof o.node.props.title !== 'string' ||
typeof o.node.props.url !== 'string'
) {
return;
}
walkerContext
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-paragraph-block-container'],
},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'a',
properties: {
href: o.node.props.url,
},
children: [
{
type: 'text',
value: o.node.props.title,
},
],
},
'children'
)
.closeNode()
.closeNode();
},
},
});
export const EmbedIframeBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
embedIframeBlockHtmlAdapterMatcher
);

View File

@@ -0,0 +1,15 @@
import type { ExtensionType } from '@blocksuite/store';
import { EmbedIframeBlockHtmlAdapterExtension } from './html';
import { EmbedIframeBlockMarkdownAdapterExtension } from './markdown';
import { EmbedIframeBlockPlainTextAdapterExtension } from './plain-text';
export * from './html';
export * from './markdown';
export * from './plain-text';
export const EmbedIframeBlockAdapterExtensions: ExtensionType[] = [
EmbedIframeBlockHtmlAdapterExtension,
EmbedIframeBlockMarkdownAdapterExtension,
EmbedIframeBlockPlainTextAdapterExtension,
];

View File

@@ -0,0 +1,46 @@
import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js';
export const embedIframeBlockMarkdownAdapterMatcher =
createEmbedBlockMarkdownAdapterMatcher(EmbedIframeBlockSchema.model.flavour, {
fromBlockSnapshot: {
enter: (o, context) => {
const { walkerContext } = context;
// Parse as link
if (
typeof o.node.props.title !== 'string' ||
typeof o.node.props.url !== 'string'
) {
return;
}
walkerContext
.openNode(
{
type: 'paragraph',
children: [],
},
'children'
)
.openNode(
{
type: 'link',
url: o.node.props.url,
children: [
{
type: 'text',
value: o.node.props.title,
},
],
},
'children'
)
.closeNode()
.closeNode();
},
},
});
export const EmbedIframeBlockMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(embedIframeBlockMarkdownAdapterMatcher);

View File

@@ -0,0 +1,31 @@
import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockPlainTextAdapterMatcher } from '../../common/adapters/plain-text';
export const embedIframeBlockPlainTextAdapterMatcher =
createEmbedBlockPlainTextAdapterMatcher(
EmbedIframeBlockSchema.model.flavour,
{
fromBlockSnapshot: {
enter: (o, context) => {
const { textBuffer } = context;
// Parse as link
if (
typeof o.node.props.title !== 'string' ||
typeof o.node.props.url !== 'string'
) {
return;
}
const buffer = `[${o.node.props.title}](${o.node.props.url})`;
if (buffer.length > 0) {
textBuffer.content += buffer;
textBuffer.content += '\n';
}
},
},
}
);
export const EmbedIframeBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(embedIframeBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1,366 @@
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { isValidUrl, stopPropagation } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/lit';
import { CloseIcon, EmbedIcon } from '@blocksuite/icons/lit';
import type { BlockModel } from '@blocksuite/store';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { EmbedIframeService } from '../extension/embed-iframe-service';
export class EmbedIframeCreateModal extends WithDisposable(LitElement) {
static override styles = css`
.embed-iframe-create-modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.embed-iframe-create-modal-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.modal-main-wrapper {
position: relative;
box-sizing: border-box;
width: 340px;
padding: 0 24px;
border-radius: 12px;
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
z-index: var(--affine-z-index-modal);
}
.modal-content-wrapper {
display: flex;
flex-direction: column;
gap: 24px;
}
.modal-close-button {
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
color: var(--affine-icon-color);
border-radius: 4px;
}
.modal-close-button:hover {
background-color: var(--affine-hover-color);
}
.modal-content-header {
display: flex;
flex-direction: column;
gap: 4px;
.icon-container {
padding-top: 48px;
padding-bottom: 16px;
display: flex;
justify-content: center;
.icon-background {
display: flex;
width: 64px;
height: 64px;
justify-content: center;
align-items: center;
border-radius: 50%;
background: var(--affine-background-secondary-color);
color: ${unsafeCSSVarV2('icon/primary')};
}
}
.title,
.description {
text-align: center;
}
.title {
/* Client/h6 */
font-family: Inter;
font-size: 18px;
font-style: normal;
font-weight: 600;
line-height: 26px; /* 144.444% */
letter-spacing: -0.24px;
color: ${unsafeCSSVarV2('text/primary')};
}
.description {
font-feature-settings:
'liga' off,
'clig' off;
/* Client/xs */
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
color: ${unsafeCSSVarV2('text/secondary')};
}
}
.input-container {
width: 100%;
.link-input {
box-sizing: border-box;
width: 100%;
display: flex;
padding: 4px 10px;
align-items: center;
gap: 8px;
align-self: stretch;
border-radius: 8px;
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
background: ${unsafeCSSVarV2('input/background')};
}
.link-input:focus {
border-color: var(--affine-blue-700);
box-shadow: var(--affine-active-shadow);
outline: none;
}
.link-input::placeholder {
color: var(--affine-placeholder-color);
}
}
.button-container {
display: flex;
justify-content: center;
padding: 20px 0px;
cursor: pointer;
.confirm-button {
width: 100%;
height: 32px;
line-height: 32px;
text-align: center;
justify-content: center;
align-items: center;
border-radius: 8px;
background: ${unsafeCSSVarV2('button/primary')};
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
color: ${unsafeCSSVarV2('button/pureWhiteText')};
/* Client/xsMedium */
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
}
.confirm-button[disabled] {
opacity: 0.5;
}
}
`;
private readonly _onClose = () => {
this.remove();
};
private readonly _isInputEmpty = () => {
return this._linkInputValue.trim() === '';
};
private readonly _addBookmark = (url: string) => {
if (!isValidUrl(url)) {
// notify user that the url is invalid
return;
}
const blockId = this.std.store.addBlock(
'affine:bookmark',
{
url,
},
this.parentModel.id,
this.index
);
return blockId;
};
private readonly _onConfirm = async () => {
if (this._isInputEmpty()) {
return;
}
try {
const embedIframeService = this.std.get(EmbedIframeService);
if (!embedIframeService) {
console.error('iframe EmbedIframeService not found');
return;
}
const url = this.input.value;
// check if the url can be embedded
const canEmbed = embedIframeService.canEmbed(url);
// if can not be embedded, try to add as a bookmark
if (!canEmbed) {
console.log('iframe can not be embedded, add as a bookmark', url);
this._addBookmark(url);
return;
}
// create a new embed iframe block
const embedIframeBlock = embedIframeService.addEmbedIframeBlock(
{
url,
},
this.parentModel.id,
this.index
);
return embedIframeBlock;
} catch (error) {
console.error('Error in embed iframe creation:', error);
return;
} finally {
this._onClose();
}
};
private readonly _handleInput = (e: InputEvent) => {
const target = e.target as HTMLInputElement;
this._linkInputValue = target.value;
};
private readonly _handleKeyDown = async (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && !e.isComposing) {
await this._onConfirm();
}
};
override connectedCallback() {
super.connectedCallback();
this.updateComplete
.then(() => {
requestAnimationFrame(() => {
this.input.focus();
});
})
.catch(console.error);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
}
override render() {
const { showCloseButton } = this;
return html`
<div class="embed-iframe-create-modal">
<div
class="embed-iframe-create-modal-mask"
@click=${this._onClose}
></div>
<div class="modal-main-wrapper">
${showCloseButton
? html`
<div class="modal-close-button" @click=${this._onClose}>
${CloseIcon({ width: '20', height: '20' })}
</div>
`
: nothing}
<div class="modal-content-wrapper">
<div class="modal-content-header">
<div class="icon-container">
<div class="icon-background">
${EmbedIcon({ width: '32px', height: '32px' })}
</div>
</div>
<div>
<div class="title">Embed Link</div>
<div class="description">
Works with links of PDFs, Google Drive, Google Maps, CodePen…
</div>
</div>
</div>
<div class="input-container">
<input
class="link-input"
type="text"
placeholder="Paste in https://…"
@input=${this._handleInput}
@keydown=${this._handleKeyDown}
/>
</div>
</div>
<div class="button-container">
<div
class="confirm-button"
@click=${this._onConfirm}
?disabled=${this._isInputEmpty()}
>
Confirm
</div>
</div>
</div>
</div>
`;
}
@state()
private accessor _linkInputValue = '';
@query('input')
accessor input!: HTMLInputElement;
@property({ attribute: false })
accessor parentModel!: BlockModel;
@property({ attribute: false })
accessor index: number | undefined = undefined;
@property({ attribute: false })
accessor std!: BlockStdScope;
@property({ attribute: false })
accessor onConfirm: () => void = () => {};
@property({ attribute: false })
accessor showCloseButton: boolean = true;
}
export async function toggleEmbedIframeCreateModal(
std: BlockStdScope,
createOptions: {
parentModel: BlockModel;
index?: number;
}
): Promise<void> {
std.selection.clear();
const embedIframeCreateModal = new EmbedIframeCreateModal();
embedIframeCreateModal.std = std;
embedIframeCreateModal.parentModel = createOptions.parentModel;
embedIframeCreateModal.index = createOptions.index;
document.body.append(embedIframeCreateModal);
return new Promise(resolve => {
embedIframeCreateModal.onConfirm = () => resolve();
});
}

View File

@@ -0,0 +1,223 @@
import { createLitPortal } from '@blocksuite/affine-components/portal';
import type { EmbedIframeBlockModel } from '@blocksuite/affine-model';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/lit';
import { EditIcon, InformationIcon, ResetIcon } from '@blocksuite/icons/lit';
import { flip, offset } from '@floating-ui/dom';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, unsafeCSS } from 'lit';
import { property, query } from 'lit/decorators.js';
const LINK_EDIT_POPUP_OFFSET = 12;
export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
static override styles = css`
:host {
width: 100%;
}
.affine-embed-iframe-error-card {
display: flex;
box-sizing: border-box;
width: 100%;
user-select: none;
height: 114px;
padding: 12px;
align-items: flex-start;
gap: 12px;
overflow: hidden;
border-radius: 8px;
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
background: ${unsafeCSSVarV2('layer/background/secondary')};
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
user-select: none;
.error-content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
flex: 1 0 0;
.error-title {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
.error-icon {
display: flex;
justify-content: center;
align-items: center;
color: ${unsafeCSSVarV2('status/error')};
}
.error-title-text {
color: ${unsafeCSSVarV2('text/primary')};
text-align: justify;
/* Client/smBold */
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 600;
line-height: 22px; /* 157.143% */
}
}
.error-message {
display: flex;
height: 40px;
align-items: flex-start;
align-self: stretch;
color: ${unsafeCSSVarV2('text/secondary')};
overflow: hidden;
font-feature-settings:
'liga' off,
'clig' off;
text-overflow: ellipsis;
/* Client/xs */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
}
.error-info {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
.button {
display: flex;
padding: 0px 4px;
align-items: center;
border-radius: 4px;
cursor: pointer;
.icon {
display: flex;
justify-content: center;
align-items: center;
}
.text {
padding: 0px 4px;
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
}
.button.edit {
color: ${unsafeCSSVarV2('text/secondary')};
}
.button.retry {
color: ${unsafeCSSVarV2('text/emphasis')};
}
}
}
.error-banner {
width: 204px;
height: 102px;
}
}
`;
private _editAbortController: AbortController | null = null;
private readonly _toggleEdit = (e: MouseEvent) => {
e.stopPropagation();
if (!this._editButton) {
return;
}
if (this._editAbortController) {
this._editAbortController.abort();
}
this._editAbortController = new AbortController();
createLitPortal({
template: html`<embed-iframe-link-edit-popup
.model=${this.model}
.abortController=${this._editAbortController}
.std=${this.std}
></embed-iframe-link-edit-popup>`,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: this.host,
computePosition: {
referenceElement: this._editButton,
placement: 'bottom-start',
middleware: [flip(), offset(LINK_EDIT_POPUP_OFFSET)],
autoUpdate: { animationFrame: true },
},
abortController: this._editAbortController,
closeOnClickAway: true,
});
};
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', stopPropagation);
}
override render() {
return html`
<div class="affine-embed-iframe-error-card">
<div class="error-content">
<div class="error-title">
<div class="error-icon">
${InformationIcon({ width: '16px', height: '16px' })}
</div>
<div class="error-title-text">This link couldnt be loaded.</div>
</div>
<div class="error-message">
${this.error?.message || 'Failed to load embedded content'}
</div>
<div class="error-info">
<div class="button edit" @click=${this._toggleEdit}>
<span class="icon"
>${EditIcon({ width: '16px', height: '16px' })}</span
>
<span class="text">Edit</span>
</div>
<div class="button retry" @click=${this.onRetry}>
<span class="icon"
>${ResetIcon({ width: '16px', height: '16px' })}</span
>
<span class="text">Reload</span>
</div>
</div>
</div>
<div class="error-banner">
<!-- TODO: add error banner icon -->
<div class="icon-box"></div>
</div>
</div>
`;
}
get host() {
return this.std.host;
}
@query('.button.edit')
accessor _editButton: HTMLElement | null = null;
@property({ attribute: false })
accessor error: Error | null = null;
@property({ attribute: false })
accessor onRetry!: () => void;
@property({ attribute: false })
accessor model!: EmbedIframeBlockModel;
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,206 @@
import { type EmbedIframeBlockModel } from '@blocksuite/affine-model';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { isValidUrl, stopPropagation } from '@blocksuite/affine-shared/utils';
import { BlockSelection, type BlockStdScope } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { DoneIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { EmbedIframeService } from '../extension/embed-iframe-service';
export class EmbedIframeLinkEditPopup extends SignalWatcher(
WithDisposable(LitElement)
) {
static override styles = css`
.embed-iframe-link-edit-popup {
display: flex;
padding: 12px;
align-items: center;
gap: 12px;
color: var(--affine-text-primary-color);
border-radius: 8px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
background: ${unsafeCSSVarV2('layer/background/primary')};
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
.input-container {
display: flex;
width: 280px;
align-items: center;
border: 1px solid var(--affine-border-color);
border-radius: 4px;
padding: 0 8px;
background-color: var(--affine-background-color);
gap: 8px;
.input-label {
color: var(--affine-text-secondary-color);
font-size: 14px;
margin-right: 8px;
white-space: nowrap;
}
.link-input {
flex: 1;
border: none;
outline: none;
padding: 8px 0;
background: transparent;
font-size: 14px;
}
}
.input-container:focus-within {
border-color: var(--affine-blue-700);
outline: none;
}
.confirm-button {
cursor: pointer;
display: flex;
padding: 2px;
justify-content: center;
align-items: center;
color: ${unsafeCSSVarV2('icon/activated')};
}
.confirm-button[disabled] {
color: ${unsafeCSSVarV2('icon/primary')};
}
}
`;
/**
* Try to add a bookmark model and remove the current embed iframe model
* @param url The url to add as a bookmark
*/
private readonly _tryToAddBookmark = (url: string) => {
if (!isValidUrl(url)) {
// notify user that the url is invalid
console.warn('can not add bookmark', url);
return;
}
const { model } = this;
const { parent } = model;
const index = parent?.children.indexOf(model);
const flavour = 'affine:bookmark';
this.store.transact(() => {
const blockId = this.store.addBlock(flavour, { url }, parent, index);
this.store.deleteBlock(model);
this.std.selection.setGroup('note', [
this.std.selection.create(BlockSelection, { blockId }),
]);
});
this.abortController.abort();
};
private readonly _onConfirm = () => {
if (this._isInputEmpty()) {
return;
}
const canEmbed = this.EmbedIframeService.canEmbed(this._linkInputValue);
// If the url is not embeddable, try to add it as a bookmark
if (!canEmbed) {
console.warn('can not embed', this._linkInputValue);
this._tryToAddBookmark(this._linkInputValue);
return;
}
// Update the embed iframe model
this.store.updateBlock(this.model, {
url: this._linkInputValue,
iframeUrl: '',
title: '',
description: '',
});
this.abortController.abort();
};
private readonly _handleInput = (e: InputEvent) => {
const target = e.target as HTMLInputElement;
this._linkInputValue = target.value;
};
private readonly _isInputEmpty = () => {
return this._linkInputValue.trim() === '';
};
private readonly _handleKeyDown = (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' && !e.isComposing) {
this._onConfirm();
}
};
override connectedCallback() {
super.connectedCallback();
this.updateComplete
.then(() => {
requestAnimationFrame(() => {
this.input.focus();
});
})
.catch(console.error);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
}
override render() {
const isInputEmpty = this._isInputEmpty();
const { url$ } = this.model;
return html`
<div class="embed-iframe-link-edit-popup">
<div class="input-container">
<span class="input-label">Link</span>
<input
class="link-input"
type="text"
spellcheck="false"
placeholder=${url$.value}
@input=${this._handleInput}
@keydown=${this._handleKeyDown}
/>
</div>
<div
class="confirm-button"
?disabled=${isInputEmpty}
@click=${this._onConfirm}
>
${DoneIcon({ width: '24px', height: '24px' })}
</div>
</div>
`;
}
get store() {
return this.model.doc;
}
get EmbedIframeService() {
return this.store.get(EmbedIframeService);
}
@state()
private accessor _linkInputValue = '';
@query('input')
accessor input!: HTMLInputElement;
@property({ attribute: false })
accessor model!: EmbedIframeBlockModel;
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,112 @@
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { BlockStdScope } from '@blocksuite/block-std';
import { EmbedIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { getEmbedCardIcons } from '../../common/utils';
export class EmbedIframeLoadingCard extends LitElement {
static override styles = css`
:host {
width: 100%;
}
.affine-embed-iframe-loading-card {
display: flex;
box-sizing: border-box;
width: 100%;
border-radius: 8px;
user-select: none;
height: 114px;
padding: 12px;
align-items: flex-start;
gap: 12px;
overflow: hidden;
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
background: ${unsafeCSSVarV2('layer/white')};
.loading-content {
display: flex;
align-items: flex-start;
gap: 8px;
flex: 1 0 0;
align-self: stretch;
.loading-spinner {
display: flex;
width: 24px;
height: 24px;
justify-content: center;
align-items: center;
}
.loading-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
flex: 1 0 0;
overflow: hidden;
color: ${unsafeCSSVarV2('text/primary')};
text-overflow: ellipsis;
/* Client/smMedium */
font-family: Inter;
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 500;
line-height: 22px; /* 157.143% */
}
}
.loading-banner {
display: flex;
width: 204px;
box-sizing: border-box;
padding: 3.139px 42.14px 0px 42.14px;
justify-content: center;
align-items: center;
flex-shrink: 0;
.icon-box {
display: flex;
width: 106px;
height: 106px;
transform: rotate(8deg);
justify-content: center;
align-items: center;
flex-shrink: 0;
border-radius: 4px 4px 0px 0px;
background: ${unsafeCSSVarV2('slashMenu/background')};
box-shadow: 0px 0px 5px 0px rgba(66, 65, 73, 0.17);
svg {
fill: black;
fill-opacity: 0.07;
}
}
}
}
`;
override render() {
const theme = this.std.get(ThemeProvider).theme;
const { LoadingIcon } = getEmbedCardIcons(theme);
return html`
<div class="affine-embed-iframe-loading-card">
<div class="loading-content">
<div class="loading-spinner">${LoadingIcon}</div>
<div class="loading-text">Loading...</div>
</div>
<div class="loading-banner">
<div class="icon-box">
${EmbedIcon({ width: '66px', height: '66px' })}
</div>
</div>
</div>
`;
}
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,2 @@
export * from './providers';
export * from './toolbar';

View File

@@ -0,0 +1,192 @@
import { EmbedIframeConfigExtension } from '../../extension/embed-iframe-config';
import {
type EmbedIframeUrlValidationOptions,
validateEmbedIframeUrl,
} from '../../utils';
const GOOGLE_DRIVE_DEFAULT_WIDTH = '100%';
const GOOGLE_DRIVE_DEFAULT_HEIGHT = '480px';
const GOOGLE_DRIVE_EMBED_FOLDER_URL =
'https://drive.google.com/embeddedfolderview';
const GOOGLE_DRIVE_EMBED_FILE_URL = 'https://drive.google.com/file/d/';
const googleDriveUrlValidationOptions: EmbedIframeUrlValidationOptions = {
protocols: ['https:'],
hostnames: ['drive.google.com'],
};
/**
* Checks if the URL has a valid sharing parameter
* @param parsedUrl Parsed URL object
* @returns Boolean indicating if the URL has a valid sharing parameter
*/
function hasValidSharingParam(parsedUrl: URL): boolean {
const usp = parsedUrl.searchParams.get('usp');
return usp === 'sharing';
}
/**
* Check if the url is a valid google drive file url
* @param parsedUrl Parsed URL object
* @returns Boolean indicating if the URL is a valid Google Drive file URL
*/
function isValidGoogleDriveFileUrl(parsedUrl: URL): boolean {
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
return (
pathSegments[0] === 'file' &&
pathSegments[1] === 'd' &&
pathSegments.length >= 3 &&
!!pathSegments[2]
);
}
/**
* Check if the url is a valid google drive folder url
* @param parsedUrl Parsed URL object
* @returns Boolean indicating if the URL is a valid Google Drive folder URL
*/
function isValidGoogleDriveFolderUrl(parsedUrl: URL): boolean {
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
return (
pathSegments[0] === 'drive' &&
pathSegments[1] === 'folders' &&
pathSegments.length >= 3 &&
!!pathSegments[2]
);
}
/**
* Validates if a URL is a valid Google Drive path URL
* @param parsedUrl Parsed URL object
* @returns Boolean indicating if the URL is valid
*/
function isValidGoogleDrivePathUrl(parsedUrl: URL): boolean {
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
// Should have at least 2 segments
if (pathSegments.length < 2) {
return false;
}
// Check for file pattern: /file/d/file-id/view
if (isValidGoogleDriveFileUrl(parsedUrl)) {
return true;
}
// Check for folder pattern: /drive/folders/folder-id
if (isValidGoogleDriveFolderUrl(parsedUrl)) {
return true;
}
return false;
}
/**
* Safely validates if a URL is a valid Google Drive URL
* https://drive.google.com/file/d/your-file-id/view?usp=sharing
* https://drive.google.com/drive/folders/your-folder-id?usp=sharing
* @param url The URL to validate
* @param strictMode Whether to strictly validate sharing parameters
* @returns Boolean indicating if the URL is a valid Google Drive URL
*/
function isValidGoogleDriveUrl(url: string, strictMode = true): boolean {
try {
if (!validateEmbedIframeUrl(url, googleDriveUrlValidationOptions)) {
return false;
}
const parsedUrl = new URL(url);
// Check sharing parameter if in strict mode
if (strictMode && !hasValidSharingParam(parsedUrl)) {
return false;
}
// Check hostname and path structure
return isValidGoogleDrivePathUrl(parsedUrl);
} catch (e) {
// URL parsing failed
console.warn('Invalid Google Drive URL:', e);
return false;
}
}
/**
* Build embed URL for Google Drive files
* @param fileId File ID
* @returns Embed URL
*/
function buildGoogleDriveFileEmbedUrl(fileId: string): string | undefined {
const embedUrl = new URL(
'preview',
`${GOOGLE_DRIVE_EMBED_FILE_URL}${fileId}/`
);
embedUrl.searchParams.set('usp', 'embed_googleplus');
return embedUrl.toString();
}
/**
* Build embed URL for Google Drive folders
* @param folderId Folder ID
* @returns Embed URL
*/
function buildGoogleDriveFolderEmbedUrl(folderId: string): string | undefined {
const embedUrl = new URL(GOOGLE_DRIVE_EMBED_FOLDER_URL);
embedUrl.searchParams.set('id', folderId);
embedUrl.hash = 'list';
return embedUrl.toString();
}
/**
* Build embed URL for Google Drive paths
* @param url The URL to embed
* @returns The embed URL
*/
function buildGoogleDriveEmbedUrl(url: string): string | undefined {
try {
const parsedUrl = new URL(url);
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
// Should have at least 2 segments
if (pathSegments.length < 2) {
return undefined;
}
// Handle file URL: /file/d/file-id/view
if (isValidGoogleDriveFileUrl(parsedUrl)) {
return buildGoogleDriveFileEmbedUrl(pathSegments[2]);
}
// Handle folder URL: /drive/folders/folder-id
if (isValidGoogleDriveFolderUrl(parsedUrl)) {
return buildGoogleDriveFolderEmbedUrl(pathSegments[2]);
}
return undefined;
} catch (e) {
console.warn('Failed to parse Google Drive path URL:', e);
return undefined;
}
}
const googleDriveConfig = {
name: 'google-drive',
match: (url: string) => isValidGoogleDriveUrl(url),
buildOEmbedUrl: (url: string) => {
if (!isValidGoogleDriveUrl(url)) {
return undefined;
}
// If is a valid google drive url, build the embed url
return buildGoogleDriveEmbedUrl(url);
},
useOEmbedUrlDirectly: true,
options: {
defaultWidth: GOOGLE_DRIVE_DEFAULT_WIDTH,
defaultHeight: GOOGLE_DRIVE_DEFAULT_HEIGHT,
allowFullscreen: true,
style: 'border: none; border-radius: 8px;',
},
};
export const GoogleDriveEmbedConfig =
EmbedIframeConfigExtension(googleDriveConfig);

View File

@@ -0,0 +1,7 @@
import { GoogleDriveEmbedConfig } from './google-drive';
import { SpotifyEmbedConfig } from './spotify';
export const EmbedIframeConfigExtensions = [
SpotifyEmbedConfig,
GoogleDriveEmbedConfig,
];

View File

@@ -0,0 +1,42 @@
import { EmbedIframeConfigExtension } from '../../extension/embed-iframe-config';
import {
type EmbedIframeUrlValidationOptions,
validateEmbedIframeUrl,
} from '../../utils';
const SPOTIFY_DEFAULT_WIDTH = '100%';
const SPOTIFY_DEFAULT_HEIGHT = '152px';
// https://developer.spotify.com/documentation/embeds/reference/oembed
const spotifyEndpoint = 'https://open.spotify.com/oembed';
const spotifyUrlValidationOptions: EmbedIframeUrlValidationOptions = {
protocols: ['https:'],
hostnames: ['open.spotify.com', 'spotify.link'],
};
const spotifyConfig = {
name: 'spotify',
match: (url: string) =>
validateEmbedIframeUrl(url, spotifyUrlValidationOptions),
buildOEmbedUrl: (url: string) => {
const match = validateEmbedIframeUrl(url, spotifyUrlValidationOptions);
if (!match) {
return undefined;
}
const encodedUrl = encodeURIComponent(url);
const oEmbedUrl = `${spotifyEndpoint}?url=${encodedUrl}`;
return oEmbedUrl;
},
useOEmbedUrlDirectly: false,
options: {
defaultWidth: SPOTIFY_DEFAULT_WIDTH,
defaultHeight: SPOTIFY_DEFAULT_HEIGHT,
allow:
'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
style: 'border-radius: 8px;',
allowFullscreen: true,
},
};
export const SpotifyEmbedConfig = EmbedIframeConfigExtension(spotifyConfig);

View File

@@ -0,0 +1,43 @@
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import type { SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
import { EmbedIcon } from '@blocksuite/icons/lit';
import { toggleEmbedIframeCreateModal } from '../../components/embed-iframe-create-modal';
import { EmbedIframeTooltip } from './tooltip';
export const embedIframeSlashMenuConfig: SlashMenuConfig = {
items: [
{
name: 'Embed',
description: 'For PDFs, and more.',
icon: EmbedIcon(),
tooltip: {
figure: EmbedIframeTooltip,
caption: 'Embed',
},
group: '4_Content & Media@10',
when: ({ model, std }) => {
const featureFlagService = std.get(FeatureFlagService);
return (
featureFlagService.getFlag('enable_embed_iframe_block') &&
model.doc.schema.flavourSchemaMap.has('affine:embed-iframe')
);
},
action: ({ std, model }) => {
(async () => {
const { host } = std;
const parentModel = host.doc.getParent(model);
if (!parentModel) {
return;
}
const index = parentModel.children.indexOf(model) + 1;
await toggleEmbedIframeCreateModal(std, {
parentModel,
index,
});
if (model.text?.length === 0) std.store.deleteBlock(model);
})().catch(console.error);
},
},
],
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,235 @@
import { toast } from '@blocksuite/affine-components/toast';
import {
BookmarkStyles,
EmbedIframeBlockModel,
} 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,
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 { EmbedIframeBlockComponent } from '../embed-iframe-block';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'bookmark',
type: 'card view',
};
export const builtinToolbarConfig = {
actions: [
{
id: 'b.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedIframeBlockModel
);
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',
run(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedIframeBlockModel
);
if (!model) return;
const { url, caption, parent } = model;
const index = parent?.children.indexOf(model);
const flavour = 'affine:bookmark';
const 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: true,
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedIframeBlockModel
);
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: 'c.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedIframeBlockComponent
);
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.reload',
label: 'Reload',
icon: ResetIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedIframeBlockComponent
);
component?.refreshData().catch(console.error);
},
},
{
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,257 @@
import {
CaptionedBlockComponent,
SelectedStyle,
} from '@blocksuite/affine-components/caption';
import type { EmbedIframeBlockModel } from '@blocksuite/affine-model';
import {
FeatureFlagService,
LinkPreviewerService,
} from '@blocksuite/affine-shared/services';
import { BlockSelection } from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { IframeOptions } from './extension/embed-iframe-config.js';
import { EmbedIframeService } from './extension/embed-iframe-service.js';
import { embedIframeBlockStyles } from './style.js';
export type EmbedIframeStatus = 'idle' | 'loading' | 'success' | 'error';
const DEFAULT_IFRAME_HEIGHT = 152;
export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIframeBlockModel> {
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
() => ({
'selected-style': this.selected$.value,
})
);
blockDraggable = true;
static override styles = embedIframeBlockStyles;
readonly status$ = signal<EmbedIframeStatus>('idle');
readonly error$ = signal<Error | null>(null);
readonly isLoading$ = computed(() => this.status$.value === 'loading');
readonly hasError$ = computed(() => this.status$.value === 'error');
readonly isSuccess$ = computed(() => this.status$.value === 'success');
private _iframeOptions: IframeOptions | undefined = undefined;
get embedIframeService() {
return this.std.get(EmbedIframeService);
}
get linkPreviewService() {
return this.std.get(LinkPreviewerService);
}
open = () => {
const link = this.model.url;
window.open(link, '_blank');
};
refreshData = async () => {
try {
// set loading status
this.status$.value = 'loading';
this.error$.value = null;
// get embed data
const embedIframeService = this.embedIframeService;
const linkPreviewService = this.linkPreviewService;
if (!embedIframeService || !linkPreviewService) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'EmbedIframeService or LinkPreviewerService not found'
);
}
const { url } = this.model;
if (!url) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'No original URL provided'
);
}
// get embed data and preview data in a promise
const [embedData, previewData] = await Promise.all([
embedIframeService.getEmbedIframeData(url),
linkPreviewService.query(url),
]);
// if the embed data is not found, and the iframeUrl is not set, throw an error
const currentIframeUrl = this.model.iframeUrl;
if (!embedData && !currentIframeUrl) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'Failed to get embed data'
);
}
// update model
this.doc.updateBlock(this.model, {
iframeUrl: embedData?.iframe_url,
title: embedData?.title || previewData?.title,
description: embedData?.description || previewData?.description,
});
// update iframe options, to ensure the iframe is rendered with the correct options
this._updateIframeOptions(url);
// set success status
this.status$.value = 'success';
} catch (err) {
// set error status
this.status$.value = 'error';
this.error$.value = err instanceof Error ? err : new Error(String(err));
console.error('Failed to refresh iframe data:', err);
}
};
private readonly _updateIframeOptions = (url: string) => {
const config = this.embedIframeService?.getConfig(url);
if (config) {
this._iframeOptions = config.options;
}
};
private readonly _handleDoubleClick = (event: MouseEvent) => {
event.stopPropagation();
this.open();
};
private readonly _selectBlock = () => {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
};
protected readonly _handleClick = (event: MouseEvent) => {
event.stopPropagation();
this._selectBlock();
};
private readonly _handleRetry = async () => {
await this.refreshData();
};
private readonly _embedIframeBlockEnabled$: ReadonlySignal = computed(() => {
const featureFlagService = this.doc.get(FeatureFlagService);
const flag = featureFlagService.getFlag('enable_embed_iframe_block');
return flag ?? false;
});
private readonly _renderIframe = () => {
const { iframeUrl } = this.model;
const {
defaultWidth,
defaultHeight,
style,
allow,
referrerpolicy,
scrolling,
allowFullscreen,
} = this._iframeOptions ?? {};
return html`
<iframe
width=${defaultWidth ?? '100%'}
height=${defaultHeight ?? DEFAULT_IFRAME_HEIGHT}
?allowfullscreen=${allowFullscreen}
loading="lazy"
frameborder="0"
src=${ifDefined(iframeUrl)}
allow=${ifDefined(allow)}
referrerpolicy=${ifDefined(referrerpolicy)}
scrolling=${ifDefined(scrolling)}
style=${ifDefined(style)}
></iframe>
`;
};
private readonly _renderContent = () => {
if (this.isLoading$.value) {
return html`<embed-iframe-loading-card
.std=${this.std}
></embed-iframe-loading-card>`;
}
if (this.hasError$.value) {
return html`<embed-iframe-error-card
.error=${this.error$.value}
.model=${this.model}
.onRetry=${this._handleRetry}
.std=${this.std}
></embed-iframe-error-card>`;
}
return this._renderIframe();
};
override connectedCallback() {
super.connectedCallback();
this.contentEditable = 'false';
if (!this.model.iframeUrl) {
this.doc.withoutTransact(() => {
this.refreshData().catch(console.error);
});
} else {
// update iframe options, to ensure the iframe is rendered with the correct options
this._updateIframeOptions(this.model.url);
this.status$.value = 'success';
}
// refresh data when original url changes
this.disposables.add(
this.model.propsUpdated.subscribe(({ key }) => {
if (key === 'url') {
this.refreshData().catch(console.error);
}
})
);
}
override renderBlock() {
if (!this._embedIframeBlockEnabled$.value) {
return nothing;
}
const classes = classMap({
'affine-embed-iframe-block': true,
...this.selectedStyle$?.value,
});
const style = styleMap({
width: '100%',
});
return html`
<div
draggable=${this.blockDraggable ? 'true' : 'false'}
class=${classes}
style=${style}
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
${this._renderContent()}
</div>
`;
}
override accessor blockContainerStyles = { margin: '18px 0' };
override accessor useCaptionEditor = true;
override accessor useZeroWidth = true;
override accessor selectedStyle = SelectedStyle.Border;
}

View File

@@ -0,0 +1,31 @@
import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
import {
BlockFlavourIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedIframeBlockAdapterExtensions } from './adapters';
import { embedIframeSlashMenuConfig } from './configs/slash-menu/slash-menu';
import { builtinToolbarConfig } from './configs/toolbar';
const flavour = EmbedIframeBlockSchema.model.flavour;
export const EmbedIframeBlockSpec: ExtensionType[] = [
FlavourExtension(flavour),
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-iframe-block`
: literal`affine-embed-iframe-block`;
}),
EmbedIframeBlockAdapterExtensions,
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
SlashMenuConfigExtension(flavour, embedIframeSlashMenuConfig),
].flat();

View File

@@ -0,0 +1,94 @@
import {
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
/**
* The options for the iframe
* @example
* {
* defaultWidth: '100%',
* defaultHeight: '152px',
* style: 'border-radius: 12px;',
* allow: 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
* }
* <iframe
* width="100%"
* height="152px"
* style="border-radius: 12px;"
* allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
* ></iframe>
*/
export type IframeOptions = {
defaultWidth?: string;
defaultHeight?: string;
style?: string;
referrerpolicy?: string;
scrolling?: boolean;
allow?: string;
allowFullscreen?: boolean;
};
/**
* Define the config of an embed iframe block
* @example
* {
* name: 'spotify',
* match: (url: string) => spotifyRegex.test(url),
* buildOEmbedUrl: (url: string) => {
* const match = url.match(spotifyRegex);
* if (!match) {
* return undefined;
* }
* const encodedUrl = encodeURIComponent(url);
* const oEmbedUrl = `${spotifyEndpoint}?url=${encodedUrl}`;
* return oEmbedUrl;
* },
* useOEmbedUrlDirectly: false,
* options: {
* defaultWidth: '100%',
* defaultHeight: '152px',
* allow: 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture'
* }
* }
*/
export type EmbedIframeConfig = {
/**
* The name of the embed iframe block
*/
name: string;
/**
* The function to match the url
*/
match: (url: string) => boolean;
/**
* The function to build the oEmbed URL for fetching embed data
*/
buildOEmbedUrl: (url: string) => string | undefined;
/**
* Use oEmbed URL directly as iframe src without fetching oEmbed data
*/
useOEmbedUrlDirectly: boolean;
/**
* The options for the iframe
*/
options?: IframeOptions;
};
export const EmbedIframeConfigIdentifier =
createIdentifier<EmbedIframeConfig>('EmbedIframeConfig');
export function EmbedIframeConfigExtension(
config: EmbedIframeConfig
): ExtensionType & {
identifier: ServiceIdentifier<EmbedIframeConfig>;
} {
const identifier = EmbedIframeConfigIdentifier(config.name);
return {
setup: di => {
di.addImpl(identifier, () => config);
},
identifier,
};
}

View File

@@ -0,0 +1,157 @@
import type { EmbedIframeBlockProps } from '@blocksuite/affine-model';
import { createIdentifier } from '@blocksuite/global/di';
import { type Store, StoreExtension } from '@blocksuite/store';
import {
type EmbedIframeConfig,
EmbedIframeConfigIdentifier,
} from './embed-iframe-config';
export type EmbedIframeData = {
html?: string;
iframe_url?: string;
width?: number | string;
height?: number | string;
title?: string;
description?: string;
provider_name?: string;
provider_url?: string;
version?: string;
thumbnail_url?: string;
thumbnail_width?: number;
thumbnail_height?: number;
type?: string;
};
/**
* Service for handling embeddable URLs
*/
export interface EmbedIframeProvider {
/**
* Check if a URL can be embedded
* @param url URL to check
* @returns true if the URL can be embedded, false otherwise
*/
canEmbed: (url: string) => boolean;
/**
* Build a API URL for fetching embed data
* @param url URL to build API URL
* @returns API URL if the URL can be embedded, undefined otherwise
*/
buildOEmbedUrl: (url: string) => string | undefined;
/**
* Get the embed iframe config
* @param url URL to get embed iframe config
* @returns Embed iframe config if the URL can be embedded, undefined otherwise
*/
getConfig: (url: string) => EmbedIframeConfig | undefined;
/**
* Get embed iframe data
* @param url URL to get embed iframe data
* @returns Embed iframe data if the URL can be embedded, undefined otherwise
*/
getEmbedIframeData: (url: string) => Promise<EmbedIframeData | null>;
/**
* Parse an embeddable URL and add an EmbedIframeBlock to doc
* @param url Original url to embed
* @param parentId Parent block ID
* @param index Optional index to insert at
* @returns Created block id if successful, undefined if the URL cannot be embedded
*/
addEmbedIframeBlock: (
props: Partial<EmbedIframeBlockProps>,
parentId: string,
index?: number
) => string | undefined;
}
export const EmbedIframeProvider = createIdentifier<EmbedIframeProvider>(
'EmbedIframeProvider'
);
export class EmbedIframeService
extends StoreExtension
implements EmbedIframeProvider
{
static override key = 'embed-iframe-service';
private readonly _configs: EmbedIframeConfig[];
constructor(store: Store) {
super(store);
this._configs = Array.from(
store.provider.getAll(EmbedIframeConfigIdentifier).values()
);
}
canEmbed = (url: string): boolean => {
return this._configs.some(config => config.match(url));
};
buildOEmbedUrl = (url: string): string | undefined => {
return this._configs.find(config => config.match(url))?.buildOEmbedUrl(url);
};
getConfig = (url: string): EmbedIframeConfig | undefined => {
return this._configs.find(config => config.match(url));
};
getEmbedIframeData = async (
url: string,
signal?: AbortSignal
): Promise<EmbedIframeData | null> => {
try {
const config = this._configs.find(config => config.match(url));
if (!config) {
return null;
}
const oEmbedUrl = config.buildOEmbedUrl(url);
if (!oEmbedUrl) {
return null;
}
// if the config useOEmbedUrlDirectly is true, return the url directly as iframe_url
if (config.useOEmbedUrlDirectly) {
return {
iframe_url: oEmbedUrl,
};
}
// otherwise, fetch the oEmbed data
const response = await fetch(oEmbedUrl, { signal });
if (!response.ok) {
console.warn(
`Failed to fetch oEmbed data: ${response.status} ${response.statusText}`
);
return null;
}
const data = await response.json();
return data as EmbedIframeData;
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Error fetching embed iframe data:', error);
}
return null;
}
};
addEmbedIframeBlock = (
props: Partial<EmbedIframeBlockProps>,
parentId: string,
index?: number
): string | undefined => {
const blockId = this.store.addBlock(
'affine:embed-iframe',
props,
parentId,
index
);
return blockId;
};
}

View File

@@ -0,0 +1,2 @@
export * from './embed-iframe-config';
export * from './embed-iframe-service';

View File

@@ -0,0 +1,6 @@
export * from './adapters';
export * from './components/embed-iframe-create-modal';
export * from './configs';
export * from './embed-iframe-block';
export * from './embed-iframe-spec';
export * from './extension';

View File

@@ -0,0 +1,12 @@
import { css } from 'lit';
export const embedIframeBlockStyles = css`
.affine-embed-iframe-block {
display: flex;
width: 100%;
border-radius: 8px;
user-select: none;
align-items: center;
justify-content: center;
}
`;

View File

@@ -0,0 +1,31 @@
/**
* The options for the embed iframe url validation
*/
export interface EmbedIframeUrlValidationOptions {
protocols: string[]; // Allowed protocols, e.g. ['https']
hostnames: string[]; // Allowed hostnames, e.g. ['docs.google.com']
}
/**
* Validate the url is allowed to embed in the iframe
* @param url URL to validate
* @param options Validation options
* @returns Whether the url is valid
*/
export function validateEmbedIframeUrl(
url: string,
options: EmbedIframeUrlValidationOptions
): boolean {
try {
const parsedUrl = new URL(url);
const { protocols, hostnames } = options;
return (
protocols.includes(parsedUrl.protocol) &&
hostnames.includes(parsedUrl.hostname)
);
} catch (e) {
console.warn(`Invalid embed iframe url: ${url}`, e);
return false;
}
}

View File

@@ -3,6 +3,7 @@ import type { ExtensionType } from '@blocksuite/store';
import { EmbedFigmaBlockSpec } from './embed-figma-block';
import { EmbedGithubBlockSpec } from './embed-github-block';
import { EmbedHtmlBlockSpec } from './embed-html-block';
import { EmbedIframeBlockSpec } from './embed-iframe-block';
import { EmbedLinkedDocBlockSpec } from './embed-linked-doc-block';
import { EmbedLoomBlockSpec } from './embed-loom-block';
import { EmbedSyncedDocBlockSpec } from './embed-synced-doc-block';
@@ -19,6 +20,7 @@ export const EmbedExtensions: ExtensionType[] = [
EmbedHtmlBlockSpec,
EmbedLinkedDocBlockSpec,
EmbedSyncedDocBlockSpec,
EmbedIframeBlockSpec,
].flat();
export { createEmbedBlockHtmlAdapterMatcher } from './common/adapters/html';
@@ -32,6 +34,7 @@ export * from './common/utils';
export * from './embed-figma-block';
export * from './embed-github-block';
export * from './embed-html-block';
export * from './embed-iframe-block';
export * from './embed-linked-doc-block';
export * from './embed-loom-block';
export * from './embed-synced-doc-block';