donteatfriedrice
2025-03-26 08:38:05 +00:00
parent f5cc4b41cd
commit 39fa8e87cf
24 changed files with 741 additions and 640 deletions

View File

@@ -1,6 +1,6 @@
import {
type InsertedLinkType,
insertEmbedIframeCommand,
insertEmbedIframeWithUrlCommand,
insertEmbedLinkedDocCommand,
type LinkableFlavour,
} from '@blocksuite/affine-block-embed';
@@ -50,7 +50,9 @@ export const insertLinkByQuickSearchCommand: Command<
const [success, { flavour }] = std.command
.chain()
.try(chain => [
chain.pipe(insertEmbedIframeCommand, { url: result.externalUrl }),
chain.pipe(insertEmbedIframeWithUrlCommand, {
url: result.externalUrl,
}),
chain.pipe(insertBookmarkCommand, { url: result.externalUrl }),
])
.run();

View File

@@ -5,9 +5,10 @@ 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 { EmbedIframeIdleCard } from './embed-iframe-block/components/embed-iframe-idle-card';
import { EmbedIframeLinkEditPopup } from './embed-iframe-block/components/embed-iframe-link-edit-popup';
import { EmbedIframeLinkInputPopup } from './embed-iframe-block/components/embed-iframe-link-input-popup';
import { EmbedIframeLoadingCard } from './embed-iframe-block/components/embed-iframe-loading-card';
import { EmbedEdgelessIframeBlockComponent } from './embed-iframe-block/embed-edgeless-iframe-block';
import { EmbedIframeBlockComponent } from './embed-iframe-block/embed-iframe-block';
@@ -85,11 +86,12 @@ export function effects() {
);
customElements.define('affine-embed-iframe-block', EmbedIframeBlockComponent);
customElements.define(
'affine-embed-iframe-create-modal',
EmbedIframeCreateModal
'embed-iframe-link-input-popup',
EmbedIframeLinkInputPopup
);
customElements.define('embed-iframe-loading-card', EmbedIframeLoadingCard);
customElements.define('embed-iframe-error-card', EmbedIframeErrorCard);
customElements.define('embed-iframe-idle-card', EmbedIframeIdleCard);
customElements.define(
'embed-iframe-link-edit-popup',
EmbedIframeLinkEditPopup
@@ -115,9 +117,10 @@ declare global {
'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-link-input-popup': EmbedIframeLinkInputPopup;
'embed-iframe-loading-card': EmbedIframeLoadingCard;
'embed-iframe-error-card': EmbedIframeErrorCard;
'embed-iframe-idle-card': EmbedIframeIdleCard;
'embed-iframe-link-edit-popup': EmbedIframeLinkEditPopup;
}
}

View File

@@ -1 +1,2 @@
export * from './insert-embed-iframe';
export * from './insert-embed-iframe-with-url';
export * from './insert-empty-embed-iframe';

View File

