mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(editor): add idle status for embed iframe block (#11142)
To close: [BS-2843](https://linear.app/affine-design/issue/BS-2843/iframe-embed-block-占位态) [BS-2844](https://linear.app/affine-design/issue/BS-2844/iframe-embed-block-create-modal-ui-调整) [BS-2880](https://linear.app/affine-design/issue/BS-2880/spotify-选中时圆角有问题) [BS-2881](https://linear.app/affine-design/issue/BS-2881/miro-圆角有问题-点击-see-the-board-加载之后就好了)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './insert-embed-iframe';
|
||||
export * from './insert-embed-iframe-with-url';
|
||||
export * from './insert-empty-embed-iframe';
|
||||
|
||||
@@ -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) => {
|
||||
@@ -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];
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ const spotifyConfig = {
|
||||
allow: 'autoplay; clipboard-write; encrypted-media; picture-in-picture',
|
||||
style: 'border-radius: 8px;',
|
||||
allowFullscreen: true,
|
||||
containerBorderRadius: 12,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ export type IframeOptions = {
|
||||
scrolling?: boolean;
|
||||
allow?: string;
|
||||
allowFullscreen?: boolean;
|
||||
containerBorderRadius?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user