@@ -17,7 +17,7 @@ import {
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
} from '../consts';
export const insertEmbedIframeCommand: Command<
export const insertEmbedIframeWithUrlCommand: Command<
{ url: string },
{ blockId: string; flavour: string }
> = (ctx, next) => {

View File

@@ -0,0 +1,55 @@
import type { EmbedIframeBlockProps } from '@blocksuite/affine-model';
import type { Command } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
import type { EmbedLinkInputPopupOptions } from '../components/embed-iframe-link-input-popup';
import { EmbedIframeBlockComponent } from '../embed-iframe-block';
export const insertEmptyEmbedIframeCommand: Command<
{
place?: 'after' | 'before';
removeEmptyLine?: boolean;
selectedModels?: BlockModel[];
linkInputPopupOptions?: EmbedLinkInputPopupOptions;
},
{
insertedEmbedIframeBlockId: Promise<string>;
}
> = (ctx, next) => {
const { selectedModels, place, removeEmptyLine, std, linkInputPopupOptions } =
ctx;
if (!selectedModels?.length) return;
const targetModel =
place === 'before'
? selectedModels[0]
: selectedModels[selectedModels.length - 1];
const embedIframeBlockProps: Partial<EmbedIframeBlockProps> & {
flavour: 'affine:embed-iframe';
} = {
flavour: 'affine:embed-iframe',
};
const result = std.store.addSiblingBlocks(
targetModel,
[embedIframeBlockProps],
place
);
if (result.length === 0) return;
if (removeEmptyLine && targetModel.text?.length === 0) {
std.store.deleteBlock(targetModel);
}
next({
insertedEmbedIframeBlockId: std.host.updateComplete.then(async () => {
const blockComponent = std.view.getBlock(result[0]);
if (blockComponent instanceof EmbedIframeBlockComponent) {
await blockComponent.updateComplete;
blockComponent.toggleLinkInputPopup(linkInputPopupOptions);
}
return result[0];
}),
});
};

View File

@@ -1,447 +0,0 @@
import { EmbedIframeService } from '@blocksuite/affine-shared/services';
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 { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
type EmbedModalVariant = 'default' | 'compact';
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);
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.modal-content-wrapper {
display: flex;
flex-direction: column;
}
.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')};
svg {
width: 32px;
height: 32px;
}
}
}
.title {
/* Client/h6 */
text-align: center;
font-size: 18px;
font-style: normal;
font-weight: 600;
line-height: 26px; /* 144.444% */
letter-spacing: -0.24px;
color: ${unsafeCSSVarV2('text/primary')};
}
}
.description {
margin-top: 8px;
text-align: center;
font-feature-settings:
'liga' off,
'clig' off;
/* Client/xs */
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
color: ${unsafeCSSVarV2('text/secondary')};
}
.input-container {
width: 100%;
margin-top: 24px;
.link-input {
box-sizing: border-box;
width: 100%;
padding: 4px 10px;
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;
.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-size: 12px;
font-style: normal;
font-weight: 500;
cursor: pointer;
}
.confirm-button[disabled] {
opacity: 0.5;
}
}
.modal-main-wrapper.compact {
padding: 12px 16px;
.modal-content-wrapper {
gap: 0;
.icon-container {
padding: 0;
.icon-background {
width: 56px;
height: 56px;
svg {
width: 28px;
height: 28px;
}
}
}
.title {
padding: 10px 0;
font-weight: 500;
}
.link-input {
padding: 10px;
font-size: 17px;
font-style: normal;
font-weight: 400;
letter-spacing: -0.43px;
}
.description,
.input-container {
margin-top: 0;
}
.title,
.description {
font-size: 17px;
font-style: normal;
line-height: 22px; /* 129.412% */
letter-spacing: -0.43px;
}
.description {
font-weight: 400;
text-align: left;
order: 2;
padding: 10px 0;
color: ${unsafeCSSVarV2('text/secondary')};
}
.input-container {
order: 1;
}
}
.button-container {
padding: 4px 0;
.confirm-button {
height: 40px;
line-height: 40px;
font-size: 17px;
font-style: normal;
font-weight: 400;
letter-spacing: -0.43px;
}
}
}
`;
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;
const { variant } = this;
const modalMainWrapperClass = classMap({
'modal-main-wrapper': true,
compact: variant === 'compact',
});
return html`
<div class="embed-iframe-create-modal">
<div
class="embed-iframe-create-modal-mask"
@click=${this._onClose}
></div>
<div class=${modalMainWrapperClass}>
${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()}</div>
</div>
<div class="title">Embed Link</div>
</div>
<div class="description">
Works with links of PDFs, Google Drive, Google Maps, CodePen…
</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;
@property({ attribute: false })
accessor variant: EmbedModalVariant = 'default';
}
export async function toggleEmbedIframeCreateModal(
std: BlockStdScope,
createOptions: {
parentModel: BlockModel;
index?: number;
variant?: EmbedModalVariant;
}
): Promise<void> {
std.selection.clear();
const embedIframeCreateModal = new EmbedIframeCreateModal();
embedIframeCreateModal.std = std;
embedIframeCreateModal.parentModel = createOptions.parentModel;
embedIframeCreateModal.index = createOptions.index;
embedIframeCreateModal.variant = createOptions.variant ?? 'default';
document.body.append(embedIframeCreateModal);
return new Promise(resolve => {
embedIframeCreateModal.onConfirm = () => resolve();
});
}

View File

@@ -6,7 +6,7 @@ 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 { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
@@ -161,7 +161,7 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
private _editAbortController: AbortController | null = null;
private readonly _toggleEdit = (e: MouseEvent) => {
e.stopPropagation();
if (!this._editButton) {
if (!this._editButton || this.readonly) {
return;
}
@@ -225,12 +225,16 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
${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>
${this.readonly
? nothing
: html`
<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._handleRetry}>
<span class="icon"
>${ResetIcon({ width: '16px', height: '16px' })}</span
@@ -251,6 +255,10 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
return this.std.host;
}
get readonly() {
return this.model.doc.readonly;
}
@query('.button.edit')
accessor _editButton: HTMLElement | null = null;

View File

@@ -0,0 +1,66 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { WithDisposable } from '@blocksuite/global/lit';
import { EmbedIcon } from '@blocksuite/icons/lit';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, unsafeCSS } from 'lit';
export class EmbedIframeIdleCard extends WithDisposable(LitElement) {
static override styles = css`
:host {
width: 100%;
}
.affine-embed-iframe-idle-card {
width: 100%;
height: 48px;
box-sizing: border-box;
display: flex;
align-items: center;
padding: 12px;
gap: 8px;
border-radius: 8px;
border: 1px solid var(--affine-border-color);
background-color: ${unsafeCSSVarV2('layer/background/secondary')};
.icon {
display: flex;
width: 24px;
height: 24px;
justify-content: center;
align-items: center;
color: ${unsafeCSSVarV2('icon/secondary')};
flex-shrink: 0;
}
.text {
/* Client/base */
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 160% */
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
color: ${unsafeCSSVarV2('text/secondary')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.affine-embed-iframe-idle-card:hover {
cursor: pointer;
}
`;
override render() {
return html`
<div class="affine-embed-iframe-idle-card">
<span class="icon">
${EmbedIcon({ width: '24px', height: '24px' })}
</span>
<span class="text">
Embed anything (Google Drive, Google Docs, Spotify, Miro…)
</span>
</div>
`;
}
}

View File

@@ -1,15 +1,12 @@
import { type EmbedIframeBlockModel } from '@blocksuite/affine-model';
import { EmbedIframeService } from '@blocksuite/affine-shared/services';
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 { SignalWatcher } 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 { css, html } from 'lit';
import { EmbedIframeLinkInputBase } from './embed-iframe-link-input-base';
export class EmbedIframeLinkEditPopup extends SignalWatcher(
WithDisposable(LitElement)
EmbedIframeLinkInputBase
) {
static override styles = css`
.embed-iframe-link-edit-popup {
@@ -70,90 +67,8 @@ export class EmbedIframeLinkEditPopup extends SignalWatcher(
}
`;
/**
* 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 isInputEmpty = this.isInputEmpty();
const { url$ } = this.model.props;
return html`
@@ -165,41 +80,18 @@ export class EmbedIframeLinkEditPopup extends SignalWatcher(
type="text"
spellcheck="false"
placeholder=${url$.value}
@input=${this._handleInput}
@keydown=${this._handleKeyDown}
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
/>
</div>
<div
class="confirm-button"
?disabled=${isInputEmpty}
@click=${this._onConfirm}
@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,131 @@
import type { EmbedIframeBlockModel } from '@blocksuite/affine-model';
import {
EmbedIframeService,
NotificationProvider,
} from '@blocksuite/affine-shared/services';
import { isValidUrl, stopPropagation } from '@blocksuite/affine-shared/utils';
import { BlockSelection, type BlockStdScope } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/lit';
import { LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
export class EmbedIframeLinkInputBase extends WithDisposable(LitElement) {
protected isInputEmpty() {
return this._linkInputValue.trim() === '';
}
protected tryToAddBookmark(url: string) {
if (!isValidUrl(url)) {
this.notificationService?.notify({
title: 'Invalid URL',
message: 'Please enter a valid URL',
accent: 'error',
onClose: function (): void {},
});
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();
}
protected async onConfirm() {
if (this.isInputEmpty()) {
return;
}
try {
const embedIframeService = this.std.get(EmbedIframeService);
if (!embedIframeService) {
console.error('iframe EmbedIframeService not found');
return;
}
const url = this._linkInputValue;
const canEmbed = embedIframeService.canEmbed(url);
if (!canEmbed) {
console.log('iframe can not be embedded, add as a bookmark', url);
this.tryToAddBookmark(url);
return;
}
this.store.updateBlock(this.model, {
url: this._linkInputValue,
iframeUrl: '',
title: '',
description: '',
});
} catch (error) {
this.notificationService?.notify({
title: 'Error in embed iframe creation',
message: error instanceof Error ? error.message : 'Please try again',
accent: 'error',
onClose: function (): void {},
});
} finally {
this.abortController?.abort();
}
}
protected handleInput = (e: InputEvent) => {
const target = e.target as HTMLInputElement;
this._linkInputValue = target.value;
};
protected 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);
}
get store() {
return this.model.doc;
}
get notificationService() {
return this.std.getOptional(NotificationProvider);
}
@state()
protected accessor _linkInputValue = '';
@query('input')
accessor input!: HTMLInputElement;
@property({ attribute: false })
accessor model!: EmbedIframeBlockModel;
@property({ attribute: false })
accessor std!: BlockStdScope;
@property({ attribute: false })
accessor abortController: AbortController | undefined = undefined;
}

View File

@@ -0,0 +1,266 @@
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { CloseIcon } from '@blocksuite/icons/lit';
import { baseTheme } from '@toeverything/theme';
import { css, html, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { EmbedIframeLinkInputBase } from './embed-iframe-link-input-base';
type EmbedLinkInputPopupVariant = 'default' | 'mobile';
export type EmbedLinkInputPopupOptions = {
showCloseButton?: boolean;
variant?: EmbedLinkInputPopupVariant;
title?: string;
description?: string;
placeholder?: string;
};
const DEFAULT_OPTIONS: EmbedLinkInputPopupOptions = {
showCloseButton: false,
variant: 'default',
title: 'Embed Link',
description: 'Works with links of Google Drive, Spotify…',
placeholder: 'Paste the Embed link...',
};
export class EmbedIframeLinkInputPopup extends EmbedIframeLinkInputBase {
static override styles = css`
.link-input-popup-main-wrapper {
box-sizing: border-box;
width: 340px;
padding: 12px;
border-radius: 8px;
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
z-index: var(--affine-z-index-modal);
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.link-input-popup-content-wrapper {
display: flex;
flex-direction: column;
}
.popup-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;
}
.popup-close-button:hover {
background-color: var(--affine-hover-color);
}
.title {
/* Client/h6 */
font-size: var(--affine-font-base);
font-style: normal;
font-weight: 500;
line-height: 24px;
color: ${unsafeCSSVarV2('text/primary')};
}
.description {
margin-top: 4px;
font-feature-settings:
'liga' off,
'clig' off;
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
line-height: 22px;
color: ${unsafeCSSVarV2('text/secondary')};
}
.input-container {
width: 100%;
margin-top: 12px;
box-sizing: border-box;
.link-input {
box-sizing: border-box;
width: 100%;
padding: 4px 10px;
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;
margin-top: 12px;
.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-size: 12px;
font-style: normal;
font-weight: 500;
cursor: pointer;
}
.confirm-button[disabled] {
opacity: 0.5;
}
}
.link-input-popup-main-wrapper.mobile {
width: 360px;
border-radius: 22px;
padding: 12px 0;
.popup-close-button {
top: 20px;
right: 16px;
}
.link-input-popup-content-wrapper {
gap: 0;
.title {
padding: 10px 16px;
font-weight: 500;
}
.input-container {
padding: 4px 12px;
}
.link-input {
padding: 11px 10px;
font-size: 17px;
font-style: normal;
font-weight: 400;
letter-spacing: -0.43px;
}
.title,
.description {
font-size: 17px;
font-style: normal;
line-height: 22px; /* 129.412% */
letter-spacing: -0.43px;
}
.description {
font-weight: 400;
text-align: left;
order: 2;
padding: 11px 16px;
color: ${unsafeCSSVarV2('text/secondary')};
}
.input-container {
order: 1;
}
}
.description,
.input-container,
.button-container {
margin-top: 0;
}
.button-container {
padding: 4px 16px;
.confirm-button {
height: 40px;
line-height: 40px;
font-size: 17px;
font-style: normal;
font-weight: 400;
letter-spacing: -0.43px;
}
.confirm-button[disabled] {
opacity: 1;
background: ${unsafeCSSVarV2('button/disable')};
}
}
}
`;
private readonly _onClose = () => {
this.abortController?.abort();
};
override render() {
const options = { ...DEFAULT_OPTIONS, ...this.options };
const { showCloseButton, variant, title, description, placeholder } =
options;
const modalMainWrapperClass = classMap({
'link-input-popup-main-wrapper': true,
mobile: variant === 'mobile',
});
return html`
<div class=${modalMainWrapperClass}>
${showCloseButton
? html`
<div class="popup-close-button" @click=${this._onClose}>
${CloseIcon({ width: '20', height: '20' })}
</div>
`
: nothing}
<div class="link-input-popup-content-wrapper">
<div class="title">${title}</div>
<div class="description">${description}</div>
<div class="input-container">
<input
class="link-input"
type="text"
placeholder=${ifDefined(placeholder)}
@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>
`;
}
@property({ attribute: false })
accessor options: EmbedLinkInputPopupOptions | undefined = undefined;
}

View File

@@ -33,7 +33,7 @@ const excalidrawConfig = {
heightInNote: EXCALIDRAW_DEFAULT_HEIGHT_IN_NOTE,
widthPercent: EXCALIDRAW_DEFAULT_WIDTH_PERCENT,
allow: 'clipboard-read; clipboard-write',
style: 'border: 0; border-radius: 8px;',
style: 'border: none; border-radius: 8px;',
allowFullscreen: true,
},
};

View File

@@ -37,8 +37,9 @@ const miroConfig = {
heightInNote: MIRO_DEFAULT_HEIGHT_IN_NOTE,
widthPercent: MIRO_DEFAULT_WIDTH_PERCENT,
allow: 'clipboard-read; clipboard-write',
style: 'border: 0; border-radius: 8px;',
style: 'border: none;',
allowFullscreen: true,
containerBorderRadius: 0,
},
};

View File

@@ -40,6 +40,7 @@ const spotifyConfig = {
allow: 'autoplay; clipboard-write; encrypted-media; picture-in-picture',
style: 'border-radius: 8px;',
allowFullscreen: true,
containerBorderRadius: 12,
},
};

View File

@@ -1,15 +1,16 @@
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
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 { insertEmptyEmbedIframeCommand } from '../../commands/insert-empty-embed-iframe';
import { EmbedIframeTooltip } from './tooltip';
export const embedIframeSlashMenuConfig: SlashMenuConfig = {
items: [
{
name: 'Embed',
description: 'For PDFs, and more.',
description: 'For Google Drive, and more.',
icon: EmbedIcon(),
tooltip: {
figure: EmbedIframeTooltip,
@@ -23,21 +24,15 @@ export const embedIframeSlashMenuConfig: SlashMenuConfig = {
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,
variant: 'default',
});
if (model.text?.length === 0) std.store.deleteBlock(model);
})().catch(console.error);
action: ({ std }) => {
std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertEmptyEmbedIframeCommand, {
place: 'after',
removeEmptyLine: true,
})
.run();
},
},
],

View File

@@ -7,6 +7,7 @@ import {
ActionPlacement,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarContext,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
@@ -35,6 +36,12 @@ export const builtinToolbarConfig = {
actions: [
{
id: 'b.conversions',
when: (ctx: ToolbarContext) => {
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
if (!model) return false;
return !!model.props.url;
},
actions: [
{
id: 'inline',
@@ -44,6 +51,8 @@ export const builtinToolbarConfig = {
if (!model) return;
const { title, caption, url } = model.props;
if (!url) return;
const { parent } = model;
const index = parent?.children.indexOf(model);
@@ -77,6 +86,8 @@ export const builtinToolbarConfig = {
if (!model) return;
const { url, caption } = model.props;
if (!url) return;
const { parent } = model;
const index = parent?.children.indexOf(model);
@@ -140,6 +151,12 @@ export const builtinToolbarConfig = {
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.caption',
when: (ctx: ToolbarContext) => {
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
if (!model) return false;
return !!model.props.url;
},
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {

View File

@@ -1,2 +1,8 @@
export const EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE = 752;
export const EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE = 116;
export const EMBED_IFRAME_DEFAULT_CONTAINER_BORDER_RADIUS = 8;
export const DEFAULT_IFRAME_HEIGHT = 152;
export const DEFAULT_IFRAME_WIDTH = '100%';
export const LINK_CREATE_POPUP_OFFSET = 4;

View File

@@ -14,7 +14,10 @@ export class EmbedEdgelessIframeBlockComponent extends toGfxBlockComponent(
override blockDraggable = false;
override accessor blockContainerStyles = { margin: '0' };
override accessor blockContainerStyles = {
margin: '0',
backgroundColor: 'transparent',
};
get edgelessSlots() {
return this.std.get(EdgelessLegacySlotIdentifier);

View File

@@ -3,6 +3,7 @@ import {
CaptionedBlockComponent,
SelectedStyle,
} from '@blocksuite/affine-components/caption';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import type { EmbedIframeBlockModel } from '@blocksuite/affine-model';
import {
type EmbedIframeData,
@@ -10,22 +11,31 @@ import {
FeatureFlagService,
type IframeOptions,
LinkPreviewerService,
NotificationProvider,
} from '@blocksuite/affine-shared/services';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { flip, offset, shift } from '@floating-ui/dom';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { query } from 'lit/decorators.js';
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 { EmbedLinkInputPopupOptions } from './components/embed-iframe-link-input-popup.js';
import {
DEFAULT_IFRAME_HEIGHT,
DEFAULT_IFRAME_WIDTH,
EMBED_IFRAME_DEFAULT_CONTAINER_BORDER_RADIUS,
LINK_CREATE_POPUP_OFFSET,
} from './consts.js';
import { embedIframeBlockStyles } from './style.js';
import type { EmbedIframeStatusCardOptions } from './types.js';
import { safeGetIframeSrc } from './utils.js';
export type EmbedIframeStatus = 'idle' | 'loading' | 'success' | 'error';
const DEFAULT_IFRAME_HEIGHT = 152;
const DEFAULT_IFRAME_WIDTH = '100%';
export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIframeBlockModel> {
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
@@ -41,6 +51,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
readonly status$ = signal<EmbedIframeStatus>('idle');
readonly error$ = signal<Error | null>(null);
readonly isIdle$ = computed(() => this.status$.value === 'idle');
readonly isLoading$ = computed(() => this.status$.value === 'loading');
readonly hasError$ = computed(() => this.status$.value === 'error');
readonly isSuccess$ = computed(() => this.status$.value === 'success');
@@ -57,7 +68,19 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
!this.selected$.value)
);
private _iframeOptions: IframeOptions | undefined = undefined;
// since different providers have different border radius
// we need to update the selected border radius when the iframe is loaded
readonly selectedBorderRadius$ = computed(() => {
if (
this.status$.value === 'success' &&
typeof this.iframeOptions?.containerBorderRadius === 'number'
) {
return this.iframeOptions.containerBorderRadius;
}
return EMBED_IFRAME_DEFAULT_CONTAINER_BORDER_RADIUS;
});
protected iframeOptions: IframeOptions | undefined = undefined;
get embedIframeService() {
return this.std.get(EmbedIframeService);
@@ -67,6 +90,10 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
return this.std.get(LinkPreviewerService);
}
get notificationService() {
return this.std.getOptional(NotificationProvider);
}
get inSurface() {
return matchModels(this.model.parent, [SurfaceBlockModel]);
}
@@ -85,11 +112,26 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
open = () => {
const link = this.model.props.url;
if (!link) {
this.notificationService?.notify({
title: 'No link found',
message: 'Please set a link to the block',
accent: 'warning',
onClose: function (): void {},
});
return;
}
window.open(link, '_blank');
};
refreshData = async () => {
try {
const { url } = this.model.props;
if (!url) {
this.status$.value = 'idle';
return;
}
// set loading status
this.status$.value = 'loading';
this.error$.value = null;
@@ -104,14 +146,6 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
);
}
const { url } = this.model.props;
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),
@@ -148,6 +182,45 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
}
};
private _linkInputAbortController: AbortController | null = null;
toggleLinkInputPopup = (options?: EmbedLinkInputPopupOptions) => {
if (this.readonly) {
return;
}
// toggle create popup when ths block is in idle status and the url is not set
if (!this._blockContainer || !this.isIdle$.value || this.model.props.url) {
return;
}
if (this._linkInputAbortController) {
this._linkInputAbortController.abort();
}
this._linkInputAbortController = new AbortController();
createLitPortal({
template: html`<embed-iframe-link-input-popup
.model=${this.model}
.abortController=${this._linkInputAbortController}
.std=${this.std}
.options=${options}
></embed-iframe-link-input-popup>`,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: this.host,
computePosition: {
referenceElement: this._blockContainer,
placement: 'bottom',
middleware: [flip(), offset(LINK_CREATE_POPUP_OFFSET), shift()],
autoUpdate: { animationFrame: true },
},
abortController: this._linkInputAbortController,
closeOnClickAway: true,
});
};
/**
* Get the iframe url from the embed data, first check if iframe_url is set,
* if not, check if html is set and get the iframe src from html
@@ -162,12 +235,11 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
private readonly _updateIframeOptions = (url: string) => {
const config = this.embedIframeService?.getConfig(url);
if (config) {
this._iframeOptions = config.options;
this.iframeOptions = config.options;
}
};
private readonly _handleDoubleClick = (event: MouseEvent) => {
event.stopPropagation();
private readonly _handleDoubleClick = () => {
this.open();
};
@@ -179,13 +251,16 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
selectionManager.setGroup('note', [blockSelection]);
};
protected _handleClick = (event: MouseEvent) => {
event.stopPropagation();
protected _handleClick = () => {
// We don't need to select the block when the block is in the surface
if (this.inSurface) {
return;
}
this._selectBlock();
if (this.isIdle$.value && !this.model.props.url) {
this.toggleLinkInputPopup();
} else {
this._selectBlock();
}
};
private readonly _handleRetry = async () => {
@@ -202,7 +277,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
referrerpolicy,
scrolling,
allowFullscreen,
} = this._iframeOptions ?? {};
} = this.iframeOptions ?? {};
const width = `${widthPercent}%`;
// if the block is in the surface, use 100% as the height
// otherwise, use the heightInNote
@@ -224,6 +299,10 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
};
private readonly _renderContent = () => {
if (this.isIdle$.value) {
return html`<embed-iframe-idle-card></embed-iframe-idle-card>`;
}
if (this.isLoading$.value) {
return html`<embed-iframe-loading-card
.std=${this.std}
@@ -286,6 +365,12 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._linkInputAbortController?.abort();
this._linkInputAbortController = null;
}
override renderBlock() {
if (!this.isEmbedIframeBlockEnabled) {
return nothing;
@@ -296,6 +381,10 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
...this.selectedStyle$?.value,
'in-surface': this.inSurface,
});
const containerStyles = styleMap({
borderRadius: `${this.selectedBorderRadius$.value}px`,
});
const overlayClasses = classMap({
'affine-embed-iframe-block-overlay': true,
show: this.showOverlay$.value,
@@ -305,6 +394,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
<div
draggable=${this.blockDraggable ? 'true' : 'false'}
class=${containerClasses}
style=${containerStyles}
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
@@ -316,11 +406,21 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
`;
}
override accessor blockContainerStyles = { margin: '18px 0' };
override accessor blockContainerStyles = {
margin: '18px 0',
backgroundColor: 'transparent',
};
get readonly() {
return this.doc.readonly;
}
override accessor useCaptionEditor = true;
override accessor useZeroWidth = true;
override accessor selectedStyle = SelectedStyle.Border;
@query('.affine-embed-iframe-block-container')
accessor _blockContainer: HTMLElement | null = null;
}

View File

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

View File

@@ -1,6 +1,6 @@
import { addSiblingAttachmentBlocks } from '@blocksuite/affine-block-attachment';
import { insertDatabaseBlockCommand } from '@blocksuite/affine-block-database';
import { toggleEmbedIframeCreateModal } from '@blocksuite/affine-block-embed';
import { insertEmptyEmbedIframeCommand } from '@blocksuite/affine-block-embed';
import { insertImagesCommand } from '@blocksuite/affine-block-image';
import { insertLatexBlockCommand } from '@blocksuite/affine-block-latex';
import {
@@ -484,24 +484,18 @@ const embedToolGroup: KeyboardToolPanelGroup = {
);
},
action: async ({ std }) => {
const [_, { selectedModels }] = std.command.exec(
getSelectedModelsCommand
);
const model = selectedModels?.[0];
if (!model) return;
const parentModel = std.store.getParent(model);
if (!parentModel) return;
const index = parentModel.children.indexOf(model) + 1;
await toggleEmbedIframeCreateModal(std, {
parentModel,
index,
variant: 'compact',
});
if (model.text?.length === 0) {
std.store.deleteBlock(model);
}
std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertEmptyEmbedIframeCommand, {
place: 'after',
removeEmptyLine: true,
linkInputPopupOptions: {
showCloseButton: true,
variant: 'mobile',
},
})
.run();
},
},
{

View File

@@ -26,6 +26,7 @@ export type IframeOptions = {
scrolling?: boolean;
allow?: string;
allowFullscreen?: boolean;
containerBorderRadius?: number;
};
/**